深入理解JVM(6)——Java内存模型和线程

 2019-12-10 11:23  阅读(633)
文章分类:Java Core

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM)用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果(“即Java程序的 write once run anywhere”)。 在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,在某些场景就必须针对不同的平台来编写程序。

1、主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。 此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、 静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的。

Java内存模型规定所有的变量都存储在主内存(Main Memory)中(JVM内存的一部分),每个线程有自己独立的工作内存(Working Memory),它保存了被该线程使用的变量的主内存复制。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其他工作内存中的变量或者变量副本。线程间的变量访问需要通过主内存来完成,三者关系如下图:

20191210001141\_1.png

2、内存间的交互操作

Java 内存模型中定义了以下8种操作来完成主内存和工作内存之间的交互:

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

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。 注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。 也就是说,read 与 load 之间、 store 与 write 之间是可插入其他指令的,如对主内存中的变量 a、 b 进行访问时,一种可能出现顺序是 read a、 read b、 load b、 load a。除此之外,Java 内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许 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 操作)。

3、volatile变量

关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。

当一个变量定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。即volatile 变量在各个线程的工作内存中不存在一致性问题。

在各个线程的工作内存中,volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,即某个线程对 volatile 变量执行 use 动作的前置条件是 read 和 load,三个动作按序执行,但不用连续执行。执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。深层原因是,在编译后的代码中,volatile 变量赋值语句后面,多执行了一个“lock addl $0x0,(%esp)”操作。这个 lock 使得 CPU 的 Cache 写入内存,也引起别的内核 Cache 无效。相当于做了一次 store 和 write 操作。辅助理解图如下:

20191210001141\_2.png

但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。例如如下情况:

定义一个方法,方法中对一个 volatile 静态变量进行自增操作。自增代码只有一行,但是在字节码中,是由四条指令构成: getstatic; iconst_1; iadd; putstatic。在执行 getstatic 指令把变量的值取值到操作栈顶时,volatile 关键字保证了变量的值在此时是正确的。但是在执行后面的指令时,其他的线程可能已经把变量的值给改变了。这时操作栈顶的值就变成了过期数据。最后 putstatic 指令就会把错误的数据同步回主内存之中。

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

使用 volatile 变量的第二个特性是禁止指令重排序优化,保证变量赋值操作的顺序与程序代码中的执行顺序一致。

4、原子性、可见性和有序性

原子性(Atomicity)

由Java内存模型来直接保证的原子性变量操作包括 read、 load、 use、 assign、 store 和 write,可以认为基本数据类型的访问读写是具备原子性的。如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性(Visibility)

Java就是利用 volatile 来提供可见性的。当一个变量被 volatile 修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。

除了 volatile 之外,Java 还有两个关键字能实现可见性,即 synchronized 和 final 。 同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)”这条规则获得的,而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见 final 字段的值。

有序性(Ordering)

Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。 前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

在 Java 里面,可以通过 volatile 关键字来保证一定的“有序性”。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

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

下面就来具体介绍下 happens-before 原则(先行发生原则):

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。(就是 volatile 的可见性)
  • 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始。

第一条程序顺序规则是说在一个线程里,所有的操作都是按顺序的,但是在JMM里其实只要执行结果一样,是允许对没有数据依赖性的指令重排序的,这边的 happens-before 强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。

有序性和 volatile 变量 示例:

int a = 0;
    volatile bool flag = false;

    public void write() {
       a = 2;              //1
       flag = true;        //2
    }

    public void multiply() {
       if (flag) {         //3
           int ret = a * a;//4
       }
    }

假如有两个线程执行上述代码段,线程1先执行write,随后线程2再执行multiply。根据volatile 特性和满足的 happens-before 原则,这个过程会满足以下规则:

volatile 变量的禁止指令重排特性:1 happens-before 2;
程序顺序规则: 3 happens-before 4;
volatile规则 (volatile的可见性特性): 2 happens-before 3;
传递性规则: 1 happens-before 4;

这样能正确执行。

再看没加 volatile 修饰的情况:

int a = 0;
    bool flag = false;

    public void write() {
        a = 2;              //1
        flag = true;        //2
    }

    public void multiply() {
        if (flag) {         //3
            int ret = a * a;//4
        }
    }

同样线程1先执行write,随后线程2再执行multiply。但是因为 没有 volatile ,语句 1 和 语句 2 会存在指令重排。

20191210001141\_3.png

线程 1 先对 flag 赋值为 true,随后执行到线程2,ret直接计算出结果为0,再到线程1,这时候a才赋值为2。

再看语句 2 和语句 3,因为没有 volatile 保证可见性, 有可能在 线程 1 对 flag 的赋值还未 write 到主内存,线程 2 就读取了任为 旧值的 flag,使得 语句 3 括号中的语句不执行。

提炼成问题:面试官最爱的volatile关键字

https://crowhawk.github.io/2018/02/10/volatile/

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 深入理解JVM(6)——Java内存模型和线程

相关推荐