Java高并发底层原理(四)—— synchronized 为什么会影响性能
第4章 synchronized 为什么会影响性能
synchronized通过互斥保护临界区,能够阻止多个线程同时修改同一份共享状态。它解决了正确性问题,但互斥也意味着线程不能再自由并发执行:一个线程持有锁时,其他竞争同一把锁的线程必须等待。等待本身不会修改业务结果,却会消耗时间和系统资源。
锁并不一定慢。没有竞争时,线程通常可以很快进入同步区域;真正影响性能的是竞争,以及竞争引发的等待、阻塞、唤醒和上下文切换。本章从线程调度的角度分析这些成本是如何产生的。
1. 正确性和性能是两个问题
下面的计数器使用synchronized保护count++:
staticclassCounter{privateintcount=0;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(){Objectlock=newObject();synchronized(lock){doSomething();}}lock只在当前方法中创建,也没有被其他线程共享。只有一个线程能够访问这个对象,因此不存在多个线程争抢同一把锁。这里的问题不是锁对象写法是否推荐,而是说明一个事实:synchronized的主要成本来自竞争,而不是关键字本身。
再看下面的代码:
privatefinalObjectlock=newObject();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);Datadata=loadRemoteData(order);synchronized(lock){updateState(order,data);}saveLog(order);}缩小临界区能够减少锁持有时间,让其他线程更快获得锁。但临界区不能只按代码行数随意缩小,而要覆盖完整的一致性操作。假设更新状态需要先检查余额,再扣减余额,那么检查和扣减必须受到同一把锁保护。
错误写法:
if(balance>=amount){synchronized(lock){balance-=amount;}}检查发生在锁外。两个线程可能同时看到余额充足,再依次进入同步区域完成扣减,最终破坏业务约束。
正确写法:
synchronized(lock){if(balance>=amount){balance-=amount;}}判断和修改属于同一次业务操作,必须一起放进临界区。优化锁范围的原则不是“同步代码越少越好”,而是“在保证操作完整性的前提下,只保护真正共享且必须互斥的部分”。
7. 锁的粒度决定并发能力
如果多个互不相关的共享状态使用同一把锁,它们也会被迫串行执行。这种锁覆盖范围称为锁粒度(Lock Granularity)。
下面的类用同一把锁保护两个独立计数器:
classStatistics{privateintsuccessCount;privateintfailureCount;publicsynchronizedvoidrecordSuccess(){successCount++;}publicsynchronizedvoidrecordFailure(){failureCount++;}}recordSuccess()和recordFailure()修改不同字段,但因为两个实例同步方法都使用this,它们不能同时执行。
如果两个计数器之间没有必须共同维护的一致性关系,可以使用两把不同的锁:
classStatistics{privatefinalObjectsuccessLock=newObject();privatefinalObjectfailureLock=newObject();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 等不可预测的耗时操作应谨慎放入临界区;
- 性能优化必须建立在正确性不被破坏的前提下。
锁通过等待解决了并发修改问题,但等待并不是唯一可能的协调方式。能否让线程在竞争失败时不立即阻塞,而是根据共享状态重新尝试,将成为下一章分析的核心问题。