【尚硅谷周阳--JUC并发编程】【第九章--原子操作类之18罗汉增强】

12 篇文章 0 订阅

一、是什么?

阿里巴巴开发手册说明

二、分类

1、基本类型原子类

1.1、基本类型原子类包含以下三个

  • AtomicInteger
  • AtomicBoolean
  • AtomicLong

1.2、常用API简介

// 获取当前的值
public final int get();

// 获取当前的值,并设置新的值
public final int getAndSet(int newValue);

// 获取当前的值,并自增
public final int getAndIncrement();

// 获取当前的值,并自减
public final int getAndDecrement();

// 获取当前的值,并加上预期的值
public final int getAndAdd(int delta);

// 如果输入的数值等于预期值,则以原子方式将值设置为输入值(update)
public final boolean compareAndSet(int expect, int update);

1.3、案例

  • 使用线程休眠方式,但是这种方式肯定是不可靠的,仅用于测试
class MyNumber {
    AtomicInteger atomicInteger = new AtomicInteger();

    public void addPlusPlus() {
        atomicInteger.getAndIncrement();
    }
}

public class AtomicIntegerDemo {

    public static final int SIZE = 50;
    public static void main(String[] args) {
        MyNumber myNumber = new MyNumber();

        for (int i = 0; i < SIZE; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    myNumber.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        // 等待上面线程执行完
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        System.out.println(Thread.currentThread().getName() + "\t" + "result:" + myNumber.atomicInteger.get());

    }
}
  • 可以使用CountDownLatch来替换线程休眠,实现主线程等待其他线程执行完毕
class MyNumber {
    AtomicInteger atomicInteger = new AtomicInteger();

    public void addPlusPlus() {
        atomicInteger.getAndIncrement();
    }
}

public class AtomicIntegerDemo {

    public static final int SIZE = 50;
    public static void main(String[] args) throws InterruptedException {
        MyNumber myNumber = new MyNumber();
        CountDownLatch countDownLatch = new CountDownLatch(SIZE);

        for (int i = 0; i < SIZE; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < 1000; j++) {
                        myNumber.addPlusPlus();
                    }
                } finally {
                    countDownLatch.countDown();
                }
            }, String.valueOf(i)).start();
        }

        countDownLatch.await();

        System.out.println(Thread.currentThread().getName() + "\t" + "result:" + myNumber.atomicInteger.get());

    }
}

2、数组类型原子类

2.1、数组类型原子类包含以下三个

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

2.2、案例

public class AtomicIntegerArrayDemo {

    public static void main(String[] args) {
        AtomicIntegerArray integerArray = new AtomicIntegerArray(5);
        // AtomicIntegerArray integerArray = new AtomicIntegerArray(new int[5]);
        // AtomicIntegerArray integerArray = new AtomicIntegerArray(new int[]{1,2,3,4,5});
        for (int i = 0; i < integerArray.length(); i++) {
            System.out.println(integerArray.get(i));
        }

        System.out.println();

        int tmpInt = 0;

        tmpInt = integerArray.getAndSet(0, 2024);
        System.out.println(tmpInt + "\t" + integerArray.get(0));

        tmpInt = integerArray.getAndIncrement(0);
        System.out.println(tmpInt + "\t" + integerArray.get(0));
    }
}

3、引用类型原子类

3.1、引用类型原子类包含以下三个

  • AtomicReference
  • AtomicStampedReference
  • AtomicMarkableReference

3.2、案例

3.2.1、AtomicReference
/**
 * 题目:实现一个自旋锁,复习CAS思想
 * 自旋锁好处:循环比较获取没有类似wait的阻塞
 *
 * 通过CAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒,B随后进来后发现
 * 当前有现成持有锁,所以只能通过自旋等待,直到A释放锁后B随后抢到
 *
 * @author 匍匐丶前行
 * @since 2024/3/19 15:25
 **/

public class SpinLockDemo {

    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void lock() {
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName() + "\t" + "------come in");
        while (!atomicReference.compareAndSet(null, thread)) {

        }
        System.out.println(Thread.currentThread().getName() + "\t" + "------拿到资源");
    }

    public void unlock() {
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
        System.out.println(Thread.currentThread().getName() + "\t" + "------task over");
    }

    public static void main(String[] args) {
        SpinLockDemo spinLockDemo = new SpinLockDemo();

        new Thread(() -> {
            spinLockDemo.lock();
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            spinLockDemo.unlock();
        }, "A").start();

        // 保证线程A先启动
        try {
            TimeUnit.MILLISECONDS.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }


        new Thread(() -> {
            spinLockDemo.lock();
            try {
                TimeUnit.MILLISECONDS.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            spinLockDemo.unlock();
        },"B").start();

    }
}

结果

3.2.2、AtomicStampedReference
  1. 携带版本号的引用类型原子类,可以解决ABA问题
  2. 解决修改过几次的问题(每次修改都会有流水记录)
public class ABADemo {
    static AtomicInteger atomicInteger = new AtomicInteger(100);
    static AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100, 1);
    public static void main(String[] args) {
        new Thread(() -> {
            int stamp = stampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t" + "首次版本号:" + stamp);

            // 暂停500毫秒,保证后面的t4线程初始化拿到的版本号和当前线程一致
            try {
                TimeUnit.MILLISECONDS.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            stampedReference.compareAndSet(100, 101, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + "\t" + "二次版本号:" + stampedReference.getStamp());

            stampedReference.compareAndSet(101, 100, stampedReference.getStamp(), stampedReference.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + "\t" + "三次版本号:" + stampedReference.getStamp());

        }, "t1").start();

        new Thread(() -> {
            int stamp = stampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t" + "首次版本号:" + stamp);

            // 等待t1线程发生ABA问题
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            boolean b = stampedReference.compareAndSet(100, 2024, stamp, stampedReference.getStamp() + 1);
            System.out.println(b + "\t" + Thread.currentThread().getName() + "\t" + "二次版本号:" + stampedReference.getStamp());
        }, "t2").start();
    }
}

结果

3.2.3、AtomicMarkableReference
  1. 原子更新带有标记位的引用类型对象
  2. 解决是否修改过
    • 它的定义就是将状态戳简化为true|false
    • 类似一次性筷子
public class AtomicMarkableReferenceDemo {

    static AtomicMarkableReference markableReference = new AtomicMarkableReference<>(100, false);

    public static void main(String[] args) {
        new Thread(() -> {
            boolean marked = markableReference.isMarked();
            System.out.println(Thread.currentThread().getName() + "\t" + marked);

            // 等待t2线程拿到相同的默认false值
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            markableReference.compareAndSet(100, 1000, marked, !marked);
        }, "t1").start();

        new Thread(() -> {
            boolean marked = markableReference.isMarked();
            System.out.println(Thread.currentThread().getName() + "\t" + marked);

            // 等待t1线程先执行完
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            boolean b = markableReference.compareAndSet(100, 2024, marked, !marked);
            System.out.println(Thread.currentThread().getName() + "\t" + "t2线程CASResult:" + b);
            System.out.println(Thread.currentThread().getName() + "\t" + markableReference.isMarked());
            System.out.println(Thread.currentThread().getName() + "\t" + markableReference.getReference());
        }, "t2").start();
    }
}

结果

4、对象的属性修改原子类

4.1、对象的属性修改原子类包含以下三个

  • AtomicIntegerFieldUpdater(原子更新对象中int类型字段的值)
  • AtomicLongFieldUpdater(原子更新对象中Long类型字段的值)
  • AtomicRefrenceFieldUpdater(原子更新引用类型字段的值)

4.2、使用目的

以一种线程安全的方式操作非线程安全对象内的某些字段

4.3、使用要求

  1. 更新的对象属性必须使用public volatile修饰符
  2. 因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性

4.4、在哪用过volatile关键字(面试)

一下三个对象的属性修改原子类,更新的对象属性必须使用public volatile修饰符

  • AtomicIntegerFieldUpdater(原子更新对象中int类型字段的值)
  • AtomicLongFieldUpdater(原子更新对象中Long类型字段的值)
  • AtomicRefrenceFieldUpdater(原子更新引用类型字段的值)

4.5、案例

4.5.1、Integer类型
/**
 * 以一种线程安全的方式操作非线程安全对象的某些字段
 *
 * 需求:
 * 10个线程
 * 每个线程 转账1000
 * 不适用synchronized,尝试使用AtomicIntegerFieldUpdater来实现
 *
 * @author 匍匐丶前进
 * @since 2024/3/21 11:03
 **/
@NoArgsConstructor
@AllArgsConstructor
@Data
class BankAccount {

    String bankName;

    // 更新对象的属性必须要使用volatile修饰符
    volatile int money;
}

public class AtomicIntegerFieldUpdaterDemo {

    public static void main(String[] args) throws InterruptedException {

        BankAccount bankAccount = new BankAccount("CCB", 0);
        // 抽象类使用newUpdater创建议个更新器
        AtomicIntegerFieldUpdater<BankAccount> integerFieldUpdater
                = AtomicIntegerFieldUpdater.newUpdater(BankAccount.class, "money");

        CountDownLatch countDownLatch = new CountDownLatch(10);

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < 1000; j++) {
                        integerFieldUpdater.getAndIncrement(bankAccount);
                    }
                } finally {
                    countDownLatch.countDown();
                }
            }, String.valueOf(i)).start();
        }
        countDownLatch.await();
        System.out.println(Thread.currentThread().getName() + "\t" + integerFieldUpdater.get(bankAccount));
    }
}
4.5.2、Reference类型
/**
 * 需求:
 * 多线程并发调用一个类的初始化方法,如果未被初始化过,将执行初始化工作
 * 要求只能被初始化一次,只有一个线程操作成功
 *
 * @author 匍匐丶前进
 * @since 2024/3/21 13:27
 **/
class MyVar {
    public volatile Boolean isInit = Boolean.FALSE;

    AtomicReferenceFieldUpdater<MyVar, Boolean> referenceFieldUpdater
            = AtomicReferenceFieldUpdater.newUpdater(MyVar.class, Boolean.class, "isInit");

    public void init(MyVar myVar) {
        if (referenceFieldUpdater.compareAndSet(myVar, Boolean.FALSE, Boolean.TRUE)) {
            System.out.println(Thread.currentThread().getName() + "\t" + "------ start init, need 2 second");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "\t" + "------ task over");
        } else {
            System.out.println(Thread.currentThread().getName() + "\t" + "------ 已经有线程在进行初始化工作");
        }
    }
}

public class AtomicReferenceFieldUpdaterDemo {

    public static void main(String[] args) {

        MyVar myVar = new MyVar();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                myVar.init(myVar);
            }, String.valueOf(i)).start();
        }
    }
}

结果

5、原子操作增强类原理深度解析

5.1、原子操作增强类包含以下四个

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder

5.2、阿里巴巴开发手册及面试题说明

阿里巴巴开发手册及面试题说明

5.3、点赞计数器案例及性能比较

5.3.1、常用API

LongAdder常用API

5.3.2、入门讲解
  1. LongAdder只能用来计算加法,且从零开始计算
  2. LongAccumulator提供了自定义的函数操作
public class LongAdderAPIDemo {
    public static void main(String[] args) {
        LongAdder longAdder = new LongAdder();

        longAdder.increment();
        longAdder.increment();
        longAdder.increment();

        System.out.println(longAdder.sum());

        LongAccumulator longAccumulator = new LongAccumulator(new LongBinaryOperator() {
            @Override
            public long applyAsLong(long x, long y) {
                return x + y;
            }
        }, 0);

        longAccumulator.accumulate(1);// x=0,y=1
        longAccumulator.accumulate(3);// x=1,y=3

        System.out.println(longAccumulator.longValue());
    }
}

结果

5.3.3、LongAdder高性能对比Code演示
/**
 * 需求:50各线程,每个线程100w次,总点赞数出来
 *
 * @author 匍匐丶前进
 * @since 2024/3/21 14:35
 **/

class ClickNumber {
    int number = 0;
    public synchronized void clickBySynchronized() {
        number++;
    }

    AtomicLong atomicLong = new AtomicLong(0);

    public void clickByAtomicLong() {
        atomicLong.getAndIncrement();
    }

    LongAdder longAdder = new LongAdder();

    public void clickLongAdder() {
        longAdder.increment();
    }

    LongAccumulator longAccumulator = new LongAccumulator((x, y) -> x + y, 0);

    public void clickLongAccumulator() {
        longAccumulator.accumulate(1);
    }
}

public class AccumulatorCompareDemo {

    public static final int _100w = 1000000;

    public static final int threadNumber = 50;

    public static void main(String[] args) throws InterruptedException {

        ClickNumber clickNumber = new ClickNumber();
        long startTime;
        long endTime;

        CountDownLatch countDownLatch1 = new CountDownLatch(threadNumber);
        CountDownLatch countDownLatch2 = new CountDownLatch(threadNumber);
        CountDownLatch countDownLatch3 = new CountDownLatch(threadNumber);
        CountDownLatch countDownLatch4 = new CountDownLatch(threadNumber);

        startTime = System.currentTimeMillis();
        for (int i = 0; i < threadNumber; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < _100w; j++) {
                        clickNumber.clickBySynchronized();
                    }
                } finally {
                    countDownLatch1.countDown();
                }
            }, String.valueOf(i)).start();
        }
        countDownLatch1.await();
        endTime = System.currentTimeMillis();
        System.out.println("------costTime:" + (endTime - startTime) + "毫秒" + "\t clickBySynchronized:" + clickNumber.number);


        startTime = System.currentTimeMillis();
        for (int i = 0; i < threadNumber; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < _100w; j++) {
                        clickNumber.clickByAtomicLong();
                    }
                } finally {
                    countDownLatch2.countDown();
                }

            }, String.valueOf(i)).start();
        }
        countDownLatch2.await();
        endTime = System.currentTimeMillis();
        System.out.println("------costTime:" + (endTime - startTime) + "毫秒" + "\t clickByAtomicLong:" + clickNumber.atomicLong.get());


        startTime = System.currentTimeMillis();
        for (int i = 0; i < threadNumber; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < _100w; j++) {
                        clickNumber.clickLongAdder();
                    }
                } finally {
                    countDownLatch3.countDown();
                }

            }, String.valueOf(i)).start();
        }
        countDownLatch3.await();
        endTime = System.currentTimeMillis();
        System.out.println("------costTime:" + (endTime - startTime) + "毫秒" + "\t clickLongAdder:" + clickNumber.longAdder.sum());


        startTime = System.currentTimeMillis();
        for (int i = 0; i < threadNumber; i++) {
            new Thread(() -> {
                try {
                    for (int j = 0; j < _100w; j++) {
                        clickNumber.clickLongAccumulator();
                    }
                } finally {
                    countDownLatch4.countDown();
                }
            }, String.valueOf(i)).start();
        }
        countDownLatch4.await();
        endTime = System.currentTimeMillis();
        System.out.println("------costTime:" + (endTime - startTime) + "毫秒" + "\t clickLongAccumulator:" + clickNumber.longAccumulator.get());

    }
}

结果

5.4、源码、原理分析

5.4.1、架构

架构图
为什么说是18罗汉,剩下的两个

5.4.2、原理(LongAdder为什么这么快)
  1. 官网说明和阿里要求
    api说明
    Java开发手册说明
5.4.3、Striped64
  1. LongAdder是Striped64的子类
  2. Striped64下面有几个比较重要的成员函数
    成员函数
  3. 最重要的两个
    最重要的两个
  4. Striped64中一些变量或者方法的定义
    Striped64中一些变量或者方法的定义
5.4.4、Cell

是java.util.concurrent.atomic下Striped64的一个内部类
Cell静态内部类

5.4.5、LongAdder为什么这么快
  1. LongAdder的基本思路就是分散热点,将value值分散到一个Cell数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个卡槽中的变量值累加返回。
  2. sum()会将所有Cell数组中的value和base累加作为返回值,核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点
    分散热点
  • 总结
    • 内部有一个base变量,一个cell[]数组。
      • base变量:低并发,直接累加到该变量上
      • Cell[]数组:高并发,累加进各个线程自己的槽Cell[i]中
    • 数学公式
      数学公式

5.5、源码解读深度分析

5.5.1、小总结
  • LongAdder在无竞争的情况下,跟AtomicLong一样,对同一个base进行操作,当出现竞争关系时则是采用化整为零分散热点的做法,用空间换时间,用一个数组cells,将一个value拆分进这个数组cells。多个线程需要同事对value进行操作时候,可以对线程id进行hash得到hash值,再根据hash值映射到这个数组cells的某个下标,再对该下标所对应的值进行自增操作。当所有线程操作完毕,将数组cells的所有值和base都加起来作为最终结果。
    LongAdder底层原理
5.5.2、LongAdder.increment()为什么快(三步走)
5.5.2.1、add(1L)
  1. 最初无竞争时职只更新base
  2. 如果更新base失败后,首次新建一个Cell[]数组
  3. 当多个线程竞争同一个Cell比较激烈时,可能就要对Cell[]扩容
    add(1L)方法
    源码解析
    简化说明
5.5.2.2、longAccumulate
  1. longAccumulate入参说明
    longAccumulate()方法入参说明
  2. Striped64中一些变量或者方法的定义
    变量或者方法定义
  3. 步骤
    • probe
      线程hash值
    • 总纲
      longAccumulate方法总纲说明
      上述代码首先给当前线程分配一个hash值没然进入一个fob(;;)自旋,这个自旋分为三个分支:
      1、CASE2:Cell[]数组未初始化(首次创建)
      首次创建
      2、CASE3:Cell[]数组正在初始化中
      初始化中走base
      3、CASE1:Cell[]数组已经初始化
      • 多个线程同时命中一个cell的竞争
      • 总体代码
if ((as = cells) != null && (n = as.length) > 0) {
    if ((a = as[(n - 1) & h]) == null) {
        if (cellsBusy == 0) {       // Try to attach new Cell
            Striped64.Cell r = new Striped64.Cell(x);   // Optimistically create
            if (cellsBusy == 0 && casCellsBusy()) {
                boolean created = false;
                try {               // Recheck under lock
                    Striped64.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
    else if (a.cas(v = a.value, ((fn == null) ? v + x :
            fn.applyAsLong(v, x))))
        break;
    else if (n >= NCPU || cells != as)
        collide = false;            // At max size or stale
    else if (!collide)
        collide = true;
    else if (cellsBusy == 0 && casCellsBusy()) {
        try {
            if (cells == as) {      // Expand table unless stale
                Striped64.Cell[] rs = new Striped64.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
    }
    h = advanceProbe(h);
}

第一种情况
第二种情况
第三种情况
第四种情况
第五种情况
第六种情况
总体流程图如下:
总体流程图

5.5.2.3、sum
  1. sum()会将所有cell数组中的value和base累加作为返回值
  2. 核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点
  3. 为什么在并发情况下sum的值不精确?
    • sum在执行时,并没有限制对base和cells的更新。所以LongAdder不是强一致性的,它是最终一致性的
    • 首次,最终返回的sum局部变量,初始被复制为base,而最终返回时,很可能base已经被更新了,而此时局部变量sum不会更新,造成不一致。其次,这里对cell的读取也无法保证是最后一次写入的值。所以,sum方法在没有并发的情况下,可以获得正确的结果。
    /**
     * Returns the current sum.  The returned value is <em>NOT</em> an
     * atomic snapshot; invocation in the absence of concurrent
     * updates returns an accurate result, but concurrent updates that
     * occur while the sum is being calculated might not be
     * incorporated.
     *
     * @return the sum
     */
    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;
    }

5.6、使用总结

5.6.1、AtomicLong
  1. 线程安全,可允许一些性能损耗,要求高精度时可使用
  2. 保证精度,性能代价
  3. AtomicLong是多个线程针对单个热点值value进行原子操作
5.6.2、LongAdder
  1. 当需要在高并发下有较好的性能表现,且对值得精度要求并不高时,可以使用
  2. 保证性能,精度代价
  3. LongAdder是每个线程拥有自己的槽,各个线程一般只对自己槽中的那个值进行CAS操作

6、小总结

6.1、AtomicLong

  1. 原理
    • CAS+自旋
    • incrementAndGet
  2. 场景
    • 低并发下的全局计算
    • AtomicLong能保证并发情况下计数的准确性,其内部通过CAS来解决并发安全性的问题
  3. 缺陷
    • 高并发后性能急剧下降
    • AtomicLong的自旋会称为瓶颈
      N个线程CAS操作修改线程的值,每次只有一个成功过,其他N-1失败,失败的不停的自旋知道成功,这样大量失败自旋的情况,一下子CPU就打高了

6.2、LongAdder

  1. 原理
    • CAS+Base+Cell数组分散
    • 空间换时间并分散了热点数据
  2. 场景
    • 高并发下的全局计算
  3. 缺陷
    • sum求和后还有计算线程修改结果的话,最后结果不够准确
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值