Java线程安全与并发

终于遇见你,还好我没放弃……  咳咳,前面铺垫了快好几章了,今天来个痛快吧,聊聊线程安全。

线程安全是什么?线程安全就是一个进程内的数据被多个线程共享,要避免数据被多线程操作出现不正确的结果。如int n=100;  每个线程都执行n--后输出,正常情况是不会出现重复的值,但多线程下,没有同步控制就会出现。

为什么会出现上述情况呢?这里要知道线程切换的2种方式:时间轮询优先级抢占。时间片轮询是给每个线程划分时间片,当时间执行完后就切换其他线程执行;优先级抢占是指高优先级的线程先执行,但这样会造成低优先级持续等待(线程饥饿),所以默认是按照时间片执行切换的。

那按照时间片切换就可能一个线程拿到了n=100,但未执行n--操作,时间片用完,等待下次有时间片时再执行,此时另一在时间片内执行了n--,输出n=99,这时前面的那个线程有时间片了,再执行n--,输出的是n=99,因为用的是之前拿到存储的n,即出现线程安全问题。

并发3要素:原子性(操作不可拆解)、有序性(操作有序)、可见性(操作可见)。

那么怎样实现上述性质保证线程安全呢?

  1. 锁:加锁控制同步,使用时加锁,用完后释放,获取锁执行期间其他线程无执行权限。
  2. CAS无锁:核心是比较并交换,底层也是锁(Lock总线锁),但方式上与锁方式不同。
  3. ThreadLocal:每个线程有独立的副本,互不影响,不需要同步控制。
  4. Final修饰:即使是多线程操作,但只能读不能写,也就不会有线程安全问题了。
  5. 线程封闭:单线程操作数据,不存在数据不安全问题。
  6. 栈限制:对于局部变量,其仅在本线程栈内,不存在数据不安全问题。

锁:重入锁(独占锁(公平锁/非公平锁)、共享锁(读写锁))

  1. 重入锁:线程已获取锁再获取锁不被阻塞,Mutex不支持重入,但synchronized隐式支持重入。重入锁中已获锁线程的同步状态+1,再获取再+1,释放时-1,不为0不结束。
  2. 独占锁:读操作写操作都加锁独占、共享锁:写操作加锁独占,读操作共享。
  3. 非公平锁:线程抢占式获取锁几,刚释放锁的线程再获取锁概率更大,这样上下文切换更少,但其他线程可能饥饿。
  4. 公平锁:线程顺序调用,有序入队,按顺序出队执行,每次同步队列中首节点获取锁。
  5. 读写锁:分为读锁和写锁,从而在读多写少时性能提升,读不阻塞写阻塞。
  6. Synchronized锁、ReentrantLock锁:(线程异常时,会抛出异常并释放锁)

(1).某线程获Synchronized锁,其他线程等待/阻塞状态,不可中断状态。对比ReentrantLock锁尝试获取锁方法(获取失败返回false)、等待中断方法,且ReentrantLock可以绑定多个对象和设置公平非公平式。

(2).1.6版前Synchronized是重量级锁:因为同步控制就线程阻塞,并且底层操作涉及用户态/内核态的切换,但在1.6版后提出了锁升级的概念,锁升级是不可逆的:无锁->偏向锁->轻量锁(CAS)->重量级锁;

(3).偏向锁:栈帧的锁记录ID,之后此线程不进行CAS,只简单判断对象头中偏向锁的标志为1则是偏向锁获取锁权限,不为1再用CAS轻量级锁。

(4).偏向锁:优点是加锁解锁操作消耗低、缺点是线程竞争就会撤销锁,有撤销消耗、    适合某一线程频繁访问同步块。

(5).轻量级锁:优点是线程竞争不阻塞其他线程、缺点是循环尝试获取锁会消耗CPU、适合需要线程响应速度高的场景。循环超过10次就用重量级锁阻塞等待。

