只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

38、Lock(下)

内容来自王争 Java 编程之美

在上上节中我们讲到,JUC 提供的锁有三类:普通互斥锁(Lock 和 ReentrantLock)、读写锁(ReadWriteLock 和 ReentrantReadWriteLock)、StampedLock
上两节我们介绍了 JUC 中的 Lock,并且讲解了其底层实现原理,特别是 AQS,本节我们讲解读写锁和 StampedLock

1、读写锁的基本用法

读写锁 ReentrantReadWriteLock

为了提高多线程环境下代码执行的并发度,两个读操作是可以并发执行的,但是读操作和写操作不能并发执行,同理写操作和写操作也不能并发执行
为了满足这样特殊的加锁需求,JUC 提供了读写锁(ReadWriteLock 接口和 ReentrantReadWriteLock 类)

1.1、ReadWriteLock 接口

ReadWriteLock 接口的定义如下所示,跟 Lock 和 ReentrantLock 的关系类似,ReadWriteLock 也只有一个可重入的实现类 ReentrantReadWriteLock

public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}

ReadWriteLock 接口中只包含两个函数

  • readLock() 函数返回读锁,用来给读操作加锁
  • writeLock() 函数返回写锁,用来给写操作加锁

1.2、共享锁和排它锁

读锁是一种 "共享锁",读锁可以被多个线程同时获取,写锁是 "排它锁",写锁同时只能被一个线程获取
除此之外,读锁和写锁之间也是排它的,因此读写锁一般用于读多写少的场景,读写锁的使用示例代码如下所示,两个线程允许并发执行 get() 函数

public class Demo {
private List<String> list = new LinkedList<>();
private ReadWriteLock rwLock = new ReentrantReadWriteLock();
private Lock rLock = rwLock.readLock(); // 读锁
private Lock wLock = rwLock.writeLock(); // 写锁
public void add(int idx, String elem) {
wLock.lock(); // 加写锁
try {
list.add(idx, elem);
} finally {
wLock.unlock(); // 释放写锁
}
}
public String get(int idx) {
rLock.lock(); // 加读锁
try {
return list.get(idx);
} finally {
rLock.unlock(); // 释放读锁
}
}
}

ReentrantReadWriteLock 既支持公平锁又支持非公平锁,跟 ReentrantLock 的公平锁和非公平锁的构建方法一样,ReentrantReadWriteLock 默认为非公平锁
如果要成创建公平锁,我们只需要在创建 ReentrantReadWriteLock 对象时,将构造函数的参数设置为 true 即可,示例代码如下所示

ReadWriteLock rwLock = new ReentrantReadWriteLock(true); // 公平锁
ReadWriteLock rwLock = new ReentrantReadWriteLock(false); // 非公平锁
ReadWriteLock rwLock = new ReentrantReadWriteLock(); // 默认为非公平锁

2、锁升级和锁降级

前面讲到绝大部分锁都是可重入锁,读写锁也不例外
一个线程获取读锁之后,在读锁释放前,还可以再次获取读锁,同理,一个线程获取写锁之后,在写锁释放前,还可以再次获取写锁

  • 但是一个线程在获取读锁之后,在读锁释放前,是否还能再获取写锁?读锁不能升级为写锁
  • 还有一个线程在获取写锁之后,在写锁释放前,是否还能再获取读锁?写锁可以降级为读锁

2.1、锁升级

读写锁不支持锁升级:一个线程获取读锁之后,在读锁释放前,不可以再获取写锁
这是因为在一个线程获取读锁时,有可能同时还有其他线程也获取了读锁
如果将一个线程的读锁升级为写锁,那么就有可能违背了读写锁中读锁和写锁互斥的要求,示例代码如下所示
image

2.2、锁降级

读写锁支持锁降级:一个线程在获取写锁之后,在写锁释放前,可以再获取读锁,当写锁释放之后,线程持有的锁从写锁降级为读锁,示例代码如下所示
image

2.3、写 + 读

当临界区中既有写操作又有读操作时

  • 如果我们用写锁来给整个临界区加锁,那么代码的并行度就不高
  • 如果我们先加写锁,写操作完成之后释放写锁,再加读锁执行读操作,如下图所示
    这样做就有可能存在多线程安全问题,我们无法保证 "写操作和读操作的组合起来" 的原子性
    线程 A 写操作完成之后释放写锁,切换到线程 B 执行时,更新了共享变量的值,那么线程 A 读操作变无法读取之前写操作之后的值
  • 而使用下图中的锁降级,我们便既可以保证临界区线程安全,又能提到代码的并行度

