Java内存模型之内存操作规则
主内存和工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,此处的变量主要是指类变量和实例属性等共享变量,存在线程race condition的变量。
Java内存模型规定所有的变量都存储在主内存中,而每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的共享变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都在工作内存中进行,而不能直接读写主内存中的变量。
Actually, the memory model does not talk about caches -- it talks about an abstraction, local memory, which encompasses caches, registers, and other hardware and compiler optimizations.
根据Java虚拟机规范的规定,volatile变量依然有共享内存的拷贝,但是由于它特殊的操作顺序性规定:
volatile读操作从工作内存中读写数据前,必须先将主内存中的数据同步到工作内存中,所有看起来如同直接在主内存中读写访问一般。
不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值得传递均需要通过主内存来完成。
内存间交互操作
Java内存模型中定义了以下8中操作来完成主内存与工作内存之间交互的实现细节:
- lock(锁定):作用于主内存的变量,它把一个变量标示为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能从主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。
long和double型变量的特殊规则
Java内存模型要求lock、unlock、read、load、assign、use、store和write这8个操作都具有原子性,但是对于64位的数据类型long和double,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。这样,如果有多个线程共享一个未被声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个既非原值,也非其他线程修改值得代表了半个变量的数值。不过这种读取到半个变量的情况非常罕见,因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还强烈建议虚拟机这样实现。目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在编码时,不需要将long和double变量专门声明为volatile。
volatile型变量的特殊规则
把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile变量自身具有下列特性:
- 可见性:对一个
volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。 - 原子性:对任意单个
volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
需要注意,volatile变量的写操作除了对它本身的读操作可见外,volatile写操作之前的所有共享变量均对volatile读操作之后的操作可见。
volatile读的内存语义如下:
当读一个
volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
volatile写的内存语义如下:
当写一个
volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
final域
final类型的域是不能修改的,除了这一点外,在Java内存模型中,final域还有着特殊的语义,final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无须同步。具体而言,就是被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到初始化了一半的对象),那么在其他线程中就能看到final字段的值,而且其外、外部可见状态永远也不会改变,它所带来的安全性是最简单最纯粹的。
synchronized与volatile
一个线程执行互斥代码过程如下:
- 获得同步锁;
- 清空工作内存;
- 从主内存拷贝对象副本到工作内存;
- 执行代码(计算或者输出等);
- 刷新主内存数据;
- 释放同步锁。
所以,synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性。
Java内存模型之内存可见性和指令重排序
以下补充内容可参考《Java并发编程实战》一书。
在Java中,我们都知道关键字synchronized可以用于实现线程间的互斥,但我们却常常忘记了它还有另外一个作用,那就是确保变量在内存的可见性:
即当读写两个线程同时访问同一个变量时,
synchronized用于确保写线程更新变量后,读线程再访问该变量时可以读取到该变量最新的值。
as-if-serial语义
无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致,Java编译器、运行时和处理器都会保证Java在单线程下遵守
as-if-serial语义。
重排序
比如说下面的例子:
public class NoVisibility { private static boolean ready = false; private static int number = 0; private static class ReaderThread extends Thread { public void run() { while (!ready) { Thread.yield(); //交出CPU让其它线程工作 } System.out.println(number); } } public static void main(String[] args) { new ReaderThread().start(); number = 42; ready = true; }} |
你认为读线程会输出什么?42?在正常情况下是会输出42,但是由于重排序问题,读线程还有可能会输出0或者什么都不输出。
Java语言规范规定了JVM线程内部维持顺序化语义,也就是说只要程序的最终结果等同于它在严格的顺序化环境下的结果,那么指令的执行顺序就可能与代码的顺序不一致,这个过程通过叫做指令的重排序。
重排序分三种类型:
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
指令重排序存在的意义在于:
JVM能够根据处理器的特性(CPU的多级缓存系统、多核处理器等)适当的重新排序机器指令,使机器指令更符合CPU的执行特点,最大限度的发挥机器的性能。在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
在单一线程中,只要重排序不会影响到程序的执行结果,那么就不能保证其中的操作一定按照程序写定的顺序执行,即使重排序可能会对其它线程产生明显的影响。这也就是说,语句ready=true的执行有可能要优先于语句number=42的执行,这种情况下,读线程就有可能会输出number的默认值0。
在Java内存模型下,重排序问题是会导致这样的内存的可见性问题。在Java内存模型下,每个线程都有它自己的工作内存,它对变量的操作都在自己的工作内存中进行,而线程之间的通信则是通过主内存和线程的工作内存之间的同步来实现的。
比如说,对于上面的例子而言,写线程已经成功的将number更新为42,ready更新为true了,但是很有可能写线程只同步了number到主内存中(可能是由于CPU的写缓冲导致),导致后续的读线程读取的ready值一直为false,那么上面的代码就不会输出任何数值。
而如果我们使用了synchronized关键字来进行同步,则不会存在这样的问题,
public class NoVisibility { private static boolean ready = false; private static int number = 0; private static Object lock = new Object(); private static class ReaderThread extends Thread { public void run() { synchronized (lock) { while (!ready) { Thread.yield(); } System.out.println(number); } } } public static void main(String[] args) { synchronized (lock) { new ReaderThread().start(); number = 42; ready = true; } }} |
这个是因为Java内存模型对synchronized语义做了以下的保证:
即当ThreadA释放锁M时,它所写过的变量(比如,x和y,存在它工作内存中的)都会同步到主内存中,而当ThreadB在申请同一个锁M时,ThreadB的工作内存会被设置为无效,然后ThreadB会重新从主内存中加载它要访问的变量到它的工作内存中(这时x=1,y=1,是ThreadA中修改过的最新的值)。通过这样的方式来实现ThreadA到ThreadB的线程间的通信。
java内存模型之 happens-before 规则
Java内存模型是通过各种操作来定义的,包括对变量的读、写操作,对象监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序中所有的操作定义了一个偏序关系,称之为
happens-before。
JSR133给Java内存模型定义以下一组happens-before规则:
- 程序顺序规则:同一线程中,如果程序中操作A在操作B之前,那么在线程中A操作将在B操作之前执行,即同一个线程中的每个操作都
happens-before于出现在其后的任何一个操作。 - 监视器锁规则:对一个监视器的解锁操作
happens-before于每一个后续对同一个监视器的加锁操作。 volatile变量规则:对volatile字段的写入操作happens-before于每一个后续的对同一个volatile字段的读操作。- 线程启动规则 :
Thread#start()的调用操作会happens-before于启动线程里面的操作,简单来说就是在调用线程启动之前的所有操作happens-before线程内代码的运行。 - 线程结束规则 :线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,简单来说就是在线程内的所有操作
happens-before于线程结束之后操作,如某个变量在线程内修改,在Thread#join()之后肯定可以看到这些修改。 - 终结器规则:对象的构造函数必须在启动该对象的终结函数之前执行完成。
- 传递性规则:如果A操作
happens-before于B操作,而B操作happens-before与C操作,那么A动作happens-before于C操作。
Java Language Specification中关于happens-before说明如下:
- An
unlockon a monitorhappens-beforeevery subsequentlockon that monitor. - A write to a
volatilefield §8.3.1.4 happens-before every subsequent read of that field. - A call to
start()on a threadhappens-beforeany actions in the started thread. - All actions in a thread
happens-beforeany other thread successfully returns from ajoin()on that thread. - The default initialization of any object
happens-beforeany other actions (other than default-writes) of a program.
实际上这组happens-before规则定义了操作之间的内存可见性,如果A操作happens-before于B操作,那么A操作的执行结果(比如对变量的写入)必定在执行B操作时可见。因此,在描述happens-before关系时,就可以使用后续的锁获取操作和后续的volatile变量读取操作等表达术语。
为了更加深入的了解这些happens-before规则,我们来看一个例子:
//线程A,B共同访问的代码Object lock = new Object();int a = 0;int b = 0;int c = 0;int y = 0; |
//线程A,调用如下代码y = 1;synchronized(lock){ a = 1; //1 b = 2; //2} //3c = 3; //4 |
//线程B,调用如下代码synchronized(lock){ //5 System.out.println(a); //6 System.out.println(b); //7 System.out.println(c); //8 System.out.println(y); //9} |
我们假设线程A先运行,分别给a,b,c三个变量进行赋值(注:变量a,b的赋值是在同步语句块中进行的),然后线程B再运行,分别读取出这三个变量的值并打印出来。那么线程B打印出来的变量a,b,c的值分别是多少?
根据单线程规则,在A线程的执行中,我们可以得出:
- 1操作
happens-before于2操作。 - 2操作
happens-before于3操作。 - 3操作
happens-before于4操作。 - 同理,在B线程的执行中,5操作
happens-before于6操作。 - 6操作
happens-before于7操作。 - 7操作
happens-before于8操作。 - 而根据监视器的解锁和加锁原则,3操作(解锁操作)是
happens-before于5操作的(加锁操作)。 - 再根据传递性规则我们可以得出,操作1,2是
happens-before操作6,7,8的。 - 变量y是在线程A加锁之前就写入内存,因为A线程解锁之前的所有操作对于后面加锁lock对象的线程B是可见的,即
happens-before于B线程的5操作的。
则根据happens-before的内存语义,操作1,2的执行结果对于操作6,7,8是可见的,那么线程B里,打印的a,b肯定是1和2。而对于变量c的操作4,和操作8。我们并不能根据现有的happens-before规则推出操作 happens-before于操作8。所以在线程B中,访问的到c变量有可能还是0,而不是3。操作9会正确输出y变量的值1。
as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
Java内存可见性简单总结:
可见性就是在多线程运行过程中内存的一种共享模式,在JMM模型里面,并发线程修改变量值的时候,必须将线程变量同步回主内存过后,其他线程才可能访问到。