并发补充学习笔记

1.start和run方法的区别
(1)代码:
编写代码尝试,Thread.currentThread().getName();新创建一个线程调用该方法,发现使用run会获得主线程的name,而使用start会获得当前Thread0的name;
源码:
(2)start里面有一个start0方法,start0是native的方法,到openJDK中去查找native包,发现Thread.c文件。
查找start0,发现调用的是JVM的JVM_startThread,由于是c语言我没太看懂,但是有一句话叫做ntive_thread = new JavaThread()。
我就大致明白了,调用start方法会创建一个新的子线程,而run()方法只是一个普通方法的调用,因此会出现上面的两种情况。

2.Thread 和 Runnable 的区别
(1)代码:
编写一个继承Thread的类,和使用runnable接口的类,发现二者都要重写run方法,并且Thread有start方法,而Runnable只有run方法。
-》然后结合start和run方法的区别进行拓展
(2)源码:
从源码层面来看,Runnable是一个接口,并且里面只有一个抽象方法run。而Thread是使用Runnable接口的类,因此二者都需要重写run方法。
Thread里面的start的方法可以用来新建子线程,他还有一个构造函数可以传进来一个Runnable对象,这样Runnable对象也可以具有start方法。
虽然使用Thread比较方便,但是多使用Runnable可以使得代码的复用性更好。


3.如何给run方法传参?
例子:https://blog.csdn.net/lee4037/article/details/42006633
构造函数传参
成员变量传参
回调函数传参:即传递给外部函数去处理数据

4.如何实现处理线程的返回值?
参考https://blog.csdn.net/Goodbye_Youth/article/details/99344446
(1)主线程等待法:主要是使用Thread.currentThread().sleep(time),但是难以确认time的大小
(2)join方法:阻塞主线程,等待子线程执行完毕,缺点是细粒度不够
(3)通过Callable接口实现:可以获取一个Future对象,调用该对象的get,就可以取到返回值。
-》实现方式有:FutureTask和线程池(通过构造函数实现)

5.sleep和wait的区别
(1)表面区别
->sleep是Thread的方法,wait是Object方法
->sleep可以在任意位置使用,而wait只能在同步代码块中使用
(2)本质区别
->sleep只释放cpu,对锁状态无影响。
->wait既释放cup,又释放锁。

补充:为什么wait只能在同步代码块中使用?
1.wait方法除了释放cpu,也一定会释放锁,因此必须对持有锁的对象使用,否则会抛出异常。
2.Lost Wake-Up Problem:https://blog.csdn.net/zl1zl2zl3/article/details/89236983
-》线程A还没有wait的时候线程B启用了notify,相当于无效操作,之后线程A进入wait,就睡过去了。

6.锁池和等待池
(1)EntryList:一个线程拥有锁,另一个线程尝试去获取锁,会进入拥有锁对象的等待池
(2)WaitSet:一个线程调用了某个对象的wait方法,就会进入到该对象等待池

7.notify和notifyAll的区别?
Monitor由一个锁(lock), 一个等待队列(waiting queue ), 一个入口队列( entry queue)组成。
notify():度器会从所有处于该对象等待队列(waiting queue)的线程中取出任意一个线程, 将其添加到入口队列( entry queue) 中。然后在入口队列中的多个线程就会竞争对象的锁, 得到锁的线程就可以继续执行。 如果等待队列中(waiting queue)没有线程, notify()方法不会产生任何作用
notifyAll():会将等待队列(waiting queue)中所有的线程都添加到入口队列中(entry queue)

8.Yield
源码:hint 暗示
当前线程愿意让出cpu的使用,但是线程调度器可能会忽略这个暗示

9.中断线程
(1)不可取:
stop:不知道停止时的状态
suspend和resume:不成对使用会死锁
(2)目前:
阻塞时:退出阻塞,抛出interruptException
正常活动状态:设置中断标志位(isInterrupted),并继续运行
需要被中断线程配合:检查标志位

10.线程状态之间的转换
(1)状态
new
Runnable
Waiting
Timed-Waiting
Blocked
Terminated
 

(2)转换
new
Runnable
Running
Block
WaitSet
EntryList
Terminated
—————————————————————————————————————————————————————

1.线程安全的诱因:
->存在共享数据
->存在多条线程共同操作这些数据
办法:同一时刻只能有一个线程操作共享数据,其他线程只有等到该线程操作完再进行。

2.互斥锁的特性
互斥性:互斥锁
可见性:jmm
顺序性:禁止指令重排