image

3、读写锁的实现原理

3.1、ReentrantReadWriteLock

前面讲到读写锁跟上一节讲到的普通锁(JUC Lock)一样,既支持公平锁,也支持非公平锁,ReentrantReadWriteLock 的代码结构如下所示

public class ReentrantReadWriteLock implements ReadWriteLock {
private final ReadLock readerLock;
private final WriteLock writerLock;
final Sync sync; // 注意
public ReentrantReadWriteLock() { this(false); }
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this); // 注意
writerLock = new WriteLock(this); // 注意
}
public WriteLock writeLock() { return writerLock; }
public ReadLock readLock() { return readerLock; }
// AQS 的子类, NonfairSync 和 FairSync 的公共父类: Sync
abstract static class Sync extends AbstractQueuedSynchronizer {
abstract boolean readerShouldBlock(); // 用来区分公平锁和非公平锁
abstract boolean writerShouldBlock(); // 用来区分公平锁和非公平锁
// 以下为 AQS 模板方法的抽象方法的代码实现
protected final boolean tryAcquire(int acquires) { ... }
protected final boolean tryRelease(int releases) { ... }
protected final int tryAcquireShared(int unused) { ... }
protected final boolean tryReleaseShared(int unused) { ... }
// ... 省略其他方法 ...
final boolean tryWriteLock() { ... }
final boolean tryReadLock() { ... }
}
// 非公平锁
static final class NonfairSync extends Sync {
final boolean writerShouldBlock() { return false; }
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
// 公平锁
static final class FairSync extends Sync {
final boolean writerShouldBlock() { return hasQueuedPredecessors(); }
final boolean readerShouldBlock() { return hasQueuedPredecessors(); }
}
}

上述代码结构跟 ReentrantLock 的代码结构类似,NonfairSync 和 FairSync 具体化抽象的模板类 AQS,并且实现了其中的抽象方法
ReentrantReadWriteLock 使用 NonfairSync 或 FairSync 来编程实现读锁(ReadLock)和写锁(WriteLock)

3.2、ReadLock 和 WriteLock

ReadLock 和 WriteLock 均实现了 Lock 接口,使用相同的 AQS,实现了 Lock 接口中的所有加锁和解锁函数,ReadLock 和 WriteLock 的代码实现如下所示

// 写锁中的加锁和解锁方法使用 AQS 的 "独占模式" 下的几个模板方法来实现
public static class WriteLock implements Lock, java.io.Serializable {
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) { sync = lock.sync; } // 注意
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
public boolean tryLock( ) { return sync.tryWriteLock(); }
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
}
// 读锁中的加锁和解锁方法使用 AQS 的 "共享模式" 下的几个模板方法来实现
public static class ReadLock implements Lock {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) { sync = lock.sync; } // 注意
public void lock() { sync.acquireShared(1); }
public void unlock() { sync.releaseShared(1); }
public boolean tryLock() { return sync.tryReadLock(); }
public void lockInterruptibly() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
}

从上述代码我们可以发现,读锁和写锁共用一个 AQS,在上一节中我们讲到,对于 JUC Lock,我们使用 AQS 中 state 变量来表示加锁情况

  • 0 表示没有加锁
  • 1 表示已经加锁
  • 大于 1 的值表示重入次数

对于读写锁来说,我们不仅需要知道有没有加锁、重入次数,还需要知道加的是读锁还是写锁,但是 AQS 中只有一个表示加锁情况的 int 类型的 state 变量
为了让 state 变量表达更多的信息,我们用 state 变量中的低 16 位表示写锁的使用情况,高 16 位表示读锁的使用情况

对于低 16 位所表示的数:表示写锁的使用情况

  • 值为 0 表示没有加写锁
  • 值为 1 表示已加写锁
  • 值大于 1 表示写锁的重入次数

对于高 16 位所表示的数:表示读锁的使用情况

  • 值为 0 表示没有加读锁
  • 值为 1 表示已加读锁
  • 不过值大于 1 并不表示读锁的重入次数,而是表示读锁总共被获取了多少次(每个线程对读锁重入的次数相加)

那么读锁的重入次数在哪里记录呢?毕竟重入次数是有用信息,只有重入次数大于 0 时,才可以继续重入

  • 当多个线程同时持有读锁时,每个线程都可以对读锁重复加锁,也就就是说,重入次数是跟每个线程相关的数据
  • 我们可以使用 ThreadLocal 变量来存储
    对于 ThreadLocal,我们在后面的章节中信息讲解,在这里你就简单将它看做线程的一个属性或者局部变量即可

