概述
- 两万字!多线程硬核50问! (qq.com)
1、为什么要使用多线程
选择多线程的原因,就是因为快。举个例子:
如果要把1000块砖搬到楼顶,假设到楼顶有几个电梯,你觉得用一个电梯搬运快,还是同时用几个电梯同时搬运快呢?这个电梯就可以理解为线程。
所以,我们使用多线程就是因为: 在正确的场景下,设置恰当数目的线程,可以用来程提高序的运行速率。更专业点讲,就是充分地利用CPU和I/O的利用率,提升程序运行速率。
当然,有利就有弊,多线程场景下,我们要保证线程安全,就需要考虑加锁。加锁如果不恰当,就很很耗性能。
2. 创建线程有几种方式?
Java中创建线程主要有以下这几种方式:
- 定义
Thread
类的子类,并重写该类的run
方法 - 定义
Runnable
接口的实现类,并重写该接口的run()
方法 - 定义
Callable
接口的实现类,并重写该接口的call()
方法,一般配合Future
使用 - 线程池的方式
2.1 定义Thread类的子类,并重写该类的run方法
public class ThreadTest {
public static void main(String[] args) {
Thread thread = new MyThread();
thread.start();
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("关注公众号:捡田螺的小男孩");
}
}
2.2 定义Runnable接口的实现类,并重写该接口的run()方法
public class ThreadTest {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("XXXXX");
}
}
//运行结果:
XXXXX
2.3 定义Callable接口的实现类,并重写该接口的call()方法
如果想要执行的线程有返回,可以使用Callable
。
public class ThreadTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyThreadCallable mc = new MyThreadCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
}
class MyThreadCallable implements Callable {
@Override
public String call()throws Exception {
return "XXXXX";
}
}
//运行结果:
XXXXX
2.4 线程池的方式
日常开发中,我们一般都是用线程池的方式执行异步任务。
public class ThreadTest {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20), new CustomizableThreadFactory("Tianluo-Thread-pool"));
executorOne.execute(() -> {
System.out.println("XXXXX");
});
//关闭线程池
executorOne.shutdown();
}
}
3. start()方法和run()方法的区别
其实start
和run
的主要区别如下:
start
方法可以启动一个新线程,run
方法只是类的一个普通方法而已,如果直接调用run
方法,程序中依然只有主线程这一个线程。start
方法实现了多线程,而run
方法没有实现多线程。start
不能被重复调用,而run
方法可以。start
方法中的run
代码可以不执行完,就继续执行下面的代码,也就是说进行了线程切换。然而,如果直接调用run
方法,就必须等待其代码全部执行完才能继续执行下面的代码。
大家可以结合代码例子来看看哈~
public class ThreadTest {
public static void main(String[] args){
Thread t=new Thread(){
public void run(){
pong();
}
};
t.start();
t.run();
t.run();
System.out.println("A:"+ Thread.currentThread().getName());
}
static void pong(){
System.out.println("B:"+ Thread.currentThread().getName());
}
}
//输出
B:main
B:main
A:main
B:Thread-0
4. 线程和进程的区别
- 进程是运行中的应用程序,线程是进程的内部的一个执行序列
- 进程是资源分配的最小单位,线程是CPU调度的最小单位。
- 一个进程可以有多个线程。线程又叫做轻量级进程,多个线程共享进程的资源
- 进程间切换代价大,线程间切换代价小
- 进程拥有资源多,线程拥有资源少地址
- 进程是存在地址空间的,而线程本身无地址空间,线程的地址空间是包含在进程中的
举个例子:
你打开QQ,开了一个进程;打开了迅雷,也开了一个进程。
在QQ的这个进程里,传输文字开一个线程、传输语音开了一个线程、弹出对话框又开了一个线程。
所以运行某个软件,相当于开了一个进程。在这个软件运行的过程里(在这个进程里),多个工作支撑的完成QQ的运行,那么这“多个工作”分别有一个线程。
所以一个进程管着多个线程。
通俗的讲:“进程是爹妈,管着众多的线程儿子”…
5. 说一下 Runnable和 Callable有什么区别?
Runnable
接口中的run()
方法没有返回值,是void
类型,它做的事情只是纯粹地去执行run()
方法中的代码而已;Callable
接口中的call()
方法是有返回值的,是一个泛型。它一般配合Future、FutureTask
一起使用,用来获取异步执行的结果。Callable
接口call()
方法允许抛出异常;而Runnable
接口run()
方法不能继续上抛异常;
大家可以看下它俩的API
:
@FunctionalInterface
public interface Callable<V> {
/**
* 支持泛型V,有返回值,允许抛出异常
*/
V call() throws Exception;
}
@FunctionalInterface
public interface Runnable {
/**
* 没有返回值,不能继续上抛异常
*/
public abstract void run();
}
为了方便大家理解,写了一个demo,小伙伴们可以看看哈:
public class CallableRunnableTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
Callable<String> callable =new Callable<String>() {
@Override
public String call() throws Exception {
return "你好,callable";
}
};
//支持泛型
Future<String> futureCallable = executorService.submit(callable);
try {
System.out.println("获取callable的返回结果:"+futureCallable.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("你好呀,runnable");
}
};
Future<?> futureRunnable = executorService.submit(runnable);
try {
System.out.println("获取runnable的返回结果:"+futureRunnable.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
executorService.shutdown();
}
}
//运行结果
获取callable的返回结果:你好,callable
你好呀,runnable
获取runnable的返回结果:null
6. 聊聊volatile作用,原理
volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符,用来修饰变量。它保证变量对所有线程可见性,禁止指令重排,但是不保证原子性。
我们先来一起回忆下java内存模型(jmm):
- Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
- Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
- 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存。并且每个线程不能访问其他线程的工作内存。
volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性。
volatile保证可见性和禁止指令重排,都跟内存屏障有关。我们来看一段volatile使用的demo代码:
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;
}
}
编译后,对比有volatile
关键字和没有volatile
关键字时所生成的汇编代码,发现有volatile
关键字修饰时,会多出一个lock addl $0x0,(%esp)
,即多出一个lock前缀指令,lock指令相当于一个内存屏障
lock
指令相当于一个内存屏障,它保证以下这几点:
- 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 将本处理器的缓存写入内存
- 如果是写入动作,会导致其他处理器中对应的缓存无效。
第2点和第3点就是保证volatile
保证可见性的体现嘛,第1点就是禁止指令重排的体现。
内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
有些小伙伴,可能对这个还是有点疑惑,内存屏障这玩意太抽象了。我们照着代码看下吧:
内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性,哈哈有关于volatile的底层实现,我们就讨论到这哈
7. 说说并发与并行的区别?
并发和并行最开始都是操作系统中的概念,表示的是CPU执行多个任务的方式。
- 顺序:上一个开始执行的任务完成后,当前任务才能开始执行
- 并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行
(即 A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定。)
- 串行:有一个任务执行单元,从物理上就只能一个任务、一个任务地执行
- 并行:有多个任务执行单元,从物理上就可以多个任务一起执行
(即在任意时间点上,串行执行时必然只有一个任务在执行,而并行则不一定。)
知乎有个很有意思的回答,大家可以看下:
- 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
- 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是同时。
来源:知乎
8.synchronized 的实现原理以及锁优化?
synchronized是Java中的关键字,是一种同步锁。synchronized关键字可以作用于方法或者代码块。
一般面试时。可以这么回答:
8.1 monitorenter、monitorexit、ACC_SYNCHRONIZED
如果synchronized作用于代码块,反编译可以看到两个指令:monitorenter、monitorexit
,JVM使用monitorenter和monitorexit
两个指令实现同步;如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记
,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED
来实现同步功能。
- 同步代码块是通过
monitorenter和monitorexit
来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。 - 同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。
8.2 monitor监视器
monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。
在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中几个关键字段的含义如图所示:
8.3 Java Monitor 的工作机理
- 想要获取monitor的线程,首先会进入_EntryList队列。
- 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
- 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
- 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
- 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。
8.4 对象与monitor关联
- 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding)。
- 对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
重量级锁,指向互斥量的指针。其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。
9. 线程有哪些状态?
线程有6个状态,分别是:New, Runnable, Blocked, Waiting, Timed_Waiting, Terminated
。
转换关系图如下:
- New:线程对象创建之后、但还没有调用
start()
方法,就是这个状态。
public class ThreadTest {
public static void main(String[] args) {
Thread thread = new Thread();
System.out.println(thread.getState());
}
}
//运行结果:
NEW
- Runnable:它包括就绪(
ready
)和运行中(running
)两种状态。如果调用start
方法,线程就会进入Runnable
状态。它表示我这个线程可以被执行啦(此时相当于ready
状态),如果这个线程被调度器分配了CPU时间,那么就可以被执行(此时处于running
状态)。
public class ThreadTest {
public static void main(String[] args) {
Thread thread = new Thread();
thread.start();
System.out.println(thread.getState());
}
}
//运行结果:
RUNNABLE
- Blocked:阻塞的(被同步锁或者IO锁阻塞)。表示线程阻塞于锁,线程阻塞在进入
synchronized
关键字修饰的方法或代码块(等待获取锁)时的状态。比如前面有一个临界区的代码需要执行,那么线程就需要等待,它就会进入这个状态。它一般是从RUNNABLE
状态转化过来的。如果线程获取到锁,它将变成RUNNABLE
状态
Thread t = new Thread(new Runnable {
void run() {
synchronized (lock) { // 阻塞于这里,变为Blocked状态
// dothings
}
}
});
t.getState(); //新建之前,还没开始调用start方法,处于New状态
t.start(); //调用start方法,就会进入Runnable状态
- WAITING : 永久等待状态,进入该状态的线程需要等待其他线程做出一些特定动作(比如通知)。处于该状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。一般
Object.wait
。
Thread t = new Thread(new Runnable {
void run() {
synchronized (lock) { // Blocked
// dothings
while (!condition) {
lock.wait(); // into Waiting
}
}
}
});
t.getState(); // New
t.start(); // Runnable
- TIMED_WATING: 等待指定的时间重新被唤醒的状态。有一个计时器在里面计算的,最常见就是使用
Thread.sleep
方法触发,触发后,线程就进入了Timed_waiting
状态,随后会由计时器触发,再进入Runnable
状态。
Thread t = new Thread(new Runnable {
void run() {
Thread.sleep(1000); // Timed_waiting
}
});
t.getState(); // New
t.start(); // Runnable
- 终止(TERMINATED):表示该线程已经执行完成。
再来看个代码demo吧:
public class ThreadTest {
private static Object object = new Object();
public static void main(String[] args) throws Exception {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
for(int i = 0; i< 1000; i++){
System.out.print("");
}
Thread.sleep(500);
synchronized (object){
object.wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
try {
synchronized (object){
Thread.sleep(1000);
}
Thread.sleep(1000);
synchronized (object){
object.notify();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
System.out.println("1"+thread.getState());
thread.start();
thread1.start();
System.out.println("2"+thread.getState());
while (thread.isAlive()){
System.out.println("---"+thread.getState());
Thread.sleep(100);
}
System.out.println("3"+thread.getState());
}
}
运行结果:
1NEW
2RUNNABLE
---RUNNABLE
---TIMED_WAITING
---TIMED_WAITING
---TIMED_WAITING
---TIMED_WAITING
---BLOCKED
---BLOCKED
---BLOCKED
---BLOCKED
---BLOCKED
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
---WAITING
10. synchronized和ReentrantLock的区别?
Synchronized
是依赖于JVM
实现的,而ReenTrantLock
是API
实现的。- 在
Synchronized
优化以前,synchronized
的性能是比ReenTrantLock
差很多的,但是自从Synchronized
引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。 Synchronized
的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而ReenTrantLock
需要手工声明来加锁和释放锁,最好在finally中声明释放锁。ReentrantLock
可以指定是公平锁还是⾮公平锁。⽽synchronized
只能是⾮公平锁。ReentrantLock
可响应中断、可轮回,而Synchronized
是不可以响应中断的
11. wait(),notify()和suspend(),resume()之间的区别
wait()
方法使得线程进入阻塞等待状态,并且释放锁notify()
唤醒一个处于等待状态的线程,它一般跟wait()
方法配套使用。suspend()
使得线程进入阻塞状态,并且不会自动恢复,必须对应的resume()
被调用,才能使得线程重新进入可执行状态。suspend()
方法很容易引起死锁问题。resume()
方法跟suspend()
方法配套使用。
suspend()不建议使用,因为suspend()
方法在调用后,线程不会释放已经占有的资 源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。
12. CAS?CAS 有什么缺陷,如何解决?
CAS
,全称是Compare and Swap
,翻译过来就是比较并交换;
CAS
涉及3个操作数,内存地址值V,预期原值A,新值B;如果内存位置的值V与预期原A值相匹配,就更新为新值B,否则不更新
CAS有什么缺陷?
- ABA 问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
可以通过AtomicStampedReference
解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
- 循环时间长开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~
- 只能保证一个变量的原子操作。
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。可以通过这两个方式解决这个问题:1. 使用互斥锁来保证原子性;2.将多个变量封装成对象,通过AtomicReference来保证原子性。
有兴趣的朋友可以看看我之前的这篇实战文章哈~CAS乐观锁解决并发问题的一次实践
13. 说说CountDownLatch与CyclicBarrier 区别
CountDownLatch和CyclicBarrier
都用于让线程等待,达到一定条件时再运行。主要区别是:
- CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
- CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。
举个例子吧:
- CountDownLatch:假设老师跟同学约定周末在公园门口集合,等人齐了再发门票。那么,发门票(这个主线程),需要等各位同学都到齐(多个其他线程都完成),才能执行。
- CyclicBarrier:多名短跑运动员要开始田径比赛,只有等所有运动员准备好,裁判才会鸣枪开始,这时候所有的运动员才会疾步如飞。
14. 什么是多线程环境下的伪共享
14.1 什么是伪共享?
CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改相互独立的变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。这就是伪共享
现代计算机计算模型:
- CPU执行速度比内存速度快好几个数量级,为了提高执行效率,现代计算机模型演变出CPU、缓存(L1,L2,L3),内存的模型。
- CPU执行运算时,如先从L1缓存查询数据,找不到再去L2缓存找,依次类推,直到在内存获取到数据。
- 为了避免频繁从内存获取数据,聪明的科学家设计出缓存行,缓存行大小为64字节。
也正是因为缓存行的存在,就导致了伪共享问题,如图所示:
假设数据a、b
被加载到同一个缓存行。
- 当线程1修改了a的值,这时候CPU1就会通知其他CPU核,当前缓存行(Cache line)已经失效。
- 这时候,如果线程2发起修改b,因为缓存行已经失效了,所以「core2 这时会重新从主内存中读取该 Cache line 数据」。读完后,因为它要修改b的值,那么CPU2就通知其他CPU核,当前缓存行(Cache line)又已经失效。
- 酱紫,如果同一个Cache line的内容被多个线程读写,就很容易产生相互竞争,频繁回写主内存,会大大降低性能。
14.2 如何解决伪共享问题
既然伪共享是因为相互独立的变量存储到相同的Cache line导致的,一个缓存行大小是64字节。那么,我们就可以使用空间换时间的方法,即数据填充的方式,把独立的变量分散到不同的Cache line~
来看个例子:
public class FalseShareTest {
public static void main(String[] args) throws InterruptedException {
Rectangle rectangle = new Rectangle();
long beginTime = System.currentTimeMillis();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
rectangle.a = rectangle.a + 1;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
rectangle.b = rectangle.b + 1;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("执行时间" + (System.currentTimeMillis() - beginTime));
}
}
class Rectangle {
volatile long a;
volatile long b;
}
//运行结果:
执行时间2815
一个long类型是8字节,我们在变量a和b之间不上7个long类型变量呢,输出结果是啥呢?如下:
class Rectangle {
volatile long a;
long a1,a2,a3,a4,a5,a6,a7;
volatile long b;
}
//运行结果
执行时间1113
可以发现利用填充数据的方式,让读写的变量分割到不同缓存行,可以很好挺高性能~
15. Fork/Join框架的理解
Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork/Join框架需要理解两个点,「分而治之」和「工作窃取算法」。
分而治之
以上Fork/Join框架的定义,就是分而治之思想的体现啦
工作窃取算法
把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时。有的线程优先把自己负责的任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算法啦~
工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。
16. 聊聊ThreadLocal原理?
ThreadLocal的内存结构图
为了对ThreadLocal
有个宏观的认识,我们先来看下ThreadLocal
的内存结构图
从内存结构图,我们可以看到:
Thread
类中,有个ThreadLocal.ThreadLocalMap
的成员变量。ThreadLocalMap
内部维护了Entry
数组,每个Entry
代表一个完整的对象,key
是ThreadLocal
本身,value
是ThreadLocal
的泛型对象值。
关键源码分析
对照着关键源码来看,更容易理解一点哈~
首先看下Thread
类的源码,可以看到成员变量ThreadLocalMap
的初始值是为null
public class Thread implements Runnable {
//ThreadLocal.ThreadLocalMap是Thread的属性
ThreadLocal.ThreadLocalMap threadLocals = null;
}
成员变量ThreadLocalMap
的关键源码如下:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//Entry数组
private Entry[] table;
// ThreadLocalMap的构造器,ThreadLocal作为key
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
}
ThreadLocal
类中的关键set()
方法:
public void set(T value) {
Thread t = Thread.currentThread(); //获取当前线程t
ThreadLocalMap map = getMap(t); //根据当前线程获取到ThreadLocalMap
if (map != null) //如果获取的ThreadLocalMap对象不为空
map.set(this, value); //K,V设置到ThreadLocalMap中
else
createMap(t, value); //创建一个新的ThreadLocalMap
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals; //返回Thread对象的ThreadLocalMap属性
}
void createMap(Thread t, T firstValue) { //调用ThreadLocalMap的构造函数
t.threadLocals = new ThreadLocalMap(this, firstValue); this表示当前类ThreadLocal
}
ThreadLocal
类中的关键get()
方法
public T get() {
Thread t = Thread.currentThread();//获取当前线程t
ThreadLocalMap map = getMap(t);//根据当前线程获取到ThreadLocalMap
if (map != null) { //如果获取的ThreadLocalMap对象不为空
//由this(即ThreadLoca对象)得到对应的Value,即ThreadLocal的泛型值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue(); //初始化threadLocals成员变量的值
}
private T setInitialValue() {
T value = initialValue(); //初始化value的值
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); //以当前线程为key,获取threadLocals成员变量,它是一个ThreadLocalMap
if (map != null)
map.set(this, value); //K,V设置到ThreadLocalMap中
else
createMap(t, value); //实例化threadLocals成员变量
return value;
}
所以怎么回答ThreadLocal的实现原理?如下,最好是能结合以上结构图一起说明哈~
Thread
线程类有一个类型为ThreadLocal.ThreadLocalMap
的实例变量threadLocals
,即每个线程都有一个属于自己的ThreadLocalMap
。ThreadLocalMap
内部维护着Entry
数组,每个Entry
代表一个完整的对象,key
是ThreadLocal
本身,value
是ThreadLocal
的泛型值。- 并发多线程场景下,每个线程
Thread
,在往ThreadLocal
里设置值的时候,都是往自己的ThreadLocalMap
里存,读也是以某个ThreadLocal
作为引用,在自己的map
里找对应的key
,从而可以实现了线程隔离。
大家可以看下我之前这篇文章哈: ThreadLocal的八个关键知识点
17. TreadLocal为什么会导致内存泄漏呢?
- 弱引用导致的内存泄漏呢?
- key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?
- ThreadLocal内存泄漏的demo
17.1 弱引用导致的内存泄漏呢?
我们先来看看TreadLocal
的引用示意图哈:
关于ThreadLocal内存泄漏,网上比较流行的说法是这样的:
ThreadLocalMap
使用ThreadLocal
的弱引用作为key
,当ThreadLocal
变量被手动设置为null
,即一个ThreadLocal
没有外部强引用来引用它,当系统GC时,ThreadLocal
一定会被回收。这样的话,ThreadLocalMap
中就会出现key
为null
的Entry
,就没有办法访问这些key
为null
的Entry
的value
,如果当前线程再迟迟不结束的话(比如线程池的核心线程),这些key
为null
的Entry
的value
就会一直存在一条强引用链:Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 永远无法回收,造成内存泄漏。
当ThreadLocal
变量被手动设置为null
后的引用链图:
实际上,ThreadLocalMap
的设计中已经考虑到这种情况。所以也加上了一些防护措施:即在ThreadLocal
的get
,set
,remove
方法,都会清除线程ThreadLocalMap
里所有key
为null
的value
。
源代码中,是有体现的,如ThreadLocalMap
的set
方法:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
//如果k等于null,则说明该索引位之前放的key(threadLocal对象)被回收了,这通常是因为外部将threadLocal变量置为null,
//又因为entry对threadLocal持有的是弱引用,一轮GC过后,对象被回收。
//这种情况下,既然用户代码都已经将threadLocal置为null,那么也就没打算再通过该对象作为key去取到之前放入threadLocalMap的value, 因此ThreadLocalMap中会直接替换调这种不新鲜的entry。
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
//触发一次Log2(N)复杂度的扫描,目的是清除过期Entry
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
如ThreadLocal的get
方法:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
//去ThreadLocalMap获取Entry,方法里面有key==null的清除逻辑
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
//里面有key==null的清除逻辑
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
// Entry的key为null,则表明没有外部引用,且被GC回收,是一个过期Entry
if (k == null)
expungeStaleEntry(i); //删除过期的Entry
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
17.2 key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?
有些小伙伴可能有疑问,ThreadLocal
的key
既然是弱引用.会不会GC贸然把key
回收掉,进而影响ThreadLocal
的正常使用?
- 弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)
其实不会的,因为有ThreadLocal变量
引用着它,是不会被GC回收的,除非手动把ThreadLocal变量设置为null
,我们可以跑个demo来验证一下:
public class WeakReferenceTest {
public static void main(String[] args) {
Object object = new Object();
WeakReference<Object> testWeakReference = new WeakReference<>(object);
System.out.println("GC回收之前,弱引用:"+testWeakReference.get());
//触发系统垃圾回收
System.gc();
System.out.println("GC回收之后,弱引用:"+testWeakReference.get());
//手动设置为object对象为null
object=null;
System.gc();
System.out.println("对象object设置为null,GC回收之后,弱引用:"+testWeakReference.get());
}
}
运行结果:
GC回收之前,弱引用:java.lang.Object@7b23ec81
GC回收之后,弱引用:java.lang.Object@7b23ec81
对象object设置为null,GC回收之后,弱引用:null
结论就是,小伙伴放下这个疑惑了,哈哈~
17.3 ThreadLocal内存泄漏的demo
给大家来看下一个内存泄漏的例子,其实就是用线程池,一直往里面放对象
public class ThreadLocalTestDemo {
private static ThreadLocal<TianLuoClass> tianLuoThreadLocal = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; ++i) {
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
System.out.println("创建对象:");
TianLuoClass tianLuoClass = new TianLuoClass();
tianLuoThreadLocal.set(tianLuoClass);
tianLuoClass = null; //将对象设置为 null,表示此对象不在使用了
// tianLuoThreadLocal.remove();
}
});
Thread.sleep(1000);
}
}
static class TianLuoClass {
// 100M
private byte[] bytes = new byte[100 * 1024 * 1024];
}
}
创建对象:
创建对象:
创建对象:
创建对象:
Exception in thread "pool-1-thread-4" java.lang.OutOfMemoryError: Java heap space
at com.example.dto.ThreadLocalTestDemo$TianLuoClass.<init>(ThreadLocalTestDemo.java:33)
at com.example.dto.ThreadLocalTestDemo$1.run(ThreadLocalTestDemo.java:21)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
运行结果出现了OOM,tianLuoThreadLocal.remove();
加上后,则不会OOM
。
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
创建对象:
......
我们这里没有手动设置tianLuoThreadLocal
变量为null
,但是还是会内存泄漏。因为我们使用了线程池,线程池有很长的生命周期,因此线程池会一直持有tianLuoClass
对象的value
值,即使设置tianLuoClass = null;
引用还是存在的。这就好像,你把一个个对象object
放到一个list
列表里,然后再单独把object
设置为null
的道理是一样的,列表的对象还是存在的。
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
Object object = new Object();
list.add(object);
object = null;
System.out.println(list.size());
}
//运行结果
1
所以内存泄漏就这样发生啦,最后内存是有限的,就抛出了OOM
了。如果我们加上threadLocal.remove();
,则不会内存泄漏。为什么呢?因为threadLocal.remove();
会清除Entry
,源码如下:
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//清除entry
e.clear();
expungeStaleEntry(i);
return;
}
}
}
18 为什么ThreadLocalMap 的 key是弱引用,设计理念是?
通过阅读ThreadLocal
的源码,我们是可以看到Entry
的Key
是设计为弱引用的(ThreadLocalMap
使用ThreadLocal
的弱引用作为Key
的)。为什么要设计为弱引用呢?
我们先来回忆一下四种引用:
- 强引用:我们平时
new
了一个对象就是强引用,例如Object obj = new Object();
即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。 - 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
- 弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)。
- 虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
我们先来看看官方文档,为什么要设计为弱引用:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了应对非常大和长时间的用途,哈希表使用弱引用的 key。
我再把ThreadLocal的引用示意图搬过来:
下面我们分情况讨论:
- 如果
Key
使用强引用:当ThreadLocal
的对象被回收了,但是ThreadLocalMap
还持有ThreadLocal
的强引用的话,如果没有手动删除,ThreadLocal就不会被回收,会出现Entry的内存泄漏问题。 - 如果
Key
使用弱引用:当ThreadLocal
的对象被回收了,因为ThreadLocalMap
持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value
则在下一次ThreadLocalMap
调用set,get,remove
的时候会被清除。
因此可以发现,使用弱引用作为Entry
的Key
,可以多一层保障:弱引用ThreadLocal
不会轻易内存泄漏,对应的value
在下一次ThreadLocalMap
调用set,get,remove
的时候会被清除。
实际上,我们的内存泄漏的根本原因是,不再被使用的Entry
,没有从线程的ThreadLocalMap
中删除。一般删除不再使用的Entry
有这两种方式:
- 一种就是,使用完
ThreadLocal
,手动调用remove()
,把Entry从ThreadLocalMap
中删除 - 另外一种方式就是:
ThreadLocalMap
的自动清除机制去清除过期Entry
.(ThreadLocalMap
的get(),set()
时都会触发对过期Entry
的清除)
19. 如何保证父子线程间的共享ThreadLocal数据
我们知道ThreadLocal
是线程隔离的,如果我们希望父子线程共享数据,如何做到呢?可以使用InheritableThreadLocal
。先来看看demo
:
public class InheritableThreadLocalTest {
public static void main(String[] args) {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
threadLocal.set("关注公众号:捡田螺的小男孩");
inheritableThreadLocal.set("关注公众号:程序员田螺");
Thread thread = new Thread(()->{
System.out.println("ThreadLocal value " + threadLocal.get());
System.out.println("InheritableThreadLocal value " + inheritableThreadLocal.get());
});
thread.start();
}
}
//运行结果
ThreadLocal value null
InheritableThreadLocal value 关注公众号:程序员田螺
可以发现,在子线程中,是可以获取到父线程的 InheritableThreadLocal
类型变量的值,但是不能获取到 ThreadLocal
类型变量的值。
获取不到ThreadLocal
类型的值,我们可以好理解,因为它是线程隔离的嘛。InheritableThreadLocal
是如何做到的呢?原理是什么呢?
在Thread
类中,除了成员变量threadLocals
之外,还有另一个成员变量:inheritableThreadLocals
。它们两类型是一样的:
public class Thread implements Runnable {
ThreadLocalMap threadLocals = null;
ThreadLocalMap inheritableThreadLocals = null;
}
Thread
类的init
方法中,有一段初始化设置:
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
......
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;
/* Set thread ID */
tid = nextThreadID();
}
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap);
}
可以发现,当parent的inheritableThreadLocals
不为null
时,就会将parent
的inheritableThreadLocals
,赋值给前线程的inheritableThreadLocals
。说白了,就是如果当前线程的inheritableThreadLocals
不为null
,就从父线程哪里拷贝过来一个过来,类似于另外一个ThreadLocal
,但是数据从父线程那里来的。有兴趣的小伙伴们可以在去研究研究源码~
20. 如何保证多线程下 i++ 结果正确?
- 使用循环CAS,实现i++原子操作
- 使用锁机制,实现i++原子操作
- 使用synchronized,实现i++原子操作
举个简单的例子,如下:
/**
* 关注公众号:捡田螺的小男孩
* 非常多干货
*/
public class AtomicIntegerTest {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
testIAdd();
}
private static void testIAdd() throws InterruptedException {
//创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(2);
for (int i = 0; i < 1000; i++) {
executorService.execute(() -> {
for (int j = 0; j < 2; j++) {
//自增并返回当前值
int andIncrement = atomicInteger.incrementAndGet();
System.out.println("线程:" + Thread.currentThread().getName() + " count=" + andIncrement);
}
});
}
executorService.shutdown();
Thread.sleep(100);
System.out.println("最终结果是 :" + atomicInteger.get());
}
}
运行结果:
...
线程:pool-1-thread-1 count=1997
线程:pool-1-thread-1 count=1998
线程:pool-1-thread-1 count=1999
线程:pool-1-thread-2 count=315
线程:pool-1-thread-2 count=2000
最终结果是 :2000
21. 如何检测死锁?怎么预防死锁?死锁四个必要条件
死锁是指多个线程因竞争资源而造成的一种互相等待的僵局。如图感受一下:
死锁的四个必要条件:
- 互斥:一次只有一个进程可以使用一个资源。其他进程不能访问已分配给其他进程的资源。
- 占有且等待:当一个进程在等待分配得到其他资源时,其继续占有已分配得到的资源。
- 非抢占:不能强行抢占进程中已占有的资源。
- 循环等待:存在一个封闭的进程链,使得每个资源至少占有此链中下一个进程所需要的一个资源。
如何预防死锁?
- 加锁顺序(线程按顺序办事)
- 加锁时限 (线程请求所加上权限,超时就放弃,同时释放自己占有的锁)
- 死锁检测
22. 如果线程过多,会怎样?
使用多线程可以提升程序性能。但是如果使用过多的线程,则适得其反。
过多的线程会影响程序的系统。
- 一方面,线程的启动和销毁,都是需要开销的。
- 其次,过多的并发线程也会导致共享有限资源的开销增大。过多的线程,还会导致内存泄漏,笔者在以前公司,看到一个生产问题:一个第三方的包是使用new Thread来实现的,使用完没有恰当回收销毁,最后引发内存泄漏问题。
因此,我们平时尽量使用线程池来管理线程。同时还需要设置恰当的线程数。
23. 聊聊happens-before原则
在Java语言中,有一个先行发生原则(happens-before
)。它包括八大规则,如下:
- 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 管程锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
- 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
- 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
24. 如何实现两个线程间共享数据
- 可以通过类变量直接将数据放到主存中
- 通过并发的数据结构来存储数据
- 使用volatile变量或者锁
- 调用atomic类(如AtomicInteger)
25. LockSupport作用是?
LockSupport是一个工具类。它的主要作用是挂起和唤醒线程。该工具类是创建锁和其他同步类的基础。它的主要方法是
public static void park(Object blocker); // 暂停指定线程
public static void unpark(Thread thread); // 恢复指定的线程
public static void park(); // 无期限暂停当前线程
看个代码的例子:
public class LockSupportTest {
private static Object object = new Object();
static MyThread thread = new MyThread("线程田螺");
public static class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
@Override public void run() {
synchronized (object) {
System.out.println("线程名字: " + Thread.currentThread());
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
LockSupport.park();
if (Thread.currentThread().isInterrupted()) {
System.out.println("线程被中断了");
}
System.out.println("继续执行");
}
}
}
public static void main(String[] args) {
thread.start();
LockSupport.unpark(thread);
System.out.println("恢复线程调用");
}
}
//output
恢复线程调用
线程名字:Thread[线程田螺,5,main]
继续执行
因为thread
线程内部有休眠2秒的操作,所以unpark
方法的操作肯定先于park
方法的调用。为什么thread
线程最终仍然可以结束,是因为park
和unpark
会对每个线程维持一个许可证(布尔值)
26 线程池如何调优,如何确认最佳线程数?
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
我们的服务器CPU核数为8核,一个任务线程cpu耗时为20ms,线程等待(网络IO、磁盘IO)耗时80ms,那最佳线程数目:( 80 + 20 )/20 * 8 = 40。也就是设置 40个线程数最佳。
有兴趣的小伙伴,也可以看这篇文章哈: 线程池到底设置多少线程比较合适?
27. 为什么要用线程池?
线程池:一个管理线程的池子。线程池可以:
- 管理线程,避免增加创建线程和销毁线程的资源损耗。
- 提高响应速度。
- 重复利用线程。
大家可以看看我之前这篇文章,很经典, 面试必备:Java线程池解析
28. Java的线程池执行原理
线程池的执行原理如下:
为了形象描述线程池执行,打个比喻:
- 核心线程比作公司正式员工
- 非核心线程比作外包员工
- 阻塞队列比作需求池
- 提交任务比作提需求
29. 聊聊线程池的核心参数
我们先来看看ThreadPoolExecutor
的构造函数
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
corePoolSize
:线程池核心线程数最大值maximumPoolSize
:线程池最大线程数大小keepAliveTime
:线程池中非核心线程空闲的存活时间大小unit
:线程空闲存活时间单位workQueue
:存放任务的阻塞队列threadFactory
:用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。handler
:线城池的饱和策略事件,主要有四种类型拒绝策略。
四种拒绝策略
AbortPolicy
(抛出一个异常,默认的)DiscardPolicy
(直接丢弃任务)DiscardOldestPolicy
(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)CallerRunsPolicy
(交给线程池调用所在的线程进行处理)
几种工作阻塞队列
ArrayBlockingQueue
(用数组实现的有界阻塞队列,按FIFO排序量)LinkedBlockingQueue
(基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列)DelayQueue
(一个任务定时周期的延迟执行的队列)PriorityBlockingQueue
(具有优先级的无界阻塞队列)SynchronousQueue
(一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态)
30.当提交新任务时,异常如何处理?
我们先来看一段代码:
ExecutorService threadPool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
threadPool.submit(() -> {
System.out.println("current thread name" + Thread.currentThread().getName());
Object object = null;
System.out.print("result## "+object.toString());
});
}
显然,这段代码会有异常,我们再来看看执行结果
虽然没有结果输出,但是没有抛出异常,所以我们无法感知任务出现了异常,所以需要添加try/catch。如下图:
OK,线程的异常处理,我们可以直接try...catch
捕获。
最近写了一篇线程池坑相关的,大家可以去看看哈: 细数线程池的10个坑
31. AQS组件,实现原理
AQS,即AbstractQueuedSynchronizer
,是构建锁或者其他同步组件的基础框架,它使用了一个int
成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。可以回答以下这几个关键点哈:
- state 状态的维护。
- CLH队列
- ConditionObject通知
- 模板方法设计模式
- 独占与共享模式。
- 自定义同步器。
- AQS全家桶的一些延伸,如:ReentrantLock等。
31.1 state 状态的维护
- state,int变量,锁的状态,用volatile修饰,保证多线程中的可见性。
- getState()和setState()方法采用final修饰,限制AQS的子类重写它们两。
- compareAndSetState()方法采用乐观锁思想的CAS算法操作确保线程安全,保证状态 设置的原子性。
31.2 CLH队列
CLH 同步队列,全英文Craig, Landin, and Hagersten locks
。是一个FIFO双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。AQS依赖它来完成同步状态state的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
31.3 ConditionObject通知
我们都知道,synchronized控制同步的时候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以实现等待/通知模式。而Lock呢?它提供了条件Condition接口,配合await(),signal(),signalAll() 等方法也可以实现等待/通知机制。ConditionObject实现了Condition接口,给AQS提供条件变量的支持
ConditionObject队列与CLH队列的爱恨情仇:
- 调用了await()方法的线程,会被加入到conditionObject等待队列中,并且唤醒CLH队列中head节点的下一个节点。
- 线程在某个ConditionObject对象上调用了singnal()方法后,等待队列中的firstWaiter会被加入到AQS的CLH队列中,等待被唤醒。
- 当线程调用unLock()方法释放锁时,CLH队列中的head节点的下一个节点(在本例中是firtWaiter),会被唤醒。
31.4 模板方法设计模式
模板方法模式:在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
AQS的典型设计模式就是模板方法设计模式啦。AQS全家桶(ReentrantLock,Semaphore)的衍生实现,就体现出这个设计模式。如AQS提供tryAcquire,tryAcquireShared等模板方法,给子类实现自定义的同步器。
31.5 独占与共享模式。
- 独占式: 同一时刻仅有一个线程持有同步状态,如ReentrantLock。又可分为公平锁和非公平锁。
- 共享模式:多个线程可同时执行,如Semaphore/CountDownLatch等都是共享式的产物。
31.6 自定义同步器
你要实现自定义锁的话,首先需要确定你要实现的是独占锁还是共享锁,定义原子变量state的含义,再定义一个内部类去继承AQS,重写对应的模板方法即可啦
32 Semaphore原理
Semaphore,我们也把它叫做信号量。可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。
我们可以把它简单的理解成我们停车场入口立着的那个显示屏,每当有一辆车进入停车场显示屏就会显示剩余车位减1,每有一辆车从停车场出去,显示屏上显示的剩余车辆就会加1,当显示屏上的剩余车位为0时,停车场入口的栏杆就不会再打开,车辆就无法进入停车场了,直到有一辆车从停车场出去为止。
32.1 Semaphore使用demo
我们就以停车场的例子,来实现demo。
假设停车场最多可以停20辆车,现在有100辆要进入停车场。
我们很容易写出以下代码;
public class SemaphoreTest {
private static Semaphore semaphore=new Semaphore(20);
public static void main(String[] args) {
ExecutorService executorService= Executors.newFixedThreadPool(200);
//模拟100辆车要来
for (int i = 0; i < 100; i++) {
executorService.execute(()->{
System.out.println("===="+Thread.currentThread().getName()+"准备进入停车场==");
//车位判断
if (semaphore.availablePermits() == 0) {
System.out.println("车辆不足,请耐心等待");
}
try {
//获取令牌尝试进入停车场
semaphore.acquire();
System.out.println("====" + Thread.currentThread().getName() + "成功进入停车场");
//模拟车辆在停车场停留的时间
Thread.sleep(new Random().nextInt(20000));
System.out.println("====" + Thread.currentThread().getName() + "驶出停车场");
//释放令牌,腾出停车场车位
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
//线程池关闭
executorService.shutdown();
}
}
}
32.2 Semaphore原理
我们来看下实现的原理是怎样的。
- Semaphore构造函数
- 可用令牌数
- 获取令牌
- 释放令牌
- Semaphore构造函数
Semaphore semaphore=new Semaphore(20);
它会创建一个非公平的锁的同步阻塞队列,并且把初始令牌数量(20)赋值给同步队列的state,这个state就是AQS
的哈。
//构造函数,创建一个非公平的锁的同步阻塞队列
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
NonfairSync(int permits) {
super(permits);
}
//把令牌数量赋值给同步队列的state
Sync(int permits) {
setState(permits);
}
2.可用令牌数
这个availablePermits
,获取的就是state
值。刚开始为20,所以肯定不会为0嘛。
semaphore.availablePermits();
public int availablePermits() {
return sync.getPermits();
}
final int getPermits() {
return getState();
}
- 获取令牌
接着我们再看下获取令牌的API
semaphore.acquire();
获取1个令牌
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//尝试获取令牌,arg为获取令牌个数
if (tryAcquireShared(arg) < 0)
//
doAcquireSharedInterruptibly(arg);
}
尝试获取令牌,使用了CAS算法。
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
可获取令牌的话,就创建节点,加入阻塞队列;重双向链表的head,tail节点关系,清空无效节点;挂起当前节点线程
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
//创建节点加入阻塞队列
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
//返回锁的state
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
//重组双向链表,清空无效节点,挂起当前线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
- 释放令牌
semaphore.release();
/**
* 释放令牌
*/
public void release() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//释放共享锁
if (tryReleaseShared(arg)) {
//唤醒所有共享节点线程
doReleaseShared();
return true;
}
return false;
}
33 synchronized做了哪些优化?什么是偏向锁?什么是自旋锁?锁租化?
在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。
- 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。
- 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
- 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
- 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
- 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
34 什么是上下文切换?
什么是CPU上下文?
CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做CPU上下文。
什么是CPU上下文切换?
它是指,先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
一般我们说的上下文切换,就是指内核(操作系统的核心)在CPU上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生CPU上下文的切换。
所以大家有时候会听到这种说法,线程的上下文切换。它指,CPU资源的分配采用了时间片轮转,即给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是线程的上下文切换。看个图,可能会更容易理解一点
35.为什么wait(),notify(),notifyAll()在对象中,而不在Thread类中
锁只是个一个标记,存在对象头里面。
下面从面向对象和观察者模式角度来分析。
- 面向对象的角度:我们可以把wait和notify直接理解为get和set方法。wait和notify方法都是对对象的锁进行操作,那么自然这些方法应该属于对象。举例来说,门对象上有锁属性,开锁和关锁的方法应该属于门对象,而不应该属于人对象。
- 从观察者模式的角度:对象是被观察者,线程是观察者。被观察者的状态如果发生变化,理应有被观察者去轮询通知观察者,否则的话,观察者怎么知道notify方法应该在哪个时刻调用?n个观察者的notify又如何做到同时调用?
- 来源:知乎 https://www.zhihu.com/question/321674476
36. 线程池中 submit()和 execute()方法有什么区别?
- execute和submit都属于线程池的方法,execute只能提交Runnable类型的任务,而submit既能提交Runnable类型任务也能提交Callable类型任务。
- execute会直接抛出任务执行时的异常,submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。
- execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。
37 AtomicInteger 的原理?
AtomicInteger的底层,是基于CAS实现的。我们可以看下AtomicInteger的添加方法。如下
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
通过Unsafe类的实例来进行添加操作
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//使用了CAS算法实现
return var5;
}
注意:compareAndSwapInt
是一个native方法哈,它是基于CAS来操作int类型的变量。并且,其它的原子操作类基本也大同小异。
38 Java中用到的线程调度算法是什么?
我们知道有两种调度模型:分时调度和抢占式调度。
- 分时调度模型:让所有的线程轮流获得cpu的使用权,并且平均分配每个线程占用的 CPU 的时间片。
- 抢占式调度:优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
Java默认的线程调度算法是抢占式。即线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。
39. shutdown() 和 shutdownNow()的区别
- shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
- shutdown()只是关闭了提交通道,用submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。
40 说说几种常见的线程池及使用场景?
几种常用线程池:
- newFixedThreadPool (固定数目线程的线程池)
- newCachedThreadPool(可缓存线程的线程池)
- newSingleThreadExecutor(单线程的线程池)
- newScheduledThreadPool(定时及周期执行的线程池)
40.1 newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
}
- 核心线程数和最大线程数大小一样
- 没有所谓的非空闲时间,即keepAliveTime为0
- 阻塞队列为无界队列LinkedBlockingQueue
使用场景
FixedThreadPool 适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。
40.2 newCachedThreadPool
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
- 核心线程数为0
- 最大线程数为Integer.MAX_VALUE
- 阻塞队列是SynchronousQueue
- 非核心线程空闲存活时间为60秒
使用场景
当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。
40.3 newSingleThreadExecutor 单线程的线程池
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}
- 核心线程数为1
- 最大线程数也为1
- 阻塞队列是LinkedBlockingQueue
- keepAliveTime为0
使用场景
适用于串行执行任务的场景,一个任务一个任务地执行。
40.4 newScheduledThreadPool
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
- 最大线程数为Integer.MAX_VALUE
- 阻塞队列是DelayedWorkQueue
- keepAliveTime为0
- scheduleAtFixedRate() :按某种速率周期执行
- scheduleWithFixedDelay():在某个延迟后执行
使用场景
周期性执行任务的场景,需要限制线程数量的场景
41 什么是FutureTask
FutureTask是一种可以取消的异步的计算任务。它的计算是通过Callable
实现的,可以把它理解为是可以返回结果的Runnable
。
使用FutureTask的优点:
- 可以获取线程执行后的返回结果;
- 提供了超时控制功能。
它实现了Runnable
接口和Future
接口,底层基于生产者消费者模式实现。
FutureTask用于在异步操作场景中,FutureTask作为生产者(执行FutureTask的线程)和消费者(获取FutureTask结果的线程)的桥梁,如果生产者先生产出了数据,那么消费者get时能会直接拿到结果;如果生产者还未产生数据,那么get时会一直阻塞或者超时阻塞,一直到生产者产生数据唤醒阻塞的消费者为止。
42 java中interrupt(),interrupted()和isInterrupted()的区别
interrupt
它是真正触发中断的方法。interrupted
是Thread中的一个类方法,它也调用了isInterrupted(true)方法,不过它传递的参数是true,表示将会清除中断标志位。isInterrupted
是Thread
类中的一个实例方法,可以判断实例线程是否被中断。。
public void interrupt() {
if (this != Thread.currentThread())
checkAccess();
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupt0(); // Just to set the interrupt flag
b.interrupt(this);
return;
}
}
interrupt0();
}
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
public boolean isInterrupted() {
return isInterrupted(false);
}
43 有三个线程T1,T2,T3,怎么确保它们按顺序执行
可以使用join方法
解决这个问题。比如在线程A中,调用线程B的join方法表示的意思就是:A等待B线程执行完毕后(释放CPU执行权),在继续执行。
代码如下:
public class ThreadTest {
public static void main(String[] args) {
Thread spring = new Thread(new SeasonThreadTask("春天"));
Thread summer = new Thread(new SeasonThreadTask("夏天"));
Thread autumn = new Thread(new SeasonThreadTask("秋天"));
try
{
//春天线程先启动
spring.start();
//主线程等待线程spring执行完,再往下执行
spring.join();
//夏天线程再启动
summer.start();
//主线程等待线程summer执行完,再往下执行
summer.join();
//秋天线程最后启动
autumn.start();
//主线程等待线程autumn执行完,再往下执行
autumn.join();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
class SeasonThreadTask implements Runnable{
private String name;
public SeasonThreadTask(String name){
this.name = name;
}
@Override
public void run() {
for (int i = 1; i <4; i++) {
System.out.println(this.name + "来了: " + i + "次");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行结果:
春天来了: 1次
春天来了: 2次
春天来了: 3次
夏天来了: 1次
夏天来了: 2次
夏天来了: 3次
秋天来了: 1次
秋天来了: 2次
秋天来了: 3次
44 有哪些阻塞队列
- ArrayBlockingQueue 一个由数组构成的有界阻塞队列
- LinkedBlockingQueue 一个由链表构成的有界阻塞队列
- PriorityBlockingQueue 一个支持优先级排序的无界阻塞队列
- DelayQueue 一个使用优先队列实现的无界阻塞队列。
- SynchroniouQueue 一个不储存元素的阻塞队列
- LinkedTransferQueue 一个由链表结构组成的无界阻塞队列
- LinkedBlockingDeque 一个由链表结构组成的双向阻塞队列
45 Java中ConcurrentHashMap的并发度是什么?
并发度就是segment
的个数,通常是2的N次方。默认是16
46 Java线程有哪些常用的调度方法?
46.1 线程休眠
Thread.sleep(long)
方法,使线程转到超时等待阻塞(TIMED_WAITING) 状态。long
参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,线程自动转为就绪(Runnable)
状态。
46.2 线程中断
interrupt()
表示中断线程。需要注意的是,InterruptedException
是线程自己从内部抛出的,并不是interrupt()
方法抛出的。对某一线程调用interrupt()
时,如果该线程正在执行普通的代码,那么该线程根本就不会抛出InterruptedException
。但是,一旦该线程进入到wait()/sleep()/join()
后,就会立刻抛出InterruptedException。可以用isInterrupted()
来获取状态。
46.3 线程等待
Object
类中的wait()
方法,会导致当前的线程等待,直到其他线程调用此对象的notify()
方法或notifyAll()
唤醒方法。
46.4 线程让步
Thread.yield()
方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
46.5 线程通知
Object的notify()
方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。
notifyAll()
,则是唤醒在此对象监视器上等待的所有线程。
47. ReentrantLock的加锁原理
ReentrantLock
,是可重入锁,是JDK5中添加在并发包下的一个高性能的工具。它支持同一个线程在未释放锁的情况下重复获取锁。
47.1 ReentrantLock使用的模板
我们先来看下是ReentrantLock使用的模板:
//实例化对象
ReentrantLock lock = new ReentrantLock();
//获取锁操作
lock.lock();
try {
// 执行业务代码逻辑
} catch (Exception ex) {
//异常处理
} finally {
// 解锁操作
lock.unlock();
}
47.2 什么是非公平锁,什么是公平锁?
ReentrantLock无参构造函数,默认创建的是非公平锁,如下:
public ReentrantLock() {
sync = new NonfairSync();
}
而通过fair参数指定使用公平锁(FairSync)还是非公平锁(NonfairSync)
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
什么是公平锁?
- 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
- 优点:所有的线程都能得到资源,不会饿死在队列中。
- 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
什么是非公平锁?
- 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
- 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
47.3 lock()加锁流程
大家可以结合AQS + 公平锁/非公平锁 + CAS去讲ReentrantLock的原理哈。
48. 线程间的通讯方式
48.1 volatile和synchronized关键字
- volatile关键字用来修饰共享变量,保证了共享变量的可见性,任何线程需要读取时都要到内存中读取(确保获得最新值)。
- synchronized关键字确保只能同时有一个线程访问方法或者变量,保证了线程访问的可见性和排他性。
48.2 等待/通知机制
等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B 调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而 执行后续操作。
48.3 管道输入/输出流
- 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要 用于线程之间的数据传输,而传输的媒介为内存。
- 管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、 PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。
48.4 join()方法
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才 从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时 时间里没有终止,那么将会从该超时方法中返回。
48.5 ThreadLocal
ThreadLocal,即线程本地变量(每个线程都有自己唯一的一个哦),是一个以ThreadLocal对象为键、任意对象为值的存储结构。底层是一个ThreadLocalMap来存储信息,key是弱引用,value是强引用,所以使用完毕后要及时清理(尤其使用线程池时)。
49 写出3条你遵循的多线程最佳实践
- 多用同步类,少用wait,notify
- 少用锁,应当缩小同步范围
- 给线程一个自己的名字
- 多用并发集合少用同步集合
50. 为什么阿里发布的 Java开发手册中强制线程池不允许使用 Executors 去创建?
这是因为,JDK开发者提供了线程池的实现类都是有坑的,如newFixedThreadPool
和newCachedThreadPool
都有内存泄漏的坑。
最后
以上就是清脆画板为你收集整理的多线程面试题的全部内容,希望文章能够帮你解决多线程面试题所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复