ReentrantReadWriteLock 相关整理

1. ReentrantReadWriteLock 概述

  • ReentrantReadWriteLock 是 Lock 的另一种实现方式,ReentrantLock 是一个排他锁,同一时间只允许一个线程访问,而 ReentrantReadWriteLock 允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问
    • 相对于排他锁,提高了并发性。
    • 在实际应用中,大部分情况下对共享数据(如缓存)的访问都是读操作远多于写操作,这时 ReentrantReadWriteLock 能够提供比排他锁更好的并发性和吞吐量。
  • ReentrantReadWriteLock 是一个可重入读写锁,内部提供了 读锁写锁 的单独实现。
  • ReentrantReadWriteLock 有以下三个重要的特性。
    • 公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
    • 重进入:读锁和写锁都支持线程重进入。
    • 锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。

公平选择性

  • 支持两种策略获取锁,非公平模式(默认)和公平模式。
    • 非公平模式不按照线程请求锁的顺序分配锁,新加入的线程总是和等待队列中的线程竞争锁,竞争失败才会进入等待队列。
      • 非公平锁的吞吐量高于公平锁。
    • 公平模式则按照 FIFO 的顺序获取锁,主要的竞争在于加入队列的顺序。
      • 公平模式下不存在写线程一直等待的问题。
public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
}
  • 公平模式和非公平模式分别由内部类 FairSync 和 NonfairSync 实现,这两个类继承自另一个内部类 Sync,Sync 继承自 AbstractQueuedSynchronizer(AQS),基本同 ReentrantLock 的内部实现一致。

重入性

  • AQS 使用 state 保存锁的状态,state 的数量(大于 1)则代表锁的重入数。
    • state 是 int 类型(4 个字节),每个字节 8 位,总共 32 位。在读写锁中,则用 state 的 高 16 位 用作读锁,低 16 位 用作写锁。
    • 读锁和写锁都最多只能被持有 65535 次。
    • 单个读线程的重入次数则用 ThreadLocalHoldCounter 来保存。
 
读写锁的状态低 16 位为写锁,高 16 位为读锁

锁降级

  • ReentrantReadWriteLock 共享锁实现中,支持当前持有写锁的线程可继续申请读锁,实现锁降级。
    • 所谓锁降级,即当前线程持有写锁,再申请读锁,然后释放写锁,这个时候则实现了锁降级。

condition 支持

  • 写锁提供了一个 Condition 实现,与 ReentrantLock.newCondition() 提供的 Condition 行为相同。
  • 读锁不支持 Condition,readLock().newCondition() 会抛出 UnsupportedOperationException 异常。

锁中断

  • 无论读锁或写锁均支持在获取锁期间被中断。

1.1 ReadWriteLock 接口

  • ReadWriteLock 是一个接口,里面定义了两个方法
public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading.
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing.
     */
    Lock writeLock();
}
 
方法说明
readLock()用来获取读锁。
writeLock()用来获取写锁。
  • 将对临界资源的读写操作分成两个锁来分配给线程,从而使得多个线程可以同时进行读操作。

1.2 ReentrantReadWriteLock 实现原理

  • ReentrantReadWriteLock 实现了 ReadWriteLock 接口。
  • ReentrantReadWriteLock 使用两把锁来解决问题,一个读锁(readLock),一个写锁(writeLock)。
    • 读锁是可以被多线程共享的,写锁是单线程独占的。也就是说写锁的并发限制比读锁高。
    • 读写锁的机制:" 读-读 " 不互斥," 读-写 " 互斥," 写-写 " 互斥。
    • 线程进入读锁的前提条件
      • 没有其他线程的写锁。
      • 没有写请求,或者有写请求但调用线程和持有锁的线程是同一个线程。
    • 进入写锁的前提条件
      • 没有其他线程的读锁。
      • 没有其他线程的写锁。
    • 锁降级:从写锁变成读锁。
    • 锁升级:从读锁变成写锁。
ReadWriteLock rtLock = new ReentrantReadWriteLock();
rtLock.readLock().lock();
System.out.println("get readLock.");
rtLock.writeLock().lock();
System.out.println("blocking");
  • 以上代码会产生死锁,同一个线程中,在没有释放读锁的情况下,去申请写锁,这属于 锁升级ReentrantReadWriteLock不支持
