乐观锁解析

一、乐观锁理论基础

所谓的乐观锁,其实主要就是一种思想,因为乐观锁的操作过程中其实没有没有任何锁的参与,乐观锁只是和悲观锁相对,严格的说乐观锁不能称之为锁。所以要了解乐观锁的概念,通常与悲观锁对比起来看才更好理解,下面我们就通过乐观锁与悲观锁的对比来更好的理解乐观锁。

1.1乐观锁与悲观锁的概念

  • 乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

注意“在此期间”的含义是拿到数据到更新数据的这段时间。因为没有加锁,所以别的线程可能会更改。还有一点那就是乐观锁其实是不加锁的来保证某个变量一系列操作原子性的一种方法。

  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞,直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

1.2 两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

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

乐观锁可以通过版本号机制或者CAS算法实现。

1.3.1 版本号机制

版本号机制实现的方式常用的也有两种:

  • 使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。
    当我们提交更新的时候,判断数据库表对应记录 的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数 据。用下面的一张图来说明:


    如上图所示,如果更新操作顺序执行,则数据的版本(version)依次递增,不会产生冲突。但是如果发生有不同的业务操作对同一版本的数据进行修 改,那么,先提交的操作(图中B)会把数据version更新为2,当A在B之后提交更新时发现数据的version已经被修改了,那么A的更新操作会失败。

  • 使用时间戳(timestamp)。这种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

1.3.2 CAS算法

即Compare And Swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS操作包含三个操作数——内存位置的值(V)、预期原值(A)和新值(B)。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。
我们使用一个例子来解释相信你会更加的清楚。
1.在内存地址V当中,存储着值为10的变量。

 

2.此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。

 


3.在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。

 


4.线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。

5.线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。

 


6.这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。

 


7.线程1进行SWAP,把地址V的值替换为B,也就是12。

 

注:CAS算法的缺点
【1】循环时间长开销很大:自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
【2】只能保证一个共享变量的原子操作:只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用CAS 来操作 ij。从 Java1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。
【3】ABA 问题:因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A-B-A 就会变成1A-2B-3A。

二、乐观锁两种方式的实现实例

2.1 利用版本号机制的解决实际问题

2.1.1 实际遇到的问题

使用 MySQL 5.7 做测试,数据库引擎为 InnoDB,数据库隔离级别为可重复读(REPEATABLE-READ),读读共享,读写互斥。在这个隔离级别下,在多事务并发的情况下,还是会出现数据更新的冲突问题。
先分析一下更新冲突的问题是如何产生的。
假设我们有一张商品表 goods,表结构如下:

字段数据类型说明
goods_idvarchar(32)商品 id
countint(11)销量

比如在某一时刻事务 A 和事务 B,在同时操作表 goods_id = 213214324 的数据,当前销量为 100。

goods_idcount
213214324100

两个事务的内容一样,都是先读取的数据,count +100 后更新。

我们这里只讨论乐观锁的实现,为了便于描述,假设项目已经集成 Spring 框架,使用 MyBatis 做 ORM,Service 类的所有方法都使用了事务,事务传播级别使用 PROPAGATION_REQUIRED ,在事务失败会自动回滚。
Service 为 GoodsService ,更新数量的方法为 addCount()

@Service
@Transaction
pubic class GoodsService{
    
    @Autowire
    private GoodsDao dao;
    
    public void addCount(String goodsId, Integer count) {
        Goods goods = dao.selectByGoodsId(goodsId);
        if (goodsSale == null) {
            throw new Execption("数据不存在");
        }
        int count = goods.getCount() + count;
        goods.setCount(count);
        int count = dao.updateCount(goods);
        if (count == 0) {
            throw new Exception("添加数量失败");
        }
    }
}

使用的 Dao 为GoodsDao ,有两个方法:

public interface GoodsSaleDao {
    Goods selectByGoodsId(@Param("goodsId") String goodsId);

    int updateCount(@Param("record") Goods goods);
}

mapper 文件对应的 sql 操作为:

<!-- 查询 -->
<select id="selectByGoodsId" resultMap="BaseResultMap">
    select
    <include refid="Base_Column_List"/>
    from goods
    where goods_id = #{goodsId}
</select>

<!-- 更新 -->
<update id="updateCount">
    update
    goods
    set count = #{record.count},
    where goods_id = #{record.goodsId}
</update>

好了,假设现在有两个线程同时调用了 GoodsServiceaddCount() ,操作同一行数据,会有什么问题?
是否可能会出现两个线程更新时出现了冲突!两次 addCount(100) ,结果应该是 300,但结果还是 200。因为上面对一个变量的查询与更新这一系列操作并不是原子性的,是有可能出现并发问题的。

 

 

2.1.2 如何解决问题

该如何处理上面的问题,有一个简单粗暴的方法,既然这里多线程访问会有线程安全问题,那就上锁,方法加上 synchronized 进行互斥。

