[12] Java 内存模型与线程
[12] Java 内存模型与线程

1. Java 内存模型

《Java虚拟机规范》中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
该内存模型于 JDK 1.2 之后建立起来并在 JDK 1.5(JSR-133) 中得到完备。
JMM 实则是一组对线程工作内存与 JVM 主内存间的抽象关系,根据 JMM 我们可以:
  • 推测现有代码的执行轨迹,即这段程序可能出现的结果。
  • 验证编译器是否正确且严格遵守内存模型的语义规则。
往常我们在面对多线程同步问题时,困惑不已,就好像量子力学中,电子之于质子的相对位置,你不去观测,你无法确定电子真正的位置,同理你不去真正跑一遍代码,你也无法知晓你的程序是否出现了并发问题。但是有了 JMM 支持就不一样了,他可以帮助我们找到规律,判断程序运行轨迹,推测程序运行可能得到的结果。

JMM 内存模型 和 JVM 内存结构的区别
  • JVM 内存结构:是指 JVM 运行时将数据分区域存储,强调对内存空间的划分。
  • JMM 内存模型:是指线程和主内存之间的抽象关系,即 JMM 中定义了线程在 JVM 主内存中的工作方式,如果要想深入了解 Java 并发编程,就要先理解好 JMM。

1.1 计算机硬件中的缓存一致性

计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。
在多处理器的系统中(或单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为此,需要各个处理器访问缓存时都遵循缓存一致性协议(MSI、MESI、MOSI 等),在读写时要根据协议进行操作,来维护缓存的一致性。
计算机硬件间的内存交互
计算机硬件间的内存交互

1.2 JVM 中的主内存与工作内存

学至此处,我们已经领略到了 JVM 在软件层面上有着和计算机硬件层面上的诸多异曲同工之妙,诸如 CPU 和 JVM 中的程序计数器、JIT 编译器与 CPU 中的指令乱序执行优化等。对于缓存一致性协议,JVM 也根据 JMM 的规则达成了一套线程与自身主内存间的缓存一致性协议。
JMM 的主要目标是定义程序中各个变量的访问规则,即在 JVM 中将变量存储到内存,和从内存中取出变量💡这样的底层细节。
💡
此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段静态字段构成数组对象的元素,但不包括局部变量与方法参数,因为后者是虚拟机栈中线程私有的,不会被共享,自然就不会存在竞争问题。
  • 主内存(Main Memory):类比的物理硬件为主内存,实则是指 JVM 内存的一部分。
  • 工作内存(Working Memory):类比的物理硬件为 CPU 高速缓存,实则是指每条线程都有自己的工作内存,其中保存了被该线程使用到的变量在主内存中的拷贝副本(对于对象实例并不是完全拷贝,而是用到了哪个字段就拷贝哪个)。
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
JVM 线程间的内存交互
JVM 线程间的内存交互
 

1.3 顺序一致性的内存模型

顺序一致性内存模型是可见性有序性强有力的保证,是一个严格的内存模型。假设 JMM 是顺序一致性的,那么许多编译器层面和 CPU 层面的优化将无法开展,虽然顺序一致性虽然能解决很多并发层面的诡异问题,但却束缚了性能优化的施展空间,其并不适合 Java。

1.4 JVM 线程与内存间交互操作

与计算机硬件的缓存一致性一样,JVM 中线程的工作内存作为与 JVM 主内存交互的缓存桥梁,为了解决并发时缓存一致性问题,JMM 中规定了 8 种基本操作来完成主内存与工作内存之间具体的交互协议:
  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于 lock(锁定)状态的变量解锁,解锁后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便之后的 load(载入)操作使用。
  • load(载入):作用于工作内存的变量,它把 read(读取) 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当 JVM 遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存中的变量,每当 JVM 遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便之后的 write(写入)操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作(JMM 只要求上述两个操作必须是顺序执行,但无需连续执行,所以它们之间是可以插入其他指令的)。
notion image
JMM 还规定了在执行 8 种基本操作时必须满足如下 8 种操作规则
  • 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中「诞生」,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说,就是对一个变量实施 use、store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行store、write 操作)。
