1. 常见锁策略

用锁前需了解锁

1.1 乐观锁vs悲观锁

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别⼈会修改,所以每次在拿数据的时候都会上锁, 这样别⼈想拿这个数据就会阻塞直到它拿到锁。其他线程想要访问,就阻塞等待,从根源上防止冲突。

如:synchronized、ReentrantLock等。

乐观锁:假设数据⼀般情况下不会产⽣并发冲突,所以在数据进⾏提交更新的时候,才会正式对数据是否产⽣ 并发冲突进⾏检测,如果发现并发冲突了,则让返回⽤⼾错误的信息,让用户决定如何去做。

如:CAS、数据库版本号机制。

1.2 重量级锁vs轻量级锁

重量级锁:依赖操作系统互斥锁(mutex),竞争失败线程直接阻塞。轻量级锁下线程自旋多次抢不到锁就会升级为重量级锁。

轻量级锁:加锁机制尽可能不使⽤mutex,而是尽量在用户态代码完成。实在搞不定了,再使⽤mutex。偏向锁发生竞争,锁还没有释放 → 撤销偏向锁,升级为轻量级锁。

轻量级锁 重量级锁
底层机制 CAS 自旋,用户态 操作系统内核互斥锁
竞争失败行为 线程自旋空转 线程阻塞挂起
CPU 开销 自旋消耗 CPU 几乎不耗 CPU
开销来源 CPU 空循环 线程上下文切换
适用场景 竞争少,锁持有时间短 竞争激烈,锁持有时间长

通俗比喻:

  • 轻量级锁:原地转圈等。别人拿着锁,你不睡觉,不停尝试抢;如果对方很快用完,你直接拿到;如果对方迟迟不放,你一直转圈很累(耗 CPU)。
  • 重量级锁:坐下睡觉等。抢不到就直接睡觉,放弃 CPU;别人用完锁再叫醒你;睡觉不费力气,但是叫醒、睡觉这个动作本身代价很高(上下文切换)。

背诵短句

  • 轻量级锁:CAS 自旋,不阻塞,消耗 CPU,适合竞争少、锁时间短;
  • 重量级锁:操作系统阻塞线程,有上下文切换开销,适合竞争激烈、锁时间长。

1.3 自旋锁

自旋锁:线程获取锁失败时,不把自己阻塞挂起,循环不停重试抢锁,直到拿到锁。线程不会放弃 CPU,一直在 CPU 上跑循环。

特点:

  • 抢不到锁不阻塞,原地循环 CAS。
  • 没有线程阻塞、没有操作系统上下文切换。
  • 会占用 CPU,一直空转消耗 CPU 资源。

伪代码:

while (抢锁(lock) == 失败) {}

优点:线程不会放弃 CPU,不发生线程阻塞、操作系统调度切换。锁一旦释放,线程可以立刻获取锁,响应快。

缺点:若锁被占用时间较长,线程持续空转,会大量消耗 CPU,而阻塞挂起的线程几乎不消耗 CPU。

1.4 公平锁vs非公平锁

公平锁:线程按照申请锁的先后顺序排队,先到先得,不允许插队。

非公平锁:允许新来的线程直接尝试抢锁,可以插队,不一定按排队顺序获取。

注意:由于操作系统内部的线程调度是随机的,正常情况可以视为非公平锁。

1.5 可重入锁vs不可重入锁

可重入锁:允许一个线程多次获取同一把锁,不会阻塞自己,也叫递归锁,如reentranlock和synchronized。

不可重入锁:简单来说就是把自己锁死,如果一个线程没有释放锁,反而再次申请锁就会锁死自己,操作系统的mutex就是不可重入锁。

1.6 读写锁

(1)读与读之间不会有线程安全问题,直接并发读取。

(2)读与写、写与写之间存在线程安全问题,需要进行互斥。

Java标准库提供ReentrantReadWriteLock类来使用读写锁,ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁,ReentrantReadWriteLock.WriteLock 类表示⼀个写锁,这两个对象都提供了lock和unlock进行加锁解锁。

代码:

package practice.thread;

import java.util.concurrent.locks.ReentrantReadWriteLock;

class RW{
  // 共享数据
  public static int data = 0;
  // 读写锁对象
  private static ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
  // 读锁
  private static ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
  // 写锁
  private static ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();

  public RW(){

  }

  //读操作
  public void read(){
    readLock.lock();
    try {
      // 读数据
      System.out.println("当前线程"+Thread.currentThread().getName()+"执行读操作,data为:" + data);
      Thread.sleep(1000);
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      // 需要手动解锁(注意)
      readLock.unlock();
    }
  }
  //写操作
  public void write(int val){
    writeLock.lock();
    try {
      data = val;
      System.out.println("当前线程"+Thread.currentThread().getName()+"已执行写操作,data为:"+data);
      Thread.sleep(1000);
    } catch (Exception e) {
      e.printStackTrace();
    }finally{
      writeLock.unlock();
    }
  }
}

public class Demo25 {
  public static void main(String[] args) throws Exception{
    RW rw=new RW();

    new Thread(()->{
      rw.read();
    },"读1").start();

    new Thread(() -> {
      rw.read();
    }, "读2").start();

    new Thread(()->{
      rw.write(10);
    },"写1").start();

    new Thread(() -> {
      rw.write(20);
    }, "写2").start();

  }
}

结果:

2. CAS

2.1 CAS是什么

CAS 全称 Compare And Swap,比较并交换,是硬件 CPU 提供的原子指令,是乐观锁的底层实现基础。
CAS 包含 3 个操作数:

  • V:内存中变量实际值
  • A:预期旧值
  • B:准备写入的新值

