JAVA工程师面试专题-《并发编程篇》

目录

一、线程

1、并发与并行的区别

2、同步和异步的区别

3、Java中创建线程有哪些方式?

4、Thread和Runnable的区别

5、Java中的Runnable、Callable、Future、FutureTask的区别和联系?

6、说一下你对 CompletableFuture 的理解

7、volatile关键字有什么用?怎么理解可见性,一般什么场景去用可见性?

8、请简述下伪共享的概念及如何避免?

9、线程的生命周期和状态?

10、sleep() 方法和 wait() 方法对比

11、线程状态,blocked()和waiting()有什么区别

12、线程的 sleep() 方法和 yield() 方法有什么区别?

13、线程的 join() 方法是干啥用的?

14、为什么 wait() 方法不定义在 Thread 中?

15、讲一下 wait 和 notify 这个为什么要在synchronized代码块中?

16、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

17、线程调度算法

18、并发编程三要素或线程的安全性问题

19、多线程编程的注意事项

二、volatile

1、volatile有什么作用?

2、volatile的典型场景

3、volatile的内存语义

4、DCL单例模式为什么需要volatile修饰实例对象

5、请说一下你对Happends-Before的理解

6、volatile底层的实现机制

7、你对内存屏障理解 ?

三、synchronized

1、synchronized 关键字作用?

2、synchronized 和 volatile 的区别?

3、synchronized 和 Lock 的区别

4、synchronized 锁升级的原理

5、synchronized和ReentrantLock的区别

6、可重入锁-ReentrantLock,特性及原理

7、死锁及解决方案

四、线程池

1、线程池的运作流程

2、线程池常用参数

3、初始化线程池时线程数的选择

4、线程池都有哪几种工作队列

5、线程池有哪些拒绝策略?

6、如何中断一个正在运行的线程?

7、线程池如何知道一个线程的任务已经执行完成

8、线程池是如何实现线程复用的?

9、阻塞队列被异步消费怎么保持顺序的?

10、什么叫做阻塞队列的有界和无界

11、阻塞队列BlockingQueue详解

12、ArrayBlockingQueue和LinkedBlockingQueue详解

13、线程池有哪些状态

14、讲下线程池的线程回收

15、Executor VS ExecutorService VS Executors

16、Java线程池系列之execute和submit区别

17、SimpleDateFormat 是线程安全的吗? 为什么?

18、什么是上下文切换

五、JUC

1、ThreadLocal原理了解吗?

2、什么是CAS

3、谈谈你对AQS的理解

4、CountDownLatch与CyclicBarrier

5、Semaphore有什么作用


一、线程

1、并发与并行的区别

并发:两个及两个以上的作业在同一时间段内执行。

并行:两个及两个以上的作业在同一时刻执行。

最关键的点是:是否是同时执行。

并发 = 两个队列和一台咖啡机。 并行 = 两个队列和两台咖啡机。

2、同步和异步的区别

同步 : 发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。

异步 :调用在发出之后,不用等待返回结果,该调用直接返回。

3、Java中创建线程有哪些方式?

  1. 继承Thread类,重写Run方法

  2. 实现Runnable接口

  3. 实现Callable接口

4、Thread和Runnable的区别

  1. Thread 是一个类,Runnable 是接口,因为在 Java 语言里面的继承特性,接口可以支持多继承,而类只能单一继承。所以如果在已经存在继承关系的类里面要实现线程的话,只能实现 Runnable 接口。

  2. Runnable 表示一个线程的顶级接口,Thread 类其实也是实现了 Runnable 接口

  3. 站在面向对象的思想来说,Runnable 相当于一个任务,而 Thread 才是真正处理的线程,所以我们只需要用 Runnable 去定义一个具体的任务,然后交给 Thread去处理就可以了,这样达到了松耦合的设计目的

  4. 接口表示一种规范或者标准,而实现类表示对这个规范或者标准的实现,所以站在线程的角度来说,Thread 才是真正意义上的线程实现。Runnable 表示线程要执行的任务,因此在线程池里面,提交一个任务传递的类型是 Runnable

5、Java中的Runnable、Callable、Future、FutureTask的区别和联系?

Runnable

Runnable是一个接口,只需要新建一个类实现这个接口,然后重写run方法,将该类的实例作为创建Thread的入参,线程运行时就会调用该实例的run方法。

@FunctionalInterfacepublic interface Runnable { public abstract void run(); }

Callable

Callable跟Runnable类似,也是一个接口。只不过它的call方法有返回值,可以供程序接收任务执行的结果。

@FunctionalInterfacepublic interface Callable<V> { V call() throws Exception; }

Future

Future也是一个接口,Future就像是一个管理的容器一样,进一步对Runable和Callable的实例进行封装,定义了一些方法。取消任务的cancel()方法,查询任务是否完成的isDone()方法,获取执行结果的get()方法,带有超时时间来获取执行结果的get()方法。

