编程知识 cdmana.com

Java Multithread concurrent reentrant lock source code analysis

synchronized Synchronization code block

A thread accesses... In an object synchronized(this) When synchronizing code blocks , Other threads trying to access the object will be blocked

/**
 * @company:  Pay Extension Education 
 * @author:  Teacher Daliang   QQ:206229531
 */
public class Test1 {
    


    public static void main(String[] args) {
    
        Task t = new Task();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);

        t1.start();
        t2.start();
    }

    /**
         Thread task class 
     */
    static class Task implements Runnable{
    

        @Override
        public void run() {
    
            // Synchronization code block 
            synchronized (this){
    
                try {
    
                    // Sleep for a second 
                    TimeUnit.SECONDS.sleep(1);
                    // Execute business logic 
                    System.out.println(Thread.currentThread().getName()+" Being implemented ...");
                } catch (InterruptedException e) {
    
                    e.printStackTrace();
                }
            }
        }
    }
}

JUC Middle heavy entry lock ReentrantLock Realize synchronous lock

/**
 * @company:  Pay Extension Education 
 * @author:  Teacher Daliang   QQ:206229531
 */
public class Test2 {
    

    /*
         Define reentry lock 
     */
    static Lock lock = new ReentrantLock();

    public static void main(String[] args) {
    
        Task t = new Task();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);

        t1.start();
        t2.start();
    }
    /**
      Thread task class 
     */
    static class Task implements Runnable{
    
        @Override
        public void run() {
    
            //*** Lock ***
            lock.lock();
            try {
    
                // Sleep 1 second 
                TimeUnit.SECONDS.sleep(1);
                // Execute business logic 
                System.out.println(Thread.currentThread().getName()+" Being implemented ...");
            } catch (InterruptedException e) {
    
                e.printStackTrace();
            } finally {
    
                //*** Unlock ***
                lock.unlock();
            }
        }
    }
}

ReentrantLock and synchronized Different

  • Synchronized Unable to respond to interrupt
  • Synchronized I can't know if I got the lock
  • Synchronized Unable to control lock timeout handling
  • Multithreaded read operation is inefficient
  • Default unfair lock , Can't achieve fair lock

Let's look at the following example , We found that Synchronized It can't be interrupted .

/**
 * @company:  Pay Extension Education 
 * @author:  Teacher Daliang   QQ:206229531
 */
public class Test3 {
    

    // Create two objects ( lock )
    static Object lock1 = new Object();
    static Object lock2 = new Object();
    static int flag = 1;

    /**
     *  Define thread implementation class 
     */
    static class TxTask implements Runnable {
    
        // Identity properties 
        int flag;
        // Constructors 
        public TxTask(int flag) {
    
            this.flag = flag;
        }

        @Override
        public void run() {
    
            // In order to generate deadlock through flag To get two threads into different branches 
            if (flag == 1) {
    
                // Hold lock 1
                synchronized (lock1) {
    
                    System.out.println(" Enter the lock 1");
                    // Gets the lock 2
                    synchronized (lock2) {
    
                        System.out.println(" Enter the lock 1 In the lock 2");
                    }
                }
            } else {
    
                // Hold lock 2
                synchronized (lock2) {
    
                    System.out.println(" Enter the lock 2");
                    // Gets the lock 1
                    synchronized (lock1) {
    
                        System.out.println(" Enter the lock 2 In the lock 1");
                    }
                }
            }
        }
    }
    public static void main(String[] args) {
    
        TxTask task = new TxTask(1);
        TxTask task1 = new TxTask(0);
        Thread t = new Thread(task);
        Thread t1 = new Thread(task1);

        t.start();
        t1.start();
        t.interrupt();
    }

}

ReentrantLock Interrupts

public class Test4 {
    

    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();


    static class Task implements Runnable {
    
        Lock lock1;
        Lock lock2;

        public Task(Lock lock1, Lock lock2) {
    
            this.lock1 = lock1;
            this.lock2 = lock2;
        }

