2025-12-14 02:13:33不了解并发编程? 一文带你了解Java并发编程基础!

文章目录

1. 为什么开发中需要并发编程2.基础概念2.1进程和线程2.1.1 进程2.1.2 线程2.1.2 CPU 核心数和线程数的关系

2.2 上下文切换2.3 并行和并发

3. 认识Java中的线程3.1 Java 程序天生就是多线程的3.2 线程的启动与终止3.2.1 启动3.2.1.1 X extends Thread;然后 X.start3.2.1.2 X implements Runnable;然后交给 Thread 运行3.2.1.3 Callable 、Future 和 FutureTask

3.2.2中止3.2.2.1 线程自然终止3.2.2.2 stop3.2.2.3 中断3.2.2.4 深入理解run 和 start

3.3 深入理解Java的线程3.3.1 线程的生命周期和状态3.3.2 其他的线程相关方法3.3.3线程的优先级3.3.4线程的调度3.3.5 线程和协程3.3.5.1内核线程实现3.3.5.2用户线程实现3.3.5.3 混合实现3.3.5.4Java线程的实现3.3.5.5协程出现的原因协程简介纤程-Java 中的协程

3.3.6 守护线程3.3.7 JOIN方法3.3.8 synchronized 内置锁对象锁和类锁错误的加锁和原因分析

3.3.9 等待/通知机制方法和锁wait和notify(为什么 wait 和 notify 方法要在同步块中调用?)为什么你应该在循环中检查等待条件?

3.4 线程安全问题3.4.1 一个经典的线程不安全的例子3.4.2 出现线程不安全的原因3.4.3 针对原因3解决线程安全问题3.4.3.1 对于synchronized的{ }3.4.3.2 死锁3.4.3.3 死锁的三种场景锁是不可重入锁,并且同一个线程针对同一个对象重复加锁两次**两个线程两把锁**:N个线程,M把锁

3.4.3.4避免死锁

3.4.4 针对原因4解决线程安全问题3.4.4.1 volatile关键字

1. 为什么开发中需要并发编程

加快用户的响应时间, 就例如我们平常使用的迅雷加载,都会开几个线程同时去下载,就是因为多线程快; 再比如做网页前端开发的时候,可能会将静态资源地址用两三个子域名去加载, 就是因为每多一个子域名,浏览器在加载页面的时候就会多开几个线程去加载页面,提升网页的响应速度使代码模块化,异步化,简单化. 例如实现电商系统, 下订单和给用户发消息、邮件是就可以进行拆分,将给用户发送短信,邮件这两个步骤独立为单独的模块, 并发给其他线程去执行,这样增加了异步的操作,提升了系统的稳定性,又是程序模块化,清晰化和简单化充分利用CPU的资源。例如我的电脑:

在多核的场景下,如果还是使用单线程的技术就太out了,无法充分利用多CPU的特点,如果设计一个多线程程序的话,那么它就可以在多个CPU的多个核的多个线程上跑,可以充分利用CPU资源,减少CPU的空闲时间,提高并发量

但是实际上单核CPU也可以利用并发编程,就例如使用微信聊天的时候,实际上程序要做很多事情,比如接受键盘的输入,将信息通过网络发送给对方,通过网络接受对方的消息,把对方的消息显示在屏幕上。 如果不能使用并发编程,那么我们在聊天的时候,程序只能专注于一个功能,我们的对话就只能一问一答的方式进行了

2.基础概念

2.1进程和线程

2.1.1 进程

我们经常听说的是应用程序,也就是app,由指令和数据组成,但是当我们不允许这个程序的时候,与这个程序有关的代码等信息就是存储在磁盘中的。一旦我们开始运行这些程序,数据要进行读写,就必须将代码指令加载到CPU,数据加载到内存,在代码执行的过程中,还需要用到磁盘,网络等设备。因此,从这个角度来看,进程 就是来加载指令,管理内存和IO的

当一个程序被运行,从磁盘加载这个程序的代码到内存中,这时候就开始了一个进程

进程类似于程序的一个实例对象(类似OOP的对象概念),大部分程序可以同时运行多个进程,有的程序 也只启动一个进程实例。显然,程序是死的、静态的,而进程是活的、动态的。 进程可以分为系统进程(用于完成操作系统功能)和用户进程(由我们自己启动的)

站在操作系统的角度, 进程是程序运行资源分配(以内存为主)的最小单位。

2.1.2 线程

一个机器中肯定会运行很多的程序, CPU 又是有限的, 怎么让有限的 CPU 运行这么多程序呢?就需要一种机制在程序之间进行协调,也就所谓 CPU 调度。 线程则是 CPU 调度的最小单位。线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分 派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不 拥有系统资源,,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈), 但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。 一个进程可 以拥有多个线程, 一个线程必须有一个父进程。线程, 有时也被称为轻量级进程(Lightweight Process ,LWP),它的创建销毁开销更小

Java 中不管任何程序都必须启动一个 main 函数的主线程; Java Web 开发里 面的定时任务、定时器、JSP 和 Servlet、异步消息处理机制,远程访问接口 RM 等, 任何一个监听事件,onclick 的触发事件等都离不开线程和并发的知识。

2.1.2 CPU 核心数和线程数的关系

前面说过, 目前主流 CPU 都是多核的, 线程是 CPU 调度的最小单位。同一 时刻, 一个 CPU核心只能运行一个线程,也就是 CPU 内核和同时运行的线程数 是 1:1 的关系, 也就是说 8 核 CPU同时可以执行 8 个线程的代码。但 Intel 引入 超线程技术后,产生了逻辑处理器的概念, 使核心数与线程数形成 1:2 的关系。

