synchronized锁对象终极解析:静态同步方法与普通方法有何不同?
发布时间:2026/9/24 22:36:39 作者:尧图编辑部 阅读量:1,286

先讲一件我在代码评审里遇到的真实事故。一个跑在核心链路里的接口调用量统计工具逻辑不复杂并发请求进来往一个全局 Map 里做累加计数。工具类里的核心方法加了 synchronized 做保护开发同学本地自测也一切正常。结果上线之后高峰期的统计数据总是比实际流量少一大截运维跟开发查了两个晚上最后定位到的根因竟然是 synchronized 加错了位置——普通方法锁的是当前实例可调用方每次都 new 一个新对象这些 synchronized 锁散落在各个实例上彼此根本不相干。这个问题的本质就是标题里的经典区别synchronized 修饰静态方法和修饰普通方法锁的到底是不是同一个东西。这篇文章不打算停留在静态方法锁 Class、普通方法锁 this这种八股层面而是把底层锁对象、字节码标志位、可重入机制、并发场景实测、以及项目里的选型坑全部串起来讲。无论你是刚接触并发不久的新人还是已经写过几个 synchronized 但没深究过的老手这篇都值得花十分钟读完。1. 先复盘那次数据丢失静态资源被实例锁保护等于没保护1.1 事故代码长什么样那段出问题的代码核心结构大概是这样的public class MetricsRecorder { private static final MapString, Long COUNTERS new HashMap(); public synchronized void record(String key) { COUNTERS.merge(key, 1L, Long::sum); } }方法本身是 synchronized 的这是事实。问题在于调用方并不是在一个全局单例上调用这个方法而是类似下面这种写法// 每次请求都新建一个对象 MetricsRecorder recorder new MetricsRecorder(); recorder.record(order_created);在 Spring 这类容器里如果 MetricsRecorder 被注册成了单例 Bean那所有线程共享同一个实例synchronized 修饰的普通方法锁的就是这个唯一的 this并发累加还能撑住。但一旦换到非容器环境或者每次手动 new情况就完全不一样了。每个线程持有的 this 都是各自独立的实例对象两把实例锁之间没有任何交集那 synchronized 就形同虚设。修复方案也很直白把方法改成静态同步方法锁对象从当前实例改成Class 对象public class MetricsRecorder { private static final MapString, Long COUNTERS new HashMap(); public static synchronized void record(String key) { COUNTERS.merge(key, 1L, Long::sum); } }这样无论调用方拿了多少个实例最终竞争的都是同一把类锁。1.2 问题出在哪一行现在回头看问题不是 synchronized 失效了而是大家默认加了 synchronized 就能保证线程安全这个前提过于粗糙。synchronized 能否生效取决于所有并发线程能否落在同一个锁对象上。普通同步方法相当于synchronized (this)锁绑定在调用方法的那个实例上。如果业务里不能保证全局只有一个实例那每一批线程之间都会各自为战。静态同步方法相当于synchronized (ClassName.class)锁绑定在类的 Class 对象上JVM 里同一个类加载器下这个对象只有一个所以天然全局唯一。那次事故之后我在团队里定了一条规矩凡是涉及静态变量、全局缓存、统计计数器的并发保护一律用静态同步方法或者synchronized (Xxx.class)不允许用实例同步方法兜底。这个规则简单粗暴但确实避免了很多后续的隐性事故。2. 本质区别普通方法锁 this静态方法锁 Class 对象2.1 synchronized 锁的永远是对象而不是方法先把最核心的概念说清楚。synchronized 在 Java 里从来都不是锁方法它锁的是一个对象监视器也就是 JVM 里的 monitor。方法只是充当了持锁的代码区域的边界真正决定互斥关系的是方法背后关联的那个对象。普通同步方法锁对象是当前实例 this。多个线程去调用同一个实例的同步方法时它们竞争的是同一个 monitor所以互斥如果两个线程分别调用两个不同实例的同步方法它们拿到的 monitor 是两把不同的锁自然可以并行。静态同步方法锁对象是当前类的 Class 实例。这里有个容易误解的点很多初学者以为每个对象都会有一个对应的 Class 对象其实不对。Class 对象是类加载阶段由 JVM 创建的同一个类加载器下一个类在 JVM 里全局只有一个 java.lang.Class 实例。所有通过这个类创建的实例共享同一个 Class 对象。所以静态同步方法的互斥范围是全局唯一的想通过多搞几个实例来绕开锁根本不可能。2.2 静态同步方法与静态代码块的等价关系理解静态同步方法最有效的方式是把它翻译成 synchronized 代码块。下面这两个写法是等价的public static synchronized void doSomething() { // 业务逻辑 }等价于public static void doSomething() { synchronized (DemoService.class) { // 业务逻辑 } }普通同步方法同理下面两个写法也是等价的public synchronized void doSomething() { // 业务逻辑 }等价于public void doSomething() { synchronized (this) { // 业务逻辑 } }把方法级同步翻译成代码块级同步之后很多问题一下子就清楚了。比如静态同步方法和实例同步方法能不能并发这个问题翻译过来就是线程 A 持有 DemoService.class 的锁线程 B 持有某个 DemoService 实例的锁这两把锁是不是同一把。答案显而易见不是所以它们可以并发执行。这个方法非常实用。我遇到任何说不清锁关系的场景都会先把 synchronized 改写成代码块形式再把锁对象写在纸上互斥关系一目了然。2.3 通过实例调用静态同步方法锁的不是这个实例还有一个常见的隐藏点。语法上 Java 允许你用实例去调用静态方法比如DemoService service new DemoService(); service.doSomething();doSomething 如果是静态同步方法编译器虽然允许这种写法但本质上它等价于DemoService.doSomething();javac 会把这种调用编译成 invokestatic 指令和实例没有任何关系。所以锁对象依然是 DemoService.class不是 service 这个实例。如果你脑子里还残留着我通过某个实例调用的锁应该在该实例上吧这种直觉务必把它纠正过来。3. 四组对照实验把锁的边界彻底试出来理论讲了半天不如直接跑一段代码观察现象。下面这几个实验是我在一台多核机器上反复跑过的结果非常稳定。每个实验的方法体里都放一个 2 秒的 sleep用来把锁竞争的窗口拉大不然线程调度太快很难观察到交错进入和排队进入的区别。3.1 同一实例的同步方法线程严格排队public class InstanceLockDemo { public synchronized void run() { System.out.println(Thread.currentThread().getName() enter, time System.currentTimeMillis()); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit, time System.currentTimeMillis()); } public static void main(String[] args) { InstanceLockDemo demo new InstanceLockDemo(); new Thread(demo::run, t1).start(); new Thread(demo::run, t2).start(); } }输出大致长这样t1 enter, time 1710000001000 t1 exit, time 1710000003000 t2 enter, time 1710000003000 t2 exit, time 1710000005000两个线程在同一个实例上执行同一个同步方法第二个线程必须等第一个线程完全释放锁之后才能进入。这是 synchronized 最基础的互斥语义。3.2 不同实例的同步方法互不相干把 main 方法稍微改一下public static void main(String[] args) { new Thread(new InstanceLockDemo()::run, t1).start(); new Thread(new InstanceLockDemo()::run, t2).start(); }同一个类同一个同步方法只是换成了两个不同实例结果立刻反转t1 enter, time 1710000001000 t2 enter, time 1710000001000 t1 exit, time 1710000003000 t2 exit, time 1710000003000两个线程几乎同时进入、同时退出互不影响。这组对比再直白不过了普通同步方法的锁是跟着实例走的没有共享实例就没有互斥。3.3 静态同步方法之间入口再多也受同一把锁约束public class StaticLockDemo { public static synchronized void run() { System.out.println(Thread.currentThread().getName() enter, time System.currentTimeMillis()); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit, time System.currentTimeMillis()); } public static void main(String[] args) { new Thread(StaticLockDemo::run, t1).start(); new Thread(StaticLockDemo::run, t2).start(); } }输出永远是这样的排队结构t1 enter, time 1710000001000 t1 exit, time 1710000003000 t2 enter, time 1710000003000 t2 exit, time 1710000005000即使这两个线程来自完全不同的业务模块只要调用的是同一个类的静态同步方法就必须排队。原因还是那句话静态同步方法锁的是 Class 对象而这个对象在 JVM 里全局唯一。3.4 类锁与实例锁并行同一个类的热闹场面最后一个实验最反直觉也最容易踩坑。让一个线程调用静态同步方法另一个线程调用同一个类的实例同步方法public class MixedLockDemo { public synchronized void instanceRun() { System.out.println(Thread.currentThread().getName() enter instance, time System.currentTimeMillis()); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit instance, time System.currentTimeMillis()); } public static synchronized void staticRun() { System.out.println(Thread.currentThread().getName() enter static, time System.currentTimeMillis()); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() exit static, time System.currentTimeMillis()); } public static void main(String[] args) { MixedLockDemo demo new MixedLockDemo(); new Thread(demo::instanceRun, t1).start(); new Thread(MixedLockDemo::staticRun, t2).start(); } }输出会看到两个线程的 enter 几乎同时打印2 秒后两个 exit 也几乎同时打印。一个类里同时有静态同步方法和实例同步方法时这两类方法之间没有任何竞争关系因为锁对象一个是 Class、一个是 this。我用一个表格把这四个实验的结论汇总平时评审代码时直接对照并发场景锁对象是否互斥同一实例的不同线程调用同步方法同一个 this互斥不同实例分别调用同步方法不同的 this不互斥同一类的静态同步方法之间同一个 Class 对象互斥实例同步方法 vs 静态同步方法this vs Class 对象不互斥4. 字节码视角为什么静态同步方法里没有 this4.1 ACC_SYNCHRONIZED 标志位才是指挥官很多资料会把 synchronized 方法和方法体里的 monitorenter/monitorexit 指令画上等号这个理解并不准确。方法级同步和代码块级同步在字节码层面的实现路径是不同的。看一段简单的测试类public class SyncMethodBytecode { public synchronized void instanceMethod() { } public static synchronized void staticMethod() { } public void blockMethod() { synchronized (this) { } } }用javap -v -p SyncMethodBytecode反编译看方法结构里的 flags 字段实例同步方法flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED静态同步方法flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED而 blockMethod 的字节码指令流里才会真正出现显式的 monitorenter 和 monitorexit。也就是说方法级 synchronized 靠的是方法访问标志 ACC_SYNCHRONIZED。JVM 在执行方法调用指令时如果发现目标方法是同步方法会自动在当前线程进入该方法的 monitor方法正常返回或者异常抛出时再自动释放 monitor。这一整套获取和释放逻辑是 JVM 内部完成的字节码指令流里看不到显式的 monitor 指令。4.2 静态同步方法的局部变量表没有 this继续用 javap 观察还有一个非常有意思的差异实例方法的 args_size 是 1局部变量表里第 0 个槽位是 this静态方法的 args_size 是 0没有 this 这个参数。这个差异解释了一个日常问题为什么静态方法里直接写 this 会编译报错因为字节码层面就没给你留 this 的位置。这个设计也直接导致了锁对象的不同——实例方法的锁对象从局部变量表第 0 个槽位取出来的 this 就是最自然的候选静态方法没有 this类加载后全局唯一的那份 Class 实例自然就成了锁对象的宿主。从调用指令上也能看出两者的差异。调用实例同步方法走的是 invokevirtual、invokespecial 或 invokeinterface都会携带 this 引用调用静态同步方法走的是 invokestatic不携带 this。JVM 要拿锁对象只能去找这个方法所属类的 Class 对象。4.3 可重入性背后的 Monitor 计数器synchronized 是可重入锁这一点无论是类锁还是实例锁都成立。JVM 的 monitor 逻辑会记录当前持有锁的线程以及该线程的重入次数。同一个线程重复获取同一把锁次数递增每退出一个 synchronized 区域次数递减减到 0 才真正释放锁其他线程才有机会进入。看这个例子public class ReentrantDemo { public static synchronized void outer() { System.out.println(enter outer); inner(); } public static synchronized void inner() { System.out.println(enter inner); } public static void main(String[] args) { outer(); } }outer 是静态同步方法锁的是 ReentrantDemo.classinner 也是静态同步方法锁的还是同一个 ReentrantDemo.class。如果 synchronized 不可重入线程在 outer 里调用 inner 时会因为尝试获取自己已经持有的锁而死锁。但运行结果是完全正常的两个 enter 都会打印。这里有个值得延伸的点类锁和实例锁是两把不同的 monitor可重入规则只在同一把 monitor下才成立。如果线程 A 持有 ReentrantDemo.class 的锁又去调用另一个实例的同步方法这时候它尝试获取的是那个实例的 this monitor和类锁不是同一把。如果这个实例的 monitor 恰好被其他线程持有线程 A 照样要等。5. 项目实战中的选型与避坑5.1 子类重写会悄悄丢掉 synchronized这个坑隐蔽性极强。看代码class BaseSync { public synchronized void log() { System.out.println(base log); } } class ChildSync extends BaseSync { Override public void log() { System.out.println(child log); } }子类重写 log 方法时没有加 synchronized那么子类实例上的 log 方法就不是同步的。原因很简单synchronized 不是方法签名的一部分Java 的方法签名只包含方法名和参数类型重写时不强制保留 synchronized。如果你依赖父类方法的同步属性来保证线程安全一旦出现这种重写保护就失效了。解决方案是在子类重写方法上也显式加 synchronized。如果子类方法里调用了父类逻辑可以考虑super.log()但要注意最终锁的对象仍然是当前这个实例同步语义和父类原始方法是一致的。这个细节建议写进团队的代码规范尤其是做框架、做模板方法的场景。5.2 静态方法里写 synchronized 代码块不等于类锁这是最近几年评审代码时遇到频率最高的一类误解。有人在静态方法里写了 synchronized 代码块以为静态方法里的代码块天然就是类锁。实际上代码块锁什么完全由括号里的对象决定public class ServiceLockDemo { private static final Object LOCK_A new Object(); public static void methodA() { synchronized (LOCK_A) { // 锁的是 LOCK_A } } public static void methodB() { synchronized (ServiceLockDemo.class) { // 锁的是类对象 } } public static synchronized void methodC() { // 锁的也是类对象 } }methodA 和 methodB 可以并行因为它们锁的对象不同methodB 和 methodC 互斥因为它们锁的都是 ServiceLockDemo.class。所以静态方法这个位置信息并不决定代码块锁什么写 synchronized 代码块时必须自己明确锁对象。还有一个相关的小技巧锁对象尽量用 private final 的普通对象不要直接锁 this更不要用公共对象做锁。因为锁对象一旦暴露到外部其他代码也能拿到同一把锁可能因为无关逻辑导致意外阻塞。5.3 锁粒度选型全局状态用类锁实例状态用实例锁讲完原理说点选型层面的经验。我一般会先问一个问题这个同步方法保护的共享资源作用域是全局的还是实例的如果是全局的比如静态 Map、静态计数器、内存缓存、连接池这类的资源优先选择静态同步方法或者synchronized (Xxx.class)。这时候用实例同步方法锁粒度看似小实际上会因为实例不唯一这个事实直接失效。如果是实例级别的状态比如每个实例自己维护一个列表、一个会话状态实例同步方法就足够了。不同实例之间本来就应该并行互不干扰硬加类锁反而把并发能力给锁没了。还有一个实用的判断技巧如果一个对象在运行期是单例的实例同步方法和静态同步方法在是否互斥这件事上效果几乎一样但语义不同。单例对象的 this 和它的 Class 对象虽然没有一一是同一把锁但都只有一把所以并发表现看起来相似。可是这个等价关系非常脆弱因为单例这个前提一旦被打破实例锁的保护范围立刻崩塌。我的建议是不要依赖这种隐性的等价性代码里保护什么资源就明明白白把锁对象选到和资源同一层级的对象上。还有一个扩展思路如果并发压力大建议优先考虑用 synchronized 代码块缩小锁的临界区而不是用方法级同步把整个方法体都锁住。比如只需要保护一个 Map 的 put 操作就把 synchronized 放在 put 那一行附近不要锁住整个方法。锁的范围越大其他线程需要等待的时间越长吞吐量下降得越厉害。Java 6 之后 synchronized 有偏向锁、轻量级锁、重量级锁的升级路径但那是 JVM 层面的优化和锁对象怎么选是两个维度的问题不能因为 JVM 优化做得好就在业务代码里随意放大锁粒度。我自己在项目里踩过几次坑之后养成了一个习惯每次写 synchronized 之前先在注释里写清楚这把锁保护的是什么资源锁对象是哪个为什么选这个锁对象。三行注释写下来大部分锁设计问题在编码阶段就暴露了。这个习惯很简单但真的能省下后面大量的排查时间。