Java并发编程 - 共享锁

Java并发编程 - 深入剖析ReentrantLock之非公平锁加锁流程(第1篇)
Java并发编程 - 深入剖析ReentrantLock之非公平锁解锁流程(第2篇)

之前的文章讲过ReentrantLock,通过调试示例分析其加锁和结锁的流程,ReentrantLock是独占锁,每次都只允许一个线程加锁和解锁。其内部的state状态只有0和1两种值,当线程无法通过CAS将其设为1的时候,则说明有其他线程持有锁,那么该线程就进入同步队列进行等待。

独占锁每次只允许一个线程持有它的使用权,与之相对应的有共享锁模式,共享锁允许多个线程同时拥有它。

Semaphore可以作为共享锁使用,这篇文章我们通过它的加锁和解锁来分析其内部实现原理。

澡堂洗澡情景

Bathhouse.java

import java.util.concurrent.Semaphore;

public class Bathhouse {

    private static final Semaphore bathhouseManager = new Semaphore(2);// 澡堂管理员手上有2张澡牌

    public void bathe() {
        try {
            bathhouseManager.acquire();
            System.out.println(Thread.currentThread().getName() + ": 在洗澡...");
            Thread.sleep(3000);
            System.out.println(Thread.currentThread().getName() + ": 出澡堂...");
            bathhouseManager.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {
        Bathhouse bathhouse = new Bathhouse();

        for (int i=1; i<=5; i++) {
            new Thread(new Runnable() {

                @Override
                public void run() {
                    bathhouse.bathe();
                }

            }, "洗澡君" + i).start();
        }
    }
}

上面的情景中,Semaphore作为共享锁使用,每次允许两个人洗澡。
有5个"洗澡君",用5个线程表示,我们依次称作为线程A、B、C、D、E。

下面的调试过程中,通过IDE多线程调试工具控制线程的执行顺序,并发有影响的代码会做说明。如果按照实际多线程并发的情形,代码不好分析。

请求锁流程分析

执行步骤:线程A、B、C、D、E依次执行

# 第一步:线程A、B获取锁不释放,线程C、D、E启动获取锁

创建Semaphore对象,传递2个令牌。

Semaphore bathhouseManager = new Semaphore(2)

此时Semaphore内部数据下图所示:


Java并发编程 - 共享锁_第1张图片
Semphore-1.png
1. 线程A执行acquire获取令牌

Semaphore.java

public void acquire() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

acquireSharedInterruptibly在AbstractQueuedSynchronizer中定义:

AbstractQueuedSynchronizer.java

public final void acquireSharedInterruptibly(int arg) throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (tryAcquireShared(arg) < 0)
        doAcquireSharedInterruptibly(arg);
}

tryAcquireShared用于判断是否获取到锁的拥有权:

Semaphore->NonfairSync

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = -2694183684443567898L;

    NonfairSync(int permits) {
        super(permits);
    }

    protected int tryAcquireShared(int acquires) {
        return nonfairTryAcquireShared(acquires);
    }
}

接下来调用nonfairTryAcquireShared, 这个方法在Sync中定义:

Semaphore->Sync

final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

这样首先获取当前的令牌数,然后减去1。如果remaining小于0,说明当前无令牌可用,直接返回remaining。如果remaining大于0,那么重设令牌数,但是CAS可能会设置失败,因为在一个线程执行到这里查看到令牌的数量之后,可能已经有线程把令牌拿走了,那么我们看到的这个令牌数量就是无效的,CAS设置就会失败。我们可以看到这里是个无限循环,所以继续执行循环直到当前线程看到无令牌或者成功设置了令牌剩余数。

回到acquireSharedInterruptibly方法,我们调试的时候当前只有线程A在操作,A拿走了一个令牌,所以tryAcquireShared返回1。

tryAcquireShared(arg) < 0

条件不满足,线程A获取令牌执行结束。

2. 线程B执行acquire获取令牌

执行流程和线程B类似,不做重复。线程B执行完后,当前是令牌数为0,及state=0。