public interface Future<V> { 

//mayInterruptIfRunning代表是否强制中断 //为true,如果任务已经执行,那么会调用Thread.interrupt()方法设置中断标识 //为false,如果任务已经执行,就只会将任务状态标记为取消,而不会去设置中断标识 
boolean cancel(boolean mayInterruptIfRunning); 
boolean isCancelled(); boolean isDone(); 
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException; 

}

FutureTask

因为Future只是一个接口,并不能实例化,可以认为FutureTask就是Future接口的实现类,FutureTask实现了RunnableFuture接口,而RunnableFuture接口继承Runnable接口和Future接口。

public class FutureTask<V> implements RunnableFuture<V> { ... } 
public interface RunnableFuture<V> extends Runnable, Future<V> { void run(); }

6、说一下你对 CompletableFuture 的理解

CompletableFuture 是 JDK1.8 里面引入的一个基于事件驱动的异步回调类,简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作,而 CompletableFuture 就可以实现这个功能。CompletableFuture 提供了 5 种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。

1.第一种,thenCombine(如图),把两个任务组合在一起,当两个任务都执行结束以后触发事件回调

2.第二种,thenCompose(如图),把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务

3.第三种,thenAccept(如图),第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值

4.第四种,thenApply(如图),和 thenAccept 一样,但是它有返回值。

5.第五种,thenRun(如图),就是第一个任务执行完成后触发执行一个实现了Runnable 接口的任务

最后,我认为,CompletableFuture 弥补了原本 Future 的不足,使得程序可以在非阻塞的状态下完成异步的回调机制

7、volatile关键字有什么用?怎么理解可见性,一般什么场景去用可见性?

当线程进行一个volatile变量的写操作时,JIT编译器生成的汇编指令会在写操作的指令后面加上一个“lock”指令。 Java代码如下:

instance = new Singleton(); // instance是volatile变量 转变成汇编代码,如下。
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

“lock”有三个作用:

  1. 将当前CPU缓存行的数据会写回到系统内存。

  2. 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效。

  3. 确保指令重排序时,内存屏障前的指令不会排到后面去,内存屏障后的指令不会排到前面去。

使用场景

1.读写锁

如果需要实现一个读写锁,每次只能一个线程去写数据,但是有多个线程来读数据,就synchronized同步锁来对set方法加锁,get方法不加锁, 使用volatile来修饰变量,保证内存可见性,不然多个线程可能会在变量修改后还读到一个旧值。

 

volatile Integer a; //可以实现一写多读的场景,保证并发修改数据时的正确。 set(Integer c) { synchronized(this.a) { this.a = c; } } get() { return a; }

2.状态位

用于做状态位标志,如果多个线程去需要根据一个状态位来执行一些操作,使用volatile修饰可以保证内存可见性。

3.单例模式

用于单例模式用于保证内存可见性,以及防止指令重排序。

8、请简述下伪共享的概念及如何避免?

如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题 。因为伪共享问题会导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会受到较 大的影响

这个问题的解决办法有两个:

  1. 使用对齐填充,因为一个缓存行大小是 64 个字节,如果读取的目标数据小于 64个字节,可以增加一些无意义的成员变量来填充。

  2. 在 Java8 里面,提供了@Contented 注解,它也是通过缓存行填充来解决伪共享问题的,被@Contented 注解声明的类或者字段,会被加载到独立的缓存行上。

Java 伪共享的原理深度解析以及避免方法 - 掘金

9、线程的生命周期和状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start() 。

  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。

  • BLOCKED :阻塞状态,需要等待锁释放。

  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)

  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

10、sleep() 方法和 wait() 方法对比

共同点 :两者都可以暂停线程的执行。

区别 :

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁 。

  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。

  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。

11、线程状态,blocked()和waiting()有什么区别

多个线程去竞争 Synchronized 同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是 BLOCKED,在线程的整个生命周期里面,只有 Synchronized 同步锁等待才会存在这个状态。Object.wait()、Object.join()、LockSupport.park()这些方法使得线程进入到 WAITING 状态,在这个状态下,必须要等待特定的方法来唤醒 。比如 Object.notify 方法可以唤醒 Object.wait()方法阻塞的线程LockSupport.unpark()可以唤醒 LockSupport.park()方法阻塞的线程。所以,在我看来,BLOCKED 和 WAITING 两个状态最大的区别有两个:

  • BLOCKED 是锁竞争失败后被被动触发的状态,WAITING 是人为的主动触发的状态

  • BLOCKED 的唤醒是自动触发的,而 WAITING 状态是必须要通过特定的方法来主动唤醒

12、线程的 sleep() 方法和 yield() 方法有什么区别?

线程执行 sleep() 方法后进入超时等待(TIMED_WAITING)状态,而执行 yield() 方法后进入就绪(READY)状态。

sleep() 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程运行的机会;yield() 方法只会给相同优先级或更高优先级的线程以运行的机会。

