Java Concurrency Series Deeply Understanding AbstractQueued Synchronizer (AQS)

Keywords: github Java network Programming

Links to the original text: https://www.guan2ye.com/2019/08/31/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3AbstractQueuedSynchronizer(AQS).html

I get Aliyun coupon at our small welfare point

Author: github: CL0610/Java-concurrency
Disclaimer:

1. The articles reproduced in this paper are all from the public network.
2. If the origin labeling is wrong or infringes on the rights and interests of the original author, please contact and delete.
3. Reprint the article, please indicate the link and author of the original text, otherwise any copyright disputes will not be related to this site.

1. Introduction to AQS

stay Last article
We have a preliminary understanding of lock and Abstract Queued Synchronizer (AQS). In the implementation of synchronous components, AQS is the core part.
The implementer of synchronization component implements the semantics of synchronization component by using the template method provided by AQS, while AQS implements the management of synchronization state and queuing of blocked threads.
Waiting for notifications and other underlying implementation processing. The core of AQS includes these aspects: synchronous queue, acquisition and release of exclusive locks, acquisition and release of shared locks and interruptible locks.
Time-out waiting for locks to capture the implementation of these features, which are actually template methods provided by AQS, is summarized as follows:

Exclusive locks:

void acquire(int arg): Exclusively acquire synchronization status, insert synchronization queue to wait if acquisition fails;
Void acquire Interruptibly (int arg): The same method as acquire, but interrupts can be detected while waiting in a synchronous queue;
Boolean try Acquire Nanos (int arg, long nanos Timeout): On the basis of acquire Interruptibly, timeout waiting function is added, and no synchronization state is returned to false in timeout.
boolean release(int arg): Releases the synchronization state, which wakes up the next node in the synchronization queue

Shared locks:

void acquireShared(int arg): Shared access to synchronization state, and exclusive difference is that at the same time there are multiple threads to obtain synchronization state;
Void acquireShared Interruptibly (int arg): On the basis of acquireShared method, the function of responding to interrupts is added.
Boolean tryAcquire Shared Nanos (int arg, long nanos Timeout): On the basis of acquire Shared Interruptibly, the function of timeout waiting is added.
Boolean release Shared (int arg): Shared release synchronization state

To grasp the underlying implementation of AQS, in fact, is to learn the logic of these template methods. Before learning these template methods, we need to first understand what kind of data structure synchronization queue is in AQS.
Because synchronization queues are the cornerstone of AQS's management of synchronization state.

2. Synchronized queue

When a shared resource is occupied by a thread, other threads requesting the resource will block and enter the synchronization queue. As far as data structure is concerned, queues are implemented in the form of arrays.
The other is in the form of a linked list. The synchronization queue in AQS is realized by chain mode. Next, it's clear that we will at least have the following questions: ** 1. What is the data structure of the node?
2. One-way or two-way? 3. Is it the leading node or the non-leading node? ** We still look at the source code first.

In AQS, there is a static internal class Node, which has some attributes:

volatile int waitStatus //Node state
volatile Node prev //The precursor node of the current node/thread
volatile Node next; //Successor Node of Current Node/Thread
volatile Thread thread;//Thread references to join synchronous queues
Node nextWaiter;//Waiting for the next node in the queue

The states of nodes are as follows:

int CANCELLED = 1//Node Cancellation from Synchronization Queue
int SIGNAL = -1//The threads of successor nodes are in a waiting state. If the current node releases the synchronization state, the successor nodes will be notified so that the threads of successor nodes can run.
int CONDITION = -2//The current node enters the waiting queue
int PROPAGATE = -3//Represents that the next shared synchronization state acquisition will propagate unconditionally
int INITIAL = 0;//Initial state

Now we know the data structure type of the node, and each node has its predecessor and successor nodes, which is obviously a two-way queue. Similarly, we can use a demo to look at it.

public class LockDemo {
    private static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                lock.lock();
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            });
            thread.start();
        }
    }
}

Five threads are opened in the example code, and the lock is acquired first and then sleeped in 10S. In fact, the purpose of sleeping threads here is to simulate the situation of entering the synchronization queue when the thread is unable to acquire the lock. With debug, when Thread-4 (the last thread in this example) fails to acquire a lock and enters synchronization, the current synchronization queue for AQS is shown as follows:

Thread-0 acquires the lock first and then sleeps. Other threads (Thread-1,Thread-2,Thread-3,Thread-4) fail to acquire the lock and enter the synchronization queue. It is also clear that each node has two domains: prev (precursor) and next (successor), and each node is used to save thread references that fail to acquire synchronization status, and so on. Information such as waiting status. In addition, there are two important member variables in AQS:

private transient volatile Node head;
private transient volatile Node tail;

