【分布式锁的演化】常用锁的种类以及解决方案
前言
上一篇分布式锁的文章中,通过超市存放物品的例子和大家简单分享了一下Java锁。本篇文章我们就来深入探讨一下Java锁的种类,以及不同的锁使用的场景,当然本篇只介绍我们常用的锁。我们分为两大类,分别是乐观锁和悲观锁,公平锁和非公平锁。
乐观锁和悲观锁
乐观锁
老猫相信,很多的技术人员首先接触到的就是乐观锁和悲观锁。老猫记得那时候是在大学的时候接触到,当时是上数据库课程的时候。当时的应用场景主要是在更新数据的时候,当然多年工作之后,其实我们也知道了更新数据也是使用锁非常主要的场景之一。我们来回顾一下一般更新的步骤:
-
检索出需要更新的数据,提供给操作人查看。
-
操作人员更改需要修改的数值。
-
点击保存,更新数据。
这个流程看似简单,但是如果一旦多个线程同时操作的时候,就会发现其中隐藏的问题。我们具体看一下:
- A检索到数据;
- B检索到数据;
- B修改了数据;
- A修改了数据,是否能够修改成功呢?
上述第四点A是否能够修改成功当然要看我们的程序如何去实现。就从业务上来讲,当A保存数据的时候,最好的方式应该系统给出提示说“当前您操作的数据已被其他人修改,请重新查询确认”。这种其实是最合理的。
那么这种方式我们该如何实现呢?我们看一下步骤:
- 在检索数据的时候,我们将相关的数据的版本号(version)或者最后的更新时间一起检索出来。
- 当操作人员更改数据之后,点击保存的时候在数据库执行update操作。
- 当执行update操作的时候,用步骤1检索出的版本号或者最后的更新时间和数据库中的记录做比较;
- 如果版本号或者最后更新时间一致,那么就可以更新。
- 如果不一致,我们就抛出上述提示。
其实上述流程就是乐观锁的实现思路。在Java中乐观锁并没有确定的方法,或者关键字,它只是一个处理的流程、策略或者说是一种业务方案。看完这个之后我们再看一下Java中的乐观锁。
乐观锁,它是假设一个线程在取数据的时候不会被其他线程更改数据。就像上述描述类似,但是只有在更新的时候才会去校验数据是否被修改过。其实这种就是我们经常听到的CAS机制,英文全称(Compare And Swap),这是一种比较交换机制,一旦检测到有冲突。它就会进行重试。直到最后没有冲突为止。
乐观锁机制图示如下:
下面我们来举个例子,相信很多同学都是C语言入门的编程,老猫也是,大家应该都接触过i++,那么以下我们就用i++做例子,看看i++是否是线程安全的,多个线程并发执行的时候会存在什么问题。我们看一下下面的代码:
/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i=0;
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.i++;
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上面的程序中,我们用50个线程同时执行i++程序,总共执行5000次,按照常规的理解,得到的应该是5000,但是我们连续运行三次,得到的结果如下:
执行完成后,i=4975
执行完成后,i=4955
执行完成后,i=4968
(注:可能有小伙伴不清楚CountDownLatch,简单说明一下,该类其实就是一个计数器,初始化的时候构造器传了5000表示会执行5000次, 这个类使一个线程等待其他线程各自执行完毕后再执行,cdl.countDown()这个方法指的就是将构造器参数减一。具体的可以自行问度娘,在此老猫也是展开 )
从上面的结果我们可以看到,每次结果都不同,反正也不是5000,那么这个是为什么呢?其实这就说明i++程序并不是一个原子性的,多线程的情况下存在线程安全性的问题。我们可以将详细执行步骤进行一下拆分。
- 从内存中取出i的值
- 将i的值+1
- 将计算完毕的i重新放入到内存中
其实这个流程和我们之前说到的数据的流程是一样的。只不过是介质不同,一个是内存,另一个是数据库。在多个线程的情况下,我们想象一下,假如A线程和B线程同时同内存中取出i的值,假如i的值都是50,然后两个线程都同时进行了+1的操作,然后在放入到内存中,这时候内存的值是51,但是我们期待的是52。这其实就是上述为什么一直无法达到5000的原因。那么我们如何解决这个问题?其实在Java1.5之后,JDK的官网提供了大量的原子类,这些类的内部都是基于CAS机制的,也就是说使用了乐观锁。我们更改一下代码,如下:
/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private AtomicInteger i= new AtomicInteger(0);
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.i.incrementAndGet();
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
此时我们得到的结果如下,执行三次:
执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000
结果看来是我们所期待的,以上的改造我们可以看到,我们将原来int类型的变量更改成了 AtomicInteger,该类是一个原子类属于concurrent包(有兴趣的小伙伴可以研究一下这个包下面的一些类)我们将原来的i++的地方改成了test.i.incrementAndGet(),incrementAndGet这个方法采用得了CAS机制。也就是说采用了乐观锁,所以我们以上的结果是正确的。
我们对乐观锁进行一下总结,其实乐观锁就是在读取数据的时候不加任何限制条件,但是在更新数据的时候,进行数据的比较,保证数据版本的一致之后采取更新相关的数据信息。由于这个特点,所以我们很容易可以看出乐观锁比较试用于读操作大于写操作的场景中。
悲观锁
我们再一起看一下悲观锁,也是通过这个例子来说明一下。悲观锁其实和乐观锁不同,悲观锁从读取数据的时候就显示地去加锁,直到数据最后更新完成之后,锁才会被释放。这个期间只能由一个线程去操作。其他线程只能等待。其实上一篇文章中我们就用到了 synchronized
关键字 ,其实这个关键字就是悲观锁。与其相同的其实还有ReentrantLock
类也可以实现悲观锁。那么以下我们再使用synchronized
关键字 和 ReentrantLock
进行悲观锁的改造。具体代码如下:
/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i= 0;
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
synchronized (test){
test.i++;
}
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
以上我们的改动就是新增了synchronized
代码块,它锁住了test的对象,在所有的线程中,谁获取到了test的对象,谁就能执行i++操作(此处锁test是因为test只有一个)。这样我们采用了悲观锁的方式我们的结果当然也是OK的执行完毕之后三次输出如下:
执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000
再看一下ReentrantLock
类实现悲观锁,代码如下:
/**
* @author kdaddy@163.com
* @date 2020/12/15 22:42
*/
public class NumCountTest {
private int i= 0;
Lock lock = new ReentrantLock();
public static void main(String[] args) {
NumCountTest test = new NumCountTest();
//线程池:50个线程
ExecutorService es = Executors.newFixedThreadPool(50);
//闭锁
CountDownLatch cdl = new CountDownLatch(5000);
for (int i = 0;i < 5000; i++){
es.execute(()->{
test.lock.lock();
test.i++;
test.lock.unlock();
cdl.countDown();
});
}
es.shutdown();
try {
//等待5000个任务执行完成后,打印出执行结果
cdl.await();
System.out.println("执行完成后,i="+test.i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
用法如上,其实也不用太多介绍,小伙伴们看代码即可,上述通过lock加锁,通过unlock释放锁。当然我们三次执行完毕之后结果也是OK的。
执行完成后,i=5000
执行完成后,i=5000
执行完成后,i=5000
三次执行下来都是5000,完全没有问题。
我们再来总结一下悲观锁,悲观锁其实就是从读取数据的那一刻就加了锁,而且在更新数据的时候,保证只有一个线程在执行更新操作,并没有如乐观锁那种进行数据版本的比较。所以可想而知,悲观锁适用于读取相对少,写相对多的操作中。
公平锁和非公平锁
前面和小伙伴们分享了乐观锁和悲观锁,下面我们就来从另外一个维度去认识一下锁。公平锁和非公平锁。顾名思义,公平锁在多线程的情况下,对待每个线程都是公平的,然而非公平锁确是恰恰相反的。就光这么和小伙伴们同步,估计大家还会有点迷糊。我们还是以之前的储物柜来说明,去超市买东西,储物柜只有一个,正好有A、B、C三个人想要用柜子,这时候A来的比较早,所以B和C自觉进行排队,A用完之后,后面排着队的B才会去使用,这就是公平锁。在公平锁中,所有的线程都会自觉排队,一个线程执行完毕之后,后续的线程在依次进行执行。
然而非公平锁则不然,当A使用完毕之后,A将钥匙往后面的一群人中一丢,谁先抢到,谁就可以使用。我们大概可以用以下两个示意图来体现,如下:
对应的多线程中,线程A先抢到了锁,A就可以执行方法,其他的线程则在队列中进行排队,A执行完毕之后,会从队列中获取下一个B进行执行,依次类推,对于每个线程来说都是公平的,不存在后加入的线程先执行的情况。
多线程同时执行方法的时候,线程A抢到了锁,线程A先执行方法,其他线程并没有排队。当A执行完毕之后,其他的线程谁抢到了锁,谁就能执行方法。这样就可能存在后加入的线程,反而先拿到锁。
关于公平锁和非公平锁,其实在我们的ReentrantLock
类中就已经给出了实现,我们来看一下源码:
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
该类中有两个构造方法,从字面上来看默认的构造方法中 sync = new NonfairSync()是一个非公平锁。再看看第二个构造方法,需要传入一个参数,true是的时候是公平锁,false的时候是非公平锁。以上我们可以看到sync有两个实现类,分别是FairSync以及NonfairSync,我们再来看一下获取锁的核心方法。
获取公平锁:
@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
非公平锁:
@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
以上两个方法,我们很容易就能发现唯一的不同点就是 !hasQueuedPredecessors() 这个方法,从名字上来看就知道这个是一个队列,因此我们也就可以推断,公平锁是将所有的线程放到一个队列中,一个线程执行完成之后,从队列中区所下一个线程。而非公平锁则没有这样的队列。这些就是公平锁和非公平锁的实现原理。这里也不去再深入去看源码了,我们重点是了解公平锁和非公平锁的含义。我们在使用的时候传入true或者false即可。
总结
其实在Java中锁的种类非常的多,在此老猫只介绍了常用的几种,有兴趣的小伙伴其实还可以去钻研一下独享锁、共享锁、互斥锁、读写锁、可重入锁、分段锁等等。
乐观锁和非乐观锁是最基础的,我们在工作中肯定接触的也比较多。
从公平非公平锁的角度,大家如果用到ReetrantLock
其实默认的就是用到了非公平锁。那什么时候用到公平锁呢?其实业务场景也是比较常见的,就是在电商秒杀的时候,公平锁的模型就被套用上了。
再往下写估计大家就不想看了,所以此篇幅到此结束了,后续陆陆续续会和大家分享分布式锁的演化过程,以及分布式锁的实现,敬请期待。