我要学并发-Java内存模型到底是什么

时间:2024-01-25 17:09:51

内存模型

在计算机CPU,内存,IO三者之间速度差异,为了提高系统性能,对这三者速度进行平衡。

  • CPU 增加了缓存,以均衡与内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

以上三种系统优化,对于硬件的效率有了显著的提升,但是他们同时也带来了可见性,原子性以及顺序性等问题。基于Cpu高速缓存的存储交互很好得解决了CPU和内存得速度矛盾,但是也提高了计算机系统得复杂度,引入了新的问题:缓存一致性(Cache Coherence)。

每个处理器都有自己独享得高速缓存,多个处理器共享系统主内存,当多个处理器运算任务涉及到同一块主内存区域时,将可能会导致数据不一致,这时以谁的数据为准就成了问题。为了解决一致性问题,各个处理器需要遵守一些协议,根据这些协议来进行读写操作。所以内存模型可以理解为是为了解决缓存一致性问题,在特定的操作协议下,对特定的内存或高速缓存进行读写的过程的抽象。

Java内存模型

JMM的作用

Java虚拟机规范试图定义一种Java内存模型(Java Memory Model, JMM),用来屏蔽掉硬件和操作系统的内存访问差异,以实现让Java程序在各种平台都能达到一致的内存访问效果。使得Java程序员可以忽略不同处理器平台的不同内存模型,而只需要关心JMM即可。

JMM抽象结构

JMM 抽象结构图

JMM借鉴了处理器内存模型的思想,从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系,它涵盖了缓存,写缓冲区,寄存器以及其他硬件和编译器优化。下图是JMM的抽象结构示意图。

JMM中线程间通信

并发编程中需要考虑的两个核心问题:线程之间如何通信(可见性和有序性)以及线程之间如何同步(原子性)。通信是指线程之间以何种方式进行信息交换;同步是指程序中用于控制不同线程间操作发生的相对顺序

JMM规定了程序中所有的变量(实例字段,静态字段,构成数组对象的元素等)都存储在主内存中;它的主要目标是定义程序种各个变量的访问规则,既从虚拟机将变量存储到内存和从内存种取出变量这样的底层细节。每个线程都有自己的本地内存,线程之间在JMM控制协议的限制下通过主内存进行通信。假设由两个线程A和B,线程A要给线程B发送"hello"消息,下图是两个线程进行通信的过程:

由图可见,假设线程A要发消息给线程B,那么它必须经过两个步骤:

  1. 线程A把本地内存中的共享变量副本message更新后刷新到主内存中
  2. 线程B到主内存取读取线程A更新的共享变量message

JMM的设计与实现

JMM相关的协议比较复杂,我们可以从编译器或者JVM工程师,以及Java工程师来进行学习。本文仅从Java工程师角度来进行探讨Java中通过那些协议来控制JMM,从而保证数据一致性。

JMM的实现可以分为两部分,包括happen-before规则以及一系列的关键字。它的核心目标就是确保编译器,各平台的处理器都能提供一致的行为,在内存中表现出一致性的结果。具体来讲就是通过happens-before规则以及volatile,synchronized,final关键字解决可见性,原子性以及有序性问题,从而保证内存中数据的一致性。

Happens-Before规则

happens-before是JMM中最核心的概念,happens-before用来指定两个操作之间的执行顺序,这两个操作可以在一个线程内,也可以在不同的线程内,因此JMM通过happen-before关系向程序员提供跨线程的内存可见性保证,JMM的具体定义如下:

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作存在着happen-before关系,并不意味着Java平台具体实现必须要按照happen-before关系指定的顺序来执行。如果重排序之后的执行结果,与按照happen-before关系来执行的结果一致,那么这种重排序不非法(也就是说,JMM允许这种重排序)

下面的示例代码,假设线程 A 执行 writer() 方法,线程 B 执行 reader() 方法,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是多少呢?

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;                 // 1
    v = true;               // 2
  }
  public void reader() {
    if (v == true) {        // 3
      // 这里 x 会是多少呢?  // 4
    }
  }
}

1. 程序顺序性规则

程序顺序规则(Program Order Rule): 一个线程内的每个操作,按照代码先后顺序,书写在前面的代码先行发生于与写在后面的操作。

2. volatile变量规则

volatile变量规则(Volatile Variable Rule):对于一个volatile修饰得变量得写操作先行发生于后面对这个变量得读操作。“后面”指得是时间上的顺序

3. 传递性规则

