同步
更新: 1/11/2026 字数: 0 字 时长: 0 分钟
概念
临界资源:一次仅允许一个线程(或进程)访问和修改的共享资源,例如共享变量、文件、数据库连接等
临界区:访问临界资源的代码块
竞态条件:
- 多个线程在临界区内执行,由于代码的执行序列不同(指令交错)而导致结果无法预测,称之为发生了竞态条件
- 一个程序运行多个线程本身没有问题,多个线程只读共享资源也没有问题。但在多个线程对共享资源进行读写操作时发生指令交错,就可能出现竞态条件,导致数据不一致或错误的结果
- 为了避免临界区的竞态条件发生(解决线程安全问题),主要有两种解决方案:
- 阻塞式的解决方案:
synchronized关键字、java.util.concurrent.locks.Lock接口的实现(如 ReentrantLock)。这些机制通过互斥锁来保证同一时刻只有一个线程进入临界区 - 非阻塞式的解决方案:原子变量(如
java.util.concurrent.atomic包下的类,如 AtomicInteger),它们通过 CAS(Compare-And-Swap)等底层硬件原语实现无锁并发
- 阻塞式的解决方案:
管程(Monitor):
- 是一种程序设计模型或数据结构,它封装了共享数据和所有访问这些共享数据的过程(方法)。管程保证在同一时刻至多只有一个线程在管程内活动(即管程内定义的操作在同一时刻只被一个线程调用),从而实现对共享资源的互斥访问。在 Java 中,
synchronized关键字和 Object 类的wait()/notify()/notifyAll()方法就是管程机制的一种实现
synchronized:
- 对象锁,保证了临界区内代码的互斥性,进而实现原子性。它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁。其它线程尝试获取这个对象锁时会进入 BLOCKED 状态(阻塞),直到持有锁的线程释放锁。这保证了拥有锁的线程可以安全地执行临界区内的代码,不用担心其他线程同时修改共享数据
- 需要注意的是,即使在
synchronized块内,线程仍然可能发生上下文切换。但由于synchronized保证了互斥,即使发生上下文切换,其他线程也无法进入同一个临界区,从而保证了数据的一致性
互斥和同步都可以采用 synchronized 关键字来完成
区别:
- 互斥:是避免临界区的竞态条件发生,保证同一时刻只能有一个线程执行临界区代码
- 同步:是指线程之间的协调与合作,通常是为了保证线程执行的先后顺序或某种特定的协作模式。例如,一个线程需要等待另一个线程运行到某个点(如
wait/notify机制),或者等待某个结果。
性能:
- 线程安全的实现(如使用
synchronized或Lock)通常会引入额外的开销(如锁的获取与释放、上下文切换等),因此相对于非线程安全的实现,其性能可能会有所下降。然而,现代 JVM 对synchronized进行了大量优化(如偏向锁、轻量级锁、自旋锁),在许多场景下其性能开销已经很小 - 线程不安全的设计在没有并发访问时性能最好。如果能够确保在应用程序的整个生命周期中,某个对象或代码块永远不会被多个线程同时访问(例如,它只在单线程环境中使用,或者它是线程局部的),那么使用非线程安全的设计可以避免不必要的同步开销。但如果存在任何并发访问的可能性,则必须使用线程安全的设计来保证程序的正确性
synchronized
使用锁
同步块
锁对象:理论上可以是任意的非 null 唯一对象
synchronized 是可重入的,默认是不公平的,并且在竞争激烈时会升级为重量级锁
原则上:
- 锁对象建议使用共享资源本身,或者一个专门用于同步的
final对象 - 在实例方法中使用
this作为锁对象,锁住的this正好是当前实例,从而保护了该实例的共享成员 - 在静态方法中使用
类名.class字节码对象作为锁对象,因为静态成员属于类,被所有实例对象共享,所以需要锁住整个类而不是某个实例
同步代码块格式:
synchronized(锁对象){
// 访问共享资源的核心代码
}实例:
public class Demo {
static int counter = 0;
//static 修饰,则元素是属于类本身的,不属于对象,与类一起加载一次,只有一个
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
}同步方法
把出现线程安全问题的核心方法锁起来,每次只能一个线程进入访问
synchronized 修饰的方法的同步性不具备继承性。这意味着子类重写父类的 synchronized 方法时,如果子类方法没有显式加 synchronized 关键字,它将不再是同步方法。如果子类方法也被 synchronized 修饰,那么它们各自的锁对象取决于方法是实例方法还是静态方法,通常不是“同一把锁”,除非它们锁的是同一个 Class 对象或 this 实例
用法:直接给方法加上一个修饰符 synchronized
// 同步实例方法
修饰符 synchronized 返回值类型 方法名(方法参数) {
方法体;
}
// 同步静态方法
修饰符 static synchronized 返回值类型 方法名(方法参数) {
方法体;
}同步方法底层也是有锁对象的:
如果方法是实例方法:同步方法默认用
this作为锁对象javapublic synchronized void test() {} // 等价于 public void test() { synchronized(this) {} }如果方法是静态方法:同步方法默认用
类名.class作为锁对象javaclass Test{ public synchronized static void test() {} } // 等价于 class Test{ public static void test() { synchronized(Test.class) {} } }
线程八锁
线程八锁是 Java 并发编程中一个经典的面试题和学习点,主要用于考察开发者对 synchronized 关键字锁定的对象类型及其作用范围的理解。核心在于辨别不同的 synchronized 用法(实例方法、静态方法、同步代码块)分别锁定了哪个对象。
核心思想:判断多个线程尝试获取的锁是否是同一个对象。如果是同一个对象,它们之间就会互斥(串行执行);如果不是同一个对象,它们可以并发执行。
- 锁住类对象(ClassName.class):当
synchronized修饰静态方法时,或者同步代码块使用synchronized(ClassName.class)时,锁定的是该类的 Class 对象。由于一个类在 JVM 中只有一个 Class 对象,因此所有线程(无论创建了多少个实例)对该类的静态同步方法或 ClassName.class 同步块的访问都会被同一把锁控制,从而实现线程安全。 - 锁住 this 对象(实例对象):当
synchronized修饰实例方法时,或者同步代码块使用synchronized(this)时,锁定的是当前方法所属的实例对象。这意味着只有对同一个实例对象的并发访问才会受到这把锁的控制。如果存在多个实例对象,每个实例都有自己的锁,它们之间互不影响,可以并发执行。
线程不安全:线程 1 调用的是静态同步方法 a(),它锁定了 Number.class 对象。线程 2 调用的是实例同步方法 b(),它锁定了 n2 实例对象(即 this)。由于 Number.class 和 n2 是两个完全不同的锁对象,它们之间不会发生互斥,因此可以并发执行。
class Number{
public static synchronized void a(){
Thread.sleep(1000);
System.out.println("1");
}
public synchronized void b() {
System.out.println("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}线程安全:线程 1 调用的是静态同步方法 a(),它锁定了 Number.class 对象。线程 2 调用的是静态同步方法 b(),它也锁定了 Number.class 对象。由于它们都尝试获取同一个 Class 对象锁,因此它们之间会发生互斥,保证了串行执行。
class Number{
public static synchronized void a(){
Thread.sleep(1000);
System.out.println("1");
}
public static synchronized void b() {
System.out.println("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}锁原理
Monitor
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象。Monitor 是 JVM 底层(通常是 C++)实现的一个同步原语,其数据结构实例存储在堆内存中。当 synchronized 给一个对象上锁,并且锁升级到重量级锁状态时,该对象头的 Mark Word 中就会被设置指向这个 Monitor 对象的指针。
- Mark Word 结构:对象头中的一部分,用于存储对象的运行时数据,如哈希码、GC 分代年龄、锁标志位、偏向线程 ID 等。最后两位是锁标志位。
|-------------------------------------------------------|---------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|---------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|---------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|---------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|---------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|---------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|---------------------|- 64 位虚拟机 Mark Word:
|----------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|----------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01 | Normal |
|----------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | Biased |
|----------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | 00 | Lightweight Locked |
|----------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | 10 | Heavyweight Locked |
|----------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|----------------------------------------------------------------------|--------------------|工作流程(重量级锁):
初始状态:Monitor 中
Owner字段为null。获取锁:当 Thread-2 执行
synchronized(obj)并成功获取锁时,Monitor 的所有者Owner字段被置为 Thread-2。一个 Monitor 只能有一个Owner。obj对象的 Mark Word 会被更新,指向该 Monitor 对象的地址,同时锁标志位变为10(表示重量级锁)。对象原有的 Mark Word(如果是从轻量级锁膨胀而来,则原 Mark Word 已在线程栈的锁记录中;如果是直接获取重量级锁,则 Mark Word 直接更新)会被妥善处理,以便解锁时恢复。
竞争锁:在 Thread-2 持有锁期间,如果 Thread-3、Thread-4、Thread-5 也尝试执行
synchronized(obj),它们会进入 Monitor 的EntryList(一个由BLOCKED线程组成的双向链表),等待获取锁。释放锁:Thread-2 执行完同步代码块的内容,会释放锁。它会根据
obj对象头中 Monitor 地址找到对应的 Monitor,将Owner字段设置回null。同时,obj对象的 Mark Word 会被恢复到其解锁前的状态(例如,01表示无锁状态,或者重新进入可偏向状态等)。唤醒竞争者:释放锁后,Monitor 会唤醒
EntryList中等待的线程来竞争锁。这个竞争是非公平的,如果这时有新的线程(不来自EntryList)想要获取锁,它可能直接抢占到,导致EntryList中的线程继续阻塞。WaitSet:WaitSet中的 Thread-0,是以前获得过锁,但因为在同步块内调用了obj.wait()方法,条件不满足而主动释放锁并进入WAITING状态的线程。它们需要通过obj.notify()或obj.notifyAll()才能被唤醒,唤醒后会尝试重新竞争锁。
注意:
synchronized必须是进入同一个对象的 Monitor 才有上述的互斥效果。- 不加
synchronized的对象不会关联监视器,自然也就不受 Monitor 机制的互斥保护。
锁升级
升级过程
synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
偏向锁
偏向锁是 synchronized 的一种优化,旨在提高在没有并发竞争的情况下获取锁的性能。其思想是“偏向”于第一个获取锁的线程,该线程之后再次获取该锁时,不再需要进行任何同步操作。
- 当锁对象第一次被线程获得时,如果该对象处于可偏向状态,JVM 会尝试将其 Mark Word 标记为
101(偏向锁状态),并使用 CAS 操作将获取锁的线程 ID 记录到 Mark Word。 - 如果 CAS 操作成功,这个线程以后再次进入这个锁相关的同步块时,只需检查 Mark Word 中的线程 ID 是否是自己。如果是,就表示没有竞争,不需要再进行任何同步操作,直接进入同步块。
- 当有另一个线程去尝试获取这个锁对象时,偏向状态就宣告结束。此时会撤销偏向(Revoke Bias),锁会恢复到无锁状态,然后升级为轻量级锁或直接升级为重量级锁(取决于竞争情况)。

一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为
0x05(即最后 3 位为101),thread、epoch、age都为 0。 - 偏向锁默认是延迟的,不会在程序启动时立即生效(JDK 8 默认延迟 4 秒)。可以通过 VM 参数
-XX:BiasedLockingStartupDelay=0来禁用延迟。JDK 8 延迟开启偏向锁的原因是:在程序刚启动时,通常会有许多线程进行初始化操作,如果一开始就启用偏向锁,频繁的偏向撤销反而会降低效率。 - 当一个对象已经计算过
hashCode(),就再也无法进入偏向状态了。这是因为hashCode值需要存储在 Mark Word 中,而偏向锁也需要 Mark Word 空间来存储线程 ID,两者会发生冲突。 - 添加 VM 参数
-XX:-UseBiasedLocking可以禁用偏向锁。需要注意的是,从 JDK 15 开始,偏向锁默认是禁用的,因为在现代多核 CPU 和高并发场景下,偏向锁带来的收益不明显,反而可能增加 JVM 的复杂性。
撤销偏向锁的状态:
- 调用对象的
hashCode():如前所述,由于 Mark Word 冲突,偏向锁会被撤销。 - 当有其它线程尝试获取偏向锁对象时:发生竞争,偏向锁会被撤销,并升级为轻量级锁或重量级锁。
- 调用
wait()/notify():wait()方法要求线程必须持有重量级锁并释放它,因此偏向锁会直接升级为重量级锁。
批量撤销:
- 批量重偏向:如果一个类的对象被多个线程轮流访问,但每次只有一个线程访问时(即没有并发竞争),JVM 会觉得是不是偏向错了。当撤销偏向锁的阈值(默认 20 次)超过后,JVM 会尝试将该类的所有新创建对象重新偏向至当前加锁线程。
- 批量撤销:如果一个类的对象频繁发生偏向锁撤销(阈值默认 40 次),JVM 会认为该类的对象不适合使用偏向锁,于是整个类的所有对象都会变为不可偏向的,后续新建的对象也将直接以轻量级锁或无锁状态创建。
轻量级锁
当一个对象有多个线程要加锁,但加锁的时间是错开的(即短时间内的交替竞争,没有同时竞争),可以使用轻量级锁来优化。轻量级锁对使用者是透明的(不可见)。
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块。synchronized 是可重入的,这意味着一个线程可以多次获取同一个锁而不会死锁。可重入锁的主要作用是简化了并发编程模型,避免了因递归调用或内部方法调用而导致的死锁。
轻量级锁在没有竞争时(如锁重入),每次重入仍然需要执行 CAS 操作。偏向锁正是为了进一步优化这种完全无竞争的场景,避免了重入时的 CAS 开销。
锁重入实例:
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2(); // 线程再次尝试获取 obj 锁,因为是可重入的,所以不会阻塞
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}轻量级锁原理:
创建锁记录(Lock Record)对象:
每个线程的栈帧都会包含一个锁记录的结构。当线程第一次进入同步块时,会在其栈帧中创建一个 Lock Record,用于存储锁定对象的 Mark Word 的副本。

尝试获取锁:
- 让锁记录中 Object reference 指向锁住的对象。
- 线程尝试用 CAS 操作替换对象的 Mark Word:将对象的 Mark Word 替换为指向当前线程栈帧中 Lock Record 的指针。
- 同时,将对象原有的 Mark Word 值存入 Lock Record 的 Displaced Mark Word 字段中。
CAS 替换成功:
如果 CAS 替换成功,对象头中存储了 Lock Record 地址和状态
00(轻量级锁)。这表示由该线程成功给对象加锁。
如果 CAS 失败,有两种情况:
- 如果是其它线程已经持有了该 Object 的轻量级锁:这时表明有竞争。当前线程会先尝试自旋,如果自旋失败,则进入锁膨胀过程,将轻量级锁变为重量级锁。
- 如果是线程自己执行了
synchronized锁重入:线程会在其栈帧中添加一个新的 Lock Record,其Displaced Mark Word字段为null,作为重入的计数标记。

当退出 synchronized 代码块(解锁时)
- 如果 Lock Record 的
Displaced Mark Word为null:表示这是一次重入的解锁。此时只需重置该 Lock Record,表示重入计数减 1。 - 如果 Lock Record 的
Displaced Mark Word不为null:表示这是最外层锁的解锁。这时使用 CAS 将 Lock Record 中保存的 Mark Word 值恢复给对象头。- 成功:则解锁成功。
- 失败:说明在解锁期间,该锁已经进行了锁膨胀(升级为重量级锁)。此时进入重量级锁解锁流程(通知 Monitor 释放锁)。
- 如果 Lock Record 的
锁膨胀
当在尝试加轻量级锁的过程中,CAS 操作无法成功,且不是锁重入(即有其它线程正在持有该对象的轻量级锁,存在真正的并发竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

Thread-1 加轻量级锁失败,进入锁膨胀流程:
- JVM 为 Object 对象申请一个 Monitor 锁(重量级锁)。
- 通过 Object 对象头中保存的 Lock Record 地址,找到当前持锁线程(Thread-0)。
- 将 Monitor 的 Owner 置为 Thread-0。
- 将 Object 的 Mark Word 更新为指向该 Monitor 的地址,并设置锁标志位为
10。 - Thread-1 自己进入 Monitor 的 EntryList(BLOCKED 状态)。

当 Thread-0 退出同步块解锁时,尝试使用 CAS 将其 Lock Record 中保存的 Mark Word 值恢复给对象头。由于 Mark Word 已经被修改为指向 Monitor 的地址,CAS 操作会失败。这时 Thread-0 会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,并唤醒 EntryList 中 BLOCKED 的线程(包括 Thread-1)。
锁优化
自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋来进行优化。自旋是指线程在循环中不断尝试获取锁,而不是立即进入阻塞状态。
注意:
- 自旋会占用 CPU 时间。在单核 CPU 上自旋是浪费时间,因为同一时刻只能运行一个线程。在多核 CPU 上自旋才能发挥优势,因为自旋线程可以在等待期间占用一个核心,等待锁释放。
- 自旋不是无限的。自旋一定次数后如果仍未获取到锁,线程会放弃自旋并进入阻塞状态。
特点:
- 优点:在锁持有时间短且竞争不激烈的情况下,可以减少线程上下文切换的消耗,提高性能。
- 缺点:当自旋的线程越来越多,或者锁持有时间较长时,自旋会白白消耗 CPU 资源,导致 CPU 利用率升高而吞吐量下降。
自旋锁情况:
自旋成功的情况:

自旋失败的情况:

自旋锁说明:
- 在 Java 6 之后,自旋锁是自适应的(Adaptive Spinning)。JVM 会根据之前自旋成功的次数和锁的持有时间等历史信息,动态地调整自旋的次数。例如,如果对象刚刚的一次自旋操作成功过,那么 JVM 认为这次自旋成功的可能性会高,就可能多自旋几次;反之,如果自旋很少成功,就可能少自旋甚至不自旋,这种方式比较智能。
- Java 7 之后不能通过参数控制是否开启自旋功能,由 JVM 自动控制。
锁消除
锁消除是指对于被 JVM 即时编译器(JIT)检测出不可能存在竞争的共享数据的锁进行消除。这是 JVM 运行时的一种优化技术。
锁消除主要是通过逃逸分析来支持。如果堆上的共享数据不可能“逃逸”出去被其它线程访问到(即它只在当前线程内部使用,是线程局部的),那么就可以把它们当成私有数据对待,也就可以将它们相关的锁操作进行消除(同步消除)。
锁粗化
当 JVM 探测到一串连续的操作都对同一个对象加锁时,它可能会将这些细粒度的加锁操作合并,把加锁的范围扩展(粗化)到整个操作序列的外部,从而减少频繁的锁获取和释放带来的性能损耗。
一些看起来没有加锁的代码,其实隐式的加了很多锁:
javapublic static String concatString(String s1, String s2, String s3) { return s1 + s2 + s3; }String 是一个不可变的类。在 JDK 1.5 之前,
+操作符进行字符串拼接通常会转化为 StringBuffer 对象的连续append()操作。StringBuffer 的每个append()方法都是同步的(因为它内部使用了synchronized)。javapublic static String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }在这种情况下,如果
sb对象没有逃逸到方法外部,JIT 编译器会发现对sb的多次synchronized(this)操作实际上都是对同一个局部对象进行的,且没有其他线程可以访问sb。因此,JIT 可能会将这些连续的锁操作粗化,扩展到第一个append()操作之前直至最后一个append()操作之后,只需要加锁一次就可以,甚至直接进行锁消除。
多把锁
多把不相干的锁:例如,一间大屋子有两个功能:睡觉、学习,它们互不相干。现在一人要学习,一人要睡觉,如果只用一间屋子(一个对象锁)的话,那么并发度很低,因为两个人必须排队。
将锁的粒度细分(Lock Granularity),即使用多把锁来保护不同的独立资源,可以提高并发度:
- 好处:可以增强并发度,提高程序的吞吐量。
- 坏处:如果一个线程需要同时获得多把不相干的锁,就容易发生死锁(需要同时获取多个资源时)。
解决方法:为不同的独立共享资源准备多个对象锁。
public static void main(String[] args) {
BigRoom bigRoom = new BigRoom();
new Thread(() -> { bigRoom.study(); }).start();
new Thread(() -> { bigRoom.sleep(); }).start();
}
class BigRoom {
private final Object studyRoom = new Object();
private final Object sleepRoom = new Object();
public void sleep() throws InterruptedException {
synchronized (sleepRoom) {
System.out.println("sleeping 2 小时");
Thread.sleep(2000);
}
}
public void study() throws InterruptedException {
synchronized (studyRoom) {
System.out.println("study 1 小时");
Thread.sleep(1000);
}
}
}活跃性
死锁
死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止
Java 死锁产生的四个必要条件:
- 互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
- 请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
- 循环等待条件,即存在一个等待循环队列,P1 等待 P2 的资源,P2 等待 P3 的资源,...,Pn 等待 P1 的资源,形成了一个等待环路
四个条件都成立的时候,便形成死锁。死锁情况下打破上述任何一个条件,便可让死锁消失
public class Dead {
public static Object resources1 = new Object();
public static Object resources2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
// 线程 1:占用资源 1,请求资源 2
synchronized(resources1){
System.out.println("线程 1 已经占用了资源 1,开始请求资源 2");
Thread.sleep(2000);//休息两秒,防止线程 1 直接运行完成。
//2 秒内线程 2 肯定可以锁住资源 2
synchronized (resources2){
System.out.println("线程 1 已经占用了资源 2");
}
}
}).start();
new Thread(() -> {
// 线程 2:占用资源 2,请求资源 1
synchronized(resources2){
System.out.println("线程 2 已经占用了资源 2,开始请求资源 1");
Thread.sleep(2000);
synchronized (resources1){
System.out.println("线程 2 已经占用了资源 1");
}
}
}).start();
}
}定位死锁的方法:
- 使用 jps 命令定位 Java 进程 ID,再用
jstack <PID>定位死锁。jstack会打印出所有线程的堆栈信息,如果存在死锁,它会自动检测并报告。找到死锁的线程后,查看其堆栈信息和锁的持有/等待情况,可以定位到源码位置进行解决优化。 - Linux 下可以通过
top先定位到 CPU 占用高的 Java 进程,再利用top -Hp <PID>来定位是哪个线程,最后再用jstack <PID>的输出来看各个线程栈 - 避免死锁:最常见的策略是打破循环等待条件,即规定所有线程以相同的顺序获取多个锁。例如,所有线程都先尝试获取
resources1,再尝试获取resources2 - 可以使用
jconsole或jvisualvm等图形化工具,它们通常提供死锁检测功能,可以直观地展示死锁情况
活锁
活锁:指的是任务或者执行者没有被阻塞,但由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程,从而无法向前推进。线程虽然没有阻塞,但却无法完成有效工作。它与死锁的区别在于,死锁是线程永远停止活动,而活锁是线程一直在活动但没有进展。
两个线程互相改变对方的结束条件,最后谁也无法结束:
class TestLiveLock {
static volatile int count = 10;
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
Thread.sleep(200);
count--;
System.out.println("线程一 count:" + count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
Thread.sleep(200);
count++;
System.out.println("线程二 count:"+ count);
}
}, "t2").start();
}
}饥饿
饥饿:一个线程由于优先级太低,或者总是被其他高优先级线程抢占 CPU 资源,导致它长时间得不到 CPU 调度执行,也无法完成其任务。虽然理论上它最终会运行,但等待时间过长,以至于实际上无法完成其任务,就像被“饿死”了一样。
wait notify
基本使用
线程必须首先获取到锁对象的监视器(即持有锁),然后才能调用 锁对象.wait()、锁对象.notify() 或 锁对象.notifyAll() 方法。
Object 类 API:
// 唤醒正在等待对象监视器的单个线程(具体唤醒哪个线程是不确定的)
public final void notify()
// 唤醒正在等待对象监视器的所有线程
public final void notifyAll()
// 导致当前线程等待,直到另一个线程调用该对象的 notify() 或 notifyAll() 方法,或者被中断
public final native void wait()
// 有时限的等待,在指定毫秒数后自动结束等待,或者被唤醒、被中断
public final native void wait(long timeout)
// 更精细地有时限等待,本质上还是调用了 wait(long timeout),只是做了一个四舍五入
public final void wait(long timeout, int nanos)说明:
wait()方法会使当前线程放弃对象锁,并进入该对象的等待队列WaitSet,状态变为WAITING或TIMED_WAITING(如果设置了超时)notify()或notifyAll()方法用于从WaitSet中唤醒线程。被唤醒的线程并不会立即执行,它们需要重新竞争对象锁,只有成功获取到锁后才能从wait()方法处继续执行
TIP
阻塞(Blocked):是在“等门开”,门一开(锁释放),阻塞的线程会自动参与竞争。
等待/挂起(Waiting):是在“进屋睡觉”,除非别人过来推醒(notify/unpark)它,否则它永远不会去竞争锁。
对比 sleep():
- 所属类:
sleep()方法属于 Thread 类,用于控制线程自身的暂停wait()方法属于 Object 类,用于线程间的协作通信
- 锁的处理机制:
- 调用
sleep()方法时,线程不会释放它持有的任何对象锁,但会释放 CPU 执行权 - 调用
wait()方法时,线程会立即释放它持有的对象锁,并进入 WaitSet,同时释放 CPU 执行权
- 调用
- 使用区域:
wait()、notify()和notifyAll()方法必须在同步代码块或同步方法中使用,即在获取到对象锁之后才能调用,否则会抛出IllegalMonitorStateExceptionsleep()方法可以在任何地方使用
底层原理:
- 当 Owner 线程(持有锁的线程)发现条件不满足时,调用
obj.wait()方法,它会释放obj的锁,并进入obj的 WaitSet,状态变为 WAITING。 - BLOCKED 状态的线程(在 EntryList 中等待获取锁)和 WAITING 状态的线程(在 WaitSet 中等待被唤醒)都处于阻塞状态,不占用 CPU 时间片。
- BLOCKED 线程会在 Owner 线程释放锁时被唤醒(进入竞争队列)。
- WAITING 线程会在 Owner 线程调用
notify()或notifyAll()时被唤醒。被唤醒后,它们会从 WaitSet 转移到 EntryList,重新竞争对象锁。只有成功获取锁后,才能继续执行。

