精灵王


  • 首页

  • 文章归档

  • 所有分类

  • 关于我

  • 搜索
设计模式-行为型 设计模式-创建型 设计模式-结构型 设计 系统设计 设计模式之美 分布式 Redis 并发编程 个人成长 周志明的软件架构课 架构 单元测试 LeetCode 工具 位运算 读书笔记 操作系统 MySQL 异步编程 技术方案设计 集合 设计模式 三亚 游玩 转载 Linux 观察者模式 事件 Spring SpringCloud 实战 实战,SpringCloud 源码分析 线程池 同步 锁 线程 线程模型 动态代理 字节码 类加载 垃圾收集器 垃圾回收算法 对象创建 虚拟机内存 内存结构 Java

源码分析:AbstractQueuedSynchronizer(AQS)—强大的同步基础框架

发表于 2020-11-01 | 分类于 JDK源码系列 | 0

简介

AQS 全称是 AbstractQueuedSynchronizer,位于java.util.concurrent.locks 包下面,AQS 提供了一个基于FIFO的队列和维护了一个状态state变量赖表示状态,可以作为构建锁或者其他相关同步装置的基础框架。AQS 支持两种模式:共享模式 和 排他模式,当它被定义为一个排他模式时,其他线程对其的获取就被阻止,而共享模式对于多个线程获取都可以成功。之所以说它是一个同步基础框架是因为很多同步类里面都用到了AQS,比如 ReentrantLock 中的内部类同步器Sync继承至AQS,ReentrantReadWriteLock中的同步器也是继承至AQS,还有 Semaphore 、CountDownLatch等都是基于AQS来实现的。

核心源码

类结构

AQS 继承了 AbstractOwnableSynchronizer, AbstractOwnableSynchronizer 这个类比较简单,就一个属性 private transient Thread exclusiveOwnerThread ,用来标识当前独占锁的持有者线程,通俗的说就是哪个线程拿到了独占锁,就调用AbstractOwnableSynchronizer 的方法把这个线程保存起来。源码如下:

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
	...
}

public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
  private transient Thread exclusiveOwnerThread;
  // 构造方法,get set 方法省略。。。
}

后面的分析中,会有大量的同步器在获得锁之后会调用setExclusiveOwnerThread(Thread) 方法来保存锁的持有者线程;

重要内部类Node

static final class Node {
	volatile int waitStatus;
	volatile Node prev;
	volatile Node next;
	volatile Thread thread;
	Node nextWaiter;
}

以上五个成员变量主要负责保存该节点的线程引用,同步队列的前驱和后继节点,同时也包括了同步状态。

属性解释:

waitStatus:表示节点的状态。其中包含的状态有:

  1. CANCELLED,值为1,表示当前的线程被取消;
  2. SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark;
  3. CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中;
  4. PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行;
  5. 值为0,表示当前节点在sync队列中,等待着获取锁。

prev:前驱节点,比如当前节点被取消,那就需要前驱节点和后继节点来完成连接

next:后继节点

thread:入队列时的当前线程

nextWaiter:存储condition队列中的后继节点

重要属性:同步队列和同步状态

节点成为同步队列和 condition 条件队列构建的基础,同步器拥有三个成员变量:头结点head、尾节点tail和同步状态state。

private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;

对于新的获取锁请求,形成Node节点,挂载到队列的尾部;对于锁资源的释放都是从队列的头部进行操作的。

      +------+  prev +-----+       +-----+
 head |      | <---- |     | <---- |     |  tail
      +------+       +-----+       +-----+

可以重写的API

实现自定义同步器时,需要使用同步器提供的getState()、setState()和compareAndSetState()方法来控制同步状态。

方法1:protected boolean tryAcquire(int arg)

描述:已排它模式获取同步状态。这个方法的实现需要查询前状态是否允许获取,然后再进compareAndSetState()修改状态,修改成功代表成功获得锁。

方法2:protected boolean tryRelease(int arg)

描述:释放锁,也就是释放同步状态state的值到初始状态,一般是0。

方法3:protected int tryAcquireShared(int arg)

描述:共享模式下获取同步状态,一般可以用来做共享锁,或者用作限制资源最多同时被访问多少次。

方法4:protected boolean tryReleaseShared(int arg)

描述:共享模式下释放同步状态。

方法5:protected boolean isHeldExclusively()

描述:在排它模式下,返回同步状态是否被占用,比如我们可以实现返回逻辑为 getState() == 1,为true的话说明资源已经被占用了。

其他代码我们通过自己实现简单的排他锁案例来进行具体的详细分析

