notebook notebook
首页
  • 计算机网络
  • 计算机系统
  • 数据结构与算法
  • 计算机专业课
  • 设计模式
  • 前端 (opens new window)
  • Java 开发
  • Python 开发
  • Golang 开发
  • Git
  • 软件设计与架构
  • 大数据与分布式系统
  • 常见开发工具

    • Nginx
  • 爬虫
  • Python 数据分析
  • 数据仓库
  • 中间件

    • MySQL
    • Redis
    • Elasticsearch
    • Kafka
  • 深度学习
  • 机器学习
  • 知识图谱
  • 图神经网络
  • 应用安全
  • 渗透测试
  • Linux
  • 云原生
面试
  • 收藏
  • paper 好句
GitHub (opens new window)

学习笔记

啦啦啦,向太阳~
首页
  • 计算机网络
  • 计算机系统
  • 数据结构与算法
  • 计算机专业课
  • 设计模式
  • 前端 (opens new window)
  • Java 开发
  • Python 开发
  • Golang 开发
  • Git
  • 软件设计与架构
  • 大数据与分布式系统
  • 常见开发工具

    • Nginx
  • 爬虫
  • Python 数据分析
  • 数据仓库
  • 中间件

    • MySQL
    • Redis
    • Elasticsearch
    • Kafka
  • 深度学习
  • 机器学习
  • 知识图谱
  • 图神经网络
  • 应用安全
  • 渗透测试
  • Linux
  • 云原生
面试
  • 收藏
  • paper 好句
GitHub (opens new window)
  • Java开发

    • My

    • Posts

    • Java SE

    • Java 并发编程

      • 黑马

      • 专栏:Java 并发编程实战

        • 可见性、原子性和有序性问题
          • 1. 可见性、原子性和有序性问题:并发编程 Bug 的源头
            • 1.1 源头一:缓存导致的可见性问题
            • 1.2 源头二:线程切换带来的原子性问题
            • 1.3 源头三:编译优化带来的有序性问题
            • 1.4 总结
          • 2. Java 内存模型
            • 2.1 什么是 Java 内存模型
            • 2.2 使用 volatile 的困惑
            • 2.3 Happens-Before 规则
            • 2.4 被我们忽视的 final
            • 2.4 总结
          • 3. 互斥锁:解决原子性问题
            • 3.1 简易锁模型
            • 3.2 改进后的锁模型
            • 3.3 Java 提供的锁技术:synchronized
            • 3.4 使用 synchronized 解决 count += 1
            • 3.5 锁和受保护资源的关系
            • 3.6 用一把锁保护多个资源
    • JVM

    • JDBC

    • Java Web

    • Spring 与 SpringMVC

    • Spring Boot

    • Spring Cloud

    • Spring Security

    • Netty

    • MyBatis

  • Python开发

  • Golang开发

  • Git

  • 软件设计与架构

  • 大数据与分布式系统

  • 区块链

  • Nginx

  • 开发
  • Java开发
  • Java 并发编程
  • 专栏:Java 并发编程实战
yubin
2023-12-16
目录

可见性、原子性和有序性问题

# 1. 可见性、原子性和有序性问题:并发编程 Bug 的源头

这一节介绍并发编程大部分 Bug 的源头。

计算机的发展中,有一个核心矛盾:CPU、内存、IO 设备的速度差异。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

而并发程序的很多诡异问题就根源于这里。

# 1.1 源头一:缓存导致的可见性问题

单核时代,所有线程都跑在一颗 CPU 上,因为所有线程都操作同一个 CPU 缓存,所以 CPU 缓存与内存的数据一致性容易解决,如下图:

20231216155812

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

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了:

20231216155915

这时,线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。

# 1.2 源头二:线程切换带来的原子性问题

操作系统对所有进程的调度是分时调度,也就是允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为时间片。

20231216160146

例如一个进程在进行一个 IO 操作时,就可以把自己标为“休眠状态”并让出 CPU 使用权。