public synchronized void addCount(String goodsId, Integer count) {
     Goods goods = dao.selectByGoodsId(goodsId);
        if (goodsSale == null) {
            throw new Execption("数据不存在");
        }
        int count = goods.getCount() + count;
        goods.setCount(count);
        int count = dao.updateCount(goods);
        if (count == 0) {
            throw new Exception("添加数量失败");
        }
}

这个方案确实也可以解决问题,但是这种简单互斥的做法,锁的粒度太高,事务排队执行,并发度低,性能低。但如果是分布式应用,还得考虑应用分布式锁,性能就更低了。
考虑到这些更新冲突发生的概率其实并不高。这里讨论另一种解决方案,在数据库表中新增版本号字段来实现乐观锁从而保证该变量操作的原子性。
接下来我们来讨论如何实现它。
第一步:数据库表 goods 新增一行 data_version 来记录数据更新的版本号。新的表结构如下:

字段数据类型说明
goods_idvarchar(32)商品 id
countint(11)销量
data_versionint(11)版本号

第二步:GoodsDaoupdateCount() 对应的 mapper 的 SQL 语句进行调整,数据更新的时候同时进行 data_version = data_version + 1 ,执行这个 sql 时候已经对数据上行锁了,所以这个 data_version 加 1 的操作为原子操作。

<!-- 乐观锁更新 -->
<update id="updateCount">
    update
    goods_sale
    set count = #{record.count}, data_version = data_version + 1
    where goods_sale_id = #{record.goodsSaleId}
    and data_version = #{record.dataVersion}
</update>

Dao 调整之后,事务 A 和事务 B 的变化如下:

 

有了发现冲突快速失败的方案,要想让更新成功,可以在 GoodsService 中加入自旋,重新开始事务业务逻辑的执行,直到没有发生冲突,更新成功。自旋的实现有两种,一种是使用循环,一种是使用递归。

  • 循环实现:

public void addCount(String goodsId, Integer count) {
    while(true) {
        Goods goods = dao.selectByGoodsId(goodsId);
        if (goods == null) {
            throw new Execption("数据不存在");
        }
        int count = goods.getCount() + count;
        goods.setCount(count);
        int count = dao.updateCount(goods);
        if (count > 0) {
            return;
        }   
    }
}
  • 递归实现:

public void addCount(String goodsId, Integer count) {
     Goods goods = dao.selectByGoodsId(goodsId);
        if (goods == null) {
            throw new Execption("数据不存在");
        }
        int count = goods.getCount() + count;
        goods.setCount(count);
        int count = dao.updateCount(goods);
         if (count == 0) {
        addCount(goodsId, count)
    }
}

通过乐观锁+自旋的方式,解决数据更新的线程安全问题,而且锁粒度比互斥锁低,并发性能好。
使用时间戳(timestamp),这种解决方式和上面的差不多,这里就不展开介绍了。

2.2 利用CAS算法解决实际问题

Java中java.util.concurrent.atomic 并发包下的所有原子类都是基于 CAS 来实现的。
这里涉及到java的源码分析,这里就不展开了。

关于CAS相关连接推荐:

  • 3
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
轻量级锁、重量级锁和偏向锁是Java中的三种不同的锁机制。轻量级锁是一种乐观锁,用于在多线程环境下提高并发性能。当锁竞争不激烈时,轻量级锁使用CAS(Compare and Swap)操作来获取锁,避免了线程阻塞,提升了性能。然而,如果锁竞争激烈,轻量级锁会膨胀为重量级锁。 偏向锁是在没有竞争的情况下,为了进一步提高性能而引入的机制。偏向锁允许第一个获得锁的线程在接下来的执行中,无需再进行同步操作,从而减少了不必要的锁竞争。当有其他线程试图获取偏向锁时,偏向锁会升级为轻量级锁或重量级锁。 重量级锁是一种传统的互斥锁,它使用操作系统原语(例如互斥量)来实现线程阻塞和唤醒,确保同一时间只有一个线程可以访问被锁定的资源。重量级锁适用于锁竞争激烈的场景,但由于涉及到内核态和用户态之间的切换,会带来较大的性能开销。 总结起来,轻量级锁和偏向锁都是为了提高并发性能而引入的机制,轻量级锁适用于锁竞争不激烈的场景,而偏向锁适用于没有竞争的情况下。重量级锁适用于锁竞争激烈的场景,但会带来较大的性能开销。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [浅谈偏向锁、轻量级锁、重量级锁](https://blog.csdn.net/yaojiqic/article/details/124619021)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *2* [Java中的偏向锁,轻量级锁, 重量级锁解析](https://blog.csdn.net/lengxiao1993/article/details/81568130)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *3* [Java锁的升级策略 偏向锁 轻量级锁 重量级锁](https://download.csdn.net/download/weixin_38512659/12749004)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值