写在前面
⭐️在无数次的复习巩固中,我逐渐意识到一个问题:面对同样的面试题目,不同的资料来源往往给出了五花八门的解释,这不仅增加了学习的难度,还容易导致概念上的混淆。特别是当这些信息来自不同博主的文章或是视频教程时,它们之间可能存在的差异性使得原本清晰的概念变得模糊不清。更糟糕的是,许多总结性的面试经验谈要么过于繁复难以记忆
,要么就是过于简略
,对关键知识点一带而过,常常在提及某项技术时,又引出了更多未经解释的相关术语和实例,例如,在讨论ReentrantLock
时,经常会提到这是一个可重入锁,并存在公平与非公平两种实现方式,但对于这两种锁机制背后的原理以及使用场景往往语焉不详。
⭐️正是基于这样的困扰与思考,我决定亲自上阵,撰写一份与众不同的面试指南。这份指南不仅仅是对现有资源的简单汇总,更重要的是,它融入了我的个人理解和解读。我力求回归技术书籍本身
,以一种层层递进的方式剖析复杂的技术概念,让那些看似枯燥乏味的知识点变得生动起来,并在我的脑海中构建起一套完整的知识体系。我希望通过这种方式,不仅能帮助自己在未来的技术面试中更加从容不迫,也能为同行们提供一份有价值的参考资料,使大家都能在这个过程中有所收获。
Java多线程相关面试题
1 线程的基础知识
面试官:聊一下并行和并发有什么区别?
候选人:
现在都是多核CPU,在多核CPU下。
并发是指在同一时间段内,系统有能力处理多个事件,多个线程轮流使用一个或多个CPU
并行是指在同一时刻,多个任务可以在多个处理器核心上同时执行,4核CPU同时执行4个线程
面试官:说一下线程和进程的区别?
候选人:
-
进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
-
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享进程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈
-
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
下⾯来思考这样⼀个问题:为什么程序计数器、虚拟机栈和本地⽅法栈是线程私有的呢?
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。所以,程序计数器私有主要是为了线程切换后能恢复到正确的执⾏位置。
- 虚拟机栈为虚拟机执⾏ Java⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。
面试官:什么是上下⽂切换?
候选人:(源自《Java并发编程艺术》1.1节)
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间后片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是⼀次上下⽂切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。
面试官:如何减少上下⽂切换?
候选人:(源自《Java并发编程艺术》1.1.3节)
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
-
无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
-
CAS 算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
-
使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
-
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
面试官:如果在java中创建线程有哪些方式?
候选人: 在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。
面试官:好的,刚才你说的runnable 和 callable 两个接口创建线程有什么不同呢?
候选人:
-
Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
-
还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息
在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
面试官:线程包括哪些状态,状态之间是如何变化的?
候选人: 在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:初始、运行、终止、阻塞、等待和超时等待六种。(图源《Java 并发编程艺术》4.1.4 节)
当一个线程对象被创建,但还未调用 start 方法时处于初始状态,调用了 start 方法,就会由初始进入运行状态。如果线程内代码已经执行完毕,由运行进入终止状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入运行状态。
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为运行状态。
还有一种情况是调用 sleep(long) 方法也会从运行状态进入超时等待状态,不需要主动唤醒,超时时间到自然恢复为运行状态。
(图源《Java 并发编程艺术》4.1.4 节):
面试官:说说sleep()⽅法和wait()⽅法区别和共同点?
候选人: 它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
不同点主要有三个方面:
第一:方法归属不同
sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有
第二:线程醒来时机不同
线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去
第三:锁特性不同
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制:
-
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)
-
sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)
面试官:好的,我现在举一个场景,你来分析一下怎么做,新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
候选人: 可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成。
import java.util.concurrent.TimeUnit;
public class Join {
public static void main(String[] args) throws Exception {
Thread previous = Thread.currentThread();
for (int i = 0; i < 3; i++) {
//每个线程拥有前一个线程的引用,需要等待前一个线程终止,才能从等待中返回
Thread thread = new Thread(new Domino(previous), String.valueOf(i));
thread.start();
previous = thread;
}
TimeUnit.SECONDS.sleep(5);
System.out.println(Thread.currentThread().getName() + " terminate.");
}
static class Domino implements Runnable {
private Thread thread;
public Domino(Thread thread) {
this.thread = thread;
}
public void run() {
try {
thread.join();
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + " terminate.");
}
}
}
输出如下。
main terminate.
0 terminate.
1 terminate.
2 terminate.
面试官:在我们使用线程的过程中,有两个方法。线程的 run()和 start()有什么区别?
候选人: start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。run方法封装了要被线程执行的代码,可以被调用多次。
总结:调⽤start() ⽅法⽅可启动线程并使线程进⼊就绪状态,直接执⾏run()⽅法的话不会以多线程的⽅式执⾏。
面试官:那如何停止一个正在运行的线程呢?
候选人:有三种方式可以停止线程:
第一:可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记
第二:可以使用线程的suspend()、resume()和stop()方法强行终止,不过一般不推荐,这个方法已作废
第三:可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程
我们项目中使用的话,建议使用第一种或第三种方式中断线程。
《Java并发编程的艺术》4.2.4节:
不建议使用suspend()、resume()和stop()方法的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。
2 线程中并发锁
面试官:说一下悲观锁与乐观锁的区别?
候选人: 悲观锁和乐观锁是数据库管理系统中用于处理并发事务的不同方式。
悲观锁
基于假设:当事务要对某个数据进行操作时,认为很可能会发生冲突。因此,在事务开始时就对数据进行锁定,阻止其他事务同时对其进行修改,直到当前事务完成。这种方式虽然能够保证数据的一致性和准确性,但由于锁的存在,可能会导致其他事务等待,增加了系统开销。
乐观锁
则基于另一种假设:认为事务之间发生冲突的概率较低,因此在读取数据时不立即加锁,而是允许多个事务同时读取数据。当事务尝试提交更改时,会检查在此期间是否有其他事务修改过相同的数据。如果有冲突,则当前事务失败,可能需要回滚并重新开始。乐观锁通常通过版本号或时间戳来检测数据是否已被修改。
简单来说,悲观锁适合于数据冲突频繁的场景,它通过加锁来防止冲突;而乐观锁更适合于读多写少的场景,通过在提交时检查冲突来减少锁的竞争。
面试官:说一下公平锁与非公平锁的区别?
候选人:
公平锁(Fair Lock)
按照线程在队列中的排队顺序,先到者先拿到锁。
非公平锁(Unfair Lock)
当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。
《Java并发编程的艺术》5.3节:
这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。
事实上,公平的锁机制往往没有非公平的效率高,但是,并不是任何场景都是以 TPS 作为唯一的指标,公平锁能够减少“饥饿” 发生的概率,等待越久的请求越是能够得到优先满足。
在测试中公平性锁与非公平性锁相比,总耗时是其 94.3倍,总切换次数是其 133 倍。可以看出,公平性锁保证了锁的获取按照 FIFO 原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
面试官:讲一下synchronized关键字的底层原理?
候选人:synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏。
synchronized 底层使用的JVM级别中的Monitor(监视器锁) 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。
在 Java 早期版本中, synchronized 属于 重量级锁,效率低下。
《Java并发编程的艺术》2.2节:
从JVM 规范中可以看到 Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
- 代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。
- 方法同步是使用另外一种方式实现的,JVM 通过该ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。
不过两者的本质都是对 对象监视器 monitor 的获取。
面试官:你能具体说下Monitor 吗?
候选人:monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
monitor内部维护了三个变量:
-
WaitSet:保存处于Waiting状态的线程
-
EntryList:保存处于Blocked状态的线程
-
Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner。
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
面试官:说说⾃⼰是怎么使⽤ synchronized 关键字的?
候选人:
1.修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得 当前对象实例的锁
synchronized void method() {
//业务代码
}
2.修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得 当前class的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。所以,如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
synchronized void staic method() {
//业务代码
}
3.修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized( .class) 表示进⼊同步代码前要获得 当前 class 的锁
synchronized(this) {
//业务代码
}
总结:
-
synchronized
关键字加到static
静态⽅法和synchronized(class)
代码块上都是是给Class
类上锁。 -
synchronized
关键字加到实例⽅法上是给对象实例上锁。 -
尽量不要使⽤
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能!
面试官:单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁⽅式实现单例模式的原理呗!
候选人:双重校验锁实现对象单例(线程安全) 这个必须要会!!!
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getInstance() {
//先判断对象是否已经实例过,没有实例化过才进⼊加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
另外,uniqueInstance
采⽤ volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执⾏:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
如果没有volatile
,由于 JVM 具有重排序的特性,执⾏顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执⾏了 1 和 3,此时 T2 调⽤ getUniqueInstance ()
后发现 uniqueInstance 不为空,因此返回uniqueInstance
,但此时 uniqueInstance
还未被初始化。所以这是一种错误的用法!
但是如果使⽤ volatile
就可以禁⽌ JVM 的指令重排,实现线程安全的延迟初始化,保证在多线程环境下也能正常运⾏。
面试官:关于synchronized 的锁升级的情况了解吗?
候选人:Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级锁修改对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令。(图源《Java并发编程的艺术》2.2.1节)
《Java并发编程的艺术》2.2.1节:
在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
面试官:好的,刚才你说了synchronized它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?
候选人:在高并发下,我们可以采用ReentrantLock来加锁。
面试官:说下ReentrantLock的使用方式和底层原理?
候选人:
ReentrantLock是一个可重入锁 ,调用 Lock 方法获取了锁之后,再次调用 Lock,是不会再阻塞,内部直接增加重入次数,标识这个线程已经重复获取一把锁而不需要等待锁的释放。
ReentrantLock是属于JUC包(JUC是Java平台提供的一个用于支持高并发程序设计的工具包)下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。
它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁。构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。
面试官:刚才你说了CAS和AQS,你能介绍一下吗?
候选人:
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
《Java并发编程的艺术》2.3节对CAS操作的解释:
CAS 操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
-
CAS使用到的地方很多:AQS框架、AtomicXXX类
-
在操作共享变量的时候使用的自旋锁,效率上更高一些
-
CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
AQS的全称是:AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。使⽤ AQS 能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,⽐如 ReentrantLock
, Semaphore
,其他的诸如ReentrantReadWriteLock
, CountDownLatch
, FutureTask
等等皆是基于 AQS 的。当然,我们⾃⼰也能利⽤ AQS ⾮常轻松容易地构造出符合我们⾃⼰需求的同步器。
内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过CAS机制设置 state 状态。
在它的内部还提供了基于 FIFO 的等待队列(CLH 队列),是一个双向列表,其中
-
tail 指向队列最后一个元素
-
head 指向队列中最久的一个元素
面试官:AQS对资源的共享方式(Semaphore、CountDownLatch、CyclicBarrier)
候选人:
Exclusive(独占):只有⼀个线程能执⾏,如 ReentrantLock ,可分为公平锁和⾮公平锁。
Share(共享):多个线程可同时执⾏,如CountDownLatch(倒计时器)、Semaphore(信号量)、CyclicBarrier(循环栅栏) 。
ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某⼀资源进⾏读。
不同的⾃定义同步器争⽤共享资源的⽅式也不同。⾃定义同步器在实现时只需要实现共享资源state 的获取与释放⽅式即可,⾄于具体线程等待队列的维护(如获取资源失败⼊队/唤醒出队等),AQS 已经在顶层实现好了。
《Java并发编程的艺术》 8.1 8.2 8.3节
- Semaphore(信号量)是用来控制同时访问特定资源的线程数量(大白话:可以指定多个线程同时访问某个资源),它通过协调各线程,以保证合理的使用公共资源。Semaphore可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。Semaphore 的用法也很简单,首先线程使用 Semaphore 的 acquire( ) 方法获取一个许可证,使用完之后调用 release( ) 方法归还许可证。还可以用 tryAcquire( )方法尝试获取许可证。
- CountDownLatch(倒计时器)允许一个或多个线程等待其他线程完成操作。(大白话:它可以让某一个线程等待直到倒计时结束,再开始执行)。CountDownLatch 的构造函数接收一个 int 类型的参数作为计数器,如果你想等待 N个点完成,这里就传入N。当我们调用CountDownLatch的countDown方法时,N就会减1,CountDownLatch 的await方法会阻塞当前线程,直到N变成零。
- CyclicBarrier 的字⾯意思是可循环使⽤( Cyclic )的屏障( Barrier )。它要做的事情是,让⼀组线程到达⼀个屏障(也可以叫同步点)时被阻塞,直到最后⼀个线程到达屏障时,屏障才会开⻔,所有被屏障拦截的线程才会继续运行。 CyclicBarrier 默认的构造⽅法是 CyclicBarrier(int parties) ,其参数表示屏障拦截的线程数量,每个线程调⽤ await() ⽅法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
// CountDownLatch的用法
import java.util.concurrent.CountDownLatch;
public class CountDownLatchTest {
static CountDownLatch c = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(1);
c.countDown();
System.out.println(2);
c.countDown();
}
}).start();
c.await();
System.out.println("3");
}
}
// CyclicBarrier的用法
// 因为主线程和子线程的调度是由CPU决定的,两个线程都有可能先执行,所以会出现两种输出
// 第一种: 1 2 第二种: 2 1
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierTest {
// 如果把 new CyclicBarrier(2)修改成 new CyclicBarrier(3),则主线程和子线程会永远等待。
// 因为没有第三个线程执行 await 方法,即没有第三个线程到达屏障,所以之前到达屏障的两个线程都不会继续执行。
static CyclicBarrier c = new CyclicBarrier(2);
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
try {
c.await();
} catch (Exception e) {
}
System.out.println(1);
}
}).start();
try {
c.await();
} catch (Exception e) {
}
System.out.println(2);
}
}
面试官:CyclicBarrier和CountDownLatch的区别
候选人:(源自《Java并发编程的艺术》 8.2.3节)
- CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以使用 reset() 方法重置。所以CyclicBarrier 能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次。
- CyclicBarrier 还提供其他有用的方法,比如 getNumberWaiting方法可以获得 CyclicBarrier阻塞的线程数量。isBroken()方法用来了解阻塞的线程是否被中断。
面试官:synchronized和Lock有什么区别 ?
候选人:
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
统合来看,需要根据不同的场景来选择不同的锁的使用。
面试官:说说synchronized关键字和volatile关键字的区别
候选人:synchronized 关键字和 volatile 关键字是两个互补的存在,⽽不是对⽴的存在!
-
volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定⽐ synchronized关键字要好。但是 volatile 关键字只能⽤于变量⽽ synchronized 关键字可以修饰⽅法以及代码块。
-
volatile 关键字能保证数据的可⻅性,但不能保证数据的原⼦性。 synchronized 关键字两者都能保证。
《Java并发编程的艺术》 3.4.1节
简而言之,volatile 变量自身具有下列特性。
可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。(大白话:volatile 无法保证原子性 ,只能保证自身读写为原子操作)
- volatile 关键字主要⽤于解决变量在多个线程之间的可⻅性,⽽ synchronized 关键字解决的是多个线程之间访问资源的同步性。
面试官:说说Synchronized和ReentrantLock的区别
候选人:
- Synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用在代码块上。
- Synchronized 会自动加锁和释放锁;ReentrantLock需手动加锁和释放锁。
- Synchronized 属于非公平锁;ReentrantLock 既可以是公平锁也可以是非公平锁。
- Synchronized 是JVM通过 monitor 实现的;ReentrantLock是通过CAS+AQS队列实现的。
面试官:死锁产生的条件是什么?
候选人:一个线程需要同时获取多把锁,这时就容易发生死锁。
死锁必须具备以下四个条件:
-
互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
-
请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
-
不剥夺条件: 线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源。
-
循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。
面试官:如何避免线程死锁?
候选人: 我上⾯说了产⽣死锁的四个必要条件,为了避免死锁,我们只要破坏产⽣死锁的四个条件中的其中⼀个就可以了。现在我们来挨个分析⼀下:
-
破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界资源需要互斥访问)。
-
破坏请求与保持条件 :⼀次性申请所有的资源。
-
破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
-
破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。破坏循环等待条件。
《Java并发编程的艺术》 1.2节
现在我们介绍避免死锁的几个常见方法。
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
面试官:如何进行死锁诊断?
候选人:这个也很容易,我们只需要通过jdk自动的工具就能搞定。
我们可以先通过jps来查看当前java程序运行的进程id,然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
拓展:
jps:输出JVM中运行的进程状态信息。
jstack:查看java进程内线程的堆栈信息。
面试官:请谈谈你对 volatile 的理解
候选人:volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
《Java并发编程的艺术》 3.2节
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
面试官:那你能聊一下ConcurrentHashMap的原理吗?
《Java并发编程的艺术》 6.1.1 为什么要使用 ConcurrentHashMap?
在并发编程中使用 HashMap 可能导致程序死循环。而使用线程安全的 HashTable 效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap 的登场机会。
(1)线程不安全的HashMap
在多线程环境下,使用 HashMap 进行 put 操作会引起死循环,导致 CPU 利用率接近100%,所以在并发情况下不能使用 HashMap。HashMap 在并发执行 put 操作时会引起死循环,是因为多线程会导致 HashMap 的 Entry 链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取 Entry。
(2)效率低下的HashTable
HashTable 容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable 的效率非常低下。因为当一个线程访问HashTable 的同步方法,其他线程也访问HashTable 的同步方法时,会进入阻塞或轮询状态。如线程1使用 put 进行元素添加,线程2 不但不能使用 put 方法添加元素,也不能使用 get 方法来获取元素,所以竞争越激烈效率越低。
(3)ConcurrentHashMap 的锁分段技术可有效提升并发访问率
HashTable 容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问 HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap 所使用的锁分段技术。
候选人:ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。
- JDK1.7的底层采用是分段的数组+链表 实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
在jdk1.7中ConcurrentHashMap 是由Segment 数组结构和HashEntry数组结构组成。Segment 是一种可重入锁(ReentrantLock),扮演锁的⻆⾊。HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个Segment数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构。一个Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组,当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的Segment锁。
在jdk1.8中ConcurrentHashMap 取消了 Segment 分段锁,采⽤ CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap的结构类似,数组+链表/红⿊⼆叉树。Java 8 在链表⻓度超过⼀定阈值8时将链表(寻址时间复杂度为O(N))转换为红⿊树(寻址时间复杂度为 O(log(N)))。synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要 hash 不冲突,就不会产⽣并发,效率⼜提升 N 倍。
面试官:说说ConcurrentHashMap的get、put、size操作
候选人:(源自《Java并发编程的艺术》 6.1.5节)
get操作:
先经过一次散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素,代码如下。
public v get(object key) {
int hash = hash(key.hashcode());
return segmentFor(hash).get(key, hash);
}
get 操作的高效之处在于整个 get 过程不需要加锁,除非读到的值是空才会加锁重读。我们知道 HashTable 容器的 get 方法是需要加锁的,那么 ConcurrentHashMap 的 get 操作是如何做到不加锁的呢?原因是它的 get方法里将要使用的共享变量都定义成 volatile 类型。定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。之所以不会读到过期的值,是因为根据 Java 内存模型的 happens-before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get操作也能拿到最新的值,这是用 volatile替换锁的经典应用场景。
put操作:
由于 put 方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put 方法首先定位到 Segment,然后在 Segment 里进行插入操作。插入操作需要经历两个步骤,第一步断是否需要对 Segment 里的 HashEntry 数组进行扩容,第二步定位添加元素的位置,然后将其放在 HashEnty 数组里。
1)是否需要扩容
在插入元素前会先判断Segment 里的 HashEnty数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment的扩容判断比 HashMap 更恰当,因为 HashMap 是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,可能扩容之后没有新元素插入,这时 HashMap 就进行了一次无效的扩容。
2)如何扩容
在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个 Segment 进行扩容。
size 操作:
如果要统计整个 ConcurrentHashMap 里元素的大小,就必须统计所有 Segment 里元素的大小后求和。Segment里的全局变量 count 是一个 volatile 变量,那么在多线程场景下,是不是直接把所有 Segment 的 count 相加就可以得到整个 ConcurrentHashMap 大小了呢? 不是的,虽然相加时可以获取每个 Segment 的 count 的最新值,但是可能累加前使用的 count 发生了变化,那么统计结果就不准了。所以,最安全的做法是在统计size的时候把所有Segment 的put、remove 和 clean 方法全部锁住,但是这种做法显然非常低效。
因为在累加 count 操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap 的做法是尝试2次:通过不锁住Segment的方式来统计各个Segment大小;如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment 的大小。
那么 ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount 变量,在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行加1.那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。
3 线程池
面试官: 为什么要⽤线程池?
候选人:(源自《Java 并发编程的艺术》 9.1节)
在开发过程中,合理地使用线程池能够带来3个好处:
-
降低资源消耗。通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
-
提⾼响应速度。当任务到达时,任务可以不需要的等到线程创建就能⽴即执⾏。
-
提⾼线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。
面试官:线程池四种创建方式
候选人:
在jdk中默认提供了4种方式创建线程池。
第一个是:newCachedThreadPool 创建可以缓存的线程池,有任务提交到线程池时如果有空闲的线程可用则立即使用空闲线程执行任务,如果没有空闲的线程可用就会创建一个新的线程执行任务,当空闲线程闲置一段时间(默认是60秒)之后还未被使用,那么就会进行销毁操作。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class CachedThreadPoolExample {
public static void main(String[] args) {
// 创建可缓存的线程池
ExecutorService executorService = Executors.newCachedThreadPool();
// 提交任务给线程池
for (int i = 0; i < 5; i++) {
final int studentId = i;
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("学生 " + studentId + " 正在报名兴趣小组...");
try {
Thread.sleep(1000); // 模拟报名过程的时间消耗
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("学生 " + studentId + " 报名成功!");
}
});
}
// 关闭线程池
executorService.shutdown();
}
}
输出结果:
第二个是:newFixedThreadPool 创建一个定长线程池。 该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolExample {
public static void main(String[] args) {
// 创建一个固定大小的线程池,大小为2
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 提交3个任务给线程池
for (int i = 0; i < 3; i++) {
Runnable task = new Task(i);
executorService.submit(task);
}
// 所有任务执行完毕,关闭线程池
executorService.shutdown();
}
static class Task implements Runnable {
private int taskId;
public Task(int taskId) {
this.taskId = taskId;
}
@Override
public void run() {
System.out.println("Task " + taskId + " 执行中...");
try {
// 暂停,
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task " + taskId + " 执行完毕");
}
}
}
输出结果:
第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledThreadPoolExample {
public static void main(String[] args) {
// 创建具有固定核心线程数的线程池
ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);
// 延迟任务,延迟3秒后仅执行一次
executorService.schedule(new Runnable() {
@Override
public void run() {
System.out.println("延迟任务开始执行....");
}
}, 3, TimeUnit.SECONDS);
// 周期性任务,每隔1秒执行一次
executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
System.out.println("周期性任务开始执行....");
}
}, 0, 1, TimeUnit.SECONDS);
// 等待一段时间后关闭线程池
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
executorService.shutdown();
System.out.println("主线程执行完毕。。。");
}
}
输出结果:
第四个是:newSingleThreadExecutor 创建一个单线程的线程池,它只会使用一个线程执行任务,可以保证任务的执行顺序。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadExecutorExample {
public static void main(String[] args) {
System.out.println("开始执行");
// 创建单线程的线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();
// 提交任务给线程池
for (int i = 0; i < 5; i++) {
final int taskNumber = i;
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("Task " + taskNumber + " 执行中...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task " + taskNumber + " 执行完毕");
}
});
}
// 关闭线程池
executorService.shutdown();
System.out.println("主线程执行完毕,等待线程池任务执行。");
}
}
输出结果:
面试官:ThreadPoolExecutor的核心参数有哪些?
候选人:ThreadPoolExecutor
类中提供的四个构造⽅法。我们来看最⻓的那个,其余三个都是在这个构造⽅法的基础上产⽣(其他⼏个构造⽅法说⽩点都是给定某些默认参数的构造⽅法⽐如默认制定拒绝策略是什么)
/**
* ⽤给定的初始参数创建⼀个新的ThreadPoolExecutor。
*/
new ThreadPoolExecutor(corePoolSize , maximumPoolSize , keepAliveTime , milliseconds , runnableTaskQueue , handler);
在线程池中一共有7个核心参数:
- corePoolSize (线程池的基本大小) - 核⼼线程数定义了最⼩可以同时运⾏的线程数量
- maximumPoolSize (线程池最大数量) - 线程池允许创建的最大线程数。最大线程数目=核心线程+救急线程的最大数目
- keepAliveTime (线程活动保持时间) - 线程池的工作线程空闲后,保持存活的时间
- timeUnit (线程活动保持时间的单位) - 如秒、毫秒等
- runnableTaskQueue (任务队列) - 用于保存等待执行的任务的阻塞队列
《Java 并发编程的艺术》 9.2.1节 详情见本模块最后一个issue…
可以选择以下几个阻塞队列:
- ArrayBlockingQueue:一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
- LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。
-
threadFactory (线程工厂) - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
-
handler (拒绝策略) - 当队列和线程池都满时,会触发拒绝策略(默认策略是AbortPolicy,表明无法处理新任务时抛出异常)
《Java 并发编程的艺术》9.2.1节
在jdk1.5中Java线程池框架提供了以下4种策略。
- AbortPolicy:直接抛出异常。
- CallerRunsPolicy:只用调用者所在线程来运行任务。
- DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
- DiscardPolicy:不处理,丢弃掉。
面试官:线程池的执行原理知道吗?
候选人:当提交一个新任务到线程池时,线程池的处理流程如下。
1)线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程(核心线程)来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
2)线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个任务队列里。如果任务队列满了,则进入下个流程。
3)线程池判断线程池的线程是否都处于工作状态。如果没有,则创建一个新的救急线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
4)如果核心线程或救急线程完成任务,会检查任务队列中是否有需要执行的任务,如果有就核心线程或救急线程会执行任务。
面试官:为什么不建议使用Executors创建线程池呢?
候选人:
好的,其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了。
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
面试官: 执⾏execute()⽅法和submit()⽅法的区别是什么呢?
候选人:
execute()
⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;
threadsPool.execute(new Runnable() {
@Override
public void run() {
// do something
}
});
submit()
⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值, get() ⽅法会阻塞当前线程直到任务完成,⽽使⽤ get(long timeout ,TimeUnitunit)⽅法则会阻塞当前线程⼀段时间后⽴即返回,这时候有可能任务没有执⾏完。
Future<Object> future = executor.submit(harReturnValuetask);
try{
Object result = future.get();
} catch(InterruptedException e){
// 处理中断异常
} catch (ExecutionException e){
// 处理无法执行任务异常
} finally {
executor.shutdown();
}
面试官:Java 里的阻塞队列都有哪些?能简单说说吗?
候选人:(源自《Java并发编程的艺术》 6.3.2节)
JDK7提供了7个阻塞队列,如下。
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
- LinkedBlockingOueue:一个由链表结构组成的无界阻塞队列。
- PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousOueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
下面重点介绍3类阻塞队列,其他的队列还请大家自行查阅书籍网站(没有先后次序之分)。
-
ArrayBlockingQueue
ArrayBlockingOueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。 -
LinkedBlockingQueue
LinkedBlockingOueue 是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出(FIFO)的原则对元素进行排序。 -
LinkedBlockingDeque
LinkedBlockingDeque 是一个由链表结构组成的双向阻寒队列。所谓双向队列指的是可以从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。
4 线程使用场景问题
面试官:如果控制某一个方法允许并发访问线程的数量?
候选人:
在jdk中提供了一个Semaphore类(信号量)
它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了
第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1
面试官:好的,那该如何保证Java程序在多线程的情况下执行安全呢?
候选人:
嗯,刚才讲过了导致线程安全的原因。要解决多线程环境下的执行安全问题,JDK提供了多种工具和机制来帮助我们:
- 原子性问题:可以通过使用
java.util.concurrent.atomic
包下的原子类(如AtomicInteger
、AtomicLong
等)来解决。此外,synchronized
关键字和ReentrantLock
也可以用来确保代码块的原子性执行。 - 可见性问题:
synchronized
关键字和volatile
关键字都可以用来确保一个线程对共享变量的修改能够被其他线程看到。使用显式锁(如ReentrantLock
)时,配合Condition
对象也可以保证可见性。 - 有序性问题:通过遵循
Happens-Before
规则,可以保证多线程环境下操作的有序性。synchronized
关键字和volatile
关键字同样可以用来保证有序性。
面试官:你在项目中哪里用了多线程?
候选人:
嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行
参考场景三:
《黑马头条》项目中使用的
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用
5 其他
面试官:讲一下JMM(Java内存模型)
候选人:
- Java线程间的通信由JMM控制,JMM定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作,从而保证指令的准确性,内存可见性。
《Java并发编程的艺术》 3.1.3节
JMM 属于语言级的内在模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
- JMM把内存分成两块,一块是本地内存,一块是主内存。
- 线程跟线程之间相互隔离,线程跟线程交互需要通过主内存。
面试官:JMM 可能会导致数据不一致?怎么理解?
候选人:
在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进⾏特别的注意的。⽽在当前的 Java 内存模型下,线程可以把变量保存本地内存(⽐如机器的寄存器)中,⽽不是直接在主存中进⾏读写。这就可能造成⼀个线程在主存中修改了⼀个变量的值,⽽另外⼀个线程还继续使⽤它在寄存器中的变量值的拷⻉,造成数据的不⼀致。
要解决这个问题,可以把变量声明为 volatile ,这就指示 JVM 这个变量是共享且不稳定的,每次使⽤它都到主存中进⾏读取。保证变量的可⻅性。
面试官:聊聊happens-before与JMM的关系?
候选人: happens-before是JMM最核心的概念。对应Java程序员来说,理解happens-before是理解JMM的关键。
JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM 可以通过happens-before 关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在 happens-before 关系,尽管a操作和b操作在不同的线程中执行,但 JMM 向程序员保证a操作将对 b操作可见)。
《JSR-133: Java Memory Model and Thread Specification》对happens-before 关系的定义如下。
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。
面试官:你知道happens-before有哪些应用场景吗?
候选人:happens-before 的应用场景主要是在多线程编程中,用于确保线程之间的操作顺序和可见性。以下是一些常见的应用场景:
-
线程同步:happens-before 可以用于保证线程之间的同步操作的正确性。例如,在使用 synchronized 或 Lock 机制进行线程同步时,happens-before规则可以确保一个线程的解锁操作 happens-before 后续线程的加锁操作,从而保证线程之间的同步性。
-
volatile 变量:happens-before 可以用于保证对 volatile 变量的写操作对后续线程的读操作可见。因为 volatile 变量具有可见性,所以对一个 volatile 变量的写操作 happens-before 后续线程对该变量的读操作,确保了变量的可见性。
-
线程间通信:happens-before 可以用于确保线程间通信的正确性。例如,使用 wait/notify 或 await/signal 机制进行线程间的等待和唤醒操作时,happens-before 可以确保等待线程在接收通知之前必须看到发送通知的线程对共享数据的修改。
-
线程安全性:happens-before 可以用于保证线程安全性。例如,在使用 synchronized 或 Lock 机制保护共享资源时,happens-before 可以确保一个线程的写操作 happens-before 后续线程的读操作,从而保证线程安全。
-
线程的启动和终止:happens-before 可以用于确保线程的启动操作 happens-before 后续线程的操作,以及线程的终止操作 happens-before 其他线程对该线程的操作。
面试官:谈谈你对ThreadLocal的理解
候选人:
通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的专属本地变量该如何解决呢? JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。
ThreadLocal 类主要解决的就是让每个线程绑定⾃⼰的值,可以将 ThreadLocal 类形象的⽐喻成存放数据的盒⼦,盒⼦中可以存储每个线程的私有数据。从而避免了线程间竞争的安全问题。
《Java并发编程的艺术》 4.3.6节
ThreadLocal,即线程变量,是一个以 ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个 ThreadLocal对象查询到绑定在这个线程上的一个值。可以通过 set(T)方法来设置一个值,在当前线程下再通过 get()方法获取到原先设置的值。
面试官:好的,那你知道ThreadLocal的底层原理实现吗?
候选人:
在ThreadLocal内部维护了一个 ThreadLocalMap 类型的成员变量,用来存储资源对象。
-
当调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中。
-
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值。
-
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值。
面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?
候选人:
因为ThreadLocalMap 中的 key 被设计为弱引用,而value是一个强引用。所以在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。这样⼀来, ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远⽆法被 GC 回收,这个时候就可能会产⽣内存泄露(OOM)。
ThreadLocalMap 实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null的记录。使⽤完 ThreadLocal ⽅法后 最好⼿动调⽤ remove() 释放key。
拓展:强引用与弱引用(《深入理解Java虚拟机:JVM高级特性与最佳实践》)
- 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“ Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。