执行逻辑:

  1. 比较内存真实值V与预期值A是否相等
  2. 如果相等,就把V更新为新值B,返回成功
  3. 如果不相等,说明已经被其他线程修改,直接返回失败,不做修改

伪代码:

boolean CAS(address, expectValue, swapValue){
    if(getMemory(address) == expectValue){
        setMemory(address,swapValue);
        return true;
    }
    return false;
}

注意:比较 + 交换这整套动作,是一条 CPU 原子指令,不会被线程打断。Java 通过Unsafe类封装 CAS 操作。

2.2 CAS 的应用

1. 实现原子类
java.util.concurrent.atomic包下面的原子类(AtomicInteger)底层基于 CAS 实现,不需要加锁保证原子性。

getAndIncrement()自增伪代码:

public int getAndIncrement(){
    int oldValue = value;
    while(!CAS(value,oldValue,oldValue+1)){
        oldValue = value;
    }
    return oldValue;
}

循环执行 CAS,直到修改成功。

2. 实现自旋锁
循环 CAS 尝试抢占锁,抢不到就不断重试。

2.3 CAS 存在的问题 — ABA 问题

1. 什么是 ABA
线程 1 读取变量值为 A,准备执行 CAS 修改;在线程 1 执行 CAS 之前,线程 2 把变量 A 修改为 B,又改回 A。此时内存值依旧是 A,CAS 校验会判定没有发生修改,执行更新;但数据中间已经被篡改过,产生逻辑错误。

举例:账户余额 100,线程 A 要扣款 50;中途线程 B 把余额改成 50,又转账改回 100;此时 A 的 CAS 依旧执行扣款,业务出错。

2. ABA 解决方案
引入版本号(时间戳),每次修改版本号自增;CAS 同时比较值 + 版本号。
Java 提供AtomicStampedReference,内部维护数据 + 版本戳,专门解决 ABA 问题。

2.4 CAS 其他缺点

  1. 竞争激烈时,循环自旋大量消耗 CPU。
  2. 只能保证单个变量的原子操作,不能同时操作多个共享变量。

3. synchronized原理

synchronized 是 JVM 实现的锁,锁只能升级,不能降级。
锁状态流转:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。

3.1 synchronized 整体特性

  1. 锁竞争少使用乐观策略,冲突频繁切换悲观锁;
  2. 锁状态逐级升级;轻量级锁使用自适应自旋;
  3. 非公平锁、可重入锁、不是读写锁。

3.2 偏向锁

偏向锁的目的:单线程无竞争场景,尽可能消除 CAS 加锁开销。

  1. 第一个线程获取锁,对象头 Mark Word 记录该线程 ID,标记偏向锁。
  2. 同一个线程再次进入同步块,直接判断线程 ID,不需要 CAS 操作。
  3. 释放锁的时候,不会清除偏向标记。标记存在不等于锁被占用。
  4. 当出现其他线程来竞争锁,立刻撤销偏向锁,升级为轻量级锁。

注意:偏向锁只是对象头的标记,不是线程状态。同一个对象,偏向锁只能记录一个线程 ID。

3.3 轻量级锁

触发时机:偏向锁发生竞争,锁还没有被释放,撤销偏向锁升级轻量级锁。

  1. 底层基于 CAS + 自适应自旋锁,工作在用户态,尽量不调用操作系统互斥锁 mutex。
  2. 竞争锁的线程不阻塞,循环 CAS 自旋尝试抢锁。
  3. JVM 自适应调整自旋次数;如果自旋多次依旧获取不到锁,升级重量级锁。

适用场景:竞争不激烈,锁持有时间很短。优点没有内核态切换;缺点自旋消耗 CPU。

3.4 重量级锁

触发时机:轻量级锁自旋多次抢锁失败,膨胀为重量级锁。

  1. 依赖操作系统内核提供的mutex互斥锁;
  2. 获取锁失败的线程停止自旋,被操作系统挂起,进入阻塞队列;
  3. 锁释放后操作系统唤醒等待的线程,重新竞争锁。

存在用户态↔内核态切换,线程上下文切换开销大;适合锁持有时间长、竞争激烈。

3.5 JVM 锁优化(synchronized 额外优化)

锁消除
JVM 编译器检测到锁对象不会发生多线程竞争,直接把加锁解锁代码消除。
例如单线程环境下StringBuffer的synchronized方法,锁直接消除,减少开销。
锁粗化
如果一段代码频繁多次加锁、释放锁;JVM 会把多次加解锁合并成一次大粒度锁,减少频繁锁操作开销。

锁的粒度

加锁和解锁范围代码越多粒度越粗,返回越细

// t1:粗粒度锁:把整个for循环全部包进synchronized
Thread t1 = new Thread(() -> {
    synchronized (locker) {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    }
});

// t2:细粒度锁:只把count++这一行包进synchronized,循环在锁外面
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 50000; i++) {
        synchronized (locker) {
            count++;
        }
    }
});

4. 面试背诵小结

  1. CAS:CPU 硬件原子指令,比较并交换;乐观锁实现;存在 ABA 问题,使用版本号AtomicStampedReference解决。
  2. synchronized 锁升级:无锁→偏向锁(单线程无竞争)→轻量级锁(CAS 自旋)→重量级锁(操作系统阻塞),锁只能升级不能降级。
  3. 自旋锁:抢锁失败循环重试;耗 CPU,无上下文切换;适合锁持有时间短。
  4. 悲观锁上来就上锁;乐观锁不加锁,更新校验冲突。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