13、线程的 join() 方法是干啥用的?

用于等待当前线程终止。如果一个线程A执行了 threadB.join() 语句,其含义是:当前线程A等待 threadB 线程终止之后才从 threadB.join() 返回继续往下执行自己的代码。

14、为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

15、讲一下 wait 和 notify 这个为什么要在synchronized代码块中?

wait 和 notify 用来实现多线程之间的协调,wait 表示让线程进入到阻塞状态,notify 表示让阻塞的线程唤醒。

Synchronized 同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线程通信的场景里面,

参与通信的线程必须要竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其

他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之间的通信。 wait/notify 就

可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。

16、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是thread 的一个普通方法调用,还是在主线程里执行。

17、线程调度算法

所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得 CPU 的使用权,分别执行各自的任务。

有两种调度模型:分时调度模型和抢占式调度模型。

  1. 分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片

  2. Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。

18、并发编程三要素或线程的安全性问题

并发编程三要素:

  1. 原子性:指的是一个或多个操作要么全部执行成功要么全部执行失败。

  2. 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)

  3. 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)

出现线程安全问题的原因:

  1. 线程切换带来的原子性问题

  2. 缓存导致的可见性问题

  3. 编译优化带来的有序性问题

解决办法:

JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题

synchronized、volatile、LOCK,可以解决可见性问题

Happens-Before 规则可以解决有序性问题

19、多线程编程的注意事项

  1. 线程安全性:确保共享数据的线程安全性,避免多个线程同时修改共享数据而导致的数据不一致或竞态条件问题。可以使用锁、同步块或并发数据结构来保护共享数据的访问。

  2. 同步机制:使用合适的同步机制来控制线程之间的访问顺序或共享资源的使用。例如,使用 synchronized 关键字、ReentrantLock 或其他并发工具类来实现线程间的同步。

  3. 原子操作:对于需要保证原子性的操作,使用原子类或同步机制来保证操作的完整性,避免数据竞争或不一致的问题。例如,使用 Atomic 原子类或 volatile 关键字来确保变量的可见性和原子更新。

  4. 线程间通信:使用合适的线程间通信机制来实现线程之间的协作和数据交换。可以使用 wait/notify、Condition、CountDownLatch、CyclicBarrier 等机制来实现线程间的通信和同步。

  5. 避免死锁:避免出现死锁情况,即多个线程相互等待对方释放资源而导致的程序无法继续执行的情况。设计良好的锁顺序、避免多个锁的嵌套使用和使用定时锁等方法可以帮助避免死锁。

  6. 线程池:使用线程池管理线程的创建和复用,避免频繁地创建和销毁线程的开销。线程池可以提供线程的生命周期管理、任务调度和线程资源的控制,可以根据具体需求配置线程池的大小和工作队列。

  7. 异常处理:合理处理线程中的异常,避免异常导致线程终止或整个应用崩溃。可以使用 try-catch 块捕获异常,并根据具体情况选择合适的处理方式,例如记录日志、重新抛出异常或进行适当的回滚操作。

  8. 性能优化:针对特定的多线程场景,进行性能优化的措施,如减少锁的竞争、减少线程间的通信、避免不必要的线程阻塞等。可以使用性能分析工具来识别性能瓶颈,并对关键代码进行优化。

  9. 避免线程泄漏:确保在不再需要的情况

二、volatile

1、volatile有什么作用?

  1. 可以保证在多线程环境下共享变量的可见性。

  2. 通过增加内存屏障防止多个指令之间的重排序。

2、volatile的典型场景

  1. 状态标志

  2. DCL单例模式

  3. CAS 轻量级乐观锁场景

3、volatile的内存语义

  1. 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

  2. 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

4、DCL单例模式为什么需要volatile修饰实例对象

我所理解的 DCL 问题,是在基于双重检查锁设计下的单例模式中,存在不完整对象的问题。而这个不完整对象的本质,是因为指令重排序导致的。解决办法就是可以在 instance 这个变量上增加一个 volatile 关键字修饰, volatile 底层使用了内存屏障机制来避免指令重排序。

5、请说一下你对Happends-Before的理解

  1. 首先,Happens-Before 是一种可见性模型,也就是说,在多线程环境下。原本因为指令重排序的存在会导致数据的可见性问题,也就是 A 线程修改某个共享变量对 B 线程不可见。 因此,JMM 通过 Happens-Before 关系向开发人员提供跨越线程的内存可见性保证。如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在Happens-Before 关系。

  2. 其次,Happens-Before 关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说只要不对结果产生影响,仍然允许指令的重排序。

  3. 最后,在 JMM 中存在很多的 Happens-Before 规则

  • 程序顺序规则,一个线程中的每个操作,happens-before 这个线程中的任意后续操作,可以简单认为是 as-if-serial也就是不管怎么重排序,单线程的程序的执行结果不能改变 。

  • 传递性规则(如图),也就是 A Happens-Before B,B Happens-Before C。就可以推导出 A Happens-Before

  • volatile 变量规则,对一个 volatile 修饰的变量的写一定 happens-before 于任意后续对这个 volatile 变量的读操作

  • 监视器锁规则(如图),一个线程对于一个锁的释放锁操作,一定 happens-before 与后续线程对这个锁的加锁操作

  • 线程启动规则(如图),如果线程 A 执行操作 ThreadB.start(),那么线程 A 的ThreadB.start()之前的操作 happens-before 线程 B 中的任意操作

  • join 规则(如图),如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功的返回

