Java Queue Synchronizer Framework AQS Implementation Principle

Preface

In Java, the "lock" is used to control the access of multiple threads to shared resources. Friends who use the Java programming language know that the lock function can be realized through the synchronized keyword, which can implicitly acquire the lock, which means We use this keyword and do not need to care about the lock acquisition and release process, but it also means that its flexibility is reduced while providing convenience. For example, there is a scenario where lock A is acquired first, and then lock B is acquired. When lock B is acquired, lock A is released and lock C is acquired. After lock C is acquired, lock B is released and lock D is acquired, in turn By analogy, it is more difficult to implement the synchronized keyword in such a more complex scenario. After Java SE 5, the Lock interface and a series of implementation classes are newly added to provide the same functions as the synchronized keyword. It requires us to display the lock acquisition and release, in addition to providing interrupt response Synchronous features such as lock acquisition operations and timeout acquisition of locks. Most of the Lock interface implementation classes provided in the JDK aggregate a subclass of a synchronizer AQS to implement multi-threaded access control. Let’s take a look at this basic framework for building locks and other synchronization components-the queue synchronizer AQS (AbstractQueuedSynchronizer ).

1

AQS basic data structure

1.1

Synchronization queue

The queue synchronizer AQS (hereinafter referred to as the synchronizer) mainly relies on an internal FIFO (first-in-first-out) two-way queue to manage the synchronization state. When the thread fails to obtain the synchronization state, the synchronizer will Information such as the current thread and current waiting state is encapsulated into an internally defined node Node, and then it is added to the queue while blocking the current thread; when the synchronization state is released, the first node in the synchronization queue will be awakened and let it try to obtain synchronization again status. The basic structure of the synchronization queue is as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

1.2

Queue Node

The synchronization queue uses the static internal class Node in the synchronizer to save the reference of the thread that obtains the synchronization state, the waiting state of the thread, the predecessor node and the successor node.

Java Queue Synchronizer Framework AQS Implementation Principle

The attribute names and specific meanings of the Node nodes in the synchronization queue are shown in the following table:
Java Queue Synchronizer Framework AQS Implementation Principle
each node thread has two lock modes, respectively SHARED means that the thread waits for the lock in a shared mode, and EXCLUSIVE means that the thread waits for the lock in an exclusive manner. At the same time, waitStatus of each node can only take the enumerated values ​​in the following table:

Java Queue Synchronizer Framework AQS Implementation Principle

1.3

Synchronization state

The synchronizer uses an int type variable named state to represent the synchronization state. The main use of the synchronizer is through inheritance. The subclass manages the synchronization state by inheriting and implementing its abstract methods. The synchronizer provides us with the following Three methods to change the synchronization status.

Java Queue Synchronizer Framework AQS Implementation Principle
In an exclusive lock, the value of the synchronization state state is usually 0 or 1 (if it is a reentrant lock, the state value is the number of reentrants), and in a shared lock, state is the number of locks held.

2

Exclusive synchronization state acquisition and release

The synchronizer provides the acquire(int arg) method to acquire the exclusive synchronization state. The synchronization state is acquired, which means the lock is acquired. The source code of this method is as follows:


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

The method first calls the tryAcquire method to try to acquire the lock. Looking at the source code of the method, you can find that the synchronizer has not implemented the method (it just throws an UnsupportedOperationException). This method requires the developer of the subsequent synchronization component. To achieve this, if the method returns true, it means that the current thread has successfully acquired the lock, and selfInterrupt() is called to interrupt the current thread (PS: here is a question for everyone: why do I interrupt the thread after acquiring the lock?), the method ends and returns If the method returns false, it means that the current thread has failed to acquire the lock, which means that other threads have previously acquired the lock. At this time, you need to add the current thread and waiting status information to the synchronization queue. Let’s take a look at the synchronizer. How the thread does not acquire the lock. Through the source code, it is found that when the lock acquisition fails, the second half of the judgment condition and operation acquireQueued(addWaiter(Node.EXCLUSIVE), arg) will be executed. First, specify the lock mode as Node.EXCLUSIVE and call the addWaiter method. The method source code is as follows:


private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

A Node node is constructed through the lock mode (shared lock or exclusive lock) specified by the method parameters and the current thread. If the synchronization queue has been initialized, then an attempt will be made to join the queue from the tail first, and the compareAndSetTail method is used to ensure atomicity and enter the The method source code can be found to be implemented based on the Unsafe class provided under the sun.misc package. If the first attempt to join the synchronization queue fails, the enq method will be called again for the enqueue operation, and the source code of the enq method will be followed up as follows:

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

