JUC并发编程07 - wait-ify/park-un/安全分析
wait-ify
基本使用
需要获取对象锁后才可以调用
锁对象.wait()
,notify 随机唤醒一个线程,notifyAll 唤醒所有线程去竞争 CPUObject 类 API:
public final void notify():唤醒正在等待对象监视器的单个线程。 public final void notifyAll():唤醒正在等待对象监视器的所有线程。 public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。 public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒
说明:wait 是挂起线程,需要唤醒的都是挂起操作,阻塞线程可以自己去争抢锁,挂起的线程需要唤醒后去争抢锁
想象一下:你和朋友约好去吃饭,但你说:“我还没到,你先等等我。”等你到了,你就说:“我到了,可以吃饭了!”——这就是 线程间的通信,而 wait
和 notify
就是这种“等我”、“我好了”的对话工具。
wait()
:我还没准备好,我先“挂起”(暂停),你先干别的。notify()
:我搞定了,叫醒一个在等的人。notifyAll()
:我搞定了,所有人别等了,都醒醒抢活干!
对比 sleep():
- 原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
- 对锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
- 使用区域不同:wait() 方法必须放在**同步控制方法和同步代码块(先获取锁)**中使用,sleep() 方法则可以放在任何地方使用
sleep()
和 wait()
虽然都能让线程“暂停”,但目的、用法、行为完全不同。
第一点:原理不同
Thread.sleep()
:是线程自己控制自己,“我想歇2秒”。Object.wait()
:是线程之间互相配合,“我没准备好,等你通知我”。
方法 | 场景 |
---|---|
sleep() | 你做饭做到一半,说:“我先躺2分钟再炒菜” → 你占着灶台,别人不能用 |
wait() | 你发现没食材,就对室友说:“我等你买菜回来叫我” → 你让出厨房,室友可以进去放东西、做饭 |
总结一下:sleep
是自我管理,wait
是协作沟通。sleep()
是线程的“个人行为”,wait()
是多个线程之间的“团队协作”。
// sleep 属于 Thread 类 → Thread.sleep()
Thread.sleep(1000);// wait 属于 Object 类 → 必须某个对象调用 obj.wait()
synchronized (lock) {lock.wait();
}
第二点:对锁的处理机制不同
方法 | 是否释放锁? | 是否释放 CPU? |
---|---|---|
sleep() | 不释放 | 释放 |
wait() | 释放 | 释放 |
也就是说:
sleep()
:占着茅坑不拉屎(有锁不干活)wait()
:主动让位,让别人进来改数据
Object lock = new Object();// 线程A:使用 sleep —— 不释放锁
new Thread(() -> {synchronized (lock) {System.out.println("A 拿到锁,开始 sleep...");try {Thread.sleep(5000); // 睡5秒,但锁不放!} catch (InterruptedException e) {}System.out.println("A sleep 结束");}
}, "A").start();// 线程B:想进 lock,但必须等 A 出来
new Thread(() -> {synchronized (lock) {System.out.println("B 终于拿到锁了!");}
}, "B").start();
代码输出如下:
A 拿到锁,开始 sleep...
(5秒后)
A sleep 结束
B 终于拿到锁了!
B 等了整整5秒!因为 A 睡着也不放锁!换成 wait()
:
Object lock = new Object();// 线程C:使用 wait —— 释放锁
new Thread(() -> {synchronized (lock) {System.out.println("C 拿到锁,发现条件不满足,开始 wait...");try {lock.wait(); // 释放锁!} catch (InterruptedException e) {}System.out.println("C 被唤醒,继续执行");}
}, "C").start();// 线程D:可以立刻拿到锁(因为C释放了)
new Thread(() -> {synchronized (lock) {System.out.println("D 拿到锁了!可以干活了!");// 干完活,叫醒 Clock.notify();}
}, "D").start();
代码输出如下:
C 拿到锁,发现条件不满足,开始 wait...
D 拿到锁了!可以干活了!
C 被唤醒,继续执行
看到了吗?C 一 wait()
,D 立刻就能进!这就是释放锁的好处。如果不释放锁,别人进不来,也就没法调用 notify()
来唤醒你,就死锁了。
第三点:使用区域不同
wait()
:必须先“进门”(拿到锁),才能说“我等等”sleep()
:在哪都能睡,站着也能打盹
wait()
像开会时说:“这事儿没结论,咱们下次再议” → 你得先在会议室里才能说这话sleep()
像打哈欠:“我困了,眯一会儿” → 在工位、走廊都能眯
Object lock = new Object();// sleep 可以随便用
public void someMethod() {try {Thread.sleep(1000); // 没问题!} catch (InterruptedException e) {}
}// wait 不能随便用
public void badWait() {// lock.wait(); // 直接报错!没在 synchronized 里
}// wait 必须在 synchronized 里
public void goodWait() {synchronized (lock) {try {lock.wait(); // 正确:先拿锁} catch (InterruptedException e) {}}
}
如果你在 synchronized
外面调 wait()
,JVM 会直接抛出:
java.lang.IllegalMonitorStateException
意思是:“你连锁都没有,凭什么说等等?”
底层原理:
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争
想象一个对象的锁(比如 synchronized(lock)
)是一个小屋子,门口有三个区域:
- Owner 区:正在屋子里干活的线程(持有锁)
- EntryList(入口等待区):一堆人排队等着进屋(BLOCKED 状态)
- WaitSet(等待区):一些人不在门口排队,而是去旁边椅子上坐着打盹(WAITING 状态)
这个“屋子”就是锁对象,比如 Object lock = new Object();
Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- 有个线程正在屋子里干活(它是 Owner)
- 它发现:“哎呀,条件不够,现在干不了”
- 它说:“我先出去歇会儿,等别人告诉我好了再来”
- 于是它走出屋子,坐到旁边的“等待椅”上(WaitSet),进入
WAITING
状态
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
BLOCKED
:在门口排队的人,站着不动,不抢 CPUWAITING
:在休息区打盹的人,睡着了,也不抢 CPU- 他们都“暂停”了,操作系统不会分配时间片给他们执行
BLOCKED 线程会在 Owner 线程释放锁时唤醒
- 一群人堵在门口(BLOCKED),等着进屋
- 只要屋里的人(Owner)一出来(释放锁)
- JVM 就会从队伍里选一个人进去(唤醒一个 BLOCKED 线程)
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争
- 打盹的人(WAITING)不会自己醒
- 必须屋里的人喊一声:“可以干活了!”(
notify()
) - 听到后,他从椅子上站起来 → 跑到门口 → 加入排队队伍(EntryList)
- 但他要和其他人一起抢锁,不一定能马上进去!
注意:唤醒 ≠ 立刻执行!只是“可以开始抢了”,但还要排队!
代码优化
虚假唤醒:notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程
解决方法:采用 notifyAll
notifyAll 仅解决某个线程的唤醒问题,使用 if + wait 判断仅有一次机会,一旦条件不成立,无法重新判断
解决方法:用 while + wait,当条件不成立,再次 wait
@Slf4j(topic = "c.demo") public class demo {static final Object room = new Object();static boolean hasCigarette = false; //有没有烟static boolean hasTakeout = false;public static void main(String[] args) throws InterruptedException {new Thread(() -> {synchronized (room) {log.debug("有烟没?[{}]", hasCigarette);while (!hasCigarette) {//while防止虚假唤醒log.debug("没烟,先歇会!");try {room.wait();} catch (InterruptedException e) {e.printStackTrace();}}log.debug("有烟没?[{}]", hasCigarette);if (hasCigarette) {log.debug("可以开始干活了");} else {log.debug("没干成活...");}}}, "小南").start();new Thread(() -> {synchronized (room) {Thread thread = Thread.currentThread();log.debug("外卖送到没?[{}]", hasTakeout);if (!hasTakeout) {log.debug("没外卖,先歇会!");try {room.wait();} catch (InterruptedException e) {e.printStackTrace();}}log.debug("外卖送到没?[{}]", hasTakeout);if (hasTakeout) {log.debug("可以开始干活了");} else {log.debug("没干成活...");}}}, "小女").start();Thread.sleep(1000);new Thread(() -> {// 这里能不能加 synchronized (room)?synchronized (room) {hasTakeout = true;//log.debug("烟到了噢!");log.debug("外卖到了噢!");room.notifyAll();}}, "送外卖的").start();} }
想象你在一个宿舍里:
- 小南在等烟(他只认烟)
- 小女在等外卖(她只认外卖)
现在送外卖的人来了,他喊了一声:“东西到了!”(notify()
)。但如果用的是 notify()
,系统是随机叫一个人!可能叫到的是小南(等烟的),但来的是外卖,不是烟!小南醒了看一眼:“没烟啊?” → 但他不会再问了(因为是 if
判断),直接说:“我没活干。” 然后走了。结果:小女一直睡着,没人叫她,外卖白送了!这就是“唤醒了错误的人,正确的那个人却没醒”。
解决方法:用 notifyAll
送外卖的喊一声:“所有人给我醒醒!”(notifyAll()
),小南和小女都醒来:
- 小南一看:没烟 → 继续睡(
wait()
) - 小女一看:有外卖!→ 开始吃饭,干活!
这样就能做到正确的人被唤醒。
为什么判断条件要用 while
,不能用 if
?
if
就像:你设了个闹钟:“8点叫我起床”,闹钟响了,你睁开眼看了一眼窗外,天还没亮。你说:“哦,天没亮,我不起了。” 然后再也不看了,结果9点了你还在睡。
while
就像:闹钟响了,你看天没亮 → 继续睡;过会儿又被叫醒(别人喊你)→ 再看一眼;直到天亮了才起床。
所以:while
是“每次醒来都再检查一遍条件”,防止“醒早了”或“叫错人”。
park-un
LockSupport 是用来创建锁和其他同步类的线程原语
LockSupport 类方法:
LockSupport.park()
:暂停当前线程,挂起原语LockSupport.unpark(暂停的线程对象)
:恢复某个线程的运行public static void main(String[] args) {Thread t1 = new Thread(() -> {System.out.println("start..."); //1Thread.sleep(1000);// Thread.sleep(3000)// 先 park 再 unpark 和先 unpark 再 park 效果一样,都会直接恢复线程的运行System.out.println("park..."); //2LockSupport.park();System.out.println("resume...");//4},"t1");t1.start();Thread.sleep(2000);System.out.println("unpark..."); //3LockSupport.unpark(t1); }
LockSupport.park()
就像按了暂停键,让某个线程“卡住不动”;
LockSupport.unpark(线程)
就像按了播放键,让那个“被卡住”的线程继续跑。
你先按“播放”(unpark),再按“暂停”(park),也不会出问题——线程照样能继续跑。
public class ParkUnparkDemo {public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {System.out.println("1. start..."); // 1try {Thread.sleep(1000); // 睡1秒,让 main 线程先执行完前面的逻辑} catch (InterruptedException e) { }System.out.println("2. park..."); // 2LockSupport.park(); // 当前线程(t1)暂停!等别人叫醒我System.out.println("4. resume..."); // 4 被叫醒后才执行}, "t1");t1.start();Thread.sleep(2000); // 主线程睡2秒,确保 t1 已经执行到 park() 了System.out.println("3. unpark..."); // 3LockSupport.unpark(t1); // 唤醒 t1 线程}
}
控制台输出结果如下:
代码分析:
步骤 | 谁干的 | 干了啥 | 说明 |
---|---|---|---|
1 | t1 | 打印 start... | t1 开始跑了 |
- | t1 | sleep(1000) | 睡1秒 |
- | main | sleep(2000) | main 线程也在睡 |
2 | t1 | 打印 park...,然后 park() | t1 暂停!卡在这儿不动了 |
3 | main | 打印 unpark...,然后 unpark(t1) | main 叫醒 t1:“可以走了!” |
4 | t1 | 打印 resume... | t1 被唤醒,继续往下走 |
关键点:park()
会阻塞线程,直到有人调用 unpark(这个线程)
。
如果反过来:先 unpark,再 park 呢?
public class ParkUnparkReverse {public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {System.out.println("1. start...");try {Thread.sleep(3000); // 睡3秒,确保 main 的 unpark 已经执行了} catch (InterruptedException e) { }System.out.println("2. park...");LockSupport.park(); // 此时:unpark 已经执行过了System.out.println("3. resume..."); // 这句会立刻执行吗?}, "t1");t1.start();Thread.sleep(1000);System.out.println("unpark before park!");LockSupport.unpark(t1); // 先叫醒,但 t1 还没 park// t1 继续睡,直到 3秒后执行 park()}
}
控制台输出结果如下:
重点来了:为什么 park()
没有卡住?因为 unpark(t1)
提前发了一个“许可” 给 t1。
park()
相当于:“我看看有没有许可,有就直接过,没有就等。”因为之前已经 unpark
了,所以 park()
发现“哦,有许可”,直接放行,不阻塞。
所以得出结论:unpark
可以提前发“通行许可”,park
会先检查有没有许可,有就直接通过。
为什么说它是“创建锁和其他同步类的线程原语”?
“原语”就是最基础的零件,就像乐高最小的积木块。
Java 里的 synchronized
、ReentrantLock
、AQS
(AbstractQueuedSynchronizer)这些高级锁,底层都是靠 park/unpark
来实现线程的等待和唤醒。
比如:
- 加锁失败?→
park()
当前线程,让它等着。 - 释放锁?→
unpark()
唤醒某个等待的线程。
它们不用 wait/notify
,就是因为 park/unpark
更灵活、更底层、不会因为顺序出错而卡死。
特性 | park/unpark | wait/notify |
---|---|---|
是否需要 synchronized | 不需要 | 必须配合 synchronized |
是否能指定线程唤醒 | 可以 unpark(具体线程) | notify() 随机唤醒一个 |
先 unpark 再 park 会阻塞吗? | 不会,有“许可”就过 | 不能先 notify,否则无效 |
更底层吗? | 是,JUC 包的基石 | 是老的同步机制 |
LockSupport.park()
和 unpark()
就像线程世界的“暂停键”和“播放键”,而且支持“提前播放”,是构建锁和并发工具的底层核心按钮。你可以把它理解为:操作系统级别的线程控制开关。
LockSupport 出现就是为了增强 wait & notify 的功能:
- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park、unpark 不需要
- park & unpark 以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify。类比生产消费,先消费发现有产品就消费,没有就等待;先生产就直接产生商品,然后线程直接消费
- wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU
为什么 Java 要搞出 LockSupport.park()
和 unpark()
?它到底比老的 wait/notify
强在哪?
wait/notify
就像“广播喇叭 + 公共厕所”——你得先抢坑位(synchronized 锁),然后喊一声“我好了!”(notify),但不知道谁来接班。
而 park/unpark
就像“对讲机 + 私人司机”——我能点名叫醒谁就叫醒谁,不需要抢厕所,还能提前发“可以开工了”的信号。
对比维度一:是否需要“锁”才能用?
wait/notify:必须配合 synchronized(对象监视器)
synchronized (obj) {obj.wait(); // 必须先拿到 obj 的锁,否则报错!obj.notify(); // 同样要先拿到锁
}
就像你要上厕所,必须先开门进去(拿到锁),才能在里面喊“我好了!”。
park/unpark:不需要 synchronized
LockSupport.park(); // 随时可以暂停自己
LockSupport.unpark(t1); // 随时可以唤醒别人
就像你在马路上走路,突然累了,直接坐下休息(park);朋友看见了,走过来拍拍你说:“起来走啦!”(unpark),完全不用进厕所。
优势:更灵活,不依赖锁,适合做底层并发工具。
对比维度二:能不能“点名唤醒”?
wait/notify:notify 只能随机叫醒一个线程,不能指定
synchronized (obj) {obj.notify(); // 随机叫醒一个正在 wait(obj) 的线程// 我也不知道是谁!
}
就像你在公司群里发:“可以来用了!”结果三个同事都在等,到底谁来?系统随机挑一个。
park/unpark:可以精确“点名道姓”唤醒某个线程
Thread t1 = new Thread(...);
Thread t2 = new Thread(...);LockSupport.unpark(t1); // 我只想叫醒 t1
// t2 还在睡
就像你微信私聊:“小王,你来接班”,小李完全不知道这事。
优势:精准控制,避免无效唤醒,性能更高。
对比维度三:顺序能不能颠倒?先叫醒再暂停行不行?
wait/notify:不能先 notify,否则白搭!
// main 线程
synchronized (obj) {obj.notify(); // 现在没人 wait,这个 notify 就“浪费”了
}// 之后 t1 才开始 wait
Thread t1 = new Thread(() -> {synchronized (obj) {try {obj.wait(); // 等一辈子也不会被叫醒!} catch (InterruptedException e) { }}
});
就像你早上6点喊“可以吃饭了!”,结果食堂7点才开门,没人听见,白喊。
park/unpark:可以先 unpark,再 park,照样有效!
Thread t1 = new Thread(() -> {try {Thread.sleep(2000); // 先睡2秒} catch (InterruptedException e) { }System.out.println("t1 准备暂停...");LockSupport.park(); // 发现:之前已经 unpark 过我了 → 直接通过!不阻塞!System.out.println("t1 被唤醒了 or 直接通过了");
});t1.start();// 先 unpark
LockSupport.unpark(t1); // 提前发“通行许可”
System.out.println("main: 我提前叫醒 t1 了");
就像你提前给司机发微信:“等下直接走,我已经说好了。”司机来了发现绿灯亮着,直接开车走人,不用等。
优势:不会因为顺序问题导致线程永久阻塞,更安全!
对比维度四:释放资源的区别
wait:会释放锁 + 释放 CPU
synchronized (obj) {obj.wait(); // 当前线程释放 obj 的锁,其他线程可以进入 synchronized 块// 同时释放 CPU,进入等待队列
}
就像你上完厕所不仅出来了,还把门钥匙交出去了,别人能进来。
park:不会释放锁,只释放 CPU
synchronized (this) {LockSupport.park(); // 线程暂停,但仍然持有当前 synchronized 锁!// 其他线程进不来!
}
这意味着:其他线程无法进入这个 synchronized 块!就像你坐在马桶上突然发呆(park),人不动,但门还锁着,别人进不来。所以:park
一般不用在 synchronized 里!它本来就是给无锁或 AQS 这种高级玩法用的。
优势:park
只负责“暂停线程”,不干涉锁逻辑,职责更单一,更适合做底层原语。
实战类比:生产者消费者模型
我们用一个“仓库放苹果”的例子对比:仓库最多放1个苹果
传统写法:wait/notify 版
Object lock = new Object();
boolean hasApple = false;// 生产者
new Thread(() -> {synchronized (lock) {if (!hasApple) {System.out.println("生产一个苹果");hasApple = true;lock.notify(); // 喊一声:有苹果了!}}
}).start();// 消费者
new Thread(() -> {synchronized (lock) {while (!hasApple) {try {lock.wait(); // 没苹果,等着} catch (InterruptedException e) { }}System.out.println("消费一个苹果");hasApple = false;}
}).start();
问题:
- 必须 synchronized
- notify 可能唤醒不了正确的线程(如果多个消费者)
- 如果先 notify,消费者后 wait,就死等!
更加底层:park/unpark 版
Thread producer = null;
Thread consumer = null;// 模拟原子变量
AtomicBoolean hasApple = new AtomicBoolean(false);// 消费者线程
consumer = new Thread(() -> {if (!hasApple.get()) {System.out.println("消费者:没苹果,我先歇会...");LockSupport.park(); // 暂停自己}System.out.println("消费者:拿到苹果,开吃!");hasApple.set(false);
});// 生产者线程
producer = new Thread(() -> {System.out.println("生产者:生产一个苹果");hasApple.set(true);LockSupport.unpark(consumer); // 精准叫醒消费者
});consumer.start();
// 模拟消费者先启动并等待
Thread.sleep(1000);
producer.start();
优点:
- 不需要 synchronized
- 可以先 unpark(比如苹果已经放好了)
- 精准唤醒 consumer
原理:类似生产者消费者
先 park:
- 当前线程调用 Unsafe.park() 方法
- 检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁
- 线程进入 _cond 条件变量挂起
- 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
- 唤醒 _cond 条件变量中的 Thread_0,Thread_0 恢复运行,设置 _counter 为 0
先 unpark:
- 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
- 当前线程调用 Unsafe.park() 方法
- 检查 _counter ,本情况为 1,这时线程无需挂起,继续运行,设置 _counter 为 0
安全分析
成员变量和静态变量:
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题
局部变量:
- 局部变量是线程安全的
- 局部变量引用的对象不一定线程安全(逃逸分析):
- 如果该对象没有逃离方法的作用访问,它是线程安全的(每一个方法有一个栈帧)
- 如果该对象逃离方法的作用范围,需要考虑线程安全问题(暴露引用)
常见线程安全类:String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包
线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的
每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:
Hashtable table = new Hashtable(); // 线程1,线程2 if(table.get("key") == null) {table.put("key", value); }
无状态类线程安全,就是没有成员变量的类
不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全
replace 等方法底层是新建一个对象,复制过去
Map<String,Object> map = new HashMap<>(); // 线程不安全 String S1 = "..."; // 线程安全 final String S2 = "..."; // 线程安全 Date D1 = new Date(); // 线程不安全 final Date D2 = new Date(); // 线程不安全,final让D2引用的对象不能变,但对象的内容可以变
抽象方法如果有参数,被重写后行为不确定可能造成线程不安全,被称之为外星方法:
public abstract foo(Student s);
什么是线程安全?
想象你和你朋友在同一个银行账户上操作。你取钱,他也在取钱。如果系统没处理好,你们俩同时看到余额是 1000 元,你取了 500,他也取了 500,结果账户变成 -500?这就叫“线程不安全”。
线程安全就是:多个线程同时操作同一个东西时,不会出错、不会数据混乱。
成员变量和静态变量
如果它们没有共享,则线程安全
成员变量(类里的变量)和静态变量(static
变量)如果每个线程都有自己的副本,那它们就是安全的。
public class Counter {private int count = 0; // 成员变量public void add() {count++;}
}
如果每个线程都 new 一个自己的 Counter
对象:
Thread t1 = new Thread(() -> {Counter c1 = new Counter();c1.add();
});Thread t2 = new Thread(() -> {Counter c2 = new Counter(); // 和 c1 不是同一个对象c2.add();
});
这两个线程用的是不同的对象,所以 count
不共享 → 线程安全
如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
如果多个线程都在用同一个对象的成员变量或静态变量,那就要看这个变量能不能被改。
如果只有读操作,则线程安全
大家都只看,不改,那没问题,就像很多人同时看一本书的内容,不会乱。
public class Config {private String version = "1.0"; // 共享的成员变量public String getVersion() {return version; // 只读}
}
- 多个线程调用
getVersion()
→ 只读 → 线程安全
如果有读写操作,则这段代码是临界区,需要考虑线程安全问题
如果多个线程又读又写同一个变量,就像两个人同时改一个文档,必须上锁,否则数据会乱。
public class Counter {private int count = 0;public void add() {count++; // 读 + 写(不是原子操作!)}
}
- 两个线程同时调用
add()
:- 线程A读到 count=5
- 线程B也读到 count=5
- A写入6,B也写入6 → 实际只加了一次 → 出错了
- 这种“可能出错的区域”叫临界区,必须加锁
局部变量
局部变量是线程安全的
方法里的变量,每个线程调用时都会在自己的“栈”里创建一份,互不影响。也就是线程安全的。
public void calculate() {int temp = 10; // 局部变量temp = temp + 5;System.out.println(temp);
}
- 线程A调用
calculate()
,有自己的temp
- 线程B调用,也有自己的
temp
- 互不干扰 → 线程安全
局部变量引用的对象不一定线程安全(逃逸分析)
如果该对象没有逃离方法的作用范围,它是线程安全的(每一个方法有一个栈帧)
虽然变量是局部的,但如果它指向的对象被“放出去”了(比如返回、传给别的方法),那就可能被多个线程访问,就不安全了。但是只要这个对象只在方法内部用,用完就扔,每个线程各玩各的,就安全。
public void processData() {List<String> list = new ArrayList<>(); // 局部变量,对象也在方法内list.add("hello");list.add("world");// list 没有被返回,也没有传给别的线程
}
- 每个线程调用这个方法,都有自己独立的
list
→ 安全
如果该对象逃离方法的作用范围,需要考虑线程安全问题(暴露引用)
如果你把这个对象“交出去”了(比如返回、存到公共地方),别人也能改它,那就可能不安全。
List<String> globalList = new ArrayList<>(); // 公共的,可能被多个线程访问public void addData() {List<String> list = new ArrayList<>();list.add("data");globalList.addAll(list); // 把局部对象的内容“暴露”出去了
}
- 虽然
list
是局部的,但它指向的数据被加到了globalList
globalList
是共享的 → 多个线程都往里加 → 可能线程不安全
这就是“逃逸”了。
常见线程安全类
String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包,这些类的设计已经考虑了多线程,你可以放心让多个线程同时用同一个实例。
String s = "hello";
Integer i = 100;
StringBuffer sb = new StringBuffer();
Vector<String> vec = new Vector<>();
Hashtable<String, String> table = new Hashtable<>();
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
- 多个线程同时调用
sb.append("x")
→ 安全(StringBuffer
是同步的) - 多个线程同时 put 到
table
→ 安全(Hashtable
方法加了锁)
但注意:StringBuilder
就不安全!它没加锁,速度快,但多线程不能用。
线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的
比如 Hashtable
,你创建一个实例,10 个线程同时调用它的 put()
方法,不会出问题。Hashtable
内部加了锁,保证线程安全。
Hashtable<String, Integer> table = new Hashtable<>();// 线程1
new Thread(() -> {table.put("a", 1);
}).start();// 线程2
new Thread(() -> {table.put("b", 2);
}).start();
每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全
单个方法是安全的,但多个方法连着调用就不一定了,中间可能被别的线程插队。
Hashtable<String, Object> table = new Hashtable<>();// 线程1 和 线程2 同时执行:
if (table.get("key") == null) {table.put("key", "value"); // 问题在这里!
}
- 线程A:
get("key")
→ null → 进入 if - 线程B:
get("key")
→ null → 也进入 if - A 执行
put("key", "value")
- B 也执行
put("key", "value")
→ 覆盖了!而且本来只想 put 一次
get
和 put
各自是安全的,但组合起来不是原子的!
正确做法:用 putIfAbsent()
或加锁。
无状态类 & 不可变类
无状态类线程安全,就是没有成员变量的类
一个类啥变量都没有,只有一些方法,那它肯定是线程安全的,因为没有东西可以被改。
public class MathUtils {public int add(int a, int b) {return a + b; // 只用参数,没有成员变量}
}
- 在这个例子中,多个线程调用
add()
是安全的。
不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全
不可变类:一旦创建,内容就不能改。比如 String s = "abc"; s.toUpperCase()
返回一个新字符串,原来的没变。
String s = "hello";// 线程1
new Thread(() -> {String upper = s.toUpperCase();
}).start();// 线程2
new Thread(() -> {String lower = s.toLowerCase();
}).start();
安全!因为 s
本身没变,只是生成了新对象。 replace
、substring
等方法都是这样:不改自己,新建一个对象返回。
final 和线程安全
写法 | 是否线程安全 | 为什么 |
---|---|---|
Map map = new HashMap<>() | 不安全 | HashMap 本身不是线程安全的 |
String S1 = "..." | 安全 | String 是不可变类 |
final String S2 = "..." | 安全 | final + 不可变类,更安全 |
Date D1 = new Date() | 不安全 | Date 是可变的,多个线程改它会乱 |
final Date D2 = new Date() | 依然不安全 | final 只保证 D2 不能再指向别的对象,但 Date 对象本身的内容可以被改(比如 setTime() ) |
final
不等于线程安全!它只锁住“引用”,不锁住“对象内容”。
抽象方法与“外星方法”
抽象方法如果有参数,被重写后行为不确定可能造成线程不安全,被称之为外星方法:public abstract foo(Student s);
“外星方法”是你控制不了的方法,比如别人重写的抽象方法,你不知道它是线程安全的还是不安全的。
public abstract class Processor {public void process(Student s) {validate(s);foo(s); // 外星方法!你不知道子类怎么实现的log(s);}public abstract void foo(Student s); // 子类自由发挥
}
你写了个 process()
方法,看起来没问题;但 foo(s)
是抽象的,子类可能这样实现:
public void foo(Student s) {sharedList.add(s); // 访问共享变量,而且没加锁
}
多个线程调用process()
→ foo()
可能导致线程不安全。调用“外星方法”时要特别小心,尤其是在同步代码块中。