加载中...


悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。像Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。悲观锁通常多用于写比较多的情况下(多写场景),避免频繁失败和重试影响性能。悲观锁适用于数据库高并发的大量写入操作,本次事务提交之前(事务提交时会释放事务过程中的锁),外界无法修改这些记录。具体使用:mysql数据库首先要设置事务不允许自动提交set autocommit=0然后select name from table where id = ? for update将查询的结果上锁,这样的话其他得事务查询的话如果不加for update能够正常查询,否则会一直显示正在查询中 并且其他事务不可以对此数据做增删改操作,当上锁的查询语句的事务提交后,即执行commit后其他事务才可以对数据进行其他的操作。悲观锁可以避免冲突的发生,但是会降低效率。
如果查询条件用了索引/主键,那么select ….. for update就会进行锁行。
如果是普通字段(没有索引/主键),那么select ….. for update就会进行锁表。

乐观锁

乐观锁实现

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或CAS算法)。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。

乐观锁大多是基于数据版本(Version)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个“version”字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。对于上面修改用户帐户信息的例子而言,假设数据库中帐户信息表中有一个version字段,当前值为1;而当前帐户余额字段(balance)为$100。操作员A此时将其读出(version=1),并从其帐户余额中扣除$50($100-$50)。2在操作员A操作的过程中,操作员B也读入此用户信息(version=1),并从其帐户余额中扣除$20($100-$20)。3操作员A完成了修改工作,将数据版本号加一(version=2),连同帐户扣除后余额(balance=$50),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录version更新为2。4操作员B完成了操作,也将版本号加一(version=2)试图向数据库提交数据(balance=$80),但此时比对数据库记录版本时发现,操作员B提交的数据版本号为2,数据库记录当前版本也为2,不满足“提交版本必须大于记录当前版本才能执行更新“的乐观锁策略,因此,操作员B的提交被驳回。这样,就避免了操作员B用基于version=1的旧数据修改的结果覆盖操作员A的操作结果的可能。从上面的例子可以看出,乐观锁机制避免了长事务中的数据库加锁开销(操作员A和操作员B操作过程中,都没有对数据库数据加锁),大大提升了大并发量下的系统整体性能表现。需要注意的是,乐观锁机制往往基于系统中的数据存储逻辑,因此也具备一定的局限性,如在上例中,由于乐观锁机制是在我们的系统中实现,来自外部系统的用户余额更新操作不受我们系统的控制,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况出现的可能性,并进行相应调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开)

update table set status=#{status},name=#{name},version=version+1 where id=#{id} and version=#{version}

乐观锁存在哪些问题

1. ABA问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的ABA问题。ABA问题的解决思路是在变量前面追加上版本号或者时间戳。JDK1.5以后的AtomicStampedReference类就是用来解决ABA问题的,其中的compareAndSet()方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}

2. 循环时间长开销大

CAS经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用:

  1. 可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起CPU流水线被清空,从而提高CPU的执行效率。

3. 只能保证一个共享变量的原子操作

CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。但是从JDK1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

分布式锁

分布式锁介绍

对于单机多线程来说,在Java中,我们通常使用ReetrantLock类、synchronized关键字这类JDK自带的本地锁来控制一个JVM进程内的多个线程对本地共享资源的访问。这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。分布式系统下,不同的服务/客户端通常运行在独立的JVM进程上。如果多个JVM进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁就诞生了。举个例子:系统的订单服务一共部署了3份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的JVM进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个JVM进程中也能获取到同一把锁,进而实现共享资源的互斥访问。这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。一个最基本的分布式锁需要满足:

  • 互斥:任意一个时刻,锁只能被一个线程持有;
  • 高可用:锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

通常情况下,我们一般会选择基于Redis或者ZooKeeper实现分布式锁,Redis用的要更多一点,我这里也以Redis为例介绍分布式锁的实现。

基于Redis实现分布式锁

如何基于Redis实现一个最简易的分布式锁?

不论是本地锁还是分布式锁,核心都在于“互斥”。在Redis中,SETNX命令是可以帮助我们实现互斥。SETNXSET if Not Exists(对应Java中的setIfAbsent方法),如果key不存在的话,才会设置key的值。如果key已经存在,SETNX啥也不做。

> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0

释放锁的话,直接通过DEL命令删除对应的key即可。

> DEL lockKey
(integer) 1

