Java高并发--乐观锁/悲观锁及CAS理论

一、前言

悲观锁(Pessimistic Lock)和乐观锁(PessimisticLock 或 OptimisticLock)是面试超高频问题。
简明来说,对应现实生活中,悲观锁有点像是一位比较悲观、未雨绸缪的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。

二、乐观锁和悲观锁

2.1 悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问会出现问题(比如共享数据被修改),所以每次在获取资源操作时都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现:

public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。

2.2 乐观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用乐观锁 CAS 方式实现的。
在这里插入图片描述

// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,性能更好。但若冲突频繁发生(写占比非常多的情况),会频繁失败并重试,也会非常影响性能,导致 CPU 飙升。

不过,大量失败重试的问题是可以解决的,像前面提到的LongAdder以空间换时间的方式就可以。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),可避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,若乐观锁解决频繁失败和重试这个问题的话(比如LongAdder),也可考虑使用乐观锁,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

三、实现乐观锁

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些。

3.1 版本号机制

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

假如:数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

1.操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除(100-$50 )。
2.在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 (100-$20 )。
3.操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 24.操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

避免操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

3.2 CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁非阻塞的原子性操作,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是原子操作,底层依赖于一条 CPU 的原子指令(不同CPU指令不同)。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

例如:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 62. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

3.3 Java 中实现 CAS

Java 实现 CAS(Compare-And-Swap, 比较并交换)操作主要是Unsafe关键类。
Unsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。
Unsafe类提供多种CAS方法,以支持不同类型变量的原子更新。主要的CAS方法:

1. compareAndSwapInt(Object o, long offset, int expected, int x)
	• o:对象实例,表示要更新的字段所在的对象。
	• offset:字段在对象内存中的偏移量。
	• expected:预期值,即当前线程认为字段应该具有的值。
	• x:新值,如果字段的当前值等于预期值,则将其更新为新值。
	• 作用:用于原子地更新Java基本类型int字段的值。
	• 参数:
	• 返回值:如果字段的当前值等于预期值,则返回true;否则返回false2. compareAndSwapLong(Object o, long offset, long expected, long x)
	• 类似于compareAndSwapInt,但用于原子地更新long类型的字段。
3. compareAndSwapObject(Object o, long offset, Object expected, Object x)
	• 用于原子地更新对象类型的字段。

Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。即Java 语言并没有直接用 Java 实现 CAS, 是 C++ 内联汇编的形式实现的,通过 JNI(Java Native Interface)调用。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。

通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作:

// 获取 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);
}

Unsafe#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;
}

可见getAndAddInt 使用 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。

由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。

栗子:使用CAS实现count++

import sun.misc.Unsafe;
import java.lang.reflect.Field;

publicclassAtomicCounter{
	// Unsafe实例,用于执行底层的原子操作
	privatestaticfinalUnsafe unsafe;
	// count字段在对象中的偏移量
	privatestaticfinallong countOffset;
	// 要进行原子操作的volatile变量
	privatevolatileintcount=0;
	
	// 静态初始化块,用于获取Unsafe实例和count字段的偏移量
	static{
		try{
		// 通过反射获取Unsafe类的theUnsafe字段
		Fieldf=Unsafe.class.getDeclaredField("theUnsafe");
		// 设置该字段为可访问
		            f.setAccessible(true);
		// 获取Unsafe实例
		            unsafe =(Unsafe) f.get(null);
		// 获取count字段在对象中的偏移量
		            countOffset = unsafe.objectFieldOffset(AtomicCounter.class.getDeclaredField("count"));
		}catch(Exception e){
		// 如果出现异常,则抛出Error
		thrownewError(e);
		}
	}

	// 原子地增加count的值
	publicvoidincrement(){
	int prev, next;
	// 使用do-while循环来尝试更新count的值
		do{
		// 读取count的当前值
		            prev = count;
		// 计算下一个值
		            next = prev +1;
		// 尝试使用compareAndSwapInt方法原子地更新count的值
		}while(!unsafe.compareAndSwapInt(this, countOffset, prev, next));
		// 如果更新失败(即count的值在尝试更新时被其他线程改变了),则循环将继续
	}

	// 获取count的当前值
	publicintgetCount(){
		return count;
	}

	// 主方法,用于测试AtomicCounter类
	publicstaticvoidmain(String[] args){
		AtomicCountercounter=newAtomicCounter();
		// 调用increment方法增加count的值
		        counter.increment();
		// 打印count的当前值
		System.out.println("Count is: "+ counter.getCount());
		// 再次调用increment方法增加count的值
		        counter.increment();
		// 再次打印count的当前值
		System.out.println("Count is: "+ counter.getCount());
	}
}

四、CAS 算法存在的问题

4.1 ABA问题是

ABA问题发生在以下场景中:

1. 线程A读取内存位置V中的值为A2. 线程A被挂起(或调度到其他任务)。
3. 线程B执行,将内存位置V的值从A改为B,然后又改回A4. 线程A恢复执行,看到内存位置V的值仍然是A,于是继续执行CAS操作。

尽管内存位置V的值在最终被线程A读取时仍然是A,但实际上该位置的值已经被线程B修改过了。这可能会导致线程A基于错误的假设继续执行,从而引发问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。

1. AtomicStampedReference:维护时间戳控制

2. AtomicMarkableReference:维护boolean值控制

JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}

4.2 循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:

  • 延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
  • 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。

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

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供AtomicReference类来保证引用对象之间的原子性,可把多个变量放在一个对象里来进行 CAS 操作.所以可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供AtomicReference类,这能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,可使用AtomicReference来执行 CAS 操作。

除 AtomicReference 这种方式之外,还可以利用加锁来保证。

五、总结

  • 悲观锁基于悲观的假设,认为共享资源在每次访问时都会发生冲突,因此在每次操作时都会加锁。这种锁机制会导致其他线程阻塞,直到锁被释放。Java 中的 synchronized 和 ReentrantLock 是悲观锁的典型实现方式。虽然悲观锁能有效避免数据竞争,但在高并发场景下会导致线程阻塞、上下文切换频繁,从而影响系统性能,并且还可能引发死锁问题。
  • 乐观锁基于乐观的假设,认为共享资源在每次访问时不会发生冲突,因此无须加锁,只需在提交修改时验证数据是否被其他线程修改。Java 中的 AtomicInteger 和 LongAdder 等类通过 CAS(Compare-And-Swap)算法实现了乐观锁。乐观锁避免了线程阻塞和死锁问题,在读多写少的场景中性能优越。但在写操作频繁的情况下,可能会导致大量重试和失败,从而影响性能。
  • 乐观锁主要通过版本号机制或 CAS 算法实现。版本号机制通过比较版本号确保数据一致性,而 CAS 通过硬件指令实现原子操作,直接比较和交换变量值。CAS 是高效的无锁算法,但也需要注意 ABA 问题、循环时间长开销大等问题。
  • 在 Java 中,CAS 通过 Unsafe 类中的 native 方法实现,这些方法调用底层的硬件指令来完成原子操作。由于其实现依赖于 C++ 内联汇编和 JNI 调用,因此 CAS 的具体实现与操作系统以及 CPU 密切相关。

悲观锁和乐观锁各有优缺点,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能和稳定性。CAS 虽然具有高效的无锁特性,但也需要注意 ABA、循环时间长开销大等问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

容若只如初见

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值