Detailed explanation of Java concurrent programming

In the previous article, the combing of multithreading related concepts (personal understanding) mainly talked about some concepts of multithreading concurrency at the macro level. This article focuses on Java and talks about concurrent programming.

sychronizedkeywords

The JVM actually only provides one kind of lock, sychronizedthe keyword , which we can see from Threadthe definition of Java classes State.

There are a total of thread states in Java NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. It BLOCKEDonly corresponds to the case where the thread enters sychronizerthe block and fails to acquire the lock. For the lock based on AQS, if the thread is blocked, the status is WAITING, because AQS actually calls LockSupportthe method of the class to achieve thread blocking, and the corresponding WAITINGstatus of these methods java.lang.Thread.Stateis written in the comments.

It is also worth noting that although Java threads have a one-to-one correspondence with the actual operating system (on HotSpot), in fact, if the system call performs IO operations and blocks the thread, the current state of the thread is still the state, RUNNABLEbecause The thread state of the operating system and the thread state of Java do not exactly correspond to each other, nor are they completely unified.

object header

As mentioned above, sychronizedthe provided lock is implemented by JVM, which means that there must be some variables in JVM to store the state of the lock. This is indeed the case. In Java, there is a part of space inside each object for storing object header information .

The memory layout of a JVM object is divided into three areas: object header , instance data , and alignment padding . The instance data stores the real information of the object.
For details on the memory layout of JVM objects, please refer to this article: Principles of Java Object Structure and Lock Implementation and Detailed Explanation of MarkWord .

The object header contains a lot of information, among which Mark Word is used to store hashCodeand lock information of the object.

In Mark Word, if the object has a lock, a monitorpointer to it will be stored, and this monitorobject is equivalent to the lock of the object. Java source code comments are generally called monitor locks .

We know that every object in Java has a monitormonitor corresponding to it. By javapdecompiling a sychronizedsource code containing blocks, we can find monitorenterand monitorexit.

Heavyweight and Lightweight Locks

I always hear that sychronizedit is a heavyweight lock, so what exactly are heavyweight locks and lightweight locks?

A heavyweight lock means that threads that fail to compete for the lock will enter a blocked state, and then need to wait to wake up after running again.

In simple terms, a lightweight lock means that threads competing for the lock will continue to acquire the lock through CAS spins, that is, they will not give up the opportunity to execute the thread. Obviously, spinning all the time will waste CPU performance in vain, so sychronizedit will be upgraded to a heavyweight lock after a certain number of spins, so as to avoid thread blocking and waking up to a certain extent and affect performance (because these operations cannot be completed in user mode, all involve Switch back and forth from user mode to kernel mode).

In addition to this, there is another concept of biased locks . The scene where it was born is because we found that the same thread is competing for locks in many cases, so the concept of biased locks was designed, that is, a thread that has acquired the lock If the lock is acquired again, it is not necessary to go through the process of lightweight lock spin to acquire the lock, but can be acquired directly, thereby improving performance.

An object is in a lock-free state at the beginning , and then it will go through the stages of biased locks, lightweight locks, and heavyweight locks. The sychronizedadded lock is such a process, during which the lock can only be upgraded and cannot be downgraded.

The specific details of Java sychronizedlocks can refer to this article: Java "locks" that must be said
.

wait/notify mechanism

With sychronizedthe lock, we can guarantee the data consistency under multi-threading, and then through the wait/notifyObject method provided by the class , we can realize the communication between multi-threading.

Why wait/notify should be locked

It is worth noting that wait/notify must be used with a lock, because of the following two points:

  1. Without locking, there is no guarantee of the happens before rule . Then the modifications you make to the condition variable may not be visible to other threads temporarily. But this volatilecan achieve the same effect as long as you add keyword modification to the condition variable.
  2. It will cause lost wake up problem . For example, if there is no lock, one thread just waits, and another thread notifies other threads at this time, because the first thread has not been added to the waiting sequence, the notification will not be received this time.

Based on the above two points, Java designers mandate that locks must be added when using wait/notify, and the object that calls wait/notify must be the object corresponding to the lock held by the current thread. (The lock must be the same, it should be just a convenient design, after all, the point is that there must be a lock)

For a detailed discussion on this point, please refer to: Why calls in Java notify()require locks .

notify()Will it really wake up the thread?

We said above that notify()we need to use it with a lock, so this requires us to notify first and then unlock. But according to the comments, we know that notify()the thread waiting for the lock will be awakened again, so won't it cause the notified thread to just wake up, and be blocked again because it cannot acquire the lock (assuming there is no unlock at this time)?

Obviously, notify()the thread cannot be woken up, otherwise such an accident will occur. java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObjectWe can confirm this from AQS . AQS maintains two types of queues, the waiting queue waiting to acquire the lock and the await()conditional queue of the blocked thread . When signal()the conditional queue is dequeued, the head element of the conditional queue is added to the waiting queue to prepare for the lock competition. (About the AQS source code will be analyzed in detail later)

