ReentrantReadWriteLock是公平锁?

2020-01-14  本文已影响0人  ssochi

证明

new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("acquire read lock 1");
                lock.readLock().lock();
                System.out.println("get read lock 1");

                sleep(200);

                lock.readLock().unlock();
                System.out.println("release read lock 1");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                sleep(100);

                System.out.println("acquire write lock 1");
                lock.writeLock().lock();
                System.out.println("get write lock 1");

                sleep(400);

                lock.writeLock().unlock();
                System.out.println("release write lock 1");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                sleep(300);
                System.out.println("acquire read lock 2");
                lock.readLock().lock();
                System.out.println("get read lock 2");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                sleep(400);
                System.out.println("acquire write lock 2");
                lock.writeLock().lock();
                System.out.println("get write lock 2");
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                sleep(500);
                System.out.println("acquire read lock 3");
                lock.readLock().lock();
                System.out.println("get read lock 3");
            }
        }).start();

这段代码陆续加了5把锁,加减锁的顺序分别是:

  1. 加1号写锁
  2. 加1号读锁
  3. 加2号写锁
  4. 加2号读锁
  5. 释放一号写锁
    我们来预测一下这五把锁的获取顺序是怎样的
    首先读写锁并没持有任何锁,因此1号写锁成功获取锁,
    接下来2,3,4步由于读写锁已经持有写锁,因此后三把锁只能等待
    接下来释放1号写锁,基于公平锁的原则,排在最前面的1号读锁会马上获取锁
    而根据只要有一把写锁,别的写锁也能成功加锁的原则,
    2号写锁也会成功加锁,
    这时只有2号写锁还在等待状态。
    然而事实是这样的吗?
acquire write lock 1
get write lock 1
acquire read lock 1
acquire write lock 2
acquire read lock 2
release write lock 1
get read lock 1

以上为控制台的输出结果,可见在1号写锁释放锁之后,只有1号读锁成功获取了锁,而2号读锁只有等着。
这显然是违反公平锁的原则,若干在1号锁获取锁成功后,别的线程来加读锁,无疑是会成功的,而2号读锁只能傻傻的等着,这不是欺负老实人吗。
所有这是JDK的逻辑bug!

原因

让我们读读源码看看为啥会这样

/**
         * Acquires the read lock.
         *
         * <p>Acquires the read lock if the write lock is not held by
         * another thread and returns immediately.
         *
         * <p>If the write lock is held by another thread then
         * the current thread becomes disabled for thread scheduling
         * purposes and lies dormant until the read lock has been acquired.
         */
        public void lock() {
            sync.acquireShared(1);
        }

首先获取写锁时,会调用AQS的acquireShared方法

 /**
     * Acquires in shared uninterruptible mode.
     * @param arg the acquire argument
     */
    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);//(1)
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt()//(2))
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

而acquireShared方法会调用doAcquireShared,当一个线程获取到读锁的时候会调用setHeadAndPropagate方法

/**
     * Sets head of queue, and checks if successor may be waiting
     * in shared mode, if so propagating if either propagate > 0 or
     * PROPAGATE status was set.
     *
     * @param node the node
     * @param propagate the return value from a tryAcquireShared
     */
    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        /*
         * Try to signal next queued node if:
         *   Propagation was indicated by caller,
         *     or was recorded (as h.waitStatus) by a previous operation
         *     (note: this uses sign-check of waitStatus because
         *      PROPAGATE status may transition to SIGNAL.)
         * and
         *   The next node is waiting in shared mode,
         *     or we don't know, because it appears null
         *
         * The conservatism in both of these checks may cause
         * unnecessary wake-ups, but only when there are multiple
         * racing acquires/releases, so most need signals now or soon
         * anyway.
         */
        if (propagate > 0 || h == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

setHeadAndPropagate方法很简单,它会尝试去唤醒队列中下一个的处于Shared状态的节点。而下一个节点被唤醒时,它应该处于doAcquireShared的parkAndCheckInterrupt这个位置。它进而也会调用setHeadAndPropagate,来唤醒下一个Shared状态的节点。这样一直唤醒,直到遇见了一个Excluse状态的节点,这种连锁唤醒行动就停止了。

Shared1 ->Shared2 ->Shared3 ->Exclusive1->Shared4

如上队列Shared1引发的连锁唤醒便不能唤醒Shared4,因此导致了我们观察到的非公平现象。

为什么会这样

这并不会造成严重的后果,之所以JDK会采用如下的实现方式,主要是由于AQS的局限性。因为AQS并不能提供后排插到前排的机制。AQS的队列调度使用了CAS,因此仅仅修改队首和队尾时只用考虑一个变量,而从后排到前排则复杂的多,首先从后排删除同时要影响前后两个节点,而插入前排同样会影响两个节点。而如果想让exclusive后排的节点被唤醒,那么就需要便利整个队列,这是需要耗时的,CAS没法保证四个变量同时被修改,若在遍历途中读锁全释放了,那遍历如何取消?若遍历途中读锁被释放又加上,怎么保证同时只有一个遍历在进行?
或许解决的办法只有加锁,AQS的state被用来存储同时被加的锁的数量,而当state=0时一般才能释放锁。那么只要在遍历开始时增加state,那么就能保证读锁在遍历结束前不会释放。
但是这样做并不是解决之道,首先在AQS这种锁底层框架中用到锁本身就很影响性能,其次state是AQS面向它的继承者的一个方法,对AQS本身没有意义,如果将state和AQS依赖在一起,那么从逻辑上来说缺乏整体性,并且可能会影响别的基于AQS实现的同步工具,这是得不偿失的。

结论

ReentrantReadWriteLock不是公平锁,但是世界上哪有绝对的公平。社会总是在效率和公平中做出折中的选择,而JDK也是,这或许就是宇宙的哲学!

上一篇 下一篇

猜你喜欢

热点阅读