java内存模型

1. 硬件内存架构

 我们知道,在计算机中,由于处理器和存储设备之间的运算速度有着数量级的差距,为了解决这个问题,现代计算机会加入一或多层告诉缓存(Cache)来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,运算结束后再从缓存同步会内存之中,这样处理器就无须等待缓慢的内存读写了。

运作原理:通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

硬件内存架构

现代硬件内架构出现的问题:

 主要出现在多线程的情况下。

缓存一致性问题

 在多核处理器中,每个处理器都有自己的高速缓存,他们共享同一主内存,因此有可能会导致各自缓存数据不一致问题。为了解决一致性问题,需要各个处理器访问缓存时遵循一些协议,读写时根据协议操作,比如MSI、MESI、MOSI、Dragon Protocol等。

处理器、高速缓存和主内存的交互

指令重排序问题

 为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

2. Java内存模型

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,java虚拟机也有自己的内存模型(Java Memory Model, JMM)。

 java内存模型的主要目的是定义程序中各种变量的访问规则,关注点在虚拟机把变量值存储到内存和从内存中取出来的底层细节。

java内存模型

java内存模型

  • 一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。
  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量仍然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

2.1 主内存与工作内存

主内存与工作内存

 JMM规定了所有的变量都存储在主内存中,内存保存了被该线程使用的变量的主内存副本。线程对变量的所有操作都会必须在工作内存中完成,不能直接读写主内存数据。不同线程之间无法直接访问对方线程的变量,他们之间的通讯需要通过主内存来完成。

 这里的内存不同于虚拟机运行时内存,这两者没什么联系。如果非要联系,从定义看,主内存对应于java堆中的对象实例数据部分,工作内存对应于虚拟机栈(因为虚拟机栈是线程私有的)。从基础层面看,主内存对应于物理机的主内存,工作内存对应于寄存器或者高速缓存。

虚拟机运行时内存

2.2 线程间通信

 线程间通信必须要经过主内存。如果线程A与线程B之间要通信的话,必须要经历2个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存中去。

  • 线程B到主内存中去读取线程A之前已更新过的共享变量。

 主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

 把变量从主内存拷贝到工作内存中需要按顺序执行readload操作,从工作内存同步回主内存需要按顺序执行storewrite操作。只要求这两者顺序,但是中间可以穿插其他操作。比如,read a、read b、load b、load a。

 以上8种操作必须满足以下规则:

  • readloadstorewrite必须成对出现,不允许出现单一操作。比如主内存读取了,但是工作内存不接受操作🈲
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量。即对一个变量实施usestore操作之前,必须先执行过了assignload操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lockunlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行loadassign操作初始化变量的值
  • 不允许unlock一个未被lock的变量,不允许unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行storewrite操作)

2.3 Java内存模型解决的问题

 在Java多线程中,Java提供了一系列与并发处理相关的关键字,比如volatilesynchronizedfinalconcurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

 Java内存模型的本质是围绕着Java并发过程中的如何处理原子性可见性顺序性这三个特征来设计的。

 Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)。

多线程读同步问题与可见性

可见性:线程对共享变量修改的可见性。当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。

线程缓存导致的可见性问题

 如果两个或多个线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,线程的更新操作对其他线程是不可见的,因为共享变量没有被同步回主内存中。这样可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。由此导致了共享变量不可见。JMM中解决这个内存可见性问题可以使用如下两种方式:

  • 使用volatile关键字:
    • volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。
    • volatile的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。
    • 关于volatile请细看下一小节
  • 使用synchronized关键字:
    • synchronized实际上是对代码块执行了lock和unlock操作,前面关于lock和unlock的规则中,“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”、”lock操作会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值“这两条决定了其可见性。
  • 使用final关键字:
    • 被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程就能看见final字段的值(无须同步)。

重排序导致的可见性问题

 为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行,比如对于那些没有依赖关系的指令,处理器可能会把他们进行重排序。重排序一般有3种类型:编译器优化的重排序、指令级并行的重排序和内存系统的重排序。

 在多线程环境下,重排序可能会导致一些可见性问题,比如以下,线程A的步骤1和2没有依赖关系所以有可能被重排,导致步骤2先于步骤1执行。这样会导致:线程B在步骤4读共享变量时,不一定能看到写线程A在执行1时对共享变量的修改,因为由于重排,1的操作被延后到4之后了。

线程执行时序

 Java语言提供了volatilesynchronized两个关键字来保证线程之间操作的有序性:

  • volatile关键字本身就包含了禁止指令重排序的语义
  • synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

