乐观锁与悲观锁小结

作者: DemonJun | 来源:发表于2019-03-01 18:48 被阅读22次

    乐观锁,就像生活中乐观的人总是想着事情往好的方向发展;悲观锁,就像生活中悲观的人总是想着事情往坏的方向发展。这两种思想各有优缺点,任何脱离实际场景讨论孰好孰坏,都是耍流氓。

    悲观锁

    因为秉承着总是把事情往坏的方向发展的思想,悲观锁又被称为排他锁,即总是假设最坏的情况,每次获取资源的时候,总是担心别人会修改该资源,所以就会在获取资源之前,对该资源进行加锁,用于阻塞别人获取该锁及资源。具体的操作结果:共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

    悲观锁多数用于多写的场景,这样可以一定程度的保证数据的一致性。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

    Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

    乐观锁

    因为秉承着总是把事情往好的方向发展的思想,即总是假设最坏好的情况,每次获取资源的时候,总是假设别人不会修改该资源,所以不会上锁。大多数的乐观锁实现方式都是采用版本号机制和CAS算法。

    乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实就是提供的乐观锁。

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

    乐观锁常见的两种实现方式

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

    • 版本号机制

    一般都会在数据库的表字段中添加一个叫 version 的字段,用于记录数据的修改次数,当每次数据被修改时,该值就加1。在每次更新数据时,都会进行条件判断,判断当前更新数据所携带的版本号是否大于当时数据库中未更新数据所携带的版本号,如果大于,则进行更新;否则不更新。

    这边举个例子:假设数据库中用户余额表中有一个 version 字段,当前值为 0 ;而当前帐户余额字段( balance )为 100 。

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

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

    • CAS算法

    Compare And Swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

    CAS算法中涉及到三个操作数:

    • 需要读写的内存值 V
    • 进行比较的值 A
    • 拟写入的新值 B

    当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

    乐观锁的缺点

    乐观锁中有一个经典且常见的问题:ABA 问题

    1. ABA问题

    如果一个变量V初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A ,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

    2. 循环时间长开销大

    自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
    如果JVM能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

    • 它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
       
    • 它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率

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

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


    这里简单对比下 CAS 算法和 synchronized 使用场景

    简单来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)。

    • 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
       
    • 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

    特别说明一下:

    Java并发编程这个领域中 synchronized 关键字一直都是元老级的角色,之前大家都称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁轻量级锁 以及其它各种优化之后变得在特定情况下不再“重量级”了。如今 synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。

    在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

    相关文章

      网友评论

        本文标题:乐观锁与悲观锁小结

        本文链接:https://www.haomeiwen.com/subject/yljjuqtx.html