Dunwu Blog

大道至简,知易行难

《极客时间教程 - 深入浅出 Java 虚拟机》笔记

开篇词:JVM,一块难啃的骨头

一探究竟:为什么需要 JVM?它处在什么位置?

JVM - Java Virtual Machine 的缩写,即 Java 虚拟机。JVM 是运行 Java 字节码的虚拟机。JVM 不理解 Java 源代码,这就是为什么要将 *.java 文件编译为 JVM 可理解的 *.class 文件(字节码)。Java 有一句著名的口号:“Write Once, Run Anywhere(一次编写,随处运行)”,JVM 正是其核心所在。实际上,JVM 针对不同的系统(Windows、Linux、MacOS)有不同的实现,目的在于用相同的字节码执行同样的结果。

JRE - Java Runtime Environment 的缩写,即 Java 运行时环境。它是运行已编译 Java 程序所需的一切的软件包,主要包括 JVM、Java 类库(Class Library)、Java 命令和其他基础结构。但是,它不能用于创建新程序。

JDK - Java Development Kit 的缩写,即 Java SDK。它不仅包含 JRE 的所有功能,还包含编译器 (javac) 和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

总结来说,JDK、JRE、JVM 三者的关系是:JDK > JRE > JVM

JDK = JRE + 开发/调试工具

JRE = JVM + Java 类库 + Java 运行库

JVM = 类加载系统 + 运行时内存区域 + 执行引擎

enter image description here

摘自 stackoverflow 高票问题 - What is the difference between JDK and JRE?

大厂面试题:你不得不掌握的 JVM 内存管理

img

img

img

大厂面试题:从覆盖 JDK 的类开始掌握类的加载机制

Java 类的完整生命周期包括以下几个阶段:

  • 加载(Loading) - 将 _.java 文件转为 _.class
  • 链接(Linking)
    • 验证(Verification) - 确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求
    • 准备(Preparation) - 为 static 变量在方法区分配内存并初始化为默认值
    • 解析(Resolution) - 将常量池的符号引用替换为直接引用的过程
  • 初始化(Initialization) - 为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化

类加载器

  • Bootstrap ClassLoader - 负责加载 <JAVA_HOME>\lib 或被 -Xbootclasspath 指定的路径

  • ExtClassLoader - 负责加载 <JAVA_HOME>\lib\ext 或被java.ext.dir 指定的路径

  • AppClassLoader - 负载加载 classpath 路径

  • 自定义类加载器 - 继承自 java.lang.ClassLoader

双亲委派机制 - 除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。

动手实践:从栈帧看字节码是如何在 JVM 中进行流转的

  • javap - javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。

  • jclasslib - jclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。

大厂面试题:得心应手应对 OOM 的疑难杂症

对象生命周期判断

  • 引用计数法
  • 可达性分析法 - GC Roots

引用类型:

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

深入剖析:垃圾回收你真的了解吗?(上)

垃圾回收算法

  • 标记-复制 - 效率最高,但会浪费大量内存空间
  • 标记-清除 - 效率一般,会产生大量内存碎片
  • 标记-整理 - 效率最差,但是不会浪费空间,也消除了内存碎片

GC 分代收集:年轻代 GC 使用标记-复制算法;老年代 GC 使用标记-清除算法、标记-整理算法。

常见 GC 收集器:

  • 年轻代:Serial、ParNew、Parallel
  • 老年代:Serial Old、Parallel Old、CMS
  • 元空间:G1、ZGC

GC 收集器配置参数:

  • -XX:+UseSerialGC 年轻代和老年代都用串行收集器
  • -XX:+UseParNewGC 年轻代使用 ParNew,老年代使用 Serial Old
  • -XX:+UseParallelGC 年轻代使用 ParallerGC,老年代使用 Serial Old
  • -XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
  • -XX:+UseConcMarkSweepGC,表示年轻代使用 ParNew,老年代的用 CMS
  • -XX:+UseG1GC 使用 G1 垃圾回收器
  • -XX:+UseZGC 使用 ZGC 垃圾回收器

深入剖析:垃圾回收你真的了解吗?(下)

  • Minor GC:发生在年轻代的 GC。
  • Major GC:发生在老年代的 GC。
  • Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。

CMS 垃圾回收器分为四个阶段:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清理

CMS 中都会有哪些停顿(STW):

  1. 初始标记,这部分的停顿时间较短;
  2. Minor GC(可选),在预处理阶段对年轻代的回收,停顿由年轻代决定;
  3. 重新标记,由于 preclaen 阶段的介入,这部分停顿也较短;
  4. Serial-Old 收集老年代的停顿,主要发生在预留空间不足的情况下,时间会持续很长;
  5. Full GC,永久代空间耗尽时的操作,由于会有整理阶段,持续时间较长。

大厂面试题:有了 G1 还需要其他垃圾回收器吗?

G1 最重要的概念,其实就是 Region。它采用分而治之,部分收集的思想,尽力达到我们给它设定的停顿目标。

案例实战:亿级流量高并发下如何进行估算和调优

GC 指标:

  • 系统容量(Capacity)
  • 延迟(Latency)
  • 吞吐量(Throughput)

选择垃圾回收器

  • 如果你的堆大小不是很大(比如 100MB),选择串行收集器一般是效率最高的。参数:-XX:+UseSerialGC。
  • 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有 1C,选择串行收集器依然是合适的,这时候启用一些并行收集器没有任何收益。参数:-XX:+UseSerialGC。
  • 如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。参数:-XX:+UseParallelGC。
  • 如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1、ZGC、CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。参数:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。

第 09 讲:案例实战:面对突如其来的 GC 问题如何下手解决

第 10 讲:动手实践:自己模拟 JVM 内存溢出场景

第 11 讲:动手实践:遇到问题不要慌,轻松搞定内存泄漏

jinfo、jstat、jstack、jhsdb(jmap)等是经常被使用的一些工具,尤其是 jmap,在分析处理内存泄漏问题的时候,是必须的。

工具进阶:如何利用 MAT 找到问题发生的根本原因

MAT 是用来分析内存快照的。

动手实践:让面试官刮目相看的堆外内存排查

预警与解决:深入浅出 GC 监控与调优

案例分析:一个高死亡率的报表系统的优化之路

案例分析:分库分表后,我的应用崩溃了

动手实践:从字节码看方法调用的底层实现

大厂面试题:不要搞混 JMM 与 JVM

动手实践:从字节码看并发编程的底层实现

动手实践:不为人熟知的字节码指令

深入剖析:如何使用 Java Agent 技术对字节码进行修改

23 动手实践:JIT 参数配置如何影响程序运行?

案例分析:大型项目如何进行性能瓶颈调优?

未来:JVM 的历史与展望

福利:常见 JVM 面试题补充

Java 并发面试三

Java 线程池

【简单】为什么要用线程池?

顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

这里借用《Java 并发编程的艺术》提到的来说一下使用线程池的好处

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

【简单】Java 创建线程池有哪些方式?

Java 提供了多种创建线程池的方法,主要通过 java.util.concurrent.Executors 工厂类和直接使用 ThreadPoolExecutor 构造函数来实现。

  • 简单场景使用 Executors 工厂方法
  • 需要精细控制时使用 ThreadPoolExecutor 构造器
  • 注意根据任务类型选择合适的线程池类型
  • 避免使用无界队列以防内存溢出

(1)通过 Executors 工厂方法

Executors 类中提供了几种内置的 ThreadPoolExecutor 实现:

  • **FixedThreadPool**:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • **SingleThreadExecutor**: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • **CachedThreadPool**: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • **ScheduledThreadPool**:给定的延迟后运行任务或者定期执行任务的线程池。

(2)直接使用 ThreadPoolExecutor 构造器

1
2
3
4
5
6
7
8
9
new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
);
  • 提供更精细的控制参数
  • 可以自定义线程工厂和拒绝策略

(3)ForkJoinPool (JDK7+)

1
ForkJoinPool forkJoinPool = new ForkJoinPool(int parallelism);
  • 适用于分治算法和并行任务
  • 使用工作窃取 (work-stealing) 算法

【中等】Java 线程池有哪些核心参数?各有什么作用?

ThreadPoolExecutor 有四个构造方法,前三个都是基于第四个实现。第四个构造方法定义如下:

1
2
3
4
5
6
7
8
public ThreadPoolExecutor(int corePoolSize,// 线程池的核心线程数量
int maximumPoolSize,// 线程池的最大线程数
long keepAliveTime,// 当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,// 时间单位
BlockingQueue<Runnable> workQueue,// 任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,// 线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler// 拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {// 略}

参数说明:

  • corePoolSize表示线程池保有的最小线程数
  • maximumPoolSize表示线程池允许创建的最大线程数
    • 如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。
    • 值得注意的是:如果使用了无界的任务队列这个参数就没什么效果。
  • keepAliveTime & unit表示非核心线程存活时间。如果一个线程空闲了keepAliveTime & unit 这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收了。
  • workQueue等待执行的任务队列。用于保存等待执行的任务的阻塞队列。 可以选择以下几个阻塞队列。
    • ArrayBlockingQueue:基于数组的有界阻塞队列
    • LinkedBlockingQueue:基于链表的无界阻塞队列,可能导致 OOM。
    • SynchronousQueue不保存任务,直接新建一个线程来执行任务(需要有可用线程,否则拒绝)。
    • **DelayedWorkQueue**:延迟阻塞队列。
    • PriorityBlockingQueue具有优先级的无界阻塞队列
  • threadFactory线程工厂。线程工程用于自定义如何创建线程。
  • handler拒绝策略。它是 RejectedExecutionHandler 类型的变量。当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。线程池支持以下策略:
    • AbortPolicy默认策略丢弃任务并抛出异常,直接抛出 RejectedExecutionException
    • DiscardPolicy丢弃任务但不抛出异常
    • DiscardOldestPolicy丢弃队列最老的任务,然后重新尝试提交
    • CallerRunsPolicy提交任务的线程自己去执行该任务
    • 如果以上策略都不能满足需要,也可以通过实现 RejectedExecutionHandler 接口来定制处理策略。如记录日志或持久化不能处理的任务。

合理配置这些参数可以优化线程池的性能和稳定性,避免 OOM 或任务丢失。

【中等】Java 线程池的工作原理是什么?

线程池的工作流程遵循 任务提交 → 线程分配 → 队列管理 → 拒绝处理 机制:

  1. 提交任务:调用 execute(Runnable)submit(Callable) 提交任务。
  2. 线程分配逻辑
    • 核心线程可用 → 立即执行任务(即使有空闲线程也会优先创建新线程直到 corePoolSize)。
    • 核心线程已满 → 任务进入任务队列(workQueue)等待。
    • 队列已满 → 创建新线程(不超过 maximumPoolSize)。
    • 线程数达 maximumPoolSize 且队列满 → 触发拒绝策略(RejectedExecutionHandler)。
  3. 线程回收:非核心线程在空闲超过 keepAliveTime 后被回收,核心线程默认常驻(除非设置 allowCoreThreadTimeOut=true)。

::: info 线程分配和队列管理源码

:::

默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。提交任务可以使用 execute 方法,它是 ThreadPoolExecutor 的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 用于控制线程池的运行状态和线程池中的有效线程数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();

// 获取 ctl 中存储的线程池状态信息
int c = ctl.get();

// 线程池执行可以分为 3 个步骤
// 1. 若工作线程数小于核心线程数,则尝试启动一个新的线程来执行任务
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}

// 2. 如果任务可以成功地加入队列,还需要再次确认是否需要添加新的线程(因为可能自从上次检查以来已经有线程死亡)或者检查线程池是否已经关闭
// -> 如果是后者,则可能需要回滚入队操作;
// -> 如果是前者,则可能需要启动新的线程
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 如果任务无法加入队列,则尝试添加一个新的线程
// 如果添加新线程失败,说明线程池已经关闭或者达到了容量上限,此时将拒绝该任务
else if (!addWorker(command, false))
reject(command);
}

execute 方法工作流程如下:

  1. 如果 workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务;
  2. 如果 workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中;
  3. 如果 workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务;
  4. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满,则根据拒绝策略来处理该任务,默认的处理方式是直接抛异常。

::: info 线程池任务状态

:::

ThreadPoolExecutor 有以下重要字段:

1
2
3
4
5
6
7
8
9
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

ctl 用于控制线程池的运行状态和线程池中的有效线程数量。它包含两部分的信息:

  • 线程池的运行状态 (runState)
  • 线程池内有效线程的数量 (workerCount)
  • 可以看到,ctl 使用了 Integer 类型来保存,高 3 位保存 runState,低 29 位保存 workerCountCOUNT_BITS 就是 29,CAPACITY 就是 1 左移 29 位减 1(29 个 1),这个常量表示 workerCount 的上限值,大约是 5 亿。

线程池一共有五种运行状态

  • RUNNING(运行状态)。接受新任务,并且也能处理阻塞队列中的任务。
  • SHUTDOWN(关闭状态)。不接受新任务,但可以处理阻塞队列中的任务。
    • 在线程池处于 RUNNING 状态时,调用 shutdown 方法会使线程池进入到该状态。
    • finalize 方法在执行过程中也会调用 shutdown 方法进入该状态。
  • STOP(停止状态)。不接受新任务,也不处理队列中的任务。会中断正在处理任务的线程。在线程池处于 RUNNINGSHUTDOWN 状态时,调用 shutdownNow 方法会使线程池进入到该状态。
  • TIDYING(整理状态)。如果所有的任务都已终止了,workerCount (有效线程数) 为 0,线程池进入该状态后会调用 terminated 方法进入 TERMINATED 状态。
  • TERMINATED(已终止状态)。在 terminated 方法执行完后进入该状态。默认 terminated 方法中什么也没有做。进入 TERMINATED 的条件如下:
    • 线程池不是 RUNNING 状态;
    • 线程池状态不是 TIDYING 状态或 TERMINATED 状态;
    • 如果线程池状态是 SHUTDOWN 并且 workerQueue 为空;
    • workerCount 为 0;
    • 设置 TIDYING 状态成功。

execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 全局锁,并发操作必备
private final ReentrantLock mainLock = new ReentrantLock();
// 跟踪线程池的最大大小,只有在持有全局锁 mainLock 的前提下才能访问此集合
private int largestPoolSize;
// 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁 mainLock 的前提下才能访问此集合
private final HashSet<Worker> workers = new HashSet<>();
//获取线程池状态
private static int runStateOf(int c) { return c & ~CAPACITY; }
//判断线程池的状态是否为 Running
private static boolean isRunning(int c) {
return c < SHUTDOWN;
}

/**
* 添加新的工作线程到线程池
* @param firstTask 要执行
* @param core 参数为 true 的话表示使用线程池的基本大小,为 false 使用线程池最大大小
* @return 添加成功就返回 true 否则返回 false
*/
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
//这两句用来获取线程池的状态
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
//获取线程池中工作的线程的数量
int wc = workerCountOf(c);
// core 参数为 false 的话表明队列也满了,线程池大小变为 maximumPoolSize
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
//原子操作将 workcount 的数量加 1
if (compareAndIncrementWorkerCount(c))
break retry;
// 如果线程的状态改变了就再次执行上述操作
c = ctl.get();
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
// 标记工作线程是否启动成功
boolean workerStarted = false;
// 标记工作线程是否创建成功
boolean workerAdded = false;
Worker w = null;
try {

w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 加锁
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//获取线程池状态
int rs = runStateOf(ctl.get());
//rs < SHUTDOWN 如果线程池状态依然为 RUNNING, 并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中
//(rs=SHUTDOWN && firstTask == null) 如果线程池状态小于 STOP,也就是 RUNNING 或者 SHUTDOWN 状态下,同时传入的任务实例 firstTask 为 null,则需要添加到工作线程集合和启动新的 Worker
// firstTask == null 证明只新建线程而不执行任务
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
//更新当前工作线程的最大容量
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
// 工作线程是否启动成功
workerAdded = true;
}
} finally {
// 释放锁
mainLock.unlock();
}
//// 如果成功添加工作线程,则调用 Worker 内部的线程实例 t 的 Thread#start() 方法启动真实的线程实例
if (workerAdded) {
t.start();
/// 标记线程启动成功
workerStarted = true;
}
}
} finally {
// 线程启动失败,需要从工作线程中移除对应的 Worker
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}

【简单】Java 线程池的核心线程会被回收吗?

在标准情况下,核心线程(core threads)即使处于空闲状态也不会被线程池回收。这是线程池的默认行为,目的是保持一定数量的常驻线程,以便快速响应新任务。通过设置 allowCoreThreadTimeOut(true) 可以改变这一行为。

【中等】如何合理地设置 Java 线程池的线程数?

根据任务类型设置线程数指导

场景 推荐设置 关键考虑
CPU 密集型 核心数+1 避免上下文切换
I/O 密集型 核心数* 2~5 IO 等待时间比例
混合型 核心数* 1.5~3 根据 CPU/IO 时间比例动态调整
未知场景 动态调整+监控 逐步优化

通用计算公式

1
线程数 = CPU 核心数 × 目标 CPU 利用率 × (1 + 等待时间/计算时间)

(目标 CPU 利用率建议 0.7-0.9)

场景化配置

  • Web 服务器(如 Tomcat)推荐:50-200(需压测确定)。考虑因素:
    • 并发请求量
    • 平均响应时间
    • 系统资源(内存、CPU)
  • 微服务调用推荐:核心数 * 2核心数 * 5,需配合熔断/降级机制
  • 批处理任务推荐:核心数 ± 2,避免与在线服务争抢资源

避坑指南

  • 禁止设置maximumPoolSize=Integer.MAX_VALUE,以避免 OOM。
  • 避免使用无界队列(推荐ArrayBlockingQueue),避免内存堆积
  • 必须配置拒绝策略(建议日志+降级)
  • 动态线程池优于静态配置

最佳实践

  • 通过Runtime.getRuntime().availableProcessors()获取核心数
  • 配合有界队列+合理拒绝策略
  • 建立线程池监控(活跃线程/队列堆积等)
  • 重要服务建议使用动态调整:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 获取服务器 CPU 核心数
int cpuCores = Runtime.getRuntime().availableProcessors();

// 创建线程池(I/O 密集型场景)
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cpuCores * 2, // corePoolSize
cpuCores * 4, // maximumPoolSize
30, // keepAliveTime (秒)
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 有界队列
new CustomThreadFactory(), // 命名线程
new LogAndFallbackPolicy() // 自定义拒绝策略
);

【中等】Java 线程池支持哪些阻塞队列,如何选择?

队列类型 数据结构 是否有界 锁机制 特点 适用场景 不适用场景
ArrayBlockingQueue 数组 有界 ReentrantLock 固定容量,内存连续,支持公平锁 已知并发量的稳定系统 任务量波动大的场景
LinkedBlockingQueue 链表 可选* 双锁分离(put/take) 默认无界 (Integer.MAX_VALUE),吞吐量高,节点动态分配 任务量不可预测的中等吞吐系统 严格内存控制的系统
SynchronousQueue 无存储 无容量 无锁 (CAS) 直接传递任务,吞吐量最高,公平/非公平模式可选 高并发短任务处理 存在长任务的场景
PriorityBlockingQueue 无界 ReentrantLock 按优先级排序,自动扩容,元素需实现 Comparable 需要任务优先级调度的系统 对内存敏感的系统
DelayQueue 堆+PriorityQueue 无界 ReentrantLock 按延迟时间排序,元素需实现 Delayed 接口 定时任务/缓存过期处理 普通任务队列

关键说明

  • 有界性
    • LinkedBlockingQueue 构造时可指定容量变为有界
    • SynchronousQueue 是特殊的”零容量”队列
  • 吞吐量排序SynchronousQueue > LinkedBlockingQueue > ArrayBlockingQueue > PriorityBlockingQueue ≈ DelayQueue
  • 内存开销PriorityBlockingQueue ≈ DelayQueue > LinkedBlockingQueue > ArrayBlockingQueue > SynchronousQueue
  • 特殊机制
    • 公平模式:ArrayBlockingQueue/SynchronousQueue 可设置公平锁(降低吞吐但减少线程饥饿)
    • 双锁分离:LinkedBlockingQueue 的 put/take 操作使用不同锁,提升并发度
    • 直接传递:SynchronousQueue 实现生产者-消费者直接握手

选型决策参考

1
2
3
4
5
6
7
是否需要优先级/延迟?
├─ 是 → PriorityBlockingQueue/DelayQueue
└─ 否 → 是否接受任务丢失?
├─ 是 → SynchronousQueue+CallerRunsPolicy
└─ 否 → 能否预估最大任务量?
├─ 能 → ArrayBlockingQueue(容量=预估峰值×1.5)
└─ 不能 → LinkedBlockingQueue(建议显式设置安全上限)

生产建议

  • Web 服务:ArrayBlockingQueue(2000-10000 容量)+ AbortPolicy
  • 消息处理:LinkedBlockingQueue(10 万上限)+ DiscardOldestPolicy
  • 实时交易:SynchronousQueue + CachedThreadPool
  • 定时任务:DelayQueue(单线程消费)

【中等】Java 线程池支持哪些拒绝策略?如何选择?

Java 线程池支持以下拒绝策略:

策略名称(实现类) 处理方式 优点 缺点 适用场景
AbortPolicy(默认) 直接抛出 RejectedExecutionException 异常 快速失败,避免系统过载 需要调用方处理异常 需要明确知道任务被拒绝的场景
CallerRunsPolicy 让提交任务的线程自己执行该任务 降低新任务提交速度,保证任务不丢失 可能阻塞调用线程,影响整体性能 低优先级任务或允许同步执行的场景
DiscardPolicy 静默丢弃新提交的任务,不做任何通知 系统行为简单 任务丢失无感知,可能造成数据不一致 允许丢弃非关键任务的场景(如日志记录)
DiscardOldestPolicy 丢弃队列中最旧的任务(队头),然后尝试重新提交新任务 优先处理新任务 可能丢失重要旧任务 新任务比旧任务更重要的场景(如实时数据)

所有策略均在以下条件同时满足时触发

  • 线程数达到 maximumPoolSize
  • 工作队列已满(对于有界队列)
  • 仍有新任务提交

策略选择建议

1
2
3
4
5
是否允许任务丢失?
├─ 允许 → 选择 DiscardPolicy/DiscardOldestPolicy
└─ 不允许 → 是否能接受降级?
├─ 能 → 自定义策略(如持久化存储)
└─ 不能 → 选择 CallerRunsPolicy(影响调用方)

生产环境推荐组合

  • 严格系统AbortPolicy + 告警监控
  • 弹性系统CallerRunsPolicy + 熔断机制
  • 最终一致性系统:自定义策略(如写入 Redis 重试队列)

Spring 的增强策略

ThreadPoolTaskExecutor 额外支持:

  • 通过 TaskRejectedException 提供更详细的拒绝信息
  • @Async 注解配合时自动应用策略

【中等】Java 线程池内部任务出异常后,如何知道是哪个线程出了异常?

在 Java 线程池中,当任务抛出异常时,默认情况下异常会被线程池”吞掉”,不会直接抛出给调用者。

  1. 对于需要获取结果的异步任务,使用submit()Future组合
  2. 对于不需要结果的批量任务,使用自定义的ThreadFactory或重写afterExecute
  3. 在复杂系统中,考虑结合日志框架记录完整的异常堆栈和线程信息

通过以上方法,你可以有效地追踪线程池中哪个线程执行的任务抛出了异常。

以下是几种方法来识别哪个线程出了异常:

(1)使用 Future.get() 捕获异常

1
2
3
4
5
6
7
8
9
10
11
12
ExecutorService executor = Executors.newFixedThreadPool(5);
Future<?> future = executor.submit(() -> {
// 任务代码
throw new RuntimeException("模拟异常");
});

try {
future.get(); // 这里会抛出 ExecutionException
} catch (ExecutionException e) {
System.out.println("任务抛出异常:" + e.getCause());
// e.getCause() 获取原始异常
}

(2)自定义 ThreadFactory 设置未捕获异常处理器

1
2
3
4
5
6
7
8
9
ThreadFactory factory = r -> {
Thread t = new Thread(r);
t.setUncaughtExceptionHandler((thread, throwable) -> {
System.out.println("线程 " + thread.getName() + " 抛出异常:" + throwable);
});
return t;
};

ExecutorService executor = Executors.newFixedThreadPool(5, factory);

(3)重写 ThreadPoolExecutorafterExecute 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ExecutorService executor = new ThreadPoolExecutor(..., ...) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t != null) {
System.out.println("任务执行抛出异常:" + t);
}
// 对于通过 FutureTask 运行的任务,异常被封装在 Future 中
if (r instanceof Future<?>) {
try {
((Future<?>) r).get();
} catch (InterruptedException | ExecutionException e) {
System.out.println("Future 任务异常:" + e.getCause());
}
}
}
};

(4)在任务内部捕获异常

1
2
3
4
5
6
7
8
executor.execute(() -> {
try {
// 任务代码
} catch (Exception e) {
System.out.println("线程 " + Thread.currentThread().getName() + " 抛出异常:" + e);
// 记录线程信息
}
});

【中等】Java 线程池中 shutdown 与 shutdownNow 的区别是什么?

shutdown 不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。

  • 将线程池切换到 SHUTDOWN 状态;
  • 并调用 interruptIdleWorkers 方法请求中断所有空闲的 worker;
  • 最后调用 tryTerminate 尝试结束线程池。

shutdownNow 立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。与 shutdown 方法类似,不同的地方在于:

  • 设置状态为 STOP
  • 中断所有工作线程,无论是否是空闲的;
  • 取出阻塞队列中没有被执行的任务并返回。

【困难】Java 线程池参数在运行过程中能修改吗?如何修改?

  • 可动态修改参数:核心线程数、最大线程数、空闲时间、拒绝策略
  • 不可动态修改:队列实现类、线程工厂
  • Spring 增强ThreadPoolTaskExecutor提供更友好的 API
  • 生产建议
    • 配合监控系统实现自动扩缩容
    • 修改时遵循先 max 后 core 的顺序
    • 对队列容量修改要特别小心

::: info ThreadPoolExecutor 原生动态修改参数方法

:::

ThreadPoolExecutor 提供了以下核心参数的动态修改方法:

参数 修改方法 注意事项
核心线程数 setCorePoolSize(int) 新值>旧值时立即生效;新值<旧时空闲线程会被逐渐回收
最大线程数 setMaximumPoolSize(int) 必须≥核心线程数;仅影响后续新增线程
空闲线程存活时间 setKeepAliveTime(long, TimeUnit) 对所有空闲的非核心线程生效
拒绝策略 setRejectedExecutionHandler() 立即生效,但已进入拒绝流程的任务不受影响

示例代码

1
2
3
4
5
6
7
8
9
10
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 5, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10)
);

// 动态调整
executor.setCorePoolSize(4); // 核心线程数 2→4
executor.setMaximumPoolSize(8); // 最大线程数 5→8
executor.setKeepAliveTime(30, TimeUnit.SECONDS); // 60s→30s
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

::: info Spring 的 ThreadPoolTaskExecutor 增强

:::

Spring 的ThreadPoolTaskExecutor在原生基础上增加了更多动态能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(50);
executor.initialize();
return executor;
}

// 动态调整示例
@Autowired
private ThreadPoolTaskExecutor taskExecutor;

public void adjustThreadPool() {
taskExecutor.setCorePoolSize(6);
taskExecutor.setMaxPoolSize(10);
taskExecutor.setQueueCapacity(100);
// Spring 会自动应用新配置
}

::: info 动态调整队列容量

:::

队列容量的动态调整需要特殊处理,因为大多数 BlockingQueue 创建后容量固定:

解决方案

  1. 使用自定义的可变容量队列
  2. 重建线程池(优雅迁移)

自定义队列示例

1
2
3
4
5
6
7
8
9
public class ResizableCapacityLinkedBlockingQueue<E> extends LinkedBlockingQueue<E> {
public ResizableCapacityLinkedBlockingQueue(int capacity) {
super(capacity);
}

public synchronized void setCapacity(int capacity) {
// 实现容量调整逻辑
}
}

扩展:

开源项目:

  • **Hippo4j**:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
  • **Dynamic TP**:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。

Java 并发同步工具

【中等】CountDownLatch 的工作原理是什么?

CountDownLatch 通过计数器实现线程间的“等待-通知”机制,适用于分阶段任务同步,但不可重复使用。

基本作用

  • 允许一个或多个线程等待,直到其他线程完成一组操作后再继续执行。
  • 典型场景:主线程等待多个子线程完成任务后再汇总结果。

核心机制

  • 计数器初始化:创建时指定初始计数值(如 new CountDownLatch(3))。
  • 计数递减:子线程完成任务后调用 countDown(),计数器减 1(线程不会阻塞)。
  • 等待阻塞:主线程调用 await() 会阻塞,直到计数器归零(或超时)。

img

关键特性

  • 一次性:计数器归零后无法重置,需重新创建实例。
  • 非中断递减countDown() 不受线程中断影响,但 await() 可被中断。

代码示例

1
2
3
4
5
6
7
8
9
10
11
CountDownLatch latch = new CountDownLatch(3);

// 子线程完成任务后递减
new Thread(() -> {
doTask();
latch.countDown(); // 计数器-1
}).start();

// 主线程等待所有子线程完成
latch.await();
System.out.println("All tasks done!");

【中等】CyclicBarrier 的工作原理是什么?

CyclicBarrier 通过“线程互相等待”实现协同,适合需要多轮同步的场景,且具备更高的灵活性。

核心作用

  • 一组线程互相等待,直到所有线程都到达某个屏障点(Barrier)后,再一起继续执行。
  • 适用于分阶段并行任务(如多线程计算后合并结果)。

关键机制

机制 说明
屏障初始化 创建时指定参与线程数 N(如 new CyclicBarrier(3))及可选的回调任务(触发后执行)。
线程等待 每个线程调用 await() 时会被阻塞,直到**所有 N 个线程都调用 await()**。
屏障突破 当所有线程到达屏障点后:
1. 执行回调任务(若设置);
2. 所有线程被唤醒,继续执行后续逻辑。
重置能力 屏障被突破后,自动重置,可重复使用(区别于 CountDownLatch)。

img

主要特性

  • 可重复使用:一轮屏障突破后,自动重置计数器,支持下一轮同步。
  • 回调任务:通过构造函数传入 Runnable,在所有线程到达后由最后一个到达的线程执行。
  • 超时与中断await(timeout, unit) 支持超时;线程在等待时若被中断,会抛出 BrokenBarrierException

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("All threads reached the barrier!");
});

for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " is working...");
try {
barrier.await(); // 等待其他线程
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " continues after barrier.");
}).start();
}

对比 CountDownLatch

特性 CyclicBarrier CountDownLatch
重置能力 自动重置,可重复使用 一次性,不可重置
等待角色 所有线程互相等待 主线程等待子线程
计数方向 递增(线程到达后计数) 递减(任务完成后计数)
回调任务 支持 不支持

典型应用场景

  • 多阶段并行计算:如 MapReduce 中,多个 Worker 完成局部计算后同步,再进入下一阶段。
  • 模拟压力测试:让所有测试线程同时开始请求。
  • 游戏同步:多个玩家加载资源完成后同时开始游戏。

【中等】Semaphore 的工作原理是什么?

Semaphore 译为信号量,是一种同步机制,用于控制多线程对共享资源的访问

Semaphore 通过许可证机制灵活控制并发度,既可用于严格互斥(许可证=1 时类似锁),也可用于资源池管理。与 CyclicBarrier/CountDownLatch 不同,它关注的是资源的访问权限而非线程间的同步。

核心作用

  • 控制并发访问资源的线程数量,通过“许可证”(permits)机制实现限流。
  • 适用于:
    • 资源池管理(如数据库连接池)
    • 限流(如接口每秒最大请求数)
    • 互斥场景(类似锁,但更灵活)

关键机制

机制 说明
许可证初始化 创建时指定许可证数量(如 new Semaphore(3) 表示最多允许 3 个线程同时访问)。
获取许可证 线程调用 acquire()
- 若有剩余许可证,立即获取并继续执行;
- 若无许可证,则阻塞等待。
释放许可证 线程调用 release():返还许可证,唤醒等待线程。
公平性 可指定公平模式(new Semaphore(3, true)),避免线程饥饿。