多线程写同步问题与原子性

 当多个线程在访问未被同步机制保护的代码时,可能会发生同步写的问题。比如A和B线程都对变量i进行++操作,A先读i,接着B读i,然后A++i同步回主内存,B++i同步回主内存。此时i的预期+2实际+1,这就出现了不同步的问题。

原子性:指一个操作是按原子的方式执行的。要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。

使用原子性保证多线程写同步问题

 原子性的实现:readloadassignusestorewrite这6种操作都是原子性的。

  • java中基本数据类型变量(long和double除外)、引用类型变量、声明为volatile的任何类型变量的访问读写是具备原子性的

  • longdouble则不一定。java虚拟机允许未被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,这样可能导致多个线程读取到的变量是”半个变量“,即第一次32位更新了但后一次32位没有。目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此写代码是不必专门声明volatile long等。

  • ++ / --等符合操作不具备原子性

  • 同步代码块可以保证更大范围的原子性。lockunlock操作可以满足,对应于monitorentermonitorexist指令来使用这两个操作。反应到java代码中就是synchronized关键字。比如:

    1
    2
    3
    synchronized (this) {
    a=1; b=2;
    }

2.4 volatile关键字

 volatile型变量特点:

  • 保证此变量对所有线程的可见性,原因上一节有讲到。

  • 禁止指令重排序优化

volatile变量对所有线程可见,但是却不一定能保证并发安全

 比如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class IntVolatile {

private static volatile int a = 0;

public static void increase() {
a++;
}

public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
increase();
}
}
});
threads[i].start();
}

//等待所有线程结束
while (Thread.activeCount() > 1)
Thread.yield();

System.out.println(a);
}
}

//increase()字节码
0: getstatic #2 // Field a:I
3: iconst_1
4: iadd
5: putstatic #2 // Field a:I
8: return

 执行可以发现,最后的结果是一个小于200000的值。说明volatile并不能保证并发安全。问题出在a++自增运算上。从上述字节码我们可以看到,getstatic指令把a的值取到操作数栈栈顶,此时volatile可以保证取到的a是正确的,但是在执行后续操作iconst_、idd操作时,其他线程可能已经把a的值改变了,而栈顶的数就变成了过期数据,putstatic指令执行后可能把较小的值同步回主内存之中了。

 注意:一条字节码指令也不一定是原子的,因为它解释器可能运行多条代码。

 以上例子说明,volatile只保证可见性,并不保证原子性。当程序的运行结果不依赖volatile变量当前值,或者变量不需要其他状态变量共同参与不变约束的时候,必须加锁来保证其原子性。

1
2
3
4
5
6
7
8
9
volatile boolean shutdown;
public void shutdown(){
shutdown = true;
}
public void do(){
while(!shutdown){
//业务代码
}
}

 以上例子就适合使用volatile来控制并发。

volatile禁止指令重排序优化

 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序

 通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

内存屏障类型如下,其中StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代处理器大多支持该屏障,但执行该屏障开销昂贵,因为需要把写缓冲区中的数据全部刷新到内存中。

JMM针对编译器制定volatile重排序规则:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

 为了实现上述规则,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

volatile和synchronized对比

  • volatile同步机制性能确实优于synchronized锁,但是虚拟机对锁进行了许多优化和消除,因此无法说volatile比synchronized快多少
  • volatile变量的读操作性能消耗和普通变量几乎无差别
  • volatile变量的写操作会比普通变量慢,因为需要加各种内存屏障。但是大多时候,仍然比锁快。

2.5 先行发生(happens before)原则

 在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系。java中先天存在不需要任何其他手段来保证的先行发生原则有:

  • 程序次序规则:一个线程内,控制流程中书写在前面的操作先行于书写在后的操作
  • 管程锁定规则:unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:一个volatileb变量的写操作先行于任意后续对这个变量的读操作
  • 线程启动规则:线程中所有的操作都先行于该线程的终止检测
  • 线程中断规则:interrupt()的调用先行于被检测线程的代码检测到中断时间的发生
  • 对象终结规则:对象的初始化完成(构造函数结束)先行于finalize()方法
  • 传递性:如果A 先行于 B,且B先行于 C,那么A先行于 C。

时间先后顺序和先行发生之间没有因果关系,衡量并发以先行发生原则为准。

3. Java内存模型和硬件内存架构之间的桥接

 硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。

 Java内存模型和硬件内存架构之间的桥接

  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存

JMM的控制模型图


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!