Java并发 总结

并发与并行

并发与并行在日常开发中经常被提到,很容易混淆,但一字之差,意思却大不相同。

  • 并发一般指的是在指定时间窗口内处理请求的数量;
  • 并行指的是在同一时刻同时执行的任务数;

可以从实现机制上来理解并发和并行,并发是通过cpu在不同的线程之间切换时间片来实现的,也就是说在单核cpu下也可以实现并发,而并行则是通过cpu多核技术实现的,由于每个cpu核心在同一时刻只能运行一个线程,引入多核后,同一时刻就可以在不同的cpu核心上同时执行多个线程。

JMM(Java内存模型)

这部分内容比较抽象,小编尽量从自己的理解触发,把这个问题说清楚。

共享资源的线程安全问题是并发编程中面临的最重要的问题之一,小编觉得,共享资源的线程安全性可以从三个方面去看,分别是:

  • 原子性:类似于事务,某些操作不允许有中间状态,主要挑战来源于cpu时间片的轮转;
  • 可见性:一个线程对数据的写要对另一线程可见,主要挑战来源于线程本地缓存;
  • 有序性:保证代码执行的有序性,主要挑战来源于Java编译器和cpu的乱序执行优化;

以上三点是我们进行多线程编程时,需要面对的主要问题。在具体聊JMM前,需要先对MESI缓存一致性协议有一个简单的了解。

MESI的全称是Modified-Exclusive-Shared-Invalid的简写,它是一种缓存一致性协议,主要用来解决cpu缓存与主内存之间的数据不一致的问题,下图是计算机cpu、缓存、和内存的一个抽象结构:
在这里插入图片描述
由于CPU运算速度和主内存的读写性能之间存在着巨大的差异,所以在cpu中引入多级缓存来避免cpu提供数据的IO速度,避免cpu由于主内的性能问题导致停摆,但由于每个cpu都有自己的缓存,如何保证这些cpu缓存数据的一致性 避免脏读就成了第一道拦路虎,为此,伟大的人类提出了MESI缓存一致性协议,下面以一个简单的例子来说明一下啥叫MESI。

在这里插入图片描述
假如有两个线程A和B对主内存中的同一个遍历x执行 x+=1操作,当线程A从主内存中读取了x后,总线上x的状态为E(Exclusive),当线程B也从主内存中读取x时,总线上x的状态变成了S(Shared),当线程A向主内存写入计算后的x值时,总线上x的状态变成了M(Modified),写入完成后,总线上x的状态变为I(Invlid),最终的这个状态I,会导致B线程所在的cpu缓存中x的值失效,最终内存模型的抽象就编程了这样

在这里插入图片描述
在主内存和cpu缓存之间多了一层MESI协议,以小编的理解,MESI协议主要解决的是内存可见性问题,对于并发编程中的另外两个问题-原子性和顺序性却无能为力。

MESI协议毕竟是协议,并不是具体的实现,Java为了解决这些并发编程中的问题,就在MESI的基础上提出了JMM,即Java Memory Model - Java内存模型,小编自己理解MESI和JMM的关系是这样的:

class abstract MESI {
	abstract void 原子性();
	abstract void 顺序性();
	void 可见性() {
		吧啦吧啦
	}
}

class JMM extends MESI {
	void 原子性() {
		吧啦吧啦
	}
	void 顺序性() {
		吧啦吧啦
	}
}

一切看起来都很美好,在JMM的保驾护航下,你觉得可以尽情的遨游在并发编程的海洋里,但是现实总喜欢打脸,JMM它说白了也是一个规范,这些规范就是一些列的happens-before规则,也就是说JMM和MESI都是只会说不会干的家伙,具体的happens-before规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
  4. 传递性规则:A happens-before B,B happens-before C,那么A happens-before C;
  5. start()规则:A线程的B.start() happens-before与B线程的任意操作;
  6. join()规则:A.join(B),那么B的任意操作 happens-before与A的后续所有操作;

那么,这些规则在Java中如何实现的呢?体现到语言层面,就是我们经常用到的volatile、synchronized、final、Lock以及concurrent包下的各种并发组件,利用这些脚手架,Java程序员就可以实现并发安全的程序。

