Java并发编程之显式锁机制

时间:2021-03-10 20:53:47

     我们之前介绍过synchronized关键字实现程序的原子性操作,它的内部也是一种加锁和解锁机制,是一种声明式的编程方式,我们只需要对方法或者代码块进行声明,Java内部帮我们在调用方法之前和结束时加锁和解锁。而我们本篇将要介绍的显式锁是一种手动式的实现方式,程序员控制锁的具体实现,虽然现在越来越趋向于使用synchronized直接实现原子操作,但是了解了Lock接口的具体实现机制将有助于我们对synchronized的使用。本文主要涉及以下一些内容:

  • 接口Lock的基本组成成员
  • 可重入锁ReentrantLock的基本使用
  • 深入ReentrantLock的实现原理

一、接口Lock的基本组成成员
     Lock 位于java.util.concurrent.locks包下,源码如下:

public interface Lock {
    void lock();
    void lockInterruptibly()
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit)
    void unlock();
    Condition newCondition();
}

其中,

  • void lock();:调用该方法将获得一个锁的入口
  • lockInterruptibly():该方法也是去获得一个锁,但是它是响应中断的,一旦在获取的过程中遭遇中断将抛出 InterruptedException。
  • boolean tryLock();:该方法尝试着去获得一个锁,如果获取失败将返回false,并不会阻塞当前线程
  • boolean tryLock(long time, TimeUnit unit):尝试着去获取一个锁,如果获取失败,将阻塞等待指定的时间,期间如果能够获得锁将返回true,否则返回false,响应中断请求。
  • void unlock();:释放一个锁
  • Condition newCondition();:条件变量,留待下篇文章学习

二、可重入锁ReentrantLock的基本使用
     ReentrantLock是接口 Lock的一个最主要的实现类,不仅实现了Lock中的基本的加锁释放锁的方法,还扩展了自己的方法。它有两个构造方法:

public ReentrantLock() {
    sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

参数 fair用于保证锁机制的公平策略,公平的策略会是的等待时间越长的线程优先获得锁。保证公平必然会降低性能,所以ReentrantLock默认并不保证公平。我们用ReentrantLock来实现对程序的原子操作:

public class MyThread extends Thread{
    
    private static Lock lock = new ReentrantLock();
    public static int count;
    
    @Override
    public void run() {
        try {
            Thread.sleep((int)Math.random()*100);
            lock.lock();
            count++;
            lock.unlock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

当我们在主程序中启动一百个线程随机唤醒对count进行加一时,无论运行多少次,结果都是一百,也就是说我们的ReentrantLock是可以为我们保证原子操作的。

ReentrantLock还有一个特性就是可以重入性,即在本身获得某个锁的前提下可以随意进入被该锁锁住的其他方法,对于一个锁可以重复进入。除此之外,ReentrantLock还具有一些其他的有关锁信息的方法:

  • public int getHoldCount():表示当前线程持有该锁的数量
  • public boolean isHeldByCurrentThread():判断锁是否为当前线程持有
  • public boolean isLocked():判断锁是否为任意一个线程持有,如果有则返回true,否则返回false
  • public final boolean hasQueuedThreads():判断该锁上是否有线程进行等待
  • public final int getQueueLength():返回当前等待队列的长度,也就是等待进入该锁的线程个数

三、深入ReentrantLock的实现原理
     ReentrantLock依赖CAS和LockSupport来实现,LockSupport有点像工具类,它主要提供两类方法,park和unpark。

  • public static void park()
  • public static void parkNanos(long nanos)
  • public static void parkUntil(long deadline)
  • public static void unpark(Thread thread)

调用park方法会使得当前线程丢失CPU使用权,从Runnable状态转变为Waiting状态。而unpark方法则反过来让Waiting状态的某个线程转变状态为Runnable,等待操作系统调度。parkNanos和parkUntil是和时间相关的两个park的变种,parkNanos指定线程要等待的时间,parkUntil则指定线程要等待到什么时候,这个时间是一个绝对时间,相对于纪元的毫秒数。

Java的并发包中有很多并发工具,ReentrantReadWriteLock,Semaphore,CountDownLatch,ReentrantLock等。这些工具有很多的共同特性,于是Java为我们抽象了一个类AbstractQueuedSynchronizer(AQS)来表示这些工具的共性。ReentrantLock是其的一个实现类,内部有三个内部类:

abstract static class Sync extends AbstractQueuedSynchronizer{
    //......
}
static final class NonfairSync extends Sync{
    //...........
}
static final class FairSync extends Sync {
    //.............
}

Sync 继承了AQS并对其中的大部分代码进行了简单的实现,FairSync 和NonfairSync 是针对公平策略而定义的,如果构造ReentrantLock的时候指定公平的策略,那么其内部的所有方法都依赖这个FairSync ,否则就全部依赖NonfairSync。接着看ReentrantLock的构造函数:

private final Sync sync;

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

两个构造方法最终会对sync进行初始化,而sync的将在后续的方法中起到相当大的作用。我们先看lock方法的具体实现:

public void lock() {
    sync.lock();
}

ReentrantLock的lock方法调用的sync的lock方法,而在sync中的lock方法是一个抽象的方法,也就是说这个方法的具体实现在子类中,我们看NonfairSync中的实现:

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

AQS中有一个整型类型的State变量,它用于标识当前锁被持有的次数,该值为0表示当前锁没有被任何线程持有。compareAndSetState是AQS中的方法,该方法调用了unsafe.compareAndSwapInt方法以CAS方式对State进行了更新,如果state的值为0,说明该锁并没有被任何线程持有,那么当前线程将持有该锁并将state的值赋为1。

这就完成了获取的动作,一旦后续的线程尝试访问临界区代码,在前面的线程没有释放锁之前,将会调用 acquire(1)。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
       acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
       selfInterrupt();
}

tryAcquire还是调用了AQS中的实现,

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

第一个if判断,想要持有的锁是否被持有(虽然之前判断过了,但是有可能在我们调用nonfairTryAcquire方法的期间,之前的线程释放了该锁),如果未被任何线程持有,那么将直接持有该锁。

第二个if判断,如果当前锁的持有者就是当前线程,表示这是同线程的重入操作,于是增加锁定次数并设置state的值。

整个方法结束之后,如果当前线程获得了锁,都将返回true,否则都会返回false。而如果tryAcquire方法返回true,那么整个acquire方法也将结束,否则就说明当前线程并没有通过锁,需要被阻塞。那么就会调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

addWaiter方法将当前线程包裹成一个Node结点,添加到AQS内部所维护的一个等待队列并返回该Node结点。最后调用acquireQueued方法:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

该方法首先会去获得node的前一个结点,判断如果是head结点,那么说明当前的node结点是整个等待队列上的第一个等待的结点。于是让它尝试着去获得锁,如果能够获得锁,将从等待队列中清除它并返回。

如果发现当前结点前面还有等待的结点或者尝试获取锁失败,那么将会调用shouldParkAfterFailedAcquire方法判断该结点锁对应的线程是否需要被park阻塞,并最终调用LockSupport.park(this)阻塞当前线程。

在第一个线程持有该锁的前提下,成功阻塞了第二个线程。这大概就是整个lock方法的调用链流程。

接下来看看unlock的具体实现,

public void unlock() {
    sync.release(1);
}

这是ReentrantLock中对AQS的unlock的具体实现,调用了sync的release方法,这个方法是其父类AQS中的方法:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

tryRelease被sync重写,具体代码如下:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
     boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

首先判断如果当前线程并不是锁的当前持有者,抛出异常(不持有该锁自然不能释放该锁)。如果c等于0则表示,当前锁只被持有一次,也就是当前线程并没有多次重入该锁,于是将该锁的持有者设置为null,表示未被任何线程持有。如果c不等于0,那么说明该锁被当前线程重入多次,于是对state减一并设置state的值。最终如果返回true则说明该锁被释放了,否则说明当前线程依然持有该锁。

回到release方法,如果tryRelease(arg)返回true,那么方法体会判断当前等待队列是否有结点在等待该锁,如果有则调用unparkSuccessor(h)方法唤醒等待队列上的第一个等待的结点线程并返回true。

这里有一个细节,其实所有未能获得锁的线程都被阻塞在方法中:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                //******等待线程唤醒的起始位置********//
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

未能获得锁的线程被方法parkAndCheckInterrupt阻塞了,所以当我们在unlock中调用unpark唤醒一个等待队列上的线程结点时,线程将从此处重新进入死循环尝试去获取锁。如果能够获得锁,将从等待队列中移除自己,并返回,否则再次被阻塞等待唤醒。

整个unlock方法的执行流程也已经大致介绍完成,最后我们看看可重入锁ReentrantLock和synchronized的一些对比。

四、ReentrantLock对比synchronized
     synchronized更倾向于一种声明式的编程方式,我们在方法前使用synchronized修饰,Java会自动为我们实现其内部的细节,什么时候加锁,什么时候释放锁都是它负责的。
     而对于我们的ReentrantLock重入锁来说,需要我们自己手动的去加锁和释放锁,对于逻辑的要求更高,也相对更难。
     而随着jvm版本的更新和优化,ReentrantLock和synchronized在性能上的差别在逐渐缩小,所以一般建议使用synchronized而尽量避免复杂难操作的ReentrantLock。

对于显式锁的基本情况大致介绍如上,如有错误之处,望指出!