(6).重量级锁:优点是不自旋消耗CPU、缺点是阻塞线程响应慢、适合吞吐量大场景。

  1. 1.6版Synchronized后:线程竞争低Synchronized性能高于ReentrantLock,但线程竞争高时Synchronized性能低于ReentrantLock,旧版Synchronized则都低于ReentrantLock。
  2. Synchronized/ReentrantLock都是监视器锁机制,两者可重入是对于锁方法,信号量计数+1实现。而监视器锁是针对于线程的独占,即不支持线程重入。 
  3. JVM保证每个monitor enter(即+1)对应一个monitor exit(即-1),执行前者获取monitor(锁)权限(对象都有monitor关联,当且一个monitor被持有则被锁定)。为0即释放锁。
  4. 具体使用Synchronized或ReentrantLock要依据业务定。
  5. 可重入锁一定线程安全、但线程安全的锁不一定可重入。
  6. ReentrantReadWriteLock读写锁:可分为读优先锁(A读B写C读,阻塞B写执行C及后续读)、写优先锁(A读B写C读,阻塞C读顺序执行B写);也可分公平或非公平,公平(不允许插队)、非公平(仅获取锁的线程是写时不允许插队)。
  7. StampedLock(邮戳锁):乐观锁,高并发性能>重入锁,但编码复杂不支持重入,不支持wait/notify机制,主要用于解决CAS的ABA问题(下面会说ABA);

AQS与并发工具类(CountDownLatch、CyclicBarrier、Semaphore、Exchanger):

  1. 倒计数CountDownLatch:传入N个后插入的线程,当前线程阻塞,每执行一次N减1,为0时唤醒被阻塞线程,场景适用于当前线程执行慢,让其他线程先操作的场景。
  2. 循环栅栏CyclicBarrier:循环操作,同步屏障;线程到屏障时被阻塞,最后1个线程也达到屏障时,才放行所用被阻塞线程,场景适用于多线程计算数据,最后合计结果。
  3. 信号量Semaphore:控制同时访问资源的线程数量,协调各线程合理使用公共资源,适用于公共资源有限的场景,如30个线程只能有10个连接数据库则需要控制线程数。
  4. 交换工具Exchanger:线程间交换数据,线程执行Exchanger()等待另一线程也执行到Exchanger()时, 两线程达到同步点可以交换数据,适用于遗传算法,俩者交配即换数据得出俩交配结果、校对工作,俩结果一致则正确等场景。
  5. AQS是构建锁和同步器的框架:并发工具类都是基于AQS框架实现,AQS内部用一个volatile整型变量维护状态,修改通过CAS操作保证线程安全。
  6. AQS是实现锁关键: AQS简化了锁的实现、屏蔽同步状态管理线程排队与等待唤醒等操作,AQS是一个抽象类,规定了-共享/独占-的实现。
  7. 队列同步器AQS:内置FIFO(双向链表)队列完成获取资源的线程排队,节点是同步队列基础,有头节点和尾节点的FIFO 双向链表队列。

(1).当前线程获取同步状态后将自己设为首节点,成功的线程是唯一的,即首节点唯一。

(2).多线程下每个线程内的循环可打断(简略流程:循环CAS获取,失败就阻塞并等待前驱节点释放或中断)。

(3).获取锁的线程修改数据释放锁后,后面经过循环获取/阻塞唤醒获取锁的线程会刷新自己的旧值再进行值比较和值交换,新线程如果首次判断就获取到锁,后续线程自旋CAS默认10次后阻塞等待获取;如果新线程首次判断未获取锁则放入队尾。

(4).同步队列:线程没成功获取锁将用CAS操作设为尾节点,而成功获取的线程唯一,则直接设为首节点(LinkedList非线程安全,用CAS函数代替它执行节点的添删)。

(5).独占式同步状态(独占锁的实现):线程获取锁成功则执行后退出、获取锁失败会加入队列尾并CAS自旋判断前驱是否为头节点是否获取,成功则变为首节点即获取锁并执行、若失败则自旋/阻塞线程等待前驱释放或中断。

(6).前驱非首节点的线程不用通信就可知道是否能获取锁,不用通信是因为通过CPU时间分片执行线程自身的程序判断前驱是否为首节点和是否可获取锁2个条件,队列里的每个线程在有时间片时都判断这2个条件,形成后面线程总是监视前驱线程,形成链。

(7).共享式同步状态:读共享写独占,当读线程获取锁成功,新来的读线程调用方法获取读锁,锁状态量+1;若失败则加入队尾,写线程流程如上。

图片摘自《Java并发编程的艺术》

 

