Java 多线程“八锁”问题深度解析

Java 多线程"八锁是 Java 并发编程中一个非常经典的面试题和教学案例,主要用于考察开发者对 synchronized 关键字、对象锁(实例锁)、类锁(静态锁)以及线程执行顺序的理解。

它的核心场景通常基于你刚才提供的代码结构:

  1. 一个资源类(如 Phone 或 Resource)。
  2. 两个同步方法(如 sendSms() 和 call())。
  3. 两个线程同时访问这个资源类。
  4. 通过改变 static 关键字、sleep 的位置、多个对象实例等条件,来观察输出顺序的变化。

🔑 "八锁"的核心逻辑总结

这八种情况其实可以归纳为三个核心原则:

1. 锁的是谁?

  • 非静态同步方法 (synchronized void method):锁的是 当前实例对象 (this)
  • 静态同步方法 (static synchronized void method):锁的是 当前类的 Class 对象 (Class)
  • 代码块同步 (synchronized(this)synchronized(Class)):锁的是括号里指定的对象。

2. 一把钥匙开一把锁

  • 如果两个线程争夺的是同一把锁(同一个对象),那么必须排队(串行执行)。
  • 如果两个线程争夺的是不同的锁(不同的对象,或者一个是实例锁一个是类锁),那么可以并行执行。

3. 方法内部的身体 vs 方法外部的等待

  • synchronized 锁住的是方法体内部的执行权。
  • 如果在方法调用之前(外部)sleep,此时线程还没拿到锁,或者还没进入同步区域,这会影响启动时间,但不影响锁的竞争逻辑。
  • 如果在方法内部 sleep,线程是持有锁睡觉的,这会阻塞其他试图获取同一把锁的线程。

📋 经典的八种情况实战演练

🟢 情况 1:两个普通同步方法 + 同一个对象 + 主线程休眠(无内部耗时)

场景描述:两个普通同步方法,同一个对象实例。线程 A 先启动,主线程休眠确保 A 执行完,B 再启动。

java 复制代码
import java.util.concurrent.TimeUnit;

public class Test1 {
    public static void main(String[] args) {
        Phone phone = new Phone();

        // 修正点 1: 去掉 "name:",直接传入字符串
        new Thread(() -> {
            phone.sendSms();
        }, "A").start();

        // 捕获
        try {
            // 修正点 2: 去掉 "timeout:",sleep 方法直接接收 long 类型的数值
            TimeUnit.SECONDS.sleep(4);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 修正点 3: 去掉 "name:",直接传入字符串
        new Thread(() -> {
            phone.call();
        }, "B").start();
    }
}

class Phone {
    public synchronized void sendSms() {
        System.out.println("sendSms");
    }

    public synchronized void call() {
        System.out.println("call");
    }
}

🖨️ 运行输出结果:

复制代码
sendSms
call

🔍 现象分析:

观察到的现象是:先打印 sendSms,4s 后打印:call。

也就是说:

  • T0 主线程开启
  • T1 线程 A 获得锁开始执行,执行完,回到调用点
  • T2 主线程 sleep(4)
  • T3 线程 B 获得锁开始执行,执行完,回到调用点

❓ 疑问点深度解析:

  1. synchronized 锁的是什么?锁的是实例对象,但是这个锁不是放在方法上的吗?

    • 锁确实是标记在方法上的,但 JVM 在执行时,锁住的是调用该方法的对象实例(this)
  2. 这跟 synchronized(this){ ... } 有什么区别?

    java 复制代码
    // 写法 A:修饰方法
    public synchronized void sendSms() {
        System.out.println("sendSms");
    }
    
    // 写法 B:修饰代码块
    public void sendSms() {
        synchronized(this) {
            System.out.println("sendSms");
        }
    }
    • 功能上 :在绝大多数普通场景下(如本例),两者完全等价。它们都意味着:任何线程想要执行这段代码,必须先拿到 phone 对象(this)的锁。
    • 粒度控制(细微差别)
      • 写法 A (方法):锁的范围是整个方法。从方法第一行到最后一行(包括 return),锁一直持有。
      • 写法 B (代码块) :锁的范围仅限于大括号 {} 内部。如果你在同步块之前或之后还有代码,那些代码是不需要锁的,其他线程可以并发执行那些非同步部分。
      • 在本例中:因为整个方法体都在同步逻辑内,所以两者效果一模一样。
    • 什么时候不一样? 如果方法里有很多耗时但不涉及共享资源的操作,用写法 B 可以把锁的范围缩小,提高并发性能。
  3. 这样跟主线程顺序执行有什么区别呢?还不是同一时间只能执行一个方法,没有起到多线程的作用。