        @Override
        public void run() {
    

            try {
    
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName()+" Perform logical ...");
                TimeUnit.MILLISECONDS.sleep(100);
                lock2.lockInterruptibly();
            } catch (InterruptedException e) {
    
                e.printStackTrace();
            } finally {
    
                lock1.unlock();
                lock2.unlock();
            }
        }
    }

    public static void main(String[] args) {
    
        Task t = new Task(lock1, lock2);
        Task t1 = new Task(lock2, lock1);

        Thread thread = new Thread(t);
        Thread thread1 = new Thread(t1);
        thread.start();
        thread1.start();

        thread.interrupt();
    }

}

ReentrantLock You can try to acquire the lock and control the timeout of acquiring the lock

public class Test5 {
    

    static Lock lock1 = new ReentrantLock();
    static Lock lock2 = new ReentrantLock();


    static class Task implements Runnable {
    
        Lock lock1;
        Lock lock2;

        public Task(Lock lock1, Lock lock2) {
    
            this.lock1 = lock1;
            this.lock2 = lock2;
        }

        @Override
        public void run() {
    
            try {
    
                // The spin 
                while (!lock1.tryLock()) {
    
                    TimeUnit.MILLISECONDS.sleep(10);
                }
                System.out.println(Thread.currentThread().getName() + " Perform logical ...");
                TimeUnit.MILLISECONDS.sleep(10);
                while (!lock2.tryLock()) {
    
                    lock1.unlock();
                    TimeUnit.MILLISECONDS.sleep(10);
                }
            } catch (Exception e) {
    
                e.printStackTrace();
            } finally {
    
                lock2.unlock();
            }
        }
    }

    public static void main(String[] args) {
    
        Task t = new Task(lock1, lock2);
        Task t1 = new Task(lock2, lock1);

        Thread thread = new Thread(t);
        Thread thread1 = new Thread(t1);
        thread.start();
        thread1.start();

    }

}

Handwriting to achieve exclusive lock : The way 1

  • Implementation process
    file
  • Code implementation
public class Test6 {
    

    static TxLock lock = new TxLock();

    public static void main(String[] args) {
    
        Task t = new Task();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);

        t1.start();
        t2.start();
    }

    static class Task implements Runnable{
    
        @Override
        public void run() {
    
            lock.lock();
            try {
    
                TimeUnit.SECONDS.sleep(1);
                System.out.println(Thread.currentThread().getName()+" Being implemented ...");
            } catch (InterruptedException e) {
    
                e.printStackTrace();
            } finally {
    
                lock.unlock();
            }
        }
    }

    static class TxLock{
    
        /**
         *  0  Indicates that the lock is not obtained 
         *  1  Have a lock 
         * Unsafe: Classes that operate directly on memory , More dangerous , It is not recommended to operate memory directly .
         */
        static final Unsafe unsafe;
        // Offset on memory 
        static final long stateOffset;
        // Visible variables to operate on memory 
        volatile long status = 0;
        static {
    
            try {
    
                // Use reflection to get Unsafe Member variables of theUnsafe
                Field field = Unsafe.class.getDeclaredField("theUnsafe");
                // Set to accessible 
                field.setAccessible(true);
                // Get the value of the variable 
                unsafe = (Unsafe) field.get(null);
                // obtain state stay TestUnSafe Assembly language offset in 
                stateOffset = unsafe.objectFieldOffset(TxLock.class.getDeclaredField("status"));
            } catch (Exception ex) {
    
                System.out.println(ex.getLocalizedMessage());
                throw new Error(ex);
            }
        }
        /**
         * CAS
         *
         */
        public void lock(){
    
            // Want to put status Change to 1
            while (!unsafe.compareAndSwapInt(status, stateOffset, 0, 1)){
    

            }
        }

        public void unlock(){
    
            // Want to put status Change to 0
            while (!unsafe.compareAndSwapInt(status, stateOffset, 1, 0)){
    

            }
        }
    }
}

Handwritten lock : The way 2

  • Implementation process
    file
  • Code implementation
public class Test7 {
    

    static TxLock lock = new TxLock();

    public static void main(String[] args) {
    
        Task t = new Task();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);

