多线程AQS(AbstractQueuedSynchronizer)介绍分析

J.U.C 简介

Java.util.concurrent 是在并发编程中比较常用的工具类,里面包含很多用来在并发场景中使用的组件。比如线程池、阻塞队列、计时器、同步器、并发集合等等

Lock

Lock 在 J.U.C 中是最核心的组件
lock的实现
Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁的不同实现
ReetrantLock:表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数
ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个类中维护了两个锁,一个是 ReadLock,一个是 WriteLock,他们都分别实现了 Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock: stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程

ReentrantLock 重入锁

重入锁,表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。

static Lock lock=new ReentrantLock();
    private static int count=0;
    public static void incr(){
        lock.lock();
        try{
            Thread.sleep(1);
        }catch(InterruptedException e){
            e.printStackTrace();
        }
        count++;
        lock.unlock();
    }

    public static void main(String[] args) throws InterruptedException {
            for (int i=0;i<1000;i++) {
                new Thread(() -> {
                    ReentrantLockDemo.incr();
                }).start();
            }
            System.out.println("result:"+count);
    }

ReentrantLock 的实现原理
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件
AQS 是什么
在 Lock 中,用到了一个同步队列 AQS,全称 AbstractQueuedSynchronizer,它是一个同步工具也是 Lock 用来实现线程同步的核心组件
AQS 的两种功能
从使用层面来说,AQS 的功能分为两种:独占和共享
独占锁,每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁
共 享 锁 , 允 许 多 个 线 程 同 时 获 取 锁 , 并 发 访 问 共 享 资 源
AQS 的内部实现
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。
Node组成

static final class Node {
        /** Marker to indicate a node is waiting in shared mode */
        static final Node SHARED = new Node();
        /** Marker to indicate a node is waiting in exclusive mode */
        static final Node EXCLUSIVE = null;
        /** waitStatus value to indicate thread has cancelled */
        static final int CANCELLED =  1;
        /** waitStatus value to indicate successor's thread needs unparking */
        static final int SIGNAL    = -1;
        /** waitStatus value to indicate thread is waiting on condition */
        static final int CONDITION = -2;
        /**
         * waitStatus value to indicate the next acquireShared should
         * unconditionally propagate
         */
        static final int PROPAGATE = -3;
        /**
         * Status field, taking on only the values:
         *   SIGNAL:     The successor of this node is (or will soon be)
         *               blocked (via park), so the current node must
         *               unpark its successor when it releases or
         *               cancels. To avoid races, acquire methods must
         *               first indicate they need a signal,
         *               then retry the atomic acquire, and then,
         *               on failure, block.
         *   CANCELLED:  This node is cancelled due to timeout or interrupt.
         *               Nodes never leave this state. In particular,
         *               a thread with cancelled node never again blocks.
         *   CONDITION:  This node is currently on a condition queue.
         *               It will not be used as a sync queue node
         *               until transferred, at which time the status
         *               will be set to 0. (Use of this value here has
         *               nothing to do with the other uses of the
         *               field, but simplifies mechanics.)
         *   PROPAGATE:  A releaseShared should be propagated to other
         *               nodes. This is set (for head node only) in
         *               doReleaseShared to ensure propagation
         *               continues, even if other operations have
         *               since intervened.
         *   0:          None of the above
         *
         * The values are arranged numerically to simplify use.
         * Non-negative values mean that a node doesn't need to
         * signal. So, most code doesn't need to check for particular
         * values, just for sign.
         *
         * The field is initialized to 0 for normal sync nodes, and
         * CONDITION for condition nodes.  It is modified using CAS
         * (or when possible, unconditional volatile writes).
         */
        volatile int waitStatus;
        volatile Node prev;	//前驱节点
        volatile Node next;	//后续节点
        volatile Thread thread;//当前线程
        Node nextWaiter;//存储在condition队列中的后续节点
        final boolean isShared() {//是否为共享锁
            return nextWaiter == SHARED;
        }

        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }
        Node() {    // Used to establish initial head or SHARED marker
        }
        //将线程构造成一个node,添加到等待队列
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }
        //这个方法会在condition中使用
        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

