面试模块:15 道题,接住下一问
ScopedValue 和 ThreadLocal 的本质区别是什么?为什么虚拟线程放大了 ThreadLocal 的成本?
ThreadLocal 是「每个线程一份可变副本」:任何被调用的代码都能 set,必须在 finally 里 remove,否则线程池复用会把上一次请求的值带到下一次;InheritableThreadLocal 还会在创建子线程时复制父线程的所有条目。
ScopedValue 是「一段执行路径上的只读绑定」:where(key, value).run/call(...) 进入作用域时安装,退出时恢复,没有 set 和 remove。绑定按线程/作用域存在,结构化 fork 会继承当前快照,手工线程和线程池不会。值对象本身可以共享,但绑定不可变。
虚拟线程可以创建几十万个,每个线程的 ThreadLocalMap 和继承复制都会被放大;ScopedValue 不需要每线程副本,生命周期由词法作用域限定。但 JEP 506 并不废弃 ThreadLocal:可变的 per-thread 状态、计数器、缓冲区、部分框架的 MDC 仍然需要它。
ScopedValue 的绑定生命周期是怎样的?嵌套、异常、结构化 fork 与手工线程分别如何变化?
where 生成一个 Carrier,run/call 安装绑定,退出时在 finally 中恢复进入前的快照。嵌套 where 会遮蔽外层,退出内层恢复外层;正常返回和抛异常都会恢复。作用域最外层结束后 isBound() 为 false。
传播边界是重点:StructuredTaskScope.fork 在创建子任务时捕获当前绑定快照,所以子任务能读到;Thread.ofVirtual().start、ExecutorService.submit 没有捕获步骤,新线程从空绑定开始。绑定跟着作用域走,不跟着「虚拟线程」这个类型走。
读取时 get() 在未绑定下抛 NoSuchElementException;orElse 提供默认值;orElseThrow 转成业务异常。25 起 orElse(null) 抛 NullPointerException,不能再拿 null 当「没有默认值」。
USER.get() 读的是当前线程当前的绑定,不是闭包里的值。JDK 25 的 ScopedValue API 与 21 预览版有哪些不兼容变化?升级时要注意什么?
25 把 ScopedValue 转正(JEP 506),where(...).run/call 不再需要 --enable-preview。同时移除了 21 的 runWhere、callWhere、getWhere 三个静态便捷方法,长期代码只能使用 Carrier 链式调用。
Carrier.call 的参数从 Callable 变成可抛受检异常的 CallableOp,lambda 源码通常兼容,但反射、方法引用和精确签名会受影响;ScopedValue 本身没有静态 call,返回值只能通过 Carrier.call。另外 orElse(null) 从允许变为抛 NPE。
升级时先替换便捷方法,再检查 orElse(null),最后用 25 重新编译。预览 API 不承诺二进制兼容,21 预览编译出的 65.65535 class 不能直接在 25 运行。
CallableOp 能让 call 抛受检异常,而 Runnable.run 不行?ScopedValue.call(...) 写进代码——没有这个静态方法;只有 ScopedValue.where(...).call(...)(即 Carrier.call)。结构化并发解决了 ExecutorService + Future 的什么问题?它不是性能优化,那它优化的是什么?
ExecutorService 允许提交者、执行者、等待者完全解耦,代价是生命周期没有结构:子任务可能比父任务活得久,一个 Future 失败不会自动取消其他 Future,取消要手工协调,线程 dump 也看不出任务属于哪个请求。
StructuredTaskScope 把一组子任务收束到一个词法块:fork/join/close 都在 try-with-resources 内;默认策略下任一子任务失败就取消其余;owner 被中断或作用域关闭时取消传播;线程 dump 按作用域展示父子层级。父任务不会在子任务仍运行时返回。
它优化的是生命周期、错误处理、取消与可观测性,不是单任务速度。子任务并行可能降低延迟,但那是并发带来的,不是结构化本身;把它当性能开关会在只需串行的场景里增加复杂度。
JDK 25 的 open()/Joiner API 怎么用?默认策略、allSuccessfulOrThrow 与 anySuccessfulResultOrThrow 有什么区别?
StructuredTaskScope 在 25 是接口,用 open() 创建。无参 open() 使用默认策略:等待所有子任务成功,任一失败就取消其余,join() 抛 FailedException;全部成功时 join() 返回 null(R 是 Void)。
allSuccessfulOrThrow() 也要求全部成功,但 join() 返回 Stream<Subtask<T>>,适合需要同一类型多个结果的 fan-out;anySuccessfulResultOrThrow() 在任一子任务成功时立即取消其余并返回该结果,全部失败才抛 FailedException。还有 awaitAll、awaitAllSuccessfulOrThrow、allUntil。
这些 API 都是 25 预览。21 的 ShutdownOnFailure / ShutdownOnSuccess 已被移除,joinUntil(Instant) 也换成了 Configuration.withTimeout(Duration);升级必须重写,不能只改参数。
失败、超时、中断时取消如何传播?为什么 close() 可能长时间阻塞?
默认/allSuccessful 策略下,一个子任务失败会短路:作用域取消其余子任务,join() 抛 FailedException,原始异常在 cause;被取消的任务状态是 UNAVAILABLE。取消通过中断传播,不是强制杀死线程。
超时由 Configuration.withTimeout(Duration) 配置,到期后作用域取消,join() 抛 StructuredTaskScope.TimeoutException(unchecked),它不是 FailedException 的子类。owner 线程被中断时,join() 抛 InterruptedException,close 会取消并等待子任务。
close() 必须等到所有子任务真正终止。如果子任务吞掉中断,或阻塞在不可中断的 I/O/锁上,close 只能等它自然结束;这也是为什么要把「可中断性」当成子任务的契约。
紧凑源文件允许什么、禁止什么?java Hello.java 与 javac 的行为差别在哪里?
紧凑源文件省略 package 和 class 外壳,可以直接写 import、顶层字段/方法、额外的顶层类,以及实例或静态的 main。编译器把未包裹的成员放进一个隐式类:final、位于未命名包、只有无参构造器,成员默认包私有。
java Hello.java 用源码启动器在内存中编译并运行,不留下 class 文件,也不接受 --release 等编译选项;javac --release 25 Hello.java 会生成 class,之后可以用 classpath 启动。两种方式都走同一个编译器和 JVM,不是解释执行。
硬限制:不能有 package 声明;一次启动一个源文件;不能声明 module-info;隐式类不是公开 API 类型。需要包、可见性、多文件或固定 release 时,展开成普通类。
java Hello.java 只是把编译和运行合并成一步。灵活构造器体允许在 super()/this() 之前写什么?哪些操作会被拒绝?
JDK 25 允许构造器在显式 super(...) / this(...) 之前写语句(prologue):可以使用参数、局部变量、静态成员,做条件判断、抛异常、try/catch,也可以给本类没有 初始化器 的字段赋值(含 final 字段的一次赋值)。
不能读取本类字段、调用实例方法、使用 this 或把 this 传出去;不能在 prologue 里 return;super/this 仍然必须恰好出现一次。给有 初始化器 的字段赋值会编译失败:cannot assign initialized field。
执行顺序是子类 prologue → 父构造器(父类 prologue/构造器体/子类 epilogue 展开)→ 子类 epilogue。这样父构造器调用可覆写方法时,子类已经通过 prologue 建立了必要字段。record 的紧凑构造器规则不同,不要直接套用。
实例 main 的启动协议是什么?为什么紧凑源文件能直接用 java.base 的类型?
启动器先找带 String[] 的 main,再找无参 main;静态就直接调用,实例则要求类有非私有无参构造器,先构造对象再调用。紧凑源文件的隐式类有默认无参构造器,所以 void main() 可以直接作为入口。
紧凑源文件自动带上 import module java.base; 的效果,java.base 导出的公开顶层类型可以直接使用,例如 List、Map;IO 位于 java.lang,也可以直接写 IO.println。但 IO 的静态方法不再隐式导入,不能简写成 println。
javac 仍可编译紧凑源文件;本机 javac 生成的隐式类名取自文件名,但规范说生成名是实现相关的,不应在源码里依赖。需要公开 API 或包边界时,展开成普通类。
java Hello.java 不接受 --release;要固定目标版本,先用 javac 编译,再运行 class。import module java.base; 到底导入了什么?它和 import java.util.*; 有什么不同?
import module M; 导入 M 向当前模块导出的所有包中的公开顶层类型,并且导入当前模块因读取 M 而读取到的模块(transitive requires 链)所导出的包。它是按需导入的放大版,不是只导入一个包。
它不导入未导出包、导出给其他模块的限定导出,也不能导入 classpath 上的未命名模块;它也不打开反射访问。import java.util.* 只导入一个包;模块导入可能一次带入几十个包,因此更容易遇到同名类。
同名冲突时,单类型导入优先于按需包导入,按需包导入优先于模块导入。java.base + java.desktop 的 List 冲突,可以用 import java.util.List; 遮蔽;不用简单名、写全限定名也不会冲突。
import module java.base 不会让 java.sql 的 Connection 可用;它只覆盖 java.base 及其传递读到的模块。模块导入能替代 requires 吗?在命名模块与 classpath 下分别有什么限制?
不能。import module 只影响编译期的名字解析,不改变运行时模块图,也不会让未读取的模块变得可读。命名模块仍需在 module-info 中声明 requires;模块导入不会隐式补上依赖。
它只接受模块名,不能导入 classpath 上的未命名模块;也不能导入未导出包或获得 setAccessible 权限。限定导出只有在导出给当前模块时才进入范围,导出给其他模块的不会。
它适合小工具、教学代码、大量使用 java.base 的单文件程序;库的公开 API、需要审计依赖的代码,仍应使用显式 import 和明确的 module-info。25 已正式,不再需要 --enable-preview。
import module java.base; 能编译,就以为模块依赖已经声明;运行时缺 requires 仍会在模块解析阶段失败。紧凑对象头把对象头压到多少位?JDK 25 默认开启吗?启用条件是什么?
64 位 HotSpot 的旧对象头是 96 位(mark 64 + compressed class 32)或 128 位(mark 64 + class 64);紧凑对象头把它压到 64 位(8 字节),把 22 位压缩类指针、31 位 identity hash、4 位 Valhalla 预留位,以及 GC 年龄/锁标记等放进同一个头。
JEP 519 在 25 把它从实验特性转为产品特性,但明确不改变默认布局:UseCompactObjectHeaders 默认仍是 false,需要显式 -XX:+UseCompactObjectHeaders。它依赖压缩类指针;关闭 UseCompressedClassPointers 会警告并自动禁用紧凑头。
收益取决于对象分布:JEP 519 引用 SPECjbb2015 等基准的堆/CPU/GC 改善,JEP 450 早期采用者报告活数据下降 10%–20%,但这不是你的服务承诺。上线前用相同堆和负载 A/B,并回归 native agent、堆分析工具。
分代 Shenandoah 与 AOT 缓存在 JDK 25 各是什么状态?默认行为变了吗?
分代 Shenandoah(JEP 521)在 25 从实验转为产品特性,但默认模式仍是 satb。要使用分代模式,必须先选 -XX:+UseShenandoahGC,再显式 -XX:ShenandoahGCMode=generational;Shenandoah 本身也不是默认 GC。
AOT 方面,JEP 514 简化了创建 AOT 缓存的命令,JEP 515 把训练运行的方法 profile 放进缓存,改善启动与预热。典型流程是 record → create → run;缓存与 JDK 版本、平台、启动参数相关,不是跨版本通用产物。
两者都不是「打开就更快」:分代模式要验证停顿与吞吐,AOT 要验证启动收益和训练覆盖面。升级时用 PrintFlagsFinal / GC 日志确认实际生效值。
从 21 升到 25,class 文件版本、预览 class 与编译参数要怎么处理?
JDK 21 的 class 文件版本是 65,25 是 69。用 javac --release 25 编译,既约束字节码版本,也约束语法和标准库 API 快照;25 编译的 class 在 21 上会抛 UnsupportedClassVersionError。
21 编译的普通 class 通常能在 25 运行,但 21 的预览 class 是 65.65535,25 只识别 69.65535,会抛 UnsupportedClassVersionError。升级后所有预览特性代码都必须用 25 重新编译,不能只改启动参数。
迁移时先盘点运行时与构建插件,统一到 25;用 --release 25 和 -Xlint:removal 暴露问题;重写结构化并发、替换 ScopedValue 的旧便捷方法;最后隔离预览代码并做回滚演练。
32 位 x86 移除、Security Manager 与 Thread.stop 在 25 分别是什么状态?升级时怎么排查?
JEP 503 在 25 移除 32 位 x86 端口,不再提供 32 位 x86 运行时。依赖 32 位 x86 JVM 的部署、native agent、JNI 库、容器基础镜像都必须换成 64 位,否则无法启动或加载本地库。
Security Manager 在 JDK 24 起永久禁用,25 中 System.setSecurityManager 抛 UnsupportedOperationException;Thread.stop 同样抛 UOE。不要再用 -Djava.security.manager=allow 试图恢复,应该迁移到进程隔离、模块边界或显式权限检查。
排查时先扫启动脚本与 JVM 参数,再扫 native 依赖和容器镜像,最后跑完整测试与压测。-Xlint:removal 能暴露部分编译期移除,但反射、JNI 和运行时参数仍需人工核对。
怎么刷这套题HOWTO
- 先按主题筛:一次只攻一个主题,避免题目切换导致理解断裂。
- 先自答再展开:用 30 秒说出结论 + 一条原因 + 一个例子。
- 重点看追问:追问模拟面试官的第二层问题,能接住才算真会。
- 最后看版本陷阱:同一 API 在不同 JDK 的状态差异,是最容易答错的点。
- 标记掌握:不确定的不要标记;隔天再复习一遍。
难度说明LEVELS
- 高频:面试中出现频率高,或属于 JDK 25 的核心卖点。
- 进阶:需要理解机制、版本边界或实现取舍,答好能拉开差距。