Through its source code, it can be found that it is similar to the code that tried to join the queue for the first time, except that the synchronization queue initialization judgment is added to the method, and the compareAndSetHead method is used to ensure the atomicity of setting the head node. The bottom layer is also based on the Unsafe class, and then the outer layer is set. A for (;;) infinite loop, the only exit condition of the loop is to enter the queue from the end of the queue successfully, that is to say, if the method returns successfully, it means that the queue has been successfully entered. At this point, addWaiter is executed and returns to the current Node node . Then use this node as the input parameter of the acquireQueued method to continue with other steps, the method is as follows:


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);
    }
}

It can be seen that this method essentially uses an infinite loop (spin) to acquire the lock and supports interruption. Two flag variables are defined outside the loop body, whether the failed flag successfully acquires the lock, and whether the interrupted flag is in the waiting process Has been interrupted. The method first obtains the predecessor node of the current node through the predecessor. When the predecessor node of the current node is the head node, tryAcquire is called to try to obtain the lock, that is, the second node tries to obtain the lock. Why do you want to try from the second node? What about acquiring the lock? The reason is that the synchronization queue is essentially a doubly linked list. In the doubly linked list, the first node does not store any data. It is a virtual node, but only serves as a placeholder. The node that actually stores data starts from the second node. of. If the lock is successfully acquired, that is, after the tryAcquire method returns true, point head to the current node and remove the previously found head node p from the queue, modify whether the lock mark is successfully acquired, and the end method returns an interrupt mark. If the predecessor node p of the current node is not the head node or the predecessor node p is the head node but the lock acquisition operation fails, then the shouldParkAfterFailedAcquire method will be called to determine whether the current node node needs to be blocked. The blocking judgment here is mainly to prevent long-term spinning. CPU brings very large execution overhead and wastes resources. The source code of the method is as follows:


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;
}

The method parameters are the predecessor node of the current node and the current node. The predecessor node is mainly used to determine whether blocking is required. First, the waiting state ws of the predecessor node is obtained. If the node status ws is SIGNAL, it means that the thread of the predecessor node is ready. , Wait for the resource to be released, and the method returns true to indicate that it can be blocked. If ws> 0, you can know that the node has only one state CANCELLED (value 1). If this condition is met, it means that the node thread's request to acquire the lock has been cancelled, and it will pass A do-while loop looks forward to the node in the CANCELLED state and removes it from the synchronization queue, otherwise it enters the else branch, and uses the compareAndSetWaitStatus atomic operation to change the waiting state of the predecessor node to SIGNAL. The above two cases do not need to be performed The blocking method returns false. When it needs to be blocked after judgment, that is, when the compareAndSetWaitStatus method returns true, the current thread will be blocked and suspended through the parkAndCheckInterrupt method, and the interrupt flag of the current thread will be returned. Methods as below:


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

Thread blocking is implemented by the LockSupport tool class. Going deep into its source code, you can find that its underlying layer is also based on the Unsafe class. If the above two methods both return true, the interrupt flag is updated. Another question here is when will the wait status of a node be changed to the CANCELLED node thread's request cancellation status for acquiring the lock? Careful friends may have discovered that the finally block in the acquireQueued method source code posted above will determine whether to call the cancelAcquire method according to the failed tag. This method is used to modify the node status to CANCELLED, the specific implementation of the method Leave it to everyone to explore. At this point, the AQS exclusive synchronization state acquisition lock process is complete, let's take a look at the overall process through a flowchart:

Java Queue Synchronizer Framework AQS Implementation Principle

Let's look at the exclusive lock release process again. The synchronizer uses the release method to let us release the exclusive lock. The source code of the method is as follows:


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

First call the tryRelease method to try to perform the lock release operation. Continue to follow up with the method and find that the synchronizer just throws an UnsupportedOperationException. This is the same as the tryAcquire method in the exclusive lock acquisition above. The developer needs to define the lock. Release operation.
Java Queue Synchronizer Framework AQS Implementation Principle

According to its JavaDoc, if it returns false, it means that the lock has failed to release and the method ends. If this method returns true, it means that the current thread releases the lock successfully, and the thread waiting to acquire the lock in the queue needs to be notified to perform the lock acquisition operation. First get the head node head. If the current head node is not null and its waiting state is not the initial state (0), then the thread blocking suspended state will be released by the unparkSuccessor method. The source code of this method is as follows:


private void unparkSuccessor(Node node) {
    /*
      * If status is negative (i.e., possibly needing signal) try
      * to clear in anticipation of signalling.  It is OK if this
      * fails or if status is changed by waiting thread.
      */
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
      * Thread to unpark is held in successor, which is normally
      * just the next node.  But if cancelled or apparently null,
      * traverse backwards from tail to find the actual
      * non-cancelled successor.
      */
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

First get the waiting state ws of the head node. If the state value is negative (Node.SIGNAL or Node.PROPAGATE), change it to the initial state (0) through the CAS operation, and then get the successor node of the head node. If the successor node is null or the status of the successor node is CANCELLED (the lock acquisition request has been canceled), start from the end of the queue to find the first node with a status other than CANCELLED, if the node is not empty, use the unpark method of LockSupport to wake it up, the bottom of the method It is implemented by unpark of the Unsafe class. The reason why it is necessary to find nodes in non-CANCELLED status from the end of the queue is that in the previous implementation of the addWaiter method of enqueue when the exclusive lock fails to be obtained, the method is as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

Suppose that a thread executes at ① in the above figure, and ② has not been executed yet. At this time, another thread happens to execute the unparkSuccessor method, then it cannot be searched from front to back because the successor pointer next of the node has not yet been assigned. So you need to search from back to front. At this point, the exclusive lock release operation is over. In the same way, we also look at the entire lock release process through a flowchart:

Java Queue Synchronizer Framework AQS Implementation Principle

3

Exclusive interruptible synchronization status acquisition

The synchronizer provides an acquireInterruptibly method to perform an interrupt-responsive acquisition lock operation. The source code of the method is as follows:


public final void acquireInterruptibly(int arg)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}

The method first checks the interruption status of the current thread. If it has been interrupted, it will directly throw an interrupted exception InterruptedException to respond to the interruption. Otherwise, call the tryAcquire method to try to acquire the lock. If the acquisition is successful, the method ends and returns. If the acquisition fails, call the doAcquireInterruptibly method to follow up the method. as follows:


private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

Observing carefully, we can find that the source code of this method is basically similar to that of the acquireQueued method above, except that the enqueue operation addWaiter is put in the method. The other difference is that it will be thrown directly when it is judged to be interrupted in the loop body. Exception to respond to interruption, the comparison of the two methods is as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

The other steps are the same as the exclusive lock acquisition. The flowchart is roughly the same as the lock acquisition that does not respond to interrupts, except that there is one more thread interruption status check and loop at the beginning that will throw an interrupt exception.

4

Exclusive timeout to obtain synchronization status

The synchronizer provides the tryAcquireNanos method to obtain the synchronization state (that is, the lock) over time. This method provides the timeout acquisition feature that was not supported by the synchronized keyword. Through this method, we can obtain the lock within the specified time period nanosTimeout. If it is obtained The lock returns true, otherwise, it returns false. The method source code is as follows:


public final boolean tryAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) ||
        doAcquireNanos(arg, nanosTimeout);
}

First, the tryAcquire method will be called to try to acquire a lock, and if the lock acquisition is successful, it will return immediately, otherwise the doAcquireNanos method will be called to enter the timeout lock acquisition process. From the above, it can be known that when the acquireInterruptibly method of the synchronizer is waiting to obtain the synchronization state, if the current thread is interrupted, it will throw an interrupted exception InterruptedException and return immediately. The process of acquiring a lock over time is actually adding the feature of timeout acquisition on the basis of responding to interrupts. The source code of the doAcquireNanos method is as follows:


private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            nanosTimeout = deadline - System.nanoTime();
            if (nanosTimeout <= 0L)
                return false;
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                LockSupport.parkNanos(this, nanosTimeout);
            if (Thread.interrupted())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

It can be seen from the source code of the above method that the main realization idea for timeout acquisition is: first use the current time plus the timeout interval deadline passed in by the parameter to calculate the timeout point, and then use the timeout point every time the loop is performed The deadline is subtracted from the current time to get the remaining time nanosTimeout. If the remaining time is less than 0, it proves that the current lock acquisition operation has timed out, and the method returns false if the remaining time is greater than 0. It can be seen that the execution of the spin inside is the same as the exclusive synchronous acquisition of lock state acquireQueued method above, that is, when the predecessor node of the current node is the head node, tryAcquire is called to try to acquire the lock, and if the acquisition is successful, it returns.