这些并发编程脚手架都有自己不同的实现原理,例如volatile可以保证内存可见性和禁用重排序优化,它是通过在指令中加入#lock标记实现的,synchronized可以保证顺序性、可见性和原子性,底层利用monitorenter和monitorexist指令实现,final提供不可变性保证(不可变性也实现并发安全的一种方式,例如经典的并发框架akka就是利用消息的不可变性实现了简单是并发模型),而Lock底层基于AQS在语言层面实现了锁的语义。

以上就是小编对JMM的理解,包含了从MESI协议到最终的Java并发编程脚手架的演进过程。

Java线程基础

什么是线程

在计算机中,进程是资源分配的最小单位,一个进程拥有独立的内存空间,在线程内部,包含了多个线程,线程是计算机运行的最小单元。

多线程编程的收益与代价

随着摩尔定理逐渐走向失效,计算机在纵向的性能提升越来越困难,进而就发展出了多cpu多核处理器技术,通过并行尽可能的压榨计算机性能就成了有效的方法。Java原生支持多线程,在日常开发中也经常被用到,使用多线程可以代理很多好处,比如:

  • 简化编程模型
  • 加快响应时间
  • 利用更多的计算资源

但是,凡事都有两面性,多线程带来好处的同时,也会引发很多问题,比如:

  • 共享资源的线程安全问题;
  • 产生死锁;
  • 性能提升受限于系统资源;
线程优先级

在java中,线程优先级被分为从1~10级别,默认级别为5,理论上,线程优先级越大,分配到cpu时间片的概率就越大,但是这并不是一定的,这取决于操作系统的实现,在有些操作系统中,甚至世界忽略了线程的优先级,我们在编写并发程序时,不能依赖于线程优先级,因为这是不确定的。

线程的状态

一个线程的状态可以分为以下几种

  • 初始态
  • 运行
  • 阻塞
  • 等待
  • 超时等待
  • 结束
    在这里插入图片描述
Daemon线程

Daemon线程是一张后台线程,它依附于启动它的主线程,如果主线程结束,那么Daemon线程会立即停止运行。可通过Thread.setDaemon(true);来设置一个线程为Daemon线程。

另外,在适应Daemon线程是,需要注意的是,不能依赖于try{}finally{}做任何事情,因为当主线程结束后,Daemon线程会被立即终止。

线程启动

new Thread(Runnable).start();

线程中断

线程中断是一种线程结束的途径,与线程中断的方法有三个,分别是:

  • interrupt():修改线程中断表示;
  • isInterrupted():当调用interrupt()方法时,该方法返回true;
  • interrupted():Thread.interrupted(),用来重置当前线程的中断状态;

线程中断结束的正确范式:


class MyTask implements Runnable {
	volatile boolean flag;
	public void run() {
			while(!flag && !isInterrupted()) {
					...
			}
	}
	public vodi setFlag(boolean flag) {
		this.flag = flag;
	}
}
线程间通信

线程之间通信的方式有两种,一种是共享变量,一种是消息传递,其中共享变量时最常用的一种,通常利用synchronized + wait()/notify()/notifyAll()来实现线程间的协作,这种实现线程间通信的编程范式如下:

synchronized(monitorObj) {
	while(condition != true) {
		wait();
	}
	do something;
}

synchronized(monitorObj) {
	do something();
	monitorObj.notifyAll();
}
ThreadLocal

ThreadLocal是一种实现线程安全的常用方案,它被称为线程封闭,可以为每个线程存储数据自己的数据。ThreadLocal底层实际上就是一个Map<Thread,Value>结构,但他的应用却非常广泛,很多时候也被用于在一个业务处理流程中的各个不同阶段共享遍历,比如用户登录信息等。

Synchronized原理

synchronized是java中最早的解决多线程同步的方式,它可以修饰静态方法、成员方法和同步代码块,其中修饰静态方法时用到的锁对象时类的Class对象,修饰同步方法时用到的锁对象是当前对象,同步代码块中用到的锁对象时在代码块中制定的锁对象。

synchronized在早期是一个重量级锁,加锁和解锁效率都比较低,但是在jdk 1.6以后,对synchronized进行了优化,引入了偏向锁、轻量级锁,在锁竞争不激烈的情况下,可以很大程度上的提高锁的加锁和解锁效率。

