《Java并发编程的艺术》第5章 Java中的锁 ——学习笔记

参考https://www.cnblogs.com/lilinzhiyu/p/8125195.html

5.1 Lock接口

锁是用来控制多个线程访问共享资源的方式。

一般来说一个锁可以防止多个线程同时访问共享资源但有些锁可以允许多个线程访问共享资源,如读写锁)。

在Lock接口出现前,java使用synchronized关键字实现锁的功能,但是在javaSE5之后,并发包中提供了Lock接口(以及其实现类)用来实现锁的功能。

Lock提供了与synchronized相似的功能,但必须显示的获取锁与释放锁,虽然不及隐式操作方便,但是拥有了锁获取与释放的可操作性、可中断的锁获取与超时获取锁等多重功能

提供场景:先获取锁A,在获取锁B,当获取锁B后,释放锁A的同时获取锁C,当获取锁C后,释放锁B的同时获取锁D,以此类推,可以通过Lock实现。

  Lock的使用:

  

【注】:在finally中释放锁,目的保证获取锁之后能够最终释放锁。

    不要把获取锁的过程写在try中,因为这样出现异常时,锁将会因为异常的抛出而被释放掉。

5.2 队列同步器

  队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基本框架。它使用了一个int成员变量表示同步状态通过内置的FIFO队列来完成资源的获取线程的排队工作

  同步器的主要使用方法是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。

  在抽象方法的实现过程中对同步状态进行更改,需要使用到同步器提供的三个方法:getState()、setState(int newState)和compareAndSetState(int expect,int update)来进行操作,这三个方法可以保证状态的改变是安全的。子类被推荐定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放方法来供自定义同步组件使用,同步器即可以支持独占式获取同步状态,也可以支持共享式地获取同步状态,这样方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock、CountDownLatch等)。

  同步器是实现锁(也可以是任何同步组件)的关键在锁中聚合同步器,利用同步器实现锁的语义

 同步器与锁两者的关系:

  • 锁是面向使用者的,他定义了使用者与锁交互的接口(比如允许两个线程并行访问),隐藏了实现细节;
  • 同步器是面向锁的实现者,它简化了锁的实现方式,屏蔽了同步管理状态、线程的排队、等待与唤醒等底层操作。
  • 锁让使用者仅仅是调用其方法既可以实现同步效果、同步器让实现者通过重写抽象方法进行了队列的底层操作。他们两个是使用者和实现者关注不同的领域实现了相同的效果。

5.2.1 队列同步器的接口与示例

  同步器的设计是基于模板方法模式的,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义的同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法会调用使用者重写的方法。

  同步器的原理可以使我们只关注于自己需要实现的方法而不需要关注其他的地方。

 重写同步器指定方法时需要使用同步器提供的如下三个方法来访问或修改同步状态:

  • getState():获取当前同步状态
  • setState(int new State):设置当前同步状态
  • compareAndState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性。

实现自定义同步组件时,将会调用同步器提供的模板方法,这些模板方法与描述:

模板方法基本分为三类:独占式同步状态获取与释放共享式同步状态获取与释放查询同步队列中等待线程情况。

以独占锁的示例来深入了解一下同步器的工作原理(以便能够更加深入的理解其他同步组件)。

独占锁:同一时刻只有一个线程能够获取锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后续的线程才能够获取锁。

public class Metux implements Lock {
    //静态内部类,自定义同步器
    private static class Sync extends AbstractQueuedSynchronizer{

        //是否处于占用状态
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
        //当状态为0的时候获取锁
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0,1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }
        //释放锁,将状态设置为0

        @Override
        protected boolean tryRelease(int arg) {
            if (getState() == 0) throw new
                    IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        //返回一个condition,每个condition都包含了一个condition队列
        Condition newCondition() {
            return new ConditionObject();
        }
    }

    //仅需要将操作代理到Sync上即可
    private final Sync sync = new Sync();
    @Override
    public void lock() {
        sync.tryAcquire(1);
    }
    @Override
    public boolean tryLock() {
        return sync.tryAcquire(1);
    }
    @Override
    public void unlock() {
        sync.tryRelease(1);
    }
    @Override
    public Condition newCondition() {
        return sync.newCondition();
    }