释放锁以及添加线程对于队列的变化
添加:
在这里插入图片描述
涉及到两个变化

  1. 新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己
  2. 通过 CAS 讲 tail 重新指向新的尾部节点head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,
    在这里插入图片描述
    是涉及到两个变化
  3. 修改 head 节点指向下一个获得锁的节点
  4. 新的获得锁的节点,将 prev 的指针指向 null
    注意:设置 head 节点不需要用 CAS,原因是设置 head 节点是由获得锁的线程来完成的,而同步锁只能由一个线程获得,所以不需要 CAS 保证,只需要把 head 节点设置为原首节点的后继节点,并且断开原 head 节点的 next 引用即可

ReentrantLock 的源码分析

reentrantLock获取锁入口

public void lock() {
        sync.lock();
    }

sync 实际上是一个抽象的静态内部类,它继承了 AQS 来实现重入锁的逻辑
Sync 有两个具体的实现类,分别是:
NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁
FailSync: 表示所有线程严格按照 FIFO 来获取锁
NofairSync.lock
以非公平锁为例,来看看 lock 中的实现

  1. 非公平锁和公平锁最大的区别在于,在非公平锁中我抢占锁的逻辑是,不管有
    没有线程排队,我先上来 cas 去抢占一下
  2. CAS 成功,就表示成功获得了锁
  3. CAS 失败,调用 acquire(1)走锁竞争逻辑
 final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

CAS实现原理

 protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

通过 cas 乐观锁的方式来做比较并替换,这段代码的意思是,如果当前内存中的state 的值和预期值 expect 相等,则替换为 update。更新成功返回 true,否则返回 false,这个操作是原子的,不会出现线程安全问题涉及到Unsafe这个类的操作,
以及涉及到 state 这个属性的意义。
state 是 AQS 中的一个属性,它在不同的实现中所表达的含义不一样,对于重入锁的实现来说,表示一个同步状态
4. 当 state=0 时,表示无锁状态
5. 当 state>0 时,表示已经有线程获得了锁,也就是 state=1,但是因为ReentrantLock 允许重入,所以同一个线程多次获得同步锁的时候,state 会递增,比如重入 5 次,那么 state=5。而在释放锁的时候,同样需要释放 5 次直到 state=0其他线程才有资格获得锁
Unsafe 类
Unsafe 类是在 sun.misc 包下,不属于 Java 标准。但是很多 Java 的基础类库,包括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、Hadoop、Kafka 等;
Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、线程的挂起和恢复、CAS、线程同步、内存屏障
而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象,第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值 var4 相等,则更新为新的期望值 var5,如果更新成功,则返回 true,否则返回 false;
stateOffset
一个 Java 对象可以看成是一段内存,每个字段都得按照一定的顺序放在这段内存里,通过这个方法可以准确地告诉你某个字段相对于对象的起始内存地址的字节偏移。用于在后面的 compareAndSwapInt 中,去根据偏移量找到对象在内存中的具体位置所以 stateOffset 表示 state 这个字段在 AQS 类的内存中相对于该类首地址的偏移量
compareAndSwapInt
在 unsafe.cpp 文件中,可以找到 compareAndSwarpInt 的实现

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);//将java对象解析成JVM的oop(普通对象的指针)
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);//根据对象偏移量和地址偏移量找到地址
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;//基于 cas 比较并替换, x 表示需要更新的值,addr 表示 state 在内存中的地址,e 表示预期值
UNSAFE_END

acquire 是 AQS 中的方法,如果 CAS 操作未能成功,说明 state 已经不为 0,此时继续 acquire(1)操作
6. 通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false
7. 如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加到 AQS 队列尾部
8. acquireQueued,将 Node 作为参数,通过自旋去尝试获取锁

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

NonfairSync.tryAcquire
这个方法的作用是尝试获取锁,如果成功返回 true,不成功返回 false它是重写 AQS 类中的 tryAcquire 方法

protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }

ReentrantLock.nofairTryAcquire
9. 获取当前线程,判断当前的锁的状态
10. 如果 state=0 表示当前是无锁状态,通过 cas 更新 state 状态的值
11. 当前线程是属于重入,则增加重入次数

final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();//获取当前执行的线程
            int c = getState();//获取statr的值
            if (c == 0) {//表示无锁状态
                if (compareAndSetState(0, acquires)) {//cas替换statr值,cas成功表示获取锁成功
                    setExclusiveOwnerThread(current);//保存当前获得锁的线程,下次来不要尝试竞争锁
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {//如果同一个线程来获得锁,直接增加重入次数
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

AQS.addWaiter
当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成Node.
入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状态。意味着重入锁用到了 AQS 的独占锁功能
12. 将当前线程封装成 Node
13. 当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的node 添加到 AQS 队列
14. 如果为空或者 cas 失败,调用 enq 将节点添加到 AQS 队列

   private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);//当前线程封装为Node
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;//tail 是 AQS 中表示同比队列队尾的属性,默认是 null
        if (pred != null) {//tail 不为空的情况下,说明队列中存在节点
            node.prev = pred;//把当前线程的 Node 的 prev 指向 tail
            if (compareAndSetTail(pred, node)) {//通过 cas 把 node加入到 AQS 队列,也就是设置为 tail
                pred.next = node;//设置成功以后,把原 tail 节点的 next指向当前 node
                return node;
            }
        }
        enq(node);//tail=null,把 node 添加到同步队列
        return node;
    }

enq
enq 就是通过自旋操作把当前节点加入到队列中

private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

例:
在这里插入图片描述
AQS.acquireQueued
通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给acquireQueued 方法,去竞争锁
15. 获取当前节点的 prev 节点
16. 如果 prev 节点为 head 节点,那么它就有资格去争抢锁,调用 tryAcquire 抢占

17. 抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head
节点
18. 如果获得锁失败,则根据 waitStatus 决定是否需要挂起线程
19. 最后,通过 cancelAcquire 取消获得锁的操作

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();//获取当前prev节点
                if (p == head && tryAcquire(arg)) {//如果是head节点,有资格去争抢锁
                    setHead(node);//获取锁成功,也就是ThreadA 已经释放了锁,然后设置 head 为 ThreadB 获得执行权限
                    p.next = null; // help GC //把原 head 节点从链表中移除
                    failed = false;
                    return interrupted;
                }
                //ThreadA 可能还没释放锁,使得 ThreadB 在执行 tryAcquire 时会返回 false
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;//并且返回当前线程在等待过程中有没有中断过
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

NofairSync.tryAcquire
就是通过 state 的状态来判断是否处于无锁状态,然后在通过 cas 进行竞争锁操作。成功表示获得锁,失败表示获得锁失败
shouldParkAfterFailedAcquire
如果 ThreadA 的锁还没有释放的情况下,ThreadB 和 ThreadC 来争抢锁肯定是会失败,那么失败以后会调用 shouldParkAfterFailedAcquire 方法
Node 有 5 中状态,分别是:CANCELLED(1),SIGNAL(-1)、CONDITION(- 2)、PROPAGATE(-3)、默认状态(0) CANCELLED: 在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该 Node 的结点, 其结点的 waitStatus 为 CANCELLED,即结束状态,进入该状态后的结点将不会再变化
SIGNAL: 只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程
CONDITION: 和 Condition 有关系,后续会讲解
PROPAGATE:共享模式下,PROPAGATE 状态的线程处于可运行状态
0:初始状态
这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是否应该被挂起

  1. 如果 ThreadA 的 pred 节点状态为 SIGNAL,那就表示可以放心挂起当前线程
  2. 通过循环扫描链表把 CANCELLED 状态的节点移除
  3. 修改 pred 节点的状态为 SIGNAL,返回 false
    注意:返回 false 时,也就是不需要挂起,返回 true,则需要调用 parkAndCheckInterrupt挂起当前线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;//前置节点waitstatus
        if (ws == Node.SIGNAL)//如果前置节点为 SIGNAL,意味着只需要等待其他前置节点的线程被释放
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;//返回 true,意味着可以直接放心的挂起了
        if (ws > 0) {//ws大于0,意味着prev节点取消排队,直接移除这个节点
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;//相当于pred=pred.prev;prev=pred;
            } while (pred.waitStatus > 0);//采用循环,从双向列表中移除CANCELLED节点
            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);//利用cas设置prev节点的状态我SIGNAL(-1)
        }
        return false;
    }

