您好!欢迎访问 www.kaoacca.cn | 常见问题 | 延伸阅读

多线程面试题

全面解析|高频考点|实战案例|助力技术面试通关

网站简介

本页面专为准备技术面试的开发者打造,聚焦多线程这一核心并发编程领域,系统梳理常见考点、原理细节与实战陷阱。内容覆盖Java、C++、Python等主流语言的并发模型,结合真实企业面试题与代码实践,帮助您构建完整的并发知识体系。

我们坚持“问题驱动+原理深挖+避坑指南”的编写逻辑,所有内容均基于一线开发者反馈与面试高频趋势整理而成,力求实现信息增益最大化。无论您是初学者还是资深工程师,都能在这里找到所需知识点,实现从“知道怎么做”到“理解为什么”的跃升。

页面采用响应式设计,适配桌面端与移动端;所有导航均为锚点跳转,无实际二级页面跳转,确保加载速度与用户体验。备案号:蜀ICP备2026017620号-5,©2026考ACCA网 版权所有。

核心价值

  • 深度覆盖:从线程生命周期到JMM模型,从synchronized原理到线程池调优,覆盖99%面试考点
  • 实战导向:每类知识点配套典型代码示例,包含常见错误与修复方案
  • 结构化呈现:采用选项卡、时间轴、FAQ等多形式组织内容,便于快速定位与复习
  • 持续更新:根据JDK演进(如Java 21虚拟线程)、并发库变化动态补充新内容

温馨提示:并发编程是工程能力的“分水岭”——掌握者能写出高吞吐、低延迟的系统,而忽视者易陷入死锁、数据不一致等隐性Bug。建议结合本文内容,动手实现至少3个线程协作模型(如生产者-消费者、线程安全单例),效果更佳。

一、多线程基础概念

什么是线程?线程与进程的区别?

线程是操作系统调度的最小单位,是进程内的实际执行单元。一个进程可包含多个线程,它们共享进程的内存空间与系统资源(如文件句柄、信号量),但各自拥有独立的栈空间与程序计数器。

对比维度 进程(Process) 线程(Thread)
资源分配 是(独立内存空间、文件描述符等) 否(共享所属进程资源)
调度单位 否 是(CPU调度以线程为最小单元)
创建/切换开销 高(需分配/回收内存空间) 低(仅需保存/恢复寄存器与栈)
通信方式 管道、消息队列、共享内存等 直接读写共享内存(需同步)
独立性 高(崩溃不影响其他进程) 低(一个线程崩溃可能导致整个进程退出)

例如,在Java中启动一个新进程需调用Runtime.exec(),而创建线程只需实例化Thread对象。若进程内所有线程结束,进程即终止。这也是为何多线程程序更高效——避免了进程间通信的复杂性与高开销。

⚠️ 注意:线程并非越多越好!过多线程会导致频繁上下文切换(Context Switch),反而降低性能。合理做法是根据任务类型(CPU/IO密集)设置线程数,通常不超过CPU核心数的2倍。

并发与并行的区别

二者常被混淆,但本质不同:

  • 并发(Concurrency):多个任务在同一时间段内交替执行,宏观上看似同时,微观上是分时复用CPU资源。例如:单核CPU上同时运行微信与浏览器。
  • 并行(Parallelism):多个任务在同一时刻同时执行,需多核CPU支持。例如:4核CPU上同时运行4个计算任务。

形象比喻:并发像一个人同时点外卖、回邮件、写报告——他切换任务;并行像三个人分工协作——各自专注一摊。多线程是实现并发的手段之一,但并发不必然需要多线程(如协程)。

线程的生命周期

Java线程状态由Thread.State枚举定义,共6种:

NEW

新建状态

线程对象已创建,但未调用start()。此时线程未与操作系统线程关联。

RUNNABLE

运行状态

调用start()后进入此状态,包含“就绪”(等待CPU时间片)与“运行中”(正在执行)。注意:JVM将两者统一为RUNNABLE。

BLOCKED

阻塞状态

线程等待进入synchronized代码块/方法时,因锁被占用而阻塞。锁释放后自动恢复。

WAITING

无限等待

调用Object.wait()、Thread.join()或LockSupport.park()后进入,需其他线程显式唤醒(notify/notifyAll/unpark)。

TIMED_WAITING

限期等待

调用sleep()、wait(timeout)、join(timeout)等带超时参数的方法后进入,超时自动恢复。

TERMINATED

终止状态

run()方法执行完毕或因未捕获异常退出,线程生命周期结束。

典型面试陷阱:sleep()与wait()都使线程暂停,但sleep()不释放锁(仅释放CPU),而wait()会释放对象锁。因此sleep()常用于“暂停+重试”,wait()用于线程间协作。

二、Java多线程实现方式

方式1:继承Thread类
方式2:实现Runnable接口
方式3:实现Callable接口

继承Thread类

通过继承Thread类并重写run()方法,直接启动线程。代码简洁,但受限于单继承。

// 示例:打印线程名
public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("当前线程:" + Thread.currentThread().getName());
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start(); // 启动线程,自动调用run()
    }
}