    public boolean isLocked() {
        return sync.isHeldExclusively();
    }

    public boolean hasQueuedThreads() {
        return sync.hasQueuedThreads();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }
}

  独占锁Mutex是一个自定义同步组件,它在同一时刻只允许一条线程占有锁。

  Mutex定义了一个静态内部类,该内部类继承了同步器并实现了独占式获取和释放同步状态。

  在tryAcquire(int acquire)方法中,如果经过CAS设置成功(同步状态设置为1),则代表获取了同步状态,而在tryRelease(int release)方法中只是将同步状态重置为0。

  用户使用Mutex时,并不会直接和内部同步器实现打交道。而是调用Mutex提供的方法,在Mutex的实现中,以获取锁的lock()犯法为例:只需要在方法实现中调用同步器的模板方法acquire(int args)即可。

  当前线程调用该方法获取同步状态失败后会被加入到同步队列中等待,这样就大大降低了实现一个可靠自定义组件的门槛。

5.2.2 队列同步器的实现分析

  同步器完成线程同步的方式:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构和模板方法。

(1)同步队列

  原理:同步器依赖于内部的同步队列(一个FIFO(先入先出)双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构成一个节点(Node)并将其加入同步队列,同时阻塞当前线程,当同步状态释放时,会将首节点中的线程唤醒,使其再次尝试获取同步状态。

  FIFO:first in first output,先入先出。

  节点(Node):用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点信息。

  

  节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会称为节点加入队列的尾部。

  同步队列的结构:

   

  注:同步器包含两个节点类型的引用,一个指向头节点,一个指向尾节点

  线程加入队列的过程必须保证线程安全,同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail(Node expect,Node update),保证线程安全。

  为什么必须保证线程安全? 同时有多条线程没有获取同步状态要加入同步队列,这时如果不是线程安全的,请问谁先谁后呢?所以在此处的这个操作必须是线程安全的。

  它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。

  同步器将节点加入同步队列的过程:

   

 同步队列遵循先入先出,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点:

  

   设置首节点是通过获取同步状态成功的线程完成的,由于只有一个线程能够获取到同步状态,因此设置头节点的方法并不需要CAS来保障,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可。

(2)独占式同步状态获取与释放

  通过调用同步器的 acquire(int arg) 方法可以获取同步状态,该方法对中断不敏感,也就是说由于线程获取同步状态失败后进入同步队列中,后继对线程进行中断操作时,线程不会从同步队列移除。acquire方法:

上述代码中完成了同步状态的获取、节点构造、加入同步队列以及同步队列中自旋等待的相关工作。主要逻辑:

  首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态。如果获取不到阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

  节点的构造以及加入同步队列依靠于addWaiter和enq方法:

       

  上述代码通过compareAndSetTail(Node expect, Node update)方法来确保节点能够被线程安全添加。

  在enq(final Node node)中,同步器通过死循环的方式来确保节点的添加,在死循环中只有通过CAS将当前节点设置为尾节点之后,当前线程才能从该方法返回,否则的话当前线程不断地尝试设置。enq(final Node node)方法将并发添加节点的请求通过CAS变得“串行化”了。

  节点进入队列后,就进入了一个自旋状态,每个节点(或者说每个线程),都在自省观察,当条件满足,获取到同步状态,就可以从这个自旋过程中退出,否则依旧留在自旋过程中:

   

在acquireQueued(final Node node, int arg)方法中,当前线程在“死循环”中尝试获取同步状态,而只有前驱节点是头节点才能够尝试获取同步状态,原因如下:

 1)头节点是成功获取到同步状态的节点,而头节点线程获取到同步状态后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。

 2)维护同步队列的先进先出原则,该方法中节点自旋获取同步状态的行为如下图:

  

  由于非首节点线程前驱节点出队或被中断而从等待状态返回,随后检查自己的前驱是否是头节点,如果是则尝试获取同步状态。可以看到节点与及节点之间在循环检查的过程中基本上不相互通信,而是简单地判断自己的前驱是否为头节点,这样就使得节点的释放符合FIFO,并且对于方便对过早通知进行处理(过早通知指的是前驱节点不是头节点的线程由于中断被唤醒)。

  独占式同步状态获取流程,也就是acquire(int arg)方法调用流程:

  

    在上图中前驱节点为头节点且能够获取同步状态与线程进入等待状态是获取同步状态的自旋过程(acquireQueued方法的死循环),当同步状态获取成功,当前线程从acquire(int arg)方法返回,这也就代表着当前线程获得了锁

    当前线程获取同步状态完成相应逻辑后,需要释放同步状态,通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。代码如下:

     

  该方法执行时,会唤醒头节点的后继节点线程,unparkSuccerssor(Node node)方法使用LcokSupport(后面讲)来唤醒处理等待状态的线程。

