Deadly Java concurrent programming (6): AQS explained in detail, this time I will thoroughly understand the principle of locking in Java concurrent packages, so I don’t have to repeat it every interview

Are you often asked about locks in Java during the interview? Maybe you have also seen related concepts and knowledge from blog posts on the Internet, but if you don’t have a deep understanding, you can sort out this piece of knowledge to form your own knowledge brain map, and you will forget it soon. The result is that every interview has to be reviewed from the beginning, which is time-consuming and laborious.

Today, I started learning about locks in Java concurrency. The main purpose is to sort out the APIs and components related to locks in Java concurrency packages. The goal is to know how to use it and how to implement it. Only by knowing what it is and why it is, can you use it correctly and deal with the interview.

2

In order to reduce the burden on readers, this article mainly talks about AQS, that is AbstractQueuedSynchronizer, to see how it implements the lock voice.

Lock interface

Speaking of locks, you will definitely think of the synchronized keyword. That's right, it was used by java programs to realize the lock function before jdk1.5. After jdk1.5, the Lock interface is added to the concurrent package to implement the lock function. Its function is similar to synchronized, but it needs to be displayed to acquire and release the lock.

The use of Lock is also very simple, as shown in the following demo:

Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
    lock.unlock();
}

Here we need to explain the main features that the synchronized provided by the Lock interface does not have:

  • Trial acquisition of the lock : The current thread tries to acquire the lock. If the current lock is not acquired by other threads, the result is acquired and held.

  • Interruptible lock acquisition : Unlike synchronized, the thread that acquires the lock can respond to the interrupt. When the thread that acquires the lock is interrupted by other threads, the interrupt exception is thrown and the lock is released at the same time.

  • Acquire the lock over time: Acquire the lock before the specified time, and return if the timeout cannot be acquired.

Lock is an interface that defines the basic operations of lock acquisition and release:

Explain the meaning of api from top to bottom:

  1. Acquire the lock. After the current thread acquires the lock, it returns from this method, and the method blocks while acquiring the lock;

  2. The difference with lock() is that this method will respond to interrupts;

  3. Non-blocking attempts to acquire the lock, the method returns immediately, and returns true if acquired, otherwise it returns false;

  4. Acquire the lock over time, when the three scenarios of timeout, interruption, and acquisition without timeout, the lock is acquired, it will return;

  5. Release the lock and wake up subsequent nodes;

  6. Obtain the waiting notification component, which is bound to the current lock, and the wait method of the component can be called only after the lock is acquired;

Queue Synchronizer (AQS)

Queue synchronizer AbstractQueuedSynchronizerthat can be said is the cornerstone of Java and contract to construct a variety of locks and security containers implemented, such as to achieve ReentrantLock, ReadWriteLock, CountDownLatch, etc. are less AQS figure.

AQS itself uses an int member variable to represent the synchronization state, and completes the queuing of the resource acquisition thread through the built-in FIFO queue. Doug Lea, the author of the java concurrent package, wanted it to become the basis for most synchronization requirements when designing it.

The synchronizer is the key to the realization of the lock. Of course, it can be any synchronization component. The synchronizer is aggregated in the realization of the lock, and the synchronizer is used to realize the semantics of the lock. The relationship between the two can be understood as that the lock is a programmer-oriented API defined in the Lock interface, which defines the interactive interface used by the programmer and hides the implementation details. The synchronizer is for the implementer of the lock. It simplifies the implementation of the lock and shields the underlying implementation details such as synchronization state management, thread queuing, waiting and notification. This design is very good, and it isolates the areas that users and implementers need to pay attention to.

AQS usage example

The design of the synchronizer AQS is based on the template method, that is, the user needs to inherit the synchronizer and rewrite the specified method, and then combine the synchronizer in a custom synchronization component and call the template method provided by the synchronizer. These template methods will Call the user's override method.

In order to allow users to rewrite the specified method, the synchronizer provides three basic methods:

  1. getState(), get the current synchronization state

  2. setState(int newState): Set the current synchronization state

  3. compareAndSetState(int expect,int update): Use CAS to set the current state, this method can guarantee the atomicity of state setting

Synchronous rewritable methods are divided into exclusive acquisition locks and shared acquisition locks. In order not to increase the burden on readers, only the rewritable methods of exclusive acquisition locks are listed here. The simplified source code is listed below

