第4章 synchronized 为什么会影响性能synchronized通过互斥保护临界区能够阻止多个线程同时修改同一份共享状态。它解决了正确性问题但互斥也意味着线程不能再自由并发执行一个线程持有锁时其他竞争同一把锁的线程必须等待。等待本身不会修改业务结果却会消耗时间和系统资源。锁并不一定慢。没有竞争时线程通常可以很快进入同步区域真正影响性能的是竞争以及竞争引发的等待、阻塞、唤醒和上下文切换。本章从线程调度的角度分析这些成本是如何产生的。1. 正确性和性能是两个问题下面的计数器使用synchronized保护countstaticclassCounter{privateintcount0;publicsynchronizedvoidincrement(){count;}publicsynchronizedintgetCount(){returncount;}}只要所有线程都通过这两个同步方法访问count计数结果就能得到保证。但“结果正确”并不等于“执行速度最快”。多个线程同时调用increment()时同一时刻只能有一个线程进入方法其余线程即使已经准备好执行也不能继续修改count。假设有四个线程不断调用increment()执行关系可能是┌──────────┬──────────────────────────────────────────────┐ │ Thread A │ Acquire → Execute → Release │ ├──────────┼──────────────────────────────────────────────┤ │ Thread B │ Wait → Acquire → Execute │ ├──────────┼──────────────────────────────────────────────┤ │ Thread C │ Wait → Wait │ ├──────────┼──────────────────────────────────────────────┤ │ Thread D │ Wait → Wait │ └──────────┴──────────────────────────────────────────────┘程序虽然创建了多个线程但临界区仍然只能串行执行。并发线程越多并不意味着临界区的处理能力越高当锁成为唯一入口时系统吞吐量最终受单线程执行速度限制。2. 无竞争和有竞争分析锁性能时需要先区分两种情况。第一种是无竞争Uncontended。线程进入同步区域时锁处于空闲状态它可以直接获得锁执行完临界区后释放。这个过程虽然仍然存在加锁和解锁操作但没有其他线程等待额外成本通常较小。第二种是有竞争Contended。线程尝试进入同步区域时锁已经被其他线程持有。它无法继续执行临界区只能等待锁释放。竞争越激烈等待线程越多锁带来的额外成本越明显。下面的代码虽然使用了synchronized但通常不会产生竞争publicvoidrunTask(){ObjectlocknewObject();synchronized(lock){doSomething();}}lock只在当前方法中创建也没有被其他线程共享。只有一个线程能够访问这个对象因此不存在多个线程争抢同一把锁。这里的问题不是锁对象写法是否推荐而是说明一个事实synchronized的主要成本来自竞争而不是关键字本身。再看下面的代码privatefinalObjectlocknewObject();publicvoidrunTask(){synchronized(lock){doSomething();}}如果大量线程同时调用runTask()它们会竞争同一个lock。临界区执行时间越长其他线程等待的时间越长调用频率越高多个线程相遇的概率也越高。因此锁竞争主要由三个因素决定同时访问临界区的线程数量线程进入临界区的频率每次持有锁的时间。任何一个因素增加都可能加剧竞争。3. 竞争锁时线程发生了什么线程执行到同步区域时首先尝试获得锁。如果锁空闲线程进入临界区如果锁已经被其他线程持有当前线程就不能继续执行这段代码。从简化模型看竞争过程可以表示为┌───────────────┐ │ Runnable │ └───────────────┘ ↓ ┌───────────────┐ │ Try Acquire │ └───────────────┘ ↓ ┌───────────────┐ │ Lock Acquired?│ └───────────────┘ ↓ ↓ Yes No ↓ ↓ ┌─────────┐ ┌───────────────┐ │ Running │ │ Blocked │ └─────────┘ └───────────────┘ ↓ Lock Released ↓ Compete AgainJava 线程状态中的BLOCKED专门表示线程正在等待进入某个synchronized保护的区域。线程处于BLOCKED状态时并没有执行临界区中的业务代码也不会因为等待时间变长而自动获得锁。锁释放以后它只是重新获得了参与竞争的机会。需要注意实际 JVM 不一定在第一次竞争失败时立即执行重量级阻塞。现代 JVM 会根据竞争情况采用不同策略有时会短暂等待有时才会进入阻塞状态。本章关注的是稳定结果竞争失败的线程无法进入临界区而等待和重新调度都会产生额外成本。4. 阻塞和唤醒为什么有成本当线程长时间无法获得锁时让它持续占用 CPU 并没有意义。操作系统可以暂停这个线程把 CPU Core 让给其他能够继续工作的线程。这个过程通常称为阻塞Block或挂起Park。线程被阻塞后锁释放并不会让它立刻开始执行。系统还需要完成一系列步骤持锁线程退出临界区并释放锁JVM 或操作系统发现存在等待线程某个等待线程被唤醒被唤醒的线程重新进入可运行状态操作系统调度器为它分配 CPU 时间线程再次尝试获得锁。当线程暂时拿不到锁时如果仍然不断尝试就会一直占用 CPU却无法执行真正的业务代码。为了避免这种浪费JVM 可以让线程进入阻塞状态。阻塞后的线程不会继续占用 CPU操作系统可以把 CPU Core 分配给其他能够正常执行的线程。不过线程从运行到阻塞再从阻塞恢复到运行也需要付出一定成本。操作系统需要记录线程当前执行到哪里、保存寄存器和栈指针等执行现场锁释放后还要把等待线程唤醒放回可运行队列并等待 CPU 再次调度。线程重新获得 CPU 后还需要恢复之前保存的执行现场CPU Cache 中的数据也可能需要重新加载。因此阻塞适合等待时间较长的情况。如果临界区非常短持锁线程马上就会释放锁那么让等待线程短暂尝试几次可能比立即阻塞再唤醒更快。JVM 会根据锁的竞争情况选择不同的处理方式而不是每次竞争失败都立刻阻塞线程。前者浪费计算资源后者增加调度开销。JVM 需要根据竞争程度在两者之间选择合适策略但无论采用哪种方式竞争都不会凭空消失。5. 什么是上下文切换一个 CPU Core 在同一时刻只能执行一个线程。操作系统把 Core 从 Thread A 切换给 Thread B 时需要保存 Thread A 的执行现场再恢复 Thread B 的执行现场这个过程称为上下文切换Context Switch。线程的执行现场包括程序执行位置、寄存器内容、栈指针等信息。只有保存这些内容Thread A 下次获得 CPU 时才能从原来的位置继续执行。┌────────────────────┐ │ Thread A │ │ Register Snapshot │ │ Program Position │ │ Stack Pointer │ └────────────────────┘ ↓ Save ┌────────────────────┐ │ CPU Core │ └────────────────────┘ ↓ Restore ┌────────────────────┐ │ Thread B │ │ Register Snapshot │ │ Program Position │ │ Stack Pointer │ └────────────────────┘保存和恢复上下文需要时间但这不是上下文切换的全部成本。线程切换后CPU Cache 和分支预测中原本适合 Thread A 的内容未必适合 Thread B。Thread B 可能需要重新加载数据和指令导致更多 Cache Miss。切换次数过多时CPU 花在恢复执行环境上的时间会增加真正用于业务计算的时间则会减少。锁竞争可能增加上下文切换因为竞争失败的线程会阻塞持锁线程释放锁后又会唤醒等待线程。线程数量远大于 Core 数量时即使没有锁调度器也会频繁切换线程如果再叠加激烈锁竞争调度成本会进一步上升。6. 临界区越大竞争越严重锁的持有时间主要由临界区中的代码决定。下面的写法把整个方法都放入同步区域publicsynchronizedvoidprocess(Orderorder){validate(order);loadRemoteData(order);updateState(order);saveLog(order);}如果loadRemoteData()涉及网络请求saveLog()涉及磁盘 IO那么线程可能长时间持有锁。即使真正需要保护的只有updateState()其他线程也必须等待整个方法执行完成。可以缩小临界区publicvoidprocess(Orderorder){validate(order);DatadataloadRemoteData(order);synchronized(lock){updateState(order,data);}saveLog(order);}缩小临界区能够减少锁持有时间让其他线程更快获得锁。但临界区不能只按代码行数随意缩小而要覆盖完整的一致性操作。假设更新状态需要先检查余额再扣减余额那么检查和扣减必须受到同一把锁保护。错误写法if(balanceamount){synchronized(lock){balance-amount;}}检查发生在锁外。两个线程可能同时看到余额充足再依次进入同步区域完成扣减最终破坏业务约束。正确写法synchronized(lock){if(balanceamount){balance-amount;}}判断和修改属于同一次业务操作必须一起放进临界区。优化锁范围的原则不是“同步代码越少越好”而是“在保证操作完整性的前提下只保护真正共享且必须互斥的部分”。7. 锁的粒度决定并发能力如果多个互不相关的共享状态使用同一把锁它们也会被迫串行执行。这种锁覆盖范围称为锁粒度Lock Granularity。下面的类用同一把锁保护两个独立计数器classStatistics{privateintsuccessCount;privateintfailureCount;publicsynchronizedvoidrecordSuccess(){successCount;}publicsynchronizedvoidrecordFailure(){failureCount;}}recordSuccess()和recordFailure()修改不同字段但因为两个实例同步方法都使用this它们不能同时执行。如果两个计数器之间没有必须共同维护的一致性关系可以使用两把不同的锁classStatistics{privatefinalObjectsuccessLocknewObject();privatefinalObjectfailureLocknewObject();privateintsuccessCount;privateintfailureCount;publicvoidrecordSuccess(){synchronized(successLock){successCount;}}publicvoidrecordFailure(){synchronized(failureLock){failureCount;}}}这样修改成功计数和修改失败计数可以并发执行。锁粒度变小提高了并发能力但也增加了设计复杂度。锁数量越多越需要明确每一份状态由哪把锁保护以及多个操作同时涉及不同状态时应当按什么顺序获得锁。锁粒度不能机械地越小越好。如果两个字段必须始终保持一致就不能分别用互不相关的锁保护。正确的粒度取决于业务不变量而不是字段数量。8. 线程越多不一定越快考虑一个完全由同一把锁保护的任务classCounter{privateintcount;publicsynchronizedvoidincrement(){count;}}假设一个线程每秒可以执行一百万次increment()。把线程数量增加到十个并不会自然得到每秒一千万次因为十个线程仍然必须串行进入同一个临界区。额外线程反而会增加竞争、等待和调度成本。可以把任务大致分成两部分Total Work ├── Parallel Part └── Serialized Part并行部分可以分配给多个 Core 同时执行串行部分则受锁保护只能由一个线程执行。当串行部分占比很高时继续增加线程数量带来的收益会迅速降低甚至出现性能下降。这也是并发编程中一个重要原则线程是利用并行能力的工具不是性能倍增器。只有任务本身能够被有效拆分并且线程之间不会频繁争夺同一资源多线程才有可能带来明显加速。9. IO 放在锁里为什么危险临界区中包含网络、磁盘、数据库或其他耗时 IO 时锁持有时间往往不可预测。一次正常请求可能只需要几毫秒但网络抖动、数据库慢查询或磁盘拥塞可能让线程持锁数秒。所有竞争同一把锁的线程都会被连带阻塞。例如publicsynchronizedvoidupdateUser(Useruser){remoteService.validate(user);repository.save(user);cache.put(user.getId(),user);}这里的远程调用和数据库操作都发生在同步方法中。如果它们并不需要与内存状态更新保持同一个原子边界就应该考虑移动到临界区外。publicvoidupdateUser(Useruser){remoteService.validate(user);synchronized(lock){cache.put(user.getId(),user);}repository.save(user);}但这种移动必须结合业务语义判断。如果缓存更新和数据库写入必须形成一个不可分割的事务仅仅为了缩短锁时间而拆开它们可能带来更严重的一致性问题。性能优化不能破坏正确性。10. 如何观察锁竞争锁竞争通常会表现为吞吐量下降、响应时间增加、CPU 利用率异常或大量线程处于BLOCKED状态。可以通过线程转储观察线程正在等待哪一个 Monitor。例如线程转储中可能出现类似信息worker-2 #24 BLOCKED at Counter.increment(CountDemo.java:18) - waiting to lock 0x0000000712ab3410 - locked 0x0000000712ab3410 by worker-1这段信息说明worker-2正在等待某个对象锁而该锁当前由worker-1持有。线程转储只能展示采样时刻的状态不能单独说明竞争持续了多久但如果多次采样都看到大量线程等待同一把锁就说明这个锁很可能成为性能瓶颈。实际分析时还需要结合方法耗时、调用频率、线程数量和 CPU 使用情况。看到synchronized不能直接认定它有问题看到线程阻塞也不能只删除锁。首先要确认锁保护的业务不变量再判断临界区是否过大、锁粒度是否不合理或者线程数量是否超过任务真正需要。11. synchronized 慢在哪里synchronized的成本可以分为几个层次加锁和解锁需要执行额外操作竞争失败的线程必须等待等待线程可能从运行状态进入阻塞状态锁释放后等待线程需要被唤醒并重新调度线程切换需要保存和恢复执行上下文切换线程可能降低 Cache 和分支预测的有效性临界区串行执行会限制系统的最大并发能力。这些成本并不会在每次同步时全部出现。无竞争的短临界区可能开销很低激烈竞争的长临界区则可能成为系统瓶颈。因此“synchronized很慢”并不是一个准确结论更准确的说法是共享状态需要互斥时竞争程度、临界区长度和线程调度共同决定同步成本。使用锁的首要目标仍然是保证正确性。只有在确认程序正确之后才有意义讨论锁范围、锁粒度和线程数量。为了性能直接删除必要的同步只会让程序重新回到竞态条件之中。本章总结synchronized通过互斥保证临界区完整执行但互斥也会把竞争同一把锁的线程串行化。没有竞争时同步成本通常较低发生竞争后线程可能等待、阻塞、唤醒并重新参与调度这些过程都会增加额外开销。本章的核心结论包括锁的主要成本来自竞争而不是synchronized关键字本身线程竞争失败后不能进入临界区严重竞争时可能进入BLOCKED状态阻塞能够避免无效占用 CPU但阻塞和唤醒需要调度器参与上下文切换需要保存和恢复线程执行现场也可能降低 Cache 利用率临界区越长锁持有时间越长其他线程等待越久锁粒度过大会让互不相关的操作被迫串行增加线程数量不能突破串行临界区的吞吐量上限IO 等不可预测的耗时操作应谨慎放入临界区性能优化必须建立在正确性不被破坏的前提下。锁通过等待解决了并发修改问题但等待并不是唯一可能的协调方式。能否让线程在竞争失败时不立即阻塞而是根据共享状态重新尝试将成为下一章分析的核心问题。