☰
synchronized详解:用法、底层原理与锁优化实战
2026/10/10 7:58:57 网站建设 项目流程

写并发编程绕不开synchronized。我做了六年Java开发,从刚开始觉得它简单到后来在项目里踩了锁的坑,再回头重新啃这块知识,才发现很多细节是面试时被追问、线上排查问题时才彻底想明白的。这篇文章是“Java进阶Thread学习”系列的第六篇,专门把这把内置锁掰开揉碎讲清楚,从最基础的用法、底层原理到JVM对它的各种优化,再到使用时的坑和排查思路,争取一篇让你对synchronized的认知上一个台阶。

1. 先从并发问题的根源说起

讲synchronized之前,必须先把并发编程的三个核心问题摆出来。很多时候你之所以代码写错,不是不知道咋加锁,而是没搞明白到底为什么要加锁、锁在防什么。

1.1 原子性、可见性、有序性到底是什么

这三个概念看着抽象,拿实际场景一举例就清楚了。原子性就好比银行转账,A账户扣钱和B账户加钱必须同时成功或者同时失败,不能出现A扣了钱但B没到账的中间状态。可见性就像两个线程同时读一个变量,线程1改了值,线程2如果读不到最新值,就会拿着旧数据做判断,结果自然不对。有序性则是编译器和CPU为了性能会调整指令执行顺序,单线程看结果没问题,但多线程环境下顺序一变就可能出幺蛾子。

// 一个很经典的可见性例子 public class VisibilityDemo { private static boolean flag = false; public static void main(String[] args) throws InterruptedException { Thread reader = new Thread(() -> { while (!flag) { // 空转 } System.out.println("读线程发现flag变了"); }); reader.start(); Thread.sleep(1000); flag = true; // 主线程修改flag,读线程可能一直看不到 } }

这段代码我在不少机器上跑过,有些环境里读线程确实会一直卡在循环里出不来。原因就是读线程在自己的工作内存里缓存了flag=false,主线程虽然改了主内存的值,但读线程压根没同步过去。这种问题在真实的业务系统里更隐蔽,因为你不会写这么直白的代码,往往是某个配置开关、某个共享状态在多个线程间传递时出问题。

synchronized之所以能一把锁同时解决这三个问题,关键在于Java内存模型(JMM)给它设定的规则:线程加锁时会清空自己的工作内存,重新从主内存加载最新值;线程释放锁时会把所有修改过的变量强制刷回主内存。这样既保证了可见性,配合锁互斥又保证了原子性,同时锁内的代码不会乱序执行,有序性也有了着落。

1.2 锁的本质是控制共享资源的访问权

打个比方,一间公共厕所只有一个小隔间,进去的人必须把门锁上,出来再打开,后面排队的人才能进。synchronized就是那把门锁,锁的对象就是那扇门(monitor),而被保护的代码块就是隔间里的空间。一次只允许一个线程进去执行,其他线程只能在门外等着。

但需要注意,锁不是锁代码,而是锁对象。这个认知特别关键。你写synchronized(this)锁的是this这个对象,如果A线程和B线程操作的是两个不同对象,那它们用的就是两把不同的锁,代码虽然一样但对并发完全没约束力。这也是很多人在写工具类、静态方法时容易搞混的地方。

2. 四种典型用法背后锁的对象是谁

synchronized的语法形式看似简单,但每种写法锁定的目标完全不同。我见过不少开发者在代码审查时被问到“你这个锁的是什么对象”就答不上来,所以这块必须掰清楚。

2.1 修饰普通方法:锁的是当前实例对象

public class TicketPool { private int tickets = 100; public synchronized void sellOne() { if (tickets > 0) { tickets--; System.out.println(Thread.currentThread().getName() + " 卖出一张,剩余: " + tickets); } } }

这里synchronized直接放在方法上,等价于在方法内部写synchronized(this)。它锁的是TicketPool这个类的实例对象。也就是说,如果两个线程用的是同一个TicketPool实例,那么sellOne方法同一时刻只能有一个线程在执行;但如果你new了两个TicketPool实例,每个实例各跑各的,锁也就互不相干了。