这 8 种操作和规则在 JSR-133 中并没有被提及,已经放弃采用这 8 种描述去片面地定义 JMM 的访问协议(仅是描述方式改变了),而是由更完备的先行发生原则(Happens-Before)因果关系来进行约束。

1.5 先行发生原则(Happens-Before

先行发生原则(Happens-Before)是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以解决并发环境下两个操作之间是否可能存在冲突的所有问题。
先行发生原则是 JMM 中定义的两项操作之间的偏序关系,例如:操作 A 先行发生于操作 B(即发生在操作 B 之前),则操作 A 产生的影响(包括修改了内存中共享变量的值、发送了消息、调用了方法等)就能被操作B观察到。
// 在线程A中执行
i=1;

// 在线程B中执行
j=i;

// 在线程C中执行
i=2;
假设线程 A 先行发生于线程 B(A、B 有先行发生关系),那么在线程 B 执行后变量 j 的值一定等于 1
假设线程 C 出现在了线程 A 和线程 B 的操作之间(A、B有先行发生关系),但线程 C 与线程 B 又没先行发生关系的情况下,那变量 j 的值是一个不确定的数,线程 B 就存在读取到过期数据的风险,不具备线程安全性。

8 条先行发生原则
  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序(控制流顺序),在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间上)对同一个锁的 lock 操作。
  • Volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的 write 操作先行发生于后面(时间上)对这个变量的 read 操作。
  • 线程启动规则(Thread Start Rule)Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测(可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行)。
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生(可以通过 Thread.interrupted() 方法检测到是否有中断发生)。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  • 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作C,那就可以得出操作 A 先行发生于操作 C 的结论。
JVM 在进行代码优化时需要保证这些先行发生关系,先行发生原则不依赖任何同步器协助就可以在编码中直接利用。如果两个操作之间的关系不在此列,并且无法从这 8 条规则推导出来的话,它们就没有顺序性保障,JVM 可以对它们随意地进行重排序

利用先行发生原则分析程序代码
class MyClass {

    private int value = 0;

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}
这是一组 getter/setter 方法,假设存在线程 A 和 B,线程 A 先调用了(时间上的先后)setValue(1) 方法,然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?利用先行发生原则进行分析:
  1. 两个方法分别由线程 A 和线程 B 调用,不在同一线程中,所以程序次序规则在这里不适用。
  1. 由于没有同步块(synchronizedjava.util.concurrent.ReentrantLock),自然不会发生 lock 和 unlock 操作,所以管程锁定规则不适用。
  1. 由于 value 变量没有被 volatile 关键字修饰,所以 Volatile 变量规则不适用。
  1. 线程启动、终止、中断规则对象终结规则也和这里完全没有关系。
因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程 A 在操作时间上先于线程 B,但是无法确定线程 B 中 getValue() 方法的返回结果,换句话说,这里面的操作不是线程安全的。
修复这个问题:
  • 要么把 getter()setter() 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则来满足先行发生关系。
  • 要么把 value 定义为 volatile 变量,由于 setter() 方法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 Volatile 变量规则来满足先行发生关系。

final 字段的先行发生原则
对于被 final 关键字修饰的字段,其引用的对象实例在构造完全之前,是不会暴露给其他线程使用的,这就是 final 字段的「安全发布」语义。
  • 构造一个含有 final 字段的对象,在构造器结束之前(构建完成前),final 字段的写入和冻结操作都会被执行完成且对其他线程完全可见。
  • 若含有 final 字段 a 指向对象 X,对象 X 含有 final 字段 b 指向对象 Y,这是一个引用链,而其中 a 字段的冻结操作会先于 b 字段执行。
  • 反序列化的场景下,一个对象的 final 字段会在该对象构建完成后改变。

1.6 因果关系(Causality)

先行发生原则仅仅是 JMM 的一个必要非充分的规则约束集,早期的 JMM 规范在不引入因果关系(Causality)这一条件约束的情况下,会出现一个看似更加诡异的问题——无中生有(out of thin air)。

场景 A
int x = 0, y = 0;

// 线程1 执行 foo 方法
void foo(){
  r1 = x;
  y=r1;
}

