里面核心内容摘自 什么是乐观锁,什么是悲观锁
作者:bootaiRocketQ
链接:https://www.jianshu.com/p/d2ac26ca6525
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
悲观锁和乐观锁的概念
悲观锁和乐观锁只是一种并发控制的思想,而非特指某一种锁类型!!!一般来说乐观锁应用于多读场景,悲观锁应用于多写场景
悲观锁:修改资源之前先锁定,再修改(PCC)(传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁)
悲观锁即不信任,对共享资源来说,所有的访问线程都想独占(独占、排他特性)
悲观锁主要分为共享锁和排他锁(主要针对关系型数据库)
- 共享锁:称为读锁,多个事务对于同一数据共享一把锁,且只能读不能写
- 排他锁:称为写锁,一旦某一事务持有了数据的排他锁,那么其它事务无法持有该数据的其它锁,持有排他锁的事务可以对数据进行修改和读取
特点:
- 保守策略,保障数据安全
- 低效(加锁释放锁的性能消耗),降低了并发度
- 提高了出现死锁的机会
乐观锁:假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于多读的应用类型,这样可以提高吞吐量(像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的)
乐观锁不会刻意使用数据库的锁机制,实现方式有CAS、版本号
- CAS(Compare and Swap):Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式
- 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会+1。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功
乐观锁相信事务之间的数据竞争的概率是很小的,所以直到提交时才会锁定数据,所以不会产生死锁
悲观锁的实现(Mysql为例)
- 对记录修改之前尝试给记录加上排他锁(exclusive locks)
- 如果加锁失败,说明该记录正在修改,那么当前查询可能要等待或者抛出异常
- 如果加锁成功,那么可以修改记录,事务结束解锁
- 期间如果其它对该记录修改或加锁的操作,都会等待解锁或抛出异常
for update
的方式加锁
注意:Mysql的InnoDB存储引擎默认行级锁(基于索引),如果sql没有使用到索引,那么会用表级锁把整张表锁住
乐观锁的实现-CAS(Compare and Swap 比较并交换):
- 冲突检测
- 数据更新
当多个线程使用CAS来更新同一个变量,那么只有一个能更新成功,其它所有线程失败(在比较的时候全部通不过),失败的线程不会被挂起,而是被告知竞争失败,可以再次尝试
例如,更新库存之前先把当前库存拿出来作为更新的条件,如果和数据库中的数据没变那么更新,否则不能更新(导致ABA问题)
ABA问题
描述:线程A认为库存是3,期间线程B先把库存改为2,又改为3,此时线程A还是可以正常修改
解决办法:使用一个递增的版本号,因为版本号不会重复,所以一条记录使用CAS总能正确修改(或者使用时间戳)
新问题:高并发下一个线程成功就代表大量线程失败,所以要控制(乐观)锁的粒度,尽量提高吞吐量
如何选择
- 需要高响应就用乐观锁,因为乐观锁并没有真正的加锁操作,效率更高
- 冲突频率高就使用悲观锁,保证成功率
- 重试代价大就使用悲观锁,保证一次成功率
锁存在的问题:
Java在JDK1.5之前都是靠 synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。这就是一种独占锁,独占锁其实就是一种悲观锁,所以可以说 synchronized 是悲观锁。
这种机制存在以下问题:
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 一个线程持有锁会导致其它所有需要此锁的线程挂起。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
以下摘自Java并发问题--乐观锁与悲观锁以及乐观锁的一种实现方式-CAS
CAS在Java中的实现
CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。(在CAS的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值)CAS有效地说明了“ 我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”
在JDK1.5 中新增 java.util.concurrent(J.U.C)就是建立在CAS之上的。相对于对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。
以 java.util.concurrent 中的 AtomicInteger 为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解 getAndIncrement 方法,该方法的作用相当于 ++i 操作。
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int get() {
return value;
}
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}
在没有锁的机制下,字段value要借助volatile原语,保证线程间的数据是可见性。这样在获取变量的值的时候才能直接读取。然后来看看++i
是怎么做到的。
getAndIncrement
采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。
而compareAndSet
利用JNI(Java Native Interface)来完成CPU指令的操作,其中unsafe.compareAndSwapInt(this, valueOffset, expect, update);类似如下逻辑:
if (this == expect) {
this = update
return true;
} else {
return false;
}
那么比较this == expect
,替换this = update
,compareAndSwapInt
是如何实现这两个步骤的原子性呢?
CAS原理:CAS通过调用JNI的代码实现的。而compareAndSwapInt就是借助C来调用CPU底层指令实现的。
下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。下面是sun.misc.Unsafe类compareAndSwapInt()
方法的源代码:
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
可以看到这是个本地方法调用。这个本地方法在JDK中依次调用的C++代码为:
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
inline jint Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference,//预期引用
V newReference,//更新后的引用
int expectedStamp, //预期标志
int newStamp //更新后的标志
)
实际应用代码(感觉不算什么实际应用):
private static AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<Integer>(100, 0);
........
atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
CAS的常见问题和改进
一个线程成功,会有大量失败的线程
自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
CAS与Synchronized的使用情景:
1、对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
2、对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充: synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
concurrent包的实现(先放这里,感觉要看一下操作系统的东西了):
由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:
- A线程写volatile变量,随后B线程读这个volatile变量。
- A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
- A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。
Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
- 首先,声明共享变量为volatile;
- 然后,使用CAS的原子条件更新来实现线程之间的同步;
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的
JVM中的CAS(堆中对象的分配):
Java调用new object()会创建一个对象,这个对象会被分配到JVM的堆中。首先new object()
执行的时候,这个对象需要多大的空间,其实是已经确定的,因为java中的各种数据类型,占用多大的空间都是固定的(对其原理不清楚的请自行Google)。那么接下来的工作就是在堆中找出那么一块空间用于存放这个对象
在单线程的情况下,一般有两种分配策略:
- 指针碰撞:这种一般适用于内存是绝对规整的(内存是否规整取决于内存回收策略),分配空间的工作只是将指针像空闲内存一侧移动对象大小的距离即可。
- 空闲列表:这种适用于内存非规整的情况,这种情况下JVM会维护一个内存列表,记录哪些内存区域是空闲的,大小是多少。给对象分配空间的时候去空闲列表里查询到合适的区域然后进行分配即可。
但是JVM不可能一直在单线程状态下运行,那样效率太差了。由于再给一个对象分配内存的时候不是原子性的操作,至少需要以下几步:查找空闲列表、分配内存、修改空闲列表等等,这是不安全的。解决并发时的安全问题也有两种策略:
- CAS:实际上虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。
- TLAB:如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来进行配置(jdk5及以后的版本默认是启用TLAB的)。