同步:程序中控制不同线程间操作操作顺序的机制
Java线程间采用共享内存变量的方式进行通信
由JVM可知,线程共享的内存区域为堆和方法区,而方法区存放的是类型参数和常量,不存在同步问题,因此共享内存变量主要针对的是堆内存
内存屏障:一组处理器指令,限制内存操作的顺序
缓存行:缓存中可分配的最小单位
原子操作:不可中断的操作
缓冲行填充:缓存内存中的操作数
缓存命中:访问的操作数在缓存中存在
写命中:写回的操作数在缓存中,则更新缓存
写缺失:写回缓冲区失败
JMM:Java内存模型的简称
可见性:保证某一个变量被一个线程更新后,这个更新也出现在其他线程的缓存中
在程序的执行中,执行的顺序不一定是我们编写的顺序
其中编译器可能对程序做重排序,执行时的处理器也可能对程序重排序
比如
x=1; y=2;
编译器优化后的执行顺序可能是
y=2; x=1;
重排序会带来什么问题呢?
多线程的情况下,重排序可能会带来同步问题,因此JMM必须采取措施来防止某些语句的重排序。
JMM的处理方法是插入指定的内存屏障。
现代处理器都采用了写缓冲区的机制,但写缓冲区里面的数据更新要刷新到内存,并被其他线程的读缓冲区读取才对其他线程可见
指令的重排序会破坏这一过程,因此需要插入内存屏障
内存屏障 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | Load1的装载先于Load2的装载 |
LoadStore | Load1; LoadStore; Store2 | Load1的装载先于Store2的刷新 |
StoreStore | Store1; StoreStore; Store2 | Store1的刷新先于Store2的刷新 |
StoreLoad | Store1; StoreLoad; Load2 | Store1的刷新先于Load2的装载 |
volatile的效果和对这个变量的写操作加锁效果是一样的
volatile long vi; public void write(){vi++;} long vi; public void synchronized write(){vi++;}
可见性:对一个volatile变量的读,总能看到最后线程的写入
对于任意volatile变量的读写具有原子性
注意,复合操作不具有原子性,比如vi++,因为vi++实际上是vi先加1再赋值
写实现:写一个volatile变量时,JMM会把缓存中的volatile共享变量值刷新到内存
读实现:读一个volatile变量时,会把缓存的值置为无效,从内存中重新装载
释放锁:JMM会把临界区缓存刷新到内存
获取锁:接下来临界区的读操作都会把缓存置为无效,从内存中读取
由于指令重排序,构造函数执行完成可能在对象初始化完成之前
比如,两个线程可能会像下面一样执行
class test { int i; test(){ i = 5; } }
JMM会禁止final域写重排序到构造函数之外,即对象构造完成时final域一定初始化完成
class test { final int i; test(){ i = 5; } }
重排序规则:初次读对象引用和初次读该对象包含的final域,禁止重排序这两个操作
由于这两个操作具有数据依赖,大多数编译器本来也不会重排序这两个操作
如果final域是引用类型:构造函数初始化final域和把final域变量赋给其他变量不能重排序
final域的重排序可以确保:任意线程引用这个final域变量时,已经成功初始化
如果是下面这种情况:
class Test { final int i; static Test obj; Test(){ i = 1; obj = this; //包含final域的引用逸出了 } }
注意,构造函数里面obj和i的赋值操作是可以重排序的,所以可能会发生下面的情况,读取到未初始化的i:
JMM的设计就是依照happens-before的原则实现的
延迟初始化:推迟某些对象的创建,直到需要的时候才进行对象的初始化
比如,典型的单例模式中的饱汉模式:
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访问对象时,对象可能还没有初始化!
有两种方案实现线程安全的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; } }
类初始化时,JVM会获取一个锁,同步多个线程对同一个类的初始化
public class FullMan{ //static确保类加载时进行初始化 private static class FullManHolder{ public static Instance instance = new Instance(); } public static Instance getInstance(){ return FullManHolder.instance; } }
原理:类加载时,Class对象会有一个初始化锁,保证同一时间只有一个线程进行Class对象的初始化