基于AQS实现的排他锁

一、定义一个MyAQSLock类

public class MyAQSLock{
}

二、定义一个内部类Sync做为同步器,继承自AbstractQueuedSynchronizer

public class MyAQSLock{
	class Sync extends AbstractQueuedSynchronizer{
	}
}

三、重写同步器部分API

因为我们要实现的是排它锁的功能,意思就是同一时刻只能有一个线程获得锁,所以只需要重写tryAcquire、tryRelease和isHeldExclusively方法即可。

class Sync extends AbstractQueuedSynchronizer{
        @Override
        protected boolean **tryAcquire**(int acquires){
            // 入参只能为1
            **assert acquires == 1;
            // 使用CAS的方式修改state值,修改成功代表成功获得锁
            if(compareAndSetState(0,1)){
                // 修改锁的持有者为当前线程
                setExclusiveOwnerThread(Thread.currentThread());
                // 返回true,表示成功获得锁
                return true;
            }
            // 返回false,没有获得锁
            return false;
        }

        @Override
        protected boolean **tryRelease**(int releases){
            assert releases == 1;
            if (getState() == 0){
                // 已经被释放了
                throw new IllegalMonitorStateException();
            }
            // lock() 和 unlock() 一般都是成对出现的,所以这里不需要同步语句,可以直接修改state值为0
            setState(0);
            return true;
        }
        @Override
        protected boolean **isHeldExclusively**() {
            // 返回true,说明已经有其他线程获得锁
            return getState() == 1;
        }

    }

三、定义锁和解锁方法

public class MyAQSLock{

    private final Sync sync;
    MyAQSLock(){
        sync = new Sync();
    }
		class Sync extends AbstractQueuedSynchronizer{
		...
		}
    public void lock(){
        // 调用同步器,获得锁
        sync.acquire(1);
    }

    public boolean tryLock(){
			  // 尝试获得锁,如果没有获取到锁,则立即返回false
        return sync.tryAcquire(1);
    }
 
    public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException{
        // 尝试获得锁,如果没有获取到锁,允许等待一段时间
        return sync.tryAcquireNanos(1,unit.toNanos(timeout));
    }
 
    public void unLock(){
        // 解锁
        sync.release(1);
    } 

    public boolean isLocked(){
        // 判断锁是否已经被占用
        return sync.isHeldExclusively();
    }
}

四、测试我们的锁

static int count = 0;
public static void main(String[] args) throws InterruptedException{
    MyAQSLock myAQSLock = new MyAQSLock();
    CountDownLatch countDownLatch = new CountDownLatch(1000);
    IntStream.range(0,1000).forEach(i->new Thread(()->{
        myAQSLock.lock();
        try{
            IntStream.range(0,10000).forEach(j->{
                count++;
            });
        }finally{
            myAQSLock.unLock();
        }
        countDownLatch.countDown();
    }).start());
    countDownLatch.await();
    System.out.println(count);
}

最后正确输出10000000,说明我们实现的锁是有效的。但是要注意我们自己写的这个锁是不支持重入的。

代码实现分析

获得锁:public void lock()

lock()方法会调用sync.acquire(int)方法,acquire在AQS里面,方法被final修饰,作为基础框架逻辑部分,不允许被继承,源码展示:

public final void acquire(int arg) {
    // tryAcquire 是我们自己实现的方法,具体实现看上面
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // acquireQueued 返回true表示线程被中断了,中断当前线程
        selfInterrupt();
}