Java Queue Synchronizer Framework AQS Implementation Principle

In addition to the difference in the timeout calculation, there is another difference in the operation after the timeout fails to acquire the lock. If the current thread fails to acquire the lock, it is judged whether the remaining timeout time nanosTimeout is less than 0. If it is less than 0, it means that the method has timed out immediately. Return, otherwise, it will judge whether it is necessary to block and suspend the current thread. If it is determined that the current thread needs to be suspended and blocked by the shouldParkAfterFailedAcquire method, further compare the remaining timeout time nanosTimeout and spinForTimeoutThreshold. If it is less than or equal to the spinForTimeoutThreshold value (1000 nanoseconds) Otherwise, the current thread will not wait for a timeout, but will spin up again. The main reason for adding the latter judgment is that waiting in a very short time (less than 1000 nanoseconds) cannot be very accurate. If the timeout waits at this time, it will let us specify the timeout of nanosTimeout as a whole. People feel that it is not very accurate. Therefore, when the remaining timeout is very short, the synchronizer will spin again to acquire the lock over time. The whole process of acquiring the lock over exclusive timeout is as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

5

Shared synchronization status acquisition and release

Shared lock, as the name implies, is that multiple threads can share a lock. Use acquireShared in the synchronizer to acquire the shared lock (synchronization state). The source code of the method is as follows:


public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

First, try to acquire a shared lock through tryAcquireShared. This method is a template method that just throws an unsupported operation exception in the synchronizer. Developers need to implement it by themselves. At the same time, the return value of the method has three different types representing three different types. Status, its meaning is as follows:

Less than 0 means the current thread failed to acquire the lock

Equal to 0 means that the current thread acquires the lock successfully, but subsequent threads will fail to acquire the lock without the lock released, which means that this lock is the last lock in the shared mode

Greater than 0 means that the current thread successfully acquired the lock, and there are remaining locks that can be acquired

When the return value of the method tryAcquireShared is less than 0, that is, the lock acquisition fails, the method doAcquireShared will be executed, and the method will continue to follow up:


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

The method first calls the addWaiter method to encapsulate the current thread and the node whose waiting state is a shared module and adds it to the waiting synchronization queue. It can be found that the nextWaiter attribute of the node is the fixed value Node.SHARED in the shared mode. Then get the predecessor node of the current node in a loop. If the predecessor node is the head node, try to obtain the shared lock. If the return value is greater than or equal to 0, it means that the shared lock is successfully obtained. The setHeadAndPropagate method is called to update the head node and if there are available resources, then Post-propagation, wake up subsequent nodes, and then check the interrupt flag, if it has been interrupted, interrupt the current thread, the method ends and returns. If the return value is less than 0, it means that the lock acquisition failed, and you need to suspend and block the current thread or continue to spin to acquire the shared lock. Let's take a look at the specific implementation of the setHeadAndPropagate method:

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();
    }
}

First, set the node that currently acquires the lock as the head node, and then the method parameter propagate> 0 means that the return value of the previous tryAcquireShared method is greater than 0, that is to say, there are still remaining shared locks that can be acquired, then the successor node of the current node is acquired And when the successor node is a shared node, wake up the node to try to acquire the lock. The doReleaseShared method is the main logic of the synchronizer's shared lock release.

Let's take a look at the release process of the shared lock. The synchronizer provides the releaseShared method to release the shared lock. The source code of the method is as follows:

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

First call the tryReleaseShared method to try to release the shared lock. The method returns false to indicate that the lock release failed, and the method ends to return false. Otherwise, the lock is successfully released. Then the doReleaseShared method is executed to wake up subsequent nodes and check whether it can propagate backward. Continue to follow up the method as follows:


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;
    }
}

It can be seen that the difference from exclusive lock release is that in shared mode, state synchronization and release can be executed at the same time, and its atomicity is guaranteed by CAS. If the head node changes, the cycle will continue. Every time a shared node wakes up in shared mode, the head node will point to it, so that it can be guaranteed that all subsequent nodes that can acquire the shared lock can wake up.

6

How to customize sync components

Most of the classes implemented based on the synchronizer in the JDK aggregate one or more classes that inherit the synchronizer, use the template method provided by the synchronizer to customize the management of the internal synchronization state, and then implement it through this internal class The function of synchronization state management actually uses the template mode to some extent. For example, the reentrant lock ReentrantLock, the read-write lock ReentrantReadWriteLock, the semaphore Semaphore, and the synchronization tool class CountDownLatch in the JDK. The screenshots of the source code are as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

