Java 基础面试回答
子主题一:IO 模型(BIO / NIO / AIO)
一句话结论(30s)
BIO、NIO、AIO 的本质区别在于谁负责把数据读到用户缓冲区:BIO 是线程同步阻塞等待,NIO 是线程主动轮询并自己去读,AIO 是内核读完回调通知——核心权衡是用「一个线程一个连接」的简单,换「一个线程多个连接」的吞吐,因为阻塞不耗 CPU 但耗线程,线程又是稀缺资源。
核心原理(2min)
三者的主流程:
- BIO(同步阻塞):
ServerSocket.accept()和read()都会阻塞当前线程。一个连接对应一个线程,连接数一多线程就爆炸(线程栈 + 上下文切换开销)。优点:代码最简单,每个连接逻辑独立清晰。这里先自己推一步:阻塞本身并不耗 CPU,那「连接多会爆炸」到底爆在哪?——不是爆 CPU,是爆「线程」:每多一个连接就多一个线程,线程栈内存和上下文切换的开销随连接数线性累积,1 万连接就是 1 万个线程,机器扛不住。 - NIO(同步非阻塞):引入 Channel、Buffer、Selector 三大组件。线程不阻塞等某一个连接,而是用
selector.select()一次监听多个 Channel 的就绪事件,就绪了再主动channel.read(buf)。因为select()返回的是「就绪」通知,读数据这一步线程还是亲自干的,所以叫「同步非阻塞」。这个设计可以这样还原思路:既然线程被「傻等」是浪费,那就别让一个线程只盯一个连接,让它先「问一圈」谁就绪了,再挑就绪的去读——把「一个线程等一个连接」换成「一个线程问多个连接」。 - AIO(异步非阻塞):提交读请求后立即返回,内核把数据读进指定 buffer,完成后回调通知。因为读数据全过程线程都是自由的,所以是真正的「异步」。顺着 NIO 再想一步:既然 NIO 里「把数据读到缓冲区」还得线程亲自干,能不能连这一步也甩给内核?——AIO 就是把「读」也交给内核,线程只负责提交请求和收回调,所以它是真正的异步。
关键机制——NIO 的 Selector 是怎么工作的:Selector 本质是操作系统 IO 多路复用的封装。Linux 上用 epoll:epoll_create 创建 epoll 对象(红黑树存 fd + 就绪链表存结果),epoll_ctl 注册 fd(一次注册),epoll_wait 只返回就绪的 fd(O(1) 取就绪事件,不遍历全量)。所以一个 Selector 线程能管理成千上万连接。这里的关键一问是:凭什么「一次注册」就能换来之后「O(1) 取就绪」?——因为内核把「哪些 fd 存在」和「哪些 fd 就绪」拆成了两个结构:红黑树负责快速增删查 fd,就绪链表只挂「当前有事件」的 fd,epoll_wait 直接取链表、根本不用扫全量。这就是它和「每次全量遍历」的本质差距。
底层深入(5-10min)
1. BIO 的阻塞是内核级的,不是 JVM 自旋。 先问一句:线程「卡在 read 上」时,它到底是在忙等还是真睡着了?这决定了阻塞是否耗 CPU。一次 accept() 要穿越六层:ServerSocket.accept() → SocketImpl.accept()(策略模式运行时多态)→ vtable 分派 → PlainSocketImpl.accept0()(native)→ JNI → libc accept() → sys_accept 内核。调用 sys_accept 后当前线程在内核中被挂起为 TASK_INTERRUPTIBLE 状态,CPU 调度器切换走,直到新连接到达才唤醒。所以 BIO 阻塞不消耗 CPU——线程在等 IO 时处于 sleep。真实源码里 ServerSocket.accept() 本身只做「状态检查 + 委托」,没有任何忙等循环:
// java.net.ServerSocket
public Socket accept() throws IOException {
if (isClosed())
throw new SocketException("Socket is closed");
if (!isBound())
throw new SocketException("Socket is not bound yet");
Socket s = new Socket((SocketImpl) null);
implAccept(s);
return s;
}
注意它 new 出一个空 Socket 后立刻交给 implAccept(s)——真正的阻塞发生在 implAccept() → SocketImpl.accept() → native accept0() 一路下沉到内核 sys_accept。这印证了「阻塞不耗 CPU」:不是 JVM 在循环自旋,而是当前线程在内核里被挂起为 TASK_INTERRUPTIBLE、让出 CPU,直到新连接到达才被唤醒。
2. select → poll → epoll 三代演进(为什么 NIO 用 epoll): 带着一个问题看这三代:每一代都在改掉上一代的哪个「浪费」?——答案都是「全量」二字。
select:用fd_set位图,上限FD_SETSIZE=1024,每次调用都全量拷贝 fd_set 到内核、内核 O(n) 遍历、用户再 O(n) 遍历。10000 个 fd 一次 select 至少 3 万次操作。poll:用动态数组pollfd突破 1024 上限,但本质还是每次全量拷贝 + O(n) 遍历。epoll:核心创新是「一次注册、事件驱动通知」。红黑树存所有 fd(增删改 O(log n)),就绪链表只存就绪 fd,epoll_wait只拷贝就绪事件。1 万个连接只有 5 个有数据,就只拷这 5 个事件(40 字节 vs 80000 字节)。这正是 NIO 能支撑百万连接的底层原因。反过来想:如果 epoll 每次还去全量扫一遍,它就和 select 没区别了——「事件驱动」的精髓就是「只碰有变化的,不碰没变化的」。
3. AIO 在 Linux 上是「伪异步」: 既然 AIO 这么好,为什么生产里少见、还总被说「假」?——问题不在 Java,在 Linux。Linux 内核长期没有真正的异步 IO API。POSIX AIO(aio_*)是 glibc 用线程池模拟的。Java AIO(AsynchronousFileChannel 等)在 Linux 上底层仍用 epoll 模拟——提交读请求 → epoll 监听就绪 → 线程池里的线程去读 → 回调,本质是「NIO + 线程池」封装。Windows 的 IOCP 才是真正内核异步(内核直接与驱动交互,不耗用户线程)。Linux 直到 5.1 的 io_uring(共享环形缓冲区 SQ/CQ,io_uring_enter() 一次系统调用同时提交 N 个请求 + 收割 M 个结果)才补上这块,JDK 官方暂未支持,Netty 有 incubator 项目包装。
4. 边界与对比总结:
| BIO | NIO | AIO | |
|---|---|---|---|
| 发起 IO 后 | 线程阻塞 | 立即返回(非阻塞) | 立即返回(异步) |
| 就绪通知 | 返回数据 | select 返回就绪 fd | 内核回调 |
| 谁读数据到缓冲区 | 线程同步等待 | 线程主动读 | 内核读完回调 |
| 适用 | 连接少且简单 | 高并发连接 | 大文件/大量异步 IO |
子主题二:HashMap 底层
一句话结论(30s)
HashMap 是「数组 + 链表/红黑树」的哈希表,用扰动函数
h ^ (h>>>16)打散哈希降低碰撞、容量到阈值就扩容——核心权衡是用空间换时间,把查找从 O(n) 降到 O(1),因为位运算(n-1)&hash替代了取模,一次位运算就完成桶定位。
核心原理(2min)
主流程(put 过程)——全程其实只在回答三个问题:这个 key 放哪个桶、撞桶了怎么办、装不下了怎么办:
- 调用
hash(key)计算扰动后的 hash(key 为 null 时 hash = 0,即放 0 号桶)。 (n-1) & hash定位桶下标(n 是数组长度,恒为 2 的幂)。这里有个隐藏前提值得留意:为什么敢用位运算替代取模?——因为 n 恒为 2 的幂,(n-1)的低位全是 1,& (n-1)恰好等价于% n,却比取模快得多。- 桶为空直接放;桶非空则沿链表/红黑树
equals比对,key 相同覆盖 value,不同则追加。 - 插入后判断是否扩容:
size > threshold(threshold = 容量 × 0.75 负载因子)就扩容为 2 倍。
关键机制:
- 负载因子 0.75:在「空间利用率」和「碰撞概率」之间折中。太小浪费空间,太大碰撞多退化。可以自己推一下:设成 1.0 会怎样?——桶几乎塞满才扩容,碰撞极多、链表变长,O(1) 退化成 O(n);设成 0.3 呢?——大部分空间空着,浪费内存。0.75 是这两个极端之间的平衡点。
- 链表 → 红黑树:链表长度 ≥ 8 且数组长度 ≥ 64 时树化,查找 O(n) → O(log n);退树化阈值是 6(不是 8,避免反复树化/退化的振荡)。想一想为什么退树化不用 8 而用 6?——如果树化/退树化都卡在 8,元素数量在 8 附近波动时会「一会儿转树、一会儿转链表」,来回振荡;6 和 8 之间留一条缓冲带,才避免这种抖动。
底层深入(5-10min)
1. 扰动函数为什么不是简单取模: 先问一句:如果直接用 hashCode % n(或 & (n-1))会踩什么坑?容量是 2 的幂,(n-1) 的二进制全是低位 1,等于只用到了 hashCode 的低位;一旦 hashCode 高位差异大、低位相同,直接取模会让它们全落同一个桶——高位信息被白白丢掉。所以 JDK 8 用 hash = hashCode ^ (hashCode >>> 16) 让高 16 位也参与桶定位。选 XOR 不选 AND/OR 是因为:AND 输出 1 概率 25%(偏 0),OR 是 75%(偏 1),只有 XOR 是 50%,分布最均匀。真实源码就三行:
// java.util.HashMap
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key == null 直接返回 0(所以 null 永远落在 0 号桶);非 null 时把 hashCode 与其无符号右移 16 位的结果异或,等价于「把高 16 位的差异折叠进低 16 位」,让原本在 & (n-1) 里被丢弃的高位信息重新参与桶定位。
2. 扩容的 hash & oldCap 魔法: 扩容后容量翻倍,为什么不需要重算每个 key 的 hash?——因为容量恒为 2 的幂,扩容后的掩码只比原来多 1 个 bit,而这个 bit 恰好就是 oldCap。所以 e.hash & oldCap 一次位运算判断节点去向:结果为 0 → 下标不变(lo 链表);结果为 1 → 下标 + oldCap(hi 链表)。一次位运算 O(1) 决策、O(n) 迁移。要是容量不是 2 的幂,这个「按位分流」的魔法就失效了,得老老实实重算——这也是容量必须取 2 的幂的隐藏收益。先看 putVal 里「定位 + 触发扩容」的两处核心:
// java.util.HashMap#putVal(节选)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
...
++modCount;
if (++size > threshold)
resize();
(n - 1) & hash 就是桶定位——n 是 2 的幂时它等价于 % n 却快一个量级;最后两行是扩容触发条件 size > threshold(threshold = 容量 × 0.75)。再看 resize 里迁移节点时「按位分流」的魔法:
// java.util.HashMap#resize(节选:迁移 lo/hi 双链表)
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
(e.hash & oldCap) == 0 就是那一次位运算:因为扩容后掩码只新增了 oldCap 这一位,该位是 0 就留原下标 j,是 1 就跳到 j + oldCap。每个节点只做一次位运算就分好流,全程不重算 hash——这就是「容量必须为 2 的幂」的隐藏收益。
3. 为什么树化阈值是 8: 一个很自然的疑问:为什么偏偏是 8,而不是 2 或 10?JDK 注释给出泊松分布解释——理想哈希分布下(λ=0.5),链表长度达到 8 的概率约 6×10⁻⁸,几乎不可能。所以链表 ≥ 8 本身就说明「哈希分布异常」(比如恶意构造碰撞 key),树化是防御。树节点内存约是普通节点 2 倍,所以还要求数组长度 ≥ 64——小表里桶数本来就少,直接扩容就能摊薄,不必急着转树;这也是「防御要留后手」的体现:先怀疑是不是桶太少,再怀疑是不是哈希被恶意碰撞。
4. JDK 1.7 vs 1.8:
| 1.7 | 1.8 | |
|---|---|---|
| 结构 | 数组 + 链表 | 数组 + 链表/红黑树 |
| 插入 | 头插法 | 尾插法 |
| 扩容 | 重新 hash 定位 | hash & oldCap 二分 |
| 并发扩容 | 头插导致死循环 | 尾插消除死循环 |
5. 1.7 头插法的死循环: 头插法(把新节点插到链表头部)当年其实是个合理选择——实现简单,且带着「新插入的 key 更可能被近期访问」的朴素直觉。但它埋了个雷:1.7 扩容时把节点插到新链表头部,并发扩容时两个线程同时迁移链表,next 指针交叉修改形成 A→B→A 循环引用,get() 进入死循环、CPU 飙高。1.8 改尾插法 + lo/hi 双链表,链表相对顺序不变,彻底消除循环引用。注意:HashMap 本身仍非线程安全,并发场景要用 ConcurrentHashMap——「修掉了死循环」不等于「变线程安全」,这是两个问题。
子主题三:ArrayList 底层
一句话结论(30s)
ArrayList 底层是
Object[] elementData动态数组,容量不够时扩容为 1.5 倍——核心权衡是用「扩容时的一次性数组拷贝」换「随机访问 O(1)」,因为扩容是低频操作,均摊到每次 add 仍是 O(1),而随机访问快是它的核心竞争力。
核心原理(2min)
主流程:
- 随机访问:
get(i)直接elementData[i],O(1);add(E)尾部追加,先ensureCapacity检查容量,不够就扩容,O(1) 均摊。为什么能直接下标取?——因为底层是连续数组,首地址 + 偏移量一次算出元素地址,这就是「随机访问快」的根。 - 扩容:
int newCapacity = oldCapacity + (oldCapacity >> 1)(即 1.5 倍),elementData = Arrays.copyOf(elementData, newCapacity),底层是System.arraycopy()(JVM Native 批量内存拷贝,比逐元素 for 快数十倍)。这里可以想:扩容为什么要「整体搬一次数组」?——因为数组大小是死的,装不下了只能换一块更大的连续内存,把旧数据整体拷过去。 - 中间插入/删除:需要
System.arraycopy搬移元素,O(n)。同一个System.arraycopy,在「扩容」里是偶尔一次的低频成本,在「中间插入」里却是每次都要付——这就是为什么「随机访问快、中间增删慢」是对称的。
关键机制——fail-fast: ArrayList 有 modCount 字段,每次结构性修改(add/remove)自增。迭代器构造时记录 expectedModCount = modCount,每次 next() 校验两者是否相等,不等就抛 ConcurrentModificationException。设计意图是在非线程安全集合上「尽力而为」地检测并发修改(遍历中修改)。值得想的是:既然 ArrayList 本身不保证线程安全,为什么还要费劲做这个检测?——因为「遍历到一半集合被改」是最隐蔽、最难查的 bug,宁可抛出异常让问题立刻暴露,也好过静默返回错乱结果。
底层深入(5-10min)
1. 为什么是 1.5 倍而不是 2 倍: 想明白这个数,先算一笔账:2 倍扩容会导致每次扩容后容量完全覆盖之前所有分配的总和,内存碎片严重、浪费大。1.5 倍是经验值——扩容频率足够低(均摊 O(1)),内存利用率又足够高。Vector 用 2 倍扩容是历史遗留(Vector 还每个方法加 synchronized,现已基本废弃)。这个「1.5」不是拍脑袋,而是「扩容次数」和「内存浪费」两个成本曲线交叉出来的折中点——和 HashMap 的 0.75 负载因子是同一个思维。真实源码里「1.5 倍」就藏在 grow 的第三个参数里:
// java.util.ArrayList#grow
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
oldCapacity >> 1 就是 1.5 倍的来源——oldCap + (oldCap >> 1) = 1.5 倍,作为「首选增长量」传给 ArraysSupport.newLength;只有当一次插入的元素太多、1.5 倍仍不够时,才按 minCapacity - oldCapacity 的实际缺口扩容。扩容动作是 Arrays.copyOf,底层走 System.arraycopy 的 native 批量内存拷贝,一次搬完整个数组。
2. RandomAccess 标记接口的妙用: 空接口,不定义方法,只是「标记」——一个什么都不做的接口凭什么有存在价值?关键在于它把「我支持 O(1) 随机访问」这个信息告诉给了算法,让调用方能据此选择最优遍历方式。Collections 内部据此分支优化,真实源码如下:
// java.util.Collections#binarySearch
public static <T>
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
return Collections.indexedBinarySearch(list, key);
else
return Collections.iteratorBinarySearch(list, key);
}
indexedBinarySearch 走 list.get(mid) 的 O(1) 下标访问,iteratorBinarySearch 走 ListIterator 的指针后移。分支判断的正是 list instanceof RandomAccess——实现类用「是否标记」这个信息,让算法在「随机访问」和「迭代器遍历」两条路径里自动选对。LinkedList 不实现 RandomAccess,因为它的 get(i) 是 O(n) 指针遍历,告诉算法「不要用下标循环遍历我」。
3. fail-fast 的边界: 它是「尽力而为」,不保证一定检测到——多线程并发修改时 modCount 的可见性不保证。所以它只能发现「单线程遍历中修改」这种确定性错误,不能替代线程安全。这里容易想当然:既然有 fail-fast,是不是就能放心并发用了?——恰恰相反,它只能「抓」不能「防」,真并发场景下它可能不报错也可能报错,都不可依赖。真正线程安全的读多写少场景用 CopyOnWriteArrayList:每次写都 Arrays.copyOf 整个数组 + setArray(volatile 写),读直接 getArray()[index](volatile 读)无锁。写 O(n) 但读无锁,适合配置列表、白名单这类「写很少、读极多」的场景。
子主题四:Java 8+ 新特性(Lambda / Stream)
一句话结论(30s)
Lambda 的本质是
invokedynamic指令——把「如何实现函数式接口」的决策从编译期推迟到运行期,而 Stream 的本质是惰性求值 + Sink 链——中间操作只构建管道不执行,终止操作触发一次遍历。核心权衡是用函数式的简洁换可读性,同时靠惰性求值避免多次全量遍历,因为数据只遍历一遍。
核心原理(2min)
Lambda 的主流程: () -> ... 编译后不是生成 .class,而是生成一条 invokedynamic 指令。第一次执行时调用 Bootstrap 方法 LambdaMetafactory.metafactory(),用 ASM 在内存中动态生成一个实现该函数式接口的类,返回 CallSite 缓存起来,后续直接走 CallSite 不再调 Bootstrap。为什么要把「怎么实现这个接口」推迟到运行期才决定?——因为这样 JVM 未来可以换一种更好的实现而不用重新编译代码,等于给语言留了升级后门。
Stream 的主流程: filter、map 等中间操作只返回新的 ReferencePipeline(持有上游引用),形成管道链表,不执行任何数据操作。只有 collect/forEach/reduce 等终止操作才调用 evaluate() 触发执行:从链尾向链头逐级 opWrapSink() 构建 Sink 链,然后数据源遍历时每个元素「一次性穿越所有中间操作」。可以先在心里问一句:如果中间操作每一步都立刻执行、结果立刻落到一个新集合,会怎样?——那 filter + map + filter 三连就会把数据遍历三遍、还创建两个中间集合。惰性求值就是为了「只遍历一遍、只留最终结果」。
底层深入(5-10min)
1. Lambda 与匿名内部类的本质区别:
| 维度 | 匿名内部类 | Lambda |
|---|---|---|
| 字节码 | 编译期 javac 生成 .class | 运行期 invokedynamic 动态生成(内存中,不落盘) |
| 类加载 | 每个匿名类都要加载 | class 只生成一次,多个 Lambda 共享 |
| 无捕获实例 | 每次 new 新实例 | 单例,复用(零对象分配) |
| 灵活性 | 编译期钉死 | Bootstrap 可被 JVM 替换,未来可升级 |
关键结论:Lambda 不是匿名内部类的语法糖。匿名内部类是 invokespecial Main$1.<init> 指向编译期定死的类;Lambda 是 invokedynamic 延迟绑定。无捕获 Lambda 会被缓存成单例(static final INSTANCE),有捕获 Lambda 每次生成新实例但 class 只生成一次。这两者的区别可以这样记:匿名内部类是「编译时就把答案写死」,Lambda 是「运行时才现场决定答案」——一个像出厂就装好的零件,一个像用到时才现做。
2. Stream 惰性求值的底层: 每个中间操作对应一个 Sink,真实源码里 filter 是通过匿名内部类继承 Sink.ChainedReference 实现的——它内部持有的 downstream 字段就是「下一个 Sink」的引用:
// java.util.stream.Sink(节选:链式 Sink 的基类)
abstract static class ChainedReference<T, E_OUT> implements Sink<T> {
protected final Sink<? super E_OUT> downstream;
public ChainedReference(Sink<? super E_OUT> downstream) {
this.downstream = Objects.requireNonNull(downstream);
}
// begin/end/cancellationRequested 都直接转发给 downstream
}
// java.util.stream.ReferencePipeline#filter(节选)
Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
return new Sink.ChainedReference<>(sink) {
@Override
public void accept(P_OUT u) {
if (predicate.test(u))
downstream.accept(u);
}
};
}
终止操作触发后从链尾向链头逐级 opWrapSink() 构建完整 Sink 链,每个元素从数据源取出后一次性穿越所有中间操作的 Sink——避免了多次全量遍历。短路操作(findFirst/limit)找到一个满足就终止,后面的元素根本不处理。
3. parallelStream 的陷阱: parallelStream() 默认用 ForkJoinPool.commonPool()——全 JVM 共享线程池。它内置工作窃取(Work Stealing),只适合纯 CPU 密集任务;IO 阻塞任务会占满 commonPool,导致整个 JVM 所有并行流卡住,必须用自定义线程池。这个坑的根子是「共享」二字:如果它是给每个并行流单独建池,谁阻塞谁自己的事;正因为它全 JVM 共用一个池,你一个地方阻塞 IO,别人无辜遭殃——这也是很多「全局共享资源」类设计共同的雷。
4. 补充(JDK 9-17 常用特性速记): List.of()/Map.of() 不可变集合、Optional 增强、var 局部变量推断、Record(不可变数据载体,自动生成构造器/equals/hashCode/toString)、switch 模式匹配、Sealed 类、String 的 isBlank()/lines()/strip()。
其中 Compact Strings(JDK 9)是面试高频点——JDK 9 前 String 用 char[](每个 char 占 2 字节),纯 ASCII 文本也浪费一半内存;JDK 9 起改成 byte[] 存字节 + coder 标记编码:
// java.lang.String(JDK 9+,字段)
@Stable
private final byte[] value; // 真正存字节的数组,替代了 JDK 8 的 char[] value
private final byte coder; // LATIN1=0 / UTF16=1,标记 value 里每个元素是 1 字节还是 2 字节
@Stable
private int hash; // 缓存 hashCode,默认 0 表示未计算
构造时按内容决定编码——全是 Latin-1 可表示字符就用 1 字节,否则退回 UTF16:
// java.lang.String(int[] codePoints, int offset, int count)(节选)
if (COMPACT_STRINGS) {
byte[] val = StringUTF16.compress(codePoints, offset, count);
this.coder = StringUTF16.coderFromArrayLen(val, count);
this.value = val;
return;
}
this.coder = UTF16;
this.value = StringUTF16.toBytes(codePoints, offset, count);
COMPACT_STRINGS 开关开启时,先 compress 尝试压成单字节并算出 coder;压不了才走 UTF16 双字节路径。纯 ASCII/拉丁文本内存直接省一半,中文等非 Latin-1 字符则与旧版等价——这也是 String 不可变带来的额外红利:byte[] value 是 final,编码方案升级不影响已有字符串的语义。
子主题五:虚拟线程(Java 21)
一句话结论(30s)
虚拟线程是 JVM 用户态调度的轻量级线程,核心机制是 mount/unmount——遇到阻塞 IO 就从 Carrier Thread 卸载、把状态存堆上,让少数几个平台线程支撑数万并发。核心权衡是用「阻塞调用也可能 Pin 住线程」的坑,换「同步代码写高并发」的简单,因为虚拟线程让你用
Thread-per-request的写法享受异步的吞吐。
核心原理(2min)
主流程(mount/unmount):
- 虚拟线程执行到 IO 阻塞时,从 Carrier Thread(平台线程)上 unmount,Continuation 状态保存在堆上。
- Carrier Thread 立刻去调度其他虚拟线程。
- IO 就绪后,虚拟线程 mount 回任意空闲 Carrier Thread 继续执行。
关键机制: 虚拟线程本质是「协程思想在 JVM 的实现」。8 个 Carrier Thread(≈ CPU 核数)就能支撑数万虚拟线程并发 IO。和 Go 的 goroutine、Python 的 asyncio 一样,共同点是让开发者用同步代码风格写异步逻辑,同时享受接近异步的性能。这里值得想通一个前提:为什么「阻塞」在传统线程里是灾难、在虚拟线程里却没事?——因为传统线程一阻塞,整个 OS 线程就躺在那里占着不干活;虚拟线程阻塞时把「现场」搬到堆上、把 OS 线程让出来给别人用。阻塞本身没变,变的是「阻塞时是否还占着昂贵的载体」。
底层深入(5-10min)
1. 进程/线程/协程的定位: 进程是资源分配基本单位(独立地址空间,隔离最强、切换最慢);线程是 CPU 调度基本单位(共享进程内存,切换约进程的 1/3);协程/虚拟线程在用户态调度、无内核参与(最快,纳秒级切换)。选择原则:强隔离用进程,共享内存用线程,高并发 + IO 密集用协程。这个「三级递进」可以理解为一条权衡主线:隔离越强、切换越贵;切换越便宜、隔离越弱——没有哪一层能同时把「隔离」和「便宜」都拿满,选型就是在这条线上找当前场景的点。
「用户态调度、无内核参与」到底怎么落到代码上?——就是 mount/unmount 这两个方法。mount 把虚拟线程「骑」到当前 Carrier Thread 上,unmount 再把它「卸」下来,全程没有内核系统调用:
// java.lang.VirtualThread#mount(节选)
private void mount() {
startTransition(/*mount*/true);
// sets the carrier thread
Thread carrier = Thread.currentCarrierThread();
setCarrierThread(carrier);
// sync up carrier thread interrupted status if needed
if (interrupted) {
carrier.setInterrupt();
} else if (carrier.isInterrupted()) {
synchronized (interruptLock) {
if (!interrupted) {
carrier.clearInterrupt();
}
}
}
// set Thread.currentThread() to return this virtual thread
carrier.setCurrentThread(this);
}
// java.lang.VirtualThread#unmount(节选)
private void unmount() {
assert !Thread.holdsLock(interruptLock);
// set Thread.currentThread() to return the platform thread
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
// break connection to carrier thread, synchronized with interrupt
synchronized (interruptLock) {
setCarrierThread(null);
}
carrier.clearInterrupt();
endTransition(/*mount*/false);
}
mount 只是把 carrier.setCurrentThread(this) 改一下「当前线程」指针、记录载体;unmount 反过来把指针切回平台线程、把 carrierThread 置空。两件事都在 JVM 内部完成、不触发内核调度,所以切换开销是纳秒级——这就是「用户态调度」的代码依据:现场始终存在堆上的 Continuation 里,切换只是改指针,不搬栈、不 syscall。
2. 虚拟线程的 Pinning 问题(重点坑): 为什么会 Pin?先抓住一个矛盾:虚拟线程要 unmount,前提是「现场」能完整搬走;但 synchronized 的 Monitor 锁(ObjectMonitor)依赖 OS 层互斥量,锁状态绑定在 Carrier Thread 上,JVM 规范要求持有 Monitor 期间虚拟线程不能安全卸载——否则其他虚拟线程 mount 到同一 Carrier Thread 会继承锁状态。所以虚拟线程进 synchronized 块后 Carrier Thread 被「钉死」,效果等同退化成平台线程。而 ReentrantLock 是纯 Java 的 AQS 实现,抢锁失败调 LockSupport.park(),虚拟线程的 park 会触发 unmount,完全兼容。同样是锁,为什么一个 Pin 一个不 Pin?——差别就在「锁状态存在哪」:存在 OS 载体上就搬不走,存在 Java 堆上就能跟着现场一起搬。排查工具:JFR 的 jdk.VirtualThreadPinned 事件,定位后把 synchronized 换成 ReentrantLock。注意隐蔽场景:StringBuffer/Vector/Hashtable 等 JDK 内部用了 synchronized 的类同样触发 Pin。
3. 与 Go goroutine 的异同(见追问 7): 对比时先问「它们各自怎么处理『阻塞』和『抢占』这两个难题」,异同自然就清晰了:
- 同:都是 M:N 用户态调度、轻量级、同步写法异步性能、栈动态伸缩。
- 异:Java 用 ForkJoinPool(工作窃取)作调度器,Go 用 GMP 模型(G=goroutine、M=OS 线程、P=逻辑处理器,本地队列 + 全局队列 + 窃取);Java 依赖 JVM 改造过的阻塞调用才能 unmount(synchronized/native 会 Pin),Go 阻塞 syscall 会让 M 解绑 P;Go 1.14 加入基于信号的异步抢占,Java 在安全点/阻塞点卸载。
追问清单(10 个追问点的完整回答)
追问 1:BIO、NIO、AIO 的区别?NIO 的 Selector 是怎么工作的?
一句话结论:三者区别在「谁负责把数据读到用户缓冲区」——BIO 线程阻塞等,NIO 线程主动读,AIO 内核读完回调,因为这是「同步」和「异步」的根本分界。
展开:BIO 一个连接一个线程,accept/read 阻塞;NIO 用 Selector 一次监听多个 Channel,就绪后主动读;AIO 提交请求立即返回,内核读完回调。Selector 底层是 epoll:epoll_create 建结构(红黑树 + 就绪链表)、epoll_ctl 一次注册 fd、epoll_wait O(1) 只取就绪事件不遍历全量,所以一个线程能管百万连接。
追问 2:HashMap 的 put 过程详细说一遍。1.7 和 1.8 有什么区别?
一句话结论:put 就是「扰动 hash → 位运算定位桶 → 链表/树比对覆盖或追加 → 超阈值扩容」,因为容量是 2 的幂才能用 (n-1)&hash 替代取模。
展开:hash(key) 做 h ^ (h>>>16) 扰动;(n-1)&hash 定位桶;桶空直接放,非空沿链表/红黑树 equals 比对,key 相同覆盖 value,不同则尾插追加;size > 容量×0.75 就扩容 2 倍。1.7 是「数组+链表、头插法、重算 hash」,1.8 是「数组+链表/红黑树、尾插法、hash&oldCap 二分迁移」,并加了树化(长度≥8 且数组≥64)。
追问 3:为什么 1.8 改成尾插法?1.7 的头插法在扩容时有什么问题?
一句话结论:1.7 头插法在并发扩容时会形成循环链表导致 get() 死循环,因为两个线程同时迁移时 next 指针交叉修改。
展开:1.7 扩容把节点插到新链表头部,并发时线程 A、B 同时迁移,节点引用被交叉修改形成 A→B→A 环,get 陷入死循环、CPU 飙高。1.8 改尾插法 + lo/hi 双链表,迁移时链表相对顺序不变,彻底消除循环引用。但要强调 HashMap 仍非线程安全,并发用 ConcurrentHashMap。
追问 4:ArrayList 扩容机制说一下。为什么扩容 1.5 倍?
一句话结论:扩容是 oldCapacity + (oldCapacity >> 1) 即 1.5 倍,用 Arrays.copyOf(底层 System.arraycopy)搬移,因为 2 倍会覆盖历史分配总和造成浪费,1.5 倍是均摊 O(1) 与内存利用率的最佳折中。
展开:add 前 ensureCapacity 检查,不够就扩容 1.5 倍;中间插入/删除要 System.arraycopy 搬移 O(n)。1.5 倍是经验值——扩容频率够低(均摊 O(1))、内存利用率够高,Vector 的 2 倍是历史遗留。
追问 5:Lambda 表达式的底层实现是什么?和匿名内部类有什么区别?
一句话结论:Lambda 底层是 invokedynamic + LambdaMetafactory 运行时动态生成类,匿名内部类是编译期生成 .class,因为 Lambda 把实现决策推迟到运行期、可被 JVM 替换。
展开:匿名内部类编译期 invokespecial Main$1.<init> 指向钉死的类,每个匿名类一个 .class、每次 new 新实例;Lambda 编译成 invokedynamic,第一次执行时 Bootstrap 用 ASM 在内存生成类并缓存 CallSite,无捕获 Lambda 还是单例(零对象分配)。
追问 6:Stream 的惰性求值是怎么实现的?中间操作和终端操作的区别?
一句话结论:中间操作只构建 Pipeline 链表不执行,终止操作调用 evaluate() 才触发 Sink 链构建和遍历,因为惰性求值让「多步操作只遍历一次数据」。
展开:filter/map 返回持有上游引用的新 Pipeline;collect/forEach/reduce 才触发执行——从链尾向链头 opWrapSink() 构建 Sink 链,每个元素一次性穿越所有中间操作。短路操作 findFirst/limit 找到即停,后续元素不处理。
追问 7:虚拟线程和 Go 的 goroutine 调度有什么异同?
一句话结论:两者都是 M:N 用户态调度、同步写法异步性能,区别在于调度器实现——Java 用 ForkJoinPool(工作窃取)、Go 用 GMP 模型,因为两者抢占/阻塞处理机制不同。
展开:Go 是 GMP(G=goroutine、M=OS 线程、P=处理器),本地队列 + 全局队列 + 窃取,1.14 后基于信号异步抢占;Java 虚拟线程 mount/unmount,依赖 JVM 改造过的阻塞调用才能卸载(synchronized/native 会 Pin),在安全点卸载。栈都动态伸缩,Go 初始 2KB。
追问 8:String 为什么是不可变的?StringBuilder 和 StringBuffer 的区别?
一句话结论:String 不可变靠「final class + final 数组 + 无 setter + 构造防御拷贝」四重保障,因为不可变带来线程安全、常量池复用、hash 缓存三大价值;StringBuffer 就是每个方法加了 synchronized 的 StringBuilder。
展开:不可变 → 天然线程安全(HashMap 默认用 String 做 key 的原因)、编译期字面量入常量池、hashCode 缓存 O(1)。StringBuilder 非线程安全单线程最优,StringBuffer 加 synchronized(约 20% 性能损耗),JDK 9 起都改 byte[] + coder(Compact Strings,纯 ASCII 省一半内存)。
追问 9:equals() 和 == 的区别?重写 equals 为什么要重写 hashCode?
一句话结论:== 比较引用地址(基本类型比数值),equals 默认也是引用比较、重写后按内容比较;必须一起重写 hashCode 是因为 HashMap 先按 hashCode 定桶、再按 equals 精确匹配,两者不一致桶定位就错。
展开:只重写 equals 不重写 hashCode,equals 相等的两个对象 hashCode 不同 → HashMap 放不同桶 → get 返回 null;只重写 hashCode 不重写 equals,相同内容对象 hashCode 相同落同桶但 equals 是引用比较 → 被当成两个 key。契约:equals 相等则 hashCode 必相等,反之尽量不相等。
追问 10:泛型擦除是什么?为什么 Java 的泛型是伪泛型?
一句话结论:泛型只存在于编译期,字节码里类型参数被擦除为 Object(或上界),所以 Java 泛型是「伪泛型」,因为这是 Java 5 引入泛型时兼容老 JVM 的历史妥协。
展开:List<String> 和 List<Integer> 编译后是同一个 class,运行期 getClass() 相同。擦除会导致多态断裂(父类 Object get() vs 子类 String get() 签名不同),编译器自动生成桥接方法(synthetic bridge)翻译。泛型信息未完全消失——声明时的泛型参数保留在 class 的 Signature 属性,可反射读取。