乐观锁与悲观锁

概述

悲观锁

悲观锁(Pessimistic Lock)的思想就是总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。Java中synchronized和ReentrantLock等独占锁就是使用悲观锁的实现的。
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。

乐观锁

乐观锁(PessimisticLock)乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其他线程修改了(可以基于版本号机制或CAS算法实现)
在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升

两种锁使用场景

  • 悲观锁通常多用于竞争激烈(出现并发冲突的概率大)时,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
  • 乐观锁通常用于竞争不激烈 (出现并发冲突的概率小)时,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。

乐观锁实现

1.版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

2.CAS算法

CAS是Compare And Swap的缩写,CAS的思想是在最终进行修改时使用一个预期值与要更新的值进行比较,如果不相同说明有其他线程修改了该变量直接放弃本次操作,重试。CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。(原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。)
CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New) 举例:线程A要对变量i的值+5,i 原值为10,线程B要对i的值-5(两边同时读取了i,线程A先执行完成) 线程A:判断i与10是否相等,相等,则修改i的值为15 线程B:判断i与10是否相等,不相等(已被A修改为了15),放弃本次更新,重新读取i进行计算
    当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败.一般情况下是一个自旋操作,即不断重试

Java中Atomic原子类就是使用的CAS算法,如AtomicInteger
AtomicInteger源码:
// 获取 Unsafe 实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        // 获取“value”字段在AtomicInteger类中的内存偏移量
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}
// 确保“value”字段的可见性
private volatile int value;

// 如果当前值等于预期值,则原子地将值设置为newValue
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

// 原子地将当前值加 delta 并返回旧值
public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

// 原子地将当前值加 1 并返回加之前的值(旧值)
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

// 原子地将当前值减 1 并返回减之前的值(旧值)
public final int getAndDecrement() {
    return unsafe.getAndAddInt(this, valueOffset, -1);
}

getAndAddInt源码:

// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    // 返回旧值
    return v;
}

CAS的缺点

1.ABA问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

2.高并发竞争下的开销问题

在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。

3.只能保证一个共享变量的原子操作

功能限制CAS是能保证单个变量的操作是原子性的,这意味着:(1)原子性不一定能保证线程安全,例如在Java中需要与volatile配合来保证线程安全;(2)当涉及到多个变量(内存值)时,CAS也无能为力。除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。

end

评论