That is to say, AQS manages synchronization queue through head and tail pointers, and implements core methods, including acquiring threads that fail to lock and notifying threads in synchronization queue when releasing locks. The schematic diagram is as follows:

By understanding the source code and experimenting with it, we can now clearly understand the following points:

  1. The data structure of the node is the static internal class Node of AQS and the waiting state of the node.
  2. Synchronization queue is a two-way queue. AQS manages the synchronization queue by holding the head and tail pointers.

So, how do the nodes join and leave the team? In fact, this corresponds to the acquisition and release of locks: acquisition locks fail to enter the queue operation, acquisition locks succeed in the queue operation.

3. Exclusive Locks

3.1 Acquisition of Exclusive Locks (Acquisition Method)

Let's continue by looking at the source code and debug. Or take demo as an example. Calling lock() is to get an exclusive lock. If the lock fails, the current thread will be added to the synchronization queue, and if it succeeds, the thread will execute. The lock() method actually calls the ** acquire()** method of AQS, with the following source code

public final void acquire(int arg) {
		//First, see if the synchronization status is successful, and if so, the method ends and returns.
		//If it fails, call the addWaiter() method before the acquireQueued() method
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}

For key information, see the comment. Acquisition does two things based on whether the current synchronization status is successful or not: 1. Successful, the method ends and returns; 2. Failed, the addWaiter() method is called first, and then the acquire Queued () method is called.

Failed to obtain synchronization status, queue entry operation

When a thread fails to acquire an exclusive lock, it will join the current thread in the synchronization queue. What is the way to join the queue? Next we should look at addWaiter() and acquireQueued(). The addWaiter() source code is as follows:

