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

Java多线程进阶-从乐观锁到读写锁

文章目录

  • 1. Java多线程进阶-从乐观锁到读写锁
    • 前言
    • 核心锁策略:并发编程的六大思想
      • 1. 乐观锁 vs 悲观锁:两种截然不同的并发控制哲学
      • 2. 重量级锁 vs 轻量级锁:性能与系统开销的权衡
      • 3. 自旋锁:一种“永不放弃”的轻量级锁实现
      • 4. 公平锁 vs 非公平锁:排队还是自由竞争?
      • 5. 可重入锁:避免“自己把自己锁死”
      • 6. 读写锁:为“读多写少”场景量身定制的性能优化
    • 本篇核心要点总结 (Key Takeaways)

1. Java多线程进阶-从乐观锁到读写锁

前言

在对 Java 多线程有了基础的了解后,我们知道如何创建线程,也懂得使用 synchronized 保证基本同步。但这,仅仅是并发编程的序章。

这篇学习笔记旨在深入多线程的核心,我们将从锁策略这一基础出发,系统性地梳理现代并发编程的指导思想。

这些内容,常被归纳为需要反复背诵的“八股文”。然而,真正的目标并非记忆,而是理解。透彻理解这些核心概念,是区分“代码牛马”与“专业懂哥”的关键。它能赋予我们编写更安全、更高性能代码的能力,以及在复杂并发场景下洞察问题本质的信心。

这趟旅程将帮助我们完成从“会用”到“理解”的转变。后续,我们还会继续深入 synchronized 的锁升级、ReentrantLockCAS 等主题,将理论与实践相结合。


核心锁策略:并发编程的六大思想

接下来要探讨的这六组概念,我认为是理解所有并发锁的“内功心法”。它们不仅是锁实现者在设计时需要权衡的维度,更是我们作为开发者,在选择和使用锁时做出正确决策的理论依据。理解它们,是写出高性能并发代码的关键一步。

1. 乐观锁 vs 悲观锁:两种截然不同的并发控制哲学

这是我们面对并发冲突时,两种最基本的思维模型,它从根本上决定了我们处理共享资源的方式。

  • 悲观锁 (Pessimistic Locking)
    它总是抱着最坏的打算,坚定地认为“只要我不锁,数据就一定会被别人改掉”。因此,在访问任何共享资源前,它都会先牢牢地加上锁,确保自己独占资源。任何其他想访问该资源的线程,都会被无情地阻塞在门外,直到锁被释放。Java中的 synchronizedReentrantLock 就是悲观锁最经典的代表。

    这里我的理解是,悲观锁的“悲观”体现在,它预判接下来的锁竞争会非常激烈,因此宁愿提前付出加锁和线程阻塞的成本,来确保数据的绝对安全。

  • 乐观锁 (Optimistic Locking)
    它的心态则完全相反,它相信“世界是美好的,线程之间通常不会相互干扰”。因此,它在访问数据时并不会加锁,而是等到要提交更新的时候,才去检查一下数据在此期间有没有被别的线程修改过。如果发现数据已经被改了,这次操作就宣告失败,然后由调用方自己决定是重试、报错还是干点别的。CAS(Compare-and-Swap)机制是乐观锁最核心的实现思想。

    我认为,乐观锁的“乐观”在于,它预测锁竞争的概率不大,所以它选择性地“忽略”了加锁的开销,把冲突检测这个动作后置了,以期望获得更高的吞吐量。

为了更好地理解这两种思维方式,我们可以设想一个“向老师请教问题”的场景:

  • 同学A(悲观锁思维):“老师肯定很忙,我得先确认他有空才行。” 于是,他会先给老师发消息:“老师,您下午两点有空吗?我想问个问题。” 这就相当于“加锁”。得到老师的肯定答复后,他才会过去。如果老师说没空,他就只能等待,下次再约。
  • 同学B(乐观锁思维):“老师应该不忙吧,我直接去办公室看看。” 于是,他直接就去了(没有预先“加锁”)。如果老师正好有空,问题立刻解决,效率极高。如果老师当时正忙,同学B也不会硬闯,而是选择先离开,下次再来。这个“发现老师正忙”的过程,就相当于冲突检测。

这两种策略没有绝对的优劣,选择哪种取决于“战况”:

  • 写多读少,竞争激烈:如果数据争用非常频繁(老师的办公室门口总是排着长队),那么悲观锁是更明智的选择。因为如果使用乐观锁,会导致大量的失败和重试(同学B一次次白跑),反而浪费了更多时间。
  • 读多写少,竞争稀疏:如果数据很少被修改(老师大部分时间都比较清闲),那么乐观锁的优势就体现出来了。它避免了不必要的加锁开销,让整个系统的运行效率更高。

