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 平台早期,很多类使用的都是第二种方法,比如StringBufferVector等,即在类的内部进行同步,但是很显然,第一种方式能获得更好的性能,并且在绝大多数情况下,这些类都是使用在单线程之中,因此逐渐StringBufferStringBuilder代替。

第 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,在从队列中取值时,如果没有数据,就会阻塞当前线程。

同步器是能让一个线程等待另一个线程的对象,最常见的是CountDownLatchSemaphore(信号量)。

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):这个类的实例是不变的,不需要外部的同步。例如StringLongBigInteger等。
  • 无条件的线程安全(unconditionlly thread-safe):这个类是可变的,但是内部有足够的同步,可以被并发使用,且无需外部同步处理。例如AtomicLongConcurrentHashMap等。
  • 有条件的线程安全(unconditionlly thread-safe):除了有些方法需要外部同步之外,其他的和无条件线程安全一致。比如Collections.synchronized包装返回的集合,它要求对迭代器进行同步。
  • 非线程安全(not thread-safe):这些类是可变的,如果要并发使用,需要调用者手动进行同步控制。例如ArrayListHashMap等。
  • 线程对立的(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 平台上最不可移植的特性,不要通过修改有限即来控制。

标签: Java

添加新评论