private Node addWaiter(Node mode) {
		// 1. Construct the current thread into Node type
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        // 2. Is the current tail node null?
		Node pred = tail;
        if (pred != null) {
			// 2.2 Insert the current tail of the node into the synchronous queue
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
		// 2.1. The current synchronization queue tail node is null, indicating that the current thread is the first thread to join the synchronization queue to wait.
        enq(node);
        return node;
}

For analysis, you can see the comments above. The logic of the program is mainly divided into two parts: ** 1. The tail node of the current synchronous queue is null, and the method of enq() insertion is called; 2. If the tail node of the current queue is not null, the method of compareAndSetTail () is adopted to join the queue. ** There's another question: What if (compare AndSetTail (pred, node) is false? The enq() method will continue to be executed, and obviously compareAndSetTail is a CAS operation. Usually, if the CAS operation fails, it will continue to spin (dead loop) to retry. Therefore, after our analysis, the enq() method may undertake two tasks: (1) to process the queue entry operation when the current synchronous queue tail node is null; 2) to spin the attempt if the CAS tail insert node fails. So is it really like what we analyzed? Only the source code will tell us the answer: enq() source code is as follows:

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
			if (t == null) { // Must initialize
				//1. Structural Head Node
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
				// 2. Tail insertion, CAS operation failed spin attempt
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
}

In the analysis above, we can see that the first step is to create the header node, which shows that the synchronous queue is a chain storage structure of the header node. Compared with the non-leading node, the leading node will get more convenience in the operation of queue entry and queue exit, so the chain storage structure of the leading node is chosen for synchronous queue. So what is the timing of queue initialization for the lead node? Naturally, when tail is null, the current thread inserts the synchronization queue for the first time. The compareAndSetTail(t, node) method uses CAS operations to set the tail nodes, and if the CAS operation fails, it will keep trying in the for (;;)for dead loop until it return s successfully. Therefore, the enq() method can be summarized as follows:

  1. When the current thread is the first to join the synchronous queue, the compareAndSetHead(new Node()) method is called to initialize the head node of the chain queue.
  2. Spin constantly tries to insert the CAS tail into the node until it succeeds.

Now it's clear how to get the failed threads of exclusive locks wrapped in Node and inserted into the synchronous queue? Then there's the next question? What do the nodes (threads) in the synchronous queue do to ensure that they have the opportunity to acquire exclusive locks? With this in mind, let's take a look at the acquireQueued() method, which is very clear from the method name. The function of this method is to queue up the process of acquiring locks. The source code is as follows:

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
				// 1. Get the pioneer node of the current node
                final Node p = node.predecessor();
				// 2. Whether the current node can acquire exclusive locks					
				// 2.1 If the precursor node of the current node is the head node and the synchronization state is successfully acquired, an exclusive lock can be obtained.
                if (p == head && tryAcquire(arg)) {
					//Queue Header Pointer to Current Node
                    setHead(node);
					//Release the precursor node
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
				// 2.2 Acquisition locks failed, threads entered a waiting state and waited to acquire exclusive locks
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
}

Program logic has been marked out by annotations, which is a spinning process as a whole (for (; (iv). The code first gets the pioneer node of the current node, if the pioneer node is the head node and succeeds in obtaining the synchronization state (if (p= head & tryAcquire (arg)), which the current node points to. Threads can acquire locks. Conversely, the acquisition lock fails to enter a waiting state. The overall sketch is as follows:

Achieve lock success, queue operation

The logic of node queuing for acquiring locks is:

//Queue Header Node Reference Points to the Current Node
setHead(node);
//Release the precursor node
p.next = null; // help GC
failed = false;
return interrupted;

The setHead() method is:

private void setHead(Node node) {
        head = node;
        node.thread = null;
        node.prev = null;
}

Set the current node as the head node of the queue through setHead() method, and then set the next field of the previous head node as null and the pre field as null, that is to say, the queue is disconnected and memory can be recovered without any reference to facilitate GC. The schematic diagram is as follows:


Then when the acquisition lock fails, the shouldParkAfterFailedAcquire() method and the parkAndCheckInterrupt() method are called to see what they have done. ShouParkAfterFailedAcquire () method source code is:

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 main logic of shouldParkAfterFailedAcquire() method is to use CAS to set the node state from INITIAL to SIGNAL using compareAndSetWaitStatus(pred, ws, Node.SIGNAL), indicating the current thread blocking. When the compareAndSetWaitStatus setup fails, the shouldParkAfterFailedAcquire method returns false, and then retries in the acquireQueued() method for (;;) dead cycle until the compareAndSetWaitStatus sets the node status to SIGNAL and the shouldParkAfterFailedAcquire returns true. EckInterrupt () method, the source code of which is:

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

The key to this method is to call the LookSupport.park() method, which is used to block the current thread, as discussed in a future article. So it should be clear here that acquireQueued() accomplishes two main things in the spin process:

  1. If the precursor node of the current node is the header node and the synchronization state can be obtained, the current thread can obtain the lock method to execute the end of exit.
  2. If the lock fails, first set the node state to SIGNAL, and then call the LookSupport.park method to block the current thread.

After the above analysis, the acquisition process of exclusive locks, that is, the execution process of acquire() method, is shown in the following figure:

3.2 Release of Exclusive Locks (release() method)

Release of exclusive locks is relatively easy to understand. Look at the source code first without much nonsense.

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

This code logic is easy to understand. If the synchronization state release is successful (tryRelease returns true), the code in the if block will be executed. The unparkSuccessor() method will only be executed if the head er point is not null and the status value of the node is not zero. UnparkSuccessor method source code:

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.
     */

	//Successor Node of Head Node
    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)
		//Wake up the thread when the successor node is not null
        LockSupport.unpark(s.thread);
}

See the comments for the key information of the source code. First, the successor node of the header node is obtained. When the successor node is called, the LookSupport.unpark() method will wake up the thread wrapped by the successor node of the node. Therefore, each lock release will wake up the thread referenced by the successor node of the node in the queue, which further proves that the process of obtaining the lock is a FIFO (first in first out) process.

Now we have finally bitten off a hard bone. We have learned the acquisition and release of exclusive locks and the synchronization queue very profoundly by learning the source code. May make a summary:

  1. Threads fail to acquire locks, and threads are encapsulated as Node s for queuing operations. The core method is addWaiter() and enq(), while enq() completes the initialization of the head nodes of the synchronous queue and retries the failed CAS operations.
  2. Thread acquisition lock is a spinning process. If and only if the precursor node of the current node is the head node and the synchronization state is successfully obtained, the thread referenced by the node gets the lock when the node is out of line. Otherwise, when the condition is not satisfied, the LookSupport.park() method will be called to block the thread.
  3. When the lock is released, the successor node is awakened.

Generally speaking, AQS maintains a synchronization queue when acquiring synchronization status. Threads that fail to obtain synchronization status join the queue to spin. The condition for removing the queue (or stopping spin) is that the precursor node is the head node and the synchronization status is successfully obtained. When the synchronization state is released, the synchronizer calls the unparkSuccessor() method to wake up the successor node.

Exclusive Lock Characteristic Learning

3.3 Interruptible Acquisition Lock (acquireInterruptible Method)

We know that lock has some more convenient features than synchronized, such as responding to interrupts and waiting overtime. Now we still use the way of learning source code to see how interrupts can be responded to. The interruptible lock callable method lock. lock Interruptibly (); and the bottom of this method calls the acquireInterruptibly method of AQS with the source code as follows:

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

The doAcquireInterruptibly method is invoked when the synchronization status is failed:

private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
	//Insert nodes into synchronous queues
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            //Get locks out of the team
			if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
				//Thread interrupt throws an exception
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

See the comment for the key information. It's easy to see this code now. It's almost the same as the acquire method logic. The only difference is that when the parkAndCheck Interrupt returns true, the thread is interrupted and the code throws an interrupt exception.

3.4 Time-out Waiting Lock Acquisition (tryAcquireNanos() method)

By calling lock.tryLock(timeout,TimeUnit) to achieve the effect of timeout waiting for the acquisition of locks, this method will return in three cases:

  1. In the timeout time, the current thread successfully acquired the lock.
  2. The current thread is interrupted in a timeout time.
  3. The timeout time is over and the lock has not been returned to false.

We still learn how to implement it by reading the source code. This method calls the method tryAcquireNanos() of AQS. The source code is:

public final boolean tryAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) ||
		//Implementing the effect of overtime waiting
        doAcquireNanos(arg, nanosTimeout);
}

