不得不说的乐观锁和悲观锁

4,338 阅读7分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

本文同时参与「掘力星计划」,赢取创作大礼包,挑战创作激励金

概念

乐观锁与悲观锁是一种广义上的概念,其实是对线程同步的不同角度看法。在Java和数据库中都有此概念对应的实际应用。

悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

悲观.PNG

乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据(具体方法可以使用版本号机制和CAS算法)。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作:重试或者报异常。

乐观.PNG

并发控制机制

当应用中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的。这就叫做并发控制。并发控制的目的是保证一个用户的工作不会对另一个用户的工作产生不合理的影响。即数据出现脏读、幻读和不可重复读等现象。

常说的并发控制,一般都和数据库管理系统(DBMS)有关。在DBMS中并发控制的任务,是确保多个事务同时增删改查同一数据时,不破坏事务的隔离性、一致性和数据库的统一性。实现并发控制的主要手段就是乐观并发控制和悲观并发控制两种。

乐观锁实现机制

版本号方法

版本号控制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数。当数据被修改时,version值会+1。当线程A要更新数据时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的 version值与当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

比如:数据库中用户表中有一个version字段,当前值为0;而当前帐户余额字段(money)为100 。

线程A此时将其读出(version=0),并从其帐户余额中扣除50(100-50)。 在线程A操作的过程中,线程B也读入此用户信息(version=0),并从其帐户余额中扣除30(100-30)。 线程A完成了修改工作,将数据版本号加一(version=1),连同帐户扣除后余额(money=50),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录version更新为1。 线程B完成了操作,也将版本号加一(version=1)试图向数据库提交数据(money=70),但此时比对数据库记录版本时发现,线程B提交的数据版本号为1 ,数据库记录当前版本也为1,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,线程B的提交被驳回。这样,就避免了操作员B用基于version=0的旧数据修改的结果覆盖线程A的操作结果的可能。

CAS算法

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

CAS中涉及三个要素:

需要读写的内存值V

进行比较的值A

拟写入的新值B

当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

JAVA对CAS的支持:在JDK1.5中新添加 java.util.concurrent (J.U.C) 就是建立在CAS之上的。对于 synchronized这种阻塞算法,CAS是非阻塞算法的一种实现。所以J.U.C在性能上有了很大的提升。更详细的介绍请参考上一篇文章:听说你想看CAS原理

悲观锁实现机制

ReentrantLock

可重入锁就是悲观锁的一种。同步状态标识:对外显示锁资源的占有状态。同步队列:存放获取锁失败的线程。等待队列:用于实现多条件唤醒。Node节点:队列的每个节点,线程封装体。cas修改同步状态标识,获取锁失败加入同步队列阻塞,释放锁时唤醒同步队列第一个节点线程。 加锁过程:调用tryAcquire()修改标识state,成功返回true执行,失败加入队列等待。加入队列后判断节点是否为signal状态,是就直接阻塞挂起当前线程。如果不是则判断是否为cancel状态,是则往前遍历删除队列中所有cancel状态节点。如果节点为0或者propagate状态则将其修改为signal状态。阻塞被唤醒后如果为head则获取锁,成功返回true,失败则继续阻塞。 解锁过程:调用tryRelease()释放锁修改标识state,成功则返回true,失败返回false。释放锁成功后唤醒同步队列后继阻塞的线程节点,被唤醒的节点会自动替换当前节点成为head节点。更详细的内容可参考之前的文章:谈谈可重入锁ReentrantLock

synchronized

synchronized和ReentrantLock都是可重入锁,可重入锁的一个优点是可一定程度避免死锁

public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

示例中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

悲观锁的问题

ReentrantLock:

需要引入相关的Class;

要在finally模块释放锁;

synchronized可以放在方法的定义里面, 而reentrantlock只能放在块里面;

synchronized:

锁的释放情况少,只在程序正常执行完成和抛出异常时释放锁;

试图获得锁是不能设置超时;

不能中断一个正在试图获得锁的线程;

无法知道是否成功获取到锁;

如何选择

悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

//悲观锁
public synchronized void testMethod() {
	// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
	lock.lock();
	// 操作同步资源
	lock.unlock();
}

//乐观锁
private AtomicInteger atomicInteger = new AtomicInteger();  // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1

通过示例可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。

在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了:

乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。

悲观锁依赖数据库锁,效率低。更新失败的概率比较低。

在互联网项目追求高并发低时延的当下,悲观锁已经越来越少的被使用到生产环境中了,尤其是并发量比较大的业务场景。