6、volatile底层的实现机制

汇编代码层面,加入lock前缀指令,相当于一个内存屏障,可以禁止指令重排序,缓存修改操作写入主存,失效掉其它cpu中对应的缓存行。

7、你对内存屏障理解 ?

JVM层面的内存屏障

  • LoadLoad屏障:(指令Load1;LoadLoad;Load2),在Load2及后续 读取操作要读取的数据访问前,保障Load1要读取的数据被读取完毕。

  • LoadStore屏障:(指令Load1;LoadStore;Store2),在Store2及后续写入操作被刷出前,保障Load1要读取的数据被读取完毕。

  • StoreStore屏障:(指令Store1;StoreStore;Store2),在Store2及后续写入操作执行前,保障Store1的写入操作对其他处理器可见;

  • StoreLoad屏障:(指令Store1;StoreLoad;Load2),在Load2及后续所有读取操作执行前保障Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障;兼具其他三种内存屏障的功能。

硬件层面内存屏障

硬件层提供了一系列的内存屏障memory barrier/memory fence来提供一致性的能力,拿X86平台来说,有以下几种内存屏障:

  • lfence,是一种Load Barrier读屏障,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。

  • sfence,是一种Store Barrier写屏障,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。

  • mfence,是一种全能型的屏障,具备lfence和sfence的能力

  • Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线或高速缓存加锁,可以理解为CPU指令级的一种锁。它先对高速缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

三、synchronized

1、synchronized 关键字作用?

主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

(1)使用方式:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;

  • synchronized 关键字加到实例方法上是给对象实例上锁;

  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能,导致对同一个对象上锁。

(2)底层实现:

  • synchronized 修饰代码块时,编译后会生成 monitorenter 和 monitorexit 指令,分别对应进入同步块和退出同步块。可以看到有两个 monitorexit,这是因为编译时 JVM 为代码块添加了隐式的 try-finally,在 finally 中进行了锁释放,这也是为什么 synchronized 不需要手动释放锁的原因。

  • synchronized 修饰方法时,编译后会生成 ACC_SYNCHRONIZED 标记,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了则会先尝试获得锁。

2、synchronized 和 volatile 的区别?

  1. volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。

  2. volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

  3. volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

3、synchronized 和 Lock 的区别

  1. 从功能角度来看,Lock 和 Synchronized 都是 Java 中用来解决线程安全问题的工具。Lock 是一个接口;synchronized 是 Java 中的关键字,是内置的语言实现;

  2. 从特性上看

  • Synchronized 可以通过两种方式来控制锁的粒度, 同步代码块和同步方法

  • Lock 的使用更加灵活,可以有响应中断、有超时时间等;而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,直到获取到锁;Lock提供了公平锁和非公平锁机制。synchronized只提供非公平的实现

  • Lock 在发生异常时,如果没有主动通过 unLock() 去释放锁,很可能会造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;synchronized 不需要手动获取锁和释放锁,在发生异常时,会自动释放锁,因此不会导致死锁现象发生;

  1. 从性能方面来看,Synchronized 和 Lock 在性能方面相差不大,在实现上会有一些区别,Synchronized 引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而 Lock 中则用到了自旋锁的方式来实现性能优化。

4、synchronized 锁升级的原理

synchronized 锁升级原理

锁对象的对象头里有一字段: threadid 。

首次访问锁对象, threadid 为空,JVM 让其持有偏向锁,并将 threadid 设置为当前线程 id;

再次访问锁对象,会先判断 threadid 是否与当前线程 id 一致,若一致则可以直接使用此对象,若不一致,则升级偏向锁为轻量级锁;

等待锁对象中,通过自旋循环一定次数来获取锁,执行一定次数后,若还未能正常获取到要使用的对象,此时就会把锁从轻量级锁升级为重量级锁。

锁的升级的目的

锁升级,是为了降低使用锁带来的性能消耗。

锁的升级不可逆

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的。

5、synchronized和ReentrantLock的区别

  • 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块。

  • 获取锁和释放锁的机制不同:synchronized 是自动加锁和释放锁的,而 ReentrantLock 需要手动加锁和释放锁。

  • 锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁。

  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。

  • 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

6、可重入锁-ReentrantLock,特性及原理

ReentrantLock 是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。

特性:

  • 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。

  • 它支持公平和非公平特性

  • 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是lock()和tryLock()。