parkAndCheckInterrupt
使用 LockSupport.park 挂起当前线程编程 WATING 状态Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回 true,意味着在 acquire 方法中会执行 selfInterrupt()。

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

selfInterrupt: 标识如果当前线程在 acquireQueued 中被中断过,则需要产生一个中断请求,原因是线程在调用 acquireQueued 方法的时候是不会响应中断请求的

static void selfInterrupt() {
        Thread.currentThread().interrupt();
    }

例:
acquireQueued 方法来竞争锁,如果 ThreadA 还在执行中没有释放锁的话,意味着 ThreadB 和 ThreadC 只能挂起了
在这里插入图片描述

锁的释放流程

ReentrantLock.unlock
在unlock中,会调用release方法来释放锁

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

 public final boolean release(int arg) {
        if (tryRelease(arg)) {//释放锁成功
            Node h = head;//得到aqs中head节点
            if (h != null && h.waitStatus != 0)//如果 head 节点不为空并且状态!=0.调用 unparkSuccessor(h)唤醒后续节点
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

ReentrantLock.tryRelease
这个方法可以认为是一个设置锁状态的操作,通过将 state 状态减掉传入的参数值(参数是 1),如果结果状态为 0,就将排它锁的 Owner 设置为 null,以使得其它的线程有机会进行执行。
在排它锁中,加锁的时候状态会增加 1(当然可以自己修改这个值),在解锁的时候减掉 1,同一个锁,在可以重入后,可能会被叠加为 2、3、4 这些值,只有 unlock()的次数与 lock()的次数对应才会将 Owner 线程设置为空,而且也只有这种情况下才会返回 true

 protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

AQS.unparkSuccessor

 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;//获取head节点状态
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);//设置head节点状态为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.
         */
        Node s = node.next;//得到head的下一个节点
        if (s == null || s.waitStatus > 0) {//如果下一个节点为null或者status>0 表示 cancelled 状态.
            s = null;//通过从尾部节点开始扫描,找到找到距离 head 最近的一个waitStatus<=0 的节点
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)//next节点不为空
            LockSupport.unpark(s.thread);//直接唤醒这个线程
    }

为什么在释放锁的时候是从 tail 进行扫描
enq

1. 将新的节点的 prev 指向 tail
2. 通过 cas 将 tail 设置为新的节点,因为 cas 是原子操作所以能够保证线程安全性
3. t.next=node;设置原 tail 的 next 节点指向新的节点在 cas 操作之后,t.next=node 操作之前。存在其他线程调用 unlock 方法从 head开始往后遍历,由于 t.next=node 还没执行意味着链表的关系还没有建立完整。就会导致遍历到 t 节点的时候被中断。所以从后往前遍历,一定不会存在这个问题
 private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

在这里插入图片描述
公平锁和非公平锁的区别
锁的公平性是相对于获取锁的顺序而言的,如果是一个公平锁,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO。 在上面分析的例子来说,只要CAS 设置同步状态成功,则表示当前线程获取了锁,而公平锁则不一样
NofairSync.lock
非公平锁在获取锁到时候会先通过 CAS 进行抢占,而公平锁则不会

final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

fairSync.lock
与非公平锁相比在于判断条件多了hasQueuedPredecessors()方法,也就是加入了[同步队列中当前节点是否有前驱节点]的判断,如果该方法返回 true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁。

final void lock() {
            acquire(1);
        }

fairSync.tryAcquire

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

nofairSync.tryAcquire

 protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

打赏
文章很值,打赏犒劳作者一下
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页

打赏

qq_38075852

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者