《Java 并发编程·线程与内存模型》2.2 线程安全的四类策略

把同一个有竞态的借阅服务分别用不可变对象、synchronized、ThreadLocal 和 ConcurrentHashMap 改写,逐项比较代价与失效场景,并给出一张按共享可变性、状态大小和读写比选型的矩阵,说明并发容器的复合操作为何仍然不安全,最后给出单机、多实例与跨 key 场景下的取舍。

承接的问题

2.1 拆出了三种竞态形态:check-then-act、read-modify-write、发布逸出。认出形态之后,下一步是选手段。

常见的反应是「加 synchronized」。这没错,但不是唯一答案,也不总是最好的答案。一个只读的配置对象加锁是浪费;一个每线程独立的格式化器放进共享 map 是自找麻烦;一个按 isbn 分区的库存表用一把全局锁会把并发压成串行。

这一节讲四类策略:

策略核心想法典型手段
不可变不共享可变状态,就没有写冲突record、final、防御性拷贝
互斥把复合操作关进同一个临界区synchronized、显式锁
线程封闭状态只属于一个线程,不共享ThreadLocal、方法内局部变量
并发容器把同步责任交给容器ConcurrentHashMap、compute

它们不是四个并列的「最佳实践」。每一种都有适用边界,也都有明确不该用的场景。

策略一:不可变

不可变对象在构造完成后状态不再变化。没有写,就没有 read-modify-write;没有发布后的修改,也就没有 check-then-act 的窗口。Book 已经是 record:

public record Book(String isbn, String title, int stock) {}

record 的字段是 final 的,访问器只读,没有 setter。但这只是浅不可变。如果字段本身是可变对象,外部仍然可以改它的内容:

package com.plume.conc.core;

import java.util.List;

public record BookDetail(String isbn, String title, List<String> tags) {
    public BookDetail {
        tags = List.copyOf(tags);   // 防御性拷贝,切断外部引用
    }
}

没有这行 List.copyOf,构造器拿到的 tags 仍然被调用方持有,调用方可以继续 add,对象看起来是 record,实际状态仍在变。

不可变的代价很直接:

  • 每次修改都要创建新对象。小对象便宜,大对象图或高频更新就贵。
  • 对象身份会变。不能再用 == 判断「还是不是同一个」,缓存和 IdentityHashMap 要重新考虑。
  • 不可变只保证对象内部不变,不保证引用替换是原子的。两个线程同时把 bookRef 从 A 换成 B,仍然会丢更新。

所以对 UnsafeLoanService 来说,把 Book 写成 record 并没有修好库存问题。竞态不在 Book 内部,而在「读引用、判断库存、替换引用」这组操作上。要让「替换整个 Book」原子,需要 CAS(第 8.2 节的 AtomicLoanService);不可变在这里是必要条件,不是充分条件。

什么时候不要用: 高频更新的大对象、需要稳定身份的对象、每次修改都只改一两个字段却要复制整个聚合根的场景。这时候互斥或分片更合适。

策略二:互斥

互斥是把「检查、计算、写回」放进同一个临界区,让它们对外表现为一个原子步骤。synchronized 是最直接的工具:

package com.plume.conc.core;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;

public final class SynchronizedLoanService implements LoanService {
    private final BookRepository books;
    private final LoanRepository loans;
    private final Object lock = new Object();

    public SynchronizedLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        synchronized (lock) {
            Book book = books.findByIsbn(isbn)
                    .orElseThrow(() -> new LoanException(
                            ErrorCode.BOOK_NOT_FOUND, "book not found: " + isbn));
            if (book.stock() <= 0) {
                throw new LoanException(ErrorCode.OUT_OF_STOCK, "out of stock: " + isbn);
            }
            books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
            return new Reservation(
                    UUID.randomUUID().toString(),
                    isbn,
                    memberId,
                    Instant.now().plus(14, ChronoUnit.DAYS));
        }
    }

    @Override
    public boolean returnBook(String loanId) {
        synchronized (lock) {
            Loan loan = loans.findById(loanId).orElse(null);
            if (loan == null) {
                return false;
            }
            Book book = books.findByIsbn(loan.isbn())
                    .orElseThrow(() -> new LoanException(
                            ErrorCode.BOOK_NOT_FOUND, "book not found: " + loan.isbn()));
            books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
            return true;
        }
    }
}

锁对象是 private final Object lock,不是 this,也不是 Book.class。原因有三点:外部代码可能已经拿 this 当锁;Book.class 是全局共享的,会把无关的类也串起来;锁字段如果是可变的,换一个实例就换了一把锁。第 4.2 节会从字节码和对象头角度展开 monitorenter/monitorexit。

互斥的代价是串行化。上面这把锁把所有书的借阅都串起来了。某本书的热点会拖慢其他书。可以按 isbn 分段加锁(第 9.3 节的思路),但锁越多,死锁和锁顺序问题越难查。

还有三个边界:

  • 锁内不要做远程调用、文件 IO、sleep。临界区越长,排队越严重。
  • 单机锁不能跨 JVM。部署两个实例,各自的 synchronized 互不知情,库存仍然会超卖。
  • 锁的可重入不等于可以随便嵌套。多把锁的获取顺序不一致会死锁。

什么时候不要用: 临界区里有慢操作、需要跨实例一致性、读远多于写且读操作本身很重。前两种要考虑拆分或外部协调,后一种考虑读写锁或不可变快照。

策略三:线程封闭

线程封闭是把状态限制在一个线程内,其他线程根本不访问它。局部变量天然封闭,ThreadLocal 则把封闭范围扩展到「当前线程的多次调用」。

请求上下文是典型场景:memberId、traceId、租户信息在整条调用链上都要用,但不应该作为参数层层透传:

package com.plume.conc.core;

public final class RequestContext {
    private static final ThreadLocal<Member> CURRENT = new ThreadLocal<>();

    private RequestContext() {
    }

    public static void bind(Member member) {
        CURRENT.set(member);
    }

    public static Member current() {
        Member member = CURRENT.get();
        if (member == null) {
            throw new IllegalStateException("no member bound to current thread");
        }
        return member;
    }

    public static void clear() {
        CURRENT.remove();
    }
}

在任务里必须用 try/finally 清理:

executor.submit(() -> {
    try {
        RequestContext.bind(member);
        service.reserve(isbn, member.memberId());
    } finally {
        RequestContext.clear();
    }
});

线程池的线程会复用。如果只在任务开始时 set、结束时忘了 remove,下一个任务可能读到上一个任务的 member。这就是「串值」。第 7.1 节会讲 ThreadLocalMap 的弱引用 key 与 value 泄漏,第 7.2 节会讲 InheritableThreadLocal 在线程池里为什么断掉。

线程封闭的边界很硬:它只适合「每个线程一份、彼此不需要看到」的状态。把库存放进 ThreadLocal,每个线程会看到自己的库存,业务不变量直接失去意义:A 线程扣到 0,B 线程仍然看到 5。这不是线程安全,而是把共享状态拆成了多个互不相干的副本。

什么时候不要用: 需要跨线程汇总的结果、需要在线程池任务之间传递的可变状态、生命周期比线程更长的缓存。虚拟线程场景下还要额外考虑每个虚拟线程一份 ThreadLocal 的内存成本(第 14.3 节)。

策略四:并发容器

ConcurrentHashMap 把单次操作的同步做进了容器:get、put、remove、compute、computeIfAbsent、merge 都是线程安全的。对「按键读写」的状态,它通常比一把全局锁伸缩性更好。

库存天然是 isbn -> Book 的映射,可以把「读、判断、写」收进 compute:

package com.plume.conc.core;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import java.util.concurrent.ConcurrentMap;

public final class ConcurrentLoanService implements LoanService {
    private final ConcurrentMap<String, Book> books;
    private final LoanRepository loans;

    public ConcurrentLoanService(ConcurrentMap<String, Book> books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        Book updated = books.compute(isbn, (key, book) -> {
            if (book == null) {
                throw new LoanException(ErrorCode.BOOK_NOT_FOUND, "book not found: " + key);
            }
            if (book.stock() <= 0) {
                throw new LoanException(ErrorCode.OUT_OF_STOCK, "out of stock: " + key);
            }
            return new Book(book.isbn(), book.title(), book.stock() - 1);
        });
        return new Reservation(
                UUID.randomUUID().toString(),
                updated.isbn(),
                memberId,
                Instant.now().plus(14, ChronoUnit.DAYS));
    }

    @Override
    public boolean returnBook(String loanId) {
        Loan loan = loans.findById(loanId).orElse(null);
        if (loan == null) {
            return false;
        }
        books.computeIfPresent(loan.isbn(), (key, book) ->
                new Book(book.isbn(), book.title(), book.stock() + 1));
        return true;
    }
}

compute 对同一个 key 是原子的:映射函数执行期间,其他线程对同一 key 的更新会等待。映射函数抛异常时,映射保持不变,异常原样抛出,所以 OUT_OF_STOCK 能正常传出去。

但 ConcurrentHashMap 只保证单次调用的原子性,不保证多次调用组成的复合操作。下面三种写法仍然不安全:

// 1. check-then-act:检查和放入是两次调用
if (!books.containsKey(isbn)) {
    books.put(isbn, book);
}

// 2. read-modify-write:get 和 put 之间可能被别人插入
books.put(isbn, new Book(isbn, title, books.get(isbn).stock() - 1));

// 3. 用 size 做容量判断:size 是弱一致快照
if (books.size() < LIMIT) {
    books.put(isbn, book);
}

第 1 种要用 putIfAbsent 或 computeIfAbsent;第 2 种要用 compute 或 merge;第 3 种在并发下没有稳定的「当前大小」,需要信号量或专门的计数器。

还有两个容易踩的边界:

  • compute 的映射函数里不要更新其他 key。不同 key 可能落在同一个桶、同一把内部锁上,嵌套更新会带来锁顺序问题,甚至死锁。
  • 迭代器是弱一致的。遍历时可能看到旧值、新值,也可能看不到遍历开始后的更新;它不会抛 ConcurrentModificationException,但也不是一致性快照。

什么时候不要用: 需要跨多个 key 的原子性(比如一次借三本书,要么全成要么全败)、需要「先读全表再决策」、状态不是按键组织。这些场景要么用互斥包住整个操作,要么交给数据库事务。

选型矩阵

把四个维度放在一起看:共享可变吗、状态多大、读写比如何、一致性边界在哪。

共享可变状态大小读写比一致性边界建议主要代价
否小到中读多写少单对象不可变 + 安全发布每次变更复制;引用替换仍需原子
是小写少、临界区短单 JVMsynchronized串行化;不能跨实例
是大读多写少单对象读写锁 / StampedLock(第 9 章)实现复杂;写饥饿
否(线程内)小任意线程内ThreadLocal池化线程必须 remove
是中到大(按键分区)读写都高单 keyConcurrentHashMap.compute跨 key 无原子性
是中到大(跨 key)任意多 key / 多表互斥或事务吞吐下降;锁范围难切
是小(单值)写多读少单值AtomicLong / LongAdder(第 8 章)LongAdder.sum() 非快照

这张表的读法是先问三个问题:

  1. 这个状态真的需要共享吗? 能改成每线程一份,就先考虑线程封闭;能改成不可变快照,就先考虑不可变。
  2. 共享的范围有多大? 单个对象、单个 key、多个 key、跨实例,对应的手段不同。范围越大,越可能需要事务或外部协调。
  3. 读写比和临界区长度是多少? 读多写少可以考虑读写分离;临界区里有慢操作,先拆临界区,再谈选锁。

顺序不是教条。一个高频写的计数器,不可变的复制成本可能高于 LongAdder;一个只需要「放入一次」的注册表,putIfAbsent 比 synchronized 更直接。

