每次准备面试,我都会经历同一个循环:打开 Java 基础八股,心想“这我熟啊”,然后被第三个追问——“那 HashMap 扩容的时候,红黑树是怎么拆的?”——问得沉默三秒。八年了,写业务代码时 HashMap 用得比呼吸还自然,但源码细节,真的是“用进废退”。
所以这篇是我给自己写的复习备忘录,也顺便给正在准备面试的你抄作业。怎么用:每个专题都分三层——🌱 初级(0–3 年)讲“是什么/怎么用”,🌿 中级(3–5 年)讲“为什么/怎么选/常见坑”,🌳 高级(5 年+)讲“怎么落地/出问题怎么办/怎么取舍”。你按自己的年限对号入座,往下读一层,就是面试官下一个追问的方向。
说明:文中版本差异会标注(JDK 8 / 17 / 21)。默认值与行为我都对照了官方 Javadoc,参考资料在文末。
一、知识地图:本文覆盖什么
先看全景图。Java 基础这块可以粗暴地分成“语言层 → 数据结构层 → 新特性层”三块,集合是面试的重灾区,权重最高:
我看了一些近一年公开的 Java 后端面经(校招、社招都有),HashMap / ConcurrentHashMap 的出现频率高得离谱,基本是“必考 + 必追问”:null 键怎么处理、hash 怎么算、JDK 7 到 8 差异、为什么 CHM 不允许 null。所以本文集合部分会写得最细。
二、面向对象与 Object 契约
🌱 初级:三大特性与常见对比
标准回答要点:
- 封装:隐藏实现细节,对外只暴露必要接口(private 字段 + getter/setter,或更好的:不可变对象)。
- 继承:子类复用父类;Java 单继承、多接口实现。
- 多态:同一方法调用,运行时按实际类型分派。前提三件套:继承/实现、方法重写、父类引用指向子类对象。
- 重载(overload)vs 重写(override):重载是编译期按参数静态类型选方法(静态分派),重写是运行期按对象实际类型选方法(动态分派)。
- 抽象类 vs 接口:抽象类可有状态与构造器,单继承;接口无实例状态(只有常量),可多实现;JDK 8 起接口可有
default/static方法,JDK 9 起可有private方法。选型口诀:“是什么(is-a)”用抽象类,“能做什么(can-do)”用接口。 ==vsequals:基本类型比值,引用类型==比地址;equals默认也比地址,业务类要重写。final/finally/finalize:三个八竿子打不着的词。finalize自 JDK 9 起废弃,JDK 18 起标记为“待移除”,别用,用try-with-resources或Cleaner。
追问链:Java 是值传递还是引用传递?——永远是值传递,传对象时传的是“引用的拷贝”,所以方法里改对象属性外面看得见,但把形参指向新对象外面不受影响。
static void change(StringBuilder sb, String s) {
sb.append("!"); // 改的是同一个对象,外面可见
s = s + "!"; // 形参 s 指向新对象,外面的 s 不变
sb = new StringBuilder("new"); // 只改了形参这个“引用副本”
}🌿 中级:equals / hashCode 契约与 Integer 缓存
契约(背下来):
- 自反、对称、传递、一致,且
x.equals(null)恒为 false; - 两个对象 equals 为 true,则 hashCode 必须相等(反过来不要求:哈希冲突合法);
- 同一对象只要参与 equals 的字段没变,多次调用 hashCode 应返回同一值。
为什么只重写 equals 会出事?把对象放进 HashMap/HashSet,先按 hashCode 定位桶,再用 equals 比较;两个“逻辑相等”的对象 hashCode 不同,就落在不同的桶,Set 里去重失效、Map 里 get 不到。
public final class OrderKey {
private final long tenantId;
private final String orderNo;
// 构造略
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderKey k)) return false; // JDK 16+ 模式匹配
return tenantId == k.tenantId && orderNo.equals(k.orderNo);
}
@Override public int hashCode() { return java.util.Objects.hash(tenantId, orderNo); }
}Integer 缓存陷阱:Integer a = 127, b = 127; a == b 为 true,Integer a = 128, b = 128; a == b 为 false。因为自动装箱调用 Integer.valueOf,默认缓存 -128~127(上界可通过 -XX:AutoBoxCacheMax 调整)。包装类型一律用 equals 或先拆箱。另一个坑是自动拆箱 NPE:Integer x = map.get(k); int y = x;,x 为 null 就炸。
🌳 高级:可变 key、金额与不可变设计
- 可变对象做 Map key 的后果:put 之后修改了参与 hashCode 的字段,对象就“丢”在了旧桶里,get 不到也 remove 不掉,等于内存泄漏。规范:key 一律不可变(String、Long、record、自己写 final 类)。
- 金额用
BigDecimal,别用 double(我做过财税 SaaS,这个坑踩得心服口服):必须用new BigDecimal("0.1")或BigDecimal.valueOf(0.1),不要new BigDecimal(0.1)(会带出二进制浮点的一长串尾巴);比较大小用compareTo,因为equals会比较 scale,new BigDecimal("1.0").equals(new BigDecimal("1.00"))是 false;除法必须指定 scale 和RoundingMode,否则不能整除时抛ArithmeticException。 - record 的取舍(JDK 16+):天然不可变、自带 equals/hashCode/toString,适合 DTO 和 Map key;但字段是浅不可变,里面装
List仍可被改,需要List.copyOf做防御性拷贝。 - 加分项:能讲出“为什么
Objects.hash在热点路径上不推荐”——它会创建可变参数数组并装箱,高频调用时手写31 * h + ...或用Long.hashCode组合更省。
三、String:最简单也最容易被问穿的类
🌱 初级:不可变与三兄弟
标准回答要点:
- String 是
final类,内部字节数组是final且不对外暴露,所以不可变。好处:线程安全、可以缓存 hashCode、可以安全地做常量池共享、适合做 Map key、安全(类加载、路径、网络地址不会被中途篡改)。 - String / StringBuilder / StringBuffer:String 不可变;StringBuilder 可变、非线程安全、快;StringBuffer 可变、方法加了
synchronized、慢,几乎没有使用场景了。 - 字符串常量池:字面量放常量池,
new String("a")会在堆里再造一个对象;intern()会返回常量池中的规范引用。JDK 7 起字符串常量池移到堆中(之前在永久代)。
String a = "hello";
String b = "hel" + "lo"; // 编译期常量折叠,b 与 a 是同一对象
String c = new String("hello");
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(a == c.intern()); // true🌿 中级:拼接的真相与内存布局
+拼接怎么实现?JDK 8 及以前,编译器把+翻译成StringBuilder.append链;JDK 9 起改用invokedynamic+StringConcatFactory,运行期选择更优策略(官方 Javadoc 也写明“编译器可能用 StringBuffer、StringBuilder 或 StringConcatFactory 实现”)。循环里s += x仍然要避免:每次循环都会产生新 String,是 O(n²)。- Compact Strings(JDK 9+):内部由
char[]改成byte[]+ 编码标记(LATIN1 或 UTF16),纯拉丁字符只占 1 字节,堆占用明显下降;Javadoc 里也强调 String 是 UTF-16 语义,增补字符要用代理对,所以length()对 emoji 返回 2。 - substring:JDK 6 共享底层数组会内存泄漏,JDK 7u6 起改为拷贝,不再有这个问题。
- JDK 11+ 好用的新方法:
isBlank、strip(比trim能处理 Unicode 空白)、repeat、lines;formatted、stripIndent配合 JDK 15 的文本块。 split/replaceAll都是正则,热点路径高频调用要预编译Pattern,用户输入进正则要限长度,否则可能被灾难性回溯拖垮(我另一篇事故文写过这个坑)。
🌳 高级:常量池膨胀、去重与日志
- String 占内存大户:堆 dump 里
byte[]和String排前两名很常见。G1 下可以开-XX:+UseStringDeduplication,让 GC 线程对重复内容的字符串共享底层数组(对 key 大量重复的 JSON 解析类场景有效,代价是一点 GC CPU)。 intern()不要滥用:常量池本质是个哈希表,大量 intern 高基数字符串会让表变大、GC 扫描变慢;-XX:StringTableSize可调桶数。需要去重优先用自己的ConcurrentHashMap或让 G1 去重。- 日志拼接:
log.debug("x=" + obj)即使 debug 关闭也会先拼接;用占位符log.debug("x={}", obj),昂贵计算用isDebugEnabled或 supplier。 - 加分项:敏感信息(密码)用
char[]存放、用完清零,因为 String 不可变,没法主动擦除,会在堆里滞留到 GC。
四、泛型:擦除与通配符
🌱 初级:为什么需要泛型
泛型把类型检查从运行期提前到编译期,避免强转的 ClassCastException,也让 API 更自描述。List<String> 里塞 Integer,编译器直接拒绝。
🌿 中级:类型擦除、通配符与 PECS
- 类型擦除:泛型信息只存在于编译期,运行时
List<String>与List<Integer>的 Class 是同一个。因此:不能new T()、不能new T[]、不能对泛型做instanceof List<String>、基本类型不能作类型参数(要用包装类,自动装箱有开销)。 - 桥方法(bridge method):擦除后子类重写的方法签名与父类不一致,编译器自动生成桥方法保证多态;这也是反射遍历
getMethods时会“多出来”方法的原因。 - 为什么运行期还能拿到泛型?类的签名信息(父类泛型、字段、方法参数)会保留在 Class 文件的
Signature属性,Jackson 的TypeReference、Gson 的TypeToken都靠匿名子类把泛型“固定”在父类签名上。 - 通配符 PECS:Producer Extends, Consumer Super。只读(生产者)用
? extends T,只写(消费者)用? super T。List<? extends Number>不能 add(除 null),因为你不知道它具体是List<Integer>还是List<Double>。Collections.copy(List<? super T> dest, List<? extends T> src)就是教科书例子。 List<Object>不是List<String>的父类,泛型是不变的,而数组是协变的(Object[] arr = new String[1]; arr[0] = 1;编译过、运行抛ArrayStoreException)。
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T t : src) dest.add(t); // 从 src 读(生产者),往 dest 写(消费者)
}🌳 高级:API 设计中的泛型取舍
- 对外 API 的参数尽量用
? extends/? super放宽,返回值尽量给具体类型;不要在返回值里用通配符,会把复杂度甩给调用方。 - 堆污染(heap pollution)与
@SafeVarargs:泛型可变参数会创建擦除后的数组,可能被污染;只有在方法确实不写数组、不泄漏数组时才标@SafeVarargs。 - 反射 + 泛型的框架封装:通用返回体
Result<T>反序列化必须传TypeReference,否则 T 会退化成LinkedHashMap——排查“ClassCastException: LinkedHashMap cannot be cast to XXX”的经典根因。
五、异常与 IO
🌱 初级:异常体系
Throwable→Error(OOM、StackOverflowError,不该捕获)和Exception;Exception下 checked(IOException、SQLException,必须处理)与 unchecked(RuntimeException子类)。try-catch-finally:finally 几乎总会执行(除System.exit、线程被杀、JVM 崩溃);在 finally 里 return 会吞掉 try 里的异常与返回值,绝对别这么写。throw是抛出,throws是声明。
🌿 中级:try-with-resources 与异常成本
try (var in = Files.newInputStream(path);
var out = Files.newOutputStream(target)) {
in.transferTo(out);
} // 自动按声明的相反顺序 close;close 抛出的异常作为 suppressed 附着在主异常上- 资源需实现
AutoCloseable;主逻辑和 close 都抛异常时,主异常胜出,close 的异常通过getSuppressed()取得(手写 finally 会丢掉主异常,这是它的核心优势)。 - 异常为什么慢?开销大头在
fillInStackTrace(构造时遍历栈帧)。高频“业务异常”可以重写fillInStackTrace返回 this,或用带writableStackTrace=false的构造器;但更好的做法是别用异常做流程控制。 - 异常处理规范:不要吞异常(空 catch)、不要
catch (Exception e) { log; throw new RuntimeException(e.getMessage()) }丢了 cause、同一异常别既打日志又抛出(日志重复刷屏)。
🌳 高级:全局异常与错误码治理
- 服务端统一走“业务异常(带错误码,不打栈)+ 系统异常(打栈、告警)”两类,在
@RestControllerAdvice里统一翻译成响应。业务异常不打堆栈可以避免日志被“用户输错密码”刷爆。 - 线程池里的异常:
submit提交的任务异常会被吞进Future,不get就看不到;execute抛出的异常会走线程的UncaughtExceptionHandler。线上排查“任务没执行也没报错”先查这里。
IO:从 BIO 到零拷贝
- 🌱 字节流 vs 字符流:
InputStream/OutputStream处理字节,Reader/Writer处理字符(带编码)。读文本务必显式指定UTF-8,别依赖平台默认编码(JDK 18 起 UTF-8 才成为默认字符集,JEP 400)。 - 🌿 BIO / NIO / AIO:BIO 一连接一线程,阻塞;NIO 是
Channel + Buffer + Selector多路复用,Linux 底层是 epoll,一个线程管很多连接;AIO(AsynchronousChannel)在 Linux 上实际收益有限,Netty 主要用 NIO(可选 epoll native transport)。 - 🌿 缓冲:无缓冲的逐字节读文件会产生海量系统调用,用
BufferedInputStream或Files.readAllBytes/Files.lines;大文件用流式处理,不要 readAllBytes 一个 2GB 文件。 - 🌳 零拷贝:传统“磁盘 → 内核缓冲 → 用户缓冲 → socket 缓冲 → 网卡”有多次拷贝;
FileChannel.transferTo底层可走sendfile,省去用户态拷贝;MappedByteBuffer(mmap)把文件映射进内存。Kafka 顺序读 + sendfile、RocketMQ mmap 都是典型应用。 - 🌳 文件上传下载的服务治理:上传走流式转存对象存储或预签名直传,别在应用里
byte[]全量缓冲;下载用流 + 限速 + Range 支持;接口超时要大于最大文件传输时间但线程要有隔离,否则慢连接会占满 Tomcat 线程。
六、HashMap:面试必考,值得拆开讲
🌱 初级:基本特性
标准回答要点(对照 JDK 21 Javadoc):
- 基于哈希表的 Map,允许 null 键和 null 值,非线程安全,不保证顺序。
- 默认初始容量 16,默认负载因子 0.75;当
size > 容量 × 负载因子时扩容,容量约翻倍。 - 结构:数组 + 链表 + 红黑树(JDK 8 起)。
- 迭代器是 fail-fast 的:迭代期间结构性修改会抛
ConcurrentModificationException(官方强调这是尽力而为,不能靠它保证正确性)。 - 性能:
get/put平均 O(1);Collections.synchronizedMap或ConcurrentHashMap用于并发场景。
追问链:null 键放在哪?——hash(null) 固定为 0,落在下标 0 的桶。
🌿 中级:put / 扩容 / 树化源码要点
1)哈希扰动与下标计算:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); // 高 16 位异或低 16 位
}
// 下标: (n - 1) & hash n 恒为 2 的幂,等价于 hash % n 但更快为什么异或高 16 位?容量较小时 (n-1) 只有低几位是 1,只看低位会让高位信息浪费,扰动后高位参与运算,减少冲突。为什么容量必须是 2 的幂?让位运算取模成立,同时让扩容时“元素要么留原位、要么移动 oldCap”成为可能。new HashMap(19) 会经 tableSizeFor 变成 32。
2)put 流程:
- table 为空则
resize()初始化(懒初始化,构造器里并不分配数组); - 算出桶下标,桶为空直接放新节点;
- 桶不空:首节点 key 相同则覆盖;是 TreeNode 则走红黑树插入;否则遍历链表,尾插(JDK 7 是头插),遇到相同 key 覆盖;
- 链表长度达到
TREEIFY_THRESHOLD = 8时调用treeifyBin:若 table 长度 < 64(MIN_TREEIFY_CAPACITY)则优先扩容而不是树化;否则转红黑树; - 插入后
++size > threshold则扩容。
红黑树退化:扩容拆分树时若某侧节点数 ≤ 6(UNTREEIFY_THRESHOLD)则退回链表。8 与 6 之间留有缓冲,避免在临界点反复树化/退化。为什么是 8?源码注释里用泊松分布估算:在 0.75 负载下,链表长度达到 8 的概率约千万分之六,所以树化是极端情况的兜底。
3)扩容:新容量、新阈值均翻倍。JDK 8 的优化:不再逐个重算 hash,而是看 (e.hash & oldCap) 是 0 还是非 0——是 0 留在原下标 j,非 0 放到 j + oldCap,且保持原相对顺序(拆成 lo、hi 两条链)。
// 扩容时链表拆分的核心判断
if ((e.hash & oldCap) == 0) { /* 放入 lo 链,新下标 = j */ }
else { /* 放入 hi 链,新下标 = j + oldCap */ }4)JDK 7 vs 8 的并发问题:JDK 7 扩容用头插法,多线程同时 transfer 会让链表成环,get 时 CPU 100% 死循环;JDK 8 改成尾插 + 保序,消除了成环,但依然不是线程安全:并发 put 会丢数据(两个线程同时写空桶,后写覆盖)、size 不准、扩容时读到旧表。结论:并发场景就别用 HashMap。
5)初始容量怎么设?预估元素 n 个,为避免扩容,容量应 ≥ n / 0.75 + 1。JDK 19+ 有现成的 HashMap.newHashMap(n) 帮你算好。批量导入十万级数据时预设容量能省下多次扩容的拷贝。
🌳 高级:热点治理与工程取舍
- 哈希碰撞攻击:构造大量 hashCode 相同的字符串(如
"Aa"与"BB"的哈希相同),让链表/树退化。JDK 8 的树化把最坏情况从 O(n) 降到 O(log n)(key 实现了Comparable时树内比较更高效);对外 Web 框架(Tomcat 参数解析)另有参数个数上限(maxParameterCount)。防御思路:限制外部输入数量与长度。 - 内存占用估算:一个
HashMap.Node在开启压缩指针时约 32 字节(对象头 12 + hash 4 + key/value/next 引用各 4,再对齐),加上 key、value 本身;百万级 entry 的 Map,光结构就几十 MB。大 Map 频繁扩容会产生大数组,触发 G1 的 Humongous 分配,注意规划容量。 - 手写 LRU:
LinkedHashMap(cap, 0.75f, true)+ 重写removeEldestEntry十行搞定,但非线程安全且没有过期、没有统计。生产环境用 Caffeine(W-TinyLFU,支持过期/刷新/统计),别自己造。 - 遍历修改:想边遍历边删,用迭代器的
remove()或removeIf/entrySet().removeIf;for-each里直接map.remove会 CME。 - 加分项:能主动提到
Map.of / Map.copyOf(不可变、禁 null、迭代顺序随机化,每次 JVM 启动可能不同)——“测试偶发失败,是因为依赖了 Map.of 的遍历顺序”是真实存在的坑。
七、ConcurrentHashMap:JDK 8 之后的设计
🌱 初级:它比 HashMap 多了什么
- 线程安全的哈希表,
get不加锁(官方文档:检索操作通常不阻塞,可以与更新重叠),更新高并发。 - 不允许 null 键和 null 值(放入直接 NPE)。
- 提供原子复合操作:
putIfAbsent、computeIfAbsent、merge、replace(k, old, new)。 - 迭代器是弱一致的:不抛 CME,可能反映也可能不反映迭代期间的修改;
size()等聚合方法在并发更新时只是估计值。
🌿 中级:源码要点与 1.7/1.8 差异
标准回答(JDK 8+):
- 结构:
Node[] table+ 链表 + 红黑树(TreeBin),树化条件同 HashMap(链表 ≥ 8 且 table ≥ 64)。 - put:
spread(hash)之后,桶为空用 CAS 放入,无锁;桶不空则对该桶头节点加synchronized,遍历链表/树插入。锁粒度是“一个桶”,比 JDK 7 的 Segment(默认 16 段)细得多。 - get 不加锁:Node 的
val、next是volatile,table 读取用Unsafe的 volatile 语义(tabAt),因此能读到最新写入;官方保证“对某个 key 的更新 happens-before 之后读到该更新值的检索”。 - 计数:思路同
LongAdder——先 CAS 更新baseCount,竞争失败就落到CounterCell[]分散热点,size()时累加 base 和所有 cell,所以并发下是估计值。 - 扩容:
sizeCtl记录阈值/扩容状态;扩容时新表nextTable,各线程领取“步长”(至少 16 个桶)并行迁移;已迁完的桶头放ForwardingNode(hash = MOVED = -1),其他线程 put 到这里会协助扩容(helpTransfer),get 则通过 forwarding 去新表查。 - 为什么不允许 null?并发下
get返回 null 无法区分“key 不存在”还是“value 就是 null”,而你没法像 HashMap 那样再调containsKey二次确认(两次调用之间可能被别的线程改了,不是原子的)。作者 Doug Lea 的设计取舍:直接禁止歧义。
JDK 7 → 8 的差异表:
| 维度 | JDK 7 | JDK 8+ |
|---|---|---|
| 结构 | Segment[] + HashEntry[] + 链表 | Node[] + 链表 + 红黑树 |
| 并发手段 | Segment 继承 ReentrantLock | 空桶 CAS + 非空桶 synchronized 头节点 |
| 锁粒度 | 段(默认 concurrencyLevel=16) | 单个桶 |
| size | 先无锁试几次,不稳定则锁全部 Segment | baseCount + CounterCell 累加 |
| 扩容 | 各 Segment 内部单独扩容 | 全表多线程协同迁移 |
为什么 JDK 8 的 synchronized 反而比 ReentrantLock 好?JDK 6 起 synchronized 有偏向/轻量级锁优化,且锁粒度缩到单桶后竞争概率极低,多数情况停留在轻量级;而且 synchronized 无需额外的 Lock 对象,节省内存(JDK 15 起偏向锁被默认关闭并在后续版本移除,但不影响这里的结论,低竞争下 CAS 自旋依然很快)。
常见坑:computeIfAbsent
// 错误:在 mappingFunction 里再修改同一个 map
map.computeIfAbsent("a", k -> map.computeIfAbsent("b", x -> 1)); // JDK 8 可能死循环,JDK 9+ 会抛 IllegalStateException
// 正确用法:函数体保持简短、无副作用、不碰同一个 map
map.computeIfAbsent(key, k -> loadFromDb(k)); // 注意:loadFromDb 会在持有桶锁的情况下执行!官方 Javadoc 明确要求计算应当“简短、简单,且不得试图更新此 map 的任何其他映射”。函数体里做慢 IO 会锁住整个桶,同桶其他 key 的写全被阻塞——这是缓存加载场景里 CHM 的常见性能事故。
🌳 高级:怎么在生产里用对
- “线程安全的 Map”不等于“线程安全的业务”:
if (!map.containsKey(k)) map.put(k, v)是典型的 check-then-act,必须用putIfAbsent/computeIfAbsent。多个 map 之间的一致性、value 本身是可变对象的线程安全,都不是 CHM 负责的。 - 计数场景:
map.computeIfAbsent(k, x -> new LongAdder()).increment()(官方 Javadoc 示例)在高并发下比merge(k, 1, Integer::sum)更抗热点 key。 - 热点 key:所有线程都写同一个 key,锁的还是同一个桶,CHM 帮不了你。要么拆 key(分片计数,读时聚合),要么用
LongAdder,要么把热点前置到本地缓存/批量合并。 - 缓存击穿:
computeIfAbsent天然“单飞”(同一个 key 只有一个线程去加载,其余等待),可以顺手当轻量级 single-flight;但别在里面阻塞太久,长加载改用ConcurrentHashMap<K, CompletableFuture<V>>先放 future 再异步加载。 - 加分项:知道
ConcurrentHashMap.newKeySet()可得到并发 Set;知道forEach(parallelismThreshold, …)这类批量操作使用ForkJoinPool.commonPool(),阈值设成Long.MAX_VALUE就是完全串行。
八、ArrayList / LinkedList / 其他集合
🌱 初级:怎么选
| 结构 | 底层 | 随机访问 | 头部/中间插入 | 线程安全 |
|---|---|---|---|---|
| ArrayList | 动态数组 | O(1) | O(n)(要搬元素) | 否 |
| LinkedList | 双向链表 | O(n) | 找位置 O(n),改指针 O(1) | 否 |
| Vector | 动态数组 + 全方法 synchronized | O(1) | O(n) | 是(过时) |
| CopyOnWriteArrayList | 写时复制数组 | O(1) | 写 O(n) 且复制 | 是 |
| ArrayDeque | 循环数组 | — | 两端 O(1) | 否 |
99% 的场景用 ArrayList。LinkedList 在现代 CPU 上因为缓存不友好、每个节点有对象头开销,即便做队列也常输给 ArrayDeque。
🌿 中级:ArrayList 源码要点与坑
- 扩容:无参构造时先是空数组,第一次 add 才分配容量 10;之后每次不足时扩为原来的 1.5 倍(
oldCapacity + (oldCapacity >> 1)),底层Arrays.copyOf。已知规模就用new ArrayList<>(n)。 - fail-fast:
modCount记录结构性修改次数,迭代器发现与预期不一致就抛 CME。最经典坑:for (String s : list) { if (...) list.remove(s); }。正确姿势:迭代器remove、list.removeIf,或倒序下标删除。 - remove(int) vs remove(Object):
List<Integer>上list.remove(1)删的是下标 1,不是值 1。 - subList 是视图:与原 list 共享数据,原 list 结构性修改后再用 subList 会抛 CME;长期持有 subList 还会阻止大 list 被回收。
Arrays.asList:返回的是固定长度的适配器(不能 add/remove,修改会写穿到原数组);List.of返回不可变列表(JDK 9+,不允许 null)。- CopyOnWriteArrayList:读无锁,写时加锁并复制整个数组,适合“读极多写极少”,如监听器列表、黑白名单;写多或数组大就会内存和 GC 双爆。迭代器是快照,不反映后续修改。
- LinkedList:同时实现
List和Deque;get(i)会从头或尾(取近的一端)遍历;不要用 for 下标遍历 LinkedList,那是 O(n²)。
🌳 高级:大集合、内存与并发容器选型
- 百万级 List 的内存与 GC:
ArrayList<Long>每个元素是一个Long对象(16 字节)+ 4 字节引用;需要极致内存时用原始类型数组或 fastutil/Eclipse Collections 的原始集合。大ArrayList的扩容拷贝会产生大对象,G1 下可能变成 Humongous 分配。 - 分页/批处理:数据库查询一次拉十万行进 List 是 OOM 的经典来源。用游标/流式(MyBatis
ResultHandler、JDBCfetchSize)或分批Lists.partition。 - 并发队列选型:
ArrayBlockingQueue(有界、一把锁)、LinkedBlockingQueue(可有界、双锁,默认无界要小心 OOM)、ConcurrentLinkedQueue(无锁 CAS、无界、非阻塞)、PriorityBlockingQueue、DelayQueue(延迟任务)。线程池队列必须用有界队列是团队规范里最该写的一条。 - JDK 21 有序集合(JEP 431):新增
SequencedCollection / SequencedSet / SequencedMap,统一了getFirst() / getLast() / reversed()等方法,List、Deque、LinkedHashSet、LinkedHashMap、SortedSet都实现了它。以前取列表最后一个元素要写list.get(list.size()-1),现在list.getLast()。
九、JDK 8 到 21 新特性速览
🌱 初级:必须会用的
- JDK 8:Lambda、函数式接口(
Function/Predicate/Consumer/Supplier)、方法引用、Stream、Optional、java.time(LocalDate/LocalDateTime/Instant,线程安全,替代SimpleDateFormat)、接口 default 方法。 - JDK 9–11:
List.of/Set.of/Map.of、var(10,局部变量类型推断)、HttpClient(11 标准化)、String 新方法、G1 成为默认 GC(9)。 - JDK 14–17:switch 表达式(14 正式)、文本块(15)、
record(16)、instanceof 模式匹配(16)、sealed类(17)。 - JDK 21(LTS):虚拟线程(JEP 444)、switch 模式匹配(JEP 441)、record 模式(JEP 440)、有序集合(JEP 431)、分代 ZGC(JEP 439)。字符串模板、作用域值、结构化并发在 21 里仍是预览特性。
// JDK 17+ 风格:sealed + record + switch 模式匹配(模式匹配 switch 在 21 正式)
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Rect(double w, double h) -> w * h; // record 解构,JDK 21
}; // sealed 保证穷尽,无需 default
}🌿 中级:Stream 与 Optional 的坑
- Stream 是惰性的:中间操作(filter/map)不执行,遇到终止操作(collect/forEach)才一次性流水线执行;Stream 只能消费一次。
- parallelStream 默认用
ForkJoinPool.commonPool():全 JVM 共享,一个慢任务能拖垮别处的并行流;在 Web 服务里几乎不建议用。小数据量并行反而更慢。 Collectors.toMap遇到重复 key 抛IllegalStateException,要传合并函数;value 为 null 会 NPE(因为内部用了merge)。- Optional 只用作返回值:不要作字段、不要作方法参数、不要
Optional.get()不判空;orElse(x)无论如何都会先算 x,昂贵计算用orElseGet。 - Lambda 捕获变量必须“有效 final”,因为局部变量在栈上,lambda 可能在别的线程延后执行,捕获的是拷贝。
- 函数式接口里的受检异常抛不出去,要么包装成 RuntimeException,要么自己定义
ThrowingFunction。
🌳 高级:升级 JDK 的落地
- 为什么线上升到 17/21?性能(G1 持续优化,ZGC 分代)、安全补丁(8 的免费更新渠道有限)、语言现代化、虚拟线程。升级要点:内部 API 强封装(JDK 16/17 起
--illegal-access失效,老库反射sun.misc/java.base内部会报错,需--add-opens或升级依赖);Spring Boot 3 要求 JDK 17+;javax.*到jakarta.*的包名迁移是升级 Spring Boot 3 的大头工作量。 - 灰度策略:先升测试环境跑全量回归 → 单实例灰度对比 GC 日志/P99 → 再放量;GC 参数不要照搬 JDK 8 的(如 CMS 相关参数在新版本直接不识别,JVM 启动失败)。
- 加分项:能说出 record 与 Lombok
@Value的取舍、var的可读性边界(右边类型不明显时别用)、以及哪些新特性你在项目里真实用过(比如用 sealed + switch 模式匹配替代一堆 if-else 的策略分支)。
十、高频追问速查表
| 问题 | 一句话答案 | 出处/延伸 |
|---|---|---|
| Java 值传递还是引用传递 | 值传递,对象传的是引用的拷贝 | 第二章 |
| equals 与 hashCode 关系 | equals 相等则 hashCode 必相等,反之不必 | Object 契约 |
| String 为什么不可变 | final 类 + 私有 final 数组;换来线程安全、hash 缓存、常量池共享 | String Javadoc |
| 循环里 s += 为什么慢 | 每次产生新 String,O(n²);用 StringBuilder | JDK 9 起 indy 拼接 |
| 泛型擦除有何影响 | 运行时无泛型参数;不能 new T、不能 instanceof 泛型 | 桥方法、Signature 属性 |
| PECS 是什么 | 读用 extends,写用 super | Collections.copy |
| try-with-resources 优势 | 自动 close,异常不丢,close 异常成 suppressed | AutoCloseable |
| HashMap 默认容量/因子 | 16 / 0.75,懒初始化,容量恒为 2 的幂 | HashMap Javadoc |
| hash 为什么异或高 16 位 | 让高位参与下标计算,减少冲突 | hash() |
| 树化条件 | 链表 ≥ 8 且数组 ≥ 64;否则先扩容;≤ 6 退化 | treeifyBin |
| 扩容如何拆链 | hash & oldCap 为 0 留原位,否则 +oldCap,保序 | resize() |
| JDK 7 HashMap 为何死循环 | 头插法并发扩容使链表成环 | JDK 8 改尾插 |
| HashMap 与 CHM 区别 | CHM 线程安全、不允许 null、弱一致迭代 | CHM Javadoc |
| CHM 1.8 怎么保证并发 | 空桶 CAS,非空 synchronized 头节点,get 靠 volatile | 第七章图 |
| CHM 为什么禁 null | 并发下 get 为 null 有歧义,无法再原子地 containsKey | Doug Lea 设计取舍 |
| CHM.size() 准吗 | 并发下是估计值(baseCount + CounterCell) | LongAdder 思想 |
| ArrayList 扩容 | 首次 add 分配 10,之后 1.5 倍 | Arrays.copyOf |
| foreach 里 remove 为何 CME | modCount 与迭代器预期不符;用 removeIf/迭代器 remove | fail-fast |
| Integer 128 == 128 | false,缓存仅 -128~127 | Integer.valueOf |
| BigDecimal 怎么比较相等 | 用 compareTo,equals 比较 scale | 字符串构造更准确 |
| JDK 21 有哪些重点 | 虚拟线程、switch 模式匹配、record 模式、有序集合、分代 ZGC | JDK 21 项目页 |
一句话备忘:集合三件事——HashMap 记“扰动 + 2 的幂 + 8/64/6 + 拆链 lo/hi”,CHM 记“空桶 CAS、非空锁头、get 靠 volatile、size 是估计、禁 null”,其余集合记“默认用 ArrayList/ArrayDeque,别在 foreach 里改集合”;语言层记“equals/hashCode 成对、String 不可变、泛型只在编译期、金额用 BigDecimal”。
最后说句实话:Java 基础这些东西,你在业务里天天用,反而最容易“会用但说不清”。我的复习办法很土——每个专题给自己讲一遍,讲不下去的地方就是要回去翻源码的地方。祝你我都少被追问三秒钟沉默。
参考资料
- JDK 21 HashMap Javadoc:默认容量 16、负载因子 0.75、允许 null、fail-fast 迭代器以及
HashMap.newHashMap的官方说明。 - JDK 21 ConcurrentHashMap Javadoc:检索不阻塞、禁止 null、弱一致迭代器、size 为估计值、computeIfAbsent 的使用约束与 LongAdder 计数示例。
- JDK 21 String Javadoc:不可变性、拼接实现说明、JDK 11 之后新增的 strip/isBlank/repeat/lines 等方法。
- OpenJDK JDK 21 项目页:JDK 21 包含的 JEP 全表(431 有序集合、439 分代 ZGC、440/441 模式匹配、444 虚拟线程等)。
- JEP 444: Virtual Threads:虚拟线程的设计目标与适用场景,本文“JDK 21 新特性”一节的依据。
一起聊聊