一个有意思的点synchronized 在现代JVM中其实非常智能。它并非一个“死板”的悲观锁,而是会根据战况动态调整策略。一开始,它会尝试使用乐观锁(偏向锁、轻量级锁),但当它发现锁的竞争开始变得激烈时,就会自动“升级”为一个标准的悲观锁(重量级锁)。这个“锁升级”的过程非常值得深入研究,我计划在后续笔记中专门探讨它。

2. 重量级锁 vs 轻量级锁:性能与系统开销的权衡

这两个概念通常与悲观锁和乐观锁的实现方式紧密相关,描述的是加锁这个动作需要付出的“代价”。

  • 重量级锁 (Heavyweight Lock): 通常是悲观锁的实现方式,为高竞争场景而设计。它的实现严重依赖操作系统提供的同步工具(比如 Linux 的 mutex)。这意味着,每次加锁和解锁,都可能导致线程在“用户态”和“内核态”之间来回切换,甚至引发线程的挂起和唤醒,性能开销非常大,因此称之为“重量级”。
  • 轻量级锁 (Lightweight Lock): 通常是乐观锁的实现方式,为低竞争场景而设计。它的实现会尽可能在用户态完成所有操作(例如通过CAS自旋),极力避免去“麻烦”操作系统。因为开销小,所以称之为“轻量级”。

要理解它们的开销差异,我们必须先搞清楚“用户态”与“内核态”的切换为什么这么耗时。

可以这样类比一下:

我们的应用程序就像一个公司,而操作系统内核就像是工商局。

  • 用户态:就是公司内部能自己搞定的事情,比如部门开会、整理文件。这些都在公司内部流转,效率高,成本可控。
  • 内核态:就是公司必须去工商局才能办的事情,比如公司注册、变更法人。这需要我们准备一大堆材料,去窗口排队,由工作人员审核办理。这个过程我们无法控制,耗时且流程复杂。

重量级锁的加锁过程,就好比公司每签一份合同,都要先去工商局备案、盖章、再拿回来,效率可想而知。而轻量级锁则主张,能公司内部解决的,就别去麻烦工商局。

在这里插入图片描述

synchronized 的锁升级机制,就完美地体现了这种对成本的权衡:它默认是一个轻量级锁,自己能搞定就自己搞定。如果发现竞争太激烈,自己搞不定了,就会“升级”成一个重量级锁,去向操作系统“求助”。

3. 自旋锁:一种“永不放弃”的轻量级锁实现

自旋锁(Spin Lock)是轻量级锁的一种非常典型的实现策略。当一个线程尝试加锁但发现锁已经被占有时,它不会立即“心灰യി冷”地进入阻塞状态(放弃CPU),而是会进入一种“忙等”(Spinning)的状态,就像原地打转一样,不断地、循环地尝试获取锁。

// 自旋锁的伪代码,直观地展现了它的“执着”
while (抢锁(lock) == 失败) {// 不放弃CPU,继续循环尝试,直到成功
}

传统的重量级锁,线程抢锁失败后会立刻被挂起,这需要操作系统介入,开销很大。但现实中,很多锁的持有时间都非常短,可能就在几个CPU周期之内。如果为了这么短的时间就去麻烦操作系统,实在有点小题大做。自旋锁正是为了优化这种“短时持有”的场景。

一个有趣的对比:

  • 挂起等待锁 (重量级锁的实现): 就像一个痴情的追求者,向心仪的女神表白后,被告知“你是个好人,但我有男朋友了”。他听后万念俱灰,从此不问世事,进入了“阻塞”状态,等待内核(月老)的唤醒。过了很久很久,女神突然发来消息:“我们试试?” 他才被重新“唤醒”。在这漫长的等待中,他完全放弃了主动权。
  • 自旋锁 (轻量级锁的实现): 则像一个“脸皮厚”的追求者。被拒绝后,他并不气馁,而是每天坚持在女神楼下转悠(自旋),时不时发个消息问候一下。一旦女神与前任分手(锁被释放),他能立刻抓住机会,成功上位的概率就大得多。

自旋锁的优缺点因此也变得非常鲜明:

  • 优点: 响应速度极快。因为它没有放弃CPU,所以一旦锁被释放,它能第一时间抢到锁,避免了线程切换的开销。
  • 缺点: 如果锁被其他线程长期占用,那么自旋的线程就会持续地空转,白白浪费CPU资源。