        t1.start();
        t2.start();
    }

    static class Task implements Runnable{
    
        @Override
        public void run() {
    
            lock.lock();
            try {
    
                TimeUnit.SECONDS.sleep(1);
                System.out.println(Thread.currentThread().getName()+" Being implemented ...");
            } catch (InterruptedException e) {
    
                e.printStackTrace();
            } finally {
    
                lock.unlock();
            }
        }
    }

    static class TxLock{
    

        ConcurrentLinkedQueue<Thread> threads = new ConcurrentLinkedQueue<>();
        /**
         *  0  Indicates that the lock is not obtained 
         *  1  Have a lock 
         *
         */
        static final Unsafe unsafe;
        static final long stateOffset;
        volatile long status = 0;
        static {
    
            try {
    
                // Use reflection to get Unsafe Member variables of theUnsafe
                Field field = Unsafe.class.getDeclaredField("theUnsafe");
                // Set to accessible 
                field.setAccessible(true);
                // Get the value of the variable 
                unsafe = (Unsafe) field.get(null);
                // obtain state stay TestUnSafe Assembly language offset in 
                stateOffset = unsafe.objectFieldOffset(TxLock.class.getDeclaredField("status"));
            } catch (Exception ex) {
    
                System.out.println(ex.getLocalizedMessage());
                throw new Error(ex);
            }
        }


        /**
         * CAS
         *
         */
        public void lock(){
    
            // Want to put status Change to 1
            while (!unsafe.compareAndSwapInt(status, stateOffset, 0, 1)){
    
                threads.offer(Thread.currentThread());
                LockSupport.park();
            }
        }

        public void unlock(){
    
            // Want to put status Change to 0
            while (unsafe.compareAndSwapInt(status, stateOffset, 1, 0)){
    
                Thread thread = threads.poll();
                LockSupport.unpark(thread);
            }
        }
    }
}

ReentrantLock Source code analysis

Inside the fair lock is FairSync, The inside of the unfair lock is NonfairSync. whether FairSync still NonfariSync, All indirectly inherited from AbstractQueuedSynchronizer This abstract class

  • ReentrantLock It can be divided into fair lock and unfair lock , Source class diagram
    file
    This abstract class provides a unified template method for our locking and unlocking process , Only some details are handled by the implementation class of the abstract class itself . So reading ReentrantLock( Reentrant lock ) Source code before , It is necessary to understand AbstractQueuedSynchronizer.

AQS In the template method pattern, the template method to get and release synchronization state is defined internally , And leave the hook function for extension when subclass inherits , It's up to the subclass to determine the details of getting and releasing the synchronization state , So as to meet the requirements of their own functional characteristics . besides ,AQS Thread failed to get synchronization status through internal synchronization queue management , The details of thread blocking and wakeup are masked to the implementer .

AQS Class structure Node Node code snippet

        static final Node SHARED = new Node();

        static final Node EXCLUSIVE = null;

        // Thread cancelled 
        static final int CANCELLED =  1;

        // The thread is in a normal state that needs to be woken up 
        static final int SIGNAL    = -1;
        // Condition pending state 
        static final int CONDITION = -2;

        static final int PROPAGATE = -3;

        volatile int waitStatus;

        /**
         *  Front pointer of current node 
         */
        volatile Node prev;

        /**
         * *  The tail pointer of the current node 
         */
        volatile Node next;

        /**
         *  Thread held by current node 
         */
        volatile Thread thread;

        Node nextWaiter;

