background

I wrote an in-depth understanding of AQS earlier

Mp.weixin.qq.com/s?__biz=MzI…

After a period of time, I found that I could not remember some places clearly, so I felt that I did not understand them thoroughly, so I decided to take a look at the materials again, and completely clear up the places that I had only half-understood before!

Look at the document

First look at the source code, AbstractQueuedSynchronizer this class has a code + notes a total of 2335 lines, can say is very long, we see first-hand the first is how to write source code comments, Through AbstractQueuedSynchronizer class annotation explanatory to know something about this class.

Class notes

Here I will not post the Original English text, which is quite long, directly on the translation content:

This class provides a framework for implementing blocking locks and synchronizers (semaphores, events, etc.), which rely on wait queues for first-in, first-out (FIFO). It provides the implementation basis for most synchronizers that represent state through a single atomic value of type INT. Subclasses must override protected methods that change synchronizer state and define what that state means in terms of getting or releasing subclass objects. Based on this, other methods in this class implement all queuing and blocking mechanisms. Subclasses can maintain other status fields, but for synchronization, only int values updated atomically using the getState, setState, and compareAndSetState operations can be tracked.

Subclasses should be defined as non-public inner helper classes that implement synchronous properties of their external classes. AbstractQueuedSynchronizer class does not implement any synchronous interface. Instead, it defines methods such as acquireInterruptably, which can be appropriately called by the specific lock and the associated synchronizer to implement their public methods.

By default, this class supports obtaining synchronization status in private and shared modes. When the synchronization state is obtained in exclusive mode, other threads attempt to obtain it without success. In shared mode, multiple threads generally (but not necessarily) succeed in obtaining synchronous state. This class ignores the mechanical difference between the two modes, which is that when a thread successfully acquires synchronized state in shared mode, the next waiting thread (if any) must determine whether it can also acquire that state.

Waiting threads in different modes share a FIFO queue.

Usually subclasses implement only one pattern, but both can work at the same time, such as ReadWriteLock. Only subclasses of one schema are supported, eliminating the need to define methods for another schema.

ConditionObject AQS defines an inner class ConditionObject that can be used as an instance of Condition for subclasses that support exclusive mode. In exclusive mode, the method #isHeldExclusively is used to indicate whether the current thread is monopolizing the subclass object, taking the return value of method #getState as input, and calling method # Release to release the subclass object completely. Use this value to call the #acquire method and eventually revert to the state of the previous acquired lock. AbstractQueuedSynchronizer in no other method to create the conditions, so if you can’t satisfy the constraints, don’t use it. AbstractQueuedSynchronizer ConditionObject behavior of course is based on its implementation class semantics.

This class provides checking/instrumentation/monitoring methods for inner classes and similar methods for Condition objects. These can be exported to use AbstractQueuedSynchronizer as synchronization classes. The serialization of this class stores only the maintenance state of atomic integers, so the thread queue resulting from deserializing the object is empty. If subclasses need serialization, they define readObject() to restore themselves to a known initial state.

Usage:

If this class is used as the basis for a synchronizer, redefine the following methods with getState()/setState()/cas to check or modify the synchronization state:

  • tryAcquire
  • tryRelease
  • tryAcquireShared
  • tryReleaseShared
  • isHeldExclusively

These methods throw UnsupportedOperationException by default, these methods internal must ensure that thread safe, and usually should be brief and no lock. Defining these methods is the point of using this class. Other methods are declared final because they cannot change independently.

You can also see inherited from AbstractOwnableSynchronizer method, used for tracing the thread of the exclusive synchronizer. You are encouraged to use these methods, which enable monitoring and diagnostics to help users determine which threads are holding locks.

Even though this class is based on an internal FIFO queue, the default FIFO policy is not enforced, and the core of exclusive synchronization uses the following policy:

Acquire: while (! tryAcquire(arg)) { enqueue thread if it is not already queued; possibly block current thread; } Release: if (tryRelease(arg)) unblock the first queued thread;Copy the code

(The sharing pattern is similar, but may involve cascading signals.)