锁的状态是由jvm自动判断的,当只有一个线程调用被synchronized修饰的方法时,就会应用偏向锁,在对象头中设置当前锁所偏向的线程id,当这个线程再次获取锁时,并不会真正加锁,而是判断偏向线程的id是否为当前线程。当有多个线程存在竞争时,偏向锁会升级为轻量级锁,轻量级锁是用cas的方法将锁对象头设置为当前线程的mark world,如果设置成功则成功获取锁,偏向锁会出现在存在竞争但在很短的时间内就可以从cas循环中退出,即成功获取到锁的场景,主要针对于短小任务,如果竞争情况进一步加剧,synchronized就会升级为重量级锁。

AQS 同步工具

AQS是AbstractQueuedSynchronizer的简称,它是Java提供的一个底层同步工具类,使用int类型的变量去表示同步状态,并通过一些列的cas操作来线程安全的管理这个同步状态,AQS的主要作用是为Java中的同步组件提供统一的底层支持,例如ReentrantLock、CountdownLatch等。

AQS中包含了两种队列:一种是同步队列,一种是等待队列。同步队列用来实现线程同步,等待队列用于实现等待通知机制。
在这里插入图片描述
在这里插入图片描述
同步队列主要用来存储获取锁获取失败的线程,它包含了head节点和tail节点,其中head节点指向了当前已经成功获取同步状态的线程,当它执行完成后,会利用LockSupport.park()方法唤醒后面正在等待的节点,当线程获取同步状态失败时,会被构造成Node节点添加到线程的末尾。
在这里插入图片描述
同步队列的作用是实现等待通知机制,对应了Lock.Condition对象上的await()和signal()方法,一个成功获取锁的线程调用await方法时,会将其从同步队列的头结点的位置移动到等待队列的尾节点,并唤醒他后面的节点,此时,会释放同步状态。当调用signal方法时,会将等待队列中的头节点移动到同步队列的尾部,此处并没有用到cas,因为能调用signal的方法的肯定是成功获取了锁的线程。

JUC中各种锁的使用及原理

JUC中的很多锁实现以及并发工具类都是基于AQS来实现的,下面就来总结一下各种锁的实现原理。JUC中的锁基本上可以分为四类:

  • 独占锁
  • 共享锁
  • 读写锁
  • 可中断锁
  • 超时锁

其中,可中断锁和超时锁是上面三种锁都具有的两个特性,下面我们一一来看。

首先来看独占锁的获取和释放原理。

class NonfairSync extends Sync { 
	final void lock() {
	        /**
				非公平模型下,一进来就会抢占锁状态
			*/
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
            //如果抢占失败,则调用acquire方法
                acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
}
public final void acquire(int arg) {
/**
	这里做了三件事
	1.尝试通过模板方法获取锁状态
	2.获取失败,则构造一个Node节点,并利用cas的方式线程安全的将其添加到同步队列末尾
	3.让被构造成Node节点的线程进入自旋状态
*/
if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
}
final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //这里实现了可重入的逻辑,如果是同一个线程返回调用lock
            //那么就会增加同步状态的值
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
 }
  • 锁的获取
  1. 调用AQS的acquire()方法;
  2. 调用模板方法tryAcquire(args)尝试获取同步状态;
  3. 如果获取失败,则将当前线程构造成一个Node节点;
  4. 利用cas线程安全的把这个节点添加到同步队列末尾;
  5. 让该线程进入自旋转状态,等到被前驱节点唤醒后,再尝试获取同步状态;
public final boolean release(int arg) {
        //调用模板方法释放同步状态
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
}

protected final boolean tryRelease(int releases) {
            //释放同步状态
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            //这里目的是为了实现重入锁的释放,只用同步状态为0时,才表示释放锁成功
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }
  • 锁的释放
  1. 调用release()方法;
  2. 调用模板方法tryRelease()是否同步状态,知道同步状态为0时表示释放成功;
  3. 获取当前节点的next节点,并利用LockSupport.unpark()方法唤醒该线程;
  4. next节点被唤醒后,判断它的prev节点是否为head节点并且是否能够获取同步状态;
  5. 如果都成立,则获取锁成功,如果获取失败,则利用LockSupport.park()重新进入等待状态;

