ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

synchronized底层原理与锁升级机制深度解析

synchronized底层原理与锁升级机制深度解析 1. 先搞明白synchronized到底锁住了什么网上讲synchronized的文章一搜一大把但大多数要么只列用法要么一上来就甩Mark Word、偏向锁、轻量级锁这些名词。如果你只是背下来这些词面试被追问两句还是会露馅。我习惯先问自己一个问题写完一个synchronized我到底是把什么东西锁住了答案不是代码而是对象。这一点必须刻在脑子里。1.1 最常见的三种锁对象方式平时写代码synchronized基本就三种挂法修饰实例方法锁的是调用这个方法的当前实例对象。同一个实例的多个线程互斥不同实例之间不互斥。修饰静态方法锁的是当前类的Class对象。所有实例共享同一个Class对象所以所有调用该静态方法的线程都互斥。修饰代码块锁的是括号里指定对象。可以锁this、锁某个成员变量、锁Class对象全看你传什么进去。public class SyncDemo { // 锁当前实例 public synchronized void instanceMethod() { // ... } // 锁SyncDemo.class public static synchronized void staticMethod() { // ... } public void blockMethod() { // 锁当前实例 synchronized (this) { // ... } // 锁指定对象 synchronized (lockObj) { // ... } } }很多人会忽略一个细节synchronized修饰的实例方法和synchronized(this)代码块锁的是同一个对象。如果线程A进入实例方法线程B在另一个方法里写synchronized(this)代码块两者会互相阻塞。因为竞争的是同一把对象锁。1.2 锁对象是“身份”而不是“代码内容”锁的本质是对象头里记录的锁状态。当你synchronized(lockObj)时lockObj这个对象的对象头会被打上锁标记。线程执行临界区前必须先拿到这把锁执行完再还回去。类比一下厕所门口挂着一个“占用中”的牌子牌子挂在哪个门上是锁定对象的关键。两个线程同时等同一个门才叫竞争。每个门挂各自的牌子那就各进各的毫无冲突。所以写同步代码时第一件事是确认所有需要互斥的线程是不是都在竞争同一个对象。如果线程A锁的是obj1线程B锁的是obj2代码写得再同步也是白搭。这个错误在实战中非常常见尤其是用new String或者new Object临时当锁的时候每个线程都拿到不同对象看起来加了锁实际等于没加。2. 字节码层面的monitorenter/monitorexit光知道锁对象还不够要理解synchronized的底层必须去字节码里看一眼。别怕看一次就通了。写一段最简单的同步代码块反编译之后会看到monitorenter和monitorexit这两个指令。public void demo() { synchronized (this) { System.out.println(hello); } }用javap -v查看字节码关键部分长这样public void demo(); flags: (0x0001) ACC_PUBLIC Code: stack2, locals3, args_size1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #7 7: ldc #13 9: invokevirtual #15 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return Exception table: from to target type 4 14 17 any2.1 为什么会有两个monitorexit注意看正常路径里有一个monitorexitgoto跳到22就return了。异常表里还藏着一个monitorexit编号17到21这一段干什么用的是处理执行中途抛异常的情况。JVM必须在异常发生时也把锁释放掉否则代码里一旦出现RuntimeException锁就永远不归还其他线程全部卡死。所以编译器会在同步代码块的正常退出和异常退出两个路径都插入monitorexit。这解释了为什么synchronized是自动释放锁的。和JUC里的Lock不一样Lock需要手动lock/unlock忘记unlock就会死锁。synchronized交给字节码和JVM处理写起来省心前提是你别自己把锁对象搞丢。2.2 可重入是怎么实现的所谓可重入就是同一个线程已经持有某把锁之后再次进入同一把锁的临界区不会被自己挡住。synchronized天然支持可重入。public synchronized void outer() { inner(); // 同一个线程继续进入 } public synchronized void inner() { // 还能拿到锁 }底层实现靠的是Monitor中的计数器。线程第一次进入时计数器从0变成1再次进入时再加1。退出一次减1减到0才真正释放锁。这个机制保证递归调用、嵌套调用不会死锁。如果你手动写一个锁不考虑可重入遇到outer调用inner的场景就会把自己锁死。这也是很多自研锁方案的坑点之一synchronized帮你把这种基础问题都处理掉了。3. 锁升级链路偏向锁到重量级锁这部分是面试的重灾区也是理解synchronized性能的关键。Java 6之前synchronized是个“重量级”选手因为底层依赖操作系统的互斥量实现线程阻塞和唤醒都要切到内核态代价很大。Java 6之后HotSpot做了大量优化引入了偏向锁、轻量级锁、自旋锁的状态不是一成不变的而是根据竞争激烈程度动态升级。理解这条链路比死记四个状态名有用得多。3.1 为什么设计这么多锁状态一句话因为不同场景下锁的开销来源不一样。偏向锁解决“只有一个线程反复进入同一临界区”的情况。既然只有一个线程在跑加锁解锁还要做CAS太浪费。干脆在对象头里记下这个线程的ID之后这个线程再来直接进入不需要任何同步操作。轻量级锁解决“偶尔有线程交替进入但竞争不激烈”的情况。通过CAS自旋抢锁不在用户态和内核态之间切换。抢不到顶多自旋几次过一会儿再来。重量级锁解决“多个线程长时间锁竞争”的情况。自旋也撑不住了直接挂起线程等持有锁的线程释放时再唤醒。这里有线程阻塞和唤醒的开销但总比所有线程空转强。这三个状态不是并列的而是逐步升级的关系。顺序是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。3.2 升级的触发条件和回退机制偏向锁不是一上来就有的。JVM启动后几秒内对象通常处于无锁状态因为启动阶段大量线程竞争初始化锁偏向锁那点优化反而添乱。之后如果检测到只有一个线程持有锁JVM会把这个对象的锁置为偏向模式。当另一个线程尝试获取这把锁时偏向锁就失效了。持有锁的线程可能已经退出临界区此时可以用CAS把对象头里的线程ID清掉退回到无锁也可能还在临界区里那就升级成轻量级锁。轻量级锁的膨胀发生在自旋失败多次之后。线程在栈帧里生成Lock Record用CAS尝试把对象头里的Mark Word替换成指向Lock Record的指针。抢到了锁定成功抢不到自旋重试。如果自旋到一定次数还抢不到说明竞争太激烈直接把锁膨胀成重量级锁。需要注意的是锁可以升级但一般不会降级。偏向锁可以因竞争撤销回无锁但轻量级锁一旦膨胀成重量级锁不会因为竞争减弱就自动退回。这是HotSpot的实际实现网上有些文章说“可以降级”准确说法是偏向锁撤销算一种特殊的反向操作严格意义上的重量级锁降级并不存在。3.3 锁消除和锁粗化编译器也在偷偷帮忙除了运行时的锁升级编译期还有两个优化值得知道锁消除说的是JIT编译器发现一个锁对象只被当前线程访问根本不存在跨线程竞争就把加锁代码整个去掉。典型例子是StringBuffer或者Vector里的方法虽然加了synchronized但在局部使用场景下没有线程共享JIT会直接把锁消除。锁粗化说的是编译器把多个相邻的加解锁操作合并成一个大范围加锁减少反复获取释放锁的次数。for (int i 0; i 1000; i) { synchronized (lock) { total arr[i]; } }这种代码编译器可能直接优化成整个循环外面加一次锁。不是所有情况都会这么做但你要理解synchronized的性能没有传说中那么不堪现代JVM已经把它优化得相当好。4. 可见性、原子性和有序性在synchronized里如何兑现很多人误以为synchronized只解决原子性。其实它同时解决了原子性、可见性和有序性。这也是面试里经常被考到的一个点。4.1 三个性质分别靠什么保证原子性靠monitorenter/monitorexit临界区里的代码要么不执行要么整体执行完。线程进入锁之后其他线程进不来这段代码就不会被交错执行。可见性靠内存屏障进入synchronized块时线程会清空工作内存中的共享变量副本重新从主内存读取退出synchronized块时会把工作内存中修改过的共享变量刷新回主内存。所以一个线程在锁内修改的变量另一个线程进入同一把锁后一定能看到。有序性靠Happens-Before规则对一个锁的解锁操作Happens-Before于后续对这个锁的加锁操作。也就是说线程A解锁前的所有写操作线程B加锁后都能看到。这个规则是JMM明确写出来的不是Java语言层面的语法规定而是JVM必须遵守的内存模型契约。画成时间线就是线程A: 修改x1 - 修改y2 - 释放锁 线程B: 获取锁 - 读取x,y - 一定看到x1,y24.2 synchronized和volatile怎么分工volatile只保证可见性和有序性不保证原子性。synchronized三个都保证但代价是让临界区变成互斥的线程之间需要排队。实际项目中如果一个变量是单一写入场景多个读取场景用volatile就够了。比如状态开关、缓存标志位这种。如果是对一个变量做复合操作比如count这种“读-改-写”三步操作volatile顶不住必须用synchronized或者AtomicInteger。常见错误是喜欢用volatile去修复合操作结果线上偶尔出现数量对不上的情况。记住原则先判断操作是不是原子的再判断要不要加锁。volatile能解决的场景千万不要上synchronized性能差别很大。5. 面试和实战中经常翻车的几个点这部分是我自己踩过坑也在面试里见过别人踩坑集合。每一句都值得记下来。5.1 getClass()和.class锁的不是同一个东西静态方法锁的是Class对象但写法上要注意。用synchronized(this.getClass())也能锁住Class对象但它和synchronized(Foo.class)行为并不完全一样吗其实锁对象是同一个。this.getClass()返回的是实例运行时的ClassFoo.class是编译期确定的类字面量如果this是Foo的子类实例this.getClass()返回的是子类Class这时两个写法锁的就不同了。所以静态同步方法里规范写法是锁Foo.class而不是this.getClass()。5.2 sleep不释放锁wait释放锁这个坑既涉及synchronized也涉及线程协作。Thread.sleep()在持有锁的情况下调用后锁依然被自己握着其他线程进不来。而Object.wait()则不同调用之后会把锁立即释放然后进入该锁的等待队列。如果写代码时不注意在synchronized块里用sleep来实现“暂停几秒”坑了后来线程还没意识到。想要实现锁内等待并让其他线程有机会执行应该用wait。还有一点wait/notify必须写在synchronized代码块里否则会抛IllegalMonitorStateException。原因是这两个方法依赖锁的所有者身份必须由持有当前对象锁的线程调用。5.3 锁对象被重新赋值等于白锁public class BadLock { private Object lock new Object(); public void setLock(Object newLock) { this.lock newLock; } public void doSomething() { synchronized (lock) { // ... } } }如果在运行期间把lock字段重新指向一个新对象那之前线程还在锁老对象之后线程去锁新对象互斥关系直接断裂。这种问题很难排查因为代码结构看起来完全合理。解决办法锁对象声明成final。一旦初始化引用不允许再变。private final Object lock new Object();5.4 String字面量当锁的隐患有人觉得synchronized(abc)看着方便图省事。但字符串字面量在JVM里会被驻留也就是多个地方同时用同一个字符串常量时它们指向的是同一个对象。这会导致两个完全没关系的类因为恰好用了相同的字符串常量而产生锁竞争。更麻烦的是如果别人在别处也对同一个字符串加锁你的代码会被无关线程阻塞。换个角度String对象的equals比较的是内容但加锁比较的是对象引用。两个内容一模一样的String对象地址不一样锁就不是同一把锁。所以用String做锁方向完全错误。项目中用专门的锁对象更干净比如private final Object lock new Object()。5.5 双重检查锁里的volatile到底防什么单例常用的双重检查锁模式synchronized代码块外面会加一个volatile修饰单例字段。很多人不理解为什么要volatile。根本原因是创建对象不是一步操作。new Singleton()在字节码层面大致分三步分配内存、初始化对象字段、把引用赋值给变量。JVM和CPU可能重排序导致引用赋值先发生而对象还没初始化完成。另一个线程读到一个非null但未完全初始化的对象直接用就出问题。volatile的关键作用就是禁止指令重排序保证对象完全初始化之后再暴露引用。如果不用volatile双重检查锁在多线程下是不安全的。这段逻辑面试问得很多原理必须讲清楚。6. 用JMH做一次简单的加锁性能验证光谈理论不行我建议你手头有一套能跑的基准测试代码。说synchronized慢的人很多还停留在Java 6之前的认知。我自己用JMH简单测过几个场景结果有点出乎意料。测试思路分别用synchronized方法、AtomicInteger、LongAdder做1千万次累加看吞吐量。import org.openjdk.jmh.annotations.*; State(Scope.Benchmark) public class LockBenchmark { private int count 0; private AtomicInteger atomicCount new AtomicInteger(0); private LongAdder adderCount new LongAdder(); Benchmark public void syncMethod() { synchronized (this) { count; } } Benchmark public void atomicMethod() { atomicCount.incrementAndGet(); } Benchmark public void adderMethod() { adderCount.increment(); } }结果通常在低竞争场景下synchronized和AtomicInteger的差距没有想象中那么大LongAdder因为分段累加的设计在高并发下明显领先。但这不代表synchronized一无是处。它胜在语义清晰、自动释放锁、支持条件等待配合wait/notify能完成复杂的线程协作。如果你处理的是像“多线程写入同一个索引”这类真正需要独占的临界资源用synchronized并不会有明显的性能瓶颈。反而是一些自以为聪明的无锁方案引入ABA问题、内存屏障缺失、死循环自旋最后调试成本比多花几毫秒高得多。6.1 锁粒度选择比锁本身性能更重要我见过不少同学优化性能盯着锁的实现细节不放但真正影响并发吞吐的往往是锁的粒度。锁粒度太粗比如在方法上直接synchronized整个请求串行化吞吐自然上不去。正确的做法是尽量缩小临界区把耗时的IO操作挪到锁外只锁真正需要保护的那几行代码。// 不推荐把整个方法都锁住 public synchronized void process(String key, String data) { String value remoteService.query(key); // 耗时操作不应持锁 cache.put(key, value); // ... } // 推荐只锁写缓存那一下 public void process(String key, String data) { String value remoteService.query(key); synchronized (cache) { cache.put(key, value); } // ... }把耗时操作放进临界区会导致持有锁的时间变长其他线程排队时间增加整体吞吐断崖式下降。锁性能优化第一步永远是缩小临界区然后再去考虑用偏向锁还是轻量级锁这些底层细节。6.2 锁内不要再嵌套锁还有一类问题让人头大死锁。synchronized是可重入的但可重入不代表能自动解决多个锁对象之间的环状等待。实例线程A持有锁L1准备获取L2线程B持有锁L2准备获取L1。两个线程互相等对方释放锁永远等不到程序卡死。// 线程A synchronized (lock1) { synchronized (lock2) { // ... } } // 线程B synchronized (lock2) { synchronized (lock1) { // ... } }排查这种死锁先jps拿到进程ID再jstack打印线程栈能直接看到线程处于BLOCKED状态并且waiting to lock和locked的锁对象能对上死锁链路一目了然。修复思路一般是调整加锁顺序让所有线程都按相同顺序获取多个锁或者用一个锁代替多个锁减少锁的数量。个人在实际项目里的体会是synchronized用得好不好不在于你背了多少底层原理而在于你有没有想清楚“锁的是什么对象、临界区范围多大、锁顺序是否一致”。这三个问题想明白大部分并发问题都能从根上避开。
返回列表