ThreadLocal 线程池上下文传递

ThreadLocal 线程池上下文传递:从 InheritableThreadLocal 缺陷到 TransmittableThreadLocal 全解析 🤔 一、JDK 设计者为什么要给每个线程配一个"私房钱罐" 多线程编程中有一个经典矛盾:线程之间要共享一部分数据来协作,又要有各自私有的数据来隔离。共享数据靠锁来保护,私有数据呢?如果每建一个新线程都要手动传参数、写包装类,代码很快就变成意大利面条。 JDK 1.2 的设计者(Josh Bloch 等人)给出的方案是 ThreadLocal——每个线程维护一个私有的 ThreadLocalMap,key 是 ThreadLocal 实例,value 是你想隔离的数据。同一个 ThreadLocal 对象在不同线程中的值互不干扰。这个设计让"线程级上下文"(traceId、事务、用户 Session)变得自然:只要在主线程 set 一下,当前线程的任何方法都能 get 到,不需要在方法签名里一路传参。 但 JDK 设计者很快发现一个新问题:ThreadLocal 在线程间是完全隔离的——如果父线程 set 了值,新建子线程时,子线程拿不到。这就是为什么后来又有了 InheritableThreadLocal:它在 Thread 构造函数中触发 init(),将父线程 ThreadLocalMap 中标记为可继承的条目浅拷贝到子线程。 然而,ITL 的设计有一个致命缺陷——它只在 new Thread() 时触发传递。线程池复用已有线程,不再走 Thread 构造函数,ITL 的传递逻辑完全不执行。第一次提交任务时碰巧用的是刚创建的新线程(触发了一次传递),第二次复用同一个线程时,父线程的新值就传不过来了。 这就是阿里开源的 TransmittableThreadLocal 要解决的问题。它的核心思路是:不再依赖线程创建时的一次性传递,而是在每次提交任务时主动 capture 父线程的快照 → replay 到工作线程 → 任务完成后再 restore 还原。 阅读本篇文章的收获: InheritableThreadLocal 是如何在 Thread 构造函数中实现传递的?源码在哪一行触发? 为什么它在线程池中会失效?根源在 JDK 源码的哪一行? TransmittableThreadLocal 又是如何在源码层面解决这些缺陷的? capture() / replay() / restore() 三个方法各自做了什么? 🧵 二、ThreadLocal 基础回顾:数据到底存在哪里 在深入 ITL 和 TTL 之前,先快速回顾 ThreadLocal 的核心数据结构。如果你已经熟悉这部分,可以直接跳到第三章。 ...

九月 5, 2022 · 13 分钟 · 2586 字 · yaomingye

ScheduledThreadPoolExecutor 定时调度增强

