Skip to content

同步

更新: 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 机制),或者等待某个结果。

性能:

  • 线程安全的实现(如使用 synchronizedLock)通常会引入额外的开销(如锁的获取与释放、上下文切换等),因此相对于非线程安全的实现,其性能可能会有所下降。然而,现代 JVM 对 synchronized 进行了大量优化(如偏向锁、轻量级锁、自旋锁),在许多场景下其性能开销已经很小
  • 线程不安全的设计在没有并发访问时性能最好。如果能够确保在应用程序的整个生命周期中,某个对象或代码块永远不会被多个线程同时访问(例如,它只在单线程环境中使用,或者它是线程局部的),那么使用非线程安全的设计可以避免不必要的同步开销。但如果存在任何并发访问的可能性,则必须使用线程安全的设计来保证程序的正确性

synchronized

使用锁

同步块

锁对象:理论上可以是任意的非 null 唯一对象

synchronized 是可重入的,默认是不公平的,并且在竞争激烈时会升级为重量级锁

原则上:

  • 锁对象建议使用共享资源本身,或者一个专门用于同步的 final 对象
  • 在实例方法中使用 this 作为锁对象,锁住的 this 正好是当前实例,从而保护了该实例的共享成员
  • 在静态方法中使用 类名.class 字节码对象作为锁对象,因为静态成员属于类,被所有实例对象共享,所以需要锁住整个类而不是某个实例

同步代码块格式:

java
synchronized(锁对象){
    // 访问共享资源的核心代码
}

实例:

java
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

java
// 同步实例方法
修饰符 synchronized 返回值类型 方法名(方法参数) { 
    方法体;
}
// 同步静态方法
修饰符 static synchronized 返回值类型 方法名(方法参数) { 
    方法体;
}

同步方法底层也是有锁对象的:

  • 如果方法是实例方法:同步方法默认用 this 作为锁对象

    java
    public synchronized void test() {}
    // 等价于
    public void test() {
        synchronized(this) {}
    }
  • 如果方法是静态方法:同步方法默认用 类名.class 作为锁对象

    java
    class 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.classn2 是两个完全不同的锁对象,它们之间不会发生互斥,因此可以并发执行。

java
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 对象锁,因此它们之间会发生互斥,保证了串行执行。

java
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 等。最后两位是锁标志位。
txt
|-------------------------------------------------------|---------------------|
|                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:
txt
|----------------------------------------------------------------------|--------------------|
|                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   |
|----------------------------------------------------------------------|--------------------|

工作流程(重量级锁)

  1. 初始状态:Monitor 中 Owner 字段为 null

  2. 获取锁:当 Thread-2 执行 synchronized(obj) 并成功获取锁时,Monitor 的所有者 Owner 字段被置为 Thread-2。一个 Monitor 只能有一个 Ownerobj 对象的 Mark Word 会被更新,指向该 Monitor 对象的地址,同时锁标志位变为 10(表示重量级锁)。对象原有的 Mark Word(如果是从轻量级锁膨胀而来,则原 Mark Word 已在线程栈的锁记录中;如果是直接获取重量级锁,则 Mark Word 直接更新)会被妥善处理,以便解锁时恢复。

  3. 竞争锁:在 Thread-2 持有锁期间,如果 Thread-3、Thread-4、Thread-5 也尝试执行 synchronized(obj),它们会进入 Monitor 的 EntryList(一个由 BLOCKED 线程组成的双向链表),等待获取锁。

  4. 释放锁:Thread-2 执行完同步代码块的内容,会释放锁。它会根据 obj 对象头中 Monitor 地址找到对应的 Monitor,将 Owner 字段设置回 null。同时,obj 对象的 Mark Word 会被恢复到其解锁前的状态(例如,01 表示无锁状态,或者重新进入可偏向状态等)。

  5. 唤醒竞争者:释放锁后,Monitor 会唤醒 EntryList 中等待的线程来竞争锁。这个竞争是非公平的,如果这时有新的线程(不来自 EntryList)想要获取锁,它可能直接抢占到,导致 EntryList 中的线程继续阻塞。

  6. WaitSetWaitSet 中的 Thread-0,是以前获得过锁,但因为在同步块内调用了 obj.wait() 方法,条件不满足而主动释放锁并进入 WAITING 状态的线程。它们需要通过 obj.notify()obj.notifyAll() 才能被唤醒,唤醒后会尝试重新竞争锁。

注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的互斥效果。
  • 不加 synchronized 的对象不会关联监视器,自然也就不受 Monitor 机制的互斥保护。

锁升级

升级过程

synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化

java
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁  // 随着竞争的增加,只能锁升级,不能降级

