懒汉式单例模式的线程问题

在多线程的情况下 有可能创建出多个实例对象

可以用线程同步的方法去处理 比如把方法声明为synchronized

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Test3 {
public static void main(String[] args) throws InterruptedException {
System.out.println("猪百万");

Cat c1 = Cat.getCatInstance();
Cat c2 = Cat.getCatInstance();

System.out.println(c1==c2); //true

c1.catchMouse();
c2.catchMouse();
}
}

class Cat{
private Cat(){

}

private static Cat cat;

public synchronized static Cat getCatInstance(){
if(cat == null){
cat = new Cat();
}

return cat;
}

void catchMouse(){
System.out.println("喵喵喵~~~~~~");
}
}

这段代码实现了单例模式(饿汉式的线程安全版本),
通过 synchronized 关键字保证了 getCatInstance() 方法在多线程环境下的安全性,确保只会创建一个 Cat 实例,因此 c1 == c2 的结果为 true

这两个单例模式的实现方式有显著区别,核心差异在于同步的范围和效率,具体如下:

1. 第一个实现(双重检查锁 / Double-Checked Locking)

java

运行

1
2
3
4
5
6
7
8
9
10
public static Cat getCatInstance() {
if (cat == null) { // 第一次检查(无锁)
synchronized (Cat.class) { // 同步块(缩小锁范围)
if (cat == null) { // 第二次检查(持有锁时)
cat = new Cat();
}
}
}
return cat;
}
  • 核心特点

    仅在catnull时才进入同步块,通过两次null检查(无锁时一次,持有锁时一次),既保证了线程安全,又减少了同步带来的性能损耗。

    • 第一次检查:避免已初始化后仍进入同步块(多数情况下直接返回,无需加锁)。
    • 第二次检查:防止多线程同时通过第一次检查后,重复初始化(持有锁时再确认)。
  • 优势

    同步范围小(仅初始化时加锁),后续调用无需竞争锁,效率更高,适合高并发场景。

2. 第二个实现(方法级同步)

java

运行

1
2
3
4
5
6
public synchronized static Cat getCatInstance() {
if (cat == null) {
cat = new Cat();
}
return cat;
}
  • 核心特点

    直接在静态方法上使用synchronized,相当于对Cat.class加锁(与第一个实现的同步块锁对象相同),但每次调用方法都会触发同步

  • 问题

    即使cat已初始化(单例已创建),后续所有调用仍需竞争锁并排队执行,导致不必要的性能开销(尤其是高并发场景下)。

总结区别

维度 双重检查锁(第一个) 方法级同步(第二个)
同步范围 仅初始化时的同步块 整个方法(每次调用都同步)
性能 高(后续调用无锁) 低(每次调用都需竞争锁)
线程安全 安全(需配合volatile修饰cat 安全(但代价高)
适用场景 高并发、频繁调用的单例 低并发、对性能不敏感的场景

注意

第一个实现需额外用volatile修饰cat变量(private static volatile Cat cat;),否则可能因指令重排序导致线程安全问题(可见性和有序性)。而第二个实现因方法级同步,无需volatile也能保证安全,但性能较差。

实际开发中,双重检查锁是更优的单例实现方式(需正确使用volatile)。

volatile

这是什么

volatile 是 Java 中的一个关键字,主要用于修饰变量,核心作用是保证变量的 可见性 和 有序性,但不保证原子性。它常被用于多线程场景,解决线程间变量读取不一致或指令重排序导致的问题(比如之前提到的双重检查锁单例)。

一、先搞懂:没有 volatile 会出什么问题?

在多线程环境中,CPU、编译器和 JVM 为了优化性能,可能会做一些 “手脚”,导致变量操作不符合预期:

  1. 缓存导致的可见性问题:每个线程有自己的工作内存(缓存),对共享变量的修改会先存在工作内存,不会立即同步到主内存;其他线程读取时也从自己的缓存取,导致看不到最新值。
  2. 指令重排序导致的有序性问题:编译器 / CPU 会调整指令执行顺序(只要不影响单线程结果),但多线程下可能破坏逻辑正确性(比如双重检查锁中的 cat = new Cat() 被重排序)。

二、volatile 的核心作用

1. 保证可见性

当一个线程修改了 volatile 修饰的变量后,这个修改会立即同步到主内存;其他线程读取该变量时,会直接从主内存读取最新值,而不是从自己的工作缓存读取过期数据。

举个例子

java

运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 无 volatile:线程A修改flag后,线程B可能长期看不到,陷入死循环
private static boolean flag = false;

// 有 volatile:线程A修改flag后,线程B能立即感知到
private static volatile boolean flag = false;

// 线程A
new Thread(() -> {
flag = true; // 修改volatile变量,立即同步到主内存
}).start();

// 线程B
new Thread(() -> {
while (!flag) { // 每次读取都从主内存获取最新值,不会一直循环
}
}).start();

2. 禁止指令重排序

volatile 会禁止编译器和 CPU 对该变量相关的指令进行重排序,保证指令按代码书写的顺序执行,避免多线程下的逻辑混乱。

关键场景:双重检查锁单例

之前的双重检查锁实现中,cat = new Cat() 看似是一步操作,实际会被拆分为 3 步:

  1. 分配内存空间;
  2. 初始化 Cat 对象;
  3. 将 cat 引用指向内存空间。

没有 volatile 时,CPU 可能重排序为 1→3→2。此时线程 A 执行到步骤 3(cat 已非 null),线程 B 第一次检查发现 cat 不为 null,直接返回一个未初始化完成的 Cat 对象,导致错误。

用 volatile 修饰 cat 后,会禁止这种重排序,确保 1→2→3 执行,避免半初始化对象问题。

三、volatile 的局限性:不保证原子性

volatile 无法保证复合操作的原子性(原子性指操作要么全部完成,要么全部不执行,中间不会被打断)。

举个反例

java

运行

1
2
3
4
5
6
7
8
9
10
private static volatile int count = 0;

// 10个线程各执行1000次count++
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // 复合操作:读取count→加1→写入count
}
}).start();
}

最终 count 的结果很可能小于 10000。因为 count++ 是 3 步复合操作,volatile 只能保证每次读取的是最新值,但无法阻止多个线程同时执行 “读取 - 加 1 - 写入” 的步骤,导致值被覆盖。

解决原子性问题:需配合锁(synchronized)或原子类(AtomicInteger)。

四、volatile 的使用场景

  1. 状态标志位:比如多线程中的开关变量(如上面的 flag),用于线程间传递状态。
  2. 双重检查锁单例:修饰单例变量,禁止指令重排序,避免半初始化对象。
  3. 轻量级同步:适用于变量的操作是 “单次读 / 写”(非复合操作)的场景,替代synchronized以提升性能。

五、总结

特性 volatile 是否保证?
可见性
有序性 是(禁止重排序)
原子性 否(仅单次读写安全)

volatile 是一种轻量级的多线程同步方案,性能优于 synchronized,但适用场景有限。核心记住它的两大作用:解决可见性问题、禁止指令重排序,同时注意它不处理原子性问题。