ScheduledThreadPoolExecutor 定时调度增强:DelayedWorkQueue 二叉堆延时队列与 Spring 体系实战 🚀 道格·李为什么需要一个能定时的线程池 Java 1.3 引入的 java.util.Timer 是 JDK 最早提供的定时任务工具。但它有两个致命设计缺陷:① 单线程执行——一个任务执行时间过长,后面的所有任务都会延迟;② 异常吞没导致线程终止——任务抛了未捕获异常,Timer 线程静默死亡,剩余任务永远不会执行。第二个问题在生产环境尤其危险——线上定时取消超时订单的任务因为一个 NullPointerException 静默停止,几天后才被发现。 道格·李在设计 JSR 166 时,ScheduledThreadPoolExecutor 是 ThreadPoolExecutor 的直接扩展。它的设计策略是复用线程池的全部管理能力(线程生命周期、拒绝策略、钩子方法),只替换两个关键组件: 任务队列:用 DelayedWorkQueue(基于二叉堆的延时队列)替换 BlockingQueue,任务按触发时间排序,堆顶是最先到期的任务 任务类型:用 ScheduledFutureTask 替换普通的 FutureTask,增加了周期执行模式(固定频率 vs 固定延迟)和下次触发时间的计算逻辑 核心改进:线程池里有 N 个工作线程,一个任务异常不会影响其他线程和任务。Timer 的单线程弱点不再存在。 🏊 ScheduledThreadPoolExecutor 的整体架构 🔗 继承关系与组件概览 ScheduledThreadPoolExecutor 直接继承 ThreadPoolExecutor,在父类基础上替换了三个关键组件: flowchart LR %% ========================================== %% 样式定义 %% ========================================== classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#bfdbfe,font-weight:bold; classDef branch fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#fde68a,font-weight:bold; classDef leaf fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; ROOT[ScheduledThreadPoolExecutor\n继承 ThreadPoolExecutor] ROOT --> B1(1. 任务类型替换) B1 --> TASK["📦 ScheduledFutureTask\nextends FutureTask\n+ implements Delayed\n+ 三态 period 模型\n+ sequenceNumber 保序"] ROOT --> B2(2. 队列替换) B2 --> QUEUE["📥 DelayedWorkQueue\n自建二叉堆\n无界阻塞延迟队列\n扩展 leader/follower 模式"] ROOT --> B3(3. 调度方法替代) B3 --> SCHED["⚡ 三个入口方法"] SCHED --> S1["schedule()\n一次性延迟任务\nperiod = 0"] SCHED --> S2["scheduleAtFixedRate()\n固定速率\nperiod > 0"] SCHED --> S3["scheduleWithFixedDelay()\n固定延迟\nperiod < 0"] ROOT --> B4(4. 关闭后行为) B4 --> SHUT["🛑 两个布尔开关"] SHUT --> C1["continueExistingPeriodicTasksAfterShutdown\nshutdown 后是否继续执行周期任务"] SHUT --> C2["executeExistingDelayedTasksAfterShutdown\nshutdown 后是否执行已延迟的任务"] ROOT --> B5(5. 线程数策略) B5 --> SIZE["🔢 maximumPoolSize = Integer.MAX_VALUE\n队列无界,永远不需要额外线程\n仅核心线程数决定并发度"] class ROOT root; class B1,B2,B3,B4,B5 branch; class TASK,QUEUE,SCHED,SHUT,SIZE leaf; class S1,S2,S3,C1,C2 highlight; 五个改进点一句话总结 : ...

九月 4, 2022 · 12 分钟 · 2502 字 · yaomingye

CompletableFuture 异步编排

CompletableFuture 异步编排:Completion 链表与多中间件应用全解析 🤔 一、道格·李为什么需要比 Future 更强大的异步工具 Java 5 引入了 Future 接口和 FutureTask 实现,解决了"异步执行、获取返回值"的基础需求。到 Java 7 时代,Future 的局限已经非常明显:它只是一个结果的容器,没有回调机制——你不能在结果就绪时自动触发下一步操作,只能调用 get() 阻塞等待。 这在简单的"提交任务→等待结果"场景中够用,但面对以下需求时完全无力: 链式编排:A 的结果作为 B 的输入,B 完成后触发 C。用 Future 只能嵌套 get(),代码缩进越来越深 多结果组合:等 A、B、C 三个结果全部就绪后做汇总。用 Future 只能逐个 get(),最慢的那个决定了总耗时 异常传播:Future.get() 把异常包装成 ExecutionException,调用方需要捕获后 getCause()——异常处理散落在各处 道格·李在设计 CompletableFuture(Java 8 引入)时参考了 JavaScript 的 Promise 模式和函数式编程中的 monad 概念。核心思路是:把异步计算的结果建模为一条流水线——每个阶段接受上一个阶段的输出,产生下一个阶段的输入,阶段之间通过回调串联。这样开发者不需要手动管理线程和等待,只需要"声明"各个步骤之间的关系: // 声明式:订单+用户拼好,再拼优惠券 orderFuture.thenCombine(userFuture, this::mergeOrderUser) .thenCombine(couponFuture, this::assembleFinal); CompletableFuture 的革新在于把异步编程从"命令式等结果"推到了"声明式编排"——关心的不是什么时候拿到结果,而是结果拿到之后要做什么。 🔮 二、数据结构:CompletableFuture 内部长什么样 ⚙️ 2.1 核心字段 从 JDK 源码中看 CompletableFuture<T> 的结构定义(Java 8,java.util.concurrent.CompletableFuture): ...

