CompletableFuture 到底该怎么用:从方法、技巧、踩坑到源码

CompletableFuture 到底该怎么用:从方法、技巧、踩坑到源码 很多人第一次接触 CompletableFuture,感觉它很优雅:链式调用、异步编排、异常处理、多个任务聚合,看起来比手写线程池高级得多。 但真到项目里,问题往往不是“不会用”,而是“以为自己会用”。 比如: 为什么有的回调在主线程执行,有的又跑到了线程池里? 为什么 thenApply() 和 thenApplyAsync() 只差一个 Async,行为却可能完全不同? 为什么我明明用了异步,结果接口还是卡住了? 为什么 allOf() 执行完了,结果还得自己一个个 join() 去拿? 为什么异常被包成了 CompletionException,日志看起来特别恶心? 为什么线上机器 CPU 很高,最后发现是把阻塞 IO 全扔进了 ForkJoinPool.commonPool()? 这篇文章不打算只列 API,而是把 CompletableFuture 真正在工程里怎么用、怎么避坑、JDK 为什么这么设计,以及核心源码怎么运作,一次讲透。 一、先建立一个正确心智模型 很多人把 CompletableFuture 理解成“异步线程工具”,这个说法不算错,但太浅了。 更准确一点,它其实同时做了两件事: 它是一个 Future,代表“未来某个时刻会完成的结果”。 它是一个 CompletionStage,代表“这个结果完成之后,还能继续挂后续动作的可编排节点”。 也就是说,CompletableFuture 不只是“拿结果”,更重要的是“描述依赖关系”。 你可以把它想成一个节点: 节点里最终会有一个结果,或者一个异常。 节点完成后,会触发依赖它的后续节点。 节点和节点之间可以串行、并行、汇聚、竞争。 这才是它和传统 Future 最大的区别。 传统 Future 的典型写法是: Future<Order> future = executor.submit(this::queryOrder); Order order = future.get(); 问题在于: get() 是阻塞的。 不能优雅地声明“拿到订单后再查用户”。 多个 Future 的组合很难写。 而 CompletableFuture 的核心价值,就是把“等待结果”和“结果完成后的动作”拆开。 二、最常用的几类 API,到底该怎么分 1. 创建任务 最常见的是这两个: ...

April 26, 2026 · 5 min · 990 words · WY

彻底搞懂 Monad:从概念到 Java 实战

彻底搞懂 Monad:从概念到 Java 实战 你可能每天都在用 Optional.flatMap、Stream.flatMap、CompletableFuture.thenCompose,却不知道它们背后的统一模型——Monad。本文带你一次性搞懂 Monad 是什么、为什么需要它、在 Java 中如何应用,以及如何自己设计 Monad。 1. 从问题出发:为什么需要 Monad? 先看一段典型的代码: User user = findUserById(id); if (user != null) { Address addr = user.getAddress(); if (addr != null) { String city = addr.getCity(); return city; } } return "Unknown"; 这段代码里有 两个判空,如果还有更多步骤,if 会层层嵌套,难以阅读和维护。 这种 “带有空值可能性的计算” 非常常见,每次我们都要手动检查,写一堆样板代码。 类似的场景还有: 异步计算:拿到 Future 后必须等它完成才能继续,还要处理异常。 多值计算:处理集合时,每个元素返回一个集合,然后要手动 addAll 合并。 可失败计算:方法可能抛出异常,每一步都要 try-catch。 Monad 的使命就是 把这些重复的控制逻辑抽离出来,让你只关心“下一步做什么”,而不是“上一步是否成功/有值/已完成”。 2. Monad 是什么? Monad 是一种设计模式,它定义了一个容器类型(比如 Optional<T>),并提供两个操作: unit(也叫 return、of、pure):把一个普通值包装进 Monad。 flatMap(也叫 bind、>>=、thenCompose):把容器里的值取出来,应用一个函数(这个函数返回同类型容器),然后自动 展平 结果,避免嵌套。 用公式表达(Haskell 风格): ...

April 26, 2026 · 4 min · 662 words · WY

JDK线程、虚拟线程、Go协程与Netty Event Loop:调度模型和阻塞队列全景解析

目录 目录 引子:这几个东西为什么总被拿来一起比较 先统一一个视角:它们本质上都在解决调度问题 JDK 平台线程:1:1 映射内核线程 JDK 虚拟线程:M:N 调度,但语义仍然是 Thread Go 协程:语言运行时主导的 GMP 调度模型 Netty Event Loop:少量线程驱动大量连接 把四者放在一张图里看 它们之间到底是什么关系 一个关键问题:阻塞到底意味着什么 JDK 阻塞队列全景梳理 ArrayBlockingQueue LinkedBlockingQueue SynchronousQueue PriorityBlockingQueue DelayQueue LinkedTransferQueue LinkedBlockingDeque 阻塞队列之间的核心区别 这些队列和线程模型怎么配合 怎么选:按场景给结论 最后总结 引子:这几个东西为什么总被拿来一起比较 很多人第一次接触这几个概念时,会感觉它们像是同一层面的东西: JDK 线程 JDK 虚拟线程 Go 协程 Netty Event Loop JDK 阻塞队列 但它们其实分布在不同层次: 线程 / 协程 / 虚拟线程 解决的是执行单元怎么表示 调度器 解决的是谁来跑、什么时候跑 Event Loop 解决的是I/O 事件如何复用少量线程 阻塞队列 解决的是任务或数据如何在并发单元之间传递 把这些层次混在一起,就很容易得出错误结论,比如: “虚拟线程就是 Java 版协程” “Netty loop 就是协程调度器” “用了虚拟线程,就不需要线程池和队列了” “所有生产者消费者问题都用 LinkedBlockingQueue 就行” 这篇文章的目标,就是把这些东西放到一张统一地图里。 ...