代码优化
虚假唤醒:
- 是指线程在没有收到
notify()或notifyAll()调用,或者其等待的条件并未满足的情况下被唤醒。虽然 Java 规范允许虚假唤醒发生,但实际 JVM 实现中并不常见。然而,为了程序的健壮性,我们必须始终防范虚假唤醒。
解决方案:
- 使用
while循环而非if判断等待条件:这是解决虚假唤醒和多条件等待问题的关键。当线程从wait()返回时,它必须再次检查条件是否满足。如果使用if,线程只检查一次,一旦被唤醒,即使条件仍不满足也可能继续执行,导致逻辑错误。而while循环确保线程只有在条件真正满足时才继续执行。 - 使用
notifyAll()替代notify():当有多个线程在等待不同的条件,或者有多个线程在等待同一个条件时,notify()只能随机唤醒一个线程。如果被唤醒的线程发现条件仍不满足,它会再次wait(),而真正等待条件满足的线程可能仍然在睡眠。notifyAll()可以唤醒所有等待线程,让它们重新检查自己的条件,从而避免“死锁”或“饥饿”问题(即本应被唤醒的线程没有被唤醒)。
点击查看案例演示
@Slf4j
public class DemoWaitNotify {
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 循环检查条件,防止虚假唤醒和条件不满足时继续执行
while (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小南").start();
new Thread(() -> {
synchronized (room) {
log.debug("外卖送到没?[{}]", hasTakeout);
// 使用 while 循环检查条件,防止虚假唤醒和条件不满足时继续执行
while (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
log.debug("外卖送到没?[{}]", hasTakeout);
if (hasTakeout) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小女").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
synchronized (room) {
// 假设是送烟的,先送烟
hasCigarette = true;
log.debug("送烟的:烟到了噢!");
room.notifyAll(); // 唤醒所有等待 room 锁的线程
// 模拟一段时间后送外卖
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
hasTakeout = true;
log.debug("送外卖的:外卖到了噢!");
room.notifyAll(); // 再次唤醒所有等待 room 锁的线程
}
}, "送货员").start();
}
}park unpark
LockSupport 是用来创建锁和其他同步类的线程原语,它提供了更底层、更灵活的线程阻塞和唤醒机制。
LockSupport 类方法:
LockSupport.park():暂停当前线程。- 如果当前线程的“许可”(permit)可用,则消耗一个许可并立即返回
- 否则,线程将被阻塞,直到获得许可
LockSupport.unpark(Thread thread):为指定线程thread颁发一个许可。- 如果
thread正在park()阻塞,则它会被唤醒 - 如果
thread没有阻塞,那么许可会在下次park()时立即被消耗
- 如果
public class ParkUnparkDemo {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " start..."); // 1
try {
// 模拟一些工作,确保 unpark 有机会先执行
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " park..."); // 2
LockSupport.park(); // 线程在此处暂停,直到获得许可
System.out.println(Thread.currentThread().getName() + " resume..."); // 4
},"t1");
t1.start();
TimeUnit.SECONDS.sleep(2); // 确保 t1 已经执行到 park() 或者 unpark() 已经先发放了许可
System.out.println("unpark..."); // 3
LockSupport.unpark(t1); // 给线程 t1 发放许可
}
}LockSupport 出现就是为了增强 wait & notify 的功能,解决其一些局限性:
- 锁绑定:
wait()、notify()和notifyAll()必须配合Object的监视器(synchronized 锁)一起使用,并且只能在同步块中调用- 而
park()、unpark()不需要持有任何对象锁
- 唤醒目标:
park()和unpark()以线程为单位来阻塞和唤醒特定线程,可以精确控制- 而
notify()只能随机唤醒一个等待线程,notifyAll()是唤醒所有等待线程
- 许可机制:
park()和unpark()采用许可机制,unpark()可以先于park()执行,这相当于预先发放了一个许可,当线程稍后调用park()时,会立即消耗这个许可并继续运行,而不会阻塞wait()和notify()则不能先notify(),否则notify()会失效,因为没有线程在等待- 类比生产消费:
park()/unpark()就像一个可以预存一个产品的仓库- 先生产
unpark()可以把产品存进去,消费者park()来取时直接取走 - 先消费
park()发现没有产品就等待,生产者unpark()来了再放进去并通知
- 先生产
- 锁释放:
wait()会释放它所关联的对象锁资源并进入等待队列park()不会释放当前线程可能持有的任何对象锁,它只负责阻塞当前线程,但会释放 CPU
安全分析
变量类型与线程安全
成员变量和静态变量:
- 如果它们没有被多个线程共享访问(例如,每个线程操作自己的实例,或者变量是线程局部的),则通常是线程安全的。
- 如果它们被多个线程共享访问:
- 如果只有读操作(不可变数据),则线程安全。
- 如果有读写操作,则这段代码构成临界区,需要考虑线程安全问题(如使用 synchronized、Lock、原子类等)。
局部变量:
- 局部变量本身是存储在线程栈中的,每个线程都有自己的栈帧,因此局部变量是线程安全的。
- 然而,局部变量引用的对象不一定线程安全。这取决于该对象是否“逃逸”出方法的作用范围(逃逸分析):
- 如果该对象没有逃离方法的作用范围(即只在方法内部使用,没有作为返回值或被其他线程访问),它是线程安全的。
- 如果该对象逃离方法的作用范围(例如,作为返回值返回,或者被存储到共享的成员变量中,导致其他线程可以访问),就需要考虑线程安全问题。
常见线程安全类
线程安全类示例:
String、Integer、StringBuffer、Random、ThreadLocalRandom、Vector、Hashtable、以及java.util.concurrent包下的所有类(如ConcurrentHashMap、CopyOnWriteArrayList等)。定义:一个类被称为线程安全的,通常是指多个线程调用它们同一个实例的某个方法时,该方法内部的操作是线程安全的。
组合非原子性:需要特别注意,即使类本身是线程安全的,多个方法的组合操作可能不是原子的,仍然需要额外的同步机制来保证组合操作的线程安全。
javaHashtable table = new Hashtable(); // 假设有两个线程同时执行这段代码 // 线程 1 检查 if(table.get("key") == null) 为真 // 线程 2 检查 if(table.get("key") == null) 也为真 // 线程 1 执行 table.put("key", value1) // 线程 2 执行 table.put("key", value2) // 结果:value1 可能被 value2 覆盖,或者反之,导致数据不一致。 if(table.get("key") == null) { // get() 是线程安全的,但 if 判断和 put() 之间不是原子的 table.put("key", value); // put() 也是线程安全的 } // 正确做法:将整个组合操作放在一个同步块中 synchronized (table) { if (table.get("key") == null) { table.put("key", value); } }无状态类线程安全:如果一个类没有任何成员变量(即没有状态),那么它自然是线程安全的,因为没有共享数据可供竞争。
不可变类线程安全:
String、Integer等都是不可变类。这意味着它们内部的状态一旦创建就不能改变。因此,它们的方法不会修改自身状态,也不会引入竞态条件,所以是线程安全的。java// String 的 replace()、substring() 等方法,底层是新建一个对象来存储结果,而不是修改原对象 Map<String, Object> map = new HashMap<>(); // 线程不安全 (HashMap 不是同步的) String S1 = "..."; // 线程安全 (String 是不可变的) final String S2 = "..."; // 线程安全 (String 是不可变的,final 只是保证引用不变) Date D1 = new Date(); // 线程不安全 (Date 是可变的,其内部状态可以被修改) final Date D2 = new Date(); // 线程不安全 (final 保证 D2 引用不能变,但 D2 引用的 Date 对象内容可以变)外星方法:指在一个同步块或临界区内部调用了外部方法(通常是用户提供的回调、抽象方法或多态方法),而这个外部方法的行为是不可控的。这种调用可能导致:
- 死锁:外部方法可能尝试获取当前同步块之外的另一个锁,如果与当前线程持有的锁形成循环等待,就可能死锁。
- 状态破坏:外部方法可能修改当前对象的状态,而这种修改没有被同步机制保护,导致线程不安全。
- 性能问题:外部方法可能执行耗时操作,长时间占用锁,降低并发度。例如:
public abstract void foo(Student s);如果在同步方法中调用foo(),而foo()的具体实现是未知的,就可能引入上述风险。应尽量避免在持有锁的情况下调用外星方法。