原理:

锁的竞争,ReentrantLock 是通过互斥变量,使用 CAS 机制来实现的。

没有竞争到锁的线程,使用了 AbstractQueuedSynchronizer 这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS 队列里面的头部唤醒下一个等待锁的线程。

公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS 队列存在等待中的线程。

最后,关于锁的重入特性,在 AQS 里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。

7、死锁及解决方案

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。

形成死锁的四个必要条件:

  • 互斥条件:一个资源只能被一个线程(进程)占用,直至释放

  • 请求与保持条件:一个线程请求资源时被阻塞,对自己已获得的资源保持不放

  • 不可抢占条件:线程(进程)已获得的资源在释放之前不被其他线程(进程)强行剥夺

  • 循环等待条件:发生死锁时,会形成环路等待。

死锁解决方案:(打破四个必要条件)

  • 对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。 所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了

四、线程池

1、线程池的运作流程

  1. 提交任务后会首先进行当前工作线程数与核心线程数的比较,如果当前工作线程数小于核心线程数,则直接调用 addWorker() 方法创建一个核心线程去执行任务;

  2. 如果工作线程数大于核心线程数,即线程池核心线程数已满,则新任务会被添加到阻塞队列中等待执行,当然,添加队列之前也会进行队列是否为空的判断;

  3. 如果线程池里面存活的线程数已经等于核心线程数了,且阻塞队列已经满了,再会去判断当前线程数是否已经达到最大线程数maximumPoolSize,如果没有达到,则会调用 addWorker() 方法创建一个非核心线程去执行任务;

  4. 如果当前线程的数量已经达到了最大线程数时,当有新的任务提交过来时,会执行拒绝策略总结来说就是优先核心线程、阻塞队列次之,最后非核心线程。

2、线程池常用参数

  1. corePoolSize:核心线程数量,会一直存在,除非allowCoreThreadTimeOut设置为true

  2. maximumPoolSize:线程池允许的最大线程池数量

  3. keepAliveTime:线程数量超过corePoolSize,空闲线程的最大超时时间

  4. unit:超时时间的单位

  5. workQueue:工作队列,保存未执行的Runnable 任务

  6. threadFactory:创建线程的工厂类

  7. handler:当线程已满,工作队列也满了的时候,会被调用。被用来实现各种拒绝

3、初始化线程池时线程数的选择

  1. 如果任务是IO密集型,一般线程数需要设置2倍CPU数以上,以此来尽量利用CPU资源。

  2. 如果任务是CPU密集型,一般线程数量只需要设置CPU数加1即可,更多的线程数也只能增加上下文切换,不能增加CPU利用率

  3. 如果任务是混合型,有一个公式:

    最佳线程数 = ((线程等待时间+线程CPU时间) /线程CPU时间 ) * CPU核数

4、线程池都有哪几种工作队列

ArrayBlockingQueue

是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。

LinkedBlockingQueue

一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列

SynchronousQueue

一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。

PriorityBlockingQueue

一个具有优先级的无限阻塞队列。

5、线程池有哪些拒绝策略?

  1. AbortPolicy:中止策略。默认的拒绝策略,直接抛出 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。

  2. DiscardPolicy:抛弃策略。什么都不做,直接抛弃被拒绝的任务。

  3. DiscardOldestPolicy:抛弃最老策略。抛弃阻塞队列中最老的任务,相当于就是队列中下一个将要被执行的任务,然后重新提交被拒绝的任务。如果阻塞队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用。

  4. CallerRunsPolicy:调用者运行策略。在调用者线程中执行该任务。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将任务回退到调用者(调用线程池执行任务的主线程),由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得线程池有时间来处理完正在执行的任务。

6、如何中断一个正在运行的线程?

在 Java Thread 里面提供了一个 interrupt()方法,这个方法配合isInterrupted()方法使用,就可以实现安全的中断机制。

7、线程池如何知道一个线程的任务已经执行完成

  1. isTerminated()方法 ,可以循环判断 isTerminated()方法的返回结果来了解线程池的运行状态,一旦线 程池的运行状态是 Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的 shutdown()方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好

  2. 在线程池中,有一个 submit()方法,它提供了一个 Future 的返回值,我们通过 Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,future.get()方法会一直阻塞,直到任务执行结束。因此,只要future.get()方法正常返回,也就意味着传入到线程池中的任务已经执行完成了

  3. 可以引入一个 CountDownLatch 对象并且计数器为 1 ,接着在线程池代码块后面调用 await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用 countDown()方法表示任务执行结束。

8、线程池是如何实现线程复用的?

线程池里面采用了生产者消费者的模式,来实现线程复用。生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。生产者不断生产任务保存到容器,消费者不断从容器中消费任务。在线程池里面,因为需要保证工作线程的重复使用,并且这些线程应该是有任务的时候执行,没任务的时候等待并释放 CPU 资源。因此(如图),它使用了阻塞队列来实现这样一个需求。提交任务到线程池里面的线程称为生产者线程,它不断往线程池里面传递任务。这些任务会保存到线程池的阻塞队列里面。然后线程池里面的工作线程不断从阻塞队列获取任务去执行。

