由于各种硬件及操作系统的内存访问差异,java虚拟机使用java内存模型(java Memory Model,JMM)来规范java对内存的访问。这套模型在jdk 1.2中开始建立,经jdk 1.5的修订,现已逐步完善起来。
什么是java内存模型
什么是java内存模型,为什么会有这个模型?关于这个问题,就不得不从并发的问题讲起。在多核系统中,处理器一般设置缓存来加速数据的读取,缓存大大提升了程序性能,却也带来了“缓存一致性”的新问题。比如,当多个处理器写同一块主内存时,以谁的缓存数据为准?读取、写入内存的变量需遵循怎样保证线程安全?针对这些问题,java设计了一套内存模型以用来定义程序中各个变量的访问规则。
java的内存模型采用的是共享内存的线程通信机制。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了共享变量的副本。
图片来自《深入理解java虚拟机 第2版》
关于共享变量,可以对应为存储在堆内存的实例变量、类变量及数组元素(堆内存是线程共享的)。私有变量可对应虚拟机栈中的局部变量。事实上,他们是java内存不同层次的划分,并没有一定联系。
内存间的交互操作
要完成主内存与工作内存的交互操作,需遵守一定的规则。java内存模型定义了相当严谨而复杂的访问规则。主要有8种原子性的操作。分别是:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)。
内存交互时,必须使用以上几种操作搭配完成,且这8种操作要满足一定规则。如read和load,store和write必须成对出现;对变量实施use、store时,必须先执行assign和load操作。
幸好,这些难以记忆的规则有一个等效判定的原则,即先行发生原则。
- 程序次序规则:在一个线程中,程序控制流前面的操作先行发生于后面的操作。
- 监视器锁规则:一个unlock操作先行发生于对同一个锁的lock操作。
- volatile变量规则:对于一个volatile变量,写操作先行发生于对这个变量的读操作。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C。
一个内存交互的例子
我们知道java的多线程通信采用共享内存的方式。线程对变量的所有操作都要在工作内存中进行,不能直接访问主内存。线程间变量传递均需主内存间接完成。
则,线程A要与线程B通信(比如B线程要读取A线程经操作后的值),需要:
- 线程A修改本地内存A的值,并将其写入主内存的共享变量。
- 线程B到主内存读取线程A修改后的值。
内存模型的3个重要特征
原子性
前面我们提到的8种原子操作都是原子性的,这样可以保证对基本数据类型的访问读写是原子性的。这里有个例外是JVM没有强制规定long、double一定是原子操作。但几乎所有的商业JVM都实现了long、double的原子操作。
可见性
可见性是指,当一个线程修改了共享变量的值,其他变量能得知这个修改。
这里需要引出本文第二个关键点:volatile。volatile有两个语义。这里用其可见性语义。经volatile修饰的变量保证新值能立即同步到主内存中,每次使用前立即从主内存刷新。保证了多线程操作时变量的可见性。后面会有更详细解释。
除volatile外,synchronized和final也能实现可见性。
synchronized的可见性由“对一个变量执行unlock前,必须先把此变量同步回主内存”。获得。
final关键字的可见性指:被final修饰的字段在构造器中初始完成,则其他线程就能看到final的值。
有序性
java程序本身具有的有序性可以总结为:如果在同一线程观察,所有操作都是有序的。而如果在一个线程观察另一线程,所有操作都是无序的。前部分指在单线程环境中程序的顺序性,后部分说的无序是指“指令的重排序”和“工作内存与主内存的同步延迟”。
指令重排序
编译器能够*的以优化的名义去改变指令顺序。在特定的环境下,处理器可能会次序颠倒的执行指令。是为指令的重排序。在单线程环境中,程序执行结果不会受到指令重排序的影响。
但有时,我们在多线程情况下,并不希望发生指令重排序来影响并发结果。
java提供了volatile和synchronized来保证线程之间操作的有序性。volatile含有禁止指令重排序的语义(即它的第二个语义),synchronized规定一个变量在同一时刻只允许一条线程对其lock操作,也就是说同一个锁的两个同步块只能串行进入。禁止了指令的重排序。
关于指令重排序,下文还有更多解释。
volatile语义
介绍完java内存模型的3个特征,现在来详细介绍volatile及它代表的语义。
准确来说,volatile是java提供的轻量的同步机制。它有两个特性:
1. 保证修饰的变量对所有线程的可见性。
2. 禁止指令的重排序优化。
根据上面的介绍,我们对可见性及禁止重排序背后的顺序性都不陌生。下面我们来详细说明下。
验证volatile具有可见性
volatile变量对所有线程是立即可见的,对volatile变量的写操作都能立即反应到其他线程中。
volatile boolean flag;
public void shundown(){
flag = true;
}
public void doWork(){
while(!flag){
doSomething();
}
}
上面的例子即是volatile的典型应用。任一线程调用了shundown()方法,都能保证所有线程执行doWork()时doSomething()方法不执行。
假设flag
不是由volatile修饰,则不能保证内存可见性,当某个线程修改了flag
的值后,其他线程不一定会马上看到或根本看不到,就会引起错误。
需注意的是,volatile变量保证可见性时,需满足以下规则:
- 运算结果不依赖变量的当前值,或保证只有单一线程修改变量值。(如i++,运算依赖当前值,就不满足)
- 变量不需要与其他状态变量共同参与不变约束。
public class TestThread2 {
public static volatile int race = 0;
public static void increase(){
race++;
}
private static final int THREADS_COUNT =20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for(int i=0;i<THREADS_COUNT;i++){
threads[i] = new Thread(()->{
for(int j=0;j<1000;j++){
increase();
}
});
threads[i].start();
}
System.out.println(race);
}
}
如上例,若正确并发,则最后应输出20*1000=20000
,可结果总输出小于20000
的结果,且每次都不相同。原因就在于volatile不能保证 race++
的可见性。race++
操作实际上有1.读取race的值;2.对race加1;3.修改race的值
3步操作,而volatile显然不能保证这些操作的原子性。
volatile禁止指令重排序
指令重排序的语句需遵守一个规则,即as-if-serial语义:
所有操作都可以为了优化而重排序,但必须保证重排序的结果和程序执行结果一致。
这里给出重排序的例子
public class Test {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while(true) {
x = 0; y = 0;
a = 0; b = 0;
i++;
Thread first = new Thread(()->{a = 1;x = b;});
Thread second = new Thread(()->{b = 1;y = a;});
first.start();second.start();
first.join();second.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
一个线程执行a = 1;x = b;
,另一个线程执行b = 1;y = a;
,由于a、x,b、y不存在依赖关系,所以有可能发生先执行x=b
,然后a=1
的指令重排序,经试验,在多次循环后出现x=b;b=1;y=a;a=1;
的线程交替执行结果。即x=0;y=0
。
这说明发生了指令重排序,将a,b,x,y用volatile修饰后,运行多次也没有出现重排序情况。
一个单例模式的例子
单例模式中的“双重检查加锁”模式如下所示
public class SingletonTest {
private volatile static SingletonTest instance = null;
private SingletonTest() { }
public static SingletonTest getInstance() {
if(instance == null) {
synchronized (SingletonTest.class){
if(instance == null) {
instance = new SingletonTest(); //非原子操作
}
}
}
return instance;
}
}
上面代码大家都不陌生,可为什么instance
一定要volatile修饰呢?这是由于instance = new SingletonTest();
并不是一个原子操作。可分解为:
- memory =allocate(); //分配对象的内存空间
- ctorInstance(memory); //初始化对象
- instance =memory; //设置instance指向刚分配的内存地址
2操作依赖1操作,但3操作并不依赖2操作,也就是说,上述操作的顺序可能为1-2-3,也可能为1-3-2,若是后者,当instance
不为空时也可能没有正确初始化对象,而导致错误。
参考
- 《深入理解java虚拟机 第2版》
- java内存模型FAQ
- 深入理解Java内存模型(一)——基础
- Java内存访问重排序的研究