01 - Java基础与并发 · 高频面试考题背诵指南


目录


一、集合框架

Q1:ArrayList 和 LinkedList 的区别?分别适用什么场景?

核心考点:底层数据结构、时间复杂度、内存占用、迭代性能。

背诵话术

ArrayList 底层是动态数组,支持随机访问,通过下标读取元素的时间复杂度是 O(1);但在中间插入或删除元素时,需要移动后续所有元素,时间复杂度是 O(n)。默认初始容量是 10,每次扩容为原来的 1.5 倍。

LinkedList 底层是双向链表,在头尾插入或删除元素时时间复杂度是 O(1),但随机访问需要从头遍历,时间复杂度是 O(n),同时每个节点需要额外存储前后指针,内存占用更大。

使用场景:如果业务以查询为主(比如我们 SRM 系统中的报价列表展示),优先选 ArrayList;如果业务频繁在首尾进行插入删除(比如用作队列),可以考虑 LinkedList。实际上由于 CPU 缓存行对连续内存更友好,ArrayList 的迭代性能通常也优于 LinkedList,所以生产中 ArrayList 使用频率更高。


Q2:HashMap 底层数据结构是什么?JDK8 做了哪些优化?

核心考点:数组+链表→数组+链表+红黑树,扩容时尾插法,树化阈值。

背诵话术

HashMap 的底层数据结构在 JDK7 是数组 + 链表,在 JDK8 优化为数组 + 链表 + 红黑树

JDK8 的核心优化主要有两点:

  1. 链表转红黑树:当链表长度超过 8 且数组长度大于等于 64 时,链表会自动转换为红黑树,将查询复杂度从 O(n) 降低到 O(logN),解决了 hash 冲突严重时查询退化的问题。当红黑树节点减少到 6 时,会退化回链表。
  2. 扩容时改用尾插法:JDK7 扩容使用头插法,在多线程环境下会导致链表成环(死链),JDK8 改为尾插法并重新计算索引位置(利用 (e.hash & oldCap) == 0 判断),避免了这个问题。虽然 JDK8 的 HashMap 依然线程不安全,但至少不会出现死循环导致 CPU 100% 的问题。

Q3:HashMap 的扩容机制?为什么容量必须是 2 的幂次方?

核心考点:负载因子 0.75、容量翻倍、位运算取模。

背诵话术

HashMap 默认初始容量是 16,负载因子是 0.75。当元素个数超过 容量 × 0.75(即 12)时,触发扩容,新容量变为原来的 2 倍。扩容时需要对所有旧元素重新 rehash 分配位置,是一个较重的操作,所以在初始化时如果能预估元素数量,应该手动指定初始容量,避免频繁扩容。

容量必须是 2 的幂次方,原因在于 HashMap 通过 (n - 1) & hash 来计算数组下标。当 n 是 2 的幂次方时,n - 1 的二进制全是 1(如 16-1=15 即 1111),此时 & 运算等价于取模 % n,但位运算比取模运算快得多。同时,这也保证了 hash 值能均匀分布在各个桶中,减少碰撞。


Q4:HashMap 为何线程不安全?JDK7 的死链问题如何发生的?

核心考点:多线程同时 put 触发扩容,头插法导致链表环形结构。

背诵话术

HashMap 线程不安全主要体现在以下几个场景:

  1. 数据丢失:两个线程同时 put,如果 hash 冲突,可能互相覆盖对方的值。
  2. JDK7 的死链(CPU 100%):在 JDK7 中,扩容时使用头插法。当线程 A 和线程 B 同时触发扩容时,两个线程会各自维护节点的 next 指针,由于操作顺序交错,可能导致节点 A 的 next 指向节点 B,同时节点 B 的 next 又指向节点 A,形成环形链表。之后一旦有线程执行 get 操作遍历这个链表,就会陷入死循环,导致 CPU 飙到 100%。这是 JDK7 HashMap 最严重的 Bug。

多线程场景下,应该使用 ConcurrentHashMap 来代替 HashMap


