一、synchronized 优化过程

synchronized 优化过程(根据竞争情况动态调整锁状态,过程只能升级不可反向): 

① 无锁 → ②偏向锁(Java 15+ 默认禁用偏向锁) → ③轻量级锁(自旋锁) → ④重量级锁

锁升级过程说明
状态 解释
无锁 可以不加锁时,编译器自动进入无锁状态
偏向锁 不是真加锁,而是做了标记,比加锁效率高
轻量级锁(自旋锁) 线程通过CAS自旋竞争锁,升级为轻量级锁(下面会提到cas)
重量级锁 轻量级锁自旋一定次数之后,目标不可快速获取锁时膨胀为重量级锁,由操作系统内核态的mutex实现

其他的优化操作:

锁消除(编译器+JVM判断锁是否可消除.如果可以,就直接消除)

锁粗化(⼀段逻辑中如果出现多次加锁解锁,编译器+JVM会⾃动进⾏锁的粗化)

二、常见的锁策略

        锁策略不是具体的锁,而是设计和实现锁时的思路、原则或方法;是在多线程/并发编程中,为了解决资源竞争、保证数据一致性而采用的不同设计思路和实现技术。

1.乐观锁 vs 悲观锁

悲观锁:

核心思想:假设“别人一定会修改数据”,所以拿数据前就上锁,让别人阻塞等待。

举例:数据库的行锁、表锁等 ; CAS

乐观锁:

核心思想:假设“数据一般不会冲突”,所以在数据提交更新时才检查冲突,冲突则返回⽤⼾错误的信息让,用户决定如何处理。

举例:Synchronized 初始使⽤乐观锁策略.当发现锁竞争⽐较频繁的时候,就会⾃动切换成悲观锁策略.

实现方式:

① 版本号机制(最常用):在表中增加一个 version 字段。每次更新成功后,版本号自增

② CAS (Compare And Swap):AtomicInteger 等原子类利用了底层的 CAS 指令

优点

  • 性能高:由于没有加锁和释放锁的过程,减少了上下文切换的开销。

  • 吞吐量大:在读多写少的场景下,能极大提高系统的并发能力。

2.重量级锁vs轻量级锁

重量级锁:

核心思想:依赖操作系统内核的调度机制(如mutex)。线程竞争锁失败时,会被挂起等待,进入内核态的等待队列。

举例:Mutex;Java 1.5 前synchronized是纯粹的重量级锁,但是现在优化了

场景

• 性能开销​大。涉及用户态到内核态的切换、线程上下文切换和调度,成本很高

• 适合锁持有时间长、线程竞争激烈的场景。如果锁很快被释放,频繁挂起和唤醒线程的代价会超过等待时间。

轻量级锁:

核心思想:在用户态通过循环尝试CAS等原子指令实现。线程竞争锁失败时,不会挂起,而是持续“自旋”尝试。(一直等)

举例:如CAS ; 自旋锁SpinLock;还有synchronized 开始是⼀个轻量级锁,锁冲突⽐较严重,就会变成重量级锁

场景

• 浪费CPU资源。如果锁被长时间持有,自旋线程会空转CPU,降低系统整体吞吐量。

• 性能开销​小,仅在用户态执行循环和原子操作,避免了系统调用和线程切换。

• 持有时间极短、线程竞争不激烈且多核CPU的场景。自旋等待比挂起再唤醒更快。

3.自旋锁(SpinLock)

核心思想:如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。 ⼀旦锁被其他线程释放,就能第⼀时间获取到锁。

举例:自旋锁是⼀种典型的轻量级锁的实现方式

场景

• 没有放弃CPU,不涉及线程阻塞和调度,⼀旦锁被释放,就能第⼀时间获取到锁

• 如果锁被其他线程持有的时间⽐较久,那么就会持续的消耗CPU资源(而重量级的挂起等待的时候是不消耗CPU的)

4.公平锁vs非公平锁

公平锁:

核心思想:遵守"先来后到",谁先来谁拿到被释放的锁

举例:ReentrantLock默认是非公平锁,但是可以给构造方法传入一个true开启公平锁模式

场景

• 性能开销大。需维护严格的FIFO队列

• 当保证公平性比最大吞吐量更重要时。例如,防止某个低优先级任务永远无法执行。

非公平锁:

核心思想:不遵守"先来后到",随机竞争,谁都有可能拿到被释放的锁

举例:synchronized 是⾮公平锁

场景

