Java 核心知识:集合、并发与 JVM 面试要点
从面试视角系统梳理 Java 核心知识体系,覆盖集合框架底层原理、异常体系、I/O 模型、并发工具、JVM 内存与 GC 等高频考点,每个知识点给出"一句话核心结论"。
深度笔记:Java 并发编程 · Java 虚拟机 · Java 性能调优 · AQS 原理深度解析 · Java 业务开发陷阱 · JVM 内存分区与 Linux 内存分配机制
目录
| 章节 | 说明 |
|---|---|
| 异常体系 | Throwable / Exception / Error 分类与最佳实践 |
| 集合框架底层原理 | HashMap / LinkedHashMap / TreeMap / ConcurrentHashMap |
| IO 模型 | BIO / NIO / AIO 对比与多路复用原理 |
| 并发核心 | synchronized / ReentrantLock / CAS / 线程池 |
| JVM 内存模型 | 内存区域 / happen-before / OOM 类型 |
| GC 与调优 | 垃圾收集器对比 / GC 调优思路 |
| 反射与动态代理 | 反射机制 / JDK 动态代理 / CGLIB |
| 泛型与类型擦除 | 泛型边界 / 类型擦除 / 通配符 |
| 类加载机制 | 双亲委派模型 / 热部署实现 |
异常体系
类层次结构
Throwable
├── Error(不应捕获)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception
├── RuntimeException(unchecked,不强制捕获)
│ ├── NullPointerException
│ ├── ArrayIndexOutOfBoundsException
│ ├── ClassCastException
│ └── IllegalArgumentException
└── 受检异常 checked(必须声明或捕获)
├── IOException
├── SQLException
└── ClassNotFoundException
一句话结论:
Error代表 JVM 级别不可恢复的错误,不要捕获;RuntimeException是编程错误,应该通过代码修复;受检异常是可预期的业务异常,必须显式处理。
经典面试题:NoClassDefFoundError vs ClassNotFoundException
| 维度 | NoClassDefFoundError | ClassNotFoundException |
|---|---|---|
| 类型 | Error(不应捕获) | Exception(受检) |
| 触发时机 | 编译时存在,运行时找不到 | 运行时动态加载失败(Class.forName) |
| 常见原因 | 类路径配置错误、jar 包缺失 | 反射加载不存在的类 |
异常处理最佳实践
// 1. 捕获具体异常,不要捕获 Exception/Throwable
// 错误
catch (Exception e) { log.error("failed", e); }
// 正确
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
log.warn("interrupted", e);
}
// 2. try-with-resources 自动关闭资源(Java 7+)
try (InputStream in = new FileInputStream("/tmp/data");
OutputStream out = new FileOutputStream("/tmp/out")) {
// 自动调用 close(),即使发生异常
}
// 3. 多异常合并捕获(Java 7+)
try {
doSomething();
} catch (IOException | SQLException e) {
log.error("operation failed", e);
}
// 4. 自定义异常:继承 RuntimeException,提供充足信息
public class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
异常性能注意事项:
- 每次实例化
Exception都会对栈进行快照,开销较重 - 不要用异常控制正常业务流程(比
if/else慢很多) - 高频异常路径考虑复用异常对象(去掉栈快照)
集合框架底层原理
Map 家族对比
| 特性 | HashMap | LinkedHashMap | TreeMap | Hashtable |
|---|---|---|---|---|
| 线程安全 | 否 | 否 | 否 | 是(粗粒度) |
| null key/value | 支持 | 支持 | key 不支持 null | 不支持 |
| 顺序 | 无序 | 插入顺序 / 访问顺序 | 按 key 排序 | 无序 |
| 时间复杂度 | O(1) | O(1) | O(log n) | O(1) |
| 底层结构 | 数组 + 链表 / 红黑树 | 数组 + 链表 + 双向链表 | 红黑树 | 数组 + 链表 |
HashMap 底层原理(Java 8+)
数组(桶)
├── bucket[0]: null
├── bucket[1]: Node(k1,v1) → Node(k2,v2) ← 链表(哈希冲突)
├── bucket[2]: TreeNode(k3,v3) ← 红黑树(链表长度 >= 8 且容量 >= 64)
└── ...
关键源码逻辑:
// 1. hash 扰动函数:高位异或低位,减少哈希冲突
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 2. 定位桶位置:位运算替代取模(容量必须是 2 的幂)
int index = (n - 1) & hash; // 等价于 hash % n,但更快
// 3. 扩容:容量翻倍,重新散列
// 触发条件:size > threshold(threshold = capacity * loadFactor)
// 默认:capacity=16,loadFactor=0.75,threshold=12
关键参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
| 初始容量 | 16 | 必须是 2 的幂 |
| 负载因子 | 0.75 | 超过则扩容,建议不修改 |
| 树化阈值 | 8 | 链表长度 ≥ 8 且容量 ≥ 64 时树化 |
| 退树阈值 | 6 | 红黑树节点数 ≤ 6 时退化回链表 |
为什么树化:防止哈希碰撞攻击(恶意构造大量相同 hash 的 key,使链表退化为 O(n),导致 CPU 100%)。
预设容量的计算:
// 已知存放 N 个元素,预设容量避免扩容
// 需要满足:capacity * 0.75 > N
// 所以:capacity > N / 0.75,向上取 2 的幂
// 存 1000 个元素:capacity = 2048(1000/0.75 ≈ 1334,向上取 2048)
Map<String, Object> map = new HashMap<>(2048);
ConcurrentHashMap 演进
Java 7:分段锁(Segment)
ConcurrentHashMap(默认 16 个 Segment)
├── Segment[0](ReentrantLock)→ HashEntry[]
├── Segment[1](ReentrantLock)→ HashEntry[]
└── ... (并发度 = Segment 数量 = 16)
- 每个 Segment 独立加锁,并发度 = Segment 数量(默认 16)
size()方法:先无锁尝试 2 次,若 modCount 变化则全段加锁统计
Java 8+:CAS + synchronized(细粒度)
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ...
for (Node<K,V>[] tab = table;;) {
if (tab == null) tab = initTable(); // CAS 初始化
else if ((f = tabAt(tab, i)) == null) {
if (casTabAt(tab, i, null, new Node<>(hash, key, value))) // 空桶:CAS 无锁写
break;
} else {
synchronized (f) { // 非空桶:只锁当前桶的头节点(更细粒度)
// ... 链表/红黑树操作
}
}
}
}
| 维度 | Java 7 | Java 8+ |
|---|---|---|
| 锁粒度 | Segment(16 个) | 单个桶头节点 |
| 锁实现 | ReentrantLock | synchronized(JVM 优化后性能更好) |
| 计数 | 分段计数 | LongAdder(Striped64,空间换时间) |
| 初始化 | 构造时初始化 | lazy-load(首次 put 时初始化) |
LinkedHashMap 实现 LRU 缓存
// LinkedHashMap 通过双向链表维护访问顺序,可实现 LRU
Map<String, String> lruCache = new LinkedHashMap<>(16, 0.75f, true) { // true = 按访问顺序
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 100; // 超过 100 个元素时删除最久未访问的
}
};
IO 模型
BIO / NIO / AIO 对比
| 模型 | 阻塞性 | 适用场景 | Java API |
|---|---|---|---|
| BIO(同步阻塞) | 阻塞 | 连接数少、并发低 | java.io、Socket |
| NIO(同步非阻塞) | 非阻塞 | 高并发、IO 密集 | java.nio(Channel/Selector/Buffer) |
| AIO(异步非阻塞) | 非阻塞 | 超高并发、长连接 | java.nio.2(AsynchronousChannel) |
NIO 核心组件
// NIO 多路复用服务器示例
Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(8888));
serverSocket.configureBlocking(false); // 非阻塞模式
serverSocket.register(selector, SelectionKey.OP_ACCEPT); // 注册感兴趣的事件
while (true) {
selector.select(); // 阻塞直到有 Channel 就绪
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
if (key.isAcceptable()) {
// 处理新连接
} else if (key.isReadable()) {
// 处理读事件
}
}
keys.clear();
}
NIO 三大核心:
- Buffer:数据容器,支持
flip()(写转读)、clear()(重置) - Channel:双向数据通道,支持非阻塞模式
- Selector:多路复用器,底层依赖 Linux
epoll/ Windowsiocp
BIO 的扩展性问题:每个连接一个线程,线程是重量级资源,连接数增大时线程上下文切换开销急剧上升。NIO 用单线程管理多个 Channel,解决了这个问题。
并发核心
synchronized vs ReentrantLock
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层次 | JVM 内置,字节码 monitorenter/monitorexit | Java 代码,基于 AQS |
| 可中断 | 不支持 | lockInterruptibly() 支持 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 公平锁 | 不支持 | new ReentrantLock(true) |
| 条件变量 | 一个(wait/notify) | 多个(Condition) |
| 性能 | Java 6+ 优化后与 Lock 相当 | 高并发下略优 |
// ReentrantLock 使用规范:必须在 finally 中 unlock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 必须在 finally,防止异常时死锁
}
// 读写锁:读多写少场景
ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock(); // 读锁(共享)
rwLock.writeLock().lock(); // 写锁(排他)
synchronized 锁升级
无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(OS Mutex)
- 偏向锁:只有一个线程访问时,记录线程 ID,无需 CAS
- 轻量级锁:多线程交替访问,CAS 自旋,不阻塞
- 重量级锁:多线程竞争激烈,挂起等待,OS 介入
CAS 与原子类
// AtomicInteger 底层:Unsafe.compareAndSwapInt(CAS 操作)
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子自增
count.compareAndSet(1, 2); // CAS:期望值 1,更新为 2
// 高并发计数推荐:LongAdder(分段累加,比 AtomicLong 更高效)
LongAdder adder = new LongAdder();
adder.increment();
long total = adder.sum();
CAS 的 ABA 问题:值从 A 变 B 再变回 A,CAS 无法感知中间变化。解决:使用 AtomicStampedReference 带版本号的 CAS。
线程池核心参数
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize:核心线程数
8, // maximumPoolSize:最大线程数
60, TimeUnit.SECONDS, // keepAliveTime:空闲线程存活时间
new LinkedBlockingQueue<>(1000), // workQueue:任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // rejectedHandler:拒绝策略
);
拒绝策略对比:
| 策略 | 行为 |
|---|---|
AbortPolicy(默认) | 抛出 RejectedExecutionException |
CallerRunsPolicy | 由调用者线程执行(降速,不丢任务) |
DiscardPolicy | 静默丢弃 |
DiscardOldestPolicy | 丢弃队列最老的任务 |
⚠️ 不要用 Executors.newCachedThreadPool():最大线程数为 Integer.MAX_VALUE,高并发时会创建大量线程导致 OOM。
JVM 内存模型
内存区域划分
JVM 内存
├── 线程私有
│ ├── 程序计数器(PC Register):当前字节码行号,唯一不会 OOM 的区域
│ ├── 虚拟机栈(VM Stack):栈帧(局部变量表、操作数栈、返回地址)
│ └── 本地方法栈(Native Method Stack):native 方法
└── 线程共享
├── 堆(Heap):对象实例、数组,GC 主要区域
│ ├── 年轻代(Eden + Survivor)
│ └── 老年代(Old Generation)
└── 方法区(Metaspace,Java 8+):类信息、常量、静态变量
└── 运行时常量池
OOM 类型对应区域:
| OOM 类型 | 对应区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象过多、内存泄漏 |
GC overhead limit exceeded | 堆 | GC 时间超过 98%,回收不足 2% |
Metaspace | 方法区 | 动态生成大量类(如 Groovy、CGLIB) |
StackOverflowError | 虚拟机栈 | 无限递归 |
Direct buffer memory | 直接内存 | NIO 直接内存分配过多 |
happen-before 规则
happen-before 是 JMM(Java Memory Model)的核心概念:如果操作 A happen-before 操作 B,则 A 的结果对 B 可见。
8 条规则(记住前 4 条最重要):
- 程序顺序规则:同一线程内,前面的操作 happen-before 后面的操作
- 监视器锁规则:
unlockhappen-before 后续的lock - volatile 规则:对 volatile 变量的写 happen-before 后续的读
- 线程启动规则:
Thread.start()happen-before 线程内的任何操作 - 线程终止规则:线程内所有操作 happen-before
Thread.join()返回 - 线程中断规则:
interrupt()happen-before 检测到中断 - 对象终结规则:构造函数结束 happen-before
finalize() - 传递性:A happen-before B,B happen-before C,则 A happen-before C
volatile 的两个保证:
- 可见性:写操作立即刷新到主内存,读操作从主内存读取
- 禁止指令重排:通过内存屏障实现
GC 与调优
垃圾收集器对比
| 收集器 | 适用代 | 算法 | 特点 |
|---|---|---|---|
| Serial | 年轻代 | 复制 | 单线程,适合小堆、客户端 |
| ParNew | 年轻代 | 复制 | Serial 多线程版,配合 CMS |
| Parallel Scavenge | 年轻代 | 复制 | 吞吐量优先 |
| Serial Old | 老年代 | 标记-整理 | Serial 老年代版 |
| CMS | 老年代 | 标记-清除 | 低停顿,但有碎片 |
| G1 | 全堆 | 标记-整理 | 可预测停顿,Java 9 默认 |
| ZGC | 全堆 | 着色指针 | 停顿 < 10ms,Java 15+ 生产就绪 |
GC 调优思路
1. 确认问题
├── jstat -gcutil <pid> 1000:观察各代 GC 频率和耗时
└── 开启 GC 日志:-Xloggc:gc.log -XX:+PrintGCDetails
2. 分析原因
├── Full GC 频繁 → 老年代空间不足 / 大对象直接进老年代
├── Young GC 频繁 → Eden 区太小 / 对象存活率高
└── GC 停顿长 → 堆太大 / 碎片化 / 收集器选择不当
3. 调整策略
├── 增大堆:-Xmx / -Xms(避免动态扩容)
├── 调整代比例:-XX:NewRatio / -XX:SurvivorRatio
├── 切换收集器:G1(-XX:+UseG1GC)/ ZGC(-XX:+UseZGC)
└── 设置停顿目标:-XX:MaxGCPauseMillis=200(G1)
4. 生产必备参数
# 生产环境推荐 JVM 参数
java -Xmx4g -Xms4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/ \
-XX:+PrintGCDateStamps \
-XX:+PrintGCDetails \
-Xloggc:/var/log/app/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M \
-jar app.jar
反射与动态代理
反射基础
// 获取 Class 对象的三种方式
Class<?> clazz1 = String.class; // 类字面量(编译期)
Class<?> clazz2 = "hello".getClass(); // 实例方法(运行时)
Class<?> clazz3 = Class.forName("java.lang.String"); // 动态加载(运行时)
// 反射操作
Class<?> clazz = UserService.class;
Method method = clazz.getDeclaredMethod("findById", Long.class);
method.setAccessible(true); // 绕过访问控制
Object result = method.invoke(userServiceInstance, 1L);
JDK 动态代理 vs CGLIB
// JDK 动态代理:基于接口,被代理类必须实现接口
public interface UserService { User findById(Long id); }
UserService proxy = (UserService) Proxy.newProxyInstance(
UserService.class.getClassLoader(),
new Class[]{UserService.class},
(proxyObj, method, args) -> {
System.out.println("Before: " + method.getName());
Object result = method.invoke(target, args);
System.out.println("After: " + method.getName());
return result;
}
);
// CGLIB:基于继承,生成子类,无需接口
// Spring 默认使用 CGLIB 代理(对于没有接口的类)
// 限制:final 类和 final 方法无法被代理
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 要求 | 目标类必须实现接口 | 无接口要求 |
| 实现 | 基于接口,反射调用 | 继承目标类,字节码增强 |
| 性能 | 反射调用有开销 | 字节码直接调用,更快 |
| 限制 | 只能代理接口方法 | final 类/方法不可代理 |
| Spring 使用 | 有接口时默认使用 | 无接口或 proxyTargetClass=true |
Spring AOP 原理:@Transactional、@Async 等注解通过 AOP 实现,本质是动态代理。这就是为什么 this.xxx() 调用无法触发事务——this 是原始对象,不是代理对象。
泛型与类型擦除
类型擦除
Java 泛型在编译期实现,运行时类型信息被擦除(Type Erasure)。
// 编译前
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);
// 编译后(类型擦除)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // 编译器自动插入强制转换
类型擦除的影响:
// 以下代码编译报错:泛型擦除后两个方法签名相同
public void process(List<String> list) { }
public void process(List<Integer> list) { } // 编译错误!
// 运行时无法获取泛型类型
List<String> list = new ArrayList<>();
list.getClass() == List.class; // true,不是 List<String>.class
// 绕过类型检查(不推荐)
List rawList = list;
rawList.add(42); // 编译通过,运行时 ClassCastException
泛型通配符
// ? extends T(上界):只读,不可写(Producer Extends)
List<? extends Number> nums = new ArrayList<Integer>();
Number n = nums.get(0); // 可以读
nums.add(1); // 编译错误,不可写(不知道具体类型)
// ? super T(下界):可写,读出来是 Object(Consumer Super)
List<? super Integer> list = new ArrayList<Number>();
list.add(1); // 可以写 Integer 或其子类
Object obj = list.get(0); // 读出来只能是 Object
// PECS 原则:Producer Extends, Consumer Super
// 从集合读数据 → extends;向集合写数据 → super
类加载机制
双亲委派模型
Bootstrap ClassLoader(启动类加载器)
↑ 委派
Extension ClassLoader(扩展类加载器)
↑ 委派
Application ClassLoader(应用类加载器)
↑ 委派
自定义 ClassLoader
加载流程:
- 收到加载请求,先委派给父加载器
- 父加载器无法加载时,自己才尝试加载
- 确保核心类(如
java.lang.String)只由 Bootstrap 加载,防止被篡改
打破双亲委派的场景:
Thread.setContextClassLoader():SPI 机制(如 JDBC Driver)- OSGi 模块化框架:每个模块有独立类加载器
- 热部署:Tomcat 每个 WebApp 有独立类加载器
参考资料
评论 (0)