上面acquire()主要的逻辑有:

  1. 尝试获得锁,调用tryAcquire(arg)方法,该方法的逻辑在我们自定义的MyAQSLock类中,我们利用了compareAndSetState来保证state字段的原子性。

  2. 如果tryAcquire返回true的话,if分支会直接退出,表示成功获得锁,继续执行调用lock() 方法后面的逻辑;

  3. 如果tryAcquire返回false的话,表示没有获得锁,会继续执行 && 后面的逻辑;

  4. 首先会调用addWaiter(Node.EXCLUSIVE)方法为当前线程创建排队节点,并加入到队列,Node.EXCLUSIVE代表这个节点是独占排他锁的意思,具体源码如下:

    private Node addWaiter(Node mode) {
        // 为当前线程创建一个节点,最后会返回出去这个节点
        Node node = new Node(Thread.currentThread(), mode);
        // 队列不为空时,快速尝试在同步队列尾部添加当前节点,如果失败了会进入enq方法自旋入队
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        // 上面入队失败了,或者是pred为空(第一个排队的线程进来),继续自旋入队
        enq(node);
        return node;
    }
    
    private Node enq(final Node node) {
        // 空的for循环,自旋操作,直到成功把节点加入到同步队列
        for (;;) {
            // 同步队列尾巴
            Node t = tail;
            if (t == null) { // Must initialize
                 // 尾巴是空的,还没有初始化, 第一个排队的线程进来的话,队头队尾都是同一个节点
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                // 进入到这里,说明同步队列已经有线程在排队了
                // 当前节点前驱直接指向同步队里的尾节点
                node.prev = t;
                // CAS 修改尾节点为当前节点
                if (compareAndSetTail(t, node)) {
                    // t还是老的尾节点,修改新的尾节点后老的尾节点的下一个节点就是当前节点,建立他们的联系
                    t.next = node;
                    // 成功把当前节点加入到了同步队列,返回当前节点,退出自旋
                    return t;
                }
            }
        }
    }
    

    addWaiter()方法总结:首先会快速尝试一次在队列的尾部添加当前线程节点,如果失败的话(在这个时候,可能有新的线程也没有获得锁,并且跑在当前的前面加入到同步队列了),会调用enq逻辑进行自旋加入队尾,直到成功加入队列为止。

  5. 再次尝试从同步队列获得锁acquireQueued(node,arg)

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            // 自旋操作
            for (;;) {
                // 当前节点的上一个节点
                final Node p = node.predecessor(); //**①**
                // 如果前驱节点是头结点,然后去尝试获得锁,tryAcquire是我们自己实现的获得锁逻辑
                **if (p == head && tryAcquire(arg)) { //②**
                    // 当前线程成功获得锁,当前节点设置为头结点
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    // 返回false,表示没有被中断
                    return interrupted;
                }
                // 到这里说明p != head 或者 **tryAcquire** 返回了false,还是没获得锁,这时候就需要阻塞线程了
                // shouldParkAfterFailedAcquire 如果线程应阻塞,则返回true
                // parkAndCheckInterrupt  阻塞当前线程 
                if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            // 节点被取消了
            if (failed)
                cancelAcquire(node);
        }
    }
    
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
         // SIGNAL值为-1,表示pred节点的后继节点包含的线程需要运行,也就是unpark
        if (ws == Node.SIGNAL)
            return true;
        if (ws > 0) {
            // 大于0的值只有1, 1表示线程被取消
            // 进入到这里说明 pred 节点被取消了,需要从同步队列上删掉它
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            // 一般初始时为0,设置成-1
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        // 返回false,外面会一直自旋操作
        return false;
    }
    
    private final boolean parkAndCheckInterrupt() {
        // 调用底层的Unsafe一直阻塞线程
        LockSupport.park(this);
        // 被unpark唤醒之后,会继续回去自旋获得锁,并返回线程在此期间是否有被中断
        return Thread.interrupted();
    }
    

    lock方法总结:

    1. 尝试获得锁,方法tryAcquire:
      1. 成功获得锁,直接退出;没有获得锁,继续执行;
    2. 新建排队节点,并加入到同步队列,方法addWaiter:
      1. 队列不为空时,尝试一次快速直接把节点加入到队列尾巴上;如果队尾为空或者快速添加失败,继续执行下面逻辑
      2. 自旋,直到成功把新建的节点加入到同步队列;方法enq:
        为什么要自旋呢?是因为在调这个方法的时候,可能有其他想要获得锁线程没有获得锁,并且已经修改了尾节点;
    3. 再次尝试从同步队列获得锁,方法acquireQueued :
      1. 上面已经把当前线程的节点加入到队列中了,理论上排队的线程很多的话,它是马上获取不到锁的
      2. 所以它会自旋判断是否到了自己可以获取锁和CAS尝试获取锁,关键代码if (p == head && tryAcquire(arg))
        理论上当前线程进入到了队列排队,只要队列中还有更早的线程在它前面排队,当前线程都不会比更早的线程先获得锁,所以在这一块对于公平锁和非公平锁肯定都是公平的。
      3. 没有资格去获取锁或没有成功获得锁,就阻塞自己,方法parkAndCheckInterrupt
      4. 阻塞线程被唤醒,自旋成功获得锁,排队期间被中断的线程也会获得锁,之后退出自旋循环,返回线程的中断状态;
    4. 线程如果被中断了,中断当前线程,被中断的线程还是会继续执行后面逻辑
    • 以上过程涉及到的技术点有:CAS,自旋,队列入队,队列删除节点(被取消的节点),阻塞线程(LockSupport.park(this))

释放锁:public final boolean release(int arg)