基于阻塞队列的特性,使得阻塞队列中如果没有任务的时候,这些工作线程就会阻塞等待。直到又有新的任务进来,这些工作线程再次被唤醒,从而达到线程复用的目的。

9、阻塞队列被异步消费怎么保持顺序的?

  1. 首先,阻塞队列本身是符合 FIFO 特性的队列,也就是存储进去的元素符合先进先出的规则。

  2. 其次,在阻塞队列里面,使用了 condition 条件等待来维护了两个等待队列(如图),一个是队列为空的时候存储被阻塞的消费者,另一个是队列满了的时候存储被阻塞的生产者并且存储在等待队列里面的线程,都符合 FIFO 的特性。

最后,对于阻塞队列的消费过程,有两种情况。

  • 第一种,就是阻塞队列里面已经包含了很多任务,这个时候启动多个消费者去消费的时候,它的有序性保证是通过加锁来实现的,也就是每个消费者线程去阻塞队列获取任务的时候 必须要先获得排他锁。

  • 第二种,如果有多个消费者线程因为阻塞队列中没有任务而阻塞,这个时候这些线程是按照 FIFO 的顺序存储到condition 条件等待队列中的。当阻塞队列中开始有任务要处理的时候,这些被阻塞的消费者线程会严格按照 FIFO 的顺序来唤醒,从而保证了消费的顺序性。

10、什么叫做阻塞队列的有界和无界

有界是指阻塞队列中能够容纳的元素个数是固定大小的。无界是没有设置固定大小的队列,但也不是没有任何限制。像 LinkedBlockingQueue,它的默认队列长度是 Integer.Max_Value,所以我们感知不到它的长度限制。

11、阻塞队列BlockingQueue详解

是什么

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列,这两个附加的操作支持阻塞的插入和移除方法。

支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。

支持阻塞的移除方法:当队列为空时,获取元素的线程会等待队列变为非空。

实现原理

阻塞队列提供了可阻塞的put和take方法。如果队列是空的,消费者使用take方法从队列中获取数据就会被阻塞,直到队列有数据可用;当队列是满的,生产者使用put方法向队列里添加数据就会被阻塞,直到队列中数据被消费有空闲位置可用。

主要的阻塞队列

1)ArrayBlockingQueue:基于数组实现的有界阻塞队列。

2)LinkedBlockingQueue:基于链表实现的有界阻塞队列。

3)PriorityBlockingQueue:支持按优先级排序的无界阻塞队列。

4)DelayQueue:优先级队列实现的无界阻塞队列。

5)SynchronousQueue:不存储元素的阻塞队列。

6)LinkedTransferQueue:基于链表实现的无界阻塞队列。

7)LinkedBlockingDeque:基于链表实现的双向无界阻塞队列。

7个阻塞队列全部实现了BlockingQueue接口,插入和移除元素分别各提供了4种处理方式。

  • 当阻塞队列满的时候,再往队列里add插入元素会抛出IllegalStateException: Queue full异常;

  • 当阻塞队列空的时候,再往队列里remove移除元素,会抛出NoSuchElementException异常;

  • 使用offer(e)添加元素,不抛异常,成功返回true,失败返回false;

  • 使用poll()移除元素时,不抛异常,成功返回队列里的元素值,失败返回null;

  • 当阻塞队列满的时候,生产者线程继续使用put(e)插入元素,会一直阻塞直到put成功,或者响应中断退出;

  • 当阻塞队列空的时候,消费者线程试图从队列里take元素,队列会一直阻塞,直到消费者线程可用;

  • 当阻塞队列满时,会等待一段时间,超时后退出:offer(e,time,unit)和poll(time,unit)。

12、ArrayBlockingQueue和LinkedBlockingQueue详解

  • ArrayBlockingQueue

通过数组实现的有界阻塞队列, 此队列按照先进先出(FIFO)的原则对元素进行排序

锁没有分离(只有一把锁),生产和消费用的同一把锁(出队和入队)

数组队列在生产和消费(出队和入队)时直接将对象从数组中插入或移除(设置为null),效率较高。

因为是数组队列,所以初始化时必须指定队列大小。

  • LinkedBlockingQueue

通过链表实现的可选容量阻塞队列, 队头元素是插入时间最长的,队尾元素是最新插入的。新的元素会被插入到队列尾部。

有两把锁,一把用于put(生产),一把用于take(消费)。

队列在生产和消费的时候,需要把数据对象封装到Node节点中。相对效率没有那么高。

队列容量限制是可选的,如果在初始化时没有指定容量,那么默认使用的int最大值作为队列容量。