为了防止误删到其他的锁,这里我们建议使用Lua脚本通过key对应的value(唯一值)来判断。选用Lua脚本是为了保证解锁操作的原子性。因为Redis在执行Lua脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

// 释放锁时,先比较锁对应的value值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这是一种最简易的Redis分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。

为什么要给锁设置一个过期时间?

为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个key(也就是锁)设置一个过期时间。

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
  • lockKey:加锁的锁名;
  • uniqueValue:能够唯一标示锁的随机字符串;
  • NX:只有当lockKey对应的key值不存在的时候才能SET成功;
  • EX:过期时间设置(秒为单位)EX3标示这个锁有一个3秒的自动过期时间。与EX对应的是PX(毫秒为单位),这两个都是过期时间设置。

一定要保证设置指定key的值和过期时间是一个原子操作!!不然的话,依然可能会出现锁无法被释放的问题。这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。你或许在想:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!

如何实现锁的优雅续期?

对于Java开发的小伙伴来说,已经有了现成的解决方案:Redisson。其他语言的解决方案,可以在Redis官方文档中找到

DistributedlockswithRedis

Redisson是一个开源的Java语言Redis客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson还支持Redis单机、RedisSentinel、RedisCluster等多种部署架构。Redisson中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的WatchDog(看门狗),如果操作共享资源的线程还未执行完成的话,WatchDog会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。看门狗名字的由来于getLockWatchdogTimeout()方法,这个方法返回的是看门狗给锁续期的过期时间,默认为30秒(redisson-3.17.6)。

// 默认30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;

public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
    this.lockWatchdogTimeout = lockWatchdogTimeout;
    return this;
}
public long getLockWatchdogTimeout() {
  	return lockWatchdogTimeout;
}

renewExpiration()方法包含了看门狗的主要逻辑:

private void renewExpiration() {
         //......
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                //......
                // 异步续期,基于Lua脚本
                CompletionStage<Boolean> future = renewExpirationAsync(threadId);
                future.whenComplete((res, e) -> {
                    if (e != null) {
                        // 无法续期
                        log.error("Can't update lock " + getRawName() + " expiration", e);
                        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                        return;
                    }

                    if (res) {
                        // 递归调用实现续期
                        renewExpiration();
                    } else {
                        // 取消续期
                        cancelExpirationRenewal(null);
                    }
                });
            }
         // 延迟internalLockLeaseTime/3(默认10s,也就是30/3)再调用
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        ee.setTimeout(task);
    }

默认情况下,每过10秒,看门狗就会执行续期操作,将锁的超时时间设置为30秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。WatchDog通过调用renewExpirationAsync()方法实现锁的异步续期:

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为30s(默认)
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

可以看出,renewExpirationAsync方法其实是调用Lua脚本实现的续期,这样做主要是为了保证续期操作的原子性。这里以Redisson的分布式可重入锁RLock为例来说明如何使用Redisson实现分布式锁:

// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备Watch Dog自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();

只有未指定锁超时时间,才会使用到Watch Dog自动续期机制。

// 手动给锁设置过期时间,不具备Watch Dog自动续期机制
lock.lock(10, TimeUnit.SECONDS);

如果使用Redis来实现分布式锁的话,还是比较推荐直接基于Redisson来做的。

如何实现可重入锁?

所谓可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入,而无需重新获得锁。像Java中的synchronized和ReentrantLock都属于可重入锁。不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁。可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于0时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。实际项目中,我们不需要自己手动实现,推荐使用我们上面提到的Redisson,其内置了多种类型的锁比如可重入锁(ReentrantLock)、自旋锁(SpinLock)、公平锁(FairLock)、多重锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)。

Redis如何解决集群情况下分布式锁的可靠性?

为了避免单点故障,生产环境下的Redis服务通常是集群化部署的。Redis集群下,上面介绍到的分布式锁的实现会存在一些问题。由于Redis集群数据同步到各个节点时是异步的,如果在Redis主节点获取到锁后,在没有同步到其他节点时,Redis主节点宕机了,此时新的Redis主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。针对这个问题,Redis之父antirez设计了Redlock算法来解决。Redlock算法的思想是让客户端向Redis集群中的多个独立的Redis实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。即使部分Redis节点出现问题,只要保证Redis集群中有半数以上的Redis节点可用,分布式锁服务就是正常的。Redlock是直接操作Redis节点的,并不是通过Redis集群操作的,这样才可以避免Redis集群主从切换导致的锁丢失问题。Redlock实现比较复杂,性能比较差,发生时钟变迁的情况下还存在安全性隐患。《数据密集型应用系统设计》一书的作者MartinKleppmann曾经专门发文(Howtododistributedlocking-MartinKleppmann-2016)怼过Redlock,他认为这是一个很差的分布式锁实现。感兴趣的朋友可以看看Redis锁从面试连环炮聊到神仙打架这篇文章,有详细介绍到antirez和MartinKleppmann关于Redlock的激烈辩论。实际项目中不建议使用Redlock算法,成本和收益不成正比。如果不是非要实现绝对可靠的分布式锁的话,其实单机版Redis就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于ZooKeeper来做,只是性能会差一些。

