浅谈 Java 多线程(一) --- JMM

为什么使用多线程

  1. 更多的处理器核心数(硬件的发展使 CPU 趋向于更多的核心数,如果不能充分利用,就无法显著提升程序的效率)
  2. 更快的响应时间(复杂的业务场景下,会存在许多数据一致性不强的操作,如果将这些操作并行执行,则响应时间会大大缩短)
  3. 更好的编程模型(Java 为多线程编程提供了良好的编程模型及众多工具,使用起来非常方便)

并发编程的挑战

  1. 线程的创建和销毁需要开销(可以通过线程池解决)
  2. 更多的线程意味着更多的上下文切换,有些情况下速度可能不如单线程
    1. 无锁并发编程,多线程竞争锁时会引起上下文切换
    2. 乐观锁:使用 CAS 算法更新数据,而不需要锁
    3. 使用最少线程:当任务很少却创建大量的线程来处理的时候,很多线程是处于等待状态的。这部分线程也会引起上下文切换
    4. 协程:在单线程中实现多任务的调度
  3. 死锁
    1. 避免一个线程同时获取多个锁
    2. 避免一个线程在锁内同时占用多个资源
    3. 尝试使用其他锁,如 lock.tryLock(timeout)来替代使用内部锁机制
    4. 对于数据库锁,加锁和解锁必须在同一个事物内
  4. 软硬件的资源限制(例如网络带宽 2mb/s,某个资源下载速度是 1mb/s,启动 10个线程下载不会让下载速度到 10mb/s)
  5. 指令重排序可能会打破程序原本的语义
    1. 编译器和处理器一般情况下仅对满足数据依赖条件的两个操作不做重排序
      1. 数据依赖条件:两个操作访问同一个变量 + 其中一个操作是写操作

Java 内存模型(JMM)

并发编程首先要解决的两个问题是:线程之间的通讯与同步,Java 多线程采用的是共享内存模型来进行线程通讯,在共享内存模型里,同步是显式执行的

Java 内存模型的抽象结构示意图

由上图可以看出,如果两个线程需要通讯的话,需要以下步骤

  1. 线程A把本地内存A里面的共享变量副本刷新到主内存
  2. 线程B读取主内存中已经更新过的共享变量

Volatile 的内存语义

volatile 的特性

  • 可见性:对一个 volatile 变量的读,总是能看到任意线程对该变量最后一次的写入

  • (伪)原子性:对任意单个 volatile 变量的读/写具有原子性,但是类似于 v++ 这样的操作不具备原子性

volatile 读、写的内存语义

  • 线程 A 写一个 volatile 变量,实质上是对接下来要读这个变量的线程发了(其对共享变量所做修改)消息
  • 线程 B 读一个 volatile 变量,实质上是接收之前某个线程发送的(在写这个变量之前对共享变量所做的修改的)消息
  • 线程 A 写,随后线程 B 读,这个过程实质上是线程 A 通过主内存向线程 B 发送消息

Happens-before

JSR-133 对 happens-before 的规则描述

  1. 程序顺序规则:一个线程中,按照程序顺序,前面的操作 happens-before 于后续的任意操作,例如
// A  happens-before B, B happens-before C
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
  1. 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁
// 假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(解锁),接着线程 B 进入代码块(加锁),此时触发了监视器锁规则。因此线程 A 对共享变量的操作能够被 B 所看见,也就是线程 B 能够看到 x == 12
synchronized (this) { // 加锁
  // x 是共享变量,初始值 = 10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 解锁
  1. volatile变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读
// 根据规则 1,可以确定 1 happens-before 2 、3 happens-before 4
// 根据规则 3,可以确定 2 happens-before 3
// 推导到这里,好像感觉没什么用,并不能确定第 4 步输出的 x 值,且继续往下看第 4 条规则
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42; // 1
    v = true; // 2
  }
  public void reader() {
    if (v == true) { // 3
      sout(x); // 4
    }
  }
}
  1. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C
// 有了这条规则,我们可以得出: 1 happens-before 4,那么第 4 步看到的 x 值将是 42。
// 也就是通过 volatile 变量,线程 A 向线程 B 发送了它对变量 x 所做的修改的消息
  1. start() 规则:指线程 A 启动子线程 B 后,子线程 B 能够看到线程 A 在启动子线程 B 前的操作

  2. join() 规则:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回

JSR-133 对 happens-before 的定义

  1. 如果一个操作 happens-before 于另一个操作,那么第一个操作的执行结果对第二个操作可见,并且第一个操作先于第二个操作执行
  2. 两个操作存在 happens-before 关系,并不意味着 Java 一定按照 happens-before 关系指定的顺序执行,只要能保证程序语义不变的重排序也并不非法

定义 1 是 JMM 对程序员的承诺,从程序员的角度来说,如果 A happens-before B,则 A 的操作对 B 可见

定义 2 是对 Java 设计者的约束,对于设计者来说只要不改变程序语义,想怎么优化、想怎么重排序都可以。这么做的目的就是在不改变程序语义的前提下,尽可能高的提高程序的并发度

总结

本篇简要介绍了 Java 内存模型结构、volatile 和 happens-before 的相关概念,volatile 是 Java 中并发容器和原子类实现的基石,理解其内存语义有助于后续理解并发容器和原子类的实现原理;而 happens-before 是 JMM 最核心的概念。对于 Java 程序员来说,理解 happens-before 是理解 JMM 的关键,希望通过此篇可以帮助读者解决 Java 并发编程中遇到的内存可见性问题

posted @ 2022-01-19 12:08  后青春期的Keats  阅读(117)  评论(0编辑  收藏  举报