深入JAVA并发编程(四):原子操作类

原子操作类

我们知道,当多线程同时对一个变量进行更新操作时,可能会出现线程安全性问题,通常我们可以使用synchronized、Lock来进行同步操作解决这个问题,但是这样程序性能会有所影响。JAVA1.5之后,JUC包中提供了一系列的原子操作类,提供了一种用法简单、性能高效、线程安全地更新变量的方式。

原子更新基本类型类

使用原子的方式更新基本类型,Atomic包提供了以下三个类

  • AtomicInteger:原子更新整数类型
  • AtomicBoolean:原子更新布尔类型
  • AtomicLong:原子更新长整型

它们的原理类似,内部都是使用Unsafe来实现,我们以AtomicLong类为例讲解

首先,该类内部定义了Unsafe类实例、实际变量值、内存偏移量等成员变量

//获取Unsafe类实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
//存放变量value的内存偏移量
private static final long valueOffset;
//实际变量值
private volatile long value;

然后,我们来看下该类中的主要函数

递增或递减函数

	//原子性递增1,返回递增后的值
	public final long incrementAndGet() {
        return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
    }
    //原子性递减1,返回递减后的值
    public final long decrementAndGet() {
        return unsafe.getAndAddLong(this, valueOffset, -1L) - 1L;
    }
    //原子性递增1,返回原始值
    public final long getAndIncrement() {
        return unsafe.getAndAddLong(this, valueOffset, 1L);
    }
    //原子性递减1,返回原始值
    public final long getAndDecrement() {
        return unsafe.getAndAddLong(this, valueOffset, -1L);
    }

我们发现递增和递减函数都是通过调用Unsafe类的getAndAddLong方法来实现的,这个函数是原子性操作。我们来看下源码

	public final long getAndAddLong(Object var1, long var2, long var4) {
        long var6;
        do {
            var6 = this.getLongVolatile(var1, var2);
        } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

        return var6;
    }

该段代码逻辑为,先拿到当前变量的值,因为变量是Volatile的,所以拿到的是最新的值,然后对其+1后,使用CAS修改变量的值,循环设置直至成功。

CAS修改函数

	public final boolean compareAndSet(long expect, long update) {
        return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
    }

同样地,内部也是调用了Unsafe的方法,Unsafe内部是一个native方法,是调用CPU指令实现的,当expect和内存中的value值一致时,更新成功返回true,否则返回false。

使用案例

例子:通过多线程使用AtomicLong来统计0的个数

public class AtomicDemo {
    
    private static AtomicLong atomicLong=new AtomicLong();

    private static Integer[] arrayA={0,1,2,3,0,5,6,0,56,0};
    private static Integer[] arrayB={10,1,2,3,0,0,0,0,56,0};

    public static void main(String[] args) throws Exception {
        Thread threadA=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i=0;i<arrayA.length;i++){
                    if(arrayA[i]==0){
                        atomicLong.incrementAndGet();
                    }
                }
            }
        });

        Thread threadB=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i=0;i<arrayB.length;i++){
                    if(arrayB[i]==0){
                        atomicLong.incrementAndGet();
                    }
                }
            }
        });

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
        System.out.println("两个数组中0的个数和为:"+atomicLong.get());

    }
}

在这里插入图片描述

Atomic包只提供了三种基本类型的原子操作类,那么其他基本类型呢?前面我们已经知道,Atomic包里面的类基本都是通过Unsafe类实现的,如果我们需要对其他基本类型进行操作,只需要自己实现即可。

原子更新数组类

  • AtomicIntegerArray:原子更新整型数组里的元素
  • AtomicLongArray:原子更新长整型数组里的元素
  • AtomicReferenceArray:原子更新引用类型数组里的元素

我们这里以AtomicLongArray为例来讲解,看下它的常用方法

	//将数组第i个元素设置为newValue,并返回旧值
	public final long getAndSet(int i, long newValue) {
        return unsafe.getAndSetLong(array, checkedByteOffset(i), newValue);
    }
    //进行CAS操作,如果第i个元素等于expect,则更新为update值
    public final boolean compareAndSet(int i, long expect, long update) {
        return compareAndSetRaw(checkedByteOffset(i), expect, update);
    }
    //将第i个元素+1
    public final long getAndIncrement(int i) {
        return getAndAdd(i, 1);
    }
    //将第i个元素-1
    public final long getAndDecrement(int i) {
        return getAndAdd(i, -1);
    }
    //以原子方式将第i个元素和delta相加
    public final long getAndAdd(int i, long delta) {
        return unsafe.getAndAddLong(array, checkedByteOffset(i), delta);
    }

可以看到其内部原理和AtomicLong大同小异,都是通过调用Unsafe类进行CAS操作设置。

使用案例

public class AtomicArrayDemo {
    static AtomicLongArray atomicLongArray=new AtomicLongArray(10);

    public static void main(String[] args) throws InterruptedException {
        Thread threadA=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i=0;i<1000;i++){
                    atomicLongArray.getAndIncrement(0);
                }
            }
        });

        Thread threadB=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i=0;i<1000;i++){
                    atomicLongArray.getAndDecrement(0);
                }
            }
        });

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
        System.out.println("值为:"+atomicLongArray.get(0));
    }
}

在这里插入图片描述

原子更新引用类型类

  • AtomicReference:原子更新引用类型
  • AtomicStampedReference:原子更新带有版本号的引用类型
  • AtomicMarkableReference:原子更新带有标记位的引用类型

原子更新引用类型的类和前面的类原理都是高度类似的,原子更新引用类型类可以保证修改对象引用时的线程安全性,基本的内部原理可以参考上面的类。

这里我们要说重点说下ABA问题,一般来说,ABA问题发生的概率很小,即使发生了,多数情况下是没有什么影响的,比如只是做个简单的加减操作,即使它不断地被修改,但只要最后改回了期望值,对我就没有任何影响。但是,有一些其他复杂的情况,就必须考虑ABA问题。

例如:有一家蛋糕店,为了挽留客户,为VIP卡里余额小于20元的客户一次性赠送20元,每位客户仅能被赠送一次。接下来我们通过代码模拟一下。

public class ABADemo {
	//给定一个初始值19
    static AtomicInteger atomicInteger=new AtomicInteger(19);

    public static void main(String[] args) {
        //负责为用户充值线程
        new Thread(){
            @Override
            public void run() {
            	//该层循环是为了不停的监控余额
                while (true) {
                    while (true) {
                        Integer m = atomicInteger.get();
                        //当余额小于20时,充值20元,并跳出循环
                        if (m < 20) {
                            if (atomicInteger.compareAndSet(m, m + 20)) {
                                System.out.println("余额小于20元,充值成功,余额为" + atomicInteger.get());
                                break;
                            }
                        } else {
                            break;
                        }
                    }
                }
            }
        }.start();

		//用户消费线程
        new Thread(){
            @Override
            public void run() {
            	//模拟用户消费三次
                for (int i=0;i<3;i++) {
                    while (true) {
                        Integer m=atomicInteger.get();
                        //当余额大于10元时,进行消费,并跳出循环
                        if(m>10){
                            System.out.println("大于十元");
                            if(atomicInteger.compareAndSet(m,m-10)){
                                System.out.println("成功消费10元,余额:"+atomicInteger.get());
                                break;
                            }else {
                                break;
                            }
                        }
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

在这里插入图片描述

我们可以看到,账户被多次反复充值,不符合我们仅充值一次的要求。归其原因,就是因为只对数值进行判断。怎么解决呢?其实我们只需要给其再增加一个状态,来标记是否已经充值过就能很好的解决。我们曾经讲过,解决ABA问题可以添加标志来解决,而Atomic包中也为我们提供AtomicStampedReference类来为我们很好的解决这个情况。

AtomicStampedReference内部不仅维护了对象值,还维护了一个状态值(可以使用任意整数来表示状态值),当AtomicStampedReference对应的数值被修改时,还必须更改时间戳,这样当AtomicStampedReference设置值时,对象值和时间戳同时满足期望值才能成功写入。因此,即使对象值被重复修改,最后修改为原值,但是只要时间戳发生变化,同样不能更新成功。

AtomicStampedReference常用方法:

	//获得当前对象引用
	public V getReference() {
        return pair.reference;
    }
    //获取时间戳
    public int getStamp() {
        return pair.stamp;
    }
    //CAS操作设置,参数依次为期望值,写入新值,期望时间戳,新的时间戳
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
    //设置当前对象引用和时间戳
    public void set(V newReference, int newStamp) {
        Pair<V> current = pair;
        if (newReference != current.reference || newStamp != current.stamp)
            this.pair = Pair.of(newReference, newStamp);
    }

我们用AtomicStampedReference来解决我们上面例子存在的问题。

public class StampDemo {
    static AtomicStampedReference<Integer> atomicStampedReference=new AtomicStampedReference<>(19,0);

    public static void main(String[] args) {
        //充值
        new Thread(){
            @Override
            public void run() {
                Integer timestap=atomicStampedReference.getStamp();
                while (true) {
                    while (true) {
                        Integer m = atomicStampedReference.getReference();
                        if (m < 20) {
                            if (atomicStampedReference.compareAndSet(m, m + 20,timestap,timestap+1)) {
                                System.out.println("余额小于20元,充值成功,余额为" + atomicStampedReference.getReference());
                                break;
                            }
                        } else {
                            break;
                        }
                    }
                }
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                for (int i=0;i<3;i++) {
                    while (true) {
                        Integer m=atomicStampedReference.getReference();
                        Integer timestap=atomicStampedReference.getStamp();
                        if(m>10){
                            System.out.println("大于十元");
                            if(atomicStampedReference.compareAndSet(m,m-10,timestap,timestap+1)){
                                System.out.println("成功消费10元,余额:"+atomicStampedReference.getReference());
                                break;
                            }else {
                                break;
                            }
                        }
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

在这里插入图片描述

原子更新字段类

Atomic还提供了原子更新字段类,如果需要原子更新某个类里的某个字段时,就需要使用原子更新字段类。原理一样,这里就不再细说。

  • AtomicIntegerFieldUpdater:原子更新整型字段的更新器
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器
  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段

更快的递增递减原子类:LongAdder

前面讲过,AtomicLong通过CAS提供了非阻塞的原子性操作,它会在一个死循环内不断尝试修改直至成功。这就会带来一个问题,在高并发情况下大量线程会同时去更新一个原子变量,但是由于同时只有一个线程能够修改成功,这就会造成大量线程竞争失败后,通过无限循环不断进行自旋尝试CAS操作,白白浪费了大量CPU资源,造成性能下降。因此,JDK8新增了一个LongAdder类,来解决高并发下使用AtomicLong的缺点。

那么LongAdder是如何解决这个问题的呢?既然AtomicLong的性能瓶颈是由于大量线程去竞争一个变量更新而产生的,那么我们将一个变量分解成多个变量,不就解决了性能问题么?

在这里插入图片描述

LongAdder设计思想就是这样的,如上图所示,使用LongAdder时,其在内部维护多个Cell变量,每个Cell里面有一个初始值为0的long类型变量,这样,在大量线程竞争情况下,竞争单个变量的线程数就会变少。并且,当多个线程在争夺同一个Cell原子变量时失败时,它并不是在当前Cell变量上一直自旋进行CAS操作重试,而是尝试在其他Cell变量上进行CAS尝试,这样就增加了当前线程重试CAS成功的可能性。最后,在获取LongAdder当前值时,会把所有Cell变量的value累加再加上base值返回。

LongAdder维护了一个延迟初始化的原子性更新数组(默认情况下Cell数组是null)和一个基值变量base。由于Cells占用的内存是相对比较大的,所以一开始并不创建,而是在需要时创建,也就是惰性加载。

当一开始判断Cell数组是null并且并发线程较少时,所有的累加操作都是对base变量进行的。保持Cell数组的大小为2的N次方,在初始化时Cell数组中的Cell元素个数为2,数组里面的变量实体是Cell类型,Cell类型是AtomicLong的一个改进,用来减少缓存的争用,解决伪共享问题。

LongAdder代码分析

首先,我们来看一下LongAdder的类图结构

在这里插入图片描述

我们可以看到,LongAdder继承自Striped64类,在Striped64类内部维护着Cell[] cells、long base、int cellsBusy等变量。LongAdder的真实值是base的值和cells数组中所有cell元素value的累加,base是个基础值,默认为0。cellsBusy是个状态值,状态值只有0和1,当创建Cell元素时,扩容Cell数组或者初始化Cell数组时,使用CAS操作该变量来保证同时只有一个线程可以进行其中之一的操作。

我们先来看下Striped64类内的Cell构造

	@sun.misc.Contended static final class Cell {
        volatile long value;
        Cell(long x) { value = x; }
        final boolean cas(long cmp, long val) {
            return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
        }

        // Unsafe mechanics
        private static final sun.misc.Unsafe UNSAFE;
        private static final long valueOffset;
        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class<?> ak = Cell.class;
                valueOffset = UNSAFE.objectFieldOffset
                    (ak.getDeclaredField("value"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

可以看到Cell的构造很简单,其内部维护一个被声明为volatile的变量,这里声明为volatile是因为线程操作value变量时没有使用锁,为了保证变量的内存可见性所以这样声明。cas方法通过CAS操作,保证了当前线程更新时被分配的Cell元素中value值的原子性,另外Cell类使用@sun.misc.Contended修饰来避免伪共享问题。

	public long sum() {
        Cell[] as = cells; Cell a;
        long sum = base;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

LongAdder的sum方法是返回当前的值,具体操作可以看到是累加cell数组的值再加上base值,由于计算总和时没有对Cell数组进行加锁,所以在累加过程中可能有别的线程对Cell数组中的值进行修改,也有可能对数组进行了扩容,所以sum返回的值并不是非常精确的。

	public void reset() {
        Cell[] as = cells; Cell a;
        base = 0L;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    a.value = 0L;
            }
        }
    }

reset方法为重置操作,把base置为0,如果Cell数组中有元素,把元素值置为0.

	public long sumThenReset() {
        Cell[] as = cells; Cell a;
        long sum = base;
        base = 0L;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null) {
                    sum += a.value;
                    a.value = 0L;
                }
            }
        }
        return sum;
    }

sumThenReset是对sum的改造,该方法在使用sum累加base和Cell数组的值后,会将Cell数组里的元素值置为0,base置为0.这样,当多线程调用该方法时就会出现问题,比如第一个线程调用后清空了值,则后一个线程调用时累加的都是0.

下面我们主要看下add方法的实现。

	public void add(long x) {
        Cell[] as; long b, v; int m; Cell a;
        if ((as = cells) != null || !casBase(b = base, b + x)) {//(1)
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||  //(2)
                (a = as[getProbe() & m]) == null ||		//(3)
                !(uncontended = a.cas(v = a.value, v + x))) //(4)
                longAccumulate(x, null, uncontended);   //(5)
        }
    }
	final boolean casBase(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, BASE, cmp, val);
    }

首先看cells是否为null,如果为null则执行casBase方法在当前基础变量base上进行累加。如果cells不为null或者线程执行casBase方法的CAS操作失败了,则会继续向下执行,首先会将冲突标志uncontended设置为true,代码(2)会判断当前Cell数组是否为空,不为空时继续向下执行,代码(2)(3)是决定当前线程应该访问cells数组中的哪一个Cell元素,这个过程是通过getProbe() & m运算的,其中m是当前cells数组元素个数-1,getProbe()则用于获取当前线程中变量threadLocalRandomProbe的值,这个值一开始为0,在代码(5)里面会对其进行初始化。如果当前线程映射的元素存在则执行代码(4),使用CAS操作去更新分配的Cell元素的value值,如果当前线程映射的元素不存在或者存在但是CAS操作失败则执行代码(5)。

接下来我们重点研究一下longAccumulate方法的逻辑,这是cells数组初始化和扩容的地方。

final void longAccumulate(long x, LongBinaryOperator fn,
                              boolean wasUncontended) {

		//(6)初始化当前线程的变量threadLocalRandomProbe的值
        int h;
        //如果当前线程getProbe为0,则会给当前线程生成一个随机值
        if ((h = getProbe()) == 0) {
            ThreadLocalRandom.current(); // force initialization
            h = getProbe();
            wasUncontended = true;
        }
        boolean collide = false;                // True if last slot nonempty
        for (;;) {
            Cell[] as; Cell a; int n; long v;
            //cells数组已经被初始化
            if ((as = cells) != null && (n = as.length) > 0) {//(7)
            	//取元素
                if ((a = as[(n - 1) & h]) == null) {   //(8)
                    if (cellsBusy == 0) {       // Try to attach new Cell
                        Cell r = new Cell(x);   // Optimistically create
                        if (cellsBusy == 0 && casCellsBusy()) {
                            boolean created = false;
                            try {               // Recheck under lock
                                Cell[] rs; int m, j;
                                if ((rs = cells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    created = true;
                                }
                            } finally {
                                cellsBusy = 0;
                            }
                            if (created)
                                break;
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
                }
                else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;      // Continue after rehash
                //当前Cell存在,则执行CAS设置(9)
                else if (a.cas(v = a.value, ((fn == null) ? v + x :
                                             fn.applyAsLong(v, x))))
                    break;
                //当前Cell元素个数大于CPU个数(10)
                else if (n >= NCPU || cells != as)
                    collide = false;            // At max size or stale
                //是否有冲突(11)
                else if (!collide)
                    collide = true;
               //如果当前元素个数没有达到CPU个数并且有冲突则扩容(12)
                else if (cellsBusy == 0 && casCellsBusy()) {
                    try {
                        if (cells == as) {      // Expand table unless stale
                            Cell[] rs = new Cell[n << 1];
                            for (int i = 0; i < n; ++i)
                                rs[i] = as[i];
                            cells = rs;
                        }
                    } finally {
                        cellsBusy = 0;
                    }
                    collide = false;
                    continue;                   // Retry with expanded table
                }
                //(13)为了能够找到一个空闲的Cell,重新计算hash值,xorshift算法生成随机数
                h = advanceProbe(h);
            }
            //(14)初始化Cell数组
            else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
                boolean init = false;
                try {                           // Initialize table
                    if (cells == as) {
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        init = true;
                    }
                } finally {
                    cellsBusy = 0;
                }
                if (init)
                    break;
            }
            else if (casBase(v = base, ((fn == null) ? v + x :
                                        fn.applyAsLong(v, x))))
                break;                          // Fall back on using base
        }
    }

当每个线程第一次执行到代码(6)处时,会初始化当前线程变量threadLocalRandomProbe的值,我们上面讲过,这个值在计算当前线程应该被分配到cells数组的哪一个Cell元素时会被用到。

cells数组的初始化是在代码(14)处进行的,其中cellsBusy是一个标示,为0说明当前cells数组没有在被初始化或扩容,也没有在新建Cell元素,为1则说明cells数组在被初始化或扩容,或者当前在创建新的Cell元素,通过CAS操作来进行0或1的状态切换,这里使用了casCellsBusy方法。假设当前线程通过CAS设置cellsBusy为1,则当前线程开始执行初始化操作。可以看到初始化cells数组元素个数为2,然后使用h&1计算当前线程应该访问cells数组的哪个位置,也就是使用当前线程的threadLocalRandomProbe变量值&(cells数组元素个数-1),然后标示cells数组已经被初始化,最后重置cellsBusy标示。显然这里没有使用CAS操作,但是是线程安全的,因为cellsBusy是volatile类型,保证了变量的内存可见性,另外此时其他地方的代码没有机会修改cellsBusy的值,这里初始化cells数组里面的两个元素的值仍然为null。

cells数组的扩容是在代码(12)处完成的,cells扩容的条件是代码(10)(11)的条件都不满足的时候,具体就是当前cells数组的元素个数小于当前机器的CPU个数并且当前多个线程访问了cells中同一个元素,从而导致冲突使其中一个线程CAS失败时才会进行扩容操作。代码(12)处的扩容操作也是先通过CAS设置cellsBusy为1,然后才能进行扩容,假设CAS执行成功则将容量扩大为以前的两倍,并赋值cells元素到扩容后数组。扩容后cells数组除了包含复制过来的Cell元素,还包含其他新元素,这些元素的值目前还是null。

在代码(7)(8)处,当前线程调用add方法并根据当前线程的随机数threadLocalRandomProbe和cells元素个数计算要访问的cell元素下标,然后如果发现对应下标元素的值为null,则新增一个Cell元素到cells数组,并且在将其添加到cells数组之前要设置cellsBusy为1。

代码(13)处对CAS失败的线程重新计算当前线程的随机值threadLocalRandomProbe,以减少下次访问cells元素时的冲突机会。

LongAdder增强版:LongAccumulator

LongAdder是LongAccumulator的一个特例,LongAccumulator功能比LongAdder更强大。LongAdder只能进行加减操作,而LongAccumulator提供了自定义的函数操作。

我们看下LongAccumulator的构造函数,LongBinaryOperator是一个双目运算器接口,其根据输入的两个参数返回一个计算值(LongBinaryOperator接收2个long作为参数,并返回1个long型计算结果),identity则是初始值。

	public LongAccumulator(LongBinaryOperator accumulatorFunction,
                           long identity) {
        this.function = accumulatorFunction;
        base = this.identity = identity;
    }
	public interface LongBinaryOperator {

	    long applyAsLong(long left, long right);
	}

LongAccumulator相比于LongAdder,可以提供非0的初始值,LongAdder只能使用默认的0初始值。其他内部原理和LongAdder基本一样,可以自己去读下源码。

使用案例

public class LongAccumulatorDemo {
    static LongAccumulator longAccumulator=new LongAccumulator(new LongBinaryOperator() {
        @Override
        public long applyAsLong(long left, long right) {
            return left*right;
        }
    },2);

    public static void main(String[] args) {
        longAccumulator.accumulate(5);
        System.out.println(longAccumulator.get());
        longAccumulator.accumulate(10);
        System.out.println(longAccumulator.get());
    }
}

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值