youyichannel

志于道,据于德,依于仁,游于艺!

0%

乐观锁和悲观锁

在计算机中,乐观锁和悲观锁的目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。

悲观锁

概念

悲观锁在访问共享资源的时候,总是假设最坏的情况,认为共享资源的每次访问都会出现问题(别的线程来修改共享资源),因此在每次获取资源时都会上锁 => 共享资源每次只给一个线程使用,其他线程被阻塞,获得共享资源的线程使用完后才会释放锁,把资源的使用权让出。

实现

Java中的synchronized关键字和java.util.concurrent.locks.Lock的实现类都是悲观锁思想的实现。

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

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

适用场景

悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确,显式的锁定之后再操作共享资源,这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。但是,如果乐观锁解决了频繁失败和重试的问题,也可以考虑使用乐观锁,这个要根据实际情况来定。

存在的问题

高并发场景下,激烈的锁竞争会造成线程阻塞,大量的阻塞线程会导致频繁的系统上下文切换,增加系统的性能开销。同时,悲观锁还可能会存在死锁问题,影响程序的正常执行。

乐观锁

概念

乐观锁在访问共享资源的时候,总是假设最好的情况,认为共享资源的每次访问不会出现问题,线程可以不停的执行,无需加锁和等待,只需要在提交修改的时候验证共享资源是否被其他线程修改了。

实现

乐观锁一般会使用版本号机制或者CAS算法实现。

版本号机制

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

UPDATE `table_name` SET col1 = val1, version = version + 1 WHERE `id` = id_val AND version = old_version;

CAS算法

CAS,Compare And Swap,思想就是用一个预期值和要更新的变量值比较,只有两个值相等的时候才会更新。

CAS是一个原子操作,底层依赖一条CPU的原子指令。

原子操作:最小不可拆分的操作,操作一旦开始执行,不能被打断。

CAS操作的三个操作数:

// Var: 要更新的变量值
// Expected: 预期值
// New: 写入的新值
CAS(Var, Expected, New)

当且仅当 Var == Expected时,CAS才会通过原子方式用新值New来更新Var的值,如果不相等,说明已经有其他线程更新了Var,当前线程放弃更新。

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

Java的sun.misc.Unsafe类中提供了三个CAS本地方法:

/**
* CAS
*
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/


/**
* Atomically update Java variable to <tt>x</tt> if it is currently
* holding <tt>expected</tt>.
* @return <tt>true</tt> if successful
*/
public final native boolean compareAndSwapObject(Object o, long offset,
Object expected,
Object x);

/**
* Atomically update Java variable to <tt>x</tt> if it is currently
* holding <tt>expected</tt>.
* @return <tt>true</tt> if successful
*/
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);

/**
* Atomically update Java variable to <tt>x</tt> if it is currently
* holding <tt>expected</tt>.
* @return <tt>true</tt> if successful
*/
public final native boolean compareAndSwapLong(Object o, long offset,
long expected,
long x);

Java中java.util.concurrent.atomic包下的原子类使用了乐观锁的一种实现方式CAS。

适用场景

乐观锁通常多于读操作多的情况下,竞争较少,这样可以避免频繁加锁影响性能。

乐观锁主要针对的对象是单个共享变量。

存在的问题

ABA问题

问题:如果一个变量 V 初次读取时值是A,并且在准备更新时检查到它的值仍然是A,这就能说明它的值没有被其他线程修改过了吗?

很明显是不能的,因为在这段时间它的值可能被改为其他值,最后又改回了A,那 CAS 操作就会误认为它从来没有被修改过。

解决思路:在变量前面追加上版本号或者时间戳

Java中的AtomicStampedReference类就是用来解决ABA问题的,其中的方法compareAndSet就是先比较当前引用是否等于预期引用,以及当前标志是否等于预期标志,如果都相等,才以原子方式将该引用和该标志的值设置为给定的新值。

/**
* Atomically sets the value of both the reference and stamp
* to the given update values if the
* current reference is {@code ==} to the expected reference
* and the current stamp is equal to the expected stamp.
*
* @param expectedReference the expected value of the reference
* @param newReference the new value for the reference
* @param expectedStamp the expected value of the stamp
* @param newStamp the new value for the stamp
* @return {@code true} if successful
*/
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)));
}

自旋时间长,开销大

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

pause指令是一种CPU指令,它可以让CPU暂停执行一段时间,以便让其他线程有机会执行。在Java中,如果JVM支持pause指令,那么在CAS操作中使用pause指令可以减少自旋时间,从而提高性能。

需要注意的是,并不是所有的处理器都支持pause指令,因此,如果要使用pause指令来优化CAS操作,需要先检查处理器是否支持该指令。另外,即使处理器支持pause指令,也不能保证在所有情况下都能提高性能,因为性能的提升取决于具体的应用场景和代码实现。

参考文章

  • https://javaguide.cn/java/concurrent/optimistic-lock-and-pessimistic-lock.html
  • https://blog.csdn.net/TZ845195485/article/details/109398072