目录
Java 多线程:线程安全、wait/notify 与单例模式详解
wait 和 synchronized 必须使用同一个锁对象
Java 多线程:线程安全、wait/notify 与单例模式详解
一、线程安全问题
1. 什么是线程安全?

线程安全指的是:
一段代码在多线程环境下运行时,无论线程如何调度,程序最终都能够得到符合预期的结果。
例如:
count++;
假设两个线程同时执行这行代码。
我们的预期是:
count = count + 2;
但最终结果可能只增加了 1。
这说明程序出现了线程安全问题。
2. 线程安全问题产生的原因
(1)线程的随机调度
线程由操作系统负责调度。
现代操作系统普遍采用抢占式调度:
-
当前线程运行过程中可能随时被暂停;
-
CPU 可能切换到其他线程执行;
-
程序员无法预测线程切换时机。
因此,多线程执行顺序是不确定的。
(2)多个线程修改同一个变量
当多个线程同时读写同一个变量时,就可能产生数据竞争(Race Condition)。
例如:
count++;
表面上看是一条语句,但实际上对应多个CPU指令:
读取 count
count + 1
写回 count
如果两个线程同时执行,就可能出现:
线程A读取 count = 0
线程B读取 count = 0
线程A写回 1
线程B写回 1
最终结果变成:
count = 1
而不是预期的:
count = 2
(3)操作不是原子的
原子性(Atomicity)指:
一个操作要么全部执行成功,要么完全不执行,中间不会被其他线程打断。
例如:
count++;
并不是原子操作。
因为它对应:
读取
计算
写回
三个步骤。
在执行过程中可能被其他线程插入执行,从而导致线程安全问题。
(4)内存可见性问题
为了提高运行效率,CPU 和编译器会进行优化。
线程读取变量时:
boolean flag = false;
可能不会每次都从主内存读取,而是缓存到自己的工作内存中。
此时:
线程A:
while(!flag){
}
线程B:
flag = true;
虽然线程B已经修改了变量,
但线程A可能一直读取自己缓存中的旧值。
导致循环无法结束。
这就是内存可见性问题。
解决方案:
volatile boolean flag;
volatile能够保证变量修改后立即刷新到主内存,并保证其他线程能够及时看到最新值。
(5)指令重排序
为了提高执行效率:
编译器和CPU可能调整指令执行顺序。
例如:
new Singleton();
理论执行过程:
1. 申请内存
2. 初始化对象
3. 引用指向对象
实际可能被优化为:
1. 申请内存
2. 引用指向对象
3. 初始化对象
此时其他线程可能拿到一个尚未初始化完成的对象。
这就是指令重排序问题。
二、wait 与 notify
wait 和 notify 用于协调多个线程之间的执行顺序。
它们无法决定CPU调度哪个线程,
但能够让线程主动进入等待状态。
例如:
线程A先执行
线程B等待
线程A完成后
唤醒线程B
这样就实现了线程之间的协作。
wait 与 join 的区别
join
t1.join();
含义:
当前线程必须等待 t1 完全执行结束。
wait
lock.wait();
含义:
当前线程等待其他线程调用 notify() 或 notifyAll()。
不要求对方线程结束运行。
因此:
join:等待线程结束
wait:等待线程被唤醒
锁竞争问题
多个线程竞争同一把锁时:
synchronized(lock){
}
锁释放后:
哪个线程获得锁是不确定的。
例如:
A获得锁
A释放锁
下一次:
可能B获得锁
也可能A再次获得锁
完全由操作系统调度决定。
三、线程饥饿(Starvation)
线程饥饿指:
某个线程长期得不到CPU时间或锁资源,导致一直无法执行。
可以把线程看成鸟宝宝:
-
CPU是鸟妈妈;
-
锁资源是食物;
如果某只鸟长期抢不到食物,
就会发生线程饥饿。
四、wait 方法详解
当线程调用:
wait();
会发生两件事:
1. 当前线程进入等待状态
RUNNING
↓
WAITING
2. 释放当前持有的锁
这是wait最重要的特性。
例如:
synchronized(lock){
lock.wait();
}
执行wait后:
释放 lock
进入等待状态
这样其他线程才能获得锁并执行notify。
如果wait不释放锁:
线程A wait
线程B获取不到锁
无法notify
线程A永远等待
程序将陷入死锁状态。
注意事项
wait 和 synchronized 必须使用同一个锁对象
正确:
synchronized(lock){
lock.wait();
}
错误:
synchronized(lock1){
lock2.wait();
}
会抛出:
IllegalMonitorStateException
五、单例设计模式
什么是设计模式
设计模式(Design Pattern):
针对软件开发中的常见问题,总结出来的一套成熟解决方案。
可以理解为程序员的“棋谱”。
什么是单例模式
单例模式要求:
一个类在整个程序运行期间只能创建一个对象。
核心思想:
构造方法私有化
↓
外部无法new
↓
类内部自己创建对象
↓
统一对外提供访问入口
六、饿汉模式
饿汉模式:
类加载时立即创建对象。
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
特点:
-
实现简单
-
天然线程安全
-
获取速度快
缺点:
-
可能提前创建不需要使用的对象
七、懒汉模式
懒汉模式:
第一次使用时才创建对象。
7.1 普通懒汉模式(线程不安全)
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
问题:
多个线程同时进入:
if(instance == null)
可能创建多个对象。
7.2 同步方法
public static synchronized Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
优点:
-
线程安全
缺点:
-
每次获取对象都要加锁
-
性能较低
7.3 双重检查锁(DCL)
public class Singleton {
private static volatile Singleton instance;
private Singleton(){}
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
第一次判断:
if(instance == null)
避免频繁加锁。
第二次判断:
if(instance == null)
防止多个线程重复创建对象。
7.4 为什么需要 volatile
对象创建过程可能发生指令重排序:
1. 分配内存
2. 初始化对象
3. 引用指向对象
可能变成:
1. 分配内存
2. 引用指向对象
3. 初始化对象
此时:
线程A刚执行到步骤2,
线程B发现:
instance != null
直接返回对象。
但对象实际上还没有初始化完成。
因此需要:
private static volatile Singleton instance;
volatile能够禁止这部分重排序,从而保证DCL模式正确运行。
总结
线程安全问题主要来源于:
线程随机调度
↓
共享资源竞争
↓
原子性问题
内存可见性问题
指令重排序问题
而在单例模式中:
饿汉模式
↓
简单、安全
懒汉模式
↓
节省资源
DCL + volatile
↓
兼顾性能与线程安全

175

被折叠的 条评论
为什么被折叠?