protected boolean tryAcquire(long arg) {    
    throw new UnsupportedOperationException();
}
protected boolean tryRelease(long arg) {
    throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

It can be seen that these methods that need to be rewritten are not implemented specifically, so we need to implement them when using them.

The methods that need to be implemented by custom synchronization components are listed above. Next, let’s take a look at which template methods the synchronizer provides. Due to space reasons, in order not to bring pressure to readers, only a few core methods are listed. you can see the specific JDK source code AbstractQueuedSynchronizerspecific implementation.

Exclusive acquisition lock

Acquire lock in response to interrupt

Release lock

In general, the template methods provided by the synchronizer are basically divided into three categories: exclusive acquisition and release of synchronization status, shared acquisition and release of synchronization status, and query of waiting threads in the synchronization queue . The custom synchronization component will use the template method provided by the synchronizer to implement its own synchronization semantics.

Having said so much, next, we will implement an exclusive lock by ourselves, and use the method of combining custom synchronizer AQS to help you master the working principle of synchronizer. Only by understanding AQS can we learn and understand more deeply in the concurrent package. Other synchronization components.

Examples are as follows:

As shown in the sample code, you can see that it is very easy to implement a simple exclusive lock using AQS. A static internal class is defined in Mutex, which inherits the synchronizer to achieve exclusive acquisition and release of synchronization state.

In the tryAcquire(int acquires)method, if the setting is successful after CAS (synchronization state is set to 1), represents a synchronization state is acquired, and in tryRelease(int releases)just reset sync state is 0. method. Users do not realize and deal directly with the internal synchronizers use Mutex, but call the method Mutex provided in achieving Mutex in order to acquire the lock lock()method, for example, only the template method synchronizers need to call in a method implementation acquire(int args)That is to say, this greatly simplifies the threshold for implementing a reliable custom synchronization component.

AQS realizes source code analysis

AQS structure

Let's first take a look at what attributes are in AQS. After reading this, you will basically know how AQS implements locks.

After reading it, you will find that it is very simple, there are only three core attributes.

The synchronizer relies on the internal synchronization queue to complete the synchronization state management. The process is like this: when the thread fails to obtain the synchronization state, the synchronizer will construct the current thread and the waiting state into a node (Node), add it to the queue, and at the same time Block the current thread. When the synchronization state is released, the thread in the first node will be awakened to make it try to acquire the lock synchronization state again.

The nodes in the synchronization queue are used to save the reference, waiting state, and predecessor and successor nodes of the thread that failed to obtain the synchronization state. Let's look at the code:

The data structure of Node is actually not complicated, it is just thread + waitStatus + pre + next + nextWaiterfive attributes. You must first have this concept in mind.

The node is the basis of the synchronization queue. The synchronizer has the head and the tail node. The thread that fails to get the synchronization will become the node and join the tail of the queue. The basic structure of the synchronization queue is as follows:

Through the above introduction, you may be anxious and want to see how AQS acquires and releases locks. Don't worry, learn the principle, slow is fast!

Then follow the specific implementation code, I am not too long-winded.

Acquire lock

As mentioned above, acquisition locks are divided into exclusive and shared types. In order to make reading smoother, here we only look at exclusive acquisition locks. I believe you have mastered the exclusive acquisition lock mode, and then there is no problem with shared acquisition. of.

There is very little code above, and the logic is relatively clear. First, the tryAcquire(arg) method will be called. As mentioned above, this method needs to be implemented by the synchronization component itself, such as the Mutex lock we implemented above. This method guarantees the thread-safe acquisition of the synchronization state, tryAcquire(arg) returns true to indicate that the acquisition succeeds and exits normally. Otherwise it will construct sync node (exclusive Node.EXCLUSIVE) and by the addWaiter(Node mode)method will be added to the tail of the queue synchronization, the final call acquireQueued(final Node node, int arg)to obtain synchronization status by "death cycle" approach. If it is not available, the corresponding thread in the node will be blocked, and the awakening after being blocked can only be achieved by dequeuing the precursor node or interrupting the blocked thread.

Let's look at the structure of the node and adding it to the synchronization queue.

The above code uses the compareAndSetTail(pred, node) method to ensure that the node can be added thread-safely when adding the constructed node to the end of the synchronization queue.

Here we look at the time when the upper end of the queue quickly added synchronization is not satisfied (ie the code shown above in the queue is empty or multiple concurrent threads into the team), we went to the enq(node)method, which uses the spin of the way into the team.

DETAILED winded not the code has been written very clear, that enq(final Node node)the method, CAS node set, before the thread is returned from the process after the end nodes in an endless loop by. Otherwise, the current thread keeps trying. It can be seen that the usage scenario of this method is not thread-safe, because at the same time there may be many threads that fail to call the tryAcquire method to obtain the synchronization status to be enqueued. Here, spin plus CAS is used cleverly to "serialize" concurrent requests.

After the above method, after the node enters the synchronization queue, it enters the next spin process. Each node (that is, the thread that failed to acquire the lock) is introspectively observed. When the condition is met, the synchronization state is obtained. You can exit from this spinning process, otherwise you will be forced to stay in this spinning process and block the node thread. The specific code is as follows:

As above, if the current node was not actually the first team or just tryAcquire(arg)did not grab the win others, went to a branch judgment is the next: shouldParkAfterFailedAcquire(p, node) the current thread did not grab the lock, if need to suspend the current thread ?

You must understand the above code by yourself. If your idea is broken, hope to follow it from above to avoid wasting time.

Here we analyze private static boolean shouldParkAfterFailedAcquire(Node pred, Node node)the return value of this method:

  1. If it returns true, it means that the waitStatus==-1 of the precursor node is normal, and the current thread needs to be suspended, waiting to be awakened later, just wait for the precursor node to get the lock, and then call you when the lock is released;

  2. If it returns false, it means that it does not need to be suspended at present, why? Look back

shouldParkAfterFailedAcquire(Node pred, Node node)After this method returns, it is true then execute parkAndCheckInterrupt()method:

Then talk about if shouldParkAfterFailedAcquire(p, node)the situation returns false: a close look at shouldParkAfterFailedAcquire (p, node), we can see that, in fact, came in first, generally do not return true, the reason is very simple, the precursor node waitStatus=-1is dependent on successor Node set. In other words, I haven't set -1 for the predecessor yet, how could it be true, but you must see that this method is nested in a loop, so the state is -1 the second time it comes in.

If you see the idea here is still relatively clear, then here we will explain why the shouldParkAfterFailedAcquire(p, node)thread is not directly suspended when false is returned?

This is because after this method, the previous node of the current node may unblock and exit the synchronization queue due to timeout or interruption. Therefore, a new parent node is set. This parent node may already be the head. Is there any surprise here? feel. . .

Here also understand AQS synchronization process obtains a lock, or hope you can Duokanjibian acquireQueued(final Node node, int arg)method. There is not much code. It is worth the time to spend time deducing the reasons for the entry of each branch.

Release lock

After the current thread obtains the synchronization state and executes the corresponding logic, it needs to release the synchronization state so that subsequent nodes can continue to obtain the synchronization state. The synchronization state can be released by calling the release(int arg) method of the synchronizer. After the synchronization state is released, this method will wake up its successor nodes (and then make the successor nodes try to obtain the synchronization status again).

The wake-up code is relatively simple. If you understand all the locks above, you don't need to read the following to know what's going on!

After the thread is awakened, the awakened thread will continue to move forward from the following code:

Well, after reading this, you must have a certain understanding of the AQS synchronizer exclusive lock and unlock process, this article will not continue to use the source code. I believe you understand the above, if you still have problems or want to see the non-exclusive lock acquisition and release process, go and honestly take a closer look at the code.

to sum up

To sum it up.

The Java concurrency package provides another implementation of the Lock interface, which defines the basic operation of lock acquisition and release.

Queue synchronizer AbstractQueuedSynchronizerthat can be said is the cornerstone of Java and contract to construct a variety of locks and security containers implemented, such as to achieve ReentrantLock, ReadWriteLock, CountDownLatch, etc. are less AQS figure.

In a concurrent environment, various locks that implement the Lock interface are provided in the concurrent package. They rely on the AQS synchronizer to complete the lock and unlock operations. The realization of AQS mainly requires the coordination of the following three components:

  1. Lock state. We need to know whether the lock is occupied by other threads. This is the function of state. When it is 0, it means that no thread has the lock. You can compete for this lock. Use CAS to set state to 1. If CAS succeeds, it means The lock is grabbed so that other threads can't grab it. If the lock is reentered, the state can be +1, and the unlock is reduced by 1 until the state becomes 0 again, which means the lock is released, so lock() and unlock() must be Need to be paired. Then wake up the first thread in the synchronization queue and let it hold the lock.

  2. Blocking and unblocking of threads. AQS uses LockSupport.park(thread) to suspend threads, and unpark to wake up threads.

  3. Block the queue. Because there may be many threads competing for locks, but only one thread can get the lock, and other threads must wait. At this time, a queue is needed to manage these threads. AQS uses a FIFO queue, which is a linked list. Each node holds a reference to subsequent nodes.

sample graph

This picture is used to review the process of acquiring the lock. If you are still a bit stunned after reading it, here is another opportunity to help you sort out your thoughts. Combine this picture and think carefully. You have this idea flow in your mind, and then look at the source code again. .

(End of this article)


Reference materials :

  1. Zhou Zhiming: "In-depth understanding of the Java virtual machine"

  2. Fang Tengfei: "The Art of Java Concurrent Programming"

Guess you like

Origin blog.csdn.net/taurus_7c/article/details/105760231