下面看一下共享锁的获取和释放流程。

protected int tryAcquireShared(int acquires) {
            for (;;) {
                if (hasQueuedPredecessors())
                    return -1;
                int available = getState();
                //尝试获取同步状态
                int remaining = available - acquires;
                if (remaining < 0 ||
                    compareAndSetState(available, remaining))
                    return remaining;
            }
}
public final void acquireShared(int arg) {
		//如果同步状态小于0了,说明资源以及被获取殆尽
        if (tryAcquireShared(arg) < 0)
        
            doAcquireShared(arg);
    }
private void doAcquireShared(int arg) {
		//构造节点
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            //自旋
            for (;;) {
            	//获取当前节点的前一个节点
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    //如果当前节点是head节点并且成功获取了同步状态
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                //否则进入阻塞状态
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }    
  • 锁的获取
  1. 调用acquireShared()方法;
  2. 调用tryAcquireShared()模板方法尝试获取同步状态;
  3. 如果返回值>=0,说明还有同步资源可以获取,成功获取锁
  4. 如果返回值<0,说明同步资源都被占用了,这是当前线程会被构造成节点加入到同步队列尾部
  5. 它的prev节点调用LockSupport.unpark()将其唤醒后,会判断它的前一个节点是否为head并且是否能够获取同步状态
  6. 如果是,则说明获取锁成功,将自己设置为头结点
  7. 如果否,则再次利用LockSupport.park挂起自己

下面来看看读写锁的实现原理和锁获取及释放流程:
该读写锁到实现原理是:将同步变量state按照高16位和低16位进行拆分,高16位表示读锁,低16位表示写锁
在这里插入图片描述

  • 写锁的获取与释放
protected final boolean tryAcquire(int acquires) {
            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c);
            if (c != 0) {
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                setState(c + acquires);
                return true;
            }
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }
  1. 获取同步状态,并从中分离出低16为的写锁状态
  2. 如果同步状态不为0,说明存在读锁或写锁
  3. 如果存在读锁(c !=0 && w == 0),则不能获取写锁(保证写对读的可见性)
  4. 如果当前线程不是上次获取写锁的线程,则不能获取写锁(写锁为独占锁)
  5. 如果以上判断均通过,则在低16为写锁同步状态上利用CAS进行修改(增加写锁同步状态,实现可重入)
  6. 将当前线程设置为写锁的获取线程
  • 写锁的释放
protected final boolean tryRelease(int releases) {
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            int nextc = getState() - releases;
            boolean free = exclusiveCount(nextc) == 0;
            if (free)
                setExclusiveOwnerThread(null);
            setState(nextc);
            return free;
        }

在释放的过程中,不断减少读锁同步状态,只为同步状态为0时,写锁完全释放。

  • 读锁的获取和释放
    读锁是一个共享锁,获取读锁的步骤如下
  1. 取当前同步状态;
  2. 计算高16为读锁状态+1后的值;
  3. 如果大于能够获取到的读锁的最大值,则抛出异常;
  4. 如果存在写锁并且当前线程不是写锁的获取者,则获取读锁失败;
  5. 如果上述判断都通过,则利用CAS重新设置读锁的同步状态;

读锁的获取步骤与写锁类似,即不断的释放写锁状态,直到为0时,表示没有线程获取读锁。

下面,以超时独占锁为例来说明超时锁的实现原理。

private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
         //这里计算了一下超时的截止时间   
        final long deadline = System.nanoTime() + nanosTimeout;
        //构造节点
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
           //进入自旋
            for (;;) {
                //这里是唤醒后的逻辑,根独占锁一样
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                //这里计算剩余时间
                nanosTimeout = deadline - System.nanoTime();
                //如果已经超时了,直接返回false
                if (nanosTimeout <= 0L)
                    return false;
                //如果剩余时间 > 1000ns,则再次挂起,否则,进行自旋等待,主要为了
                //避免由于线程的挂起和唤醒导致超时时间不准
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

最后在看看可中断锁的实现逻辑。正常情况下,处在自旋等待中的线程,对其调用interrupt方法并不会真正中断它的等待状态

private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
                //在这里,当自旋状态被唤醒后,会检查当前线程的同步状态
                //如果Thread.interrupted()返回true,那么久抛出中断异常
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
 }

