本文是《Java 多线程从零到一》系列第 03 篇,初级篇收官。前两篇讲了”怎么创建线程”和”怎么保证线程安全”,这一篇讲多个线程怎么互相配合干活。
一、wait() / notify() / notifyAll() 机制
1.1 为什么需要”等待-唤醒”?
想象一个场景:
- 厨师(生产者)做好菜放到传菜台上
- 服务员(消费者)从传菜台上取菜送出去
传菜台容量有限(比如只能放 3 盘菜):
- 台子满了,厨师不能再放 → 厨师等待
- 台子空了,服务员不能取 → 服务员等待
- 厨师放了一盘菜 → 通知服务员”有菜了”
- 服务员取走一盘菜 → 通知厨师”有空位了”
这种”条件不满足就等,条件满足了就叫醒对方”的机制,就是 wait() / notify() 要解决的问题。
1.2 基本用法和铁律
1 | synchronized (lock) { // 铁律①:必须在 synchronized 里面 |
为什么要加 synchronized? wait() 的语义是”释放锁、去等待”——你都没拿锁,谈何释放?没锁直接调 wait() 会抛 IllegalMonitorStateException。
为什么用 while 不用 if? 被唤醒后,条件可能又被别的线程改了(唤醒到你重新抢到锁之间有时间差)。while 会重新检查条件,不满足就继续等;if 只检查一次,醒来就往下走,容易出错。
为什么用 notifyAll() 不用 notify()? notify() 只随机叫醒一个,可能叫醒的是”不需要醒”的那个(比如叫醒的还是厨师,但台上已经满了)。notifyAll() 叫醒所有人各自检查,保证不漏。多花一点 CPU,换安全,值。
1.3 底层原理:两个”等候区”
每个对象的锁背后有两个等候区:
- EntrySet(锁池):想抢锁但没抢到的线程,在这里排队(BLOCKED 状态)
- WaitSet(等待池):调了
wait()的线程在这里睡觉(WAITING 状态)
流程:
- 线程调
wait()→ 释放锁,从”持锁”进入 WaitSet 睡觉 - 另一个线程调
notifyAll()→ 把 WaitSet 里所有人都叫醒,挪到 EntrySet 排队抢锁 - 抢到锁的那个,从
wait()调用处继续往下执行
1.4 实战:生产者-消费者模型
用 wait/notifyAll 实现一个”有界消息队列”——满了生产者等,空了消费者等:
1 | package com.frank.concurrent.ch03; |
测试:
1 | package com.frank.concurrent.ch03; |
某次输出:
1 | [生产者] 放入 消息-1,长度=1 |
1.5 wait() vs sleep() 对比
| 维度 | wait() |
sleep() |
|---|---|---|
| 释放锁 | 释放(让别人能干活) | 不释放(抱着锁睡) |
| 唤醒条件 | notify()/notifyAll() 或中断或超时 |
时间到或中断 |
| 使用前提 | 必须在 synchronized 里 |
没有锁要求 |
| 典型用途 | 线程协作(等数据/等空位) | 单纯暂停一会儿 |
一句话:wait 是”等通知”(把锁让出去),sleep 是”睡一觉”(锁不离手)。
常见误区提醒
- 用
if判断等待条件是最常见的错误——醒来后必须重新检查条件,永远用while。notify()之后锁不会立刻移交——当前线程把同步块走完才释放,被叫醒的线程只是获得了”竞争资格”。
二、join() 与 yield()
2.1 join():等子线程干完再继续
场景:主线程启动了 4 个子线程分头算数据,接下来要汇总——必须等它们全部完成。
t.join() 的意思就是:”当前线程在这里等,直到 t 结束”。
1 | package com.frank.concurrent.ch03; |
输出:
1 | 主线程等子线程... |
join() 还可以加超时参数:join(1000) 表示最多等 1 秒,超时就放弃(子线程可能还活着)。
原理小知识:join() 内部其实是调 wait()——在被等的线程对象上等待,线程结束时 JVM 会自动调 notifyAll() 唤醒等待者。所以 join 和 wait 一样响应中断。
2.2 实战:主线程等多个子线程后汇总
1 | package com.frank.concurrent.ch03; |
输出:
1 | 期望值:5000000500000 |
每个线程写 partial 数组的不同位置,天然没有竞争;join() 之后汇总——这是并行计算最基础的”分治”骨架。
2.3 yield():让出 CPU 时间片
Thread.yield() 是个”提示”:告诉调度器”我愿意让出当前时间片”。但仅仅是提示——调度器可以完全无视。
关键点:
- 不释放锁(和
wait的本质区别) - 效果不确定:让出后可能立刻又被调度上来了
- 实际开发几乎不用,需要确定性协作请用
join/wait/ 锁
思考与练习
- 把
ParallelSum里的partial数组换成一个普通的long total变量,每个线程直接total += k,不加任何同步——跑几次看结果对不对,为什么?- 把
worker.join()移到worker.start()后紧邻的位置(启动一个等一个),输出会变成什么?还有并行效果吗?
三、ThreadLocal:线程局部变量
3.1 ThreadLocal 是干什么的?
场景:Web 应用里,Controller 拿到当前登录用户信息后,深层的业务代码也需要用户 ID——难道要一层层传参数?太麻烦了。
ThreadLocal 给每个线程一个独立的副本:同一个 ThreadLocal 变量,每个线程看到自己的那份值,互不干扰。
打个比方:ThreadLocal 就像给每个员工发一个私人抽屉——大家都在同一个办公室(同一个类、同一个静态变量),但各自往自己抽屉里放东西、取东西,永远不会拿错。
3.2 基本用法
1 | // 定义:通常做成 static final |
3.3 原理简述
ThreadLocal 的实现思路是反过来存:
- 每个
Thread对象内部有一个ThreadLocalMap(可以理解为线程私有的一个小哈希表) - 你调
USER.set("张三")时,实际是往当前线程的 Map 里存了一条记录,键是USER这个对象,值是 “张三” - 你调
USER.get()时,是从当前线程的 Map 里取
因为 Map 是线程私有的,所以各线程互不干扰——这就是”线程隔离”的全部秘密。
3.4 内存泄漏问题:为什么要 remove()?
上图的 Entry 里,key 是弱引用(GC 会回收),value 是强引用(GC 不会回收)。
当外部不再使用某个 ThreadLocal 对象时:
- key 被 GC 回收,变成 null
- 但 value 还被
Thread → Map → Entry → value这条强引用链拽着,无法回收 - 如果线程活得很久(线程池里的线程几乎永生),这些”僵尸 value”会越积越多
解决办法很简单:用完必须在 finally 里调 remove():
1 | try { |
不 remove 的另一个坑:下一个复用这个线程的任务会读到上一个任务残留的值——“用户 A 的请求看到了用户 B 的数据”就是这么来的。
3.5 InheritableThreadLocal:父传子
普通 ThreadLocal 父子线程互不相通。想让创建子线程时把父线程的值传过去,用 InheritableThreadLocal:
1 | static final InheritableThreadLocal<String> CTX = new InheritableThreadLocal<>(); |
输出:
1 | 子线程读到:主线程的值 |
注意:值是在创建子线程那一刻复制过去的,之后父线程再改,子线程看不到。线程池场景(线程提前创建、反复复用)下这个机制基本失效,生产要用阿里的 TransmittableThreadLocal(TTL)。
3.6 实战:Web 应用传递用户上下文
模拟一个 Web 请求的处理流程:Filter 设置用户 → 业务层读取 → finally 清理:
1 | package com.frank.concurrent.ch03; |
输出:
1 | [http-1] 创建订单,操作人:张三 |
http-1 处理了两个不同用户的请求,没有串号——finally 里的 remove() 功不可没。把 remove() 注释掉再跑,第三个请求大概率打出”张三”的残留值——亲手复现一次”串号事故”。
常见误区提醒
- ThreadLocal 不是用来解决线程安全的——它是把”共享”变成”不共享”。该加锁的还得加锁。
- 大对象放进 ThreadLocal 不 remove,在线程池里等于”永久租用”,内存慢慢泄漏。
InheritableThreadLocal遇到线程池就失效(只在创建时复制一次),别在池化环境依赖它。
思考与练习
- 写代码验证:两个线程对同一个
ThreadLocalset 不同值、各自 get,互相看不到。- 把实战代码里的
remove()注释掉,第三个请求会打出谁的名字?为什么?- 面试题:ThreadLocal 的 key 为什么设计成弱引用?(提示:如果 key 是强引用,ThreadLocal 对象本身也会被永久拽住,连”顺路清理”的判断条件都没了。)
初级篇收官
三篇初级篇串成一条线:
1 | 第 01 篇 线程是什么 ──> 创建、启动、停止 |
你已经具备了阅读并发代码、排查常见问题的地基:
- 能看懂 jstack 里的线程状态
- 能解释
count++为什么会丢数据 - 能写出不会假死的生产者-消费者
- 知道线程池里为什么必须
remove()
真实项目里你很少裸写这些——大家都站在 JUC(java.util.concurrent)的肩膀上:线程池怎么配参数?ConcurrentHashMap 为什么又快又安全?CompletableFuture 怎么编排异步任务?这些是”进阶篇”的内容。地基已打好,欢迎继续。