AQS The implementation of the synchronous waiting queue in is a pointer with head and tail ( Here is a pointer to show the reference is to explain the source code can be more intuitive image , Besides, the reference itself is a kind of restricted pointer ) And without sentinel nodes ( The head node in the following article represents the queue head element node , It's not the sentinel node ) Two-way linked list .
head It's the head pointer , Point to the first element of the queue ;tail Is the tail pointer , Point to the end element of the queue . And the element node of the queue Node It's defined in AQS Inside , There are several member variables as follows

  • prev: Pointer to the previous node
  • next: Pointer to the next node
  • thread: The thread represented by the current node , Because the nodes in the synchronization queue encapsulate the threads that failed to compete for locks , Therefore, there must be a reference to the thread instance in the node
  • waitStatus: For re-entry locks , There are mainly 3 It's worth .0: Initialization status ;-1(SIGNAL): The thread represented by the current node needs to wake up the thread of the subsequent node after releasing the lock ;1(CANCELLED): The thread waiting in the synchronization queue timed out or was interrupted , Cancel and wait .

ReentrantLock Lock logic code

/**
         *  Lock logic 
         */
        final void lock() {
    
            // adopt cas How to state from 0 Update to 1
            if (compareAndSetState(0, 1))
                // If the lock is acquired successfully, the current thread will be marked as the thread holding the lock , Then go straight back 
                setExclusiveOwnerThread(Thread.currentThread());
            else
                // If the subsequent operation fails to obtain the lock 
                acquire(1);
        }

First try to get the lock quickly , With cas The way to state The value of is updated to 1, Only when state The original value of is 0 When the update is successful , because state stay ReentrantLock In this context, it is equal to the number of times the lock is re entered by the thread , This means that the action will return success only if the current lock is not held by any thread . If the lock is obtained successfully , Then mark the current thread as the thread holding the lock , Then the whole process of locking is over . If getting lock fails , execute acquire Method

Get lock logic

When we failed to acquire the lock for the first time , We'll try to get another call acquire(int arg)

/**
     *  tryAcquire(arg)  yes AQS Hook method defined in :
     *             This method throws an exception by default , Force the synchronization component to extend AQS To implement the synchronization function, you must override this method ,ReentrantLock There are different ways to achieve this under fair and unfair models 
     *  addWaiter()  How can the thread that failed to acquire the lock safely join the synchronization queue 
     *
     *  acquireQueued()   The new node thread is suspended after joining the synchronization queue 
     */
    public final void acquire(int arg) {
    
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

The main logic of this method is if In the judgment condition , There are 3 An important way tryAcquire(),addWaiter() and acquireQueued(), These three methods encapsulate the main processing logic in the lock process , Understand what these three methods have done , The whole process of locking is clear .

Unfair lock acquisition lock

/**
         * Performs non-fair tryLock.  tryAcquire is implemented in
         * subclasses, but both need nonfair try for trylock method.
         *  The logic of unfair lock acquisition 
         */
        final boolean nonfairTryAcquire(int acquires) {
    
            // Get the currently running thread 
            final Thread current = Thread.currentThread();
            // obtain state The value of the variable , That is, the number of times the current lock has been re entered 
            int c = getState();
            //state by 0, Indicates that the current lock is not held by any thread 
            if (c == 0) {
    
                // Use cas Way to get lock 
                if (compareAndSetState(0, acquires)) {
    
                    // If you succeed , Set the current thread to hold the thread 
                    setExclusiveOwnerThread(current);
                    // Get lock return result 
                    return true;
                }
            }// If the current thread is the holding thread 
            else if (current == getExclusiveOwnerThread()) {
    
                // Add one... To the number of reentries 
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                // Set the number of reentries 
                setState(nextc);
                // Get lock return result 
                return true;
            }
            // Not getting the lock 
            return false;
        }

This is a general way to get locks in unfair mode . It covers all possible situations when the current thread attempts to acquire a lock :

1. The current lock is not held by any thread (state=0), with cas Way to get lock , Set... If successful exclusiveOwnerThread Is the current thread , Then return the successful result ; if cas Failure , Explain that you are getting state=0 and cas There are other threads between acquire locks that have acquired locks , Return failure result .
2. If the lock has been acquired by the current thread (state>0,exclusiveOwnerThread Is the current thread ), Add the number of reloads of the lock to 1(state+1), Then return the success result . Because the thread has acquired the lock before , So this accumulation operation does not need to be synchronized .
3. If the current lock is already held by another thread (state>0,exclusiveOwnerThread Not for the current thread ), Then return the failure result directly
Because we use state To count the number of times the lock has been re entered by the thread , So whether the current thread attempts to acquire the lock successfully can be simplified as :state Is the value accumulated successfully 1, Yes, the attempt to acquire the lock succeeded , Otherwise, the attempt to acquire the lock fails .

establish node Node logic

tryAcquire(arg) Return to success , It indicates that the current thread has successfully acquired the lock ( To acquire or reenter for the first time ), By reverse and && You know , The whole process ends here , Only if the current thread fails to acquire the lock will the subsequent judgment be executed . First look addWaiter(Node.EXCLUSIVE)
part , This part of code describes how to join the synchronization waiting queue safely when the thread fails to acquire the lock . This part of the code can be said to be the essence of the whole lock process source code , It fully embodies the artistry of concurrent programming .

private Node addWaiter(Node mode) {
    
        // First create a new node , And encapsulate the current thread instance inside ,mode Here for null
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        // Try to get into the team quickly 
        // Define the tail temporary node 
        Node pred = tail;
        // If the tail is not empty 
        if (pred != null) {
    
            // Set the front pointer of the new node to the tail node 
            node.prev = pred;
            //CAS Set the new node to the tail node 
            if (compareAndSetTail(pred, node)) {
    
                // Set the tail pointer of the old tail node to the new one 
                pred.next = node;
                // Back to the new stage 
                return node;
            }
        }
        // Spin into the team 
        enq(node);
        return node;
    }

First, a new node is created , And encapsulate the current thread instance inside it , After that, let's look directly at enq(node) The method is ok , The middle part of the logic is enq(node) There are , The reason why this part is added “ Duplicate code ” And when trying to acquire the lock “ Duplicate code ” equally , In some special cases
To deal with in advance , Sacrifice certain code readability for performance improvement .

  • establish node Node spins into line
private Node enq(final Node node) {
    
        //
        for (;;) {
    
            // Define the tail temporary node 
            Node t = tail;
            // First node situation 
            if (t == null) {
     // Must initialize
                // At this stage CAS Way as the first stage 
                if (compareAndSetHead(new Node()))
                    // At the same time, the tail stage is also set as a new node 
                    tail = head;
            } else {
    
                // The front pointer of the new node is set to the old tail node 
                node.prev = t;
                //CAS Method to set the tail node 
                if (compareAndSetTail(t, node)) {
    
                    // The old tail node points to the new node 
                    t.next = node;
                    return t;
                }
            }
        }
    }

There are two CAS operation :

compareAndSetHead(new Node()),CAS Way update head The pointer , Only if the original value is null Time update successful

// At this stage CAS Way as the first stage 
                if (compareAndSetHead(new Node()))
                    // At the same time, the tail stage is also set as a new node 
                    tail = head;

The outer for The loop ensures that all threads that fail to acquire the lock can finally join the synchronization queue after failing to retry . because AQS The synchronization queue of is without sentinel node , Therefore, when the queue is empty, special processing is required , This part is in if In the clause . Note that the node of the current thread cannot be inserted directly
Empty queue , Because the blocked thread is awakened by the predecessor node . So first insert a node as the first element of the queue , When the lock is released, it wakes up the blocked thread , Logically, the first element of the queue can also represent the thread that is currently acquiring the lock , Although it is not necessarily true to hold its thread instance .

First, through new Node() Create an empty node , And then to CAS Method to point the head pointer to the node ( This node is not the node of the current thread ), If the operation is successful , Then point the tail pointer to the node .
compareAndSetTail(t, node),CAS Way update tial The pointer , Only if the original value is t Time update successful

if (compareAndSetTail(t, node)) {
    
                    // The old tail node points to the new node 
                    t.next = node;
                    return t;
                }

First, the forward pointer of the node where the current thread is located pre Point to the end node considered by the current thread , Source used in t Express . And then to CAS To point the tail pointer to the current node , This operation only applies to tail=t, The tail pointer is in progress CAS Succeed when you haven't changed . if CAS Successful implementation , The backward pointer of the original tail node next Point to the new tail node .
The whole process of joining the team is not complicated , Is a typical CAS Optimistic lock strategy with failed retries . Only update the head pointer and update the tail pointer CAS Sync , It can be predicted that the performance in high concurrency scenarios is very good .

Node suspend and get execution logic

final boolean acquireQueued(final Node node, int arg) {
    
        // Sign of success 
        boolean failed = true;
        try {
    
            // Break sign 
            boolean interrupted = false;
            for (;;) {
    
                // Get the previous node of the current new node 
                final Node p = node.predecessor();
                // The front node is the head ,  And try to get the lock successfully 
                if (p == head && tryAcquire(arg)) {
    
                    // Set yourself as the head node 
                    setHead(node);
                    // Let go of the old head node 
                    p.next = null; // help GC
                    // Failure is false , The successful 
                    failed = false;
                    // Return interrupt flag 
                    return interrupted;
                }
                // If the last one if It didn't happen , I.e. no lock obtained to hang 
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
    
            // If you fail 
            if (failed)
                // Cancel acquisition lock , Recycling 
                cancelAcquire(node);
        }
    }

The main content of this code is for In circulation , It's a dead cycle , There are two main ones if The construction of clauses . first if In the clause , The current thread will first determine whether the predecessor node is the header node , If so, try to acquire the lock , If the lock is acquired successfully, the current node will be set as the header node ( Update head pointer ). Why do you have to have the front node as the head node to try to acquire the lock ? Because the header node represents the thread currently holding the lock , Normally, the thread will notify the blocked thread in the subsequent node after releasing the lock , The blocked thread is awakened to acquire the lock , This is what we want to see . However, there is another situation , It's the precursor node that cancels the wait , At this time, the current thread will also be awakened , You shouldn't get the lock at this time , But go back to find a node that doesn't cancel waiting , Then connect yourself to it . Once we have successfully acquired the lock and successfully set ourselves as the head node , It's going to jump out for loop . Otherwise, the second if Clause : Make sure that the state of the precursor node is SIGNAL, Then block the current thread .

First look shouldParkAfterFailedAcquire(p, node) Determine whether to block the current thread

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    
        // Get the waiting state of the previous node 
        int ws = pred.waitStatus;
        // If the former node is normally waiting for the wake-up state to return true
        if (ws == Node.SIGNAL)
            return true;
            // The previous thread was canceled waiting or timed out 
        if (ws > 0) {
    
            do {
    
                // Back to the previous normal waiting node 
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            // Before normal, the node tail pointer points to the current new node 
            pred.next = node;
        } else {
    
            // Initial knowledge setting 
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

You can see that for the precursor node pred The state of will be handled differently

1.pred Status as SIGNAL, Then return to true, To block the current thread .
2.pred Status as CANCELLED, Then go back to the head of the queue until you find a state that is not CANCELLED The node of , Set the current node node Hanging behind this node .
3.pred The state of is initialization state , At this point through compareAndSetWaitStatus(pred, ws, Node.SIGNAL) Methods will pred Change the status of SIGNAL.
In fact, the meaning of this method is very simple , It is to ensure that the state of the current node's predecessor node is SIGNAL,SIGNAL It means that the thread will wake up the blocked thread after releasing the lock . After all , Only make sure to be awakened , The current thread can be safely blocked .

But it should be noted that only in the precursor node is SIGNAL Only after the state is executed will the subsequent method immediately block , Corresponding to the first case above . The other two cases are due to the return of false And do it again
for loop .

shouldParkAfterFailedAcquire return true Indicates that the current thread should be blocked , Will perform parkAndCheckInterrupt Method , This method is relatively simple , The underlying call LockSupport To block the current thread , Source code is as follows :

private final boolean parkAndCheckInterrupt() {
    
        // Block the current thread 
        LockSupport.park(this);
        // Return interrupt flag 
        return Thread.interrupted();
    }

Learn more free courses on Architecture :java Architect free course
Every night 20:00 Live sharing advanced java Architecture Technology
Scan to add QQ Communication group 264572737
 Insert picture description here
Enter the group and get a large amount of free java Structure the interview questions
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here
 Insert picture description here

版权声明
本文为[renlianggee]所创,转载请带上原文链接,感谢

Scroll to Top