我对悲观锁与乐观锁的一点理解
序
说起到锁,我们都知道,加锁就意味这开销的加大,那什么时候需要加锁呢?
多个用户针对同一资源进行处理,此时就有可能出现并发问题。比如,常见的商城下单购物,扣减商品库存这一操作,在并发的情况下就有可能出现商品超卖的情况,而为了避免商品超卖这一情况,我们就需要考虑到在操作商品库存时加锁。
有一话来说就是:在并发的场景下,我们需要有序的更新某条记录时。
悲观锁与乐观锁到底是什么
悲观锁:
用一句开玩笑的话说就是总有刁民想害朕,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,知道操作完成后才会释放锁。这样别人想拿这个数据就会阻塞直到它拿到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁、排他锁等,都是在操作之前先上锁。在Java中synchronized和ReentrantLock等独占锁都是悲观锁机制的体现。
乐观锁
也就是说很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下,在此期间别人有没有去更新这个数据,如果别人修改了数据则放弃操作,否则执行操作。
这两种锁对又有哪些优劣呢
乐观锁
优势:
轻量级锁,避免了线程切换的开销
劣势:
-
会有ABA问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
-
只能对单一变量加锁
-
自旋操作导致额外开销
悲观锁
优势:
可以锁住多个变量
劣势:
重量级锁,加锁、释放锁操作会增加开销,而且操作系统层面的上下文切换和线程调度也会引起很大的开销。
一个线程持有锁会导致其他需要此锁的线程挂起。
两种锁的使用场景
从上面的介绍,我们知道两种锁各有优缺点,需要根据场景进行对应的选择。像乐观锁适用于多读写少的情况下,这样可以省去了锁的开销,加大系统的整体吞吐量。但如果是多写的情况下,经常会产生冲突,会导致上层应用不断重试,这样反而降低了性能,所以一般多写的场景下用悲观锁就比较合适。
放在现实开发中,比如说同为商城系统,也可以根据用户量的不同,业务的不同,使用不同的加锁机制。合适的就是最好的。
这两种锁怎么去实现
乐观锁的实现主要方式有两种:CAS算法和版本号机制
版本号机制
一般是在数据表中加入一个版本号字段,标识数据被修改的次数,读取数据时,需要同时读取版本号,当数据修改时,需要判断版本号跟数据库中的是否一致,一致时才更新,版本号+1。如果不一致则执行重试更新操作。
CAS算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
- 需要读写的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
操作逻辑如下:
如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。
许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
这里引出一个新的问题,既然CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?
答案是:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。
悲观锁的两种实现:synchronized和数据库加锁
代码实现悲观锁:synchronized
synchronized通过对代码块加锁来保证线程安全:在同一时刻,只有一个线程可以执行代码块中的代码。
synchronized是一个重量级的操作,不仅是因为加锁需要消耗额外的资源,还因为线程状态的切换会涉及操作系统核心态和用户态的转换;
不过随着JVM对锁进行的一系列优化(如自旋锁、轻量级锁、锁粗化等),synchronized的性能表现已经越来越好。
数据库加锁
例:排他锁,select … for update
该查询语句会为改行记录加上排他锁,知道事务提交或回滚时才会释放排他锁。
在此期间,如果其他事务只能对改行记录执行查询操作。
注意:select … for update 一定要跟上where id = ? 的条件,id字段一定要是主键或者唯一索引,不然会导致锁全表。
最后
随着互联网的发展,三高架构的提出,悲观锁已经越来越少的使用到生产环境中了,尤其时是并发量比较大的业务场景。
所以对我们来说高并发环境下锁粒度把控是一门重要的学问,选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。