接下来,我们详细看下,读锁和写锁的实现原理

3.3、写锁的实现原理

WriteLock 写锁是排它锁,因此它的实现原理跟上一节讲到的 ReentrantLock 的实现原理类似
WriteLock 实现了 Lock 接口,因此它也支持各种不同的加锁方式,比如:可中断加锁、非阻塞加锁、可超时加锁
接下来我们重点讲解 WriteLock 中的 lock() 函数和 unlock() 函数的实现原理
对于 WriteLock 中的 tryLock()、带超时时间的 tryLock()、lockInterruptibly() 这三个加锁函数,你可以参考 ReentrantLock 中这三个函数的实现原理,以及结合源码,自行研究

我们先来看 WriteLock 中的 lock() 函数
lock() -> acquire() -> tryAcquire() -> addWaiter() -> acquireQeuued()

实现比较简单,直接调用了 AQS 中的 acquire() 模板方法

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

AQS 中的 acquire() 模板方法如下所示,在上一节中已经讲解
使用 tryAcquire() 竞争锁,如果竞争锁成功,则直接返回
如果竞争锁失败,则调用 addWaiter() 将线程放入等待队列的尾部,然后调用 acquireQueued() 阻塞线程等待被唤醒

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

上一节我们已经详细讲解了 addWaiter() 函数和 acquiredQueued() 函数,这里就不再赘述
我们重点看下 tryAcquire() 竞争锁的逻辑,它是 AQS 中的抽象方法,在 NonfairSync 和 FairSync 的公共父类 Sync 类中实现,代码如下所示

protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState(); // c 为 state 的值
int w = exclusiveCount(c); // 低 16 位的值, 也就是写锁的加锁情况
// 1、已经加读锁或写锁(state != 0)
if (c != 0) {
// 已加读锁(w == 0)或者当前加写锁的线程不是自己
if (w == 0 || current != getExclusiveOwnerThread())
return false; // 去排队
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
setState(c + acquires); // 更新写锁的重入次数
return true; // 获取到了锁
}
// 2、没有加锁(state == 0)
if (writerShouldBlock()) return false; // 去排队
if (!compareAndSetState(c, c + acquires)) return false; // 去排队
setExclusiveOwnerThread(current);
return true; // 获取到了锁
}

我们重点看下 writerShouldBlock() 这个函数,这个函数控制着锁是否为公平锁,在 state = 0,也就是没有加读锁和写锁的情况下

  • 如果 writerShouldBlock() 函数返回值为 true,那么线程不尝试竞争锁,而是直接去排队
  • 如果 writerShouldBlock() 函数返回值为 false,那么线程先尝试竞争锁,不行再去排队

对于非公平锁,writerShouldBlock() 总是返回 false
对于公平锁,如果等待队列中有线程,那么 writerShouldBlock() 返回 true,如果等待队列中没有线程,那么 writerShouldBlock() 返回 false

static final class NonfairSync extends Sync {
final boolean writerShouldBlock() { return false; }
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
static final class FairSync extends Sync {
final boolean writerShouldBlock() { return hasQueuedPredecessors(); }
final boolean readerShouldBlock() { return hasQueuedPredecessors(); }
}

在注释中我对代码逻辑做了详细的介绍,这里就不再赘述,我将 tryAcquire() 的执行逻辑梳理并绘制成了一张流程图,如下所示,你可以对比着流程和注释来理解 tryAcquire() 的代码逻辑
image

我们再来看 WriteLock 的 unlock() 函数
unlock() -> release() -> tryRelease()

代码实现也比较简单,直接调用了 AQS 的 release() 模板方法

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

AQS 中的 release() 模板方法如下所示,在上一节中已经讲解,使用 tryRelease() 释放锁,然后唤醒等待队列中位于队首的线程

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

我们重点看下在写锁中 tryRelease() 抽象方法的代码实现,代码如下所示
tryRelease() 的代码实现比较简单,在代码中我详细作了注释,你可以参看注释了解其代码逻辑,这里就不再赘述了

protected final boolean tryRelease(int releases) {
// tryRelease() 是 AQS 工作在独占模式下的函数, 只能用于排它锁, 也就是写锁
if (!isHeldExclusively()) throw new IllegalMonitorStateException();
// 更新 state 值, 写锁的重入次数 - releases, 对于锁来说, releases 总是等于 1
int nextc = getState() - releases;
// 只有更新之后的 state 值为 0 时, 才可以将写锁释放
boolean free = exclusiveCount(nextc) == 0;
if (free) setExclusiveOwnerThread(null);
setState(nextc);
return free;
}

3.4、读锁的实现原理

刚刚我们讲了读写锁中的写锁的实现原理,现在我们再来看下读锁的实现原理,写锁是排它锁,实现原理比较简单,而读锁是共享锁,实现原理相对来说更加复杂
跟 WriteLock 相同,ReadLock 也实现了 Lock 接口,同样支持各种不同的加锁方式(lock()、tryLock()、带超时时间的 tryLock()、lockInterruptibly())
接下来我们还是重点讲解 lock() 和 unlock() 这两个函数,对于其他加锁方式,你可以参看上一节的内容和源码,自行研究

我们先来看 ReadLock 中的 lock() 函数
lock() -> acquireShared() -> tryAcquireShared() -> doAcquireShared()

前面讲到,WriteLock 中的 lock() 函数调用了 AQS 中的 acquire() 模板方法,这里 ReadLock 的 lock() 函数调用的是 AQS 中的 acquireShared() 模板方法
acquire() 模板方法用于 "独占模式",acquireShared() 模板方法用于 "共享模式"

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

我们再来看下 AQS 中 acquireShared() 的代码实现,如下所示
对比 acquire() 的代码实现,acquireShared() 的代码实现同样也比较简单
调用 tryAquireShared() 去竞争锁,如果竞争成功,则直接返回,如果竞争失败,则调用 doAcquireShared() 去排队等待唤醒

public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0) // 竞争读锁
doAcquireShared(arg); // 竞争失败去排队
}