在 Java 中提供了 Runme.getRunme().availableProcessors(),可以让我们获 取当前的 CPU 核心数, 注意这个核心数指的是逻辑处理器数。

获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往 和 CPU 核心数密切相关。

2.2 上下文切换

既然操作系统要在多个进程(线程) 之间进行调度, 而每个线程在使用 CPU 时总是要使用 CPU中的资源,比如 CPU 寄存器和程序计数器。这就意味着,操 作系统要保证线程在调度前后的正常执行,所以, 操作系统中就有上下文切换的概念,它是指 CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

上下文是CPU 寄存器和程序计数器在任何时间点的内容。寄存器是CPU 内部的一小部分非常快的内存(相对于CPU 内部的缓存和CPU 外部较慢的RAM 主内存),它通过提供对常用值的快速访问来加快计算机程序的 执行。 程序计数器是一种专门的寄存器,它指示CPU 在其指令序列中的位置,并 保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。

上下文切换可以更详细地描述为内核(即操作系统的核心)对 CPU 上的进程 (包括线程)执行以下活动:

暂停一个进程的处理, 并将该进程的 CPU 状态(即上下文)存储在内存中的某个地方从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。

从数据来说, 以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源;

以线程的角度来看, 是方法的调用栈中存储的各类信息。

引发上下文切换的原因一般包括: 线程、进程切换、系统调用等等。上下文 切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就 CPU 时间而言, 一次上下文切换大概需要 5000~20000 个时钟周期, 相对一个简单指令几个乃至十几个左右的执行时钟周期, 可以看出这个成本的巨大。

2.3 并行和并发

我们举个例子,如果有条高速公路 A 上面并排有 8 条车道,那么最大的并行车 辆就是 8 辆此条高速公路 A 同时并排行走的车辆小于等于 8 辆的时候,车辆就可 以并行运行。

CPU 也是这个原理,一个CPU 相当于一个高速公路 A, 核心数或者线程数就相当于并排可以通行的车道;而多个 CPU 就相当于并排有多条高速公路,而 每个高速公路并排有多个车道。

当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少? 离开了单位时间其实是没有意义的。

综合来说:

**并发 Concurrent:**指应用能够交替执行不同的任务, 比如单 CPU 核心下执行多 线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉 到的速度不断去切换这两个任务, 已达到"同时执行效果",其实并不是的, 只是计算机的速度太快,我们无法察觉到而已.

**并行 Parallel:**指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边 打电话,这两件事情可以

同时执行

两者区别:一个是交替执行,一个是同时执行

3. 认识Java中的线程

3.1 Java 程序天生就是多线程的

一个 Java 程序从 main()方法开始执行,然后按照既定的代码逻辑执行,看 似没有其他线程参与,但实际上 Java 程序天生就是多线程程序, 因为执行 main() 方法的是一个名称为 main 的线程。而一个 Java 程序的运行就算是没有用户自己开启的线程,实际也有有很多 JVM 自行启动的线程

通过jdk自带的程序就可以查看:

public class Main {

public static void main(String[] args) {

int i = new Scanner(System.in).nextInt();

}

}

3.2 线程的启动与终止

刚刚看到的线程都是 JVM 启动的系统线程,我们学习并发编程希望的自己 能操控线程,所以我们先来看看如何启动线程。

3.2.1 启动

3.2.1.1 X extends Thread;然后 X.start

public class ThreadTest1 {

public static void main(String[] args) {

Thread thread = new Thread(new Runnable() {

@Override

public void run() {

System.out.println("这自定义的线程" + Thread.currentThread().getName());

}

});

thread.start();

System.out.println("这是main线程" +Thread.currentThread().getName());

/**

* 执行结果:

* 这是main线程main

* 这自定义的线程Thread-0

*/

}

}

3.2.1.2 X implements Runnable;然后交给 Thread 运行

public static void main(String[] args) {

Runnable runnable = new Runnable() {

@Override

public void run() {

System.out.println("这自定义的线程" + Thread.currentThread().getName());

}

};

Thread thread = new Thread(runnable);

thread.start();

System.out.println("这是main线程" +Thread.currentThread().getName());

/**

* 执行结果:

* 这是main线程main

* 这自定义的线程Thread-0

*/

}

Thread 和 Runnable****

Thread 才是 Java 里对线程的唯一抽象, Runnable 只是对任务(业务逻辑) 的抽象。 Thread 可

以接受任意一个 Runnable 的实例并执行。

3.2.1.3 Callable 、Future 和 FutureTask

Runnable 是一个接口, 在它里面只声明了一个 run()方法,由于 run()方法返 回值为 void 类型,所以在执行完任务之后无法返回任何结果。

Callable 位于 java.ul.concurrent 包下, 它也是一个接口, 在它里面也只声明 了一个方法,只不过这个方法叫做call(),这是一个泛型接口, call()函数返回的 类型就是传递进来的 V 类型。

Future 就是对于具体的 Runnable 或者 Callable 任务的执行结果进行取消、查 询是否完成、获取结果。必要时可以通过 get 方法获取执行结果, 该方法会阻塞直到任务返回结果。

因为 Future 只是一个接口,所以是无法直接用来创建对象使用的,因此就 有了下面的FutureTask。

FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口和 Future 接口,

而 FutureTask 实现了 RunnableFuture 接口。所以它既可以 作为 Runnable 被线程执行,又可以作为

Future 得到 Callable 的返回值。

因此我们通过一个线程运行 Callable,但是 Thread 不支持构造方法中传递 Callable 的实例,所以我们需要通过 FutureTask 把一个 Callable 包装成 Runnable, 然后再通过这个 FutureTask 拿到 Callable运行后的返回值。

示例:

package jwcb.concurrent_programming;

import java.util.concurrent.Callable;

import java.util.concurrent.ExecutionException;

