• Java并发编程之显式锁机制


         我们之前介绍过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。

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

  • 相关阅读:
    測试AtomicInteger与普通int值在多线程下的递增操作
    《漫画线性代数》读书笔记 矩阵
    Android下雪动画的实现
    Live555实战之交叉编译live555共享库
    JAVA_SE基础——24.面向对象的内存分析
    Linux下利用signal函数处理ctrl+c等信号
    tomcat6url请求400错误(%2F与%5C)
    python的交互式shell-ipython体验
    1906月读书清单
    Linux对变量的截取替换
  • 原文地址:https://www.cnblogs.com/yangming1996/p/7735707.html
Copyright © 2020-2023  润新知