Java并发编程(三)之悲观锁与乐观锁

何谓悲观锁?何谓乐观锁?

乐观锁就像生活中那些乐观的人,对于事情的发展总是往好的方向去想。悲观锁就像生活中那些悲观的人,对于事情的发展总是往坏的方向去想。这两种锁各有各的优缺点,不能不以场景而定某一种锁就比另一种锁好。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都会认为会有其他线程修改该数据,所以在每次拿数据的时候都会对该数据上锁,这样其他想要操作该数据的线程就会阻塞直到获取到该数据的锁(共享资源在同一时刻只能给一个线程操作,其他线程会被阻塞,等待一个线程操作完之后才会给其他线程操作)。传统关系型数据库中就用到了很多这样的锁机制。比如行锁、表锁、读锁、写锁等,都是在操作共享资源之前先对共享资源上锁。Java中的synchronized和Reentrantlock等独占锁就属于悲观锁机制。

乐观锁

总是假设最好的情况,每次去拿数据的时候都会认为不会有其他线程修改该数据,所以在每次拿数据的时候都不会对数据上锁,但是在更新数据的时候会判断一下在它从读取到操作数据到更新操作数的期间有没有其他线程操作过该数据,这个判断可以使用版本号机制和CAS算法实现。乐观锁适用于读多写少的场景,可以提高吞吐量。关系型数据库中也有类似的机制,比如write_condition机制,在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

两种锁的使用场景

我们知道悲观锁和乐观锁都有各自的优缺点,不能片面的认为一种锁比另一种锁好。乐观锁适用于读多写少的场景,也就是很少会发生线程冲突的场景,这样可以省去获取锁和释放锁的开销,从而大大提高程序的吞吐量,如果是写操作比较多的场景,一般线程冲突也比较多,使用乐观锁机制会导致很多线程做一些无用并且不断的自旋,反而降低了程序的性能,所以这种情况适合用悲观锁,

乐观锁的两种实现机制

版本号机制

一般会在数据上面加一个版本号version字段,表示数据被修改时当前的版本号,也可以理解为数据被修改的次数,每次数据被修改的时候version都会加1。当线程A要修改数据的时候,在读取数据的同时也会读取数据当时的version值,修改完成之后再提交更新数据的时候,会读取当前数据的version值和修改之前读取的version值进行对比,如果相等才会更新,否则重新读取当前数据以及当前数据的version值再进行修改,直到更新成功。
举一个简答的例子:
你去ATM机取钱,假设当前银行卡余额balance为100元,当前balance对应的version值为1,此时你要取出50元。

  1. 操作A,先读取当前版本号(version=1)和余额(balance=100),然后开始执行余额扣除操作也就是100-50。
  2. 操作A中的余额扣除操作还没有执行完的时候,有人给你转账100,此时操作B读取当前版本号(version=1)和余额(balance=100),然后开始执行余额增加操作也就是100+100。
  3. 操作A余额扣除操作完成,将版本号加1(version+1),此时银行卡账号中余额(balance=50),版本号(version=2)。
  4. 操作B完成了余额增加操作,将版本号加1(version+1)余额加100(balence+100),然后提交更新银行卡账号中的version和balance。此时先读取到当前银行卡账号中的版本号(version=2),与提交之前操作B读取到的版本号(version=1)不相等,所以操作B的提交失败。
    这样利用版本号机制就可以避免操作B修改基于version=1的旧版本数据的结果覆盖操作A的结果。

CAS算法

