关于分布式系统:分布式锁的演化常用锁的种类以及解决方案

3次阅读

共计 7049 个字符,预计需要花费 18 分钟才能阅读完成。

前言

上一篇分布式锁的文章中,通过超市寄存物品的例子和大家简略分享了一下 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 ++ 是否是线程平安的,多个线程并发执行的时候会存在什么问题。咱们看一下上面的代码:

/**
 * @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 ++ 程序并不是一个原子性的,多线程的状况下存在线程安全性的问题。咱们能够将具体执行步骤进行一下拆分。

  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 其实默认的就是用到了非偏心锁。那什么时候用到偏心锁呢?其实业务场景也是比拟常见的,就是在电商秒杀的时候,偏心锁的模型就被套用上了。

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

正文完
 0