注意:必须用start()启动线程,直接调用run()只是普通方法调用,不会启动新线程!

实现Runnable接口

推荐方式!避免单继承局限,可将任务与线程分离,便于线程池管理。

// 示例:多线程下载文件
public class DownloadTask implements Runnable {
    private final String url;
    public DownloadTask(String url) { this.url = url; }
    @Override
    public void run() {
        System.out.println("开始下载:" + url);
        // 实际下载逻辑...
    }
}
public class Main {
    public static void main(String[] args) {
        new Thread(new DownloadTask("https://example.com/file1.zip")).start();
        new Thread(new DownloadTask("https://example.com/file2.zip")).start();
    }
}

此方式更符合“组合优于继承”原则,且能复用任务对象(如在线程池中提交同一Runnable实例)。

实现Callable接口

支持返回值与异常处理,常配合FutureTask或线程池使用。

// 示例:计算斐波那契数列
import java.util.concurrent.*;
public class FibonacciTask implements Callable {
    private final int n;
    public FibonacciTask(int n) { this.n = n; }
    @Override
    public Long call() {
        if (n <= 1) return n;
        long a = 0, b = 1;
        for (int i = 2; i <= n; i++) {
            long temp = a + b;
            a = b;
            b = temp;
        }
        return b;
    }
}
public class Main {
    public static void main(String[] args) throws Exception {
        FutureTask<Long> task = new FutureTask<>(new FibonacciTask(40));
        new Thread(task).start();
        System.out.println("结果:" + task.get()); // 阻塞等待结果
    }
}

优势:可获取线程执行结果与异常,但需注意get()会阻塞当前线程,建议配合get(timeout, unit)避免无限等待。

线程池:推荐生产环境使用方式

直接创建线程存在三大隐患:
① 频繁创建/销毁线程开销大;
② 缺乏统一管理,可能无限创建导致OOM;
③ 不支持高级功能(如定时、周期执行)。
线程池通过复用线程、控制并发数、提供队列缓冲,完美解决上述问题。

// 示例:创建固定大小线程池处理任务
import java.util.concurrent.*;
public class ThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService pool = new FixedThreadPool(4); // 核心线程数=4
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            pool.execute(() -> {
                System.out.println("处理任务:" + taskId + ",线程:" + Thread.currentThread().getName());
            });
        }
        pool.shutdown(); // 平滑关闭,等待任务完成
    }
}

更推荐使用ThreadPoolExecutor自定义参数(见FAQ部分),而非Executors工厂的简化版本,以避免潜在风险。

三、同步机制与线程安全

synchronized关键字详解

底层通过对象监视器(monitor)实现,每个对象有唯一monitor。进入synchronized块前需获取monitor,退出时释放。JDK6后引入三大优化:

偏向锁

无竞争优化

首次获取锁时,将线程ID记录在对象头,后续同一线程进入无需CAS操作。适用于单线程重复加锁场景。

轻量级锁

轻度竞争优化

通过CAS将对象头指向栈帧中的锁记录,避免操作系统互斥量开销。仅适用于锁持有时间短的场景。

自旋锁

中度竞争优化

锁竞争时,线程不阻塞而是循环等待(自旋),避免线程挂起/恢复开销。默认自旋10次,可配置。

面试陷阱:synchronized修饰静态方法时,锁对象是Class对象(如MyClass.class),而非实例对象!因此不同实例的静态同步方法仍会互斥。

volatile关键字的作用与局限

volatile提供可见性与禁止指令重排序,但不保证原子性。典型应用:

  • 状态标志:如volatile boolean running = true;用于线程控制
  • 双重检查锁单例:避免指令重排序导致的半初始化对象
// 示例:线程安全的单例(DCL)
public class Singleton {
    private static volatile Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton(); // 避免重排序
                }
            }
        }
        return instance;
    }
}

为何要加volatile?因为new Singleton()分三步:
① 分配内存;② 初始化对象;③ 将引用指向内存地址。
若无volatile,JIT可能重排序为①→③→②,导致其他线程看到未初始化的对象。

线程安全的三大核心原则

原则 实现方式 适用场景
无状态设计 不存储共享数据(如Spring Bean默认单例无状态) 工具类、服务层(推荐!)
不可变对象 用final修饰字段,无setter方法(如String、Long) 配置对象、DTO
显式同步 synchronized、ReentrantLock、Atomic类 共享可变状态

最佳实践:优先用不可变对象与无状态设计,其次考虑线程局部存储(ThreadLocal),最后才是显式锁。避免“锁滥用”,因锁竞争会成为性能瓶颈。

常见线程安全容器对比

容器 线程安全? 并发性能 典型场景
ArrayList ❌(需Collections.synchronizedList) 低 单线程环境
CopyOnWriteArrayList ✅ 高(读无锁) 读多写少(如监听器列表)
HashMap ❌ 低 单线程环境
ConcurrentHashMap ✅ 极高(JDK8 CAS+synchronized) 高并发读写(推荐!)
Vector ✅ 低(全方法同步) 已淘汰,勿用