unlock方法调用的是sync.release(1),而release是AQS 方法中的方法,表示将同步状态设置回初始状态,将锁释放。

public final boolean release(int arg) {
    // tryRelease 是我们自己的实现,就是把state字段设置成0,如果是可重入的,只能慢慢减到初始状态
	  if (tryRelease(arg)) {
        // 进入到这里说明CAS 设置成功,也就代表锁成功释放了,需要唤醒队列中的第一个排队的节点线程
	      Node h = head;
        // head 表示的是当前获得锁的节点
	      if (h != null && h.waitStatus != 0)
            // 唤醒头结点的下一个节点
	          unparkSuccessor(h);
	      return true;
	  }
	  return false;
}
private void unparkSuccessor(Node node) {
    // 这里的node 是当前持有锁的节点
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);     
    // 找到头结点的后继节点
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
         // 大于0的状态只有1,表示被取消了,如果被取消了,就继续取下一个节点唤醒
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        **LockSupport.unpark(s.thread);**
}

释放锁的逻辑比加锁逻辑要简单很多,主要逻辑有:

  1. 修改同步状态为初始值,方法tryRelease(arg):

    这里我们自己实现的会直接将同步状态设置为0,如果是支持可重入,就需要慢慢减了;

  2. 释放锁储层,唤醒头结点(当前获得锁的节点)的后继节点:unparkSuccessor(h);
    找到头结点的后继节点中第一个没有被取消的节点,并唤醒该节点所处线程

基于AQS实现自己的共享锁

设计一个同步器,在同一时刻,只允许最多两个线程能够并行访问,超过限制的其他线程将进入阻塞状态。

这个功能和 Semaphore 的功能很相似,这个学会了,以后看 Semaphore 的源码也就很简单了。

实现思路:

可以利用AQS 的API tryAcquireShared 实现获得共享锁,定义一个状态,允许的范围为【0,1,2】,状态为2代表新的线程进入的时候需要阻塞等待

public class MyAqsSharedLock{

    // 定义最大共享值
    private final int maxSharedValue = 2;
    // 同步器
    private final Sync sync;

    MyAqsSharedLock(){
        // 构造方法初始化同步器
        sync = new Sync();
    }
    // 基于AQS实现的同步器
    class Sync extends AbstractQueuedSynchronizer{
        @Override
        protected int tryAcquireShared(int arg){
            // 为什么要自旋呢?因为可能满足state的条件,但是CAS修改失败
            while(true){
                int state = getState();
                // 检查同步状态是否达到最大值
                if(state >= maxSharedValue){
                    // 返回-1 表示没有获得锁
                    return -1;
                }
                // CAS 修改同步状态
                if(compareAndSetState(state,state + arg)){
                    // 修改成功,表示获得了锁,大于等于0表示获得了锁
                    return getState();
                }
            }
        }

        @Override
        protected boolean tryReleaseShared(int arg){
            // 为什么要自旋呢?因为可能满足state的条件,但是CAS修改失败
            while(true){
                int state = getState();
                // CAS 修改同步状态,修改成功返回true,失败继续自旋 
                if(compareAndSetState(state,state - arg)){
                    return true;
                }
            }
        }
    }
    /** 加锁 */
    public void lock(){
        sync.acquireShared(1);
    }
    /** 解锁 */
    public void unLock(){
        sync.releaseShared(1);
    }
}

测试方法:

5个线程循环打印输出线程名和当前时间

public static void main(String[] args){
        MyAqsSharedLock lock = new MyAqsSharedLock();
        IntStream.range(0,5).forEach(i -> new Thread(new Runnable(){
            @SneakyThrows
            @Override
            public void run(){
                while(true){
                    lock.lock();
                    try{
                        System.out.println(Thread.currentThread().getName()+":执行。。。时间:"+ LocalDateTime.now());
                        TimeUnit.SECONDS.sleep(2);
                    }finally{
                        lock.unLock();
                        TimeUnit.SECONDS.sleep(1);
                    }

                }
            }
        },"T"+i).start());
    }

输出结果示例:

T0:执行。。。时间:2020-10-30T17:45:45.117
T1:执行。。。时间:2020-10-30T17:45:45.117
T3:执行。。。时间:2020-10-30T17:45:47.118
T2:执行。。。时间:2020-10-30T17:45:47.118
T1:执行。。。时间:2020-10-30T17:45:49.119
T4:执行。。。时间:2020-10-30T17:45:49.119

会发现几乎在同一时间最多只有2个线程在打印输出,满足我们的要求。

代码实现分析

获得共享锁:public void lock()