图解Redis分布式锁

基于ZooKeeper实现分布式锁

Redis实现分布式锁性能较高,ZooKeeper实现分布式锁可靠性更高。实际项目中,我们应该根据业务的具体需求来选择。

如何基于ZooKeeper实现分布式锁?

客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推;客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;执行业务代码;完成业务流程后,删除对应的子节点释放锁。ZooKeeper分布式锁是基于临时顺序节点和Watcher(事件监听器) 实现的。

获取锁:

  1. 首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点。
  2. 假设客户端1创建了/locks/lock1节点,创建成功之后,会判断lock1是否是/locks下最小的子节点。
  3. 如果lock1是最小的子节点,则获取锁成功。否则,获取锁失败。
  4. 如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端1并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端1(避免无效自旋),这样客户端1就加锁成功了。

释放锁:

  1. 成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。
  2. 成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。
  3. 我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。

实际项目中,推荐使用Curator来实现ZooKeeper分布式锁。Curator是Netflix公司开源的一套ZooKeeper Java客户端框架,相比于ZooKeeper自带的客户端zookeeper来说,Curator的封装更加完善,各种API都可以比较方便地使用。Curator主要实现了下面四种锁:

  • InterProcessMutex:分布式可重入排它锁
  • InterProcessSemaphoreMutex:分布式不可重入排它锁
  • InterProcessReadWriteLock:分布式读写锁
  • InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。
CuratorFramework client = ZKUtils.getClient();
client.start();
// 分布式可重入排它锁
InterProcessLock lock1 = new InterProcessMutex(client, lockPath1);
// 分布式不可重入排它锁
InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2);
// 将多个锁作为一个整体
InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));

if (!lock.acquire(10, TimeUnit.SECONDS)) {
  	throw new IllegalStateException("不能获取多锁");
}
System.out.println("已获取多锁");
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
try {
    // 资源操作
 	 		resource.use(); 
} finally {
    System.out.println("释放多个锁");
    lock.release(); 
}
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
client.close();

为什么要用临时顺序节点?

每个数据节点在ZooKeeper中被称为znode,它是ZooKeeper中数据的最小单元。我们通常是将znode分为4大类:

  • 持久(PERSISTENT)节点:一旦创建就一直存在即使ZooKeeper集群宕机,直到将其删除。
  • 临时(EPHEMERAL)节点:临时节点的生命周期是与客户端会话(session)绑定的,会话消失则节点消失。并且,临时节点只能做叶子节点,不能创建子节点。
  • 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外,子节点的名称还具有顺序性。比如/node1/app0000000001/node1/app0000000002
  • 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。

可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。

使用Redis实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而ZooKeeper直接利用临时节点的特性即可。假设不适用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。

为什么要设置对前一个节点的监听?

Watcher(事件监听器),是ZooKeeper中的一个很重要的特性。ZooKeeper允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候,ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是ZooKeeper实现分布式协调服务的重要特性。

同一时间段内,可能会有很多客户端同时获取锁,但只有一个可以获取成功。如果获取锁失败,则说明有其他的客户端已经成功获取锁。获取锁失败的客户端并不会不停地循环去尝试加锁,而是在前一个节点注册一个事件监听器。这个事件监听器的作用是:当前一个节点对应的客户端释放锁之后(也就是前一个节点被删除之后,监听的是删除事件),通知获取锁失败的客户端(唤醒等待的线程,Java中的wait/notifyAll),让它尝试去获取锁,然后就成功获取锁了。

如何实现可重入锁?

这里以Curator的InterProcessMutex对可重入锁的实现来介绍(源码地址:InterProcessMutex.java)。当我们调用InterProcessMutex#acquire方法获取锁的时候,会调用InterProcessMutex#internalLock方法。