3. 线程C执行acquire获取令牌

当前令牌数为0,tryAcquireShared返回-1。

if (tryAcquireShared(arg) < 0)

条件满足,执行if语句块内容,执行doAcquireSharedInterruptibly。

AbstractQueuedSynchronizer.java

private void doAcquireSharedInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

##第一句:创建代表线程C的节点,并加入到同步队列(节点入队列不做说明)

执行完后,Semaphore内部数据如下:


Java并发编程 - 共享锁_第2张图片
Semphore-2.png

##第二句:获取当前节点的前置节点

final Node p = node.predecessor();

当成node节点为C节点,根据我们上面的图p=head。

这里要注意一下,由于共享锁可多线程获取的影响,如果其他线程抢先线程C执行addWaiter方法,那么这里的p就不会是head。不是head的执行流程与我们下面要讲述的D节点类似。

##第三句:重试获取令牌

 int r = tryAcquireShared(arg);

这里由于多线程的影响,可能此时可以获取到令牌,前提是当前节点是head的后继节点,我们这里因为线程A和线程B还没释放锁,所以r=-1。

这里可以获得令牌的情况,在同步队列中的线程被唤醒阶段说明。

##第四句:将节点的前置节点的waitStatus设置为SIGNAL(-1)

shouldParkAfterFailedAcquire

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

这里不做具体说明,想了解可以参考文章头部的两篇文章。

我们这里shouldParkAfterFailedAcquire返回false,第一次循环结束,进行第二次循环,同样的第一个if语句不执行,继续执行第二个语句,此时调用shouldParkAfterFailedAcquire,因为p也就是我们的head节点的waitStatus在第一次循环时已经设置为-1,所以shouldParkAfterFailedAcquire返回true,执行parkAndCheckInterrupt,线程C被挂起。

AbstractQueuedSynchronizer.java

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

此时Semaphore内部数据如下所示:

Java并发编程 - 共享锁_第3张图片
Semphore-3.png
4. 线程D、E执行acquire获取令牌

执行流程和线程C操作类似,这里不再做说明,线程D、E执行完后,Semaphore内部数据如下:

Java并发编程 - 共享锁_第4张图片
Semphore-4.png

释放锁流程分析

执行步骤:线程A先执行,执行到doReleaseShared的unparkSuccessor行后停住(方法里面的Node s = node.next行),然后线程B执行

# 第一步:线程A执行

执行release方法释放令牌:

Semaphore.java

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

releaseShared在AbstractQueuedSynchronizer重定义:

AbstractQueuedSynchronizer.java

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

执行tryReleaseShared:

Semaphore->Sync

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

由于多线程的影响,需要循环执行,直到设置正确的值。我们这里执行后state=1。

  • @@releaseShared方法执行流程

AbstractQueuedSynchronizer.java