(8). 超时独占式同步状态:前面流程一致,但在获取锁失败加入队尾后多了些条件流程;失败后在有时间片时先判断2个条件,都成功则获取锁;若其一失败则会根据剩余时间(time-=当前时间-上次结束时间)值判断<=0则退出,>0则在剩余时间里循环或阻塞获取锁(如果剩余time<=1000纳秒,剩余时间内会快速循环尝试获取锁代替阻塞等待),如果期间前驱节点释放或中断就重计算剩余时间;若之后有中断操作会中断并抛出异常退出,没中断就再次判断2条件……  (等待中断不会立即执行需要被触发)

           图片摘自《Java并发编程的艺术》

CAS(比较并交换):

  1. 比较并交换:指1个共享变量,多线程均有其副本,多线程操作后,若A线程想要操作就先要进行自身副本与变量比较,如果一致那么就意味着A线程有权修改这个变量,期间其他线程无法抢占(因为底层Lock锁使A独占),A修改自身副本并替换共享变量,共享变量被改后,其他线程要操作再比较并交换。实现了以内存为中介来连通多线程
  2. CAS无锁是指上述流程中主要以多线程通过与共享变量的比较来决定是否有权修改。
  3. ABA问题:上述流程中如果A线程将共享变量改为A后,B线程将共享变量改为B后,C线程将共享变量改为A,即ABA,值的变化无影响,但操作上却无法得知是几次。
  4. 解决ABA:即唯一标识变量:加时间戳、加邮戳;以及Atomic原子操作类的邮戳锁。
  5. CAS其实内部维护了volatile变量,而volatile底层才是由Lock锁保证的。
  6. CAS有volatile读和写的语义,从而保证操作的数据可见性,实现线程通信

(1).A线程写volatile变量后, B线程读volatile变量。

(2).A线程写volatile变量后, B线程CAS原子更新volatile变量。

(3).A线程CAS原子更新volatile变量,后B线程读volatile变量。

(4).A线程CAS原子更新volatile变量,后B线程CAS原子更新volatile变量。

  1. 线程通信:线程通信即数据的同步,主要是2种方式:Synchronized等锁同步控制,通过等待/唤醒操作实现数据在线程间同步; 另一种是上述1所说的CAS操作,以内存为中介连通多线程,A线程获取权限后更新内存共享数据,B线程要更新数据就会先更新自身副本,即先拿到了A修改后的数据,实现数据在线程间的同步。
  2. 常见的锁、Concurrent包等,底层都是volatile和CAS,(这里Lock指常见的锁)。

 

  1. Volatile修饰作用:操作的数据有可见性(一致性保证)、避免操作重排序。
  2. Volatile操作可见性:是由处理器支持的,即Lock总线锁/处理器缓存锁。处理器提供了对数据原子性的支持:

(1).处理器总线锁:当前处理器发送LOCK信号去独占内存,其他处理器阻塞,消耗大。

(2).处理器缓存锁:锁定缓存数据,消耗小,但当数据不在缓存中、或跨多缓存行、或CPU不支持时是不可用的。

(3).JVM为屏蔽各个硬件平台和操作系统对内存访问机制的差异化,提出JMM的概念,保障在不同编译器不同处理器上都能避免重排,内存可见一致性。JMM决定了线程对共享变量的写入何时对其他线程可见。

(4).MESI(缓存一致性协议)同理上述1:A线程获取权限后更新内存共享数据,B线程更新数据就要先更新自身副本,保持数据的一致性。

(5).已有MESI但仍需volatile:数据在L1/L2缓存中时2者皆可,但在L3缓存中volatile性能更佳,volatile是基于MESI保证L3缓存一致性的,顾2者均不可缺。MESI仅保证了CPU层面的一致性,但编译器层面仍需要volatile的2特性。volatile是MESI的多层抽象后的结果。

(6).可见性还遵循happens-before规则:A线程写B线程读,A操作结果必在B前。