Because the lock acquisition is checked before enqueueing, a new thread may be inserted before another enqueued thread. However, if you wish, you can define the tryAcquire()/tryAcquireShared() methods by calling one or more of the internal check methods to provide a fair FIFO fetching order. In particular, most fair synchronizers allow tryAcquire() to return false when hasqueuedToraise () (which was designed specifically for the fair synchronizer) returns true. Of course, other approaches are possible.

For the default queue strategy (also called greedy/renouncement/subsequent acquisition – avoidance), usually the highest throughput and scalability. Although this does not guarantee fairness, it does allow the thread that joins the queue early to recompete before the thread that follows, and each recompeting thread has a fair chance of beating the newcomer. Although the fetch behavior usually does not go on forever, threads may call tryAcquire() several times before they are blocked, along with other calculations. When the exclusive synchronizer is only held for a short time, this is good for spin, not too much of a burden. You can enhance this with the previous call to get a method with a “fast-path”. If the synchronizer might not be contested, only hasContended() and hasQueuedThreads() might be pre-checked.

This class provides an effective and extensible basis for synchronization, in part by focusing scope on synchronizers that rely on digital state, get/release parameters, and internal FIFO. When these are not enough, you can use atomic classes, your own java.util.Queue class, and LockSuppor to build lower-level synchronizers.

Usage Examples:

This is a non-reentrant mutex, where 0 means open and 1 means locked. Although the non-reentrant lock does not require strict logging of the locally owned thread, this class does so to make it easier to monitor. Conditions are also supported and a test method is exposed.

class Mutex implements Lock.java.io.Serializable {