13、线程池有哪些状态

  • RUNNING:线程池一旦被创建,就处于 RUNNING 状态,任务数为 0,能够接收新任务,对已排队的任务进行处理。

  • SHUTDOWN:不接收新任务,但能处理已排队的任务。调用线程池的 shutdown() 方法,线程池由 RUNNING 转变为 SHUTDOWN 状态。

  • STOP:不接收新任务,不处理已排队的任务,并且会中断正在处理的任务。调用线程池的 shutdownNow() 方法,线程池由(RUNNING 或 SHUTDOWN ) 转变为 STOP 状态。

  • TIDYING:

SHUTDOWN 状态下,任务数为 0, 其他所有任务已终止,线程池会变为 TIDYING 状态,会执行 terminated() 方法。线程池中的 terminated() 方法是空实现,可以重写该方法进行相应的处理。

线程池在 SHUTDOWN 状态,任务队列为空且执行中任务为空,线程池就会由 SHUTDOWN 转变为 TIDYING 状态。

线程池在 STOP 状态,线程池中执行中任务为空时,就会由 STOP 转变为 TIDYING 状态。

  • TERMINATED:线程池彻底终止。线程池在 TIDYING 状态执行完 terminated() 方法就会由 TIDYING 转变为 TERMINATED 状态

14、讲下线程池的线程回收

首先,线程池里面分为核心线程和非核心线程。核心线程是常驻在线程池里面的工作线程,它有两种方式初始化

  1. 向线程池里面添加任务的时候,被动初始化

  2. 主动调用 prestartAllCoreThreads 方法

当线程池里面的队列满了的情况下,为了增加线程池的任务处理能力。线程池会增加非核心线程。核心线程和非核心线程的数量,是在构造线程池的时候设置的,也可以动态进行更改。

由于非核心线程是为了解决任务过多的时候临时增加的,所以当任务处理完成后,工作线程处于空闲状态的时候,就需要回收。因为所有工作线程都是从阻塞队列中去获取要执行的任务,所以只要在一定时间内,阻塞队列没有任何可以处理的任务,那这个线程就可以结束了。

这个功能是通过阻塞队列里面的 poll 方法来完成的。这个方法提供了超时时间和超时时间单位这两个参数 ,当超过指定时间没有获取到任务的时候,poll 方法返回 null,从而终止当前线程完成线程回收。

默认情况下,线程池只会回收非核心线程,如果希望核心线程也要回收,可以设置 allowCoreThreadTimeOut 这个属性为 true,一般情况下我们不会去回收核心线程。因为线程池本身就是实现线程的复用,而且这些核心线程在没有任务要处理的时候是处于阻塞状态,并没有占用 CPU 资源。

15、Executor VS ExecutorService VS Executors

Executor

ExecutorService

Executors

区别一

接口

接口

区别二

ExecutorService 接口继承了 Executor 接口,是 Executor 的子接口

提供工厂方法用来创建不同类型的线程池

区别三

Executor 接口定义了 execute()方法用来接收一个Runnable接口的对象

ExecutorService 接口中的 submit()方法可以接受RunnableCallable接口的对象

区别四

Executor 中的 execute() 方法不返回任何结果

ExecutorService 中的 submit()方法可以通过一个 Future 对象返回运算结果

16、Java线程池系列之execute和submit区别

  1. execute是Executor接口的方法,而submit是ExecutorService的方法,并且ExecutorService接口继承了Executor接口。

  2. execute只接受Runnable参数,没有返回值;而submit可以接受Runnable参数和Callable参数,并且返回了Future对象,可以进行任务取消、获取任务结果、判断任务是否执行完毕/取消等操作。其中,submit会对Runnable或Callable入参封装成RunnableFuture对象,调用execute方法并返回。

  3. 通过execute方法提交的任务如果出现异常则直接抛出原异常,是在线程池中的线程中;而submit方法是捕获了异常的,只有当调用Future的get方法时,才会抛出ExecutionException异常,且是在调用get方法的线程。

17、SimpleDateFormat 是线程安全的吗? 为什么?

SimpleDateFormat 不是线程安全的,SimpleDateFormat 类内部有一个 Calendar 对象引用,它用来储存和这个 SimpleDateFormat 相关的日期信息。当我们把 SimpleDateFormat 作为多个线程的共享资源来使用的时候。意味着多个线程会共享 SimpleDateFormat 里面的 Calendar 引用,多个线程对于同一个 Calendar 的操作,会出现数据脏读现象导致一些不可预料的错误。在实际应用中,我认为有 4 种方法可以解决这个问题。

  • 第一种,把 SimpleDateFormat 定义成局部变量,每个线程调用的时候都创建一个新的实例。

  • 第二种,使用 ThreadLocal 工具,把 SimpleDateFormat 变成线程私有的

  • 第三种,加同步锁,在同一时刻只允许一个线程操作 SimpleDateFormat

  • 第四种,在 Java8 里面引入了一些线程安全的日期 API,比如 LocalDateTimer、DateTimeFormatter 等。

