当前位置: 首页 > news >正文

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 Again

Java 线程状态中的BLOCKED,专门表示线程正在等待进入某个synchronized保护的区域。线程处于BLOCKED状态时,并没有执行临界区中的业务代码,也不会因为等待时间变长而自动获得锁。锁释放以后,它只是重新获得了参与竞争的机会。

需要注意,实际 JVM 不一定在第一次竞争失败时立即执行重量级阻塞。现代 JVM 会根据竞争情况采用不同策略,有时会短暂等待,有时才会进入阻塞状态。本章关注的是稳定结果:竞争失败的线程无法进入临界区,而等待和重新调度都会产生额外成本。

4. 阻塞和唤醒为什么有成本

当线程长时间无法获得锁时,让它持续占用 CPU 并没有意义。操作系统可以暂停这个线程,把 CPU Core 让给其他能够继续工作的线程。这个过程通常称为阻塞(Block)挂起(Park)

线程被阻塞后,锁释放并不会让它立刻开始执行。系统还需要完成一系列步骤:

  1. 持锁线程退出临界区并释放锁;
  2. JVM 或操作系统发现存在等待线程;
  3. 某个等待线程被唤醒;
  4. 被唤醒的线程重新进入可运行状态;
  5. 操作系统调度器为它分配 CPU 时间;
  6. 线程再次尝试获得锁。

当线程暂时拿不到锁时,如果仍然不断尝试,就会一直占用 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的成本可以分为几个层次:

  1. 加锁和解锁需要执行额外操作;
  2. 竞争失败的线程必须等待;
  3. 等待线程可能从运行状态进入阻塞状态;
  4. 锁释放后,等待线程需要被唤醒并重新调度;
  5. 线程切换需要保存和恢复执行上下文;
  6. 切换线程可能降低 Cache 和分支预测的有效性;
  7. 临界区串行执行会限制系统的最大并发能力。

这些成本并不会在每次同步时全部出现。无竞争的短临界区可能开销很低,激烈竞争的长临界区则可能成为系统瓶颈。因此,“synchronized很慢”并不是一个准确结论,更准确的说法是:

共享状态需要互斥时,竞争程度、临界区长度和线程调度共同决定同步成本。

使用锁的首要目标仍然是保证正确性。只有在确认程序正确之后,才有意义讨论锁范围、锁粒度和线程数量。为了性能直接删除必要的同步,只会让程序重新回到竞态条件之中。

本章总结

synchronized通过互斥保证临界区完整执行,但互斥也会把竞争同一把锁的线程串行化。没有竞争时,同步成本通常较低;发生竞争后,线程可能等待、阻塞、唤醒并重新参与调度,这些过程都会增加额外开销。

本章的核心结论包括:

  • 锁的主要成本来自竞争,而不是synchronized关键字本身;
  • 线程竞争失败后不能进入临界区,严重竞争时可能进入BLOCKED状态;
  • 阻塞能够避免无效占用 CPU,但阻塞和唤醒需要调度器参与;
  • 上下文切换需要保存和恢复线程执行现场,也可能降低 Cache 利用率;
  • 临界区越长,锁持有时间越长,其他线程等待越久;
  • 锁粒度过大会让互不相关的操作被迫串行;
  • 增加线程数量不能突破串行临界区的吞吐量上限;
  • IO 等不可预测的耗时操作应谨慎放入临界区;
  • 性能优化必须建立在正确性不被破坏的前提下。

锁通过等待解决了并发修改问题,但等待并不是唯一可能的协调方式。能否让线程在竞争失败时不立即阻塞,而是根据共享状态重新尝试,将成为下一章分析的核心问题。

http://www.gsyq.cn/news/1642733.html

相关文章:

  • 解决edg v150版本后,通过cmd命令无法启动msedge.exe服务的问题
  • PCF8591与PIC18F26K80的嵌入式信号处理系统设计
  • 基于Si4731与STM32的数字收音机开发指南
  • 3步掌握AI图像控制:ComfyUI IPAdapter Plus全功能实战指南
  • Gemini Ultra与ChatGPT-4 Turbo选型实战指南:按任务类型决策
  • 3款主流OCR API对比:百度 vs 阿里云 vs 腾讯云驾驶证识别实测
  • YOLO26优化:MicroViTv2与SEAM模块提升目标检测精度
  • GPT应用开发实战:从场景设计到架构落地的完整指南
  • Matlab来绘制三维曲面图、等高线图等
  • 基于异步编程与Playwright的高效自动化任务处理与状态监控系统构建
  • 开发板通过 Ubuntu/Linux 连接外网
  • 3 种梯度计算方式对比:数值微分、符号微分与反向传播的效率分析
  • 大数据原生集群 (Hadoop2.X为核心) 本地测试环境搭建二
  • 水利枢纽三维智能监控技术解析与应用
  • MobaXterm连接RedHat服务器SSH密钥登录失败排查与配置详解
  • 医学影像异常检测:MVFA框架的零样本与少样本实践
  • ICM-42688-P与MKV44F64VLH16在工业自动化中的高性能应用
  • Spring Boot与Vue3前后端RSA加密登录实战:原理、实现与安全优化
  • 工业级传感器与执行器控制方案:基于AD74115H与STM32F765ZI
  • YOLOv12遥感目标检测:MGCM模块创新与应用
  • 洛雪音乐全网音源完全指南:从零开始打造你的个性化音乐库
  • 通义App:Qwen3大模型的终极交互载体与体验中枢
  • 如何重构现有RAG系统:模块化多模态集成技术指南
  • Redis 主从复制,哨兵,集群——(1)主从复制篇
  • SARCLIP框架:多模态预训练提升SAR图像理解
  • Steam ROM Manager:告别游戏库混乱,打造你的终极游戏收藏中心
  • 一键转换PDF、Word、Excel等数十种文档到Markdown:MarkItDown终极指南
  • Wireshark实战:从CTF流量分析到网络安全排查核心技巧
  • Windows上配置完整Linux开发环境(二):Linux发行版Anaconda安装与使用
  • docker-flask-example数据库管理:使用Flask-DB进行迁移与种子数据操作