《Effective Java》阅读笔记-第十一章
Effective Java 阅读笔记
第十一章 并发
第 78 条 同步访问共享的可变数据
多线程访问变量时,需要进行同步,否则就会产生并发问题。
同步代码块、加锁等
或者直接不共享变量,也就是将可变数据限制在单个线程内。
ThreadLocal这种
第 79 条 避免过度同步
为了避免活性失败和安全性失败,在一个同步区域内,不要放弃对调用者的控制。换句话来说,就是同步区域内不应该调用应该被重写的方法,或者调用者传过来的函数。
To avoid liveness and safety failures, never cede control to the client within a synchronized method or block. In other words, inside a synchronized region, do not invoke a method that is designed to be overridden, or one provided by a client in the form of a function object.
从包含同步区域的类来看,这样的方法是外来的,当前类不知道这个方法会做什么事情,也无法控制它,在同步区域中调用这种方法很容易造成死锁或者数据损坏。
通常来说,在同步区域内的工作应该尽可能少。过度同步也会影响到性能。
如果正在编写一个可变的类,有两种选择:第一种是放弃所有同步,如果想并发使用,需要调用者从类外部控制同步;第二种是在内的内部进行同步,使这个类变成线程安全的。
Java 平台早期,很多类使用的都是第二种方法,比如StringBuffer
、Vector
等,即在类的内部进行同步,但是很显然,第一种方式能获得更好的性能,并且在绝大多数情况下,这些类都是使用在单线程之中,因此逐渐StringBuffer
被StringBuilder
代替。
第 80 条 executor、task 和 stream 优先于线程
就是使用 ExecutorService 线程池来代替手动创建线程。
第 81 条 并发工具类优先于 wait 和 notify
Java 5 中添加了很多并发工具类,已经没有理由继续使用 wait 和 notify 了。
这些工具类分为三类:Executor 框架(Executor Framework)、支持并发的集合类(Concurrent Collection)、同步器(Synchronizer)。
并发集合在内部进行了状态同步,比如 Map 接口下有实现类ConcurrentHashMap
,List 接口下有实现类CopyOnWriteArrayList
,并且应该优先使用这种内部控制的并发集合类,而不是使用Collections.synchronizedMap()
对集合类进行同步。
有些集合接口通过阻塞进行了扩展,比如BlockingQueue
,在从队列中取值时,如果没有数据,就会阻塞当前线程。
同步器是能让一个线程等待另一个线程的对象,最常见的是CountDownLatch
和Semaphore
(信号量)。
CountDownLatch 是一次性的,可以进行计数,调用countDownLatch.countDown()
将计数 -1,在调用countDownLatch.await()
时如果计数不为 0,就会阻塞当前线程,可以用来多线程协同处理。
JDK 官方示例
这是个类,其中一组工作线程使用两个倒计时锁存器:
- 第一个是启动信号,阻止任何工人继续前进,直到司机准备好让他们继续前进;
- 第二个是完成信号,允许驱动程序等待所有工作人员完成。
import java.util.concurrent.CountDownLatch;
class Driver {
// ...
void main() throws InterruptedException {
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch doneSignal = new CountDownLatch(N);
for (int i = 0; i < N; ++i) // 创建并启动线程
new Thread(new Worker(startSignal, doneSignal)).start();
doSomethingElse(); // 先不让这些线程工作
startSignal.countDown(); // 让所有线程开始工作
doSomethingElse();
doneSignal.await(); // 等待所有线程结束
}
}
class Worker implements Runnable {
private final CountDownLatch startSignal;
private final CountDownLatch doneSignal;
Worker(CountDownLatch startSignal, CountDownLatch doneSignal) {
this.startSignal = startSignal;
this.doneSignal = doneSignal;
}
public void run() {
try {
startSignal.await();
doWork();
doneSignal.countDown();
} catch (InterruptedException ex) {
// return;
}
}
void doWork() {
// ...
}
}
需要注意的是执行countDown
方法的线程一定要比 CountDownLatch 的数量多,否则线程就会无限等待,也就是线程饿死。
Semaphore 类似令牌,设置一个数量,semaphore.acquire()
会阻塞直到获得许可,semaphore.release()
都会释放一个许可。
如果只是用来计时,应该用System.nanoTime()
而不是System.currentTimeMillis()
,因为前者更精确,并且和系统时间无关(nanoTime 基准点也不确定,但是启动时固定)。
第 82 条 线程安全的文档化
一个类是否可被多个线程安全使用,应该在文档中说明它所支持的线程安全级别:
- 不可变的(immutable):这个类的实例是不变的,不需要外部的同步。例如
String
、Long
、BigInteger
等。 - 无条件的线程安全(unconditionlly thread-safe):这个类是可变的,但是内部有足够的同步,可以被并发使用,且无需外部同步处理。例如
AtomicLong
、ConcurrentHashMap
等。 - 有条件的线程安全(unconditionlly thread-safe):除了有些方法需要外部同步之外,其他的和无条件线程安全一致。比如
Collections.synchronized
包装返回的集合,它要求对迭代器进行同步。 - 非线程安全(not thread-safe):这些类是可变的,如果要并发使用,需要调用者手动进行同步控制。例如
ArrayList
、HashMap
等。 - 线程对立的(thread-hostile):这种类不能安全的被多个线程使用,即使外围进行了同步。这种类一般根源在于修改静态数据时没有进行同步,这种类一般会得到修正,或者被标注为不再建议使用。
有条件的线程安全中,应该举出例子必须获得哪个锁才能线程安全。
如果使用一个对象作为锁,这个对象应该声明为 final,并且作用域应对最小:
public class Demo {
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// ...
}
}
}
第 83 条 慎用延迟初始化
大多数时候,正常的初始化优先于延迟初始化。
// 普通方式初始化一个字段
private final FieldType field = computeFieldType();
如果想延迟初始化来进行优化,那就使用同步方法,这是最简单、最清楚的一种方式:
private final FieldType field;
private synchronized FieldType getField() {
if (field == null) {
field = computeFieldType();
}
return field;
}
这两种方式对静态字段也同样适用(正常初始化和同步方法)。
如果出于性能考虑需要对静态字段延迟初始化,可以使用延迟初始化持有者模式(lazy initialization holder):
private static class FieldHolder {
static final FieldType field = computeFieldType();
}
private static FieldType getField() {
return FieldHolder.field;
}
当getField
方法第一次被调用的时候,它第一次读取FieldHolder.field
,会导致FieldHolder
类被初始化。这种方法没有增加任何访问成本(访问方法没有同步,性能更好)。
现代虚拟机会延迟加载FieldHolder
,在加载时对其进行初始化。
学到了!
如果出于性能考虑需要对实例字段进行延迟初始化,就用双重检查模式:
// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) { // First check (no locking)
synchronized (this) {
if (field == null) { // Second check (with locking)
field = result = computeFieldType();
} else {
result = field; // 或者直接返回 field
}
}
}
return result;
}
这段代码不是很好理解,尤其是局部变量 result。
最外面返回 result 主要是想要避免 volatile 变量读取时的缓存行失效,这样可以提升性能(着实是没什么用处的提升)。如果 DCL 最外层检测失败,或者修改后代码没进入 else ,也可以确保最少限度地访问 field (因为每次访问 volitale 都会使缓存行失效从而从主内存加载最新变量副本到工作缓存)
volatile
关键字作用如下:
可见性(Visibility):当一个线程修改了 volatile 变量的值,这个新值对于其他线程是立即可见的。这是因为 volatile 会告诉编译器不要将该变量的值缓存到线程的本地存储中,而是直接从主存中读取和写入该变量。
禁止指令重排序:volatile 还会禁止虚拟机对指令进行重排序,确保 volatile 变量的读取和写入操作按照程序中的顺序执行。
使用volatile
修饰的字段访问时可能会使缓存行失效:
缓存行(Cache Line)是计算机系统中的一小块内存,通常大小为 64 字节。多个处理器核心(或线程)共享同一块缓存行。当一个线程修改缓存行中的某个变量时,其他线程也可能会受到影响,因为它们可能缓存了相同的缓存行。
在使用 volatile 关键字的情况下,当一个线程写入 volatile 变量时,它会强制将该变量的值刷新到主内存中,而其他线程在读取该变量时会从主内存中获取最新的值。这确保了变量的可见性,即一个线程对变量的修改对其他线程是可见的。
然而,与缓存行失效相关的问题通常是指当一个线程修改了 volatile 变量时,这个变量所在的缓存行可能会失效,导致其他线程的缓存无效,从而需要重新从主内存中加载该缓存行。这可能引起性能问题,因为缓存行的失效和重新加载需要一些开销。
注意,原书第三版中这段代码是错误的,少了同步代码块中的 else 分支,这样会导致取到的值是 null
还有一种情况是初始化一个可以接受重复初始化的实例字段,这种情况主需要检查一次即可:
// 同样需要使用 volatile 修饰
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) {
field = result = computeFieldType();
}
return result;
}
这就是单检查模式。
总结:大多数字段应该正常初始化,如果为了性能,或者为了解决循环问题,必须延迟初始化,非静态字段可以使用双重检查方式,静态字段可以使用延迟加载方式。
其实用枚举单例也可以
第 84 条 不要依赖于线程调度器
当有多个线程可以运行时,由线程调度器决定运行哪个线程。
任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能都是不可移植的。
线程不应该一直处于 busy-wait 状态,即反复检查一个共享对象:
while (true) {
synchronized (this) {
// do something
}
}
这是做法会增加 CPU 负担。
如果一个线程因为没有足够的 CPU 时间导致无法工作,不要通过在其他线程内调用Thread.yield()
来解决。这个方法在不同的虚拟机上实现可能不同,Thread.yield
没有可测试语义。
还有就是通过调整线程优先级,线程优先级是 Java 平台上最不可移植的特性,不要通过修改有限即来控制。