上篇博客我们提到了Java内存模型,而Java内存模型的建立是围绕三个特征建立的:原子性、可见性、有序性。
那么可见性可能出现的问题例如脏读,主内存和工作内存之间的共享变量操作方式,保证了数据可见性,另外volatile的强制刷新增强了可见性;
有序性可能出现的问题例如单例的双重锁隐藏的安全性问题,Happens-Before“禁止部分”编译器重排序,另外volatile通过插入内存屏障来禁止处理器重排序,保证了数据有序性,;
原子性可能出现的问题例如i++问题,java内存模型中定义了8中操作都是原子的,不可再分的,保证了数据原子性。
原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。
java内存模型中定义了8中操作都是原子的,不可再分的:
- lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
- unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
- load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
- use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
- assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
- write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
有序性
在单线程程序里,代码依次执行;在多线程并发时,JMM为了性能优化,编译器和处理器会进行指令重排序,这样程序的执行就可能会出现乱序,从而影响最后的结果。
通过上一篇博客我们知道,JMM的Happens-Before原则禁止了一部分重排序问题;另外volatile和synchronized来保证多线程之间操作的有序性,volatile关键字本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现。
最经典的由指令重排序导致的线程安全问题是单例模式的双重锁问题
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
我们看到用volatile修饰了实例变量,从而禁止了指令重排序问题。如果不用volatile修饰,我们看看指令重排序问题会导致什么样的结果。
instance = new Singleton();实际上包含三个操作:1.分配对象的内存空间;2.初始化对象;3.设置instance指向刚分配的内存地址。但由于存在重排序的问题,可能有以下的执行顺序:
如果2和3进行了重排序的话,对象地址已存在,但对象还没有初始化完成、线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,线程B就会获取到一个空对象。而用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。volatile包含禁止指令重排序的语义,其具有有序性。
可见性
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
上篇博客提到的JMM主内存和工作内存的工作方式,是是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
我们知道volatile是可以保证共享变量可见性的,既然JMM实现了可见性机制,为什么只有volatile修饰的变量可以保证呢?其实无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。