Q5:ConcurrentHashMap 在 JDK7 和 JDK8 中的实现有何不同?

核心考点:JDK7 分段锁 Segment,JDK8 CAS + synchronized。

背诵话术

JDK7 的实现:采用**分段锁(Segment)**机制。整个 HashMap 被划分为若干个 Segment(默认 16 个),每个 Segment 内部是一个小 HashMap,每个 Segment 持有一把 ReentrantLock 锁。并发写操作时,只需锁住对应的 Segment,不同 Segment 可以并行操作,理论上支持 16 个线程同时写。

JDK8 的实现:放弃了 Segment,改为数组 + 链表 + 红黑树,并发控制使用CAS + synchronized

  • 当数组的目标槽位为空时,使用 CAS 无锁插入,完全不需要加锁。
  • 当槽位不为空(发生 hash 冲突)时,使用 synchronized 锁住链表/红黑树的头节点,只锁住当前桶的头节点而不是整个 Segment,锁的粒度更细,并发度提升显著。
  • JDK8 还引入了 sizeCtlCounterCell 数组来高效统计元素数量,避免了 size() 操作时的全局加锁。

Q6:HashSet 的底层原理?如何保证元素不重复?

核心考点:HashSet 内部持有一个 HashMap,以元素为 Key,固定 Object 为 Value。

背诵话术

HashSet 的底层实际上就是一个 HashMap。每次调用 add(element) 时,本质上是把 element 作为 Key,把一个固定的空 Object(PRESENT)作为 Value,调用 HashMap.put() 方法。

保证元素不重复的逻辑完全依赖 HashMap 的 Key 唯一性,而 Key 唯一性由 hashCode()equals() 共同保证:先比较 hashCode,相同再调 equals,两者都相同则认为是同一个 Key,新值覆盖旧值(在 HashSet 中就体现为添加失败不重复)。

这就是为什么要重写 equals() 时必须同时重写 hashCode():否则两个”相等”的对象 hashCode 不同,会被存储在不同的桶中,HashSet 就失去了去重能力。


二、并发基础

Q7:volatile 关键字的作用?能保证原子性吗?

核心考点:可见性、有序性(禁止指令重排),不能保证原子性。

背诵话术

volatile 主要有两个作用:

  1. 保证可见性:当一个线程修改了 volatile 变量的值,这个修改会立即被刷新回主内存,其他线程读取该变量时会强制从主内存重新加载,而不是使用 CPU 缓存中的旧值。这依赖于 CPU 的 MESI 缓存一致性协议和内存屏障(Memory Barrier)实现。
  2. 禁止指令重排(保证有序性):volatile 变量的写操作前会插入 StoreStore 屏障,写之后插入 StoreLoad 屏障;读操作前插入 LoadLoad 屏障,读之后插入 LoadStore 屏障。双重检查锁(DCL)单例模式中的 instance 变量必须加 volatile,就是为了禁止”分配内存→返回引用→初始化对象”三步的指令重排,防止其他线程拿到未初始化的对象引用。

volatile 不能保证原子性i++ 这个操作在底层是三步:读取 i → 计算 i+1 → 写回 i,volatile 只能保证每次读到的是最新值,但三步之间仍然可能被其他线程打断,所以 i++ 在多线程下依然不安全,需要用 AtomicIntegersynchronized


Q8:synchronized 的实现原理?对象锁和类锁的区别?

核心考点:Monitor 对象(monitorenter/monitorexit 字节码),对象头 Mark Word 存储锁状态。

背诵话术

synchronized 的底层依赖 JVM 的 Monitor(监视器锁) 机制。在字节码层面,对同步代码块加锁会生成 monitorentermonitorexit 两条指令;对同步方法则是在方法的访问标志上加 ACC_SYNCHRONIZED 标志。

