ARTICLE DETAIL

资讯详情

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

synchronized与Lock并发锁机制全解析:从原理到选型

synchronized与Lock并发锁机制全解析:从原理到选型 写并发的博客我一般不太愿意上来就堆锁机制的底层层级图更愿意先回答一个问题你写的代码里到底哪个瞬间需要一把锁拿最常见的秒杀场景来说库存从100扣到0两个线程同时读到stock1都执行了stock--结果库存变0了但订单可能生成了两单。这就是典型的并发安全事件。解决问题的核心思路只有两个方向要么让读写操作原子化要么让线程排队。Java里最原始、最直接的排队工具就是synchronizedJDK 5之后又引入了JUC包下的Lock锁家族。这篇博客不聊空泛概念围绕这两个锁机制的原理、差异、选型和排障从底层到实战过一遍适合正被并发问题折磨的业务开发也适合准备多线程面试的朋友做系统性梳理。1. synchronizedJDK自带的那把锁到底锁住了什么1.1 三种写法与锁对象的细微差别synchronized在语法层面有三种用法修饰实例方法、修饰静态方法、修饰代码块。很多初学者以为这三者只是写法不同核心逻辑完全一样其实这里藏着一个最容易踩坑的点锁对象。修饰实例方法时锁的是当前实例对象this。修饰静态方法时锁的是当前类的Class对象。修饰代码块时锁的是你在括号里手动指定的对象。举个例子两个线程分别调用同一个类的两个不同实例的synchronized实例方法由于锁对象分别是两个不同的this这两线程其实可以同时进入方法体完全不互斥。这种情况经常被当成锁失效实际上不是锁失效是你压根没锁在同一个对象上。聊到代码块还有一个常见误区有人喜欢这么写synchronized (this)对象锁没问题但如果类内部有多个不同的资源需要同步比如一个订单对象里既要扣减库存又要更新状态统一锁this等于把所有操作串行化性能白白被浪费。更合理的做法是锁粒度拆分扣库存的代码块用一个stockLock对象更新状态的代码块用另一个statusLock对象互不干扰。锁粒度这个东西会在后面的选型部分再展开。从字节码层面看synchronized代码块会生成monitorenter和monitorexit两条指令修饰方法则通过ACC_SYNCHRONIZED标志来标记。进入块或方法时JVM需要先获取对应对象的Monitor获取成功才能继续执行否则线程进入阻塞等待。1.2 Java对象头与Mark Word锁信息的存储地JVM里的一个Java对象在内存中由三部分组成对象头、实例数据、对齐填充。对象头又分两块存放运行时数据的Mark Word以及指向类元数据的类型指针数组还有额外的长度字段。Mark Word就是理解synchronized锁升级的关键。它是一块很小的内存区域64位虚拟机下是64bit但里面塞的信息非常丰富对象的哈希码、GC分代年龄、锁状态标志位、持有锁的线程ID、指向Monitor的指针等。关键是这些信息不是同时有效而是根据锁状态动态复用。无锁状态下Mark Word存的是对象哈希码和分代年龄偏向锁状态下存的是偏向线程ID和时间戳轻量级锁状态下存的是指向线程栈中锁记录的指针重量级锁状态下存的是指向Monitor对象的指针。这个设计思路值得停下来想一下为什么JVM要把锁状态直接存进对象头里因为线程获取锁时第一步只是看一眼这个对象现在的状态判断能不能低成本加锁。如果把锁状态单独放到一个外部结构里每次加锁都要额外做一次间接寻址性能消耗就上去了。把状态压进对象头属于在内存和性能之间做了一次非常精巧的取舍。1.3 Monitorsynchronized背后真正的管理员说synchronized获取锁很多人的理解是拿到了一个标志位。更准确的说法是线程获取了一个关联在对象上的Monitor锁管程。Monitor的核心数据结构可以理解为三个关键字段_owner持有锁的线程、_EntryList竞争锁但还没抢到的线程队列、_WaitSet调用了wait()方法主动释放锁并等待的线程集合。线程执行到monitorenter时会尝试把自己设置为_owner。如果_owner已经是其他线程就进入_EntryList排队等待。持有锁的线程调用wait()会释放锁并进入_WaitSet其他线程调用notify()或notifyAll()则从_WaitSet里唤醒一个或全部线程把它们移回_EntryList重新竞争。这套机制解释了synchronized搭配的wait/notify为什么必须写在synchronized代码块内因为调用wait()的前提是当前线程已经持有对象的Monitor否则JVM无法确认要把谁放进_WaitSet直接抛IllegalMonitorStateException。1.4 锁升级从偏向锁到重量锁的四级演进JDK 1.6之前synchronized获取锁就是直接走重量级线程拿不到锁就挂起涉及用户态到内核态的切换开销很大。这也是早期版本synchronized被诟病性能差的原因。1.6之后引入锁升级机制锁状态分为四档无锁 → 偏向锁 → 轻量级锁 → 重量级锁。状态只能单向升级不能降级所以叫锁膨胀。偏向锁解决的是一个锁被同一个线程反复获取的场景。获取偏向锁时CAS将Mark Word里的偏向线程ID改成当前线程ID。成功之后这个线程下次再来拿锁只要检查偏向线程ID是自己直接通过不再做任何CAS操作开销几乎为0。轻量级锁解决的是多线程交替执行但不会同时竞争。线程加锁前在自己的栈帧中创建锁记录Lock Record然后通过CAS把Mark Word复制到锁记录里同时将Mark Word指向锁记录。CAS成功说明拿到锁失败说明有竞争但不会立即升级而是先自旋等待一会儿用CPU空转换取避免线程挂起的开销。重量级锁解决的是多线程真正同时竞争的场景。自旋到一定程度仍没拿到锁线程会被阻塞挂起进入内核态排队。这就是为什么竞争激烈时synchronized性能会迅速下降。这里有一个面试常考的点轻量级锁失败后为什么不直接升级而是先自旋因为线程从阻塞到唤醒涉及内核态切换成本很高如果另一个线程很快释放锁自旋等待比挂起线程划算得多。JDK没有用固定自旋次数而是采用适应性自旋前一次自旋成功获取到锁JVM会认为这次自旋也有较大概率成功于是增加自旋次数反之则减少。1.5 偏向锁撤销与批量重偏向面面观偏向锁虽好但有个明显的代价一旦出现另一个线程来竞争偏向锁就得撤销。撤销偏向锁需要等待全局安全点SafePoint在这个时间点所有用户线程都暂停JVM这时才能判断偏向线程是否存活。如果偏向线程已退出同步块Mark Word恢复到无锁状态允许重新偏向或者升级。如果偏向线程仍存活需要先暂停该线程再决定是恢复到无锁还是膨胀成轻量级锁。在实际业务中一个类创建了大量对象并加锁但只有少数对象存在竞争撤销偏向锁的代价会非常高频。JVM为此引入批量重偏向机制当一个类的对象发生多次撤销动作默认阈值20次JVM会把这些对象的Mark Word里的epoch字段进行调整让后续同一线程对这些对象的加锁重新获取偏向锁而不是再次走撤销流程。这个机制在生产环境里的实际影响是如果你在代码里大量使用synchronized锁定某个频繁创建和销毁的对象并且实际竞争并不激烈偏向锁能带来可观的性能提升但如果是高并发场景偏向锁的撤销本身就会成为负担。这也是为什么在JDK 15之后偏向锁被默认禁用JDK 15的JEP 374表示要逐步废弃偏向锁。理解为这个背景再回头看很多人纠结为什么我的synchronized感觉有偏向锁延迟就能对JVM的优化方向有更完整的把握。2. Lock接口从JVM内置锁到AQS的视野转换2.1 synchronized的四个痛点synchronized虽然用起来简单但功能上限也很明显。第一无法中断。一个线程获取不到锁就会一直阻塞即使被其他线程调用了interrupt()它也无动于衷。这在业务系统里非常危险一个死锁发生相关线程会永久挂起想靠外部中断来解围是做不到的。第二无法超时。synchronized没有等多久就算了的机制。如果持锁线程因为网络IO等原因迟迟不释放其他线程只能无限等待。第三非公平。这里的公平指的是线程获取锁的顺序是否遵循先来后到。synchronized锁释放后_EntryList里的线程是竞争式获取谁抢到算谁的没有保证排队顺序。第四单一等待队列。一个synchronized锁只能通过一个wait/notify队列管理等待线程。比如多个生产者消费者场景需要使用多条件队列分别管理缓冲池满和缓冲池空synchronized做起来很别扭。这些痛点正是Lock接口出现的直接原因。2.2 Lock接口核心方法解读Lock接口定义在java.util.concurrent.locks包下核心方法不多但每一个都在补synchronized的短板void lock()获取锁获取不到就阻塞。void lockInterruptibly()可中断地获取锁线程在等待过程中可以被中断并收到异常。boolean tryLock()非阻塞尝试获取锁拿不到立即返回false。boolean tryLock(long time, TimeUnit unit)带超时的尝试获取等待超时仍未拿到则返回false。void unlock()释放锁。Condition newCondition()返回一个新的条件变量。特别说下解锁的规范写法。Lock锁不像synchronized有JVM自动释放机制必须手动调用unlock()所以标准范式是先把lock.lock()写在方法最开始紧接着在try代码块里写业务逻辑finally块里释放锁。ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这个写法不是可有可无的规范建议而是防止异常的底线。如果在业务代码里抛异常不经过finally就不会解锁锁会一直被当前线程持有其他线程全部阻塞整个系统都可能被这个小疏忽拖垮。我自己见过不止一次生产事故原因就是忘记在finally里释放锁。2.3 ReentrantLock与AQS从一把锁到一个同步框架JUC包下最常用的Lock实现是ReentrantLock可重入锁。它在底层没有直接自己实现锁逻辑而是依托一个同步器框架AbstractQueuedSynchronizer简称AQS。AQS是JUC整个并发包的基石不只是ReentrantLockSemaphore、CountDownLatch、ReentrantReadWriteLock全部建立在AQS基础上。理解了AQS再去看其他并发工具类基本是降维打击。AQS的核心有两个一个volatile int state一条CLH变体等待队列。state的含义由具体的子类自行定义。在ReentrantLock里0表示锁未被占用1表示锁已被一个线程占用如果同一个线程再次获取锁state会累加到2、3、4对应重入次数。在Semaphore里state表示剩余的许可数量在CountDownLatch里state表示还需要等待的计数。理解了这个字段的复用逻辑你会发现AQS本质上是一个通用的同步状态管理器。CLH队列是AQS维护的等待线程队列它的作用是管理所有因获取不到同步状态而阻塞的线程。所谓的CLH变体队列与原始的CLH有区别但核心思想都是通过自旋和阻塞交织的方式把并发控制降维成队列操作。2.4 从lock()到加锁成功一次完整的时间线以非公平锁的ReentrantLock.lock()为例走一遍完整的调用链。第一步非公平锁的lock()会直接调用compareAndSetState(0, 1)尝试通过CAS把state从0改成1。这一步是非公平的体现新来的线程不检查队列里有没有人在等上来先抢一次。抢成功了说明当前无竞争加锁完成把exclusiveOwnerThread设置为当前线程。第二步CAS失败说明锁被占用了进入acquire(1)方法。acquire首先调用tryAcquire(1)尝试再获取一次。为什么这里要再试一次因为从CAS失败到走进acquire中间可能正好持锁线程释放了锁再次尝试可以避免线程立刻挂起。第三步tryAcquire再次失败执行addWaiter(Node.EXCLUSIVE)把当前线程封装成一个Node节点追加到CLH队列尾部。这个入队操作同样通过CAS完成防止并发入队时丢失节点。第四步进入acquireQueued循环。当前节点的前驱是头节点时再次尝试获取锁获取成功将自己设为新的头节点获取失败则通过LockSupport.park()阻塞当前线程。第五步线程被唤醒后重复第四步的逻辑直到拿到锁。这个流程里最关键的设计是park之后的唤醒。unlock()时会调用LockSupport.unpark()唤醒后继节点被唤醒的线程重新检查自己是不是排队第一是就再次尝试获取锁。整个机制没有用任何自旋空转线程被阻塞时CPU资源是被释放的这一点比轻量级锁的自旋策略高效得多也是重量级锁在竞争激烈场景下仍然可靠的原因。2.5 公平锁与非公平锁的差异实现ReentrantLock的构造函数支持传入boolean fair决定是公平锁还是非公平锁。默认是非公平。非公平锁的tryAcquire直接尝试CAS修改state不看排队情况。公平锁的tryAcquire里多了一步先调用hasQueuedPredecessors()判断CLH队列中是否有线程在排队。如果队列里有节点且不是当前线程就放弃本次获取排队去。// 公平锁tryAcquire的核心判断 if (hasQueuedPredecessors()) { return false; // 有人在排队我放弃抢 }这个差别很微妙但影响很大。非公平锁以性能优先后续线程可以插队因此吞吐量更高但存在线程饥饿的可能极端情况下某个线程一直抢不到锁。公平锁保证先来后到但每次加锁都要先检查队列额外开销更大。在生产环境里我刚工作那会儿也迷信公平锁更可靠后来在压测中发现公平锁吞吐量明显低于非公平锁尤其在持锁时间短、竞争激烈的场景下。公平不是银弹需要根据业务容忍度决定。2.6 Condition更细粒度的等待-通知Condition是Lock体系里的等待通知机制等价于synchronized里的wait/notify但功能更强。一个Lock可以创建多个Condition对象每个Condition维护着独立的等待队列。这直接解决了synchronized单一等待队列的痛点。比如实现一个有界阻塞队列一个Condition管理队列已满生产者等待另一个Condition管理队列为空消费者等待。生产者往满队列塞数据时在notFull上等待消费者取出数据后调用notFull.signal()唤醒生产者。两者互不干扰。ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { while (isFull()) { notFull.await(); } enqueue(data); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (isEmpty()) { notEmpty.await(); } Object item dequeue(); notFull.signal(); return item; } finally { lock.unlock(); }这里要注意await()和synchronized里的wait()一样会释放锁资源线程进入该Condition的等待队列。但判断等待条件必须用while而不是if因为线程被唤醒到真正执行之间可能存在其他线程抢先改变了条件。这属于经典的多线程安全范式写条件等待时务必要用循环再判断。3. synchronized与Lock的六大对比证件照级别的差异3.1 加锁方式隐式 vs 显式synchronized是隐式锁加锁和解锁均由JVM自动管理。代码块编译后自动插入monitorenter和monitorexit异常路径也会自动释放锁。Lock是显式锁开发者需要明确写lock()和unlock()。这个差异听起来简单实际影响很大。隐式锁降低了开发者的心智负担只要不写错synchronized的作用域基本不会出现忘记解锁的问题。显式锁则把控制权完全交给开发者灵活度更高但也意味着更重的责任。3.2 可中断性一条重要分水岭synchronized不能响应中断。线程阻塞在锁上时其他线程调用interrupt()只是设置中断标志位线程将继续等待锁。ReentrantLock.lockInterruptibly()则可以在等待锁的期间响应中断。一旦线程被中断会抛出InterruptedException结束阻塞状态。这在某些需要主动取消任务的场景里非常关键。比如一个任务队列里的任务如果某个线程长时间拿不到锁管理员想把它停掉用synchronized做不到用lockInterruptibly()就很容易。3.3 超时机制给阻塞上一条保险synchronized没有提供超时接口。Lock可以通过tryLock(timeout, unit)实现超时等待超过指定时间仍未获取锁就返回false线程可以做其他处理而不是无限期挂起。这个能力在分布式场景的本地组件设计中非常实用。比如一个资源池的连接获取要求最多等待3秒超时则抛出异常返回错误提示。用synchronized实现这个超时逻辑会很别扭需要借助外部定时器或者自旋计时稍不注意还会引入更多并发问题。换成tryLock(3, TimeUnit.SECONDS)一行代码解决。3.4 公平性先来后到 vs 插队synchronized只能作为非公平锁使用。Lock可以实现公平锁也可以实现非公平锁。关于公平锁再补一个容易忽略的要点即使构造了new ReentrantLock(true)ReentrantLock也只是在tryAcquire时检查队列lock()方法的入口依然会先CAS尝试一次。也就是说公平锁不保证绝对公平只是尽可能公平。真要实现严格意义的FIFO锁需要在此基础上自己加层控制但业务上一般用不到。3.5 等待队列一组 vs 多组synchronized搭配wait/notify只有一个条件变量所有等待线程都挂在同一个集合里。Lock通过newCondition()可以创建多个条件队列按不同的业务条件分别挂起和唤醒。这个差异在有界缓冲、生产者消费者场景中体现得特别明显。一个条件队列也能写生产者消费者但notifyAll()可能唤醒不相关的线程导致惊群效应额外消耗CPU。多条件队列可以让唤醒更精确生产者只唤醒消费者消费者只唤醒生产者。3.6 底层机制与性能表现synchronized底层依赖JVM的Monitor机制锁状态由对象头Mark Word描述支持基于偏向锁、轻量级锁、重量级锁的自动升级。Lock底层基于AQS CAS LockSupport.park/unpark。性能方面JDK 1.6之后synchronized经过优化在低竞争场景下偏向锁和轻量级锁的性能不比ReentrantLock差。竞争激烈时两者都会升级到阻塞机制性能差异更多取决于等待调度策略。ReentrantLock的非公平模式开销略低于synchronized重量级锁但差距不像网上传的那么夸张。我整理了一张对比表方便面试前快速复习对比维度synchronizedLock (ReentrantLock)加锁方式隐式JVM自动管理显式需手动lock/unlock锁释放自动释放异常也释放finally中手动释放可中断不支持支持 lockInterruptibly超时不支持支持 tryLock(timeout)公平性非公平可选择公平/非公平条件队列单一 wait/notify多个 Condition底层实现对象头 Mark Word MonitorAQS CAS LockSupport可重入支持支持4. 生产环境锁选型与实操要点4.1 选型原则不是越高级越好很多人在编码时有能用Lock就不用synchronized的执念理由是Lock功能更强。但从生产实践看这个想法并不总是对的。synchronized的优势是简单、不易出错、自动释放锁、不需要额外API。在单机内部竞争不激烈的场景比如缓存更新的临界区、状态流转的保护段用synchronized完全够用代码还更清爽。JUC包的作者Doug Lea在多个场合也表达过类似观点能简单就不要复杂。Lock的优势集中在更复杂的需求上需要超时、需要可中断、需要多条件队列、需要公平策略、追求极致的非公平吞吐量。碰到这些场景就应该果断用Lock。我的个人倾向是无特殊需求优先synchronized。需要超时控制用tryLock(timeout)。需要可中断等待用lockInterruptibly()。需要多个等待条件用Condition。读多写少的场景考虑ReentrantReadWriteLock或StampedLock这两个也属于Lock体系的扩展。4.2 锁粒度把该锁的锁住不该锁的放掉锁粒度是并发编程里最容易拿捏不好的地方。锁太粗比如一个方法从头锁到尾包含大量不涉及共享变量的计算性能直接拉胯。锁太细比如把一次需要原子性保证的复合操作拆成多个锁块又会引入中间态不一致的问题。判断锁粒度是否合理核心标准是锁的作用域是否覆盖了完整的临界区。临界区指的是从读取共享变量到基于该变量执行操作并回写的整个过程这个过程的任意两步之间都不允许其他线程插入。以一个简单的计数器为例。下面这种写法是典型错误public void increment() { int current count; // 读 current current 1; // 算 count current; // 写 }如果只把这个方法中某一行加锁比如只锁count current这一步根本无法阻止另一个线程在读取和写回之间插入自己的读写。所以锁必须覆盖这三步的完整区间要么用synchronized修饰方法要么用Lock包住整个方法体。还有一个常见坏味道在循环内加锁。比如批量更新一批订单的状态把加锁写在了for循环内部每次循环都进行一次lock/unlock频繁上下文切换性能极差。正确做法是评估这批订单是否可以合并成一个临界区处理能合并就合并不能合并则要考虑调整数据结构。4.3 死锁产生的原因与规避策略死锁的经典定义是两个或多个线程互相持有对方需要的锁又都在等待对方释放锁导致全部阻塞无法推进。死锁的产生需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。要预防死锁只要破坏其中一个条件即可。实际编码中最常用的方式是破坏循环等待所有线程都按照固定的全局顺序获取锁。假设有两个锁对象lockA和lockB线程1先拿lockA再拿lockB线程2也先拿lockA再拿lockB即使竞争激烈也不会出现循环等待。相反如果线程1先A后B线程2先B后A死锁就随时可能发生。还有一招是使用带超时的tryLock。如果线程获取第二个锁超时失败就回滚自己已经获取的锁并释放打破持有并等待的条件。这个策略在复杂锁链场景下比较实用。4.4 实战示例正确使用Lock实现库存扣减下面给一个贴近业务的完整示例模拟库存扣减要求支持重试和超时。这里不用synchronized是因为我们希望并发高时部分线程快速失败返回系统繁忙而不是无限等待。public class StockService { private int stock 100; private final ReentrantLock lock new ReentrantLock(); public boolean deductStock(int count) { boolean getLock false; try { // 最多等待200毫秒拿不到就快速失败 getLock lock.tryLock(200, TimeUnit.MILLISECONDS); if (!getLock) { return false; } if (stock count) { stock - count; return true; } return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (getLock) { lock.unlock(); } } } }几个细节值得说明。tryLock抛出InterruptedException时要在catch中重新设置中断标志位而不是吞掉异常这是一个很容易被忽略的线程协作规范。finally里必须判断getLock为true才解锁否则可能对未持有的锁执行unlock()抛异常。库存扣减操作的检查与扣减必须放在同一个锁临界区内确保读-判-写是原子的。5. 常见问题与排查实录5.1 锁失效加在错误对象上的synchronized一个常见的锁失效场景是把synchronized加在了一个每次调用都会新建的对象上。public void doSomething() { synchronized (new Object()) { // 业务逻辑 } }每次进入方法都会new一个新的Object这个锁对象每次都是全新的每个线程拿到的都是不同的锁自然无法互斥。正确做法是把这个锁对象定义成类的成员变量并且是同一个实例复用。这个问题在Spring默认单例的Bean里不太容易暴露但在原型模式下就非常明显。写出这段逻辑的时候本质上是对synchronized锁对象的语义理解不到位。5.2 压测发现锁竞争激烈先看锁的粒度我曾经接手过一个订单系统压测时发现QPS始终上不去线程Dump一看大量线程阻塞在一个synchronized方法上。进一步分析这个方法内部包含了订单校验、库存预占、优惠计算、落库等多个步骤整个操作全部串行化。这实际上是典型的锁粒度过粗。当时重构的方案是把库存预占这个真正需要原子性的关键操作单独提取成一个方法使用ReentrantLock完成扣减订单校验和优惠计算不发生共享写入移出锁临界区落库操作由数据库自身的事务保证。优化后QPS提升接近一个数量级。锁不是不能用而是别把不需要互斥的代码塞进临界区。5.3 用jstack定位死锁死锁排查是并发开发的基本功。最直接的工具是JVM自带的jstack。先通过jps找到目标Java进程的PID然后执行jstack PID。如果存在死锁jstack的输出末尾通常会有专门的Found one Java-level deadlock段落明确指出哪些线程持有哪把锁、等待哪把锁、对应的代码行号。Thread-1: waiting to lock 0x000000076b3c36e8 (a java.lang.Object) locked 0x000000076b3c36d8 (a java.lang.Object) Thread-2: waiting to lock 0x000000076b3c36d8 (a java.lang.Object) locked 0x000000076b3c36e8 (a java.lang.Object)这种输出已经给出了非常明确的修复方向两个线程的加锁顺序不一致调整为相同顺序即可。顺带提一句生产环境排查线程问题不要只靠jstack配合jstat和jcmd能获得更完整的现场信息。如果线程长期处于BLOCKED状态结合JFRJava Flight Recorder能记录锁竞争的时间线定位到具体是哪一行代码导致了长阻塞。5.4 锁升级在业务代码中不可见但不要忽略它的行为偏向锁、轻量级锁的变化在实际业务代码里通常感知不到因为JVM会自行选择最优策略。但有个值得留意的现象在某些高并发、短临界区的代码里使用synchronized后线程大量处于BLOCKED状态而使用ReentrantLock的非公平锁后阻塞减少。原因在于ReentrantLock的非公平模式允许新线程直接CAS抢占减少了线程进入队列挂起再唤醒的开销而synchronized在锁竞争激烈时会直接膨胀为重量级锁阻塞唤醒的成本更高。不是说这种情况下synchronized就不能用而是排列组合的选型需要考虑具体的并发特征。如果临界区极其短小比如只是对一个int变量做CAS自增完全可以用AtomicInteger替代锁连阻塞都不会发生。锁不是唯一解根据场景选择最小的同步手段才是并发编程的真正功力。5.5 一个容易忽视的Lock使用禁忌unlock()必须在finally中调用这点前面提过。另外还有一个很多人踩过的坑在lock()和try之间有了额外业务代码。比如ReentrantLock lock new ReentrantLock(); lock.lock(); System.out.println(获取锁成功开始处理); try { // 业务 } finally { lock.unlock(); }如果lock()之后、try之前抛出异常比如上面的System.out.println因为IO问题抛错锁将永远不会被释放。所以正确写法一定是lock()之后立刻进入try中间不要插入任何可能抛出异常的逻辑。这两个步骤必须紧紧贴在一起。6. 最后从会用锁到会设计同步方案ReentrantLock用多了之后我自己的体会是锁机制本身不难理解真正难的是判断什么时候该用锁、用多大的锁、用哪种锁。synchronized是JVM替你操心的锁Lock是JUC替你操心的锁但两者都需要开发者自己拿捏同步的边界。深入理解了synchronized的锁升级和Mark Word再看JVM对无竞争场景做的极致优化就会明白为什么简单的同步关键字能够活这么多年还没有被淘汰。理解了AQS再看Semaphore、CountDownLatch、ReentrantReadWriteLock会发现它们背后是同一套状态管理和队列阻塞思想学一个框架收益覆盖整个并发编程领域。如果是刚接触并发编程的读者我的建议是先把synchronized用熟理解它的锁对象和等待通知机制再上手ReentrantLock和Condition。不要一开始就追求最复杂的方案锁机制不是越多越好、越高级越好合适才是最好的。遇到性能问题先怀疑锁粒度遇到死锁先检查加锁顺序遇到线程阻塞先看竞争激烈程度。把这几个排查思路刻在脑子里生产环境里的并发问题基本都能有条不紊地解决。
返回列表