并发集合

ConcurrentHashMap、ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue、SynchronousQueue、LinkedTransforQueue。

线程池原理

线程池可以说是JUC中最常用的组件之一,因为在日常开发有,有很多需要异步和多线程处理的场景。线程池的原理相对于AQS来说要简单很多,下面我们就来看一下吧。
在这里插入图片描述
这张图摘自《Java并发编程的艺术》,描述了juc中线程池的原理,实际上,理解线程池原理值需要弄清楚ThreadPoolExecutor构造函数中的几个参数就可以了:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) { 
}

这是ThreadPoolExecutor的核心构造函数,下面分别解释一下这些参数的含义:

  • corePoolSize:核心线程数,表示线程池中长期活跃的线程的数量;
  • maximumPoolSize:最大线程数,线程池中能够启动的最大线程数;
  • keepAliveTime/unit:这两个参数组合在一起定义了(maximumPoolSize - corePoolSize)这部分线程的最大空闲时间,如果超过这个时间仍然没有新任务,则线程资源就会被回收;
  • workQueue:等待队列,当活跃线程数达到核心线程数并都处于工作状态时,新进任务就会被添加到等待队列;
  • threadFactory:线程工厂,用于创建线程池中的线程,一般通过自定义线程工厂来定义线程的名称;
  • handler:拒绝策略,当线程书已经达到最大线程数时,就会触发拒绝策略,保证系统资源背会被耗尽;

了解了这几个核心参数的作用实际上也就基本清楚了线程池的工作原理了,配合上面的流程图,再来描述一下线程的的工作原理:首先,我们通过submit或execute方法向线程池提交任务,如果当前线程数没有达到核心线程数,就会新启动一个线程来执行,不管其他线程是否处于空闲状态(这其实是为了尽快对线程池进行预热,使其进入工作状态),如果以及达到核心线程数,并且没有空闲线程,那么新进任务就会被扔到等待队列中,当核心线程中有空闲的线程了,就会从等待队列中获取任务并执行,但是,如果等待队列也以及达到最大上限了,那么就会再启动临时线程想,相当于雇一些临时工,来执行这些任务,执行完成之后,如果在指定时间内,没有进行的任务过来,这些临时工就会被解雇,也就是销毁掉,但如果线程数已经达到了最大上限,那么就会触发拒绝策略,来保护系统资源,避免无限创建线程,耗尽系统资源,有以下几种拒绝策略可供选择:

  • AbortPolicy:直接抛出异常;
  • CallerRunsPolicy:甩给提交任务的线程区执行;
  • DiscardOldestPolicy:丢掉队列中最早被提交的任务;
  • DiscardPolicy:直接丢掉不处理;

具体选择哪种策略还需要根据不同的场景进行选择。

同时,juc还提供另一个Executor框架,提供了一些默认的针对不同场景的线程池实现,比如:

  • FixedThreadPool:可重用固定线程数线程池;
  • SingleThreadPool:只有一个线程的线程池;
  • CachedThreadPool:可根据需要自动创建线程的线程池(maxPoolSize=Integer.MAX_VALUE);
  • ScheduledThreadPoolExecutor:用于执行定时任务的线程池;

其中,前三种虽然看起来比较方便,但是日常工作中尽量不要使用,在阿里编码规约中也明确禁止使用,原因在于,这三种线程池内部都使用了无界阻塞队列LinkedBlockingQueue,队列长度没有限制,极易出现大量任务堆积最终造成内存溢出的问题,一般,我们在使用线程池的时候回自己指定线程池的核心参数,例如:

  • 根据需求来确定核心线程数和最大线程树;
  • 自定义线程工厂,给线程指定有意义的名称,方便查问题排查故障;
  • 自定义实现拒绝策略,或记录日志或进行补偿处理;

ScheduledThreadPoolExecutor在一些定时任务的场景会替代传统的java.util.Timer,ScheduledThreadPoolExecutor解决了Timer的一些弊端,比如异常会导致Timer运行中断等。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值