Java内存模型与线程安全
参考:
- 《深入理解Java虚拟机》第四版——周志明
- 再有人问你Java内存模型是什么,就把这篇文章发给他
由于CPU技术的发展,内存读写速度跟不上CPU执行速度,导致CPU每次操作内存都要耗费很多等待时间。所以在CPU和内存之间加入了高速缓存(速度快、内存小、昂贵)。
由此,程序执行的过程中会将运算需要的数据从主存复制一份到CPU的高速缓存当中,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
随后,技术的发展开始支持多核CPU,多线程。
如下图所示为单CPU双核缓存示意图:
![](https://img.haomeiwen.com/i9307436/a6d01c4216db8f76.jpg)
由于CPU和内存之间存在着缓存,导致会出现缓存一致性问题(即在不同core中的缓存中对内存中某个数据的值可能不一致);
![](https://img.haomeiwen.com/i9307436/5fe4c60b328e3137.jpg)
处理器优化和指令优化
除了前面提到的缓存一致性问题。为了处理器内部的运算单元能够尽量地被充分利用,处理器可能会对输入的代码进行乱序执行(将代码分派给各电路单元处理),称为处理器优化;
很多编程语言的编译器做的类似的优化称为指令重排;
并发编程的问题
- 原子性问题:指一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不执行完成,要不就不执行;对应处理器优化问题
- 可见性问题:当多个线程访问同一个变量时,一个线程修改这个变量的值,其他线程能够立即得知;对应缓存一致性问题;volatile、synchronized和final均可实现可见性,但实现方式不同;
- 有序性问题:程序执行的顺序按照代码的先后顺序执行;对应指令重排
内存模型
为了保证共享内存的正确性(原子性、可见性、有序性),内存模型定义了共享内存系统中度线程程序读写操作行为的规范;
-
内存模型规定了工作内存和主内存之间如何做数据同步以及什么时候做数据同步;
-
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
-
屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
-
内存间交互操作及相关规则
-
8种操作中每个操作都必须是原子的、不可再分的(例外:double、long类型变量,load、store、read、write操作在某些平台下允许有例外);8种操作的定义在这里不做复述;
-
不允许read/load、store/write的操作之一单独出现,例如不允许出现从主内存读取(read)了数据但是工作内存不接受(没有load)的情况;
-
不允许一个操作丢弃最近的assign操作,即变量在工作内存保存后,必须同步回主内存;
-
不允许无assign操作直接执行store、write操作将数据保存至主内存;
-
新的变量只能在主内存“诞生”,在对一个变量进行use、store之前必须先执行过load、assign;
-
一个变量同一时刻只能由一个线程执行lock操作,但是一个线程可以对其重复执行多次lock操作,同时之后需要之星对应次数的unlock才会释放变量锁;
-
如果对一个变量执行lock操作,那就会清空工作内存中该变量的值,避免出现缓存不一致的问题;
-
不允许跳过lock,直接unlock的操作;
-
对一个变量unlock之前,必须对先对该变量同步到主内存(执行store、write操作)
...
-
![](https://img.haomeiwen.com/i9307436/4b11fe8ad16eb240.jpg)
volatile关键字
只保证可见性,在不满足以下两条规则的运算场景中,仍需要通过加锁来实现(通过sychronized或java.util.concurrent中的原子类)
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量
- 变量不需要与其他的状态共同参与不变约束
例:
public static volatile int count = 0;
public static void increase(){
count ++;
}
public static void main(String args[]){
Thread threads = new Thread[100];
for(int i = 0; i < 100; i++) {
threads[i] = new Thread(new Runnable(){
@Override
public void run(){
for(int i = 0; i<100; i++) {
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(count);
}
以上代码中count的新值依赖于count的当前值,且不止一个线程在改变它的值;显然这段代码的运算结果不会是理想状态下的10000;
禁止指令重排序
内存模型
在特定的操作写一下,对特定的内存或高速缓存进行访问的过程抽象
线程安全的两个方面:执行控制和内存可见
内存屏障(Memory Barrier)
可以保证在此之前的代码全部执行完才开始执行在此之后的代码
- 保证特定操作的执行顺序
- 影响某些数据(或某条指令的执行结果)的内存可见性
happens-before(先行发生原则)
如果一个操作的执行结果对于另一个操作可见,那么两个操作之间必须要存在happens-before关系,这两个操作可以在同一个线程,也可以不是;
happens-before规则如下:
- 程序顺序规则:同一线程中的任意操作,happens-before于该线程中任意的后续操作;
- 监视器锁规则:对同一个锁的解锁操作,happens-before于随后对这个锁的加锁操作;
- volatile域规则:对于一个volatile域的写操作,happens-before于随后任意线程对这个volatile的读操作;
- 传递性规则:如果A happens-before于 B, 且B happens-before于 C,那么A happens-before于 C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始;
-
上述的中随后是时间顺序上的先后,衡量并发安全问题时不应该受到时间顺序的干扰,以先行发生原则为主。
例如
class A { private int value = 0; public void setValue(int value){ this.value = value; } public int getValue(){ return value; } }
public static void main(){ final A a = new A(); Thread t1 = new Thread(new Runnable() { @Override public void run() { a.setValue(1); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { System.out.println(a.getValue()); } }); t1.start(); t2.start(); }
上面两个线程t1在时间上早于t2执行,那么打印出来的数字会是1吗?在判断方法上我们可以采用是否符合先行发生原则;很明显的不同线程(排除程序次序规则)、无volatile(-volatile规则)、无synchronized(-管程锁定规则),跟线程启动/终止/中断/终结规则均无关,更不提传递性规则。所以我们可以得出这个操作是线程不安全的,getValue()的值无法确定;
从更易理解的角度,可以从内存交互操作和缓存一致性进行分析,当setValue()在工作内存中对变量副本assign后,①(在变量的值store和write到主内存前),②(t2中的getValue()操作可能已经read和load到了value的旧值0),当然也存在②发生在①之后的情况,取得value值为修改后的1,所以我们说这是线程不安全的;
如何解决?
如何判断就如何解决,可以通过将value设为volatile修饰,或者把setter和getter方法都定义为synchronized方法等实现线程安全.