首页 Java内存模型JMM与多线程
文章
取消

Java内存模型JMM与多线程

首先注意的是JMM(Java Memory Model)Java内存模型与JVM的内存结构不是一个概念,前者常和多线程相关。

缓存一致性

我们知道线程是CPU调度的最小单元,线程中的字节码指令最终都会在CPU中被执行,所以它免不了和各种数据打交道,而Java中所有的数据是存放在主内存(RAM)中的,随着CPU技术的发展,CPU的执行速度越来越快,但内存的技术并没有太大的变化,所以在内存中读取和写入数据的过程和CPU执行的速度比起来,差距越来越大,CPU对主存的访问需要等待较长时间,降低了CPU的执行效率。因此后来,为了弥补这个问题,在CPU中加入了高速缓存作为缓冲,在执行任务时,CPU会先将运算所需要使用到的数据复制到高速缓存中,让运算能够快速执行,当运算完成之后,再将缓存中的结果刷回到主内存,这样CPU就不用等待主内存的读写操作了。

然而,每个处理器都有自己的高速缓存,同时又操作同一块主存,当多个处理器同时操作主内存时,可能就会导致数据不一致,这就是缓存一致性问题。

指令重排

另外除了上面缓存一致性问题以外,处理器或者编译器为了提高运算效率,可能会对输入的字节码指令进行重新排序,也就是优化

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
int a = 1;
int b = 2;
a = a + 1;

// 多上面三行语句使用javap编译成字节码后:
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iconst_1
         6: iadd
         7: istore_1
         8: return
       ……
}
// 优化后:
0: iconst_1
1: iconst_1
2: iadd
3: istore_1
4: iconst_2
5: istore_2

// 相当于:
int a = 1;
a = a + 1;
int b = 2;         

上面指令7 并不依赖指令2和指令3,这样CPU就会对其进行优化,也就是说,CPU或编译器指令的重排,可能对执行结果产生影响,最后导致在不同平台、硬件、操作系统输出结果不一致。

为了解决上面问题,Java虚拟机提出了一套机制——Java内存模型

JMM

内存模型是一套共享内存系统中多线程读写操作行为的规范,这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了 CPU 多级缓存、CPU 优化、指令重排等导致的内存访问问题,从而保证 Java 程序(尤其是多线程程序)在各种平台下对内存的访问效果一致。

JMM分为主内存和工作内存两种,其中工作内存是对CPU中寄存器或者高速缓存的抽象,而线程之间的共享变量存储在主内存,每个线程都有一个工作内存,本地工作内存存储了该线程读/写变量的副本。

image-20220823172219088

Java中采用的共享内存的方式完成两个线程之间通信:

  1. 线程A把工作内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

这样一来,就完成了线程A到线程B的通信。JMM通过主内存与每个线程工作内存的交互来为Java程序提供内存可见性保证。

为了支持 JMM,Java 定义了 8 种原子操作(Action),用来控制主存与工作内存之间的交互。

  1. read(读取)作用于主内存,它把变量从主内存传动到线程的工作内存中,供后面的 load 动作使用。

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

  3. store(存储)作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的 write 操作使用。

  4. write (写入)作用于主内存,它把 store 传送值放到主内存中的变量中。

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

  6. assign(赋值)作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时,执行该操作。

  7. lock(锁定)作用于主内存,把变量标记为线程独占状态。

  8. unlock(解锁)作用于主内存,它将释放独占状态。

    image-20220823173942201

三大特征

原子性

原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。

JMM 保证了 read、load、assign、use、store 和 write 六个操作具有原子性,可以认为除了 long 和 double 类型以外,对其他基本数据类型所对应的内存单元的访问读写都是原子的。

如果要保证一个代码块的原子性,提供了monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。另外也可以用锁。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程也能立即感知到这种变化。

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

除此之外,final和synchronized以及锁也能实现可见性。

synchronized以及锁是把更多个操作转化为原子化的过程,在执行完,进入unlock之前,必须将共享变量同步到主内存中。

final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。

有序性

文章开头提到过,除了多线程间无序性,还有一个造成无序的原因就是指令重排,而Java中,可以使用synchronized和锁或者volatile保证多线程之间操作的有序性。

synchronized和锁的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized或者锁包住的代码块在多线程之间是串行执行的。

volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。

内存屏障(Memory Barrier)用于控制在特定条件下的重排序和内存可见性问题。JMM 内存屏障可分为读屏障和写屏障,Java 的内存屏障实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。它分为这4类:

  • LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
  1. 在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障。

image-20220823181110291

  1. 在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StreLoad屏障。

image-20220823181501097

指令重排序是 JVM 为了优化指令,来提高程序运行效率的,在不影响单线程程序执行结果的前提下,按照一定的规则进行指令优化。在某些情况下,这种优化会带来一些执行的逻辑问题,在并发执行的情况下,按照不同的逻辑会得到不同的结果。

这里JMM有一个非常重要的原则:happens-before 即先行发生原则

它用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。即如果一个操作 A happens-before 另一个操作 B,那么操作 A 的执行结果将对操作 B 可见。上述定义我们也可以反过来理解:如果操作 A 的结果需要对另外一个操作 B 可见,那么操作 A 必须 happens-before 操作 B。

JMM默认定义了一些符合先行发生原则的规则:

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

综上,JMM 可以说是 Java 并发的基础,它的定义将直接影响多线程实现的机制。而原子性、可见性、有序性这三大特征几乎贯穿了整个并发编程,对于后面起到铺垫作用。

本文由作者按照 CC BY 4.0 进行授权

JVM对象回收机制(下)

Java同步实现原理与锁优化