// 获取可重入互斥锁,直到获取成功为止
@Override
public void acquire() throws Exception {
  if (!internalLock(-1, null)) {
    throw new IOException("Lost connection while trying to acquire lock: " + basePath);
  }
}

internalLock方法会先获取当前请求锁的线程,然后从threadData(ConcurrentMap<Thread,LockData>类型)中获取当前线程对应的lockData。lockData包含锁的信息和加锁的次数,是实现可重入锁的关键。第一次获取锁的时候,lockData为null。获取锁成功之后,会将当前线程和对应的lockData放到threadData中

private boolean internalLock(long time, TimeUnit unit) throws Exception {
  // 获取当前请求锁的线程
  Thread currentThread = Thread.currentThread();
  // 拿对应的lockData
  LockData lockData = threadData.get(currentThread);
  // 第一次获取锁的话,lockData为null
  if (lockData != null) {
    // 当前线程获取过一次锁之后
    // 因为当前线程的锁存在,lockCount自增后返回,实现锁重入.
    lockData.lockCount.incrementAndGet();
    return true;
  }
  // 尝试获取锁
  String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
  if (lockPath != null) {
    LockData newLockData = new LockData(currentThread, lockPath);
     // 获取锁成功之后,将当前线程和对应的lockData放到threadData中
    threadData.put(currentThread, newLockData);
    return true;
  }

  return false;
}

LockData是InterProcessMutex中的一个静态内部类。

private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();

private static class LockData
{
    // 当前持有锁的线程
    final Thread owningThread;
    // 锁对应的子节点
    final String lockPath;
    // 加锁的次数
    final AtomicInteger lockCount = new AtomicInteger(1);

    private LockData(Thread owningThread, String lockPath)
    {
      this.owningThread = owningThread;
      this.lockPath = lockPath;
    }
}

如果已经获取过一次锁,后面再来获取锁的话,直接就会在if (lockData != null)这里被拦下了,然后就会执行lockData.lockCount.incrementAndGet();将加锁次数加 1。整个可重入锁的实现逻辑非常简单,直接在客户端判断当前线程有没有获取锁,有的话直接将加锁次数加1就可以了。

总结

这篇文章我们介绍了分布式锁的基本概念以及实现分布式锁的两种常见方式。至于具体选择Redis还是ZooKeeper来实现分布式锁,还是要看业务的具体需求。如果对性能要求比较高的话,建议使用Redis实现分布式锁。如果对可靠性要求比较高的话,建议使用ZooKeeper实现分布式锁。

原文链接

相关文章

关于分布式锁的面试题都在这里了 分布式锁之Zookeeper 电商防超卖的锁的解决方案
阿里面试官:分布式锁到底用Redis好?还是Zookeeper好? 分布式锁用Redis还是Zookeeper? Redis分布式锁到底安全吗
Redis分布式锁深入探究 七种方案!探讨Redis分布式锁的正确使用姿势 聊聊redis分布式锁的8大坑
这才叫细:带你深入理解Redis分布式锁 RedisTemplate分布式锁演变、Redission分布式锁实现 年轻人,看看Redisson分布式锁—可重入锁吧!太重要了
Redis分布式锁的正确实现原理演化历程与Redisson实战总结 从零到一编码实现Redis分布式锁 面试官问:Redis分布式锁如何自动续期?

Java中的锁

synchronized

synchronized是什么?有什么用?

synchronized是Java中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。在Java早期版本中,synchronized属于重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。不过,在Java6之后,synchronized引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让synchronized锁的效率提升了很多。因此,synchronized还是可以在实际项目中使用的,像JDK源码、很多开源框架都大量使用了synchronized。

如何使用synchronized?

synchronized关键字的使用方式主要有下面3种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法(锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

synchronized void method() {
    //业务代码
}

2、修饰静态方法(锁当前类)

给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    //业务代码
}

静态synchronized方法和非静态synchronized方法之间的调用互斥么?不互斥!如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁

3、修饰代码块(锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object)表示进入同步代码库前要获得给定对象的锁。
  • synchronized(类.class)表示进入同步代码前要获得给定Class的锁。
synchronized(this) {
    //业务代码
}

总结

  • synchronized关键字加到static静态方法和synchronized(class)代码块上都是是给Class类上锁;
  • synchronized关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用synchronized(String a)因为JVM中,字符串常量池具有缓存功能。