import java.util.concurrent.FutureTask;

public class CallableFutureExample {

public static void main(String[] args) {

// 创建Callable任务,计算1到n的和

Callable sumTask = new Callable() {

@Override

public Integer call() throws Exception {

int sum = 0;

for (int i = 1; i <= 100; i++) {

sum += i;

// 模拟任务执行时间

Thread.sleep(10);

}

System.out.println("任务计算完成");

return sum;

}

};

// 使用FutureTask包装Callable

FutureTask futureTask = new FutureTask<>(sumTask);

// 创建线程并启动

Thread thread = new Thread(futureTask);

thread.start();

try {

// 主线程可以做其他事情

System.out.println("主线程执行其他操作...");

// 获取任务结果,如果任务未完成会阻塞等待

Integer result = futureTask.get();

System.out.println("1到100的和为: " + result);

// 检查任务是否完成

System.out.println("任务是否完成: " + futureTask.isDone());

} catch (InterruptedException e) {

System.out.println("线程被中断");

e.printStackTrace();

} catch (ExecutionException e) {

System.out.println("任务执行异常");

e.printStackTrace();

}

}

/**

* 执行结果

* 主线程执行其他操作...

* 任务计算完成

* 1到100的和为: 5050

* 任务是否完成: true

*/

}

3.2.2中止

3.2.2.1 线程自然终止

要么是 run 执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。

3.2.2.2 stop

暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend() 、resume() 和 stop()。但是这些API 是过期的,也就是不建议使用的。不建议使用的原因主 要有:以 suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如 锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样, stop()方 法在终结一个线程时不会保证线程的资源正常释放, 通常是没有给予线程完成资 源释放工作的机会, 因此会导致程序可能工作在不确定状态下。正因为 suspend()、 resume()和 stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方 法。

3.2.2.3 中断

安全的中止则是其他线程通过调用某个线程 A 的 interrupt()方法对其进行中 断操作, 中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表 线程 A 会立即停止自己的工作,同样的 A

线程完全可以不理会这种中断请求。线程通过检查自身的中断标志位是否被置为 true 来进行响应,线程通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法 Thread.interrupted()来进行判断当前线程是否被中断,**不过 ****Thread.interrupted() **会同时将中断标识位改写为 false。

如果一个线程处于了阻塞状态(如线程调用了 thread.sleep 、thread.join、thread.wait 等),则在线程在检查中断标示时如果发现中断标示为 true,则会在 这些阻塞方法调用处抛出 InterruptedExcepon 异常,并且在抛出异常后会立即 将线程的中断标示位清除,即重新设置为false。

不建议自定义一个取消标志位来中止线程的运行。因为 run 方法里有阻塞调 用时会无法很快检测到取消标志, 线程必须从阻塞调用返回后, 才会检查这个取 消标志。这种情况下,使用中断会更好,因为:

一般的阻塞方法,如 sleep 等本身就支持中断的检查,检查中断位的状态和检查取消标志位没什么区别, 用中断位的状态还可 以避免声明取消标志

位,减少资源的消耗。

package jwcb.concurrent_programming;

public class ThreadInterruptExample {

public static void main(String[] args) {

// 创建一个可中断的线程

Thread workerThread = new Thread(new Worker(), "WorkerThread");

workerThread.start();

// 主线程休眠1秒后中断工作线程

try {

Thread.sleep(1000);

} catch (InterruptedException e) {

e.printStackTrace();

}

System.out.println("主线程请求中断工作线程");

workerThread.interrupt();

}

static class Worker implements Runnable {

@Override

public void run() {

System.out.println("工作线程开始执行");

try {

// 模拟长时间运行的任务,包含阻塞操作

for (int i = 0; i < 10; i++) {

// 检查是否被中断

if (Thread.currentThread().isInterrupted()) {

System.out.println("工作线程检测到中断请求,准备退出");

return;

}

System.out.println("工作线程正在执行任务 " + i);

// 模拟工作,休眠500毫秒

Thread.sleep(500);

}

} catch (InterruptedException e) {

// 当在阻塞状态(如sleep)被中断时会抛出此异常

System.out.println("工作线程在阻塞状态被中断");

// 检查中断状态(此时应该已经被清除为false)

System.out.println("中断标志位状态: " + Thread.currentThread().isInterrupted());

}

System.out.println("工作线程退出");

}

}

}

注意:处于死锁状态的线程无法被中断

3.2.2.4 深入理解run 和 start

Thread 类是Java对线程的抽象, 我们通过new Thread()实际上只是new出了一个Thread的实例,操作系统中还没有真正的线程挂起来, 只有执行了start()方法之后,才实现了真正意义上的启动线程

从 Thread 的源码可以看到, Thread 的 start 方法中调用了 start0()方法,而 start0()是个 nave 方法, 这就说明 Thread#start 一定和操作系统是密切相关的。

start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常

而 run 方法是业务逻辑实现的地方, 本质上和任意一个类的任意一个成员方 法并没有任何区别,

可以重复执行,也可以被单独调用。

3.3 深入理解Java的线程

3.3.1 线程的生命周期和状态

Java的线程中的状态分为6种:

初始(New): 新创建了一个线程对象,但是还没有调用start方法运行(RUNNABLE): Java线程中将就绪(Ready) 和 运行中(Running) 两种状态笼统地称为" 运行"

线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。 该状态的线程位于可

运行线程池中, 等待被线程调度选中, 获取 CPU 的使用权, 此时处于就绪状态(ready)。就绪状

态的线程在获得 CPU 时间片后变为运行中 状态(running)。

阻塞(BLOCKED):表示线程阻塞于锁。等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时 间后自行返回。终止(TERMINATED):表示该线程已经执行完毕。

3.3.2 其他的线程相关方法