九月 3, 2022 · 9 分钟 · 1825 字 · yaomingye

ThreadPoolExecutor 源码解析

ThreadPoolExecutor 源码解析:Worker 机制、生命周期、拒绝策略与动态线程池实践 🚀 道格·李为什么需要一个线程池 Java 1.0 就支持多线程,但管理线程生命周期这件事一直缺少标准方案。开发者每次需要异步执行时,要么 new Thread().start(),要么自己维护一个线程管理队列——前者浪费资源,后者极易出错。 一个线程的创建和销毁是有成本的。JVM 要为每个线程分配栈内存(默认约 1MB),操作系统要为每个线程维护内核线程表项和调度上下文。当并发请求量上来后,频繁创建/销毁线程会导致: 内存压力——大量线程的栈内存吃掉堆外空间 CPU 浪费在上下文切换——线程数远超 CPU 核心数时,CPU 的时间片都消耗在"换人"而不是"干活"上 线程数不可控——请求峰值时线程数无上限增长,最终 OOM 或系统不可用 道格·李在设计 JSR 166 时面对的核心问题是:如何让开发者既能享受多线程的并发收益,又不用直接管理线程的创建和销毁? 答案是把线程抽象为一种可复用的资源——线程池。 线程池的本质是一个"线程 + 任务队列"的组合:核心线程常驻,任务多时创建临时线程分担,任务少时回收空闲线程,任务太多时由拒绝策略兜底。从设计上看,ThreadPoolExecutor 把线程的创建策略(core/max)、存活策略(keepAliveTime)、排队策略(workQueue)和过载策略(rejectedExecutionHandler)全部暴露为可配置参数——这正是道格·李的设计风格:不替开发者做决定,而是把决策权交给调用方。 📐 七大核心参数 ThreadPoolExecutor 最完整的构造器接受 7 个参数: public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) ⚙️ 1. corePoolSize — 核心线程数 线程池中始终存活的线程数量(除非 allowCoreThreadTimeOut 设为 true)。即使这些线程当前空闲,也不会被回收。 关键行为:当提交任务时,即使有空闲的核心线程,只要当前线程数少于 corePoolSize,线程池也会继续创建新的线程——先凑够核心线程数量,再谈复用。这种"先扩容再复用"是出于设计上的简单性:判断是否达到核心线程数的开销远小于判断是否有空闲线程且空闲线程是否可用。 📐 2. maximumPoolSize — 最大线程数 线程池允许创建的最大线程数。只有当工作队列已满且当前线程数不足 maximumPoolSize 时,才会创建超出核心线程数的额外线程。 ...

九月 2, 2022 · 20 分钟 · 4131 字 · yaomingye

ForkJoinPool 源码深度解析

