【分布式锁的演化】经常使用锁的种类以及解决方案

前言

上一篇分布式锁的文章中,经过超市存放物品的例子和你们简单分享了一下Java锁。本篇文章咱们就来深刻探讨一下Java锁的种类,以及不一样的锁使用的场景,固然本篇只介绍咱们经常使用的锁。咱们分为两大类,分别是乐观锁和悲观锁,公平锁和非公平锁。java

乐观锁和悲观锁

乐观锁

老猫相信,不少的技术人员首先接触到的就是乐观锁和悲观锁。老猫记得那时候是在大学的时候接触到,当时是上数据库课程的时候。当时的应用场景主要是在更新数据的时候,固然多年工做以后,其实咱们也知道了更新数据也是使用锁很是主要的场景之一。咱们来回顾一下通常更新的步骤:数据库

  1. 检索出须要更新的数据,提供给操做人查看。
  2. 操做人员更改须要修改的数值。
  3. 点击保存,更新数据。

这个流程看似简单,可是若是一旦多个线程同时操做的时候,就会发现其中隐藏的问题。咱们具体看一下:编程

  1. A检索到数据;
  2. B检索到数据;
  3. B修改了数据;
  4. A修改了数据,是否可以修改为功呢?

上述第四点A是否可以修改为功固然要看咱们的程序如何去实现。就从业务上来说,当A保存数据的时候,最好的方式应该系统给出提示说“当前您操做的数据已被其余人修改,请从新查询确认”。这种实际上是最合理的。安全

那么这种方式咱们该如何实现呢?咱们看一下步骤:多线程

  1. 在检索数据的时候,咱们将相关的数据的版本号(version)或者最后的更新时间一块儿检索出来。
  2. 当操做人员更改数据以后,点击保存的时候在数据库执行update操做。
  3. 当执行update操做的时候,用步骤1检索出的版本号或者最后的更新时间和数据库中的记录作比较;
  4. 若是版本号或者最后更新时间一致,那么就能够更新。
  5. 若是不一致,咱们就抛出上述提示。

其实上述流程就是乐观锁的实现思路。在Java中乐观锁并无肯定的方法,或者关键字,它只是一个处理的流程、策略或者说是一种业务方案。看完这个以后咱们再看一下Java中的乐观锁。并发

乐观锁,它是假设一个线程在取数据的时候不会被其余线程更改数据。就像上述描述相似,可是只有在更新的时候才会去校验数据是否被修改过。其实这种就是咱们常常听到的CAS机制,英文全称(Compare And Swap),这是一种比较交换机制,一旦检测到有冲突。它就会进行重试。直到最后没有冲突为止。分布式

乐观锁机制图示以下:
乐观锁
下面咱们来举个例子,相信不少同窗都是C语言入门的编程,老猫也是,你们应该都接触过i++,那么如下咱们就用i++作例子,看看i++是不是线程安全的,多个线程并发执行的时候会存在什么问题。咱们看一下下面的代码:ui

/**
 * @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,可是咱们连续运行三次,获得的结果以下:this

执行完成后,i=4975
执行完成后,i=4955
执行完成后,i=4968

(注:可能有小伙伴不清楚CountDownLatch,简单说明一下,该类其实就是一个计数器,初始化的时候构造器传了5000表示会执行5000次, 这个类使一个线程等待其余线程各自执行完毕后再执行,cdl.countDown()这个方法指的就是将构造器参数减一。具体的能够自行问度娘,在此老猫也是展开 )spa

从上面的结果咱们能够看到,每次结果都不一样,反正也不是5000,那么这个是为何呢?其实这就说明i++程序并非一个原子性的,多线程的状况下存在线程安全性的问题。咱们能够将详细执行步骤进行一下拆分。

  1. 从内存中取出i的值
  2. 将i的值+1
  3. 将计算完毕的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其实默认的就是用到了非公平锁。那何时用到公平锁呢?其实业务场景也是比较常见的,就是在电商秒杀的时候,公平锁的模型就被套用上了。

再往下写估计你们就不想看了,因此此篇幅到此结束了,后续陆陆续续会和你们分享分布式锁的演化过程,以及分布式锁的实现,敬请期待。

相关文章
相关标签/搜索