对 UnsafeLoanService 的四类改法取舍

把四类策略逐一放回 2.1 的服务,结论并不一样:

改法具体怎么改能否修好这个服务适用边界
不可变Book 已是 record;每次扣减生成新 Book单独不够,还要原子替换引用书目元数据、返回值、快照
互斥synchronized 包住 reserve 与 returnBook单机能修好低竞争、单实例、临界区短
线程封闭把 memberId/traceId 放进 ThreadLocal对库存无效,方向错误请求上下文,不用于共享库存
并发容器ConcurrentMap + compute单本书能修好按 key 分区、单 key 原子

具体取舍:

  • 只部署一个实例、QPS 不高。 SynchronizedLoanService 改动最小,审查成本最低。缺点是所有书共享一把锁,热点书会拖慢其他书;但在这个量级下,通常不是瓶颈。
  • 单实例、按书分片后仍有热点。 ConcurrentLoanService 的 compute 把同步范围缩小到单个 key 的更新,伸缩性更好。代价是 BookRepository 接口不暴露 compute,要么把 ConcurrentMap 交给 service,要么给仓库加一个原子的 update 方法。这是接口设计问题,不是并发问题。
  • 多实例部署。 上面两个都不够。两个 JVM 各自的锁和 ConcurrentHashMap 互不可见,库存仍然会超卖。需要数据库行锁、乐观锁版本号或集中式协调;第 8 章的 CAS 思路和第 9 章的锁是本地工具,卷三会讲线上事故。
  • 不可变。 它适合 Book 的元数据(title 之类发布后不变的部分),也适合把借阅结果做成不可变快照返回。但库存扣减需要「比较并替换」,这落在第 8.2 节。把 Book 改成 record 就宣布线程安全,是这一节最常见的误判。
  • 线程封闭。 它对库存是错配。库存必须跨线程共享,放进 ThreadLocal 只会让每个线程看到自己的副本。它真正的位置是请求上下文:memberId、traceId、租户 ID。用对地方很省事,用错地方会制造「看起来加了隔离,实际业务错了」的 bug。

如果只能选一个:单机低竞争选互斥,单机按 key 热点选并发容器,多实例选事务/乐观锁。没有一种改法能同时满足「跨实例、跨 key、高并发、低延迟」。

常见错误与边界

  • 锁对象选错。 锁 this、锁 Book.class、锁字符串字面量、锁一个可变的字段,都会让锁的范围失控或根本锁不住。
  • ConcurrentHashMap 的复合操作。 get 再 put、containsKey 再 put、用 size 做判断,仍然是 2.1 的三种形态。
  • record 里有可变字段。 record 只保证字段引用不变,不保证引用对象不变;List、Map、数组都要防御性拷贝。
  • 线程池里不 remove。 线程复用会把上一个任务的值带给下一个任务。
  • 锁内做 IO。 临界区被 IO 拉长,排队和超时会被放大。
  • 把 Collections.synchronizedMap 当成复合操作安全。 它只同步单次方法调用,get 再 put 仍要客户端加锁。
  • 以为不可变可以替代一切。 不可变解决的是「对象不被改」,不解决「引用替换的原子性」。

小结

  • 四类策略分别对应四种切断共享的路径:不改变、串行化访问、每线程一份、把同步交给容器。
  • synchronized 修的是复合操作的原子性与可见性;不可变修的是对象状态;ThreadLocal 修的是共享范围;ConcurrentHashMap 修的是单次按键操作。
  • 对 UnsafeLoanService,互斥和 compute 能修好单机库存,不可变需要配合 CAS,线程封闭不适用。
  • ConcurrentHashMap 的 compute 是原子的,但 get 再 put 不是。复合操作永远是复合操作。
  • 下一节用 JCStress 把「丢失更新」从偶发现象变成可统计的结果。

阅读导航:上一节 2.1 竞态的三种形态 | 下一节 2.3 用 JCStress 复现计数器竞态

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收