JVM内存模型

概要:Java制定的 内存模型 是为了在各种硬件和OS下达到一致的内存访问效果,但它的引入也带来了并发下的一致性问题(因为有各自的变量副本)。因此Java内存模型的主要内容:

一是内存模型的结构(内存划分);

二是解决在这种模型下并发(多线程)访问时变量的一致性问题,包括原子性、可见性、有序性。(解决方法是定义共享变量的访问操作和执行规则)

 

1、Java内存模型

内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。 

Java虚拟机规范定义了Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果(C/C++等则直接使用物理机和OS的内存模型,使得程序须针对特定平台编写),它在多线程的情况下尤其重要。

JMM的主要内容是定义程序中共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量是指共享变量,存在竞争问题的变量,如实例字段、静态字段、数组对象元素等,不包括线程私有的局部变量、方法参数等,因为私有变量不存在竞争问题。可以认为JMM包括内存划分、变量访问操作与规则两部分。

1.1、JMM结构(内存划分)

物理机中的内存模型:

这里的缓存一致性协议是指确保一个变量在各高速缓存中的变量副本的值保持一致的协议。

Java内存划分如下所示(可与上述物理机中的内存模型作类比):

 

分为主内存和工作内存,每个线程都有自己的工作内存,它们共享主内存。

主内存(Main Memory)存储所有共享变量的值。

工作内存(Working Memory)存储该线程使用到的共享变量在主内存的的值的副本拷贝。

  • 线程对共享变量的所有读写操作都在自己的工作内存中进行,不能直接读写主内存中的变量(volatile变量也不例外,虽然它看起来如同直接访问主内存一般)。
  • 不同线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存完成。

注:这种划分与Java内存区域中堆、栈、方法区等的划分是不同层次的划分,两者基本没有关系。硬要联系的话,大致上主内存对应Java堆中对象的实例数据部分、工作内存对应栈的部分区域;从更低层次上说,主内存对应物理硬件内存、工作内存对应寄存器和高速缓存。

 

1.2、内存访问的操作和规则

(这里介绍的访问操作及规则完全确定了Java程序中哪些内存访问在并发下是安全的,1.3节介绍与此等效的判断原则——先行发生原则)

1.2.1、8个原子操作和执行规则

8个原子操作:JMM定义了8个原子操作来完成工作内存和主内存间的交互:

1、lock(锁定):作用于主内存的变量,它把一个变量标示为一条线程独占的状态。
2、unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
3、read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到工作内存中,以便随后的load动作使用。
4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
5、use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
6、assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
7、store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的write操作使用。
8、write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

8个原子操作的执行规则:JMM还规定了执行上述8中基本操作时需满足如下规则:

1、不允许read和load、store和write操作之一单独出现,即不允许一变量从主内存读取了但工作内存不接受,或从工作内存发起回写了但主内存不接受的情况。即要求read、load成对顺序出现,但不要求连续出现(中间可以插入其他指令),store、write亦然
2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
3、不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
4、一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
5、一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
6、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
7、如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

从上面可以看出,把变量从主内存复制到工作内存需要顺序执行read、load,从工作内存同步回主内存则需要顺序执行store、write。总结:

read、load、use必须成对顺序出现,但不要求连续出现。assign、store、write同之;

变量诞生和初始化:变量只能从主内存“诞生”,且须先初始化后才能使用,即在use/store前须先load/assign;lock一个变量后会清空所有工作内存中该变量的值,使用前须重新初始化;

一个变量同一时刻只能被一线程lock,lock几次就须unlock几次;未被lock的变量不允许被执行unlock,一个线程不能去unlock其他线程lock的变量;

unlock前须将变量同步回主内存。

 

1.2.2、volatile变量访问规则

 volatile变量是JVM提供的最轻量级的同步机制。Java内存模型对volatile专门定义了一些特殊的访问规则,被volatile修饰的变量可以保证可见性、有序性,但不保证原子性。

1、保证可见性:即一个线程修改了一变量的值,其他线程立即可见该变量的新值。

原理:一个线程对变量修改(assign)后立即同步回主内存,即 assign、store、write须连续执行、其他线程对变量读取(use)前立即从主内存刷新新值到工作内存即 read、load、use须连续执行

示例:用volatile变量控制并发:

