为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障有四种,JMM可以让这四种内存屏障都插入(保守策略,对优化有很多没有必要的限制),但是这样效率会很低,结合上表以及在volatile写后插入StoreLoad内存屏障的功能是对volatile的写和之后的读写操作都不会进行重排序,JMM选择了采用在volatile写后插入一个StoreLoad内存屏障,并且StoreLoad屏障会让写入的值立即刷新到主存中。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。
处理器也可以对重排序和内存屏障进行优化,比如X86的处理器只会对写-读操作进行重排序,所以对于x86计算机来说,会自动省略掉除StoreLoad之外的三中内存屏障。
从这可以看出来,通过以上两种方式就可以保证volatile关键字修饰的变量在多线程并发环境中不会出现读写错误,可以保证线程间可见性和有序性,可见性主要是通过缓存一致性协议保证的,有序性则主要是通过内存屏障保证的。但是volatile关键字是不能保证原子性的,只能保证在读写单操作时是原子性的,比如一个自增操作就是一个读+计算+写三步完成的,所以说不能保证原子性。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) // 1
instance = new Instance(); // 2
return instance; // 3
}
}
比如有两个线程都在执行该方法,可能出现【这只是一种可能】
A线程执行1
B线程执行1
A线程执行2
A线程执行3
B线程执行2
B线程执行3
这样就会拿到两个不同的实例,这并不是单例想要的。
这就出现了第二种:写一个getInstance()用来获取单例,在这个方法上使用synchronized关键字加锁
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
这虽然满足了单例的要求,但是如果有大量的线程都在获取这个实例,无论这个单例是否已经被初始化过,都要进行加锁释放锁的操作,那效率就太低啦
之后出现了第三种:双重检查锁定(Double-Checked Locking)
public class DoubleCheckedLocking {
private static Instance instance;
public static Instance getInstance() { // 1
if (instance == null) { // 2:第一次检查
synchronized (DoubleCheckedLocking.class) { // 3:加锁
if (instance == null) // 4:第二次检查
instance = new Instance(); // 5:创建一个Instance实例
}
}
return instance;
}
}
这是,调用getInstance方法时,如果发现单例已经初始化过了,就直接返回,如果没有这个对象才去获得锁然后创建这个单例。拿到锁之后还是需要二次判断,因为可能在线程A进行4操作时,线程B刚拿到锁正在创建对象,随后线程A去拿锁时线程B创建了所需的实例并释放了锁。
这时候可能会觉得已经大功告成了,但是因为会有指令重排序,new Instance操作分为以下三步:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
在某些JIT编译器上可能会对2和3进行重排序:
memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址,此时没有初始化!
ctorInstance(memory); // 2:初始化对象
Java语言规范明确指出,重排序不能导致单线程情况下的执行结果与原先不同,将2和3重排序会提高性能,但是在多线程情况下如果对2和3进行重排序可能会出现一下情况:
在这种情况下,线程B就会拿到一个null对象。
那么现在就有两种解决方法:
1.不允许2和3重排序
2.不允许别的线程看到2和3的重排序
根据volatile的有序性可以保证2和3在线程间不被重排序:
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance(); // instance为volatile
}
}
return instance;
}
}
这样就不会出现判断instance不为null,但还没有为对象分配空间而导致的安全问题了。通过volatile修饰共享变量达到禁止指令重排从而保证线程安全的方式。还有另外一个让别的线程看不到的方式可以采用静态内部类方式保证,这里就不多说啦~
注:本文参考《Java并发编程的艺术》