Java内存模型
JVM(Java Virtual Machine)的JMM(Java Memory Model)分为主内存和各个工作内存, 工作内存里存有该线程下的局部变量和从主内存里拷贝的共享变量副本.
线程对主内存的读写都需要三步, 读会执行read + load + use 三步, 写会执行assign + store + write 三步. 注意, 它们并不是原子操作, 也不是连续执行, read可能远早于use, assign也可能远早于write, 在它们中间可能会发生的其他线程的读写.
多线程要处理的问题
多线程下JMM主要考虑三个问题: 原子性, 内存可见性, 有序性.
- 原子性 是指某一操作或一系列操作不可被中断, 即这些操作要么不执行, 要么肯定会执行完毕; 在多线程情况下, 这些操作中间不会夹杂其他线程的处理. 原子性可以使用Atomic或者同步锁保证, volatile不能保证原子性. 在java中,不加锁的情况下只有下列操作是原子操作:
- all assignments of primitive types except for long and double 除了long/double以外的所有基本类型的赋值
- all assignments of references 所有引用的赋值
- all operations of java.concurrent.Atomic* classes 各个Atomic类中的所有操作
- all assignments to volatile longs and doubles 使用volatile修饰的long/double的复制操作
- 内存可见性 是指JMM在工作线程中读写变量均是真实值, 即在工作线程中进行写操作时会立即同步到主内存中, 进行读操作时立即从主内存中获取, 保证多线程对数据的读写都是实时的. 使用volatile或者同步锁可以保证内存可见性.
- 有序性 主要是由于 指令重排 的原因. 指令重排时不会改变单线程情况下的指令语义顺序, 但是不能保证多线程下的运行情况. 例如, 线程A执行
a=2; i=3;
, 这两条语句没有相关性, 所以可能被优化为i=3; a=2;
, 但是线程B根据A的代码顺序认为a==2
时i==3
, 这就会因为指令重排产生错误. 指令重排 相关介绍请看下面. 另外, 有序性并不是说多个线程调度的情况,比如线程A先start, 线程B后start, 这里应该是A.run happens-before B.run, 不会有无序的问题, 否则synchronized也保证不了有序性了(个人理解).
volatile
volatile 有三种语义:
- 保证内存可见性, 即在读写的时候都是操作主存, 而不是操作分配给各个线程的副本.volatile修饰的变量在读操作时, 会连续执行read+load+use 三步; 写操作时会连续执行assign+store+write三步, 这样就保证了多线程下读写的值均是主内存的真实值.
- 阻止写操作指令重排, 此作用并不是因为语义1的原因. 具体请看下面关于 java对象的初始化中的指令重排 的说明.
- 保证 long 和 double 的原子操作( 注意, 不能保证其他类型的原子性 ),此条跟JVM具体实现有关系,对于某些JVM可以保证long、double的原子性,有一些JVM需要加volatile.
volatile 语义的实现原理:
- 保证内存可见性: 在多核处理器中,当进行一个volatile变量的写操作时,编译器在写操作的指令前在上一个“lock”前缀。“lock”前缀的指令在多核处理器下会引发了以下两件事情是其内存可见性的根本原因:
- 将当前处理器缓存行的数据会写回到系统内存.
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效.
- 阻止写指令重排: 根据 happens-before 规则, JMM 在每个 volatile 的写操作后面插入一个 StoreLoad 内存屏障, StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令.
volatile只能保证内存可见性, 但不能保证非基本类型对象的原子性、基本类型自增操作的原子性
基本类型的自增操作 a++ 或者 a = a+1, 可以拆解为以下几步
1. 从主内存获取到a值, volatile修饰的情况下可以保证取到的是真实值
2. a+1, 把计算结果暂存到工作内存某位置(每个CPU核心的缓存行,L1、L2、L3三级)
3. 把工作内存存储的值赋给主内存的a, 也能保证同步
但是注意, 123三步中间可能插有其他线程的操作, 假设原来 a=0, 线程A和线程B同时获取到了a的值0, 假设当A执行到2/3中间时 B已经执行完了 a=-1 的原子操作, 主内存中的a已经变为了 -1, 但是A在执行完毕后, 会把a值修改为在第二步保存下来的1, 这样线程B的操作全部无效.
如果多线程下需要自增操作可以使用 Atomic 类或者加锁实现.
指令重排##
- 指令重排是什么
指令队列在CPU执行时不是串行的, 当某条指令执行时消耗较多时间时, CPU资源足够时并不会在此无意义的等待, 而是开启下一个指令. 开启下一条指令是有条件的, 即上一条指令和下一条指令不存在相关性. 例如下面这个例子:
a /= 2; // 指令A
a /= 2; // 指令B
c++; // 指令C
这里的指令B是依赖于指令A的执行结果的, 在A处于执行阶段时, B会被阻塞, 直到A执行完成. 而指令C与A/B均没有依赖关系, 所以在A执行或者B执行的过程中, C会同时被执行, 那么C有可能在A+B的执行过程中就执行完毕了, 这样指令队列的实际执行顺序就是 C->A->B 或者 A->C->B.
- happens-before原则
happens-before原则在尽量放宽了编译器优化的同时, 定义了几种不允许指令重排的情况, 请见
- java对象的初始化中的指令重排
一条对象初始化语句 AA aa = new AA() 的实际执行其实分为三步:
AA aa: 分配内存
new AA(): 创建对象
=: 把创建的对象指给分配的内存地址
根据上面所说的, 2和3没有依赖关系, 在2执行的过程中, 3可能已经执行完毕了, 这时实际执行顺序为 1->3->2. 注意, 此时线程A操作的是aa在主内存的拷贝, 如果在3执行/2完毕的临界时间点时 此工作内存同步到了主内存中, 其他线程引用aa的时候就会得到一个初始化不完全的AA对象.
volatile 的语义之一就是禁止指令重排, 使用 volatile 修饰AA对象, 可以保证 AA aa = new AA() 的三条语义是按照123的顺序执行的, 这时其他的线程在访问aa对象的时候只会得到 null 或者初始化完成的 aa.
注意
- 此处是利用的volatile的禁止指令重排语义, 并不是内存可见性. 指令重排后其他线程可能会通过第一层判断获取到一个未初始化完成的instance, 这也是为什么单例模式中使用了可以保证内存可见性的synchronized的同时还需要volatile的原因;
- AA aa = new AA() 实际的三步执行过程也说明了 volatile不能保证原子性.
synchronized
synchronized是对象同步锁, 可以保证多线程情境下的内存可见性, 原子性和多线程观察下的执行有序性(不是单线程).
各种类型各种用法本质上都是锁住的对象/类, 当执行synchronized fun1时, synchronized fun2 也会等待.
- synchronized 可以同时保证变量的内存可见性以及修改的有序性, 类似下方的操作读写到的data都是真实值, 变量a也不会出错.
ps. 简单的这种情景, 使用 Atomic 效率会更高, 具体请看我的另一博客的Atomic部分 从源码看Android常用的数据结构 ( 一 , 总述 )
public synchronized Data getData(){
a++;
return data;
}
public synchronized void setData(Data data){
a--;
this.data = data;
}
- 写单例的时候要给getInstance加锁, 用于防止同步问题, 一般写法是:
public class AA{
private volatile static AA instance;
private AA(){}
public static AA getInstance(){
if(instance == null) {
synchronized(AA.class) {
if(instance == null) {
instance = new AA();
}
}
}
return instance;
}
// 其他方法不能加锁, 加了锁之后多线程下效率太差
public void otherFunction(){...}
}