面试必问系列:悲观锁和乐观锁的那些事儿

img

程序安全

线程安全是程序开发中很是须要咱们注意的一环,当程序存在并发的可能时,若是咱们不作特殊的处理,很容易就出现数据不一致的状况。面试

一般状况下,咱们能够用加锁的方式来保证线程安全,经过对共享资源 (也就是要读取的数据) 的加上"隔离的锁",使得多个线程执行的时候也不会互相影响,而悲观锁乐观锁正是并发控制中较为经常使用的技术手段。算法

乐观锁和悲观锁

什么是悲观锁?什么是乐观锁?其实从字面上就能够区分出二者的区别,通俗点说,sql

悲观锁

悲观锁就好像一个有迫害妄想症的患者,老是假设最坏的状况,每次拿数据的时候都觉得别人会修改,因此每次拿数据的时候都会上锁,直到整个数据处理过程结束,其余的线程若是要拿数据就必须等当前的锁被释放后才能操做。数据库

使用案例编程

悲观锁的使用场景并很多见,数据库不少地方就用到了这种锁机制,好比行锁,表锁,读锁,写锁等,都是在作操做以前先上锁,悲观锁的实现每每依靠数据库自己的锁功能实现。Java程序中的Synchronized和ReentrantLock等实现的锁也均为悲观锁。安全

在数据库中,悲观锁的调用通常是在所要查询的语句后面加上 for update并发

select * from db_stock where goods_id = 1 for update

当有一个事务调用这条 sql 语句时,会对goods_id = 1 这条记录加锁,其余的事务若是也对这条记录作 for update 的查询的话,那就必须等到该事务执行完后才能查出结果,这种加锁方式能对读和写作出排他的做用,保证了数据只能被当前事务修改。高并发

固然,若是其余事务只是简单的查询而没有用 for update的话,那么查询仍是不会受影响的,只是说更新时同样要等待当前事务结束才行。工具

值得注意的是,MySQL默认使用autocommit模式,也就是说,当你执行一个更新操做后,MySQL会马上将结果进行提交,就是说,若是咱们不只要读,还要更新数据的话,须要手动控制事务的提交,好比像下面这样:性能

set autocommit=0;
//开始事务
begin;
//查询出商品id为1的库存表数据
select * from db_stock where goods_id = 1 for update;
//减库存
update db_stock set stock_num = stock_num - 1 where goods_id = 1 ;
//提交事务
commit;

虽然悲观锁能有效保证数据执行的顺序性和一致性,但在高并发场景下并不适用,试想,若是一个事务用悲观锁对数据加锁以后,其余事务将不能对加锁的数据进行除了查询之外的全部操做,若是该事务执行时间很长,那么其余事务将一直等待,这无疑会下降系统的吞吐量。

这种状况下,咱们能够有更好的选择,那就是乐观锁。

乐观锁

乐观锁的思想和悲观锁相反,老是假设最好的状况,认为别人都是友好的,因此每次获取数据的时候不会上锁,但更新数据那一刻会判断数据是否被更新过了,若是数据的值跟本身预期同样的话,那么就能够正常更新数据。

场景

这种思想应用到实际场景的话,能够用版本号机制和CAS算法实现。

CAS

CAS是一种无锁的思想,它假设线程对资源的访问是没有冲突的,同时全部的线程执行都不须要等待,能够持续执行。若是遇到冲突的话,就使用一种叫作CAS (比较交换) 的技术来鉴别线程冲突,若是检测到冲突发生,就重试当前操做到没有冲突为止。

原理

CAS的全称是Compare-and-Swap,也就是比较并交换,它包含了三个参数:V,A,B,V表示要读写的内存位置,A表示旧的预期值,B表示新值

具体的机制是,当执行CAS指令的时候,只有当V的值等于预期值A时,才会把V的值改成B,若是V和A不一样,有多是其余的线程修改了,这个时候,执行CAS的线程就会不断的循环重试,直到能成功更新为止。

正是基于这样的原理,CAS即时没有使用锁,也能发现其余线程对当前线程的干扰,从而进行及时的处理。

缺点

CAS算是比较高效的并发控制手段,不会阻塞其余线程。可是,这样的更新方式是存在问题的,看流程就知道了,若是C的结果一直跟预期的结果不同的话,线程A就会一直不断的循环重试,重试次数太多的话对CPU也是一笔不小的开销。