yield()方法:使当前线程让出 CPU 占有权, 但让出的时间是不可设定的。也 不会释放锁资源。同

时执行 yield()的线程有可能在进入到就绪状态后会被操作系 统再次选中马上又被执行。

比如, ConcurrentHashMap#initTable 方法中就使用了这个方法,

这是因为 ConcurrentHashMap 中可能被多个线程同时初始化 table,但是其 实这个时候只允许一个线程进行初始化操作, 其他的线程就需要被阻塞或等待, 但是初始化操作其实很快, 这里 为了避免阻塞或者等待这些操作 引发的上下文切换等等开销, 就让其他不执行初始化操作的线程干脆执行 yield() 方法,以让出 CPU 执行权,让执行初始化操作的线程可以更快的执行完成。

3.3.3线程的优先级

在 Java 线程中, 通过一个整型成员变量 priority 来控制优先级, 优先级的范 围从 1~10,在线程构建的时候可以通过 setPriority(int)方法来修改优先级,默认 优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。

设置线程优先级时, 针对频繁阻塞(休眠或者 I/O 操作) 的线程需要设置较 高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的 优先级,确保处理器不会被独占。在不同的 JVM 以及操作系统上,线程规划会 存在差异,有些操作系统甚至会忽略对线程优先级的设定。

3.3.4线程的调度

线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种: 协同式线程调度(Cooperave Threads-Scheduling)、抢占式线程调度(Preempve Threads-Scheduling)

使用协同式线程调度的多线程系统, 线程执行的时间由线程本身来控制, 线 程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上。使用协同 式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系 统进行线程切换, 所以没有线程同步的问题, 但是坏处也很明显, 如果一个线程 出了问题,则程序就会一直阻塞。

使用抢占式线程调度的多线程系统, 每个线程执行的时间以及是否切换都由 系统决定。在这种情况下, 线程的执行时间不可控, 所以不会有「一个线程导致 整个进程阻塞」的问题出现。

Java 线程调度就是抢占式调度(后面会分析)。

在 Java 中, Thread.yield()可以让出 CPU 执行时间,但是对于获取执行时间, 线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是 设置线程优先级, Java 设置了 10 个级别的程序优先级,当两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

3.3.5 线程和协程

为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。

我们已经知道线程其实是操作系统层面的实体, Java 中的线程怎么和操作系统层面对应起来呢?

任何语言实现线程主要有三种方式:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实

现) ,使用用户线程加轻量级进程混合实现(N:M 实现)。

3.3.5.1内核线程实现

使用内核线程实现的方式也被称为 1: 1 实现。 内核线程(Kernel-Level Thread, KLT) 就是直接由操作系统内核(Kernel , 下称内核) 支持的线程

这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进 行调度, 并负责将

线程的任务映射到各个处理器上。

由于内核线程的支持, 每个线程都成为一个独立的调度单元, 即使其中某一 个在系统调用中被

阻塞了, 也不会影响整个进程继续工作,相关的调度工作也不 需要额外考虑,已经由操作系统处理

了。

局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要

进行系统调用。而系统调用的代价相对较高, 需要在用户 态(User Mode)和内核态(Kernel Mode)

中来回切换。其次, 每个语言层面的 线程都需要有一个内核线程的支持, 因此要消耗一定的内核资

源(如内核线程的 栈空间),因此一个系统支持的线程数量是有限的。

3.3.5.2用户线程实现

严格意义上的用户线程指的是完全建立在用户空间的线程库上, 系统内核不 能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完 全在用户态中完成, 不需要内核的帮助。 如果程序实现得当, 这种线程不需要 切换到内核态, 因此操作可以是非常快速且低消耗的,也能够支持规模更大的 线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

用户线程的优势在于不需要系统内核支援, 劣势也在于没有系统内核的支援, 所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度 都是用户必须考虑的问题, 而且由于操作系统只把处理器资源分配到进程, 那诸 如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至有些是不可能实现的。

因为使用用户线程实现 的程序通常都比较复杂, 所以一般的应用程序都不倾向使用用户线程。Java 语言 曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点 的编程语言又普遍支持了用户线程,譬如 Golang。

3.3.5.3 混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将 内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。 在这种混合实 现下, 既存在用户线程, 也存在内核线程。

用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。同样又可以使用内核提供的线程调度功能及处理器映射, 并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的 数量比是不定的,是 N:M 的关系。

3.3.5.4Java线程的实现

Java线程在早期的JDK1.2以前的虚拟机上是用户线程实现的,但是从JDK1.3开始, 主流商用的java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型

以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线 程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉 线程调度的,全权交给底下的操作系统去处理。

所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线 程优先级是通过映射到操作系统的原生线程上实现的, 所以线程的调度最终取决 于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。

3.3.5.5协程

出现的原因

随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。 比如, 互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不 同机器上的大量服务共同协作来实现, 也就是我们常说的微服务, 这种服务细分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服 务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服 务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服 务被阻塞而出现等待。

Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1:1 的内核 线程模型是如今 Java虚拟机线程实现的主流选择, 但是这种映射到操作系统上 的线程天然的缺陷是切换、调度成本高昂, 系统能容纳的线程数量也很有限。 以 前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本 也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下, 用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成严重的浪费。

另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十 个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得 过来,但其中的切换损耗也是相当可观的。

这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一 个非常可行的方案。

协程简介

为什么用户线程又被称为协程呢?我们知道, 内核线程的切换开销是来自于 保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉 吗? 答案还是“不能”。 但是, 一旦把保护、恢复现场及调度的工作从操作系 统交到程序员手上, 则可以通过很多手段来缩减这些开销。

由于最初多数的用户线程是被设计成协同式调度(Cooperave Scheduling) 的,所以它有了一个别名—“协程”(Coroune) ,完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroune)。

