JUC相关面试题

JUC相关面试题
玦尘你必须非常努力,才能看起来毫不费劲。
1 线程的基础知识
面试官:聊一下并行和并发有什么区别?(高频)
候选人:
并发是指在同一时间段内,系统有能力处理多个事件,多个线程轮流使用一个或多个CPU。换句话说,并发并不一定要求任务同时进行,而是通过快速切换任务来实现“看起来同时运行”的效果。
并行是指在同一时刻,多个任务可以在多个处理器核心上同时执行,4核CPU同时执行4个线程。(宏观并行,微观并发)
面试官:说一下线程和进程的区别?
候选人:
进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享进程的堆和 ⽅法区 资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
下⾯来思考这样⼀个问题:为什么程序计数器、虚拟机栈和本地⽅法栈是线程私有的呢?
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。如果执⾏的是 native ⽅法,那么程序计数器记录的是 undefined 地址,只有执⾏的是 Java 代码时程序计数器记录的才是下⼀条指令的地址。所以,程序计数器私有主要是为了线程切换后能恢复到正确的执⾏位置。
- 虚拟机栈为虚拟机执⾏ Java⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地⽅法栈是线程私有的。
面试官:什么是线程上下⽂切换?(高频)
候选人:(源自《Java并发编程艺术》1.1节)
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间后片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是⼀次上下⽂切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。
面试官:如何减少上下⽂切换?
候选人:(源自《Java并发编程艺术》1.1.3节)
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
无锁并发编程。 多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
CAS 算法。 Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
使用最少线程。 避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
协程: 在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
面试官: 同步和异步的区别?(高频)
候选人:
首先说一下什么是同步,同步指的是任务按照顺序依次执行的方式。在这种模式下,调用者会阻塞等待任务完成并返回结果后,才会继续执行后续的操作。
然后再说一下什么是异步,异步指的是任务无需等待立即返回,调用方可以继续执行其他操作,而任务的结果会在稍后通过如回调函数、事件通知或 Future 对象等机制传递给调用方。
最后说一下同步和异步的区别:
第一是执行方式不同,同步是阻塞式的,调用方需要等待任务完成才能继续;而异步是非阻塞式的,调用方无需等待任务完成即可继续执行。
第二是响应机制不同,同步直接返回任务的结果,调用方可以直接使用;而异步通常通过回调函数、事件通知或 Future 对象等方式传递结果。
第三是适用场景不同,同步适合简单、短时间的任务,或者需要立即获取结果的场景;而异步适合需要提高系统吞吐量的场景,或者用于耗时较长的任务,如:网络请求、文件读写等。
面试官:如果在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() 如果不唤醒就一直等下去。
第三:锁特性不同(《Java程序员面试笔试宝典》第二版 4.8节)
由于 sleep 方法的主要作用是让线程休眠指定的一段时间,在时间到时自动恢复,不涉及线程间的通信,因此,调用 sleep 方法并不会释放锁。(相当于我放弃 cpu,你们也用不了)
而 wait 方法则不同,当调用 wait 方法后,线程会释放掉它所占用的锁,从而使线程所在对象中的其他 synchronized 数据可被别的线程使用。(相当于我放弃 cpu,但你们还可以用)
《Java程序员面试笔试宝典》第二版 4.8节
引申:sleep()方法与yield()方法的区别是什么?
sleep()方法与yield()方法的区别主要表现在以下几个方面:
1)sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会,而 yield()方法只会给相同优先级或更高优先级的线程以运行的机会。
2)线程执行sleep()方法后会转入超时等待状态,所以,执行sleep()方法的线程在指定的时间内肯定不会被执行,而 yield()方法只是使当前线程重新回到可执行状态,所以执行yield()方法的线程有可能在进入到可执行状态后马上又被执行。
3)sleep()方法声明抛出
InterruptedException
,而 yield()方法没有声明任何异常。4)sleep()方法比 yield()方法(跟操作系统相关)具有更好的可移植性。
面试官:好的,我现在举一个场景,你来分析一下怎么做,新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
候选人: 这道题不难,⼤部分⼈都是⽤ join() 或者 CountDownLatch 实现。话不多说上代码:
1 | public class ThreadSequence { |
面试官:在我们使用线程的过程中,有两个方法。线程的 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 原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
面试官:说一下悲观锁与乐观锁的区别?(高频)
候选人: 悲观锁和乐观锁是数据库管理系统中用于处理并发事务的不同方式。
悲观锁
:假设数据会被并发修改,所以访问数据时会加锁,其他线程必须等待锁释放。适用于高并发写操作较多的场景,以确保数据的一致性。
数据库级别的悲观锁(常用于 MySQL)
1
2# `FOR UPDATE` 会在查询行上加排他锁,阻止其他事务修改。
SELECT * FROM table_name WHERE id = ? FOR UPDATE;Java 代码实现(基于
synchronized
或ReentrantLock
)1
2
3
4
5
6
7
8
9
10
11class PessimisticLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void update() {
lock.lock(); // 加锁
try {
// 执行业务逻辑
} finally {
lock.unlock(); // 释放锁
}
}
}
乐观锁
: 假设数据不会被冲突修改,先尝试更新,如果发现冲突(版本号不匹配),则重试或失败。适用于读多写少的场景,比如商品库存扣减、用户账户余额更新等。乐观锁通常通过版本号或时间戳来检测数据是否已被修改。
数据库级别的乐观锁(基于版本号)
1
2
3UPDATE table_name
SET value = ?, version = version + 1
WHERE id = ? AND version = ?;Java 代码实现(使用
AtomicInteger
或CAS
机制)1
2
3
4
5
6
7
8
9
10
11
12class OptimisticLockExample {
private AtomicInteger version = new AtomicInteger(0);
public void update() {
int oldVersion = version.get();
// 业务逻辑
if (!version.compareAndSet(oldVersion, oldVersion + 1)) {
System.out.println("更新失败,版本号冲突,重试...");
update(); // 递归重试
}
}
}compareAndSet(oldValue, newValue)
采用 CAS(Compare-And-Swap)操作,确保版本号未被其他线程修改,否则失败并重试。
简单来说,悲观锁适合于数据冲突频繁的场景,它通过加锁来防止冲突;而乐观锁更适合于读多写少的场景,通过在提交时检查冲突来减少锁的竞争。
面试官:讲一下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.修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得 当前对象实例的锁
1 | synchronized void method() { |
**2.修饰静态⽅法: ** 也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得 当前class的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。所以,如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
1 | synchronized void staic method() { |
3.修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized( .class) 表示进⼊同步代码前要获得 当前 class 的锁
1 | synchronized(this) { |
总结:
synchronized
关键字加到static
静态⽅法和synchronized(class)
代码块上都是是给Class
类上锁。synchronized
关键字加到实例⽅法上是给对象实例上锁。尽量不要使⽤
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能!
面试官:单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁⽅式实现单例模式的原理呗!
候选人:双重校验锁实现对象单例(线程安全) 这个必须要会!!!
1 | public class Singleton { |
另外,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 ⾮常轻松容易地构造出符合我们⾃⼰需求的同步器。
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 我已经到达了屏障,然后当前线程被阻塞。
1 | // CountDownLatch的用法 |
1 | // CyclicBarrier的用法 |
面试官: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和ReentrantLock的区别(高频)
候选人:
- Synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用在代码块上。
- Synchronized 会自动加锁和释放锁;ReentrantLock需手动加锁和释放锁。
- Synchronized 属于非公平锁;ReentrantLock 既可以是公平锁也可以是非公平锁。
- Synchronized 是JVM通过 monitor 实现的;ReentrantLock是通过CAS+AQS队列实现的。
面试官:请谈谈你对 volatile 的理解
候选人:volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能:
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
《Java并发编程的艺术》 3.2节
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
3 线程池
面试官: 为什么要⽤线程池?
候选人:(源自《Java 并发编程的艺术》 9.1节)
在开发过程中,合理地使用线程池能够带来3个好处:
降低资源消耗。通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
提⾼响应速度。当任务到达时,任务可以不需要的等到线程创建就能⽴即执⾏。
提⾼线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。
面试官:线程池四种创建方式
候选人:
在jdk中默认提供了4种方式创建线程池。
第一个是:newCachedThreadPool 创建可以缓存的线程池,有任务提交到线程池时如果有空闲的线程可用则立即使用空闲线程执行任务,如果没有空闲的线程可用就会创建一个新的线程执行任务,当空闲线程闲置一段时间(默认是60秒)之后还未被使用,那么就会进行销毁操作。
1 | import java.util.concurrent.ExecutorService; |
输出结果:
第二个是:newFixedThreadPool 创建一个定长线程池。 该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
1 | import java.util.concurrent.ExecutorService; |
输出结果:
第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
1 | import java.util.concurrent.Executors; |
输出结果:
第四个是:newSingleThreadExecutor 创建一个单线程的线程池,它只会使用一个线程执行任务,可以保证任务的执行顺序。
1 | import java.util.concurrent.ExecutorService; |
输出结果:
面试官:ThreadPoolExecutor的核心参数有哪些?(高频)
候选人:ThreadPoolExecutor
类中提供的四个构造⽅法。我们来看最⻓的那个,其余三个都是在这个构造⽅法的基础上产⽣(其他⼏个构造⽅法说⽩点都是给定某些默认参数的构造⽅法⽐如默认制定拒绝策略是什么)
1 | /** |
在线程池中一共有7个核心参数:
- corePoolSize (线程池的基本大小) - 核⼼线程数定义了最⼩可以同时运⾏的线程数量
- maximumPoolSize (线程池最大数量) - 线程池允许创建的最大线程数。最大线程数目=核心线程+救急线程的最大数目
- keepAliveTime (线程活动保持时间) - 线程池的工作线程空闲后,保持存活的时间
- timeUnit (线程活动保持时间的单位) - 如秒、毫秒等
- runnableTaskQueue (任务队列) - 用于保存等待执行的任务的阻塞队列
在多线程环境下,两个线程是有可能拿到同一个任务的,如果任务是存储在 线程安全的队列(如
BlockingQueue
)中,并且线程通过poll()
或take()
方式消费任务,那么 同一个任务不会被多个线程同时拿到,因为poll()
或take()
操作是原子的。示例:使用LinkedBlockingQueue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 public class TaskQueueExample {
public static void main(String[] args) {
BlockingQueue<String> taskQueue = new LinkedBlockingQueue<>();
taskQueue.add("Task1");
taskQueue.add("Task2");
ExecutorService executor = Executors.newFixedThreadPool(2);
Runnable worker = () -> {
try {
String task = taskQueue.take(); // 保证同一个任务不会被两个线程拿到
System.out.println(Thread.currentThread().getName() + " processing " + task);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
executor.execute(worker);
executor.execute(worker);
executor.shutdown();
}
}《Java 并发编程的艺术》 9.2.1节 详情见本模块最后一个issue…
可以选择以下几个阻塞队列:
- ArrayBlockingQueue:一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
- LinkedBlockingQueue:一个基于链表结构的无界阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。
threadFactory (线程工厂) - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
handler (拒绝策略) - 当队列和线程池都满时,会触发拒绝策略(默认策略是AbortPolicy,表明无法处理新任务时抛出异常)
《Java 并发编程的艺术》9.2.1节
在jdk1.5中Java线程池框架提供了以下4种拒绝策略。
(高频)
- AbortPolicy: 当线程池无法接受新任务时,直接抛出异常。
- CallerRunsPolicy: 它会将被拒绝的任务回退给提交任务的线程执行。
- DiscardPolicy: 它会直接丢弃无法处理的任务,并且不会抛出任何异常。
- DiscardOldestPolicy: 它会丢弃任务队列中最旧的任务(即等待时间最长的任务),然后尝试重新提交当前任务。
面试官:线程池的核心参数怎么设置?
候选人:
如果任务是
CPU 密集型(如计算任务)
,建议设置为 CPU 核心数 或 CPU 核心数 + 1,以充分利用 CPU 资源。如果任务是
IO 密集型(如网络请求、数据库操作)
,可以设置为 CPU 核心数 * 2 或更高,因为线程会频繁阻塞,增加线程数可以提高并发能力。
为什么是 CPU 核心数 * 2?
一个常见的假设是,IO 密集型任务中线程大约 50% 的时间在执行计算,50% 的时间在等待 IO。如果有 N 个 CPU 核心,为了让 CPU 始终有任务可执行,理论上需要
N * (1 + 等待时间/执行时间)
个线程。对于 50% 等待的情况,计算为N * (1 + 1) = N * 2
。
面试官:线程池的执行原理知道吗?
候选人:当提交一个新任务到线程池时,线程池的处理流程如下。
1)线程池判断核心线程池里的线程是否都在执行任务。 如果不是,则创建一个新的工作线程(核心线程)来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
2)线程池判断工作队列是否已经满。 如果工作队列没有满,则将新提交的任务存储在这个任务队列里。如果任务队列满了,则进入下个流程。
3)线程池判断线程池的线程是否都处于工作状态。 如果没有,则创建一个新的救急线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
4)如果核心线程或救急线程完成任务,会检查任务队列中是否有需要执行的任务,如果有就核心线程或救急线程会执行任务。
追问: 线程池的coreSize是16,maxSize是20,队列大小是10,现在已经有16个任务在运行,这时第17个任务来了之后会怎么做?那如果是第31个任务来了后发生什么?
当第17个任务到来时,由于核心线程已满(1-16)且队列未满(17-26),任务会被放入队列等待执行。
当第31个任务到来时,核心线程已满(1-16),队列也已满(17-26),由于最大线程数为20,线程池会创建额外的线程来执行任务,直到线程数达到20(27-30)。当第31个任务到来时,任务会根据拒绝策略处理,默认情况下会抛出 RejectedExecutionException。
追问: 临时线程(救急线程)销毁过程?销毁的一定是后来创建的临时线程吗?
- 线程池会在线程空闲时间超过 keepAliveTime 时销毁临时线程(非核心线程)。
- 默认核心线程不会销毁,除非调用
allowCoreThreadTimeOut(true)
。- 一定是临时线程销毁,而不是核心线程。
面试官:为什么不建议使用Executors创建线程池呢?
候选人:
好的,其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了。
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
面试官: 执⾏execute()⽅法和submit()⽅法的区别是什么呢?
候选人:
- execute() ⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;
1 | threadsPool.execute(new Runnable() { |
- submit() ⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值, get() ⽅法会阻塞当前线程直到任务完成,⽽使⽤ get(long timeout ,TimeUnitunit)⽅法则会阻塞当前线程⼀段时间后⽴即返回,这时候有可能任务没有执⾏完。
1 | Future<Object> future = executor.submit(harReturnValuetask); |
面试官:Java 里的阻塞队列都有哪些?能简单说说吗?
候选人:(源自《Java并发编程的艺术》 6.3.2节)
JDK7提供了7个阻塞队列,如下。
- ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue:一个由链表结构组成的无界阻塞队列。
- PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousQueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
下面重点介绍3类阻塞队列,其他的队列还请大家自行查阅书籍网站(没有先后次序之分)。
ArrayBlockingQueue
ArrayBlockingQueue是一个用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。
LinkedBlockingQueue
LinkedBlockingQueue 是一个用链表实现的无界阻塞队列。此队列的默认和最大长度为
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
关键字同样可以用来保证有序性。
5 其他
面试官:介绍下Fork/Join框架(工作窃取算法)
候选人:(源自《Java并发编程艺术》6.4.1节)
Fork/Join 框架是 Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
我们再通过 Fork 和 Join 这两个单词来理解一下 Fork/Join 框架。Fork 就是把一个大任务切分为若干子任务并行的执行,Join 就是合并这些子任务的执行结果,最后得到这个大任务的结果。比如计算 1+2+…+10000,可以分割成 10个子任务,每个子任务分别对 1000个数进行求和,最终汇总这10个子任务的结果。Fork /Join的运行流程图如图4-5所示。(图源《Java程序员面试笔试宝典》第二版 4.18节)
这种模型内部使用了线程池来执行各个子任务,它的工作原理为:线程池中的每个线程都有自己的工作队列,当自己队列中的任务都完成以后,会从其他线程的工作队列中“偷”一个任务执行,这样可以充分利用资源,这种思想被称为工作窃取算法(work-stealing)。执行流程如图 4-6 所示。(本段文字图片皆源自《Java程序员面试笔试宝典》第二版 4.18节)
为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
扩展:工作窃取算法的优缺点?
工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。
工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时,并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列。
面试官:讲一下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()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
面试官:跨线程传递ThreadLocal的问题
候选人:
在Java中,ThreadLocal
提供了线程本地变量,通常用于存储线程独有的数据,避免线程间的资源竞争。然而,ThreadLocal
的数据只能在当前线程中访问,跨线程时数据无法直接传递。
解决方案:
- 使用
InheritableThreadLocal
InheritableThreadLocal
允许父线程的数据传递到子线程。它在创建子线程时会复制父线程的值。- 适用于简单的父子线程场景。
- 注意:数据是引用传递,如果子线程修改了数据,父线程的数据会受到影响。
- 使用
TransmittableThreadLocal
(TTL)- 针对线程池等复杂场景,可以使用阿里巴巴的TTL组件。
TransmittableThreadLocal
在任务提交到线程池前会备份当前线程的ThreadLocal
数据,并在任务执行时恢复。- 常用于分布式追踪、日志上下文等场景。