DAI

Java内存模型(Java Memory Model)

Java虚拟机规范试图定义一种Java内存模型(Java Memory Model)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。定义Java内存模型并非一件容易的事情,必须定义得足够严谨,才能让Java的并发内存访问不会产生歧义;同样,也必须足够宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性来获取更好的执行速度。经过长时间的验证和修补,在JDK1.5发布后,Java内存模型趋于成熟,这个版本在目前的JDK1.8中仍在使用。

主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。它规定了所有变量都存储在主内存(Main Memory)中,每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的主内存变量的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存这三者的交互关系如下图所示:

内存间交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。

volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最最轻量级的同步机制,了解volatile变量对后面了解多线程操作的其他特性很有意义。Java内存模型专门为volatile定义了一些特殊的访问规则,在介绍这些访问规则之前,先简单介绍一下这个关键字的作用,当一个变量被volatile修饰之后,它就具备两种特性:

1.对所有线程的可见性

这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改了一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成后再从主内存进行读取操作,新变量值才会对线程B可见。但是并不能认为基于volatile变量的运算在并发下是安全的,因为Java里面的运算并非原子操作。

我们可以通过一段简单的示例来说明原因:

public class VolatileTest {

  private static volatile int race = 0;

  public static void main(String[] args) {
	  for (int i = 0; i < 20; i++) {
		  new Thread(new Runnable() {
			  @Override
			  public void run() {
				  for (int j = 0; j < 10000; j++)
					  race++;
			  }
		  }).start();
	  }
	  // 等待所有累加线程都结束
	  while (Thread.activeCount() > 1)
		  Thread.yield();
	  System.out.println(race);
  }
}

这段代码启动了20个线程,每个线程对race变量进行10000次自增操作,我们期望的结果应该是20000。但是实际运行结果并不是我们的预期,每次运行的结果都不一样,而且都小于20000,这是为什么呢?

问题就出现在自增操作race++中,因为Java里面的运算并非原子操作,所以我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。而在如下代码所示的场景中就很适合使用volatile来控制并发,当shutdown()方法被调用时,能保证所有线程的doWork()方法都立即停下来。

volatile boolean shutdownRequested;

public void shutdown() {
	shutdownRequested = true;
}

public void doWork() {
	while(!shutdownRequested) {
		// do work
	}
}

2. 禁止指令重排序优化

通过分析汇编代码,发现volatile修饰的变量会多出一个lock前缀指令,这个指令相当于一个内存屏障,它确保重排序时不会把后面的指令排到内存屏障之前,也不会把前面的指令排到内存屏障之后。这样便形成了「指令重排序无法越过内存屏障」的效果。

volatile的同步机制的性能确实要优于锁,但是由于虚拟机对锁实行的的许多消除和优化,使得我们很难量化地认为volatile比synchronized快多少。如果让volatile自己跟自己比较,那可以确定一个原则:volatile读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不会发生乱序执行。我们在volatile与锁之中选择的唯一依据仅仅是volatile的语义能否满足使用场景的需求。

原子性、可见性和有序性

介绍完Java内存模型的相关操作和规则,在整体回顾一下这个模型的特征。Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

先行发生原则

如果Java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,那么一些操作将会变得很繁琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个「先行发生」(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以一揽子地解决并发环境下两个操作之间是否存在冲突的所有问题。

现在就来看看「先行发生」原则指的是什么。先行发生是Java内存模型中定义

下面是Java内存模型下一些「天然的」先行发生关系,这些先行发生关系无须任何同步器协助就已经存在。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

程序次序规则(Program Order Rule)
在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

管程锁定规则(Monitor Lock Rule)
一个unlock操作先行发生于后面对同一个锁的lock操作。

volatile变量规则(Volatile Variable Rule)
对一个volatile变量的写操作先行发生于后面对这个变量的读操作。

线程启动规则(Thread Start Rule)
Thread对象的start()方法先行发生于此线程的每一个动作。

线程终止规则(Thread Termination Rule)

线程中断规则(Thread Interruption Rule)

对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法。

传递性(Transitivity)
如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A就先行发生于操作C。

Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些了,下面演示一下如何使用这些规则去判定操作间是否具有顺序性,对于读写共享变量的操作来说,就是线程是否安全,我们还可以从下面的例子中感受一下,「时间上的先后顺序」与「先行发生」之间有什么不同。

private int value = 0;

public void setValue(int value) {
	this.value = value;
}

public int getValue() {
	return this.value;
}

上边的小示例是一组再普通不过的getter/setter方法,假设线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B得到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则,由于两个方法分别由两个线程调用,所以程序「次序规则」在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以「管程锁定规则」不适用;由于value变量没有被volatile关键字修饰,所以「volatile变量规则不适用」;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

那么怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定义为synchronized方法,这样就可以套用「管程锁定规则」;要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile的使用场景,这样就可以套用「volatile变量的使用规则」来实现先行发生关系。

对于上面的例子,我们可以得出结论:一个操作「时间上的先发生」不代表这个操作会是「先行发生」,那么如果一个操作「先行发生」是否就能推导出这个操作必定是「时间上的先发生」呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的「指令重排序」,示例如下:

// 以下操作在同一个线程中
int i = 1;
int j = 2;

程序中的两条赋值语句在同一个线程中,根据程序次序规则,int i = 1;的操作先行发生于int j = 2;,但是int j = 2;的代码完全可能先被处理器执行。所以综上两个例子得出一个结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

×