[Java] 乐观锁?公平锁?可重入锁?盘点Java中锁相关的概念

前言


Java多线程/并发编程里,无法回避的一个问题是多个线程对共享资源的竞争会导致出现数据一致性问题。而锁就是解决这个问题的,然而锁相关的概念很多,如标题里提到的乐观锁、公平锁等之外还有独享锁、共享锁、自旋锁、分段锁、偏向锁等大量概念,通常看得人眼花缭乱,笔者将利用本文尽可能地盘点梳理这些概念。

锁概念


公平锁与非公平锁


根据线程竞争锁时是否需要排队,是否是先来后到(FIFO)的顺序,锁被分为了 公平锁非公平锁

公平锁

公平锁,Fair Lock1,对锁的获取需要排队,先进先出(FIFO),多个线程竞争锁时按请求顺序来,通常通过等待队列来实实现,各个申请锁的线程信息会被记录在等待队列里。Java里ReentrantLock、ReentrantReadWriteLock、Semaphore等也都通过其类构造器参数,提供了公平锁版本。

非公平锁

非公平锁,Unfair lock1,与公平锁相对的一个概念,锁的获取不需要排队,多个线程竞争获取锁时各凭本事,最先请求锁的线程不一定能够宝珠最先获取到锁。像Java内置锁synchronized就是典型的非公平锁,Java里ReentrantLock、ReentrantReadWriteLock、Semaphore等默认是非公平的。

可重入锁与不可重入锁


根据线程获取锁后,该线程再次获取该锁时,是否会被自己锁死。锁被分为了可重入锁和不可重入锁。递归调用同步方法时,就是很经典的锁重入场景。

可重入锁

可重入锁,ReentrantLock2一个线程在成功竞争到锁之后能够多次对同一个锁加锁而不会被自己锁死的锁被称为可重入锁。如果锁不支持可重入,那么一个线程在获取一个锁之后进到被同一个锁管理的代码块时,会被自己锁死。Java的内置锁和JUC包里的ReentrantLock等都是可重入锁。

Recall that a thread cannot acquire a lock owned by another thread. But a thread can acquire a lock that it already owns. Allowing a thread to acquire the same lock more than once enables reentrant synchronization. This describes a situation where synchronized code, directly or indirectly, invokes a method that also contains synchronized code, and both sets of code use the same lock. Without reentrant synchronization, synchronized code would have to take many additional precautions to avoid having a thread cause itself to block3.

通常认为可重入锁的实现是通过计数器(Counter)来实现的,如ReentrantLock4内部可重入的实现就是通过每次进入对内部AQS5的计数加1,每次释放时计数减一直到计数归0则锁回归到自由的可被任意线程获取的状态。

不可重入锁

不可重入锁,NonReEntrant lock,仅作为可重入锁相对的概念。实际意义并不大,如果硬要说不可重入锁,信号量(Semaphore)6勉强算半个。虽然和ReentrantLock一样内部基于AQS的子类实现来管理锁状态,也拥有AQS的计数器(state)。 ReentrantLock是从0开始计数请求锁加1释放锁减1,而Semaphore是预先设置一个通行证数量(permits值),请求锁减1释放锁加1,到0则不再允许继续请求。

如果Semaphore预先设置permits为1,同一个线程请求Semaphore两次就会把自己锁死。

排它锁与共享锁


根据多个线程是否能共享同一把锁,锁被分为了排它锁和共享锁。这些个概念在关系型数据库和文件系统里被广泛提及。

排它锁 / 独享锁 / X锁

排它锁,也称独享锁、X锁,英语是Exclusive Lock7 、X-Lock、X锁。多个线程竞争排它锁时,只有一个线程能持有排它锁。排他锁很好理解,也很常见,像最基本的Java内置锁、ReentrantLock、读写锁里的写锁、数据库里的表锁、行锁等都是排他的,排它锁往往会锁住整个共享资源的访问权限(读写),这时如果其他线程只是想读取资源的数据也会被阻塞住,这不利于提高应用的性能,为了提高性能,读写锁被引入,通过排它锁限定在同一时间写权限仅能被一个线程拥有,共享锁共享读权限给多个线程的方式去提高性能,读写锁将在后面章节介绍。