构造方法可以用synchronized修饰么?(不能)

先说结论:构造方法不能使用synchronized关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized底层原理了解吗?

synchronized关键字底层原理属于JVM层面的东西。

synchronized同步语句块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized代码块");
        }
    }
}

通过JDK自带的javap命令查看SynchronizedDemo类的相关字节码信息:首先切换到类的对应目录执行javac SynchronizedDemo.java命令生成编译后的.class文件,然后执行javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出:synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。当执行monitorenter指令时,线程试图获取锁也就是获取对象监视器monitor的持有权。

在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为0则表示锁可以被获取,获取后将锁计数器设为1也就是加1。

执行monitorenter获取锁

对象锁的的拥有者线程才可以执行monitorexit指令来释放锁。在执行monitorexit指令后,将锁计数器设为0,表明锁被释放,其他线程可以尝试获取锁。

执行monitorexit释放锁

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized修饰方法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized方法");
    }
}

synchronized关键字原理

synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法。JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM会尝试获取实例对象的锁。如果是静态方法,JVM会尝试获取当前class的锁。

总结

synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法。不过两者的本质都是对对象监视器monitor的获取。

相关推荐:Java锁与线程的那些事
🧗🏻进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器monitor。

JDK1.6之后的synchronized底层做了哪些优化?

JDK1.6对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。关于这几种优化的详细信息可以查看下面这篇文章:Java6及以上版本对synchronized的优化

synchronized和volatile有什么区别?

synchronized关键字和volatile关键字是两个互补的存在,而不是对立的存在!

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。
  • volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。

原文链接

相关文章

synchronized关键字和Lock、区别 Synchronized天天用,实现原理你懂吗? 关于Synchronized的一个点,网上99%的文章都错了
13张图,深入理解Synchronized synchronized中的4个优化,你知道几个? 使用了synchronized,为啥还有线程安全问题!?
synchronized加锁this和class的区别 为什么wait方法要在synchronized中调用? synchronized底层了解一下…
深入理解Synchronized synchronized的8种用法,你会几种? synchronized的用法,你知道多少?
Synchronized的锁升级过程是什么样的

ReentrantLock

ReentrantLock是什么?

ReentrantLock实现了Lock接口,是一个可重入且独占式的锁,和synchronized关键字类似。不过,ReentrantLock更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

public class ReentrantLock implements Lock, java.io.Serializable {}

ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。Sync有公平锁FairSync和非公平锁NonfairSync两个子类。

img

ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。

// 传入一个boolean值,true时为公平锁,false时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

从上面的内容可以看出,ReentrantLock的底层就是由AQS来实现的。关于AQS的相关内容推荐阅读AQS详解这篇文章。

公平锁和非公平锁有什么区别?

  • 公平锁:锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronized和ReentrantLock有什么区别?

1. 两者都是可重入锁

可重入锁也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。在下面的代码中,method1()和method2()都被synchronized关键字修饰,method1()调用了method2()。

public class ReentrantLockDemo {
    public synchronized void method1() {
        System.out.println("方法1");
        method2();
    }

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

由于synchronized锁是可重入的,同一个线程在调用method1()时可以直接获得当前对象的锁,执行method2()的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行method2()时获取锁失败,会出现死锁问题。

2. synchronized依赖于JVM而ReentrantLock依赖于API

synchronized是依赖于JVM实现的,前面我们也讲到了虚拟机团队在JDK1.6为synchronized关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock是JDK层面实现的(也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

3. ReentrantLock比synchronized增加了一些高级功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:

  • 等待可中断:ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁:ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件):synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

如果你想使用上述功能,那么选择ReentrantLock是一个不错的选择。

关于Condition接口的补充:

Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用notify()/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。synchronized就属于是不可中断锁。

聊一聊ReentrantLock类的一些玩法
demo

ReentrantReadWriteLock

ReentrantReadWriteLock在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK1.8引入了性能更好的读写锁StampedLock。

ReentrantReadWriteLock是什么?

ReentrantReadWriteLock实现了ReadWriteLock,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable{
}
public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}
  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
  • 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。

ReentrantReadWriteLock其实是两把锁,一把是WriteLock(写锁),一把是ReadLock(读锁)。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。和ReentrantLock一样,ReentrantReadWriteLock底层也是基于AQS实现的。

img

ReentrantReadWriteLock也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。

// 传入一个boolean值,true时为公平锁,false时为非公平锁
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