并且,CAS的操做范围也比较局限,只能保证一个共享变量的原子操做,若是须要一段代码块的原子性的话,就只能经过Synchronized等工具来实现了。

除此以外,CAS机制最大的缺陷就是"ABA"问题。

ABA问题

前面说过,CAS判断变量操做成功的条件是V的值和A是一致的,这个逻辑有个小小的缺陷,就是若是V的值一开始为A,在准备修改成新值前的期间曾经被改为了B,后来又被改回为A,通过两次的线程修改对象的值仍是旧值,那么CAS操做就会误任务该变量历来没被修改过,这就是CAS中的“ABA”问题。

看完流程图相信也不用我说太多了吧,线程多发的状况下,这样的问题是很是有可能发生的,那么如何避免ABA问题呢?

加标志位,例如搞个自增的字段,没操做一次就加一,或者是一个时间戳,每次更新比较时间戳的值,这也是数据库版本号更新的思想(下面会说到)

在Java中,自JDK1.5之后就提供了这么一个并发工具类AtomicStampedReference,该工具内部维护了一个内部类,在原有基础上维护了一个对象,及一个int类型的值(能够理解为版本号),在每次进行对比修改时,都会先判断要修改的值,和内存中的值是否相同,以及版本号是否相同,若是所有相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

适用场景

CAS通常适用于读多写少的场景,由于这种状况线程的冲突不会太多,也只有线程冲突不严重的状况下,CAS的线程循环次数才能有效的下降,性能也能更高。

版本号机制

版本号机制是数据库更新操做里很是实用的技巧,其实原理很简单,就是获取数据的时候会拿一个能对应版本的字段,而后更新的时候判断这个字段是否跟以前拿的值是否一致,一致的话证实数据没有被别人更新过,这时就能够正常实现更新操做。

仍是上面的那张表为例,咱们加上一个版本号字段version,而后每次更新数据的时候就把版本号加1,

select goods_id,stock_num,version from db_stock where goods_id = 1

update db_stock set stock_num = stock_num - 1,version = version + 1 where goods_id = 1 and version = #{version}

这样的话,若是有两个事务同时对goods_id = 1这条数据作更新操做的话,必定会有一个事务先执行完成,而后version字段就加1,另外一个事务更新的时候发现version已经不是以前获取到的那个值了,就会从新执行查询操做,从而保证了数据的一致性。

这种锁的方式也不会影响吞吐量,毕竟你们均可以同时读和写,但高并发场景下,sql更新报错的可能性会大大增长,这样对业务处理彷佛也不友好。

这种状况下,咱们能够把锁的粒度缩小,好比说减库存的时候,咱们能够这么处理:

update db_stock set stock_num = stock_num - 1  where goods_id = 1 and stock_num > 0

这样一来,sql更新冲突的几率会大大下降,并且也不用去单独维护相似version的字段了。

最后

关于悲观锁和乐观锁的例子介绍就到这儿了,固然,本文也只是略微讲解,更多的知识点还要靠你们研究,并且,除了这两种锁,并发控制中还有不少其余的控制手段,像什么Synchronized、ReentrantLock、公平锁,非公平锁之类的都是很常见的并发知识,不论是为了平常开发仍是应付面试,掌握这些知识点仍是颇有必要的,并且,并发编程的知识思想是共通的,知道一块知识点后很容易就能延伸去学习其余的知识点。

拿我本身来讲,最近也在认真研究Java并发编程的一些知识点,也由于要写乐观锁的缘故,顺道复习了一下CAS和它的使用案例,从而也了解到了ReentrantLock底层其实就是经过CAS机制来实现锁的,并且还了解了独占锁,共享锁,可重入锁等使用场景,由点到面,也让我知识体系储备更加的丰富,近期也有打算撸几篇关于ReentrantLock知识的文章出来,欢迎你们多来踩踩!


做者:鄙人薛某,一个不拘于技术的互联网人,技术三流,吹水一流,想看更多精彩文章能够关注个人公众号哦~~~

img


原创不易,您的 【三连】 将是我创做的最大动力,感谢各位的支持!

相关文章
相关标签/搜索