Java 开发面试题(多线程模块)
问题 1:请你解释一下 Java 中 “进程” 与 “线程” 的核心区别,以及为什么我们开发中更倾向于使用多线程而非多进程?
考察点
- 对 “进程” 和 “线程” 本质概念的理解(是否明确两者在资源分配和任务调度中的核心角色)。
- 能否从 “资源开销”“通信效率”“调度成本” 三个关键维度对比差异,避免仅停留在 “进程是线程的容器” 这类表面描述。
- 结合开发场景说明多线程的优势,体现 “理论联系实际” 的思维。
参考答案
进程和线程是操作系统中任务执行的基本单位,核心区别体现在资源归属和调度粒度上,具体对比及多线程的优势如下:
1. 核心区别(3 个关键维度)
对比维度 | 进程(Process) | 线程(Thread) |
---|---|---|
资源分配单位 | 操作系统资源分配的最小单位(拥有独立的内存空间、CPU 寄存器、文件描述符等) | 进程内的资源共享单位(共享进程的内存空间、文件描述符,仅拥有独立的程序计数器 PC、栈空间) |
任务调度单位 | 操作系统调度的基本单位(调度粒度粗) | 操作系统调度的最小单位(调度粒度细) |
资源开销 | 高(创建 / 销毁需分配 / 释放全套资源,内存占用大) | 低(创建 / 销毁仅需操作独立栈和 PC,内存占用小) |
通信方式 | 复杂(需依赖进程间通信 IPC 机制,如管道、Socket、共享内存) | 简单(可直接访问进程共享内存,如通过 volatile、锁同步访问共享变量) |
独立性 | 高(一个进程崩溃不影响其他进程) | 低(一个线程崩溃可能导致整个进程崩溃,因共享进程资源) |
2. 开发中倾向用多线程的原因
- 资源开销低:多线程共享进程资源,创建 / 销毁速度比多进程快 10-100 倍,适合高频创建销毁任务(如 Web 服务处理请求)。
- 通信效率高:线程间可直接操作共享内存,无需额外 IPC 机制,减少数据拷贝开销(如电商系统中 “订单线程” 与 “库存线程” 共享商品库存变量)。
- 调度响应快:线程调度粒度细,操作系统可在同一进程内快速切换线程,减少 CPU 空闲时间(如 UI 程序中 “界面渲染线程” 与 “数据加载线程” 并行,避免界面卡顿)。
指导
- 回答时避免只罗列定义,需先明确 “进程是资源单位,线程是调度单位” 这一核心结论,再展开对比,逻辑更清晰。
- 易混淆点:“线程共享进程内存”≠“线程间无需同步”—— 正因为共享资源,才需要 volatile、synchronized 等机制保证线程安全,这点需在回答中隐含,体现对多线程安全的认知。
问题 2:Java 线程有哪几种状态?请详细说明每种状态的含义,以及状态之间的转换条件(举例说明)?
考察点
- 对 Java 线程状态模型(而非操作系统线程状态)的精准掌握,是否明确 JDK 定义的 6 种状态(避免混淆 “就绪”“运行” 等操作系统概念)。
- 能否清晰梳理状态转换的 “触发动作” 与 “转换路径”,例如 “WAITING 状态如何回到 RUNNABLE 状态”。
- 区分易混淆状态(如 BLOCKED vs WAITING),体现对线程阻塞原因的理解。
参考答案
根据 JDK 源码(java.lang.Thread.State
枚举),Java 线程分为6 种状态,状态转换需依赖特定 API 调用或系统事件,具体如下:
1. 6 种线程状态及含义
状态枚举 | 核心含义 | 典型场景 |
---|---|---|
NEW(新建) | 线程对象已创建(如new Thread() ),但未调用start() 方法,未与操作系统线程绑定 | Thread t = new Thread(); 后,未执行t.start(); |
RUNNABLE(可运行)runnable | 线程已调用start() ,与操作系统线程绑定,处于 “就绪” 或 “运行” 状态(Java 层面不区分两者) | 执行t.start() 后,线程等待 CPU 调度(就绪)或正在 CPU 上执行(运行) |
BLOCKED(阻塞) | 线程因等待 synchronized 锁而阻塞(获取锁前的临时状态) | 线程 A 进入synchronized 块,线程 B 也尝试进入该块,线程 B 会进入 BLOCKED 状态 |
WAITING(无限等待)waiting | 线程因调用无超时 API(如wait() 、join() ),需等待其他线程显式唤醒才会恢复 | 线程调用object.wait() (无超时),或thread.join() (无超时) |
TIMED_WAITING(计时等待)time_waiting | 线程因调用有超时 API(如sleep(long) 、wait(long) ),等待超时或被唤醒 | 线程调用Thread.sleep(1000) ,或object.wait(5000) |
TERMINATED(终止) | 线程执行完成(run() 方法结束)或因异常退出,生命周期结束 | 线程run() 方法执行完return ,或执行中抛未捕获异常 |
2. 核心状态转换路径(举例)
- NEW → RUNNABLE:调用线程对象的
start()
方法(如t.start()
),底层会调用操作系统create_thread
,绑定内核线程。 - RUNNABLE → BLOCKED:线程尝试进入
synchronized
块 / 方法,但锁已被其他线程持有(如线程 B 抢线程 A 的 synchronized 锁)。 - RUNNABLE → WAITING:线程在
synchronized
块内调用object.wait()
(无超时),会释放锁并进入 WAITING 状态。 - WAITING → RUNNABLE:其他线程调用同一
object
的notify()
/notifyAll()
,唤醒该线程,线程重新竞争锁,获锁后回到 RUNNABLE。 - RUNNABLE → TIMED_WAITING:线程调用
Thread.sleep(1000)
,无需释放锁,直接进入计时等待。 - TIMED_WAITING → RUNNABLE:
sleep(1000)
超时,或其他线程调用thread.interrupt()
(线程需处理 InterruptedException)。 - RUNNABLE → TERMINATED:线程
run()
方法正常执行完毕,或抛未捕获异常(如NullPointerException
)导致线程终止。
指导
- 易混淆点 1:
BLOCKED
仅因 “等 synchronized 锁”,而WAITING
/TIMED_WAITING
因 “等其他线程动作”(如唤醒、超时),需明确区分阻塞原因。 - 易踩坑点:调用
start()
后线程不会立即运行,而是进入 RUNNABLE 的 “就绪” 状态,等待 CPU 调度;直接调用run()
方法不会创建新线程,仅在当前线程执行run()
逻辑,状态始终是 RUNNABLE(非 NEW→RUNNABLE),需特别提醒。
问题 3:Java 中创建线程的方式有哪几种?请分别说明每种方式的实现步骤、优缺点,以及适用场景?
考察点
- 对 “创建线程” 的全面掌握,是否涵盖 “基础方式”(Thread、Runnable)与 “进阶方式”(Callable、线程池),避免遗漏 Callable。
- 能否深入分析每种方式的核心差异(如是否有返回值、是否能抛异常、是否支持线程复用),而非仅描述实现代码。
- 结合实际开发场景(如 “需要任务结果”“高并发请求处理”)说明适用场景,体现技术选型能力。
参考答案
Java 创建线程的核心方式有4 种,本质可分为 “手动创建线程” 和 “线程池管理线程” 两类,具体实现、优缺点及场景如下:
1. 方式 1:继承 Thread 类
- 实现步骤:
- 自定义类继承
Thread
,重写run()
方法(线程执行逻辑); - 创建自定义类实例,调用
start()
方法启动线程。
- 代码示例:
class MyThread extends Thread {@Overridepublic void run() {System.out.println("线程执行逻辑");} } // 启动 MyThread t = new MyThread(); t.start();
- 自定义类继承
- 优缺点:
- 优点:实现简单,直接调用
start()
即可启动。 - 缺点:1. Java 单继承限制,自定义类继承 Thread 后无法再继承其他类;2.
run()
无返回值,无法获取线程执行结果;3. 无异常抛出声明,无法向上传递 checked 异常。
- 优点:实现简单,直接调用
- 适用场景:简单的无返回值任务,且无需继承其他类(如简单的日志打印线程)。
2. 方式 2:实现 Runnable 接口
- 实现步骤:
- 自定义类实现
Runnable
接口,重写run()
方法; - 创建
Runnable
实例,作为参数传入Thread
构造器,调用Thread
的start()
。
- 代码示例:
class MyRunnable implements Runnable {@Overridepublic void run() {System.out.println("线程执行逻辑");} } // 启动 Thread t = new Thread(new MyRunnable()); t.start();
- 自定义类实现
- 优缺点:
- 优点:1. 规避单继承限制,自定义类可同时继承其他类;2. 可共享
Runnable
实例(如多个 Thread 传入同一 MyRunnable,共享实例变量)。 - 缺点:1.
run()
仍无返回值、无法抛 checked 异常;2. 启动需依赖 Thread 类,无独立启动方法。
- 优点:1. 规避单继承限制,自定义类可同时继承其他类;2. 可共享
- 适用场景:需共享资源的无返回值任务(如多线程卖票,共享 “剩余票数” 变量)。
3. 方式 3:实现 Callable 接口(结合 FutureTask)
- 实现步骤:
- 自定义类实现
Callable<V>
接口(V 为返回值类型),重写call()
方法(有返回值、可抛 checked 异常); - 创建
Callable
实例,传入FutureTask<V>
构造器(FutureTask 实现 RunnableFuture,可作为 Thread 参数); - 将
FutureTask
传入 Thread 构造器,调用start()
;通过futureTask.get()
获取call()
返回值。
- 代码示例:
class MyCallable implements Callable<Integer> {@Overridepublic Integer call() throws Exception {return 1 + 1; // 有返回值,可抛异常} } // 启动与获取结果 FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable()); Thread t = new Thread(futureTask); t.start(); Integer result = futureTask.get(); // 阻塞等待结果,可处理InterruptedException
- 自定义类实现
- 优缺点:
- 优点:1. 有返回值(通过
get()
获取);2.call()
可抛 checked 异常(调用get()
时需捕获);3. 规避单继承限制。 - 缺点:1. 实现较复杂;2.
get()
方法会阻塞当前线程,需注意超时控制(可用get(long timeout, TimeUnit unit)
)。
- 优点:1. 有返回值(通过
- 适用场景:需获取任务执行结果的场景(如多线程计算数据后汇总结果,如统计多个订单的总金额)。
4. 方式 4:使用线程池(如 ThreadPoolExecutor)
- 实现步骤:
- 通过
ThreadPoolExecutor
构造器创建线程池(指定核心参数:核心线程数、最大线程数等); - 调用线程池的
submit(Runnable)
或submit(Callable<V>)
提交任务; - 任务执行后,通过
Future
获取结果(若为 Callable);线程池使用完后调用shutdown()
关闭。
- 代码示例:
// 创建线程池(核心2,最大4,空闲线程存活10s,队列容量10) ExecutorService executor = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10) ); // 提交Callable任务,获取Future Future<Integer> future = executor.submit(new MyCallable()); Integer result = future.get(); // 关闭线程池 executor.shutdown();
- 通过
- 优缺点:
- 优点:1. 线程复用(避免频繁创建销毁线程,降低资源开销);2. 可统一管理线程(如控制并发数、任务排队、拒绝策略);3. 支持批量提交任务,简化线程管理。
- 缺点:1. 配置复杂(需合理设置核心参数,否则可能导致性能问题);2. 若未正确关闭,可能导致线程泄漏。
- 适用场景:高并发、多任务场景(如 Web 服务处理 HTTP 请求、批量处理数据库数据),是实际开发的首选方式(避免手动创建线程的资源浪费)。
指导
- 易混淆点:“前 3 种是手动创建线程,第 4 种是线程池管理线程”—— 实际开发中几乎不使用前 3 种手动方式,而是优先用线程池,需强调线程池的 “线程复用” 核心优势。
- 易踩坑点:
FutureTask.get()
会阻塞,若任务执行时间过长,可能导致当前线程卡死,建议使用带超时的get()
方法;线程池提交 Runnable 任务时,submit()
返回的 Futureget()
结果为 null,需注意区分。
问题 4:synchronized 关键字的作用是什么?它有几种用法?底层是如何实现的(JDK1.6 及以后的锁升级机制)?
考察点
- 对 synchronized 核心功能(原子性、可见性、有序性)的理解,是否明确其是 Java 中的 “内置锁”(隐式锁)。
- 能否区分 synchronized 的三种用法(锁对象、锁方法、锁类),以及每种用法对应的 “锁对象” 是什么。
- 对 JDK1.6 锁升级机制(偏向锁→轻量级锁→重量级锁)的掌握,体现对 synchronized 性能优化的理解(避免认为 synchronized 只是 “重量级锁”)。
参考答案
synchronized 是 Java 的内置同步锁(隐式锁,无需手动释放锁),核心作用是保证多线程访问共享资源时的原子性、可见性、有序性,避免线程安全问题(如 i++ 的原子性问题)。
1. synchronized 的 3 种用法(及锁对象)
synchronized 的锁对象必须是 “引用类型”(不能是基本类型,如 int),具体用法如下:
用法分类 | 代码示例 | 锁对象(关键) |
---|---|---|
1. 同步代码块(锁对象) | synchronized (lockObj) { // 共享资源操作(如 count++) } | 显式指定的lockObj (通常是this 或static final Object ,建议用独立对象避免锁竞争) |
2. 同步实例(非静态)方法 | public synchronized void add() { // 共享资源操作 } | 当前对象实例(this )—— 锁的是 “对象”,不同实例间无锁竞争 |
3. 同步静态方法 | public static synchronized void add() { // 共享资源操作 } | 当前类的 Class 对象(如MyClass.class )—— 锁的是 “类”,所有实例共享同一把锁 |
2. 底层实现(JDK1.6 锁升级机制)
JDK1.6 前,synchronized 是 “重量级锁”(依赖操作系统的互斥量 Mutex,切换线程需从用户态到内核态,开销大);JDK1.6 为优化性能,引入锁升级机制(从低开销到高开销,按需升级,不可降级),核心是通过 “对象头中的 Mark Word” 存储锁状态,具体升级路径如下:
(1)无锁状态
- 场景:对象未被任何线程锁定,Mark Word 存储对象哈希码、GC 分代年龄等信息。
(2)偏向锁(Biased Locking)
- 核心目标:减少无竞争场景下的锁开销(单线程反复获取释放同一把锁时,无需 CAS 操作)。
- 原理:
- 第一个线程获取锁时,通过 CAS 将 Mark Word 中的 “锁标志位” 设为 “偏向锁”,并记录当前线程 ID;
- 后续该线程再次获取锁时,无需 CAS,直接判断 Mark Word 中的线程 ID 是否为当前线程,即可快速获取锁;
- 释放锁时无需操作(仅当其他线程竞争时才会释放)。
- 触发升级:当有其他线程尝试获取该锁时,偏向锁会升级为轻量级锁。
(3)轻量级锁(Lightweight Locking)
- 核心目标:处理短时间的轻度竞争(线程交替执行,无长时间阻塞)。
- 原理:
- 线程获取锁时,先将 Mark Word 复制到线程栈的 “锁记录(Lock Record)” 中,然后通过 CAS 尝试将 Mark Word 中的 “锁标志位” 设为 “轻量级锁”,并指向当前线程的锁记录;
- CAS 成功则获取锁;CAS 失败(说明有其他线程竞争),先自旋(循环 CAS 尝试获取锁,避免立即升级为重量级锁);
- 释放锁时,通过 CAS 将栈中保存的 Mark Word 恢复到对象头,成功则释放。
- 触发升级:1. 自旋次数超过阈值(JDK1.6 默认 10 次,或自适应自旋);2. 有更多线程参与竞争(如 3 个线程抢锁),轻量级锁升级为重量级锁。
(4)重量级锁(Heavyweight Locking)
- 核心目标:处理长时间、高竞争场景(线程需阻塞等待锁)。
- 原理:
- 升级为重量级锁后,Mark Word 中的 “锁标志位” 设为 “重量级锁”,并指向操作系统的 “互斥量(Mutex)”;
- 未获取锁的线程会被阻塞(从用户态切换到内核态),放入锁的 “阻塞队列”;
- 持有锁的线程释放锁时,会唤醒阻塞队列中的线程,竞争锁的线程需重新进入内核态参与调度。
- 特点:开销大(内核态切换),但适合长时间竞争场景。
指导
- 易混淆点:synchronized 的 “锁对象” 决定了锁的范围 —— 同步实例方法锁
this
,同步静态方法锁Class
对象,同步代码块锁显式指定的对象,需明确 “锁的是谁”,避免多线程竞争时锁失效(如多个线程用不同锁对象同步同一资源)。 - 易踩坑点:JDK1.6 后的锁升级是 “单向的”(偏向→轻量→重量,不可降级),目的是 “按需优化性能”,而非所有场景下都是重量级锁,回答时需体现这一优化,避免让面试官觉得对 synchronized 的理解停留在旧版本。
问题 5:volatile 关键字的作用是什么?它能保证原子性吗?为什么?请举例说明。
考察点
- 对 volatile 核心功能(内存可见性、禁止指令重排序)的精准理解,是否明确其与 synchronized 的功能差异(volatile 无原子性保证)。
- 能否从 “Java 内存模型(JMM)” 的角度解释 volatile 的实现原理(如内存屏障),避免仅停留在 “表面作用”。
- 通过具体案例(如 i++ 问题)说明 volatile 无法保证原子性,体现对 “原子操作” 定义的理解(不可分割的操作)。
参考答案
volatile 是 Java 中用于修饰实例变量或静态变量的关键字,核心作用是保证多线程间的内存可见性和禁止指令重排序,但不能保证原子性,具体分析如下:
1. volatile 的两大核心作用
(1)保证内存可见性
- 问题背景:JMM 中,线程操作变量时会先将主内存的变量加载到线程私有工作内存,修改后再刷新回主内存;若变量无 volatile 修饰,线程 A 修改后可能未及时刷新主内存,线程 B 读取的仍是旧值(内存不可见)。
- volatile 的解决:volatile 修饰的变量,线程修改后会立即刷新到主内存,且其他线程读取时会直接从主内存加载(跳过工作内存缓存),确保多线程看到的是变量的最新值。
- 示例场景:
class VolatileDemo {private volatile boolean flag = false; // volatile修饰public void setFlag() {flag = true; // 线程A修改后立即刷主内存}public void doWork() {while (!flag) { // 线程B每次读取都从主内存获取最新值// 若flag无volatile,线程B可能一直读旧值,进入死循环}System.out.println("执行完成");} }
(2)禁止指令重排序
- 问题背景:编译器或 CPU 为优化性能,会对无依赖关系的指令重排序(如
int a=1; int b=2;
可重排为int b=2; int a=1;
);但在多线程场景下,重排序可能导致逻辑错误(如单例模式的双重检查锁问题)。 - volatile 的解决:volatile 修饰的变量会在其前后插入内存屏障(Memory Barrier),禁止编译器和 CPU 对 volatile 变量相关的指令进行重排序,确保指令执行顺序与代码顺序一致。
- 典型场景(单例模式双重检查锁):
public class Singleton {// volatile修饰,禁止instance实例化指令重排序private static volatile Singleton instance; private Singleton() {}public static Singleton getInstance() {if (instance == null) { // 第一次检查(无锁)synchronized (Singleton.class) { // 加锁if (instance == null) { // 第二次检查(有锁)// 若instance无volatile,new Singleton()可能重排序为:// 1. 分配内存 → 2. 赋值instance → 3. 初始化对象// 线程B可能拿到未初始化的instance(步骤2后步骤3前),调用时抛NPEinstance = new Singleton(); }}}return instance;} }
2. volatile 不能保证原子性的原因(举例说明)
(1)原子性的定义
原子性是指 “一个操作或多个操作,要么全部执行且执行过程不被中断,要么全部不执行”(不可分割)。
(2)volatile 无法保证原子性的核心原因
volatile 仅保证 “读写操作的可见性”,但复合操作(如 i++)并非原子操作——i++ 可拆分为 3 步:1. 读取 i 的当前值;2. 计算 i+1;3. 将结果写回 i。这 3 步间可能被其他线程插入操作,导致结果错误。
(3)示例验证(i++ 问题)
class VolatileAtomicDemo {private volatile int i = 0;// 线程不安全的自增方法(i++非原子)public void increment() {i++; // 拆分为:读i→算i+1→写i,3步非原子}public static void main(String[] args) throws InterruptedException {VolatileAtomicDemo demo = new VolatileAtomicDemo();// 10个线程,每个线程执行1000次incrementExecutorService executor = Executors.newFixedThreadPool(10);for (int j = 0; j < 10; j++) {executor.submit(() -> {for (int k = 0; k < 1000; k++) {demo.increment();}});}executor.shutdown();executor.awaitTermination(1, TimeUnit.MINUTES);System.out.println(demo.i); // 结果通常小于10000(如9876),证明无原子性}
}
- 结果分析:若 volatile 能保证原子性,i 最终应为 10000(10 线程 ×1000 次);但实际结果小于 10000,因多个线程在 “读→算→写” 过程中相互干扰(如线程 A 读 i=100,线程 B 也读 i=100,均算为 101,写回后 i=101,而非 102)。
(4)解决原子性的方案
- 用 synchronized 修饰 increment () 方法(保证方法内操作原子);
- 用
java.util.concurrent.atomic.AtomicInteger
(原子类,底层用 CAS 实现原子操作)。
指导
- 易混淆点:“volatile 有可见性,所以有原子性”—— 这是常见误区,需明确 “可见性≠原子性”,复合操作的原子性需额外通过锁或原子类保证。
- 易踩坑点:在单例模式双重检查锁中,若 instance 无 volatile 修饰,可能因指令重排序导致拿到未初始化的实例,这是面试高频考点,需熟练掌握其原理。