线程初步——Java内存模型

多线程

既然要说线程池,那就不得不聊到多线程。Java并发场景中,因可见性,原子性,有序性等问题常常成为并发编程的Bug来源。本周想总结的是其中解决可见性和有序性导致问题基石——Java内存模型
Java内存模型需要与JVM运行时数据区做区分。Java内存模型是一种规范,下面听我细细道来。

Java内存模型

Java内存模型是一个比较复杂的规范,可以从不同的视角来解读,本质上可以理解为:Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile,synchronized和final三个关键字,以及七项Happens-Before规则。
我们已知导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性,有序性最直接的办法就是禁用缓存和编译优化。
先从volatile关键字开始吧。volatile最原始的语义就是禁用CPU缓存。例如,声明一个volatile变量x,volatile int x = 0, 它表达的是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或写入。这个语义看上去相当正确,但是在实际使用的时候却很容易出问题。
先贴一段代码:

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()方法,按照volatile语义,会把变量"v=true"直接写入内存。假设线程B执行reader方法,同样按照volatile语义,线程B会从内存中读取变量v,如果线程B看到"v==true"时,那么线程B看到的变量x是多少呢?
直觉上看,应该是42,而实际上,这一问题需要结合Java版本来看,1.5之前的版本可能是0,也可能是42;1.5之后的版本x就是等于42。这是因为Java内存模型在1.5之后对volatile语义进行了增强,通过Happens-Before原则实现。

Happens-Before规则

Happens-Before规则想要表达的是:前面一个操作的结果对后续操作是可见的。它通过约束编译器的优化行为,允许编译器优化,但是要求编译器优化后,一定要遵守Happens-Before规则。
具体的规则内容总结如下:

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。可能大家对管程这个词比较陌生。"管程"是一种通用的同步原语,学过os的同学都听说过mutex,在Java中就是synchronized, synchronized是Java对管程的实现。管程中的锁在Java里是隐式实现的,以下面代码为例
synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁
  在进入同步块之前,会自动加锁,而在代码块执行完全后,会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
  • volatile变量规则:对一个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 。这就是volatile原则的内容。根据传递性的内容,x=42 happens-before 读变量v=true,这就是1.5之后的优化。如果线程B读到了v=true,那么线程A设置的x=42 对线程B是可见的。也就是说,线程B能看到 x==42。1.5版本的并发工具包(java.util.concurrent)就是靠volatile语义实现可见性的。

  • 线程启动规则:Thread对象的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();
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。例如主线程A等待子线程B完成(主线程A通过调用子线程B的join方法实现。)当子线程B完成后(子线程B的join方法返回),主线程能够看到子线程的操作,这里的看到其实就是能够共享变量。通过一段代码理解一下吧
Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

final关键字

volatile为的是禁用缓存以及编译优化(又叫指令重排序)。final修饰变量时,告诉编译器,这个变量从放入线程栈的那一刻,就是不变的。最经典的就是双重校验法的单利模式中,构造函数的错误重排导致线程可能看到final变量的值会变化。
Java1.5之后Java内存模型对final类型变量的重排进行了约束。现在只要提供正确的构造函数没有"逸出"就不会出错。
关于逸出这个概念,建议你去重读一下《Java并发编程实战》这本书的第二章。举个简单例子,有助于我们理解。在构造函数里面将this赋值给了全局变量 global.obj 这就是逸出。线程通过global.obj读取x是有可能读到0的,因此需要避免逸出。

final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}

Java内存模型底层是如何实现的

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值