Java解决可见性和有序性问题---Java内存模型

本文来自《Java并发编程实战》的第二篇《Java内存模型:看Java如何解决可见性和有序性问题》,主要介绍了Java如何通过内存模型解决上篇文章中提到的可见性和有序性问题。更多文章查看:Java并发学习记录总目录


Java主要依靠Java内存模型来解决并发编程中的并发问题(可见性,有序性)。Java内存模型(JMM,Java Memory Model),跟JVM是不一样的。具体不同可参考:JVM内存结构 VS Java内存模型 VS Java对象模型

什么是Java内存模型?

在上篇文章,我们讲述了导致可见性的原因是缓存,导致有序性的原因是编译优化。那解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化。按需禁用就是指程序员按照自己的需求,禁用缓存和编译优化。Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatilesynchronized final 三个关键字,以及六项 Happens-Before 规则。

使用 volatile 的困惑

volatole关键字很多语言中多有,它最原始的意义是禁用CPU缓存。被volatile修饰的变量,读写时,不能使用CPU缓存,必须从内存中读取或者写入。在JDK 1.5 之前,没有Happens-Before,使用volatitle时还可能出现CPU导致的可见性问题,例如:


// 以下代码来源于【参考1】
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

当线程A执行了writer(),线程B执行reader(),当线程A将V=true写入内存,线程B看到的x可能为0(CPU未将值写入内存或因为CPU缓存),可能为42(CPU将值写入内存或因为CPU缓存)。这个结果是完全随机的。但是在JDK 1.5以后,Java内存模型对volatile进行了增强,通过Happens-Before保证了多线程访问的安全性。

Happens-Before 规则

Happens-Before指:前面一个操作的结果对后续操作是可见的。A Happens-Before B说明A的操作对B来说是可见的。这个规则优化了编译器的优化行为:例如语句重排等。下面解释一下程序员要知道的六项规则。

1. 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

2. volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。结合下面第三条规则。

3. 传递性这条规则

是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。如下图,根据规则一,x = 42 Happens-Before 写v=true,根据第二条规则知道线程A的写v=true Happens-Before 线程B的读v=true ,根据规则三,x= 42 Happens-Before 读v=true

4. 管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码。


Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

6. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。


Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

被我们忽视的 final

前面我们讲 volatile 为的是禁用缓存以及编译优化,有没有办法告诉编译器优化得更好一点呢?有,就是 final 关键字。final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java 编译器在 JDK 1.5 以前的版本有可能优化错了。问题类似于上一期提到的利用双重检查方法创建单例,构造函数的错误重排导致线程可能看到 final 变量的值会变化。

在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。


// 以下代码来源于【参考1】
final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}

个人思考:Java内存模型主要解决了在多核cpu和多线程情况下,不同缓存,寄存中的共享数据是否可见的问题,以及编译时期的编译结果的顺序问题,主要是针对可见性和有序性。与JVM不同的是,JVM内存模型主要解决数据在内存中的位置问题。

Java内存模型底层怎么实现的?主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值