ForkJoinPool 源码深度解析:从分治思想到工作窃取的完整实现 🤔 一、道格·李为什么需要一个"能偷工作"的线程池 Java 5 的 ThreadPoolExecutor 解决了线程复用的问题,但它有一个结构性的局限:所有线程共享一个任务队列。当一个线程提交了子任务后阻塞等待子任务结果,而子任务又在同一个队列里等待被执行时,就会发生线程饥饿——等待的线程占着一个槽位但不干活,队列里的子任务没人执行,形成死锁。 这个问题在递归分治算法(把大问题拆成小问题递归求解)中尤为致命。分治算法天然适合并行——子问题之间互不依赖,可以同时计算。但如果每个线程都把子任务扔到共享队列然后等结果,队列很快就会堆满等待被执行的任务而所有线程都在等。 道格·李在 Java 7 中引入 ForkJoinPool 时,核心创新是工作窃取(Work-Stealing): 每个工作线程有自己的双端队列(Deque),线程从自己的队列头部取任务 当一个线程 fork 子任务时,子任务被 push 到该线程自己的队列 当线程自己的队列空了,它会从其他线程的队列尾部窃取任务来执行 这个设计解决了两个问题:① 递归 fork 的子任务不会堵塞共享队列;② 快线程不会空等——它会偷慢线程的活来干。ForkJoinPool 也是 Java 8 并行流(parallelStream())的底层引擎。 二、设计思想:分治算法 + 工作窃取 📌 2.1 分治算法(Divide-and-Conquer) ForkJoinPool 的设计基础是分治算法(Divide-and-Conquer),其核心过程为三个方面: flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; ROOT[根任务\n问题规模N] ROOT -->|拆分| L1[子任务\n规模N/2] ROOT -->|拆分| R1[子任务\n规模N/2] L1 -->|继续拆分| L2[子任务\n规模N/4] L1 -->|继续拆分| R2[子任务\n规模N/4] R1 -->|继续拆分| L3[子任务\n规模N/4] R1 -->|继续拆分| R3[子任务\n规模N/4] L2 -->|达到阈值\n直接计算| SOLVE1[原子任务] R2 -->|达到阈值\n直接计算| SOLVE2[原子任务] L3 -->|达到阈值\n直接计算| SOLVE3[原子任务] R3 -->|达到阈值\n直接计算| SOLVE4[原子任务] SOLVE1 -->|合并| MERGE1[汇总结果] SOLVE2 -->|合并| MERGE1 SOLVE3 -->|合并| MERGE2[汇总结果] SOLVE4 -->|合并| MERGE2 MERGE1 -->|最终合并| FINAL[最终结果] MERGE2 -->|最终合并| FINAL class FINAL,L1,L2,L3,MERGE1,MERGE2,R1,R2,R3,SOLVE1,SOLVE2,SOLVE3,SOLVE4 process; class ROOT root; 阶段 操作 说明 Divide(拆分) fork() 将大任务递归拆分为小任务,直到达到阈值 Conquer(求解) compute() 对原子任务执行实际计算 Combine(合并) join() 递归汇总所有子任务的结果 📌 2.2 工作窃取算法(Work-Stealing) 普通的 ThreadPoolExecutor 使用单一共享阻塞队列(BlockingQueue),所有线程竞争同一个队列的头元素,存在单点竞争瓶颈。ForkJoinPool 则采用完全不同的设计:每个工作线程维护自己的双端队列(WorkQueue)。 ...

九月 2, 2022 · 20 分钟 · 4180 字 · yaomingye

BlockingQueue 设计解析:四组方法语义、锁机制分化与生产者-消费者模型的工程实践