每个 Java 对象都有一个关联的 Monitor,Monitor 中记录了锁的持有者线程、等待队列等信息。对象头中的 Mark Word(64位 JVM 下占 8 字节)用来存储锁状态,包括锁标志位(无锁/偏向/轻量级/重量级)、锁对象的 hashCode、GC 年龄等信息。

  • 对象锁(实例锁)synchronized(this)synchronized 修饰非静态方法,锁的是当前实例对象,不同实例的锁互不影响。
  • 类锁synchronized(ClassName.class)synchronized 修饰静态方法,锁的是 Class 对象,全局只有一个,所有实例共享这把锁。

Q9:锁升级的过程(无锁→偏向锁→轻量级锁→重量级锁)?

核心考点:锁升级的触发条件,各阶段的 Mark Word 存储内容,自旋的意义。

背诵话术

JDK6 对 synchronized 进行了大幅优化,引入了锁升级机制,从低到高分为四个级别:

  1. 无锁:对象刚创建时,Mark Word 记录 hashCode 和 GC 年龄,锁标志位为 01

  2. 偏向锁:当第一个线程访问同步代码时,JVM 会将 Mark Word 中记录该线程的 ID,之后这个线程再次进入同步块时无需任何同步操作,直接检查 Mark Word 中的线程 ID 是否是自己。适用于”只有一个线程访问同步块”的场景,开销极低。

  3. 轻量级锁:当有第二个线程来竞争锁时,偏向锁撤销,升级为轻量级锁。线程通过 CAS(Compare And Swap) 操作尝试将 Mark Word 替换为指向自己栈帧中 Lock Record 的指针。如果 CAS 成功则获得锁;失败则进行自旋等待(忙等循环)。适用于”竞争不激烈、持锁时间短”的场景。

  4. 重量级锁:当自旋超过一定次数(JDK8 默认自适应自旋)仍未获得锁,升级为重量级锁。此时线程会进入 OS 层面的阻塞(调用 pthread_mutex_lock),并被挂起,等待被唤醒。重量级锁会涉及用户态和内核态的切换,开销较大。

注意:锁只能升级不能降级(偏向锁特殊情况下可以撤销)。


Q10:synchronized 和 ReentrantLock 有什么区别?

核心考点:可中断性、公平锁、条件变量、tryLock 超时。

背诵话术

synchronized 是 JVM 层面的关键字,ReentrantLock 是 JDK 提供的 API。两者都是可重入锁,主要区别如下:

特性 synchronized ReentrantLock
实现层面 JVM 内置 JDK API(基于 AQS)
锁释放 自动释放(退出同步块) 必须手动 unlock()(需 finally)
公平性 非公平锁 支持公平锁(new ReentrantLock(true)
可中断 不支持 支持 lockInterruptibly()
超时获取 不支持 支持 tryLock(timeout)
条件变量 只有一个等待队列 支持多个 Condition(精确唤醒)

在我们的招行调度中台项目里,部分业务场景需要在等待锁时支持超时取消(防止长时间阻塞),因此使用了 ReentrantLock.tryLock() 配合超时时间,这是 synchronized 做不到的。


Q11:Java 内存模型(JMM)是什么?happens-before 原则有哪些?

核心考点:主内存与工作内存的抽象,happens-before 保证可见性的规则。

背诵话术

JMM(Java Memory Model) 定义了 JVM 如何与计算机内存进行交互的抽象规范。核心思想是:每个线程都有自己的工作内存(本地缓存),线程操作的变量都是从主内存拷贝到工作内存进行操作,完成后再写回主内存。这种设计导致了多线程之间的可见性问题

happens-before 是 JMM 定义的一组规则,如果操作 A happens-before 操作 B,则 A 的执行结果对 B 可见,且 A 的执行顺序在 B 之前。常见的规则有:

  1. 程序顺序规则:同一线程中,前面的操作 happens-before 后面的操作。
  2. 监视器锁规则unlock happens-before 后续的 lock
  3. volatile 规则:对 volatile 变量的写 happens-before 后续对该变量的读。
  4. 线程启动规则Thread.start() happens-before 线程内的所有操作。
  5. 线程终止规则:线程的所有操作 happens-before Thread.join() 的返回。
  6. 传递性:A happens-before B,B happens-before C,则 A happens-before C。

Q12:什么是 AQS?它的核心原理是什么?

核心考点AbstractQueuedSynchronizer,state 变量,CLH 双向等待队列,独占/共享模式。

背诵话术

AQS(AbstractQueuedSynchronizer) 是 JDK 并发包的核心框架,ReentrantLockCountDownLatchSemaphoreReadWriteLock 等都是基于 AQS 实现的。

核心原理

  1. state 变量:AQS 内部维护一个 volatile int state 变量来表示同步状态。以 ReentrantLock 为例,state=0 表示未锁,state>0 表示锁被持有(值代表重入次数)。子类通过重写 tryAcquire()tryRelease() 方法来定义具体的加锁和解锁逻辑,这些操作通过 CAS 来保证原子性。
  2. CLH 队列:当线程抢锁失败时,AQS 会将该线程封装为一个 Node 节点,加入一个 FIFO 的双向等待队列(CLH 队列变体),并将线程通过 LockSupport.park() 挂起。当锁被释放时,头节点的后继节点会被 LockSupport.unpark() 唤醒,重新尝试抢锁。
  3. 独占模式 vs 共享模式:独占模式同时只允许一个线程持有(如 ReentrantLock);共享模式允许多个线程同时持有(如 Semaphore、CountDownLatch)。

Q13:CountDownLatch、CyclicBarrier、Semaphore 的使用场景?

核心考点:三者的功能差异和适用场景。

背诵话术

  • CountDownLatch(倒计时门栓):主线程等待多个子线程都完成后再继续执行。计数器只能减不能增,减到 0 之后不能重置。在我们 SRM 采购比价系统中,主线程需要等待对多个供应商的并发 API 调用全部完成后,再进行汇总比价,这个场景就可以用 CountDownLatch(实际上我们用了 CompletableFuture.allOf(),原理类似)。

  • CyclicBarrier(循环屏障):多个线程互相等待,当所有线程都到达屏障点时才继续执行,且可以循环复用。适用于多线程分批处理数据,每批处理完后所有线程在屏障点汇集再继续下一批的场景。

  • Semaphore(信号量):控制同时访问某资源的线程数量,本质是一个计数器。比如数据库连接池最多允许 10 个线程同时访问,可以用 Semaphore(10) 来限制并发数。在我们医博系统中,如果导出任务过多,可以用 Semaphore 限制同时导出的任务数,防止内存撑爆。


Q14:死锁的四个必要条件?如何用 jstack 排查线上死锁?

核心考点:四个条件(互斥/请求保持/不可剥夺/循环等待),jstack 输出中 BLOCKED 状态线程和 deadlock 关键字。

背诵话术

死锁的四个必要条件

  1. 互斥条件:资源同一时刻只能被一个线程持有。
  2. 请求与保持条件:线程持有至少一个资源,同时在等待另一个被其他线程持有的资源。
  3. 不可剥夺条件:线程已获得的资源在未使用完之前不能被强行剥夺。
  4. 循环等待条件:若干线程之间形成头尾相接的循环等待链。

排查方式

  1. 执行 jstack <pid> 打印线程堆栈,输出末尾会有 Found one Java-level deadlock: 的提示,并列出死锁的线程和它们等待的锁。
  2. 用 Arthas 执行 thread -b 命令,可以直接找出当前阻塞其他线程的罪魁祸首线程。

预防措施:统一锁的获取顺序;使用 tryLock(timeout) 加超时控制;尽量减小锁的粒度和持锁时间。


Q15:线程的 6 种状态及转换

核心考点Thread.State 枚举,各状态的转换条件。

背诵话术

Java 线程有 6 种状态(定义在 Thread.State 枚举中):

  1. NEW:线程对象被创建但尚未调用 start()
  2. RUNNABLE:调用 start() 后进入此状态,包含”就绪(Ready)”和”运行中(Running)”两个子状态,由 CPU 调度器决定是否真正在执行。
  3. BLOCKED:线程等待获取一个被其他线程持有的 synchronized 锁时进入此状态。
  4. WAITING:调用 Object.wait()Thread.join()LockSupport.park() 等不带超时参数的方法后进入此状态,需要被其他线程显式唤醒(notify()/unpark())。
  5. TIMED_WAITING:调用带超时参数的 Thread.sleep(ms)Object.wait(ms)Thread.join(ms) 等方法,超时后自动返回 RUNNABLE。
  6. TERMINATED:线程执行完毕或抛出未捕获的异常,进入终止状态。

jstack 排查问题时,最需要关注的是大量线程处于 BLOCKED 状态(说明锁竞争激烈或有死锁)和 WAITING 状态(说明大量线程在等待某个条件)。


三、线程池与异步

Q16:ThreadPoolExecutor 的七大核心参数详解

核心考点:七个参数的含义、工作队列选型、拒绝策略。

背诵话术

ThreadPoolExecutor 的构造函数有七个核心参数:

  1. corePoolSize(核心线程数):线程池长期维持的最小线程数,即使空闲也不会销毁(除非设置 allowCoreThreadTimeOut=true)。
  2. maximumPoolSize(最大线程数):线程池最多能创建的线程数。当工作队列满了,且当前线程数小于 maxPoolSize 时,才会继续创建线程。
  3. keepAliveTime(空闲线程存活时间):超过 corePoolSize 的空闲线程,存活超过此时间后会被销毁。
  4. unit(时间单位):keepAliveTime 的单位,如 TimeUnit.SECONDS
  5. workQueue(工作队列):存储等待执行任务的阻塞队列。常用的有:LinkedBlockingQueue(无界队列,有 OOM 风险)、ArrayBlockingQueue(有界队列,推荐)、SynchronousQueue(不存储任务,直接移交)。
  6. threadFactory(线程工厂):用于创建线程,建议自定义工厂并为线程命名(如 "order-pool-%d"),方便在 jstack 中识别。
  7. handler(拒绝策略):当线程数达到 max 且队列已满时,对新任务的处理策略。

线程池任务提交的执行流程:核心线程数未满 → 创建核心线程执行 → 核心线程已满 → 任务加入队列 → 队列已满 → 创建非核心线程 → 非核心线程也满 → 执行拒绝策略。


Q17:四种拒绝策略的行为和适用场景

核心考点:四种策略的行为差异和业务适用性。

背诵话术

  1. AbortPolicy(默认):直接抛出 RejectedExecutionException 异常。适用于需要立即感知到任务被拒绝的场景,方便调用方做异常处理和告警。
  2. CallerRunsPolicy(调用者运行):由提交任务的线程(调用者线程)自己来执行这个任务,不会丢失任务。有反压效果:当线程池满时,让调用方”降速”,避免任务队列无限增长。在我们 SRM 采购报价引擎中,这是一个不错的选择,因为即使线程池满,任务也不会丢失。
  3. DiscardPolicy(静默丢弃):直接丢弃新任务,不抛异常。适用于允许丢弃的非关键性任务,如日志上报。
  4. DiscardOldestPolicy(丢弃最旧任务):丢弃队列中最旧(等待时间最长)的任务,让新任务入队。慎用,被丢弃的任务可能很重要。

Q18:CompletableFuture 异步编排在 SRM 项目中的实战应用

核心考点supplyAsyncallOfexceptionallythenCombine、线程池隔离。

背诵话术

在我们 SRM 采购比价中台里,每次生成询价单时,需要并发地向 5~10 家供应商的接口发起报价请求。如果串行调用,假设每家接口平均耗时 500ms,10 家串行就是 5 秒,完全不可接受。我们用 CompletableFuture 来实现并发编排:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 为每个供应商的报价请求创建异步任务,使用独立的 IO 线程池
List<CompletableFuture<QuoteResult>> futures = suppliers.stream()
.map(supplier -> CompletableFuture
.supplyAsync(() -> fetchQuote(supplier), ioThreadPool)
.exceptionally(ex -> {
log.warn("供应商 {} 报价失败,使用默认值", supplier.getId(), ex);
return QuoteResult.empty(supplier.getId()); // 降级返回空对象
}))
.collect(Collectors.toList());

// 等待所有任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

// 汇总所有报价结果
List<QuoteResult> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());

