Java并发锁详解:六类锁策略与 synchronized 底层原理

发布时间:2026/10/2 6:57:59
Java并发锁详解:六类锁策略与 synchronized 底层原理 这一部分的内容主要针对面试所以内容相对有些干巴~1.常见锁策略1.1 乐观锁和悲观锁不知道你第一次见是不是误以为一个锁比较开心另一个锁比较消极不过其实描述的主要是对于“接下来会不会发生线程冲突”悲观锁每次访问共享数据都先假设别人可能会修改因此线上锁其他线程如果也需要访问就只能等待锁释放。悲观锁的想法比较保守加锁的时候预测接下来的锁竞争的情况会非常激烈就需要针对这样的激烈情况额外做一些工作。还是继续拿公司举例子假设财务办公室有一份账本。张三觉得“这个账本这么重要李四王五说不定随时都会来改我还是先把门锁上。”乐观锁先假设并发冲突发生的概率不高在真正提交更新时再检查是否发生冲突。乐观锁的想法就相反加锁的时候预测接下来的锁竞争的情况不激烈就不需要做额外的工作。乐观锁和悲观锁的对比就像下面一样说的一样比如现在张三和李四想找老师问问题。张三比较悲观“老师平时这么忙我直接过去估计也是白跑。”所以张三就先发消息“老师你下午两点有空吗”老师确认“有空”后张三才过去~像上面这样就是悲观锁李四就比较乐观“老师应该没这么忙吧我直接过去”。于是李四直接跑办公室如果老师正好有空那最好直接把问题解决~这样就根本没有提前预约产生额外流程。如果老师正忙“哦那算了我下次再来。”虽然这次失败了但他能够识别到“现在发生冲突了”然后重新尝试~这就是乐观锁的类比两种策略没有绝对优劣关键看当前场景。比如冲突非常频繁你还一直乐观“失败 再试 失败 又试”就像知老师每天都忙得要死你还天天直接跑办公室碰碰运气。最后反而白跑很多次一直浪费资源这就比较适合悲观锁了反过来假如有 1000 次操作可能只有 1 次冲突。如果每一次都申请锁-等待-解锁这就有点像老师一年都闲的要死你每次都为“11”等于几之前还提前三天预约~这种冲突概率很低的场景就更适合乐观锁策略。那么乐观锁到底怎么知道“别人改没改”这个问题就先埋下来~而悲观锁其实很好理解真的把锁拿到手那乐观锁既然不提前把别人挡住那它凭什么知道别人有没有改过数据一个典型的方案就是版本号~就比如当前数据是余额 100version 1线程 A 读到100 / version 1准备修改为50真正提交的时候再看version是否等于 1如果还是version 1就说明我读取之后没人改过如果发现version ! 1就说明我不在的时候有人动过这份数据~那么本次修改就失败这其实就是为我后面要说的CAS埋个伏笔既然提到锁就不得不提一下synchronizedsynchronized开始时会倾向使用更乐观、开销较低的策略如果发现锁竞争逐渐频繁则会逐步转向更悲观、开销更高的处理方式。1.2 重量级锁和轻量级锁从前面我们学习到了锁的核心目的是保证原子性 / 互斥可以先暂时不用深入研究 JVM 和操作系统底层是如何实现锁的就记住Java 的锁当然不是凭空出现的它最终还是要依赖 CPU 提供的原子指令以及操作系统提供的线程调度能力。那么重量级锁和轻量级锁是什么意思重量级锁这里提到的重量级不是说把锁本身“更大”而是发生锁竞争时为了实现线程之间的互斥需要付出的系统开销比较大。就比如当一个线程获取不到锁时可能需要让该线程进入阻塞状态。等到锁被释放以后又需要将这个线程唤醒让它重新参与 CPU 调度。这其中可能涉及到用户态与内核态之间的切换线程阻塞与唤醒线程上下文切换操作系统线程调度这些操作相对于普通的用户态代码来说成本比较高所以被称为重量级锁。轻量级锁轻量级锁的思路则是如果锁竞争并不严重就尽量不要马上让线程进入阻塞状态而是在用户态先尝试解决竞争。就比如JVM 可以利用 CPU 提供的CAS 原子指令或者让线程进行短暂的自旋尝试获取锁。整个过程中线程可能并没有真正进入阻塞状态因此也就避免了比较昂贵的线程阻塞、唤醒和上下文切换。这也就是所说的“轻量级”。上面提及的用户态/内核态指的就是用户态程序自己在用户空间里完成不需要操作系统直接介入线程的阻塞和唤醒。内核态当锁竞争严重、线程需要被阻塞或唤醒时需要操作系统内核参与线程调度可能产生用户态与内核态切换以及线程上下文切换。你可以这么理解用户态和内核态假设你去银行在大厅外面自己能完成的事情填表、看号码、整理材料这些事情自己就能做这就可以类比为用户态。但是 有些事情你没有权限自己完成必须交给拥有更高权限的工作人员所以只能去柜台让银行工作人员处理这就可以类似内核态。对于上面所说的“重量级锁”和“轻量级锁”主要区别并不是谁更安全而是谁的实现成本更高。所以还是那句话没有哪一种策略永远最好关键看当前的竞争情况。1.3 自旋锁和挂起等待锁我们知道线程在竞争锁时可能会遇到一种情况线程 A 已经持有锁线程 B 尝试获取锁但失败了。对于线程 B 通常会有两种不同的处理思路继续占用 CPU不断尝试获取锁 - 自旋等待暂时放弃 CPU进入阻塞等待状态 - 挂起等待这也就是我这一节要说的自旋锁和挂起等待锁~自旋锁刚刚说到的第一种思路就是自旋锁继续占用 CPU不断尝试获取锁按照传统的处理方式如果线程获取锁失败可以直接进入阻塞状态将 CPU 让给其他线程。这样做虽然不会一直浪费 CPU但是线程阻塞、唤醒以及上下文切换本身也是有成本的。于是就产生了一个问题假设线程 A 虽然正在持有锁但是它马上就要释放了如果这时候线程 B 马上进入阻塞那么阻塞和唤醒线程的成本可能反而比等待锁释放的时间还长~所以换一种思路就是先不要马上阻塞线程让线程继续运行一小段时间不断尝试获取锁这就是自旋的含义。自旋你可以抽象理解为while(获取锁失败){// 继续尝试获取锁}注意哈这只是帮助理解的伪代码。我让 AI 解释了下实际 JVM 或并发组件中的实现会更加复杂并不会简单地让线程无条件无限循环。再补充一下自旋锁其实是属于一种偏轻量级的等待策略。自旋线程仍然处于运行状态因此仍然会占用 CPU~挂起等待锁挂起等待锁其实就是另一种思路了既然暂时获取不到那我就先不抢了把 CPU 让出来。从过程上来看其实就是“我拿不到锁我先休息把 CPU 让出来等锁释放后再唤醒我”此处所说的“挂起”并不是说 Java 线程永远停止而是线程暂时进入等待 / 阻塞状态不再持续占用 CPU 执行。这里说到的自旋锁和挂起等待锁你可以这样理解一下想象一下去追求一个女神。当男生像女神表白后女神说你是个好人但是我有男朋友了~~挂起等待锁陷入沉沦不能自拔…过了很久很久之后突然女神发消息过来“要不咱俩试试”注意这个很长时间间隔里女神可能已经换了好几个男票了自旋锁死皮赖脸坚韧不拔仍然每天坚持地和女神说早安晚安。一旦女神和上一任分手那么就能立刻抓住机会上位。我让 GPT 总结了上面的内容给出了对比表格对比自旋等待挂起等待获取锁失败后不断尝试获取锁线程进入等待 / 阻塞是否继续占用 CPU是通常不持续占用 CPU是否需要线程阻塞通常不需要需要是否涉及调度较少通常需要等待时间很短更合适阻塞成本可能不划算等待时间很长会浪费 CPU更合适所以上述两种方式没有绝对的好坏而是适用的场景不同~其实还是回到了锁竞争的激烈程度悲观锁 - 重量级锁 - 挂起等待锁乐观锁 - 轻量级锁 - 自旋锁1.4 公平锁和非公平锁这一部分其实更好理解些主要说的是如果很多线程都在等同一把锁那么这把锁到底要不要讲“先来后到”假设现在有三个线程分别是 ABCA 先尝试获取锁获取成功。然后 B 再尝试获取锁获取失败阻塞等待。然后 C 也尝试获取锁C 也获取失败也阻塞等待。那么当线程 A 释放锁的时候会有两种情况公平锁遵循“先来后到”B 比 C 先来所以当 A 释放锁的时候B 会先于 C 获取到锁。非公平锁不遵循“先来后到”B 和 C 都有可能获取到锁。其实你可以认为非公平锁也能定义为公平因为锁默认情况下就相当于“概率均等”操作系统针对线程的调度是随机的~操作系统线程调度本身可以看成带有随机性如果没有额外机制限制锁通常不会天然保证公平。如果要实现公平就需要额外的数据结构记录线程到来的先后顺序。下面这个例子希望更能加深对公平锁和非公平锁的印象~再补充一句synchronized其实是非公平锁在之前学习synchronized的时候就说到当一个线程释放锁以后其他等待的线程会重新竞争并不一定严格按照先来后到。1.5 可重入锁和不可重入锁在之前的初阶已经稍微接触过只是当时是为了说明synchronized不会让线程“自己把自己锁死”。可重入的意思就是同一个线程已经拿到第一把锁以后还可以再次获取同一把锁。或者逼入一个递归函数里有加锁操作递归过程中这个锁会阻塞自己吗如果不会那么这个锁就是可重入锁因为这个原因可重入锁也被称为递归锁。就比如下面这样publicclassDemo{publicsynchronizedvoidmethod1(){System.out.println(method1);method2();}publicsynchronizedvoidmethod2(){System.out.println(method2);}}DemodemonewDemo();demo.method1();比如我new了一个Demo调用了method1method1内部又调用了method2它们同样都是synchronized如果是可重入锁method2允许再次进入。但如果是不可重入锁method2就会被拒之门外然后method1又无法释放锁。此时就会发生死锁情况。这样的锁就称为不可重入锁。synchronized是可重入锁1.6 普通互斥锁和读写锁在多线程环境下对共享数据的访问通常可以分为两类读取数据和修改数据如果只是两个线程同时读取数据两个线程都不会修改数据因此一般不会破坏数据的一致性。但如果一个线程读取数据一个线程修改数据这样就可能产生问题同样如果是两个线程都在修改数据也必须得保持互斥。你可以这样认为普通互斥锁普通互斥锁的问题主要是即使多个线程之间只是读取操作它们仍然不能同时执行。就像是之前学习synchronized一样publicsynchronizedintgetData(){returndata;}publicsynchronizedvoidsetData(intdata){this.datadata;}这样虽然能保证线程安全但是假设三个线程都执行getData()只是读取数据。但又由于synchronized是互斥的。也就是说即使多个线程之间只是读取操作他们仍然不能同时执行。线程安全是保证了但是并发能力下降了。所以这时候就产生了一个新的思路既然读操作不会修改数据那就允许多个线程同时读取读写锁这就是读写锁由来。读写锁并不是只有“一把锁”而是把锁拆为了读锁 Read Lock 和 写锁 Write Lock 分别对待读取操作和修改操作。Java 标准库提供了ReentrantReadWriteLock它的内部提供了两种锁分别是读锁类ReentrantReadWriteLock.ReadLock和写锁类ReentrantReadWriteLock.WriteLock。以上两个类都提供了 lock / unlock 方法分别进行加锁和释放锁。读锁最大的特点就是读锁之间可以共享所以读锁也被称为共享锁而写锁的话就必须保证互斥所以读锁也能理解为独占锁因为同一时间只能有一个线程持有写锁。这样就实现了上面那种图片的效果了读锁 读锁不互斥写锁 写锁互斥读锁 写锁互斥也就是说只要有线程持有写锁其他线程既不能获取读锁也不能获取写锁反过来只要还有线程持有读锁写线程就不能获取写锁使用实例如下ReentrantReadWriteLockreadWriteLocknewReentrantReadWriteLock();ReentrantReadWriteLockreadWriteLocknewReentrantReadWriteLock();LockreadLockreadWriteLock.readLock();LockwriteLockreadWriteLock.writeLock();// 读操作加锁publicintgetData(){readLock.lock();try{returndata;}finally{readLock.unlock();}}// 写操作加锁publicintgetData(){readLock.lock();try{returndata;}finally{readLock.unlock();}}再补充一句synchronized不是读写锁~对于以上锁的内容一般面试考察的就是概念性的然后对于读写锁来说还有一个要注意的地方在ReentrantReadWriteLock中持有写锁的线程可以继续获取读锁。例如这种操作叫做锁降级也就是从写锁变成了读锁权限从独占变成了共享读取~2.synchronized原理synchronized前面已经学过了但结合上面说到的锁策略问题你可以将synchronized先理解成开始倾向乐策略竞争频繁后转向更悲观的处理开始使用轻量级实现竞争严重后可能转成重量级锁轻量级阶段可能使用自旋非公平锁可重入锁不是读写锁JVM 将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。2.1 加锁过程synchronized不是一上来就用最重的方案你可以这么理解竞争不严重的时候能简单处理就简单处理。当竞争越来越严重简单办法搞不定再逐渐换成更重的办法。先不急着背这几个词可以把线程放进一个具体场景。classCounter{privatefinalObjectlocknewObject();privateintcount0;publicvoidadd(){synchronized(lock){count;}}}假设线程 A、B 都调用同一个Counter对象的add()。它们竞争的就是该对象中的同一个lock。无锁目前还没有人使用这把锁lock刚创建出来的时候还没有线程进入这段同步代码~这时候线程 A 来执行add他想做的事情就只是“我要进入synchronized(lock)修改count”JVM 需要保证 A 修改期间如果 B 也来了B 不能同时修改~但现在只有 A一个竞争者都没有。但如果每次都按“许多线程争锁”的方式处理成本就白花了。偏向锁一直都是 A 在使用我还是拿之前的例子来吧假设我是个妹子我要谈男朋友我又希望把男朋友换的快一些那么就有下面两个步骤和当前男朋友分手我需要 zuo 一 zuo打一打拳逐渐消耗他的耐心~~我再谈分手一定得是哭的梨花带雨让它认为都是他的名字~和下一个小哥哥培养感情当我最开始谈这个男朋友的时候我和他做各种情侣之间做的事情但是从不和他确认关系搞暧昧这就让他能够更好的满足我对于男朋友的需求~然后当有一天我对他厌烦了这个时候就不必麻烦了直接和他说咱们不要再见面了~但这样搞暧昧的手段也有一定副作用万一有别的妹子也在接近我的小哥哥我的小哥哥也可以瞬间把我给踹了然后用同样的话“咱们只是普通朋友”应对我所以我只要发现了有妹子试图接近我家哥哥只要一有这样的苗头我就立即和我家哥哥确认关系并且朋友圈官宣~让其他妹子就可以 gun 远了~ok像上述这样的过程就是偏向锁的过程~进行synchronized刚一上来不是真加锁而是只是简单做一个标记搞暧昧这个标记非常轻量相比于加锁解释来说效率高很多~如果没有其他线程来竞争这个锁最终当前线程执行到解锁代码也就只是简单清除上述标记即可不涉及真加锁、真解锁就像是搞暧昧不真确立关系后续分手就很快如果有其他线程来竞争就抢先一步在另一个线程拿到锁之前抢先拿到锁。也就是真加锁了 偏向锁 -轻量级锁。其他线程只能阻塞等待~本质上也是懒汉模式思想的体现偏向锁之所以省事前提是基本没有别的线程来争前提变了策略也得变。轻量级锁有竞争但也许很快就结束假设 A 正在执行synchronized(lock){count;}此时 B 恰好也来了。B 发现自己暂时拿不到lock。如果同步代码只有一个countA 很可能马上就执行完了。此时此刻让 B 挂起等操作系统以后再唤醒它可能比“稍等一下”还费事。尝试取得锁用 CAS 检查并尝试更新与锁有关的状态。先把 CAS 理解成一个“确认状态仍符合预期才尝试修改”的动作短暂自旋如果这次没取得锁B 可以短暂重试看看 A 是否已经释放。这就和上面提到的自旋锁策略接上了“我先不去休息在小试一小会也许你马上就用完了”但 如果 A 持续时间很长B 一直原地重试。这时候 B 就会持续消耗 CPU所以自旋不会毫无节制地一直进行。短暂等待仍拿不到锁就需要考虑成本更合适的处理方式。重量级锁持续竞争就让线程等待假设 A 进入同步代码后久久不退出B 试了多次还是拿不到锁或者有更多线程都来争这把锁。此时要是再试一下就不划算了~所以就需要才需更重的处理方式也就是重量级锁有 JVM 的监视器机制管理竞争拿不到锁的线程可能进入等待等锁释放后再被唤醒、重新争取。对于上述的内容我是这样认为无锁 -- 偏向锁代码进入synchronized的代码块偏向锁 -- 轻量级锁拿到偏向锁的线程运行过程中遇到了其他线程尝试竞争这个锁轻量级锁 -- 重量级锁JVM 发现当前竞争锁的情况非常激烈当前 JVM 中只提供了“锁升级”不能“锁降级”。注意此处我说的锁降级与前面提到的ReentrantReadWriteLock的锁降级不是同一种。ReentrantReadWriteLock的“锁降级”是并发锁 API 的读写权限转换synchronized所谓“锁升级”是 JVM/HotSpot 对 Monitor 的底层实现形态变化。2.2 其他的优化操作锁消除锁消除就是编译器和 JVM 判断某些锁实际上不会发生线程竞争于是把没有必要的加锁、解锁操作直接去掉~就比如都知道StringBuffer很多方法本身就带同步。StringBuffersbnewStringBuffer();sb.append(a);sb.append(b);sb.append(c);sb.append(d);但如果sb 只是一个方法里的局部变量而且整个过程只有当前线程能访问。那么就根本没有线程在跟你抢那这时候还“加锁、append、解锁”就纯粹是白忙活~所以 JVM 如果能分析出这个对象不会被其他线程访问那么就可以把这些没有意义的同步操作消掉~再注意一下JVM 只有在能够判断这个锁不会发生真实竞争时才能进行这种优化锁粗化再看另一种情况for(inti0;i1000;i){synchronized(lock){count;}}假设 JVM 按代码表面上的范围处理每轮循环都要取得锁 - count - 释放锁如果是循环一千次那么就重复一千次但问题是如果这些操作紧挨着进行反复去取得、释放同一把锁本身也会有成本。所以锁粗化就是在合适的情况下JVM 可能把多次相邻的加锁范围合并减少反复申请和释放锁的次数。举个例子假设你要向领导交代三个任务方式一打电话交代任务 1挂断再打电话交代任务 2挂断再打一次交代任务 3.方式二打一次电话把三个任务交代完再挂断。你给领导打电话对领导加锁这个时候领导需要全心全意应付你没法做别的事情所以必然会对整体效率产生影响~所以方法二就省掉了重复的成本锁粗化成一把锁省的也是反复“取得、释放”的成本。所以上面的加锁可以优化成这样synchronized(lock){for(inti0;i1000;i){count;}}所以synchronized真正厉害的地方不只是“能加锁”而是 JVM 在背后替我们做了很多优化。那么对于如果有类似下面的面试题回答就不算困难了~什么是偏向锁偏向锁不是真的加锁而只是在锁的对象头中记录一个标记记录该锁所属的线程。如果没有其他线程参与竞争锁那么就不会真正执行加锁操作从而降低程序开销。一旦真的涉及到其他的线程竞争再取消偏向锁状态进入轻量级锁状态。synchronized 实现原理是什么