独占式同步状态获取和释放:

    在获取同步状态时,同步器会维持一个同步队列,获取失败的线程都会被加入到同步队列中,并在同步队列中自旋(判断自己前驱节点为头节点)。

    移出队列(停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

(3)共享式同步状态获取与释放

  共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态

  以读写为例:

    如果一个程序对文件进行读操作时,那么这一时刻对于该文件的写操作均被堵塞,而读操作能够同时访问。写操作要求对资源的独占,而读操作是可以共享式的访问。

  如下图,共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞; 独占式访问资源时,同一时刻的其他访问均被阻塞

   

  通过调用同步器的acquireShared(int arg)方法可以共享式地获取同步状态:

  

  在acquireShared(int arg)方法中,同步器调用tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为int型,当返回值大于等于0时,表示能够获取到同步状态。在共享式获取自选状态过程中,成功获取到同步状态并退出自旋的条件就是tryAcquireShared(int arg)方法的返回值大于等于0。可以看到,在doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于0,表示该次获取同步状态成功,并从自旋过程中退出。

  与独占式相同,共享式获取也需要释放同步状态,通过调用releaseShared(int arg)方法可以释放同步状态:

  

  注:该方法释放同步状态之后,将会唤醒后续处于等待状态的节点。对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般都是通过CAS和循环来保证的,因为释放同步状态的操作会同时来自多个线程。

(4)独占式超时获取同步状态

  通过调用同步器的tryAcquireNanos(int arg,long nanosTimeout)方法可以超时获取同步状态,即在指定的时间内获取同步状态,如果获取到同步状态则返回true,否则,返回false。

  在java5之后,同步器提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptException异常。(1.5之前并不会)。

  超时获取同步状态的过程可以被视作响应中断获取同步状态过程的“增强版”。doAcquireNanos(int arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。

  针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知,nanosTimeout计算公式为:nanosTimeout=now-lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间,如果nanosTimeout大于0表示超时时间未到,需要继续睡眠nanosTimeout纳秒,反之,表示已经超时。

  

  该方法在自旋过程中,当节点的前驱节点为头节点时尝试获取同步状态,如果获取成功则从该方法返回,这个过程和独占式同步获取的过程类似,但是在同步状态获取失败的处理上不同。如果当前线程获取同步状态失败,则判断是否超时(nanosTimeout小于0表示超时),如果没有超时,重新计算超时间隔nanosTimeout,然后使线程等待nanosTimeout纳秒(当已到设置的超时时间,该线程会从LockSupport.parkNanos(Object blocker, long nanos)方法返回)。

  如果nanosTimeout小于等于spinForTimeoutThreshold(1000纳秒)时,将不会使该线程进行超时等待,而是进入快速的自旋过程。原因:非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现的不精确。

  因此在超时非常短的场景下,同步器会进入无条件的快速自旋。

  

  独占式超时获取同步状态doAcquireNanos(int arg, long nanosTimeout)与独占式获取同步状态acquire(int args)的主要区别在于 未获取到同步状态的逻辑

  • acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状态,
  • doAcquireNanos(int arg, long nanosTimeout)会使当前线程等待nanosTimeout纳秒,如果当前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。

(5)自定义同步组件——TwinsLock

  通过自定义自己的同步组件提高对同步器的理解。

  同步组件功能:该组件同一时刻最多只允许两个线程访问,超过两个线程的访问将被阻塞,我们将这个同步工具命名为TwinsLock。

    1)确定访问模式:

      TwinsLock能够在同一时刻支持多个线程的访问,这是共享式访问。

      同步器应该提供acquireShared(int args)方法与Shared相关的方法。

      这就要求TwinsLock必须重写tryAcquireShared(int args)方法和tryReleaseShared(int args)方法,这样才能保证同步器的共享式同步状态的获取与释放方法的执行。

    2)定义资源数:

      TwinsLock在同一时刻允许至多两个线程的同时访问,同步资源数为2。设置初始状态status为2,当一个线程进行获取时,status减1,该线程释放,则status加1,状态的合法范围0、1、2。其中0代表两个线程已经获取了同步资源,此时再有其他线程对同步状态进行获取,该线程只能被阻塞。在同步状态变更时,需要使用compareAndSet(int expect, int update)方法做原子性保障

    3)定义同步器:

      自定义同步组件通过组合自定义同步器来完成同步功能,一般情况下自定义同步器会被定义为自定义同步组件的内部类。