• 可能导致“线程饥饿”,某些线程长期得不到执行。

• 绝大多数场景。在竞争激烈时,非公平锁的性能优势非常明显,也是许多库(如Java synchronized关键字)的默认选择。

5.可重入锁vs不可重入锁

可重入锁:

核心思想:“可以重新进⼊的锁”,即允许同⼀个线程多次获取同⼀把锁。

举例:比如⼀个递归函数⾥有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重⼊锁也叫做递归锁);同一线程多次加锁,最外面的是真实锁;ReentrantLock和synchronized是可重⼊

场景

• 记录持有线程和重入计数器

• 这是现代编程语言中锁的标准行为

自己实现:

1锁内部记录是谁持有锁

2通过计数器记录当前加解锁次数,{触发计数器++;}触发计数器--,为0时解锁

不可重入锁:

核心思想:同一线程不可重复获取

举例:理论/原语上标准的Mutex是典型的不可重入锁,实践/编程层面“Mutex”类是可重入的

场景

• 通常只记录一个布尔状态(锁定/未锁定)

• 会导致自死锁,尽量避免使用

6.读写锁(readers-writerlock)

核心思想:多线程读之间不会产⽣线程安全问题,但写之间以及和读之间都需要进行互斥。

如果两种场景下都用同⼀个锁,就会产生极大的性能损耗。所以读写锁因此产⽣

举例:ReentrantReadWriteLock 类

ReentrantReadWriteLock.ReadLock 类表示⼀个读锁.这个对象提供了lock/unlock方法进行加锁解锁

ReentrantReadWriteLock.WriteLock 类表示⼀个写锁.这个对象也提供了lock/unlock方法进行加锁解锁

场景:

• 两个线程都只是读⼀个数据,此时并没有线程安全问题,直接并发的读取即可

• 两个线程都要写⼀个数据,有线程安全问题,互斥(互斥就会产生线程的挂起等待)

• ⼀个线程读另外⼀个线程写,也有线程安全问题

• 读写锁特别适合读多写少的场景中--一旦有线程持有写锁,其他所有读/写线程都必须等待

读写锁的规则矩阵

7.分布式锁

原因:

Java 原生的 synchronizedReentrantLock 只能锁住当前 JVM 进程内的线程。当多台服务器同时操作同一个全局资源(如:扣减同一个商品的库存、修改同一个用户的余额)时,就需要分布式锁

核心思想:

分布式锁的核心是一个所有服务器都能访问到的“第三方组件”,用来记录谁占有了锁

核心实现方式:

基于 Redis 实现(最常用,追求性能)

利用 Redis 的原子操作

SET lock_key unique_value NX PX 30000
  • lock_key:锁的唯一标识,表示哪个资源加锁

    业务场景 lock_key 的写法 锁定范围
    商品秒杀 seckill:stock:1001 只锁定 ID 为 1001 的手机,不影响抢购别的手机
    博客点赞 blog:like:20240512 只锁定这篇文章的点赞操作
  • unique_value:由客户端生成的唯一标识(如 UUID),表示是谁(哪个加锁请求介入这个业务)加的锁

  • NX:Only Not Exists,表示只有 key 不存在时才设置成功

  • PX 30000:自动过期时间(30秒),防止死锁

标准执行流程:

第一阶段:加锁--客户端向 Redis 发送 SET NX PX 命令

第二阶段:看门狗续期--如果业务执行时间可能超过锁的过期时间,就需要一个后台线程,隔一段时间检查业务是否完成,如果没有完成,重新延长锁的过期时间

第三阶段:解锁--先判断锁是不是自己的,业务完成后,必须释放锁

具体场景:秒杀场景扣减库存

question:

假设集群有两台服务器(A 和 B),都在处理“华为手机”的秒杀,库存只剩 1 件

用户请求:

用户 1 请求发送到 服务器 A,执行:SET lock_phone_1001 uuid_A NX PX 10000

用户 2 请求发送到 服务器 B,执行:SET lock_phone_1001 uuid_B NX PX 10000

由于 Key 已存在,Redis 返回 Nil,服务器 B 进入等待或报错

开始秒杀:

服务器 A 开始操作数据库:UPDATE stock SET num = num - 1 WHERE id = 1001

服务器 A 业务逻辑完成后,执行 Lua 脚本,检查 get lock_phone_1001 是否等于 uuid_A

匹配成功,执行 DEL lock_phone_1001

释放锁:

锁被释放,其他服务器(如 B)现在可以竞争这把锁了

