关于后端:Java-并发编程之-ReentrantLock-源码分析

31次阅读

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

我是一个优良的人,但也有毛病,比我优良的人有很多很多。连本人都认输,何谈你的对手,与你一样优良,甚至比你优良的人。战败本人,就是最大的超过。

在 Java5.0 之前,协调对共享对象的拜访能够应用的机制只有 synchronized 和 volatile。咱们晓得 synchronized 关键字实现了内置锁,而 volatile 关键字保障了多线程的内存可见性。

在大多数状况下,这些机制都能很好地实现工作,但却无奈实现一些更高级的性能,例如,无奈中断一个正在期待获取锁的线程,无奈实现限定工夫的获取锁机制,无奈实现非阻塞构造的加锁规定等。而这些更灵便的加锁机制通常都可能提供更好的活跃性或性能。

因而,在 Java5.0 中减少了一种新的机制:ReentrantLock。ReentrantLock 类实现了 Lock 接口,并提供了与 synchronized 雷同的互斥性和内存可见性,它的底层是通过 AQS 来实现多线程同步的。与内置锁相比 ReentrantLock 不仅提供了更丰盛的加锁机制,而且在性能上也不逊色于内置锁(在以前的版本中甚至优于内置锁)。

说了 ReentrantLock 这么多的长处,那么上面咱们就来揭开它的源码看看它的具体实现。

1.synchronized 关键字的介绍

Java 提供了内置锁来反对多线程的同步,JVM 依据 synchronized 关键字来标识同步代码块,当线程进入同步代码块时会主动获取锁,退出同步代码块时会主动开释锁,一个线程取得锁后其余线程将会被阻塞。

每个 Java 对象都能够用做一个实现同步的锁,synchronized 关键字能够用来润饰对象办法,静态方法和代码块,当润饰对象办法和静态方法时锁别离是办法所在的对象和 Class 对象,当润饰代码块时需提供额定的对象作为锁。每个 Java 对象之所以能够作为锁,是因为在对象头中关联了一个 monitor 对象(管程),线程进入同步代码块时会主动持有 monitor 对象,退出时会主动开释 monitor 对象,当 monitor 对象被持有时其余线程将会被阻塞。

当然这些同步操作都由 JVM 底层帮你实现了,但以 synchronized 关键字润饰的办法和代码块在底层实现上还是有些区别的。synchronized 关键字润饰的办法是隐式同步的,即无需通过字节码指令来管制的,JVM 能够依据办法表中的 ACC_SYNCHRONIZED 拜访标记来辨别一个办法是否是同步办法;而 synchronized 关键字润饰的代码块是显式同步的,它是通过 monitorenter 和 monitorexit 字节码指令来控制线程对管程的持有和开释。

monitor 对象外部持有_count 字段,_count 等于 0 示意管程未被持有,_count 大于 0 示意管程已被持有,每次持有线程重入时_count 都会加 1,每次持有线程退出时_count 都会减 1,这就是内置锁重入性的实现原理。

另外,monitor 对象外部还有两条队列_EntryList 和_WaitSet,对应着 AQS 的同步队列和条件队列,当线程获取锁失败时会到_EntryList 中阻塞,当调用锁对象的 wait 办法时线程将会进入_WaitSet 中期待,这是内置锁的线程同步和条件期待的实现原理。

2.ReentrantLock 和 Synchronized 的比拟

synchronized 关键字是 Java 提供的内置锁机制,其同步操作由底层 JVM 实现,而 ReentrantLock 是 java.util.concurrent 包提供的显式锁,其同步操作由 AQS 同步器提供反对。ReentrantLock 在加锁和内存上提供的语义与内置锁雷同,此外它还提供了一些其余性能,包含定时的锁期待,可中断的锁期待,偏心锁,以及实现非块构造的加锁。另外,在晚期的 JDK 版本中 ReentrantLock 在性能上还占有肯定的劣势,既然 ReentrantLock 领有这么多劣势,为什么还要应用 synchronized 关键字呢?

事实上的确有许多人应用 ReentrantLock 来代替 synchronized 关键字的加锁操作。然而内置锁依然有它特有的劣势,内置锁为许多开发人员所相熟,应用形式也更加的简洁紧凑,因为显式锁必须手动在 finally 块中调用 unlock,所以应用内置锁相对来说会更加平安些。同时将来更加可能会去晋升 synchronized 而不是 ReentrantLock 的性能。

