I. 内存模型的基础
同步:程序中控制不同线程间操作操作顺序的机制
Java线程间采用共享内存变量的方式进行通信
由JVM可知,线程共享的内存区域为堆和方法区,而方法区存放的是类型参数和常量,不存在同步问题,因此共享内存变量主要针对的是堆内存
0. 一些相关术语
-
内存屏障:一组处理器指令,限制内存操作的顺序
-
缓存行:缓存中可分配的最小单位
-
原子操作:不可中断的操作
-
缓冲行填充:缓存内存中的操作数
-
缓存命中:访问的操作数在缓存中存在
-
写命中:写回的操作数在缓存中,则更新缓存
-
写缺失:写回缓冲区失败
1. Java内存的抽象模型
JMM:Java内存模型的简称
可见性:保证某一个变量被一个线程更新后,这个更新也出现在其他线程的缓存中
2. 指令的重排序
在程序的执行中,执行的顺序不一定是我们编写的顺序
其中编译器可能对程序做重排序,执行时的处理器也可能对程序重排序
比如
x=1;
y=2;
编译器优化后的执行顺序可能是
y=2;
x=1;
重排序会带来什么问题呢?
多线程的情况下,重排序可能会带来同步问题,因此JMM必须采取措施来防止某些语句的重排序。
JMM的处理方法是插入指定的内存屏障。
3. 内存屏障
现代处理器都采用了写缓冲区的机制,但写缓冲区里面的数据更新要刷新到内存,并被其他线程的读缓冲区读取才对其他线程可见
指令的重排序会破坏这一过程,因此需要插入内存屏障
- Load:数据从内存装载到线程缓冲区
- Store:数据从线程缓冲区刷新到内存
内存屏障 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | Load1的装载先于Load2的装载 |
LoadStore | Load1; LoadStore; Store2 | Load1的装载先于Store2的刷新 |
StoreStore | Store1; StoreStore; Store2 | Store1的刷新先于Store2的刷新 |
StoreLoad | Store1; StoreLoad; Load2 | Store1的刷新先于Load2的装载 |
II. volatile内存语义
volatile的效果和对这个变量的写操作加锁效果是一样的
volatile long vi;
public void write(){vi++;}
long vi;
public void synchronized write(){vi++;}
-
可见性:对一个volatile变量的读,总能看到最后线程的写入
-
对于任意volatile变量的读写具有原子性
注意,复合操作不具有原子性,比如vi++,因为vi++实际上是vi先加1再赋值
1. volatile内存语义的实现
-
写实现:写一个volatile变量时,JMM会把缓存中的volatile共享变量值刷新到内存
-
读实现:读一个volatile变量时,会把缓存的值置为无效,从内存中重新装载
III. 锁的内存语义
释放锁:JMM会把临界区缓存刷新到内存
获取锁:接下来临界区的读操作都会把缓存置为无效,从内存中读取
IV. final域的内存语义
1. final写的重排序
由于指令重排序,构造函数执行完成可能在对象初始化完成之前
比如,两个线程可能会像下面一样执行
class test {
int i;
test(){
i = 5;
}
}
JMM会禁止final域写重排序到构造函数之外,即对象构造完成时final域一定初始化完成
class test {
final int i;
test(){
i = 5;
}
}
2. final读的重排序
重排序规则:初次读对象引用和初次读该对象包含的final域,禁止重排序这两个操作
由于这两个操作具有数据依赖,大多数编译器本来也不会重排序这两个操作
如果final域是引用类型:构造函数初始化final域和把final域变量赋给其他变量不能重排序
3. 为什么final引用不能从构造函数逸出
final域的重排序可以确保:任意线程引用这个final域变量时,已经成功初始化
如果是下面这种情况:
class Test {
final int i;
static Test obj;
Test(){
i = 1;
obj = this; //包含final域的引用逸出了
}
}
注意,构造函数里面obj和i的赋值操作是可以重排序的,所以可能会发生下面的情况,读取到未初始化的i:
V. happens-before原则
JMM的设计就是依照happens-before的原则实现的
- 程序顺序规则:一个线程中的任意操作,先于这个线程中的任意后续操作(虽然可能会指令重排,但就结果来看,是没有影响的
- 监视器锁规则:对于一个锁的解锁,先于随后对这个锁的加锁
- volatile规则:对volatile域的写,先于之后对这个域的读
- 传递性:A先于B,B先于C,则A先于C
- start()规则:线程的start()操作早于线程的任何操作
- join()规则:如果线程A中执行B.join(),则B线程的执行先于join()返回后的任意操作
VI. double-check与延迟初始化
1. double-check
延迟初始化:推迟某些对象的创建,直到需要的时候才进行对象的初始化
比如,典型的单例模式中的饱汉模式:
public class FullMan{
private static Instance instance; //暂时不初始化
//要用的时候才初始化
public static Instance getInstance(){
if(instance == null) instance = new Instance();
return instance;
}
}
但是,这个例子在多线程的环境下,缺乏同步机制,可能会出现问题
比如A线程初次进入,开始创建对象;还没创建完成时B线程又进来,也开始创建对象
因此,可以通过加锁解决这个问题:
public class FullMan{
private static Instance instance; //暂时不初始化
//要用的时候才初始化
public static synchronized Instance getInstance(){
if(instance == null) instance = new Instance();
return instance;
}
}
不过synchronized会引起上下文切换,并发量高的情况下性能会很低
因此,可以使用double-check来进行延迟初始化
public class FullMan{
private static Instance instance; //暂时不初始化
//要用的时候才初始化
public static Instance getInstance(){
if(instance == null){ //1.第一次check
synchronized(FullMan.class){
if(instance == null) instance = new Instance(); //2.第二次check
}
}
return instance;
}
}
和上面的单次check相比,多个线程访问时,只要instance != null
马上就能返回,不需要排队
只有在进行初始化才需要加锁操作,大大提高了效率,不过这个double-check也是有问题的
对象初始化这个操作,其实可以分解为下面三行伪代码:
memory = allocate(); //1.分配内存
ctorInstance(memory); //2.在内存块上初始化对象
instance = memory; //3.地址赋给instance
操作2和操作3可能会被重排序,执行顺序变成1->3->2,先得到地址,再进行初始化
只要在单线程中,操作2在访问对象的域对象之前执行,这种重排序就是合法的
就单线程而言,这种重排序是可以的,但如果在多线程中呢?
线程B访问对象时,对象可能还没有初始化!
2. 优化延迟初始化
有两种方案实现线程安全的double-check
- 不允许2和3重排
- 允许2和3重排,但new操作完成之前,这个重排序对其他线程不可见
2.1 基于volatile的double-check
采用的第一种方案,volatile修饰的对象初始化在多线程环境中会禁止重排序
设置要读取的变量为volatile即可
public class FullMan{
private volatile static Instance instance; //暂时不初始化
//要用的时候才初始化
public static Instance getInstance(){
if(instance == null){ //1.第一次check
synchronized(FullMan.class){
if(instance == null) instance = new Instance(); //2.第二次check
}
}
return instance;
}
}
2.2 基于类初始化的解决方案
类初始化时,JVM会获取一个锁,同步多个线程对同一个类的初始化
public class FullMan{
//static确保类加载时进行初始化
private static class FullManHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return FullManHolder.instance;
}
}
原理:类加载时,Class对象会有一个初始化锁,保证同一时间只有一个线程进行Class对象的初始化