(7).happens-before规则:程序顺序规则(一个线程的每个操作,happens-before在此线程中的任意操作后续去操作)、监视器锁规则(一个锁的解锁,happens-before在随后对其加锁)、Volatile变量规则(对一个volatile修饰的变量写操作,happens-before任意后续对其读)、传递性(A1与A2有happens-before关系,A2与A3有关系, 则A1与A3有happens-before关系)。

  1. Volatile避免重排序:上述性质仅保证结果有序,但操作未必有序。编译器为了性能、多核并行处理时无指令依赖、内存缓存缓冲区加载指令等可能重排操作。Volatile则强制修饰的变量不被重排。(编译器生成指令前,插入内存屏障)避免处理器重排序。
  1. 每个Volatile写操作前插入StoreStore屏障(避免上面普通写和下面V写重排)。
  2. 每个V写操作后面插入StoreLoad屏障(避免上面V写和下面V读V写重排)。
  3. 每个V读操作前面插入LoadLoad屏障(避免下面普通读操作和上面V读重排)。
  4. 每个V读操作后面插入LoadStore屏障(避免下面普通写和上面V读V写重排)。
  5. 单线程中:as-if-serial语义使有依赖关系的操作不受重排序干扰。
  1. Volatile保数据可见性,但不保证多线程操作的原子性,而CAS操作只保证1个共享变量的原子性,多个变量可以用AtomicReference的方法、Synchronized、合并变量。
  2. 线程间通信用Volatile变量过多会降低执行效率,可以用Synchronized同步控制。
  3. volatile(8种内存操作):lock、unlock、read、write、load、store、use、assign

  1. (32位处理器中)JMM不保证对64位的long和double变量写操作有原子性,需要顺序执行保障。新版本中对64位数据写操作拆分2个32bit无原子性部分,读操作加读事务;volatile的原子用法:在修改long变量时,其他线程可能读到值的一半(前32位),volatile修饰long/double变量后,数据具有原子性,则写操作可以原子性。
  2. 双重检查锁(DCL):为延迟初始化降低类初始化和对象创建的开销,实现使用时创建。 

(1).value=10时:操作如下:分配对象内存空间、初始化对象至空间、被赋值变量指向空间地址;双检锁问题:因为第2步第3步可以乱序,被赋值变量先有了空间指向,则不为null,其实未完成赋值,如果此时其他线程进入判断null就会给通过。

(2).双检锁:2次判断避免直接synchronized降低性能,volatile修饰可避免重排。

(3)除volatile修饰外,单例也可以减少消耗,这里就写了线程安全的懒汉模式。

 

(4).因是JVM对静态初始化只加载一次,全局1份。JVM通过内部维护1个状态标记保障多线程下静态只加载一次,第1个执行线程标记并加锁创建,其他线程判断已标记后进入等待,已创建完后不再创建。

(5).单例只对静态字段延迟创建,Volatile双检锁对静态/实例字段均可延迟初始化。

  1. 线程并发不一定比线程串行快:并发量>1百万时,串行更慢(因阻塞等待影响); 并发量<1百万,并发更慢(因线程切换消耗影响)。

ThreadLocal:线程隔离

  1. 一个线程的数据存到一个ThreadLocal中,以对应线程的ThreadLocal对象为Key,任意对象为Value进行存储。线程可以根据自己的ThreadLocal对象查询绑定在自身线程的值,可set设置get获取。存储的数据仅属于当前线程,多线程下防其他线程篡改。
  2. ThreadLocal类set/get是静态内部类ThreadLocalMap的,K=ThreadLocal对象,V=任意。ThreadLocalMap是数组结构并非Map结构,元素为(KV对)数据,单线程可存多种类型 ThreadLocal实例(key),各个对应的KV数据对都存在Entrytable数组中,无链表结构,当key的HashCode寻址冲突时会向下1位判断为空则插入。
  3. ThreadLocal数据共享:父子线程数据传递,InheritableThreadLocal可实现多线程共享 ThreadLocal数据,如果父子线程的InheritableThreadLocals(ThreadLocalMap)变量都非空,将父变量赋值给子变量。
  4. ThreadLocal内存泄漏:ThreadLocalMap的key是弱引用防止内存泄漏,value强引用。Key可以通过GC回收,而value要在ThreadLocal调remove/get/set才删除,未手动删可能内存泄漏。ThreadLocal正确用法:每次用完调用remove()清除,或调方法(Key为null均清除)。
  5. ThreadLocal的value因为不确定是否有其他对象引用,所以没有设置为弱引用。
  6. Spring采用ThreadLocal保证单线程同一数据库连接,七大传播隔离级别也是用ThreadLocal来管理多事务配置切换。
  7. DAO层/Service层(线程安全)是ThreadLocal(数据隔离)实现,事务功能是AOP和ThreadLocal的实现。
  8. cookie、session 等数据隔离经常用ThreadLocal实现、线程生命周期内有效。

