JAVA并发编程之核心概念(JMM内存模型、Happens-Before原则等)
线程安全
简单来说,线程安全是为了保证在多线程工作时,不会因为多个线程的执行顺序没有符合一定的逻辑依赖而造成意想不到的结果。《深入理解JAVA虚拟机》中对线程安全如下定义:“多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。”
产生线程不安全的原因可以总结为:主内存和线程工作内存数据不一致、重排序。下文将阐明两个因素是如何造成线程不安全的、及其解决方案。
JMM内存模型与数据不一致问题
在JAVA运行时数据结构中我们对JAVA虚拟机的内存模型有初步的了解。下面我们深入了解一下虚拟机的内存模型是如何在多线程中产生影响的。
在堆内存中,存放了线程共享的变量,如对象,静态变量和数组元素。而在线程私有的栈内存中,存放了的局部变量是不会被共享的。由于CPU的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,每个CPU都会有缓存。因此,共享变量会先放在主存中,而每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。如下图所示:
如果线程A和线程B要使用共享变量进行读和写,应该是下面的形式:
- 线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
- 线程B从主存中读取最新的共享变量
如果说A在修改了位于工作内存的共享变量副本还未来得及写回到主内存,B读到了主存中的共享变量,那么就出现了“脏读”的现象。所以为了实现线程安全,我们需保证一个线程对共享变量的修改对其他线程是可见的,即在一个线程修改过一个共享变量后,别的线程应该看到的是共享变量最新的值。可以通过volatile关键字使得被volatile修饰的变量总能强制刷新到主存中,保证修改对其他线程可见。
重排序与Happens-Before原则
重排序指的是,为了提高程序的并发度,编译器和处理器对指令的执行顺序进行重新排序的操作。显然,对于一些互相不存在依赖关系的指令,是可以对其进行重排序,以加大并发度、提升效率的。但是对于一些存在依赖关系的指令,就不允许进行任意的重排序了。举个例子:
double pi = 3.14 //A double r = 1.0 //B double area = pi * r * r //C
这是一个计算圆面积的代码,由于A,B之间没有任何关系,对最终结果也不会存在关系,它们之间执行顺序可以重排序。因此可以执行顺序可以是A->B->C或者B->A->C执行最终结果都是3.14,即A和B之间没有数据依赖性。具体的定义为:如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性这里就存在三种情况:1. 读后写;2.写后写;3. 写后读,者三种操作都是存在数据依赖性的,如果重排序会对最终执行结果会存在影响。因此在重排序时,编译器和处理器应遵守数据依赖性,不会改变存在数据依赖性关系的两个操作的执行顺序。
as-if-serial语义
字面上理解,as-if-serial的意思是“仿佛是串行”。实际上它正是这个意思。
它的意思是:不管一个单线程程序怎么重排序(编译器和处理器为了提供并行度),它的执行结果都是一致的。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。比如上面计算圆面积的代码,在单线程中,会让人感觉代码是一行一行顺序执行上,实际上A、B两行不存在数据依赖性可能会进行重排序,即A,B不是顺序执行的。as-if-serial语义使程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。
happens-before原则
符合as-if-serial语义强调的是,无论编译器、处理器怎么重排序以提高运行效率,都保证单线程程序执行结果一致。而happens-before原则是类似的,它的定义有两个方面:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且保证第一个操作的执行顺序排在第二个操作之前。
- 但是两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序也是被允许的。
那么怎么才算是happens-before的关系呢?
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
happens-before意味着:如果从程序的角度来说A happens-before B,那么JMM内存模型保证A对B是可见的。但是只要不改变程序的执行结果,JMM允许编译器和处理器怎么优化都行。也就是说,用户只需写出正确的同步关系,JMM保证程序符合用户要求的执行逻辑的,但是对于不影响执行结果的happens-before关系,JMM允许编译器和处理器对其进行重排序优化。
比较happens-before与as-if-serial
两者关心的主要矛盾是一致的,不过对象有所不同:
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻觉:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
并发编程三大性质
原子性
原子性是指保证一个操作是不可中断、不可分割的,要么全部执行成功要么全部执行失败,不存在只执行了一半,另一半没执行的结果。
有序性
有序性指线程在访问共享变量时必须是“串行”的。当一个线程在访问,别的线程只能等待。
可见性
可见性指一个线程对共享变量的修改对于别的进程来说是可见的,即当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
参考
https://juejin.cn/post/6844903600318054413
https://blog.csdn.net/u011521203/article/details/80186218
https://www.cnblogs.com/dolphin0520/p/3920373.html
《深入理解Java虚拟机》