BlockingQueue 设计解析 🤔 道格·李为什么需要一个阻塞队列接口 生产者-消费者模式是多线程编程里最常见的协作模型——一个(或多个)线程生产数据,另一个(或多个)线程消费数据。在 JUC 出现之前,Java 开发者只能用 wait() / notify() 手写这个模型。 手写版本的典型代码如下: public synchronized void put(E e) throws InterruptedException { while (list.size() == capacity) { wait(); } list.addLast(e); notifyAll(); } 这段代码表面正确,但道格·李在分析并发程序的常见错误时发现了几个根深蒂固的问题: 生产者唤醒生产者:notifyAll() 唤醒等待队列里的所有线程——包括生产者和消费者。当队列满时,多个生产者同时被唤醒,只有第一个能成功插入,其余又回到 wait。这些"无效唤醒"不是 Bug,但大量浪费 CPU 无法区分等待原因:所有线程在同一个条件队列上等待,生产者因为"队列满"而等,消费者因为"队列空"而等。notifyAll() 叫醒所有人,但被叫醒的线程可能发现条件仍不满足,继续睡——这就是为什么 wait() 必须放在 while 循环里 没有标准接口:每个项目都在重新发明这个轮子,而且各自的行为语义不一致——有的用 null 表示失败,有的抛异常,有的阻塞等待 道格·李的解决方案是两层的:接口层——BlockingQueue 接口定义了四组标准方法(抛异常、返回特殊值、阻塞、超时),统一了所有阻塞队列的行为契约。实现层——用 ReentrantLock 的两个 Condition(notFull 和 notEmpty)精确分离生产者与消费者的等待条件,让"队列满"只唤醒消费者,“队列空"只唤醒生产者,消除无效唤醒。 🚧 BlockingQueue 接口设计:四组方法的语义定义 BlockingQueue 接口最核心的设计决策在于: 同一操作提供四种不同的线程协作策略 ,以方法名区分行为,以返回类型区分语义。 行为模式 插入 移除 检查 语义 抛异常 add(e) remove() element() 操作无法立即执行时抛出 IllegalStateException ,调用方需自行处理 返回特殊值 offer(e) poll() peek() 操作无法立即执行时返回 false 或 null ,调用方通过返回值判断是否成功 阻塞 put(e) take() — 操作无法立即执行时阻塞当前线程,直到条件满足,调用方被挂起 超时 offer(e, t, u) poll(t, u) — 操作无法立即执行时阻塞最多指定时长,超时返回 false 或 null 四组方法的核心设计哲学是"让调用方选择等待策略而非被动接受”。 同一个"放入元素"的需求,调用方可以根据业务场景选择: ...

八月 31, 2022 · 9 分钟 · 1713 字 · yaomingye

FutureTask 源码深度解析

FutureTask 源码深度解析:从 Runnable 的局限到异步结果获取的完整实现 🤔 一、道格·李为什么需要一个"身兼两职"的任务对象 Java 的 Thread 构造函数接受 Runnable,但 Runnable.run() 返回值是 void——执行完就完了,拿不到结果。在 Java 1.0 ~ 1.4 时代,想在主线程拿到子线程的计算结果,只能靠共享变量(比如把结果写进一个 final int[] result = new int[1]),这种写法没有类型安全,也无法向调用方传递异常。 道格·李在 Java 5 的 JSR 166 中为这个问题设计了三个层次: 第一层:Callable<V>——任务接口。和 Runnable 功能等价,但 call() 有返回值且可抛异常。解决了"任务有结果"的问题。 第二层:Future<V>——结果句柄。提供 get()(阻塞获取结果)、cancel()(取消任务)、isDone()(判断完成)等方法。解决了"怎么拿到异步结果"的问题。但它只是一个接口,不知道任务在哪执行、怎么执行。 第三层:FutureTask——把两者粘在一起。它同时实现了 RunnableFuture<V> 接口(该接口同时继承 Runnable 和 Future),所以一个 FutureTask 对象既是可执行的任务(可以传给 Thread 或提交给 Executor),又是可查询的结果句柄(可以 get() 拿结果、cancel() 取消)。 道格·李这个设计的精巧之处在于:通过 FutureTask 这个"桥梁",ExecutorService.submit(Callable) 可以把任意 Callable 包装成 FutureTask,提交到线程池执行后立即返回 Future 句柄——调用方拿到了一个"未来的结果承诺",可以继续干别的事,需要结果时再 get()。 🔮 二、类继承体系:RunnableFuture 的双重身份 🏗️ 2.1 继承结构图 flowchart LR %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; R[Runnable\nvoid run] F[Future\nget/cancel/isDone] RF[RunnableFuture\n同时继承Runnable+Future] FT[FutureTask] C[Callable\nV call throws Exception] R --> RF F --> RF RF --> FT C -->|组合-而非继承| FT class F,FT,R,RF process; class C reject; 接口/类 角色 核心方法 Runnable 可执行任务 void run() Callable<V> 有结果的任务 V call() throws Exception Future<V> 结果句柄 get(), cancel(), isDone() RunnableFuture<V> 二者的桥接接口 继承 Runnable + Future,无新增方法 FutureTask<V> 具体实现 组合 Callable,实现所有逻辑 📌 2.2 RunnableFuture 接口 public interface RunnableFuture<V> extends Runnable, Future<V> { void run(); } 这个接口只有 3 行,没有新增任何方法,只是将 Runnable 和 Future 合并。它的价值在于类型层面的统一——一个 RunnableFuture 实例可以同时作为任务提交给线程池和作为 Future 供调用方查询结果。 ...