tryAcquiredShared() 为 AQS 的抽象方法,其在 AQS 的子类 Sync 中实现,具体代码如下所示,tryAcquireShared() 为了提高性能做了很多代码层面的优化,导致代码量很大
为了聚焦在基本实现原理上,在不改变基本实现原理的情况下,我对 tryAcquireShared() 中的代码做了简化,如果你想了解完成的代码,请自行查看源码

// 返回 -1 表示竞争锁失败, 返回 1 表示竞争锁成功
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
// 如果 state 没加锁或者是加了读锁, 那么线程会通过 CAS 操作改变 state 值来竞争锁
// 如果其他线程也在竞争读锁, 并且竞争成功, 那么此线程就会竞争失败
// 于是, 此线程就要自旋(for 循环)再次尝试去竞争读锁
for (; ; ) {
int c = getState();
if (exclusiveCount(c) != 0) { // 已加写锁
// 如果加写锁的线程不是此线程, 那么读锁也加不成, 直接返回 -1
// 否则, 读写锁支持锁降级, 加了写锁的线程可以再加读锁
if (getExclusiveOwnerThread() != current)
return -1;
}
// 理论上讲, 如果没有加写锁, 不管有没有加读锁, 都可以去竞争读锁了, 毕竟读锁是共享锁
// 但是, 存在两个特殊情况
// 1、对于公平锁来说, 如果等待队列不为空, 并且当前线程没有持有读锁(重入加锁), 那么, 线程就要去排队
// 2、对于非公平锁来说, 如果等待队列中队首线程(接下来要被唤醒的)是写线程
// 那么, 线程就要去排队, 这样做是为了避免请求写锁的线程迟迟获取不到写锁
if (sharedCount(c) != 0) { // 已加读锁
if (readerShouldBlock()) { // 上述 1、2 两种情况对应此函数的返回值为 true
if (readHolds.get().count == 0) // 此线程没有持有读锁, 不能重入
return -1;
}
}
// 以下是对上述代码中 readHolds 的解释
// readHolds 是 ThreadLocal 变量, 保存跟这个线程的读锁重入次数
// 如果重入次数为 0, 表示没有加读锁, 返回 -1 去排队
// 如果重入次数大于等于 0, 表示已加读锁, 可以继续重入, 不用排队
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// CAS 竞争读锁, 此时有可能还有其他线程在竞争读锁或写锁
if (compareAndSetState(c, c + SHARED_UNIT)) { // SHARED_UNIT = 1<<16
// 竞争读锁成功
readHolds.get().count++; // 更新线程重入次数
return 1; // 成功获取读锁
}
}
}

从上述代码我们可以发现,相对于 tryAcquire() 抽象方法,tryAcquireShared() 要复杂很多,在注释中我对代码逻辑做了详细的介绍,这里就不再赘述
我将 tryAcquireShared() 的执行逻辑梳理并绘制成了一张流程图,如下所示,你可以对比着流程和注释来理解 tryAcquireShared() 的代码逻辑
image