3.获取锁的分类
(1)获取对象锁:同步代码块、同步非静态方法
(2)获取类锁:同步代码块、静态方法
最大的不同在于,当传入同一个类的不同对象时,得到的结果会是一样的
并且类锁和对象锁是互不干扰的(因为在内部)

4.对象锁和类锁总结:
->一个线程访问对象的同步代码块时,另一个线程可以访问非同步代码块
->一个线程访问对象的同步代码块时,另一个线程访问同步代码块会被阻塞
->一个线程访问对象的同步方法时,另一个线程访问同步方法会被阻塞
->一个线程访问对象的同步方法时,另一个线程访问同步代码块会被阻塞(反之亦然)
->类锁也是一种特殊的对象锁,因此性质和上述相似
->同一个类的不同对象的对象锁互不干扰
->类锁和对象锁互不干扰

————————————————————难点——————————————————————————
1.什么是Synchronized关键字
->保证同一时间只能允许一个线程去访问一段特定的代码,保证线程安全。
->两个特性
->三种实现方式:同步代码块、同步静态方法、同步非静态方法

2.实现基础:
Java对象包含:对象头、实例数据、对齐填充
synchronized的实现基础是:JAVA对象头->Monitor

3.对象头的结构
(1)Mark Word:存储对象自身的运行时数据,包括:
对象的 HashCode / 分代年龄 / 锁类型 / 锁标志位 / GC标记 / 类型指针/ThreadId
(2)Class Metadata Address:类元数据指针,JVM通过它确定该对象是属于哪个类的数据

4.Monitor:
重量级锁类型指针指向Monitor对象头
每个对象天生自带了一把看不见的锁
源码层面:是由ObjectMonitor实现的,位于hotspot虚拟机源码,是通过c++实现的
由于是c语言我没太深究,但是我看到ObjectMonitor下面有两个队列,分别是_waitSet和_entryList,这也就是线程中常见的锁池和等待池了。
还有一个变量是_owner,指向当前获取锁的这个对象。count线程计数器,持有时+1,释放时-1

5.重入锁:
对于一个synchronized加持的代码块,其他线程试图访问该代码块时,线程会阻塞。若是持有锁的线程再次请求自己持有的锁时,则能成功获得
通过cas将_owner指向当前线程,若当前线程再次请求获得锁, _owner指向不变

6.同步代码块和同步方法的实现机理?
使用JAVAC生成字节码文件,再使用javap-verbos反编译,也可以看到
(1)同步代码块
MonitorEnter:synchronized起始的位置,试图获取对象锁(monitor的所有权),当线程计数器为0,就可以获得对象锁
MonitorExit:synchronized结束的位置
(2)同步方法
隐式锁:ACC synchronized 表示已经持有monitor对象


7.通常不使用synchronized的原因?
早期的synchronized属于重量级锁
线程切换需要从用户态转换到内核态,开销较大
->JAVA6之后做出的优化
(1)锁消除:?StringBuffer
(2)锁粗化:?while循环重复操作
(3)自适应自旋锁:?
不释放cpu,通过自旋尝试获取锁(大多数情况,持有锁的时间不会很长),自旋时间由上一次自旋时间以及当前要获取的锁的状态决定。
缺点?
(4)偏向锁:?适合总是同一个线程获取同一把锁的情况
(5)轻量级锁:?适合线程交替执行的情况

8.偏向锁:
如果一个线程获得了锁,那么线程就进入到了偏向模式
当线程再次请求锁时,只需要检查mark-word的锁模式是否是偏向锁,
并且当前线程Id等于mark-word的Thread ID即可。减少了锁申请的操作

—————————————synchronized和ReentrantLock————————————————————

ReentrantLock 位于JUC包下面(可以提到JUC包)
继承自AQS

1.可重入性:
都可重入,两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

2.锁的实现:
Synchronized是依赖于JVM实现 -> 操作系统来控制实现
ReenTrantLock是JDK -> 用户自己敲代码实现的区别
前者的实现是比较难见到的,后者有直接的源码可供阅读

3.性能的区别:
在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了。

4.功能区别:
-》便利性:Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以必须在finally中声明释放锁。
-》锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized

5.ReenTrantLock独有的能力:
-》ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。(会带来吞吐量的下降,只有程序必须使用公平性才打开)
(此处可以提到自己尝试的公平性例子)ReentrantLock fairLock = new ReentrantLock(true);
-》 ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
-》 ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。

6.什么情况下使用ReenTrantLock:
答案是,如果你需要实现ReenTrantLock的三个独有功能时。

 

—————————————————————AQS代码的梳理—————————————————————————

AQS的数组表征状态:
state
getState
setState
一个先进先出的等待队列
以及各种基于CAS的操作方法
acquire 和release