八月 31, 2022 · 11 分钟 · 2292 字 · yaomingye

ConcurrentLinkedQueue

ConcurrentLinkedQueue:无锁并发队列、Michael-Scott 算法与 HOPS 延迟更新机制全解析 🚀 道格·李为什么需要一个无锁队列 生产环境中大量使用多生产者-多消费者的队列模型:多个线程投递任务,多个线程取出执行。传统的做法是用 LinkedList 加 synchronized——任意时刻只有一个线程能操作队列,其他线程排队等锁。在高并发下,锁争用(Lock Contention)迅速成为吞吐量瓶颈:CPU 时间大量消耗在线程的阻塞-唤醒切换上,而非实际的消息处理。 道格·李在设计 JSR 166 时为这个场景引入了一个完全不同的方案:无锁并发队列。ConcurrentLinkedQueue 使用 CAS(Compare-And-Swap,比较并交换)原子操作替代锁,基于 Michael-Scott 算法(1996 年由 Maged Michael 和 Michael Scott 提出的无锁队列算法)实现多线程并发入队和出队。 核心设计思想: 入队和出队操作各自独立——生产者在队尾 CAS 插入,消费者在队头 CAS 移除,彼此不阻塞 没有锁,就不会有线程被操作系统挂起——CAS 失败意味着有其他线程抢先了一步,重试即可,没有上下文切换开销 HOPS 延迟更新策略——tail 指针不必每次都更新到最后一个节点,允许滞后 1 ~ 2 个位置,用额外的 CAS 判断换来更少的 volatile 写操作 ConcurrentLinkedQueue 是 JUC 无锁数据结构的基础模型——理解了它的 CAS 操作模式和 HOPS 策略,再看 ConcurrentHashMap、LinkedTransferQueue 等无锁结构会轻松很多。 🏗️ 核心数据结构 整体架构:基于 Node 的单向链表 ConcurrentLinkedQueue 的底层是一个 单向链表,由 head 和 tail 两个 volatile 指针维护: ...

八月 28, 2022 · 12 分钟 · 2547 字 · yaomingye

ConcurrentHashMap 深度解析:数据结构、线程安全与扩容机制

ConcurrentHashMap 深度解析:从数据结构到线程安全的底层实现 一、道格·李为什么需要重新设计一个并发哈希表 Java 1.0 提供了 Hashtable——一个线程安全的 Map 实现。它的线程安全策略很简单:在所有 public 方法上加 synchronized。这个策略正确但不实用——任何时候只有一个线程能操作整个表,即使两个线程操作的是不同的键。在 1.0 时代并发不常见时还凑合,到了 Java 5 时代,服务器端的多线程访问同一个缓存 Map 已经是常规操作,Hashtable 的全局锁成了吞吐量的天花板。 HashMap 是 Hashtable 的非线程安全替代,性能好得多,但一旦多线程并发 put,就会出现数据丢失、size 计数错误,甚至在 JDK 7 扩容时出现链表成环导致 CPU 100%。 道格·李在设计 ConcurrentHashMap 时面临的问题是:既要保证线程安全(不能丢数据),又要提供接近 HashMap 的并发吞吐量(不能全局锁)。这是两个互相矛盾的目标,传统的 synchronized 方案只能取其一。 道格·李的解决方案是把锁的粒度从"整张表"缩小到"单个桶"。JDK 5 ~ 7 中用了 Segment 分段锁(16 个段,每段独立加锁),JDK 8 进一步细化为桶级别 CAS + synchronized——对空桶用 CAS 无锁插入,对非空桶只锁链表/红黑树的头节点。这种设计让 16 个线程同时操作 16 个不同桶时完全无竞争,并发度从 Hashtable 的 1 提升到桶的数量级。 🗺️ 二、ConcurrentHashMap 的数据结构 JDK 8 的 ConcurrentHashMap 放弃了 JDK 7 的 Segment 分段锁设计,直接采用与 HashMap 相似的结构: Node 数组 + 链表 + 红黑树 。 ...

