ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java并发编程:从AQS到线程池的同步机制与实战

Java并发编程:从AQS到线程池的同步机制与实战 读完《Java 并发编程的艺术》第二遍正好赶上要做并发相关的技术分享于是把这一遍的笔记整理成了第二辑书摘。为什么要单独拿第二辑出来写因为这书的章节结构很有意思前四章在讲“为什么会有并发问题”——对象为什么没有安全发布、编译器为什么要重排指令、内存模型里的 happens-before 到底想约束什么。到了第五到第十章书才开始真正动刀锁是怎么工作的ConcurrentHashMap 为什么可以做到既安全又飞快线程池的参数到底应该怎么定Executor 框架又是如何把线程管理变成一种资源调度。如果你已经动手写过一阵多线程代码但总觉得思路是散的这一辑非常适合你如果你正打算系统性梳理 Java 并发面试题那这一辑同样是高频考点的集中区。1. 第二辑的主线从锁机制一路走到线程池1.1 为什么前四章是地基第五到第十章才是真正的战场这本书的结构安排其实是刻意为之。前四章解决的是“你为什么会写出有问题的并发代码”第二辑则直接切换到“这些同步机制到底是怎么实现的”。第五章讲锁从 synchronized 的膨胀过程一路讲到 AQS、ReentrantLock 的源码级拆解第六章铺开并发容器和阻塞队列第七章讲原子操作类第八章讲 CountDownLatch、CyclicBarrier、Semaphore、Exchanger 这组并发工具第九章和第十章进入线程池与 Executor 框架。这里有个阅读上的关键点第二辑的内容是强依赖的。你不看第五章的 AQS就没法真正理解第八章的信号量凭什么能控制许可数不懂线程池的核心参数Executor 框架里的那些工厂方法对你来说就只是黑盒。我给团队的建议一直是按 锁 - 容器 - 原子类 - 工具类 - 线程池 的顺序读。我见过太多人跳过锁直接抢着用 ConcurrentHashMap遇到并发问题去翻源码时连 ReentrantLock 的 lock 方法为什么不直接 synchronized 都要停下来想半天这就是基础顺序没理顺。1.2 一条隐形的贯通线state 与队列第二辑里有一条贯穿始终的设计主线状态位加等待队列。AQS 是这种结构Condition 是这种结构Semaphore 也是这种结构甚至 ConcurrentHashMap 的扩容迁移控制字段 sizeCtl 也能看到同样的影子。另一个反复出现的设计原则是“能无锁就无锁非不得已再上锁”。作者不断提醒读者所有同步机制都有成本这也是为什么 ConcurrentHashMap 在 JDK8 里会从分段锁演进成 CAS 配合局部 synchronized。单看某一章的结论其实不难难的是把这些结论串起来。同样是基于 AQSReentrantLock 的 state 记录的是可重入次数Semaphore 的 state 记录的是剩余许可CountDownLatch 的 state 记录的是剩余计数。同一个字段在不同类里代表完全不同的业务含义这种抽象能力才是这本书真正值得反复琢磨的地方。读到这里我自己的感受是别把 AQS 当成一个只能被 JUC 使用的黑盒把它看成一套“排队与唤醒”的通用框架你的并发知识体系会顺畅很多。1.3 最优阅读姿势边读边写迷你版 AQS这本书不适合当小说一口气读完也不适合当字典遇到再翻。我的笨办法是每读完第五章的一个小节就把对应源码的断点打上看一眼线程入队前后状态怎么变化再盯着 state 从 0 到 1 再到 0 的过程。第二遍读的时候我顺手写了一个 100 行不到的 MiniAQS只支持独占锁但足够让我看清 acquireQueued 里的那层 for 循环到底在做什么。等这个迷你版本跑通后面再读 Semaphore、CountDownLatch 基本是顺水推舟。如果时间紧第十章 Executor 框架可以先通读把 FixedThreadPool 和 CachedThreadPool 在队列选择上的差异弄明白后面再针对具体参数做实验。第二辑不是一本读得快的书但只要你在第五章多花点时间后续章节的阅读速度会明显提上来因为你会发现自己读的已经不是陌生概念而是同一套思想在不同场景下的变体。2. AQS 与锁的底层实现别再说“AQS 就是一个队列”2.1 state、CLH 变体队列和模板方法AQS 的三层骨架AQS 是 JUC 包最核心的类没有之一。它的整体结构可以拆成三层。第一层是 volatile int state记录同步状态第二层是双向的 CLH 变体队列每个节点包装一个正在等待的线程第三层是模板方法你选择重写 tryAcquire 和 tryRelease 来实现独占锁或者重写 tryAcquireShared 和 tryReleaseShared 来实现共享锁剩下的入队、阻塞、唤醒、中断处理都由 AQS 的公共逻辑接管。特别容易误解的一点是CLH 队列本身并不持有锁它只存放那些“暂时没抢到锁的线程”。线程调用 lock 时会先尝试用 CAS 修改 state改成功就直接拿到锁改不成功才把自己包装成节点挂到队尾接着在 acquireQueued 里自旋或者 park。书里用“用 state 记录资源用队列记录等待者”这句话把结构说明白了但很多人写代码时容易把锁对象、等待队列、state 混成一个概念结果一到 Condition 就彻底绕晕。2.2 ReentrantLock 加锁解锁的完整过程把 ReentrantLock 的非公平锁走一遍是理解 AQS 的捷径。以非公平锁为例线程来抢锁时先不看队列里有没有人排着直接对 state 做 CAS从 0 改成 1成功就把 exclusiveOwnerThread 设为当前线程。如果 CAS 失败会进入 AQS 的 acquire然后走到 tryAcquire。此时如果发现锁已经被当前线程持有state 会加 1实现重入如果锁被别的线程持有就返回 false线程随即被封装成节点入队。入队之后是关键中的关键。acquireQueued 里有个 for 循环先检查当前节点的前驱是不是头节点如果是就再尝试抢一次锁抢不到就判断前驱节点的 waitStatus 是否为 SIGNAL是就把自己 park 住。解锁时tryRelease 会把 state 减 1只有减到 0 才清除 exclusiveOwnerThread再交给 AQS 去唤醒头节点的后继线程。整个过程解释了为什么 ReentrantLock 的 unlock 可以在完全不同的方法里执行也解释了节点状态里为什么是 -1 的节点才需要被唤醒。2.3 公平锁与非公平锁到底差在哪读过源码之后你很难再说“公平锁就是更公平”。公平锁在 tryAcquire 之前会先调 hasQueuedPredecessors队列里一旦有排队的节点新线程就老实去排队非公平锁则先抢一次抢不中才入队。这个差异带来的性能变化非常有意思非公平锁的吞吐量通常更好因为唤醒排队线程需要时间新来的线程可以利用这个窗口快速拿到锁减少线程切换但它确实可能让队列里某些线程等得更久。工程上的取舍很清楚如果业务对响应时间稳定性要求高可以选公平锁但要接受吞吐下降的代价如果系统本身没有强公平性要求用默认的非公平锁就好。书里特意点了一句ReentrantLock 的默认值不是拍脑袋定的多数并发场景下非公平锁能换来更好的整体吞吐公平锁用来防止极端饥饿。2.4 共享锁如何复用同一副骨架看完独占锁再看共享锁你会感受到 AQS 的抽象能力。CountDownLatch 把计数存进 stateawait 方法走 acquireSharedInterruptibly当 state 不为 0 时线程入队等待countDown 走 releaseShared对 state 做 CAS 减 1减到 0 就唤醒队列里的共享节点。Semaphore 的逻辑几乎一样只是 acquire 对应 state 减 1release 对应 state 加 1许可耗尽时线程入队。这里最值得琢磨的是 tryAcquireShared 的返回值约定小于 0 表示失败且需要入队等于 0 表示成功但已经没有剩余资源大于 0 表示成功且后续共享节点可以继续唤醒。读懂这套约定就会明白 CountDownLatch 和 Semaphore 虽然业务含义完全不同但底层走的却是同一套排队和唤醒机制。CyclicBarrier 有点特殊它没有完全依赖 AQS而是结合 ReentrantLock 和 Condition 实现了可复用的栅栏语义这部分还得多看几眼。3. 并发容器与数据一致性高频热点但最容易被用歪3.1 ConcurrentHashMap 从分段锁到 CAS 的演进JDK7 的 ConcurrentHashMap 用分段锁一个 Segment 锁住一段哈希桶最多可以并发写 16 个分段。JDK8 直接把分段锁去掉了改成对桶数组的头节点做 synchronized同时用 CAS 协助初始化、计数等操作。为什么要走这一步因为分段锁的粒度还是太粗扩容时要锁住整个 Segment代价不比全局锁小多少而 JDK8 的桶在冲突严重时会升级成红黑树锁住单个头节点就能控制一整条冲突链锁粒度细了很多。实际使用里ConcurrentHashMap 的并发安全只限于单次操作。如果有人告诉你“用了 ConcurrentHashMap 就一定安全”你要多留个心眼先用 containsKey 判断再 put这两个动作之间完全可能有别的线程插入元素先 get 再根据结果做 put同样会丢中间状态。书里没有把这句话说得特别直白但这是我在生产环境里看到最多的问题。任何“依赖旧值再做更新”的复合操作都不能因为容器本身是并发安全的就放弃外部锁。3.2 CopyOnWriteArrayList快照读的取舍CopyOnWriteArrayList 的原理很容易懂每次写都拿一把 ReentrantLock复制一份新数组在新数组上完成修改再用 volatile 引用把数组指针替换掉读的时候不加锁直接读当前引用指向的数组。因为读操作永远在读一个完整快照所以迭代时不会抛 ConcurrentModificationException也不会读到半修改状态。但这种便利要付出代价。每次写都是 O(n) 的数组复制内存占用和 GC 压力都会上升。我一般不建议把大集合塞进 CopyOnWriteArrayList 里做高频写它更适合监听器列表、配置项缓存这类“写极少、读极多、集合还不大”的场景。学这一节的重点不是记住某个类名而是理解并发容器的设计出发点每种容器都有一套适合自己的并发策略没有哪个容器是万能药。3.3 JMM 三要素原子性、可见性、有序性数据一致性是并发编程的核心话题但很多讨论一开始就把问题问歪了。Java 内存模型关注的是三件独立的事原子性一个操作要么整体生效要么整体不生效可见性一个线程的修改能不能被另一个线程及时看到有序性指令重排会不会导致逻辑结果和预期不一致。这三件事不是一个层面的东西所以也没法用同一种工具解决。synchronized 和 Lock 可以同时覆盖三者volatile 只保证可见性以及 volatile 变量自身读写的原子性原子类能保证单个复合操作的原子性但救不了“先读再写”这种业务场景。最经典的例子是多个线程对同一个 volatile 变量做 i结果总是小于等于线程数因为 i 根本不是原子操作volatile 只保证读到的值新不新解决不了两个线程同时读到旧值再写回去的问题。3.4 一张表理清 volatile、原子类、锁分别守住了什么给新人讲这块时我习惯用下面这张表快速定位不同同步手段的能力边界手段原子性可见性有序性典型使用场景volatile否自身读除外是部分保证状态开关、安全发布不可变对象原子类AtomicInteger 等是单操作是是计数器、累加器、统计数据synchronized / ReentrantLock是是是复合操作、整体保护共享资源这张表不是绝对真理因为 synchronized 内部还有锁升级和偏向锁之类的前置过程但用它来定位“为什么我用 volatile 修计数还是不准”已经够用了。书里对这块的表述很严谨核心建议是先想明白你要保护的是什么资源、操作是不是复合的、对数据新鲜度要求有多高再决定用哪种手段。这一节是整个第二辑的理论地基后面的并发工具类全都建立在这三要素的理解之上。4. 线程池参数不是背出来的从源码到真实业务4.1 三个核心参数如何协同工作ThreadPoolExecutor 的可配参数有五个加两个可选项但真正决定线程池行为的是 corePoolSize、maximumPoolSize 和 workQueue 之间的三角关系。任务刚提交时如果工作线程数小于 corePoolSize线程池会新建线程执行达到 corePoolSize 后新任务会放进队列队列满了才会继续创建线程直到 maximumPoolSize再超出的任务才会走拒绝策略。这套流程里最反直觉的是只要队列还没满线程池就不会把线程数扩到 corePoolSize 以上。哪怕你把 maximumPoolSize 配成 200只要 workQueue 是一个无界队列200 就只是摆设线程数永远贴着 corePoolSize 走。Executors.newFixedThreadPool 用的正是无界队列这是它在高并发下能把内存打满的根本原因。书里明确提过这一点但工程里还是经常能看到裸用工厂方法的代码。4.2 拒绝策略的正确打开方式四种内置拒绝策略各有脾气。AbortPolicy 是默认策略直接抛 RejectedExecutionException适合那些“任务失败要立刻暴露”的场景DiscardPolicy 和 DiscardOldestPolicy 都会静默丢任务使用之前必须确认业务允许丢CallerRunsPolicy 最温和它会让提交任务的线程自己去执行相当于一种变相限流。我自己踩过一个大坑为了不丢任务把所有线程池的拒绝策略都设成 CallerRunsPolicy。结果高峰期调用外部接口的线程全跑去执行内部通知任务接口响应时间瞬间拉高最后还是靠监控发现线程池指标异常才定位到。正确做法是组合使用先根据业务设定合理的队列容量再设置线程数上限拒绝时至少要打日志并考虑把任务落盘或者投递到消息队列兜底。4.3 线程数到底怎么算两个真实案例的计算过程线程数设置是个老生常谈的话题书里给了一个通用公式线程数 CPU 核数 / (1 - 阻塞系数)阻塞系数取值在 0 到 1 之间IO 密集型的阻塞系数很高CPU 密集型的接近 0。公式不是银弹但至少比“感觉”靠谱。我拿两个实际案例演示。案例一一套纯本地计算任务单核执行需要 1 秒机器是 8 核阻塞系数几乎为 0那么线程数 8 / (1 - 0) 8再加一个兜底线程取 9。这时候把线程池配到 64 反而会更慢线程切换和缓存失效的开销会吞掉并发带来的收益。案例二一个业务接口要查 MySQL 还要调外部 HTTP 服务单次总耗时约 200ms其中有 180ms 在等 IO。阻塞系数大致是 0.9机器 8 核线程数 8 / (1 - 0.9) 80。80 是理论启动值上线前一定要做压测校正但至少你已经有一个合理的基线而不是凭感觉填一个数字。4.4 给线程池加上名字和监控线程池调参不是一锤子买卖它需要数据和反馈。第一件事是自定义 ThreadFactory给每条线程起一个有业务含义的名字否则线上出问题的时候你看到的全是 pool-1-thread-x根本不知道是哪一个业务线程池在拖后腿。第二件事是持续记录核心指标taskCount、completedTaskCount、activeCount、queueSize。我一般会写一个定时任务每 30 秒采集一次按线程池名称分维度打印再对接已有的监控系统。书里没有花太大篇幅讲监控但这恰恰是把线程池知识落地的关键。你可以基于 ThreadPoolExecutor 写一个 Reporter 子类重写 beforeExecute 和 afterExecute 把任务耗时和执行结果埋点上报。从 Java 并发编程的实际体感来说Executor 框架的真正价值是把线程管理变成资源调度问题而不是让你每次想跑异步任务就 new 一个 Thread。5. 实战里的坑与排查记一次被并发问题支配的下午5.1 用 park 和 unpark而不是 while 循环自旋我早期手写同步逻辑时差点就用 while(true) 搭配 Thread.sleep(10) 去实现“等待某个状态”。这种写法在低并发时看不出问题一旦线程数上来CPU 会被空转耗尽。书里介绍的 LockSupport.park/unpark 才是正解AQS 内部也是靠它在管理阻塞线程。park 会让线程进入 WAITING 状态不消耗 CPUunpark 能精确唤醒指定线程。不过等待方式还是要看场景如果等待时间极短比如几个时钟周期的锁冲突自旋反而有优势如果等待时间可能达到毫秒级以上自旋就是事故。线上排查时我见过很多 CPU 飙到百分百的案例最后定位到 synchronized 获取失败的线程在反复自旋重试或者自定义循环在空转。遇到这种情况拿 jstack 一看线程状态是 RUNNABLE 还是 WAITING基本就能判断是自旋问题还是阻塞问题。5.2 Future.get 一定要加超时并且想清楚拒绝策略Future 是并发编程里最容易“卡住”的一环。拿到 Future 后直接调用 get()如果任务所在线程恰好被无界队列长期阻塞或者任务本身陷入死锁你的调用线程就会无限期等下去。书里介绍了 FutureTask 的实现但很少强调调用侧的防御。我的习惯是 Future.get(3, TimeUnit.SECONDS)超时抛 TimeoutException再根据业务决定是重试、降级还是记录日志。这个坑和线程池参数直接相关。无界队列意味着任务会不断堆积线程池只按 corePoolSize 速度消费Future.get 的等待时间会被无限拉长。我会把队列容量放进响应时间模型里估算队列长度乘上单任务平均耗时就是最坏情况下的等待时间。如果这个值超过了调用方的超时设置就必须重新设计队列长度或者切换拒绝策略。5.3 用 CompletableFuture 时别忽略公共线程池的干扰Lambda 和函数式接口让并发代码写起来流畅很多但 CompletableFuture 默认使用的 ForkJoinPool.commonPool 有隐藏的共享陷阱。这个公共线程池的并行度是 CPU 核数减一如果你在一个 4 核机器上无脑提交几百个阻塞型任务公共池会被占满所有依赖 commonPool 的其他异步逻辑全部跟着遭殃。书中讲解 Executor 框架时没专门提醒这个坑但我在实际项目里见过太多次。处理方式可以拆成两层。第一层所有耗时任务都显式传入自定义线程池不依赖默认的 commonPool。第二层lambda 捕获外部变量时确认变量是 effectively final否则编译器直接报错。我现在的做法是把 CompletableFuture 和自定义线程池组合成一套异步编排服务每个任务的超时时间、线程池、兜底结果都统一配置出问题时能从监控里直接按业务维度检索。5.4 常见并发问题速查表把我在项目和团队代码评审里反复遇到的并发问题整理成一张表方便对照排查现象可能原因先查什么多线程计数结果偏小i 非原子volatile 救不了是否使用原子类或加锁线程池线程数长期不变无界队列把新任务全吞了workQueue 类型和容量设置接口偶发超时CPU 不高队列堆积Future.get 无超时队列长度、超时时间配置线程名全是 pool-1-thread-x没自定义 ThreadFactoryThreadFactory 实现与命名规范同一用户数据读到旧值可见性问题或对象未安全发布volatile、final、加锁策略ThreadLocal 出现脏数据线程池复用线程导致残留任务结束后是否执行 remove这张表不能替代排查工具但能帮你快速建立怀疑方向。每次定位完线上并发问题我都会把结论补回团队文档书摘这种东西本来就适合做成可持续积累的知识库读完就扔是最可惜的用法。把这一辑读完后我做的第一件事是把团队里所有裸用的 Executors.newFixedThreadPool 改成显式构造的 ThreadPoolExecutor再配上自定义线程名和监控。之后又花了一个周末基于 AQS 的思路把 ReentrantLock 和 Semaphore 的迷你版重新实现了一遍。这些动作短期内看不到业务收益但下一次再遇到并发问题我就不再是“上网搜一个解法”的状态而是能顺着 state、队列、调度这几条线自己往下推。Java 并发编程的知识体系很容易被当成一堆零碎的面试题但这本书第二辑的价值就在于帮你把这堆碎片粘成一个整体。读的时候遇到卡壳别急着跳把源码断点打上一遍一遍走比问任何人都有用。
返回列表