利用AQS去实现同步方法,至少要实现两个基本方法:
acquire获取资源的独占权  release释放对某个资源的独占和

—————————————————————Condition————————————————————————————

是否能将wait/notify/notifyAll对象化?
java.util.concurrent.locks.Condition

Condition最经典的应用是: ArrayBlockingQueue->JUC包下面
 是数组实现的线程安全的有界的阻塞队列,
 线程安全是指ArrayBlockingQueue内部通过互斥锁保护竞争资源,
 其互斥锁是通过ReentrantLock来实现的,实现了多线程对互斥资源的访问
-》有界:固定容量
-》阻塞队列:多线程访问资源时,当竞争资源已被某线程获取时,其他要获取该资源的线程需要阻塞等待。

源码层面:
ArrayBlockingQueue和Condition是组合的关系
包含两个成员变量condition:
notEmpty = lock.newCondition();
notFull = lock.newCondition();

take时如果队列为null(通过count == 0)来判断,则notEmpty.await(),等待直到有值才返回。
enqueue时,count++,notEmpty.signal通知等待的线程
对应入队操作,一旦有消息入队,count++;notEmpty就会调用signal方法通知等待的线程,tack就可以取到对应的东西。

notEmpty = lock.newCondition();
源码层面:进入到newCondition中,来自于sync.newCondition中,来自于ConditionObject,来自于AQS
就是将wait/notify/notifyAll转化为可控的对象


synchronized和ReentrantLock的区别
->synchronized是关键字,ReentrantLock是类
->ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
->ReentrantLock可以获取各种锁的信息
->ReentrantLock可以灵活地实现多路通信
机制:sync操作Mark Word,lock调用Unsafe类的park()方法;

源码层面:
ReentrantLock->lock()->sync.acquire->acquireQueued->parkAndCheckInterrupt
->LockSupport.park(this)方法->最终调用 U.park()->park位于Unsafe这个类里面
Unsafe类似于一个后门的工具,可以用于在任意内存地址处读写数据,对于普通用户使用起来比较危险

——————————————————————JUC包的梳理————————————————————————— 
CAS:V/A/B,A/B/A

AQS:?

线程执行器:executor
锁:locks
原子变量类:atomic
并发工具类:tools
并发集合:collections

tools:
CountDownLatch
CyclicBarrier
Semaphore
Executor
Exchanger

locks:
lock:ReentranLock
Condition

atomic:基于CAS
increase->自增
decrease->自减
原子更新基本类型、原子更新数组、原子更新引用、原子更新字段

Collections:
Queue:ConcurrentLinkedQueue/BlockingQueue/Deque  队列为空时get会被阻塞,队列为满时put会被阻塞
ConcurrentMap:ConcurrentHashMap/ConcurrentNavigableMap

Executor:
Future
Callable
Executor
CompletionService
RejectedExecutionHandler
TimerUnit

___________________________________

并发工具类:
闭锁 CountDownLatch
珊栏 CyclicBarrier
信号量 Semaphore
交换器 Exchanger

————————————————jmm的内存可见性——————————————————————
1.JMM(java Memory Model)描述的是一组规则或规范,通过这组规范定义了程序中各个变量的访问方式 

    线程A                            线程B 
      |                                        |
   本地内存A                    本地内存B
(共享变量的副本)       (共享变量的副本)
      |  <——JMM控制——>     |
      |                                         |
【     共享变量   共享变量   共享变量   】
                     【主内存】

含义:
每个线程在创建时,都会拥有自己的本地内存,所有的变量都存储在主内存中
当线程要对变量进行操作时,必须从主内存读取数据到本地内存,然后在本地内存中进行操作。
操作完成后再将更新的变量刷新到主内存中
因此线程之间的通信必须通过主内存,而不同线程的本地内存是不能直接访问的。
拓展:JMM引入了数据依赖性(happend before / 指令重排序)

2、主内存
存储:实例对象、成员变量、类信息、常量、静态变量等
是数据共享的区域,可能引发线程安全问题

3.工作内存
存储:本地变量信息,字节码行号指示器,Native方法信息
线程私有区域,不存在线程安全问题

4.JMM与java内存区域划分是不同的概念层次
->JMM描述的是一组规则,围绕原子性,有序性,可见性展开
->相似点:存在共享区域和私有区域

5.存储类型归纳
->方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中(基本数据类型)
->引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
->成员变量、static变量、类信息均会被存储在主内存中
->主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新回主内存

6.JMM是如何解决可见性问题?
把数据从内存加载到缓存寄存器,然后运算结束写回主内存
多线程共享引入了复杂的数据依赖性,不管编译期处理器怎么做重排序,都必须尊重数据依赖性的要求
否则就打破了数据的正确性