接下来,我们再来看下 doAcquireShared() 函数,此函数负责排队和等待唤醒,代码如下所示
doAcquireShared() 函数跟上一节讲到的 acquireQueued() 函数非常类似,区别主要有两点,如下注释所示

  • 区别一:等待读锁的线程标记为 SHARED
  • 区别二:线程获取到读锁之后,如果下一个节点对应的线程也在等待读锁,那么也会被唤醒
    下一个节点对应的线程获取到读锁之后,又会去唤醒下下个节点对应的线程(如果下下个节点对应的线程也在等待读锁的话)
    唤醒操作一直传播下去,直到遇到等待写锁的线程为止
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) {
// 区别二: 如果下一个节点对应的线程也在等待读锁, 那么顺道唤醒它
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted) selfInterrupt();
failed = false;
return;
}
}
if (parkAndCheckInterrupt()) interrupted = true;
}
} finally {
if (failed) cancelAcquire(node);
}
}

我们先来看 ReadLock 中的 unlock() 函数
unlock() -> releaseShared() -> tryReleaseShared() -> doReleaseShared()

代码实现也比较简单,直接调用了 AQS 的 releaseShared() 模板方法

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

AQS 中的 releaseShared() 模板方法如下所示,调用 tryReleaseShared() 释放读锁,只有当所有的读锁都释放之后,state 变为 0,才会调用 doReleaseShared() 唤醒等待队列中位于队首的线程

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

doReleaseShared() 的代码实现比较简单,我们重点看下 tryReleaseShared(),tryReleaseShared() 是 AQS 中的抽象方法,在 Sync 中实现,代码如下所示

// 当所有的读锁都释放之后(state 变成 0)才会返回 true
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
readHolds.get().count--; // 更新本线程对读锁的重入次数
// 因为有可能多个线程同时释放读锁, 同时 CAS 更新 state, 因此要自旋 + CAS
for (; ; ) {
int c = getState();
// c - SHARED_UNIT: 相当于将读锁的加锁次数 - 1
int nextc = c - SHARED_UNIT; // SHARED_UNIT = 1<<16
if (compareAndSetState(c, nextc))
return nextc == 0; // state 变为 0 才会返回 true, 才会去唤醒等待队列中的线程
}
}

4、读写锁的升级版

StampedLock 是对 ReadWriteLock 的进一步优化,在读锁和写锁的基础之上,又提供了 "乐观读锁",实际上乐观读锁并没有加任何锁
在读多写少的应用场景中

  • 大部分读操作都不会被写操作干扰,因此我们甚至可以将读锁也省略掉
  • 只有验证读操作真正有被写操作干扰的情况下,线程再加读锁重复执行读操作

我们举一个例子解释一下,代码如下所示

public class Demo {
private List<String> list = new LinkedList<>();
private StampedLock slock = new StampedLock(); // 提供了 "乐观读锁"
public void add(int idx, String elem) {
long stamp = slock.writeLock(); // 加写锁
try {
list.add(idx, elem);
} finally {
slock.unlockWrite(stamp); // 释放写锁
}
}
public String get(int idx) {
long stamp = slock.tryOptimisticRead(); // 加乐观读锁
String res = list.get(idx);
// 没写操作干扰, validate 验证
if (slock.validate(stamp)) {
return res;
}
// 有写操作干扰, 重新使用读锁, 重新执行读操作
stamp = slock.readLock(); // 加读锁
try {
return list.get(idx);
} finally {
slock.unlockRead(stamp); // 释放读锁
}
}
}

在上述代码中,tryOptimisticRead() 获取的是乐观读锁,返回一个时间戳 stamp,因为乐观读锁并非真正加锁,所以乐观读锁并不需要解锁
在执行完读操作之后,我们只需要验证 stamp 是否有被更改,如果有被更改,说明执行读操作期间,writeLock() 函数有被执行
也就说明有对共享资源的写操作发生(也就是执行了add() 函数),此时之前得到的结果需要作废,使用读锁来重新获取数据

5、课后思考题

如果一个线程在获取读锁之后,在读锁释放前,再次请求写锁,将会发生什么事情?
答案:程序会出现死锁,验证代码如下所示

public class LockDemo {
private static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Lock readLock = readWriteLock.readLock();
private static Lock writeLock = readWriteLock.writeLock();
public static void main(String[] args) {
readLock.lock(); // 获得读锁
writeLock.lock(); // 获得写锁
System.out.println("read locked -> write locked.");
writeLock.unlock(); // 释放写锁
readLock.unlock(); // 释放读锁
}
}
posted @   lidongdongdong~  阅读(39)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开