Java 并发是基于多线程的,这些线程也受到操作系统的调度。现代 Java 语言的一行代码会被编译为多条 CPU 指令完成,而操作系统的任务切换可能发生在任何一条 CPU 指令执行完时,这导致一行代码的执行被切分开执行了。

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

比如,Java 的 long 类型是 64 位的,当运行在 32 位的机器上时,long 类型变量的加减操作都会被转换成多个指令执行,这就有可能导致并发问题的出现。

# 1.3 源头三:编译优化带来的有序性问题

有序性是指程序按照代码的先后顺序执行。

编译器为了优化性能,有时会调整语句的执行顺序,但这可能导致并发时出现意想不到的 Bug。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,代码如下:

public class Singleton {

  static Singleton instance;

  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这段代码看上去没问题。但这个 getInstance() 方法并不完美,问题出在 new 操作上,我们以为的 new 操作是:

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

但实际上优化后的执行路径却可能是:

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

这会导致什么问题呢?们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

# 1.4 总结

并发程序经常出现的诡异问题看上去非常无厘头,但是深究的话,无外乎就是直觉欺骗了我们,只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

# 2. Java 内存模型

上一节说到,在并发编程中,因可见性、原子性、有序性导致的问题常常会违背我们的直觉,从而成为并发编程的 Bug 之源。

Java 在诞生之初就支持多线程,并有针对这三者的技术方案。这一节介绍如何解决可见性和有序性导致的问题,也就是今天的主角 —— Java 内存模型。

# 2.1 什么是 Java 内存模型

导致可见性的原因是缓存,导致有序性的原因是编译优化,那合理的解决方案就是:按需禁用缓存以及编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法,这些方法包括:volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也正是本期的重点内容。

# 2.2 使用 volatile 的困惑

volatile 在 C 语言中就已存在,它最原始的意义就是禁用 CPU 缓存。

例如,我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 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 会是多少呢?
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

直觉上看,应该是 42,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 42。

我们可以猜到是因为变量 x 被 CPU 缓存而导致的可见性问题。我们只是禁用了 boolean v 变量的缓存优化,但 x 仍然面临可见性的问题。直到 Java 1.5 才解决了这个问题。因为 Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。

# 2.3 Happens-Before 规则

Happens-Before 真正要表达的意思是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。

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

Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的。下面分别介绍。

# 1)程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。简单说就是,程序前面对某个变量的修改一定是对后续操作可见的。

# 2)volatile 变量规则

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

这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。

# 3)传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

我们将规则 3 的传递性应用到我们的例子中,会发生什么呢?可以看下面这幅图

20231216210912

如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42”。这就是 Java 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的。

# 4)管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,Java 中的 synchronized 就是对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) { // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此处自动解锁
1
2
3
4
5
6

所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

# 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();
1
2
3
4
5
6
7
8
9

# 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
1
2
3
4
5
6
7
8
9
10
11
12

这里只讲了 6 个 happens-before 规则,其实还有两个:

  • 线程中断规则:对线程 interrupt 方法的调用先行发生于被中断线程的代码检测到中断事件的发生。【先中断,后检测】
  • 对象终结规则:一个对象的初始化完成先行发生于它的 finalize 方法的开始。

# 2.4 被我们忽视的 final

volatile 为的是禁用缓存和编译优化,而另一个关键字 final 修饰变量时,初衷就是告诉编译器:这个变量生而不变,可以可劲儿优化。

Java 编译器在 1.5 以前的版本的确优化得很努力,以至于都优化错了。导致的问题类似于之前双重检查锁创建单例时的问题,错误的指令重排可能会导致线程看到 final 变量的值发生变化。在 Java 1.5 以后,内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。

“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免 this 逸出:

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

# 2.4 总结

Java 的内存模型是并发编程领域的一次重要创新,其中最晦涩的部分就是 Happens-Before 规则了。Happens-Before 规则最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System 的论文中提出来的,在这篇论文中,Happens-Before 的语义是一种因果关系。在 Java 语言里面,Happens-Before 的语义本质上是一种可见性。

Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。

# 3. 互斥锁:解决原子性问题

之前说过,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。

在单核 CPU 环境下,禁止 CPU 中断可以禁止线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

但在多核 CPU 环境下,不同线程可能在不同 CPU 上运行,所以即使禁止 CPU 中断也不能保证对一个变量的访问在同一时刻只有一个线程在执行。

“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

# 3.1 简易锁模型

谈到互斥,就能想到一个杀手级的解决方案:锁。

很容易想到下面这个简易锁模型:

20231216215024

我们把一段需要互斥执行的代码称为临界区。

# 3.2 改进后的锁模型

上图没有体现一个重要关系:锁和要保护的资源的关联关系。你不能拿别人家的锁来保护你们家的东西。

改进后的锁模型如下:

20231216215232

这种关联关系特别重要。“自家门来保护他家资产”的 bug 很难诊断。

# 3.3 Java 提供的锁技术:synchronized

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。

synchronized 可以修饰方法,也可以修饰代码块,示例:

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }

  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }

  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
} 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

编译器会在 synchronized 修饰的方法或代码块前后自动加锁 lock() 和解锁 unlock()。

注意,当 synchronized 修饰方法的时候,锁定的是什么呢?这是 Java 的一条隐式规则:

  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this

# 3.4 使用 synchronized 解决 count += 1

我们知道,对于 long count 的类型来说,多个线程同时对其调用 count += 1 会出现并发问题,导致最后的结果不可预测,现在尝试使用 synchronized 来解决一下这个问题。

看一下下面这个写法:

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
1
2
3
4
5
6
7
8
9

根据管程中的锁规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁因此每一次进入临界区修改 value 的结果对于其他线程再次进入临界区都是可见的,因此最终多次累加后的结果一定是正确的。

但 get() 方法还存在问题。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是 get() 方法也 synchronized 一下,完整的代码如下所示:

class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}
1
2
3
4
5
6
7
8
9

上面的代码转换为我们提到的锁模型,就是下面图示这个样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的:

20231217111728

# 3.5 锁和受保护资源的关系

受保护资源和锁之间的关联关系非常重要,一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。也就是可以拿一把锁来保护多个对象,但最好不要用多把锁来保护一个对象,否则容易出问题。

比如我们把之前 count +=1 的例子稍微改动一下:

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}
1
2
3
4
5
6
7
8
9

观察一下就可以发现,改动后的代码其实是在用两个锁(this 和 SafeCalc.class)来保护同一个 value,用图来描述这个关系如下:

20231217112901

由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

# 3.6 用一把锁保护多个资源

这一小节看一下转账业务,假设每个人的账户都是一个 Account 对象,其中有一个属性 balance 记录余额,这样每次转账都涉及到两个 account 对象的改变,对于有关联关系的多个资源,我们需要使用同一个把锁来保护。

这里最简单的一种方式就是对 Account.class 进行加一把大锁,这是所有 account 都能操作的锁。于是,代码如下:

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}
1
2
3
4
5
6
7
8
9
10
11
12

下面这幅图很直观地展示了我们是如何使用共享的锁 Account.class 来保护不同对象的临界区的:

20231217115355

通过以上示例,我们可以更加理解“原子性”。原子性的本质就是:多个资源间有一致性的要求,操作的中间状态对外不可见。所以,解决原子性问题,是要保证中间状态对外不可见。

编辑 (opens new window)
上次更新: 2023/12/17, 03:56:18
ReentrantLock 实现原理
Java 代码的运行和 Java 的基本类型

← ReentrantLock 实现原理 Java 代码的运行和 Java 的基本类型→

最近更新
01
Deep Reinforcement Learning
10-03
02
误删数据后怎么办
04-06
03
MySQL 一主多从
03-22
更多文章>
Theme by Vdoing | Copyright © 2021-2024 yubincloud | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
×