private void doReleaseShared() {
    /*
     * Ensure that a release propagates, even if there are other
     * in-progress acquires/releases.  This proceeds in the usual
     * way of trying to unparkSuccessor of head if it needs
     * signal. But if it does not, status is set to PROPAGATE to
     * ensure that upon release, propagation continues.
     * Additionally, we must loop in case a new node is added
     * while we are doing this. Also, unlike other uses of
     * unparkSuccessor, we need to know if CAS to reset status
     * fails, if so rechecking.
     */
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

先来看这个方法的作用, 方法的注释如下:

Release action for shared mode -- signals successor and ensures propagation. (Note: For exclusive mode, release just amounts to calling unparkSuccessor of head if it needs signal.)

共享模式下的释放操作 —— 通知后继者保证传播性。(注意:对于互斥模式,释放仅仅意味如果head节点需要通知的话就调用unparkSuccessor方法。)

Ensure that a release propagates, even if there are other in-progress acquires/releases. This proceeds in the usual way of trying to unparkSuccessor of head if it needs signal. But if it does not, status is set to PROPAGATE to ensure that upon release, propagation continues. Additionally, we must loop in case a new node is added while we are doing this. Also, unlike other uses of unparkSuccessor, we need to know if CAS to reset status fails, if so rechecking.

即使有其他的acquires/releases操作正在执行,也要确保释放的传播。如果head节点需要传播那么尝试使用普通方式来执行uparkSuccessor。如果不是,那么当前节点的状态要设置成PROPAGAE以保证上面的释放,传播继续。另外,我们必须循环处理因为当我们执行的时候可能会有新的节点添加进行。再者,不想其他地方使用unparkSuccessor,我们需要知道CAS设置状态是否失败,如果失败就重新检查。

##第1句:获取head节点

Node h = head;

unparkSuccessor操作,总是从head节点开始。需要注意的是上面说过了,在我们处理的过程中,可能会有其他的线程改变了head节点,也就是说这个h保存的是当前线程这一时刻看到的head节点。这个代码之后,如果有其他线程改变了同步队列的内容,那么这个h指向的是旧的head,此时h指向的节点的内部属性可能已经改变。

##第2句:判断h是否为空并且是否为tail节点

有人会说,h怎么会为空呢?

因为我们执行完h=head之后h指向的这个头节点可能已经被其他线程移出了同步队列,这个时候它的next为空,可能会进行垃圾回收使得
h指向的对象不再存在。

为什么还要判断是否等于tail节点?

因为可能在我们执行h= head之前,已经其他线程把队列中所有的节点都处理完了,在将ReentrantLock锁释放的那篇文章的时候,最后的那个图我们可以看到head和tail指向同一个节点了,这种情况下就没有节点需要处理。

多线程的影响使得这里的判断会有点复杂,不过其实也就两方面:h=head执行前多线程的影响和h=head执行后多线程的影响。

##第3句:获取waitStatus状态

int ws = h.waitStatus;

##第4句:判断现在我们处理的这个节点的状态是否还是SIGNAL,如果是就处理

if (ws == Node.SIGNAL) {
    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
        continue;            // loop to recheck cases
    unparkSuccessor(h);
}

if能执行的前提条件当前的线程这一刻抢到了先处理同步队列的权利,头节点还未被其他线程处理并更新状态。

##第5句:通过CAS将head节点的waitStatus状态设置为0

if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
        continue;

这里如果CAS设值失败的话,就继续循环。有人会说我上面不是已经抢到了处理头节点的权利了吗,这里怎么会失败呢?因为你上一刻是抢到了,但是下一刻被其他线程给抢到了,并且对队列进行了处理,也就是说此时此刻h指向的那个头节点,已经被移出了队列,waitStatus的值不再是SIGNAL。

##第6句:执行unparkSuccessor

unparkSuccessor(h);

此时此刻,说明当前线程已经完全抢到了队列head节点的处理权。只要我们这里不再执行,其他线程永远进不来这个地方。

此方法执行会唤醒head的后继节点。

##第7句:如果当前线程看到的head节点的waitStatus为0,尝试将它改为PROPAGATE

不是在unparkSuccessor之前已经把waitStatus改为0,然后被唤醒的线程会把当前表示自己的节点设置为head,这时候原head节点就已经出队列了吗,出队列的节点设置这个状态有什么用?故事就发生我们的上一步,一个线程已经把head节点的状态设置为0了,但是它还没来得及,或者被唤醒的线程还没来得级重设队列头,头节点的状态已经被成0了,但是还在队列中。这时候我们这个线程来了,看到了当前头结点的状态为0,然后就把它设置成PROPAGATE。

还会设置不成功? 对的,因为可能会同时有两个线程到达这里,一个线程设置成功了,另一个线程就会设置失败,设置失败的线程重新循环。

在我们这个例子中这样测试:线程A先执行,然后到达unparkSuccessor这个地方停住;线程B在执行,这时候线程B到这里,你就会看到它会把头节点的waitStatus设置为PROPAGATE。

##第8句:检查当前线程看到的是不是还是队列的头节点

  if (h == head)                   // loop if head changed
     break;

线程执行doReleaseShared可能的情况总结

线程执行doReleaseShared可能出现的执行情况如下:

  • 第一种:线程抢到了调度unparkSuccessor权利,做了唤醒头节点的任务,运行到h == head这里,唤醒的线程还没有做完更新同步队列的操作,头节点并未改变,这时候h=head,循环结束;唤醒的线程已经做完了更新同步队列的操作,头结点改变了,这时候h!=head,这个线程执行新一轮循环;
  • 第二种:线程执行,发现其他线程正在对头结点进行处理(compareAndSetWaitStatus设置waitStatus为0失败),于是它把当前头结点的waitStatus设置为PROPAGATE,运行到h == head这里,这是否发现同步队列还未被更新,头结点并未改变,这时候h=head,循环结束;唤醒的线程已经做完了更新同步队列的操作,头结点改变了,这时候h!=head,这个线程执行新一轮的循环;
  • 第三种:线程执行,发现其他线程正在对头结点进行处理(compareAndSetWaitStatus设置waitStatus为0失败)并且尝试设置waitStatus为PROPAGATE失败(出现第二种情况),线程执行新一轮的循环。

从这里可以看出,线程执行release操作,并不一定能唤醒同步队列中的线程,也许只是做了设置waitStatus为PROPAGATE这个功能。

# 第二步:线程B执行

按照我们上面规定的动作,线程A执行到unparkSuccessor时候停住(方法内部Node s = node.next行),然后线程B执行,线程B执行完,线程A再从unparkSuccessor开始执行。

按照我们上面的执行步骤,执行完成后,Semaphore内部数据如下:

Java并发编程 - 共享锁_第5张图片
Semphore-5.png

被唤醒的线程执行流程分析

在"请求锁流程分析"小节,线程C在doAcquireSharedInterruptibly方法内部停住:

AbstractQueuedSynchronizer.java

private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

调用parkAndCheckInterrupt这个方法的时候停住的。

现在经过我们上面释放锁步骤的执行,线程C被唤醒了,继续执行循环。

##第1句:获取C节点的前置节点

final Node p = node.predecessor();

在我们的示例当中,p==head,执行第一个if语句块。

##第2句:获取令牌

int r = tryAcquireShared(arg);

这个r是令牌的余数,所以r=1,此时state=1;

##第3句:判断令牌余数

if (r >= 0)

唤醒的线程不一定能获得令牌,所以这里要做判断。为什么令牌等于0也可以呢?当前线程获得了一个令牌后,令牌余数它此时看到的是0,但是有可能下一刻其他线程归还了令牌。

##第4句:设置头部并传播

setHeadAndPropagate(node, r);

这里先提一个注意点:我们进入到setHeadAndPropagate方法内部去,发现设置头部知识简单地调用了setHead方法,有的人会说不是多线程会影响吗?不应该用CAS来设置吗?

虽然有多线程的影响,但是这里不用考虑。因为通过上面的p=head的判断,当前线程已经可以进入if块了,此时如果同步队列中其他线程被唤醒了,代表它们的节点的前置节点不是head节点,只要当前线程不调用setHead重设头结点,那么其他的线程就无法进入。setHead的调用前后在多线程情况下的影响非常关键。

AbstractQueuedSynchronizer.java

/**
 * 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 either before
     *     or after setHead) 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 ||
        (h = head) == null || h.waitStatus < 0) {
        Node s = node.next;
        if (s == null || s.isShared())
            doReleaseShared();
    }
}

##第5句:记录老头结点

 Node h = head; // Record old head for check below

##第6句:重设头节点

setHead(node);

##第7句:根据条件判断是否可以传播

首先,我们需要弄清楚传播了什么?

我们回忆一下,在使用ReentrantLock的时候,当一个线程释放锁后,同步队列中head节点的后继节点被唤醒了,然后从挂起点开始,重新执行循环获取锁,获取锁后它的操作是这样的:

if (p == head && tryAcquire(arg)) {
    setHead(node);
    p.next = null; // help GC
    failed = false;
    return interrupted;
}

这里可以看到,仅仅是把代表它的节点重设成了头节点。那么新的头节点的后继节点所代表的线程被唤醒是什么时候?是等到重设头节点的这个线程释放锁的时候。为什么这样唤醒?因为锁是独占的,重设头节点的这个线程持有锁,那么其他的线程就无法持有锁,无法持有锁唤醒干嘛!

也就是独占锁执行步骤是:被唤醒的线程请求到锁->将代表它的节点设置为新头->被唤醒的线程释放锁->唤醒同步队列新头的后继节点...依次循环。

那么共享锁模式呢?被唤醒的线程获得锁之后来这里设置新头,当它调用setHead设置新头之后,它还必须等到它释放锁之后再唤醒新头节点的后继节点吗?不是必须的,因为令牌有多个,当前线程你不释放掉你的这个令牌,新头节点的后继节点所代表的线程也可以获取其他线程归还的令牌。

传播:同步队列更新后的唤醒传播

比如说:

共享模式:房间里有2个医生在看病,A,C,D,E依次在排队等待进入, A被准许进入了,这时候队头是C了,A看到还有一个医生在空闲着,他就会告诉C,对C说这里还有一个医生没在看病,你可以试试进来。C可能就进去了,也可能没进去,因为在非公平模式下如果E比较蛮横不排队,直接就闯进去了,C就只能继续等待。

独占模式::房间里有1个医生在看病,A,C,D,E依次在排队等待进入, A被准许进入了,只有等他出来通常C,C出来再通知D,D出来再通知E,以这样的方式看完病才行。同时人出来了,下一个对头的人也不一定能进去,因为非公平模式下会有不排队闯入的人存在。

就行来看什么情况下可以进行唤醒传播:

if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
    Node s = node.next;
    if (s == null || s.isShared())
        doReleaseShared();
}
  • propagate > 0
    propagate就是令牌的余数,大于0说明此时看到有令牌。

  • h == null
    h为老头结点的引用,此时老头结点已经被移除队列了,新队列产生了,并且其他线程调用了p.next = null; // help GC,老头结点被垃圾回收了。

  • h.waitStatus < 0
    我们回到doReleaseShared方法:

private void doReleaseShared() {
    /*
     * Ensure that a release propagates, even if there are other
     * in-progress acquires/releases.  This proceeds in the usual
     * way of trying to unparkSuccessor of head if it needs
     * signal. But if it does not, status is set to PROPAGATE to
     * ensure that upon release, propagation continues.
     * Additionally, we must loop in case a new node is added
     * while we are doing this. Also, unlike other uses of
     * unparkSuccessor, we need to know if CAS to reset status
     * fails, if so rechecking.
     */
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

当前线程通过unparkSuccessor被唤醒了,那么此时的head节点的waitStatus就会被设置为0,表明我通知的任务已经完成了,那为什么上面要判断是否小于0呢,其实我们上面已经分析了,因为还有这里的操作:

else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))

这个做标记了明确指定了需要传播。

PROPAGATE是如何设置的看释放锁流程的说明。

  • (h = head) == null || h.waitStatus < 0

能执行到这里来,说明前面都是false,个人觉得这两个条件要联合判断,if条件要成立的话,那么(h = head) == null这个条件就不满足,也就是实际上要为true就是(h = head) != null && h.waitStatus < 0, 这就是设置完新头结点,新头waitStatus=-1这种情况。

单独(h = head) == null为空成立,还让继续往下走,目前没想到是什么样的情况。

##第8句:执行传播

 Node s = node.next;
if (s == null || s.isShared())
    doReleaseShared();
  • s == null
    我理解的node节点是尾节点,但是下一个时刻可能会有新节点入队列。
  • s.isShared
    节点就是需要传播的节点。共享模式下入队列的节点初始态s.isShared都满足。

总结

共享锁之所以能共享,是因为它内部管理者多张令牌,某一时刻多个线程可以同时获取,同时,释放也能并发得发生。相反的独占锁就只有一个令牌,只能严格按照获取和释放这样的步骤来获取令牌。

你可能感兴趣的:(Java并发编程 - 共享锁)