ReadWriteLock rtLock = new ReentrantReadWriteLock();
rtLock.writeLock().lock();
System.out.println("writeLock");
rtLock.readLock().lock();
System.out.println("get read lock");=
  • ReentrantReadWriteLock 支持锁降级,以上代码不会造成死锁,但没有正确的释放锁。从写锁降级成读锁,并不会自动释放当前线程获取的写锁,仍然需要显示的释放,否则别的线程永远也获取不到写锁。
1.2.1 写锁的获取
  • 在同一时刻写锁是不能被多个线程所获取,写锁是独占式锁,而实现写锁的同步语义是通过重写 AQS 中的 tryAcquire() 方法实现。
//获取写锁
public void lock() {
    sync.acquire(1);
}

//AQS 实现的独占式获取同步状态方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

//自定义重写的 tryAcquire 方法
protected final boolean tryAcquire(int acquires) {
    //当前线程
    Thread current = Thread.currentThread();
    //获取状态
    int c = getState();
    //写线程数量(即获取独占锁的重入数)
    int w = exclusiveCount(c);
    
    //当前同步状态 state != 0,说明已经有其他线程获取了读锁或写锁
    if (c != 0) {
        // 当前 state 不为 0,此时:如果写锁状态为 0 说明读锁此时被占用返回 false。
        // 如果写锁状态不为 0 且写锁没有被当前线程持有返回 false。
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        
        //判断同一线程获取写锁是否超过最大次数(65535),支持可重入。
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        //更新状态
        //此时当前线程已持有写锁,现在是重入,所以只需要修改锁的数量即可。
        setState(c + acquires);
        return true;
    }
    
    //到这里说明此时 c=0,读锁和写锁都没有被获取。
    //writerShouldBlock 表示是否阻塞
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    
    //设置锁为当前线程所有
    setExclusiveOwnerThread(current);
    return true;
}
  • 其中 exclusiveCount() 方法表示占有写锁的线程数量。
    • EXCLUSIVE _MASK 为 1 左移 16 位然后减 1(左移 16 位是 65536,减 1 后为 65535),即为二进制(1111 1111 1111 1111)十六进制(0x0000FFFF)。
    • exclusiveCount 方法是将同步状态 state 与 0x0000FFFF 相与,即取同步状态的低 16 位。(二进制进行与计算,都为 1 则为 1,否则为 0)
static final int SHARED_SHIFT   = 16;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
1 & 1111111111111111 = 1
  • 获取写锁的步骤。
    1. 判断同步状态 state 是否为 0。如果 state!=0,说明已经有其他线程获取了读锁或写锁,执行步骤 2,否则执行步骤 5。
    2. 判断同步状态 state 的低 16 位(w)是否为 0。如果 w=0,说明其他线程获取了读锁,返回 false,如果 w!=0 说明其他线程获取了写锁,执行步骤 3。
    3. 判断获取了写锁是否是当前线程,若不是返回 false,否则执行步骤 4。
    4. 判断当前线程获取写锁是否超过最大次数(65535),若超过,抛异常,反之更新同步状态(此时当前线程已获取写锁,更新是线程安全的),返回 true。
    5. 若此时读锁或写锁都没有被获取,判断是否需要阻塞(公平和非公平方式实现不同),如果不需要阻塞,则 CAS 更新同步状态,若 CAS 成功则返回 true,否则返回 false。如果需要阻塞则返回 false。
 
获取写锁的步骤
  • writerShouldBlock() 表示当前线程是否应该被阻塞。
    • NonfairSync 和 FairSync 中有不同是实现。
//FairSync 中需要判断是否有前驱节点,如果有则返回 false,否则返回 true。遵循 FIFO。
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}

//NonfairSync 中直接返回 false,可插队。
final boolean writerShouldBlock() {
    return false; // writers can always barge
}
  • 主要逻辑是,当读锁已经被读线程获取或者写锁已经被其他写线程获取,则写锁获取失败;否则,获取成功并支持重入,增加写状态