ReentrantReadWriteLock适合什么场景?

由于ReentrantReadWriteLock既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用ReentrantReadWriteLock能够明显提升系统性能。

共享锁和独占锁有什么区别?

  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。

线程持有读锁还能获取写锁吗?

  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

读写锁的源码分析,推荐阅读聊聊Java的几把JVM级锁,写的很不错。

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

StampedLock

StampedLock面试中问的比较少,不是很重要,简单了解即可。

StampedLock是什么?

StampedLock是JDK1.8引入的性能更好的读写锁,不可重入且不支持条件变量Conditon。不同于一般的Lock类,StampedLock并不是直接实现Lock或ReadWriteLock接口,而是基于CLH锁独立实现的(AQS也是基于这玩意)。

public class StampedLock implements java.io.Serializable {}

StampedLock提供了三种模式的读写控制模式:读锁、写锁和乐观读。

  • 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于ReentrantReadWriteLock的写锁,不过这里的写锁是不可重入的。
  • 读锁(悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于ReentrantReadWriteLock的读锁,不过这里的读锁是不可重入的。
  • 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。

另外,StampedLock还支持这三种锁在一定条件下进行相互转换。

long tryConvertToWriteLock(long stamp){}
long tryConvertToReadLock(long stamp){}
long tryConvertToOptimisticRead(long stamp){}

StampedLock在获取锁的时候会返回一个long型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为0则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。

// 写锁
public long writeLock() {
    long s, next;  // bypass acquireWrite in fully unlocked case only
    return ((((s = state) & ABITS) == 0L &&
             U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
            next : acquireWrite(false, 0L));
}
// 读锁
public long readLock() {
    long s = state, next;  // bypass acquireRead on common uncontended case
    return ((whead == wtail && (s & ABITS) < RFULL &&
             U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
            next : acquireRead(false, 0L));
}
// 乐观读
public long tryOptimisticRead() {
    long s;
    return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

StampedLock的性能为什么更好?

相比于传统读写锁多出来的乐观读是StampedLock比ReadWriteLock性能更好的关键原因。StampedLock的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

StampedLock适合什么场景?

和ReentrantReadWriteLock一样,StampedLock同样适合读多写少的业务场景,可以作为ReentrantReadWriteLock的替代品,性能更好。不过,需要注意的是StampedLock不可重入,不支持条件变量Conditon,对中断操作支持也不友好(使用不当容易导致CPU飙升)。如果你需要用到ReentrantLock的一些高级性能,就不太建议使用StampedLock了。另外,StampedLock性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用StampedLock之前,看看StampedLock官方文档中的案例

StampedLock的底层原理了解吗?

StampedLock不是直接实现Lock或ReadWriteLock接口,而是基于CLH锁实现的(AQS也是基于这玩意),CLH锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock通过CLH队列进行线程的管理,通过同步状态值state来表示锁的状态和类型。

StampedLock的原理和AQS原理比较类似,这里就不详细介绍了,感兴趣的可以看看下面这两篇文章:

AQS详解
StampedLock底层原理分析

原文链接

其他锁

聊聊13种锁的实现方式 一文足以了解什么是Java中的锁 图解Java中那18把锁
一文详解Java的几把JVM级锁 一文图解带你了解Java中的那些锁! 彻底说清楚JAVA锁的种类以及区别
老大吩咐的可重入分布式锁,终于完美的实现了 你用对锁了吗?浅谈Java”锁”事 一文看懂JUC之AQS机制
『图解Java并发』面试必问的CAS原理你会了吗? 了解这两个接口后,阿里多线程面试题秒AC 1.3w字,一文详解死锁!
重磅出击,20张图带你彻底了解ReentrantLock加锁解锁的原理 一文掌握ReentrantLock加解锁原理 什么是自旋锁?自旋的好处和后果是什么
被问到可重入锁条件队列,看这一篇就够了 3分钟带你搞懂AQS原理设计 3分钟秒懂死锁产生原因!

文章作者: xmxe
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 xmxe !
 上一篇
消息队列-MQ 消息队列-MQ
什么是消息队列我们可以把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列Queue是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。 参与消息传递的双方称为生产者和消费者
下一篇 
Java中的volatile关键字 Java中的volatile关键字
VolatileTest.java 如何保证变量的可见性?在Java中,volatile关键字可以保证变量的可见性,如果我们将变量声明为volatile,这就指示JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。 vo
  目录