18、什么是上下文切换

当前线程任务在执行完 CPU 时间片切换到另一个线程任务之前会先保存自己的状态,以便下次再切换回这个线程任务时,可以再加载这个线程任务的状态。任务从保存到再加载的过程就是一次上下文切换

五、JUC

1、ThreadLocal原理了解吗?

是什么

从字面意思上,ThreadLocal会被理解为线程本地存储,就是对于代码中的一个变量,每个线程拥有这个变量的一个副本,访问和修改它时都是对副本进行操作。ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性

原理

每个线程中独立的ThreadLocalMap副本所存储的值,只能被当前线程读取和修改。ThreadLocal类通过操作每一个线程特有ThreadLocalMap副本,从而实现了变量访问在不同线程中的隔离。因为每个线程的变量都是自己特有的,完全不会有并发错误。

class ThreadLocalMap { 

//初始容量 
private static final int INITIAL_CAPACITY = 16; 
//存放元素的数组 
private Entry[] table; 
//元素个数 
private int size = 0; 

}

table 就是存储线程局部变量的数组,数组元素是Entry类,Entry由key和value组成,key是Threadlocal对象,value是存放的对应线程变量

使用场景

当很多线程需要多次使用同一个对象,并且需要该对象具有相同初始化值的时候最适合使用ThreadLocal

造成内存泄漏原因

ThreadLocalMap中的Entry继承自WeakReference(弱引用,生命周期只能存活到下次GC前),但只有Key是弱引用类型的,Value并非弱引用。(问题马上就来了)由于ThreadLocalMap的key是弱引用,而Value是强引用。这就导致了一个问题,ThreadLocal在没有外部对象强引用时,发生GC时弱引用Key会被回收,而Value不会回收。当线程没有结束,但是ThreadLocal已经被回收,则可能导致线程中存在ThreadLocalMap<null, Object>的键值对,造成内存泄露。(根本原因:由于ThreadLocalMap的生命周期跟Thread一样长。如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用)

内存泄漏解决方案

每次使用完ThreadLocal,都调用它的remove()方法,清除数据

2、什么是CAS

CAS 是Java 中Unsafe类里面的方法,它的全称是CompareAndSwap,比较并交换的意思。是指一个旧的预期值A,主内存的值是B,要修改的值C,当且仅当A==B的时候,A的值才会被修改成C。CompareAndSwap 是一个 native 方法,底层实现中在多核 CPU 环境下,会增加一个 Lock指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。

CAS主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性,典型使用场景:

  1. 第一个是 J.U.C 里面 Atomic 的原子实现,比如 AtomicInteger,AtomicLong。

  2. 第二个是实现多线程对共享资源竞争的互斥性质,比如在AQS、ConcurrentHashMap、ConcurrentLinkedQueue 等都有用到

3、谈谈你对AQS的理解

  1. AQS ( Abstract Queued Synchronizer )是一个抽象的队列同步器,通过维护一个共享资源状态( Volatile Int State )和一个先进先出( FIFO )的线程等待队列来实现一个多线程访问共享资源的同步框架

  2. 多个线程通过对这个 state 共享变量进行修改来实现竞态条件,竞争失败的线程加入到 FIFO 队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照 FIFO 顺序实现有序唤醒。

  3. 两种资源共享方式:

  • 一种是独占资源,同一个时刻只能有一个线程获得竞态资源。比如ReentrantLock就是使用这种方式实现排他锁
  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

  • 另一种是共享资源,同一个时刻,多个线程可以同时获得竞态资源。CountDownLatch或者 Semaphore 就是使用共享资源的方式,实现同时唤醒多个线程。

   4. 为什么使用双向链表

  • 第一方面,没有竞争到锁的线程会加入阻塞队列,并且阻塞等待的前提是当前线程所在节点的前置节点是正常状态,为了避免链表中存在异常线程导致无法唤醒后续线程。

  • 第二方面,没有竞争到锁的线程加入同步队列等待后,允许外部线程通过interrupt()方法触发唤醒并中断的。

  • 第三方面,为了避免线程阻塞和唤醒的开销

4、CountDownLatch与CyclicBarrier

CyclicBarrier

CountDownLatch

是否可以重复使用

使用场景

一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行

一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行

具体场景

CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景

实现多个线程开始执行任务的最大并行性:

某一线程在开始运行前等待 n 个线程执行完毕:

实现原理

ReentrantLock(AQS的独占模式)

AQS 的共享模式

实例

假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,只有当所有人翻越第一个障碍物之后,才开始翻越第二个,如果有一个员工没翻过,其他员工都要等,以此类推

比如王者荣耀,比如仅仅控制开始游戏,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。开始游戏后就结束了,所以CountDownLatch只能用一次

下一步动作的动作实施者

其他线程

main函数

5、Semaphore有什么作用

Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。

Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

码农滴自我修养

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

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

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

打赏作者

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

抵扣说明:

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

余额充值