面试复习资料 列表全部文章
面试复习资料

面试复习:Java 基础与集合(初中高级)—— HashMap / ConcurrentHashMap / String / 泛型 / 新特性一网打尽

一篇按🌱初级/🌿中级/🌳高级三层组织的 Java 基础与集合复习笔记:面向对象、String、泛型、异常、IO、HashMap 与 ConcurrentHashMap 源码要点、ArrayList/LinkedList、JDK 8–21 新特性,附追问速查表与官方文档出处。

更多

每次准备面试,我都会经历同一个循环:打开 Java 基础八股,心想“这我熟啊”,然后被第三个追问——“那 HashMap 扩容的时候,红黑树是怎么拆的?”——问得沉默三秒。八年了,写业务代码时 HashMap 用得比呼吸还自然,但源码细节,真的是“用进废退”。

所以这篇是我给自己写的复习备忘录,也顺便给正在准备面试的你抄作业。怎么用:每个专题都分三层——🌱 初级(0–3 年)讲“是什么/怎么用”,🌿 中级(3–5 年)讲“为什么/怎么选/常见坑”,🌳 高级(5 年+)讲“怎么落地/出问题怎么办/怎么取舍”。你按自己的年限对号入座,往下读一层,就是面试官下一个追问的方向。

说明:文中版本差异会标注(JDK 8 / 17 / 21)。默认值与行为我都对照了官方 Javadoc,参考资料在文末。

一、知识地图:本文覆盖什么

先看全景图。Java 基础这块可以粗暴地分成“语言层 → 数据结构层 → 新特性层”三块,集合是面试的重灾区,权重最高:

Java 基础与集合 语言层 面向对象:封装 / 继承 / 多态 String:不可变 / 常量池 / 拼接 泛型:类型擦除 / 通配符 PECS 异常:checked / try-with-resources IO:BIO / NIO / 零拷贝 equals / hashCode 契约 Integer 缓存 / BigDecimal 集合层(重灾区) HashMap:数组+链表+红黑树 扩容 / 树化 / 哈希扰动 ConcurrentHashMap:CAS+synchronized ArrayList 扩容 / LinkedList fail-fast / 弱一致性迭代 CopyOnWrite / ArrayDeque LinkedHashMap 做 LRU 新特性层(8 → 21) 8:Lambda / Stream / Optional 9–11:var / List.of / String 增强 14–17:switch 表达式 / record 15:文本块 / 17:sealed 21:switch 模式匹配 / 虚拟线程 21:有序集合 SequencedCollection LTS:8 / 11 / 17 / 21

我看了一些近一年公开的 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)”用接口。
  • == vs equals:基本类型比值,引用类型 == 比地址;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 缓存

契约(背下来):

  1. 自反、对称、传递、一致,且 x.equals(null) 恒为 false;
  2. 两个对象 equals 为 true,则 hashCode 必须相等(反过来不要求:哈希冲突合法);
  3. 同一对象只要参与 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 流程:

  1. table 为空则 resize() 初始化(懒初始化,构造器里并不分配数组);
  2. 算出桶下标,桶为空直接放新节点;
  3. 桶不空:首节点 key 相同则覆盖;是 TreeNode 则走红黑树插入;否则遍历链表,尾插(JDK 7 是头插),遇到相同 key 覆盖;
  4. 链表长度达到 TREEIFY_THRESHOLD = 8 时调用 treeifyBin:若 table 长度 < 64(MIN_TREEIFY_CAPACITY)则优先扩容而不是树化;否则转红黑树;
  5. 插入后 ++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 差异

spread(hash)key/value 判 null table 未初始化?CAS sizeCtl 抢初始化 桶为空?CAS 直接放入 头节点 hash = MOVED?正在扩容 → helpTransfer synchronized(头节点)链表 / 红黑树 TreeBin 插入 addCount:更新计数baseCount CAS,失败落 CounterCell[] 达到 sizeCtl 阈值 → 扩容多线程按步长(≥16)分段迁移,旧桶放 ForwardingNode 锁粒度:JDK 7 = Segment(默认 16 段,ReentrantLock);JDK 8 = 单个桶的头节点

标准回答(JDK 8+):

  1. 结构:Node[] table + 链表 + 红黑树(TreeBin),树化条件同 HashMap(链表 ≥ 8 且 table ≥ 64)。
  2. put:spread(hash) 之后,桶为空用 CAS 放入,无锁;桶不空则对该桶头节点加 synchronized,遍历链表/树插入。锁粒度是“一个桶”,比 JDK 7 的 Segment(默认 16 段)细得多。
  3. get 不加锁:Node 的 val、next 是 volatile,table 读取用 Unsafe 的 volatile 语义(tabAt),因此能读到最新写入;官方保证“对某个 key 的更新 happens-before 之后读到该更新值的检索”。
  4. 计数:思路同 LongAdder——先 CAS 更新 baseCount,竞争失败就落到 CounterCell[] 分散热点,size() 时累加 base 和所有 cell,所以并发下是估计值。
  5. 扩容:sizeCtl 记录阈值/扩容状态;扩容时新表 nextTable,各线程领取“步长”(至少 16 个桶)并行迁移;已迁完的桶头放 ForwardingNode(hash = MOVED = -1),其他线程 put 到这里会协助扩容(helpTransfer),get 则通过 forwarding 去新表查。
  6. 为什么不允许 null?并发下 get 返回 null 无法区分“key 不存在”还是“value 就是 null”,而你没法像 HashMap 那样再调 containsKey 二次确认(两次调用之间可能被别的线程改了,不是原子的)。作者 Doug Lea 的设计取舍:直接禁止歧义。

JDK 7 → 8 的差异表:

维度JDK 7JDK 8+
结构Segment[] + HashEntry[] + 链表Node[] + 链表 + 红黑树
并发手段Segment 继承 ReentrantLock空桶 CAS + 非空桶 synchronized 头节点
锁粒度段(默认 concurrencyLevel=16)单个桶
size先无锁试几次,不稳定则锁全部 SegmentbaseCount + 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动态数组 + 全方法 synchronizedO(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、JDBC fetchSize)或分批 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²);用 StringBuilderJDK 9 起 indy 拼接
泛型擦除有何影响运行时无泛型参数;不能 new T、不能 instanceof 泛型桥方法、Signature 属性
PECS 是什么读用 extends,写用 superCollections.copy
try-with-resources 优势自动 close,异常不丢,close 异常成 suppressedAutoCloseable
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 有歧义,无法再原子地 containsKeyDoug Lea 设计取舍
CHM.size() 准吗并发下是估计值(baseCount + CounterCell)LongAdder 思想
ArrayList 扩容首次 add 分配 10,之后 1.5 倍Arrays.copyOf
foreach 里 remove 为何 CMEmodCount 与迭代器预期不符;用 removeIf/迭代器 removefail-fast
Integer 128 == 128false,缓存仅 -128~127Integer.valueOf
BigDecimal 怎么比较相等用 compareTo,equals 比较 scale字符串构造更准确
JDK 21 有哪些重点虚拟线程、switch 模式匹配、record 模式、有序集合、分代 ZGCJDK 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 新特性”一节的依据。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人