Final修饰变量不可变:

  1. Final修饰要遵循的编译器和处理器避免重排序规则:

(1).类的构造函数中的final修饰域的写操作与此类的构造对象引用给其他变量,这2操作不可重排。(禁止把final修饰域的写重排到构造函数外)

(2). 初次读包含final修饰域的对象引用与初次读此final修饰域,2操作不可重排。(读含final域的对象引用与读final域有间接依赖避免重排)

(3).包含Final域的类不能从构造内将this赋值给其他对象;没有构造溢出情况下:构造返回前,final域未完成初始化则对象不可见,构造后final可见。

线程池:Tomcat、Jetty处理客户请求时都用到了线程池技术

  1. 线程池7个参数:最小线程数、最大线程数、保活时间、时间单位TimeUnit阻塞队列new LinkedBlockingQueue()线程工厂new ThreadFactory(){....}(默认正常优先级且非守护线程,可自定义)、拒绝策略new ThreadPoolExecutor.AbortPolicy()。
  2. 基础流程:判断当前是否有线程可用,没有则创建线程,当超过最小线程数时会将任务(Runnable对象)先存入阻塞队列,当阻塞队列满时会创建最大线程数的非核心线程,当超过最大线程数时会执行相应的拒绝策略。如果已经创建了最大线程数的线程,当线程空闲时执行保活指定的时间,时间过后会销毁线程,但最小线程数的线程不销毁。
  3. 7种阻塞队列:线程池在创建的线程数大于最小核心数时,首先将任务存至阻塞队列。

(1).ArrayBlockingQueue:有界阻塞队列,特性是可以设置公平与非公平式的出队。

(2).LinkedBlockingQueue:无界队列(Integer.MAX_VALUE),有出队入队2个独立锁。

(3).PriorityBlockingQueue:无界队列,特性是可以设置优先级来顺序执行。

(4).DelayQueue:无界队列,支持优先级,特性是可以设置定时执行任务。

(5).SynchronousQueue:无队列,存入1个就要将其取出才能再存下一个。

(6).LinkedTransferQueue:无界队列,有尝试取出/存入任务方法,失败返回false。

(7).LinkedBlockingDeque:无界队列,双向链表,能从2端操作,可用于工作窃取

          7种阻塞队列是支持2个附加操作(队满插入阻塞/队空移除等待)的队列,队列已满是有界阻塞队列或超过无界的最大限制时才有的情况,4种处理方式:

(1).抛出异常:队列已满还进行存储抛异常,队列为空还进行取出抛异常。

(2).返回特殊值:向队列中存储会返回true/false,从队列中取出返会null/值。

(3).队列阻塞:队列满时存会阻塞直至队列有空间,队列空时取会阻塞直至队列非空。

(4).超时退出:队列已满还进行存储,会阻塞一定时间,期间不可用,超时会退出。

          4种拒绝策略:线程数超最大数时执行,可实现RejectedExecutionHandler来自定义。

(1).AbortPolicy:对于超过最大数的线程任务直接拒绝,抛出异常。

(2).CallerRunsPolicy:对于超过最大数的线程任务先执行,但会造成队列任务等待。

(3).DiscardOldestPolicy:丢弃最旧的线程任务,即抛弃马上要执行的1个。

(4).DiscardPolicy:丢弃无法处理的线程任务,将不能处理的任务默默抛弃掉。

  1. Fork/Join架构:将1个大任务拆分多个小任务,最后汇总(如1+2+...n每10个一组最后汇总);其为工作窃取算法:一组任务一个队列一个线程来执行,已完成的线程随机帮助其他线程即从队尾进行窃取执行(正常执行的线程从队头执行,窃取的线程从尾执行),双向操作减少竞争,再分1个总线程一个队列用于存各组任务结果并汇总。每个子任务调用分割方法:当任务不够小时会继续分割直到足够小则等待执行,并返回结果给汇总队列。

(1).优点:充分利用多线程,且线程间减少了竞争。

(2).缺点:双向执行用1个任务也会有竞争,创建多个线程多个队列消耗资源大。

