JMM基本结构
Java 内存模型 JMM - 希望使用这个内存模型屏蔽不同硬件下管理内存的差异。
JMM规定每一个变量都是存储在主内存中的 , 每一个Java线程都有自己的一个工作内存 , 里面存储主内存中每一个变量的拷贝 ; 工作内存和主内存之间使用的是 load 和 save 的原子操作来控制。
对于每一个线程的工作内存,就像是一个线程内的栈 , 其使用寄存器和高速缓存 ; 那么主内存就是在堆中。
内存间的交互操作
上面说的 工作内存 和主内存之间的操作如 load save 来完成变量的拷贝的更新 , 那么这个过程有哪些规则可以完成不同的线程和主内存安全的进行变量的交换呢
对于主内存的变量:
- lock
- unlock
- read
- write
对于工作内存的变量
- load
- use
- assign - 赋值
- store
变量从主内存拷贝到工作内存 : read → load
变量从工作内存拷贝到主内存 : store → write
上面的操作都是成对出现的
一个新的变量只能从主内存产生
volatile 变量
volatile 可以依赖主内存的数值读之前必须从主内存重新刷新新的值 , 可以保证变量的线程可见性 , 即一个线程修改后 , 新的值对于其他线程立即可见。
但是对于普通变量 , 值的传递依赖主内存的配合。
但是可见性不代表是线程安全的 , 比如 volatile long num = 0 ; 并发环境下的 num++ 还是会存在问题 ,这是因为虽然数值的修改对于其他线程可见 , 但是不是原子的。
同时 volatile 有一个禁止指令重排序的语义。
对于指令重排序 , 即JVM会优化没有关联变量语义的指令 , 所以其执行的顺序不像是代码描述的那样。
volatile 加一个内存屏障 ,保证后面的指令不能重新排序到这个屏障之前。
比如经典的单例的双重检测加锁的 , 就必须将对象指定为 volatile 类型 ,不是final 就结束了, 不然重排序的初始化时间还是无法保证。
关于性能 , 读取这个变量的性能和一般的变量没什么差别 , 但是写的话 , 就需要刷新到其他线程来读取,所以性能会差一点。同时,因为存在内存屏障 , 整体也会存在性能损耗。
同步问题的特性
- 原子性 : 一个操作或者多个操作,要么全部执行成功,要么全部执行失败。满足原子性的操作,中途不可被中断
- 使用上面的几个原子性的操作 如 load 来实现基本类型的原子性
- 对于更大范围的原子性的保证 , 使用 lock 和 unlock完成
- 可见性: 多个线程共同访问共享变量时,某个线程修改了此变量,其他线程能立即看到修改后的值
- 有序性: 程序执行的顺序按照代码的先后顺序执行。(由于JMM模型中允许编译器和处理器为了效率,进行指令重排序的优化。指令重排序在单线程内表现为串行语义,在多线程中会表现为无序。那么多线程并发编程中,就要考虑如何在多线程环境下可以允许部分指令重排,又要保证有序性)
- 本线程内观察 , 所有的操作都是有序的 : 线程内 表现为串行的语义
- 如果在一个线程观察另一个线程,所有的操作都是无序的: 指令重排序现象 和 工作内存和主内存的同步延迟问题
synchronized
关键字同时保证上述三种特性。
synchronized
是同步锁,同步块内的代码相当于同一时刻单线程执行,故不存在原子性和指令重排序的问题synchronized
关键字的语义JMM有两个规定,保证其实现内存可见性:- 线程解锁前,必须把共享变量的最新值刷新到主内存中;
- 线程加锁前,将清空工作内存中共享变量的值,从主内存中冲洗取值
volatile
关键字作用的是保证可见性和有序性,并不保证原子性
那么对于final 字段 , 如何实现可见性呢?
final的变量初始化完成后 , 可以确保变量的值是不可变的 + JMM也会对final完成禁止重排序 , 完成其不同线程的可见性
Happends-Before关系
如果JMM中所有的有序性仅靠volatile和synchronized完成,有些操作会变得很繁琐,但我们在写Java并发代码时并没有感觉到这一点,是因为JMM中有个happens-before关系。
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
i = 1; // a in thread1
j = i; // b in thread2
i = 2; // c in thread3
// 若a操作happens before b操作,那么可以确定b操作执行后j一定为1
// 但如果现在来了c操作,且c与a,b无happens-before关系,则j的值就不确定了,1和2都有可能。
Final 字段
一旦你将引用声明作final,你将不能改变这个引用了,编译器会检查,如果你试图将变量再次初始化的话,编译器会报错。一个值不会变,就为JMM提供了很多便利。如编译器可将final字段的值缓存在寄存器里,且不用从主存里重新载入。相反,非final字段可能就需要重新载入。
但在旧的JMM中 ,最严重的一个缺陷就是线程可能看到 final 域的值会改变。
所以在新的JMM中,对于final域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(先写入final变量,后调用该对象引用)因为编译器会在final域的写之后,插入一个StoreStore屏障。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。(先读对象的引用,后读final变量)因为编译器会在读final域操作的前面插入一个LoadLoad屏障。
class FinalExample {
int i;
final int j;
static FinalExample obj;
public FinalExample () {
i = 1;
j = 2;
}
public static void writer () {
obj = new FinalExample ();
}
public static void reader () {
FinalExample object = obj;
int a = object.i;
int b = object.j;
}
}
若final 域为一个引用类型,重排序规则对编译器和处理器增加了如下约束:
- 在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。