Java【多线程】(7)常见的锁策略
目录
1.前言
2.正文
2.1悲观锁和乐观锁
2.2重量级锁和轻量级锁
2.3挂起等待锁和自旋锁
2.4互斥锁与读写锁
2.5可重入锁与不可重入锁
2.6公平锁与不公平锁
2.7synchronized优化
2.7.1锁升级
2.7.2锁消除
2.7.3锁粗化
3.小结
1.前言
哈喽大家好,今天来给大家分享Java多线程中常见的锁策略,锁策略不是和Java强相关,但凡涉及到并发编程涉及到锁都会涉及锁策略,概念较多但都很重要,废话不多说让我们开始吧。
2.正文
首先在这里声明一点:以下讲的各种锁,不是针对某一种具体的锁,而是某个具体锁具有“悲观"特性或者“乐观”等特性~~
2.1悲观锁和乐观锁
悲观锁:
定义:假设并发冲突一定会发生,因此在操作数据前先加锁,确保同一时刻只有一个线程能访问资源。
特点:强一致性,但性能开销大。
乐观锁:
定义:假设并发冲突很少发生,操作数据时不加锁,只在提交修改时检查是否被其他线程修改过。
特点:高性能,但可能需重试。
悲观锁的现实场景:
你去银行取钱,柜员会说:"稍等,我先锁上保险箱再给你拿钱。"
为什么?银行默认你会和别人争抢取钱这个操作,必须锁住资源。
乐观锁的现实场景:
你和同事改同一份Docs,直接编辑,保存时系统提示:"有冲突,请解决。"
为什么?默认你们不会同时改同一段落,冲突了再处理。
根本区别在于对接下来锁竞争的是否激烈。
对比维度 悲观锁 乐观锁 默认态度 "肯定会有人抢,先锁再说!" "应该没人抢,冲突了再说~" 实现方式 synchronized
、ReentrantLock
版本号、CAS(如 AtomicInteger
)性能开销 高(上下文切换、阻塞) 低(无阻塞,可能重试) 适用场景 写多读少(如支付、转账) 读多写少(如商品库存、点赞计数) 失败处理 线程阻塞等待 回滚或自动重试
2.2重量级锁和轻量级锁
重量级锁:
定义:依赖操作系统内核的互斥量(Mutex Lock)实现的锁机制,线程竞争时会直接进入阻塞状态,由操作系统负责线程调度。
特点:功能完备(支持公平性、超时等),但性能开销大(涉及用户态到内核态的切换)。
轻量级锁:
定义:基于CAS(Compare-And-Swap)自旋实现的锁机制,线程通过循环尝试获取锁,避免直接进入阻塞状态。
特点:性能高(无系统调用),但长时间自旋会浪费CPU资源。
重量级锁的现实场景:
你去医院挂号,发现窗口排队的人很多,直接去休息区睡觉(线程阻塞),等护士叫号(操作系统唤醒)。
为什么?因为你知道要等很久,不如让出资源。
轻量级锁的现实场景:
你在便利店排队结账,发现前面只有一个人,于是站在原地不停张望(自旋),等对方结束立马抢位置。
为什么?因为等待时间短,不值得去找座位。
- 重量级锁,当悲观的场景下,此时就要付出更多的代价。(更低效)
- 轻量级锁,应对乐观的场景,此时付出的代价就会更小。(更高效)
对比维度 重量级锁 轻量级锁 实现原理 通过操作系统内核的互斥量(Mutex) 用户态的CAS自旋(如 AtomicInteger
)线程状态 阻塞(挂起) 运行中(自旋等待) 性能开销 高(上下文切换约1-10μs) 低(自旋耗时约0.1-1ns/次) 适用场景 高竞争、长临界区 低竞争、短临界区 失败处理 线程进入等待队列 继续自旋或升级为重量级锁
2.3挂起等待锁和自旋锁
挂起等待锁就是重量级锁的典型实现,而自旋锁就是轻量级锁的典型实现。
挂起等待锁(阻塞锁):
定义:当线程获取锁失败时,立即释放CPU资源,进入阻塞状态(挂起),等待被唤醒
核心机制:依赖操作系统调度,涉及线程上下文切换
典型实现:Java的
synchronized
在重量级锁状态、ReentrantLock.lock()
自旋锁:
定义:当线程获取锁失败时,不放弃CPU,而是循环重试(自旋),直到成功获取锁
核心机制:通过CPU空转(忙等待)避免上下文切换
典型实现:Java的
AtomicInteger
CAS操作、ReentrantLock.tryLock()
自旋版本(以后会详细讲解Java中的CAS)
挂起等待锁场景:
你去热门餐厅取号,服务员说:"现在没位,去旁边商场逛2小时再回来"(线程挂起)
为什么合理:等待时间长时,干等着(自旋)反而浪费精力
自旋锁场景:
你在便利店排队,收银员说:"稍等1分钟马上好",你选择站着玩手机等待(自旋)
为什么合理:短暂等待时,来回走动(上下文切换)更耗能
对比维度 挂起等待锁 自旋锁 等待机制 立即释放CPU进入阻塞状态 保持CPU占用循环检测 系统开销 高(上下文切换约1-10μs) 低(但浪费CPU周期) 实现复杂度 高(需OS支持线程调度) 低(CAS即可实现) 适用场景 锁持有时间长(>1ms) 锁持有时间短(<1μs) 线程状态 BLOCKED/WATING RUNNABLE 公平性 通常可实现公平 通常是非公平的 典型应用 数据库事务、文件IO 计数器、状态标志
2.4互斥锁与读写锁
分析下这个读写锁:多个线程读取一个数据,是本身就线程安全的。多个线程读取,一个线程修改,肯定会涉及到线程安全问题。如果你把读和写都加上普通的互斥锁,意味着锁冲突将会非常严重,读锁和读锁之间不互斥,读锁和写锁互斥,写锁和写锁之间也互斥。于是乎读写所的存在,保证线程安全的前提下,降低锁冲突概率提高效率。
互斥锁(Mutex Lock):
定义:独占锁,同一时刻只允许一个线程访问共享资源
特点:强排他性,读/写操作同等对待
典型实现:Java的
synchronized
、ReentrantLock
读写锁(ReadWrite Lock):
定义:分离锁,将读操作和写操作区别对待
特点:允许多个读线程并发,写线程独占
典型实现:Java的
ReentrantReadWriteLock
互斥锁场景:
图书馆自习室规则:"每次只允许一人进入,无论你是看书(读)还是做笔记(写)"
结果:即使多人只想看书,也得排队轮流进
读写锁场景:
改进后的规则:"看书的人可以一起进,但做笔记的人必须单独使用房间"
结果:读书效率提升,写作时仍保证独占
对比维度 互斥锁 读写锁 并发粒度 完全互斥 读读并发,读写/写写互斥 吞吐量 低(所有操作串行) 高(读操作可并行) 实现复杂度 简单 复杂(需维护读/写状态) 适用场景 读写操作耗时相近 读多写少(≥5:1) 线程饥饿风险 无 写线程可能被读线程长期阻塞 锁升级 不支持 读锁不能升级为写锁(会死锁) 公平性 可公平/非公平 可公平/非公平
2.5可重入锁与不可重入锁
之前文章中讲解过这里不过多展开了喔~简单总结~
可重入锁(Reentrant Lock):
定义:同一个线程可以多次获取同一把锁,锁会维护一个持有计数(hold count)
关键特性:防止线程自己造成死锁
典型实现:Java的
synchronized
、ReentrantLock
不可重入锁(Non-reentrant Lock):
定义:线程获取锁后,再次尝试获取会立即阻塞/失败
关键特性:严格线性获取锁
典型实现:早期的简单锁实现、某些特定场景的自旋锁
可重入锁场景:
你家大门装了智能锁,你(线程)进入时:
- 第一次进门:验证指纹(获取锁)
- 进卧室时:不再验证(重入计数+1)
- 离开卧室:不真正锁门(计数-1)
- 最终出门:才真正上锁(计数归零)
不可重入锁场景:
老式钥匙锁的尴尬情况:
- 你进门后锁上门(获取锁)
- 想进里屋时发现需要钥匙(再次获取锁)
- 钥匙插在外门锁上(死锁形成)
- 最终困在门厅里(线程阻塞)
对比维度 可重入锁 不可重入锁 死锁预防 避免同一线程自我死锁 可能因递归调用导致自我死锁 实现复杂度 高(需维护线程ID和计数) 低(只需布尔状态) 性能开销 略高(计数器操作) 略低 适用场景 递归调用、回调函数 简单线性流程 锁释放 必须完全释放(计数归零) 单次解锁即释放 典型应用 Java同步机制、数据库事务 某些内核锁、特殊优化场景
2.6公平锁与不公平锁
公平锁(Fair Lock):
定义:按照线程请求锁的先后顺序分配锁,遵循FIFO(先进先出)原则
特点:避免线程饥饿,保证公平性
实现方式:通过队列维护等待线程(如
ReentrantLock(true)
)非公平锁(Non-fair Lock):
定义:允许线程插队获取锁,不保证请求顺序
特点:吞吐量高,但可能导致线程饥饿
实现方式:直接尝试CAS获取锁(如
synchronized
和ReentrantLock()
默认模式)
公平锁场景:
银行VIP窗口叫号系统:"请A001号到3号窗口"(严格按取号顺序服务)
优点:先来的人一定能先办业务
缺点:即使窗口空闲,也必须叫号
非公平锁场景:
地铁早高峰排队:"车门一开,所有人挤着上车"(谁快谁上)
优点:车厢利用率高(减少空置时间)
缺点:可能有人永远挤不上去
对比维度 公平锁 非公平锁 排队机制 严格FIFO 允许插队(新线程可直接竞争) 吞吐量 较低(约降低30%) 较高 线程饥饿 不会发生 可能发生 实现复杂度 高(需维护等待队列) 低(直接CAS) 响应时间 稳定但较长 不稳定(可能极快或极慢) 适用场景 交易系统、计费系统 高并发缓存、计数器 JVM实现 ReentrantLock(true)
synchronized
、ReentrantLock()
公平锁:
public class FairLockDemo { private static final ReentrantLock lock = new ReentrantLock(true); // 公平模式 public static void main(String[] args) { for (int i = 0; i < 3; i++) { new Thread(() -> { lock.lock(); try { System.out.println(Thread.currentThread().getName() + "获取锁"); } finally { lock.unlock(); } }, "Thread-" + i).start(); } } } // 输出保证按线程启动顺序获取锁
非公平锁:
public class NonFairDemo { private static final ReentrantLock lock = new ReentrantLock(); // 默认非公平 public static void main(String[] args) { // 先让主线程持有锁 lock.lock(); new Thread(() -> { System.out.println("子线程尝试获取锁"); lock.lock(); // 这里会插队! System.out.println("子线程获取成功"); lock.unlock(); }).start(); Thread.sleep(100); // 确保子线程启动 System.out.println("主线程释放锁"); lock.unlock(); // 释放后子线程可能抢到,即使有其他等待线程 } }
2.7synchronized优化
2.7.1锁升级
JVM根据竞争情况,动态调整synchronized
的锁状态,从低开销到高开销逐步升级,避免一刀切使用重量级锁。 JVM没有提供锁降级。
graph LR A[无锁] -->|首次获取| B[偏向锁] B -->|有竞争| C[轻量级锁] C -->|竞争加剧| D[重量级锁]
偏向锁(Biased Locking)
场景:单线程反复访问同步块
原理:在对象头记录线程ID(无需CAS)
轻量级锁(Thin Lock)
场景:多线程交替执行(无真正竞争)
原理:
栈帧中创建Lock Record
通过CAS将对象头指向Lock Record
重量级锁(Heavyweight Lock)
场景:高并发竞争
原理:通过
ObjectMonitor
实现开销:上下文切换约1-10μs
补充以下何为偏向锁:
刚一上来,不是真加锁, 而是只是简单做一个标记,进行synchronized。这个标记,非常轻量, 相比于加锁解锁来说,效率高很多~如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上标记即可~~(不涉及真加锁,真解锁)如果有其他线程来竞争,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁真假锁了,偏向锁 =>轻量级锁,其他线程只能阻塞等待。
2.7.2锁消除
这也是编译器优化的一种体现。
概念:编译器会判定,当前这个代码逻辑是否真的需要加锁,如果确实不需要加锁,但是你写了 synchronized,,就会自动把synchronized给去掉,像一些判定不清楚的情况,不会触发锁消除。
如果到处有synchronized,意味着优化机制,只能把其中一部分,他能明确判定的给优化掉,还会有很多不应该使用, 但是编译器也优化不调。
2.7.3锁粗化
概念:
将相邻的多个细粒度锁合并为单个大锁,减少锁申请/释放开销。(加锁和解锁之间,包含的代码越多,就认为锁的粒度就越粗)触发场景:
// 原始代码 for (int i = 0; i < 100; i++) { synchronized(obj) { // 每次循环都加锁 doSomething(); } } // 优化后等效代码 synchronized(obj) { // 合并为单个锁 for (int i = 0; i < 100; i++) { doSomething(); } }
以上三种做个总结:
优化手段 | 适用场景 | 性能提升幅度 | 实现层级 |
---|---|---|---|
锁升级 | 所有synchronized 场景 | 10-100倍 | JVM运行时 |
锁消除 | 线程私有对象 | 2-5倍 | JIT编译期 |
锁粗化 | 密集短锁操作 | 1.5-3倍 | 字节码优化阶段 |
3.小结
今天的分享到这里就结束了,喜欢的小伙伴点点赞点点关注,你的支持就是对我最大的鼓励,大家加油!