<Java多线程编程实践指南-核心篇>读书笔记
Contents
用户线程VS守护线程
用户线程
它会阻止 Java 虚拟机的正常停止, 即一个 Java 虚拟机只有在其所有用户线程都运行结束的情况下才能正常停止
正常停止
指不是通过 System.exit
调用, 也不是通过强制终止进程(如Linux中使用 kill 命令停止Java进程)实现的Java虚拟机停止.
本人在Mac上测试, 即使是普通的 kill 也不是正常的停止.(无论是否是 kill PID , 更不用说是 kill -9 PID 了).
测试代码
public class TestKill {
public static void main(String[] args) {
System.out.println(ManagementFactory.getRuntimeMXBean().getName());
int times = 40;
while (times-- > 0) {
try {
Thread.currentThread().sleep(3 * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("exit");
}
}
第一行打印出的是PID@hostname, 这时直接 kill PID
, 可以看到整个JVM都退出了.
守护线程
即应用程序中有守护线程在运行, 也不影响 Java 虚拟机的正常停止. 因此, 守护线程通常用于执行一些重要性不是很高的任务, 例如监视其他线程的运行情况
默认情况
一个线程是否是一个守护线程默认取决于父线程: 默认情况下, 父线程是守护线程, 则子线程也是守护线程; 父线程是用户线程, 则子线程也是用户线程. 默认情况下的优先级, 也与父线程的优先级相同.
父子线程的生命周期
Java 平台中并没有API获取一个线程的父线程, 或获取一个线程的所有子线程. 并且, 父线程与子线程的生命周期, 也没有必然的联系. (即是相互独立的).
竞态
是指计算的正确性依赖于相对相间顺序或线程的交错.
即竞态的结果可能是时而正确, 时而错误.
竞态的模式
read-modify-write 读-改-写
例如
i++
check-then-act 检测后行动
- 读取某个共享变量的值
- 根据该变量的值决定下一步的动作是什么
线程安全
一般而言, 如果一个类在单线程环境下能够动作正常, 并且在多线程环境下, 在其使用方不必为其做任何改变的情况下也能运作正常, 那么我们就称其是线程安全的. 反之就是线程不安全的.
在多线程环境中, 使用一个类时, 必须先弄清楚这个类是否是线程安全的.
线程安全的三个方面
原子性
即该操作是不可分割的.
所为的不可分割, 是指: 访问(读, 写) 某个共享变量的操作从其执行线程以外的任何线程来看, 该操作要么已经执行完, 要么尚未发生, 即其他线程不会”看到”该操作执行了部分的中间结果.
- 原子操作是针对访问共享变量的操作而言的. 仅是局部变量的话, 无所谓的原子操作.
- 这是从该操作的执行线程以外的任何线程来看的, 即其他线程不会看到该操作执行了部分的中间结果.
实现原子性
- 使用锁.(这通常是在软件层)
- 使用 CAS.(这通常是在硬件层)
Java 语言中, long 和 double 以外的任何类型变量的 写
操作都是原子操作. 即除了 long 和 double 外的基础类型变量和引用型变量的写操作, 都是原子的.
但可以通过添加 volatile
关键字来保障 long 和 double 类型的变量的 写
操作是原子的.
可见性
如果一个线程对某个共享变量进行更新之后, 后续访问该变量的线程可以读取到该更新的结果, 那么我们就称这个线程对该共享变量的更新对其他线程可见, 否则就是对其他线程不可见.
原理:
它是通过使更新共享变量的处理器执行冲刷处理器缓存的动作, 并使读取共享变量的处理器执行刷新缓存的动作来实现的.
实现可见性
使用关键字 volatile
读取一个 volatile
关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作, 写一个 volatile
关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作, 从而保障了可见性.
可见性是多线程的问题, 与目标环境是单核还是多核处理器无关.
线程的启动与可见性
JLS规定
父线程在启动之线程(即调用子线程的
start()
方法之前
, 对共享变量的更新对子线程来说是可见的.)一个线程终止后该线程对共享变量的更新对于调用线程的
join
方法的线程是可见的.
原子性与可见性
从保障线程安全的角度来看, 光保障原子性是不够的, 有时还要同时保障可见性. 它们同时才能保障一个线程能够 “正确” 地看到其他线程对共享变量所做的更新.
有序性
volatile
和 synchronized
关键字都可以实现有序性(逻辑上有序, 物理上不一定)
上下文切换
一个线程被暂停, 即被剥夺处理器的使用权, 另一个线程被选中开始或者继续运行的过程, 就叫作线程上下文切换.
被剥夺的线程, 被称为切出. (switch out) 被选中的线程, 被称为切入. (switch in)
自发性上下文切换
- Thread.sleep(long mills)
- Object.wait()
- Thread.yield()
- Thread.join()
- LockSupport.park()
- IO 操作
- 等待其他线程持有的
锁
非自发性上下文切换
由线程调度器的原因被迫调出.
例如, 线程的时间片用完, 或一个比被切出线程优先级更高的线程需要被运行. 再如GC
开销
- OS保存和恢复上下文的开销(主要是CPU时间)
线程调度器调度的开销
处理器高速缓存加载的开销
可能导致整个一级高速缓存中的内容被冲刷
查看
linux 中可以使用 perf
命令来监视Java程序运行过程中的上下文切换的次数和频率.
书中给出的示例
perf stat -e cpu-clock, task-clock,cs,cache-references,cache-misses java your-main-class parameter
锁
- 内部锁: synchronized 关键字(非公平锁)(由JVM自动获取和释放锁)
- 显式锁: java.concurrent.locks.Lock 接口的实现类(支持公平锁, 也支持非公平锁)(人工显式获取和释放器)
说明
锁的作用包括保障 保障原子性
(通过互斥) , 保障可见性
(隐含着刷新处理器缓存的动作), 保障有序性
(同一时间, 只能一条线程能执行临界区的代码)
前提: 访问同一组共享数据的多个线程必须同步在同一锁实例上.
可重入锁
如果一个线程持有一个锁的时候还能够继续成功申请该锁, 那么我们就称该锁是可重入的.(Reentrant), 否则就是非可重入的.
内部锁
Java平台中的任何一个对象, 都有唯一一个与之关联的锁. 这种锁被称为监视器(monitor) 或 内部锁(Intrinsic Lock)
内部锁的调度
Java 虚拟机会为每个内部锁分配一个入口集(Entry Set), 用于记录等待获得相应内部锁的线程. 多个线程申请同一个锁的时候, 只有一个申请者能够成为该锁的持有线程, 其他申请者则会申请失败. 这些申请失败的线程并不会抛出异常, 而是会被暂停(BLOCKED)并被存入相应锁的入口集中等待再次申请锁的机会.
注意, 内部锁是非公平调度的.
显式锁
java.concurrent.locks.Lock
接口的实现类.
一般的使用范式
private final Lock loc = ....;
try {
lock.lock()
...
} finally {
lock.unlock()
}
显式锁的调度
ReentrantLock
既支持公平锁, 也支持非公平锁.(构造函数有个参数 boolean fair)
公平锁保障锁的公平性往往是增加了线程的暂停与唤醒的可能性, 即增加了上下文切换为代价的.
一般来说, 公平锁适合于锁被持有的时候相对较长或者线程申请锁的平均间隔相对较长的情况.
通常, 公平锁的开销比非公平锁的开销要大, 因此显式锁默认使用的是非公平调试策略.
锁的适用场景
- check-then-act 操作: 一个线程读取共享数据并在此基础上决定下一个操作是什么
- read-modify-write 操作: 一个线程读取共享数据并在此基础上更新该数据.
- 多个线程对多个共享数据进行更新: 如果这些共享数据之间存在关联关系, 那么为了保障操作的原子性我们可以考虑使用锁.
线程同步底层助手: 内存屏障
按可见性
- 加载屏障(Load Barrier) : 刷新处理器缓存; Java 虚拟机会在 MonitorEnter(申请锁)对应的机器码指令之后临界区开始之前, 插入一个加载屏障, 这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中.
- 存储屏障(Store Barrier): 冲刷处理器缓存; Java 虚拟机在 MonitorExit(释放锁)对应的机器码指令之后, 插入一个存储屏障, 这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的.
因此, 可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的.
按有序性
- 获取屏障(Acquire Barrier) : 在一个读操作之后, 插入该内存屏障, 作用是禁止该读操作与其后的任何读写操作之间进行重排序.(MonitorEnter)
- 释放屏障(Release Barrier) : 在一个写操作之前, 插入该内存屏障, 其作用是禁止该写操作与其前面的任何读写操作之间进行重排序.(MonitorExit)
重排序规则
- 临界区内的操作不允许被重排序到临界区之外(前或后)
- 临界区内的操作之间允许被重排序
- 临界区外(前或后)的操作之间, 可以被重排序
- 锁申请与锁释放操作不能被重排序
- 两个锁申请操作不能被重排序
- 两个锁释放操作不能被重排序
- 临界区外(前或后)的操作可以重排序到临界区内
volatile
它保证可见性和有序性, 原子性方面只保障读写本身的原子性, 但没有锁的排他性. 它的使用不会引起上下文切换(所以称为轻量级锁)
但不保障对 volatile 变量操作一定具有原子性. 例如 volatile 修饰的变量 counter:
counter = counter + 1
用 volatile 修饰并不能保障这是原子性的.
volatile 与 数组和引用型变量
注意, volatile 关键字只能够对数组引用本身的操作起作用, 而无法对数组元素的操作起操作.
如果要对数据元素的读写也能够触发 volatile 关键字的作用, 则可以使用 AtomicIntegerArray
之类的类.
对引用型变量也是, 只对该引用自身起作用, 而不会对它的实例/静态变量起作用.
volatile 使用场景
- volatile 变量作为状态标志
- 使用 volatile 保障可见性
- 使用 volatile 变量替代锁(但 volatile 关键字并非锁的替代品)
- 使用 volatile 实现简易版读写锁
正确实现单例模式
版本1
check-then-act
package hello;
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { // 操作1
instance = new Singleton(); // 操作2
}
return instance;
}
}
它的问题:
当 instance 的值还是 null 时, 线程T1 和T2 同时执行到操作1, 接着在T1执行操作2之前T2已经完成操作2, 下一时刻, 当T1执行到操作2时, 尽管 instance 实际上不为 null, 但是T1依然会再创建一个实例. 这就导致了多个实例的创建.
版本2
synchronized
package hello;
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
}
它的问题: 这固然是线程安全的, 但这意味着 getInstance 方法的任何一个执行线程都要申请锁这种开销.
版本3
double check
package hello;
public class Singleton {
private static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) { // 操作1
synchronized (Singleton.class) {
if (instance == null) { // 操作2
instance = new Singleton(); // 操作3
}
}
}
return instance;
}
}
这看起来好像是没问题了. 但这种写法, 没考虑到重排序的因素. 操作3 可以分解为以下伪代码:
objRef = allocate() // 子操作1
invokeConstructor(objRef) //子操作2
instance = objRef // 子操作3
根据重排序规则, 临界区内的操作之间允许被重排序. 因此 JIT 可能将上述的子操作重排序为: 1 -> 3 -> 2
即在初始化对象之前将对象的引用写入实例变量 instance . 而操作1读取变量 instance 是没加锁的, 因此上述重排序对操作1的执行线程是有影响的: 该线程可能看到一人未初始化或未初始化完毕的实例, 即变量 instance 的值不为 null, 但该变量所引用的对象中的某些实例变量的变量值可能仍然是默认值, 而不是构造函数中设置的初始值.
解决: 利用 volatile 来修饰 instance 即可.
版本4
基于静态内部类
package hello;
public class Singleton {
private static class Holder {
private static final Singleton instance = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return Holder.instance;
}
}
完美~
版本5
基于枚举
package hello;
public enum Singleton {
INSTANCE;
public void someService() {
// do something
}
}
也很完美~
CAS
Compare and Swap
它是一种对处理器指令的称呼. 例如下面的情景
package hello;
public class Counter {
private int counter;
public void incre() {
synchronized (this) {
counter++;
}
}
}
- 使用 synchronized 固然可以保证线程安全, 但有点杀鸡用牛刀. 因为在所有同步机制中, 锁的开销是最大的.
- volatile 虽然可以保障读或写的原子性, 但不能保障(counter++) (read-modify-write) 这种操作是原子性的.
- 最佳方式: CAS
它可以由 CAS 转换为一种 if-then-act
的操作, 并且由处理器保障该操作的原子性.
注意, 修改为 CAS 后, 仍然要使用 volatile 来修饰 counter . 因为 CAS 只是保障了共享变量更新这个操作的原子性, 它并不保障可见性. 例如, JDK 源码里的 AtomicInteger
里维护的一个 int 字段, 它的声明如下:
private volatile int value;
JDK 里提供的原子变量类有:
基础类型
AtomicBoolean
AtomicInteger
AtomicLong
字段更新器
AtomicIntegerFieldUpdater
AtomicLongFieldUpdater
AtomicReferenceFieldUpdater
引用型
AtomicMarkableReference
AtomicReference
AtomicStampedReference
数组型
AtomicIntegerArray
AtomicLongArray
AtomicReferenceArray
对象的发布与逸出
多个线程共享变量还有其他途径, 它们被统称为对象发布(Publish)
- 将对象引用存储到 public 变量中
- 在非 private 方法中返回一个对象
- 创建内部类
- 通过方法调用将对象传递给外部方法
static
- 它能保证一个线程即使在未使用其他同步机制的情况下, 也总是可以读取到一个类的静态变量初始值(而不是默认值)
- 对引用型静态变量, 还能保障一个线程读取到该变量的初始值时, 这个值所指向(引用)的对象已经初始化完毕
final
通过上面可以知道, 由于重排序, 一个线程读取到一个对象引用时, 该对象可能尚未初始化完毕, 即这些线程可能读取到该对象字段的默认值而不是初始值.
在多线程环境下 final 关键字的特殊作用:
- 当一个对象被发布到其他线程的时候, 该对象的所有 final 字段(实例变量) 都是初始化完毕的. 即其他线程读取这些字段的时候, 所读取到的值都是相应字段的初始值(而不是默认值). 而非 final 字段没有这种保障.
对于引用型 final 字段, final 关键字还进一步确保该字段所引用的对象已经初始化完毕, 即这些线程读取该字段所引用的对象的各个字段时所读取到的值都是相应字段的初始值.
package hello; public class Counter { private final int x; private int y; static Counter instance; public Counter() { this.x = 1; this.y = 2; } public static void writer() { instance = new Counter(); } public static void reader() { final Counter counter = instance; if (counter != null) { int diff = counter.y - counter.x; // diff 可能为 1(2-1), 也可能为-1(0-1) System.out.println(diff); } } }
在JVM内联的优化下, 可能会将 Counter 的构造函数代码挪入 writer 方法:
objRef = allocate() //1
objRef.x = 1; //2
objRef.y = 2 //3
instance = objRef //4
操作 3 可能被JIT编译器, 处理器重排序到子操作4(对象发布)之后, 因此当其他线程通过共享变量 instance 看到对象引用 objRef 的时候, 该对象的实例变量 y 可能还没有被初始化, 即这些线程看到的 y 是默认值0, 而不是初始值2.
而 JIT 编译器不会将构造器中对 final 字段的赋值操作重排序到操作4之后, 并且还要禁止处理器做这种重排序(通过在编译后的机器码中插入特殊的内存屏障来实现). 通过这种限定, Java 虚拟机, 处理器一起保障了对象 instance 被发布前, 其 final 字段 x 必然是被化完毕的.
注意
final 关键字只保障有序性, 即保障一个对象对外可见的时候, 该对象的 final 字段必然是初始化完毕的. final 关键字并不保障对象引用本身对外的可见性.
安全发布与逸出
即对象以一种线程安全的方式被发布.
安全发布的方式
- 使用 static 关键字修饰引用该对象的变量
- 使用 final 关键字修饰引用该对象的变量
- 使用 volatile 关键字修饰引用该对象的变量
- 使用 AtomicReference 来引用该对象
- 对访问该对象的代码进行加锁
逸出: 当一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所期望的时候, 我们就称该对象逸出(Escape)
逸出形式:
- 在构造器中, 将 this 赋值给一个共享变量
- 在构造器中, 将 this 作为方法参数传递给其他方法
- 在构造器中, 启动基于匿名类的线程
总结
描述 | 锁 | volatile | CAS | final | static |
---|---|---|---|---|---|
原子性保障 | 具备 | 具备(2) | 具备 | 不涉及 | 不涉及 |
可见性保障 | 具备 | 具备 | 不具备 | 不具备 | 具备(3) |
有序性保障 | 具备 | 具备 | 不涉及 | 具备 | 具备(4) |
上下文切换? | 可能(1) | 不会 | 不会 | 不会 | 可能(5) |
备注
(1) 被争用的锁可能导致上下文切换 (2) 仅保障对 volatile 变量读/写操作本身的原子性 (3)(4) 仅在一个线程初次读取一个类的静态变量时起作用 (5) 静态变量所属类的初始化可能导致上下文切换
处理线程
分而治之
- 基于数据的分割
- 基于任务的分割
合理设置线程数
Amdahl's
定律
Smax = 1 / (P + (1-P)/N)
1: 为该程序的单线程运行时间 N: 为多线程版本总耗时 P: 串行部分的耗时比率
N->无穷大的时候, Smax 趋向于 1/P , 最终决定多线程程序提速的因素, 是整个计算中串行部分的耗时比率P, 而不是线程数N! P越大, 即程序中不可并行化的部分所占越大, 那么提速就越小.
CPU 密集型任务
通常可以设置为 N(CPU数) + 1
IO 密集型任务
优先设置为1, 然后向 2 * N(CPU数) 靠近.
混合型
N(threads) = N(CPU) * U(CPU) * (1+ WT/ST)
U(CPU) 为目标CPU使用率[ 0<U(CPU)<=1 ] WT: 等待时间 ST: 实际时间
可以通过 jvisualvm 来计算.(WT = Total-Total(CPU), ST=Total(CPU))
线程间的协作
wait/notify 的开销及问题
- 过早唤醒(notifyAll)(可以用条件变量 Condition 来解决)
- 信号丢失(wait之前没判断保护条件是否成立)
- 欺骗性唤醒
- 上下文切换过多
- 用 notify 代替 notifyAll 可减少
- 执行完 notify/notifyAll 后尽快释放相应的内部锁
notify() VS notifyAll() 的选用
- notifyAll() 效率不太高, 但在正确性方面比较有保障
- notify() 可能会导致信号丢失.
用 notify() 替代 notifyAll() 时, 要注意确保以下两个条件要同时满足
- 一次通知仅需要唤醒至多一个线程
- 相应对象上的所有等待线程都是同质等待线程(即处理的逻辑是一样的)
wait/notify 与 Thread.join()
join 可使当前线程等待目标线程结束之后才继续运行. join(timeout) 这个版本则指定一个超时时间, 如果目标线程在指定时间内没执行完毕, 当前线程也会继续运行.(内部是通过使用 wait/notify 来实现的)
join() 相当于调用了 join(0)
条件变量
Condition
接口的 await
, signal
和 signalAll
分别相当于 Object.wait()
, Object.notify()
和 Object.notifyAll()
CounDownLatch
用来实现一个(或多个)线程, 等待其他线程完成一组特定的操作之后才继续运行.
不要重复使用
CyclicBarrier
多个线程相互等待对方执行到代码中的某个地方, 这时这些线程才能够继续执行的情况.
可重复使用
生产者-消费者
BlockingQueue
- 有界队列
- 无界队列
信号量(Semaphore)
它一般用来控制同一时间内对虚拟资源的访问次数.
访问资源前, 要申请相应的配额(Semaphore.acquire()
), 访问结束后则返还相应的配额(Semaphore.release()
).
acquire
与 release
要成对使用. 但, 它们可以分别在不同的线程执行.(即一条线程, 可以在未调用 acquire 的情况下, 调用 release)
管道 PipedOutputStream 与 PipedInputStream
线程间直接的输出和输入(即不借助文件, 数据库, 网络连接等其他交换中介)
双缓冲 与 Exchanger
线程中断
每个线程都会维护一个中断标记(Interrup status), 用于相应的线程是否接收到了中断(注意, 只是接收)
获取:
Thread.currentThread().isInterrupted()
获取并重置(清空标记)
Thread.interrupted()
设置:
Thread.interrupt()
中断响应
- 无影响.
- 取消任务的运行(只是取消当前任务, 但不会影响它继续处理其他任务)
- 工作者线程停止(整个线程生命周期变为 TERMINATED)
对 InterruptedException
等的异常处理来实现中断响应.
- 不捕获(向上抛出, 由调用者来处理)
- 捕获后, 再重新抛出(中间处理一下, 然后继续由调用者来处理)
- 捕获后, 中断当前线程.
- 捕获后, 不重新抛出, 也不保留中断标志(这比较危险)(以上三种是 “正确” 的处理方式)
保障线程安全的设计技术
- 无状态对象(天生是线程安全的)
- 不可变对象(Immutable Object)
- 线程特有对象(ThreadLocal)
- 装饰器模式(如 Collections.synchronizedXXX 之类的)
死锁产生的条件及规避
- 资源互斥
- 资源不可抢夺
- 占用并等待资源
- 循环等待资源
只要破坏以上其中一个条件即可.
- 粗锁法
- 锁排序法: 相关线程使用全局统一的顺序申请锁
- 使用 tryLock 超时
- 使用开放调用: 调用外部方法时不加锁
- 使用锁的替代品
死锁的检测
java.management.ThreadMXBean.findDeadlockedThreads()
来实现.
异步
- Executors
- CompletionService
- FutureTask
- ScheduleExecutorService