synchronized 中的轻量级锁,其底层就是通过一种“自适应”的自旋锁来实现的。JVM会智能地判断自旋的成功率,如果自旋经常成功,就多转一会儿;如果自旋经常失败,就少转一会儿,甚至直接升级为重量级锁。

4. 公平锁 vs 非公平锁:排队还是自由竞争?

假设现在有A、B、C三个线程。A线程首先获取了锁。紧接着,B线程前来尝试获取,失败后进入了等待队列。随后,C线程也来了,同样失败并进入等待队列。现在,当A线程释放锁的那一刻,会发生什么呢?

  • 公平锁 (Fair Lock): 严格遵守“先来后到”的原则。因为B比C先进入等待队列,所以当A释放锁后,锁会毫无疑问地交给B。ReentrantLock 可以通过构造函数 new ReentrantLock(true) 来创建一把公平锁。
  • 非公平锁 (Non-fair Lock): 不保证先来后到,信奉“自由竞争”。当A释放锁时,系统会唤醒等待队列中的线程(比如B和C),但与此同时,一个刚刚跑来的新线程D,也可能直接参与竞争。最终谁能抢到锁,全凭本事(和运气)。synchronizedReentrantLock 在默认情况下都是非公平锁。

在这里插入图片描述

延伸一下:为什么非公平锁的性能通常要优于公平锁?

这似乎有点反直觉,但原因在于:实现公平锁需要维护一个等待队列,并进行额外的同步操作来保证顺序,这本身就有开销。更重要的是,当一个线程释放锁时,如果采用非公平策略,新来的线程可以直接尝试获取锁,如果成功,就省去了一次“唤醒-阻塞”的线程切换过程。而非公平锁能够更充分地利用CPU时间片,减少线程上下文切换,从而提高整体的吞吐量。因此,除非业务上真的有严格的“先来后到”的需求,否则非公平锁是更高效的选择。

另外,需要明确的是,synchronized 只支持非公平锁。

5. 可重入锁:避免“自己把自己锁死”

可重入锁(Reentrant Lock),也叫递归锁,它的核心特性是:允许同一个线程多次获取同一把锁

我们可以想象一个如果没有“可重入”特性,会发生多么可怕的场景。假设我们用的是一把不可重入锁

// 这是一个不可重入锁
lock(); // 线程T第一次加锁, 成功
// ... do something ...
lock(); // 线程T第二次尝试加锁, 发现锁已被占用(虽然是自己占用的), 于是进入阻塞等待

此时,线程T自己把自己给锁死了!因为它在等待一把永远不会被释放的锁——因为释放锁的操作(unlock())也需要它自己来执行,而它已经陷入了无限的等待。这种情况就是一种典型的死锁

在这里插入图片描述

可重入锁的出现,就是为了解决这个问题。它在递归调用、同一个类中方法相互调用等场景下,是必不可少的。

那么,一个可重入锁是如何实现的呢?

要实现“可重入”的特性,锁的内部至少需要维护两个关键信息:

  1. 一个变量,记录当前持有该锁的线程 (Owner Thread)
  2. 一个计数器,记录该线程重入的次数 (Recursion Count)

当一个线程请求锁时,锁的内部逻辑大致如下:

  • 首先检查锁是否空闲。如果空闲,就把 owner 设置为当前线程,并将计数器置为1。
  • 如果锁已被占用,就检查请求锁的线程是否就是当前的 owner。
    • 如果是,说明发生了重入,只需将计数器加一,然后直接允许访问。
    • 如果不是,那么该线程就需要等待。
  • 当线程退出同步代码块(执行 unlock())时,计数器会减一。只有当计数器减到零时,锁才会被真正释放(owner 置为 null),这样其他线程才有机会获取它。

在Java中,我们最常用的 synchronizedReentrantLock 都被设计成了可重入锁。

6. 读写锁:为“读多写少”场景量身定制的性能优化

在多线程环境中,我们对共享数据的访问,无外乎两种操作:读数据和写数据。仔细分析,我们会发现:

  • 两个线程都只是一个数据,此时并不会产生线程安全问题。
  • 两个线程都要一个数据,这显然有线程安全问题。
  • 一个线程,另一个线程,这同样有线程安全问题。

如果我们不加区分,在所有场景下都用一把互斥锁(比如 synchronizedReentrantLock),就会导致在“读-读”这种本可以并行进行的场景下,也强制线程串行访问,造成了不必要的性能浪费。读写锁(Readers-Writer Lock) 正是为了解决这个问题而生的。