7.指令重排序的条件
->在单线程环境下不能改变程序运行的结果
->存在数据依赖关系的不允许重排序
【无法通过happens-before原则推导出来的】
A操作的结果需要对B操作可见,则A与B存在happens-before关系
例子:
i = 1;//线程A执行
j = i;//线程B执行
存在happens-before原则,j==1才成立

8.happens-before的八大原则
->程序次序规则:对单一线程是有序的
->锁定规则:unLock后才可以lock
->Volatile规则:写操作发生于读操作前
->传递规则:A先于B,B先于C,则A先于C
->线程启动规则:Thread的start()先行发生于此线程的每一个动作
->线程中断规则:对线程interrupt()方法的调用先行发生于中断检测动作
->线程终结规则:所有操作先行发生于线程的终止检测
->对象终结规则:一个对象初始化先行发生于他的finalize()方法的开始

9.happens-before的概念
满足上述任意一个规则,就不能进行重排序
例子:下例子不满足

private int value = 0;
public void write(int input){
    value = input;
}
public int read(){
    return value;
}

10.volatile
作用:
->可见性(写操作)
->禁止指令重排序

11.不保证安全性:
例子:如果多个线程同时increase
public class VolatileVisibility{
    public static volatile int value = 0;
    public static void increase(){
        value++;
    }
}
使用javac反编译,再使用javap - verbos获取字节码指令
value的操作是:先获取值,再写回一个新值,分两步来完成
如果另一个线程在前一个线程读取新值和写回新值之间读取value的值
这样实在未更新的value基础上+1并且刷新,最终只+1,会引发线程安全的问题
->必须使用synchronized,阻止其他线程获取当前线程的值

12.例子:(原子操作)

public class VolatileSafe{
    volatile boolean shutdown;//volatile实现让shutdown操作对其他变量立即可见
    public void close(){
        shutdown = true;
    }
    public void doWork(){
        while(!shutdown){
            System.out.println("safe....");
        }
    }
}

13.如何做到立即可见
写一个volatile变量时,JMM把该线程工作内存中的共享变量刷新到主内存中;
当读一个volatile变量时,JMM会把该线程对应的工作内存置为无效。

14.如何禁止重排优化?

内存屏障(Memory Barrier)
->保证特定操作的执行顺序
{它是一个cpu指令,编译器和处理器都能执行指令的重排优化,如果插入内存屏障,则会告诉cpu
不管是什么指令,都不能和这条Memory Barrier重排序 }
->保证某些变量的内存可见性
{强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本}

【通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化】

15.单例双重检测实现