1.2.2 写锁的释放
//自定义重写的 tryRelease 方法
protected final boolean tryRelease(int releases) {
    //若锁的持有者不是当前线程,抛出异常
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    //写锁的新线程数
    int nextc = getState() - releases;
    //如果独占模式重入数为 0 了,说明独占模式被释放
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        //若写锁的新线程数为 0,则将锁的持有者设置为null
        setExclusiveOwnerThread(null);
    //设置写锁的新线程数
    //不管独占模式是否被释放,更新独占重入数
    setState(nextc);
    return free;
}
  • 写锁的释放步骤。
    1. 查看当前线程是否为写锁的持有者,如果不是抛出异常。
    2. 检查释放后写锁的线程数是否为 0,如果为 0 则表示写锁空闲,释放锁资源将锁的持有线程设置为 null,否则释放仅仅只是一次重入锁而已,并不能将写锁的线程清空。
 
释放写锁的步骤
1.2.3 读锁的获取
  • 读锁是一个可重入的共享锁,采用 AQS 提供的共享式获取同步状态的策略。
    • 实现共享式同步组件的同步语义需要通过重写 AQS 的 tryAcquireShared 方法和 tryReleaseShared 方法。
public void lock() {
    sync.acquireShared(1);
}

//使用 AQS 提供的共享式获取同步状态的方法
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

//自定义重写的 tryAcquireShared 方法,参数是 unused,因为读锁的重入计数是内部维护的。
protected final int tryAcquireShared(int unused) {
    // 获取当前线程
    Thread current = Thread.currentThread();
    // 获取状态
    int c = getState();
    
    //如果写锁线程数 != 0 ,且独占锁不是当前线程则返回失败,因为存在锁降级。
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    // 读锁数量
    int r = sharedCount(c);
    /*
     * readerShouldBlock():读锁是否需要等待(公平锁原则)
     * r < MAX_COUNT:持有线程小于最大数(65535)
     * compareAndSetState(c, c + SHARED_UNIT):设置读取锁状态
     */
     // 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        //r == 0,表示第一个读锁线程,第一个读锁 firstRead 是不会加入到 readHolds 中。
        if (r == 0) { // 读锁数量为 0
            // 设置第一个读线程
            firstReader = current;
            // 读线程占用的资源数为 1
            firstReaderHoldCount = 1;
        } else if (firstReader == current) { // 当前线程为第一个读线程,表示第一个读锁线程重入
            // 占用资源数加 1
            firstReaderHoldCount++;
        } else { // 读锁数量不为 0 并且不为当前线程
            // 获取计数器
            HoldCounter rh = cachedHoldCounter;
            // 计数器为空或者计数器的 tid 不为当前正在运行的线程的 tid。
            if (rh == null || rh.tid != getThreadId(current)) 
                // 获取当前线程对应的计数器
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0) // 计数为 0
                //加入到 readHolds 中
                readHolds.set(rh);
            //计数+1
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}
  • 其中 sharedCount() 方法表示占有读锁的线程数量。
    • 直接将 state 右移 16 位,就可以得到读锁的线程数量,因为 state 的高 16 位表示读锁。
static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
  • 读锁的获取步骤。
    1. 通过同步状态低 16 位判断,如果存在写锁且当前线程不是获取写锁的线程,返回 -1,获取读锁失败,否则执行步骤 2。
    2. 通过 readerShouldBlock() 判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,否则执行步骤 3。
    3. 第一次获取读锁失败,通过 fullTryAcquireShared() 再次尝试获取读锁。
 
读锁的获取步骤
  • readerShouldBlock 方法用来判断当前线程是否应该被阻塞,NonfairSync 和 FairSync 中有不同是实现。