因为 synchronized 是 JVM 的内置属性,它能执行一些优化,例如对线程关闭的锁对象的锁打消优化,通过减少锁的粒度来打消内置锁的同步,而如果通过基于类库的锁来实现这些性能,则可能性不大。所以当须要一些高级性能时才应该应用 ReentrantLock,这些性能包含:可定时的,可轮询的与可中断的锁获取操作,偏心队列,以及非块构造的锁。否则,还是应该优先应用 synchronized。

3. 获取锁和开释锁的操作

咱们首先来看一下应用 ReentrantLock 加锁的示例代码。

public void doSomething() {
    // 默认是获取一个非偏心锁
    ReentrantLock lock = new ReentrantLock();
    try {
        // 执行前先加锁
        lock.lock();
        // 执行操作...
    } finally {
        // 最初开释锁
        lock.unlock();}
}

以下是获取锁和开释锁这两个操作的 API。

// 获取锁的操作
public void lock() {sync.lock();
}
// 开释锁的操作
public void unlock() {sync.release(1);
}

能够看到获取锁和开释锁的操作别离委托给 Sync 对象的 lock 办法和 release 办法。

public class ReentrantLock implements Lock, java.io.Serializable {
    private final Sync sync;
    abstract static class Sync extends AbstractQueuedSynchronizer {abstract void lock();
    }
    // 实现非偏心锁的同步器
    static final class NonfairSync extends Sync {final void lock() {...}
    }
    // 实现偏心锁的同步器
    static final class FairSync extends Sync {final void lock() {...}
    }
}

每个 ReentrantLock 对象都持有一个 Sync 类型的援用,这个 Sync 类是一个形象外部类它继承自 AbstractQueuedSynchronizer,它外面的 lock 办法是一个形象办法。ReentrantLock 的成员变量 sync 是在结构时赋值的,上面咱们看看 ReentrantLock 的两个构造方法都做了些什么?

// 默认无参结构器
public ReentrantLock() {sync = new NonfairSync();
}
// 有参结构器
public ReentrantLock(boolean fair) {sync = fair ? new FairSync() : new NonfairSync();}

调用默认无参结构器会将 NonfairSync 实例赋值给 sync,此时锁是非偏心锁。有参结构器容许通过参数来指定是将 FairSync 实例还是 NonfairSync 实例赋值给 sync。NonfairSync 和 FairSync 都是继承自 Sync 类并重写了 lock()办法,所以偏心锁和非偏心锁在获取锁的形式上有些区别,这个咱们上面会讲到。

再来看看开释锁的操作,每次调用 unlock()办法都只是去执行 sync.release(1)操作,这步操作会调用 AbstractQueuedSynchronizer 类的 release()办法,咱们再来回顾一下。

// 开释锁的操作(独占模式)
public final boolean release(int arg) {
    // 拨动密码锁, 看看是否可能开锁
    if (tryRelease(arg)) {
        // 获取 head 结点
        Node h = head;
        // 如果 head 结点不为空并且期待状态不等于 0 就去唤醒后继结点
        if (h != null && h.waitStatus != 0) {
            // 唤醒后继结点
            unparkSuccessor(h);
        }
        return true;
    }
    return false;
}

这个 release 办法是 AQS 提供的开释锁操作的 API,它首先会去调用 tryRelease 办法去尝试获取锁,tryRelease 办法是形象办法,它的实现逻辑在子类 Sync 外面。