共享锁的 lock() 调用的是sync.acquireShared(1);acquireShared也在AQS里面,同样被final修饰作为基础框架逻辑部分,不允许被继承,源码展示:

public final void acquireShared(int arg) {
    // tryAcquireShared 是我们自己实现的逻辑,返回-1,表示没有获得锁
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
// 没有获得共享,再次尝试获得锁,和排他模式的acquireQueued方法非常相似
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) {
                // 前驱节点是头结点,说明轮到咱获得锁了
                // 继续调用我们自己的逻辑,CAS 获得锁
                int r = tryAcquireShared(arg);
                // 这里再次印证了,我们的tryAcquireShared返回值定义,负值是没有获得锁,>=0 表示成功获得锁
                if (r >= 0) {
                    // 设置新的头结点,如果后面的排队节点是共享模式的节点,直接唤醒它
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        // 中断当前线程
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            // 到这了,说明要么没有排队到当前线程,要么CAS获取锁失败,那就只有阻塞线程了
            // shouldParkAfterFailedAcquire 如果线程应阻塞,则返回true
            // parkAndCheckInterrupt  阻塞当前线程admol, 具体实现分析可以看上面lock的分析
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 线程被取消,摘掉节点
        if (failed)
            cancelAcquire(node);
    }
}

获取共享锁总结:

  1. 尝试获得锁,方法:tryAcquireShared
    1. 获得锁成功,直接返回;获得锁失败,继续执行下面逻辑;
  2. 再次尝试获得锁,方法:doAcquireShared
    1. 新建排队节点,并加入到同步队列,方法:addWaiter,逻辑和获得排它锁的一致
    2. 自旋(尝试获得锁,阻塞线程,等待被唤醒),直到成功获得锁

释放共享锁:public void unLock()

unlock方法调用的是sync.releaseShared(1),releaseShared也是AQS 方法中的方法,不允许被继承,表示将同步状态设置回初始状态,将锁释放。

public final boolean releaseShared(int arg) {
    // tryReleaseShared 我们自己实现的逻辑
    if (tryReleaseShared(arg)) {
        // 释放锁失败,继续释放,自旋直到释放成功
        doReleaseShared();
        return true;
    }
    return false;
}

private void doReleaseShared() {
    // 自旋
    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
                // 加入h节点是持有锁的节点,会唤醒它的下一个节点线程
                unparkSuccessor(h);
            } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        // 理论上唤醒一个就会退出
        if (h == head)                   // loop if head changed
            break;
    }
}

总结

  1. AQS是Java中可以实现同步器功能的一个基础框架,我们自己也可以基于AQS实现想要的同步功能
  2. AQS 中用Node节点维护了一个双向链表,用来保存排队获取锁的线程,已经用来唤醒线程
  3. AQS 中为了一个state的同步状态变量,可以基于这个变量实现很多功能
  4. 实现AQS的几个重要API,就可以实现一个简单同步器的功能,其他像自旋,排队,阻塞,唤醒,AQS都已经帮我们做好了
精 灵 王 wechat
👆🏼欢迎扫码关注微信公众号👆🏼
  • 本文作者: 精 灵 王
  • 本文链接: https://jinglingwang.cn/archives/abstractqueuedsynchronizer
  • 版权声明: 本博客所有文章除特别声明外,均采用CC BY-NC-SA 3.0 许可协议。转载请注明出处!
# 设计模式-行为型 # 设计模式-创建型 # 设计模式-结构型 # 设计 # 系统设计 # 设计模式之美 # 分布式 # Redis # 并发编程 # 个人成长 # 周志明的软件架构课 # 架构 # 单元测试 # LeetCode # 工具 # 位运算 # 读书笔记 # 操作系统 # MySQL # 异步编程 # 技术方案设计 # 集合 # 设计模式 # 三亚 # 游玩 # 转载 # Linux # 观察者模式 # 事件 # Spring # SpringCloud # 实战 # 实战,SpringCloud # 源码分析 # 线程池 # 同步 # 锁 # 线程 # 线程模型 # 动态代理 # 字节码 # 类加载 # 垃圾收集器 # 垃圾回收算法 # 对象创建 # 虚拟机内存 # 内存结构 # Java
Java 虚拟机常用垃圾收集器总结
源码分析:①ReentrantLock之公平锁和非公平锁
  • 文章目录
  • 站点概览
精 灵 王

精 灵 王

青春岁月,以此为伴

106 日志
14 分类
48 标签
RSS
Github E-mail
Creative Commons
Links
  • 添加友链说明
© 2023 精 灵 王
渝ICP备2020013371号
0%