Java 多线程"八锁是 Java 并发编程中一个非常经典的面试题和教学案例,主要用于考察开发者对 synchronized 关键字、对象锁(实例锁)、类锁(静态锁)以及线程执行顺序的理解。
它的核心场景通常基于你刚才提供的代码结构:
- 一个资源类(如 Phone 或 Resource)。
- 两个同步方法(如 sendSms() 和 call())。
- 两个线程同时访问这个资源类。
- 通过改变 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 获得锁开始执行,执行完,回到调用点
❓ 疑问点深度解析:
-
synchronized 锁的是什么?锁的是实例对象,但是这个锁不是放在方法上的吗?
- 锁确实是标记在方法上的,但 JVM 在执行时,锁住的是调用该方法的对象实例(this)。
-
这跟
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 可以把锁的范围缩小,提高并发性能。
-
这样跟主线程顺序执行有什么区别呢?还不是同一时间只能执行一个方法,没有起到多线程的作用。
- 是的,在这个特定实验(串行启动)下,确实没有起到"加速"的作用,它的意义在于演示互斥性。
-
这块
TimeUnit.SECONDS.sleep(1)锁上睡觉是哪个线程?是主线程还是线程 A?- 是**主线程(Main Thread)**在睡觉。
- 执行流程推导 :
- T0 时刻:main 方法开始执行。
- T1 时刻 :
new Thread(..., "A").start()被调用。此时,线程 A 被创建并启动,它开始尝试运行phone.sendSms()。注意:start()方法是异步的。主线程调用完start()后,不会等待线程 A 执行完,而是继续往下执行下一行代码。 - T2 时刻 :主线程执行到
TimeUnit.SECONDS.sleep(1)。此时,主线程进入休眠状态,暂停 1 秒。与此同时,线程 A 正在 CPU 上运行(获取锁 -> 打印 "sendSms" -> 释放锁)。 - 设计意图:代码里加这 1 秒休眠,是为了确保线程 A 有足够的时间在线程 B 启动之前,已经拿到了锁并执行完毕(或者至少已经开始执行)。如果不加这行休眠,极端情况下(虽然概率低),主线程可能瞬间就启动了线程 B,导致 A 和 B 几乎同时去抢锁。虽然结果依然是串行的(因为锁的存在),但加上休眠可以让实验现象更稳定、更符合"先 A 后 B"的预期逻辑。
- T3 时刻:1 秒后,主线程醒来。
- 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 的锁。
- 因为
phone1和phone2是两个完全不同的对象,它们的锁互不干扰。 - 线程 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 的锁机制。