// 线程2 执行 bar 方法
void bar(){
  r2 = y;
  x=r2;
}

// 有可能出现 r1 == r2 == 42
场景A
场景 A 如果以顺序一致性方式执行,是完全不会有任何同步问题的,但 JMM 并没有选择顺序一致性。而单纯地根据先行发生原则约束来看,这里首先不构成任何先行发生关系,其次由于 xy 变量均在多线程环境下读写,这里也必然存在访问冲突,两个条件综合来看,xy 势必出现数据竞争。

为什么值有可能为 42
However, in a future aggressive system, Thread 1 could speculatively write the value 42 to y, which would allow Thread 2 to read 42 for y and write it out to x, which would allow Thread 1 to read 42 for x, and justify its original speculative write of 42 for y.
x 依赖 y 的值,y 也依赖 x 的值,构成了循环依赖,成为了因果循环(causal cycle)的场景。
当一个相当激进的编译器遇到这样的场景,可能就会做出不可思议的优化手段,它有可能推测 y 为任何值,当然可能就是 42,因为无论什么值,这个循环场景都能够自圆其说。
在这种自圆其说(self-justifying)的场景下,配合这套先行发生原则来约束,就会出现严重问题,说到底还是先行发生原则太弱,除非刻意给变量加上 volatile 关键字,进而构成先行发生关系,但是使用 volatile 是有代价的,似乎没必要,我们只是需要采取某种措施防止编译器过于激进的优化即可。

场景 B
int x = 0, y = 0;

// 线程1 执行 foo 方法
void foo(){
  r1 = x;
  if(r1 != 0)
    y = 42;
}

// 线程2 执行 bar 方法
void bar(){
  r2 = y;
  if(r2 != 0)
     x = 42;
}

// 有可能出现 r1 == r2 == 42
场景B
场景 B 同样不构成任何先行发生关系,也没有额外的同步动作,却可能出现直接把 xy 的写入操作越过条件语句,不分青红皂白地提前执行。
但场景 B 更令人费解的点在于,按照逻辑来看,由于 r1r2 均为 0,应该不存在任何 x,y 的写入操作,也就不会出现数据竞争,原则上不需要任何同步手段也不会有同步问题。而事实上,上面两个场景恰恰说明,单凭这套先行发生原则的约束也没办法保证场景 B 正确执行。

庆幸的是,无论场景 A 还是场景 B ,在 JMM 里是绝对禁止的,而先行发生原则又无法对这两个场景做出约束,JMM 就借助了因果关系(Causality)来保证。
简单说,因果关系核心在于对数据流(数据赋值关系)和控制流(条件控制语句,如 if)的依赖关系的分析,比如场景 B,xy 的赋值操作前提是 r1r2 不为 0,这种就是基于控制流的因果关系分析。
那么既然场景 B 出现了 r1 == r2 == 42 的情况,就说明编译器破坏了这层因果关系,这显然不能被 JMM 所接受。基于因果关系,场景 A 也不会凭空推测出 42,因为程序中并没有 42 写入的行为。

因果关系的破坏
int a = 0, b = 1;

// 线程 1 执行 foo
void foo(){
  r1 = a;
  r2 = a;
  if(r1 == r2)
    b = 2;
} 

// 线程 2 执行 bar
void bar(){
  r3 = b;
  a = r3;
}

// 有可能出现 r1 == r2 == r3 == 2
场景 C
场景 C 有可能出现 r1 == r2 == r3 == 2 的结果是被「允许」的。基于编译器优化策略:
// 线程 1 执行 foo
void foo(){
  b = 2;
  r1 = a;
  r2 = r1; 
}

// 线程 2 执行 bar
void bar(){
  r3 = b;
  a = r3;
}
场景 C 被编译器优化后的代码
  • foo() 方法中变量 a 重复读被清理,r2 = ar2 = r1 取代;
  • if(r1 == r2) 必然为 true,那么条件判断多余,可以被清理;
  • b = 2 此时就有可能被重排序到 foo() 方法的开始位置。