协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核 线程要轻量得多。

协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这 些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协 程上也存在。

总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的 延迟)。

纤程-Java 中的协程

在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java 调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正 常切换协程而不影响整个线程? 另外,如果协程中遇传统的线程同步措施会怎样?

所以 Java 开发组就 Java 中协程的实现也做了很多努力, OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber) ”这个 名字。

Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为 了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟 机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似 的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为 了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型

目前 Java 中比较出名的协程库是 Quasar[ˈkweɪzɑː®](Loom 项目的 Leader 就 是 Quasar 的作者Ron Pressler),** Quasar 的实现原理是字节码注入,在字节码 层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚 拟机的现场保护虽然能够工作,但影响性能。**

3.3.6 守护线程

Daemon(守护) 线程是一种支持型线程,因为它主要被用作程序中后台调 度以及支持性工作。

这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的 时候, Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是Daemon 线程。

Daemon 线程被用作完成支持性工作, 但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时, 不能依靠 finally 块中 的内容来确保执行关闭或清理资源的逻辑。

3.3.7 JOIN方法

现在有 T1、T2、T3 三个线程, 你怎样保证 T2 在 T1 执行完后执行, T3 在 T2 执行完后执行?

答:用 Thread#join 方法即可, 在 T3 中调用 T2.join,在 T2 中调用 T1.join。

Join()把指定的线程加入到当前线程, 可以将两个交替执行的线程合并为顺序执行。 比如在线程 B 中

调用了线程 A 的 Join()方法, 直到线程 A 执行完毕后, 才会继续 执行线程 B 剩下的代码。

3.3.8 synchronized 内置锁

线程开始运行, 拥有自己的栈空间, 就如同一个脚本一样, 按照既定的代码 一步一步地执行,直到终止。但是, 每个运行中的线程, 如果仅仅是孤立地运行, 那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作, 包括数据之间的共享,协同处理事情。这将会带来巨大的价值。

Java 支持多个线程同时访问一个对象或者对象的成员变量,但是多个线程同 时访问同一个变量,会导致不可预料的结果。

public class CounterExample {

// 共享变量

private static int count = 0;

public static void main(String[] args) throws InterruptedException {

// 线程1:对count累加10000次

Thread thread1 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

count++;

}

});

// 线程2:对count累加10000次

Thread thread2 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

count++;

}

});

thread1.start();

thread2.start();

thread1.join();

thread2.join();

// 预期结果应该是20000,但实际结果往往小于20000

System.out.println("最终count值:" + count);

}

/**

* 多次输出

* 最终count值:17414

* 最终count值:16496

*/

}

关键字 synchronized 可以修饰方法 或者以同步块的形式来进行使用, 它主

要确保多个线程在同一个时刻, 只能有一 个线程处于方法或者同步块中, 它保证了线程对变量访问的可见性和排他性, 使 多个线程访问同一个变量的结果正确,它又称为内置锁机制。

package jwcb.concurrent_programming;

public class ThreadSafeCounter {

// 共享变量

private static int count = 0;

// 加锁的累加方法:同一时间只有一个线程能执行此方法

private static synchronized void increment() {

count++;

}

public static void main(String[] args) throws InterruptedException {

// 线程1:对count累加10000次

Thread thread1 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

increment(); // 调用加锁的方法

}

});

// 线程2:对count累加10000次

Thread thread2 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

increment(); // 调用加锁的方法

}

});

thread1.start();

thread2.start();

thread1.join();

thread2.join();

// 加锁后,结果稳定为20000

System.out.println("最终count值:" + count);

}

}

对象锁和类锁

对象锁是用于对象实例方法, 或者一个对象实例上的, 类锁是用于类的静态 方法或者一个类的class 对象上的。

package jwcb.concurrent_programming;

public class LockExample {

// 静态变量(类级别的共享资源)

private static int classCount = 0;

// 实例变量(对象级别的共享资源)

private int objectCount = 0;

// 1. 对象锁:修饰实例方法

public synchronized void objectLockMethod() {

objectCount++;

try {

Thread.sleep(100); // 模拟耗时操作

} catch (InterruptedException e) {

e.printStackTrace();

}

System.out.println("对象锁方法 - 对象计数: " + objectCount + " - 线程: " + Thread.currentThread().getName());

}

// 2. 类锁:修饰静态方法

public static synchronized void classLockMethod() {

classCount++;

try {

Thread.sleep(100); // 模拟耗时操作

} catch (InterruptedException e) {

e.printStackTrace();

}

System.out.println("类锁方法 - 类计数: " + classCount + " - 线程: " + Thread.currentThread().getName());

}

public static void main(String[] args) {

// 创建两个不同的对象实例

LockExample instance1 = new LockExample();

LockExample instance2 = new LockExample();

// 测试对象锁:不同对象的锁互不干扰

new Thread(() -> instance1.objectLockMethod(), "线程A").start();

new Thread(() -> instance2.objectLockMethod(), "线程B").start();

// 测试类锁:所有对象共享同一把类锁

new Thread(() -> instance1.classLockMethod(), "线程C").start();

new Thread(() -> instance2.classLockMethod(), "线程D").start();

}

}

对象锁(Object Lock):

通过synchronized修饰实例方法(如objectLockMethod())锁的是当前对象实例(this)不同对象的对象锁相互独立,例如instance1和instance2的对象锁锁互不干扰运行结果中,线程 A 和线程 B 可以同时执行,因为它们持有不同对象的锁

类锁(Class Lock):

通过synchronized修饰静态方法(如classLockMethod())锁的是类的Class对象(LockExample.class)所有对象实例共享同一把类锁运行结果中,线程 C 和线程 D 会串行执行,因为它们争夺同一把类锁