主要特性

  • 动态调整:可通过 release() 增加许可证,或 reducePermits() 动态减少。
  • 非阻塞尝试tryAcquire() 立即返回是否成功,支持超时(如 tryAcquire(2, TimeUnit.SECONDS))。
  • 可中断acquire() 可被其他线程中断(抛出 InterruptedException)。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Semaphore semaphore = new Semaphore(3); // 允许 3 个线程并发

for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 获取许可证
System.out.println(Thread.currentThread().getName() + " 占用资源");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放许可证
System.out.println(Thread.currentThread().getName() + " 释放资源");
}
}).start();
}

输出

1
2
3
4
5
6
7
8
9
Thread-0 占用资源
Thread-1 占用资源
Thread-2 占用资源
2 秒后)
Thread-0 释放资源
Thread-3 占用资源
Thread-1 释放资源
Thread-4 占用资源
...

对比其他同步工具

特性 Semaphore ReentrantLock CountDownLatch
核心能力 控制并发线程数 互斥锁 等待事件完成
是否可重入 是(但需手动释放) 是(可重复加锁) 不适用
资源释放 必须显式调用 release() 必须显式解锁 自动递减
适用场景 限流、资源池 临界区保护 主线程等待子线程

典型应用场景

  • 连接池管理:限制同时使用的数据库连接数。
  • 流量控制:限制每秒处理的请求数。
  • 生产者-消费者模型:通过信号量控制缓冲区大小。

Java 并发分工工具

【中等】ForkJoinPool 的工作原理是什么?

ForkJoinPool 通过工作窃取机制高效处理分治任务,适合递归并行计算,核心是本地队列+LIFO 处理+FIFO 窃取。

设计目标

  • 高效执行分治任务:适用于递归分解的可并行计算(如归并排序、MapReduce)。
  • 工作窃取(Work-Stealing):每个线程有自己的任务队列,空闲线程可从其他队列“窃取”任务,避免资源闲置。

核心组件

  • 工作线程(ForkJoinWorkerThread):每个线程维护一个双端队列(Deque),存放自己的任务。
  • 任务队列
    • 本地队列:LIFO(后进先出)处理自己生成的任务(fork())。
    • 窃取队列:FIFO(先进先出)窃取其他线程的任务(公平性)。
  • 任务类型(ForkJoinTask)RecursiveAction(无返回值) / RecursiveTask(有返回值)。

工作流程

  1. 任务拆分(Fork):调用 fork() 将子任务压入本地队列(LIFO)。
  2. 任务执行(Join):调用 join() 等待子任务结果,期间线程会优先处理本地任务
  3. 工作窃取(Stealing):若线程无任务,从其他线程队列尾部窃取任务(FIFO,减少竞争)。

关键特性

  • 低竞争:线程优先处理本地任务,减少同步开销。
  • 负载均衡:空闲线程自动窃取任务,提高 CPU 利用率。
  • 递归优化:适合深度递归任务,避免线程阻塞。

与普通线程池对比

特性 ForkJoinPool ThreadPoolExecutor
任务类型 分治任务(递归拆分) 独立任务
任务调度 工作窃取(本地队列+窃取) 全局队列(可能竞争)
适用场景 CPU 密集型并行计算 IO 密集型或短任务

注意事项

  • 避免阻塞操作:不适合 IO 密集型任务(线程数有限,易阻塞)。
  • 任务粒度:过小的任务会增加调度开销。

【中等】CompleteFuture 的工作原理是什么?

CompletableFuture 通过回调链和 Completion 阶段机制实现灵活的异步编程,支持任务编排、结果转换和异常传播,底层采用无锁设计优化性能。

核心设计

  • 异步编程模型:基于 Future 的增强实现,支持显式完成(手动设置结果)
  • 回调驱动:通过链式调用实现异步任务编排
  • 双阶段执行
    • 异步计算阶段(任务执行)
    • 完成阶段(结果处理)

关键组件

  • CompletionStage 接口:定义 50+种组合操作(thenApply/thenAccept/thenRun 等)
  • 依赖关系堆栈:维护任务依赖链(类似链表结构)
  • 执行器支持:可指定自定义线程池(默认使用 ForkJoinPool.commonPool)

工作流程

  1. 任务创建

    1
    CompletableFuture.supplyAsync(() -> "task")
  2. 结果转换

    1
    .thenApply(s -> s + " result")
  3. 最终处理

    1
    .thenAccept(System.out::println)

核心机制

  • 回调链(Completion 链)
    • 每个操作生成新的 Completion 节点
    • 节点间形成单向链表
  • 触发机制
    • 前置任务完成时触发后续操作
    • 支持同步/异步执行切换
  • 结果传递
    • 异常传播(exceptionally 处理)
    • 结果转换(thenApply)

线程模型

  • 默认线程池:ForkJoinPool.commonPool()
  • 可控性
    • 支持显式指定线程池
    • 可强制指定同步执行(thenApply vs thenApplyAsync)

特殊功能

  • 组合操作
    • allOf/anyOf(多任务协调)
    • thenCombine(双源合并)
  • 完成控制
    • complete()/completeExceptionally()(手动完成)
    • obtrudeValue(强制覆盖结果)

性能特点

  • 无锁设计:基于 CAS 操作
  • 零等待:回调立即触发(无轮询)
  • 最小化线程切换:优化执行路径

注意事项

  • 线程泄漏风险:未指定线程池时使用默认池
  • 回调地狱:过度链式调用降低可读性
  • 异常处理:必须显式处理异常

适用场景

  • 服务调用编排
  • 异步流水线处理
  • 多源结果聚合

【中等】Timer 的工作原理是什么?

Timer 通过单线程+优先级队列调度任务,简单但不可靠;生产环境建议用线程池替代。

基本组成

  • **Timer**:任务调度器,管理任务队列和后台线程。
  • **TimerTask**:需实现 run(),定义要执行的任务。

