【Android面试】2023最新面试专题八:Java并发编程(三)

11 死锁的场景和解决方案 腾讯

这道题想考察什么?

是否真正了解死锁的定义?是否掌握死锁的排查与解决

考察的知识点

并发编程 死锁

考生应该如何回答

死锁的定义

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。

危害

1、线程不工作了,但是整个程序还是活着的

2、没有任何的异常信息可以供我们检查。

3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。

死锁的发生必须具备以下四个必要条件。

  1. 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
  2. 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  3. 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生。

  1. 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
  2. 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
  3. 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
  4. 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

避免死锁常见的算法有:有序资源分配法银行家算法等。

有序资源分配法

有序资源分配法是预防死锁的一种算法,按某种规则对系统中的所有资源统一编号,申请时必须以上升的次序。

例如做饭时候盐为1,酱油为2等等。如果A、B两个厨师同时做饭,使用资源顺序分别为:

A:申请顺序1->2

B:申请顺序2->1

此时,A在拿着盐的同时要使用酱油,但是由于酱油被B持有,两人谁也不让谁。此时形成环路条件,造成死锁 。但是采用有序资源分配法,则:

A:申请顺序1->2

B:申请顺序1->2

A如果先获取到盐,那么B此时只能等待。这样就破坏了环路条件,避免了死锁的发生。

总结

死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有B一个去,不要2个,打十个都没问题;单资源呢?只有13,A和B也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。

12 锁分哪几类?

这道题想考察什么?

是否了解并发相关锁的知识?

考察的知识点

  1. 锁的分类和概念
  2. 如何运用锁解决并发问题

考生应该如何回答

Java锁的种类
  • 乐观锁/悲观锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 可重入锁
  • 公平锁/非公平锁
  • 分段锁
  • 偏向锁/轻量级锁/重量级锁
  • 自旋锁

以上是一些锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计。

乐观锁/悲观锁

乐观锁与悲观锁并不是特指某两种类型的锁,是人们定义出来的概念或思想,主要是指看待并发同步的角度。

  • 乐观锁:获取数据时认为不会被其他线程修改,所以不会上锁,但是在更新的时候会判断其他线程是否修改此数据,如果被其他线程修改,则会发生自旋。
  • 悲观锁:总是假设最坏的情况,获取数据时都认为其他线程会修改,因此在获取数据时都会上锁,这样保证其他线程需要等待获取锁的线程处理完成并且释放锁。

乐观锁适用于频繁读取的场景,因为不会上锁,因此可以提高吞吐量。在Java中java.util.concurrent.atomic包下面的原子变量类就是基于乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。

悲观锁适合写操作较多的场景,synchronized关键字的实现就是悲观锁。

独享锁/共享锁
  • 独享锁是指该锁一次只能被一个线程所持有。
  • 共享锁是指该锁可被多个线程所持有。

ReentrantLock是独享锁。但是对于Lock的另一个实现读写锁ReadWriteLock,读锁是共享锁,而写锁则是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

  • 互斥锁在Java中的具体实现就是ReentrantLock。

  • 读写锁在Java中的具体实现就是ReadWriteLock。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。synchronized与ReetrantLock都是可重入锁。可重入锁的一个好处就是可以在一定程度避免死锁:

synchronized void setA() throws Exception{
	Thread.sleep(1000);
	setB();
}
    
synchronized void setB() throws Exception{
	Thread.sleep(1000);
}

上述代码中,如果synchronized不是可重入锁的话,setA首先获取锁,在此方法还未释放锁的情况下,调用setB也需要获取相同的对象锁,此时会造成死锁。

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序获取锁,非公平锁则是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。非公平锁的优点在于吞吐量比公平锁大,但是也有可能会造成优先级反转或者饥饿现象。

Java中ReetrantLock可以通过构造函数指定该锁是否是公平锁,默认是非公平锁。而synchronized则是非公平锁。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁。比如ConcurrentHashMap,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

ConcurrentHashMap中的分段锁封装为Segment,它本身也是类似于HashMap的结构,其内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

13 ThreadLocal是什么?

这道题想考察什么?

是否了解ThreadLocal与真实场景使用,是否熟悉ThreadLocal

考察的知识点

ThreadLocal的概念在项目中使用与基本知识

