Skip to content
Go back

Java 基础面试回答——IO 模型、HashMap、ArrayList、Java8 与虚拟线程

Java 基础面试回答

子主题一:IO 模型(BIO / NIO / AIO)

一句话结论(30s)

BIO、NIO、AIO 的本质区别在于谁负责把数据读到用户缓冲区:BIO 是线程同步阻塞等待,NIO 是线程主动轮询并自己去读,AIO 是内核读完回调通知——核心权衡是用「一个线程一个连接」的简单,换「一个线程多个连接」的吞吐,因为阻塞不耗 CPU 但耗线程,线程又是稀缺资源。

核心原理(2min)

三者的主流程:

关键机制——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): 带着一个问题看这三代:每一代都在改掉上一代的哪个「浪费」?——答案都是「全量」二字。

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. 边界与对比总结:

BIONIOAIO
发起 IO 后线程阻塞立即返回(非阻塞)立即返回(异步)
就绪通知返回数据select 返回就绪 fd内核回调
谁读数据到缓冲区线程同步等待线程主动读内核读完回调
适用连接少且简单高并发连接大文件/大量异步 IO

子主题二:HashMap 底层

一句话结论(30s)

HashMap 是「数组 + 链表/红黑树」的哈希表,用扰动函数 h ^ (h>>>16) 打散哈希降低碰撞、容量到阈值就扩容——核心权衡是用空间换时间,把查找从 O(n) 降到 O(1),因为位运算 (n-1)&hash 替代了取模,一次位运算就完成桶定位。

核心原理(2min)

主流程(put 过程)——全程其实只在回答三个问题:这个 key 放哪个桶、撞桶了怎么办、装不下了怎么办:

  1. 调用 hash(key) 计算扰动后的 hash(key 为 null 时 hash = 0,即放 0 号桶)。
  2. (n-1) & hash 定位桶下标(n 是数组长度,恒为 2 的幂)。这里有个隐藏前提值得留意:为什么敢用位运算替代取模?——因为 n 恒为 2 的幂,(n-1) 的低位全是 1,& (n-1) 恰好等价于 % n,却比取模快得多。
  3. 桶为空直接放;桶非空则沿链表/红黑树 equals 比对,key 相同覆盖 value,不同则追加。
  4. 插入后判断是否扩容:size > threshold(threshold = 容量 × 0.75 负载因子)就扩容为 2 倍。

关键机制:

底层深入(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.71.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)

主流程:

关键机制——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);
}

indexedBinarySearchlist.get(mid) 的 O(1) 下标访问,iteratorBinarySearchListIterator 的指针后移。分支判断的正是 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 的主流程: filtermap 等中间操作只返回新的 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 类、StringisBlank()/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[] valuefinal,编码方案升级不影响已有字符串的语义。


子主题五:虚拟线程(Java 21)

一句话结论(30s)

虚拟线程是 JVM 用户态调度的轻量级线程,核心机制是 mount/unmount——遇到阻塞 IO 就从 Carrier Thread 卸载、把状态存堆上,让少数几个平台线程支撑数万并发。核心权衡是用「阻塞调用也可能 Pin 住线程」的坑,换「同步代码写高并发」的简单,因为虚拟线程让你用 Thread-per-request 的写法享受异步的吞吐。

核心原理(2min)

主流程(mount/unmount):

  1. 虚拟线程执行到 IO 阻塞时,从 Carrier Thread(平台线程)上 unmount,Continuation 状态保存在堆上。
  2. Carrier Thread 立刻去调度其他虚拟线程。
  3. 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): 对比时先问「它们各自怎么处理『阻塞』和『抢占』这两个难题」,异同自然就清晰了:


追问清单(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) 与内存利用率的最佳折中。

展开addensureCapacity 检查,不够就扩容 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 属性,可反射读取。


Share this post on:

Previous Post
MySQL 面试回答——事务 MVCC、索引、锁、日志与主从
Next Post
LeetCode Hot 100——SQL篇(窗口函数、NOT EXISTS、行转列)