偏向锁

偏向锁是 synchronized 的一种优化,旨在提高在没有并发竞争的情况下获取锁的性能。其思想是“偏向”于第一个获取锁的线程,该线程之后再次获取该锁时,不再需要进行任何同步操作。

  • 当锁对象第一次被线程获得时,如果该对象处于可偏向状态,JVM 会尝试将其 Mark Word 标记为 101(偏向锁状态),并使用 CAS 操作将获取锁的线程 ID 记录到 Mark Word。
  • 如果 CAS 操作成功,这个线程以后再次进入这个锁相关的同步块时,只需检查 Mark Word 中的线程 ID 是否是自己。如果是,就表示没有竞争,不需要再进行任何同步操作,直接进入同步块。
  • 当有另一个线程去尝试获取这个锁对象时,偏向状态就宣告结束。此时会撤销偏向(Revoke Bias),锁会恢复到无锁状态,然后升级为轻量级锁或直接升级为重量级锁(取决于竞争情况)。

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05(即最后 3 位为 101),threadepochage 都为 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 开销。

锁重入实例:

java
static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2(); // 线程再次尝试获取 obj 锁,因为是可重入的,所以不会阻塞
    }
}
public static void method2() {
    synchronized( obj ) {
        // 同步块 B
    }
}

轻量级锁原理:

  1. 创建锁记录(Lock Record)对象:

    每个线程的栈帧都会包含一个锁记录的结构。当线程第一次进入同步块时,会在其栈帧中创建一个 Lock Record,用于存储锁定对象的 Mark Word 的副本。

  2. 尝试获取锁:

    • 让锁记录中 Object reference 指向锁住的对象。
    • 线程尝试用 CAS 操作替换对象的 Mark Word:将对象的 Mark Word 替换为指向当前线程栈帧中 Lock Record 的指针。
    • 同时,将对象原有的 Mark Word 值存入 Lock Record 的 Displaced Mark Word 字段中。
  3. CAS 替换成功:

    如果 CAS 替换成功,对象头中存储了 Lock Record 地址和状态 00(轻量级锁)。这表示由该线程成功给对象加锁。

  4. 如果 CAS 失败,有两种情况:

    • 如果是其它线程已经持有了该 Object 的轻量级锁:这时表明有竞争。当前线程会先尝试自旋,如果自旋失败,则进入锁膨胀过程,将轻量级锁变为重量级锁。
    • 如果是线程自己执行了 synchronized 锁重入:线程会在其栈帧中添加一个新的 Lock Record,其 Displaced Mark Word 字段为 null,作为重入的计数标记。

  5. 当退出 synchronized 代码块(解锁时)

    • 如果 Lock Record 的 Displaced Mark Wordnull:表示这是一次重入的解锁。此时只需重置该 Lock Record,表示重入计数减 1。
    • 如果 Lock Record 的 Displaced Mark Word 不为 null:表示这是最外层锁的解锁。这时使用 CAS 将 Lock Record 中保存的 Mark Word 值恢复给对象头。
      • 成功:则解锁成功。
      • 失败:说明在解锁期间,该锁已经进行了锁膨胀(升级为重量级锁)。此时进入重量级锁解锁流程(通知 Monitor 释放锁)。

锁膨胀

当在尝试加轻量级锁的过程中,CAS 操作无法成功,且不是锁重入(即有其它线程正在持有该对象的轻量级锁,存在真正的并发竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  • Thread-1 加轻量级锁失败,进入锁膨胀流程:

    1. JVM 为 Object 对象申请一个 Monitor 锁(重量级锁)。
    2. 通过 Object 对象头中保存的 Lock Record 地址,找到当前持锁线程(Thread-0)。
    3. 将 Monitor 的 Owner 置为 Thread-0。
    4. 将 Object 的 Mark Word 更新为指向该 Monitor 的地址,并设置锁标志位为 10
    5. 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 探测到一串连续的操作都对同一个对象加锁时,它可能会将这些细粒度的加锁操作合并,把加锁的范围扩展(粗化)到整个操作序列的外部,从而减少频繁的锁获取和释放带来的性能损耗。

  • 一些看起来没有加锁的代码,其实隐式的加了很多锁:

    java
    public static String concatString(String s1, String s2, String s3) {
        return s1 + s2 + s3;
    }
  • String 是一个不可变的类。在 JDK 1.5 之前,+ 操作符进行字符串拼接通常会转化为 StringBuffer 对象的连续 append() 操作。StringBuffer 的每个 append() 方法都是同步的(因为它内部使用了 synchronized)。

    java
    public 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),即使用多把锁来保护不同的独立资源,可以提高并发度:

  • 好处:可以增强并发度,提高程序的吞吐量。
  • 坏处:如果一个线程需要同时获得多把不相干的锁,就容易发生死锁(需要同时获取多个资源时)。