传递性规则(Transitivity): 如果操作A先行发生于操作B, 操作B先行发生于操作C,那么A先行发生于操作C。

针对上述的1,2,3项happens-before我们作出个总结,下图是我们根据volatile读写建立的happens-before关系图。

4. 程锁定规则

管程锁定规则(Monitor Lock Rule): 一个unlock操作先行发生于后面对这个锁得lock操作。“后面”指得是时间上的顺序

在之前文章并发问题的源头中并发问题中count++的问题提到了线程切换导致计数出现问题,在此我们就可以尝试利用happens-before规则解决这个原子性问题。

public class SafeCounter {
  private long count = 0L;
  public long get() {
    return cout;
  }
  public synchronized void addOne() {
    count++;
  }
}

上述代码真的解决可以解决问题吗?

4. 线程启动规则

线程启动规则(Thread Start Rule): Thread对象的start()方法,先行发生于此线程的每一个动作。

6.线程终止规则

线程终止规则(Thread Termination Rule): 线程中的所有操作都先行发生于对于此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()返回值等手段来检测线程是否执行完毕。

7. 线程中断规则

线程中断规则(Thread Interruption Rule): 对线程的interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

8. 对象终结规则

对象终结规则(Finalizer Rule): 一个对象的初始化完成(构造函数执行完毕)先行发生于它的finalize()方法。

happens-before规则一共可分为以上8条,笔者只针对在并发编程中常见的前6项进行了详细介绍,具体内容可以参考http://gee.cs.oswego.edu/dl/jmm/cookbook.html。在JMM中,我认为这些规则也是比较难以理解的概念。总结下来happens-before规则强调的是一种可见性关系,事件A happens-before B,意味着A事件对于B事件是可见的,无论事件A和事件B是否发生在一个线程里。

volatile关键字

volatile自身特性

  1. 可见性:对一个volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写入。
  2. 原子性: 对单个volatile变量的读/写具有原子性,注意,对于类似于vaolatile ++ 这种操作不具有原子性,因为这个操作是个符合操作。

volatile在JMM中表现出的内存语义

  1. 当写一个变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
  2. 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。接下来将从主内存中读取共享变量。

volatile是java中提供用来解决可见性问题得关键字,可以理解为jvm看见volatile关键字修饰的变量时,会“禁用缓存”既线程的本地内存,每次对此类型变量的读操作时都会从主内存中重新读取到本地内存中,每次写操作也会立刻同步到主内存中,这也正进一步诠释了volatile变量规则中描述的,对于一个volatile修饰得变量得写操作先行发生于后面对这个变量得读操作;被volatile修饰的共享变量,会被禁用某些类型的指令重排序,来保证顺序性问题。

synchronized-万能的锁

由管程锁定规则,一个unlock操作先行发生于后面对这个锁的lock操作。在Java中通过管程(Monitor)来解决原子性问题,具体的表现为Synchronized关键字。被synchronized修饰的代码块在编译时会在开始位置和结束位置插入monitorenter和monitorexit指令,JVM保证monitorenter和monitorexit与之与之配对,并且这段代码得原子性。synchronized中的lock和unlock操作是隐式进行的,在java中我们不仅可以使用synchronized关键字,同样可以使用各种实现了Lock接口的锁来实现。

synchronized的内存语义

  1. 当线程获取锁时,会把线程本地内存置为无效
  2. 当线程释放锁时,会将共享变量刷新到主内存中

final-默默无闻的优化

在并发编程中的原子性,可见性以及顺序性的问题导致的根本就是共享变量的改变。final关键字解决并发问题的方式是从源头下手,让变量不可变,变量被final修饰表示当前变量不会发生改变,编译器可以放心进行优化。

总结

  1. JMM是用来屏蔽掉硬件和操作系统的内存访问差异,以实现让Java程序在各种平台都能达到一致的内存访问效果
  2. 站在称序员角度来看JMM是一系列的协议(hanppens-before规则)和一些关键字,Synchronized,volatile和final
  3. volatile通过禁用缓存和编译优化保证了顺序性和可见性
  4. synchronzed能保证程序执行的原子性,可见性和有序性,是并发中的万能要是
  5. final关键字修饰的变量 不可变

Q&A

上文中尝试用synchronized解决count++的问题,为了方便观察将代码copy到此处,这段代码有没有什么不对劲呢?可以在留言区说出你的想法,我们一起来学习!

public class SafeCounter {
  private long count = 0L;
  public long get() {
    return cout;
  }
  public synchronized void addOne() {
    count++;
  }
}