考生应该如何回答

ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。

ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("享学");
System.out.println("主线程获取变量:"+threadLocal.get());
Thread thread = new Thread() {
    @Override
    public void run() {
        super.run();
        System.out.println("子线程获取变量:"+ threadLocal.get());
        threadLocal.set("教育");
        System.out.println("子线程获取变量:"+ threadLocal.get());
    }
};

在上述代码中,主线程输出:享学,子线程第一次输出:null,第二次输出教育。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定。

set

通过ThreadLocal#set设置线程本地变量,set的实现为:

public void set(T value) {
	Thread t = Thread.currentThread();
	ThreadLocalMap map = getMap(t);
	if (map != null)
		map.set(this, value);
	else
		createMap(t, value);
}

通过Thread.currentThread()方法获取了当前的线程引用,并传给了getMap(Thread)方法获取一个ThreadLocalMap的实例。

ThreadLocalMap getMap(Thread t) {
	return t.threadLocals;
}

可以看到getMap(Thread)方法直接返回Thread实例的成员变量threadLocals。它的定义在Thread内部:

public class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

每个Thread里面都有一个ThreadLocal.ThreadLocalMap成员变量,也就是说每个线程通过ThreadLocal.ThreadLocalMap与ThreadLocal相绑定,这样可以确保每个线程访问到变量的都是本线程自己的。

获取了ThreadLocalMap实例以后,如果它不为空则调用ThreadLocalMap.ThreadLocalMap 的set方法设值;若为空则调用ThreadLocal 的createMap方法new一个ThreadLocalMap实例并赋给Thread.threadLocals。

void createMap(Thread t, T firstValue) {
    // this = ThreadLocal
	t.threadLocals = new ThreadLocalMap(this, firstValue);
}
get

而ThreadLocal 的 get 方法,源码如下:

public T get() {
	Thread t = Thread.currentThread();
	ThreadLocalMap map = getMap(t);
	if (map != null) {
		ThreadLocalMap.Entry e = map.getEntry(this);
		if (e != null)
			return (T)e.value;
	}
	return setInitialValue();
}

同样通过Thread.currentThread()方法获取了当前的线程引用,并传给了getMap(Thread)方法获取一个ThreadLocalMap的实例。 而如果从ThreadLocalMap未能找到当前线程的变量则返回setInitialValue

private T setInitialValue() {
	T value = initialValue();
	Thread t = Thread.currentThread();
	ThreadLocalMap map = getMap(t);
	if (map != null)
		map.set(this, value);
	else
		createMap(t, value);
	return value;
}

在setInitialValue中首先调用 initialValue()方法来获得一个value,然后执行ThreadLocal#set同样的处理并返回这个value,也就是说可以通过重写ThreadLocal的initialValue方法能够实现在set变量值之前,使用get获取的就是这个initialValue返回的结果。

ThreadLocal<String> threadLocal = new ThreadLocal<String>(){
	@Nullable
	@Override
	protected String initialValue() {
		return "享学";
	}
};
// 享学
String value = threadLocal.get();

在set/get中其实就是借助ThreadLocalMap实现线程与本地变量的绑定与获取。每个线程都有自己的一个ThreadLocalMap,ThreadLocalMap是一个映射集合,以ThreadLocal为key。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mln0Pz7y-1688439193379)(images\threadlocalmap.png)]

ThreadLocal简化的伪代码为:

class Thread extends Thread {
     ThreadLocalMap threadLocals;
}

class ThreadLocal<T> {
	public void set(T t) {
		Thread thread = Thread.currentThread();
		thread.threadLocals.put(this, t);
	}

	public T get() {
		Thread thread = Thread.currentThread();
		thread.threadLocals.get(this);
	}
}

14 Java多线程对同一个对象进行操作(字节跳动)

这道题想考察什么?

是否了解Java多线程对同一个对象进行操作与真实场景使用,是否熟悉Java多线程对同一个对象进行操作?

考察的知识点

Java多线程对同一个对象进行操作的概念在项目中使用与基本知识

考生应该如何回答

在多线程环境下,多个线程操作同一对象,本质上就是线程安全问题。因此为了应对线程安全需要对多线程操作的对象加锁。

例如当我们遇到需求:实现三个窗口同时出售20张票。

程序分析:

1、票数要使用一个静态的值。

2、为保证不会出现卖出同一张票,要使用同步锁。