CAS是compare and swap(比较与交换)的缩写,是一种典型的无锁算法,无锁编程,也就是在不使用锁的情况下实现多线程之间(没有线程会被阻塞)共享数据的同步,所以可以叫做非阻塞式同步(No-Block Synchronization),CAS算法有三个核心操作数:要更新的内存值(V)、要修改的值也叫作预期内存值(A)、修改后需要替换内存值的值(B)。
当且仅当V=A的时候,CAS才会通过原子操作将B赋给V,否则不会做任何操作,比较和替换是一个原子操作,一般情况下会以自旋的方式不断的重试去更新内存值(V)
举一个简单的例子,假设内存值为10,线程1和线程2都要对内存值进行加1操作:

  1. 线程1先获取到内存值10,对内存值进行加1操作,V=10
    线程1:A=10、B=11
  2. 线程2在此时也获取到内存值10,对内存值进行加1操作,V=10
    线程2:A=10、B=11
  3. 线程1先将内存值进行了更新,V=11
    线程1:A=10、B=11
    线程2:A=10、B=11
  4. 线程2去更新内存值的时候,发现A!= V,所以更新内存值失败,此时线程2重新获取内存值V=11并且重新计算需要替换内存值的新值(B),这个重新尝试的过程就叫做自旋
    线程2:A=11、B=12
  5. 线程2操作完成更新内存值,发现A=V,没有其他线程修改V的值,所以将B的值赋值给V,更新内存值,此时内存值V=12

乐观锁存在的问题

ABA问题

CAS操作会导致一个很经典的“ABA”问题,如果内存值V初次读取的时候是A,一个线程准备更新内存值的时候发现此时内存值依然是A,内存值没有发生变化,就会把内存值由A变成B,但是我们是不确定内存值是否被其他线程改变过,如果内存值由最开始的A变为了B,又从B变成了A,那么CAS操作就会认为内存值从来没有改变过,这就是“ABA”问题,这个问题仅仅是这样从表面来看好像没什么问题,因为线程本来就是将内存值A变为B,那么下面举一个实际生活中的例子体会一下“ABA”问题:

1.你的银行卡余额(balance)为100元,此时你去ATM机取款50元,但是由于ATM取款机出现问题,导致你的一个取款操作同时发起了两次请求,从而开启两个线程去执行扣款操作,在理想的情况下,线程A的扣款操作成功之后线程B的扣款操作就会失败,这样只会扣款一次:

线程A:读取内存值100,需要扣除50,期望内存值变为50
线程B:读取内存值100,需要扣除50,期望内存值变为50

2.但是出于各种不可抗因素导致此时线程B阻塞了,同时又有人向你转账50元,开启线程C进行转账操作:

线程A:读取内存值100,成功将内存值更新为50
线程B:读取内存值100,需要扣除50,期望内存值变为50(此时它阻塞了)
线程C:读取内存值50,需要增加50,期望内存值变为100

3.此时线程C执行完毕,成功将内存值更新为100:

线程A:读取内存值100,成功将内存值更新为50(已成功返回,线程已结束)
线程B:读取内存值100,需要扣除50,期望内存值变为50(此时线程B还在阻塞中)
线程C:读取内存值50,成功将内存值更新为100

4.此时线程B恢复正常,因为线程B读取的内存值为100,与当前内存值相等,所以通过CAS可以成功将内存值进行更新:

线程A:读取内存值100,成功将内存值更新为50(已成功返回,线程已结束)
线程B:读取内存值100,成功将内存值更新为50
线程C:读取内存值50,成功将内存值更新为100(已成功返回,线程已结束)

发现问题所在了吗?平白无故少了50元,原本线程B去更新内存值的时候应该是失败的,但是由于“ABA”问题导致更新内存值成功,CAS操作配合上面所说的版本号机制可以解决“ABA”问题。在JDK1.5之后的AtomicStampedReference类就提供了这种解决方式,其中CAS操作不仅会检查当前内存值(V)是否等于预期内存值(A),还会检查当前版本号是否等于预期版本号,如果全部相等,就会以原子方式将当前内存值和当前版本号一起更新为给定的更新值(B)。

循环时间长开销大

自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU 带来非常大的执行开销。 如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。

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

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

CAS 与 synchronized 的使用情景

CAS是乐观锁的代表,synchronized是悲观锁的代表,CAS适用于读多写少冲突少的场景,synchronized适用于写多读少冲突多的场景。
1.对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗CPU资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋机率较少,因此可以获得更高的性能。
2.对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,可能会导致长时间自旋又获取不到资源的情况,从而浪费更多的 CPU 资源,效率低于 synchronized。

对于synchronized不是很熟悉的小伙伴可以点这里Java并发编程(二)之synchronized

练习、用心、持续------致每一位追梦人!加油!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章