起因

​ java中的JUC不仅是面试的高频考点,也是java中比较难的知识点,其中包含大量的设计技巧,内容庞大复杂。希望借该系列文章加深对JUC的理解。

为什么要学习JUC

​ 由于java是支持多线程编程的,因此会发生线程安全问题,JUC是java.util.concurrent包的简称,即Java并发编程工具包,目的是为了更好地支持高并发任务,让开发者进行多线程编程时有效减少竞争条件和死锁线程。也就是说如果想要使用java进行并发编程,那么一定离不开JUC。

​ 你可能会问为什么会发生线程安全问题,这是一个相对复杂的问题,涉及计算机的底层设计。

为什么会发生线程安全问题

​ 为了提高系统的性能,CPU往往是支持线程并发执行的,也就是在单核CPU情况下,如果当前运行的线程被阻塞,操作系统会调度一个新的线程,从而充分利用CPU资源。

可见性

​ 在java中为了规定硬件架构与java语言的内存模型,引入JMM(java memory model)。需要注意的是JMM是一套规范,并不存在具体的代码。

​ 在java中每创建一个线程,jvm都会为该线程分配一个工作内存(线程栈),这是该线程私有的,对其他线程不可见。并且JMM规定,所有变量都存储在主内存中(所有线程共享,包括堆和方法区),每个线程想要操作某个变量(包括对象)时都需要先从主存中拷贝一份到自己的工作内存,然后在自己的工作内存中处理数据,再存放入主存中。

​ 这种工作模式就会发生以下问题

image-20240327131036567

在左图中A、B两个线程同时将共享数据i拷贝至自己的工作内存,然后对其进行+1操作,最终主内存的i等于1(而不是我们想要的2)

在右图中,A线程先从主内存中拷贝共享数据i至工作内存,然后对其进行操作,这时线程B想要操作i,于是去主内存中查询数据i,此时i的值是变化的,取决于线程A将数据i写回主存的时间。

以上两种情况都发生了线程安全问题。

那你可能会问:为啥要给线程分配一个独立的空间呢?不分配是不是就不会发生线程安全问题呢?

​ 首先线程在执行时需要使用栈来存储局部变量、函数参数和返回地址。你可能会问那都存在主存中不行吗?如果多个线程共享一个区域,可能会导致数据混乱和程序崩溃。

​ 再者,计算机的体系结构就必然会导致可见性问题,为了平衡CPU和主存间的速度不匹配,在它们中间引入了cache。线程其实是CPU的抽象,线程操作数据的本质其是CPU在操作数据,而CPU操作数据时优先从缓存中读写数据,然后再同步到主存中,在同步的过程中就可能发生线程安全问题。同样是上述例子,也就是说运行线程A的CPU从主存中读取数据i(缓存中没有),操作完后会先存入缓存中,此时运行线程B的CPU也从主存中读取数据i,由于线程A修改后的数据还没写回主存,导致线程B读取的是旧数据。

原子性

​ 原子性指的是一个操作是不可中断的,要么全部执行成功,要么全部执行失败。

​ 我们知道CPU每次只能执行一条指令,而java中有些操作其实是由多条指令组成,比如自增操作在底层其实是由3条指令组成(读取,修改和写回),那么在这个过程中就可能发生线程安全问题。比如线程A执行i++操作,底层CPU执行到修改指令时,由于时间片到或其他原因CPU发生线程切换,如果新的线程也要操作数据i就会发生线程安全问题。

​ 有点要注意的是:对于32位系统的来说,byte、short、int、float、boolean、char等基本数据类型的单次读写是原子操作,而long、double类型的数据,它们的单次读写并非原子性的!因为long和double是64位需要分两次进行读写(高32位,低32位),不过目前商用的虚拟机都把64位数据的读写作为原子操作执行。

有序性

​ 在计算机中,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。如果不存在数据依赖(即后面执行的语句无需依赖前面语句的执行结果),处理器可以改变语句对应机器指令的执行顺序。

编译器和处理器的重排序都会导致线程安全问题的发生。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
int a = 0;
boolean f = false;

public void methodA(){
a = 1;
f = true;
}
public void methodB(){
if(f){
int i = a + 1
}
}

如上述代码,线程A、B同时对该实例对象进行操作,其中A线程调用methodA方法,而B线程调用methodB方法,由于指令重排等原因,可能导致程序执行顺序变为如下

1
2
3
4
5
6
线程A                      线程B
methodA: methodB:
代码1:f= true; 代码1:f= true;
代码2:a = 1; 代码2: a = 0 ; //读取到了未更新的a
代码3: i = a + 1;

以上问题是导致线程安全问题的原因,下一节我们会讲述如何解决这些问题。

参考文章:

(一)玩命死磕Java内存模型(JMM)与Volatile关键字底层原理 - 掘金 (juejin.cn)