读写锁将“读”和“写”两种操作区别对待,其核心规则非常精妙:

  • 读锁与读锁之间,不互斥:允许多个读操作并发执行,大家可以一起读。
  • 写锁与写锁之间,互斥:同一时间只允许一个写操作。
  • 读锁与写锁之间,互斥:当有线程在读取时,写操作需要等待;当有线程在写入时,读操作也需要等待。

读写锁的核心思想,就是通过将一把大锁细化为两把功能性的小锁(读锁和写锁),最大限度地将串行操作转变为并行操作,从而显著提高系统的并发能力。

Java 标准库提供了 ReentrantReadWriteLock 类来实现读写锁。它特别适合于那些“频繁读,不频繁写”的业务场景。

一个绝佳的类比:教务系统

教务系统就是一个典型的“读多写少”应用。

  • 读操作(高频):成千上万的学生随时要查成绩、看课表;老师每节课都要点名。这些操作的并发量非常大。
  • 写操作(低频):只有当有新生入学、有同学退学、或者老师在期末集中录入成绩时,才需要修改数据。这些操作的频率相对低得多。

在这种场景下,如果使用读写锁,就可以让所有“查询”操作并行执行,极大地提升系统的并发读取性能,而不会因为某个同学在查成绩就影响到其他人。

需要注意的是,synchronized 关键字本身并不支持读写分离,它是一把纯粹的互斥锁。


本篇核心要点总结 (Key Takeaways)

  • 锁的哲学与权衡:并发控制的核心在于对“冲突可能性”的预判。悲观锁(如synchronized)假定冲突频繁,适合写多读少的场景;乐观锁(如CAS)假定冲突稀疏,适合读多写少的场景。
  • 锁的成本与实现:锁的性能开销主要源于与操作系统的交互。重量级锁涉及内核态切换,成本高;轻量级锁(如自旋锁)在用户态完成,成本低但消耗CPU。synchronized能在这两者间智能升级。
  • 锁的公平与效率公平锁保证先来后到,但有额外开销;非公平锁允许“插队”,能减少线程切换,吞吐量更高,是synchronizedReentrantLock的默认选择。
  • 锁的专业化设计可重入锁解决了递归调用中的死锁问题;读写锁通过分离读写操作,极大提升了“读多写少”场景下的并发性能。
    平与效率**:公平锁保证先来后到,但有额外开销;非公平锁允许“插队”,能减少线程切换,吞吐量更高,是synchronizedReentrantLock的默认选择。
  • 锁的专业化设计可重入锁解决了递归调用中的死锁问题;读写锁通过分离读写操作,极大提升了“读多写少”场景下的并发性能。
http://www.lryc.cn/news/620219.html

相关文章:

  • 西门子TIA-SCL转STL指令项目案例及技巧
  • 【Python】Python 函数基本介绍(详细版)​
  • ARM 实操 流水灯 按键控制 day53
  • ACL 可以限制哪些流量?入方向和出方向怎么判断?
  • vue路由_router
  • rk3588 ubuntu20.04安装包经常出现的问题总结(chatgpt回复)
  • C++ 优选算法 力扣 209.长度最小的子数组 滑动窗口 (同向双指针)优化 每日一题 详细题解
  • VUE基础笔记
  • 计算机网络---IPv6
  • 向长波红外成像图注入非均匀噪声
  • ROS2实用工具
  • 小电视视频内容获取GUI工具
  • Ansible 实操笔记:Playbook 与变量管理
  • 传输层协议 TCP(1)
  • C语言队列的实现
  • 浪浪山小妖怪电影
  • HarmonyOS 开发实战:搞定应用名字与图标更换,全流程可运行示例
  • 《卷积神经网络(CNN):解锁视觉与多模态任务的深度学习核心》
  • 从 VLA 到 VLM:低延迟RTSP|RTMP视频链路在多模态AI中的核心角色与工程实现
  • AI驱动的前端革命:10项颠覆性技术如何在LibreChat中融为一体
  • Java19 Integer 位操作精解:compress与expand《Hacker‘s Delight》(第二版,7.4节)
  • Docker部署RAGFlow:开启Kibana查询ES数据指南
  • 学习嵌入式的第十九天——Linux——文件编程
  • 如何生成.patch?
  • 开发Excel Add-in的心得笔记
  • Redis ubuntu下载Redis的C++客户端
  • 3分钟 Spring AI 实现对话功能
  • 二次筛法Quadratic Sieve因子分解法----C语言实现
  • 【MCP开发】Nodejs+Typescript+pnpm+Studio搭建Mcp服务
  • 每日五个pyecharts可视化图表-line:从入门到精通 (5)