解决方法:为不同的独立共享资源准备多个对象锁。

java
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 死锁产生的四个必要条件:

  1. 互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放
  3. 请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
  4. 循环等待条件,即存在一个等待循环队列,P1 等待 P2 的资源,P2 等待 P3 的资源,...,Pn 等待 P1 的资源,形成了一个等待环路

四个条件都成立的时候,便形成死锁。死锁情况下打破上述任何一个条件,便可让死锁消失

java
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
  • 可以使用 jconsolejvisualvm 等图形化工具,它们通常提供死锁检测功能,可以直观地展示死锁情况

活锁

活锁:指的是任务或者执行者没有被阻塞,但由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程,从而无法向前推进。线程虽然没有阻塞,但却无法完成有效工作。它与死锁的区别在于,死锁是线程永远停止活动,而活锁是线程一直在活动但没有进展。

两个线程互相改变对方的结束条件,最后谁也无法结束:

java
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:

java
// 唤醒正在等待对象监视器的单个线程(具体唤醒哪个线程是不确定的)
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,状态变为 WAITINGTIMED_WAITING(如果设置了超时)
  • notify()notifyAll() 方法用于从 WaitSet 中唤醒线程。被唤醒的线程并不会立即执行,它们需要重新竞争对象锁,只有成功获取到锁后才能从 wait() 方法处继续执行

TIP

阻塞(Blocked):是在“等门开”,门一开(锁释放),阻塞的线程会自动参与竞争。

等待/挂起(Waiting):是在“进屋睡觉”,除非别人过来推醒(notify/unpark)它,否则它永远不会去竞争锁。

对比 sleep()

  • 所属类:
    • sleep() 方法属于 Thread 类,用于控制线程自身的暂停
    • wait() 方法属于 Object 类,用于线程间的协作通信
  • 锁的处理机制:
    • 调用 sleep() 方法时,线程不会释放它持有的任何对象锁,但会释放 CPU 执行权
    • 调用 wait() 方法时,线程会立即释放它持有的对象锁,并进入 WaitSet,同时释放 CPU 执行权
  • 使用区域:
    • wait()notify()notifyAll() 方法必须在同步代码块或同步方法中使用,即在获取到对象锁之后才能调用,否则会抛出 IllegalMonitorStateException
    • sleep() 方法可以在任何地方使用

底层原理:

  • 当 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() 可以唤醒所有等待线程,让它们重新检查自己的条件,从而避免“死锁”或“饥饿”问题(即本应被唤醒的线程没有被唤醒)。
点击查看案例演示
java
@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() 时立即被消耗
java
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、原子类等)。

局部变量:

  • 局部变量本身是存储在线程栈中的,每个线程都有自己的栈帧,因此局部变量是线程安全的。
  • 然而,局部变量引用的对象不一定线程安全。这取决于该对象是否“逃逸”出方法的作用范围(逃逸分析):
    • 如果该对象没有逃离方法的作用范围(即只在方法内部使用,没有作为返回值或被其他线程访问),它是线程安全的。
    • 如果该对象逃离方法的作用范围(例如,作为返回值返回,或者被存储到共享的成员变量中,导致其他线程可以访问),就需要考虑线程安全问题。

常见线程安全类

  • 线程安全类示例:StringIntegerStringBufferRandomThreadLocalRandomVectorHashtable、以及 java.util.concurrent 包下的所有类(如 ConcurrentHashMapCopyOnWriteArrayList 等)。

  • 定义:一个类被称为线程安全的,通常是指多个线程调用它们同一个实例的某个方法时,该方法内部的操作是线程安全的。

  • 组合非原子性:需要特别注意,即使类本身是线程安全的,多个方法的组合操作可能不是原子的,仍然需要额外的同步机制来保证组合操作的线程安全。

    java
    Hashtable 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);
        }
    }
  • 无状态类线程安全:如果一个类没有任何成员变量(即没有状态),那么它自然是线程安全的,因为没有共享数据可供竞争。

  • 不可变类线程安全:StringInteger 等都是不可变类。这意味着它们内部的状态一旦创建就不能改变。因此,它们的方法不会修改自身状态,也不会引入竞态条件,所以是线程安全的。

    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() 的具体实现是未知的,就可能引入上述风险。应尽量避免在持有锁的情况下调用外星方法。

贡献者

The avatar of contributor named as LI SIR LI SIR

页面历史