核心机制

  • 单线程调度
    • 所有任务由单个后台线程TimerThread)顺序执行。
    • 任务队列按执行时间排序(优先级队列,最小堆)。
  • 任务触发流程
    1. 调用 schedule() 将任务加入队列。
    2. 线程循环检查队首任务,通过 wait(timeout) 休眠至执行时间。
    3. 执行 run() 后,根据调度类型计算下次执行时间:
      • 固定延迟(schedule:基于实际结束时间 + 周期。
      • 固定速率(scheduleAtFixedRate:基于计划开始时间 + 周期(可能追赶延迟)。

关键问题

  • 单线程阻塞:一个任务执行过长或崩溃会导致后续任务延迟/终止。
  • 异常影响:任务抛出未捕获异常时,整个 Timer 线程停止。
  • 资源释放:必须调用 cancel() 避免内存泄漏。

替代方案

ScheduledThreadPoolExecutor 更优:支持多线程、异常隔离、灵活调度。

【困难】时间轮(Time Wheel)的工作原理是什么?

JDK 内置的三种实现定时器的方式,实现思路都非常相似,都离不开任务任务管理任务调度三个角色。三种定时器新增和取消任务的时间复杂度都是 O(logn),面对海量任务插入和删除的场景,这三种定时器都会遇到比较严重的性能瓶颈。对于性能要求较高的场景,一般都会采用时间轮算法来实现定时器

时间轮通过环形数组分片管理定时任务,以 O(1) 时间复杂度实现高效调度,多级设计兼顾长短延迟任务,是高性能定时器的核心实现方案。

时间轮(Timing Wheel)是 George Varghese 和 Tony Lauck 在 1996 年的论文 Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility 实现的,它在 Linux 内核中使用广泛,是 Linux 内核定时器的实现方法和基础之一。

图片 22.png

核心设计思想

  • 环形数组结构:采用环形缓冲区(类似时钟表盘)分层管理定时任务
  • 时间分片:将时间划分为固定间隔的槽(tick),每个槽对应一个任务链表
  • 层级扩展:支持多级时间轮(小时/分钟/秒)处理不同精度的时间任务

核心组件

组件 作用
环形数组 存储各时间槽的任务(如数组长度 60=1 分钟精度,每个槽代表 1 秒)
任务链表 每个槽挂载到期时间相同的任务节点
当前指针 指向当前时间槽,随 tick 前进
层级指针 多级时间轮间的任务传递(如秒轮→分钟轮)

工作流程

  1. 任务添加

    • 计算目标槽位:槽位 = (当前指针 + 延迟时间/tick) % 轮盘大小
    • 相同槽位的任务以链表形式存储
    1
    2
    # 示例:tick=1s,轮盘大小=60,添加 10 秒后执行的任务
    slot = (current_pos + 10) % 60 # 存入第 10 个槽
  2. 时间推进(tick)

    • 每次 tick 移动当前指针到下一槽位
    • 执行该槽位所有任务
    • 多级时间轮:当低级轮转完一圈,高级轮降级一个任务到低级轮
  3. 任务降级(多级时间轮)

    1
    2
    当秒级时间轮(60 槽)转完一圈:
    将分钟轮当前槽的任务重新映射到秒轮

关键优势

优势 说明
O(1) 时间复杂度 添加/删除任务仅需计算槽位,与任务数量无关
低内存开销 仅存储未到期任务,空槽不占资源
适合高频调度 Kafka/Netty 等框架用于心跳检测、超时控制等场景

单级 vs 多级时间轮

类型 精度 缺点 适用场景
单级 高精度 轮盘大(内存占用高) 短延迟任务(<1 分钟)
多级 分级精度 任务降级开销 长短延迟混合任务

实际应用

  • Kafka:延迟消息处理(DelayedOperationPurgatory
  • Netty:连接超时控制(HashedWheelTimer
  • Linux 内核:定时器管理

性能对比

方案 添加复杂度 触发复杂度 内存占用
时间轮 O(1) O(1) O(n)
优先级队列 O(log n) O(1) O(n)
轮询检测 O(1) O(n) O(1)

HashedWheelTimer 是 Netty 中时间轮算法的实现类。

案例

生产者消费者模式

经典问题

(1)什么是生产者消费者模式

(2)Java 中如何实现生产者消费者模式

知识点

(1)什么是生产者消费者模式

生产者消费者模式是一个经典的并发设计模式。在这个模型中,有一个共享缓冲区;有两个线程,一个负责向缓冲区推数据,另一个负责向缓冲区拉数据。要让两个线程更好的配合,就需要一个阻塞队列作为媒介来进行调度,由此便诞生了生产者消费者模式。

(2)Java 中如何实现生产者消费者模式

在 Java 中,实现生产者消费者模式有 3 种具有代表性的方式:

  • 基于 BlockingQueue 实现
  • 基于 Condition 实现
  • 基于 wait/notify 实现

【示例】基于 BlockingQueue 实现生产者消费者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class ProducerConsumerDemo01 {

public static void main(String[] args) throws InterruptedException {
BlockingQueue<Object> queue = new ArrayBlockingQueue<>(10);
Thread producer1 = new Thread(new Producer(queue), "producer1");
Thread producer2 = new Thread(new Producer(queue), "producer2");
Thread consumer1 = new Thread(new Consumer(queue), "consumer1");
Thread consumer2 = new Thread(new Consumer(queue), "consumer2");
producer1.start();
producer2.start();
consumer1.start();
consumer2.start();
}

static class Producer implements Runnable {

private long count = 0L;
private final BlockingQueue<Object> queue;

public Producer(BlockingQueue<Object> queue) {
this.queue = queue;
}

@Override
public void run() {
while (count < 500) {
try {
queue.put(new Object());
System.out.println(Thread.currentThread().getName() + " 生产 1 条数据,已生产数据量:" + ++count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

}

static class Consumer implements Runnable {

private long count = 0L;
private final BlockingQueue<Object> queue;

public Consumer(BlockingQueue<Object> queue) {
this.queue = queue;
}

@Override
public void run() {
while (count < 500) {
try {
queue.take();
System.out.println(Thread.currentThread().getName() + " 消费 1 条数据,已消费数据量:" + ++count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

}

}

【示例】基于 Condition 实现生产者消费者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class ProducerConsumerDemo02 {

public static void main(String[] args) {

MyBlockingQueue<Object> queue = new MyBlockingQueue<>(10);
Runnable producer = () -> {
while (true) {
try {
queue.put(new Object());
System.out.println("生产 1 条数据,总数据量:" + queue.size());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

new Thread(producer).start();

Runnable consumer = () -> {
while (true) {
try {
queue.take();
System.out.println("消费 1 条数据,总数据量:" + queue.size());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

new Thread(consumer).start();
}

public static class MyBlockingQueue<T> {

private final int max;
private final Queue<T> queue;

private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();

public MyBlockingQueue(int size) {
this.max = size;
queue = new LinkedList<>();
}

public void put(T o) throws InterruptedException {
lock.lock();
try {
while (queue.size() == max) {
notFull.await();
}
queue.add(o);
notEmpty.signalAll();
} finally {
lock.unlock();
}
}

public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
T o = queue.remove();
notFull.signalAll();
return o;
} finally {
lock.unlock();
}
}

public int size() {
return queue.size();
}

}

}

【示例】基于 wait/notify 实现生产者消费者模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class ProducerConsumerDemo03 {

public static void main(String[] args) {

MyBlockingQueue<Object> queue = new MyBlockingQueue<>(10);
Runnable producer = () -> {
while (true) {
try {
queue.put(new Object());
System.out.println("生产 1 条数据,总数据量:" + queue.size());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

new Thread(producer).start();

Runnable consumer = () -> {
while (true) {
try {
queue.take();
System.out.println("消费 1 条数据,总数据量:" + queue.size());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};

new Thread(consumer).start();
}

public static class MyBlockingQueue<T> {

private final int max;
private final Queue<T> queue;

public MyBlockingQueue(int size) {
max = size;
queue = new LinkedList<>();
}

public synchronized void put(T o) throws InterruptedException {
while (queue.size() == max) {
wait();
}
queue.add(o);
notifyAll();
}

public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
T o = queue.remove();
notifyAll();
return o;
}

public synchronized int size() {
return queue.size();
}

}

}

Java 并发面试二

Java 锁

【中等】Java 中,根据不同维度划分,锁有哪些分类?

在 Java 中,锁可以按照 多个维度 进行分类,不同维度的锁适用于不同的并发场景。以下是详细的分类:

按锁的公平性划分

锁类型 特点 实现类/关键字
公平锁 严格按照线程请求顺序(FIFO)分配锁,避免线程饥饿,但性能较低。 ReentrantLock(true)
非公平锁 允许插队,新请求的线程可能直接抢到锁,吞吐量高,但可能导致线程饥饿(默认方式)。 ReentrantLock(false)synchronized

按锁的获取方式划分

锁类型 特点 实现类/关键字
悲观锁 认为并发冲突必然发生,先加锁再操作(阻塞其他线程)。 synchronizedReentrantLock
乐观锁 认为并发冲突较少,不加锁,更新时检查(CAS 或版本号机制)。 AtomicIntegerStampedLock

按锁的可重入性划分

锁类型 特点 实现类/关键字
可重入锁 同一线程可多次获取同一把锁(避免死锁)。 ReentrantLocksynchronized
不可重入锁 同一线程重复获取同一把锁会导致死锁(Java 无原生实现,需自定义)。 无(需自行实现)

按锁的共享性划分

锁类型 特点 实现类/关键字
独占锁(排他锁) 同一时间只有一个线程能持有锁(如 synchronizedReentrantLock)。 synchronizedReentrantLock
共享锁 允许多个线程同时读取,但写入时独占(如 ReadWriteLock)。 ReentrantReadWriteLock

按锁的阻塞方式划分

锁类型 特点 实现类/关键字
阻塞锁 获取不到锁时,线程进入阻塞状态(如 synchronized)。 synchronizedReentrantLock
自旋锁 获取不到锁时,线程循环尝试(避免线程切换,但消耗 CPU)。 AtomicInteger(CAS 自旋)
适应性自旋锁 JVM 自动优化自旋次数(如 synchronized 在 JDK 6+ 的优化)。 JVM 内部优化

按锁的优化策略划分

锁类型 特点 实现类/关键字
偏向锁 单线程访问时无同步开销(JDK 6+ 对 synchronized 的优化)。 JVM 自动优化(synchronized
轻量级锁 多线程无竞争时,使用 CAS 代替阻塞(JDK 6+ 优化)。 JVM 自动优化(synchronized
重量级锁 真正的互斥锁,涉及 OS 线程阻塞(如 synchronized 竞争激烈时)。 JVM 自动升级(synchronized

按锁的实现方式划分

锁类型 特点 实现类/关键字
内置锁(JVM 锁) 由 JVM 实现(如 synchronized)。 synchronized
显式锁 由 Java API 提供(如 ReentrantLock)。 ReentrantLockReadWriteLock
分布式锁 跨 JVM 的锁(如 Redis、ZooKeeper 实现)。 RedissonCurator

总结

分类维度 锁类型
公平性 公平锁、非公平锁
获取方式 悲观锁、乐观锁
可重入性 可重入锁、不可重入锁
共享性 独占锁、共享锁
阻塞方式 阻塞锁、自旋锁、适应性自旋锁
优化策略 偏向锁、轻量级锁、重量级锁
实现方式 内置锁(synchronized)、显式锁(ReentrantLock)、分布式锁(Redisson

选择合适的锁取决于:

  • 并发竞争程度(高竞争→悲观锁,低竞争→乐观锁)
  • 任务执行时间(长任务→公平锁,短任务→非公平锁)
  • 读写比例(读多→共享锁,写多→独占锁)
  • 是否需要跨 JVM(是→分布式锁)

这些分类帮助开发者根据业务场景选择最优的锁策略,平衡 性能、公平性、一致性

【中等】悲观锁和乐观锁有什么区别?

以下是悲观锁与乐观锁的详细对比:

对比维度 悲观锁 乐观锁
核心思想 假定并发冲突必然发生,先加锁再访问数据 假定并发冲突较少,先操作再检测冲突
锁机制 显式加锁(阻塞其他线程) 无锁机制(依赖 CAS 或版本号控制)
实现方式 synchronizedReentrantLock、数据库SELECT FOR UPDATE Atomic类(CAS)、版本号机制、数据库乐观锁(如 MVCC)
线程阻塞 会阻塞竞争线程(线程挂起) 不阻塞线程,但可能自旋重试或失败
数据一致性 强一致性(独占访问) 最终一致性(可能需重试)
适用场景 - 写操作频繁
- 临界区代码执行时间长
- 强一致性要求高
- 读多写少
- 短平快操作
- 高吞吐量需求
性能特点 - 高竞争时性能下降明显(线程切换开销)
- 低竞争时仍有固定锁开销
- 低竞争时性能极佳(无阻塞)
- 高竞争时 CPU 自旋浪费
冲突处理 通过锁排队避免冲突 通过重试或放弃处理冲突
典型应用 - 银行转账
- 订单支付
- 数据库行级锁
- 库存扣减
- 计数器
- 点赞系统
优缺点 ✅ 强一致性
❌ 吞吐量低、死锁风险
✅ 高并发性能好
❌ 实现复杂、可能 ABA 问题

选择建议

  • 悲观锁适合”宁可排队等,不能出错”的场景(如金融交易)。
  • 乐观锁适合”宁可重试,不要阻塞”的场景(如电商库存)。

【中等】公平锁和非公平锁有什么区别?

Java 中公平锁和非公平锁的对比

对比维度 公平锁 (Fair Lock) 非公平锁 (Nonfair Lock)
锁获取顺序 严格按照线程请求顺序(FIFO)分配锁 允许插队,新请求的线程可能直接抢到锁
性能表现 吞吐量较低(上下文切换频繁) 吞吐量较高(减少线程切换,但可能线程饥饿)
响应时间 等待时间稳定(适合长任务) 短任务可能更快获取锁(适合高并发短任务)
适用场景 - 需要严格公平性
- 线程执行时间差异大(避免饥饿)
- 高并发短任务
- 追求吞吐量
锁实现类 ReentrantLock(true) ReentrantLock(false)(默认)
实现 依赖 AQS 维护等待线程,先到先得 先尝试 CAS 抢锁,失败后进入 AQS 队列
线程饥饿 不会发生 可能发生(高并发时某些线程长期无法获取锁)
操作系统调度影响 依赖系统线程调度,可能因优先级反转影响公平性 更依赖 JVM 的锁优化策略
锁重入性 支持(与公平性无关) 支持(与公平性无关)
适用并发模型 适合任务执行时间不均衡的场景 适合任务执行时间短的场景

如何选择?

  • 选公平锁

    • 需要严格顺序执行(如订单处理)
    • 避免低优先级线程饥饿
    • 线程任务执行时间差异大
  • 选非公平锁

    • 追求高吞吐量(如秒杀系统)
    • 任务执行时间短且均匀
    • 能接受偶尔的线程饥饿

注意事项:

  • 默认行为ReentrantLocksynchronized 默认都是非公平锁(因为性能更好)。
  • 性能差异:非公平锁在高并发下吞吐量可提升 **10%~30%**,但可能增加延迟方差。
  • synchronized 的公平性:Java 的 synchronized 不支持公平锁,仅 ReentrantLock 可配置。

【中等】synchronized 和 ReentrantLock 有什么区别?

使用差异:

::: code-tabs#synchronized 和 ReentrantLock 使用差异

@tab synchronized 使用

1
2
3
4
5
6
7
8
9
10
// 1. 用于代码块
synchronized (this) {}
// 2. 用于对象
synchronized (object) {}
// 3. 用于方法
public synchronized void test () {}
// 4. 可重入
for (int i = 0; i < 100; i++) {
synchronized (this) {}
}

@tab ReentrantLock 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void test () throw Exception {
// 1. 初始化选择公平锁、非公平锁
ReentrantLock lock = new ReentrantLock(true);
// 2. 可用于代码块
lock.lock();
try {
try {
// 3. 支持多种加锁方式,比较灵活;具有可重入特性
if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
} finally {
// 4. 手动释放锁
lock.unlock()
}
} finally {
lock.unlock();
}
}

:::

以下是 synchronizedReentrantLock 的详细对比表格,涵盖 锁机制、功能、性能、使用场景 等核心维度:


对比维度 synchronized ReentrantLock
锁类型 JVM 内置关键字(隐式锁) JDK 提供的类(显式锁)
加锁解锁方式 自动加锁/释放锁(进入同步代码块加锁,退出时释放) 需手动调用 lock()unlock()(必须配合 try-finally 使用)
是否可重入 支持(同一线程可重复获取) 支持(同一线程可重复获取)
是否支持公平 仅支持非公平锁 可配置公平锁或非公平锁(构造函数传参 true/false
是否可中断 不支持中断 支持 lockInterruptibly(),可响应中断
是否支持超时 不支持超时 支持 tryLock(timeout, unit),可设置超时时间
是否支持多条件 通过 wait()/notify() 实现,单一等待队列 支持多个 Condition,可精确控制线程唤醒(如 await()/signal()
性能 JDK 6+ 优化后(偏向锁→轻量级锁→重量级锁)性能接近 ReentrantLock 在高竞争场景下性能略优(减少上下文切换)
死锁检测 无内置死锁检测 可通过 tryLock 避免死锁
适用场景 简单同步场景(如单方法同步) 复杂同步需求(如公平锁、可中断锁、超时锁)
底层实现 JVM 通过 monitorenter/monitorexit 字节码实现 基于 AbstractQueuedSynchronizer (AQS) 实现

关键区别总结

  • 灵活性

    • ReentrantLock 更强大:支持公平锁、可中断、超时、多条件变量。
    • synchronized 更简单:自动管理锁,适合基础同步需求。
  • 性能差异:JDK 6 后两者性能接近,但 ReentrantLock 在高竞争场景仍略有优势。

  • 使用选择建议

    • **选择 synchronized**:
      • 需要简单的代码块同步。
      • 不需要高级功能(如超时、公平锁)。
    • **选择 ReentrantLock**:
      • 需要精细控制(如公平性、可中断)。
      • 需要避免死锁(tryLock)。
  • 注意

    • ReentrantLock 必须手动释放锁,否则会导致死锁!
    • synchronized 是 Java 并发的基础,而 ReentrantLock 是它的增强扩展。

适用场景

  • synchronized 适用场景:单例模式的双重检查锁、简单的线程安全计数器。
  • ReentrantLock 适用场景
    • 需要公平性的任务队列(如订单处理)。
    • 需要超时控制的资源争用(如避免死锁)。
    • 复杂的多条件线程协调(如生产者-消费者模型)。

【困难】ReentrantLock 的实现原理是什么?

ReentrantLock 基于 AQS(AbstractQueuedSynchronizer)实现

  • 核心依赖ReentrantLock 通过内部类 Sync(继承 AQS)实现锁机制。
  • AQS 作用:提供线程阻塞/唤醒的队列管理(CLH 变体)和状态(state)的原子操作。

锁状态(state)管理

  • state 字段
    • =0:锁未被占用。
    • >0:锁被占用,数值表示重入次数(可重入性)。
  • 修改方式:通过 CAS(Compare-And-Swap)保证原子性。

获取锁(公平 / 非公平)

  • 公平锁FairSync):严格按照 FIFO 顺序获取锁(先检查队列是否有等待线程)。
    • 先检查是否有前驱节点(队列中有无等待线程),有则排队。
    • 无则尝试 CAS 获取锁。
  • 非公平锁NonfairSync,默认):新线程直接尝试 CAS 抢锁(可能插队),失败才进入队列。
    • 直接尝试 CAS 修改 state(抢锁)。
    • 失败后调用 AQS.acquire() 进入队列等待。

释放锁

  1. 减少 state 值(重入次数减 1)。
  2. state=0,唤醒队列中的下一个线程(通过 LockSupport.unpark())。

可重入性

  • 记录当前持有锁的线程(exclusiveOwnerThread)。
  • 同一线程重复获取锁时,state 递增(无需重新排队)。

关键方法

  • **tryLock()**:非阻塞尝试获取锁(直接 CAS)。
  • **lockInterruptibly()**:支持中断的锁获取。
  • **Condition**:基于 AQS 实现多个等待队列(如 await()/signal())。

性能优化

  • 非公平锁:减少线程切换,提高吞吐量(但可能饥饿)。
  • 自旋优化:短暂自旋尝试获取锁,避免立即入队。

总结

ReentrantLock 的核心是通过 AQS 队列 + CAS 操作 实现:

  • 锁竞争:通过 state 和 CLH 队列管理线程阻塞/唤醒。
  • 灵活性:支持公平性、可中断、超时等高级功能。
  • 可重入:记录持有线程和重入次数。

适用于需要精细控制锁行为的场景(如公平性、条件变量)。

【困难】AQS 的实现原理是什么?

AQS(AbstractQueuedSynchronizer)是 Java 并发包(java.util.concurrent.locks)的核心框架,用于构建锁(如 ReentrantLock)和同步器(如 CountDownLatchSemaphore)。它的核心思想是 CLH 队列 + CAS + 状态管理,提供了一种高效、灵活的同步机制。

关键属性

  • 状态变量(state):一个 volatile 整型变量,用于表示同步状态。不同的同步组件对 state 有不同的解读,例如在 ReentrantLock 里,state 为 0 表示锁未被持有,大于 0 表示锁已被持有,且重入次数就是 state 的值。
  • 等待队列(head 和 tail):指向 FIFO 队列的头尾节点。队列中的每个节点都代表一个等待获取同步状态的线程。每个 Node 包含以下重要属性:
    • **thread**:指向等待获取同步状态的线程。
    • **prevnext**:分别指向前一个节点和后一个节点,从而形成双向链表。
    • **waitStatus**:表示节点的等待状态,常见的状态有:
      • CANCELLED(1):表示该节点对应的线程已取消等待。
      • SIGNAL(-1):表示该节点的后继节点需要被唤醒。
      • CONDITION(-2):表示该节点处于条件队列中。
      • PROPAGATE(-3):用于共享模式下,表明状态需要向后传播。

同步模式

AQS 支持两种同步模式:

  • 独占模式:同一时刻仅允许一个线程获取同步状态,例如 ReentrantLock
    • 获取锁
      • 线程调用 acquire(int)tryAcquire(int)(子类实现)。
      • 如果成功(state 修改成功),则获取锁。
      • 如果失败,线程被封装成 Node 加入 CLH 队列,并进入 park() 等待。
    • 释放锁
      • 线程调用 release(int)tryRelease(int)(子类实现)。
      • 如果成功,唤醒队列中的下一个线程(unparkSuccessor)。
  • 共享模式:同一时刻允许多个线程获取同步状态,例如 CountDownLatchSemaphore
    • 获取锁
      • 线程调用 acquireShared(int)tryAcquireShared(int)(子类实现)。
      • 如果成功(返回 ≥0),获取锁;否则进入队列等待。
    • 释放锁
      • 线程调用 releaseShared(int)tryReleaseShared(int)(子类实现)。
      • 如果成功,唤醒后续等待的线程(可能多个)。

关键方法

  • 独占模式
    • **tryAcquire(int arg)**:尝试以独占模式获取同步状态,此方法需由子类实现。
    • **acquire(int arg)**:以独占模式获取同步状态,若获取失败则将线程加入队列并阻塞。
    • **tryRelease(int arg)**:尝试以独占模式释放同步状态,需子类实现。
    • **release(int arg)**:以独占模式释放同步状态,若释放成功则唤醒队列中的后继节点。
  • 共享模式
    • **tryAcquireShared(int arg)**:尝试以共享模式获取同步状态,需子类实现。
    • **acquireShared(int arg)**:以共享模式获取同步状态,若获取失败则将线程加入队列并阻塞。
    • **tryReleaseShared(int arg)**:尝试以共享模式释放同步状态,需子类实现。
    • **releaseShared(int arg)**:以共享模式释放同步状态,若释放成功则唤醒队列中的后继节点。

::: info AQS 核心机制

CAS(Compare-And-Swap)

使用 Unsafe 类的 compareAndSwapXXX 方法保证 state 和队列操作的原子性。

例如:

1
2
3
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

自旋 + park() 等待

  • 线程在入队前会自旋尝试获取锁(减少上下文切换)。
  • 如果仍然失败,则调用 LockSupport.park() 挂起线程。

公平性控制

  • 公平锁:严格按照 CLH 队列顺序获取锁(hasQueuedPredecessors() 检查是否有前驱节点)。
  • 非公平锁:新线程可以插队(tryAcquire 直接尝试获取锁,不检查队列)。

:::

::: tip 扩展

从 ReentrantLock 的实现看 AQS 的原理及应用

:::

【困难】ReentrantReadWriteLock 的实现原理是什么?

ReentrantReadWriteLock 是为【读多写少】的并发场景设计的锁实现

ReentrantReadWriteLock 允许多个线程同时持有读锁,但同一时刻只允许一个线程持有写锁。此外,存在读锁时无法获取写锁,存在写锁时无法获取读锁。

ReentrantReadWriteLock 有以下特性:

  • 可重入:读写锁都支持可重入。
  • 支持公平锁,默认为非公平锁。
  • 支持锁降级持有写锁可以获取读锁;反之不允许

ReentrantReadWriteLock 基于 AQS 实现的读写锁,其核心设计思想是将一个 32 位的 int 状态变量拆分为两部分

  • 高 16 位:表示读锁的持有数量(包括重入次数)
  • 低 16 位:表示写锁的重入次数
1
2
3
4
状态变量结构:
+-------------------------------+-------------------------------+
| 读锁状态 (16 位) | 写锁状态 (16 位) |
+-------------------------------+-------------------------------+

写锁实现(WriteLock)

  • 排他锁,使用 AQS 的独占模式
  • 获取条件:
    • 读锁计数为 0(没有读锁)
    • 写锁计数为 0 或当前线程已持有写锁(可重入)
  • 实现方法:
    1
    2
    3
    4
    5
    6
    protected final boolean tryAcquire(int acquires) {
    // 检查是否有读锁或其他线程持有写锁
    if (c != 0 && w == 0) return false;
    // 检查重入或 CAS 设置状态
    // ...
    }

读锁实现(ReadLock)

  • 共享锁,使用 AQS 的共享模式
  • 获取条件:
    • 没有线程持有写锁,或写锁被当前线程持有(锁降级)
  • 实现特点:
    • 使用 ThreadLocal 记录每个线程的重入次数
    • 第一个获取读锁的线程会记录自己(firstReader)
    • 后续线程使用 cachedHoldCounter 优化性能

锁降级实现

1
2
3
4
5
6
7
8
9
// 锁降级示例代码
writeLock.lock(); // 获取写锁
try {
// 修改数据。..
readLock.lock(); // 在保持写锁的情况下获取读锁(锁降级关键步骤)
} finally {
writeLock.unlock(); // 释放写锁,降级为读锁
}
// 此时仍持有读锁,其他线程可以获取读锁但不能获取写锁

关键数据结构

HoldCounter

1
2
3
4
static final class HoldCounter {
int count; // 重入次数
final long tid = Thread.currentThread().getId(); // 线程 ID
}

ThreadLocalHoldCounter

1
2
3
4
5
6
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}

性能优化技巧

  • firstReader 优化:记录第一个获取读锁的线程,避免 ThreadLocal 查找
  • cachedHoldCounter:缓存最近一个获取读锁的线程计数器
  • 读锁计数存储:使用 ThreadLocal 保存每个线程的重入次数,避免竞争

【困难】StampedLock 的实现原理是什么?

StampedLock是 JDK8 引入的高性能锁,适合读多写少且追求极致吞吐的场景,但需谨慎处理乐观读失败和死锁风险。

StampedLock 通过版本号+状态位拆分实现无锁读,牺牲重入性和公平性换取更高吞吐,适合短期读操作的并发场景。

StampedLock 支持三种锁模式

  • 写锁(独占锁):类似ReentrantLock,同一时刻只有一个线程能获取。阻塞其他所有读锁和写锁请求。
  • 悲观读锁(共享锁):允许多线程并发读,但会阻塞写锁请求(类似ReentrantReadWriteLock的读锁)。
  • 乐观读(无锁优化):不阻塞写操作,仅通过tryOptimisticRead()获取一个”邮戳”(版本号),读完后需校验邮戳是否有效(未被写操作修改)。

特性

  • 更高的并发度:乐观读允许读操作与写操作并发执行(无阻塞)。
  • 不可重入:锁不可重入,嵌套获取可能导致死锁。
  • 支持锁升级/降级
    • 锁降级:写锁→悲观读锁(类似ReentrantReadWriteLock)。
    • 锁升级:乐观读→悲观读锁或写锁(需校验邮戳后尝试转换)。
  • **不支持 Condition**:不能像ReentrantLock那样使用await()/signal()

StampedLock vs. ReentrantReadWriteLock

特性 StampedLock ReentrantReadWriteLock
读并发度 最高(乐观读无阻塞) 高(悲观读阻塞写)
写饥饿 可能发生 非公平模式下可能发生
锁重入 不支持 支持
公平性 仅非公平 支持公平/非公平
条件变量 不支持 支持

状态设计

  • 64 位长整型状态变量state)拆分为三部分:
    • 写锁标记(最低位):WBIT(写锁占用标志)
    • 版本号(中间 7 位):乐观读的邮戳版本
    • 读锁计数(剩余 56 位):记录悲观读锁的持有数量
1
2
State 结构:
[读锁计数 (56 位) | 版本号 (7 位) | 写锁标记 (1 位)]

关键操作实现

写锁获取

  • CAS 设置 WBIT 位:若成功则获取写锁,失败则进入队列等待
  • 版本号+1:每次写锁释放时递增版本号(保证乐观读的可见性)

悲观读锁获取

  • 检查无写锁(WBIT=0)时通过 CAS 增加读计数
  • 写锁占用时:进入等待队列(类似 AQS 的 CLH 队列)

乐观读实现

  1. 调用tryOptimisticRead()获取当前版本号(不修改状态)
  2. 读取共享数据
  3. 调用validate(stamp)检查版本号是否变化(无写操作则有效)

锁转换机制

**tryConvertToXLock()**:核心转换方法(避免释放再获取的开销)

  • 乐观读→悲观读:验证邮戳后直接获取读锁
  • 读锁→写锁:当读计数=1 且当前线程唯一持有读锁时可转换

性能优化手段

  • 无锁乐观读:完全不阻塞写操作(通过版本号校验)
  • 延迟唤醒:读锁释放时不立即唤醒等待线程(减少竞争)
  • 自旋优化:短时冲突时先自旋再入队(类似 AQS)

与 AQS 的差异

  • 非 AQS 实现:独立的状态机设计(更轻量)
  • 无公平性:所有锁均为非公平模式
  • 无条件队列:不支持Condition功能

典型使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
StampedLock lock = new StampedLock();

// 乐观读示例
long stamp = lock.tryOptimisticRead();
// 读取共享数据。..
if (!lock.validate(stamp)) {
// 版本失效,转悲观读
stamp = lock.readLock();
try {
// 重新读取数据。..
} finally {
lock.unlockRead(stamp);
}
}

// 写锁示例
long stamp = lock.writeLock();
try {
// 修改数据。..
} finally {
lock.unlockWrite(stamp);
}

Java 无锁

【中等】什么是 CAS?CAS 的实现原理是什么?

::: info 什么是 CAS?

:::

CAS(Compare-And-Swap,比较并交换) 是一种 无锁(Lock-Free) 的并发编程技术,基于 比较-交换 实现原子操作。它是现代并发编程的基石,被广泛应用于 Java 的 Atomic 类、ReentrantLockConcurrentHashMap 等并发工具中。

CAS 底层实现依赖 CPU 指令(如 CMPXCHG),通过 Unsafe 类调用本地方法。

CAS 的核心应用有:原子类、自旋锁、无锁数据结构(如 ConcurrentHashMap)。

CAS 的核心思想

  • 比较:检查某个内存位置的值是否等于预期值(Expected Value)。
  • 交换:如果相等,则更新为新值(New Value),否则不做任何操作。
  • 原子性:整个操作由 CPU 指令 保证不可分割,不会出现线程安全问题。

::: info CAS 的实现原理是什么?

:::

(1)底层 CPU 指令支持

在 Java 中,通过 Unsafe 类调用本地方法(Native Method)实现 CAS。更底层的实现依赖于 硬件指令(如 x86 的 CMPXCHG、ARM 的 LL/SC),确保操作是原子的。

1
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int newValue);

(2)CAS 操作流程

1
2
3
4
5
6
7
8
// 伪代码
boolean CAS(Variable var, int expected, int newValue) {
if (var.value == expected) { // 比较当前值是否等于预期值
var.value = newValue; // 如果相等,更新为新值
return true;
}
return false; // 否则失败
}

实际执行流程

  1. 读取内存值 V
  2. 比较 V 和预期值 A
    • 如果 V == A,说明没有其他线程修改过,更新为 B
    • 如果 V != A,说明值已被修改,放弃更新。
  3. 返回操作是否成功。

(3)Java 中的 CAS 实现(以 AtomicInteger 为例)

1
2
3
4
AtomicInteger count = new AtomicInteger(0);

// CAS 操作:如果当前值是 0,则设置为 1
boolean success = count.compareAndSet(0, 1); // 内部调用 Unsafe.compareAndSwapInt

底层实现

1
2
3
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

其中:

  • this:目标对象(如 AtomicInteger 实例)。
  • valueOffset:字段在对象内存中的偏移量(通过 Unsafe.objectFieldOffset 获取)。
  • expect:预期值。
  • update:新值。

CAS 的典型应用

(1)原子类

1
2
AtomicInteger atomicInt = new AtomicInteger(0);
atomicInt.incrementAndGet(); // CAS 实现原子自增

底层实现

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

(2)自旋锁

1
2
3
while (!CAS(lock, 0, 1)) {  // 尝试获取锁
// 自旋等待
}

(3)无锁数据结构

  • ConcurrentHashMap(JDK 8 使用 CAS + synchronized 替代分段锁)。
  • CopyOnWriteArrayList(CAS 保证写入原子性)。

CAS 的优缺点

优点

优点 说明
无锁 避免线程阻塞,减少上下文切换
高性能 在低竞争场景下比锁更高效
可扩展 适合高并发读多写少场景

缺点

缺点 说明
ABA 问题 值从 A→B→A,CAS 无法感知中间变化
自旋开销 高竞争时 CPU 空转
单变量限制 只能保证一个变量的原子性
公平性问题 无法保证先来先服务

CAS vs 锁

对比项 CAS 锁(如 synchronized)
实现方式 无锁(CPU 指令) 阻塞(JVM 管理)
性能 低竞争时更优 高竞争时更稳定
适用场景 简单原子操作 复杂临界区保护
公平性 非公平 可公平(如 ReentrantLock(true)

【中等】CAS 算法存在哪些问题?

CAS(Compare-And-Swap)是一种无锁并发编程技术,广泛用于 Java 的 Atomic 类、AQS、ConcurrentHashMap 等并发工具中。但它也存在一些问题和限制:

ABA 问题

  • 问题描述:变量值从 ABA,CAS 检查时认为没有变化,但实际上已经被修改过。
  • 影响:可能导致数据不一致(如链表操作时节点被替换但指针仍有效)。
  • 解决方案
    • 使用 版本号/时间戳(如 AtomicStampedReference)。
    • 使用 boolean 标记(如 AtomicMarkableReference)。

自旋产生的 CPU 空转

  • 问题描述:如果 CAS 长时间失败,线程会持续自旋(while 循环),占用 CPU 资源。
  • 影响:高并发竞争时,可能导致 CPU 使用率飙升。
  • 解决方案
    • 限制自旋次数(如 LongAdder 改用分段 CAS)。
    • 结合 yield()Thread.sleep() 减少竞争。

只能保证单个变量的原子性

  • 问题描述:CAS 只能对一个变量进行原子操作,无法保证多个变量的复合操作(如 i++j--)。
  • 影响:需要额外同步机制(如锁)来保证多变量一致性。
  • 解决方案
    • 使用 synchronizedReentrantLock
    • 设计不可变对象(如 StringBigInteger)。

公平性问题

  • 问题描述:CAS 是非公平的,新线程可能比等待队列中的线程更快获取锁。
  • 影响:可能导致线程饥饿(某些线程长期得不到执行)。
  • 解决方案
    • 使用公平锁(如 ReentrantLock(true))。
    • 结合队列调度(如 AQS 的 CLH 队列)。

不适用于复杂操作

  • 问题描述:CAS 适合简单操作(如 count++),但不适合复杂逻辑(如数据库事务)。
  • 影响:需要拆分为多个 CAS 步骤,可能引入中间状态不一致。
  • 解决方案
    • 使用锁(如 synchronized)。
    • 改用事务内存(如 Clojure STM)。

平台依赖性

  • 问题描述:CAS 依赖底层 CPU 指令(如 CMPXCHG),不同架构性能可能差异较大。
  • 影响:在 ARM 等弱内存模型平台可能出现意外行为。
  • 解决方案:使用 JVM 内置原子类(如 AtomicInteger),而非手动实现。

总结

问题 影响 解决方案
ABA 问题 数据不一致 AtomicStampedReference
自旋开销 CPU 占用高 限制自旋次数 / 退让策略
单变量限制 复合操作不安全 锁 / 不可变对象
公平性 线程饥饿 公平锁 / 队列调度
复杂操作 难以实现 锁 / 事务内存
平台依赖 跨平台兼容性差 使用标准库

CAS 在无锁编程中非常高效,但需结合场景权衡利弊。在高竞争环境下,可能需要改用锁或其他并发策略。

【中等】什么是 ThreadLocal?

::: info 什么是 ThreadLocal?

:::

在多线程环境下,共享变量存在并发安全问题。换个思路,如果变量非共享,而是各个线程独享,就不会有并发安全问题。这种思想有个术语叫线程封闭,其本质上就是避免共享。没有共享,自然也就没有并发安全问题。在 Java 中,ThreadLocal 正是根据这个思路而设计的。

ThreadLocal 为每个线程都创建了一个本地副本,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。

::: info ThreadLocal 有哪些应用场景?

:::

(1)存储线程私有数据

  • 用户会话(Session)管理:每个请求线程存储当前用户的 Session

    1
    2
    3
    4
    5
    6
    private static final ThreadLocal<User> currentUser = ThreadLocal.withInitial(() -> null);

    // 设置当前用户
    currentUser.set(user);
    // 获取当前用户
    User user = currentUser.get();
  • 数据库连接(Connection)管理:避免传递 Connection 参数。

    1
    2
    private static final ThreadLocal<Connection> connectionHolder =
    ThreadLocal.withInitial(() -> dataSource.getConnection());

避免参数透传

问题:多层方法调用需要透传某个上下文参数(如 traceId)。

解决:使用 ThreadLocal 存储,避免方法参数传递。

1
2
3
4
5
6
7
private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();

// 在入口处设置 traceId
traceIdHolder.set("req-123");

// 在任意深层方法获取
String traceId = traceIdHolder.get(); // 无需透传参数

(3)线程安全的工具类

例如SimpleDateFormat 是线程不安全的,但可以用 ThreadLocal 包装:

1
2
3
4
5
private static final ThreadLocal<SimpleDateFormat> dateFormatHolder =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 线程安全地使用
String formattedDate = dateFormatHolder.get().format(new Date());

最佳实践

(1)尽量用 static final

1
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();

避免重复创建 ThreadLocal 实例。

(2)必须调用 remove()

尤其在线程池场景,否则会导致内存泄漏。

(3)推荐初始化默认值

1
ThreadLocal<User> userHolder = ThreadLocal.withInitial(() -> new User());

(4)避免在父子线程间传递

ThreadLocal 不能自动继承,需手动处理(可用 InheritableThreadLocal)。

【中等】ThreadLocal 的原理是什么?

内部结构

ThreadLocal 主要依赖于两个类:ThreadLocal 自身和 ThreadLocalMap

  • Thread:每个 Thread 对象内部都有一个类型为 ThreadLocalMap 的成员变量 threadLocals,用于存储该线程的所有 ThreadLocal 变量及其对应的值。
  • **ThreadLocalMap**:它是 ThreadLocal 的一个静态内部类,类似于 HashMap,但它使用弱引用的 ThreadLocal 对象作为键,值则是用户设置的对象。

存储机制

  • 当调用 ThreadLocalset 方法时,它会首先获取当前线程的 ThreadLocalMap
  • 如果 ThreadLocalMap 存在,则以当前 ThreadLocal 对象为键,将值存储到 ThreadLocalMap 中。
  • 如果 ThreadLocalMap 不存在,则创建一个新的 ThreadLocalMap,并将当前 ThreadLocal 对象和值作为第一个元素存入其中。

获取机制

  • 当调用 ThreadLocalget 方法时,它会先获取当前线程的 ThreadLocalMap
  • 如果 ThreadLocalMap 存在,则以当前 ThreadLocal 对象为键去查找对应的值。
  • 如果 ThreadLocalMap 不存在或者没有找到对应的值,则调用 initialValue 方法(可以通过继承 ThreadLocal 类并重写该方法来设置初始值)来获取初始值,并将其存储到 ThreadLocalMap 中。

弱引用机制

ThreadLocalMap 的键是对 ThreadLocal 对象的弱引用。这意味着当外部对 ThreadLocal 对象的强引用被释放后,ThreadLocal 对象会在下次垃圾回收时被回收。这样可以避免内存泄漏,因为如果使用强引用,即使外部不再使用 ThreadLocal 对象,它也不会被回收,从而导致 ThreadLocalMap 中的条目一直存在。

【中等】如何解决 ThreadLocal 内存泄漏问题?

ThreadLocal 的内存泄漏问题源于其特殊的 “弱引用 Key + 强引用 Value” 存储结构,主要发生在以下两种场景:

(1) Key 被回收,Value 残留(主要泄漏场景)

  • ThreadLocal 实例(Key)是弱引用,会被 GC 回收
  • 对应的 Value 是强引用,会持续占用内存
  • 导致 ThreadLocalMap 中出现 key=nullvalue≠null 的无效 Entry

(2) 线程长期存活时的累积泄漏

  • 线程池复用线程(如 Tomcat worker 线程)
  • 每次任务执行后未调用 remove()
  • 导致多个无效 Entry 堆积在 ThreadLocalMap

::: code-tabs#内存泄漏的具体场景

@tab 线程池环境未清理

1
2
3
4
5
6
7
8
ExecutorService pool = Executors.newFixedThreadPool(5);
ThreadLocal<BigObject> tl = new ThreadLocal<>();

pool.execute(() -> {
tl.set(new BigObject()); // 存储大对象
// 业务逻辑。..
// 缺少 tl.remove()!线程复用后旧 Value 仍然存在
});

后果:线程被重复使用时,之前的 BigObject 实例无法被回收

@tab 静态 ThreadLocal 长期持有

1
2
3
4
5
6
7
static final ThreadLocal<User> userHolder = new ThreadLocal<>();

void processRequest() {
userHolder.set(new User()); // 每次请求新 User 对象
// 业务逻辑。..
// 忘记调用 userHolder.remove()
}

后果:Web 应用中,线程处理多个请求后,内存中堆积多个废弃 User 对象

@tab 使用非 static 的 ThreadLocal

1
2
3
4
5
6
7
8
class Service {
ThreadLocal<Config> configHolder = new ThreadLocal<>(); // 非 static

void serve() {
configHolder.set(loadConfig());
// ...
}
}

后果:每次创建 Service 实例都会产生新的 ThreadLocal,增加内存泄漏风险

:::

解决方案与最佳实践

(1) 强制清理方案

方案 实现方式 适用场景
try-finally 确保 remove() 执行 通用场景
拦截器清理 AOP/@Around Web 应用
线程池钩子 afterExecute 线程池任务

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 方案 1:try-finally(推荐)
try {
threadLocal.set(data);
// 业务逻辑。..
} finally {
threadLocal.remove();
}

// 方案 2:Spring 拦截器
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
threadLocal.remove();
}

(2) 设计优化方案

  1. 使用 static final 修饰

    1
    private static final ThreadLocal<User> holder = new ThreadLocal<>();
    • 避免重复创建 ThreadLocal 实例
  2. 初始化默认值

    1
    ThreadLocal.withInitial(() -> new LightweightObject());
    • 避免持有大对象
  3. 改用 InheritableThreadLocal(需谨慎)

    • 适用于需要父子线程传递数据的场景

ThreadLocalMap 的自动清理机制

虽然 ThreadLocalMap 有部分自清理能力,但不可依赖

  • set() 触发清理:探测式清理(expungeStaleEntry)
  • get() 触发清理:启发式清理(cleanSomeSlots)
  • remove() 触发清理:完全清理指定 Entry

重要结论

  • 自动清理不彻底(只清理部分无效 Entry)
  • 高并发场景可能清理不及时
  • 必须显式调用 remove()

【中等】InheritableThreadLocal 的实现原理是什么?

核心设计目标

  • 线程间值继承:子线程自动继承父线程的 ThreadLocal 值
  • 与 ThreadLocal 兼容:继承自ThreadLocal,保持相同 API

数据存储位置

继承自ThreadLocal,但使用线程对象的独立字段Thread.inheritableThreadLocals(专门存储可继承的变量)

线程创建时的值拷贝

  • 触发时机:当父线程创建子线程(Thread.init()方法)

  • 拷贝逻辑

    1
    2
    3
    4
    if (parent.inheritableThreadLocals != null) {
    this.inheritableThreadLocals =
    ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    }
  • 深拷贝保证隔离:子线程获得父线程值的独立副本(修改互不影响)

值传递规则

  • 仅初始化时拷贝:子线程创建后父线程对值的修改不再影响子线程
  • 浅拷贝问题:若存储引用对象,父子线程仍共享同一对象(需开发者自行处理线程安全)

与 ThreadLocal 的对比

特性 InheritableThreadLocal ThreadLocal
继承性 子线程自动继承父线程值 完全隔离
存储字段 Thread.inheritableThreadLocals Thread.threadLocals
性能开销 略高(需初始化时拷贝数据) 更低
使用场景 需要跨线程传递上下文(如 TraceID) 线程私有数据

使用注意事项

  • 对象共享风险:若值是可变的引用对象,需自行保证线程安全
  • 线程池陷阱:线程池复用线程时会导致旧值残留(需手动清理)
  • 性能影响:大量线程创建时,值拷贝可能成为瓶颈

典型应用场景

1
2
3
4
5
6
7
8
// 父线程设置值
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("parent_value");

new Thread(() -> {
// 子线程自动读取到父线程设置的值
System.out.println(itl.get()); // 输出:parent_value
}).start();

实现局限

  • 不支持动态更新:子线程启动后父线程的修改不可见
  • 无回调机制:无法像ThreadLocalinitialValue()那样自定义子线程初始值

【中等】Java 中支持哪些原子类?

原子性是确保并发安全三大特性之一。为了兼顾原子性以及锁带来的性能问题,Java 引入了 CAS (主要体现在 Unsafe 类)来实现非阻塞同步(也叫乐观锁),CAS 底层基于 CPU 指令(硬件支持)支持,具有原子性。并基于 CAS ,提供了一套原子工具类。

原子类比锁的粒度更细,更轻量级,并且对于在多处理器系统上实现高性能的并发代码来说是非常关键的。原子变量将发生竞争的范围缩小到单个变量上。

原子类相当于一种泛化的 volatile 变量,能够支持原子的、有条件的读/改/写操作。

原子类可以分为 5 个类别,这 5 个类别提供的方法基本上是相似的:

  • 基本数据类型:基本数据类型原子类针对 Java 基本类型提供原子操作。
    • AtomicBoolean - 布尔类型原子类
    • AtomicInteger - 整型原子类
    • AtomicLong - 长整型原子类
  • 引用数据类型:Java 数据类型分为 基本数据类型引用数据类型 两大类(不了解 Java 数据类型划分可以参考: Java 基本数据类型 )。如果想针对引用类型做原子操作怎么办?Java 也提供了相关的原子类:
    • AtomicReference - 引用类型原子类
    • AtomicMarkableReference - 带有标记位的引用类型原子类
    • AtomicStampedReference - 带有版本号的引用类型原子类
  • 数组数据类型数组类型的原子类为数组元素提供了 volatile 类型的访问语义,这是普通数组所不具备的特性——**volatile 类型的数组仅在数组引用上具有 volatile 语义**。
    • AtomicIntegerArray - 整形数组原子类
    • AtomicLongArray - 长整型数组原子类
    • AtomicReferenceArray - 引用类型数组原子类
  • 属性更新器类型属性更新器支持基于反射机制的更新字段值的原子操作
    • AtomicIntegerFieldUpdater - 整型字段的原子更新器
    • AtomicLongFieldUpdater - 长整型字段的原子更新器
    • AtomicReferenceFieldUpdater - 原子更新引用类型里的字段
  • 累加器:相比原子化的基本数据类型,速度更快,但是不支持 compareAndSet() 方法。
    • DoubleAdder - 浮点型原子累加器
    • LongAdder - 长整型原子累加器。
    • DoubleAccumulator - 更复杂的浮点型原子累加器
    • LongAccumulator - 更复杂的长整型原子累加器

原子类底层实现

所有原子类都基于 Unsafe + CAS 实现:

1
2
3
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
  • Unsafe:直接操作内存(CAS 原子指令)
  • valueOffset:字段内存偏移量

适用场景

  • 读多写少AtomicXXX
  • 高并发写LongAdder
  • 无锁数据结构AtomicReference + CAS

注意事项

  • 原子类 不适用于复合操作(如 check-then-act,仍需锁)
  • LongAdder 适合统计,但 不保证实时精确值(调用 sum() 时才合并)。LongAdder 在操作后的返回值只是一个近似准确的数值,但是 LongAdder 最终返回的是一个准确的数值,所以在一些对实时性要求比较高的场景下,LongAdder 并不能取代 AtomicIntegerAtomicLong

Java 基础面试三

Java 泛型

【中等】Java 泛型的作用是什么?

::: info Java 泛型是什么?

:::

泛型允许在类、接口、方法上使用类型参数(如 <T>,使代码能适应多种数据类型,同时保证类型安全。

::: info Java 泛型有什么用?

:::

  • 类型安全:编译时检查类型,避免运行时 ClassCastException
  • 代码复用:同一套逻辑可处理不同数据类型(如 List<String>List<Integer>)。
  • 消除强制转换:直接使用泛型类型,无需手动转换(如 (String) list.get(0))。

::: info Java 泛型有什么特性?

:::

  • 类型擦除:泛型仅在编译时有效,运行时类型信息会被擦除(List<String> 运行时变成 List)。
  • **通配符 <?>**:表示未知类型(如 List<?> 可接受任意类型的 List)。
  • 界限限定
    • T extends Class(限定类型范围,如 <T extends Number>)。
    • <? super T>(支持父类类型)。

简单示例

1
2
3
4
5
6
7
8
9
10
11
// 泛型类
class Box<T> {
private T content;
public void set(T content) { this.content = content; }
public T get() { return content; }
}

// 使用
Box<String> box = new Box<>();
box.set("Hello");
String value = box.get(); // 无需强制转换

一句话总结:泛型让代码更灵活、安全,减少冗余和运行时错误。

【中等】什么是 Java 泛型的上下界限定符?

Java 泛型的上下界限定符用于限制泛型类型参数的范围,确保类型安全,提供更灵活的类型约束。

::: info Java 什么是上界限定符?有什么用?

:::

上界限定符(<? extends T> 限定泛型类型必须是 T 或其子类T 可以是类或接口)。

特点

  • 只读安全:能安全读取数据(因为元素至少是 T 类型)。
  • 不能写入:无法确定具体子类型,防止类型污染。

示例

1
2
3
4
5
6
7
// 接受 Number 或其子类(如 Integer, Double)
void printList(List<? extends Number> list) {
for (Number num : list) { // 安全读取
System.out.println(num);
}
// list.add(1); // 编译错误!无法安全写入
}

::: info Java 什么是下界限定符?有什么用?

:::

下界限定符(<? super T>)限定泛型类型必须是 T 或其父类

特点

  • 可写入:能安全添加 T 及其子类的对象。
  • 读取受限:只能以 Object 类型读取(因为父类型不确定)。

示例

1
2
3
4
5
6
7
// 接受 Integer 或其父类(如 Number, Object)
void addNumbers(List<? super Integer> list) {
list.add(1); // 安全写入 Integer
list.add(2);
// Integer num = list.get(0); // 编译错误!需强制转换
Object obj = list.get(0); // 只能以 Object 读取
}

通配符限定对比

类型 语法 读取 写入 应用
上界 ? extends T 安全(作为T) 禁止 生产者场景
下界 ? super T 需转Object 安全(T及子类) 消费者场景
无界 ? 作为Object 禁止 完全不确定类型

小结

  • **extends T**:安全读取,限制类型上界。如遍历 List<? extends Number>
  • **super T**:安全写入,限制类型下界。如 Collections.copy(dest<? super T>, src<? extends T>)
  • PECS 原则(Producer-Extends, Consumer-Super)指导何时用哪种限定符。
    • 生产者(Producer)extends(输出数据)。
    • 消费者(Consumer)super(输入数据)。

【中等】泛型擦除的作用是什么?

泛型擦除是 Java 在编译时检查类型安全运行时丢弃类型信息的折中设计,平衡了兼容性、性能和类型安全,但牺牲了部分运行时灵活性。

泛型擦除是 Java 泛型的实现机制:

  • 编译时:泛型类型(如 <T>List<String>)会被检查,确保类型安全。
  • 运行时:所有泛型类型信息会被擦除,替换为原始类型(Raw Type)边界类型(如 Object/extends 上限)

泛型擦除规则

泛型定义 擦除后类型 示例
无界限 <T> Object List<T>List
有界限 <T extends Number> Number(边界类型) Box<T>Box<Number>
通配符 <?> / <? extends T> 边界类型 List<?>List
<? super T> Object List<? super Integer>List

泛型擦除作用

  • 兼容性:确保泛型代码能与旧版 Java(非泛型)字节码兼容。
  • 运行时效率:避免为每个泛型类型生成新类,减少 JVM 负担。
  • 简化设计:统一类型系统,避免 C++ 模板的复杂性。

泛型擦除的问题

  • 类型信息丢失:运行时无法获取泛型参数(如 List<String>List<Integer> 运行时都是 List)。

    1
    2
    List<String> list = new ArrayList<>();
    System.out.println(list.getClass()); // 输出 ArrayList,而非 ArrayList<String>
  • 强制类型转换:编译器自动插入类型转换代码。

    1
    2
    List<String> list = new ArrayList<>();
    String s = list.get(0); // 编译后实际为:(String) list.get(0)
  • 不支持原生类型:不能直接使用 List<int>,必须用包装类(如 List<Integer>)。

绕过擦除的限制

  • **显式传递 Class<T>**:通过反射保留类型信息。

    1
    2
    3
    <T> void create(Class<T> clazz) {
    T instance = clazz.newInstance(); // 运行时知道具体类型
    }
  • 类型令牌(Type Token):利用匿名子类捕获泛型类型。

    1
    new TypeToken<List<String>>() {};  // Guava 提供的方案

典型问题与解决方案

问题场景 解决方案
需要运行时获取泛型类型 传递 Class<T> 参数或使用 Type Token
泛型数组创建(new T[] 使用 Object[] 转换或反射(Array.newInstance
方法重载冲突(如 void foo(List<String>)void foo(List<Integer>) 编译报错(擦除后方法签名相同)

Java 反射

【简单】什么是反射?反射有什么作用?

反射(Reflection)是 Java 提供的动态机制,允许程序在运行时

  • 获取类的信息(类名、方法、字段、注解等)
  • 操作类的成员(调用方法、访问/修改字段、创建对象等)
  • 绕过访问控制(如调用私有方法)

反射核心类

  • Class<T>:表示类或接口
  • Method:表示类的方法
  • Field:表示类的字段
  • Constructor:表示类的构造方法

反射的主要用途

  • 动态加载类(如插件化开发)
  • 框架设计(如 Spring 的依赖注入、Hibernate 的 ORM 映射)
  • 测试工具(如 Mockito 模拟对象)
  • 绕过访问限制(调试或特殊场景)

如何使用反射?

::: code-tabs#反射使用示例

@tab 获取 Class 对象

1
2
3
4
5
6
7
8
9
// 方式1:通过类名.class
Class<String> strClass = String.class;

// 方式2:通过对象.getClass()
String s = "Hello";
Class<?> strClass2 = s.getClass();

// 方式3:通过Class.forName("全限定类名")
Class<?> strClass3 = Class.forName("java.lang.String"); // 需处理ClassNotFoundException

@tab 创建对象

1
2
3
4
5
6
7
// 方式1:直接调用无参构造(需强制类型转换)
Class<?> clazz = Class.forName("com.example.User");
User user = (User) clazz.newInstance(); // 已过时,推荐用 getConstructor()

// 方式2:调用带参构造
Constructor<?> constructor = clazz.getConstructor(String.class, int.class);
User user = (User) constructor.newInstance("Alice", 25);

@tab 调用方法

1
2
3
4
5
6
7
8
9
// 获取方法(需方法名 + 参数类型)
Method method = clazz.getMethod("setName", String.class);

// 调用方法(需对象实例 + 参数值)
method.invoke(user, "Bob"); // 相当于 user.setName("Bob")

// 调用静态方法
Method staticMethod = clazz.getMethod("staticMethod");
staticMethod.invoke(null); // 静态方法传 null

@tab 访问/修改字段

1
2
3
4
5
6
7
8
9
10
11
// 获取字段(包括私有字段)
Field field = clazz.getDeclaredField("name");

// 允许访问私有字段
field.setAccessible(true); // 关闭访问检查

// 读取字段值
String name = (String) field.get(user); // 相当于 user.name

// 修改字段值
field.set(user, "Charlie"); // 相当于 user.name = "Charlie"

@tab 获取注解信息

1
2
3
4
5
// 获取类/方法/字段上的注解
Annotation[] annotations = clazz.getAnnotations();
if (clazz.isAnnotationPresent(MyAnnotation.class)) {
MyAnnotation anno = clazz.getAnnotation(MyAnnotation.class);
}

:::

【简单】反射有什么优缺点?

优点 缺点
动态性高(运行时决定行为) 性能较差(比直接调用慢)
可访问私有成员(突破封装) 代码可读性降低
支持泛型擦除后的类型操作 安全隐患(如破坏单例)

性能优化建议

  • 缓存 Class/Method/Field 对象:避免重复反射调用。
  • **优先使用 getDeclaredXXX**:比 getXXX 更高效(不检查继承链)。
  • **限制 setAccessible(true)**:频繁调用影响性能。

注意事项

  • 反射可以破坏封装性(如修改 final 字段、调用私有方法)。
  • **慎用 setAccessible(true)**:可能导致安全漏洞(如绕过权限检查)。

::: tip 扩展

Java Reflection: Why is it so slow?

:::

【中等】什么是 Java 中的动态代理?

动态代理是一种在运行时动态创建代理对象的技术,允许在不修改原始类代码的情况下,增强或拦截目标对象的方法调用。

Java 动态代理通过 ProxyInvocationHandler 在运行时生成接口代理对象,非侵入式地实现方法拦截和功能增强,是 AOP 和框架设计的核心技术。

  • **java.lang.reflect.Proxy**:提供静态方法创建代理对象(核心方法:Proxy.newProxyInstance())。
  • **java.lang.reflect.InvocationHandler**:接口,实现代理逻辑(核心方法:invoke())。

动态代理的特点

  • 运行时生成:代理类在运行时动态生成,无需手动编写。
  • 基于接口:只能代理接口(不能代理普通类)。
  • 非侵入性:无需修改原始代码即可增强功能。

应用场景

  • AOP(面向切面编程):如日志、事务管理(Spring AOP 基于动态代理)。
  • 远程方法调用(RPC):如 Dubbo 的消费者代理。
  • 权限控制:拦截方法调用检查权限。

动态代理 vs 静态代理

对比项 动态代理 静态代理
生成时机 运行时动态生成 编译时手动编写
维护成本 低(自动适配接口) 高(需为每个类编写代理)
灵活性 高(通用逻辑集中处理) 低(逻辑分散)

局限性

  • 仅支持接口代理:不能代理普通类(CGLIB 可弥补此问题)。
  • 性能开销:反射调用比直接调用略慢(现代 JVM 已优化)。

扩展:CGLIB 动态代理

  • 原理:通过字节码技术生成目标类的子类代理。
  • 特点:可代理普通类,但无法代理 final 类/方法。

【中等】JDK 动态代理和 CGLIB 动态代理有什么区别?

JDK 动态代理 vs. CGLIB 动态代理:

代理类型 JDK 动态代理 CGLIB 代理
实现机制 基于接口,运行时生成代理类($Proxy0 基于继承,生成目标类的子类
技术依赖 Java 反射 API(Proxy类) ASM 字节码操作库
限制条件 目标类必须实现接口 无法代理 final 类/方法
可代理目标 只能代理接口 可代理普通类和接口

性能对比

维度 JDK 动态代理 CGLIB 代理
生成速度 较快(反射生成) 较慢(需操作字节码)
调用速度 反射调用,略慢 直接方法调用,更快
内存占用 较小 较大(生成子类)

:现代 JVM 对反射做了优化,JDK 代理性能差距已不明显。

使用示例

::: code-tabs#反射使用示例

@tab JDK 动态代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 要求:目标类必须实现接口
public interface UserService {
void save();
}

// 代理逻辑
InvocationHandler handler = (proxy, method, args) -> {
System.out.println("JDK 代理前置处理");
Object result = method.invoke(target, args);
System.out.println("JDK 代理后置处理");
return result;
};

UserService proxy = (UserService) Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(), // 关键:需传入接口
handler
);

@tab CGLIB 代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 目标类无需实现接口
public class UserService {
public void save() { System.out.println("保存用户"); }
}

// 代理逻辑
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
System.out.println("CGLIB 代理前置处理");
Object result = proxy.invokeSuper(obj, args); // 直接调用父类方法
System.out.println("CGLIB 代理后置处理");
return result;
});

UserService proxy = (UserService) enhancer.create(); // 生成子类对象

:::

如何选择?

场景 推荐代理 理由
目标对象实现了接口 JDK 动态代理 轻量级,标准库支持
目标对象无接口 CGLIB 唯一选择
需要代理 final 方法 JDK 动态代理 CGLIB 无法代理 final 方法
高性能要求(如高频调用) CGLIB 直接方法调用更快
避免额外依赖 JDK 动态代理 CGLIB 需引入第三方库

主流框架的选择

  • Spring AOP
    • 默认使用 JDK 动态代理(如果目标有接口)
    • 无接口时自动切换为 CGLIB
    • 可通过 @EnableAspectJAutoProxy(proxyTargetClass=true) 强制使用 CGLIB
  • MyBatis:Mapper 接口代理使用 JDK 动态代理

一句话总结

  • JDK 动态代理:基于接口,反射实现,轻量但功能有限。
  • CGLIB:基于继承,字节码增强,功能强但有 final 限制。
  • 选择依据:目标是否有接口、性能需求、是否允许第三方依赖。

Java 注解

【中等】Java 中的注解原理是什么?

注解通过编译期处理(APT)或运行时反射实现元数据编程,其本质是特殊接口,由 JVM 或工具库按生命周期策略处理。

注解本质

  • 元数据标签:注解本质是继承自 java.lang.annotation.Annotation 的接口
  • 编译后保留策略:通过 @Retention 指定生命周期
    • SOURCE:仅保留在源码(如 @Override
    • CLASS:保留到字节码(默认)
    • RUNTIME:运行时可通过反射读取(如 @SpringBootApplication

核心处理机制

  • 编译期处理
    • APT(Annotation Processing Tool):在编译时生成代码(如 Lombok)
    • 编译器检查:如 @Override 验证方法重写
  • 运行时处理
    • 反射读取:通过 getAnnotation() 获取注解信息(如 Spring 扫描 @Component
    • 动态代理:结合 AOP 实现功能增强(如 @Transactional

关键技术点

  • 元注解:修饰注解的注解(如 @Target 指定作用目标)
  • 注解属性:本质是接口方法(需编译时常量值)
  • 字节码操作:ASM 等工具可直接修改字节码中的注解信息

应用场景

  • 框架配置:Spring 的 @Autowired@RequestMapping
  • 代码生成:Lombok 的 @Data
  • 静态检查@Nullable@Deprecated

Java SPI

【中等】什么是 SPI,有什么用?

SPI 通过接口+配置文件实现运行时服务发现,是解耦和扩展的利器,JDBC/日志等经典框架均基于此机制。

SPI 是 Java 提供的服务发现机制,通过接口与实现分离,实现:

  • 运行时动态加载实现类
  • 解耦接口与实现
  • 可插拔式扩展

核心组成

组件 作用 示例
接口 定义服务标准 java.sql.Driver
实现类 提供具体功能 com.mysql.cj.jdbc.Driver
配置文件 声明实现类 META-INF/services/接口全限定名

工作原理

  • META-INF/services/下创建以接口全限定名命名的文件
  • 文件中写入实现类全限定名(每行一个)
  • 通过ServiceLoader动态加载实现类

主要应用场景

  • JDBC 驱动加载DriverManager
  • 日志门面实现(SLF4J → Logback/Log4j)
  • Spring Boot 自动配置
  • Dubbo 扩展点机制

优势与局限

优势 局限
实现热插拔 配置文件需严格规范
解耦接口与实现 原生SPI会加载所有实现类(可能浪费资源)
扩展性强 无默认实现筛选机制

与API的区别

维度 SPI API
调用方向 由实现方提供,调用方选择 由提供方定义,调用方使用
控制权 调用方控制 提供方控制
典型场景 JDBC驱动、日志实现 Java标准库

改进方案

  • Dubbo SPI:增加按需加载、扩展点缓存等优化
  • Spring FactoriesMETA-INF/spring.factories机制

Java IO

【简单】什么是序列化?什么是反序列化?

基本概念

  • 序列化:将对象转换为字节流(用于存储/传输)
  • 反序列化:将字节流恢复为对象

核心用途

  • 持久化存储(如保存到文件/数据库)
  • 网络传输(如RPC调用)
  • 深拷贝实现(通过序列化+反序列化)

Java实现方式

方式 特点 示例
Serializable接口 标记接口,默认Java序列化 class User implements Serializable
Externalizable接口 需手动实现读写逻辑 覆盖writeExternal()/readExternal()
第三方库(JSON/Protobuf等) 跨语言、高效 Gson、Jackson、Protobuf

关键注意事项

  • **serialVersionUID**:显式声明版本号,避免反序列化失败

    1
    private static final long serialVersionUID = 1L;
  • 敏感字段处理:用transient跳过序列化

    1
    private transient String password;  // 不会被序列化
  • 性能优化

    • 避免序列化大对象
    • 第三方库(如Protobuf)比Java原生序列化更快

常见序列化协议对比

协议 语言支持 可读性 性能 典型应用
Java原生 仅Java Java RMI
JSON 多语言 Web API
Protobuf 多语言 gRPC
Hessian 多语言 Dubbo

安全风险

  • 反序列化漏洞:恶意字节流可触发代码执行(需校验数据来源)
  • 解决方案
    • 使用白名单控制反序列化类
    • 替换为JSON等文本协议

【中等】Java 提供了哪些 IO 方式?

Java 提供了多种 I/O(输入输出)方式,主要分为 传统 I/O(BIO)、NIO(New I/O)、AIO(异步 I/O) 三大类,并支持 文件操作、网络通信、序列化 等场景。以下是主要 I/O 方式的概述及要点:

::: info 什么是 BIO?

:::

传统 I/O(BIO,Blocking I/O)是同步阻塞式 I/O,适用于连接数较少、延迟不敏感的场景。

核心类

  • 字节流InputStream / OutputStream(如 FileInputStreamFileOutputStream
  • 字符流Reader / Writer(如 FileReaderFileWriter
  • 缓冲流BufferedReaderBufferedWriter(提升性能)
  • 标准 I/OSystem.in(输入)、System.out(输出)

示例

1
2
3
4
5
6
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}

缺点:每个连接需要独立的线程,高并发时资源消耗大。

::: info 什么是 NIO?

:::

NIO(New I/O,Non-blocking I/O)是同步非阻塞 I/O,基于 通道(Channel)缓冲区(Buffer),支持多路复用(Selector)。

核心类

  • BufferByteBufferCharBuffer(数据存储)
  • ChannelFileChannelSocketChannelServerSocketChannel(数据传输)
  • Selector:监听多个通道的事件(如连接、读、写)

示例(NIO 文件复制)

1
2
3
4
try (FileChannel src = FileChannel.open(Paths.get("src.txt"));
FileChannel dest = FileChannel.open(Paths.get("dest.txt"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
src.transferTo(0, src.size(), dest);
}
  • 优点:单线程可处理多个连接,适合高并发(如 Netty 框架底层)。
  • 缺点:编程复杂度较高。

::: info 什么是 AIO?

:::

AIO(Asynchronous I/O)是异步非阻塞 I/O,基于回调或 Future 机制,适用于高吞吐场景。

核心类

  • AsynchronousFileChannel(文件操作)
  • AsynchronousSocketChannel(网络通信)
  • CompletionHandler(回调接口)

示例(AIO 文件读取)

1
2
3
4
5
6
7
8
9
10
11
12
AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(Paths.get("file.txt"));
ByteBuffer buffer = ByteBuffer.allocate(1024);
fileChannel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer attachment) {
System.out.println("Read " + result + " bytes");
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
exc.printStackTrace();
}
});
  • 优点:真正异步,适合长连接、高吞吐场景(如大文件传输)。
  • 缺点:JDK 实现较少,Linux 支持有限(底层依赖 epoll)。

::: info 有哪些常见的 IO 工具?

:::

  • 序列化ObjectInputStream / ObjectOutputStream(Java 原生序列化)
  • 压缩流GZIPInputStreamZipOutputStream
  • 内存映射文件MappedByteBuffer(NIO 高性能文件访问)
  • Files 工具类(Java 7+):
    1
    Files.readAllLines(Paths.get("file.txt")); // 快速读取文件

::: info BIO vs. NIO vs. AIO?

:::

类型 模型 适用场景 典型框架
BIO 同步阻塞 低并发、简单 I/O Java Socket
NIO 同步非阻塞 高并发、网络通信 Netty、Tomcat NIO
AIO 异步非阻塞 高吞吐、大文件操作 较少使用

选择建议

  • BIO:简单文件操作或低并发场景。
  • NIO:高并发网络编程(如 Netty)。
  • AIO:需要真正异步 I/O 的场景(但实际使用较少)。

如果需要更高层次的封装,可以考虑 Apache Commons IOGuava 等工具库。

【困难】NIO 如何实现多路复用?

::: info Java NIO 的核心组件有哪些?

:::

Java NIO 多路复用的核心是通过 Selector 轮询事件 + 非阻塞 Channel + Buffer 数据交换,允许单线程管理多个通道的 I/O 操作。这是构建高性能网络应用的基础,也是 Netty 等框架的底层原理。

Java NIO 核心组件

  • Selector(选择器):核心多路复用器,可监控多个 Channel 的 I/O 事件(如连接、读、写)
    • 通过 Selector.open() 创建
    • 一个 Selector 可绑定多个 Channel
  • Channel(通道):非阻塞 I/O 操作的抽象,支持读写。主要类型:
    • SocketChannel:TCP 网络通信
    • ServerSocketChannel:监听 TCP 连接
    • FileChannel:文件 I/O(不支持 Selector)
  • Buffer(缓冲区):数据容器(如 ByteBuffer),Channel 通过 Buffer 读写数据。

::: info Java NIO 多路复用的实现步骤是怎样的?

:::

多路复用实现步骤

(1) 创建 Selector 并注册 Channel

1
2
3
4
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false); // 必须设为非阻塞
serverChannel.register(selector, SelectionKey.OP_ACCEPT); // 注册监听事件

(2) 事件类型

  • SelectionKey.OP_ACCEPT:接受连接(ServerSocketChannel
  • SelectionKey.OP_CONNECT:连接就绪(SocketChannel
  • SelectionKey.OP_READ:数据可读
  • SelectionKey.OP_WRITE:数据可写

(3) 事件轮询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while (true) {
int readyChannels = selector.select(); // 阻塞直到有事件就绪
if (readyChannels == 0) continue;

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();

if (key.isAcceptable()) {
// 处理新连接
} else if (key.isReadable()) {
// 处理读事件
} else if (key.isWritable()) {
// 处理写事件
}

keyIterator.remove(); // 必须移除已处理的键
}
}

::: info Java NIO 的关键机制有哪些?

:::

(1) 非阻塞模式

  • Channel 必须设置为非阻塞:channel.configureBlocking(false)
  • 避免单线程因 I/O 操作阻塞

(2) 事件驱动

  • Selector 通过操作系统级轮询(如 Linux 的 epoll)监听事件
  • 仅处理活跃的 Channel,避免无效遍历

(3) SelectionKey

  • 绑定 Channel 与 Selector 的关系
  • 可通过 key.attachment() 附加自定义对象(如会话状态)

::: info Java NIO 的底层原理是什么?

:::

  • Linux:基于 epoll 实现(高效监控大量文件描述符)
  • Windows:基于 IOCP(完成端口)
  • 相比传统 BIO 的线程池模型,NIO 单线程可处理数千连接

NIO 优点

  • 单线程管理多连接,资源消耗低
  • 高并发支持(如 Netty 框架底层依赖 NIO)
  • 避免线程上下文切换开销

NIO 适用场景

  • 高并发网络服务(如聊天服务器、API 网关)
  • 需要长连接的应用(如 WebSocket)
  • 大数据量、低延迟的 I/O 操作

Java 语法糖

【中等】Java 中有哪些常见的语法糖?

语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。

Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。所有这些语法糖在编译阶段都会被”脱糖”(desugar),即转换为更基础的Java语法结构。可以使用javap -c命令查看字节码来验证这一点。语法糖虽然不增加语言功能,但能显著提高代码的可读性和编写效率,是Java语言不断演进的重要组成部分。

自动装箱与拆箱 (Autoboxing/Unboxing)

1
2
3
4
5
// 自动装箱
Integer i = 10; // 实际编译为 Integer.valueOf(10)

// 自动拆箱
int n = i; // 实际编译为 i.intValue()

增强 for 循环 (foreach)

1
2
3
4
5
6
7
8
9
10
List<String> list = Arrays.asList("a", "b", "c");
// 语法糖形式
for (String s : list) {
System.out.println(s);
}
// 实际编译为迭代器模式
for (Iterator<String> it = list.iterator(); it.hasNext();) {
String s = it.next();
System.out.println(s);
}

变长参数 (Varargs)

1
2
3
4
5
6
7
public void print(String... args) {
for (String arg : args) {
System.out.println(arg);
}
}
// 实际编译为数组参数
public void print(String[] args) { ... }

数值字面量下划线

1
int million = 1_000_000;  // 编译后等同于 1000000

字符串拼接

1
2
3
4
5
6
7
8
9
String s = "a" + "b" + "c";
// 编译优化为
String s = "abc";

// 变量拼接会转为 StringBuilder
String a = "a", b = "b";
String result = a + b;
// 编译为
String result = new StringBuilder().append(a).append(b).toString();

switch 支持字符串 (Java 7+)

1
2
3
4
5
6
7
String fruit = "apple";
switch (fruit) {
case "apple":
System.out.println("It's an apple");
break;
// 实际编译为基于hashCode()和equals()的比较
}

默认构造方法

1
2
public class Person {}
// 如果没有显式定义构造方法,编译器会自动添加无参构造方法

枚举类 (Java 5+)

1
2
enum Color { RED, GREEN, BLUE }
// 实际编译为继承java.lang.Enum的类

内部类访问外部类成员

1
2
3
4
5
6
7
8
class Outer {
private int x = 10;
class Inner {
void print() {
System.out.println(x); // 实际通过 Outer.this.x 访问
}
}
}

方法引用 (Java 8+)

1
2
3
4
List<String> list = Arrays.asList("a", "b", "c");
list.forEach(System.out::println);
// 编译为lambda表达式
list.forEach(s -> System.out.println(s));

钻石操作符 (Diamond Operator, Java 7+)

1
2
3
List<String> list = new ArrayList<>();  // 类型推断
// Java 7之前需要
List<String> list = new ArrayList<String>();

集合字面量 (Java 9+ 的List.of等)

1
2
3
List<String> list = List.of("a", "b", "c");
Set<Integer> set = Set.of(1, 2, 3);
Map<String, Integer> map = Map.of("a", 1, "b", 2);

Lambda 表达式 (Java 8+)

1
2
3
// Lambda表达式
Runnable r = () -> System.out.println("Hello");
// 实际生成实现Runnable的匿名类

try-with-resources (Java 7+)

1
2
3
4
try (InputStream is = new FileInputStream("file.txt")) {
// 使用资源
} // 自动调用close()
// 编译为try-finally块

接口中的默认方法和静态方法 (Java 8+)

1
2
3
4
5
6
7
8
9
interface MyInterface {
default void defaultMethod() {
System.out.println("Default method");
}

static void staticMethod() {
System.out.println("Static method");
}
}

记录类 (Record, Java 14+)

1
2
3
4
5
6
record Point(int x, int y) {}
// 编译后自动生成:
// - 私有final字段x和y
// - 公共构造方法
// - 访问器方法x()和y()
// - equals(), hashCode(), toString()

instanceof 模式匹配

1
2
3
4
if (obj instanceof String s) {
// 可以直接使用s
System.out.println(s.length());
}

文本块 (Text Blocks, Java 15+)

1
2
3
4
5
6
7
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";

Java 基础面试二

Java 面向对象

【简单】对象实体与对象引用有何不同?

(1)对象是用来描述客观事物的一个抽象。一个对象由一组属性和对这组属性进行操作的一组服务组成。

(2)类是具有相同属性和方法的一组对象的集合,它为属于该类的所有对象提供了统一的抽象描述,其内部包括属性和方法两个主要部分。

(3)对象实体与对象引用的不同之处在于:

  • new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)
  • 一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
  • 一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。

【简单】接口和抽象类有什么区别?

(1)接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。

接口的主要特性有:

  • 接口不能实例化。
  • 接口不能包含任何非常量成员,任何字段都隐式的被 public static final 修饰。
  • 接口中没有非静态方法,也就是说要么是抽象方法,要么是静态方法。
  • 从 Java8 开始,接口增加了 default 方法特性,可以定义方法的默认实现;Java 9 以后,甚至可以定义私有的 default 方法。

(2)抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。

(3)接口和抽象类有什么相同点和不同点?

Java 中的类可以实现多个接口。

(4)与 C++ 等语言不一样,Java 类不支持多继承。这意味着,Java 不能通过继承多个抽象类来重用逻辑。那么,如何来实现重用呢?Java 的解决方案是:接口支持多继承,准确的说,接口支持扩展多个接口,而接口也支持实现多个接口。

【中等】什么是 Java 内部类?内部类有什么作用?

::: info 什么是内部类?
:::

内部类 (Inner Class) 是定义在另一个类内部的类。Java 中有四种类型的内部类:

  • 成员内部类:作为外部类的成员存在
  • 局部内部类:定义在方法或作用域内的类
  • 匿名内部类:没有名字的局部内部类
  • 静态嵌套类:用 static 修饰的嵌套类

::: info 内部类有什么作用?
:::

  • 逻辑分组:当某个类只对另一个类有用时,可以将其嵌入使用它的类中,保持代码在一起
  • 增强封装性:内部类可以访问外部类的私有成员,同时自身也可以对外部完全隐藏
  • 实现多重继承:通过内部类可以间接实现多重继承的效果
  • 回调机制:常用于事件处理和监听器实现
  • 代码简洁:特别是匿名内部类可以减少代码量

::: info 内部类有哪些特点?
:::

  • 内部类可以访问外部类的所有成员(包括 private)
  • 外部类需要通过实例化内部类来访问其成员
  • 内部类编译后会生成独立的。class 文件(格式:OuterClass$InnerClass.class)
  • 非静态内部类不能有静态成员(静态内部类可以)
  • 内部类可以继承其他类或实现接口

【简单】为什么 Java 不支持多重继承?

Java 不支持多重继承的核心原因是为了避免【菱形继承问题(Diamond Problem)】

::: info 什么是菱形继承问题?
:::

菱形继承存在歧义性:

  • 如果类 C 继承自类 A 和类 B,而 A 和 B 都有同名方法 method()
  • 调用 C.method() 时无法确定应该调用 A 还是 B 的版本

由于菱形继承歧义性而引发的复杂性增加问题:

  • 多重继承会显著增加编译器和 JVM 的实现复杂度
  • 方法调用、构造函数调用顺序变得难以确定

::: info Java 如何解决多重继承?
:::

在 Java 中,类可以实现多个接口。接口提供多重继承的行为规范,但不包含具体实现。

JDK8 之后,接口支持默认方法(default),是不是又出现了菱形继承问题?

为了规避这个问题,Java 强制规定,如果多个接口存在相同的默认方法,子类必须重写这个方法。否则,编译器会报错。

【中等】深拷贝和浅拷贝有什么区别?

::: info 深拷贝和浅拷贝有什么区别?
:::

关键点 浅拷贝 深拷贝
复制对象 只复制对象本身(基本类型值拷贝) 递归复制对象及其引用的所有子对象
引用类型字段 新旧对象共享同一引用(修改相互影响) 创建全新引用对象(修改完全隔离)
内存开销 小(仅复制一层) 大(递归复制所有关联对象)
实现方式 默认Object.clone() 需手动实现递归克隆/序列化/工具类
适用场景 对象无可变引用字段 对象含可变引用字段且需完全独立

本质区别:浅拷贝是”复制钥匙”,深拷贝是”复制钥匙+保险箱”。

注意事项

  • 深拷贝需处理循环引用问题
  • 推荐使用SerializationUtils.clone()或 JSON 序列化实现深拷贝
  • 不可变对象(如 String)的浅拷贝是安全的

::: info 深拷贝和浅拷贝实现方式有什么区别?
:::

实现方式对比

方法 浅拷贝 深拷贝 说明
Object.clone() 默认浅拷贝
手动递归克隆 需所有引用类型实现Cloneable
序列化反序列化 通过ObjectOutputStream实现
工具类(Apache Commons) SerializationUtils.clone()

::: code-tabs#深拷贝和浅拷贝实现示例

@tab 浅拷贝实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person implements Cloneable {
String name;
Address address; // 引用类型字段

@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 默认浅拷贝
}
}

// 测试
Person p1 = new Person("Alice", new Address("北京"));
Person p2 = (Person)p1.clone();
p2.address.city = "上海"; // p1.address.city 也会变成"上海"

@tab 深拷贝实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected Object clone() throws CloneNotSupportedException {
Person cloned = (Person)super.clone();
cloned.address = (Address)address.clone(); // 手动复制引用对象
return cloned;
}

// Address 类也需实现 Cloneable
class Address implements Cloneable {
String city;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

:::

【简单】面向对象和面向过程有什么区别?

面向对象和面向过程的主要区别:

维度 面向对象(OOP) 面向过程(POP)
核心思想 对象为中心 步骤为中心
代码组织 现实实体抽象为类 功能流程拆分为函数
数据管理 数据与行为封装在对象中 数据与函数独立
扩展方式 通过继承/多态扩展(开闭原则) 需修改函数逻辑
典型特性 封装、继承、多态三大特性 无三大特性
典型语言 Java, Python, C++ C, Pascal

【中等】面向对象三大特征和五大原则是什么?

::: info 面向对象三大特征是什么?
:::

面向对象三大特征:

  • 封装(Encapsulation)隐藏内部细节,暴露安全接口

    • private 保护数据,通过 getter/setter 控制访问
    • 示例:BankAccount 类隐藏余额,提供 deposit()/withdraw() 方法
  • 继承(Inheritance)子类复用父类属性和方法

    • 通过 extends 实现(如 Dog extends Animal
    • 注意:Java 是单继承(一个子类只能有一个父类)
  • 多态(Polymorphism)同一行为的不同实现方式

    • 编译时多态:方法重载(Overload
    • 运行时多态:方法重写(Override)+ 父类引用指向子类对象(如 Animal a = new Dog(); a.sound();

一言以概之封装保证安全性,继承提高复用性,多态增强扩展性

::: info 面向对象的五大原则是什么?
:::

面向对象的五大原则是 SOLID 原则:

  • 单一职责原则 (SRP)一个类只负责一个功能,避免职责过多导致代码臃肿。
  • 开闭原则 (OCP)对扩展开放,对修改关闭。通过抽象和继承扩展功能,而非直接修改原有代码。
  • 里氏替换原则 (LSP)子类必须能替换父类,确保继承关系不会破坏程序逻辑。
  • 接口隔离原则 (ISP)接口应当小而专,避免臃肿接口强制实现不必要的方法。
  • 依赖倒置原则 (DIP)依赖抽象而非具体,高层模块不直接依赖低层模块,而是通过接口或抽象类交互。

一言以概之:SOLID 原则让代码更灵活、可维护、易扩展。

设计模式

典型问题

(1)你知道哪些设计模式?

(2)你知道哪些设计模式在 Java 源码中的应用案例?

(3)你知道哪些设计模式在主流框架中的应用案例?

知识点

(1)23 种经典设计模式分类如下:

  • 创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)。
  • 结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。
  • 行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。

(2)设计模式在 Java 源码中应用的经典案例:

InputStream 是一个抽象类,标准类库中提供了 FileInputStream、ByteArrayInputStream 等各种不同的子类,分别从不同角度对 InputStream 进行了功能扩展,这是典型的装饰器模式应用案例。

(3)设计模式在主流框架中应用的经典案例:

如 Spring 等如何在 API 设计中使用设计模式。你至少要有个大体的印象,如:

  • BeanFactoryApplicationContext 应用了工厂模式。
  • 在 Bean 的创建中,Spring 也为不同 scope 定义的对象,提供了单例和原型等模式实现。
  • Spring Aop 使用了代理模式、装饰器模式、适配器模式等。
  • 各种事件监听器,是观察者模式的典型应用。
  • 类似 JdbcTemplate 等则是应用了模板模式。

Object

【简单】Object 类的常见方法有哪些?

Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:

方法签名 作用 默认行为
String toString() 返回对象的字符串表示 类名@十六进制哈希码(如 Person@1b6d3586
boolean equals(Object obj) 比较两个对象是否逻辑相等 比较内存地址(==
int hashCode() 返回对象的哈希码 基于内存地址生成
Class<?> getClass() 返回对象的运行时类(Class 对象) 由 JVM 提供
protected Object clone() 创建并返回对象的副本 浅拷贝(需实现 Cloneable 接口)
protected void finalize() 已废弃,对象被 GC 回收前调用 空实现(不推荐使用)
void notify() 唤醒一个等待该对象监视器的线程 依赖 JVM 实现
void notifyAll() 唤醒所有等待该对象监视器的线程 依赖 JVM 实现
void wait() 让当前线程等待,直到被唤醒 必须在同步代码块中调用
void wait(long timeout) 让线程等待,最多 timeout 毫秒 超时后自动唤醒
void wait(long timeout, int nanos) 更精确的等待(纳秒级) 实际精度依赖系统

【简单】== 和 equals() 有什么区别?

对比项 == equals()
基本类型比较 比较 不能比较
引用类型比较 比较内存地址 默认比较内存地址(同 ==),但可重写为逻辑比较(如内容是否相同)
是否可重写 否(运算符,行为固定) 是(可自定义比较逻辑)
用途 快速判断基本类型值相等或引用是否指向同一对象 判断对象逻辑是否相等(如内容、属性等)

【简单】为什么重写 equals() 时必须重写 hashCode() 方法?

因为 Java 规定:两个对象若equals()相等,它们的hashCode()必须相同

如果违背,则哈希集合(如 HashMapHashSet)无法正确去重或查找。

  • HashMap/HashSet 先通过 hashCode() 快速定位数据,再用 equals() 精确匹配。
  • hashCode() 不一致,即使 equals()true,集合会误判为不同对象。

::: info 如何正确重写 hashCode()
:::

  • **equals()**:比较所有关键字段(如 nameage)。
  • **hashCode()**:用 Objects.hash(字段1, 字段2) 生成(确保与 equals() 字段一致)。

::: tip 扩展

Java hashCode() 和 equals() 的若干问题解答

:::

【简单】finalize 有什么用?

一言以概之,**finalize 可用于对象销毁前的清理,但不可靠且性能差,现代 Java 开发应避免使用,改用 AutoCloseableCleaner。**

**Java 9+ 已弃用 finalize**,推荐使用:

  • try-with-resources(实现 AutoCloseable 接口)
  • CleanerPhantomReference(更可控的清理机制)。

finalize 的作用(Java)

  • 对象被垃圾回收前的清理:在对象被 GC 回收前,finalize() 会被调用,可用于释放非内存资源(如文件句柄、数据库连接等)。
  • 最后的补救机会:如果对象未被正确关闭,finalize 提供最后一次资源释放的机会。

finalize 的问题

  • 不保证执行:JVM 不保证 finalize 一定会执行(如程序突然终止时)。即使对象可达性失效,GC 可能延迟回收,导致 finalize 延迟调用。
  • 性能开销:覆写 finalize 的对象会被 JVM 放入特殊队列,垃圾回收变慢。可能引发内存泄漏(如果 finalize 阻塞或执行过久)。
  • 安全问题:在 finalize 中抛出异常会导致清理中断,且异常被忽略。可能被恶意代码利用(如通过重写 finalize 复活对象,干扰 GC)。

String

【简单】String、StringBuffer、StringBuilder 的区别?

特性 String StringBuffer StringBuilder
可变性 ❌ 不可变 ✅ 可变 ✅ 可变
线程安全 ✅(因不可变) ✅(同步方法) ❌(非线程安全)
性能 ⚠️ 最差(频繁创建新对象) ⚠️ 中等(同步开销) ✅ 最高(无同步开销)
适用场景 常量、少量拼接 多线程字符串操作 单线程字符串操作(推荐)

概括

  • String 存储常量StringBuilder 高效拼接(单线程)StringBuffer 保证线程安全(多线程)
  • **优先选 StringBuilder**(90%场景适用)。

【简单】String 为什么是不可变的?

String 的不可变性是 Java 为安全、性能、线程安全做的核心设计。

String 不可变的核心原因

  • final 修饰的 char[] 数组:Java 中 String 内部用 private final char[](JDK 9+ 改为 byte[])存储数据,数组引用和内容均不可修改。
  • 无修改内部状态的方法:所有看似“修改”的方法(如 concat()substring())都返回String 对象,原对象不变。

设计安全优化

  • 线程安全:不可变天然线程安全,无需同步。
  • 缓存哈希值StringhashCode() 计算结果可缓存(因内容不变),提升性能(如 HashMap 的键)。
  • 字符串常量池复用:如 String s = "abc" 会复用常量池中的相同字符串,减少内存开销。

为什么这样设计

  • 安全:防止恶意修改(如网络请求参数、数据库连接字符串被篡改)。
  • 性能:哈希缓存、常量池复用提升效率。
  • 简单:避免多线程同步问题。

示例验证不可变性

1
2
3
4
String s1 = "Hello";
String s2 = s1.concat(" World");
System.out.println(s1); // 输出 "Hello"(原字符串未变)
System.out.println(s2); // 输出 "Hello World"(新对象)

【简单】字符串拼接用“+” 还是 StringBuilder?

循环/动态拼接 → StringBuilder;简单常量拼接 → “+”;多线程 → StringBuffer(极少用)。
StringBuilder 是默认推荐选择!

优先用 StringBuilder(大多数场景)

  • 适用情况:循环、动态拼接、大量字符串操作。
  • 原因
    • 高性能:直接修改缓冲区,避免 + 频繁创建新对象。
    • 低内存开销:减少临时对象和 GC 压力。

简单拼接可用 “+”(编译期优化)

  • 适用情况:少量固定字符串拼接(如 "a" + "b")。
  • 原因
    • 代码简洁:可读性更好。
    • 编译器优化:JVM 自动合并为常量(如 "ab"),无性能损失。
    • 通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的。
    • 在循环内使用“+”,会导致创建过多的 StringBuilder 对象。JDK9 中,优化了这个问题,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了。

多线程拼接用 StringBuffer(极少需要)

  • 适用情况:多线程环境且需线程安全(通常局部变量仍可用 StringBuilder)。

::: tip 扩展

StringBuilder?来重温一下字符串拼接吧

:::

【简单】String#equals() 和 Object#equals() 有何区别?

对比项 Object#equals() String#equals()
默认行为 比较内存地址== 比较字符串内容(逐字符对比)
重写目的 需子类自行重写以实现逻辑相等 已优化为内容比较,满足字符串业务需求
性能影响 无额外开销 需遍历字符数组,但优先检查地址和长度
使用场景 通用对象比较(默认不满足内容相等) 字符串内容对比(如 "abc".equals("abc")

【简单】字符串常量池有什么用?

字符串常量池是JVM 的特殊内存区域,用于存储字符串字面量(如 "abc"),确保相同内容的字符串只存一份。

字符串常量池通过复用相同字符串,节省内存并提升性能,直接赋值("abc")优先使用池,new String() 强制创建新对象。

字符串常量池的作用有:

节省内存:相同字符串复用,避免重复创建(如 String s1 = "hello"String s2 = "hello" 指向同一对象)。

提升性能

  • 快速比较:直接通过 == 判断地址是否相同(比 equals() 更快)。
  • 哈希优化:如 HashMap 的键可复用缓存的 hashCode

实现规则

  • 直接赋值String s = "abc")→ 优先从常量池引用
  • new String("abc")强制在堆中创建新对象(不推荐,除非需隔离实例)。
  • intern() 方法 → 将堆中的字符串对象添加到常量池(若池中不存在)。

注意事项

  • **避免滥用 new String()**:无特殊需求时,直接用字面量赋值。
  • intern() 慎用:可能增加常量池内存压力,需权衡性能。

【简单】String s = new String("abc") 创建了几个字符串对象?

new String("abc") 可能创建1~2个对象(取决于常量池是否已存在”abc”),但堆中的新对象必定创建。

  • 常量池已存在”abc”1个对象(仅堆中的 new String
  • 常量池不存在”abc”2个对象(常量池的”abc” + 堆中的 new String

【简单】String#intern 方法有什么用?

String#intern 方法的作用有:

  • 强制字符串入池:将堆中的 String 对象添加到字符串常量池(若池中不存在)
  • 返回池中引用:保证相同内容的字符串始终返回同一内存地址

注意

  • JDK7+ 优化:常量池从方法区移至堆内存,减少内存溢出风险。
  • 慎用场景
    • 避免对动态生成的短生命周期字符串使用(可能导致池膨胀)
    • 优先用于高频使用的静态字符串(如配置键值)

【简单】String 类型的变量和常量做“+”运算时会发生什么?

常量相加编译期优化,变量相加隐式转 StringBuilder,循环拼接必须显式使用 StringBuilder 避免性能损耗。

常量折叠(编译期优化)

  • 纯常量运算(如 "a"+"b")→ 直接合并为 "ab",仅存于常量池
  • final 变量 视为常量,同样触发优化

变量拼接(运行时行为)

  • 含变量的运算(如 str + "b")→ 隐式转换为 StringBuilder 操作
    1
    2
    // 实际执行逻辑
    new StringBuilder().append(str).append("b").toString()
  • 每次运算 生成临时 StringBuilder 和最终 String 对象

性能关键差异

场景 内存/性能表现 优化建议
常量+常量 零运行时开销 无需处理
单次变量+常量 1次 StringBuilder 创建 可接受
循环内拼接 多次创建 StringBuilder(性能陷阱) 必须显式用 StringBuilder

最佳实践

  • 简单拼接:直接使用 +(可读性优先)

  • 循环/批量拼接

    1
    2
    3
    4
    5
    6
    7
    8
    // ✅ 正确写法
    StringBuilder sb = new StringBuilder();
    for (String str : list) sb.append(str);
    String result = sb.toString();

    // ❌ 错误写法(低效)
    String s = "";
    for (String str : list) s += str; // 每次循环隐式新建 StringBuilder

Java 容器面试一

Java 容器简介

【简单】Java 中有哪些集合类?

img

Java 容器类主要位于 java.util 包,分为 CollectionMap 两大类:

  • Collection(存储独立元素)
    • List(有序、可重复)
      • ArrayList:基于 Object[] 动态数组,查询快,增删慢
      • LinkedList:基于双链表(JDK1.6 前是循环链表,1.7 取消循环),增删快,查询慢
      • Vector:线程安全的 Object[] 动态数组(已过时,推荐 ArrayList + Collections.synchronizedList
    • Set(无序、不可重复)
      • HashSet:基于 HashMap 实现,不保证顺序
      • LinkedHashSet:基于 LinkedHashMap,维护插入顺序
      • TreeSet:基于 TreeMap,支持自然排序自定义 Comparator
    • Queue(队列,FIFO 或优先级)
      • ArrayDeque:基于动态数组,实现栈和队列
      • PriorityQueue:基于堆,优先级队列(按 Comparator 排序)
      • LinkedList:也可作为队列/双端队列
  • Map(键值对存储)
    • HashMap:基于哈希表,无序,查找高效(最常用)
    • LinkedHashMap:继承 HashMap,额外维护双向链表,保持插入顺序访问顺序
    • TreeMap:基于红黑树,键有序(自然排序或 Comparator
    • Hashtable:线程安全(synchronized 修饰方法),但性能差,已被 ConcurrentHashMap 取代
    • ConcurrentHashMap:分段锁(JDK7)或 CAS + synchronized(JDK8+),高并发优化
  • 工具类
    • Collections:提供集合操作(排序、查找、同步化等)
    • Arrays:提供数组操作(排序、二分查找等)
    • Stream(Java 8+):支持函数式编程的流式处理

关键区别

类型 特点 主要实现类
List 有序、可重复 ArrayListLinkedList
Set 无序、不可重复 HashSetLinkedHashSetTreeSet
Queue 队列/栈 ArrayDequePriorityQueue
Map 键值对 HashMapLinkedHashMapTreeMap

线程安全

  • 单线程:ArrayListHashMap
  • 多线程:ConcurrentHashMapCopyOnWriteArrayList

【简单】Comparable 和 Comparator 有什么区别?

Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用。

  • Comparable → “我能比较”(类自己实现的比较能力)
  • Comparator → “比较器”(外部提供的比较工具)

两者通常一起使用,为Java对象提供灵活多样的排序能力。

Comparable vs. Comparator

特性 Comparable Comparator
包位置 java.lang java.util
接口方法 compareTo(T o) compare(T o1, T o2)
排序逻辑位置 定义在要排序的类内部 定义在单独的类或匿名类中
使用场景 类的”自然排序” 多种排序方式或无法修改类时的排序
调用方式 Collections.sort(list) Collections.sort(list, comparator)
影响范围 修改类的原始定义 不修改原有类

设计目的不同

  • Comparable:定义对象的自然排序(如String按字母顺序,Integer按数值大小)
  • Comparator:定义多种排序策略或为无法修改源代码的类提供排序

实现方式不同

  • Comparable:需要修改类本身,实现compareTo()方法
1
2
3
4
5
class Person implements Comparable<Person> {
public int compareTo(Person other) {
return this.age - other.age;
}
}
  • Comparator:独立实现,通常使用匿名类或lambda表达式
1
Comparator<Person> byName = (p1, p2) -> p1.getName().compareTo(p2.getName());

使用场景选择

  • Comparable当:
    • 类有明确的自然排序标准
    • 你能修改类的源代码
    • 只需要一种主要排序方式
  • Comparator当:
    • 需要多种排序方式(如按姓名、年龄、工资等)
    • 不能修改类的源代码(如第三方库的类)
    • 需要临时或特殊的排序规则

Java 8+的便利支持

  • Comparator提供了许多方便的静态方法:
1
2
3
4
5
6
7
8
// 多级排序
Comparator<Person> comparator =
Comparator.comparing(Person::getLastName)
.thenComparing(Person::getFirstName);

// 逆序排序
Comparator<Person> reverseAge =
Comparator.comparingInt(Person::getAge).reversed();

List

【简单】ArrayList 和 Array(数组)的区别?

ArrayList vs. 数组

对比点 数组 (Array) ArrayList
长度可变性 固定长度,创建后无法调整大小 动态扩容(默认扩容1.5倍)
存储类型 支持基本类型(int[])和对象类型 仅支持引用类型(基本类型需装箱,如 Integer
内存占用 更紧凑(无额外对象开销) 有额外内存开销(记录大小、扩容预留空间等)
访问方式 通过索引直接访问(arr[0] 通过 get(index)/set(index) 方法访问
操作效率 - 查询:O(1)(极快)
- 增删:O(n)(需移动元素)
- 查询:O(1)(底层是数组)
- 增删:
- 尾部操作:O(1)
- 中间操作:O(n)(需移动元素)
功能方法 功能简单(依赖 Arrays 工具类) 提供丰富方法(add()remove()contains() 等)
线程安全 非线程安全 非线程安全(需用 Collections.synchronizedList 包装)
泛型支持 不支持泛型(类型检查在运行时) 支持泛型(编译时类型安全)

小结

  • 动态性ArrayList 自动扩容,数组长度固定。
  • 类型支持:数组可直接存基本类型,ArrayList 需包装类。
  • 性能
    • 数组的随机访问稍快(少一次方法调用)。
    • ArrayList 的尾部插入高效,但中间插入/删除需移动元素。
  • 功能ArrayList 提供更多便捷方法(如迭代、搜索)。
  • 内存:数组更节省内存,ArrayList 有额外结构开销。

应用

  • 选数组:需极致性能、固定长度或存储基本类型时(如数学计算)。
  • 选ArrayList:需要动态大小、便捷操作或泛型安全时(大多数业务场景)。

【简单】ArrayList 可以添加 null 值吗?

ArrayList 可以添加任意数量的 null,包括重复 null,但需谨慎处理潜在的空指针问题。

ArrayList底层基于 Object[] 数组实现,天然支持 null

注意

  • **可能引发 NullPointerException**:
    • 直接调用 null 的方法(如 list.get(0).length())会报错。
    • 使用 contains(null) 或遍历时需判空。
  • 慎用于特定场景:如数据库映射、JSON 序列化工具可能对 null 有特殊限制。

与其他容器对比

  • HashSet:允许一个 null
  • TreeSet:若用自然排序,添加 null 会抛 NullPointerException
  • HashMap:允许 null 键和值。
  • Hashtable:禁止 null 键和值。

建议

  • 明确是否需要 null,避免滥用导致代码健壮性问题。
  • 必要时用 Optional 或默认值替代 null

【简单】ArrayList 和 LinkedList 有什么区别?

ArrayList vs. LinkedList

对比维度 ArrayList LinkedList
底层数据结构 动态数组(Object[] 双向链表(Node 节点)
内存占用 更紧凑(连续内存) 更高(每个元素需额外存储前后节点指针)
随机访问性能 ⚡ **O(1)**(通过索引直接访问) 🐢 **O(n)**(需遍历链表)
插入/删除性能 - 尾部操作:⚡ O(1)
- 中间/头部操作:🐢 **O(n)**(需移动元素)
- 头尾操作:⚡ O(1)
- 中间操作:🐢 **O(n)**(需遍历定位)
适用场景 - 频繁随机访问
- 数据量稳定或尾部操作多
- 频繁头尾插入/删除
- 数据动态性强
额外功能 仅基础列表操作 实现了 Deque 接口(可作队列/栈使用)
空间局部性 ✅ 更好(CPU 缓存友好) ❌ 较差(节点分散存储)

对比小结
List 需遍历链表。 2. **增删效率**:ArrayList 尾部插入快,中间/头部插入慢;Lin

  1. 访问速度ArrayList 随机访问极快(数组索引),LinkedkedList 头尾插入快,中间插入仍需遍历。
  2. 内存开销LinkedList 每个元素多消耗 2 个指针空间(前驱+后继)。
  3. 功能扩展LinkedList 支持队列/栈操作(如 addFirst(), pollLast())。

选型建议

  • 优先用 **ArrayList**(大多数场景性能更优)。
  • 仅当需要频繁在 头部/中间插入删除,或需要 队列/栈功能 时选 LinkedList

💡 Java 实践提示

  • 默认情况下,Collections.synchronizedList 包装的 ArrayListLinkedList 线程安全开销更低。
  • Java 8+ 的 Stream 操作在 ArrayList 上效率更高。

Set

【简单】HashSet、LinkedHashSet 和 TreeSet 有什么区别?

特性 HashSet LinkedHashSet TreeSet
底层实现 哈希表 (HashMap) 哈希表 + 链表 红黑树
排序保证 无顺序 插入顺序 自然顺序/自定义排序
时间复杂度 添加/删除/查找: O(1) 添加/删除/查找: O(1) 添加/删除/查找: O(log n)
允许null元素 允许1个null 允许1个null 不允许(除非自定义Comparator允许)
线程安全 非线程安全 非线程安全 非线程安全
性能特点 最快的基础操作 比HashSet稍慢但保持顺序 最慢但自动排序
使用场景 只需唯一性不关心顺序 需要保持插入顺序 需要排序的集合

顺序特性

  • HashSet:完全不保证任何顺序(基于哈希值存储)
  • LinkedHashSet:维护元素插入顺序(迭代时按插入顺序返回)
  • TreeSet:根据元素的自然顺序Comparator进行排序

性能比较

  • 操作速度:HashSet ≈ LinkedHashSet > TreeSet
  • 内存占用:LinkedHashSet > HashSet > TreeSet
  • 迭代性能:LinkedHashSet最优(顺序访问快)

实现原理

  • HashSet:基于HashMap实现,只使用键
  • LinkedHashSet:继承HashSet,通过链表维护插入顺序
  • TreeSet:基于TreeMap实现(红黑树结构)

构造方式

1
2
3
4
5
6
7
8
9
10
11
// HashSet
Set<String> hashSet = new HashSet<>();

// LinkedHashSet
Set<String> linkedHashSet = new LinkedHashSet<>();

// TreeSet - 自然排序
Set<String> treeSet = new TreeSet<>();

// TreeSet - 自定义排序
Set<String> customTreeSet = new TreeSet<>(Comparator.reverseOrder());

使用场景建议

  • 需要最快查询且不关心顺序 → HashSet
  • 需要保持插入顺序 → LinkedHashSet
  • 需要自动排序范围查询 → TreeSet
  • 需要频繁迭代 → LinkedHashSet

特殊注意事项

  • 相等性判断

    • 三者都使用equals()方法判断元素是否相同
    • TreeSet同时会使用compareTo()compare()方法(必须与equals逻辑一致)
  • TreeSet排序规则

    • 元素必须实现Comparable接口,或在构造时提供Comparator
    • 否则会抛出ClassCastException
  • 线程安全替代方案

    1
    2
    Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
    Set<String> syncTreeSet = Collections.synchronizedSet(new TreeSet<>());

选择哪种Set实现取决于你的具体需求:要速度(HashSet)、要插入顺序(LinkedHashSet)还是要自动排序(TreeSet)。

Queue

【简单】Queue 与 Deque 有什么区别?

::: info Queue vs. Deque
:::

特性 Queue (队列) Deque (双端队列)
进出原则 先进先出 (FIFO) 两端都可进出 (FIFO + LIFO)
主要操作 队尾入队(add/offer),队首出队(remove/poll) 支持队首/队尾的入队和出队操作
继承关系 基础接口 继承自 Queue 接口
代表子类 LinkedList, PriorityQueue ArrayDeque, LinkedList
特殊功能 - 支持栈操作(push/pop/peek)

基本操作对比

::: code-tabs#重载和重写的示例

@tab Queue 操作

1
2
3
4
5
6
queue.offer(e);  // 队尾添加(推荐)
queue.add(e); // 队尾添加(可能抛异常)
queue.poll(); // 队首移除并返回(推荐)
queue.remove(); // 队首移除并返回(可能抛异常)
queue.peek(); // 查看队首(不移除)
queue.element(); // 查看队首(可能抛异常)

@tab Deque 扩展操作

1
2
3
4
5
6
7
8
9
10
11
12
13
// 队首操作
deque.offerFirst(e); deque.addFirst(e);
deque.pollFirst(); deque.removeFirst();
deque.peekFirst(); deque.getFirst();

// 队尾操作
deque.offerLast(e); deque.addLast(e);
deque.pollLast(); deque.removeLast();
deque.peekLast(); deque.getLast();

// 栈操作
deque.push(e); // = addFirst(e)
deque.pop(); // = removeFirst()

:::

使用场景差异

  • Queue 适用场景(标准的先进先出场景)
    • 任务调度系统(先来先服务)
    • 消息队列(生产者-消费者模型)
    • 广度优先搜索(BFS)
  • Deque 适用场景(需要两端操作的场景)
    • 撤销操作历史(两端添加,一端移除)
    • 滑动窗口算法
    • 可同时作为队列和栈使用
    • 工作窃取算法(如ForkJoinPool使用Deque)
    • 实现高效的头尾操作(ArrayDeque比LinkedList更高效)

小结:

  • 需要标准队列行为 → 选择 Queue
  • 需要两端操作栈功能 → 选择 Deque
  • 需要优先级排序 → 使用 PriorityQueue(Queue实现)
  • 追求高性能 → 优先考虑 ArrayDeque(优于 LinkedList)

性能特点

  • ArrayDeque(Deque实现)比LinkedList
    • 内存更紧凑(数组实现)
    • 大多数操作更高效(O(1)时间)
    • 但不适合频繁的中间插入/删除
  • PriorityQueue(Queue实现):
    • 基于堆结构
    • 保证每次取出的都是优先级最高的元素(O(log n)时间)

线程安全注意

  • 两者主要实现类(LinkedList/ArrayDeque)都非线程安全
  • 线程安全替代方案:
    1
    2
    Queue<String> safeQueue = new ConcurrentLinkedQueue<>();
    Deque<String> safeDeque = new ConcurrentLinkedDeque<>();

【简单】ArrayDeque 与 LinkedList 有什么区别?

  • **性能优先选 ArrayDeque**:队列/栈场景,追求更高吞吐和更低内存。
  • **功能灵活选 LinkedList**:需要中间操作、随机访问或混合数据结构时。

以下是 ArrayDequeLinkedList 的对比表格,清晰概括两者的核心差异:

对比项 ArrayDeque LinkedList
底层数据结构 动态数组(循环数组) 双向链表
内存占用 更低(连续存储,无节点开销) 更高(每个元素需存储前后节点引用)
头部/尾部操作 O(1),常数时间更优 O(1),但实际更慢(需操作节点)
中间插入/删除 O(n)(需移动元素) O(1)(已知位置时)
随机访问 理论上 O(1),但通常不支持直接索引操作 O(n)(需遍历链表)
扩容机制 动态扩容(默认翻倍),扩容时有开销 无扩容概念,按需分配节点
功能支持 仅双端队列操作(Deque 同时实现 ListDeque,支持索引和中间操作
线程安全 非线程安全 非线程安全
迭代效率 更高(连续内存访问) 较低(非连续内存访问)
适用场景 高频双端操作(如栈、队列) 需要中间操作或混合 List/Deque 需求的场景

【简单】PriorityQueue 有什么用?

PriorityQueue 是自动排序的堆结构队列,默认小顶堆,适用优先级调度,但线程不安全。

基本特性

  • 基于堆(默认小顶堆),元素按优先级出队(最小/最大值先出)。
  • 无界队列(自动扩容),但初始容量为 11
  • **不允许 null**,且元素需实现 Comparable 或提供 Comparator

关键操作

方法 时间复杂度 说明
add(E e) / offer(E e) O(log n) 插入元素,触发堆调整。
poll() O(log n) 移除并返回队首(优先级最高)。
peek() O(1) 查看队首但不移除。
remove(Object o) O(n) 删除指定元素(需遍历堆)。

排序规则

  • 默认自然排序(元素需实现 Comparable)。
  • 自定义排序:通过 Comparator 指定(如大顶堆)。
    1
    PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

使用场景

  • 任务调度(按优先级执行)。
  • Top K 问题(维护前 K 个最大/最小值)。
  • Dijkstra 算法(优先处理最短路径)。

注意事项

  • 非线程安全:多线程需用 PriorityBlockingQueue
  • 迭代无序:遍历顺序不等于优先级顺序。
  • 性能权衡:插入/删除 O(log n),但查找 O(n)。

【简单】BlockingQueue 有什么用?

BlockingQueue 是线程安全的队列,支持阻塞操作(队列满时阻塞插入,空时阻塞取出)。主要用于生产者-消费者模型,协调多线程数据交换。

关键方法

方法 说明
put(E e) 队列满时阻塞,直到有空间插入。
take() 队列空时阻塞,直到有元素可取。
offer(E e) 非阻塞插入,成功返回 true,失败返回 false
poll() 非阻塞取出,有元素返回元素,无元素返回 null
peek() 查看队首元素但不移除(无元素返回 null)。

常见实现类

  • **ArrayBlockingQueue**:固定大小数组,单锁,适合低并发。
  • **LinkedBlockingQueue**:链表,双锁(高并发),默认几乎无界。
  • **PriorityBlockingQueue**:优先级队列(堆实现),无界。
  • **SynchronousQueue**:不存储元素,直接传递任务(一对一通信)。

适用场景

  • 任务调度(线程池任务队列)。
  • 数据缓冲(生产者-消费者模型)。
  • 流量控制(通过固定容量限制并发)。

注意事项

  • 线程安全:所有实现均线程安全,但需注意 peek()poll() 的竞态条件。
  • 阻塞策略put()/take() 会阻塞,offer()/poll() 可设置超时。
  • 无界队列风险LinkedBlockingQueue 默认无界,可能导致 OOM,建议设置容量。

一句话总结: 多线程间安全传递数据的阻塞队列,核心方法是 put()(阻塞插入)和 take()(阻塞取出),按场景选实现类。

【中等】ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?

ArrayBlockingQueueLinkedBlockingQueue 都是 Java 并发包(java.util.concurrent)中的线程安全阻塞队列,但它们在底层实现、性能和适用场景上有显著区别。

  • **ArrayBlockingQueue**:固定容量,单锁,适合低并发或内存敏感场景。
  • **LinkedBlockingQueue**:动态扩容,双锁,适合高并发和高吞吐场景。
  • **避免 OOM**:如果使用 LinkedBlockingQueue,建议设置合理容量(默认 MAX_VALUE 可能导致内存问题)。

ArrayBlockingQueue vs. LinkedBlockingQueue

对比项 ArrayBlockingQueue LinkedBlockingQueue
底层数据结构 固定大小的数组(循环队列) 链表(可动态扩容)
初始化容量 必须指定容量(无默认构造方法) 可选指定容量(默认 Integer.MAX_VALUE
内存占用 更紧凑(连续存储) 稍高(每个节点存储前后指针)
锁机制 单锁(入队和出队共用同一把锁) 双锁(入队和出队分离锁,减少竞争)
吞吐量 较低(锁竞争更激烈) 较高(读写分离,并发性能更好)
适用场景 固定大小队列,避免 OOM 高并发、动态扩容场景

底层数据结构

  • ArrayBlockingQueue

    • 基于数组(循环队列),初始化时必须指定固定容量。
    • 存储连续,内存局部性好,但扩容需重建数组(不支持动态扩容)。
  • LinkedBlockingQueue

    • 基于链表,默认容量 Integer.MAX_VALUE(几乎无界)。
    • 可动态增长,但每个节点需额外存储前后指针,内存开销稍大。

锁机制

  • ArrayBlockingQueue

    • 使用单锁ReentrantLock),入队和出队操作共用同一把锁,竞争较激烈。
    • 适合低并发容量固定的场景。
  • LinkedBlockingQueue

    • 采用双锁putLocktakeLock),入队和出队操作互不阻塞。
    • 高并发下吞吐量更高(如生产者-消费者模型)。

性能对比

操作 ArrayBlockingQueue LinkedBlockingQueue
入队(put 较慢(单锁竞争) 更快(双锁分离)
出队(take 较慢(单锁竞争) 更快(双锁分离)
内存占用 更紧凑 稍高(链表节点开销)

使用场景建议

选择 ArrayBlockingQueue 的情况

  • 队列大小固定,防止内存耗尽(如任务队列有严格上限)。
  • 低/中并发,且对内存占用敏感。

选择 LinkedBlockingQueue 的情况

  • 高并发(生产者-消费者模型)。
  • 队列大小不固定(默认几乎无界,但可手动指定容量)。
  • 需要更高的吞吐量(双锁机制减少竞争)。

Java 容器面试三

Java 容器工具类

Collections 工具类常用方法:

  • 排序
  • 查找,替换操作
  • 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合)

排序操作

1
2
3
4
5
6
void reverse(List list)//反转
void shuffle(List list)//随机排序
void sort(List list)//按自然排序的升序排序
void sort(List list, Comparator c)//定制排序,由 Comparator 控制排序逻辑
void swap(List list, int i , int j)//交换两个索引位置的元素
void rotate(List list, int distance)//旋转。当 distance 为正数时,将 list 后 distance 个元素整体移到前面。当 distance 为负数时,将 list 的前 distance 个元素整体移到后面

查找,替换操作

1
2
3
4
5
6
7
int binarySearch(List list, Object key)//对 List 进行二分查找,返回索引,注意 List 必须是有序的
int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比 int min(Collection coll)
int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由 Comparatator 类控制。类比 int min(Collection coll, Comparator c)
void fill(List list, Object obj)//用指定的元素代替指定 list 中的所有元素
int frequency(Collection c, Object o)//统计元素出现次数
int indexOfSubList(List list, List target)//统计 target 在 list 中第一次出现的索引,找不到则返回-1,类比 int lastIndexOfSubList(List source, list target)
boolean replaceAll(List list, Object oldVal, Object newVal)//用新元素替换旧元素

同步控制

Collections 提供了多个synchronizedXxx()方法·,该方法可以将指定集合包装成线程同步的集合,从而解决多线程并发访问集合时的线程安全问题。

我们知道 HashSetTreeSetArrayList,LinkedList,HashMap,TreeMap 都是线程不安全的。Collections 提供了多个静态方法可以把他们包装成线程同步的集合。

最好不要用下面这些方法,效率非常低,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合。

方法如下:

1
2
3
4
synchronizedCollection(Collection<T>  c) //返回指定 collection 支持的同步(线程安全的)collection。
synchronizedList(List<T> list)//返回指定列表支持的同步(线程安全的)List。
synchronizedMap(Map<K,V> m) //返回由指定映射支持的同步(线程安全的)Map。
synchronizedSet(Set<T> s) //返回指定 set 支持的同步(线程安全的)set。

集合判空

《阿里巴巴 Java 开发手册》的描述如下:

判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式。

这是因为 isEmpty() 方法的可读性更好,并且时间复杂度为 O(1)。

绝大部分我们使用的集合的 size() 方法的时间复杂度也是 O(1),不过,也有很多复杂度不是 O(1) 的,比如 java.util.concurrent 包下的某些集合(ConcurrentLinkedQueueConcurrentHashMap…)。

下面是 ConcurrentHashMapsize() 方法和 isEmpty() 方法的源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
public boolean isEmpty() {
return sumCount() <= 0L; // ignore transient negative values
}

集合转 Map

《阿里巴巴 Java 开发手册》的描述如下:

在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。

1
2
3
4
5
6
7
8
9
10
11
class Person {
private String name;
private String phoneNumber;
// getters and setters
}

List<Person> bookList = new ArrayList<>();
bookList.add(new Person("jack","18163138123"));
bookList.add(new Person("martin",null));
// 空指针异常
bookList.stream().collect(Collectors.toMap(Person::getName, Person::getPhoneNumber));

下面我们来解释一下原因。

首先,我们来看 java.util.stream.Collectors 类的 toMap() 方法 ,可以看到其内部调用了 Map 接口的 merge() 方法。

1
2
3
4
5
6
7
8
9
10
public static <T, K, U, M extends Map<K, U>>
Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper,
Function<? super T, ? extends U> valueMapper,
BinaryOperator<U> mergeFunction,
Supplier<M> mapSupplier) {
BiConsumer<M, T> accumulator
= (map, element) -> map.merge(keyMapper.apply(element),
valueMapper.apply(element), mergeFunction);
return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID);
}

Map 接口的 merge() 方法如下,这个方法是接口中的默认实现。

如果你还不了解 Java 8 新特性的话,请看这篇文章:《Java8 新特性总结》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
default V merge(K key, V value,
BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
Objects.requireNonNull(remappingFunction);
Objects.requireNonNull(value);
V oldValue = get(key);
V newValue = (oldValue == null) ? value :
remappingFunction.apply(oldValue, value);
if(newValue == null) {
remove(key);
} else {
put(key, newValue);
}
return newValue;
}

merge() 方法会先调用 Objects.requireNonNull() 方法判断 value 是否为空。

1
2
3
4
5
public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}

集合遍历

《阿里巴巴 Java 开发手册》的描述如下:

不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

通过反编译你会发现 foreach 语法底层其实还是依赖 Iterator 。不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iteratorremove/add方法

这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException 来提示用户发生了并发修改异常。这就是单线程状态下产生的 fail-fast 机制

fail-fast 机制:多个线程对 fail-fast 集合进行修改的时候,可能会抛出ConcurrentModificationException。 即使是单线程下也有可能会出现这种情况,上面已经提到过。

相关阅读:什么是 fail-fast

Java8 开始,可以使用 Collection#removeIf()方法删除满足特定条件的元素,如

1
2
3
4
5
6
List<Integer> list = new ArrayList<>();
for (int i = 1; i <= 10; ++i) {
list.add(i);
}
list.removeIf(filter -> filter % 2 == 0); /* 删除 list 中的所有偶数 */
System.out.println(list); /* [1, 3, 5, 7, 9] */

除了上面介绍的直接使用 Iterator 进行遍历操作之外,你还可以:

  • 使用普通的 for 循环
  • 使用 fail-safe 的集合类。java.util包下面的所有的集合类都是 fail-fast 的,而java.util.concurrent包下面的所有的类都是 fail-safe 的。
  • ……

集合去重

《阿里巴巴 Java 开发手册》的描述如下:

可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 Listcontains() 进行遍历去重或者判断包含操作。

这里我们以 HashSetArrayList 为例说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Set 去重代码示例
public static <T> Set<T> removeDuplicateBySet(List<T> data) {

if (CollectionUtils.isEmpty(data)) {
return new HashSet<>();
}
return new HashSet<>(data);
}

// List 去重代码示例
public static <T> List<T> removeDuplicateByList(List<T> data) {

if (CollectionUtils.isEmpty(data)) {
return new ArrayList<>();

}
List<T> result = new ArrayList<>(data.size());
for (T current : data) {
if (!result.contains(current)) {
result.add(current);
}
}
return result;
}

两者的核心差别在于 contains() 方法的实现。

HashSetcontains() 方法底部依赖的 HashMapcontainsKey() 方法,时间复杂度接近于 O(1)(没有出现哈希冲突的时候为 O(1))。

1
2
3
4
private transient HashMap<E,Object> map;
public boolean contains(Object o) {
return map.containsKey(o);
}

我们有 N 个元素插入进 Set 中,那时间复杂度就接近是 O (n)。

ArrayListcontains() 方法是通过遍历所有元素的方法来做的,时间复杂度接近是 O(n)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}

集合转数组

《阿里巴巴 Java 开发手册》的描述如下:

使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组。

toArray(T[] array) 方法的参数是一个泛型数组,如果 toArray 方法中没有传递任何参数的话返回的是 Object类 型数组。

1
2
3
4
5
6
7
String [] s= new String[]{
"dog", "lazy", "a", "over", "jumps", "fox", "brown", "quick", "A"
};
List<String> list = Arrays.asList(s);
Collections.reverse(list);
//没有指定类型的话会报错
s=list.toArray(new String[0]);

由于 JVM 优化,new String[0]作为Collection.toArray()方法的参数现在使用更好,new String[0]就是起一个模板的作用,指定了返回数组的类型,0 是为了节省空间,因为它只是为了说明返回的类型。详见:https://shipilev.net/blog/2016/arrays-wisdom-ancients/

数组转集合

《阿里巴巴 Java 开发手册》的描述如下:

使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常。

我在之前的一个项目中就遇到一个类似的坑。

Arrays.asList()在平时开发中还是比较常见的,我们可以使用它将一个数组转换为一个 List 集合。

1
2
3
4
String[] myArray = {"Apple", "Banana", "Orange"};
List<String> myList = Arrays.asList(myArray);
//上面两个语句等价于下面一条语句
List<String> myList = Arrays.asList("Apple","Banana", "Orange");

JDK 源码对于这个方法的说明:

1
2
3
4
5
6
7
/**
*返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的 API 之间的桥梁,
* 与 Collection.toArray() 结合使用。返回的 List 是可序列化并实现 RandomAccess 接口。
*/
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

下面我们来总结一下使用注意事项。

问题一、不能直接使用 Arrays.asList 来转换基本类型数组

1
2
3
int[] arr = { 1, 2, 3 };
List list = Arrays.asList(arr);
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());

在上面的示例中,通过 Arrays.asListint[] 数组初始化为 List 后。这个List 包含的其实是一个 int 数组,整个 List 的元素个数是 1,元素类型是整数数组。

其原因是,只能是把 int 装箱为 Integer,不可能把 int 数组装箱为 Integer 数组。我们知 道,Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个 对象成为了泛型类型 T

1
2
3
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

直接遍历这样的 List 必然会出现 Bug。

问题二、使用集合的修改方法:add()remove()clear()会抛出异常。

Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类。这个内部类继承自 AbstractList 类,但没有覆写父类的 add、remove、clear 方法,而父类中的这几个方法默认会抛出 UnsupportedOperationException。

1
2
3
4
5
String[] arr = { "1", "2", "3" };
List list = Arrays.asList(arr);
list.add(4);//运行时报错:UnsupportedOperationException
list.remove(1);//运行时报错:UnsupportedOperationException
list.clear();//运行时报错:UnsupportedOperationException

下图是 java.util.Arrays$ArrayList 的简易源码,我们可以看到这个类重写的方法有哪些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
...

@Override
public E get(int index) {
...
}

@Override
public E set(int index, E element) {
...
}

@Override
public int indexOf(Object o) {
...
}

@Override
public boolean contains(Object o) {
...
}

@Override
public void forEach(Consumer<? super E> action) {
...
}

@Override
public void replaceAll(UnaryOperator<E> operator) {
...
}

@Override
public void sort(Comparator<? super E> c) {
...
}
}

我们再看一下java.util.AbstractListadd/remove/clear 方法就知道为什么会抛出 UnsupportedOperationException 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public E remove(int index) {
throw new UnsupportedOperationException();
}
public boolean add(E e) {
add(size(), e);
return true;
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}

public void clear() {
removeRange(0, size());
}
protected void removeRange(int fromIndex, int toIndex) {
ListIterator<E> it = listIterator(fromIndex);
for (int i=0, n=toIndex-fromIndex; i<n; i++) {
it.next();
it.remove();
}
}

那我们如何正确的将数组转换为 ArrayList ?

1、手动实现工具类

1
2
3
4
5
6
7
8
9
10
11
12
//JDK1.5+
static <T> List<T> arrayToList(final T[] array) {
final List<T> l = new ArrayList<T>(array.length);

for (final T s : array) {
l.add(s);
}
return l;
}

Integer [] myArray = { 1, 2, 3 };
System.out.println(arrayToList(myArray).getClass());//class java.util.ArrayList

2、最简便的方法

1
List list = new ArrayList<>(Arrays.asList("a", "b", "c"))

3、使用 Java8 的 Stream(推荐)

1
2
3
4
5
Integer [] myArray = { 1, 2, 3 };
List myList = Arrays.stream(myArray).collect(Collectors.toList());
//基本类型也可以实现转换(依赖 boxed 的装箱操作)
int [] myArray2 = { 1, 2, 3 };
List myList = Arrays.stream(myArray2).boxed().collect(Collectors.toList());

4、使用 Guava

对于不可变集合,你可以使用 ImmutableList 类及其 of()copyOf() 工厂方法:(参数不能为空)

1
2
List<String> il = ImmutableList.of("string", "elements");  // from varargs
List<String> il = ImmutableList.copyOf(aStringArray); // from array

对于可变集合,你可以使用 Lists 类及其 newArrayList() 工厂方法:

1
2
3
List<String> l1 = Lists.newArrayList(anotherListOrCollection);    // from collection
List<String> l2 = Lists.newArrayList(aStringArray); // from array
List<String> l3 = Lists.newArrayList("or", "string", "elements"); // from varargs

5、使用 Apache Commons Collections

1
2
List<String> list = new ArrayList<String>();
CollectionUtils.addAll(list, str);

6、 使用 Java9 的 List.of()方法

1
2
Integer[] array = {1, 2, 3};
List<Integer> list = List.of(array);

使用 List.subList 进行切片操作居然会导致 OOM

List.subList 返回的子 List 不是一个普通的 ArrayList。这个子 List 可以认为是原始 List 的视图,会和原始 List 相互影响。如果不注意,很可能会因此产生 OOM 问题。

如下代码所示,定义一个名为 data 的静态 List 来存放 Integer 的 List,[也就是说 data 的成员本身是包含了多个数字的 List。循环 1000 次,每次都从一个具有 10 万个 Integer 的 List 中,使用 subList 方法获得一个只包含一个数字的子 List,并把这个子 List 加入 data 变量:

1
2
3
4
5
6
7
8
private static List<List<Integer>> data = new ArrayList<>();

private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1));
}
}

出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用。

Java 容器面试二

Map

【中等】HashMap 和 Hashtable 有什么区别?

HashMap 更高效且灵活,Hashtable 线程安全但过时,推荐用 ConcurrentHashMap 替代。

对比项 HashMap (JDK 1.2+) Hashtable (JDK 1.0)
线程安全 ❌ 非线程安全(需额外同步) ✅ 线程安全(方法用 synchronized 修饰)
性能 ⚡ 更高(无锁竞争) ⏳ 较低(同步开销)
Null 键/值 ✅ 允许 null 键和值 ❌ 不允许 null
迭代器 **fail-fast**(快速失败,并发修改抛异常) **enumerator**(不抛异常)
继承体系 继承 AbstractMap 继承 Dictionary(已过时)
初始容量与扩容 默认 16,扩容为 2 倍 默认 11,扩容为 2 倍 + 1
哈希冲突解决 链表 + 红黑树(JDK 8+) 仅链表

使用建议

  • **优先用 HashMap**:大多数场景(性能更好),搭配 Collections.synchronizedMap()ConcurrentHashMap 实现线程安全。
  • Hashtable 适用场景:遗留系统兼容,或需要简单线程安全且不介意性能损耗时(现代开发已少用)。

【中等】对比一下 HashMap 和 HashSet?

  • HashMap键值对容器,适合快速键值查询。
  • HashSet唯一元素集合,基于 HashMap 实现,仅关注元素是否存在。

核心区别

特性 HashMap HashSet
数据结构 哈希表(键值对存储) 基于 HashMap(仅用键,值固定为虚拟对象)
存储内容 键(Key) + 值(Value) 仅存储元素(Key)
重复规则 Key 不可重复(Value 可重复) 元素(Key)不可重复
Null 支持 允许 1 个 null 键和多个 null 允许 1 个 null 元素

常用方法对比

操作 HashMap HashSet
添加元素 put(key, value) add(element)
查询元素 get(key)(返回值) contains(element)(返回布尔值)
删除元素 remove(key) remove(element)

底层机制

HashSet 内部直接使用 HashMap 实现,元素作为 Key,值固定为一个虚拟的 PRESENT 对象(占位符)。

两者均依赖哈希表,平均时间复杂度为 O(1)(冲突时可能退化为 O(n))。

1
2
3
4
5
6
7
8
// HashSet 的简化实现(本质是 HashMap 的包装)
public class HashSet<E> {
private HashMap<E, Object> map; // 键存储元素,值固定为 PRESENT
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null; // 若 Key 已存在,返回 false
}
}

使用场景

  • **HashMap**:需通过键快速访问值的场景(如缓存、数据库索引)。 示例:用户 ID → 用户详细信息
  • **HashSet**: 需存储唯一元素的集合(如去重、黑名单)。示例:IP 黑名单单词去重

【中等】HashMap、TreeMap、LinkedHashMap 有什么区别?

核心特性

特性 HashMap TreeMap LinkedHashMap
底层结构 哈希表(数组+链表/红黑树) 红黑树(平衡二叉搜索树) 哈希表 + 双向链表
顺序性 无序 按键的自然顺序或自定义顺序排序 保持插入顺序或访问顺序(LRU)
null 支持 允许 1 个 null 键和多个 null 值 不允许 null 键(除非自定义比较器) 同 HashMap
线程安全 非线程安全 非线程安全 非线程安全
时间复杂度 平均 O(1) 增删查 O(log n) 平均 O(1)

排序与顺序

  • HashMap:完全无序,迭代顺序不可预测。
  • TreeMap:默认按键的自然顺序排序(Key 需实现Comparable)。可通过Comparator自定义排序规则。
  • LinkedHashMap:默认保持插入顺序。可配置为访问顺序(最近最少使用 LRU)。

使用场景

  • HashMap
    • 需要最高效的查找、插入和删除操作。
    • 不关心元素的顺序。
    • 示例:缓存、快速查找表。
  • TreeMap
    • 需要元素按键排序。
    • 需要范围查询(如subMap()headMap()tailMap())。
    • 示例:字典、有序事件调度。
  • LinkedHashMap
    • 需要保持插入顺序或实现 LRU 缓存。
    • 示例:记录访问顺序的缓存、需要按插入顺序迭代的场景。

性能对比

操作 HashMap TreeMap LinkedHashMap
插入 O(1) O(log n) O(1)
删除 O(1) O(log n) O(1)
查找 O(1) O(log n) O(1)
迭代顺序 无序 有序(Key) 插入/访问顺序

选择依据

  • 速度:选HashMap
  • 排序:选TreeMap
  • 顺序(插入或访问顺序):选LinkedHashMap

扩展

  • LinkedHashMap可通过accessOrder参数实现 LRU 缓存。
  • TreeMap支持丰富的导航方法(如ceilingKey()floorKey())。

【困难】HashMap 底层实现原理是什么?

HashMap 通过哈希函数定位桶,用链表和红黑树解决冲突,动态扩容平衡性能,但非线程安全。

数据结构

HashMap 的数据结构是:数组 + 链表(JDK 8 以前)数组 + 链表 + 红黑树(JDK 8+)

  • 数组(桶)Node<K,V>[] table,初始长度默认为 16
  • 链表:相同哈希值的元素组成链表,以解决哈希冲突(拉链地址法)。
  • 红黑树:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树(提升查询效率至 O(log n))。

哈希计算

  • 计算哈希值:高位与低位异或,使哈希分布更均匀。

    1
    2
    3
    4
    5
    // JDK 8 的哈希扰动函数(减少碰撞)
    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 计算桶索引

    1
    index = (table.length - 1) & hash;  // 等价于 hash % table.length

解决哈希冲突

  • 拉链地址法:冲突的键值对以链表形式存储在同一桶中。
  • 红黑树优化:长链表(≥8)转为红黑树,避免极端情况下性能退化至 O(n)

扩容机制(Rehash)

  • 触发条件:当元素数量 > 容量 × 负载因子(默认负载因子 0.75,容量 16 时阈值为 12)。
  • 扩容操作
    • 新建 2 倍大小的数组(newCap = oldCap << 1)。
    • 重新计算键的索引位置(newIndex = (newCap - 1) & hash)。
    • JDK 8 优化:无需重新计算哈希,通过高位掩码判断新索引位置原索引原索引 + oldCap)。

关键参数

参数 默认值 说明
初始容量 16 必须为 2 的幂(方便位运算计算索引)。
负载因子(Load Factor) 0.75 权衡空间与时间效率(过高增加冲突,过低浪费内存)。
树化阈值 8(链表 → 红黑树) 需同时满足数组长度 ≥ 64,否则优先扩容。
退化阈值 6(红黑树 → 链表) 扩容或删除节点时检查。

线程安全问题

  • 非线程安全:多线程下可能导致:
    • 死循环(JDK 7 头插法扩容时产生环形链表)。
    • 数据丢失(并发插入覆盖节点)。
  • 解决方案
    • 使用 ConcurrentHashMap
    • 或通过 Collections.synchronizedMap() 包装。

JDK 8 的优化

  • 链表 → 红黑树:解决哈希攻击导致的性能退化。
  • 尾插法:扩容时保持链表顺序,避免环形链表。
  • 高位掩码优化扩容:减少哈希重计算开销。

PUT 流程源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final V putVal(int hash, K key, V value, boolean onlyIfAbsent) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 数组为空时初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算索引,若桶为空直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 3. 处理哈希冲突(链表/红黑树)
// ...(省略冲突处理逻辑)
}
// 4. 检查扩容
if (++size > threshold) resize();
}

【困难】HashMap 为什么线程不安全?

HashMap 在多线程环境下会出现:

  • JDK 7:死循环 + 数据丢失(头插法导致)。
  • **JDK 8+**:数据丢失 + 脏读(无死循环,但依然非线程安全)。
  • 替代方案:高并发场景始终优先选择 ConcurrentHashMap

一句话:HashMap 的线程不安全源于非原子操作和并发修改冲突,多线程环境下必须使用同步机制。

(1)并发修改导致数据丢失

问题场景(JDK 8+)

  • 两个线程同时执行 put(),计算出的 桶索引相同,且该位置为 null
  • 预期:两个键值对都成功插入。
  • 实际:后一个线程的 put 可能覆盖前一个线程的写入,导致数据丢失。

示例代码(伪并发):

1
2
3
4
// 线程 1 和线程 2 同时执行:
if ((p = tab[i = (n - 1) & hash]) == null) {
tab[i] = newNode(hash, key, value, null); // 可能被覆盖
}

(2)JDK 7 扩容死循环问题

问题原因(仅 JDK 7)

  • 扩容时采用 头插法 迁移链表,多线程并发可能导致 环形链表
  • 后续调用 get()put() 时,遍历链表进入死循环(CPU 100%)。

示意图:

1
2
3
线程 1A -> B → null
线程 2B -> A → null
最终:AB(环形链表)

(3)并发扩容导致数据错乱

多个线程同时触发 resize(),可能导致:

  • 部分节点丢失(未正确迁移到新数组)。
  • 链表断裂(节点 next 指针被错误修改)。

(4)非原子操作导致脏读

size++modCount++ 等操作非原子性,可能导致:

  • size 不准确(影响扩容判断)。
  • 迭代时触发 ConcurrentModificationException(快速失败机制)。

解决方案

问题 解决方案
数据丢失/覆盖 使用 ConcurrentHashMap(CAS + 分段锁)
死循环(JDK 7) 升级到 JDK 8+(改用尾插法)
脏读 Collections.synchronizedMap() 包装

【中等】WeakHashMap 有什么用?

WeakHashMap 通过弱引用键实现自动清理,适合管理临时性、生命周期与键对象绑定的数据,但需注意值对象的引用管理和线程安全问题。

基于弱引用的键(Key)管理

  • 键是弱引用:当 WeakHashMap 的键(Key)不再被其他强引用指向时,该键值对会被垃圾回收器自动回收,避免内存泄漏。
  • 适用场景:适合存储与对象生命周期相关的临时数据(如缓存),当键对象外部不再使用时,自动清理对应条目。

自动清理无引用键值对

  • 依赖垃圾回收机制:当键对象仅被 WeakHashMap 弱引用时,GC 会回收该键,并移除对应的键值对(通过内部 ReferenceQueue 机制触发清理)。
  • 无需手动移除:与普通 HashMap 不同,无需显式调用 remove() 方法避免内存泄漏。

典型应用场景

  • 缓存系统:缓存数据时,若缓存键(如临时对象)不再使用,自动释放对应值(如大对象),防止内存堆积。
  • 监听器/元数据存储:存储对象的附加信息,当对象销毁时,关联数据自动清除。

注意事项

  • 值(Value)不是弱引用:仅键是弱引用,值仍可能因强引用导致内存泄漏(需确保值未在其他地方被强引用)。
  • 非线程安全:需外部同步(如使用 Collections.synchronizedMap)。
  • 不可预测的清理时机:依赖 GC 运行,条目移除时机不确定。

示例代码

1
2
3
4
5
6
7
8
9
WeakHashMap<Object, String> weakMap = new WeakHashMap<>();
Object key = new Object();
weakMap.put(key, "Value");

// 当 key 的强引用置为 null,且发生 GC 后,weakMap 中的条目会被自动移除
key = null;
System.gc(); // 仅示例,实际中不推荐显式调用 GC

// 此时 weakMap 可能已为空(条目被回收)

【中等】ConcurrentHashMap 和 Hashtable 有什么区别?

  • **优先使用 ConcurrentHashMap**:适用于现代高并发程序,性能更优。
  • **避免 Hashtable**:除非维护历史代码,否则建议替换为 ConcurrentHashMapCollections.synchronizedMap()(非高并发场景)。

以下是 ConcurrentHashMap 和 Hashtable 的区别对比表格,清晰展示核心差异:

对比项 Hashtable ConcurrentHashMap
线程安全实现 全表锁(synchronized 方法) 分段锁(JDK7)CAS + synchronized(JDK8+)
并发性能 低(串行化操作,高并发时阻塞严重) 高(读写并发优化,锁粒度更细)
Null 支持 不允许 null 键或值(抛出异常) 不允许 null 键或值(避免并发歧义)
迭代器行为 强一致性(修改会抛 ConcurrentModificationException 弱一致性(可能部分反映修改,不抛异常)
版本与演进 JDK1.0 遗留类,已过时 JDK1.5 引入,持续优化(如 JDK8 改用 CAS)
适用场景 旧代码兼容(不推荐新项目使用) 高并发首选(缓存、计数器等场景)

【困难】ConcurrentHashMap 的底层实现原理是什么?

ConcurrentHashMap 是 Java 并发编程中最常用的线程安全 Map,其底层实现经历了 JDK7(分段锁)JDK8+(CAS + synchronized 优化) 两个重要阶段。以下是核心实现原理:

::: info JDK7 中,ConcurrentHashMap 的实现原理是什么?
:::

JDK7 中,ConcurrentHashMap 的核心实现思想是:将整个哈希表分成多个 Segment(默认 16 个),每个 Segment 是一个独立的 HashEntry 数组,锁粒度细化到Segment 级别,不同 Segment 可并发操作。

数据结构

1
2
3
4
ConcurrentHashMap
├── Segment[](默认 16 个,每个 Segment 继承 ReentrantLock)
│ └── HashEntry[](链表结构,存储键值对)
└── 全局的并发控制参数(如 loadFactor)

关键特点

  • 锁分段(Segment Locking)
    • 写操作仅锁对应的 Segment,其他 Segment 仍可并发访问。
    • 读操作无锁(HashEntryvaluevolatile 修饰,保证可见性)。
  • 并发度(Concurrency Level)
    • 默认 16 个 Segment,即最多支持 16 个线程并发写。

缺点

  • 内存占用较高(每个 Segment 独立维护数组)。
  • 查询时需要两次哈希计算(先定位 Segment,再定位 HashEntry)。

::: info JDK8 中,ConcurrentHashMap 的实现原理是什么?
:::

JDK8 中,ConcurrentHashMap 的核心实现思想是:抛弃 Segment,改用 Node 数组 + 链表/红黑树,锁粒度细化到 单个桶(链表头节点),并引入 CAS(无锁化)synchronized 结合的方式提升并发性能。

数据结构

1
2
3
4
5
ConcurrentHashMap
├── Node[] table(数组 + 链表/红黑树)
│ ├── Node(普通链表节点)
│ └── TreeBin(红黑树封装,维护平衡)
└── volatile 变量(如 sizeCtl,控制扩容)

关键优化

  • 锁粒度更细(桶级别锁)

    • 写操作仅锁当前桶(链表头节点),其他桶仍可并发访问。
    • 读操作完全无锁(Nodevaluenextvolatile 修饰)。
  • CAS + synchronized 结合

    • 插入数据:先尝试 CAS 无锁插入,失败后 synchronized 锁住头节点。
    • 扩容:支持多线程协同扩容(通过 sizeCtl 标志位控制)。
  • 链表转红黑树(优化查询)

    • 当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树(TreeBin),防止哈希冲突导致性能退化。
  • 更高效的计算方式

    • 使用 spread() 方法优化哈希计算,减少冲突。
    • size() 方法通过 CounterCell 分段统计,避免全局锁。

::: info JDK8 中,ConcurrentHashMap 关键操作流程是怎样的?
:::

(1)PUT 操作(JDK8)

  1. 计算 key 的哈希,定位到桶(数组下标)。
  2. 如果桶为空,CAS 插入新节点(无锁化)。
  3. 如果桶不为空,synchronized 锁住头节点,处理链表或红黑树插入。
  4. 如果链表长度 ≥ 8,尝试转红黑树。

(2)GET 操作(完全无锁)

  1. 计算 key 的哈希,定位到桶。
  2. 遍历链表或红黑树(依赖 volatile 保证可见性)。

(3)扩容(多线程协同)

  1. 当元素数量超过阈值(sizeCtl),触发扩容。
  2. 其他线程检测到扩容时,可协助迁移数据(transfer 方法)。

::: info ConcurrentHashMap 在 JDK7 和 JDK8 中的实现有哪些差异?
:::

对比项 JDK7(分段锁) JDK8+(CAS + synchronized
锁粒度 Segment 级别(粗粒度) 桶级别(更细粒度)
并发度 固定 16 个 Segment 动态调整,更高并发
内存占用 较高(每个 Segment 维护数组) 更低(单层 Node 数组)
哈希冲突处理 链表 链表 + 红黑树(优化查询)
扩容机制 单 Segment 扩容 多线程协同扩容

小结

JDK7:分段锁降低冲突,但并发度固定,内存开销大。

**JDK8+**:

  • 更细粒度的锁(桶级别),CAS 无锁化优化。
  • 红黑树优化极端哈希冲突场景。
  • 多线程协同扩容,提升性能。

适用场景:高并发读写(如缓存、计数器),是 HashtableCollections.synchronizedMap() 的现代替代方案。

【中等】ConcurrentHashMap 为什么 key 和 value 不能为 null?

ConcurrentHashMap 在设计上明确禁止 null 作为 keyvalue,而普通的 HashMap 是允许的。

ConcurrentHashMap 禁止 null 是为了避免并发场景下的二义性问题

  • 替代方案:使用特殊标记(如 Optional)或额外方法(如 containsKey())明确语义。
  • 设计一致性:延续 Hashtable 的严格约束,确保线程安全行为的清晰性。

如果业务必须使用 null,可以考虑:

  • 使用 HashMap + 外部同步(如 synchronized)。
  • Optional 或自定义空对象代替 null

ConcurrentHashMap 禁止 null 的详细原因如下:

(1)并发场景下的歧义问题(核心原因)

ConcurrentHashMap 是线程安全的,但在高并发环境下,null 值会导致 二义性(Ambiguity),无法区分:

  • Key 不存在(返回 null)。
  • **Key 存在,但 Value 本身就是 null**。

示例场景:

1
2
3
4
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
map.get("non_existent_key"); // 返回 null(表示 key 不存在)
map.put("key", null); // 如果允许,这里存储 null 值
map.get("key"); // 仍然返回 null,无法区分是 "key 不存在" 还是 "value 是 null"

问题:在并发环境下,这种歧义会导致业务逻辑错误(比如缓存系统无法判断数据是否有效)。

(2)HashMap 为什么允许 null

HashMap 是单线程使用的,开发者可以自行约束 null 的使用逻辑,例如:

1
2
3
if (map.get(key) == null) {
// 明确知道是 key 不存在,或者 value 是 null(需业务逻辑保证)
}

但在并发环境下,这种约束不可靠,因为其他线程可能同时修改数据。

(3)ConcurrentHashMap 的设计哲学

为了保证 线程安全明确语义ConcurrentHashMap 直接禁止 null,强制开发者:

  • **用特殊占位符(如 Optional.empty())代替 null**。
  • 显式处理 key 不存在的情况(如 containsKey() 检查)。

替代方案示例:

1
2
3
4
5
6
7
8
9
10
ConcurrentHashMap<String, Optional<String>> map = new ConcurrentHashMap<>();
map.put("key", Optional.empty()); // 用 Optional 表示空值
if (!map.containsKey("key")) {
// key 不存在
} else {
Optional<String> value = map.get("key");
if (value.isEmpty()) {
// value 是 "逻辑上的 null"
}
}

(4)历史原因(兼容性)

  • Hashtable(早期线程安全 Map)也不允许 nullConcurrentHashMap 延续了这一设计。
  • 如果允许 null,会导致从 Hashtable 迁移到 ConcurrentHashMap 时出现兼容性问题。

(5)对比其他 Map

Map 类型 允许 null Key 允许 null Value 原因
HashMap ✅ 是 ✅ 是 单线程使用,无并发歧义
Hashtable ❌ 否 ❌ 否 线程安全,避免歧义
ConcurrentHashMap ❌ 否 ❌ 否 并发安全,避免歧义
Collections.synchronizedMap 取决于底层 Map 取决于底层 Map 包装类,行为与被包装 Map 一致

【中等】ConcurrentHashMap 能保证复合操作的原子性吗?

ConcurrentHashMap 不能保证复合操作的原子性,尽管它本身提供了高并发性能和线程安全的单个操作。

说明如下

单个操作的原子性

  • put(), get(), remove() 等单个操作是线程安全的
  • 这些操作在内部使用分段锁或 CAS 操作保证原子性

复合操作的非原子性

像【检查然后执行(check-then-act)】这样的复合操作不是原子的。例如:if (!map.containsKey(key)) { map.put(key, value); },在检查和方法调用之间,其他线程可能已经修改了 map

解决方案

  • 使用 putIfAbsent(), computeIfAbsent(), computeIfPresent() 等原子性复合方法
  • 使用显式同步(但会降低并发性能)
  • 使用 compute() 方法原子性地更新值

示例:

1
2
3
4
5
6
7
8
9
10
11
12
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 非原子性复合操作 - 不安全
if (!map.containsKey("key")) {
map.put("key", 1); // 可能有竞态条件
}

// 原子性替代方案
map.putIfAbsent("key", 1);

// 或者使用 computeIfAbsent
map.computeIfAbsent("key", k -> 1);

总结:ConcurrentHashMap 只保证单个方法的原子性,复合操作需要特别处理才能保证线程安全。

Java 虚拟机面试一

JVM 简介

【中等】JVM 由哪些部分组成?

类加载→内存分配→执行引擎运行→GC 回收内存,通过 JNI 与外部交互。

JVM(Java 虚拟机)主要由以下核心部分组成:

  • 类加载子系统:负责加载、验证、准备、解析和初始化类文件(.class)。
  • 运行时数据区
    • 方法区:存储类元数据、常量池等。
    • :存放对象实例(主 GC 区域)。
    • 虚拟机栈:存储方法调用的栈帧(局部变量、操作数栈等)。
    • 本地方法栈:为 Native 方法服务。
    • 程序计数器:记录当前线程执行的字节码位置。
  • 执行引擎:解释或编译字节码为机器码执行(含 JIT 编译器)。
    • 解释器(Interpreter):逐行解释执行字节码(启动快,执行慢)。
    • 即时编译器(JIT Compiler):将热点代码(频繁执行的代码)编译为本地机器码(如 HotSpot 的 C1、C2 编译器)。
    • 垃圾回收器(GC):自动回收堆中无用的对象(如 Serial、Parallel、G1、ZGC 等算法)。
  • 本地方法接口(JNI):调用 C/C++实现的 Native 方法。
  • 本地方法库(Native Libraries):由其他语言(如 C/C++)编写的库,供 JNI 调用(如文件操作、网络通信等底层功能)。

【中等】Java 是如何实现跨平台的?

Java 实现跨平台的本质是:源码 → 统一字节码 → JVM 按需转换为目标平台机器码,通过分层抽象实现跨平台。

Java 【一次编写,到处执行(Write Once, Run Anywhere)】 的要点:

  • JVM(Java 虚拟机)—— 统一运行环境
    • 不同操作系统(Windows/Linux/macOS)安装对应的 JVM,屏蔽底层硬件和系统差异
    • JVM 负责加载、验证并执行字节码,确保相同字节码在不同平台表现一致。
  • 字节码(Bytecode)—— 平台无关的中间代码
    • Java 代码编译成平台无关的字节码(.class 文件),而非直接生成机器码。
    • 由 JVM 解释或 JIT 编译为当前平台的机器指令。
  • 标准化的 Java API:提供统一的 API(如 java.iojava.net),底层通过 JVM 适配不同操作系统的具体实现。
  • 严格的规范与兼容性:JVM 规范(如字节码格式、内存管理)和 Java 语言规范由 Oracle 统一制定,确保各厂商实现的 JVM 行为一致。

例外情况(需注意)

  • JNI(本地方法调用):依赖系统原生库时,需为不同平台编译对应的动态库(如 .dll.so)。
  • 平台相关细节:如文件路径分隔符、字符编码、GUI 渲染等可能需要适配。

【中等】说说 Java 的执行流程?

Java 程序的执行流程经历了从编译到字节码的生成,再到类加载和 JIT 编译的过程,最终在 JVM 中执行。并且在程序运行过程中,JVM 负责内存管理、垃圾回收和线程调度等工作。

主要流程如下:

  1. 编写 Java 源代码:编写 .java 文件。
  2. 编译:Java 编译器(javac) 将 .java 文件编译为 .class 文件(字节码)。
  3. 类加载:JVM 通过类加载子系统加载 .class 文件到内存。
    1. 加载:采用双亲委派机制,分层级加载字节码。
    2. 链接
      1. 验证:检查字节码合法性(如魔数 0xCAFEBABE)。
      2. 准备:为静态变量分配内存并赋默认值(如 static int a 初始化为 0)。
      3. 解析:将符号引用(如类名、方法名)转为直接引用(内存地址)。
    3. 初始化:执行静态代码块(static{})和静态变量赋值(如 static int a = 1;)。
  4. 存储运行时数据区:加载后的类信息存储到内存区域。
    • 方法区:存储类结构(如 HelloWorld 的类名、方法定义、常量池)。
    • :存放对象实例(如 String 对象)。
    • 虚拟机栈:线程私有,存储 main() 方法的栈帧(局部变量、操作数栈等)。
    • 程序计数器:记录当前线程执行的字节码指令地址。
  5. 执行阶段
    • 解释执行:逐行解释字节码指令(如 invokestatic 调用 System.out.println)。启动快,执行效率低。
    • 本地方法调用(JNI):若调用 native 方法(如 Object.clone()),通过 JNI 执行本地库(C/C++)代码。
    • JIT 编译优化(可选):将热点代码(频繁执行的方法)编译为本地机器码。相关优化技术:方法内联逃逸分析等。
  6. 垃圾回收:JVM 管理内存,并回收不再使用的对象。
  7. 程序结束:main 方法结束,退出程序。

【中等】什么是 JIT?

JIT(Just-In-Time Compilation,即时编译)在运行时将热点代码(频繁执行的字节码)动态编译为本地机器码,提升执行效率。

  • JIT 是 Java 高性能的关键:通过运行时编译热点代码,平衡解释执行的灵活性和原生代码的速度。
  • 核心优化:方法内联、逃逸分析、分层编译。
  • 调优方向:根据应用特点调整编译阈值、代码缓存大小。

与解释器的区别

  • 解释器:逐行解释执行字节码,启动快但运行慢。
  • JIT:编译后直接执行机器码,运行快但有编译开销。

JIT 作用

  • 性能优化:对重复执行的代码(如循环、高频方法)编译为机器码,避免重复解释。
  • 自适应优化:根据运行时数据(如方法调用次数、分支预测)动态优化代码。

JIT 工作流程

  • 热点检测:通过计数器统计方法调用次数或循环执行次数(如 -XX:CompileThreshold 默认阈值 10000)。
  • 编译优化:将热点字节码编译为机器码,存入代码缓存(Code Cache)
  • 替换执行:后续调用直接执行编译后的机器码。

JIT 优化技术

  • 方法内联(Inlining):将小方法调用替换为方法体代码(如 -XX:+InlineSmallMethods)。
  • 逃逸分析(Escape Analysis):判断对象作用域,优化为栈分配或标量替换。
  • JIT 分层编译(Tiered Compilation)
    • 混合模式:结合解释器、C1(Client Compiler)和 C2(Server Compiler):
      • C1:快速编译,优化启动速度(如 -client 模式)。
      • C2:深度优化,提升峰值性能(如 -server 模式)。
    • JDK 8+ 默认启用-XX:+TieredCompilation
  • 循环展开(Loop Unrolling):减少循环控制开销。
  • 去虚拟化(Devirtualization):将虚方法调用转为直接调用。

JIT 关键参数

参数 作用
-XX:+UseJIT 启用 JIT(默认开启)
-XX:CompileThreshold=10000 触发 JIT 编译的方法调用阈值
-XX:+PrintCompilation 打印 JIT 编译日志
-XX:ReservedCodeCacheSize 设置代码缓存大小(默认 240MB)
-XX:+TieredCompilation 启用分层编译(JDK 8+ 默认)

JIT 特点

  • 优点
    • 显著提升热点代码性能(接近原生代码速度)。
    • 自适应优化更灵活。
  • 缺点
    • 编译开销导致启动变慢(如短生命周期应用不适用)。
    • 代码缓存占用内存。

JIT 适用场景

  • 长期运行应用:如 Web 服务、大数据处理(JIT 优势明显)。
  • 短时任务:如命令行工具,解释器可能更高效。

【困难】什么是逃逸分析?

逃逸分析 是 JVM 在 即时编译(JIT)阶段 进行的一种优化技术,用于分析对象的动态作用域,判断对象是否会“逃逸”出当前方法或线程,从而决定是否可以进行栈上分配、锁消除或标量替换等优化。

逃逸分析通过判断对象作用域,实现栈分配、锁消除、标量替换等优化,是 JVM 提升性能的关键技术之一,尤其在高频代码中效果显著。

  • 逃逸对象(Escape)
  • 方法逃逸:对象被其他方法引用(如作为参数传递或返回值)。
    • 线程逃逸:对象被其他线程访问(如赋值给静态变量或共享实例变量)。
  • 非逃逸对象(Non-Escaping):对象仅在当前方法内创建和使用,未被外部引用。

逃逸分析的优化场景

  • 栈上分配(Stack Allocation)
    • 对于非逃逸对象,JVM 直接在栈帧中分配内存(而非堆),对象随方法调用结束自动销毁,减少 GC 压力。
    • _示例_:方法内部的临时对象。
  • 标量替换(Scalar Replacement)
    • 将非逃逸对象的字段拆解为局部变量(标量),避免创建完整对象。
    • _示例_:Point 对象的 xy 字段被替换为两个局部变量。
  • 锁消除(Lock Elision)
    • 若对象未线程逃逸且同步块无竞争,JVM 会移除不必要的锁(如 synchronized)。
    • _示例_:局部 StringBuffer 的同步操作会被优化掉。

逃逸分析的触发条件

  • 需 JVM 启用逃逸分析(默认开启):
    1
    2
    -XX:+DoEscapeAnalysis  # 开启(默认)
    -XX:-DoEscapeAnalysis # 关闭
  • 配合 JIT 编译器(如 C2)在热点代码中应用。

性能影响

  • 优点:减少堆分配、降低 GC 开销、提升局部性。
  • 限制:分析本身有开销,复杂对象可能无法优化。

示例代码

1
2
3
4
5
6
7
8
9
10
public void example() {
// 非逃逸对象(可能被栈分配或标量替换)
Point p = new Point(1, 2);
System.out.println(p.x + p.y);
}

static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}

【困难】什么是 AOT?

::: info 什么是 AOT?
:::

Java 9 引入 AOT(Ahead of Time Compilation,提前编译) 。AOT 模式下,程序运行前直接编译为机器码(类似 C/C++/Rust)。

::: info AOT 和 JIT 有什么区别?
:::

AOT vs. JIT

维度 AOT JIT
启动速度 ⭐⭐⭐(极快) ⭐(依赖预热)
内存占用 ⭐⭐⭐(低) ⭐⭐(较高)
峰值性能 ⭐⭐(静态优化) ⭐⭐⭐(动态优化)
动态支持 ❌(受限) ✅(完整支持)
适合场景 云原生/微服务 高吞吐/动态框架

提到 AOT 就不得不提 GraalVM 了!GraalVM 是一种高性能的 JDK(完整的 JDK 发行版本),它可以运行 Java 和其他 JVM 语言,以及 JavaScript、Python 等非 JVM 语言。 GraalVM 不仅能提供 AOT 编译,还能提供 JIT 编译。感兴趣的同学,可以去看看 GraalVM 的官方文档。如果觉得官方文档看着比较难理解的话,也可以找一些文章来看看,比如:

::: tip 扩展

:::

::: info 既然 AOT 这么多优点,那为什么不全部使用这种编译方式呢?
:::

AOT 的局限性在于不支持动态特性

  • 不支持反射、动态代理、运行时类加载、JNI 等
  • 影响框架兼容性(如 Spring、CGLIB 依赖 ASM 技术生成动态字节码)

AOT 的适用场景

  • 适合:启动敏感的微服务、云原生应用
  • 不适合:需动态特性的复杂框架或高频优化的长运行任务

JVM 内存管理

【困难】JVM 的内存区域是如何划分的?

JDK7 和 JDK8 的 JVM 的内存区域划分有所不同,如下图所示:

线程私有区域

  • 程序计数器
    • 记录当前线程执行的字节码指令地址(Native 方法时为undefined)。
    • JVM 中唯一无 OOM 的区域
  • 虚拟机栈
    • 存储方法调用的栈帧(局部变量表、操作数栈、动态链接、返回地址)。
      • 局部变量表:用于存放方法参数和方法内部定义的局部变量。
      • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
      • 动态连接 - 用于一个方法调用其他方法的场景。Class 文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析;另一部分将在每一次的运行期间转化为直接应用,这部分称为动态连接
      • 方法返回地址 - 用于返回方法被调用的位置,恢复上层方法的局部变量和操作数栈。Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。无论采用何种退出方式,都会导致栈帧被弹出。也就是说,栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
    • 异常:StackOverflowError(栈深度超限)、OOM(扩展失败)。
    • 可以通过 -Xss 指定占内存大小
  • 本地方法栈:与虚拟机栈的作用非常相似,二者区别仅在于:虚拟机栈为 Java 方法服务;本地方法栈为 Native 方法服务

线程共享区域

  • 堆(Heap)
    • 存放所有对象实例和数组,是 GC 主战场。
    • 分区:新生代(Eden+Survivor)、老年代。
    • 异常:OOM: Java heap space(对象过多或内存泄漏)。
  • 字符串常量池:用于存储字符串字面量,位于堆内存中的一块特殊区域。通过 String 类的 intern() 方法可以将字符串键入到字符串常量池。
  • 方法区(JDK 8+:元空间)
    • 存储类元信息、运行时常量池、静态变量(JDK 7 后移至堆)。
    • JDK 8 用元空间(本地内存)替代永久代,默认无上限。
    • 异常:OOM(加载过多类)。
  • 运行时常量池:Class 文件中存储编译时生成的常量信息,并在类加载时进入 JVM 方法区。

直接内存(非 JVM 规范)

直接内存是 JVM 堆外的本地内存。具有读写快、无 GC 开销,需手动管理的特性。

  • 分配:ByteBuffer.allocateDirect()
  • 清理:DirectBuffer.cleaner().clean()
  • 场景:高频 I/O(如 NIO、Netty、MMAP)
  • 异常:Direct buffer memory
  • JVM 参数:可以通过 -XX:MaxDirectMemorySize 设置直接内存大小,如果无设置,默认大小等于 -Xmx 值。

【困难】JVM 产生 OOM 有哪几种情况?

JVM 发生 OutOfMemoryError(OOM) 的原因多种多样,主要与内存区域划分和对象分配机制相关。以下是所有可能的 OOM 类型及其触发条件、典型案例和排查方法:

Java heap space

  • 触发条件堆内存不足,无法分配新对象。

  • 常见原因

    • 内存泄漏(如静态容器持续增长、未关闭的资源)。
    • 堆内存设置过小(-Xmx 值不合理)。
    • 大对象(如一次性加载超大文件到内存)。
  • 案例代码

    1
    2
    3
    4
    List<byte[]> list = new ArrayList<>();
    while (true) {
    list.add(new byte[1024 * 1024]); // 持续分配 1MB 数组
    }
  • 解决方向

    • 检查 -Xmx-Xms 参数是否合理。
    • 使用 jmap -histo:live <pid>MAT(Memory Analyzer Tool) 分析堆转储(-XX:+HeapDumpOnOutOfMemoryError)。

Metaspace(JDK 8 及以后)

  • 触发条件元空间(Metaspace)不足,无法加载新的类信息。

  • 常见原因

    • 动态生成大量类(如反射、CGLIB、动态代理)。
    • 未设置元空间上限(默认依赖本地内存,可能耗尽)。
  • 案例代码

    1
    2
    3
    4
    5
    for (int i = 0; i < 1000000; i++) {
    Enhancer enhancer = new Enhancer(); // CGLIB 动态生成类
    enhancer.setSuperclass(OOM.class);
    enhancer.create();
    }
  • 解决方向

    • 调整元空间大小:-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
    • 检查类加载器泄漏(如热部署未清理旧类)。

PermGen space(JDK 7 及以前)

  • 类似 Metaspace,但发生在永久代(PermGen),JDK 8 后被元空间取代。
  • 常见原因:大量字符串常量或类加载未卸载。

Direct buffer memory

  • 触发条件直接内存(堆外内存)耗尽

  • 常见原因

    • NIO 的 ByteBuffer.allocateDirect() 未释放。
    • 直接内存上限过小(-XX:MaxDirectMemorySize)。
  • 案例代码

    1
    2
    3
    4
    List<ByteBuffer> buffers = new ArrayList<>();
    while (true) {
    buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 1MB 直接内存
    }
  • 解决方向

    • 显式调用 ((DirectBuffer) buffer).cleaner().clean() 或复用缓冲区。
    • 增加 -XX:MaxDirectMemorySize=1G

Unable to create new native thread

  • 触发条件线程数超过系统限制(非堆内存问题)。

  • 常见原因

    • 线程池配置不合理(如无界线程池)。
    • 系统级限制(ulimit -u 查看用户最大线程数)。
  • 案例代码

    1
    2
    3
    4
    5
    while (true) {
    new Thread(() -> {
    try { Thread.sleep(100000); } catch (Exception e) {}
    }).start();
    }
  • 解决方向

    • 改用线程池(如 ThreadPoolExecutor)。
    • 调整系统限制(Linux 下修改 /etc/security/limits.conf)。

GC overhead limit exceeded

  • 触发条件:GC 耗时超过 98% 且回收内存不足 2%(JVM 自我保护)。
  • 本质原因:堆内存几乎耗尽,GC 无效循环。
  • 解决方向
    • heap space 排查内存泄漏。
    • 关闭保护机制(不推荐):-XX:-UseGCOverheadLimit

CodeCache is full(JIT 编译代码缓存满)

  • 触发条件:JIT 编译的本地代码超出缓存区(-XX:ReservedCodeCacheSize)。
  • 常见原因:动态生成大量方法(如频繁调用反射)。
  • 解决方向
    • 增加缓存:-XX:ReservedCodeCacheSize=256M
    • 关闭分层编译:-XX:-TieredCompilation

Requested array size exceeds VM limit

  • 触发条件:尝试分配超过 JVM 限制的数组(如 Integer.MAX_VALUE - 2)。

  • 案例代码

    1
    int[] arr = new int[Integer.MAX_VALUE]; // 直接崩溃
  • 解决方向:检查代码中不合理的数组分配逻辑。

OOM 类型速查表

OOM 类型 关联内存区域 典型原因
Java heap space 内存泄漏/堆太小
Metaspace / PermGen space 元空间/永久代 类加载爆炸
Unable to create native thread 系统线程数 线程池失控/系统限制
Direct buffer memory 堆外内存 NIO Buffer 未释放
GC overhead limit exceeded GC 无效循环
CodeCache is full JIT 代码缓存 动态方法过多
Requested array size exceeds VM 超大数组分配

类加载

【中等】Java 里的对象在虚拟机里面是怎么存储的?

64 位 JVM 中,一个空Object占 16 字节(12 字节头 + 4 字节填充)。

每个 Java 对象在堆内存中分为 3 个部分

  • 对象头(Header)
    • Mark Word:存储哈希码、GC 年龄、锁状态(如偏向锁信息)。
    • Class Pointer:指向类元数据的指针(压缩后占 4 字节,否则 8 字节)。
  • 实例数据(Fields):对象的所有成员变量(包括继承的字段),按类型对齐存储。
  • 对齐填充(Padding):确保对象大小为 8 字节的整数倍(优化 CPU 缓存行访问)。

对象分配策略

  • 新生代分配:大多数对象优先分配在** Eden 区**(若开启 TLAB,线程先分配至私有缓冲区)。触发 Young GC 后,存活对象移至 Survivor 区或晋升老年代。
  • 老年代分配:大对象(如-XX:PretenureSizeThreshold=1MB)直接进入老年代。长期存活对象(年龄 > MaxTenuringThreshold)从 Survivor 晋升。

分配方式

  • 指针碰撞(堆内存规整时,如 Serial 收集器)。
  • 空闲列表(堆内存碎片化时,如 CMS 收集器)。

【中等】Java 类的生命周期是怎样的?

Java 类的生命周期可以分为 7 个阶段:加载 → 链接(验证→准备→解析) → 初始化 → 使用 → (可能)卸载。

  • 加载(Loading)
    • 读取 .class 文件,生成 Class<?> 对象。
    • 触发条件:new、访问静态成员、反射等。
  • 链接(Linking)
    • 验证(Verification):检查字节码合法性(如魔数、继承规则)。
    • 准备(Preparation):为 static 变量分配内存,赋默认值(如 int0)。
    • 解析(Resolution):将符号引用(如类名)转为直接引用(内存地址)。
  • 初始化(Initialization)
    • 执行 <clinit>(),完成 static 赋值和静态代码块。
    • 触发条件:首次 new、访问非 final 静态变量、反射初始化等。
  • 使用(Using)
    • 正常调用方法、创建实例。
  • 卸载(Unloading)
    • 条件:类无实例、ClassLoader 被回收、无 Class<?> 引用。
    • 典型场景:动态加载的类(如热部署)。

【困难】什么是类加载器吗?

Java 类加载器是 JVM(Java 虚拟机) 的核心组件之一,负责在运行时动态加载 Java 类(.class 文件)到内存,并生成对应的 Class<?> 对象。

类加载器层次结构

类加载器采用 “双亲委派模型” 进行层次化管理,确保类的唯一性和安全性。按层级自上而下有 4 种类加载器:

img

类加载器 加载范围 说明
Bootstrap ClassLoader(启动类加载器) JRE/lib-Xbootclasspath 由 C++ 实现,是 JVM 的一部分,无 Java 父类加载器
Extension ClassLoader(扩展类加载器) JRE/lib/ext-Djava.ext.dirs 加载 Java 扩展库(如 javax.*
Application ClassLoader(应用类加载器) -Djava.class.path-cp-classpath 默认加载用户编写的类(main() 方法所在类)
Custom ClassLoader(自定义类加载器) 用户自定义路径(如网络、加密类) 可继承 ClassLoader 实现个性化加载逻辑

双亲委派模型

双亲委派模型(Parents Delegation Model)要求除了顶层的 Bootstrap ClassLoader 外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。

工作原理只有当父类加载器加载失败的情况下,才会用子类加载器去加载类

优势

  • 避免重复加载:双亲委派模型使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而确保类在 JVM 中唯一(如 java.lang.Object 只由 Bootstrap 加载)。
  • 安全性:防止用户伪造核心类(如自定义 java.lang.String 会被父类加载器拦截)。

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public abstract class ClassLoader {
// The parent class loader for delegation
private final ClassLoader parent;

public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先判断该类型是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
// 如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
// 如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
// 如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器加载失败,会抛出 ClassNotFoundException
}

if (c == null) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}

【说明】

  • 先检查类是否已经加载过,如果没有则让父类加载器去加载。
  • 当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

字节码

【中等】Java 是编译型语言还是解释型语言?

结论:Java 既是编译型语言,也是解释型语言

::: info 什么是编译型语言?什么是解释型语言?
:::

  • 编译型语言 - 程序在执行之前需要一个专门的编译过程,把程序编译成为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了。一般情况下,编译型语言的执行速度比较快,开发效率比较低。常见的编译型语言有 C、C++、Go 等。
  • 解释型语言 - 程序不需要编译,只是在程序运行时通过 解释器 ,将代码一句一句解释为机器代码后再执行。一般情况下,解释型语言的执行速度比较慢,开发效率比较高。常见的解释型语言有 JavaScript、Python、Ruby 等。

::: info 为什么说 Java 既是编译型语言,也是解释型语言?
:::

Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因此,我们说 Java 是编译和解释并存的。

  • 编译:源码 → 字节码(.java.class)。
  • 解释/JIT:字节码 → 机器码(解释执行 + 热点代码编译优化)。

Java 的源代码,首先,通过 Javac 编译成为字节码(bytecode),即 *.java 文件转为 *.class 文件;然后,在运行时,通过 Java 虚拟机(JVM)内嵌的解释器将字节码转换成为最终的机器码来执行。正是由于 JVM 这套机制,使得 Java 可以【一次编写,到处执行(Write Once, Run Anywhere)】。

为了改善解释语言的效率而发展出的 即时编译 技术,已经缩小了这两种语言间的差距。这种技术混合了编译语言与解释型语言的优点,它像编译语言一样,先把程序源代码编译成 字节码 。到执行期时,再将字节码直译,之后执行。JavaLLVM 是这种技术的代表产物。常见的 JVM(如 Hotspot JVM),都提供了 JIT(Just-In-Time)编译器,JIT 能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是解释执行了。

::: tip 扩展

基本功 | Java 即时编译器原理解析及实践

:::

【中等】什么是 Java 字节码?它与机器码有什么区别?

Java 字节码(Java Bytecode)是 Java 源代码编译后生成的中间代码,它是 Java 虚拟机(JVM)执行的指令集。JVM 通过解释器或即时编译(JIT)将字节码转换为机器码执行。字节码是 Java 实现【一次编写,到处执行(Write Once, Run Anywhere)】的核心技术之一。

机器码是直接由 CPU 执行的二进制指令。

Java 字节码要点

  • 基本概念
    • 平台无关的中间代码,存储在 .class 文件中。
    • 包含类结构、字段、方法及对应的字节码指令。
  • 指令集:包含加载(aload/iload)、存储(astore)、运算(iadd)、控制流(if_icmpgt)等操作。
  • 执行方式
    • 解释执行:JVM 逐条解释字节码。
    • JIT 编译:热点代码动态编译为机器码优化性能。
  • 动态能力
    • 反射:运行时动态解析/修改字节码(如生成代理类)。
    • 字节码增强:框架(Spring AOP 等)通过 ASM、Javassist 等工具修改字节码,实现 AOP 等功能。

::: tip 扩展

美团 - 字节码增强技术探索

:::

【中等】.class 文件的结构包含哪些主要部分?

  • 魔数 (Magic Number)
  • 版本信息
  • 常量池 (Constant Pool)
  • 访问标志
  • 类索引、父类索引和接口索引
  • 字段表
  • 方法表
  • 属性表

【中等】如何查看 Java 字节码?常用工具有哪些?

  • javap (JDK 自带)
  • ASM
  • Bytecode Viewer
  • JBE (Java Bytecode Editor)

【中等】Java 字节码有哪些典型应用场景?

  • 性能优化:JIT 编译、方法内联、热点代码分析
  • AOP 与动态代理:Spring AOP、CGLIB、JDK 动态代理
  • ORM 与懒加载:Hibernate 字节码增强实现延迟加载
  • 代码分析与安全:静态分析(FindBugs)、漏洞检测、代码混淆
  • 热部署与热修复:JRebel、阿里 Sophix(运行时替换字节码)
  • 动态语言支持:Groovy、Kotlin 等 JVM 语言编译成字节码
  • Mock 测试:Mockito 动态生成 Mock 类字节码
  • 序列化优化:Jackson、FastJSON 使用字节码加速反射
  • 调试与监控:Arthas、JProfiler 插桩分析执行情况
  • JVM 研究与学习:理解 Java 语法底层实现(如try-with-resourceslambda

核心作用

  • 运行时增强(AOP、代理)
  • 性能优化(JIT、减少反射开销)
  • 动态能力(热修复、Mock 测试)
  • 跨语言支持(JVM 生态多语言)

调优

【简单】JDK 内置了哪些工具?

以下是较常用的 JDK 命令行工具:

名称 描述
jps 查看 Java 进程。显示系统内的所有 JVM 进程。
jstat JVM 统计监控工具。监控虚拟机运行时状态信息,它可以显示出 JVM 进程中的类装载、内存、GC、JIT 编译等运行数据。
jmap 生成内存快照(Heap Dump)。用于打印 JVM 进程对象直方图、类加载统计。并且可以生成堆转储快照(一般称为 heapdump 或 dump 文件)。
jstack 线程堆栈分析(排查死锁、线程阻塞)。用于打印 JVM 进程的线程和锁的情况。并且可以生成线程快照(一般称为 threaddump 或 javacore 文件)。
jhat 用来分析 jmap 生成的 dump 文件。
jinfo 查看/修改 JVM 运行参数。用于实时查看和调整 JVM 进程参数。

扩展命令行工具:

  • ArthasArthas 是阿里开源的 Java 诊断工具,无需重启应用,实时监控方法调用、查看类加载、分析性能瓶颈、热修复代码,快速定位线上问题(如 CPU 飙高、内存泄漏、方法阻塞等)。

以下是较常见的 JVM GUI 工具:

工具名称 主要功能 适用场景 优点 缺点
VisualVM - 监控内存、CPU、线程、GC - 堆转储分析 - 插件扩展(如 MBeans 监控) 开发调试、性能分析 免费、轻量、JDK 自带 功能较基础,对大堆支持有限
JConsole - 监控堆、类、线程、MBean - 简单的 GC 分析 快速监控 JVM 状态 JDK 自带,使用简单 功能较少,无法深入分析
Eclipse MAT (Memory Analyzer Tool) - 分析堆转储(heapdump) - 检测内存泄漏、大对象 内存泄漏排查、OOM 分析 强大的内存分析能力,可视化展示对象引用链 需要手动导出堆转储,对超大堆分析较慢
JProfiler - CPU 分析、内存分析、线程分析 - 实时监控、方法级调用追踪 企业级性能调优、生产环境监控 功能全面,支持多种分析模式 商业软件(付费),学习成本较高
Java Mission Control (JMC) - 实时监控 JVM - 飞行记录(Flight Recorder) - 低开销性能分析 生产环境监控、性能诊断 JDK 商业版自带,低开销 部分功能需商业授权(Oracle JDK)

【中等】常用的 JVM 配置参数有哪些?

内存相关参数

参数 作用 适用场景
-Xss 设置每个线程的栈大小
-Xms 初始堆大小 避免堆动态扩展带来的性能波动
-Xmx 最大堆大小 防止 OOM,需留 20% 系统内存余量
-Xmn 新生代大小(建议占堆 1/3~1/2) 优化 GC 频率和停顿时间
-XX:PermSize 永久代空间的初始值 Java 7 及以前用于设置方法区大小,Java 8 废弃
-XX:MaxPermSize 永久代空间的最大值 Java 7 及以前用于设置方法区大小,Java 8 废弃
-XX:MetaspaceSize 元空间初始大小(JDK8+) 避免频繁 Full GC 扩容
-XX:MaxMetaspaceSize 元空间最大大小(默认无限制) 防止元空间占用过多内存
-XX:+UseCompressedOops 启用压缩指针(64位系统默认开启) 减少内存占用(堆 < 32GB 时有效)
-XX:NewRatio 新生代与年老代的比例(默认为 2)
-XX:SurvivorRatio Eden 区与 Survivor 区比例(默认 8:1:1) 调整新生代对象晋升速度

GC 相关参数

参数 作用 示例/默认值 适用场景
-XX:+UseG1GC 启用 G1 垃圾收集器(JDK9+ 默认) -XX:+UseG1GC 大堆(>4GB)低延迟场景
-XX:MaxGCPauseMillis G1 最大停顿时间目标(毫秒) -XX:MaxGCPauseMillis=200 控制 GC 延迟
-XX:ParallelGCThreads 并行 GC 线程数(默认=CPU 核数) -XX:ParallelGCThreads=4 多核服务器优化 GC 效率
-XX:+UseConcMarkSweepGC 启用 CMS 收集器(已废弃,JDK14 移除) 不推荐使用 老年代低延迟(历史项目)
-XX:+PrintGCDetails 打印详细 GC 日志 配合 -Xloggc:/path/gc.log 调试 GC 问题
-XX:+HeapDumpOnOutOfMemoryError OOM 时自动生成堆转储文件 -XX:HeapDumpPath=/path/dump.hprof 内存泄漏分析

【中等】如何在 Java 中进行内存泄漏分析?

  • 内存泄漏的本质是对象被意外持有无法回收,通过引用链分析找到“谁在引用它”。
  • 生产环境优先配置 -XX:+HeapDumpOnOutOfMemoryError 防患未然。

确认内存泄漏现象

  • 堆内存持续增长(通过 jstat -gcutil <pid> 观察 Old GenMetaspace 使用率)。
  • Full GC 频繁但无法回收内存(jstat 显示 Full GC 次数增加)。
  • 最终触发 OutOfMemoryError: Java heap space

获取内存快照

方法 1:主动触发堆转储(Heap Dump)

1
2
3
4
5
# 使用 jmap 导出堆转储文件(需进程权限)
jmap -dump:format=b,file=heap.hprof <pid>

# 或配置 JVM 参数自动生成(OOM 时触发)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap.hprof

方法 2:通过工具生成

  • VisualVM:右键进程 → “Heap Dump”。
  • JConsole:”MBeans” → “com.sun.management” → “HotSpotDiagnostic” → “dumpHeap”。

分析堆转储文件

工具选择

工具 特点
Eclipse MAT 功能强大,支持对象引用链分析、泄漏嫌疑报告(推荐首选)。
VisualVM 基础分析,适合快速查看大对象分布。
JProfiler 商业工具,可视化交互好,支持实时监控。

MAT 关键操作步骤

  1. 打开堆转储文件FileOpen Heap Dump
  2. 查看泄漏报告
    • 首页会提示 Leak Suspects(泄漏嫌疑对象)。
    • 示例报告:"java.lang.Thread" instances retained by thread stack(线程未释放)。
  3. 分析对象引用链
    • 右键对象 → Path to GC Rootsexclude weak/soft references(排除弱引用)。
    • 查找意外被持有的对象(如静态集合、未关闭的资源)。
  4. 统计对象占比Histogram 视图按类/包名分组,排序 Retained Heap(对象总占用内存)。

常见内存泄漏场景与修复

泄漏类型 典型原因 修复方案
静态集合 静态 Map/List 持续添加对象未清除。 使用弱引用(WeakHashMap)或定期清理。
未关闭资源 数据库连接、文件流未调用 close() try-with-resources 自动关闭。
线程未终止 线程池或 Thread 未销毁(如定时任务)。 调用 shutdown() 或设为守护线程。
缓存未清理 本地缓存(如 Guava Cache)无过期策略。 设置大小限制或过期时间。
监听器未注销 事件监听器未移除(如 Spring Bean)。 在销毁时手动注销监听器。

实时诊断工具(无需堆转储)

Arthas(阿里开源)

1
2
3
4
5
6
7
8
9
# 监控对象增长
watch java.util.HashMap size '{params,returnObj}' -n 5

# 查看类实例数量
sc -d *MyClass | grep classLoaderHash
jad --source-only com.example.LeakClass > LeakClass.java

# 生成火焰图分析 CPU/内存
profiler start -d 30 -f /tmp/flamegraph.html

JVisualVM:安装 VisualGC 插件,实时观察各内存区域变化。

【中等】如何对 Java 的垃圾回收进行调优?

调优核心目标

  • 降低延迟(Latency):减少 GC 停顿时间(STW),提升响应速度。
  • 提高吞吐量(Throughput):最大化应用处理业务的时间占比(GC 时间占比最小化)。
  • 控制内存占用(Footprint):合理分配堆内存,避免浪费或频繁扩容。

调优原则

  • 数据驱动:基于监控而非猜测调整参数。
  • 渐进式修改:每次只改一个参数,观察效果。
  • 权衡取舍:低延迟可能牺牲吞吐量,需根据业务需求选择。

通过以上步骤,可系统性地优化 Java GC 性能,解决停顿时间长、吞吐不足等问题。

调优步骤

监控与基线分析

  • 工具
    • jstat -gcutil <pid>:实时监控 GC 各区域使用率。
    • GC 日志:通过 -Xlog:gc*-XX:+PrintGCDetails 记录详细 GC 行为。
    • VisualVM/Grafana + Prometheus:可视化内存和 GC 趋势。
  • 关键指标:Young GC / Full GC 频率、平均停顿时间、吞吐量(1 - GC时间/总时间)。

选择垃圾收集器

收集器 适用场景 关键参数
G1 GC 平衡延迟与吞吐(JDK8+ 默认) -XX:MaxGCPauseMillis=200(目标停顿时间)
ZGC 超低延迟(JDK11+,大堆) -XX:+UseZGC -Xmx>8G
Parallel GC 高吞吐量(批处理任务) -XX:+UseParallelGC -XX:ParallelGCThreads=8

堆内存分配优化

  • 总堆大小-Xms/-Xmx):
    • 建议设为物理内存的 50%~70%(预留空间给 OS 和其他进程)。
    • 容器化环境需启用 -XX:+UseContainerSupport
  • 新生代与老年代比例:G1 无需手动设置(自动调整),Parallel GC 可设 -Xmn(如堆的 1/3)。

关键参数调优

  • G1 专用参数

    1
    2
    3
    -XX:InitiatingHeapOccupancyPercent=45  # 老年代占用阈值触发Mixed GC
    -XX:G1NewSizePercent=20 # 新生代最小占比
    -XX:G1MaxNewSizePercent=50 # 新生代最大占比
  • 通用参数

    1
    2
    -XX:MetaspaceSize=512M                # 避免元空间动态扩容
    -XX:+HeapDumpOnOutOfMemoryError # OOM时自动转储内存

避免常见陷阱

  • Full GC 频繁
    • 检查老年代对象晋升过快(调整 -XX:MaxTenuringThreshold)。
    • 避免大对象直接进入老年代(如 -XX:G1HeapRegionSize 适配对象大小)。
  • MetaSpace OOM
    • 增加 -XX:MaxMetaspaceSize(如 1G),并检查动态类生成(反射/CGLIB)。

验证与迭代

  • 压测对比:使用相同负载对比调优前后的 GC 日志。
  • 持续监控:生产环境通过 APM(如 SkyWalking)观察长周期效果。

调优示例

场景:Web 服务(低延迟优先)

1
2
3
4
5
6
7
# G1 GC 配置示例
-Xms4G -Xmx4G
-XX:+UseG1GC
-XX:MaxGCPauseMillis=150
-XX:InitiatingHeapOccupancyPercent=40
-XX:G1HeapRegionSize=4M
-Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime

场景:大数据计算(高吞吐优先)

1
2
3
4
5
6
# Parallel GC 配置示例
-Xms8G -Xmx8G
-XX:+UseParallelGC
-XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=500
-XX:+UseAdaptiveSizePolicy # 自动调整新生代/老年代比例

高级工具

  • JFR(Java Flight Recorder)

    1
    -XX:StartFlightRecording=duration=60s,settings=profile,jfr=memory=on
  • Arthas:实时诊断内存泄漏(如 heapdump 命令)。

Java 基础面试一

Java 常识

【简单】Java 语言有什么优势?

  • 跨平台:【一次编写,到处执行(Write Once, Run Anywhere)】——JVM 执行字节码。
  • 自动垃圾回收:垃圾回收(GC)减少内存泄漏风险。
  • 强大生态:Spring、Hadoop、Android 等广泛支持。
  • 面向对象:支持封装、继承、多态,代码结构清晰易维护。
  • 高性能:JIT 编译优化,多线程支持高并发。
  • 健壮安全:强类型检查、异常处理、JVM 安全机制。

【简单】Oracle JDK 和 Open JDK 有什么区别?

OpenJDK Oracle JDK
是否开源 完全开源 闭源
是否免费 完全免费 JDK8u221 之后存在限制
更新频率 一般每 3 个月发布一个版本;不提供 LTS 服务 一般每 6 个月发布一个版本;大概每三年推出一个 LTS 版本
功能性 Java 11 之后,OracleJDK 和 OpenJDK 的功能基本一致
协议 GPL v2 BCL/OTN

【简单】Java SE 和 Java EE 有什么区别?

Java 技术既是一种编程语言,又是一种平台。Java 编程语言是一种具有特定语法和风格的高级面向对象语言。Java 平台是 Java 编程语言应用程序运行的特定环境。

  • Java SE(Java Platform, Standard Edition) - Java 平台标准版。Java SE 的 API 提供了 Java 编程语言的核心功能。它定义了从 Java 编程语言的基本类型和对象到用于网络、安全、数据库访问、图形用户界面 (GUI) 开发和 XML 解析的高级类的所有内容。除了核心 API 之外,Java SE 平台还包括虚拟机、开发工具、部署技术以及 Java 技术应用程序中常用的其他类库和工具包。
  • Java EE(Java Platform, Enterprise Edition) - Java 平台企业版。Java EE 构建在 Java SE 基础之上。 Java EE 定义了企业级应用程序开发和部署的标准和规范,如:Servlet、JSP、EJB、JDBC、JPA、JTA、JavaMail、JMS。

::: tip 扩展

Your First Cup

:::

【简单】JDK、JRE、JVM 之间有什么关系?

JDK、JRE、JVM 的定义和简介:

  • JVM - Java Virtual Machine 的缩写,即 Java 虚拟机。JVM 是运行 Java 字节码的虚拟机。JVM 不理解 Java 源代码,这就是为什么要将 *.java 文件编译为 JVM 可理解的 *.class 文件(字节码)。Java 有一句著名的口号:“Write Once, Run Anywhere(一次编写,随处运行)”,JVM 正是其核心所在。实际上,JVM 针对不同的系统(Windows、Linux、MacOS)有不同的实现,目的在于用相同的字节码执行同样的结果。
  • JRE - Java Runtime Environment 的缩写,即 Java 运行时环境。它是运行已编译 Java 程序所需的一切的软件包,主要包括 JVM、Java 类库(Class Library)、Java 命令和其他基础结构。但是,它不能用于创建新程序。
  • JDK - Java Development Kit 的缩写,即 Java SDK。它不仅包含 JRE 的所有功能,还包含编译器 (javac) 和工具(如 javadoc 和 jdb)。它能够创建和编译程序。

总结来说,JDK、JRE、JVM 三者的关系是:JDK > JRE > JVM

1
2
3
JDK = JRE + 开发/调试工具
JRE = JVM + Java 类库 + Java 运行库
JVM = 类加载系统 + 运行时内存区域 + 执行引擎

::: tip 扩展

stackoverflow 高票问题 - What is the difference between JDK and JRE?

:::

【中等】Java 如何调用外部可执行程序或系统命令?

Java 提供了两种调用外部可执行程序或系统命令的方式:

  • ProcessBuilder
  • Runtime.exec()

::: tip 扩展

https://blog.csdn.net/m0_46487331/article/details/128827908

:::

Java 基础语法

【简单】Java 有几种注释形式?

注释用于在源代码中解释代码的作用,可以增强程序的可读性,可维护性。 空白行,或者注释的内容,都会被 Java 编译器忽略掉。

Java 注释主要有三种类型:

  • 单行注释
  • 多行注释
  • 文档注释(JavaDoc)
1
2
3
4
5
6
7
8
9
10
11
12
13
public class HelloWorld {
/**
* 文档注释
*/
public static void main(String[] args) {
// 单行注释
/*
多行注释
*/
System.out.println("Hello World");
}

}

【简单】Java 有哪些标识符命名规则?

Java 所有的组成部分都需要名字。类名、变量名以及方法名都被称为标识符。

标识符基本规则

  • 组成元素:类名、变量名、方法名等统称为标识符
  • 允许字符:可包含字母、数字、$_
  • 首字符要求:不能以数字开头
  • 禁止关键字:如 classpublic 等保留字不可作为标识符
  • 大小写敏感ageAge 被视为不同标识符

命名规范

在 Java 中,标识符通常遵循 驼峰命名法

类型 命名法 示例
类/接口名 大驼峰(Upper CamelCase) StudentInfoUserService
方法/变量名 小驼峰(Lower CamelCase) getUserName()studentAge
常量名 全大写蛇形(SNAKE_CASE) MAX_SIZEDEFAULT_TIMEOUT

注意事项

  • **避免使用 $**:虽然合法,但通常用于编译器生成代码
  • 无长度限制:但应保持简洁且语义明确(如用 count 而非 c
  • Unicode 支持:可使用中文等字符(但不推荐)

【简单】Java 中有哪些关键字?

下面列出了 Java 保留字,这些保留字不能用于常量、变量、和任何标识符的名称。

分类 关键字
访问级别修饰符 private、protected、public、default
类,方法和变量修饰符 abstract、class、extends、final、implements、interface、native、new、static、strictfp、synchronized、transient、volatile、enum
程序控制语句 break、continue、return、do、while、if、else、for、instanceof、switch、case
错误处理 assert、try、catch、throw、throws、finally
包相关 import、package
数据类型 boolean、byte、char、short、int、long、float、double、enum
变量引用 super、this、void
其他保留字 goto、const

::: warning

Java 的 null 不是关键字,类似于 truefalse,它是一个字面常量,不允许作为标识符使用。

官方文档https://docs.oracle.com/javase/tutorial/java/nutsandbolts/_keywords.html

:::

【中等】如果移位操作位数超限会怎样?

移位位数处理机制

Java 对移位位数超限的处理采用隐式取模运算

  • int 类型(32 位):实际移位位数 = 指定位数 % 32
    • 例如:x << 42 → 实际左移 42 % 32 = 10
  • long 类型(64 位):实际移位位数 = 指定位数 % 64
    • 例如:x << 100 → 实际左移 100 % 64 = 36

位操作统一规则

操作符 示例 等效操作 说明
<< x << 35 x << 3 (35%32=3) 左移,低位补 0
>> x >> 35 x >> 3 (35%32=3) 右移,高位补符号位(算术右移)
>>> x >>> 35 x >>> 3 (35%32=3) 无符号右移,高位补 0

底层原理

  • 硬件优化:CPU 执行移位指令时,实际只使用指定位数的低 5 位(int)或低 6 位(long),与 Java 的取模规则一致。
  • 安全设计:避免无效的大位数移位(如 x << 1000)导致不可预测行为。

示例

1
2
3
4
5
6
int i = -1; // 二进制全 1(32 个 1)
System.out.println(i << 10); // 左移 10 位,输出 -1024
System.out.println(i << 42); // 等效左移 10 位(42%32=10),同样输出 -1024

long l = -1L;
System.out.println(l << 70); // 等效左移 6 位(70%64=6),输出 -64

特殊情况

  • 移位 0 位:任何 x << 32x >> 64 等效不移位(因 32%32=064%64=0)。
  • 负数移位:移位位数可为负数,但会通过取模转为正数(如 x << -6x << 26,因 -6 % 32 = 26)。

::: info 为什么这样设计?
:::

  • 兼容性:与 C/C++的移位行为一致。
  • 性能:直接映射到 CPU 指令,无需额外检查。
  • 确定性:保证结果可预测,避免未定义行为。

Java 数据类型

【简单】Java 有哪些值类型?

Java 中的数据类型有两类:

  • 值类型(又叫内置数据类型,基本数据类型)
  • 引用类型(除值类型以外,都是引用类型,包括 String、数组等)

Java 语言提供了 8 种基本类型,大致分为 4 类:布尔型、字符型、整数型、浮点型。

基本数据类型 分类 大小 默认值 取值范围 包装类 说明
boolean 布尔型 - false false, true Boolean boolean 的大小,是由具体的 JVM 实现来决定的
char 字符型 16 bit 'u0000' [0, 2^16 - 1] Character 存储 Unicode 码,用单引号赋值
byte 整数型 8 bit 0 [-2^7, 2^7 - 1] Byte
short 整数型 16 bit 0 [-2^15, 2^15 - 1] Short
int 整数型 32 bit 0 [-2^31, 2^31 - 1] Integer
long 整数型 64 bit 0L [-2^63, 2^63 - 1] Long 赋值时一般在数字后加上 lL
float 浮点型 32 bit 0.0f [2^-149, 2^128 - 1] Float 赋值时必须在数字后加上 fF
double 浮点型 64 bit 0.0d [2^-1074, 2^1024 - 1] Double 赋值时一般在数字后加 dD

::: tip 扩展

菜鸟教程 - Java 基本数据类型

:::

【简单】什么是装箱、拆箱?

::: info 什么是装箱、拆箱?
:::

Java 中为每一种基本数据类型提供了相应的包装类,如下:

1
2
3
4
5
6
7
8
Byte <-> byte
Short <-> short
Integer <-> int
Long <-> long
Float <-> float
Double <-> double
Character <-> char
Boolean <-> boolean

引入包装类的目的就是:提供一种机制,使得基本数据类型可以与引用类型互相转换

基本数据类型与包装类的转换被称为装箱和拆箱。

  • 装箱(boxing)是将值类型转换为引用类型。例如:intInteger
    • 装箱过程是通过调用包装类的 valueOf 方法实现的
  • 拆箱(unboxing)是将引用类型转换为值类型。例如:Integerint
    • 拆箱过程是通过调用包装类的 xxxValue 方法实现的。(xxx 代表对应的基本数据类型)。

::: info 什么是自动装箱与拆箱?
:::

1
2
Integer a = 10;  //装箱
int b = a; //拆箱

上面这两行代码对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
L1

LINENUMBER 8 L1

ALOAD 0

BIPUSH 10

INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;

PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;

L2

LINENUMBER 9 L2

ALOAD 0

ALOAD 0

GETFIELD AutoBoxTest.i : Ljava/lang/Integer;

INVOKEVIRTUAL java/lang/Integer.intValue ()I

PUTFIELD AutoBoxTest.n : I

RETURN

通过字节码代码,不难发现,装箱其实就是调用了 包装类的 valueOf() 方法;而拆箱其实就是调用了 xxxValue() 方法。再次印证前文的内容:

  • 装箱过程是通过调用包装类的 valueOf 方法实现的
  • 拆箱过程是通过调用包装类的 xxxValue 方法实现的

因此,

  • Integer a = 10 等价于 Integer a = Integer.valueOf(10)
  • int b = a 等价于 int b = a.intValue();

::: tip 扩展

深入剖析 Java 中的装箱和拆箱

:::

【中等】包装类型的缓存机制了解么?

Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False

如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。

::: tabs

@tab Integer 缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
private static class IntegerCache {
static final int low = -128;
static final int high;
static {
// high value may be configured by property
int h = 127;
}
}

@tab Character 缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Character valueOf(char c) {
if (c <= 127) { // must cache
return CharacterCache.cache[(int)c];
}
return new Character(c);
}

private static class CharacterCache {
private CharacterCache(){}
static final Character cache[] = new Character[127 + 1];
static {
for (int i = 0; i < cache.length; i++)
cache[i] = new Character((char)i);
}

}

@tab Boolean 缓存

1
2
3
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}

@tab FloatDouble 无缓存

两种浮点数类型的包装类 Float,Double 并没有实现缓存机制。

1
2
3
4
5
6
7
8
9
10
11
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出 true

Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22);// 输出 false

Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出 false

:::

下面我们来看一个问题:下面的代码的输出结果是 true 还是 false 呢?

1
2
3
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2);

Integer i1=40 这一行代码会发生装箱,也就是说这行代码等价于 Integer i1=Integer.valueOf(40) 。因此,i1 直接使用的是缓存中的对象。而Integer i2 = new Integer(40) 会直接创建新的对象。

因此,答案是 false 。你答对了吗?

值得一提的是,包装类通过缓存一定范围的常用数值,避免重复创建对象,以减少内存使用的思想,正是采用了享元模式(设计模式之一)。

记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较

【简单】比较包装类型为什么不能用 ==?

Java 值类型的包装类大部分都使用了缓存机制来提升性能:

  • ByteShortIntegerLong 这 4 种包装类,默认都创建了数值在 [-128,127] 范围之间的相应类型缓存数据;
  • Character 创建了数值在 [0,127] 范围之间的缓存数据;
  • Boolean 直接返回 True or False

试图装箱的数值,如果超出缓存范围,则会创建新的对象。

Long.valueOf 方法为例:

1
2
3
4
5
6
7
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

【中等】为什么浮点数运算的时候会有精度丢失的风险?

浮点数运算精度丢失代码演示:

1
2
3
4
5
float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a); // 0.100000024
System.out.println(b); // 0.099999905
System.out.println(a == b); // false

为什么会出现这个问题呢?

这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。

就比如说十进制下的 0.2 就没办法精确转换成二进制小数:

1
2
3
4
5
6
7
8
// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...

【简单】如何解决浮点数运算的精度丢失问题?

BigDecimal 直接使用字符串初始化(如 new BigDecimal("0.1"))可完全避免二进制浮点误差。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)可以通过 BigDecimal 来处理。

1
2
3
4
5
6
7
8
9
10
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");

BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);

System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */

【简单】超过 long 整型的数据应该如何表示?

基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。

在 Java 中,64 位 long 整型是最大的整数类型。

1
2
3
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true

BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。

相对于常规整数类型的运算来说,BigInteger 运算的效率会相对较低。

Java 变量

【简单】静态变量、成员变量、局部变量的区别?

静态变量、成员变量、局部变量的主要区别

特性 静态变量(static) 成员变量(非 static) 局部变量
所属 类(所有实例共享) 对象(每个实例独立) 方法/代码块内
生命周期 类加载时创建,程序结束时销毁 对象创建时存在,垃圾回收时销毁 方法调用时创建,执行完销毁
存储位置 方法区(JDK8+在元空间/堆) 堆(对象内部) 栈(方法栈帧)
默认值 有(如int默认为 0) 有(同静态变量) (必须手动初始化)
访问方式 类名.变量名对象.变量名 对象.变量名 只能在声明的方法/块内使用

一句话总结

  • 静态变量:全局唯一,类共享。
  • 成员变量:对象私有,每个实例独立。
  • 局部变量:临时使用,方法内有效。

【简单】为什么成员变量有默认值?

成员变量有默认值的核心原因是:防止随机值风险

  • 内存安全:未初始化的变量会指向内存中的随机值,可能导致程序行为异常或崩溃。
  • 稳定运行:自动赋默认值(如 int0booleanfalse)确保程序逻辑可预测。

编译器设计的权衡

  • 成员变量自动赋默认值是内存安全与灵活性的平衡
    • 运行时可能通过反射、构造器等动态赋值,编译器无法完全静态检测。
    • 为避免误报错误,统一自动赋默认值。
  • 局部变量严格编译检查确保代码可靠性
    • 作用域限于方法内,编译器可严格检查是否赋值。
    • 强制手动初始化以规避潜在风险。

【简单】字符型常量和字符串常量的区别?

场景 字符常量 字符串常量
表示形式 单引号括起的单个字符'A' 双引号括起的字符序列"ABC"
数据类型 char(基本类型) String(引用类型)
内存占用 2 字节(Unicode 字符,如 '中''\n' 对象开销+字符数据(可变长度)
转义字符 支持('\t''\\' 同样支持("\t""\\"
空值表示 不可为空(至少 1 字符) 可为空(""
运算行为 按 Unicode 值运算 重载+为拼接

Java 方法

【简单】Java 方法有哪些类型?

Java 方法的类型可以从不同维度分类。

::: tabs

@tab 按从属划分

类型 关键字 调用方式 特点 示例
实例方法 对象名.方法名 () 依赖对象实例,可访问实例成员 list.add("item")
静态方法 static 类名.方法名 () 不依赖实例,只能访问静态成员 Math.abs(-1)
构造方法 new 类名 () 用于对象初始化,无返回值类型 new String("hello")

@tab 按能否 override 划分

类型 关键字 特点 示例
普通方法 可被重写(除非final修饰) public void show()
final 方法 final 禁止子类重写 public final void lock()
抽象方法 abstract 无实现,需子类重写 abstract void draw();
默认方法 default Java 8 接口中的默认实现 default void log()

@tab 按参数与返回值划分

类型 特点 示例
无参方法 不需要参数 String getName()
有参方法 可接受基本类型/对象参数 void setAge(int age)
可变参方法 参数数量可变(...语法) void print(String... strs)
无返回值方法 返回类型为void void shutdown()
有返回值方法 必须返回指定类型值 int calculate()

@tab 特殊方法

类型 特点 示例
native 方法 native声明,由本地代码实现 public native void start()
synchronized 方法 synchronized修饰,线程安全 public synchronized void save()
递归方法 方法内部调用自身 int factorial(int n)
泛型方法 声明类型参数 <T> T getData()

@tab 接口中的方法

类型 关键字 特点
抽象方法 默认public abstract
默认方法 default Java 8 引入,提供默认实现
静态方法 static Java 8 引入,接口直接调用
私有方法 private Java 9 引入,仅供接口内部使用

:::

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 实例方法 vs 静态方法
class Calculator {
// 实例方法
public int add(int a, int b) { return a + b; }

// 静态方法
public static int staticAdd(int a, int b) { return a + b; }
}

// 抽象方法
abstract class Shape {
abstract void draw(); // 必须由子类实现
}

// 默认方法
interface Logger {
default void log(String msg) { System.out.println(msg); }
}

// 泛型方法
class Box {
public <T> T wrap(T item) { return item; }
}

::: info 如何选择方法类型?
:::

  • 需要操作对象状态 → 实例方法(如user.getName()
  • 工具类操作 → 静态方法(如Collections.sort()
  • 强制子类实现 → 抽象方法(如Animal.eat()
  • 接口功能扩展 → 默认方法(Java 8+)
  • 线程安全控制synchronized方法

【简单】静态方法和实例方法有何不同?

静态方法和实例方法主要区别

维度 静态方法 (Static Method) 实例方法 (Instance Method)
归属 属于类 属于对象实例
关键字 使用 static 修饰 static 修饰
调用方式 类名.方法名 () 对象名.方法名 ()
内存分配 类加载时分配,永久代(JDK8 前)/元空间(JDK8+) 对象实例化时分配,堆内存
生命周期 与类相同(从类加载到 JVM 退出) 与对象相同(从对象创建到被 GC 回收)

访问权限对比

维度 静态方法 实例方法
访问静态成员 ✅ 可直接访问 ✅ 可直接访问
访问实例成员 ❌ 不能直接访问(需先创建对象) ✅ 可直接访问
this/super ❌ 不可使用 ✅ 可使用

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Calculator {
// 静态方法
public static int add(int a, int b) {
return a + b; // 不依赖对象状态
}

// 实例方法
private int base;
public void setBase(int base) {
this.base = base; // 依赖对象状态
}
public int calculate(int x) {
return base + x; // 访问实例变量
}
}

// 调用示例
public class Main {
public static void main(String[] args) {
// 静态方法调用
int sum = Calculator.add(3, 5); // 无需创建对象

// 实例方法调用
Calculator calc = new Calculator();
calc.setBase(10);
int result = calc.calculate(5); // 需要对象实例
}
}

【简单】重载和重写有什么区别?

Java 重载(Overload)与重写(Override)的核心区别

特性 重载(Overload) 重写(Override)
定义 同一类中方法名相同但参数不同 子类重新实现父类的方法
目的 处理不同类型/数量的参数 修改或扩展父类方法的行为
多态类型 编译时多态(静态绑定) 运行时多态(动态绑定)
作用范围 同一类中(或父子类间) 子类与父类之间
方法签名 必须不同参数(类型/数量/顺序) 必须完全相同(方法名+参数)
返回值 可自由修改 基本类型/void:必须相同;引用类型:可协变(子类更具体)
异常 可自由声明 子类异常 ≤ 父类异常范围
访问权限 可自由修改 子类权限 ≥ 父类(不能更严格)
限制方法 不能重写 private/final/static 方法

::: code-tabs#重载和重写的示例

@tab 重载示例

1
2
3
4
5
6
7
8
class Calculator {
// 参数类型不同
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }

// 参数数量不同
int add(int a, int b, int c) { return a + b + c; }
}

@tab 重写示例

1
2
3
4
5
6
7
8
9
10
class Animal {
protected String sound() { return "Unknown sound"; }
}

class Cat extends Animal {
@Override
public String sound() { // 访问权限扩大,返回值相同
return "Meow";
}
}

:::

::: note 关键区别总结

  • 绑定时机
    • 重载:编译时根据参数决定调用的方法(Calculator.add(int) vs Calculator.add(double)
    • 重写:运行时根据对象实际类型决定方法(Animal.sound() 实际调用 Cat.sound()
  • 设计目的
    • 重载:横向扩展(同一功能的不同参数版本)
    • 重写:纵向覆盖(子类定制父类行为)
  • 验证阶段
    • 重载:编译器检查参数差异
    • 重写:编译器检查方法签名 + JVM 运行时验证

:::

【简单】什么是可变长参数?

从 Java5 开始,Java 支持定义可变长参数,所谓可变长参数就是允许在调用方法时传入不定长度的参数。就比如下面这个方法就可以接受 0 个或者多个参数。

1
2
3
public static void method1(String... args) {
//......
}

另外,可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。

1
2
3
public static void method2(String arg1, String... args) {
//......
}

遇到方法重载的情况怎么办呢?会优先匹配固定参数还是可变参数的方法呢?

答案是会优先匹配固定参数的方法,因为固定参数的方法匹配度更高。

我们通过下面这个例子来证明一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class VariableLengthArgument {

public static void printVariable(String... args) {
for (String s : args) {
System.out.println(s);
}
}

public static void printVariable(String arg1, String arg2) {
System.out.println(arg1 + arg2);
}

public static void main(String[] args) {
printVariable("a", "b");
printVariable("a", "b", "c", "d");
}
}

输出:

1
2
3
4
5
ab
a
b
c
d

另外,Java 的可变参数编译后实际会被转换成一个数组,我们看编译后生成的 class文件就可以看出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VariableLengthArgument {

public static void printVariable(String... args) {
String[] var1 = args;
int var2 = args.length;

for(int var3 = 0; var3 < var2; ++var3) {
String s = var1[var3];
System.out.println(s);
}

}
// ......
}

Java 异常

【简单】Exception 和 Error 有什么区别?

在 Java 中,所有的异常都有一个共同的祖先 java.lang 包中的 Throwable 类。Throwable 类有两个重要的子类:

  • Exception - 程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又分为检查(checked)异常和非检查(unchecked)异常,检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。
  • Error - Error 属于程序无法处理的错误。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。

【简单】Checked Exception 和 Unchecked Exception 有什么区别?

差异对比

特性 Checked Exception Unchecked Exception
编译检查 必须显式处理(catch/throws),否则编译失败 不强制处理,编译可通过
继承体系 继承自 Exception(非 RuntimeException 分支) 继承自 RuntimeException
设计目的 处理可预见的、可恢复的异常情况(如文件不存在) 处理程序逻辑错误(如空指针)

::: tabs#Checked Exception 和 Unchecked Exception 示例对比

@tab Checked Exception 示例

1
2
3
4
5
6
// 必须处理 IOException(受检异常)
try {
Files.readAllBytes(Paths.get("file.txt"));
} catch (IOException e) { // 或声明 throws IOException
System.err.println("文件读取失败:" + e.getMessage());
}

@tab Unchecked Exception 示例

1
2
3
// 可不处理 NullPointerException(非受检异常)
String str = null;
System.out.println(str.length()); // 运行时抛出 NullPointerException

:::

常见异常类型

Checked Exception Unchecked Exception
IOException NullPointerException
SQLException IllegalArgumentException
ClassNotFoundException ArrayIndexOutOfBoundsException
InterruptedException ClassCastException

选择原则

  • 用 Checked Exception

    • 调用方必须处理该异常(如文件不存在、网络断开)
    • 异常是业务逻辑的合法流程(如用户输入校验)
  • 用 Unchecked Exception

    • 表示程序错误(如参数为 null、数组越界)
    • 调用方无法合理恢复(如内存溢出)

【简单】Throwable 类常用方法有哪些?

  • String getMessage(): 返回异常发生时的简要描述
  • String toString(): 返回异常发生时的详细信息
  • String getLocalizedMessage(): 返回异常对象的本地化信息。使用 Throwable 的子类覆盖这个方法,可以生成本地化信息。如果子类没有覆盖该方法,则该方法返回的信息与 getMessage()返回的结果相同
  • void printStackTrace(): 在控制台上打印 Throwable 对象封装的异常信息

【简单】try-catch-finally 如何使用?

  • try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
  • catch块:用于处理 try 捕获到的异常。
  • finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。

代码示例:

1
2
3
4
5
6
7
8
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
} finally {
System.out.println("Finally");
}

输出:

1
2
3
Try to do something
Catch Exception -> RuntimeException
Finally

注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。

jvm 官方文档 中有明确提到:

If the try clause executes a return, the compiled code does the following:

  1. Saves the return value (if any) in a local variable.
  2. Executes a jsr to the code for the finally clause.
  3. Upon return from the finally clause, returns the value saved in the local variable.

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
System.out.println(f(2));
}

public static int f(int value) {
try {
return value * value;
} finally {
if (value == 2) {
return 0;
}
}
}

输出:

1
0

【简单】finally 中的代码一定会执行吗?

不一定的!在某些情况下,finally 中的代码不会被执行。

就比如说 finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。

1
2
3
4
5
6
7
8
9
10
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
// 终止当前正在运行的 Java 虚拟机
System.exit(1);
} finally {
System.out.println("Finally");
}

输出:

1
2
Try to do something
Catch Exception -> RuntimeException

另外,在以下 2 种特殊情况下,finally 块的代码也不会被执行:

  1. 程序所在的线程死亡。
  2. 关闭 CPU。

【简单】如何使用 try-with-resources 代替try-catch-finally

  1. 适用范围(资源的定义): 任何实现 java.lang.AutoCloseable或者 java.io.Closeable 的对象
  2. 关闭资源和 finally 块的执行顺序:try-with-resources 语句中,任何 catch 或 finally 块在声明的资源关闭后运行

《Effective Java》中明确指出:

面对必须要关闭的资源,我们总是应该优先使用 try-with-resources 而不是try-finally。随之产生的代码更简短,更清晰,产生的异常对我们也更有用。try-with-resources语句让我们更容易编写必须要关闭的资源的代码,若采用try-finally则几乎做不到这点。

Java 中类似于InputStreamOutputStreamScannerPrintWriter等的资源都需要我们调用close()方法来手动关闭,一般情况下我们都是通过try-catch-finally语句来实现这个需求,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//读取文本文件的内容
Scanner scanner = null;
try {
scanner = new Scanner(new File("D://read.txt"));
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (scanner != null) {
scanner.close();
}
}

使用 Java 7 之后的 try-with-resources 语句改造上面的代码:

1
2
3
4
5
6
7
try (Scanner scanner = new Scanner(new File("test.txt"))) {
while (scanner.hasNext()) {
System.out.println(scanner.nextLine());
}
} catch (FileNotFoundException fnfe) {
fnfe.printStackTrace();
}

当然多个资源需要关闭的时候,使用 try-with-resources 实现起来也非常简单,如果你还是用try-catch-finally可能会带来很多问题。

通过使用分号分隔,可以在try-with-resources块中声明多个资源。

1
2
3
4
5
6
7
8
9
10
try (BufferedInputStream bin = new BufferedInputStream(new FileInputStream(new File("test.txt")));
BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(new File("out.txt")))) {
int b;
while ((b = bin.read()) != -1) {
bout.write(b);
}
}
catch (IOException e) {
e.printStackTrace();
}

【简单】NoClassDefFoundError 和 ClassNotFoundException 有什么区别

NoClassDefFoundError是一个 Error,而 ClassNotFoundException 是一个 Exception。

ClassNotFoundException 产生的原因:

  • 使用 Class.forNameClassLoader.loadClassClassLOader.findSystemClass 方法动态加载类,如果这个类没有被找到,那么就会在运行时抛出 ClassNotFoundException 异常;
  • 当一个类已经被某个类加载器加载到内存中了,此时另一个类加载器又尝试着动态地从同一个包中加载这个类。

NoClassDefFoundError 产生的原因:当 JVM 或 ClassLoader 试图加载类,却找不到类的定义时(编译时存在,运行时找不到),抛出异常。

【简单】异常使用有哪些需要注意的地方?

  • 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动 new 一个异常对象抛出。
  • 抛出的异常信息一定要有意义。
  • 建议抛出更加具体的异常比如字符串转换为数字格式错误的时候应该抛出NumberFormatException而不是其父类IllegalArgumentException
  • 避免重复记录日志:如果在捕获异常的地方已经记录了足够的信息(包括异常类型、错误信息和堆栈跟踪等),那么在业务代码中再次抛出这个异常时,就不应该再次记录相同的错误信息。重复记录日志会使得日志文件膨胀,并且可能会掩盖问题的实际原因,使得问题更难以追踪和解决。
  • ……

【中等】Java 中 final、finally 和 finalize 有什么区别?

特性 final finally finalize
类型 关键字 代码块 方法
作用域 变量/方法/类 异常处理块 Object 类方法
作用 声明不可变性 即使有异常也必然执行,确保资源释放 对象回收前的清理(已废弃)
特点 可修饰变量(常量)、方法(不可重写)、类(不可继承) try-catch搭配,必然执行(除非 JVM 退出) 不推荐用,执行时机不可控
使用场景 定义常量/限制继承 资源清理 历史遗留的清理逻辑

一句话总结final不变性finally必执行finalize过时的清理机制

(注:现代 Java 开发用try-with-resources替代finalize