深入理解JUC01
起因
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规定,所有变量都存储在主内存中(所有线程共享,包括堆和方法区),每个线程想要操作某个变量(包括对象)时都需要先从主存中拷贝一份到自己的工作内存,然后在自己的工作内存中处理数据,再存放入主存中。
这种工作模式就会发生以下问题
在左图中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 | int a = 0; |
如上述代码,线程A、B
同时对该实例对象进行操作,其中A
线程调用methodA
方法,而B
线程调用methodB
方法,由于指令重排等原因,可能导致程序执行顺序变为如下
1 | 线程A 线程B |
以上问题是导致线程安全问题的原因,下一节我们会讲述如何解决这些问题。
参考文章: