【JAVA归纳】☀️ Lock - 总结

Lock的作用 

由于多线程访问共享资源时,线程间因在同一时刻操作共享资源,可能导致共享资源不准确,线程获取的结果可能有误 。

所以为保障每个线程获取 / 操作的结果是准确的,就需要用 Lock 来实现。 

Lock究竟是怎样的一个东西?🙃

饭要一口一口吃,想要弄明白lock 这个东西,得先知道对象内存中究竟是一种怎样的结构。

对象的内存结构分为3块:对象头(markword、类型指针)实例数据对齐填充(忽略)

📌实例数据 指的是 对象的成员变量," 其实对象在堆中所占的空间都是用来存放成员变量的!"        

📌对象头中的类型指针 指的是 当前对象是哪个Class的,在JVM堆中存放着Class对象,而每个实例对象就是根据这个类型指针来归纳到属于哪个Class(大类)中。

📌对齐填充 指的是 将对象的大小填充至8的倍数。为什么?作为CPU、寄存器的总线每次读取的数据大小应该要刚好填满寄存器效率才高。以64位操作系统来说,对应的寄存器大小也是64位,所以每次往寄存器放东西也应该放8个字节的数据大小,才保证寄存器刚好满负载,得到最大限度使用,因此对象不是8的倍数,就得填充够。


重点是对象头中的markword

我们来看看 markword 的结构(以64位操作系统为例)🧐

当对象处于不同锁状态下,它的内存分配就是这样子的了。

观察到它的 【无锁、轻量级锁状态】=【00】,【重量级锁】=【10】,【偏向锁】=【101】

.....................so easy👌

事不宜迟,现在就进行实战验证😉,上代码:

无锁对象🔽

 偏向锁对象🔽 

ps. 要看到偏向锁的效果,需要等JVM启动后才可以看到。在JVM启动的过程中会创建多个线程,这些线程都是启动JVM所必须的,避免不了出现内存资源的争抢,因此需要待JVM启动后,才进行对象的创建,避免在创建对象的过程中也参与进内存资源的争夺战中。

轻量级锁对象🔽 


暂时扩展到这里....👍

接下来回归正题

Lock其实就是一个标识,当对象被打上这个标识的时候,就意味着同一时刻只能允许一个线程对其进行使用。

Lock它只是一个接口,具体有哪些实现类?🙄

当对象被打上锁的标识后==资源被一个线程独占,其它的线程要想去使用这个对象,这时JVM或者说操纵系统(OS)应该怎么样处理?

同样的,饭要一口一口吃,需要知道 " java语言的线程模型 " 是怎样的!为什么要强调是java 语言的线程模型? 因为不同语言对应内核

都有属于各自的线程模型。

📌而 java语言的线程模型是 【java通过new Thread()创建的一个线程 对应 内核的一个线程,意思就是 java线程:内核线程 = 1:1】,

为什么通过 new Thread() 创建的线程 就和 OS的线程 成 1:1关系呢?答案是:new Thread()方法实际调用的是本地方法创建的。


目光转回来。

基于这个线程模型,当对象被独占,而其他线程想要使用的话,就需要通过 Lock接口提供的实现类进行上锁和解锁操作和线程间的通信(这个等下说),看一下具体有哪些实现类(我是看jdk 1.8版本的)🔽 

ReentrantLock 剖析

ReentrantLock 是一把可重入的锁,什么叫可重入?意思就是当一个线程获取到这个对象的锁之后,可以再次获取这把锁,不需要因可能拿锁失败而被阻塞等待。

这个很好理解......😁

📌其工作原理是咋样的?

在分析之前,需要知道一个极其重要的概念,相信有了解并发编程的伙伴都知道—— CAS 和 AQS  😫

📌CAS(compareAndSet.....)是轻量级锁的实现原理,这里又可以进行扩展了😄

-------有轻量级锁就有重量级锁,那什么是轻量级锁?什么是重量级锁?两者间的关系又是怎样的?

在老版本的 jdk中,是通过重量级锁来进行线程同步操作,通过对系统总线加锁从而达到资源独占的效果;当有一定并发量

但并发又不过高的情况下,这种加锁方式就显得尤为耗性能。鉴于此就衍生出了轻量级锁,轻量级锁是一个概念,目的是

将操作系统管理线程的工作交给了JVM进行管理,JVM对线程进行资源的访问进行控制。继续探究,它们之间的关系是怎样的?

它们之间存在一个升级的过程,是这样的🔽

CAS处理过程是这样的🔽

但CAS也存在一些问题:ABA问题,自旋方式的局限性,范围不能灵活控制。

✍ABA问题具体是什么?

线程A和线程B同时改变内存v(值为0),线程A正在准备开始执行CAS时,线程B已经将内存v的值改为1后又改为0,对于线程A进行CAS时是察觉不到值已经被修改过了。

解决办法:加版本号 或者  使用AtomicStampedReference

✍自旋方式的局限性?

由于一次CAS并不一定能够成功,所以往往会将CAS操作放入到循环中,而每次CAS都需要CPU切换线程来进行比较,一旦线程数多了,CPU来回切换频繁而且又不成功,白白浪费CPU资源,所以在高并发场景下CAS效率并不高。

✍范围不能灵活控制?

CAS只能对单个变量进行比较,而非多个变量。

CAS的底层是通过C++实现的

Unsafe类中的compareAndSwapXX是一个本地方法,该方法的实现位于unsafe.cpp中

1. 先想办法拿到变量value在内存中的地址;
2. 通过Atomic::cmpxchg实现比较替换,其中参数X是即将更新的值,参数e是原内存的值;

📌AQS(AbstractQueueSynchronizer)是一个除了synchronized关键之外的锁机制,实现了锁是怎样工作的,也可以视作一个框架,通过继承的方式进行锁机制的重写从而实现各种锁处理的方式,最重要的是其中含有一个双向的同步队列(CLH)。

📌CLH同步队列 是将多个Node节点(由线程包装而成)连接在一起的一个抽象队列。

 💡. 现在就可以结合AQS、CAS、CLH去看看ReentrantLock是如何实现锁功能的。

加锁解锁两方面进行探究.........

首先看看ReentrantLock的类结构🔽

好戏来了,要有心理准备,过程比较枯燥🤕

从一个简单的例子入手:

public class ReentrantLockTest {
    
    public static void main(String[] args) {

        // 默认非公平锁实现
        ReentrantLock lock = new ReentrantLock();

        // 公平锁实现
        //ReentrantLock lock = new ReentrantLock(true);
        
        new Thread(()->{
            //1. 上锁
            lock.lock();
            try{
                System.out.println("..........");
                TimeUnit.SECONDS.sleep(60*60);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                //2. 解锁
                lock.unlock();
            }
        }).start();
    }
}

📌上锁

//1. 进入lock方法
public void lock() {
    //2.使用非公平锁实现 
    sync.lock();
}
static final class NonfairSync extends Sync {
        private static final long serialVersionUID = 7316153563782823691L;

        //1.进入到非公平锁实现
        final void lock() {

            //2. 试想一下,当多个线程走到这里,是通过CAS操作来争抢修改state的值(代表持有锁的状 
            //态),谁修改成功就谁获得到锁(这里就是公平与公平实现的不用之处)
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                //3. 如果当前线程的锁被抢走了,就要排队了...
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
    }
//看看它是怎样排队的...
//第一步:排队前,还不死心,看看还能不能拿到锁---tryAcquire()
//第二部:死心了,就把自己打包成Node然后去排队---addWaiter()
//第三步:在队列中挣扎吧,哈哈!!---acquireQueued()
public final void acquire(int arg) {
 
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

对这三步进行分析:

第一步🔽

//非公平实现
protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }



//进入到这个方法
final boolean nonfairTryAcquire(int acquires) {
            
            //1. 拿到当前的线程
            final Thread current = Thread.currentThread();
            
            //2. 拿到当前锁的状态
            int c = getState();
            
            //3. 状态为0表示没有线程拿到锁,锁处于空闲状态
            if (c == 0) {
                
                //4. 试想一下,当有多个线程同时走到这里进行抢锁,抢到的就返回true
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //4. 就算锁被某线程拿到了,但拿锁的那个线程再次进入到这里就不需要抢了,直接把锁给 
            //它(重入锁的效果)
            else if (current == getExclusiveOwnerThread()) {

                //5. 锁状态+1
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }

            //6. 抢又抢不到,也不是可重入,那只好返回false,安心排队去
            return false;
        }

第二步🔽

private Node addWaiter(Node mode) {

        //1. 将当前线程包装成一个Node节点(下面的注释都用 "当前节点" 代替)
        Node node = new Node(Thread.currentThread(), mode);
        
        //2. 拿到等待对列的尾节点
        Node pred = tail;
        
        //3. 队列不为空,将当前节点 前置指向尾节点
        if (pred != null) {
            node.prev = pred;
            
            //4. 试想一下,如果多个线程都死心了,也跑到这一步争先排队,这里就要进行CAS操作
            if (compareAndSetTail(pred, node)) {
               
                //5. 将原来的尾节点 后置指向当前节点,这样就完成了入队操作
                pred.next = node;
                return node;
            }
        }

        //6. 队列为空,这里应该是创建一个队列,走进这个方法看看
        enq(node);
        return node;
    }



//进入到这个方法,验证一下是否创建队列,答案是正确的
private Node enq(final Node node) {

        //1. 哟,是一个死循环。试想一下,当多个线程冲破重重关卡跑到这里来,看谁能抢到头节点这 
        //个位置了
        for (;;) {
            
            //2. 再次获取一下尾节点(被volatile关键字修饰的,此关键字的作用接下来就会说);
            //为什么要再次获取一下尾节点呢?
            //答:当某个线程占了先机,当了头节点,同时也是尾节点,因为一开始就只有自己那么一个 
            //节点嘛~然后第二个线程进行compareAndSetHead()的时候,肯定失败的,接着就会进入下 
            //一轮循环,其他线程也是同样的命运;到达第二轮循环的时候,就会进入到else{}这里->
            Node t = tail;
            if (t == null) { 
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
 
                //3. ->这里,将当前节点 前置指向尾节点,这里多个线程都以同一个尾节点作为前置 
                //是没问题的,毕竟是预设,能CAS成功才算入队成功呢
                node.prev = t;
                
                //4. CAS操作,争抢当尾节点
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

第三步🔽

//挣扎吧,少年~~~
final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            
            //多个线程都走到这里挣扎,就像一个滚筒洗衣机,一堆衣服在这转...
            for (;;) {
                
                //1. 先拿到当前节点的 前置节点
                final Node p = node.predecessor();

                //2. 如果前置节点是头节点(那当前节点就是排在第二位咯),有希望了,再次尝试去 
                //获取锁,其实进入到这里都是能拿到锁的了
                if (p == head && tryAcquire(arg)) {
 
                    //3. 获取锁成功之后,将当前节点变成队头(其实只有老二才能有资格拿锁,老大 
                    //在此前已经拿过锁了,所以要把机会留给老二,毕竟要人人有份才公平嘛!)
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }

                //3. 而不是老二的线程们,就跑到这里了,这里干了什么呢?估计是挂起这些线程,进 
                //入方法验证一下,推理是正确的!
                //如果达不到被挂起的资格,就继续执行循环,直到当前节点是老二就可以有资格被挂起 
                //了
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())

                    //4. 线程一旦被中断,就抛出中断异常,直接执行下面finally代码块。如果是正 
                    //常唤醒,就直接进入下一轮循环
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }




//-->这里 看这个方法名所表达的意思是 “当获取锁失败时应该要挂起?”
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {

        //1. 看看当前节点 的前置节点正处于何种状态
        //CANCELLED =  1
        //SIGNAL    = -1
        //CONDITION = -2
        //PROPAGATE = -3
        int ws = pred.waitStatus;

        //2. 前置节点处于SIGNAL状态的就表明(当老大的标识),当前节点(老二)需要被挂起了
        if (ws == Node.SIGNAL)
            return true;

        //3. 前置节点大于0意味着放弃获取锁,所以它会往前找,找到状态不是大于0的节点,作为当前 
        //节点的前置节点。说白了就是忽略被放弃的节点,重组队列
        if (ws > 0) {
             
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);

            pred.next = node;
        } else {
             
            //4. 还没有被挂起的资格,也没被取消的线程们就会集中到这里来,都会尝试去修改各自对 
            //应的前置节点的状态,修改好之后直接返回false,进行下一轮循环(把一个个线程挂起 
            //来)
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }


//有资格去挂起的话
private final boolean parkAndCheckInterrupt() {
        //1. 挂起当前线程呗~~
        LockSupport.park(this);

        //--------挂起后,这里会被阻塞,不会往下执行代码-----------

        //两种情况:
        //1. 直到线程被中断,抛出异常,就会执行到上面的finally代码段中了
        //2. 直到线程被唤醒而非中断的话,就返回false
        return Thread.interrupted();
    }

在代码里面走了一遭,感觉不难吧,弄懂了非公平锁的源码,再去理解公平锁就很简单了,我就不细说了。😎在上面的代码中,有一个虚拟的等待队列(CLH),在整个上锁过程,这个队列究竟是怎样的呢?下面就画一下🔽

到这里就说清上锁的过程了。

📌解锁

解锁过程相对来说简单很多了,直接分析源码

//进入unlock方法
public void unlock() {
    //使用sync同步器方法解锁,解锁就不需要分公平或者非公平了 
    sync.release(1);
}
//进入方法
public final boolean release(int arg) {

        //1. 尝试着释放锁,进去瞧瞧->
        if (tryRelease(arg)) {

            //2. 获取CLH队列的头节点(虚拟的头节点)
            Node h = head;
  
            //3. 队列不为空
            if (h != null && h.waitStatus != 0)
                
                //4. 唤醒头节点中的线程,进入这个方法->
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
//->看看是怎样修改锁状态的
//这里不涉及争抢解锁,因为同一时刻就只要一个线程拥有锁,所以这里就不担心线程安全问题
protected final boolean tryRelease(int releases) {

            //1. 先将锁状态-1(重入的状态值>1)
            int c = getState() - releases;

            //2. 必须得是持有锁的线程才能解自己的锁。总不能让别的线程来帮自己解吧
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();

            
            boolean free = false;
            if (c == 0) {

                //3. 如果state=0,代表锁空闲
                free = true;
                setExclusiveOwnerThread(null);
            }

            //4. 更新一下状态
            setState(c);
            return free;
        }
//->到这个方法
private void unparkSuccessor(Node node) {
        
        //1. 拿到头节点的状态
        int ws = node.waitStatus;
        if (ws < 0)
            
            //2.如果在释放锁前,节点没被取消,就把头节点状态还原为 0
            //TODO 明明只有一个线程在进行解锁,为什么要CAS呢?
            compareAndSetWaitStatus(node, ws, 0);

        //2. 到这里就去“叫醒老二”,拿到第二个节点
        Node s = node.next;

        //3. 如果节点为空或者已经被取消了
        if (s == null || s.waitStatus > 0) {
            s = null;

            //4. 从队尾开始遍历,往前找,找到节点状态是-1(这个就是在队列中等待的正常状态)的 
            //紧随老二的节点
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)           
        
                    s = t;
        }

        //5. 唤醒线程
        if (s != null)
            LockSupport.unpark(s.thread);
    }

到这里就分析完解锁过程了,说白了就是找到没被取消而且紧随队首的节点,进行唤醒罢了~~👌

接下来要说的是🔽

线程间的通信

以往线程间进行通信(线程间你叫我等,或者我叫醒你的过程),是使用 object的 wait() 、notify() 、notifyAll() 方法且需要结合synchronized关键字来实现。而到了现在,有一种新

的方式来替代这种实现 —— Condition

我们先看一下以往的方式,直接上例子:

package locks;

public class Synchronized_ {


    public static void main(String[] args){
        Object o = new Object();
        Synchronized_ synchronized_= new Synchronized_();
        new Thread(()->{
                    try {
                        synchronized_.m1(o);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
        ).start();

        new Thread(()->{
            try {
                synchronized_.m3(o);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        ).start();

        //这里会存在问题
        //当此线程优先完成唤醒的时候,而其他线程正在挂起,那么这些线程就永远不会被唤醒了
        new Thread(()->{ synchronized_.m2(o); }).start();

    }


    public void m1 (Object obj) throws InterruptedException {
        synchronized (obj){
            System.out.println("m1执行");
            obj.wait();
            System.out.println("m1唤醒");
        }
    }

    public void m3 (Object obj) throws InterruptedException {
        synchronized (obj){
            System.out.println("m3执行");
            obj.wait();
            System.out.println("m3唤醒");
        }
    }


    public void m2(Object obj){
        synchronized (obj){
            obj.notifyAll();
        }
    }
}

wait():当线程执行这个方法的时候,会释放对这个对象的锁,然后进入等待队列挂起;

notify() \ notifyAll():当线程执行到这个方法的时候,会唤醒等待队列中一个或者全部线程;

我们再看看condition是怎样处理的:

package locks;

import java.util.PriorityQueue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Condition_ {

    private int queueSize = 10;
    private PriorityQueue<Integer> queue = new PriorityQueue<Integer>(queueSize);
    private Lock lock = new ReentrantLock();
    private Condition notFull = lock.newCondition();
    private Condition notEmpty = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        Condition_ test = new Condition_();
        Producer producer = test.new Producer();
        Consumer consumer = test.new Consumer();
        producer.start();
        consumer.start();
        Thread.sleep(10);
        producer.interrupt();
        consumer.interrupt();

    }


    class Consumer extends Thread{
        @Override
        public void run() {
            consume();
        }
        volatile boolean flag=true;
        private void consume() {
            while(flag){
                lock.lock();
                try {
                    while(queue.isEmpty()){
                        try {
                            System.out.println("队列空,等待数据");
                            notEmpty.await();
                        } catch (InterruptedException e) {
                            flag =false;
                        }
                    }
                    queue.poll();                //每次移走队首元素
                    notFull.signal();
                    System.out.println("从队列取走一个元素,队列剩余"+queue.size()+"个元素");
                } finally{
                    lock.unlock();
                }
            }
        }
    }

    class Producer extends Thread{
        @Override
        public void run() {
            produce();
        }
        volatile boolean flag=true;
        private void produce() {
            while(flag){
                lock.lock();
                try {
                    while(queue.size() == queueSize){
                        try {
                            System.out.println("队列满,等待有空余空间");
                            notFull.await();
                        } catch (InterruptedException e) {

                            flag =false;
                        }
                    }
                    queue.offer(1);        //每次插入一个元素
                    notEmpty.signal();
                    System.out.println("向队列取中插入一个元素,队列剩余空间:"+(queueSize-queue.size()));
                } finally{
                    lock.unlock();
                }
            }
        }
    }
}

await()  == wait()

signal() == notify()

signalAll() == notifyAll()

两者的区别是:condition 更安全,更灵活

通过示例都知道怎么进行线程间的通信了,接下来剖析一下condition的工作原理🔽

 📌挂起等待

public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();

            //1. 添加到condition等待队列,看看是怎样添加到队列的->
            Node node = addConditionWaiter();

            //2. 释放当前线程持有的锁,其他线程可以获取锁了
            int savedState = fullyRelease(node);
            int interruptMode = 0;

            //3. 如果在同步队列就跳出循环,否则直接挂起不加入同步队列。
            while (!isOnSyncQueue(node)) {

                //4. 当前线程一直在这里阻塞住,等到被唤醒才继续往下执行
                LockSupport.park(this);

                //5. 线程被唤醒
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }



//->进入此方法
private Node addConditionWaiter() {
            
            //1. 这个lastWaiter是ConditionObject中的成员变量,是一个Node节点
            Node t = lastWaiter;

            //2. 如果队列不为空,且 尾节点的状态不是 -2
            if (t != null && t.waitStatus != Node.CONDITION) {
                
                //3. 移除节点
                unlinkCancelledWaiters();

                //4. 等待队列经过处理后,再拿尾节点
                t = lastWaiter;
            }

            //5. 将当前线程包装为 状态-2 的节点(代表是condition类型的)
            Node node = new Node(Thread.currentThread(), Node.CONDITION);

            //6. 加入conditionObject里面的队列,一个lock有多个conditionObject
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }


//删除节点
private void unlinkCancelledWaiters() {
            
            //1. 拿到第一个等待节点
            Node t = firstWaiter;

            Node trail = null;

            //2. 遍历整个等待队列
            while (t != null) {

                //3. 拿到下一个节点
                Node next = t.nextWaiter;

                //4. 节点不等于 -2
                if (t.waitStatus != Node.CONDITION) {

                    //5. 将当前节点移出队列
                    t.nextWaiter = null;

                    if (trail == null)
                        firstWaiter = next;
                    else
                        trail.nextWaiter = next;
                    if (next == null)
                        lastWaiter = trail;
                }
                else
                    trail = t;
                t = next;
            }
        }

📌唤醒

public final void signal() {
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            Node first = firstWaiter;
            if (first != null)

                //1. 唤醒condition队列中的第一个节点
                doSignal(first);
        }
private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&
                     (first = firstWaiter) != null);
        }



//主要是这个方法进行唤醒操作
final boolean transferForSignal(Node node) {
        
        //1. 将condition队首节点状态改为0
        if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
            return false;

        //2. 将队首节点加入CLH同步队列中
        Node p = enq(node);
        int ws = p.waitStatus;
        if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
            
            //3. 唤醒
            LockSupport.unpark(node.thread);
        return true;
    }

结合流程图有助于理解代码,来看看整个流程是怎样的🔽

 

 以上就是condition的内容了。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值