三、死锁构成条件

一、锁的基本特性

1.互斥条件:资源在一段时间内只能由一个进程占用

2.不可抢占:程已获得的资源在未使用完之前,不能被抢占,只能由该进程在使用完后自己手动释放

二、去打破死锁的途径

1.请求和保持:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占用。此时请求进程阻塞,但对自己已获得的资源保持不放

2.循环等待:存在一个进程等待队列 {P1, P2, ..., Pn},其中P1在等待P2占有的资源,P2在等待P3占有的资源……而Pn正在等待P1占有的资源

四、CAS(Compare and swap)

CAS(Compare and Swap,比较并交换)

由来:【不想加锁,要提高效率】默认加锁依赖操作系统mutex,这是内核态代码的互斥锁,一旦锁请求失败,会进入阻塞状态,放弃CPU。下次执行时间不确定,所以想办法不通过操作系统来实现加锁

介绍:先比较再交换,算是CPU的一条复杂指令(CPU的指令具有原子性)。是一种乐观锁,也是一种轻量级锁 

原理: CAS 是一种原子操作,它包含三个操作数:需要更新的内存位置(V)、预期的原值(A)和新值(B)。只有当V==A时,处理器才修改V=B。下面是伪代码

public boolean compareAndSwap(int address, int expect, int swapValue) {
       
        // 模拟原子操作
        synchronized (lock) {
            int currentValue = memory[address];
            
            // 比较当前值是否等于期望值
            if (currentValue == expect) {
                // 如果相等,则设置为新值
                memory[address] = swapValue;
                return true;  // 操作成功
            } else {
                return false; // 操作失败
            }
        }
    }

过程:

 线程从主内存中读取共享变量的值V,并保存一份作为预期值A线程根据业务逻辑计算出需要更新的新值B

 判断内存中的当前值V是否等于预期值A

如果不相等,说明值已被修改,CAS 操作失败。此时线程可以选择放弃,或者自旋(重新读取值并再次尝试)。

应用场景:

· 原子类更新: Java 中的 AtomicInteger、AtomicLong 等

· 实现自旋锁(轻量级锁),当获取锁失败时,线程不会阻塞,而是通过一个循环不断地重试 CAS 操作,直到成功为止,下面是实现自旋锁伪代码

public class SpinLock {
    private Thread owner = null;

    public void lock() {
        // 通过 CAS 看当前锁是否被某个线程持有。
        // 如果这个锁已经被别的线程持有,那么就自旋等待。
        // 如果这个锁没有被别的线程持有,那么就把 owner 设为当前尝试加锁的线程。
        while (!CAS(this.owner, null, Thread.currentThread())) {
        }
    }
    public void unlock() {
        this.owner = null;
    }
}

一句话总结什么是CAS:通过一个原子操作,同时完成"读取内存、比较是否相等、修改内存"三步,本质上需要CPU指令的支撑,在代码角度它不是原子的,但在硬件层面上可以让一条指令完成操作,也就是原子操作,CAS是直接读取内存而不是寄存器

优点:在低竞争环境下,CAS 不需要阻塞线程,可以避免加锁,效率较高

缺点:ABA 问题

ABA 问题

介绍:值从 A 变为 B,又变回 A。CAS 检查时发现值没变(还是 A),就认为没被修改过,但这可能掩盖了中间发生的重要逻辑变化。

举例:此时余额为100,想转账五十块,不小心点击了两次,这两次之间有人转入了五十

// 初始:
balance = 100

// 线程1:转账扣款
balance.compareAndSet(100, 50);  // 成功,balance=50
// 线程2:他人转入
balance.compareAndSet(50, 100);  // 成功,balance=100
// 线程3:使用旧的expected值(100)尝试CAS
boolean success = balance.compareAndSet(expected, 50);  // 成功!但expected是旧值
  • 线程3在开始时读取了余额为100

  • 在它执行CAS之前,余额从100→50→100

  • 线程3的CAS仍然成功,因为当前值确实等于它之前读取的100

  • 但这中间发生了两次交易,线程3的CAS不应该成功

解决:引入版本号,每次修改版本号+1,CAS 比较当前的值和旧值的同时也要比较版本号是否符合预期

若当前版本号与之前读的一致,就执行修改操作,并版本号自增,若当前版本号大于之前读到的,就认为操作失败

版本号

介绍:版本号只增不减,是一种广泛的概念。比如可以使用时间戳来实现版本号,因为时间是只增不减的

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