多线程面试题

多线程面试题
Nuyoah1. 线程池中提交一个任务的流程是怎样的
文字
- 在使用execute()方法提交一个Runnable对象时
- 会先判断当前线程池中的线程数是否小于corePoolSize
- 如果小于,则创建新线程并执行Runnable
- 如果大于等于,则尝试将Runnable加入到workQueue中
- 如果workQueue没满,则将Runnable正常入队,等待执行
- 如果workQueue满了,则会入队失败,那么会尝试继续增加线程
- 如果当前线程池中的线程数是否小于maximumPoolSize
- 如果小于,则创建新线程并执行任务
- 如果大于等于,则执行拒绝策略,拒绝次Runnable
流程图
代码
创建线程执行
1 |
|
进入execute方法中观察,如果传入的方法为null,则返回空指针异常,然后查看线程池中的线程数量是否小于核心线程数,如果小于核心线程数则调用addWorker方法将任务放到线程池中,如果大于核心线程数,则将其加入到工作队列中,如果加入成功等待执行,若没有入队成功,则使用addWorker加入线程,addWorker中会判断是否大于最大线程数,
1 | public void execute(Runnable command) { |
addWorker有两个参数,第一个是需要执行的任务,第二个是判断需要跟核心线程池大小比较还是最大线程池大小比较
2.线程池有几种状态?分别是如何变化的?
线程池一共有五种状态
状态 | 描述 |
---|---|
RUNNING | 会接受新任务并且会处理队列中的任务 |
SHUTDOWN | 不会接收新任务但是会处理队列中的任务,任务执行完毕之后会中断所有的线程 |
STOP | 不会接受新任务,并且不会执行队列中的任务 |
TIDYING | 所有线程都停止之后,线程池状态就会转变成TIDYING,一旦达到此状态,就会调用线程池的terminated |
TERMINATED | terminated()执行完之后就会转变成TERMINATED |
状态转换
RUNNING -> SHUTDOWN 执行shutdown方法
在状态转变的时候,先修改状态,执行队列中剩余的信息,防止在执行的时候又有新的任务进来
1 | executor.shutdown(); |
RUNNING -> STOP 执行shutdownNow方法
1 | executor.shutdownNow(); |
SHOWDOWN -> TIDYING 当线程池中的剩余任务都执行完毕之后自动转变
在上面的shutdown方法执行过程中,有个tryTerminate方法
1 | final void tryTerminate() { |
3. 如何停止一个线程
Thread线程拥有两个方法
- start()开启一个线程
- stop()关闭一个线程
但是stop方法比较粗暴,当调用stop方法的时候,会直接停掉线程,这样会导致我们不知道任务执行到了哪一步了,该释放的锁释放了没有
stop会自动释放synchronized锁,而不会自动释放ReentrantLock锁
synchronized 锁测试
1 | private static final Object lock = new Object(); |
输出结果是:
1 | 0 |
ReentrantLock测试
1 |
|
结果:ReentrantLock一直没有释放
1 | 0 |
interrupt方法
通过interrupt方法告知线程,现在外部需要次线程进行停止工作,请结束工作并进行结束后的一些操作,然后终止线程
这种方式的优点是可以让线程内部拥有更加灵活的停用策略,可以有时间反应并做一些后续操作,并且将是否终止线程的决定权交给线程本身。
1 |
|
输出结果:可以得出当使用interrupt操作终止线程之后,线程不会立刻终止,而是等到输出到300000之后再进行终止
1 | ... |
4.如何理解JAVA并发中的可见性
由于CPU和内存中的速度差异性,当线程A读取内存中的变量i的时候 读取到的是1,然后将变量i放到cpu1的内存中,此刻线程A对变量i进行操作,将变量的值修改为2,但是此刻并没有将内存中的变量i也同时修改。
同时线程B读取内存中的变量i,发现i还是1,这就会导致变量读取不一致。出现了可见性问题
解决方法:在java中使用volatile关键字来保证变量的可见性,如果对变量使用volatile关键字,那么线程读取该变量的时候会直接从内存中读取,在修改改变量的时候,会同时修改内存中该变量的值和cpu高速换成中该变量的值,保证变量的一致性。
5.如何理解JAVA并发中的原子性
并发的原子性:在多线程并发操作的时候,一段代码要么完全执行成功,要么完全不执行,不出现线程A执行一半被打断或者干扰的情况。换句话说,就是对同一个变量的多个操作能够像原子操作一样,保证多线程环境下的数据一致性,避免数据竞争和脏数据等问题。
出现原因
由于CPU,内存,IO(磁盘,网络)之间的性能差距,为了能够充分的利用CPU,当线程执行IO操作的时候,线程会让出CPU,让CPU去执行其他指令,并且本身来说,为了达到线程并发执行的效果,CPU也会按照固定的时间片来切换执行不同线程
例子
当多个线程去执行i++这个操作的时候,底层对应的就是三条指令
- 从内存中读取值
- 对i+1
- 写回i的值到CPU高速缓存中
1 | private static int counter = 0; |
最后结果
1 | 最终变量值: 1509 |
线程A在执行i++操作的时候,在读取到i的时候,切换到线程B,线程B也读取i,然后线程A和B都对i进行加一操作,然后线程AB都进行写入,最后i的值是2,而不是3
解决方法
加锁或者使用原子变量(底层也是加锁)
1 | private final static Object lock2 = new Object(); |
最总输出结果
1 | 最终变量值: 2000 |
6.如何理解JAVA并发中的有序性
出现原因
JAVA编译的时候为了能够提高性能,会进行指令从新排列,这样就会导致程序在并发执行时出现有序性问题。
例如
1 | public class Person() { |
new Person的操作在执行的时候被分为三步
- 申请内存空间
- 在内存空间中初始化Person对象相关内容
- 返回内存地址
编译优化之后:
- 申请内存空间
- 返回内存地址
- 在内存空间中初始化Person对象相关内容
线程A在调用getInstance方法的时候已经申请到内存地址并返回了,线程B在调用getInstance方法后发现instance不是null,就不会进行初始化,会直接返回,这时候instance还没有进行初始化,会导致,线程B后续操作出现问题。
解决方法
volatile关键字
volatile关键字,可以保证变量的可见性,同时一定程度上禁止执行重排序
1 | public class Person() { |
synchronized关键字
syncchronized关键字可以保证代码块在同一时刻只能被一个线程访问,从而保证代码块方法内执行的指令是有序的。
synchronized关键字,通过获取和释放锁来实现同步,在获取锁和释放锁的过程中,会插入内存屏障,进行指令重排序
1 | public class Person() { |
Lock接口
同样是加锁,Lock相比于synchronized更加灵活,想要在哪加锁,在哪解锁都是由自己决定
1 | public class Person() { |
7.如何避免死锁
出现原因
- 互斥:一个资源在同一时刻只能被一个进程使用
- 不可剥夺:一个资源在没有被线程使用完的时候,不能够被别的线程调用
- 请求和保持:一个线程在访问一个资源的时候,想要访问另一个资源,但是当先访问的资源并不释放
- 循环等待:若干线程,依次在等待资源,并且形成等待环
我们只需要将其中一个条件破坏掉,就可以打破死锁条件
但是前三个条件是锁的必要条件,所以我们可以破坏第四个条件
解决方法
顺序加锁
所有线程都按照同样的顺序加锁,例如,线程A先锁资源A,在锁资源B,线程B也应该和线程A同样的加锁顺序
1 | class Resource { |
限时加锁
对每个锁进行超时时间,如果超时还没有获取到锁,就会返回false,这样可以避免线程无限期的等待锁。
1 | import java.util.concurrent.locks.ReentrantLock; |
减少锁的持有时间
减少线程持有锁的时间,可以降低其他线程等待锁的时间,从而减少死锁的可能性。可以将不需要加锁的代码放到同步快之外执行
1 | public class ReduceLockHoldTimeExample { |
检测是否含有死锁
可以通过定期检测系统中是否存在死锁,如果发现死锁,则采取相应的恢复措施,如终止某些线程或释放某些锁。Java 中可以使用 ThreadMXBean 来检测死锁。
1 | import java.lang.management.ManagementFactory; |
8.HashMap扩容原理
1.7版本之前
- 先生成新数组
- 遍历老数组每个位置上的链表上的每个元素
- 取每个元素的的key,并基于新数组的长度计算出在新数组下的下标
- 将元素添加到新数组中去
- 将所有的元素转移完之后,将新数组赋值给HashMap对象的table属性
1.8版本之后
1.8之后HashMap的底层结构和1.7不同
- 先生成新数组
- 遍历老数组每个位置上的链表或红黑树
- 如果时链表则直接计算链表中的每个数组的下标,并添加到新数组中
- 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置
- 统计每个下标位置的元素个数
- 如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点的添加到新数组的对应位置
- 如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置
- 将所有的元素转移完之后,将新数组赋值给HashMap对象的table属性
9.JDK1.7到JDK1.8HashMap发生了什么变化
- 1.7中底层是数组加链表,1.8中底层是数组+链表+红黑树,加入红黑树的目的是提高hashmap插入和查询的整体效率
- 1.7中链表插入使用的头插法,1.8链表插入的是尾插法,因为1.8中插入key和value的时候需要判断链表元素的个数,所以正好遍历链表,因此采用尾插法
- 1.7中HashMap中的哈希算法比较复杂,存在各种右移与异或运算,1.8中进行了简化,因为复杂的哈希算法目的就是提高散列性,来提拱HashMap的整体效率,而1.8中新增加了红黑树,所以可以适当简化哈希算法,节省CPU资源
10.HashMap的Put方法
流程:
- 根据key通过哈希算法与运算得出数组下标
- 如果数组下标位置为空,则将key和value封装为entity对象(jdk1.7是entity对象,jdk1.8中是node对象)并放入该位置
- 如果数组下标不为空,则需要分情况讨论
- jdk1.7,先判断是否需要扩容,如果需要扩容则进行扩容,如果不需要扩容则生成entity对象,并使用头插法插入到当前位置的链表中
- 如果是1.8则先会判断当前位置的node类型,看是红黑树还是链表
- 如果是红黑树,则将key和value封装称为一个node转入到红黑树中,在这个过程中判断红黑树是否存在当前key,如果存在则进行更新value
- 如果此位置上是链表,则将key和value封装成一个node对象使用尾插法插入到链表尾部,使用尾插法需要遍历链表,判断链表上是否有该key,如果有则进行更新,遍历完之后没有,则插入到尾部,插入到链表中会看当前链表的节点数,如果节点数大于等于8,则将链表转换成红黑树。
- 将node插入到链表或者红黑树中,在判断是否需要扩容,如果需要就扩容,不需要就结束put方法
11.ConcurrentHashMap的扩容机制
1.7 版本之前
1.7版本之前的ConcurrentHashMap是基于Segment分段实现的
1.7之前ConcurrentHashMap会将HashMap分为一个个的segment段,每一个段使用ReentrantLock锁来加锁和解锁,保证线程的安全性
每一个Segment是一个小型的HashMap
每个Segment都会进行扩容,和HashMap的扩容机制类似,都是实现二倍扩容
先生成新数组,然后转移元素到新数组中,是否扩容也是每一个Segment分段内部单独实现的,判断是否超过阈值
1.8版本之后
ConcurrentHashMap就废弃了Segment分段操作,改为正常的HashMap了,每一个HashMap的数组块都是一个桶采用 CAS(Compare-And-Swap)和 synchronized 来解决并发问题。它使用数组 + 链表 + 红黑树的数据结构,通过对每个桶节点加锁来实现并发控制。当插入的时候当前的位置没有元素,则直接插入,如果有元素的话则通过synchronized来进行加锁,每个线程访问不同的桶,可以加不同的锁,能够提高访问效率
扩容流程,当一个线程想要向concurrentHashMap中添加元素的时候,发现当前需要扩容,则当前线程暂时放弃添加元素,去帮助扩容,在扩容期间如果有新的添加元素的线程到达,则同样会放弃当前的添加操作进行扩容。
如果添加的时候没有发现需要扩容,则直接添加,添加完毕之后,在进行判断,超过了阈值则进行扩容
concurrentHashMap是支持多线程同时扩容的
扩容前也是先生成一个新数组
在转移元素之前,先将原数组分组,将每一个组分配给不同的线程来进行元素转移,每一个线程负责一组或者多组元素转移工作
12.ReentrantLock中的tryLock和lock方法的区别
- tryLock表示尝试加锁,可能加锁成功,也可能加锁失败,但是该方法不会阻塞当前线程,加锁成功返回true,加锁失败返回false可以通过返回值来判断是否需要进行下一步操作
- lock会阻塞加锁,如果加锁成功则进行下一步操作,如果加锁失败则会一直阻塞等待加锁,没有返回值
13.ReentrantLock中公平锁和非公平锁底层实现
不管是公平锁还是非公平锁,他们底层实现都是会使用AQS来进行排队,他们的区别在于:当线程使用lock()方式加锁的时候,
- 如果是公平锁,会先检查AQS中是否有对象在排队,如果有排队的情况,则自己也会进入排队状态,如果没有排队情况则会去竞争锁
- 如果是非公平锁,会先去竞争锁,如果竞争成功则进行下一步操作,如果没有竞争成功,则会去排队
公平锁和非公平锁区别只是在先排队还是先竞争锁,锁释放的时候都是从队列中唤醒第一个线程
14.sychronized的偏向锁,轻量级锁和重量级锁
偏向锁
在锁对象的对象头中记录一下当前获取到该锁的线程ID,该线程下次如果再次来之后可以直接获取到这个锁
轻量级锁
轻量级锁由偏向锁升级来,当一个线程获取到一个锁的时候,此时该锁是偏向锁,等到有第二个线程来竞争锁,偏向锁就会升级为轻量锁,之所以为轻量锁,是为了和重量级锁分开,轻量级锁的底层是通过让等待线程自旋等待来实现的,不会调用操作系统底层让线程停止工作阻塞。但是从用户角度看,线程都是被阻塞了
重量级锁
当一个线程自旋次数过多也没有获取到锁,则会升级为重量级锁,重量级锁会调用操作系统底层来使线程阻塞
自旋锁
自旋锁就是在线程获取锁的过程中,不会去阻塞线程,也就无所谓唤醒线程,阻塞和唤醒都是交到操作系统层面去做的,比较耗费时间,自旋锁是线程CAS获取预期的一个标志,如果没有获取到则继续循环获取,如果获取到了则表示获取到了锁,这个过程中线程一直在运行,相对而言没有耗费太多的操作系统资源,比较轻量
15.sychronized和ReentrantLock的区别
- sychronized是一个关键字而ReentrantLock是一个类
- sychronized会自动加锁和解锁,RenntranLock需要程序员手动的去加锁和解锁
- sychronized的底层是jvm层面的锁,RenntranLock是API层米的锁
- sychronized是非公平锁,RenntranLock可以选择公平和非公平锁
- sychronized锁的是对象,锁信息保存在对象头中,ReentranLock通过代码中的int类型state标识来标识锁的状态
- sychronized底层是一个锁升级的过程,偏向锁->轻量锁->重量锁
16.ThreadLocal底层原理
ThreadLocal是JAVA中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程的内部,该线程可以在任意时刻,任意方法中获取缓存数据。
ThreadLocal底层是通过ThreadLocalMap来实现的,每一个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象是用完之后,应该要把设置的key,value,也就是entity对象进行回收,但是线程池中的线程不会进行回收,而线程池中的对象是通过强引用指向的ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entity对象,线程不会回收,Entity对象也不会回收,从而出现内存泄漏,解决方法,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清除Entry对象
ThreadLocal经典使用场景就是连接管理(一个线程持有一个链接,该链接对象可以在不同的方法中进行传递,线程之间不共享一个链接)
17.并发,并行,串行之间的区别
串行:一个任务执行完毕,在执行下一个任务,排着队依次来
并行:多个任务同时进行
并发:宏观上来看是多个任务同时进行,微观来看就是多个任务交替执行
18.对守护线程的理解
线程分为用户线程和守护线程,用户线程就是普通的线程,守护线程就是jvm的后台线程,比如说垃圾回收线程就是一个守护线程,守护线程会在其他的普通线程都停止运行之后自动关闭,我们可以通过thread.setDaemon(true)来把一个线程设置为守护线程
19.说说对线程安全的理解
线程安全指定是:我们在写某段代码的时候,再多个线程同时执行这段代码的时候,不会产生混乱,依然能够得到正确的结果,比如说i++,i初始话为0,那么两个线程来同时执行这个代码,如果代码是线程安全的,那么两个线程会一个得到1,一个得到2,如果两个线程的结果都是1,则表明这段代码是线程不安全的。
所以线程安全,主要指的是一段代码在多个线程同时执行的情况下,能够得到正确的结果
20.谈一下对AQS的理解,AQS如何实现可重入锁
AQS是一个JAVA线程同步框架,是JDK很多锁工具的核心实现框架
在AQS中维护了一个信号量state和一个线程组成的双向链表队列(先进先出),其中这个线程队列,就是用来给线程排队的
- 同步状态:AQS 使用一个
volatile int
类型的变量state
来表示同步状态。volatile
关键字保证了该变量在多线程环境下的可见性。通过getState()
、setState(int newState)
和compareAndSetState(int expect, int update)
方法可以对同步状态进行操作,其中compareAndSetState
是基于 CAS(Compare-And-Swap)操作实现的,能保证原子性地更新同步状态。 - CLH 队列:AQS 内部维护了一个 FIFO(先进先出)的双向队列,该队列基于 CLH(Craig, Landin, and Hagersten)锁队列的变体实现。当多个线程竞争同步状态时,获取失败的线程会被封装成一个
Node
节点加入到这个队列中进行排队等待。
可重入锁
**可重入锁:**可重入锁是指同一个线程在外层方法获取到指定锁的时候,如果该线程内部方法也需要获取该锁,则可以直接获取,不会造成阻塞。这种锁允许线程多次进入该锁保护的代码块。
特点:
- 线程可重复获取锁:同一线程可以多次获取同一把锁,避免了死锁的发生,例如在一个线程调用一个方法的时候已经获取了一个锁,在该方法内部有调用了另一个需要获取相同锁的方法,此时线程可以直接进入,不会因为锁已经被自己持有而阻塞
- 锁的计数机制:可重入锁通常有一个计数器来记录锁被获取的次数,每次线程获取锁的时候,数量加一,线程释放锁的时候数量减一,只有当计数器为0的时候锁才能够被真正的释放,其他线程才能够获取到该锁
JAVA中的实现
-
sychronized关键字
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class SynchronizedReentrantExample {
public synchronized void outerMethod() {
System.out.println("进入外层方法");
innerMethod();
System.out.println("离开外层方法");
}
public synchronized void innerMethod() {
System.out.println("进入内层方法");
}
public static void main(String[] args) {
SynchronizedReentrantExample example = new SynchronizedReentrantExample();
example.outerMethod();
}
}在上述代码中,
outerMethod
和innerMethod
都使用了synchronized
关键字。当线程调用outerMethod
时,它会获取对象的锁。在outerMethod
内部调用innerMethod
时,由于innerMethod
也需要同一把锁,而该线程已经持有了这把锁,所以可以直接进入innerMethod
,不会被阻塞。 -
ReentrantLock
类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void outerMethod() {
lock.lock();
try {
System.out.println("进入外层方法");
innerMethod();
System.out.println("离开外层方法");
} finally {
lock.unlock();
}
}
public void innerMethod() {
lock.lock();
try {
System.out.println("进入内层方法");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ReentrantLockExample example = new ReentrantLockExample();
example.outerMethod();
}
}在这个示例中,
ReentrantLock
被用于保护代码块。线程在调用outerMethod
时获取锁,在outerMethod
内部调用innerMethod
时再次获取同一把锁,由于ReentrantLock
是可重入的,线程可以正常进入innerMethod
。在每个方法结束时,需要在finally
块中调用unlock
方法来释放锁,以确保锁最终会被释放。
AQS如何实现可重入锁
AQS 借助一个 volatile int
类型的 state
变量来表示同步状态。对于可重入锁,state
用于记录锁被当前线程持有的次数:
- 当
state
为 0 时,意味着锁未被任何线程持有。 - 当线程首次获取锁时,
state
会被设置为 1,并且记录下持有该锁的线程。 - 若持有锁的线程再次获取锁,
state
会递增,例如变为 2、3 等,这体现了锁的可重入特性。 - 每次线程释放锁时,
state
会递减。当state
减为 0 时,表明锁已被完全释放,其他线程就有机会获取该锁。
21.线程池的底层工作原理
线程池的内部是通过队列+线程来实现的,当我们再利用线程池执行任务的时候
- 如果此时的线程池中的线程数量小于corePoolSize,即、即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务
- 如果线程池中的线程数量等于corePoolSize,但是缓冲队列没有满,那么任务放到缓冲队列中
- 如果线程池中的线程数量大于corePoolSize,缓冲队列workQueue满,并且线程池中线程个数小于maximunPoolSize,建立新的线程来处理被添加的任务
- 如果线程池中的线程数量大于corePoolSize,缓冲队列workQueue满,并且线程池中线程个数等于maximunPoolSize,执行拒绝策略来处理此任务
- 当线程池中的线程数量大于corePoolSize,如果某空闲时间超过keepAliveTime,则线程被终止,线程池可动态的调整线程池中的线程数
22.线程池为什么是先添加队列而不是先创建最大线程
核心思想:尽量想要通过最小的线程数解决最多的问题,让每一个线程的利用率都达到最大
当线程池中的核心线程数都在忙的时候,如果继续往线程池中添加任务,那么任务会先放入队列,队列满了之后才会新开线程。
例子:一个公司有10个程序员,本来这十个程序员都能正常的处理各种请求,但是随着公司的发展,需求在慢慢增加,但是一开始这些需求只会增加在待开发列表中,然后这10个程序员加班加点从待开发队列中获取请求并进行处理,但是某一天待开发列表满了,公司发现10个程序员真的处理不过来了,所以就需要开始招聘新的员工
23.线程之间是如何进行通讯的
两种通讯方式:
- 基于共享内存来进行通讯
- 基于网络进行通讯
共享内存通讯
**共享变量:**多个线程可以访问同一个共享变量,通过对共享变量的读写来实现数据的传递与交互,为了保证数据的一致性和可见性通常需要使用volatile关键字或者同步机制
1 | public class SharedVariableCommunication { |
**协作机制:**通过java中的wait和notify和notifyAll方法,用于实现线程时间的协作,wait让当前线程等待,直到其他线程使用notify或者notifyAll来唤醒它,notify方法随机唤醒一个等待的线程,notifyAll唤醒所有线程,通常在synchronized块中使用
1 | public class ThreadCommunication { |
使用Future和Callable:Callable是一个泛型接口,它的call()方法返回一个结果。Future表示一个异步的操作,可以通过Future获取Callable任务的执行结果,ExecutorService可以提交Callable任务,并返回一个Future对象
1 | import java.util.concurrent.ExecutorService; |
24.提交任务的时候,线程队列已满,这时候会发生什么
- 如果使用无界队列,可以继续提交任务没有关系
- 如果使用有界队列,则需看线程池中的线程数量有没有达到最大线程数,如果没有达到则可以创建新线程,如果达到最大线程数则需要使用拒绝策略
25.FixedThreadPool用的阻塞队列是什么
FixedThreadPool,代表定长线程池,底层使用的LinkedBlockingQueue,表示无界的阻塞队列。
26.volatile关键字是如何保证可见性,有序性的
使用volatile修饰的关键字,会在修改的时候直接修改主内存中的数据,并且在读取的时候也是直接从主内存中读取,从而保证了可见性
volatile修饰的成员变量进行读写的时候,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而保证有序性