public class TwinsLock implements Lock {
    private final Sync sync = new Sync(2);

    private static final class Sync extends AbstractQueuedSynchronizer {

        Sync(int count) {
            if(count < 0) {
                throw new IllegalArgumentException("count must large than zero");
            }
            setState(count);
        }

        @Override
        public int tryAcquireShared(int reducecount) {
            for (;;) {
                int current = getState();
                int newCount = current - reducecount;
                if (newCount < 0 || compareAndSetState(current,newCount)) {
                    return newCount;
                }
            }
        }

        @Override
        protected boolean tryReleaseShared(int returnCount) {
            for(;;) {
                int current = getState();
                int newCount = current + returnCount;
                if (compareAndSetState(current,newCount)) {
                    return true;
                }
            }
        }
    }
    @Override
    public void lock() {
        sync.acquireShared(1);
    }

    @Override
    public void unlock() {
        sync.releaseShared(1);
    }
    //其他接口略
}

  在上例中TwinsLock实现了Lock接口,提供了面向使用者的接口,使用者调用lock()方法获取锁,使用unlock()释放锁,而且同一时刻只能有两个线程获取到锁。   TwinsLock同时还包含了一个自定义同步器sync,而该同步器面向线程访问和同步状态控制。   以共享式获取同步状态为例:同步器会先计算出获取后的同步状态,然后通过CAS确保状态的正确设置,当tryAcquireShared(int reduceCount)方法返回值大于等于0时,当前线程才能获取同步状态,对于上层的TwinsLock而言,则表示当前线程获取锁。

5.3 重入锁(ReentrantLock)

重入锁 ReentrantLock:支持冲进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平性选择

  sychnronized关键字隐式的支持重进入,比如一个sychnronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获取该锁。

  ReentrantLock虽然没能像sychnronized关键字一样隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞

公平锁   指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来依次获得锁。(等待时间最长的线程最优先获取锁

      优点:等待锁的线程不会饿死。能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。

      缺点:整体效率相对较低。

非公平锁:可以抢占,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,则该线程会直接抢占,而这时阻塞在等待队列的线程不会被唤醒。

ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。

默认实现的是非公平锁,因为可能会出现线程连续获取锁的情况,因此非公平锁可能造饥饿,但由于线程切换很少,保证其吞吐量大。

那么为什么非公平锁是默认的呢?
在书中给出的例子中,非公平锁的开销比较少,应为相同线程再次获得锁快呀。。。

到这里会问,为什么要发明重入锁这么个东西,在平常的场景中会有这样的情况,当前线程访问一个加锁的方法时候,这个线程又在执行另一个方法也要走这个加锁的方法,如果不是重入锁,那么就会变成了死锁(当前线程的走的第一个方法一直持有锁,第二个方法始终等待锁,方法1可能会等待方法2的返回值,但是方法2一直等待方法1的释放锁,这样就成了死锁)。。。有了重入锁,就避免了这样的问题。

下面分析ReentrantLock如何实现重进入和公平性获取锁的特性:

(1)实现重进入

  重进入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞,改特性的实现需要解决以下两个问题:

  • 线程再次获取锁。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功被释放

  ReentrantLock是通过组合自定义同步器来实现锁的获取与释放。(nofairTryAcquire(int acquires)、tryAcquire(int acquires))

(2)公平与非公平获取锁的区别

  公平锁实现方法tryAcquire(int acquires)与非公平方法nofairTryAcquire(int acquires)相比,唯一不同的是队列一个 同步队列中是否有前驱结点的判断,如果有(说明有线程比当前线程更早地请求获取锁),则需要等待前驱线程获取并释放锁之后才能继续获取锁。

  公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁很可能会出现一个线程连续获取锁的情况。为什么会出现一个线程连续获取锁的情况呢?nofairTryAcquire(int acquires)方法,当一个线程请求锁时,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获取同步状态的几率会非常大,使得其他线程只能在同步队列中等待。

  公平锁保证了锁的获取按照先入先出的原则,而代价是进行大量的线程切换。非公平锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。

 5.4 读写锁(ReentrantReadWriteLock)

前面提到的锁基本都是排他锁。

排他锁同一时刻只允许一个线程进行访问

读写锁同一时刻可以允许多个读线程访问,但是在写访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大的提升。

Java并发包提供读写锁的实现是ReentrantReadWriteLock,它的特性如下:

特性 说明
公平性选择   支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平
重进入   该锁支持重进入。以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁;而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁。
锁降级   遵循获取写锁、获取读锁再获取写锁的次序,写锁可以降级成为读锁

5.4.1 读写锁的接口与示例

public class Cache {
static Map<String, Object> map = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
//获取一个key对应的value
public static final Object get(String key){
    r.lock();
    try {
        return map.get(key);
    } finally {
        r.unlock();
    }
}

//设置key对应的value,并返回旧的value
public static final Object put(String key,Object value){
    w.lock();
    try {
        return map.put(key, value);
    } finally {
        w.unlock();
    }
}
//清空所有的内容
public static final void clear(){
    w.lock();
    try {
        map.clear();
    } finally {
        w.unlock();
    }
}
}
  • 上述示例中,Cache组合一个非线程安全的HashMap作为缓存的实现,同时使用读写锁的读锁和写锁来保证Cache是线程安全的。
  • 在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。
  • 写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放后,其他读操作才可以继续。

5.4.2 读写锁的实现分析

(1)读写状态的设计

  读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。

  如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁是将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图1所示:

  

  当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁是如何迅速的确定读和写各自的状态呢?答案是通过位运算。假设当前同步状态值为S,写状态等于 S & 0x0000FFFF(将高16位全部抹去),读状态等于 S >>> 16(无符号补0右移16位)。当写状态增加1时,等于S + 1,当读状态增加1时,等于S + (1 << 16),也就是S + 0x00010000。

   高十六位为读锁统计位,低十六位为写锁统计位。一切状态为的更新都变为对这个数的位运算

  根据状态的划分能得出一个推论:S不等于0时,当写状态(S & 0x0000FFFF)等于0时,则读状态(S >>> 16)大于0,即读锁已被获取

 (2)写锁的获取与释放

  写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如代码清单2所示。

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
        // 存在读锁或者当前获取线程不是已经获取写锁的线程
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        if (w + exclusiveCount(acquires) &gt; MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        setState(c + acquires);
        return true;
    }
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
        return false;
    }
    setExclusiveOwnerThread(current);
    return true;
}

  该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此只有等待其他读线程都释放了读锁,写锁才能被当前线程所获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞

  写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。

 (3)读锁的获取与释放

  读锁是一个支持重进入的共享锁,它能被多个线程同时获取,在没有其他写线程访问(或写状态为0)时,读状态总会被成功地获取,而所做的也只是(线程安全的)增加读状态。

  如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。

  读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这使得获取读锁的实现变得复杂。