//FairSync 中需要判断是否有前驱节点,如果有则返回 false,否则返回 true。遵循 FIFO。
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}
//当 head 节点不为 null 且 head 节点的下一个节点 s 不为 null 且 s 是独占模式(写线程)且 s 的线程不为 null 时,返回 true。
//目的是不应该让写锁始终等待。作为一个启发式方法用于避免可能的写线程饥饿,这只是一种概率性的作用,因为如果有一个等待的写线程在其他尚未从队列中出队的读线程后面等待,那么新的读线程将不会被阻塞。
final boolean apparentlyFirstQueuedIsExclusive() {
    Node h, s;
    return (h = head) != null &&
        (s = h.next)  != null &&
        !s.isShared()         &&
        s.thread != null;
}
  • 在 firstReaderHoldCount 中或 readHolds(ThreadLocal 类型)的本线程副本中会记录当前线程重入数。
    • readHolds 是为了实现 JDK 1.6 中加入的 getReadHoldCount() 方法的,这个方法能获取当前线程重入共享锁的次数(state 中记录的是多个线程的总重入次数)。
    • 如果当前只有一个线程的话,还不需要动用 ThreadLocal,直接使用 firstReaderHoldCount 这个成员变量保存重入数,当有第二个线程的时候,就要动用 ThreadLocal 变量 readHolds,使每个线程拥有自己的副本,用来保存自己的重入数。
  • 如果 CAS 失败或者已经获取读锁的线程再次获取读锁,是依靠 fullTryAcquireShared() 方法实现。
final int fullTryAcquireShared(Thread current) {

    HoldCounter rh = null;
    for (;;) { // 无限循环
        // 获取状态
        int c = getState();
        if (exclusiveCount(c) != 0) { // 写线程数量不为0
            if (getExclusiveOwnerThread() != current) // 不为当前线程
                return -1;
        } else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
            // Make sure we're not acquiring read lock reentrantly
            if (firstReader == current) { // 当前线程为第一个读线程
                // assert firstReaderHoldCount > 0;
            } else { // 当前线程不为第一个读线程
                if (rh == null) { // 计数器不为空
                    // 
                    rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
                        rh = readHolds.get();
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                if (rh.count == 0)
                    return -1;
            }
        }
        if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
            throw new Error("Maximum lock count exceeded");
        if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
            if (sharedCount(c) == 0) { // 读线程数量为0
                // 设置第一个读线程
                firstReader = current;
                // 
                firstReaderHoldCount = 1;
            } else if (firstReader == current) {
                firstReaderHoldCount++;
            } else {
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                rh.count++;
                cachedHoldCounter = rh; // cache for release
            }
            return 1;
        }
    }
}
1.2.4 读锁的释放
public  void unlock() {
    sync.releaseShared(1);
}

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

protected final boolean tryReleaseShared(int unused) {
    // 获取当前线程
    Thread current = Thread.currentThread();
    if (firstReader == current) { // 当前线程为第一个读线程
        // assert firstReaderHoldCount > 0;
        if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
            firstReader = null;
        else // 减少占用的资源
            firstReaderHoldCount--;
    } else { // 当前线程不为第一个读线程
        // 获取缓存的计数器
        HoldCounter rh = cachedHoldCounter;
        if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
            // 获取当前线程对应的计数器
            rh = readHolds.get();
        // 获取计数
        int count = rh.count;
        if (count <= 1) { // 计数小于等于1
            // 移除
            readHolds.remove();
            if (count <= 0) // 计数小于等于0,抛出异常
                throw unmatchedUnlockException();
        }
        // 减少计数
        --rh.count;
    }
    for (;;) { // 无限循环
        // 获取状态
        int c = getState();
        // 获取状态
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc)) // 比较并进行设置
            // Releasing the read lock has no effect on readers,
            // but it may allow waiting writers to proceed if
            // both read and write locks are now free.
            return nextc == 0;
    }
}
  • 读锁释放的步骤。
    1. 判断当前线程是否为第一个读线程 firstReader,若是,则判断第一个读线程占有的资源数 firstReaderHoldCount 是否为 1,若是,则设置第一个读线程 firstReader 为空,否则,将第一个读线程占有的资源数 firstReaderHoldCount 减 1。
    2. 若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者 tid 不等于当前线程的 tid 值,则获取当前线程的计数器,如果计数器的计数 count 小于等于 1,则移除当前线程对应的计数器,如果计数器的计数 count 小于等于 0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态 state。
 