陷阱提示:ConcurrentHashMap的迭代器是弱一致性(fail-safe),遍历时可能不反映最新修改,但不会抛ConcurrentModificationException。

四、线程池深度解析

ThreadPoolExecutor核心参数

构造方法:
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

参数 说明 典型值
corePoolSize 核心线程数(常驻线程数) CPU核心数(CPU密集型)
2×CPU核心数(IO密集型)
maximumPoolSize 最大线程数 核心数 + 队列容量
workQueue 任务队列 LinkedBlockingQueue(无界)
ArrayBlockingQueue(有界)
handler 拒绝策略 AbortPolicy(抛异常)
CallerRunsPolicy(回压)

工作流程:
1. 线程数 < corePoolSize → 创建新线程处理任务
2. 线程数 ≥ corePoolSize & 队列未满 → 入队等待
3. 队列满 & 线程数 < maximumPoolSize → 创建新线程
4. 线程数 = maximumPoolSize & 队列满 → 触发拒绝策略

拒绝策略详解

策略 行为 适用场景
AbortPolicy 抛出RejectedExecutionException 监控告警后人工介入
CallerRunsPolicy 由调用线程(提交任务的线程)执行任务 限流保护(提交速度下降)
DiscardPolicy 直接丢弃任务,不抛异常 允许任务丢失的场景
DiscardOldestPolicy 丢弃队列中最旧任务,尝试提交新任务 实时系统(如日志处理)

生产建议:避免使用默认的AbortPolicy,应结合业务设计降级方案(如缓存兜底、异步补偿)。

线程池最佳实践

  • 避免使用Executors工厂:其创建的LinkedBlockingQueue无界,可能导致OOM
  • 显式命名线程:通过ThreadFactory设置有意义的名称(如"order-pool-1"),便于问题排查
  • 合理设置超时:使用get(timeout, unit)避免无限等待
  • 优雅关闭:先shutdown(),再awaitTermination()等待任务完成
// 示例:自定义线程池
import java.util.concurrent.*;
public class CustomThreadPool {
    public static final ExecutorService ORDER_POOL = new ThreadPoolExecutor(
        8, // corePoolSize
        16, // maximumPoolSize
        60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(1000), // 有界队列
        new ThreadFactory() {
            private int count = 0;
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "order-pool-" + (count++));
            }
        },
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
}

五、高频面试题精讲

Q1:什么是线程安全?如何判断一段代码是否线程安全?

答:线程安全指多线程访问时,代码始终表现出正确的行为(符合预期逻辑),无需额外同步措施。判断三要素:
① 是否共享可变状态;
② 是否被多个线程并发访问;
③ 是否缺乏同步保护。

实战案例:以下代码非线程安全:

public class Counter {
    private int count = 0;
    public void increment() {
        count++; // 非原子操作:读→改→写
    }
    public int getCount() { return count; }
}

修复方案:
① 加synchronized;
② 改用AtomicInteger;
③ 无状态设计(count作为局部变量)。

Q2:什么是死锁?如何排查与避免?

答:死锁指两个或多个线程因争夺资源而互相等待的现象,需满足四个条件:
① 互斥;② 请求与保持;③ 不剥夺;④ 循环等待。

排查方法:
① jstack -l <pid> 查看线程堆栈;
② jcmd <pid> Thread.print 导出死锁报告;
③ 使用JVisualVM图形化监控。

避免策略:
① 按固定顺序获取锁;
② 使用tryLock()设置超时;
③ 减少锁粒度;
④ 用并发工具类替代手动加锁。

Q3:ThreadLocal原理是什么?如何解决内存泄漏?

答:ThreadLocal为每个线程提供独立变量副本,内部维护ThreadLocalMap(Entry继承WeakReference),以ThreadLocal对象为key。

内存泄漏原因:ThreadLocal引用为弱引用,但Entry的value为强引用。当ThreadLocal被GC回收后,value无法访问却长期驻留内存。

解决方案:
① 每次使用后调用remove();
② 避免在线程池中复用ThreadLocal;
③ 自定义子类重写remove逻辑。

Q4:Java内存模型(JMM)的核心概念有哪些?

答:JMM定义线程与主内存的关系:
① 所有变量存储于主内存;
② 每条线程有独立工作内存(副本);
③ 线程对变量的操作在工作内存进行。

三大特性:
① 原子性:synchronized保证;
② 可见性:volatile、synchronized保证;
③ 有序性:volatile、synchronized通过内存屏障实现。

关键原则: happens-before原则是判断内存可见性的依据,如:
- 程序顺序原则;
- 监视器锁原则;
- volatile变量原则;
- 线程启动/加入原则。

Q5:volatile与synchronized的区别?

答:

特性 volatile synchronized
可见性 ✅ ✅
原子性 ❌ ✅
阻塞 ❌ ✅
锁对象 无 对象监视器
性能 轻量 较重(但JDK6后优化)

典型误区:volatile不能替代synchronized!例如i++需原子性,必须用锁或Atomic类。

斌知网
蜀ICP备2026017620号-5