共享锁 / S锁

共享锁,Shared Lock7 ,略称S-Lock,也被称为S锁。与排它锁相对的,能被多个线程能同时持有的锁被称为共享锁,比较典型的有信号量(Semaphore)读写锁里的读锁

互斥锁


互斥锁,Mutual Exclusion Lock,简称Mutex Lock。互斥锁和上面提到的排它锁(Exclusive Lock)类似,多个线程竞争锁时,仅有一个线程能成功获取资源访问权(锁),其他线程都会陷入等待状态。
Java里一般不提mutex这个概念,mutex通常是在OS层面的,像C++语言的内置库中则是有Mutex的,OS层面的mutex使用时需要和OS内核交流(切换用户态到内核态),被认为是相当低效的。JVM中重量级锁就使用了OS互斥锁。

读写锁


读写锁,ReadWriteLock8,Java里的主要实现是ReentrantReadWriteLock

读写锁不是一个锁,它是一对锁,读锁和写锁的组合,读锁主要负责读操作,而写锁主要负责写操作。

读写锁主要有以下几个性质9

  1. 读锁是共享锁,写锁是排他锁。即读取时支持多线程读取、修改时仅支持单线程修改。
  2. 请求读锁时、必须保证资源是无写锁的状态(无锁或多个读锁),否则请求读锁失败。
  3. 请求写锁时、必须保证资源是无锁的状态(可重入读写锁实现下写锁持有者是当前线程也行),否则请求失败。
  4. 请求读锁时,如果有先请求写锁的线程正在等待,必须保证先让请求写锁的线程先获得写锁。(ReentrantReadWriteLock无论公平版本或不公平版本都能保证,先等待的写锁都能优先于读锁获得锁。这是为了避免Writer线程无限饥饿,无法获取到锁。)

在并发条件下,读写锁与互斥锁对比,读写锁总是能比互斥锁有更优的吞吐量。不难理解,读写锁在写操作越少时,对比互斥锁的性能优势越明显。

乐观锁与悲观锁


乐观锁

乐观锁,Optimistic lock10,与悲观锁是一对,乐观锁总是乐观地认为别人不会修改资源的数据,因此在读资源数据时不会上锁,多个线程都能同时读取数据内容,仅当某线程需要更新数据时才通过检查 资源版本号的方式(version number mechanism) 来判断数据是否被其他线程修改过,如果被修改过就重新获取最新数据再重复一次计算和更新操作知道更新成功。

乐观锁可由version number mechanism和CAS算法来实现,在资源数据多被读取而少被修改的场景下能显著提高应用的吞吐量(throughput)

悲观锁

悲观锁,Pessimistic lock10,与乐观锁是一对,相比乐观锁,悲观锁如其名,总是悲观地认为会有其他人修改资源的数据,所以比较严格,一时间还能有一个线程能对资源进行操作,其他想要线程想要操作资源(包括读操作)都只能等到成功获取到锁之后才能。常见的应用场景有如传统关系型数据库里的行锁、表锁。

分段锁


分段锁,Segment locks11,如其名,对于资源进行锁粒度方面的调整,由锁住整个资源变为使用多个锁分别复制资源的某一部分,这样并发操作时就能同时操作资源的不同分区提高应用性能。当然分段锁只是一种锁的设计思想,并没有一个锁叫做分段锁,在Java里与分段锁一起听到的通常是ConcurrentHashMap容器。

自旋锁


自旋锁,Spinlock12,自旋锁是一种获取锁的机制,这种机制使得线程在获取锁失败时不会令线程进入阻塞状态,而是会用一个无限循环去不停重试直到成功获取到锁,这种设计会让当事线程时刻保持“活跃”状态,自旋锁也因其循环(Loop)重试机制而得名旋转(Spin)锁,最终被翻译为自旋锁,

自旋锁的优势在于不必浪费时间在 线程的上下文切换(Content Switch) 上,非自旋锁则是在线程竞争锁失败后会直接休眠,让出CPU资源,被唤醒后再度尝试竞争锁资源,失败则会再度休眠,线程频繁的挂起和唤醒会有很高的线程上下文切换开销。

自旋锁因其非常高的CPU使用,如果持有锁的线程长期霸占锁资源,会导致其他等待中的自旋锁每次都将被分配到的CPU时间片全部用于空跑循环,因此自旋锁通常被使用在资源占用时间短的场景,反之非自旋锁通常被使用在资源占用时间长的场景,这时线程可以休眠很久直到被唤醒。

自旋锁有好几个实现:

  1. while + CAS实现
  2. Ticket Spinlock
  3. MCS SpinLock

第一个在Java的AtomicXXX类里就有很多应用,而后两个如果有兴趣的读者可以看这里延伸阅读1延伸阅读2

锁膨胀机制与偏向锁、轻量级锁、重量级锁


锁膨胀机制

锁膨胀机制,也被称为锁升级机制,Lock expansion / Lock upgrade / Lock inflate。是JVM里为了优化内置锁性能而引入的一种机制,这种机制能使JVM在检测到内置锁竞争强度改变时改用重量级更高的锁实现。
这些锁实现分别被称为偏向锁、轻量级锁和重量级锁
锁的升级顺序是 偏向锁 → 轻量级锁 → 重量级锁,JVM中偏向锁可以直接膨胀为重量级锁,

Java1.6之前,Java内置锁都是重量级锁,这会导致无锁竞争时获取锁会增加不必要的开销。引入锁膨胀机制之后大幅优化了内置锁的性能。

锁膨胀机制是非常底层且复杂的机制。根据JEP 374: Deprecate and Disable Biased Locking的描述,开发团队考虑到偏向锁相关代码高企的维护成本和偏向锁带来的整体层面的微弱性能提升,决定在Java15开始不推荐使用偏向锁以及在观测一定时间后根据社区反馈决定是否要完全移除偏向锁特性,因此在Java15开始JVM默认不开启偏向锁,如果要开启偏向锁特性则需要显式地告诉JVM(JVM参数:XX:+UseBiasedLocking)。

偏向锁

偏向锁,Bias lock,偏向锁并不是正真的锁,而是JVM为了优化非竞争加锁(uncontended locking)性能的一种技术手段,非竞争加锁用白话说就是单个线程多次进入synchronized同步代码块,没有偏向锁时这会导致单个线程也多次用CAS操作去更新锁状态(比如可重入锁的计数器数据等),导致性能下降。

引入偏向锁能使得JVM仅在第一次加锁时使用CAS操作更新Object表头(源码中oop的markWord属性) 的锁状态为偏向锁模式(bias pattern)及偏向的线程内存地址,直到第二个线程尝试获取同一个偏向锁导致锁膨胀为轻量级锁之前,第一个线程加锁都不会再使用到CAS操作进而提高性能。

Biased locking is an optimization technique used in the HotSpot Virtual Machine to reduce the overhead of uncontended locking. It aims to avoid executing a compare-and-swap atomic operation when acquiring a monitor by assuming that a monitor remains owned by a given thread until a different thread tries to acquire it13.

当第二个线程尝试获取偏向锁时,如果检查到内置锁仍在偏向锁状态并且偏向的线程不是自己的时候,则会触发锁升级,升级到轻量级锁。如果读者有兴趣的,可以看OpenJDK的biasedLocking.hpp源码和注释。

轻量级锁

轻量级锁,Lightweight lock、thin lock,在OpenJDK里的实现是BasicLock
从轻量级锁开始,代表锁所处环境不再是单线程(非竞争加锁)环境。轻量级锁是个标准的自旋锁,轻量级锁拥有自旋锁的标准特征,循环持续检查锁是否可用并尝试获取锁,在锁平均等待时间短时能避免线程上下文切换的开销,JVM会收集并统计轻量级锁的性能信息,并在认为轻量级锁不再高效时膨胀为重量级锁。

轻量级锁在JDK15开始偏向锁不推荐使用后,是JVM里默认使用的最低级锁。

重量级锁

重量级锁,Heavyweight lock、fat lock、monitor lock,在OpenJDK里的实现是ObjectMonitor。因其基于OS互斥锁(Mutex)并利用OS调度引擎(Scheduler engine)来实现线程挂起和唤醒,线程上下文切换被认为是毫秒级的高额开销,频繁地挂起唤醒线程会导致高额的线程上线文开销。
如果线程等待重量级锁时间足够长、挂起唤起线程频率够低,那么线程上下文切换的时间开销占比将会大幅下降。相比偏向锁、轻量级锁,因为重量级锁每次都需要与OS内核交互,就算没有竞争导致的上下文切换,单纯申请锁和释放锁就比前两种锁要慢很多。

fat: the ‘strongest’ type of lock when JVM requests for an OS mutex and uses OS scheduler engine for threads parkings and wake ups. It is much costly than previous types because in this case JVM should directly interact with OS every time when thread acquires and frees the lock14.

内置锁的三种模式的性能对比

如果想知道几种锁的性能到底差距有多大,测试环境为OpenJDK11,测试对象为无锁、偏向锁、轻量级锁、重量级锁,测试内容为一亿次加锁int自增,可以看到确实偏向锁对比轻量级锁提升确实不明显,可以理解为什么OpenJDK要通过JEP 374: Deprecate and Disable Biased Locking准备废除偏向锁了。

测试对象测试耗时(毫秒)
无锁约15
偏向锁约150
轻量级锁约200
重量级锁约2000

内置锁


内置锁,也称对象锁,Intrinsic lock,这是Oracle文档3里提到的概念,也就是synchronized关键字使用的锁。
Java对象在内存中是由连续的字节组成,对象在内存中的起始位置均固定为两个重要属性分别是_mark和_metadata,这两个属性被合称为对象头(Object Header)

// jdk17-master\src\hotspot\share\oops\oop.hpp
class oopDesc {
  friend class VMStructs;
  friend class JVMCIVMStructs;
 private:
  volatile markWord _mark; // 对象无锁、偏向锁、轻量级锁、重量级锁的信息存储在此
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata; // 
  // 以下略...
}

// jdk17-master\src\hotspot\share\oops\markWord.hpp
class markWord {
 private:
  uintptr_t _value;
  // 以下略...
}

第二个属性_metadata是类相关如方法等数据存储的地址。
第一个属性_mark则是封装了一个uintptr_t ,这玩意儿在32位JVM里就是32位,64位JVM里就是64位。这个属性复合了4种模式,分别是

  1. 无锁:纯净的Java对象,其所有数据位中包含了HashCode、对象年龄等信息。
  2. 偏向锁模式:其所有数据为中包含了偏向线程的地址、对象年龄等信息。施加偏向锁时原有的无锁_mark不会被替换,这是最轻量级的内置锁。
  3. 轻量级锁地址:原有的_mark被隐藏到轻量级锁对象里被管理起来,原来Java对象的_mark则被替换为轻量级锁的内存地址(可以理解为用wrapper包装了一下)。
  4. 重量级锁地址:和轻量级锁一样,原有的_mark被隐藏到重量级锁对象里被管理起来,原来Java对象的_mark则被替换为重量级锁的内存地址

JVM利用对象头的_mark后三位来判断对象的状态,具体可以参考下列OpenJDK的注释。也是因为所有对象都有对象头,任何对象都能作为锁被使用,所以内置锁也有称为对象锁。

// jdk17-master\src\hotspot\share\oops\markWord.hpp
//    [JavaThread* | 1 | 01]  biased             lock is biased toward given thread
//    [0           | 1 | 01]  biased             lock is anonymously biased
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used to mark an object
//    [0 ............ 0| 00]  inflating          inflation in progress

Java里的锁


synchronized关键字


也就是上面提到的内置锁、对象锁了。内置锁无论何种模式(偏向锁、轻量级锁、重量级锁)都是不公平的可重入锁。synchronized关键字能把所有实例作为锁来使用。synchronized作用于方法时,实例方法锁当前实例,类方法则会锁当前类的实例。

AQS框架


AQS框架,全称AbstractQueuedSynchronizer,是个抽象类,提供了一套完整的API用于管理状态为单个int的锁。是JUC包里锁非常重要的基础设施类,大量锁的实现内部都依靠了AQS来保证管理锁状态相关操作的原子性。AQS名字中带Queued,其支持排队的特性也使得实现公平锁成为可能,当然这个特性是可选的,实现非公平锁也是可以的。

This class is designed to be a useful basis for most kinds of synchronizers that rely on a single atomic int value to represent state15.

我们熟知的ReentrantLock、Semaphore等其内部都有继承自AQS的公平同步器和非公平同步器。像下面类似的代码我们可以在很多锁相关类里都看到,锁类提供向外的高层锁操作API,内部则是基于AQS框架实现公平同步器和非公平同步器来实现公平锁和非公平锁两种版本。

/**
     * Base of synchronization control for this lock. Subclassed
     * into fair and nonfair versions below. Uses AQS state to
     * represent the number of holds on the lock.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer { // 代码略... }

    /**
     * Sync object for non-fair locks
     */
    static final class NonfairSync extends Sync { // 代码略... }

    /**
     * Sync object for fair locks
     */
    static final class FairSync extends Sync { // 代码略... }

像Semaphore的状态就是一个int,初始给定一个许可证的数量,取一个就通过AQS减一,取到许可证数量到0就需要等待,归还一个就通过AQS加一。
ReentrantLock的状态是计数器,其也是一个int,初始为0代表未上锁,加锁一次就通过AQS加一,解锁一次就通过AQS减一,不难看出重复加锁会导致计数器一直上升,如果一旦计数器达到MAX_INT,就会报错"Maximum lock count exceeded"。

Condition接口


简单来说呢就是Object内置锁相关的监视器方法(wait、notify、notifyAll)的一个高级替代。对应的是Condition的await、signal、signalAll

Condition factors out the Object monitor methods (wait, notify and notifyAll) into distinct objects to give the effect of having multiple wait-sets per object, by combining them with the use of arbitrary Lock implementations. Where a Lock replaces the use of synchronized methods and statements, a Condition replaces the use of the Object monitor methods.

用内置锁时就需要Object的监视器方法,用Lock时则使用Condition的方法,相比Object的监视器方法只能支持单个等待集,而通过Lock创建多个Condition则支持多个等待集

例如监视器方法notifyAll会唤醒等待集里所有的线程,告诉他们可以醒来去试试能不能获取锁,而一个锁的多个Condition等于是把锁继续细分,通过一个锁的多个Condition你可以定向选择唤醒某个等待集里的一个或全部线程(signal / signalAll),达到更细致地操控多线程的目的。

如JUC里的ArrayBlockingQueue,就是经典的生产者消费者模式,可以参考以下节选源码(已追加注释)。

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition(); // 作为消费者线程的等待集。接收“仓库内有货的通知”
    notFull =  lock.newCondition(); // 作为生产者线程的等待集,接收“仓库有空间放货的通知”
}

public void put(E e) throws InterruptedException {
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await(); // 作为生产者,发现仓库没空间了,等仓库腾出空间“可以继续放货的通知”
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await(); // 作为消费者,发现仓库库存空了,等生产者的“到货通知”
        return dequeue();
    } finally {
        lock.unlock();
    }
}

private void enqueue(E e) {
    // assert lock.isHeldByCurrentThread();
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = e;
    if (++putIndex == items.length) putIndex = 0;
    count++;
    notEmpty.signal(); // 通知消费者,有数据了。
}