但是有一点必须注意的是, 其实类锁只是一个概念上的东西, 并不是真实存 在的,类锁其实锁

的是每个类的对应的 class 对象,但是每个类只有一个 class 对 象,所以每个类只有一个类锁

错误的加锁和原因分析

package jwcb.concurrent_programming;

public class IntegerLockExample {

// 使用Integer作为锁对象

private static Integer lock = 0;

private static int count = 0;

public static void main(String[] args) throws InterruptedException {

// 创建10个线程,每个线程对count累加1000次

Thread[] threads = new Thread[10];

for (int i = 0; i < 10; i++) {

threads[i] = new Thread(() -> {

for (int j = 0; j < 1000; j++) {

// 尝试使用Integer对象作为锁

synchronized (lock) {

count++;

// 关键问题:i++会创建新的Integer对象,导致锁对象改变

lock++;

}

}

});

threads[i].start();

}

// 等待所有线程完成

for (Thread thread : threads) {

thread.join();

}

// 预期结果应该是10000,但实际会小于10000(锁失效导致)

System.out.println("最终count值:" + count);

}

/**

* 最终count值:9057

*/

}

3.3.9 等待/通知机制

线程之间相互配合,完成某项工作,比如: 一个线程修改了一个对象的值, 而另一个线程感知到了变化,然后进行相应的操作。前者是生产者, 后者就是消费者, 这种模式隔离了 “做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地 循环检查变量是否符合预期在 while 循环中设置不满足的条件, 如果条件满足则 退出 while 循环,从而完成消费者的工作。却存在如下问题:

1)难以确保及时性。

2)难以降低开销。如果降低睡眠的时间,比如休眠 1 毫秒,这样消费者能 更加迅速地发现条件变化, 但是却可能消耗更多的处理器资源, 造成了无端的浪费。

等待/通知机制则可以很好的避免,这种机制是指一个线程 A 调用了对象 O 的 wait()方法进入等待状态,而另一个线程 B 调用了对象 O 的 nofy()或者nofyAll()方法, 线程 A 收到通知后从对象 O 的 wait()方法返回, 进而执行后续操 作。上述两个线程通过对象 O 来完成交互, 而对象上的 wait()和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

notify():

通知一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程 获取到了对象的锁,

没有获得锁的线程重新进入 WAITING 状态。

notifyAll():

通知所有等待在该对象上的线程

wait()

调用该方法的线程进入 WAITING 状态, 只有等待另外线程的通知或被中断 才会返回. 需要注意,调用

wait()方法后,会释放对象的锁

wait(long)

超时等待一段时间,这里的参数时间是毫秒,也就是等待长达 n 毫秒,如果没有 通知就超时返回

wait (long,int)

对于超时时间更细粒度的控制,可以达到纳秒

package jwcb.concurrent_programming;