(3). Fork/Join因无法在主线程中捕获异常,则根据提供方法的返回值进行判断。

  1. 原理流程:put任务异步添加至队列,唤醒或创建一个线程来执行,join方法来阻塞当前线程并等待获取结果(join根据状态返回信息:已完成返回结果、被取消/出现异常就抛异常、未完成的就取出来执行,成功则标记已完成,异常就记录并标记)。
  2. 常用线程池:通过Executors方法创建ExecutorService对象,这里举例4种

(1).FixedThreadPool:限制当前线程数量,适于负载重的服务器。

(2).SingleThreadExecutor:单线程执行,适于顺序执行各个任务。

(3).SingleThreadScheduledExecutor:适于单后台线程执行周期任务且顺序执行。

(4).CachedThreadPool:适用于处理多个短期异步任务,和负载轻的服务器。

(5).FixedThreadPool和SingleThreadExecutor使用LinkedBlockingQueue阻塞队列。

(6).SingleThreadScheduledExecutor使用SynchronousQueue无队列存储。

(7).CachedThreadPool使用SynchronousQueue无队列存储,且CachedThreadPool的最大线程数是Integer.MAX_VALUE(但空闲线程60秒内无任务则终止)提交任务快于非核新线程处理速度时会不断创建新线程(极端情况:创建过多线程耗尽CPU和内存)。

 

  1. 线程池用无界阻塞队列:(队列不满)就不创建非核心线程、就用不到拒绝策略、就用不到保活时间参数。
  2. 有界阻塞队列:可增加系统稳定性和预警能力,也可根据需求设置大小(推荐使用)。
  3. 线程池在创建最小核心数的新线程时和创建最大非核心数的新线程时都会加全局锁。ThreadPoolExecutor尽量避免创最大数非核心新线程(避免加全局锁),做到大多任务用核心线程完成。
  4. 向线程池提交任务的2种方法:execute()无返回值无法判断是否执行成功、submit()返回future可通过get方法阻塞当前线程直到任务完成,get(Long l, TimeUnit tu)阻塞当前线程一定时间,超时后返回(l时间值,tu时间单位)。
  5. 线程池的关闭:shutdown方法、shutdownNow方法:2者遍历线程池所有工作线程,逐一调interrupt中断,无法响应中断的线程可能无法终止。(建议由线程池任务的特性决定使用哪种方式)。

(1).shutdownNow先将线程池状态设stop,后尝试停止运行或已暂停的线程,返回等待执行任务表。

(2).shutdown将线程池状态设为shutdown后,中断所有没处于正在执行任务的线程。

  1. 任务特性:大致分了一下几种:

(1).性质不同的任务:分为CPU密集型任务、IO密集型任务。

(2).优先级不同的任务:可用PriorityBlockingQueue先执行高优先级(但会线程饥饿)。

(3).执行时间不同的任务:可以使用不同规模的线程池,时间短的任务先执行。

(4).基于数据库连接池的任务:提交SQL后等待返回,CPU空闲时间长,创建更多线程充分利用CPU。

  1. 大量使用线程池要进行监控:任务执行前后和关闭线程池前执行代码监控、可自定义线程池、重写线程池方法、通过线程池提供的参数监控(taskCount线程池需要执行的任务数量,completedTaskCount线程池运行过程中已完成的任务数量,largestPoolSize线程池曾经创建的最大线程数,getPoolSize线程池中线程的数量,getActiveCount活动的线程数量)。
  2. 线程池策略:对于复杂场景可以用多个不同性质的线程池处理(线程池1处理IO密集任务,之后给线程池2处理CPU密集任务,之后线程池3定时任务....)。
  3. 任务池:任务池由多个线程池构成,每台机器启动一个任务池。某台机器提交一个任务时,任务池将任务保存至数据库(持久化)中,其他机器从数据库中获取任务并执行。每个任务5种状态:创建、执行、重试、挂起、中止、完成。
  4. 任务池任务隔离:任务类型少按任务类型隔离,任务类型多按优先级方式隔离。
  5. 任务池重试策略:实时性要求高就将重试时间间隔设置更短,实时性要求低则默认为间隔时间与重试次数成正比。
  6. 任务池注意事项:任务在完成后必须无状态,任务不能在执行任务的机器上保存数据。
  7. 异步任务属性:任务名、任务类型、优先级、下次执行时间、已执行次数、错误信息。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值