这就是基于控制流依赖的因果关系被打破的典型例子,从 foo() 方法执行语句来看,重复读消除是合理的,避免读到不同的值,这直接导致 if 判断确实无论什么情况下都是 true,这样一来,这一层因果关系被打破了,不需要因为满足 if 条件 b = 2 才会被执行,这种打破是由开发者的编码逻辑决定的,合情合理。反观场景 B 才是由编译器自身激进优化的问题才被因果关系「拦下」的。

2. 并发三大特征

JMM 就是是围绕着在并发过程中如何处理原子性可见性有序性这 3 个特征来建立的。

2.1 原子性(Atomicity)

原子性(Atomicity)操作是指不可再被划分(不会被分不同时间片中)的操作指令,JMM 要求在没有任何的同步手段的前提下,Java 基本数据类型变量的读写必须具备原子性。
但是允许了两个特例存在,非 volatile 修饰的 64位 doublelong 类型,这就是它们的非原子性协定
不同版本的 Java 可能有不同的处理方式,一般对 doublelong 的处理方式是,将一个 64 位拆分成两个 32 位分别原子性读写,这样一来多线程环境下就有问题了,很可能高 32 位和低 32 位分别被两个不同线程读写,可能出现读取到「半个变量」的诡异问题。
「这种情况非常罕见,在目前商用 JVM 中不会出现,虽然 JMM 有非原子性协定,但还是强烈建议 JVM 将 doublelong 类型的读写操作实现为原子操作。目前各种平台下的商用 JVM 几乎都选择把 64 位数据的读写操作作为原子操作来对待。读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况。」——《深入理解 Java 虚拟机》

Java 实现原子性
  • synchronized 关键字
  • sun.misc.Unsafe 类中的一系列 compareAndSwap() 方法

synchronized 关键字实现原子性
JMM 提供了 lock 和 unlock 操作来满足这种需求,尽管 JVM 未把 lock 和 unlock 操作直接开放给用户使用,但是却依托于管程(monitor)提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字。
Java 中每个对象实例都关联着一个 monitor,这个 monitor 维护着一个计数器,monitorenter 加锁时计数器值 + 1,monitorexit 解锁时 - 1,当计数器为 0 表示没有任何线程占用该锁。计数器的数值表示该锁被加锁的次数,这是为了实现可以被同个线程多次加锁,即可重入特性,所以 synchronized 是不会把自己死锁的,因此在 synchronized 块之间的操作也具备原子性。

compareAndSwap*() 方法实现原子性
sun.misc.Unsafe 类中的一系列 compareAndSwap*() 方法也可以实现原子性操作,这些都是 native 方法,会调用 CPU 提供的原子汇编指令来实现。

2.2 可见性(Visibility)

可见性(Visibility)是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM 通过在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,反映到 Java 代码中就是 volatile 关键字。

Java 实现可见性
  • volatile 关键字
  • synchronized 关键字
  • final 关键字

volatile 关键字实现可见性
volatile 关键字的语义:
  • 保证此变量对所有线程的可见性:当一条线程修改了这个变量的值会立刻回写到主内存,新值对于其他线程来说是可以立即得知的,所有写操作都能立刻反应到其他线程中。而普通变量被修改后,什么时候被回写到主内存是不定的,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。
  • 禁止此变量赋值操作指令重排序优化:普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,这就是 Java 程序中线程内表现为串行的语义(Within-Thread As-If-Serial Semantics),但普通的变量并不能保证赋值操作的顺序与程序代码中的执行顺序一致,所以在当前线程里看程序代码像是有序的,但在其他线程里看来实则是无序的。
volatile 关键字可以说是 JVM 提供的最轻量级的控制并发机制,下面这类场景就很适合使用 volatile 关键字来控制并发,当 shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都能停止下来。
volatile boolean shutdownRequested;

public void shutdown() {
    shutdownRequested = true;
}

public void doWork() {
    while (!shutdownRequested) {
        // do something...
    }
}
volatile 使用场景
volatile 仅仅只能够保证变量的可见性,在不符合以下两条规则的运算场景中,仍需要通过加锁来保证原子性:
  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。