public class ProducerConsumerExample {

// 共享的缓冲区

private static Integer buffer = null;

public static void main(String[] args) {

// 创建共享对象(作为锁对象)

Object lock = new Object();

// 消费者线程

Thread consumer = new Thread(() -> {

synchronized (lock) {

// 如果缓冲区为空,进入等待状态

while (buffer == null) {

System.out.println("缓冲区为空,消费者等待...");

try {

// 释放锁并等待通知

lock.wait();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

// 收到通知后消费数据

System.out.println("消费者消费数据: " + buffer);

buffer = null; // 清空缓冲区

lock.notify(); // 通知生产者可以继续生产

}

}, "消费者");

// 生产者线程

Thread producer = new Thread(() -> {

synchronized (lock) {

// 如果缓冲区不为空,等待消费者消费

while (buffer != null) {

try {

lock.wait();

} catch (InterruptedException e) {

e.printStackTrace();

}

}

// 生产数据

buffer = 1;

System.out.println("生产者生产数据: " + buffer);

// 通知消费者可以消费了

lock.notify();

}

}, "生产者");

// 启动线程

consumer.start();

try {

Thread.sleep(1000); // 确保消费者先启动

} catch (InterruptedException e) {

e.printStackTrace();

}

producer.start();

}

/**

* 缓冲区为空,消费者等待...

* 生产者生产数据: 1

* 消费者消费数据: 1

*/

}

方法和锁

调用 yield() 、sleep() 、wait() 、nofy()等方法对锁有何影响? yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。

调用 wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后, 会重新 去竞争锁,锁竞争到后才会执行 wait 方法后面的代码。

调用 nofy()系列方法后, 对锁无影响, 线程只有在 syn 同步代码执行完后才 会自然而然的释放锁,所以 nofy()系列方法一般都是 syn 同步代码的最后一行。

wait和notify(为什么 wait 和 notify 方法要在同步块中调用?)

Java Api强制要求这么做,本质上在所有多线程环境下都要求这么做

假如我们有两个线程, 一个消费者线程, 一个生产者线程

生产者线程的任务可以简化为将count + 1,而后唤醒消费者

消费者则是将 count 减一,而后 在减到 0 的时候陷入睡眠:

// 生产者伪代码:

count+1;

notify();

//消费者伪代码:

while(count<=0) {

wait;

}

count--;

这里面有什么问题呢?

生产者是两个步骤:

count+1;notify();

消费者也是两个步骤:

检查 count 值;睡眠或者减一;

万一这些步骤混杂在一起呢?比如说,初始的时候 count 等于 0,这个时候 消费者检查 count 的值,发现 count 小于等于 0 的条件成立; 就在这个时候, 发 生了上下文切换,生产者进来了,噼噼啪啪一顿操作,把两个步骤都执行完了, 也就是发出了通知, 准备唤醒一个线程。这个时候消费者刚决定睡觉, 还没睡呢, 所以这个通知就会被丢掉。紧接着,消费者就睡过去了……

这就是所谓的 lost wake up 问题。

现在我们应该就能够看到,问题的根源在于,消费者在检查 count 到调用 wait()之间, count 就可能被改掉了。

这就是一种很常见的竞态条件。

很自然的想法是, 让消费者和生产者竞争一把锁, 竞争到了的, 才能够修改 count 的值

为什么你应该在循环中检查等待条件?

处于等待状态的线程可能会收到错误警报和伪唤醒, 如果不在循环中检查等 待条件, 程序就会在没有满足结束条件的情况下退出。因此, 当一个等待线程醒 来时,不能认为它原来的等待状态仍然是有效的,在 nofy()方法调用之后和等 待线程醒来之前这段时间它可能会改变。这就是在循环中使用 wait()方法效果更 好的原因。

3.4 线程安全问题

我们知道,线程是随机抢占式执行,这样的随机性,就会使程序的执行顺序产生变数 ,就会得到不同的结果,但是有的时候,遇到不同的结果,认为是不可接受的,认为是一种bug

3.4.1 一个经典的线程不安全的例子

预期的值是10000

这是一个典型的多线程并发导致的问题

通过上述执行过程,我们发现,执行了两次++,最后内存的结果仍然是1,因为后一次计算的结果把前一次计算的结果给覆盖掉了

由于当前线程执行的顺序不确定,有的执行顺序加两次,结果是对的;有的加两次,结果只加一次,具体有多少次是随机的

因此看到的结果也不是精确的5w

并且两个线程的执行顺序情况是无数种的,不可预测

作为程序员就要保证每一组情况下的计算结果都是要对的才行

我们如果能保证,一个线程的save在另一个线程的load之前就是ok的

3.4.2 出现线程不安全的原因

线程在系统中是随机调度的,抢占式执行的(这也是线程不安全的罪魁祸首,万恶之源)当前代码中,多个线程同时修改同一个变量

如果是一个线程修改一个变量,那就没事

多个线程读取同一个变量,也没事

多个线程分别修改不同的变量,也没事

但是如果是多个线程针对同一个变量修改操作,那就有问题了

线程针对变量的操作不是"原子的"

有的操作.比如针对int / double 进行赋值操作,在cpu中就是一个move指令,那就是原子的,但是有的修改操作不是"原子的",像count++这种,就不行了

内存可见性问题,引起的不安全指令重排序问题,引起的线程不安全

解决问题需要从原因入手,但是像原因1这种就没办法了,我们无法干预

原因2虽然是一个切入点,但是在java中,这种做法不是很普适,只是针对一些特定的场景是可以做到的,例如String是不可变变量,但是如果一些代码,就是要多个线程修改同一个代码,那就没办法了

3.4.3 针对原因3解决线程安全问题

原因3是解决线程安全问题最普适的方案,可以通过一些操作,把上述一系列"非原子"的操作,打包成一个"原子"的操作,这个操作就是"加锁"

package jwcb.concurrent_programming;

public class ThreadSafeCounter {

// 共享变量

private static int count = 0;

// 加锁的累加方法:同一时间只有一个线程能执行此方法

private static synchronized void increment() {

count++;

}

public static void main(String[] args) throws InterruptedException {

// 线程1:对count累加10000次

Thread thread1 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

increment(); // 调用加锁的方法

}

});

// 线程2:对count累加10000次

Thread thread2 = new Thread(() -> {

for (int i = 0; i < 10000; i++) {

increment(); // 调用加锁的方法

}

});

thread1.start();

thread2.start();

thread1.join();

thread2.join();

// 加锁后,结果稳定为20000

System.out.println("最终count值:" + count);

}

}

当t2进行load的时候,此时t2就会阻塞等待(因为锁已经被t1占有了),阻塞到t1释放锁操作为止,t2的lock操作才完成

这样的阻塞,就使t2的load出现在t1的save之前,强行构造出"串行执行"效果

3.4.3.1 对于synchronized的{ }

在其他语言中,使用的都是lock() / unlock(),而unlock()是非常容易遗忘的,但是使用synchronized就能够避免忘记unlock的情况

3.4.3.2 死锁

class Counter {

private static int count = 0;

public void add(){

synchronized(this){

count++;

}

}

public static int getCount() {

return count;

}

}

public class Demo1 {

public static void main(String[] args) throws InterruptedException {

Counter counter = new Counter();

Thread t1 = new Thread(() -> {

for(int i = 0; i < 50000; i++){

synchronized (counter){

counter.add();

}

}

});

t1.start();

t1.join();

System.out.println(Counter.getCount());

}

}

于这种就类似于锁里面又套了一层锁,那么在t1启动的时候,第一次加锁肯定成功,但是在里面调用了add方法的时候,相当于准备再加一层锁,但是我们前面说过,针对同一个对象加锁,就会引发阻塞等待,想要获取第二把锁,就要等执行到第一把锁的第二个大括号,但是要想执行到第二个大括号,就要先获得第二把锁,那么就矛盾了

这种情况,就叫做"死锁"

按照我们上述的逻辑来说,那么就会卡死.但是实际当我们运行:!在这里插入图片描述 居然运行通过了??

实际上上述过程,对于synchronized是不适用的,但是在C++/Python就会出现死锁

是因为在synchronized里面内部自己做了特殊处理,在每一个锁对象里,.都会记录了是当前哪个线程持有了这个锁,当当前针对这个对象加锁操作时,就会先判定一下,当前尝试加锁的线程是否是持有锁的线程

如果不是,就阻塞,如果是,就放行

3.4.3.3 死锁的三种场景

锁是不可重入锁,并且同一个线程针对同一个对象重复加锁两次

(synchronized不会出现这个问题)

两个线程两把锁:

线程1和线程2,存在锁1和锁2,在线程1里面都需要获取锁1和锁2

线程1拿到锁1后,不释放锁1,继续去获取锁2,但是锁2已经被线程2拿走了,且此时线程2继续去拿锁1

public class Demo2 {

public static void main(String[] args) {

Object locker1 = new Object();

Object locker2 = new Object();

Thread t1 = new Thread(()->{

synchronized (locker1){

try {

Thread.sleep(1000);

} catch (InterruptedException e) {

throw new RuntimeException(e);

}

//...

synchronized (locker2){

System.out.println("t1 get two locker");

}

}

});

Thread t2 = new Thread(()->{

synchronized (locker2){

try {

Thread.sleep(1000);

} catch (InterruptedException e) {

throw new RuntimeException(e);

}

//...

synchronized (locker1){

System.out.println("t2 get two locker");

}

}

});

t1.start();

t2.start();

}

}

运行上述代码会发现,进程没有退出,也没有打印任何线程里面的内容,僵住了,死锁了

此时就是t1尝试针对locker2加锁,等待阻塞,等待t2释放locker2

但是同时t2也在尝试针对locker1加锁,等待t1释放locker1

N个线程,M把锁

随着线程数目 / 锁的个数增加,此时情况就更加复杂了,就更加容易写出死锁了

一个典型的例子就是哲学家就餐问题

每个哲学家都坐在两个筷子之间,每个哲学家啥时候吃面条,啥时候思考人生都是不确定的(抢占式执行)

这么模型在大部分情况下是没问题的,可以最后正常工作的

但是如果出现极端情况,就会出现问题

即在同一时刻,所有的哲学家都拿起左边的筷子,此时就会出现所有的哲学家都无法拿起右边的筷子的情况,但是哲学家又是比较固执,不吃到面条就永远不会放下筷子

这就是典型的死锁状态

3.4.3.4避免死锁

死锁是一个非常严重的问题,就会使得线程被卡住,没办法继续工作了,更可怕的是,死锁这种bug,往往都是概率性事件,可能测试的时候怎么测试都没事,发布了也没事,但是就像一个不定时炸弹一样,突然整出问题

如何避免死锁问题??

我们就要从死锁的必要条件入手

(1)锁具有互斥性(这是synchronized的基本特性,其他线程就得阻塞等待)

(2)锁不可抢占(不可被剥夺):一个线程拿到锁之后,除非他自己释放锁,否则别人抢不走(锁的基本特点)

(3)请求和保持 一个线程拿到一把锁之后,不释放这个锁的前提下,再去获取其他锁(代码结构)

(4)循环等待 多个线程获取多个锁的过程中,出现了A等待B,B等待A的情况(代码结构)

"必要条件"说明缺一不可

那么我们要避免死锁就要从这4个必要条件入手,只要缺少一个.都不构成死锁

对于前两点.由于是锁的基本特性,除非你自己实现锁.实现可以打破互斥,打破不可剥夺这样的条件,对于synchronized这样的锁是不行的

那么我们就可以从(3)(4)代码结构入手

第一点就是不要让锁嵌套获取

但是有的时候就必须嵌套获取了

第二点就是破除循环等待

即约定好加锁的顺序,让所有的线程都按照固定的顺序来获取锁

3.4.4 针对原因4解决线程安全问题

(内存可见性引起的线程安全问题)

public class Demo3 {

public static int count = 0;

public static void main(String[] args) {

Thread t1 = new Thread(() -> {

while(count == 0){

//

}

System.out.println("t1结束了");

});

Thread t2 = new Thread(() -> {

Scanner scanner = new Scanner(System.in);

System.out.println("输入一个整数");

count = scanner.nextInt();

});

t1.start();

t2.start();

}

}

按照我们的设计,当我们随便输入一个整数的时候,由于count改变了,t1线程就会退出,但是当我们运行后发现:

循环没有退出,程序没有按照我们的设想走,这也是bug

我们来分析一下t1线程中while(count == 0)的执行流程:

(1)load 从内存读取到cpu寄存器

(2)cmp (同时会产生跳转),条件成立的话,继续执行流程,条件不成立就跳转到另一个地址来执行

实际上,由于当前循环旋转速度很快,短时间内出现大量的load和cmp反复执行的效果,而load执行消耗的时间,会比cmp多上几千倍几万倍,jvm发现,在t2修改count之前,每次执行load的结果实际上是一样的,这时候jvm干脆就把上述的load给优化掉了(把速度慢的优化掉,使程序执行速度更快了),只是第一次真正的load,后续执行到对应的代码的时候,就不再真正的load了,而是直接读取刚才已经load过的寄存器中的值了

而load被优化后.导致后续t2修改count时,t1感知不到,就不会退出循环

上述过程,多线程确实有锅,但是也是编译器/jvm优化的问题,正常来说,优化是需要保证逻辑是等价的,但是,编译器 / jvm在单线程代码中优化是比较靠谱的,但一旦引入多线程,编译器 / jvm就不那么准确了

注意,如果上述代码循环体中存在IO操作或者阻塞操作(sleep),这时候就会使循环的旋转速度大幅度降低了,此时的IO操作才是应该被优化的那一个,但是IO操作是不能被优化掉的,load被优化的前提是反复load的结果是相同的.而IO操作注定是反复执行结果是不相同的

3.4.4.1 volatile关键字

volale 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某 个变量的值,这新值对其他线程来说是立即可见的。

给变量修饰加上这个关键字后,此时编译器就知道,这个变量是"反复无常的",就不能按照上述优化策略进行优化了

但是这个操作和之前的synchronized保证的原子性是没有任何关系的

volatile是专门针对内存可见性的场景来解决问题的,并不能解决循环count++的问题