As you can see from the above, we can customize the exclusive lock synchronization component and the shared lock synchronization component separately based on the synchronizer. The following is to implement a synchronization tool that allows only 3 threads to access at the same time, and the access of other threads will be blocked Take TripletsLock as an example. Obviously, this tool is a shared lock mode. The main idea is to implement a Lock interface in JDk to provide user-oriented methods, such as calling the lock method to acquire the lock, and using unlock to release the lock. Inside the TripletsLock class, there is a custom synchronizer Sync inherited from the synchronizer AQS, which is used to control the access and synchronization status of the thread. When the thread calls the lock method to acquire the lock, the custom synchronizer Sync first calculates the lock after acquiring the lock Synchronize the state, and then use the Unsafe operation to ensure the atomicity of the synchronization state update. Since only 3 threads can access at the same time, we can set the initial value of the synchronization state state to 3, which represents the number of synchronization resources currently available. When a thread successfully acquires the lock, the synchronization state state is reduced by 1, and when a thread successfully releases the lock, the synchronization state is increased by 1. The value range of the synchronization state is 0, 1, 2, and 3. When the synchronization state is 0, it means that no synchronization is available Resources, if there is thread access at this time, it will be blocked. Let's take a look at the implementation code of this custom synchronization component:


/**
 * @author mghio
 * @date: 2020-06-13
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class TripletsLock implements Lock {

  private final Sync sync = new Sync(3);

  private static final class Sync extends AbstractQueuedSynchronizer {
    public Sync(int state) {
      setState(state);
    }

    Condition newCondition() {
      return new ConditionObject();
    }

    @Override
    protected int tryAcquireShared(int reduceCount) {
      for (; ;) {
        int currentState = getState();
        int newState = currentState - reduceCount;
        if (newState < 0 || compareAndSetState(currentState, newState)) {
          return newState;
        }
      }
    }

    @Override
    protected boolean tryReleaseShared(int count) {
      for (; ;) {
        int currentState = getState();
        int newState = currentState + count;
        if (compareAndSetState(currentState, newState)) {
          return true;
        }
      }
    }
  }

  @Override
  public void lock() {
    sync.acquireShared(1);
  }

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

  @Override
  public boolean tryLock() {
    return sync.tryAcquireShared(1) > 0;
  }

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

  @Override
  public void unlock() {
    sync.releaseShared(1);
  }

  @Override
  public Condition newCondition() {
    return sync.newCondition();
  }
}

Let's start 20 thread tests to see if the custom synchronization tool class TripletsLock meets our expectations. The test code is as follows:


/**
 * @author mghio
 * @date: 2020-06-13
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class TripletsLockTest {
  private final Lock lock = new TripletsLock();
  private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

  @Test
  public void testTripletsLock() {
    // 启动 20 个线程
    for (int i = 0; i < 20; i++) {
      Thread worker = new Runner();
      worker.setDaemon(true);
      worker.start();
    }

    for (int i = 0; i < 20; i++) {
      second(2);
      System.out.println();
    }
  }

  private class Runner extends Thread {
    @Override
    public void run() {
      for (; ;) {
        lock.lock();
        try {
          second(1);
          System.out.println(dateFormat.format(new Date()) + " ----> " + Thread.currentThread().getName());
          second(1);
        } finally {
          lock.unlock();
        }
      }
    }
  }

  private static void second(long seconds) {
    try {
      TimeUnit.SECONDS.sleep(seconds);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

The test results are as follows:

Java Queue Synchronizer Framework AQS Implementation Principle

From the above test results, it can be found that only three threads can acquire the lock at the same time, which is in line with expectations. What needs to be clear here is that the lock acquisition process is unfair.

7

to sum up

This article mainly analyzes the basic data structure, exclusive and shared synchronization state acquisition and release process in the synchronizer. Due to the limited level, please leave a message for discussion. Queue synchronizer AbstractQueuedSynchronizer is the basic framework for the realization of many multithreaded concurrency tools in the JDK. In-depth study and understanding of it will help us to better use its features and related tools. (PS: Because there are many codes posted, it may not be easy to read and view on the official account, you can move to the personal blog: https://www.mghio.cn )

Guess you like

Origin blog.51cto.com/15075507/2607593