线程同步是Java并发编程里绕不开的话题。当多个线程同时修改同一个共享变量时,如果没有同步措施,结果往往不可预期。synchronized是Java语言层面提供的同步机制,使用简单、不易出错,但它背后的锁原理、锁升级过程以及使用上的细节,很多开发者只停留在表面。本文从用法、原理、典型场景和常见陷阱几个角度,把synchronized的关键点讲透。

一、synchronized的两种基本用法
synchronized可以修饰方法,也可以修饰代码块。修饰实例方法时,锁的是当前对象实例this;修饰静态方法时,锁的是类的Class对象;修饰代码块时,锁的是括号里指定的对象。这三种方式的锁对象各不相同,理解这一点是正确使用synchronized的第一步。
先看一个典型的线程不安全例子:
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
count++看起来是一行代码,实际上包含读取、加一、写回三个步骤。多个线程交错执行时,部分更新就会丢失。加锁后的写法如下:
public class Counter {
private int count = 0;
// 方式一:同步方法,锁是当前实例
public synchronized void increment() {
count++;
}
// 方式二:同步代码块,锁是显式指定的对象
public void increment2() {
synchronized (this) {
count++;
}
}
public int getCount() {
return count;
}
}
两种方式效果相同,但同步代码块更细粒度,只锁必要的部分。如果方法里只有几行涉及共享变量,其余都是耗时操作(比如IO),把整个方法标记为synchronized会白白降低并发度。此外要注意,静态同步方法锁的是Class对象,一个线程进入静态同步方法,另一个线程仍然可以进入同一个类的实例同步方法,因为它们锁的不是同一个对象。
二、锁的底层原理:monitor与Mark Word
synchronized的实现依赖对象头中的Mark Word和monitor监视器。每个Java对象都关联一个monitor,在HotSpot虚拟机中由ObjectMonitor实现。线程进入同步块时,执行monitorenter指令尝试获取monitor所有权;退出时执行monitorexit释放。同步方法则不同,它没有显式的字节码指令,而是通过方法的ACC_SYNCHRONIZED访问标志,由调用方法的过程隐式处理加锁解锁。
可以通过javap -v命令查看字节码验证这一点。同步代码块编译后能看到monitorenter和monitorexit成对出现,同步方法则只是在方法flags里多了一个synchronized标志。monitor内部维护着几个关键字段:_owner指向持有线程,_EntryList存放阻塞等待的线程,_WaitSet存放调用wait()后进入等待状态的线程。这也是wait和notify必须放在synchronized块内调用的原因,它们本质上是操作monitor的等待队列。
对象头中的Mark Word存储了锁状态信息。由于对象头空间有限,Mark Word采用复用设计,不同锁状态下存储的内容不同:无锁态存对象哈希码和分代年龄,偏向锁态存偏向线程ID,轻量级锁态存指向栈中锁记录的指针,重量级锁态存指向monitor的指针。锁的状态不是一成不变的,而是随着竞争情况逐步升级。
三、偏向锁、轻量级锁与重量级锁的升级过程
JDK 1.6之后,HotSpot对synchronized做了大量优化,引入了锁升级机制。偏向锁是最乐观的假设:如果一把锁始终由同一个线程获取,就没有必要反复CAS。线程第一次获取锁时,通过CAS把自己的线程ID写入Mark Word,之后该线程再进入同步块只需比对线程ID即可,几乎零成本。
一旦有第二个线程来竞争,偏向锁升级为轻量级锁。竞争线程会在自己的栈帧里创建锁记录,然后CAS尝试把Mark Word指向锁记录。获取成功的线程持有锁,失败的线程自旋等待。轻量级锁适合线程交替执行、锁持有时间很短的场景,自旋几次就能拿到锁,避免了线程挂起的开销。
如果自旋超过阈值或者有第三个线程同时竞争,锁膨胀为重量级锁。重量级锁依赖操作系统的互斥量,竞争失败的线程会被挂起并放入monitor的_EntryList中,涉及用户态到内核态的切换,开销较大。除了锁升级,JVM还有锁消除和锁粗化两项优化:锁消除指JIT通过逃逸分析发现锁对象不可能被共享时直接去掉锁;锁粗化指连续对同一对象加锁解锁时,把多个同步块合并成一个。理解这些机制后就知道,不必因为早期synchronized性能差的说法而回避它,现代JVM中它的性能已经很好,绝大多数场景下与显式锁差距很小。
四、典型应用场景与常见陷阱
最经典的应用是生产者消费者模型,利用wait和notify实现线程间协作:
public class TaskQueue {
private final Queue<Integer> queue = new LinkedList<>();
private final int MAX_SIZE = 10;
public synchronized void put(int value) throws InterruptedException {
while (queue.size() == MAX_SIZE) {
wait(); // 队列满,等待消费者取走
}
queue.offer(value);
notifyAll(); // 唤醒消费者
}
public synchronized int take() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 队列空,等待生产者放入
}
int value = queue.poll();
notifyAll(); // 唤醒生产者
return value;
}
}
注意这里的等待条件判断用的是while而不是if。被唤醒的线程重新获得锁后,条件可能又被其他线程改变了,必须再次检查,否则会出现虚假唤醒导致的逻辑错误。notifyAll虽然比notify效率低一点,但更安全,notify只随机唤醒一个线程,可能唤醒了错误角色的线程导致所有线程都在等待。
另一个常见场景是双重检查锁定的单例模式:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这里volatile不是多余的。new Singleton()分为分配内存、初始化、赋值引用三步,指令重排序可能让instance先指向未初始化的内存,另一个线程在第一次检查时拿到非null但未初始化完成的对象。volatile禁止了这种重排序。此外还有几个常见陷阱需要提醒:一是不要用String或Integer等不可变类型实例作为锁对象,字符串常量和缓存的小整数可能被其他代码引用,造成锁被意外共享;二是不要在持有锁时调用外部回调或耗时方法,容易造成长时间阻塞甚至死锁;三是加锁顺序要保持一致,多个线程以不同顺序获取多把锁是死锁的典型成因,排查时可以用jstack命令打印线程dump查看死锁信息。
五、synchronized与ReentrantLock如何选择
有了synchronized为什么还需要ReentrantLock?两者都是可重入锁,但ReentrantLock提供了更灵活的能力:支持公平锁、可中断获取锁、超时获取锁,以及一个锁上绑定多个等待条件的Condition。当需要这些高级特性时,比如要实现一个带有等待时间的资源获取,ReentrantLock更合适。
synchronized的优势在于简洁和自动释放。线程执行完同步块或抛出异常时,锁自动释放,不存在忘记unlock的问题;而ReentrantLock必须在finally块中手动调用unlock,一旦遗漏就是事故。此外经过锁升级优化后,synchronized在低竞争场景下性能出色。经验法则是:默认用synchronized,只有当明确需要公平性、可中断、超时或多个等待条件时,再换用ReentrantLock。选型不必过度纠结,先把同步逻辑写对,再谈优化。
Java线程同步synchronized锁机制修改时间:2026-09-05 03:30:32