public class Singleton{
    private volatile static Singleton instance;
    private Singleton(){};
    public static Singleton getInstance(){
        //第一次检测
        if (instance==null) {
            //第二次检测
            synchronized(Singleton.class){
                if (instance==null) {
                    //多线程环境下可能会出现问题的地方
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}


如果不加vloatile会出现线程安全的原因是?
memory = allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null;

但是步骤2和步骤3不存在数据依赖的关系
memory = allocate();//1.分配对象内存空间
instance = memory;//3.设置instance指向刚分配的内存地址,此时instance!=null;
但是对象还没有初始化完成
instance(memory);//2.初始化对象

-》在多线程的情况下,就另一个线程可能在3执行完就判断不为null,并且返回结果了。

16.volatile 和 synchronized 的区别
->volatile 告诉JVM当前变量在工作内存不确定,需要去主存读取。而synchronized则是锁住当前变量,只有当前线程可以访问。
->volatile 仅能使用在变量级别;synchronized 可以使用在变量、方法和类
->volatile 不会造成阻塞,synchronized 会
->...不会被编译期优化,...会被编译期优化。

——————————————————————CAS———————————————————————
悲观锁:synchronized
乐观锁:CAS

1.CAS(Compare and Swap)
->支持原子更新操作,是用于计数器,序列发生器(给原子自增的工具)
->乐观锁,号称lock-free
->操作失败时由开发者决定是继续尝试,还是执行别的操作

2.思想:
V(内存位置)/A(预期原值)/B(新值)
将内存位置的值与预期原值进行比较,如果相同就将新值刷新到内存位置处
->内存位置的值,即主内存的值

3.当一个线程需要修改共享变量的值,先取出共享变量的值赋给A,基于A计算,赋给新值B
例子:

public class CASCase{
    public volatile int value;
    public  void add(){
        value++;
    }
}

add的字节码操作,拆分为
getfield 将主内存中的值加载到当前线程的工作内存
iadd 进行+1操作
putfield 将修改的值写回到主内存中

通过volatile修饰的变量可以保证内存的可见性,同时也不允许重排序
但是不能保证这些指令的原子安全
如何解决?
1.方法前加synchronized
2.使用atomicInteger原子类


4.AtomicInteger的原理
源码:
unsafe包的相关操作
CAS : 比较新值和原值VAB
使用increase和decrease自增自减来实现

5.CAS的使用
->JUC的 atomic 包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具
->Unsafe类提供CAS服务,但是能够操作任意内存地址读写会有隐患

6.缺点:
->循环时间长,则开销很大
->只能保证一个共享变量的原子操作
->ABA问题:控制变量值的版本来解决

—————————————————线程池——————————————————
1.怎么来?
->减少创建和销毁的复用,提高复用性
2.是什么?
3.怎么用?

1.线程池创建五种方式
newFixedThreadPool(int nThreads)
指定工作线程数量的线程池
newCachedThreadPool()
处理大量短时间工作任务的线程池
试图缓存线程并重用,当无缓存线程用 则新建工作线程
如果线程闲置时间超过阈值,则终止并且移出缓存
长时间闲置时候不会消耗什么资源
newSingleThreadExecutor()
创建唯一线程来执行,如果异常结束则另外一个线程取代
newSingleThreadScheduledExecutor()-newSingleScheduleThreadPool(int coresize)
定时或者周期性的周期调度
newWorkStealingPool()
forkjoinpool 会使用workingsteal 并行处理任务
 


源码层面:
五种线程都是起源于 Executor
前三种都会:return new ThreadPoolExecutor();
ThreadPoolExecutor->AbstractExecutorService->ExecutorService->Executor
DelegatedScheduledExecutorService->DelegatedExecutorService->ExecutorService->Executor
ForkJoinPopl-> AbstractExecutorService->ExecutorService->Executor

Fork/Join框架
->把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架
->使用到了Work-Stealing算法
    Work-Stealing算法:某个线程从其他队列里窃取任务来执行
->窃取队列使用的是双端队列,已经完成的线程从队尾窃取,原线程从队列头取

2.为什么要使用线程池?
->降低资源消耗
->提高线程的可管理性(调优、分配和监控)

3.Executor的框架
见图
JUC的三个Executor
->Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
源码:Executor里面只有一个execute方法,可以有多种用途...
Thread t = new Thread();
t.start();
Thread t = new Thread();
executor.execute(t);

->ExecutorService
源码:
submit 比较 execute 提供了更加完善的任务提交机制

->ScheduledExecutorService:支持Future和定期执行任务

4.ThreadPoolExecutor
见图......
工作队列->线程池->处理完成后返回结果
线程池实际上是维护了一组Worker

5.Worker源码
ThreadPoolExecutor->Worker
entends AbstractQueuedSynchronizer
implements Runnable (因此是一个线程)

firstTask 保存传入的任务  
thread 通过构造方法创建出来的线程(this.thread = getThreadFactory().newThread(this));

调用 run 方法执行里面的逻辑

6.ThreadPoolExecutor的构造函数
corePoolSize
核心线程数量

maxinumPoolSize
能创建的最大线程数

workQueue
任务等待队列

keepAliveTime
当线程池中的线程数大于corePoolSize时,如果一个线程空闲的时间达到keepAliveTime,则会终止,直到线程池中的线程数不超过corePoolSize。如果调用allowCoreThreadTimeOut的话,在线程池中线程数量不大于corePoolSize的时候,keepAliveTime参数也可以起作用的,知道线程数目为0为止;
Timeunit

threadFactory:
创建新线程,Executors.defaultThreadFactory()

handler 饱和拒绝策略
AbortPolicy 直接抛出异常 (默认策略)
CallerRunsPolicy 用调用者所在线程执行任务
DiscardPolicy 直接丢弃任务
DiscardOldestPolicy 丢弃队列中靠最前任务,并执行当前任务.
或者:实现 RejectedExecutionHandler 接口的自定义 handler


7.提交任务的方法
sublime()得到一个future类的返回值
execute()没有返回值

如果线程少于corepoolsize 则创建新线程来处理任务 即使其他线程是空闲的
如果线程池数量大于等于coresize小于maximumpoolsize 则等到workQueue满的时候才去创建新的线程去处理任务
如果core与max相等则创建线程池大小固定,如果工作队列没有满则放入工作队列 等待空闲线程去执行任务
如果大于maximum 而且workqueue满了 则通过handler策略来处理
线程池最后必须调用shutdown关闭 和锁一样必须在finallyunlock
 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IMUHERO

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值