2.2 修饰静态方法:锁的是Class对象

public class Counter { private static int count = 0; public static synchronized void increment() { count++; } }

这个锁的就不是实例了,而是Counter.class这个Class对象。Class对象在整个JVM里对一个类只有一个,所以即使是不同的实例调用这个静态方法,它们争抢的也是同一把锁。我在项目里做过一个全局发号器,就是这个套路——多个服务实例(同进程内的线程池)调用静态同步方法生成唯一序号,靠的就是Class对象级别的锁。

2.3 同步代码块配合自定义锁对象

public class Counter { private int count = 0; private final Object lock = new Object(); public void increment() { synchronized (lock) { count++; } } }

这种方式最大的好处是粒度更细,你可以只锁需要保护的代码片段,而不是锁整方法。比如一个方法里前半部分是纯计算不涉及共享数据,后半部分才操作共享变量,那只需要给后半段加锁。用private final Object lock单独声明的锁对象还避免了this被外部直接当作锁调用造成的不确定性——因为外部代码很难拿到你内部那个private对象,就没办法在你不知情的情况下占用你的锁。

提醒:锁对象一定要声明为final。如果锁对象的引用在运行期间被重新赋值,相当于中途换了把锁,并发保护就失效了。这个坑我在代码评审里抓到过两次。

2.4 修饰类代码块:和静态方法锁同一个对象

