记一个面试问题,什么是乐观锁和悲观锁?有什么区别?乐观锁怎么实现?
一、什么是乐观锁和悲观锁?
乐观锁:对于获取资源总是持乐观态度,认为操作资源时其他人不会同时操作该资源。在此思想中资源总是可达的,因此不会对资源加锁,只在更新数据时检查数据是否冲突。乐观态度下,可能存在多个线程同时操作同一资源的情况,因而需要一些其他方法来保证数据一致性。
悲观锁:对于获取资源总是持悲观态度,认为操作资源时总是存在冲突的可能。在此思想中资源总是存在冲突,需要加锁保证数据操作,在操作完成后释放锁。
二、乐观锁的实现
乐观锁的实现主要有两种:版本号机制和CAS
1.版本号机制:
数据库表设计时添加版本号列,举个例子,张三有一张银行卡,余额为156246.15,数据库表中记录如下:
card_number | owner | phone_number | identity_number | balance | version |
622xxxxxxxx1234 | 张三 | 18912341234 | 110000202404225961 | 156246.15 | 2462 |
张三业余时间在大学后街摆了个烤肠摊赚点外快。此时有两个人A和B分别购买了一根3块钱烤肠,并通过银联向张三付款。A、B的付款操作均需要更新张三的余额,因为乐观锁未对数据操作加锁,所以A、B的款项可能同时到达数据库。引入版本号机制后,假设A的付款操作更新张三的余额时获取到版本号为2462,在A的付款操作完成提交前,B的付款操作也获取到张三此时的余额版本号为2462;A完成数据操作后提交前检查发现版本号还是2462就直接提交了更新,并将版本号升级为2463.
card_number | owner | phone_number | identity_number | balance | version |
622xxxxxxxx1234 | 张三 | 18912341234 | 110000202404225961 | 156249.15 | 2463 |
在B完成 数据操作后提交前检查发现版本号变成了2463,与自己此前获取的版本号2462不一致,因此更新失败。此时可以通过重试机制进行重试,或者返回失败信息,由用户重新提交更新。
2.CAS机制:
CAS(Compare And Swap)比较并交换,过程中包含三个数据,内存位置,预期原值和新值。当内存位置的值与预期原址一致时执行更新操作并返回成功,若不一致则不做处理返回失败。
java在JDK1.5后才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
unsafe类的设计并不是供用户调用的类,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如java.util.concurrent包里面的整数原子类。
下面是AtomicInteger类中的compareAndSet方法:
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
可以看到,compareAndSet实际使用了unsafe类的compareAndSwapInt()方法。
三、乐观锁的缺点
1.冲突处理复杂。乐观锁在提交时需要检查数据是否被其他事务修改,如果发现冲突,需要回滚事务或重新尝试操作,这增加了冲突处理的复杂性
2.高并发场景,并发冲突较多的情况下,即多个事务同时对同一数据进行修改的概率较高时,可能会导致效率降低,因为每个事务在更新数据时都需要检查数据是否被其他事务修改。对于较长事务,频繁的重试或回滚会增加开销。
3.需要额外字段。为了实现乐观锁,通常需要在数据表中添加额外的版本号或时间戳字段,这增加了存储空间开销
4.业务逻辑变复杂,乐观锁是在应用程序层面实现的,如果业务逻辑复杂,可能会导致实现起来较为繁琐。
5.ABA问题,使用CAS实现的乐观锁会存在ABA问题,即线程1操作时将数据由A修改为B,又由B修改为A,线程2去操作时,拿到的数据是A,更新数据前校验时数据仍然是A,数据成功更新。 很多情况下ABA问题没有什么实际影响,但是部分场景ABA会造成错误操作,如有一个栈,栈中依次压入了B和A两个元素。
有两个线程A、B: A线程的操作是,如果栈顶元素是A则清空栈,并将D、C、A依次压入。 B线程的操作是,如果栈顶元素是A则获取下一个元素B。
线程1获取到栈顶元素是A,于是将A,B依次弹出并依次压入D、C、A,此时线程2获取到栈顶元素也是A,而后线程2尝试获取下一个元素B时,取出的元素却是C。
四、CAS实现乐观锁ABA问题的优化
java.util.concurrent.atomic中提供了AtomicMarkableReference<V>和AtomicStampedReference<V>来解决ABA问题,本质上还是版本号机制。将值与mark或stamp绑定为一个pair,并添加volatile修饰保证线程间的可见性。如下图:
CAS操作修改数据时同时判断reference值和mark(stamp)是否为预期值,符合预期则更新pair,如下为AtomicMarkableReference<V>的compareAndSet方法
/**
* Atomically sets the value of both the reference and mark
* to the given update values if the
* current reference is {@code ==} to the expected reference
* and the current mark is equal to the expected mark.
*
* @param expectedReference the expected value of the reference
* @param newReference the new value for the reference
* @param expectedMark the expected value of the mark
* @param newMark the new value for the mark
* @return {@code true} if successful
*/
public boolean compareAndSet(V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedMark == current.mark &&
((newReference == current.reference &&
newMark == current.mark) ||
casPair(current, Pair.of(newReference, newMark)));
}