volatile boolean shuDownFlag;

    //线程1
    public void doWork() {
        while (!shuDownFlag) {
            // do stuff
        }
    }

    //线程2
    public void showDown() {
        shuDownFlag = true;
    }
View Code

该例子中,由于线程2修改变量的值时会立即同步回主内存、修改时对变量加锁会清空线程1工作内存中该变量的值从而使之失效、线程1读该变量值时须先从主内存同步到工作内存,故保证了可见性。若不加volatile则会导致线程1死循环。

2、保证有序性:解决并发访问情况下指令重排序导致的程序正确性(变量值正确性)问题。

原理:禁止指令重排序——将读写volatile变量的代码对应的指令加上lock前缀,(相当于 内存屏障,Memory Barrier,也称内存栅栏)。这样在进行指令重排序时,不会把访问volatile变量的代码前面的代码对应的指令重排到屏障后面、也不会把后面的代码对应的指令重排到屏障前,即在执行内存屏障指令时,该指令对应的原代码之前的代码已全部执行完成。

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5
View Code

示例:

Java单例常用DCL(双锁检测)来实现,单例变量用volatile修饰:

public class DoubleCheckLock {

    private static volatile DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();// 分为 分配空间、初始化对象、引用赋值 三步,若没有volatile则后两步可能重排序从而出错
                }
            }
        }
        return instance;
    }
}
View Code

用变量标识配置文件是否加载完成,若没有volatile修饰,变量则可能被提前置true,导致其他线程提前开始使用配置信息,而此时实际上还没加载好。代码示例:

class MixedOrder{
    int a = 0;
    boolean flag = false;

    //线程1
    public void writer(){
        a = 1;
        flag = true;
    }

    //线程2
    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}
View Code

3、不保证原子性

示例:

 1     private static volatile int race = 0;
 2 
 3     public static void increase() {
 4         race++;
 5     }
 6 
 7     public static void main(String[] args) {
 8         int threadNum = 10;
 9         int countRange = 1000;
10         Thread[] threads = new Thread[threadNum];
11         for (int i = 0; i < threadNum; i++) {
12             threads[i] = new Thread(new Runnable() {
13 
14                 @Override
15                 public void run() {
16                     // TODO Auto-generated method stub
17                     for (int i = 0; i < countRange; i++) {
18                         increase();
19                     }
20                 }
21             });
22             threads[i].start();
23         }
24         while (Thread.activeCount() > 1) {
25             Thread.yield();
26         }
27         System.out.println(race);// 结果不是threadNum*countRange,说明volatile变量并没保证原子性。原因也很明了:race++不是原子操作,虽然线程拿到race值时是最新的,但执行完加一操作写回时可能race值已经被其他线程更新了,导致更新被覆盖。
28     }
View Code

输出值不等于threadNum*countRange,说明volatile变量并没保证原子性。原因也很明了:race++不是原子操作,虽然线程拿到race值时是最新的,但执行完加一操作写回时可能race值已经被其他线程更新了,导致更新被覆盖。

 

指令重排序

含义:只要程序的最终执行结果与代码顺序化执行的结果一样,那么指令的实际执行顺序可与代码顺序不一致。示例:

int i = 0;              
boolean flag = false;
i = 1;              //语句1  
flag = true;        //语句2

上述例子中语句1、2对换后的执行结果与对换前的执行结果一样,故两语句可对换。

类型:计算机在执行程序时,为了提高执行效率,编译器和处理器的常常会对指令做重排,一般分以下3种:

编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

内存系统的重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排。

指令重排序的意义:

优:能根据处理器特性(CPU多级缓存系统、多核处理器等)适当地对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能,从而提高执行效率。

劣:指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。如,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题。

影响:Java语言规范规定一个JVM线程执行时遵循顺序化语义,即允许编译器和处理器对指令进行重排序,故在并发访问情况下不同线程看到的变量值结果可能与预期的不一致,因此在并发访问下需要注意指令重排序问题。

 

1.2.3、long和double型变量的特殊规则(非原子性协定)

Java内存模型要求前述8个操作具有原子性,但对于64位的数据类型long和double,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即未被volatile修饰时线程对其的读取不是原子操作,可能只读到“半个变量”值。虽然如此,商用虚拟机几乎都把64位数据的读写实现为原子操作,因此我们可以忽略这个问题

 

