并发问题产生的源头

缓存导致的可见性问题

可见性定义: 一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

在单核CPU时代,所有线程都是被同一个CPU调度,因此共享同一块cpu的缓存,不存在缓存一致性问题:
在这里插入图片描述
多核CPU时代,每个线程都有可能同时被不同的cpu调度,每个cpu都有各自的缓存,并且读写优先走缓存,这就会导致缓存一致性问题:

在这里插入图片描述
高速缓存和主内存之间如何保持数据一致性


线程切换导致的原子性问题

CPU能保证的原子操作是CPU指令级别的,高级语言里一条语句往往需要多条 CPU 指令完成,例如:count += 1,至少需要三条 CPU 指令:
在这里插入图片描述


编译优化带来的有序性问题

有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,从而导致意外的bug。

最常见的例子就是双重锁检查创建单例对象了:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

new创建对象在java中分为三步:

  • 分配一块内存 M;
  • 在内存 M 上初始化 Singleton 对象;
  • 然后 M 的地址赋值给 instance 变量。

但是如果编译器进行了指令重排序优化,变成了下面这样:

  • 分配一块内存 M;
  • 将 M 的地址赋值给 instance 变量;
  • 最后在内存 M 上初始化 Singleton 对象。

在下面场景中,线程B访问未初始化过的instance,可能会触发空指针异常:
在这里插入图片描述


小结

java并发编程问题三大根本来源: 可见性,原子性,有序性

  • 缓存导致的可见性问题
  • 线程切换导致的原子性问题
  • 编译优化导致的有序性问题

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

Java内存模型与JVM内存模型的区别

  1. Java内存模型定义了一套规范,能使JVM按需禁用cpu缓存和禁止编译优化。这套规范包括对volatile, synchronized, final三个关键字的解析,和7个Happen-Before规则。

  2. JVM内存模型是指程序计数器,虚拟机栈,本地方法栈,堆,方法区这5和要素。


volatile关键字

volatile在c语言中最原始的含义就是禁用cpu缓存,volatile修饰符表达的是: 对某个变量的读写,不能使用cpu缓存,必须从内存中读取或者写入。

大家看下面这个例子: 线程A执行writer方法,按照volatile语义,会把变量v=true写入内存,假设线程B执行reader方法,同样按照volatile语义,线程B会从内存中读取变量v, 如果线程B看到v==true时,那么线程B看到的变量x的值是多少呢?

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

答案:

  • jdk 1.5之前,x可能是42,也可能是0 ,因为变量x可能被cpu缓存而导致可见性问题
  • jdk 1.5之后, x就是等于42。因为java内存模型在1.5版本对volatile语义进行了增强

怎么增强的呢?

  • happens-before规则

Happens-Before规则

Happens-Before规则:前面一个操作的结果对后续操作是可见的;

Happens-Before规则约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。

具体规则如下:

  1. 程序的顺序性规则:在一个线程中,前面的操作Happens-Before于后续的任意操作。
  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” ,这是规则 1 的内容;
  • 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。

再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

  1. 管程中锁的规则:synchronized是Java对管程的实现,隐式加锁、释放锁,对一个锁的解锁Happens-Before于后续对这个锁的加锁。
  2. 线程start()规则:主线程A启动子线程B后,start()操作 Happens-Before于子操作中的任意操作。
  3. 线程join规则:在线程A中调用线程B的join()并返回,线程B中的任意操作 Happens-Before于join()的返回。
  4. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  5. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

上述很多规则都需要配合传递性规则进行理解。


小结

Java内存模型涉及的几个关键词:锁、volatile字段、final修饰符与对象的安全发布。

  • 第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
  • 第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。
  • 第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。

Java内存模型底层怎么实现的?

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

在java中,Happens-Before规则本质还是一种可见性,A Happens-Before B,意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里,例如: 事件A发生在线程1,事件B发生在线程2,Happens-Before规则保证线程2上也能看到A事件的发送。


思考题

还是文中给出的案例,大家思考会不会产生x=42和v=true重排序,导致线程B读取到x=0的结果呢?

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

解答:

  • 程序顺序性规则是针对单线程的。如果只考虑单线程,那么编译器可以对范例代码进行指令重排优化。但是对于多线程,volatile 变量规则、传递性这 2 条规则,就附加了新的限制。对于多线程,这 3 条 happens-before 规则要求,线程 B 在读到 v = true 的时候,也能见到 x = 42。如果编译器仍然按照单线程的情况,对这两条语句进行指令重排,把 v = true 放到 x = 42 之前。那么,线程 B 就有可能看不到 x 的值为 42。这显然违背了 happens-before 的规定。编译器为了符合规则,只能不进行指令重排优化了。
  • 为了符合 happens-before 规定,对于示例代码,编译器不能进行指令重排的编译优化。但实际上,仅仅不进行指令重排编译优化,并不能保证编译后的代码的执行结果,符合 happnes-before 规定。因为指令重排这一优化措施,并不仅仅是编译器会做。现代 cpu 在执行机器指令的时候,同样会做指令重排的优化。所以,如果 cpu 在执行机器指令时,发生了机器指令的重排序。上述实例代码的结果,仍然有机会不符合 happens-before 规定。
  • 为了令到编译后的代码的执行结果,能够符合 happens-before 规定。编译器除了不能做指令重排序编译优化之外,还要在生成的机器代码中。加入特定 cpu 指令,令到 cpu 只会执行完这条特定指令之后,才会执行后续的其它机器指令。而这种特定指令,是通过建立 “内存屏障” 来禁止 cpu 的指令重排序的。那为什么叫 “内存屏障” 呢?可以理解为一种特殊指令,要求 cpu 把缓存数据写回到主内存中。这就像在内存中建立了一道屏障,令到后面的代码不能越过屏障,提前执行。

jmm 是一个规范,它用于指导编译器的行为。但它本身不会限制编译器所使用的具体编译技术。所以,在 jmm 规范中,不会提到 “指令重排” 或者 "内存屏障” 这些具体的实现技术。这是我们在学习规范类知识的时候,需要注意的。


参考

JAVA并发编程实战

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