So we can reasonably speculate that notify()the thread cannot be woken up, which can also be confirmed by writing a small demo.

We know that notify()it is a local method, and the bottom layer is implemented by C language, so it is not easy to directly view the source code. wait()Compared with the standard in c in Java pthread_cond_wait(), this method has very loose requirements, and does not even require a lock to be called, so I found such a question: Should unlock be done before or after notify in c , you can see Gao Zan's answer The explanation in is that generally speaking, the designer will consider that if notify is unlocked again, the awakened thread will be blocked again, so the designer will not design notify to directly wake up the thread.

why wait()in loop body

Note that java.lang.Object#wait()there is this comment:

A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. In other words, waits should always occur in loops, like this one:
synchronized (obj) {
while (condition does not hold)
obj.wait(timeout);
… // Perform action appropriate to condition
}

It is said that wait()there may be false wake-ups , which requires us to check whether the condition variable meets the condition in a loop, and call wait().

We know that wait()these functions require system calls in the end, because operations such as thread blocking are involved, which cannot be done in user mode. And these blocking system calls will return when they are interrupted EINTR. If you wait at this time, problems may occur, because you cannot be sure whether other threads have called to notify()wake you up during this process. Waiting, may miss the wakeup, resulting in a permanent deadlock situation. Therefore, at the operating system level, as long as you call wait(), if you are interrupted, you will not choose to continue waiting, even if false wakeups may occur.

For a discussion on this point, please refer to this answer: Why do conditional locks generate spurious wakeups? .

It is also worth noting that there java.util.concurrent.locks.AbstractQueuedSynchronizer.ConditionObject#await()will be no false wake-up, because its implementation completely eliminates the occurrence of this situation (it is also a wireless loop judgment condition).

notify()Are the wakeups random?

According to java.lang.Object#notifythe comments, we know that this method randomly wakes up a waiting thread, but in fact, it is also based on the implementation:

The choice is arbitrary and occurs at the discretion of the implementation.

In the HotSpot virtual machine, notify()the order of FIFO is followed, while notifyAll()the order of LIFO is followed.

For notify()sequence:

public class Main {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
    
    
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
    
    
                main.await(name);
            }).start();
        }
        for (int i = 0; i < 5; i++) {
    
    
            Thread.sleep(1000); // 由于synchronized不是公平锁,这里得每隔一段时间notify一次
            main.signal();
        }
    }

    private synchronized void await(String name){
    
    
        try {
    
    
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
    
    
        notify();
    }

    private synchronized void signalAll(){
    
    
        notifyAll();
    }
}

The program prints as:

Thread-0 is blocked
Thread-1 is blocked
Thread-2 is blocked
Thread-3 is blocked Thread-
4 is blocked
Thread-0 continues execution
Thread-1 continues execution
Thread-2 continues execution
Thread-3 continues execution
Thread-4 continues execution

For notifyAll()sequence:

public class Main {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Main main = new Main();
        for (int i = 0; i < 5; i++) {
    
    
            String name = "线程-" + i;
            Thread.sleep(1000);
            new Thread(()->{
    
    
                main.await(name);
            }).start();
        }
        Thread.sleep(1000); // 这里得等所有线程被wait方法阻塞后再notifyAll
        main.signalAll();
    }

    private synchronized void await(String name){
    
    
        try {
    
    
            System.out.println(name + "被阻塞");
            wait();
            System.out.println(name + "继续执行");
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

    private synchronized void signal(){
    
    
        notify();
    }

    private synchronized void signalAll(){
    
    
        notifyAll();
    }
}

The program prints as:

Thread-0 is blocked
Thread-1 is blocked
Thread-2 is blocked
Thread-3 is blocked Thread-4 is blocked
Thread-4
continues execution Thread -
3 continues execution
Thread-2 continues execution
Thread-1 continues execution
Thread-0 continues execution

AQS

In addition to the locks provided by the JVM sychronized, we can also rely AbstractQueuedSynchronizeron AQS to implement locks. Typical locks such as ReentrantLock, ReentrantReadWriteLocketc. are based on AQS. It can be said that the core of JUC is AQS.

Locks based on AQS provide finer-grained locking, multiple conditional queues ( Condition), etc., which are superior to sychronizedlocks, and can be said to completely replace sychronizedlocks. Perhaps sychronizedthe only benefit of locking is that it is easier to use and easier to read.

If you understand the source code of AQS, you will have a deeper understanding of locks. As I said in my previous article, locks are a high-level abstraction that facilitates us to solve concurrency problems. The bottom layer is still built on the instructions of the CPU. AQS uses a lot of CAS operations and LockSupportclasses to build, here is a macro summary:

Source code analysis

AQS is a synchronous queue. Using this queue, we can realize the concept of locks. lock()It corresponds to calling the one in AQS acquire(), unlock()and it corresponds to calling the one in AQS release().

acquire()It is a template method, which calls tryAcquire()and by default acquireQueued(). The former is to actually acquire the lock, and there is no implementation given in AQS. The latter is equivalent to adding the tryAcquire()failed node——the node that failed to acquire the lock, to the queue, and cyclic acquisition. Generally speaking, if the lock acquisition fails in the loop, it will be blocked, waiting for the predecessor node—the node holding the lock to wake it up.

release()It is also a template method, which will be called tryRelease(). This is also a method to release the lock to be implemented by the subclass. After the release is successful, if the current node is the head node (because the queue is empty, at this time the first A thread that has acquired the lock will not be enqueued, enqueue is only enq()implemented in ), it will be executed again unparkSuccessor(), and the next thread will be woken up.

Others, such as acquireShared()etc., are also template methods.

About ConditionObject:

AQS also implements the Condition function, which maintains a condition queue, which is different from the waiting queue maintained by AQS, and the two queues are different. The methods in ConditionObjectit are given a complete implementation. The node's in the conditional queue waitStatusis CONDITION.

In await(), the method in AQS will be called fullyRelease()to realize that the thread releases the lock, exits the waiting queue, and then joins the conditional queue. Then in signal()the method, call layer by layer, and finally call transferForSignal()to realize the operation of exiting the conditional queue and joining the waiting queue.

It is worth noting that whether it is a conditional queue or a waiting queue, the order of the FIFO is always followed, not the random order. In addition, objects in Object notify()also follow FIFO, but notifyAll()follow LIFO. But these are native methods, mainly depends on the implementation of the JVM, at least in HotSpot.

About exclusive locks and shared locks:

As mentioned earlier, AQS maintains two types of queues, waiting queues and conditional queues . In the Node class, there is a member variable nextWaiterto record the next node in the conditional queue.

In the waiting queue , this variable is used to mark whether the mode of the node is a shared mode or an exclusive mode, and a member variable is used in the waiting queue nextto indicate the next node. You may be curious, what variable is used in the conditional queue to identify the mode of the queue node, in fact, there is no need to identify the mode for the node in the conditional queue, because the conditional queue can only be accessed in exclusive mode.

From this aspect, the readability of the AQS source code is actually not very good. After all, one variable is used for multiple purposes, and the AQS source code has a large number of one-line codes to implement multiple functions, so the readability is really poor. But it is undeniable that the performance is indeed high, at least under the premise of sacrificing readability.

In the waiting queue, by acquireShared()acquiring the lock, when the shared lock is obtained, the setHeadAndPropagate()method will be called cyclically to determine whether the subsequent node that has obtained the shared lock waitStautsis less than 0 (because PROPAGATEit may or become SIGNAL), and if so, then judge the subsequent node Whether the state is a shared lock, or if it is null, wake up the subsequent nodes.

It is also necessary to judge whether it nullis because it nulldoes not mean that the node is at the end of the queue. This is because enq()the operation of joining the queue in the middle is to first node.prev = t;point the predecessor node of the joining node to the tail of the queue, then compareAndSetTail(t, node)replace the tail of the queue with CAS, and finallyt.next = node;

We know that if multiple threads have been acquiring shared locks, we only need to continuously increase the state, but there may be an exclusive lock added at a certain time. During the period, many nodes with shared locks have accumulated behind. If the lock is released, then after the first shared lock node obtains the lock, it should notify all subsequent shared lock nodes.

Expand here. A node that acquires a lock will not join the queue. It will only join the queue (pass) if the acquisition fails enq(). After successful acquisition, it will be removed from the queue (for example acquireQueued()). And the algorithm we want to implement only involves how to acquire locks and how to release locks. As for the queue management, all the logic has been implemented in AQS. For example, the logic of fair locks and unfair locks should be implemented in subclasses, because this belongs to how to acquire and release locks.

About canceling nodes:

In the Node class of AQS, waitStatusthere is another value CANCELLEDthat indicates that the node has timed out or been interrupted.

It passes cancelAcquire()settings. Whereas methods are called in blocks in cancelAcquire()all methods that acquire locks (eg .acquireQueued()finally

cancelAcquire()This method will only point the next of the node to be canceled to itself, and will not remove it from the queue. The dequeue operation will be performed in other methods such as shouldParkAfterFailedAcquire()or again .cancelAcquire()

Regarding whether there is Interruptibly in the method:

Methods with this keyword will throw an interrupt exception. If not, it calls the current thread interrupt(). As for what will happen when calling this method, you can refer to java.lang.Thread#interruptthe comments. Different situations will have different reactions:

The serial numbers correspond to the situations when the four threads are interrupted

Guess you like

Origin blog.csdn.net/weixin_55658418/article/details/130795566
Recommended