关键点:

  • 使用自定义隔离线程池 ioThreadPool 而非公共的 ForkJoinPool,防止报价任务占满公共资源。
  • .exceptionally() 对单个供应商失败进行兜底,返回默认空报价,保证整体流程不中断。
  • 10 家供应商的总耗时从串行 5s 缩短为并发约 500ms(最慢那家的耗时)。

Q19:ThreadLocal 内存泄漏原因及如何避免

核心考点:弱引用 Key + 强引用 Value 导致 Value 无法被 GC,线程池场景下线程复用加剧泄漏。

背诵话术

ThreadLocal 的底层是每个 Thread 对象内部持有一个 ThreadLocalMapThreadLocalMap 中的 Entry 结构是 {Key: ThreadLocal的弱引用, Value: 实际存储值的强引用}

内存泄漏的原因:当我们把 ThreadLocal 变量置为 null 时,下次 GC 时 Key(弱引用) 会被回收变为 null,但 Value 由于是强引用,只要 Thread 对象本身不被销毁,ThreadLocalMap → Entry → Value 这条引用链就永远存在,Value 永远无法被 GC 回收,导致内存泄漏。

线程池场景下,线程会被复用,线程永远不会销毁,所以泄漏会随着时间不断累积,最终可能导致 OOM。

解决方案

  1. finally 块中强制调用 remove(),这是最根本的解决方式。
  2. 可以配合代码规范检查(如 Checkstyle)强制要求使用 ThreadLocal 的地方必须有对应的 remove()