JMM 对 volatile 关键字专门定义了一些特殊的访问规则:
  • 要求在工作内存中,每次使用变量前都必须先从主内存刷新最新的值(固定的 load -> use 顺序),用于保证能看见其他线程对变量所做的修改后的值。
  • 要求在工作内存中,每次修改变量后都必须立刻同步回主内存中(固定的 assign -> store 顺序),用于保证其他线程可以看到当前线程对变量所做的修改。

synchronized 同步块中,对一个变量执行 unlock 操作前,必须先把该变量同步回主内存中(执行 store、write 操作),所以 synchronized 关键字也保证了该变量的可见性。
final 关键字修饰的字段在构造器中一旦初始化完成,并且构造器没有把 this 的引用传递出去,那再其他线程中就能看见 final 字段的值,且 final 字段的值不能再被修改,也算保证了该变量的可见性。
this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到「初始化了一半」的对象,例如《Java 并发编程实践》中的例子:
public class ThisEscape {
  public ThisEscape(EventSource source) {
    source.registerListener(new EventListener() {
      public void onEvent(Event e) {
        doSomething(e);
      }
    });
  }
 
  void doSomething(Event e) {
  }
 
  interface EventSource {
    void registerListener(EventListener e);
  }
 
  interface EventListener {
    void onEvent(Event e);
  }
 
  interface Event {
  }
}
this 引用逃逸
这将导致 this 引用逸出,所谓逸出,就是在不该发布的时候发布了一个引用。在这个例子里面,当我们实例化 ThisEscape 对象时,会调用 sourceregisterListener() 方法,这时便启动了一个线程,而且这个线程持有了 ThisEscape 对象(调用了对象的 doSomething() 方法),但此时 ThisEscape 对象却没有实例化完成(还没有返回一个引用),造成了 this 引用逸出,即还没有完成的实例化 ThisEscape 对象的动作,却已经暴露了对象的引用。其他线程访问还没有构造好的对象,可能会造成意料不到的问题。只有当构造函数返回时,this 引用才应该从线程中逸出。构造函数可以将 this 引用保存到某个地方,只要其他线程不会在构造函数完成之前使用它。

2.3 有序性(Ordering)

有序性(Ordering)volatile 关键字的禁止指令重排序语义中是也有提到,编译器和 CPU 会进行代码优化,打乱原本的代码顺序,进行一些执行预测分析等操作,三种重排序类型:
  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代 CPU 采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,CPU 可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于 CPU 使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

Java 实现有序性
在 Java 程序中就天然存在的有序性:
  • 线程内表现为串行的语义:如果在本线程内观察,所有的操作都是有序的。
  • 指令重排序和工作内存与主内存同步延迟现象:如果在一个线程中观察另一个线程,所有的操作都是无序的。
Java 语言也提供了 volatilesynchronized 两个关键字来保证线程间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而被 synchronized 关键字锁住的变量在同一时刻只允许一条线程对其进行 lock 操作,这决定了持有同一个锁的两个同步块只能串行地进入,所以也保证了有序性。

3. Java 与线程

3.1 线程的实现

  • 使用内核线程(Kernel-Level Thread,KLT)实现:一对一模型,使用内核线程的高级接口:轻量级进程(Light Weight Process,LWP)来实现。
    • 优点:每个轻量级进程都是独立的调度单元,能真正实现并发运行,即使一个阻塞,也不会影响整个进程,
    • 缺点:各种线程操作都需要进行系统调用,来回切换用户态(User Mode)和内核态(Kernel Mode)开销大,每个轻量级进程都需要一个内核线程支持,消耗一定内核资源(如内核线程栈空间),因此系统支持线程数量有限。移植性弱。
  • 使用用户线程(User Thread,UT)实现:一对多模型,一个内核线程对应多个用户线程,内核无法调度这些线程,而由程序自发决定创建、终止、切换等操作。已经很少采用,Java、Ruby 等语言曾经使用过。
    • 优点:用户线程建立在用户空间的线程库上,不需要切换内核态,低消耗,因此系统支持规模更大的线程数量。移植性强。
    • 缺点:没有系统内核支持,所有操作包括调度都需要用户程序自己处理,实现复杂。
  • 使用用户线程加轻量级进程(两级线程)混合实现:多对多模型,既存在用户线程也存在轻量级进程。
    • 优点:集合前二者优势,用户线程还是完全建立在用户空间中,因此线程的创建、切换、析构等操作依然廉价。支持大规模用户线程并发。轻量级进程作为用户线程和内核线程之间的桥梁。