3、设计思路:创建一个站台类Station,继承Thread,重写run方法,在run方法内部执行售票操作。

售票要使用同步锁:即有一个站台卖这张票时,其他站台要等待这张票卖完才能继续卖票!

package com.multi_thread;

//站台类
public class Station extends Thread {
    // 通过构造方法给线程名字赋值
    public Station(String name) {
        super(name);// 给线程起名字
    }

    // 为了保持票数的一直,票数要静态
    static int tick = 20;
    // 创建一个静态钥匙
    static Object ob = "aa";// 值是任意的

    @Override
    public void run() {
        while (tick > 0) {
            // 这个很重要,必须使用一个锁,进去的人会把钥匙拿在手上,出来后把钥匙让出来
            synchronized (ob) {
                if (tick > 0) {
                    System.out.println(getName() + "卖出了第" + tick + "张票");
                    tick--;
                } else {
                    System.out.println("票卖完了");
                }
            }
            try {
                // 休息一秒钟
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
package com.multi_thread;

public class MainClass {
    // java多线程同步所的使用
    // 三个售票窗口同时出售10张票
    public static void main(String[] args) {
        // 实例化站台对象,并为每一个站台取名字
        Station station1 = new Station("窗口1");
        Station station2 = new Station("窗口2");
        Station station3 = new Station("窗口3");
        // 让每一个站台对象各自开始工作
        station1.start();
        station2.start();
        station3.start();
    }
}
程序运行结果:

窗口1卖出了第20张票
窗口3卖出了第19张票
窗口2卖出了第18张票
窗口2卖出了第17张票
窗口3卖出了第16张票
窗口1卖出了第15张票
窗口1卖出了第14张票
窗口3卖出了第13张票
窗口2卖出了第12张票
窗口1卖出了第11张票
窗口3卖出了第10张票
窗口2卖出了第9张票
窗口1卖出了第8张票
窗口3卖出了第7张票
窗口2卖出了第6张票
窗口1卖出了第5张票
窗口3卖出了第4张票
窗口2卖出了第3张票
窗口3卖出了第2张票
窗口1卖出了第1张票

15 线程生命周期,线程可以多次调用start吗? 会出现什么问题? 为什么不能多次调用start?

这道题想考察什么?

是否了解Java并发线程的相关知识

考察的知识点

线程生命周期及变化

考生应该如何回答

线程生命周期中重要的状态

  • 新建 New;
  • 就绪 Runnable
  • 运行 Running
  • 阻塞 Blocked
  • 死亡 Dead

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wYnmnG7B-1688439193381)(images/lifecycle.webp)]

新建 new
public class CThread extends Thread{
    @Override
        public void run() {
            
        }
}
//新建就是new出对象
CThread thread = new CThread();

当程序使用new关键字创建了一个线程之后,该线程就处于一个新建状态(初始状态),此时它和其他Java对象一样,仅仅由Java虚拟机为其分配了内存,并初始化了其成员变量值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

就绪 Runnable

当线程对象调用了Thread.start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态的线程并没有开始运行,它只是表示该线程可以运行了。从start()源码中看出,start后添加到了线程列表中,接着在native层添加到VM中,至于该线程何时开始运行,取决于JVM里线程调度器的调度(如果OS调度选中了,就会进入到运行状态)。回看一下下面start方法源码:

public synchronized void start() {
        /**
         * This method is not invoked for the main method thread or "system"
         * group threads created/set up by the VM. Any new functionality added
         * to this method in the future may have to also be added to the VM.
         *
         * A zero status value corresponds to state "NEW".
         */
        // Android-changed: throw if 'started' is true
        if (threadStatus != 0 || started)
            throw new IllegalThreadStateException();

        /* Notify the group that this thread is about to be started
         * so that it can be added to the group's list of threads
         * and the group's unstarted count can be decremented. */
         //通知组此线程即将启动,以便将其添加到组的线程列表中,并且可以减少组的未启动计数。
        group.add(this);
        started = false;
        try {
            nativeCreate(this, stackSize, daemon);
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }

C/C++中的nativeCreate的源码

static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, 
                                  jlong stack_size, jboolean daemon) {
  Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

C/C++中的CreateNativeThread

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
  Thread* self = static_cast<JNIEnvExt*>(env)->self;
  Runtime* runtime = Runtime::Current();

  ...
  Thread* child_thread = new Thread(is_daemon);
  child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
  stack_size = FixStackSize(stack_size);

  env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer,
                    reinterpret_cast<jlong>(child_thread));

  std::unique_ptr<JNIEnvExt> child_jni_env_ext(
      JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM()));