protected final int tryAcquireShared(int unused) {
    for (;;) {
        int c = getState();
        int nextc = c + (1 &lt;&lt; 16);
        if (nextc &lt; c)
            throw new Error("Maximum lock count exceeded");
        if (exclusiveCount(c) != 0 &amp;&amp; owner != Thread.currentThread())
            return -1;
        if (compareAndSetState(c, nextc))
            return 1;
    }
}

  在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁

  读锁的每次释放均(线程安全的,可能有多个读线程同时释放读锁)减少读状态,减少的值是(1 << 16)

(4)锁降级

  锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

  锁降级中读锁的获取是否有必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新

  【注】:RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。原因也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程不可见。

5.5 LockSupport工具

  LockSupport工具类定义了一组park开头的方法来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程

  • void  park():阻塞当前线程,如果用unpark(Thread thread)方法或者当前线程被中断,才能从park()方法返回。
  • void  parkNanos(long nanos):阻塞当前线程,最长不超过nanos秒,返回条件在park()基础上增加了超时返回。
  • void  parkUntil(long deadline):阻塞当前线程,直到deadline时间(从1970年开始到deadline时间的毫秒数)。
  • void  unpark(Thread thread):唤醒处于阻塞状态的线程thread。

 5.6 Condition接口

   任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()、wait(long timeout)、notify()、notifyAll()方法,这些方法与sychronized同步关键字配合,可以实现等待/通知模式。

  Condition接口也提供了类似Objext的监视器方法,与Lock配合可以实现等待/通知模式。

   

使用方式,代码示例:

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
public void conditionWait()throws InterruptedException{
    lock.lock();
    try {
        condition.await();//当前线程进入等待状态知道被通知(signal)或中断
    } finally {
        lock.unlock();
    }
}
public void conditionSignal()throws InterruptedException{
    lock.lock();
    try {
        condition.signal();//唤醒一个等待在Condition上的线程
    } finally {
        lock.unlock();
    }
}

Condition的实现分析:

  ConditionObject是同步器AbstractQueuedSynchronizer的内部类,因为Condition的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理。每个Condition对象都包含着一个队列(以下称为等待队列),该队列是Condition对象实现等待/通知功能的关键

(1)等待队列

  等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。

  一个Condition包含一个等待队列,Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)。当前线程调用Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列。Condition拥有首尾节点的引用,而新增节点只需要将原有的尾节点nextWaiter指向它,并且更新尾节点即可。上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。

  

  在Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列,如下图所示:

(2)等待

  调用Condition的await()方法(或者以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁。

  如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到Condition的等待队列中。

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    // 同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaiter()方法把当前线程构造成一个新的节点并将其加入等待队列中
    Node node = addConditionWaiter();
    // 释放同步状态,也就是释放锁
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    // 检测节点是否在同步队列中
    // 若节点没有在同步队列中则一直阻塞
    while (!isOnSyncQueue(node)) {
        // 阻塞线程
        LockSupport.park(this);
        // 检查阻塞过程中是否发生中断,若有中断则退出while循环
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    // 调用signal()方法将当前线程从等待队列放到同步队列中
    // 线程被唤醒,退出while循环
    
    // acquireQueued()方法尝试获取同步状态
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null)
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

  如果从队列的角度去看,当前线程加入Condition的等待队列,该过程如图所示:

(3)通知

  调用Condition的 signal()方法,将会 唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中调用该方法的前置条件是当前线程必须获取了锁。接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程。
  节点从等待队列移动到同步队列的过程如下图所示:

  通过调用同步器的enq(Node node)方法,等待队列中的头结点线程安全地移动到同步队列。当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程。

  被唤醒后的线程,将从await()方法中的while循环中退出(isOnSyncQueue(Node node)方法返回true,节点已经在同步队列中),进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中。

  成功获取同步状态(或者说锁)之后,被唤醒的线程将从先前调用的await()方法返回,此时该线程已经成功地获取了锁。


参考 https://www.jianshu.com/p/559cc069626b


 



猜你喜欢

转载自www.cnblogs.com/toria/p/bingfa5.html