关于单机 & 分布式锁的那些事

关于锁,是作为一个开发人员无法逃避的问题。本文主要在于介绍java开发中用到的几种锁的大致原理。有问题欢迎拍砖~~

单vm下的锁

    在一个java进程内,我们如果想对某个共享资源加锁,java提供了几种方式,最常用的莫过于synchronized和基于AQS的ReentrantLock了。

· synchronized

    synchronized是jvm帮我们实现的锁,可对某个对象加锁,释放锁由jvm自动释放。synchronized提供的锁是一种可重入锁,可重入锁的介绍参考:点击我可看【重入锁辨析】 ,可重入锁大致意思可总结为,程序在获取锁后,在释放锁之前,可再次进行锁获取。这种锁带来的最大好处便是解决了死锁,以及代码重用。举个例子:

public class SynchronizedTest {
    //该方法被调用了,则锁住对象
    public synchronized void method1() {
        System.out.println("method1被调用了");
    }
    //该方法被调用了,则锁住对象
    public synchronized void method2() {
        method1();//这里又一次获取锁并锁了对象 如果synchronized不支持重入锁,那么这里会发生死锁(锁被当前线程占有了,此时又获取锁)
        System.out.println("method2被调用了");
    }
    public static void main(String[] args) {
        new SynchronizedTest().method2();
    }
}
 

    以上代码,除了体现了synchronized重入锁避免死锁的情况,还体现了代码重用的思想。试想,如果为了不出现死锁,我们再写一个不加synchronized的method1方法method3,method2里不调加synchronized的method1方法而是调method3不就行了吗?但是,这样就暴露了一个不是线程安全的method3方法,代码就不好维护了。

· ReentrantLock

    这是jdk1.5后提供的一个基于java实现的锁,顾名思义,它是支持可重入的。ReentrantLock基于AQS(AbstractQueuedSynchronizer),提供了公平锁和非公平锁两种实现。举个例子看看怎么使用ReentrantLock的重入锁:

public class ReentrantLockTest {
 
    protected ReentrantLock lock = new ReentrantLock();
 
    public void parentMethod() {
        lock.lock();
        try {
            System.out.println("parentMethod被调用了");
        } finally {
            lock.unlock();
        }
    }
 
    static class ReentrantLockTest2 extends ReentrantLockTest {
 
        @Override
        public void parentMethod() {
            lock.lock();
            try {
                super.parentMethod();//这里又一次获取锁并锁了对象 如果ReentrantLock不支持重入锁,那么这里会发生死锁(锁被当前线程占有了,此时又获取锁)
                doOtherThings();
            } finally {
                lock.unlock();
            }
        }
 
        public void doOtherThings() {
            System.out.println("doOtherThings被调用了");
        }
    }
 
    public static void main(String[] args) {
        new ReentrantLockTest2().parentMethod();
    }
}
 

    那么,它是如何保证可重入的呢?因为它是基于java实现的,我们可以很容易的看到代码。有兴趣的可以自己去debug跟踪一遍加锁和解锁的代码。
大致原理是,在AQS里:
1、维护了一个变量,保存了当前加锁的线程。
2、维护了一个状态字段state,这个状态字段用来标记重入次数,lock了N次,那么这个值就为N,释放锁的时候我们也需要unlock N次。
3、维护了一个队列,用以保存当前未获取到锁的线程。当线程未获取到锁时就会被挂起,状态为waiting,只有当持有锁的线程释放所有锁,也就是state为0的时候,才会唤醒这些等待线程中的一个。唤醒策略就看你用的是公平锁还是非公平锁了。

    关于synchronized和ReentrantLock两者的比较,什么时候该使用哪个,网上资料也很多,推荐一篇文章:点击我可看【JDK 5.0中更灵活更具可伸缩性的锁定机制】

分布式锁

    以上介绍的为处理单vm里的共享资源可使用的锁,那如果我们要处理多vm之间共享的资源要怎么办?显然synchronized和ReentrantLock都不能满足这种应用场景。我们需要一种能在多vm之间解决共享资源锁定的机制。redis和zookeeper提供了一些重要的特性,利用这些特性,我们可以实现分布式锁。以下我大致说一下这两种方式实现分布式锁的原理,以及我们经常会遇到的一些问题。

· 基于Redis的分布式锁

    如果要实现redis分布式锁,我们应该怎么做呢?参考单VM下的ReentrantLock,我们需要有一个锁对象。我们可以这么做:
1、创建一个key,这个key表示一个锁对象。
2、利用redis 的setnx命令,哪个机器上的线程,执行该命令成功,则表示获取到锁,否则,获取锁失败。
3、释放锁,直接delete这个key即可。

    但是上面这个过程,会有一个问题:假设某个机器加锁成功了,在释放锁之前,宕机了,怎么办?这个锁没人释放了,其他线程也就无法获取到锁,导致出现饿死情况。
    针对这个问题,我们可以在setnx时给这个key设置一个值,这个值是过期时间,字符串类型。线程在获取锁失败情况下,去校验key对应的value,如果发现时间过期,则重新进行锁竞争。假设出现了key过期情况,线程该如何重新获取锁呢?还是用setnx?显然不行?有其他的命令可以用吗?我们可以用getSet命令,这个命令会给这个key设置一个新值,然后返回key的旧值。通过这个特性,可以实现重新获取锁的方式。过程大致如下给出的伪代码:

public boolean tryLock(String key) {
        String expireTimeStr = String.valueOf(System.currentTimeMillis() + (1000 * 60));
        if (redis.setnx(key, expireTimeStr)) {
            //加锁成功,返回true
            return true;
        }
        //加锁失败,则获取key当前的过期时间
        String oldExpireTimeStr = redis.get(key);
        if (isTimeout(oldExpireTimeStr)) {
            String retryGetOldExpireTimeStr = redis.getSet(key, expireTimeStr);
            //如果相等,说明获取到锁,根据getSet的语义不难理解,这里实现的是一个抢占获取锁方式.
            //锁竞争情况下,其他线程也能执行getSet命令成功,但是我们认为只有这个命令返回的是我们期望的旧值,则表示当前线程获取到锁
            if (retryGetOldExpireTimeStr.equals(oldExpireTimeStr)) {
                return true;
            }
        }
        return false;
    }
 
    以上方式,可实现redis分布式锁的功能,但是却无法满足可重入。看看上面伪代码,想想看,如果程序使用以上代码进行重入锁调用,会有什么问题呢?
· 基于Redis的可重入分布式锁

    上面说的问题,很显然可能导致的后果就是锁失效。那我们应该如何设计一个可重入的redis分布式锁呢?参考ReentranLock的设计思路,我们需要在加锁时保存当前加锁的线程,以及加锁的次数。这两个信息我们需要和过期时间,一起储存到value里,value结构如下:
class LockInfo {
String EXPIRE_TIME; //锁过期时间
int COUNT; //重入次数
String CURR_THREAD; // 当前加锁线程,因为是分布式,所以这里可以为 UUID + 线程id;
……
}
我们来看看以下伪代码,如何实现重入锁加锁:

public boolean tryLock(String key) {
        LockInfo newLockInfo = LockInfo.newInstance();
        if (redis.setnx(key, newLockInfo.toJson())) {
            //加锁成功,返回true
            return true;
        }
        //加锁失败,则获取key当前的过期时间
        LockInfo oldLockInfo = LockInfo.fromJson(redis.get(key));
        if (isTimeout(oldLockInfo)) {
            String retryGetOldExpireTimeStr = redis.getSet(key, newLockInfo.toJson());
            //如果相等,说明获取到锁,根据getSet的语义不难理解,这里实现的是一个抢占获取锁方式.
            //锁竞争情况下,其他线程也能执行getSet命令成功,但是我们认为只有这个命令返回的是我们期望的旧值,则表示当前线程获取到锁
            if (retryGetOldExpireTimeStr.equals(oldLockInfo.toJson())) {
                return true;
            }
        } else {
            //重入锁逻辑
            //是否被当前线程持有锁
            if (isLockedByCurrThread(oldLockInfo)) {
                oldLockInfo.reSetExpireTime();//重设过期时间
                oldLockInfo.incCount(1);//计数器加一
                redis.set(key, oldLockInfo.toJson());//更新
                return true;
            }
        }
        return false;
    }

    unlock方法,其实就是对count计数器进行减一。减一前如果count为1,则删除key,释放所有重入的锁。
    其实,redis实现分布式锁方式还是有很多问题的,如
1、加锁时间超过过期时间,如过期时间设置不合理,或者有个别情况加锁时间就是大于超时时间。
2、性能问题,获取锁失败的线程一直在sleep,然后重试,无法像java的ReentrantLock实现锁释放后的自动唤醒等待线程
    Redisson提供了以上问题的解决方案,但是个人认为有点重。点击我查看【基于Redis实现分布式锁,Redisson使用及源码分析】

    zookeeper天生可解决以上几个问题,我们来一起看下zk如何实现分布式锁。

· 基于zk的分布式锁

    了解zk的人一定知道zk节点大致分为四种,一种是持久节点,一种是临时节点,还有两种是前两种的时序节点。

  • 持久节点:创建后一直存在,除非主动删除。
  • 临时节点:客户端创建后,节点的生命周期和客户端会话保持一致,当客户端宕机了,那么这个节点就会被zk自动删除。
  • 持久时序节点:在某个节点A下,创建的持久时序节点,节点序号是递增的。A节点会维护一份子节点的先后顺序。
  • 临时时序节点:同持久时序节点类似。

    利用临时节点的特性,我们可以用来实现分布式锁功能:客户端通过往某个节点下创建一个临时节点,创建成功,则认为获取到锁,否则获取失败。
为什么用临时节点呢?因为使用临时节点,我们就不需要设置过期时间了。想想我们前面讲的为什么redis需要设置过期时间呢?因为可能在获取锁后,释放锁之前,机器宕机了,锁就无法释放了。而zk的临时节点天生就不会出现这种情况,强调一下,它具有当机器宕机,临时节点自动被删除的特性。

    我们采用临时时序节点来做分布式锁,一个线程获取锁的流程大致如下:

1、加锁时,客户端在某个节点下创建时序临时节点,节点数据可以为客户端生成的某个值(如机器ip+线程号或者UUID)
2、客户端获取节点下的所有节点,查看编号最小的临时节点上的数据是否是当前线程的。
     2.1、 是则加锁成功。
     2.2、否则的话加锁失败,并且监听节点编号比自己小的节点,如果监听的节点被删除了,则重新获取锁。(实现了公平锁机制,即线程获取锁的顺序是有序的)

    如何实现基于zk的可重入锁?

· 基于zk的可重入分布式锁

    大致流程和以上差不多,只是我们在临时节点上多存一些数据,如当前客户端的主机信息和线程信息以及重入次数。获取锁的时候我们可判断最小节点上的主机和线程信息是否是当前加锁的主机上和线程。是的话,认为是重入了,重入次数加1。否则新创建临时节点。

其实还有一种用数据库实现的分布式锁,本文没有提及。推荐一篇文章,这篇文章中比较了几种分布式锁的优缺点,包含了数据库实现分布式锁方式,大家可看看:点击我可看【分布式锁的几种实现方式】

相关推荐