写点什么

Java 并发编程实战(2)- Java 内存模型

发布于: 2021 年 01 月 09 日
Java并发编程实战(2)- Java内存模型

本文主要描述了在 Java 并发编程中非常重要的 Java 内存模型以及 Happens-Before 规则。

概述

对于 Java 并发程序问题存在的各种问题,主要有 3 个根源:

  • 由缓存引发的可见性问题

  • 由线程切换引发的原子性问题

  • 由编译优化引发的有序性问题

为了解决可见性和有序性的问题,Java 引入了 Java 内存模型,我们这篇文章来介绍一下它。

可见性问题和有序性问题由缓存和编译优化造成的, 那么最直接的方法就是禁用缓存和编译优化,这样做是可以解决问题的,但是程序的性能会下降到不能接受的程度。

合理的方案是按需禁用缓存和编译优化, 所谓“按需禁用”,就是指按照程序员的要求来禁用,来为程序员开放相应的方法。

什么是 Java 内存模型

Java 内存模型是一个很复杂的规范,可以从不同的角度进行解读,站在程序员的角度,可以将其解决为它规范了 JVM 如何提供按需禁用缓存和编译优化的方法。

Java 内存模型对应的规范是 JSR-133,链接:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf.

Java 内存模型和 JVM 的区别

  • Java 内存模型定义了一套规范,它能让 JVM 按需禁用 CPU 缓存和编译优化,这套规范包括 volatile、synchronized、final 三个关键字和 7 个 Happen-Before 规则。

  • JVM 内存模型是指程序计数器、JVM 方法栈、本地方法栈、堆、方法区这 5 部分。

volatile 关键字

volatile 关键字的用途是禁用 CPU 缓存。

例如我们定义一个 volatile 变量volatile int x = 0;,它表达的是:编译器在对这个变量进行读写操作时,不能使用 CPU 缓存,而是从内存中直接操作。

我们来看下面的代码示例。

public class VolatileDemo {
int x = 0; volatile boolean v = false; public void write() { x = 42; v = true; } public void read() { if (v == true) { System.out.println(String.format("x is %s", x)); } }}
复制代码

如果对同一个 VolatileDemo 对象,有 2 个线程,一个调用 write()方法,一个调用 read()方法,那么当 read()方法中 v 等于 true 时,x 的值是多少?

在 Java 1.5 版本之前,x 的值可能是 0 或者 42, 在 Java 1.5 版本之后,x 的值只能是 42。

这是由于 Happens-Before 规则导致的。

Happens-Before 规则

什么是 Happens-Before 规则?

Happens-Before 规则表达的是前面一个操作的结果对后续操作是可见的。它约束了编译器的优化行为,保证其一定要遵守 Happens-Before 规则。

Happens-Before 的语义本质是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程中。

Happens-Before 规则有很多条,其中和程序员相关的有 6 条,我们来一一描述。

顺序性规则

在一个线程中,按照程序顺序,前面的操作 Happens-Before 后续的任意操作。

这条规则比较直观,符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

volatile 变量规则

对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作。

传递性

如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

我们再看上面的示例代码:

  • x=42 Happens-Before 写变量 v=true,这是规则 1。

  • 写变量 v=true Happens-Before 读变量 v=true,这是规则 2。

然后根据传递性规则,我们可以得出 x=42 Happens-Before 读变量 v=true。所以在示例代码中,在判断 v==true 时,x 的值等于 42。

synchronized 规则

对一个锁的解锁要 Happens-Before 于后续对这个锁的加锁。

我们要首先了解什么是“管程”,管程是操作系统中的一个重要概念,一个管程是一个由过程、变量及数据结构等组成的一个集合,它由四个部分组成:1)管程名称,2)共享数据的说明,3)对数据进行操作的一组过程,4)对共享数据赋初值的语句。

在 Java 中,管程是通过 synchronized 关键字实现的。

我们对这个规则可以理解为:假设 x 的初始值是 10,线程 A 获取锁,执行完代码,x 的值会变为 12,之后释放锁,接下来线程 B 获取锁,这时线程 B 看到的 x,一定是 12,不应该是 10。

线程 start()规则

主线程 A 启动子线程 B,子线程 B 能够看到主线程在启动子线程 B 之前的操作。

我们来看下面的示例。

public class HappensBeforeDemo {
private int x = 10; public void threadStartTest() { Thread t = new Thread(() -> { System.out.println(String.format("x is %s.",x)); } ); x = 20; t.start(); } public static void main(String[] args) { HappensBeforeDemo demoObj = new HappensBeforeDemo(); demoObj.threadStartTest(); }}
复制代码

程序的输出结果如下。

x is 20.
复制代码

线程 join()规则

主线程 A 通过调用子线程的 join()方法等待子线程结束,当子线程结束后,主线程能够看到子线程对共享变量的操作。

这个规则和线程 start()规则类似,我们来看下面的示例代码。

public class HappensBeforeDemo {
private int x = 10; public void threadJoinTest() throws InterruptedException { Thread t = new Thread(() -> { try { java.lang.Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } x = 30; }) ; t.start(); t.join(); System.out.println(String.format("x is %s.",x)); } public static void main(String[] args) throws InterruptedException { HappensBeforeDemo demoObj = new HappensBeforeDemo(); demoObj.threadJoinTest(); }}
复制代码

程序的输出结果如下。

x is 30.
复制代码

final 规则

我们用 final 修饰变量时,就是告诉编译器,这个变量生而不变,可以尽情优化。

但是如果我们将变量设置成 final,它的构造函数由于编译优化后的错误重排,还是可能会导致错误,例如我们之前谈到的单例模式的代码。

在 Java 1.5 之后,Java 内存模型对 final 类型变量的重排进行了约束,只要我们提供的构造函数没有“逸出”,那么就不会有问题。

所谓“逸出”,就是指构造函数中使用了生命周期超过了该对象生命周期的变量。

参考资料


发布于: 2021 年 01 月 09 日阅读数: 31
用户头像

点滴技术感悟,记录人生成长 2017.10.25 加入

还未添加个人简介

评论

发布
暂无评论
Java并发编程实战(2)- Java内存模型