3.2 Java 线程的实现

在 JDK 1.2 之前基于称为「绿色线程(Green Threads)」的用户线程实现。而在 JDK 1.2 中线程模型替换为基于操作系统原生线程模型实现,JVM 只管将 Java 线程映射到操作系统线程中,所以 Thread 类中主要对线程操作的方法都是被 native 关键字修饰的原生本地方法,这代表无法使用「平台无关」的方法实现,但往往这样的本地方法才是效率最高的。

HotSpot VM 中
在 HotSpot VM 的实现中,每个 Java 线程都与操作系统的本地线程直接映射。当一个 Java 线程准备好执行后,操作系统的本地线程也同时创建;Java 线程执行终止后,本地线程也会被操作系统回收。

3.3 Java 线程调度

线程调度(Thread Scheduling)是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)抢占式线程调度(Preemptive Threads-Scheduling)

协同式线程调度
线程执行时间由线程本身控制,线程完成工作后要主动通知系统切换到另一个线程。
  • 优点:实现简单,把自己线程的事干完才会进行线程切换,切换操作对自己线程时可知的,没有线程同步问题。
  • 缺点:线程执行时间不可控,编码有问题(不告知系统进行线程切换)会导致程序阻塞。

抢占式线程调度
每个线程由系统分配执行时间,线程的切换不由线程本身决定(Java 中仅能通过 Thread.yield() 让出执行时间)。
  • 优点:线程执行时间可控,不会因为一个线程导致整个程序阻塞。
  • 缺点:实现不简单,有线程同步问题,对于共享数据会出现数据竞争的可能。
Java 使用的线程调度方式就是抢占式线程调度

线程优先级设置
只是建议系统给某些线程多或少分配一点执行时间,并不靠谱。各平台实现不一,操作系统自身也有完备的线程调度器,并一定能起到多少作用,略。

3.4 Java 线程状态转换

Java 线程状态转换关系
Java 线程状态转换关系
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这 5 种状态分别如下:

新建(New)
创建后尚未启动的线程处于这种状态。

运行(Runnable)
Runnable 包括了操作系统线程状态中的 Running 和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着 CPU 为它分配执行时间。

无限期等待(Waiting)
处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:
  • 没有设置 timeout 参数的 Object.wait() 方法。
  • 没有设置 timeout 参数的 Thread.join() 方法。
  • LockSupport.park() 方法。

限期等待(Timed Waiting)
处于这种状态的线程也不会被分配 CPU 执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
  • Thread.sleep() 方法。
  • 设置了 timeout 参数的 Object.wait() 方法。
  • 设置了 timeout 参数的 Thread.join() 方法。
  • LockSupport.parkNanos() 方法。
  • LockSupport.parkUntil() 方法。

阻塞(Blocked)
线程被阻塞了,阻塞状态等待状态的区别是:阻塞状态在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而等待状态则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

结束(Terminated)
已终止线程的线程状态,线程已经结束执行。

3.5 JVM 系统线程分类

如果你使用 jConsole 或者任何一个调试工具,都能看到在后台有许多线程在运行。这些后台线程不包括调用 main() 方法的 main 线程以及所有这个 main 线程自己创建的线程。
这些 JVM 后台系统线程在 HotSpot VM 里主要是以下几种:
  • 虚拟机线程:这种线程需要在 JVM 达到安全点才会出现,原因是达到安全点时堆才不会变化。这种线程的执行包括 STW 的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
  • 周期任务线程:这种线程是时间周期事件的体现(比如中断),一般用于周期性操作的调度执行。
  • GC 线程:这种线程对于 JVM 里不同种类的垃圾收集行为提供了支持。
  • 编译线程:这种线程在运行时会通过 JIT 编译器将字节码编译为本地代码。
  • 信号调度线程:这种线程接收信号并发送给 JVM,在其内部通过调用适当的方法进行处理。

4. Java 与协程

在 JDK 后续版本中有可能会提供协程(Coroutines)方式来进行多任务处理.