Java 八股文查漏补缺系列写到第 26 期,我越来越确定一件事:大家背得最熟的东西,往往最经不起追问。比如问你 String 到底创建了几个对象、HashMap 为什么要用红黑树、线程怎么才能等全部结束后再继续、Redis 的 increment() 为什么会报 not integer or out of range,单拎出来都见过,可组合到一起,很多工作三五年的朋友也会卡壳。这篇笔记不做入门教学,而是把最近高频出现的考点按基础语法、集合框架、排序算法、动态代理与 Lambda、线程协作、Redis 原子操作、JDK 环境异常七个方向串一遍。适合准备 Java 面试的初级和中级工程师,也适合日常写代码时做对照自查。
1. 字符串与基础语法:送分题里的连环坑
1.1 多行字符串:从“+”拼接到 Text Block
在 Java 15 以前,写多行字符串是一件痛苦的事:每行末尾要手动加 \n,或者用 + 号逐行拼接,还要小心双引号转义。网上流传的各类“字符串多行写法”帖子,其实是各种 DIY 方案,直到 Java 15 正式推出文本块,这个问题才被语言层面解决。文本块用三引号包起来,里面的换行、缩进、引号都可以按原文保留,写 JSON、SQL、HTML 非常舒服。
String json = """ { "name": "hello", "list": ["java", "spring"] } """;面试里关于文本块的追问通常有两个方向。第一个,前导空白怎么算?编译器会根据文本块内容中最少的前导空白数统一去除缩进,所以你在 IDE 里把代码缩进成几层,生成的字符串不会包含这些多余的缩进。第二个,三引号里还能不能正常写包含引号的内容?能,文本块的设计目标就是嵌入 SQL、HTML、JSON,不需要像老写法那样疯狂转义。老实说,我现在看到项目里还有人用 + 号拼接大段 SQL,改起来非常痛苦,换文本块之后肉眼可读性高很多,也省了纠结 \n 和转义符的时间。
1.2 ==、equals 和 Integer 缓存:输出题想考察什么
这一节几乎每次面试都会出现。先看一个经典输出题:
Integer a = 100, b = 100; Integer c = 200, d = 200; System.out.println(a == b); // true System.out.println(c == d); // false为什么?因为 Integer 内部有一个 IntegerCache,默认缓存 -128~127 之间的对象。在这个范围内,valueOf 会直接返回缓存对象;超出范围就会 new 新对象。而 == 比较的是对象引用,两个对象值相同但引用不同,结果自然是 false。要把这种题做对,核心不是背 true/false,而是理解自动装箱调用的是 Integer.valueOf()。同样的例子还有 Short、Long、Byte,它们都有缓存;Boolean 只有 true/false 两个对象;Float、Double 没有缓存。
面试官看到你答出了缓存,还会继续延伸到 String 的 == 和 equals。equals 看的是值,== 看的是引用。但如果两个字符串都指向常量池里的同一个对象,== 也会是 true。这里真正想考的还是“对象引用 vs 内容相等”,以及字符串常量池的机制。
1.3 new String("hello") 到底创建了几个对象
这是最经典的字符串八股。直接说结论:如果常量池里没有“hello”,那么这一步会创建两个对象——new 出来的堆对象,以及常量池里的字面量对象;如果常量池已经有“hello”,那只创建一个堆对象。面试官往往还会接着问 intern() 是干什么的。JDK 7 之前 intern 会把字符串复制到永久代;JDK 7 之后字符串常量池移到了堆里,intern 如果发现字符串不在常量池里,有机会直接加入引用而不是复制。
这里有一个容易被忽略的细节:代码里写new String("hello")时,字面量“hello”其实早在类加载阶段就已经被放入常量池了,并不是在 new 那一刻才创建。所以更严谨的说法是:如果这个类的字面量之前从未出现过,类加载时会先创建常量池对象,然后 new 再创建一个对象。我见过不少人在这个点上给出模棱两可的答案,建议面试前自己写几行代码验证一下引用关系,把“背结论”变成“理解过程”。
2. 集合框架:HashMap 与 ArrayList 的底层逻辑
2.1 HashMap 为什么是“数组+链表+红黑树”
一句话回答:数组实现 O(1) 定位桶,链表解决哈希冲突,红黑树防止冲突严重时查询退化成 O(n)。但这不够,面试官会追问为什么是红黑树而不是 AVL 平衡二叉树。红黑树是一种近似平衡的二叉查找树,插入、删除、查找都是 O(log n),比起严格平衡的 AVL 树,调整次数更少,工程实现更省性能,所以 JDK 选择了红黑树。
链表什么时候转树?链表长度达到 8,且桶数组长度达到 64,否则会先扩容而不是树化。树化阈值 8 不是随便拍的,源码注释里给出了泊松分布推导:在负载因子 0.75 且哈希分布均匀的情况下,链表长度达到 8 的概率已经接近千万分之六,普通业务场景几乎不会出现。出现超过 8 的情况,基本可以认定是有人恶意构造了同哈希值的 key,或者哈希函数质量太差,这时候用红黑树保证最坏情况下系统不崩。被问到“为什么树化阈值是 8”,能答出这层原因,比背结论要加分得多。
2.2 负载因子 0.75 和 2 倍扩容背后的取舍
HashMap 默认负载因子是 0.75。所谓负载因子,就是“桶里已经装了多少比例时触发扩容”。0.75 是工程上时间和空间的折中:太低比如 0.5,能减少哈希冲突,但内存浪费严重;太高比如 1,省内存但冲突增加、查找变慢。扩容为什么是 2 倍?因为 HashMap 拿到 key 的 hash 后,用(n - 1) & hash计算桶下标,n 保持 2 的幂才能让高位也参与计算,并且扩容后元素要么留在原位置,要么跑到“原位置 + oldCap”。
JDK 1.8 里的优化就是利用这个特性,按节点 hash 与 oldCap 的与运算结果,把链表拆成 lo 和 hi 两条,省去重新计算 hashCode 的开销。这个点经常被拿来区分“背过底层”和“真正懂底层”。顺带提醒:HashMap 在多线程下扩容,旧的头插法会造成死循环,1.8 改为尾插之后避免了这个死循环,但并发 put 仍然会有脏数据,多线程场景请一律使用 ConcurrentHashMap。
2.3 ArrayList 扩容、fail-fast 与 Arrays.asList 陷阱
ArrayList 默认容量是 10,扩容时每次增长为原来的 1.5 倍。为什么是 1.5 而不是 2?这是 Java 官方在“均摊复杂度”和“空间利用”之间平衡出来的策略:每次扩容都要数组拷贝,扩太少会导致频繁拷贝,扩太大又浪费内存。面试常考的手写模拟扩容,核心就是oldCapacity + (oldCapacity >> 1)。
说到 fail-fast,它并不是集合为了并发安全做的强一致保证,而是通过 modCount 字段快速暴露并发修改问题。迭代器在 next 的时候会检查 modCount 是否变化,变了就抛 ConcurrentModificationException。典型踩坑:迭代集合时直接用 list.remove() 删除元素,应该使用 Iterator.remove()。
另一个高频坑是Arrays.asList()返回的集合,底层是一个定长数组,不能调用 add 和 remove,一调就是 UnsupportedOperationException。它虽然是 Arrays 内部类,看着像 ArrayList,本质上还是数组包装。如果有人把这个集合传给后续需要增删的逻辑,代码直接崩。看清这一点,排查问题时能省很多时间。
3. 手撕排序:快速排序与冒泡排序的高频追问
3.1 冒泡排序:从入门到优化
冒泡排序在面试中更多是热身题,但写得好不好,能看出基础扎不扎实。最无脑的写法是双重循环,相邻元素比较交换,每轮把当前最大值冒到最后。一个常见优化是:如果某一轮没有任何交换,说明数组已经有序,可以提前终止。
public void bubbleSort(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); swapped = true; } } if (!swapped) break; } }面试官经常会追加一句:最优时间复杂度是多少?答案是 O(n),对应已经有序的数组,因为第一轮跑完 swapped 始终为 false,循环提前终止;平均和最坏是 O(n^2)。虽然冒泡排序实际项目很少用,但它是理解“稳定排序”的入门例子:相邻元素比较交换,相等元素不会发生相对位置变化,所以是稳定排序。如果面试要求一个稳定又简单的手写方案,冒泡永远是最稳妥的兜底。
3.2 快速排序:partition 的正确写法
快速排序是“手撕算法”环节的必考题。思路一句话:选定基准,把数组分成小于等于基准和大于基准的两部分,再递归。最容易写对的是这种 Lomuto 分区:
int partition(int[] arr, int low, int high) { int pivot = arr[high]; int i = low; for (int j = low; j < high; j++) { if (arr[j] < pivot) { swap(arr, i, j); i++; } } swap(arr, i, high); return i; }Lomuto 分区优点是逻辑简单,不容易写错,缺点是在处理大量重复元素的数组时效率相对差一点。如果面试官要求优化,可以说随机选基准、三数取中,避免最坏 O(n^2);或者用双指针的 Hoare 分区,减少不必要的交换次数。被追问快排为什么平均 O(n log n) 时,可以这样解释:每次 partition 都能把数组分成接近一半的两部分,递归深度是 log n,每一层都要扫描一遍 n 个元素,所以总耗时 O(n log n);最坏情况每次只把基准放到最终位置,递归深度变成 n,性能就退化成 O(n^2)。
3.3 排序现场常见的三个追加问题
手撕完排序,面试官总爱追加几个问题,提前准备能省不少时间。第一个,快排是不是稳定排序?不是。partition 过程会发生远距离交换,相同元素的相对顺序可能被打乱,所以快排不稳定。想要稳定的 O(n log n) 排序,一般回答归并排序。第二个,Arrays.sort() 底层用了什么?基本类型用的是 DualPivotQuicksort,也就是双轴快排;对象类型用的是 TimSort,这是归并和插入排序的混合版本,因为对象排序要求稳定。第三个,实际项目里怎么选排序?数据量小且基本有序,插入排序、冒泡都很合适;数据量大用快排或归并;对稳定性有要求就选归并。把这三个问题答好,手撕环节基本不会冷场。
4. 动态代理与 Lambda:从“会用”到“能讲清”
4.1 JDK 动态代理 vs CGLIB:面试官为什么总问接口
动态代理是 Spring AOP、MyBatis Mapper、RPC 框架的基石,所以八股必问。JDK 动态代理只能代理接口,因为它在运行时生成一个实现了指定接口的代理类,所有方法调用统一交给 InvocationHandler 的 invoke 处理;CGLIB 走的是生成被代理类子类的路线,通过继承和重写方法实现代理,所以不要求接口,但 final 类、final 方法、private 方法都没法代理。Spring 的默认策略,早期版本是“有接口用 JDK 代理,无接口才用 CGLIB”,但 Spring Boot 2.x 之后默认已经改为强制 CGLIB,也就是 spring.aop.proxy-target-class=true。这个点经常有同学答成旧版本,建议看一下自己项目的配置。
还有一个容易忽略的坑:代理对象在内部调用同类里的另一个方法时,第二个方法不会走代理,因为内部调用是 this.method(),不是通过代理对象调用。要让 @Transactional、@Async 这类注解生效,只能注入代理对象自身再调用,或者把方法拆到另一个 Bean 里。这个细节在实际调试 AOP 失效时特别有用,我在项目里排查过一次事务不生效的问题,最后就是自调用导致的。
4.2 Lambda 不是匿名内部类那么简单
很多人以为 Lambda 就是匿名内部类的语法糖,这个理解在大多数情况下没毛病,但在面试中被追问 JVM 层面时就站不住了。Lambda 使用的是 invokedynamic 指令,编译时不会为每个 Lambda 表达式生成一个独立的 class 文件,而是在运行时通过 LambdaMetafactory 生成函数式接口的实现;匿名内部类编译后会生成 xxx$1.class,每次 new 都会加载类。所以 Lambda 更轻量,第一次调用时有一次引导方法开销,后续调用基本就是普通方法调用。
另外,Lambda 能捕获的局部变量必须满足 effectively final,也就是变量在初始化后没被修改过。为什么?因为被捕获的变量会变成入参传入,如果允许修改,容易出现“改的不是原变量”的语义混乱。下面这段代码是编译不过的:
int count = 0; list.forEach(x -> count++); // 编译报错要计数,可以用 AtomicInteger 或先收集再统计。但这里有一个很多人理解错的地方:AtomicInteger 只是让这段代码能编译通过、保证单个自增的原子性,它并不能保证整个“读取-累加-使用”流程的线程安全。如果是多线程并发累加,还要考虑 LongAdder 或加锁。这个点面试官问到并发时经常顺带挖坑。
4.3 方法引用与 Stream 的几个顺手坑
方法引用本质是 Lambda 更简短的写法,面试常问 Class::method 和 obj::method 的区别:前者可以直接引用静态方法,也可以当成“给对象调实例方法”的入口,比如 String::length 等价于 s -> s.length()。方法引用的好处是让意图更直接,被问到底层时,它仍然会创建一个函数式接口实例,和 Lambda 属于同一个体系。Stream 这块我踩过的坑,第一个是惰性求值:中间操作如 filter、map 不会立即执行,只有终止操作如 collect、forEach 出现后才会真正开始跑。很多人写了一大串 filter、map,以为数据已经过滤完了,结果半天没反应,就是漏了终止操作。
第二个坑是并行流 parallelStream 默认用公共线程池 ForkJoinPool.commonPool,开发机上跑可能没事,真实项目中多个并行流同时执行,或者与框架共用线程池,很容易出现线程饥饿。生产环境建议自己传入线程池,CompletableFuture 同理。另外,并行流内部如果操作的是共享的 HashMap 或 ArrayList,还会引发数据竞争和并发修改异常,这也是为什么我建议对并行流保持谨慎。面试时能主动提这一句,比干背 API 更能体现项目经验。
5. 线程协作:等所有线程都完成再继续的四种写法
5.1 join():最朴素但容易忘的两件事
多个线程都执行完了,主线程再继续,最原始的实现是 Thread.join()。join 的语义就是当前线程等待目标线程终止。我遇到过不少人面试时只记得函数名,忘了它底层是 wait/notify 机制:join() 源码里会while (isAlive()) wait(0),目标线程死亡后由 JVM 的 notifyAll 唤醒。这里要注意,调用 wait 意味着当前线程会释放目标线程对象上的监视器锁,并不是无条件阻塞。用 join 做聚合并拿子线程计算结果,需要自己设计共享容器交接数据,代码容易绕,所以现在工程上很少直接用 join 聚合多个任务。但在面试里它是一道很好的引导题,能从它自然引出更高级的协作工具。
5.2 CountDownLatch 与 CyclicBarrier:一张表讲清区别
CountDownLatch 和 CyclicBarrier 是线程协作题的常客。我直接把区别拉个表:
| 对比点 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 核心语义 | 一个或多个线程等待其他线程完成各自工作 | 一组线程互相等待,全部到达后才一起继续 |
| 计数方向 | 倒计数,countDown() 每调用一次减 1 | 正计数,等待线程数量达到 parties |
| 是否可以复用 | 不能,计数归零后失效 | 可以,重置后继续下一轮 |
| 使用场景 | 主线程等所有子任务完成后汇总 | 多线程并行处理并把结果合并,一轮轮栅栏同步 |
CountDownLatch 的真实使用场景很常见:一个线程池派发 10 个任务,每个任务在 finally 块里执行 countDown(),主线程用 latch.await() 等所有任务结束。这里有个强烈建议:countDown() 一定要放 finally,否则只要有一个任务抛异常,主线程就永远等下去,轻则流程卡死,重则把连接池占满。CyclicBarrier 的场景更像跑步比赛:所有选手到齐才能开枪,而且还可以在 barrier 上设置一个到达后的聚合动作。另外,Semaphore 控制同时访问的线程数,本质是限流器,可以结合后面 Redis 限流的思路一起理解。
5.3 CompletableFuture.allOf:写起来最爽,坑也最隐蔽
现代 Java 项目里,多线程聚合我推荐 CompletableFuture。想等多个任务全部完成再继续,用 allOf:
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> callServiceA()); CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> callServiceB()); CompletableFuture.allOf(f1, f2).join();allOf 返回 CompletableFuture ,只表示所有任务完成,不关心返回值。要拿结果可以再接 thenApply,或者等 join 后单独 get f1、f2。我真实踩过的坑有三个。第一个坑,allOf 只是把任务组织起来,不会自动等待,如果不调用 join 或 get,主流程可能很快就跑完了,任务结果根本没拿到。第二个坑,默认线程池是 ForkJoinPool.commonPool,线程数默认是 CPU 核数减 1,如果任务是阻塞型 I/O,很容易把所有线程占满,互相等死。正确做法是统一传入自定义线程池:
ExecutorService pool = new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy()); CompletableFuture.supplyAsync(() -> doSomething(), pool);第三个坑是异常处理。allOf().join() 里如果任何一个任务抛异常,join 直接抛 CompletionException,没 catch 的话整个调用方跟着崩。通常我会在每个 future 后面加 exceptionally 或 whenComplete 做兜底,保证单个任务失败不影响整体流程。能把这三个坑讲清楚,说明你确实在项目里写过并发聚合,而不是只背了 API。
6. Redis 的 increment() 与库存减一:原子操作实战
6.1 RedisTemplate.increment() 报错排查实录
“使用 RedisTemplate 的 increment() 报错不是 integer or out of range”是一个很真实的场景。报错底层一般长这样:ERR value is not an integer or out of range。原因几乎都是:key 对应的 value 已经不是合法整数,或者超出了 64 位有符号整数范围。Redis 的 INCR 命令只能对整数字符串做自增,不能对 abc、1.5 使用;注意 INCR 也不支持浮点,浮点要使用 INCRBYFLOAT。
实际开发里我用 RedisTemplate 时习惯统一用 StringRedisTemplate,或者给 RedisTemplate 设置 StringRedisSerializer。原因很简单:默认的 JdkSerializationRedisSerializer 会把 key 存成带序列化表头的二进制,排查问题时看到的一堆 \xac\xed\x00 开头,特别费劲。increment 的正确写法:
Long val = stringRedisTemplate.opsForValue().increment("stock:sku:10001", 1);increment 第二个参数可以是负数,返回的是自增后的新值。这里还有一个容易忽略的细节:increment 是原子操作,Redis 单线程执行一条命令,不会存在竞争;但如果你先 get 再 set,就不是原子的,并发下一定会出问题。这也是为什么扣库存推荐用 increment(-1)。
6.2 扣库存为什么要用原子自减
再讲一个最经典的超卖问题。假如库存还有 1 件,两个请求同时 get 到库存都是 1,然后都执行 set 成 0,最后卖出 2 件。解决思路很简单:把“判断库存 > 0”和“扣减库存”合并成一条原子命令。increment(key, -1) 就能一步完成扣减,它返回扣减后的剩余值,我们再判断剩余值是否小于 0,如果小于 0,说明扣超了,要反向补回来。
但这里有一个细节:判断剩余 < 0 并回补是两个命令,中间依然存在并发窗口。严谨的做法是用 Lua 脚本把“判断库存 > 0 再扣减”包成一个原子操作。下面这段脚本可以放进 DefaultRedisScript 里执行:
local stock = tonumber(redis.call('get', KEYS[1])); if stock == nil or stock <= 0 then return -1; end return redis.call('decr', KEYS[1]);这个脚本逻辑是:库存为空或已扣完直接返回 -1,否则执行 decr。因为整个脚本在 Redis 中是原子执行的,不会插入其他客户端命令,所以并发扣减不会超卖。Lua 脚本虽然能解决这条命令的原子性,但要注意 KEYS 和 ARGV 的传参规范,不要把 key 写死在脚本字符串里,否则集群模式下会报 key 不在同一个 slot 的错误。
6.3 从 increment 展开的三个面试追问
第一个追问,increment 能用来做分布式 ID 吗?可以,但要注意 ID 会带上步长间隙和容量上限,如果量极大,要结合时间戳或分段方案;另外 Redis 重启后持久化策略也影响 ID 的连续性。第二个追问,基于 INCR 怎么实现接口限流?常见方案是固定窗口:每次请求对同一个 key 做 INCR,第一次请求时给 key 设置过期时间,随后判断返回值是否超过阈值。这就能当一个最简单的固定窗口限流器。第三个追问,为什么 Redis 的命令是原子性的?因为 Redis 是单线程处理命令,每个命令在事件循环里被完整执行,执行期间不会有其他命令插入。这个“单线程 + 事件循环”模型,也是后续理解分布式锁、Lua、缓存一致性等话题的基础。把这些顺下来,Redis 八股至少能多撑十分钟。
7. JDK 环境与运行时异常:差点被一个报错卡死
7.1 安装与环境变量配置:JAVA_HOME、PATH、CLASSPATH
很多新手在安装 JDK 后卡在环境变量上。Windows 下常见报错“java 不是内部或外部命令”,十有八九是 PATH 没配好。我推荐按下面三步检查。第一步,确认安装的是 JDK 而不是 JRE,项目开发一般需要 JDK。第二步,新建系统变量 JAVA_HOME,值指向 JDK 安装根目录,例如 C:\Program Files\Java\jdk-17。第三步,在 PATH 中新增 %JAVA_HOME%\bin,注意不要写死绝对路径,这样以后切换 JDK 版本只需要改 JAVA_HOME 一个变量。配置完成后,一定要新开一个终端窗口,因为环境变量在配置前打开的终端里不会自动生效,然后再运行 java -version 验证。
那 CLASSPATH 呢?很多人被旧教程误导去配一个全局 CLASSPATH,其实从 JDK 1.5 开始基本不需要了,javac 和 java 会按 classpath 参数或当前目录查找类,乱配全局 CLASSPATH 反而会导致各种 ClassNotFound。多个 JDK 版本并存时,可以同时装 JDK8 和 JDK17,靠切换 JAVA_HOME 来切换版本,但 PATH 别把多个 JDK 的 bin 都放进去,否则到底用哪个 JDK 由 PATH 顺序决定,非常混乱。
7.2 NoClassDefFoundError 与 ClassNotFoundException:别傻傻分不清
这两个名字很像,但一个是 Error,一个是 Exception,面试常拿来做区分题。ClassNotFoundException 是类加载器在加载类时找不到对应的 class 文件,常出现在 Class.forName、Spring 容器实例化 Bean、反射等场景,属于受检异常,需要 catch。NoClassDefFoundError 则发生在类之前加载过、现在加载器在内存里找不到定义,或依赖的类初始化失败,通常是因为运行环境的依赖缺失、jar 包版本冲突、类的静态初始化抛错。
举个例子:一个项目用 JDK8 编译,运行环境只有 JRE,第三方 jar 没打进去,运行时就可能先报 ClassNotFoundException,再演变成 NoClassDefFoundError。排查思路不要死抠异常名,而是看完整堆栈:是加载不到外部类,还是某个类的初始化失败。前者检查 classpath 和依赖 jar,后者检查静态块和日志中隐藏的 ExceptionInInitializerError。能把这个区分讲清楚,说明你是真遇到过线上环境问题,不是只看了异常名。
7.3 高版本 JDK 移除 API 引起的启动异常:Applet、Logisim 这类旧软件的坑
最近很多人遇到NoClassDefFoundError: java/applet/Applet,以及 Logisim 启动时提示需要 Java 1.5.0。这不是你环境坏了,而是高版本 JDK 移除了 Applet API 或其他旧模块。Java 9 引入模块化之后,很多以前 JDK 自带的 API,比如 java.applet、CORBA、JAXB,被逐步移除或标记废弃,JDK 11 之后尤其明显。旧软件如果是在 Java 5/8 时代编译的,硬跑在 JDK 17 上,出现 NoClassDefFoundError 再正常不过。
解决办法也很朴素:给这类旧软件单独装一个匹配的 JDK/JRE,最好装在默认目录之外,然后用启动脚本里的 JAVA_HOME 或 java 绝对路径显式指定。比如:
export JAVA_HOME=/path/to/jdk1.8.0_202 export PATH=$JAVA_HOME/bin:$PATH java -jar logisim.jar不要为了装新版去强行改系统全局 JAVA_HOME,容易影响其他项目的版本要求。另外,如果是 Maven 项目报java.lang.NullPointerException出现在 annotation processor 或 mapping processor 里,通常是 Lombok、MapStruct 这类注解处理器和 JDK 版本不匹配,优先统一 JDK 版本、升级依赖、检查 annotationProcessorPaths 配置。这类问题本质都是编译期工具版本错配,和业务代码关系不大。我自己的习惯是,遇到这种环境类报错,先打开启动日志看实际使用的 JDK 路径,再决定改环境还是加依赖,很多时候问题已经写在日志里了。