private E dequeue() {
    // assert lock.isHeldByCurrentThread();
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E e = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length) takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal(); // 通知生产者,仓库有空间了,可以开工了。
    return e;
}

ReentrantLock类


JDK里标准的可重入锁实现,可通过构造函数参数创建公平锁和非公平锁两个版本,是最基本最简单的锁实现。内部一个AQS同步器管理锁状态,锁状态为计数器,其他笔者不再赘述。

ReentrantReadWriteLock类


JDK里标准的读写锁实现,一样提供了公平和非公平两个版本。和前面章节提到的读写锁一样,对外提供了读锁和写锁,在内部其读锁和写锁共享一个AQS同步器用于管理锁的状态。

// java/util/concurrent/locks/ReentrantReadWriteLock.java
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync(); // AQS同步器用于原子地管理锁状态(一个int)
    readerLock = new ReadLock(this);  // 读锁
    writerLock = new WriteLock(this); // 写锁
}

protected ReadLock(ReentrantReadWriteLock lock) {
  sync = lock.sync; // 与写锁共享同一个同步器
}

protected WriteLock(ReentrantReadWriteLock lock) {
  sync = lock.sync; // 与读锁共享同一个同步器
}

在OpenJDK的实现中,读写锁的状态是AQS来管的,也就是一个int,合计32位。其中低16位被用于写锁,高16位被用于读锁,参考下面列出的源码。

static final int SHARED_SHIFT   = 16;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 排它锁(写锁)的Bit Mask (0x1111_1111_1111_1111)
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1; // 写锁最大可重入次数

写锁是排他的,写锁相当于一个锁状态为16位的ReentrantLock,所以其最大可重入次数(上述源码的MAX_COUNT)被限定在65535(2^16-1),比ReentrantLock是小了非常多了。

读锁是共享的,读锁利用的是高16位来记录读锁个数(并非可重入锁的计数器),所以其最大共享锁数量也是65535(MAX_COUNT)

final int getReadLockCount() {
	return sharedCount(getState());
}

重入计数器则由内部类HoldCounter类负责,多个线程的HoldCounter则由ThreadLocalHoldCounter类负责,这样每个线程共享写锁时就可以单独计数。

结语


因为锁概念缺少权威标准,所以学习锁时,相关概念总是非常多、复杂且混乱的。有一个良好的对各类锁的印象是学好并发编程的基础之一,希望本文的盘点能对你有所帮助。

参考


  1. Fair lock, unfair lock, reentrant lock, recursive lock, spin lock - fatalerrors ↩︎ ↩︎

  2. Binary Semaphore vs Reentrant Lock - baeldung ↩︎

  3. Intrinsic Locks and Synchronization - Oracle ↩︎ ↩︎

  4. openjdk-jdk11/src/java.base/share/classes/java/util/concurrent/locks/ReentrantLock.java - OpenJDK 11 ↩︎

  5. openjdk-jdk11/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java - OpenJDK 11 ↩︎

  6. openjdk-jdk11/src/java.base/share/classes/java/util/concurrent/Semaphore.java - OpenJDK 11 ↩︎

  7. Difference between Shared Lock and Exclusive Lock - geeksforgeeks ↩︎ ↩︎

  8. openjdk-jdk11/src/java.base/share/classes/java/util/concurrent/locks/ReadWriteLock.java - OpenJDK 11 ↩︎

  9. Exclusive locks and shared locks - IBM ↩︎

  10. MySQL lock mechanism pessimistic lock and optimistic lock - programmer.group ↩︎ ↩︎

  11. Difference between Bucket level lock and segment level lock in ConcurrentHashMap? - StackOverflow ↩︎

  12. Spinlock - wikipedia ↩︎

  13. JEP 374: Deprecate and Disable Biased Locking - OpenJDK ↩︎

  14. How does JVM handle locks - javacodegeeks ↩︎

  15. Class AbstractQueuedSynchronizer - Oracle Javadoc ↩︎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值