Java【多线程】(7)常见的锁策略

Java【多线程】(7)常见的锁策略_第1张图片


目录

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,直接编辑,保存时系统提示:"有冲突,请解决。"

  • 为什么?默认你们不会同时改同一段落,冲突了再处理。

根本区别在于对接下来锁竞争的是否激烈。


对比维度 悲观锁 乐观锁
默认态度 "肯定会有人抢,先锁再说!" "应该没人抢,冲突了再说~"
实现方式 synchronizedReentrantLock 版本号、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的AtomicIntegerCAS操作、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的synchronizedReentrantLock

读写锁(ReadWrite Lock)

  • 定义:分离锁,将读操作和写操作区别对待

  • 特点:允许多个读线程并发,写线程独占

  • 典型实现:Java的ReentrantReadWriteLock


互斥锁场景

  • 图书馆自习室规则:"每次只允许一人进入,无论你是看书(读)还是做笔记(写)"

  • 结果:即使多人只想看书,也得排队轮流进

读写锁场景

  • 改进后的规则:"看书的人可以一起进,但做笔记的人必须单独使用房间"

  • 结果:读书效率提升,写作时仍保证独占


对比维度 互斥锁 读写锁
并发粒度 完全互斥 读读并发,读写/写写互斥
吞吐量 低(所有操作串行) 高(读操作可并行)
实现复杂度 简单 复杂(需维护读/写状态)
适用场景 读写操作耗时相近 读多写少(≥5:1)
线程饥饿风险 写线程可能被读线程长期阻塞
锁升级 不支持 读锁不能升级为写锁(会死锁)
公平性 可公平/非公平 可公平/非公平

2.5可重入锁与不可重入锁

之前文章中讲解过这里不过多展开了喔~简单总结~


可重入锁(Reentrant Lock)

  • 定义:同一个线程可以多次获取同一把锁,锁会维护一个持有计数(hold count)

  • 关键特性:防止线程自己造成死锁

  • 典型实现:Java的synchronizedReentrantLock

不可重入锁(Non-reentrant Lock)

  • 定义:线程获取锁后,再次尝试获取会立即阻塞/失败

  • 关键特性:严格线性获取锁

  • 典型实现:早期的简单锁实现、某些特定场景的自旋锁


可重入锁场景

  • 你家大门装了智能锁,你(线程)进入时:

  1. 第一次进门:验证指纹(获取锁)
  2. 进卧室时:不再验证(重入计数+1)
  3. 离开卧室:不真正锁门(计数-1)
  4. 最终出门:才真正上锁(计数归零)

不可重入锁场景

  • 老式钥匙锁的尴尬情况:

  1. 你进门后锁上门(获取锁)
  2. 想进里屋时发现需要钥匙(再次获取锁)
  3. 钥匙插在外门锁上(死锁形成)
  4. 最终困在门厅里(线程阻塞)

对比维度 可重入锁 不可重入锁
死锁预防 避免同一线程自我死锁 可能因递归调用导致自我死锁
实现复杂度 高(需维护线程ID和计数) 低(只需布尔状态)
性能开销 略高(计数器操作) 略低
适用场景 递归调用、回调函数 简单线性流程
锁释放 必须完全释放(计数归零) 单次解锁即释放
典型应用 Java同步机制、数据库事务 某些内核锁、特殊优化场景

2.6公平锁与不公平锁

公平锁(Fair Lock)

  • 定义:按照线程请求锁的先后顺序分配锁,遵循FIFO(先进先出)原则

  • 特点:避免线程饥饿,保证公平性

  • 实现方式:通过队列维护等待线程(如ReentrantLock(true)

非公平锁(Non-fair Lock)

  • 定义:允许线程插队获取锁,不保证请求顺序

  • 特点:吞吐量高,但可能导致线程饥饿

  • 实现方式:直接尝试CAS获取锁(如synchronizedReentrantLock()默认模式)


公平锁场景

  • 银行VIP窗口叫号系统:"请A001号到3号窗口"(严格按取号顺序服务)

  • 优点:先来的人一定能先办业务

  • 缺点:即使窗口空闲,也必须叫号

非公平锁场景

  • 地铁早高峰排队:"车门一开,所有人挤着上车"(谁快谁上)

  • 优点:车厢利用率高(减少空置时间)

  • 缺点:可能有人永远挤不上去


对比维度 公平锁 非公平锁
排队机制 严格FIFO 允许插队(新线程可直接竞争)
吞吐量 较低(约降低30%) 较高
线程饥饿 不会发生 可能发生
实现复杂度 高(需维护等待队列) 低(直接CAS)
响应时间 稳定但较长 不稳定(可能极快或极慢)
适用场景 交易系统、计费系统 高并发缓存、计数器
JVM实现 ReentrantLock(true) synchronizedReentrantLock()

公平锁:

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[重量级锁]
  1. 偏向锁(Biased Locking)

    • 场景:单线程反复访问同步块

    • 原理:在对象头记录线程ID(无需CAS)

  2. 轻量级锁(Thin Lock)

    • 场景:多线程交替执行(无真正竞争)

    • 原理

      • 栈帧中创建Lock Record

      • 通过CAS将对象头指向Lock Record

  3. 重量级锁(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.小结

今天的分享到这里就结束了,喜欢的小伙伴点点赞点点关注,你的支持就是对我最大的鼓励,大家加油!

你可能感兴趣的:(java,开发语言,intellij-idea)