    • 是的,在这个特定实验(串行启动)下,确实没有起到"加速"的作用,它的意义在于演示互斥性
  4. 这块 TimeUnit.SECONDS.sleep(1) 锁上睡觉是哪个线程?是主线程还是线程 A?

    • 是**主线程(Main Thread)**在睡觉。
    • 执行流程推导
      1. T0 时刻:main 方法开始执行。
      2. T1 时刻new Thread(..., "A").start() 被调用。此时,线程 A 被创建并启动,它开始尝试运行 phone.sendSms()。注意:start() 方法是异步的。主线程调用完 start() 后,不会等待线程 A 执行完,而是继续往下执行下一行代码。
      3. T2 时刻 :主线程执行到 TimeUnit.SECONDS.sleep(1)。此时,主线程进入休眠状态,暂停 1 秒。与此同时,线程 A 正在 CPU 上运行(获取锁 -> 打印 "sendSms" -> 释放锁)。
      4. 设计意图:代码里加这 1 秒休眠,是为了确保线程 A 有足够的时间在线程 B 启动之前,已经拿到了锁并执行完毕(或者至少已经开始执行)。如果不加这行休眠,极端情况下(虽然概率低),主线程可能瞬间就启动了线程 B,导致 A 和 B 几乎同时去抢锁。虽然结果依然是串行的(因为锁的存在),但加上休眠可以让实验现象更稳定、更符合"先 A 后 B"的预期逻辑。
      5. T3 时刻:1 秒后,主线程醒来。
      6. T4 时刻 :主线程执行 new Thread(..., "B").start(),启动线程 B。此时线程 A 早就跑完了(因为 sendSms 只是打印一句话,耗时远小于 1 秒)。线程 B 轻松获取锁,打印 "call"。

🟡 情况 2:两个普通同步方法 + 同一个对象 + 方法内部 Sleep

场景描述:线程 A 进入同步方法后休眠 4 秒(持有锁睡眠),线程 B 随后尝试获取同一把锁。

java 复制代码
import java.util.concurrent.TimeUnit;

public class Test2 {
    public static void main(String[] args) {
        Phone phone = new Phone();

        // 线程 A 调用 sendSms()
        new Thread(() -> {
            phone.sendSms();
        }, "A").start();

        // 主线程休眠 1 秒,确保线程 A 已经启动并获取到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程 B 调用 call()
        new Thread(() -> {
            phone.call();
        }, "B").start();
    }
}

class Phone {
    // 同步方法 + 内部休眠 4 秒(模拟耗时操作)
    public synchronized void sendSms() {
        try {
            TimeUnit.SECONDS.sleep(4); // 模拟发送短信耗时
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("发短信");
    }

    // 同步方法
    public synchronized void call() {
        System.out.println("打电话");
    }
}

🖨️ 现象与结论:

  • 输出顺序:先发短信,再打电话。
  • 分析
    • T0 时刻:主线程开启。
    • T1 时刻:线程 A 获取锁开始执行。
    • T1+1s :主线程睡醒,执行到了线程 B 调用。但是此时线程 A 还在 sleep(4) 中,还没有释放对象锁 。所以线程 B 被阻塞,无法进入 call() 方法。
    • T1+4s :线程 A 执行结束,打印"发短信",并释放锁
    • 随即:线程 B 获得锁,开始执行,打印"打电话"。
  • 最终现象 :点击运行 4s 后 开始连续打印"发短信"、"打电话"。

🔄 变体实验:调整 Sleep 时间

如果我们将主线程的等待时间拉长,而方法内的睡眠时间缩短:

java 复制代码
// 主线程休眠改为 8 秒
try {
    TimeUnit.SECONDS.sleep(8); 
} catch (InterruptedException e) {
    e.printStackTrace();
}

// 方法内休眠改为 1 秒
public synchronized void sendSms() {
    try {
        TimeUnit.SECONDS.sleep(1); 
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("发短信");
}

🖨️ 现象:

  • 等待 1s 打印"发短信"(线程 A 早就跑完了)。
  • 再等 7s(主线程还在睡),主线程醒来启动线程 B。
  • 打印:"打电话"。
  • 结论 :这证明了锁的竞争取决于谁先拿到锁 以及锁何时释放,与主线程何时启动 B 无关(只要 A 没跑完,B 就得等;如果 A 跑完了,B 随时进)。

🔵 情况 3:同步方法 vs 非同步方法

场景描述:两个同步方法(sendSms, call)和一个非同步方法(hello)。验证非同步方法是否受锁影响。

java 复制代码
import java.util.concurrent.TimeUnit;

public class Test3 {
    public static void main(String[] args) {
        Phone phone = new Phone();

        // 线程 A 调用 sendSms() ------ 同步方法
        new Thread(() -> {
            phone.sendSms();
        }, "A").start();

        // 主线程休眠 1 秒,确保线程 A 已经启动并获取到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程 B 调用 call() ------ 同步方法
        new Thread(() -> {
            phone.call();
        }, "B").start();

        // 线程 C 调用 hello() ------ 非同步方法
        new Thread(() -> {
            phone.hello();
        }, "C").start();
    }
}

class Phone {
    // 同步方法 + 内部休眠 4 秒(模拟耗时操作)
    public synchronized void sendSms() {
        try {
            TimeUnit.SECONDS.sleep(4); // 模拟发送短信耗时
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("发短信");
    }

    // 同步方法
    public synchronized void call() {
        System.out.println("打电话");
    }

    // 非同步方法 ------ 不受锁限制!
    public void hello() {
        System.out.println("hello");
    }
}

🔍 流程分析:

  • T0 时刻:线程 A 获得锁,开始执行(进入 4s 睡眠)。
  • T0+1 时刻
    • 主线程睡眠结束。
    • 线程 B 尝试获得锁,未果(因为 A 还抱着锁睡觉),进入阻塞状态。
    • 线程 C 调用 hello(),该方法没有 synchronized ,不需要抢锁,直接执行 ,输出 hello
  • T0+4 时刻
    • 线程 A 打印"发短信",然后释放锁。
    • 线程 B 立刻获得锁,执行"打电话"。

🖨️ 最终现象:

  • 1s 后 :打印 hello
  • 4s 后 :连续打印 发短信 打电话

💡 核心结论:
synchronized 关键字会把并行任务搞成串行(针对同一把锁),但不会影响非同步方法的执行。非同步方法就像"开后门",不需要排队。


🟣 情况 4:两个普通同步方法 + 两个不同对象实例

场景描述:创建两个不同的 Phone 对象,分别调用各自的同步方法。验证实例锁的独立性。

(注:你提供的代码中类名为 Phone2,方法未加 static,符合"两个普通同步方法 + 两个对象"的特征)

java 复制代码
import java.util.concurrent.TimeUnit;

public class Test4_Instance {
    public static void main(String[] args) {
        // 创建两个不同的 Phone2 对象实例
        Phone2 phone1 = new Phone2();
        Phone2 phone2 = new Phone2();

        // 线程 A 调用 phone1 的 sendSms()
        new Thread(() -> {
            phone1.sendSms();  
        }, "A").start();

        // 主线程休眠 1 秒,确保线程 A 已经启动并获取到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程 B 调用 phone2 的 call()
        new Thread(() -> {
            phone2.call();   
        }, "B").start();
    }
}

class Phone2 {
    // 普通同步方法,锁的是 this (即 phone1)
    public synchronized void sendSms() {
        try {
            TimeUnit.SECONDS.sleep(4); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("发短信");
    }

    // 普通同步方法,锁的是 this (即 phone2)
    public synchronized void call() {
        System.out.println("打电话");
    }
}

🔍 流程分析:

  • T0 :线程 A 获得 phone1 实例对象的锁,开始执行(睡眠 4s)。
  • T0+1 :主线程睡完。线程 B 开始执行,调用 phone2.call()
    • 线程 B 需要获取 phone2 的锁。
    • 因为 phone1phone2 是两个完全不同的对象,它们的锁互不干扰。
    • 线程 B 成功获得锁,立即执行,打印"打电话"。
  • T0+4:线程 A 睡醒,打印"发短信"。

🖨️ 最终现象:

  • 1s 后 :打印 打电话
  • 3s 后 (总共 4s):打印 发短信
  • 结论 :两把不同的钥匙(锁),互不阻塞,并行执行

🟠 情况 5:两个静态同步方法 + 两个不同对象实例

场景描述 :即使创建了两个对象,但因为方法是 static synchronized,锁的是 Class 对象,依然会串行。

java 复制代码
import java.util.concurrent.TimeUnit;

public class Test4_Static {
    public static void main(String[] args) {
        // 创建两个不同的 Phone3 对象实例
        Phone3 phone1 = new Phone3();
        Phone3 phone2 = new Phone3();

        // 线程 A 调用 phone1 的 sendSms() ------ 注意:是 static synchronized!
        new Thread(() -> {
            phone1.sendSms();  // 实际锁的是 Class 对象 (Phone3.class),不是 phone1 实例
        }, "A").start();

        // 主线程休眠 1 秒,确保线程 A 已经启动并获取到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程 B 调用 phone2 的 call() ------ 也是 static synchronized!
        new Thread(() -> {
            phone2.call();   // 同样锁的是同一个 Class 对象 (Phone3.class)!
        }, "B").start();
    }
}

class Phone3 {
    // ⚠️ 关键:static synchronized ------ 锁的是 Class 对象(Phone3.class),不是 this!
    public static synchronized void sendSms() {
        try {
            TimeUnit.SECONDS.sleep(4); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("发短信");
    }

    // ⚠️ 同样是 static synchronized ------ 和 sendSms() 共享同一把锁!
    public static synchronized void call() {
        System.out.println("打电话");
    }
}

🔍 核心原理:

锁静态方法锁的是 Class 对象,这个在 JVM 内存区(方法区/元空间)中。无论创建多少个实例,Class 对象只有一个。

⏱️ 流程分析:

  • T0 :线程 A 获得 Class 锁。
  • T0+1:主线程睡觉睡醒。线程 B 尝试获得锁,但是这个时候线程 A 的 Class 锁还没释放(A 还在睡)。线程 B 阻塞。
  • T0+4:线程 A 释放 Class 锁,打印"发短信"。线程 B 随即获得 Class 锁,打印"打电话"。

🖨️ 最终现象:

  • 4s 之后 连续打印:"发短信"、"打电话"。
  • 结论 :即使是两个不同的对象,只要锁的是同一个 Class,依然串行执行

🔴 情况 6:一个静态同步方法 + 一个普通同步方法 + 同一个对象

场景描述:混合使用静态锁和实例锁。验证两种锁是否互斥。

java 复制代码
import java.util.concurrent.TimeUnit;

/**
 * 1. 1个静态的同步方法,1个普通的同步方法,一个对象,先打印 发短信?打电话?
 */
public class Test5 {
    public static void main(String[] args) {
        // 两个对象改成 Class 类模板只有一个,static,锁的是 class
        Phone4 phone = new Phone4();  // ⚠️ 注意:这里只创建了一个对象!

        // 线程 A 调用 phone 的 sendSms() ------ static synchronized → 锁 Phone4.class
        new Thread(() -> {
            phone.sendSms();
        }, "A").start();

        // 主线程休眠 1 秒,确保线程 A 已经启动并获取到锁
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 线程 B 调用 phone 的 call() ------ 普通 synchronized → 锁 this (phone 实例)
        new Thread(() -> {
            phone.call();
        }, "B").start();
    }
}

// Phone4 只有一个 Class 对象
class Phone4 {
    // synchronized 锁的对象是方法的调用者!
    // static 静态方法
    // 这一句就说明了!锁的是 Class
    public static synchronized void sendSms() {
        try {
            TimeUnit.SECONDS.sleep(4); // 模拟耗时操作
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("发短信");
    }

    // 普通的同步方法
    public synchronized void call() {
        System.out.println("打电话");
    }
}

🔍 流程分析:

  • T0 时刻 :线程 A 获得 Class 对象锁
  • T0+1 :主线程睡醒。线程 B 执行,尝试获取 实例对象锁(this)
    • 因为 Class 锁和实例锁是两把完全不同的锁,互不干扰。
    • 线程 B 成功获得锁,立即执行,打印"打电话"。
  • T0+4:线程 A 打印"发短信",释放 Class 锁。

🖨️ 最终现象:

  • 1s 后 :打印 打电话
  • 4s 后 :打印 发短信
  • 结论 :两个锁是完全独立的。并行执行

📝 总结回顾

情况 方法类型 A 方法类型 B 对象情况 锁的情况 结果
1 & 2 普通同步 普通同步 同一个 同一把 (this) 串行 (A 睡 B 等)
3 普通同步 非同步 同一个 有锁 vs 无锁 并行 (C 不等 A)
4 普通同步 普通同步 两个不同 两把 (this1, this2) 并行 (互不干扰)
5 静态同步 静态同步 两个不同 同一把 (Class) 串行 (全局唯一锁)
6 静态同步 普通同步 同一个 两把 (Class, this) 并行 (不同维度锁)

这就是 Java "八锁"问题的精髓!掌握了这些,你就彻底理解了 synchronized 的锁机制。

相关推荐
寻见9031 小时前
Java为什么能“一次编写,到处运行”?JVM到底解决了什么核心痛点?
java·jvm·java ee
AI克斯1 小时前
【通俗易懂】注解(@)的理解
java
人还是要有梦想的1 小时前
QT的起源
开发语言·qt
spencer_tseng1 小时前
‘<>‘ operator is not allowed for source level below 1.7
java
柏箱2 小时前
文件上传漏洞入门:(upload-labs Pass-1 & Pass-2)
开发语言·前端·javascript
人道领域2 小时前
Day | 07 【苍穹外卖:菜品套餐的缓存】
java·开发语言·redis·缓存击穿·springcache
biter down2 小时前
C++ 精准控制对象的创建位置(堆 / 栈)
开发语言·c++
星轨初途2 小时前
类和对象(上)
开发语言·c++·经验分享·笔记
m0_706653232 小时前
数据库与缓存操作策略:数据一致性与并发问题
java·数据库·缓存