互斥锁ReentrantLock不好用?试试读写锁ReadWriteLock一文中,我们对比了互斥锁ReentrantLock和读写锁ReadWriteLock的区别,说明了读写锁在读多写少的场景下具有明显的性能优势,但是人的欲望是无穷的,还是不能被满足。。

【漫画】读写锁ReadWriteLock还是不够快?再试试StampedLock!-LMLPHP

数据库中的锁

由于大部分码农接触锁都是从数据库中的锁开始的,所以这里不妨先聊聊数据库中的锁。

我们以火车票售票的例子,假设如下场景,两处火车票售票点同时读取某一趟列车车票数据库中的余票数量,然后两处售票点同时卖出一张车票,同时修改余票为 X -1,写回数据库,这样就造成了实际卖出两张火车票而数据库中的记录却只减少了一张。

如果你阅读了公众号【胖滚猪学编程】的并发系列文章,包括:如何解决原子性问题ReentrantLock互斥锁读写锁ReadWriteLock,那么你一定知道出现原因和解决方案,对了,可以使用锁

锁可以分为两大类,乐观锁和悲观锁:

  • 悲观锁:顾名思义,就是很悲观,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改, 所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
  • 乐观锁:乐观锁,每次去拿数据的时候想法都是“没事,肯定没被改过”,于是就开心地获取到数据,不放心吗?那就在更新的时候判断一下在此期间别人有没有去更新过这个数据,可以使用版本号等机制。

一般情况下,数据库都会有读共享写独占的锁并发的方案,也就是说读读并发是没问题的,但在读写并发时,则有可能出现读取不一致情况,也就是常说的脏读,所以在悲观锁的模式下,在有写线程的时候,是不允许有任何其他的读和写线程的,也就是说写是独占的,这样会导致系统的吞吐明显下降。我们所说的ReadWriteLock的写锁就属于悲观锁。

如何避免这一情况,答案是使用乐观锁。每个线程都不会修改原始数据,而是从原始数据上拷贝上一份数据,同时记录版本号,不同的线程更新自己的数据,在最终写会时会判断版本号是否变更,如果变更则意味有人已经更改过了,那么当前线程需要做的就是自旋重试,如果重试指定的次数依然失败,那么就应该放弃更新,这种策略仅仅适合写并发并不强烈的场景,如果写竞争严重,那么多次自旋重试的开销也是非常耗性能的,如果竞争激烈,那么写锁独占的方式则更加适合。

那么具体怎么使用版本号机制呢?

很简单,对数据库表添加了一个version字段,设置为bigint类型。查询的时候我们需要查出版本信息,更新的时候,需要将版本信息+1。

1.查询数据信息
select xxx,version from xxx where id= #{id}
2.根据数据信息是判断当前数据库中的version是否还是刚才查出来的那个version
update xxx set xxx=xxx ,version = version+1 where id=#{id} and version= #{version};

由于update指定了where条件,可根据返回修改记录条数来判断当前更新是否生效,如果成功改动0条数据,说明version发生了变更,这时候可以根据自己业务逻辑来决定是否需要回滚事务。

数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于今天我们要说的 StampedLock 里面的 stamp。基于上面谈到的这些内容,我们再来分析StampedLock类,就会非常比较容易理解。

StampedLock

Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能比读写锁还要好。

对比ReadWriteLock

我们先来看看StampedLock 和上一篇文章讲的 ReadWriteLock 有哪些区别。

ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而 StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读

其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。

不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp,这里的stamp就类似刚刚我们说的数据库version,相信你已经明白了。

我们通过代码演示一下写锁、悲观读锁是如何使用的:

    // 锁实例
    private final StampedLock sl = new StampedLock();

    // 排它锁-写锁
    void writeLock() {
        long stamp = sl.writeLock();//获取写锁
        try {
          // 业务逻辑
        } finally {
            sl.unlockWrite(stamp);//释放写锁
        }
    }

    // 悲观读锁
    void readLock() {
        long stamp = sl.readLock();
        try {
          // 业务逻辑
        } finally {
            sl.unlockRead(stamp);
        }
    }

乐观读

StampedLock 的性能之所以比 ReadWriteLock 还要好,其关键是 StampedLock 支持乐观读的方式。所谓乐观读,即读的时候也能允许一个线程获取写锁,也就是说不是所有的写操作都被阻塞,自然而然的会比所有写都阻塞性能要强。

还是通过代码来说明一下乐观读是如何使用的:

    // 乐观读
    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();//(1)
        double currentX = x, currentY = y;

        // 检查在(1)获取到读锁票据后,锁有没被其他写线程排它性抢占
        if (!sl.validate(stamp)) {
            // 如果被抢占则获取一个共享读锁(悲观读锁)
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX*currentX + currentY*currentY);
    }

tryOptimisticRead() 就是我们前面提到的乐观读。不过需要注意的是,由于 tryOptimisticRead() 是无锁的,所以共享变量 x 和 y 读入方法局部变量时,x 和 y 有可能被其他线程修改了。因此最后读完之后,还需要再次验证一下是否存在写操作,这个验证操作是通过调用 validate(stamp) 来实现的。

还有一个巧妙的地方:如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x 和 y 的正确性和一致性),而循环读会浪费大量的 CPU。升级为悲观读锁,代码简练且不易出错。

【漫画】读写锁ReadWriteLock还是不够快?再试试StampedLock!-LMLPHP

锁的升级

在上一篇读写锁文章中,我们说到锁的升级和降级,ReadWriteLock是只允许降级而不允许升级的,而StampedLock 支持锁的降级(通过 tryConvertToReadLock() 方法实现)和升级(通过 tryConvertToWriteLock() 方法实现),读锁居然也可以升级为写锁,这也是它区别于读写锁的一大特性!

    // 读锁升级成写锁
    void moveIfAtOrigin(double newX, double newY) { // upgrade
        // Could instead start with optimistic, not read mode
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                // 尝试将获取的读锁升级为写锁
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    // 读锁升级写锁失败则释放读锁,显示获取独占写锁,然后循环重试
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }

StampedLock 使用注意事项

StampedLock真有这么完美吗?挑刺时间又来咯!

1、StampedLock 在命名上并没有增加 Reentrant,显然,StampedLock 不支持重入。这个是在使用中必须要特别注意的。

2、StampedLock 的悲观读锁、写锁都不支持条件变量(Condition),这个也需要你注意。

3、使用 StampedLock 一定不要调用中断操作,即不要调用interrupt() 方法,因为内部实现里while循环里面对中断的处理有点问题。如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。

总结

如何解决原子性问题为起点,我们初始了锁的概念,了解了synchronized锁模型,之后又走进了J.U.C Lock包,首先接触到了ReentrantLock互斥锁,由于互斥锁在读多写少场景的效率不高,因此接触了读写锁ReadWriteLock,而今天,又学习了一种比读写锁还要快的锁StampedLock。说明JAVA真是博大精深,连锁都有那么多种,需要根据实际情况合理选择才是!

关于StampedLock,重点应该了解它独特的思想:乐观的思想。就像人一样,不能总是悲观思想,乐观思想积极面对生活效率才更高!StampedLock通过一个叫做stamp的类似于数据库版本号的字段,实现了乐观读。当然永远乐观也是不行的,StampedLock也有它的缺陷,对于这些,你也需要特别注意。

05-17 23:14