在我们招行调度中台中,每个请求开始时通过 ThreadLocal 存储当前登录用户的上下文信息,请求处理完毕后在 AOP 的后置拦截器中统一执行 remove(),确保不会产生泄漏。


Q20:并发线程池的实际参数配置经验与监控实战?

核心考点:CPU密集型与IO密集型的线程数设置依据,有界队列防OOM,CallerRunsPolicy,动态监控。

背诵话术

线程池的参数配置没有万能公式,需要结合具体业务类型来决定:

  • CPU 密集型(如复杂计算、数据加密、音视频编解码)corePoolSize = CPU 核数 + 1。多出来的一个线程用于防备线程偶发性缺页中断或其他原因导致的停顿。核心在于减少线程上下文切换开销。
  • IO 密集型(如数据库查询、第三方 HTTP 接口调用、文件读写)corePoolSize = 2 × CPU 核数。等待 IO 时,CPU 是空闲的,可以多配置一些线程以提高 CPU 利用率。

结合项目(SRM 采购比价系统)的实际配置经验
我们在 SRM 系统中对供应商报价接口调用的线程池进行了如下配置(以 8 核 CPU 为例):

  • 核心线程数(corePoolSize:设置为 16(即 2 × 8)。
  • 最大线程数(maximumPoolSize:设置为 32。
  • 阻塞队列(workQueue:使用有界的 ArrayBlockingQueue,容量为 500。拒绝使用无界的 LinkedBlockingQueue,防止极端并发下队列无限增长导致内存泄漏和 OOM。
  • 拒绝策略(handler:配置为 CallerRunsPolicy(调用者执行)。若发生积压,让主线程(提交任务的 Web 线程)来执行当前任务。这样可以产生“反压(Backpressure)”效果,自然降速,保证任务不丢失,也给线程池缓冲的时间。

线程池监控实战
仅仅配置参数是不够的,生产中我们需要监控线程池的状态:

  1. 核心指标采集:通过暴露 Actuator 端点,或在自定义 ThreadPoolExecutor 中覆写 beforeExecute()afterExecute(),收集活动线程数(getActiveCount())、当前线程池大小(getPoolSize())和队列积压大小(getQueue().size())。
  2. 告警机制:配合 Prometheus + Grafana 进行监控,一旦工作队列使用率达到 80%(如 500容量积压到 400 以上)或者活跃线程数接近最大线程数,立刻触发钉钉或企业微信告警,以便人工干预或动态扩容。