八月 27, 2022 · 15 分钟 · 3061 字 · yaomingye

CopyOnWriteArrayList 源码深度解析

CopyOnWriteArrayList 源码深度解析:从线程安全列表到写时复制的实现原理 一、道格·李为什么需要"写时复制"的列表 Java 1.0 的 Vector 用 synchronized 保证线程安全——所有方法加锁,读写都互斥。Collections.synchronizedList 同理。这在读多写少的场景中有一个严重的浪费:多个线程同时读取不应该互相阻塞,因为读取不修改数据。 但去掉锁也不行——ArrayList 的迭代器有 fail-fast 机制,遍历时如果有其他线程写入,直接抛 ConcurrentModificationException。而且多线程同时 add() 还会导致数据丢失(elementData 数组和 size 计数器都没有同步保护)。 道格·李在 JSR 166 中为这个场景设计了一个完全不同的策略:写时复制(Copy-On-Write)。每次写入(add、set、remove)不直接修改原数组,而是复制一份新数组,在新数组上操作,最后用 volatile 写把引用指向新数组。读操作完全无锁——直接读当前的数组引用,不需要任何同步。 这个设计的取舍非常明确:写操作很贵(要复制整个数组),但读操作极其便宜(无锁 + volatile 读)。因此 CopyOnWriteArrayList 仅适用于读多写极少(比如读:写 > 100:1)的场景——配置信息、监听器列表、白名单等写入很少但频繁遍历的数据结构。 📐 二、设计理念:Copy-On-Write 📌 2.1 什么是写时复制 写时复制(Copy-On-Write,COW)是一种并发优化策略。它的核心思想只有一句话:当容器需要被修改时,不直接在原数组上操作,而是先复制一份新数组,在新数组上修改,修改完成后用新数组替换旧数组的引用。 这就保证了:读操作永远在不变的数组上进行,完全不需要加锁;写操作虽然开销大,但只影响它自己,不会阻塞任何读线程。 📌 2.2 CopyOnWriteArrayList 的架构总览 CopyOnWriteArrayList 的并发安全由"一锁一数组"两个组件配合完成: flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; COW[CopyOnWriteArrayList] COW --> ARRAY["array: volatile Object[]"] COW --> LOCK["lock: ReentrantLock"] ARRAY --> A0["元素0"] ARRAY --> A1["元素1"] ARRAY --> A2["元素2"] ARRAY --> AN["元素N"] LOCK --> W["写线程"] ARRAY --> R["读线程"] W --> |写操作加锁| LOCK R --> |volatile读无锁| ARRAY class A0,A1,A2,AN,ARRAY,COW,LOCK,Object,R,W process; 组件 类型 作用 array volatile Object[] 存储元素,volatile 保证写后对其他线程立即可见 lock final ReentrantLock 写操作的互斥锁,同一时刻只允许一个写线程 array 加上 volatile 是关键——写线程完成数组替换后,volatile 写语义将所有读线程看到的旧引用刷成新引用,保证最终一致性。 ...

八月 27, 2022 · 9 分钟 · 1854 字 · yaomingye
Cat Radio