读锁释放的步骤
  • 在读锁的获取、释放过程中,总是会有一个对象存在着,同时该对象在获取线程获取读锁是 + 1,释放读锁时 - 1,该对象是 HoldCounter。
    • 只有当线程获取共享锁后才能对共享锁进行释放、重入操作。
    • HoldCounter 的作用就是当前线程持有共享锁的数量,这个数量必须要与线程绑定在一起,否则操作其他线程锁就会抛出异常。
  • firstRead 和 firstReaderHoldCount 同时存在,这是为了效率,firstReader 是不会放入到 readHolds 中的,如果读锁仅有一个的情况下就会避免查找 readHolds。
private transient Thread firstReader = null;
private transient int firstReaderHoldCount;

static final class HoldCounter {
    int count = 0;
    final long tid = Thread.currentThread().getId();
}

static final class ThreadLocalHoldCounter
    extends ThreadLocal<HoldCounter> {
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}
  • HoldCounter 是绑定线程上的一个计数器。
  • ThradLocalHoldCounter 是线程绑定的 ThreadLocal。
    • ThreadLocal 将 HoldCounter 绑定到当前线程上,同时 HoldCounter 也持有线程 id,释放锁的时候能够知道 ReadWriteLock 里面缓存的上一个读取线程(cachedHoldCounter)是否是当前线程。这样做可以减少 ThreadLocal.get() 的次数。
    • HoldCounter 绑定线程 id 而不绑定线程对象的原因是避免 HoldCounter 和 ThreadLocal 互相绑定从而造成 GC 难以释放。
1.2.5 锁降级
  • 读写锁支持锁降级,遵循按照获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁,不支持锁升级。
class CachedData {
   Object data;
     //保证状态可见性
   volatile boolean cacheValid;
   ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

   void processCachedData() {
     //读锁获取
     rwl.readLock().lock();
     if (!cacheValid) {
        // 在获取写锁前必须释放读锁
        rwl.readLock().unlock();
        rwl.writeLock().lock();
        //再次检查其他线程是否已经抢到  
        if (!cacheValid) {
           //获取数据
          data = ...
          cacheValid = true;
        }
        //获取读锁。在写锁持有期间获取读锁
        //此处获取读锁,是为了防止,当释放写锁后,又有一个线程T获取锁,对数据进行改变,而当前线程下面对改变的数据无法感知。
        //如果获取了读锁,则线程T则被阻塞,直到当前线程释放了读锁,那个T线程才有可能获取写锁。
        rwl.readLock().lock();
        //释放写锁,保持读锁
        rwl.writeLock().unlock();
     }
     //锁降级完成
     try {
               //使用数据的流程
               use(data);
     } finally{
                //释放读锁
                readLock.unlock();
     }
   }
}

1.3 总结

  • 读锁的重入是允许多个申请读操作的线程的,而写锁同时只允许单个线程占有,该线程的写操作可以重入。
  • 如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。
  • 对于同时占有读锁和写锁的线程,如果完全释放了写锁,那么它就完全转换成了读锁,以后的写操作无法重入,在写锁未完全释放时写操作是可以重入的。
  • 公平模式下无论读锁还是写锁的申请都必须按照 AQS 锁等待队列先进先出的顺序。
  • 非公平模式下读操作插队的条件是锁等待队列 head 节点后的下一个节点是 SHARED 型节点,写锁则无条件插队。
  • 读锁不允许 newConditon 获取 Condition 接口,而写锁的 newCondition 接口实现方法同 ReentrantLock。
  • 在使用某些种类的 Collection 时,可以使用 ReentrantReadWriteLock 来提高并发性。
    • 通常,在预期 Collection 很大,读取线程访问次数多于写入线程,并且 entail 操作的开销高于同步开销时。
class RWDictionary {
    private final Map<String, Data> m = new TreeMap<String, Data>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();

    public Data get(String key) {
        r.lock();
        try { return m.get(key); }
        finally { r.unlock(); }
    }
    public String[] allKeys() {
        r.lock();
        try { return m.keySet().toArray(); }
        finally { r.unlock(); }
    }
    public Data put(String key, Data value) {
        w.lock();
        try { return m.put(key, value); }
        finally { w.unlock(); }
    }
    public void clear() {
        w.lock();
        try { m.clear(); }
        finally { w.unlock(); }
    }
 }

 

 

参考资料

https://www.cnblogs.com/xiaoxi/p/9140541.html




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值