一、核心结论速览
synchronized可以修饰方法,也可以修饰代码块,锁的对象取决于具体用法:
| 使用方式 | 锁的对象 | |
|---|---|---|
| 修饰方法 | 非静态方法 | 当前实例对象(方法调用者) |
| 静态方法 | 方法所在的Class对象 | |
| 修饰代码块 | synchronized括号中传入的对象 | |
二、辅助案例1:非静态方法 —— 锁的是方法调用者
2.1 基础代码(无 synchronized)
public class SynchronizedTest1 { public static void main(String[] args) { Data data = new Data(); Thread t1 = new Thread() { @Override public void run() { data.fun1(); } }; t1.start(); TimeUnit.SECONDS.sleep(1); Thread t2 = new Thread() { @Override public void run() { data.fun2(); } }; t2.start(); } } class Data { public void fun1() { TimeUnit.SECONDS.sleep(3); System.out.println("1...."); } public void fun2() { System.out.println("2...."); } }输出结果:先输出2,约2秒后输出1。
执行流程:
main 线程启动,main方法的栈帧入栈。
程序首次使用Data类,进行类加载,在堆中创建Data对象,main栈帧中的局部变量data存储该对象的引用地址。
通过匿名内部类的方式创建线程t1并加入到就绪队列中,等到t1获得CPU执行权后,调用data.fun1()方法,但fun1()需要等待3秒才输出。
与此同时,主线程继续执行,休眠1秒后,通过匿名内部类的方式创建线程t2并加入到就绪队列中。
此时,由于t1在休眠,CPU是空闲的,所以线程t2获得执行权,调用data.fun2()方法并直接输出。
3秒后,t1的sleep时间到,进入就绪状态。再次获得CPU执行权后,继续执行后续代码,进行输出。
2.2 场景1:fun1() 和 fun2() 都加 synchronized
class Data { public synchronized void fun1() { ... } public synchronized void fun2() { ... } }输出结果:等待3秒,输出1,紧接着输出2。
执行流程:
main线程启动,main方法的栈帧入栈。
程序首次使用Data类,进行类加载,在堆中创建Data对象,main栈帧中的局部变量data存储该对象的引用地址。
通过匿名内部类的方式创建线程t1并加入到就绪队列中,等到t1获得CPU执行权后,t1尝试获取方法调用者data的锁,此时未被占用,t1成功获取锁,开始执行。在休眠3s时,t1并不会释放锁。
与此同时,主线程休眠1秒后,通过匿名内部类的方式创建线程t2并加入到就绪队列中。
此时,由于t1在休眠,CPU是空闲的,所以线程t2获得执行权,但是锁仍在t1中,t2无法获取,所以进入阻塞状态,等待t1释放锁后重新竞争。
3s后,t1休眠时间到,进入就绪队列,再次获得CPU执行权后,继续执行后续代码,进行输出1,完成之后释放锁,此时t2被唤醒,成功获得锁,执行输出2,完成后释放锁。
2.3 场景2:fun1() 有 synchronized,fun2() 没有 synchronized
class Data { public synchronized void fun1() { ... } public void fun2() { ... } }输出结果:先输出2,约2秒后输出 1。
原因:fun2()没有被synchronized修饰,调用时无需获取锁,直接执行,不受t1持有的锁影响。
2.4 场景3:两个Data对象,t1调用data1.fun1(),t2调用data2.fun2(),两个方法都有 synchronized
Data data1 = new Data(); Data data2 = new Data(); // t1 → data1.fun1() // t2 → data2.fun2()输出结果:先输出2,约2秒后输出1。
执行流程:
main线程启动,main方法的栈帧入栈。
程序首次使用Data类,进行类加载,在堆中创建data1对象和data2对象,main栈帧中的局部变量data1和data2分别存储各自对象的引用地址。
通过匿名内部类的方式创建线程t1并加入到就绪队列中。等到t1获得CPU执行权后,t1尝试获取方法调用者data1的锁,此时未被占用,t1成功获取锁,开始执行。在休眠3s时,t1并不会释放锁。
与此同时,主线程休眠1秒后,通过匿名内部类的方式创建线程t2并加入到就绪队列中。
此时,由于t1在休眠,CPU是空闲的,所以线程t2获得CPU执行权,并尝试获取方法调用者data2的锁,此时未被占用,t2成功获取到锁,执行并直接输出,完成后释放锁。
3s后,t1休眠时间到,进入就绪队列,再次获得CPU执行权后,继续执行后续代码,进行输出1,完成之后释放锁。
结论:t1和 t2锁的是不同的对象(data1和data2),互不干扰。这证明了synchronized修饰非静态方法时,锁的是方法调用者(当前实例对象),而不是类。
三、辅助案例2:静态方法 —— 锁的是 Class 对象
3.1 Scenario 4: fun1() is static synchronized, fun2() is regular synchronized
class Data { public static synchronized void fun1() { ... } public synchronized void fun2() { ... } } Data data1 = new Data(); Data data2 = new Data(); // t1 → data1.fun1() // t2 → data2.fun2()输出结果:先输出2,约2秒后输出1。
原因:fun1()是静态方法,锁的是Data.class对象;fun1()是普通方法,锁的是data2实例对象。这是两把完全独立的锁,不存在资源竞争。
3.2 场景5:两个线程都调用 static synchronized fun1()
class Data { public static synchronized void fun1() { sleep(3); System.out.println("1...."); } } Data data1 = new Data(); Data data2 = new Data(); // t1 → data1.fun1() // t2 → data2.fun1()输出结果:等待3秒,输出1(来自 t1),再等待3秒,输出1(来自 t2)。
原因:因为synchronized修饰fun1静态方法锁的是Data.class对象,虽然两个线程中方法调用者不同,但所有实例共享同一把类锁,所以说会发生资源争抢。线程t2需要等待t1执行完毕释放锁之后才能拿到锁,执行输出。
结论:虽然两个线程调用的是不同的实例(data1和data2),但static synchronized方法锁的是Data.class 类对象,所有实例共享同一把类锁,因此必须排队执行。这证明了synchronized修饰静态方法时,锁的是类,而不是方法调用者。
四、辅助案例3:同步代码块 —— 锁的是括号中传入的对象
4.1 基础代码:无synchronized的5个线程
class Data2 { public void fun() { System.out.println("start"); TimeUnit.SECONDS.sleep(3); System.out.println("end"); } } // main 中启动 5 个线程,共享同一个 Data2 对象 for (int i = 0; i < 5; i++) { new Thread(() -> data.fun()).start(); }输出结果:5次 "start" → 间隔约3秒 → 5次 "end"。
执行流程:
main线程启动,main方法的栈帧入栈。
程序首次使用Data类,进行类加载,在堆中创建Data对象,main栈帧中的局部变量data存储该对象的引用地址。
进入for循环,i=0时,通过匿名内部类的方式创建线程t1并加入到就绪队列中,等待CPU调度。
循环继续,i=1~4时,依次创建t2、t3、t4、t5并启动,它们都进入就绪状态,等待CPU调度。
CPU会快速依次调度5个线程执行run()方法。
每个线程执行data.fun()时。先输出 "start"(5个线程几乎同时输出),然后调用Thread.sleep(3000),5个线程几乎同时进入休眠状态。
3秒后,5个线程的sleep时间几乎同时到期,它们又转为就绪状态。
CPU依次调度这5个线程,每个线程继续执行后续代码,输出 "end"(5个"end"几乎同时输出)。
4.2 场景6:synchronized 代码块锁 Integer 缓存对象
Integer num = 1; // 指向 Integer 缓存池中的对象 class Data3 { public void fun(Integer num) { synchronized (num) { // 锁的是 num 对象 System.out.println("start"); TimeUnit.SECONDS.sleep(3); System.out.println("end"); } } } // main 中启动 5 个线程,传入同一个 num for (int i = 0; i < 5; i++) { new Thread(() -> data.fun(num)).start(); }输出结果:start → 3s → end → start → 3s → end ...(重复 5 轮,总耗时约 15 秒)
流程分析:
main线程启动,main方法的栈帧入栈。Integer num = 1指向的是Integer缓存池中的对象(-128~127范围内的值会被缓存),因此5个线程传入的是同一个Integer对象。
进入for循环,i=0时,创建 data3 对象,通过匿名内部类的方式创建线程t1并加入到就绪队列中,等待CPU调度。
循环继续,i=1~4时,依次创建data3 对象并创建启动线程t2、t3、t4、t5,它们都进入就绪状态,等待CPU调度。
t1获得CPU,执行data.fun(num),此时t1获取num锁,先输出 "start"(5个线程几乎同时输出),然后调用Thread.sleep(3000)进入休眠状态。但此时t1持有num锁且不释放。
与此同时,线程t2、t3、t4、t5依次获得CPU,执行data.fun(num),获取num锁时发现被t1持有,所以线程阻塞,等待锁释放。
3秒后,t1的sleep时间到期,转为就绪状态,获得CPU后输出 "end",释放锁。
接下来。t2线程被唤醒,成功获得锁,输出“start”,3秒sleep结束后,输出“end”,释放锁。
t3到t5线程以此类推。
关键注意:虽然每轮循环都new Data3(),但锁的对象是num,而不是Data3实例。由于num是同一个 Integer 缓存对象,所有线程竞争的是同一把锁,因此串行执行。
五、总结
synchronized 的锁对象可以归纳为以下三点:
修饰非静态方法→ 锁的是方法调用者(当前实例对象)
修饰静态方法→ 锁的是Class对象(类锁)
修饰代码块→ 锁的是括号中传入的对象
加锁原则:加锁会带来性能损耗,因此锁的范围应尽可能小、尽可能精准,只锁需要同步的代码块,避免锁住整个方法。