April 22, 2026 · 5 min · 905 words · WY

Java引用类型详解:从源码到实战

目录 目录 引言 先搞清楚一个前提:可达性分析 强引用(Strong Reference) 软引用(Soft Reference) 基本用法 源码级分析:MyBatis的SoftCache 实际开发:用软引用做本地缓存 弱引用(Weak Reference) 基本用法 源码级分析:ThreadLocal为什么用弱引用 源码级分析:WeakHashMap的工作原理 实际开发:用WeakHashMap避免监听器泄漏 实际开发:Tomcat的ConcurrentCache 虚引用(Phantom Reference) 基本用法 虚引用 vs finalize():为什么要用虚引用 源码级分析:JDK的Cleaner机制 源码级分析:DirectByteBuffer如何释放堆外内存 实际开发:用虚引用监控资源泄漏 ReferenceQueue:引用回收的通知机制 核心机制 源码级分析:ReferenceHandler线程 实际开发:用ReferenceQueue实现自动清理的缓存 一张图总结 常见问题与解答 Q1:ThreadLocal用弱引用为什么还会内存泄漏? Q2:软引用和弱引用做缓存有什么区别? Q3:虚引用的get()为什么永远返回null? Q4:ReferenceQueue的poll和remove有什么区别? Q5:什么时候该用WeakHashMap? 引言 提到Java的引用类型,很多人脑子里只有一句"强软弱虚",但真到面试或者写代码的时候,往往一问就懵。这篇文章我打算换个思路——不搞概念罗列,直接从JDK源码和实际开发场景出发,把每种引用类型到底解决了什么问题、怎么用的,掰开了讲清楚。 先搞清楚一个前提:可达性分析 在讲引用类型之前,得先明白JVM是怎么判断一个对象该不该回收的。JVM使用的是可达性分析算法:从GC Roots出发,沿着引用链往下找,如果某个对象到GC Roots没有任何引用链可达,那这个对象就是可回收的。 四种引用类型的区别,本质上就是:GC Roots到对象的这条引用链,有多"硬"。 强引用:铁链子,GC死也不断 软引用:橡皮筋,内存不够就断 弱引用:纸糊的,GC一来就断 虚引用:根本不算链子,只是个事后通知 强引用(Strong Reference) 强引用没什么好说的,就是平时写的 Object obj = new Object()。只要引用还在,GC宁可抛OOM也不会回收这个对象。 但强引用有个容易踩的坑——长生命周期对象持有短生命周期对象的引用。比如: public class UserController { // 这是一个长生命周期的缓存,value是强引用 private static final Map<Long, User> userCache = new HashMap<>(); public User getUser(Long userId) { return userCache.computeIfAbsent(userId, id -> loadFromDb(id)); } } 这个 userCache 是 static 的,生命周期跟类一样长。放进去的 User 对象永远不会被回收,哪怕已经没有任何业务代码在使用它了。这就是典型的内存泄漏——对象已经没用了,但因为强引用还在,GC回收不了。 ...

April 15, 2026 · 8 min · 1603 words · WY

线程池里用 InheritableThreadLocal 传值,怎么就丢了?

线程池里用 InheritableThreadLocal 传值,怎么就丢了? 你肯定遇到过这种怪事:代码里明明用了 InheritableThreadLocal,信心满满地觉得子线程能自动继承主线程的变量,结果一跑,值没了,或者串了。 我第一次碰到这问题时, debug 了一下午,最后发现是线程池的锅。今天我就把当时挖出来的东西从头捋一遍,包括 Java 源码里到底是怎么拷贝的,还有阿里那个 TransmittableThreadLocal 到底是怎么救场的。 先看 InheritableThreadLocal 是怎么“继承”的 每个 Java 线程,也就是 Thread 类的实例,肚子里都揣着两个 ThreadLocalMap: 一个叫 threadLocals,专门给普通的 ThreadLocal 存变量。 另一个叫 inheritableThreadLocals,给 InheritableThreadLocal 用。 这两个 map 平时都是懒加载的,你第一次 set 的时候才会创建。 当我们 new 一个子线程的时候,事情就发生在 Thread 的 init() 方法里。这个方法会在子线程的构造函数中被调用,关键的一段逻辑是这样的(我摘了简化版的源码): if (parent.inheritableThreadLocals != null) { this.inhertableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); } 也就是说,如果父线程(也就是创建子线程的那个线程)的 inheritableThreadLocals 里面有东西,就把整个 map 拷贝一份,作为子线程的 inheritableThreadLocals。 那 createInheritedMap 是怎么拷贝的呢?它实际上是调用了 ThreadLocalMap 的一个特殊构造方法: private ThreadLocalMap(ThreadLocalMap parentMap) { // ... 一些初始化代码 ... for (int i = 0; i < len; i++) { Entry e = parentMap.table[i]; if (e != null) { ThreadLocal<Object> key = (ThreadLocal<Object>) e.get(); if (key != null) { // 注意这一行:调用了 childValue 方法 Object value = key.childValue(e.value); Entry c = new Entry(key, value); // ... 把新 entry 放到子线程的 map 里 ... } } } } 重点就是这个 childValue 方法。它是 InheritableThreadLocal 里定义的一个 protected 方法,默认实现就是直接返回传进来的值: ...

April 15, 2026 · 2 min · 336 words · WY