(五)Java 的内存模型 JMM

5.1 并发编程的两个关键问题

并发编程中有两个关键问题:

  1. 线程间如何通信?(线程间以何种机制来交换信息)
  2. 线程间如何同步?(线程以何种机制来控制不同线程间操作发生的相对顺序)

有两种并发模型可以解决这两个问题:

  1. 消息传递并发模型
  2. 共享内存并发模型

这两个模型之间的区别:

模型如何通信如何同步
消息传递并发模型线程之间没有公共状态,线程间的通信必须通过发送消息来显式进行通信发送消息天然同步,因为发送消息总是在接受消息之前,所以同步是隐式的
共享内存并发模型线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信必须显式指定某段代码需要在线程之间互斥执行,同步是显式的

在 Java 中,使用的是共享内存并发模型。

5.2 Java 内存模型的抽象结构

01、运行时内存的划分

Java 运行时数据区如下图所示:
在这里插入图片描述

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说在栈中的变量(局部变量、方法定义参数、异常处理参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,我们称之为共享变量。

所以,内存可见性针对的是共享变量(堆中的变量)

02、堆中的内存不可见问题

因为计算机为了高效,往往会在高速缓存区中缓存共享变量,因为 CPU 访问缓存区比访问内存要快得多。

线程之间的共享变量存在主内存中,每个线程都有一个私有的本地内存,存储了该线程以读、写共享变量的副本。本地内存是 Java 内存模型的一个抽象概念,并不是真实存在的。它涵盖了缓存、写缓冲区、寄存器等。

Java 线程之间的通信由 Java 内存模型(简称 JMM)控制,从抽象的角度来说,JMM 定义了线程和主内存之间的抽象关系。

JMM 的抽象示意图如图所示:
在这里插入图片描述
从图中可以看出:

  1. 所有的共享变量都存在主内存中;
  2. 每个线程都保存了一份该线程使用到的共享变量的副本;
  3. 如果线程 A 与线程 B 之间要通信的话,必须要经历两个步骤:
    ① 线程 A 将本地内存 A 中更新过的共享变量刷新到主内存中去
    ② 线程 B 到主内存中去读取线程 A 之前已经更新过的共享变量

所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主内存。并且,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取

所以,线程 B 并不是直接去主内存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主内存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。

那又是怎么知道这个共享变量的值被其他线程更新了呢?这就是 JMM 的功劳了,也是 JMM 存在的必要性之一。JMM 通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

Java 中的 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized 关键字不仅保证了可见性,同时也保证了原子性(互斥性)。在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止指令重排序。

03、JMM与Java内存区域划分的区别与联系

  • 区别

    两者是不同的概念层次。JMM 是抽象的,它是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时,必要的内存划分。

  • 联系

    都存在私有数据区和共享数据区域。一般来说,JMM 中的主内存属于共享数据区域,它是包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

5.3 指令重排序

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

为什么指令重排序可以提高性能?简单地说,每个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,就出现了流水线技术。它的工作原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束之后再执行指令 2,这样就提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断,指令重排就是减少中断的一种技术。

分析一下下面这两行代码的执行情况:

a = b + c;
d = e - f;

先加载 b、c(其中,可能先加载 b,也可能先加载 c),但是在执行 add(b, c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这就降低了计算机的执行效率。

为了减少这个停顿,可以先加载 e 和 f,然后再去加载 add(b, c),这样做对程序(串行)是没有影响的,但是却减少了停顿。

所以,指令重排对于提高 CPU 处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为三种:

  1. 编译器优化重排

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

  2. 指令并行重排

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

  3. 内存系统重排

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

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排可能会导致一些问题。

5.4 顺序一致性模型与JMM的保证

顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。

01、数据竞争与顺序一致性

当程序未正确同步的时候,就可能存在数据竞争。

数据竞争:在一个线程中写一个变量,在另一个线程中读同一个变量,并且读和写没有通过同步来排序。

如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在写之前,可能就会读出错误的值;如果一个线程能够正确同步,那么就不存在数据竞争。

Java 内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。

如果在程序中没有正确使用 volatile、final、synchronized,即使是使用了同步(单线程下的同步),JMM 也不会有内存可见性的保证,可能会导致程序出错,并且具有不可重现性,很难排查。所以,应该正确地使用 volatile、final、synchronized 这几个关键字。

02、顺序一致性模型

顺序一致性内存模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。

顺序一致性模型有两大特征:

  1. 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
  2. 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。

假设有两个线程 A 和 B 并发执行,线程 A 有 3 个操作,他们在程序中的顺序是 A1 -> A2 -> A3,线程 B 也有 3 个操作,在线程中的顺序是 B1 -> B2 -> B3。

  • 正确使用了同步

    A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果为:
    在这里插入图片描述

  • 没有使用同步

    在这里插入图片描述
    操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。

但是,JMM 没有这样的保证。

比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。

03、JMM中同步程序的顺序一致性效果

在顺序一致性模型中,所有的操作完全按照程序的顺序串行执行。但是在 JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码跑到临界区之外,因为这样会破坏锁的内存语义)。

虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

同时,JMM 会在退出临界区和进入临界区时做特殊处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。

由此可见,JMM 的具体实现方式是:在不改变(正确同步)程序执行结果的前提下,尽量为编译器和处理器的优化提供方便。

04、JMM中未同步程序的顺序一致性效果

对于未同步的多线程程序,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。

为了实现这个安全性,JMM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。

JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。

未同步程序在 JMM 和顺序一致性内存模型中的执行特性有以下差异:

  1. 顺序一致性保证单线程内的操作会按照程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)
  3. 顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。

5.5 happens-before

01、什么是 happens-before?

一方面,需要 JMM 提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。

JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都可以。

而对于我们开发来说,JMM 提供了 happens-before 规则(JSR-133规范),满足了我们的需求——简单易懂,并且提供了足够强的内存可见性保证。我们只要遵循 happens-before 规则,那么程序就能保证在 JMM 中具有强的内存可见性。

JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程内,也可以是不同的线程之间。所以,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证。

happens-before 关系定义:

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

happens-before 关系本质上和 as-if-serial 语义是一回事。

as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。

总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管他们在不在一个线程。

02、天然的 happens-before 关系

在 Java 中,有以下天然的 happens-before 关系:

  1. 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. start 规则:如果线程 A 执行操作 ThreadB.start() 启动线程 B,那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。
  6. join 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 吃饱饭 ThreadB.join() 操作成功返回。
int a = 1; // A操作
int b = 2; // B 操作
int sum = a + b; // C操作
System.out.println(sum);

假如只有一个线程,不难得出一下结论:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

注意,真正在执行指令的时候,其实 JVM 有可能对操作 A & B 进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。

如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。

所以,我们只需要关心 happens-before 规则,而不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就可以了。

重排序有两类,JMM 对这两类重排序有不同的策略:

  • 会改变程序执行结果的重排序,比如 A->C,JMM 要求编译器和处理器都禁止这种重排序。
  • 不会改变程序执行结果的重排序,比如 A->B,JMM 对编译器和处理器不做要求,允许这种重排序。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值