  int pthread_create_result = 0;
  if (child_jni_env_ext.get() != nullptr) {
    pthread_t new_pthread;
    pthread_attr_t attr;
    child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
    //创建线程
    pthread_create_result = pthread_create(&new_pthread,
                         &attr, Thread::CreateCallback, child_thread);

    if (pthread_create_result == 0) {
      child_jni_env_ext.release();
      return;
    }
  }
  
  ...
}

C/C++中的pthread_create,pthread_create的分析暂时不分析,涉及到Linux知识代深入了解再分析,先说说pthread_create的参数

  • 原型:int pthread_create((pthread_t thread, pthread_attr_t *attr, void *(start_routine)(void *), void *arg)
  • 头文件:#include
  • 输入参数:thread:线程标识符; attr:线程属性设置; start_routine:线程函数的起始地址; - arg:传递给start_routine的参数;
  • 返回值:成功则返回0;出错则返回-1。
  • 功能:创建线程,并调用线程起始地址所指向的函数start_routine。
运行 Running

如果处于就绪状态的线程获得了CPU资源,就开始执行run方法的线程执行体,则该线程处于运行状态。run方法的那里呢?其实run也是在native线程中。源码如下:

status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
    Mutex::Autolock _l(mLock);
    //保证只会启动一次
    if (mRunning) {
        return INVALID_OPERATION;
    }
    ...
    mRunning = true;

    bool res;
    
    if (mCanCallJava) {
        //还能调用Java代码的Native线程
        res = createThreadEtc(_threadLoop,
                this, name, priority, stack, &mThread);
    } else {
        //只能调用C/C++代码的Native线程
        res = androidCreateRawThreadEtc(_threadLoop,
                this, name, priority, stack, &mThread);
    }

    if (res == false) {
        ...//清理
        return UNKNOWN_ERROR;
    }
    return NO_ERROR;
}

mCanCallJava在Thread对象创建时,在构造函数中默认设置mCanCallJava=true.

  • 当mCanCallJava=true,则代表创建的是不仅能调用C/C++代码,还能能调用Java代码的Native线程
  • 当mCanCallJava=false,则代表创建的是只能调用C/C++代码的Native线程。

关于createThreadEtc和androidCreateRawThreadEtc方法都不一一列出来了,感兴趣的自已查检源码了解。

从start方法进入nativeCreate经过层层调用,最终都会进入clone系统调用,这是linux创建线程或进程的通用接口。Native线程中是否可以执行Java代码的区别,在于通过javaThreadShell()方法从而实现在_threadLoop()执行前后增加分别将当前线程增加hook到虚拟机和从虚拟机移除的功能。调用过程,顺序为:

1.Thread.run
2.createThreadEtc
3.androidCreateThreadEtc
4.javaCreateThreadEtc
5.androidCreateRawThreadEtc
6.javaThreadShell
7.javaAttachThread
8._threadLoop
9.javaDetachThread

阻塞 Blocked

阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况大概三种:

1、等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)
2、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
3、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)。

线程睡眠:Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为就绪(Runnable)状态。sleep()平台移植性好。
线程等待:Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。唤醒线程后,就转为就绪(Runnable)状态。
线程让步:Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
线程加入:join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
线程I/O:线程执行某些IO操作,因为等待相关的资源而进入了阻塞状态。比如说监听system.in,但是尚且没有收到键盘的输入,则进入阻塞状态。
线程唤醒:Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意性的,并在对实现做出决定时发生。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。

死亡 Dead

线程会以以下三种方式之一结束,结束后就处于死亡状态:

  • run()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的Exception或Error。
  • 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。
线程多次启动

Java线程是不允许启动多次的,第二次调用必然会抛出IllegalThreadStateException。 根据线程生命周期可知,线程初始状态为NEW,此状态不能由其他状态转变而来。

最后

此面试题会持续更新,请大家多多关注!!!

扫描下方二维码即可领取面试题~

ps:群内还设有ChatGPT机器人,可以解答各位在技术上遇到的难题

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值