// 尝试开释锁
protected final boolean tryRelease(int releases) {int c = getState() - releases;
    // 如果持有锁的线程不是以后线程就抛出异样
    if (Thread.currentThread() != getExclusiveOwnerThread()) {throw new IllegalMonitorStateException();
    }
    boolean free = false;
    // 如果同步状态为 0 则表明锁被开释
    if (c == 0) {
        // 设置锁被开释的标记为真
        free = true;
        // 设置占用线程为空
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

这个 tryRelease 办法首先会获取以后同步状态,并将以后同步状态减去传入的参数值失去新的同步状态,而后判断新的同步状态是否等于 0,如果等于 0 则表明以后锁被开释,而后先将锁的开释状态置为真,再将以后占有锁的线程清空,最初调用 setState 办法设置新的同步状态并返回锁的开释状态。

4. 偏心锁和非偏心锁

咱们晓得 ReentrantLock 是偏心锁还是非偏心锁是基于 sync 指向的是哪个具体实例。在结构时会为成员变量 sync 赋值,如果赋值为 NonfairSync 实例则表明是非偏心锁,如果赋值为 FairSync 实例则表明为偏心锁。如果是偏心锁,线程将依照它们发出请求的程序来取得锁,但在非偏心锁上,则容许插队行为:当一个线程申请非偏心的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有期待的线程间接取得这个锁。上面咱们先看看非偏心锁的获取形式。

// 非偏心同步器
static final class NonfairSync extends Sync {
    // 实现父类的形象获取锁的办法
    final void lock() {
        // 应用 CAS 形式设置同步状态
        if (compareAndSetState(0, 1)) {
            // 如果设置胜利则表明锁没被占用
            setExclusiveOwnerThread(Thread.currentThread());
        } else {
            // 否则表明锁曾经被占用, 调用 acquire 让线程去同步队列排队获取
            acquire(1);
        }
    }
    // 尝试获取锁的办法
    protected final boolean tryAcquire(int acquires) {return nonfairTryAcquire(acquires);
    }
}
// 以不可中断模式获取锁(独占模式)
public final void acquire(int arg) {if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {selfInterrupt();
    }
}

能够看到在非偏心锁的 lock 办法中,线程第一步就会以 CAS 形式将同步状态的值从 0 改为 1。其实这步操作就等于去尝试获取锁,如果更改胜利则表明线程刚来就获取了锁,而不用再去同步队列外面排队了。如果更改失败则表明线程刚来时锁还未被开释,所以接下来就调用 acquire 办法。

咱们晓得这个 acquire 办法是继承自 AbstractQueuedSynchronizer 的办法,当初再来回顾一下该办法,线程进入 acquire 办法后首先去调用 tryAcquire 办法尝试去获取锁,因为 NonfairSync 笼罩了 tryAcquire 办法,并在办法中调用了父类 Sync 的 nonfairTryAcquire 办法,所以这里会调用到 nonfairTryAcquire 办法去尝试获取锁。咱们看看这个办法具体做了些什么。

// 非偏心的获取锁
final boolean nonfairTryAcquire(int acquires) {
    // 获取以后线程
    final Thread current = Thread.currentThread();
    // 获取以后同步状态
    int c = getState();
    // 如果同步状态为 0 则表明锁没有被占用
    if (c == 0) {
        // 应用 CAS 更新同步状态
        if (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;
}

nonfairTryAcquire 办法是 Sync 的办法,咱们能够看到线程进入此办法后首先去获取同步状态,如果同步状态为 0 就应用 CAS 操作更改同步状态,其实这又是获取了一遍锁。如果同步状态不为 0 表明锁被占用,此时会先去判断持有锁的线程是否是以后线程,如果是的话就将同步状态加 1,否则的话这次尝试获取锁的操作宣告失败。于是会调用 addWaiter 办法将线程增加到同步队列。

综上来看,在非偏心锁的模式下一个线程在进入同步队列之前会尝试获取两遍锁,如果获取胜利则不进入同步队列排队,否则才进入同步队列排队。接下来咱们看看偏心锁的获取形式。

// 实现偏心锁的同步器
static final class FairSync extends Sync {
    // 实现父类的形象获取锁的办法
    final void lock() {
        // 调用 acquire 让线程去同步队列排队获取
        acquire(1);
    }
    // 尝试获取锁的办法
    protected final boolean tryAcquire(int acquires) {
        // 获取以后线程
        final Thread current = Thread.currentThread();
        // 获取以后同步状态
        int c = getState();
        // 如果同步状态 0 则示意锁没被占用
        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;
    }
}

调用偏心锁的 lock 办法时会间接调用 acquire 办法。同样的,acquire 办法首先会调用 FairSync 重写的 tryAcquire 办法来尝试获取锁。在该办法中也是首先获取同步状态的值,如果同步状态为 0 则表明此时锁刚好被开释,这时和非偏心锁不同的是它会先去调用 hasQueuedPredecessors 办法查问同步队列中是否有人在排队,如果没人在排队才会去批改同步状态的值,能够看到偏心锁在这里采取礼让的形式而不是本人马上去获取锁。

除了这一步和非偏心锁不一样之外,其余的操作都是一样的。综上所述,能够看到偏心锁在进入同步队列之前只查看了一遍锁的状态,即便是发现了锁是开的也不会本人马上去获取,而是先让同步队列中的线程先获取,所以能够保障在偏心锁下所有线程获取锁的程序都是先来后到的,这也保障了获取锁的公平性。

那么咱们为什么不心愿所有锁都是偏心的呢?毕竟偏心是一种好的行为,而不偏心是一种不好的行为。因为线程的挂起和唤醒操作存在较大的开销而影响零碎性能,特地是在竞争强烈的状况下偏心锁将导致线程频繁的挂起和唤醒操作,而非偏心锁能够缩小这样的操作,所以在性能上将会优于偏心锁。

另外,因为大部分线程应用锁的工夫都是十分短暂的,而线程的唤醒操作会存在延时状况,有可能在 A 线程被唤醒期间 B 线程马上获取了锁并应用完开释了锁,这就导致了双赢的场面,A 线程获取锁的时刻并没有推延,但 B 线程提前应用了锁,并且吞吐量也取得了进步。

5. 条件队列的实现机制

内置条件队列存在一些缺点,每个内置锁都只能有一个相关联的条件队列,这导致多个线程可能在同一个条件队列上期待不同的条件谓词,那么每次调用 notifyAll 时都会将所有期待的线程唤醒,当线程醒来后发现并不是本人期待的条件谓词,转而又会被挂起。这导致做了很多无用的线程唤醒和挂起操作,而这些操作将会大量节约系统资源,升高零碎的性能。如果想编写一个带有多个条件谓词的并发对象,或者想取得除了条件队列可见性之外的更多控制权,就须要应用显式的 Lock 和 Condition 而不是内置锁和条件队列。一个 Condition 和一个 Lock 关联在一起,就像一个条件队列和一个内置锁相关联一样。要创立一个 Condition,能够在相关联的 Lock 上调用 Lock.newCondition 办法。咱们先来看一个应用 Condition 的示例。

public class BoundedBuffer {final Lock lock = new ReentrantLock();
    final Condition notFull = lock.newCondition();   // 条件谓词:notFull
    final Condition notEmpty = lock.newCondition();  // 条件谓词:notEmpty
    final Object[] items = new Object[100];
    int putptr, takeptr, count;
    // 生产办法
    public void put(Object x) throws InterruptedException {lock.lock();
        try {while (count == items.length)
                notFull.await();  // 队列已满, 线程在 notFull 队列上期待
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal(); // 生产胜利, 唤醒 notEmpty 队列的结点} finally {lock.unlock();
        }
    }
    // 生产办法
    public Object take() throws InterruptedException {lock.lock();
        try {while (count == 0)
                notEmpty.await(); // 队列为空, 线程在 notEmpty 队列上期待
            Object x = items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();  // 生产胜利, 唤醒 notFull 队列的结点
            return x;
        } finally {lock.unlock();
        }
    }

}

一个 lock 对象能够产生多个条件队列,这里产生了两个条件队列 notFull 和 notEmpty。当容器已满时再调用 put 办法的线程须要进行阻塞,期待条件谓词为真 (容器不满) 才醒来继续执行;当容器为空时再调用 take 办法的线程也须要阻塞,期待条件谓词为真 (容器不空) 才醒来继续执行。

这两类线程是依据不同的条件谓词进行期待的,所以它们会进入两个不同的条件队列中阻塞,等到适合机会再通过调用 Condition 对象上的 API 进行唤醒。上面是 newCondition 办法的实现代码。

// 创立条件队列
public Condition newCondition() {return sync.newCondition();
}

abstract static class Sync extends AbstractQueuedSynchronizer {
    // 新建 Condition 对象
    final ConditionObject newCondition() {return new ConditionObject();
    }
}

ReentrantLock 上的条件队列的实现都是基于 AbstractQueuedSynchronizer 的,咱们在调用 newCondition 办法时所取得的 Condition 对象就是 AQS 的外部类 ConditionObject 的实例。所有对条件队列的操作都是通过调用 ConditionObject 对外提供的 API 来实现的。

至此,咱们对 ReentrantLock 源码的分析也告一段落,心愿浏览本篇文章可能对读者们了解并把握 ReentrantLock 起到肯定的帮忙作用。

正文完
 0