Obviously, this source code ultimately relies on the doAcquireNanos method to achieve the effect of timeout waiting. The source code of this method is as follows:

private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
	//1. Calculate the deadline based on the timeout time and the current time
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
			//2. The current thread gets the lock-out queue
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
			// 3.1 Recalculate timeout
            nanosTimeout = deadline - System.nanoTime();
            // 3.2 has timed back to false
			if (nanosTimeout <= 0L)
                return false;
			// 3.3 Thread Blocking Wait 
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > spinForTimeoutThreshold)
                LockSupport.parkNanos(this, nanosTimeout);
            // 3.4 Thread interrupted throws interrupted exception
			if (Thread.interrupted())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

The program logic is shown in the figure.

Program logic is basically the same as exclusive lock response interrupt acquisition. The only difference is that after acquisition failure, in dealing with the timeout time, the theoretical deadline will be calculated according to the present time and timeout time in step 1. For example, the current time is 8 H10 min, and the timeout time is 10 min. Then, according to deadli, the theoretical deadline will be calculated according to the current time and timeout time. NE = System. nanoTime () + nanosTimeout calculates that the system time that just reaches the timeout time is 8h 10min+10min = 8h 20min. The deadline - System.nanoTime() can then be used to determine whether the timeout has been exceeded. For example, the current system time is 8h 30min, which obviously exceeds the theoretical system time of 8h 20min. The deadline - System.nanoTime() is calculated as a negative number, and will naturally return false between the If judgments in step 3.2. If the if in step 3.2 is true, it will continue to execute step 3.3 to block the current thread through LockSupport.parkNanos. At the same time, the interrupt detection is added in step 3.4. If the interrupt is detected, the interrupt exception is thrown directly.

4. Shared locks

4.1 Acquisition of shared locks (acquireShared() method)

After talking about AQS's implementation of exclusive locks, let's continue to work together to see how shared locks are implemented. The acquisition method of shared lock is acquireShared, and the source code is:

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

The logic of this source code is easy to understand. In this method, the tryAcquireShared method is called first. The return value of tryAcquireShared is an int type. When the return value is greater than or equal to 0, the method ends with a successful lock acquisition. Otherwise, it indicates that the thread acquisition lock referred to fails if the synchronization state acquisition fails. The doAcquireShared method is executed. The source code of the method is:

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) {
					// When the precursor node of the node is the head node and the synchronization state is successfully obtained
                    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);
    }
}

Would it be easy to see this code now? Logic is almost identical to the acquisition of exclusive locks, where the spin process can exit if the precursor node of the current node is the head node and the return value of tryAcquireShared(arg) is greater than or equal to zero.

4.2 Release of shared locks (releaseShared() method)

The release of shared locks in AQS calls the method release Shared:

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

When the synchronization state is successfully released, tryReleaseShared continues to execute the doReleaseShared method:

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

This method is slightly different from the exclusive lock release process. In the release process of shared lock, for concurrent components that can support multiple threads accessing at the same time, it is necessary to ensure that multiple threads can safely release the synchronization state. The CAS guarantees adopted here are repeated in the next cycle when the CAS operation fails to continue. Try.

4.3 Interruptible (acquireSharedInterruptibly() method) and timeout (tryAcquireSharedNanos() method)

The implementation of interruptible lock and timeout wait is almost the same as that of exclusive lock, interruptible acquisition lock and timeout wait. We will not talk about it in detail. If we understand the above, the understanding of this part will come naturally.

Through this article, we have deepened the understanding of the underlying implementation of AQS, and laid a foundation for understanding the implementation principle of concurrent components, learning endless, continue to refuel:; If you feel good, please give praise, hey.

Reference

Art of concurrent programming in java

My official website

My official website http://guan2ye.com

My CSDN address http://blog.csdn.net/chenjianandiyi

My brief book address http://www.jianshu.com/u/9b5d1921ce34

My github https://github.com/javanan

My code cloud address https://gitee.com/jamen/

Aliyun coupon https://promotion.aliyun.com/ntms/yunparter/invite.html?userCode=vf2b5zld

** Personal Wechat Public Number: dou_zhe_wan**
Welcome your attention

Posted by Kieran Huggins on Sun, 08 Sep 2019 23:41:28 -0700