导语:多线程编程中,线程安全问题往往是并发开发的第一道门槛。本文从线程调度随机性、共享变量修改、原子性等根源问题出发,系统讲解 synchronized 的多种写法、可重入锁、死锁的四个必要条件,以及 volatile 如何解决内存可见性与指令重排序,最后梳理 wait 与 sleep 的区别,帮助你建立完整的并发安全知识体系。
一、线程安全问题的根源
在并发编程中,线程安全问题通常由以下几个原因引起,理解这些根源是解决问题的前提。
1. 线程调度的随机性
操作系统对线程的调度是随机的,我们无法预测某个线程何时获得 CPU、何时被切换出去。这种随机性是并发问题的根本原因,也是我们无法直接干预的因素。
2. 多个线程针对同一变量进行修改
当两个或多个线程同时对一个共享变量进行修改操作时,由于调度顺序不确定,最终结果可能与预期不符。
3. 修改操作不是原子的
以count++为例,这一行代码在底层实际包含三个操作:读取 count 的值、将值加 1、将结果写回内存。这三个操作并非原子操作,线程可能在执行过程中被切换,导致数据覆盖。解决办法是引入锁机制,将多个操作合成一个整体来执行。
小结:原子性问题的本质是多个操作在执行过程中可能被其他线程打断,锁的作用就是把一组操作绑定为一个不可分割的整体。
4. 内存可见性引起的线程安全问题
每个线程都有自己的工作内存,当一个线程修改了共享变量的值,其他线程可能无法及时看到这个修改,从而引发线程安全问题。
5. 指令重排序引起的线程安全问题
编译器和 CPU 为了优化执行效率,可能会对指令进行重排序。在多线程环境下,这种重排序可能导致其他线程看到不符合预期的执行顺序。
二、线程安全问题的解决办法
针对上述不同的原因,Java 提供了对应的解决方案。其中,针对修改操作非原子性的问题,最常用的手段就是使用锁。
1. 锁的基本概念
锁(操作系统层面)的核心操作只有两个:加锁和解锁。在 Java 中,synchronized关键字是使用锁最直接的方式。
synchronized (obj) { // 这里编写需要保证原子性的代码 }关于 synchronized 的使用,有以下几个要点:
- 进入
{表示加锁,进入}表示解锁。 - 加锁操作是防止其他线程“插队”,但不会影响本线程被调度出 CPU。
- 锁对象是关键:两个线程必须针对同一个对象加锁,才会产生锁竞争现象(部分串行化);如果针对不同对象加锁,则不会产生锁竞争,也就无法解决线程安全问题。
2. synchronized 修饰静态方法
synchronized 可以直接修饰一个静态方法,此时锁对象是当前类的 Class 对象。
synchronized public static void add() { count++; } public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { synchronized (Demo11.class) { for (int i = 0; i < 5000; i++) { add(); } } }); // 启动线程... }上面的代码中,synchronized (Demo11.class)就是针对类对象加锁。
3. synchronized 的其他写法
除了修饰静态方法,synchronized 还有两种常见写法。
写法一:synchronized 修饰实例方法
// 第一种写法:同步代码块,锁对象为 this /*public void add() { synchronized (this) { count++; } }*/ // 第二种写法:直接修饰实例方法 synchronized public void add() { count++; }当 synchronized 修饰实例方法时,锁对象是当前实例对象(this)。
写法二:synchronized 修饰静态方法
synchronized public static void add() { count++; }当 synchronized 修饰静态方法时,锁对象是当前类的 Class 对象。
注意点:synchronized 修饰实例方法时,不同实例可以出现两把锁;而 synchronized 修饰静态方法时,由于 Class 对象全局唯一,不会出现两把锁的情况。
4. 可重入锁
可重入锁的使用前提是:同一个线程、同一把锁。如果第一次加锁成功后,再次进行第二次加锁,synchronized 内部会判断第二次加锁的线程是否与第一次加锁是同一个线程。如果是同一个线程,则直接跳过;如果不是同一个线程,第二次加锁才真正生效,产生阻塞效果。
// 同一个线程对同一把锁连续加锁三次,因为可重入而不会阻塞 synchronized (c) { synchronized (c) { synchronized (c) { c.add(); } } }上面的代码中,同一个线程对同一把锁连续加锁三次,由于可重入特性,不会产生死锁。
三、死锁问题
1. 一个线程一把锁,连续加两次
如果一把锁不可重入,同一个线程连续加锁两次就会导致死锁。可重入锁正是为了解决这个问题而设计的。
2. 两个线程两把锁,相互获取对方的锁
这是最典型的死锁场景:线程 t1 持有 locker1 后尝试获取 locker2,而线程 t2 持有 locker2 后尝试获取 locker1,双方都在等待对方释放锁,从而陷入死锁。
// 线程 t1:先拿到 locker1,再尝试拿 locker2 Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1获取到locker1"); sleep(1000); synchronized (locker2) { System.out.println("t1获取到locker2"); } } }); // 线程 t2:先拿到 locker2,再尝试拿 locker1,与 t1 形成环路等待 Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t2获取到locker2"); sleep(1000); synchronized (locker1) { System.out.println("t2获取到locker1"); } } });3. 死锁形成的四个必要条件
死锁的形成必须同时满足以下四个条件:
- 锁是互斥的:一个锁同时只能被一个线程持有(通常不可更改)。
- 锁不可以被抢占:线程只能等待持有者主动释放锁(通常不可更改)。
- 请求和保持:A 线程在获取到 locker1 的情况下,保持不释放的状态,再尝试获取 locker2。解决办法:在代码中避免锁的嵌套。
- 循环等待(环路等待):多个线程之间形成等待环路。解决办法:约定加锁顺序,把锁进行编号,约定任何线程在持有多个锁时,都按照编号从小到大的顺序加锁(对应 M 个线程 N 把锁的哲学家就餐问题)。
小结:前两个条件是锁本身的固有属性,通常无法改变;后两个条件可以通过合理的代码设计来规避,是我们在开发中重点防范的方向。
四、volatile 解决内存可见性
1. 什么是内存可见性
在 Java 内存模型中,每个线程都有一份工作内存(由 CPU 的寄存器和缓存组成),所有线程共享一份主内存(即平时所说的内存)。当一个线程对某个数据进行操作时,遵循以下流程:
- 修改操作:先把数据从主内存拷贝到工作内存,对工作内存操作后,再写回到主内存。
- 读取操作:把数据从主内存拷贝到工作内存,再从工作内存中读取。
2. 内存可见性问题是如何引起的
由于编译器优化了读写操作,导致 A 线程无法第一时间读取到 B 线程新修改的值。此时引入 volatile 可以关闭这个优化,并强制每次读取都是最新值,每次写完立即对所有线程可见。
package Thread; import java.util.Scanner; // volatile 解决内存可见性 public class Demo13 { private static volatile int flag = 0; public static void main(String[] args) { Thread t1 = new Thread(() -> { while (flag == 0) { // 循环等待 flag 变为非 0 } System.out.println("t1结束"); }); Thread t2 = new Thread(() -> { Scanner sc = new Scanner(System.in); System.out.println("输入flag的值"); flag = sc.nextInt(); System.out.println("t2结束"); }); t1.start(); t2.start(); } }在上面的示例中,如果不加 volatile 修饰 flag,t1 线程可能永远无法感知 t2 线程对 flag 的修改,从而陷入死循环。
五、volatile 解决指令重排序
1. 指令重排序问题
以单例模式中的懒汉式为例,private volatile static SingletonLazy instance = null;的实例化过程可以简单视为三个步骤:
- 分配内存空间。
- 针对内存空间进行初始化。
- 将内存空间首地址复制到引用变量中。
如果不是在多线程环境中,这三个步骤的顺序可以改变。但在多线程环境中,顺序是不能改变的,因为其他线程可能会看到未初始化完全的对象。
2. 使用 volatile 禁止指令重排序
用 volatile 修饰 instance 时,会关闭 instance 变量读写的相关优化,确保从内存进行读写,操作顺序就不会被打乱了。
注意点:volatile 的两个核心作用分别是保证内存可见性和禁止指令重排序,它并不保证原子性。如果需要保证原子性,仍需借助 synchronized 或原子类。
六、wait 和 notify
1. wait 的阻塞机制
wait 方法用于让当前线程进入阻塞状态,其执行流程如下:
- 先释放锁。
- 等待 notify 通知(进入阻塞状态)。
- 当通知到达时,从阻塞状态变为就绪状态,并重新获取锁。
此外,t.wait(设置时间)可以指定等待时间,时间到了会自动唤醒。
2. notify 的唤醒机制
如果有若干个线程在等待,notify 会随机唤醒其中一个;t.notifyAll()则会唤醒所有正在等待的线程。
面试常问:notify 与 notifyAll 的核心区别在于:notify 随机唤醒一个等待线程,notifyAll 则唤醒所有等待线程。回答时可结合具体业务场景说明选择依据。
七、wait 和 sleep 的区别
wait 和 sleep 虽然都能让线程阻塞,但二者在设计意图和使用方式上有本质区别:
- 设计意图不同:wait 的设计是为了被 notify 唤醒,超过时间自动唤醒只是“后手”;sleep 的设计就是按照一定时间发生阻塞。
- 对锁的要求不同:wait 必须搭配锁使用;sleep 不需要搭配锁使用。
- 被打断时的行为不同:wait 被打断时,先进行抢锁,再抛异常;sleep 被打断时,不会释放锁(可能导致线程饿死问题出现),直接抛异常。
面试常问:wait 和 sleep 的区别是 Java 并发面试的高频考点,建议从设计意图、锁的依赖、中断行为三个维度来回答。
八、总结
本文围绕线程安全的核心问题展开,梳理了从问题根源到解决方案的完整链路:线程调度的随机性无法避免,但针对共享变量修改的非原子性,可以使用 synchronized 加锁解决;针对内存可见性和指令重排序,可以使用 volatile 解决;而死锁问题则需要从四个必要条件出发,通过避免锁嵌套、约定加锁顺序等方式预防。掌握这些基础概念,是深入理解 Java 并发编程的必经之路。