public class AuditService { public static void writeLog(String msg) { synchronized (AuditService.class) { // 写入日志文件 } } }

其实public static synchronized方法本质上就是synchronized (Xxx.class)的语法糖。两者锁的都是Class对象。在真实项目中,这种写法通常用在需要跨实例同步的静态工具方法上,比如一个静态的内存缓存清理方法、一个静态的配置加载方法。

为了让你一眼看清区别,我整理了一个锁目标对照表:

写法锁的目标影响范围
synchronized修饰实例方法this(当前实例)同一实例上的调用才互斥
synchronized修饰静态方法Xxx.class(Class对象)所有实例调用都互斥
synchronized(lockObj)代码块lockObj对象持有同一把锁对象的代码段互斥
synchronized(Xxx.class)代码块Xxx.class类似静态方法,全局互斥

3. 深入底层看synchronized在JVM里做了什么

如果你只是停留在知道怎么加锁的层面,遇到复杂的并发问题还是会懵。因为锁的升级、锁的释放、wait/notify的协作机制,全都跟底层实现强相关。这一章带你啃下底层逻辑。

3.1 Monitor机制与字节码层面的秘密

在JVM底层,每个对象都有一个内部结构可以理解为“监视器”(Monitor)。synchronized的本质就是获取这个对象上的Monitor所有权。从字节码角度去看,加锁代码块的字节码里会出现monitorenter和monitorexit两条指令。

public void doSomething() { synchronized (lock) { System.out.println("hello"); } }

对应的核心字节码大概是这样的逻辑:进入同步代码块时执行monitorenter,成功获取Monitor计数器加1;退出时执行monitorexit,计数器减1。当计数器为0时,锁就释放了。同一个线程可以多次执行monitorenter,每进入一次计数器加1,这就是可重入锁的含义。

方法级别的同步不显式出现这两条指令,而是在方法表结构上的ACC_SYNCHRONIZED标志位声明。JVM判断到方法带有这个标志,执行时会先尝试获取Monitor,方法执行完再释放。

3.2 wait和notify为什么必须在锁内调用

这是synchronized体系里特别容易被人忽略的一个细节。Object.wait()和Object.notify()必须和synchronized配合使用,否则会抛IllegalMonitorStateException。

原因很简单,wait()字面意思是让当前线程等待,但前提是你得先持有这个对象的Monitor,才能对它的“等待队列”做操作。调用wait()时,线程会释放当前持有的Monitor,然后进入WAITING状态;调用notify()时,会唤醒一个在该Monitor上等待的线程,但它需要重新抢到锁才能真正执行下去。

public class TaskQueue { private final LinkedList<String> tasks = new LinkedList<>(); public synchronized void put(String task) { tasks.addLast(task); notifyAll(); // 唤醒所有等待的消费者线程 } public synchronized String take() throws InterruptedException { while (tasks.isEmpty()) { wait(); // 队列空了,释放锁,进入等待 } return tasks.removeFirst(); } }

注意这个循环判断while (tasks.isEmpty())而不是if。原因是线程被唤醒后可能又被其他线程抢走任务,所以必须重新检查条件,这是等待通知机制的经典范式。之前在一个消息分发组件里,因为用了if判断导致线程被唤醒后拿到null,排查了半天才发现是这里写错了。

3.3 异常时锁会自动释放

synchronized和Lock接口一个很大的差异,在于synchronized不需要你在代码里手动解锁。不管同步代码块是正常执行完,还是抛了异常中途退出,JVM都会保证锁正常释放。这是由字节码层面的monitorenter/monitorexit成对结构保证的,异常路径上也有对应的monitorexit指令。

我在项目里见过因为用Lock没在finally里unlock()导致线程池里的线程全部锁死的线上事故,非常惨烈。所以如果你的锁逻辑不复杂,优先用synchronized是最稳妥的,它帮你规避了一类最容易犯的低级错误。

4. JVM对synchronized的优化:锁升级机制

很多人对synchronized的印象还停留在“重量级锁、性能差”上,这其实是十年前的老黄历了。从JDK 1.6开始,JVM对内置锁做了大幅优化,引入了锁升级机制,今天它的性能已经和显式锁差距非常小了。

4.1 从偏向锁到重量级锁的升级路径

锁的升级方向是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。它不只是性能调优,更是一套根据竞争激烈程度动态调节的机制。

偏向锁的核心思路是:如果一个线程反复获取同一把锁,那么每次加锁都走CAS操作其实很浪费,干脆在对象头里记录这个线程的ID,下次它再来的时候直接放行。这就像健身房办了年卡的老会员,门禁系统认出他了就直接让他进,不用每次刷卡核对。偏向锁在只有一个线程访问的同步代码块里几乎没有开销。

一旦有第二个线程来竞争这把锁,偏向锁就会撤销并升级为轻量级锁。轻量级锁的原理是线程在栈帧中创建锁记录,通过CAS操作将对象头替换为指向锁记录的指针。如果CAS成功就拿到锁;如果多个线程同时竞争,轻量级锁就膨胀为重量级锁。重量级锁会让没抢到锁的线程进入阻塞状态,涉及操作系统层面的线程挂起和唤醒,这才有了“重”的说法。

4.2 自旋锁、锁消除、锁粗化

自旋锁是为了避免线程挂起而做的妥协。因为线程阻塞唤醒的成本很高,如果锁持有时间很短,让线程在原地循环尝试获取锁反而更快。JDK的默认自旋次数在JDK 1.6之后改成了自适应,JVM会根据历史竞争情况自动调整自旋次数。这也是为什么很多性能调优文章里建议锁内的代码尽量短、尽量快,就是为了让自旋锁发挥最大价值。

锁消除是JIT编译优化的一项能力,JVM如果能通过逃逸分析确认一个锁对象不可能被其他线程访问到,就会直接把锁消除掉。所以有时候你在代码里写了个synchronized块,实际运行中可能根本没有加锁的开销。锁粗化则相反,如果JVM检测到同一个线程反复对同一把锁加锁解锁,比如循环体里有加锁,它会自动把锁的范围扩展到整个循环外面,减少重复加锁解锁的消耗。

注意:锁升级机制有偏向锁延迟和非延迟两种配置,JVM启动后前几秒偏向锁是关闭状态,如果要性能压测,需要加上-XX:BiasedLockingStartupDelay=0参数。这个参数在JDK 15以后的版本废弃了偏向锁默认启用,但理解升级路径对读懂锁状态依然重要。

4.3 锁状态查看工具

要观察锁当前处于什么状态,可以用JOL(Java Object Layout)工具。下面是一个简单的演示思路:

public class LockStateDemo { public static void main(String[] args) throws InterruptedException { Object obj = new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } } }

通过输出对象头中mark word的内容,可以直观看到锁标志位的变化。我建议对这块感兴趣的人都在本机跑一下这个实验,比看十篇文章管用。

5. synchronized与volatile:线程安全的两条路线

在项目里经常被问到“synchronized和volatile选哪个”。我认为这不是二选一的问题,关键是看你的业务场景需要保护什么。

5.1 两者定位完全不同

volatile主要有两个作用:一个是保证变量的可见性,即一个线程修改变量后,其他线程能第一时间看到最新值;另一个是禁止指令重排序。它并不保证复合操作的原子性。经典例子就是count++这种操作,读、加、写三步下来,中间可能被其他线程插一脚,volatile完全挡不住。

count++这样的复合操作必须用synchronized或者原子类(AtomicInteger)来保证原子性。所以如果只是状态标记位的开关、双检锁的单例实现里对实例引用加volatile,那就用volatile;凡是要执行“读出来-改掉-写回去”这种复合操作,必须上锁。

5.2 实战选择一个配置开关的例子

public class FeatureSwitch { // 简单状态开关,并发下只要求可见性,用volatile足够 private static volatile boolean featureEnabled = true; public static boolean isEnabled() { return featureEnabled; } public static void setEnabled(boolean enabled) { featureEnabled = enabled; } }

这种写法在真实业务里很常见,比如灰度开关、限流开关、远程配置推送更新。因为它不涉及复合操作,用volatile就足够了,完全不需要synchronized。而如果哪天有人在isEnabled里加了个判断后还要做其他共享状态的修改,那就不行了,得考虑换锁实现。

6. ReentrantLock和synchronized怎么选

这也是并发编程里永恒的争议话题。ReentrantLock作为API层面的显式锁,提供了更多高级能力,但两者在基础互斥功能上是等价的。选谁的关键还得看你需要哪些额外能力。

6.1 高级功能对比

ReentrantLock支持超时获取锁(tryLock(long timeout, TimeUnit unit)),支持可中断的锁获取,还支持公平锁和多个条件变量(Condition)。这些synchronized都不直接支持。具体差异整理一下:

对比项synchronizedReentrantLock
语法关键字,简单便捷API调用,需手动lock/unlock
异常释放自动释放必须在finally里手动释放
超时获取不支持支持tryLock带超时
中断响应不支持支持lockInterruptibly
公平性默认非公平可构造公平锁
条件变量只有wait/notify多个Condition更灵活

6.2 我的选择建议

我的原则很简单:能用synchronized就用它,只有碰到场景确实需要tryLock超时控制或者可中断获取锁时,才换ReentrantLock。一个典型的场景是并发任务合并写库,我需要在两秒内拿到锁就执行,拿不到就降级走异步队列,这种情况下synchronized就做不到了,必须用tryLock。

还有一个细节值得提:synchronized新版本加锁过程中即使触发GC的锁相关优化,语义也不会变;但ReentrantLock的公平锁在性能上一般比非公平锁差,因为公平锁要先检查队列里有没有等待线程。除非业务必须严格按先来后到执行任务,否则尽量不要使用公平锁。

7. 锁粒度之争:加锁不是越粗越好

很多并发问题的根源不是没用锁,而是锁的粒度没控制好。加锁太大,并发度低甚至变成串行;加锁太小,逻辑漏洞百出,该保护的数据没保护住。

7.1 粗锁的代价

假设你的方法里有三段逻辑,第一段是网络请求拿数据(耗时300ms),第二段改共享变量(耗时1ms),第三段写日志(耗时50ms)。如果你把整个方法都加上锁,那么所有线程在这个方法上都串行执行了,有效率极大浪费。网络请求那段根本不需要锁,它不属于共享资源操作。

我之前在某后台系统见过一个性能瓶颈,一个查询方法全方法加了synchronized,所有请求串行跑,高峰期排队严重。排查后发现其实只有更新本地缓存的那几行代码需要锁保护。把锁从方法级别缩小到代码块级别,性能立竿见影地提升了几十倍。

7.2 细锁和锁分段设计

细粒度的典型例子是锁分段。ConcurrentHashMap在JDK 7里的设计就是分为多个Segment,每个Segment一把锁,不同Segment的写操作互不干扰,从而提升并发吞吐。

日常业务里,如果你有一个大数据量的Map需要频繁读写,也可以用类似思路:按key的哈希值把数据分成若干桶,每桶一个锁,操作不同桶的数据不会互相阻塞。但如果你的数据对象之间本身有强一致性的约束,比如账户转账场景,拆太细反而会导致需要同时持有多把锁,增加死锁风险。

锁粒度给的实操建议是三点:只保护真正会被多个线程同时访问的共享数据;锁内的代码要快,避免网络、磁盘IO等耗时操作;如果确实需要做耗时操作,考虑用读写锁或者原子类替代。

8. 使用synchronized最常见的坑和排查思路

看了这么多理论,总要落到实际开发上。以下这些问题是这些年我在项目里真实遇到过的,也给每个都配上排查思路。

8.1 锁的不是同一个对象

最常见的坑,就是没搞清锁对象归属。多个线程分别创建了实例对象,然后调用同一个public synchronized void method()。这里锁的是各自实例,根本起不到互斥效果。还有一种常见误用:字符串常量作为锁对象。两个不同的字符串变量如果内容相同,实际指向常量池里的同一个对象,当你不小心用了字符串作为锁,可能在毫无察觉的情况下被其他代码用了同一把锁,导致奇怪的阻塞。

排查建议:在同步方法入口打印System.identityHashCode(this)或锁对象的hashCode,看不同线程拿到的锁对象地址是不是同一个。这个办法在排查“并发但不互斥”问题时很有效。

8.2 锁内部抛出异常导致不完整操作

虽然synchronized会释放锁,但如果同步块内有复合操作,中途抛异常会导致部分状态已经改变,比如先扣了库存又没写订单。你需要用try/catch包裹并做回滚处理,或者把共享状态的修改尽量改成纯内存操作且具备可控性。

这类问题排查主要靠日志。我做过一个库存系统,给同步块里每一步操作都加了序号日志,异常发生范围一眼就能定位到哪步出错了,再针对性地做补偿处理。

8.3 死锁和活锁

锁嵌套最容易产生死锁。线程1持有了锁A,想拿锁B;线程2持有了锁B,想拿锁A。互相等待,谁也走不下去。避免死锁最常见的手段是保证所有线程都以相同的顺序获取锁。

比如下面这个转账场景就很容易死锁:

public void transfer(Account from, Account to, int amount) { synchronized (from) { synchronized (to) { from.balance -= amount; to.balance += amount; } } }

如果线程1执行transfer(a, b),线程2执行transfer(b, a),就可能出现死锁。解决思路是给每个账户分配一个全局唯一的ID,获取锁时统一按ID升序来拿,保证锁获取顺序一致,破坏循环等待条件。同时建议在代码里启用jstack抓线程栈,看到“Found one Java-level deadlock”字样的报告就能确认死锁现场。

8.4 使用synchronized和Spring事务让锁失效的迷惑

这个坑比较隐蔽。你可能会写出这样的代码:

@Transactional public synchronized void updateData(String id) { // 查库、改库 }

你以为加了synchronized方法就互斥了,实际上Spring事务代理是在进入方法前开启事务、方法返回后提交事务。如果线程1提交事务的动作发生在释放锁之后,那么线程2可能在线程1事务提交前就拿到了锁进入方法,读到旧数据,逻辑照样出错。

解决思路:不要在@Transactional方法上直接加synchronized,而是把锁的边界和事务的边界对齐,比如在调用方先获取锁,在锁内调用事务方法,确保锁覆盖到事务提交完成。

9. 与wait/notify配合实现线程间协作

synchronized不只是做互斥,它配合wait和notify还能实现线程间的协作。前面提过它们必须在锁内使用,这里多讲几个组合细节。

9.1 一个完整的生产者消费者骨架

public class BlockingQueueDemo { private final Queue<String> queue = new LinkedList<>(); private final int capacity = 10; public synchronized void produ(String item) throws InterruptedException { while (queue.size() == capacity) { wait(); // 队列满了,等待消费者消费后唤醒 } queue.offer(item); notifyAll(); // 唤醒消费者 } public synchronized String consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 没任务,等待生产者生产 } String item = queue.poll(); notifyAll(); // 唤醒生产者 return item; } }

这个骨架在多个场景里都用得上,比如线程池里的任务队列、消息推送的缓冲队列。notifyAll比notify稳妥得多,因为notify如果唤醒的是同类线程可能导致信号丢失。比如三个生产者两个消费者,队列满时等的是生产者,你用notify唤醒的可能唤醒的是另一个消费者,而消费者没有消费任务也会继续等待,生产者永远没人通知,最终所有线程都卡死。

9.2 等待超时的处理

有时你不希望线程无限期等下去,可以借助wait(long timeout)。比如设置5秒超时,5秒没被唤醒就返回执行其他降级逻辑。但要注意一个问题,wait(5000)从进入等待到超时返回的时间并不是精确的5秒,不建议用它做高精度定时。需要高精度定时还是用线程池或专门的调度工具。

10. 多线程调试和性能分析的实用经验

学习synchronized不只是写代码,调试和排查也要跟上。下面是我这几年积攒下来的一套实用方法论。

10.1 使用jstack排查锁状态

当线上服务出现请求卡死、线程假死时,我第一反应就是执行jstack <pid>抓线程快照。如果看到大量线程处于BLOCKED状态,并且monitor信息指向同一个地址,那说明它们都在等待同一把锁。再结合业务日志看是哪个方法持锁时间太长,问题基本就能锁定。

jstack 12345 | grep -A 20 "BLOCKED"

输出里会显示类似这样的片段:

"http-nio-8080-exec-3" #32 prio=5 os_prio=0 tid=0x... java.lang.Thread.State: BLOCKED (on object monitor) at com.example.HotMethod.doSomething(HotMethod.java:120) - waiting to lock <0x00000000abcdef01> (a java.lang.Object)

这个锁地址就是关键字,再搜一通整个线程dump里谁持有了0x00000000abcdef01,就能找到持锁线程在执行什么。

10.2 压测中观察吞吐量变化

锁的优化效果我用JMH压测过,结论是:在低竞争场景下synchronized的开销很小,偏向锁和轻量级锁帮了大忙;在高竞争场景下,synchronized和ReentrantLock的差距也不大。真正影响吞吐的往往不是锁本身,而是锁内代码的耗时。如果你发现压测结果不好,优先优化持锁时间,而不是纠结换成哪个锁。

10.3 性能优化的几个实用技巧

第一,锁内的日志和远程调用必须挪出去,这类非核心操作既耗时又不可控,夹杂在锁内会成倍放大阻塞。第二,读多写少的场景先把锁换成读写锁或者CopyOnWrite容器试试,收益通常很可观。第三,如果多个锁之间有强顺序,把锁顺序抽成一个常量枚举,从代码规范上杜绝死锁。第四,如果一段逻辑不需要强一致,只是要求最终一致,可以彻底不用锁,用队列异步消费,这对系统整体吞吐的提升是数量级的。

11. 学习synchronized的路线图总结

学了再多的理论,最终要落地到掌握程度的自测。这里以自己的经验总结几个阶段。

第一阶段,语法理解,能正确写出四种加锁方式,知道锁对象是谁。第二阶段,原理理解,懂Monitor机制、可见性保证、可重入、锁释放时机。第三阶段,优化理解,能说出偏向锁到重量级锁的升级条件,知道锁消除和锁粗化是什么。第四阶段,工程能力,能根据业务场景设计锁粒度,能通过jstack排查线上锁冲突。

我见过很多新人卡在第二到第三阶段的断层,会用锁但不知道锁为什么快、什么情况下慢。我个人的体会是,如果你能用JOL观察过对象头里mark word的变化,再用JMH测过不同锁方案的吞吐差距,最后遇到线上死锁能靠jstack独立定位出来,那这章内容就算真正学到手了。并发编程这东西,理论是骨架,实操才是血肉,多写多踩坑,理解就会越来越深。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询