    // Our internal helper class
    private static class Sync extends AbstractQueuedSynchronizer {
        // Acquires the lock if state is zero
        public boolean tryAcquire(int acquires) {
            assert acquires == 1; // Otherwise unused
            if (compareAndSetState(0.1)) {
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        // Releases the lock by setting state to zero
        protected boolean tryRelease(int releases) {
            assert releases == 1; // Otherwise unused
            if(! isHeldExclusively())throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);
            return true;
        }

        // Reports whether in locked state
        public boolean isLocked(a) {
            returngetState() ! =0;
        }

        public boolean isHeldExclusively(a) {
            // a data race, but safe due to out-of-thin-air guarantees
            return getExclusiveOwnerThread() == Thread.currentThread();
        }

        // Provides a Condition
        public Condition newCondition(a) {
            return new ConditionObject();
        }

        // Deserializes properly
        private void readObject(ObjectInputStream s)
                throws IOException, ClassNotFoundException {
            s.defaultReadObject();
            setState(0); // reset to unlocked state}}// The sync object does all the hard work. We just forward to it.
    private final Sync sync = new Sync();

    public void lock(a) {
        sync.acquire(1);
    }

    public boolean tryLock(a) {
        return sync.tryAcquire(1);
    }

    public void unlock(a) {
        sync.release(1);
    }

    public Condition newCondition(a) {
        return sync.newCondition();
    }

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

    public boolean isHeldByCurrentThread(a) {
        return sync.isHeldExclusively();
    }

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

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

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

This is a similar to Java. Util. Concurrent. CountDownLatch CountDownLatch latch, except that it only need one can trigger signal. Because the latch is non-exclusive, it uses shared get and release methods.


class BooleanLatch {

    private static class Sync extends AbstractQueuedSynchronizer {
        boolean isSignalled(a) {
            returngetState() ! =0;
        }

        protected int tryAcquireShared(int ignore) {
            return isSignalled() ? 1 : -1;
        }

        protected boolean tryReleaseShared(int ignore) {
            setState(1);
            return true; }}private final Sync sync = new Sync();

    public boolean isSignalled(a) {
        return sync.isSignalled();
    }

    public void signal(a) {
        sync.releaseShared(1);
    }

    public void await(a) throws InterruptedException {
        sync.acquireSharedInterruptibly(1); }}Copy the code

If it is difficult to understand the above translation, it is still there, and after reading the following, it will be transparent again.

See the design

AQS is essentially a FIFO bidirectional queue, threads are wrapped in the form of nodes, waiting to acquire resources in the queue based on the spin mechanism (resources here can be simply understood as object locks)

Looking at this class, you can see that there are two inner classes and all that’s left is a bunch of member variables and member methods.

Design ideas

Here is the AQS model:

AQS consists of three parts: the state synchronization state, the CLH queue made up of Nodes, and ConditionObject (including the conditional one-way queue made up of nodes).

State is volatile to keep our operations visible, so any thread that gets the state from getState() can get the latest value, but setState() does not guarantee atomicity. Therefore, AQS gives us the compareAndSetState method to implement atomicity using the underlying UnSafe CAS functionality.

For AQS, the key to thread synchronization is the operation of state. It can be said that the success of obtaining and releasing resources is determined by state. For example, state>0 means available resources, otherwise it cannot be obtained, so the specific semantics of state are defined by implementer. The existing state semantics of ReentrantLock, ReentrantReadWriteLock, Semaphore, and CountDownLatch are all different.

  • The state of the ReentrantLock is used to indicate whether there are lock resources, and the variable records the number of reentrants of the lock
  • ReentrantReadWriteLock State The high 16 bits indicate the read lock status, and the low 16 bits indicate the write lock status
  • Semaphore state is used to indicate the number of available signals
  • The state of CountDownLatch is used to represent the value of the counter

AQS implements two types of queues, namely synchronous queues and conditional queues.

Synchronous queues serve when a thread is blocked waiting to acquire a resource, while conditional queues serve when a thread enters a waiting state because a condition is not met. Conditions in the queue thread actually have access to the resources, but can not continue execution conditions, so was held in the queue and release resources, and to assign other threads of execution, if sometime in the future conditions are met, then the thread will be transferred from conditions queue to synchronous queue, resources continue to compete, to continue downward.

Synchronous queue

CLH

Synchronous queue is a bidirectional queue based on linked list implementation and a variant of CLH lock. CLH locking is the basis of AQS queue synchronizer implementation.

Look at the CLH queue

  • CLH Lock is a Lock invented by Craig, Landin, and Hagersten, named after the initials of three people.
  • The CLH lock is a spin lock. Can ensure no hunger. Provide first come, first served fairness.
  • CLH queue locks are also scalable, high-performance, and fair spinlocks based on linked lists. The requisition thread spins only on local variables. It continuously polls the state of the precursor and terminates the spin if it finds that the precursor releases the lock.

Node

AQS defines synchronous queue nodes in the form of internal class nodes. This is the first inner class you saw earlier.

static final class Node {

    /** Pattern definition */

    static final Node SHARED = new Node();
    static final Node EXCLUSIVE = null;

    /** Thread status */

    static final int CANCELLED = 1;
    static final int SIGNAL = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;

    /** Thread wait state */
    volatile int waitStatus;

    /** the precursor node */
    volatile Node prev;
    /** * after the node */
    volatile Node next;

    /** Hold thread object */
    volatile Thread thread;

    /** For exclusive mode, point to the next node in CONDITION wait state; For SHARED mode, the SHARED node */
    Node nextWaiter;

    / /... Omitting method definitions
}
Copy the code

Node is a variant of CLH. CLH is a unidirectional queue whose main feature is to check the locked states of the precursor nodes for spin. The AQS synchronization queue is a bidirectional queue, and each node also has status waitStatus, which does not always judge the state spin of the precursor node, but blocks the CPU time slice after a certain period of spin (context switch) and waits for the precursor node to actively wake up the successor node.

WaitStatus has the following five states:

  • CANCELLED = 1 indicates that the current node has been CANCELLED. When a timeout or interrupt occurs (in the case of a response interrupt), a change is triggered to this state, and the node will not change after entering this state.
  • SIGNAL = -1 indicates that the successor node is waiting for the current node to wake up. When a successor node joins the queue, its status is updated to SIGNAL.
  • CONDITION = -2 means that the node is waiting on CONDITION. When other threads call CONDITION signal(), the node in CONDITION will be transferred from the wait queue to the synchronization queue, waiting for the synchronization lock.
  • In PROPAGATE = -3 sharing mode, the predecessor node not only wakes up the successor node, but also may wake up the successor node.
  • INITIAL = 0 Specifies the default status for new nodes to join the queue.

As you can see from the code above, threads in the CLH linked list wait for resources in two modes: SHARED and EXCLUSIVE, where SHARED means SHARED and EXCLUSIVE means EXCLUSIVE. The main difference between the shared mode and the exclusive mode is that only one thread can obtain resources at the same time in the exclusive mode, while in the shared mode, multiple threads can obtain resources at the same time. A typical scenario is a read/write lock. Multiple threads can acquire read lock resources at the same time in a read operation, but only one thread can acquire write lock resources at the same time in a write operation. Other threads will block when trying to acquire resources.

Synchronous queue main behavior

The head and tail fields of the AQS member variables point to the head and tail of the synchronous queue, respectively:


    /** * Head of the wait queue, lazily initialized. Except for * initialization, it is modified only via method setHead. Note: * If head exists, its waitStatus is guaranteed not to be * CANCELLED. */
    private transient volatile Node head;

    /** * Tail of the wait queue, lazily initialized. Modified only via * method enq to add new wait node. */
    private transient volatile Node tail;

Copy the code

Where head represents the head node of the synchronization queue, and tail represents the tail node of the synchronization queue. The specific organization is shown in the following figure:

When AQS acquire method is called to acquire resources, if resources are insufficient, the current thread will be encapsulated as Node Node and added to the end of the synchronization queue (enqueue). The head Node is used to record the thread Node currently holding resources, and the successor Node of head is the next thread Node to be scheduled. When the release method is called, the thread on that node is woken up and continues to fetch resources.

The main behaviors of a synchronous queue are: queue entry and queue exit

The team

If a thread fails to acquire a resource, it needs to be wrapped as a Node Node and then queued. Provide addWaiter function in AQS to complete the creation and queuing of a Node Node. When adding a node, if the CLH queue already exists, CAS is used to quickly add the current node to the end of the queue. If adding fails or the queue does not exist, the synchronization queue is initialized.

/**
 * Creates and enqueues node for current thread and given mode.
 *
 * @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
 * @return the new node
 */
private Node addWaiter(Node mode) {
    Node node = new Node(mode);

    for (;;) {
        Node oldTail = tail;
        if(oldTail ! =null) {
            node.setPrevRelaxed(oldTail);
            if (compareAndSetTail(oldTail, node)) {
                oldTail.next = node;
                returnnode; }}else{ initializeSyncQueue(); }}}Copy the code

Conclusion: The thread failed to acquire the lock when enqueued. After enqueued, add the new node to tail, perform CAS on tail, and move the tail pointer back to the new node.

Out of the team

The nodes in the CLH queue are thread nodes that failed to acquire resources. When the thread holding the resource releases the resource, it wakes up the thread node pointed to by head.next (the second node of the CLH queue). Original head node out (original sentry node)


  protected final boolean tryRelease(int releases) {
        int c = getState() - releases; 
        if(Thread.currentThread() ! = getExclusiveOwnerThread())throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) { // If state=0, the lock can be released
            free = true; 
            setExclusiveOwnerThread(null); // Set the lock thread to null
        }
        setState(c); // Reset the state of the synchronizer
        return free; // Returns whether the release was successful
  }

  private void unparkSuccessor(Node node) {
    // Node is the current lock release node and the head node of the synchronization queue
    int ws = node.waitStatus;
    // If the node has been canceled, set the node state to initialized
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    // Take out team 2 s
    Node s = node.next;
    // if s is empty, the next node of the node is empty
    // if s.waitStatus is greater than 0, the s node has been canceled
    // In both cases, start at the end of the queue and traverse forward to find that the first waitStatus field is not cancelled
    if (s == null || s.waitStatus > 0) {
        s = null;
   		
        // The end condition is that the front node is head
        for(Node t = tail; t ! =null&& t ! = node; t = t.prev)// t.waitStatus <= 0 indicates that t is not currently cancelled and must be waiting to be woken up
            if (t.waitStatus <= 0)
                s = t;
    }
    // Wake up the thread found by the above code
    if(s ! =null)
        LockSupport.unpark(s.thread);
}

Copy the code

When the thread is woken up, it resumes the acquireQueued method and enters the loop

 /**
 * Acquires in exclusive uninterruptible mode for thread already in
 * queue. Used by condition wait methods as well as acquire.
 *
 * @param node the node
 * @param arg the acquire argument
 * @return {@code true} if interrupted while waiting
 */
final boolean acquireQueued(final Node node, int arg) {
    boolean interrupted = false;
    try {
        for (;;) {
            // Get the precursor node
            final Node p = node.predecessor();
            // If the precursor node is the first node, get the resource (subclass implementation)
            if (p == head && tryAcquire(arg)) {
                // The resource is successfully obtained. Set the current node as the head node, clear the information about the current node, and change the current node to the sentinel node
                setHead(node);
                // The pointer to the next node is null
                p.next = null; // help GC
                // return thread interrupt status
                return interrupted;
            }
            if(shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); }}catch (Throwable t) {
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throwt; }}Copy the code

The head’s successor node wakes up from the block and begins to grab the lock. The lock is acquired successfully. At this time, the head pointer moves back one position, and the successor node of the original head becomes the new head.

Finally, an overview of the process for synchronizing queues

Conditions of the queue

An AQS can correspond to multiple condition variables

ConditionObject maintains a one-way conditional queue, which is different from the CLH queue. Conditional queues are only await thread nodes, and conditional nodes cannot be in CLH queue.

When a thread blocks the current thread and executes the await function of ConditionObject, the thread is wrapped as a Node and added to the conditional queue. The other thread executes ConditionObject signal. The thread node in the head of the conditional queue will be transferred to the CLH queue to compete for resources, and the specific process is shown as follows:

A Condition object has a single waiting task queue. In a multi-threaded task we can create multiple waiting task queues. Let’s say we create two new wait queues.

 private Lock lock = new ReentrantLock();
 private Condition FirstCond = lock.newCondition();
 private Condition SecondCond = lock.newCondition();

Copy the code

SignalAll = signalAll = signalAll = signalAll = signalAll = signalAll = signalAll

Design patterns

From the perspective of design patterns, AbstractQueuedSynchronizer is an abstract class, all use method of the class to inherit such several methods, the corresponding design pattern is a template pattern. This solves a lot of the details involved in implementing the synchronizer and can greatly reduce the implementation effort.

Several methods are those described in the translation of the notes above:

  • tryAcquire
  • tryRelease
  • tryAcquireShared
  • tryReleaseShared
  • isHeldExclusively

Explain what each method does:

  • TryAcquire: Attempts to acquire resources in exclusive mode, returning true if successful, false otherwise.
  • TryRelease: Attempts to release the resource in exclusive mode, returning true if successful, false otherwise.
  • TryAcquireShared: Try to get a resource in shared mode. If a positive number is returned, the resource was obtained successfully and there are still resources available. If 0 is returned, the resource was successfully obtained, but there are no available resources. If a negative value is returned, the resource failed to be obtained.
  • TryReleaseShared: Attempts to release the resource in shared mode, returning true if successful, false otherwise.
  • IsHeldExclusively: Determines whether the current thread is monopolizing the resource and returns true if so, false otherwise.

AbstractQueuedSynchronizer methods in the implementation according to the functional division can be divided into two categories, namely access to resources (acquire) and release resources (release), at the same time distinguish monopolistic mode and sharing mode

As you can see, concrete implementation classes need to concretely define the acquisition and release of resources under different schemas. For an example, check out Sync, an internal class in ReentrantReadWriteLock.

Current thread holding the lock: exclusiveOwnerThread

Since the implementation

AQS defines a set of multi-thread access to the shared resource synchronization template, which solves a lot of details involved in the implementation of the synchronizer, can greatly reduce the implementation work, now we implement a non-reentrant exclusive lock based on AQS, directly use the exclusive template provided by AQS, Just specify the semantics of state and implement the tryAcquire and tryRelease functions (get and release resources). In this case, state 0 means that the lock is not held by a thread, and state 1 means that the lock is held by a thread. Since it is a non-reentrant lock, there is no need to record the lock acquisition times of the thread holding the lock.

The code for the non-reentrant exclusive lock is as follows


public class NonReentrantLock implements Lock {

    / * * * *@DescriptionCustom synchronizer */
    private static class Sync extends AbstractQueuedSynchronizer {

        /** * whether the lock is held by the thread */
        @Override
        protected boolean isHeldExclusively(a) {
            //0: not held 1: held
            return super.getState() == 1;
        }

        /** * get lock */
        @Override
        protected boolean tryAcquire(int arg) {
            if(arg ! =1) {
                // State must be updated to 1, so arg must be 1
                throw new RuntimeException("arg not is 1");
            }
            if (compareAndSetState(0, arg)) {// The CAS update state is 1 successfully, indicating that the lock is successfully obtained
                // Set the thread holding the lock
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;
        }

        /** * release lock */
        @Override
        protected boolean tryRelease(int arg) {
            if(arg ! =0) {
                // Update state to 0, so arg must be 0
                throw new RuntimeException("arg not is 0");
            }
            // Clear the thread holding the lock
            setExclusiveOwnerThread(null);
            // Set state to 0. Cas is not used here, because this function is executed only on the thread that successfully acquired the lock
            setState(arg);
            return true;
        }

        /** * provides the create condition variable entry */
        public ConditionObject createConditionObject(a) {
            return newConditionObject(); }}private final Sync sync = new Sync();

    /** * get lock */
    @Override
    public void lock(a) {
        //Aqs exclusive - get resource template function
        sync.acquire(1);
    }
        
    /** * get lock - response interrupt */
    @Override
    public void lockInterruptibly(a) throws InterruptedException {
        // get resource template function (in response to thread interrupt)
        sync.acquireInterruptibly(1);
    }

    /** * Whether the lock was obtained successfully - no blocking */
    @Override
    public boolean tryLock(a) {
        // Subclass implementation
        return sync.tryAcquire(1);
    }
    
    /** * get lock-timeout mechanism */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        // get resource template function (timeout mechanism)
        return sync.tryAcquireNanos(1,unit.toNanos(time));
    }
    
    /** * release lock */
    @Override
    public void unlock(a) {
        //Aqs exclusive - free resource template function
        sync.release(0);
    }
    
    /** * create the condition variable */
    @Override
    public Condition newCondition(a) {
        returnsync.createConditionObject(); }}Copy the code

NonReentrantLock defines an internal class Sync that implements specific lock operations. Sync inherits AQS, overwrites tryAcquire and tryRelease functions because it uses exclusive templates, and provides an entry point to create condition variables. The following uses a custom exclusive lock to synchronize two threads on j++.

    private static int j = 0;

    public static void main(String[] agrs) throws InterruptedException {
        NonReentrantLock  nonReentrantLock = new NonReentrantLock();

        Runnable runnable = () -> {
            / / acquiring a lock
            nonReentrantLock.lock();
            for (int i = 0; i < 100000; i++) {
                j++;
            }
            / / releases the lock
            nonReentrantLock.unlock();
        };

        Thread thread = new Thread(runnable);
        Thread threadTwo = newThread(runnable); thread.start(); threadTwo.start(); thread.join(); threadTwo.join(); System.out.println(j); } No matter how many times you execute, the output will be:200000

Copy the code

other

LockSupport auxiliary class

LockSupport is a thread blocking utility class, all methods are static methods, can let the thread block at any position, of course, after blocking must have a wake up method.

Park means to park a car. If we think of Thread as a car, park means to stop the car, and unpark means to start the car and run.

Park /unpark calls the native code in Unsafe (which provides CAS operations).

instructions

This article refers to a lot of network pictures and article materials, strictly speaking, it is not the original article, if some content cited in this article has infringement, please contact to delete.

reference

  • Blog.csdn.net/lpf46306165…
  • www.codenong.com/cs106963035…
  • Mp.weixin.qq.com/s/bxWgo9Iug…
  • www.modb.pro/db/108644
  • Mp.weixin.qq.com/s/BLnZYa4lb…
  • Mp.weixin.qq.com/s/Y4GbMdNmS…
  • www.baiyp.ren/CLH%E9%98%9…
  • Juejin. Cn/post / 687302…
  • Mp.weixin.qq.com/s?__biz=MzU…