1.3、先行发生原则

先行发生(happens-before,译为“先行生效”更合适?)原则是在没有任何代码同步机制(volatile、synchronized等)下JVM实现的一些默认的“序”。可借之确定一个访问在并发环境下是否安全、数据是否存在竞争的规则。其与1.2节介绍的访问操作及规则等效

Java内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized等)就能够得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序不符合先行原则且无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

这些规则包括:

1、程序次序规则(Program Order Rule):一个线程内,按照代码顺序(虑到分支、循环等结构,准确地说应该是控制流顺序),书写在前面的操作先行发生于书写在后面的操作。
2、锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。“后面”指时间上的先后顺序。
3、volatile变量规则(Volatile Variable Rule):对一个变量的写操作先行发生于后面对这个变量的读操作。“后面”指时间上的先后顺序。
4、传递规则(Transitivity):如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
5、线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个一个动作。
6、线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生(通过Thread.interrupted()检测)。
7、线程终止规则(Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
8、对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于他的finalize()方法的开始。

注:先行发生与时间先后顺序之间没有必然联系,衡量并发安全问题必须以先行发生原则为准。如对于一个普通变量的不带同步的get、set方法,让时间上线程1、2先后调用set、get方法,线程2并不一定得到线程1设置的值。因为它们不符合先行原则也不能由之导出。

 

3、原子性、可见性、有序性

JMM是围绕着在并发过程中(多线程环境下)如何处理原子性、可见性、有序性这3个特征来建立的。

总结:

JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可见性及其有序性。

3.1、原子性

含义:是指对变量的某操作在同一时刻只能被一个线程执行,只有该线程执行完了其他线程才能执行该操作。该操作可以只有一个步骤也可以有多个步骤,后者可以称为组合操作。

JMM实现:

由JMM直接保证的原子性变量操作包括read、load、use、assign、store、write;

基本数据类型的读写也是原子性的(long、double也可以当做原子性),注意:只有简单的读取、赋值(且须是将值赋给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。示例:

//以下语句只有语句1是原子性的

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4
View Code

由JMM的lock、unlock可实现更大范围的原子性保证,虽用户没法直接用之,但可用synchronized关键字来保证原子性。synchronized关键字编译后在同步块前后形成字节码指令monitorenter、monitorexit,这两个指令最终即利用了lock、unlock操作保证其间组合操作的原子性。

3.2、可见性

含义:是指当一个线程修改了共享变量的值,其他线程立即得知该修改。

JMM实现:

(volatile)变量值被一个线程修改后会立即同步回主内存、变量值被其他线程读取前立即从主内存刷新值到工作内存。即read、load、use三者连续顺序执行,assign、store、write连续顺序执行

(synchronized)1.2.1节中原子操作执行规则8——“对一个变量执行unlock操作之前,必须先把此变量同步回主内存,即执行store、write”。

(final)final修饰的字段在构造器中一旦初始化完成,且构造器没有把“this”的引用传递出去,则其他线程可立即看到final字段的值。

3.3、有序性

含义:

在一个线程内所有的操作都是有序的,表现为串行化语义(在一个线程观察另一个则操作不一定有序,是因为有“指令重排序”现象和“工作内存与主内存同步延迟”现象),因此这里的有序性是指并发访问情况下指令重排序导致的程序正确性(变量值正确性)问题。

可见,有两个条件导致有序性问题:并发(多线程)访问共享变量、指令重排序,故要保证有序性只需对条件下“药”即可。

JMM实现:

(volatile)简单粗暴,直接禁止指令重排序。

(synchronized)简单粗暴,避免变量并发访问。1.2.1节中原子操作执行规则5——“一个变量在同一个时刻只允许一条线程对其执行lock操作”,此规则决定了持有同一个锁的两个同步块只能串行进入,因此共享变量不会被并发访问。

 

 

4、参考资料

1 《深入理解Java虚拟机——JVM高级特性与最佳实践》

https://www.cnblogs.com/dolphin0520/p/3920373.html

 

posted @ 2017-07-17 16:57  March On  阅读(472)  评论(0编辑  收藏  举报
top last
Welcome user from
(since 2020.6.1)