一直做 Java 后端的人隔一段时间回头看基础多少会有点“熟悉的陌生人”的感觉。我上个月把项目里几个老模块重构了一遍顺手把 Java 基础重新过了一轮踩了不少坑也把之前面试时答得磕磕巴巴的概念重新串了一遍。这篇是复习 Java 系列的第二篇专门记录那些“看着简单、实际容易翻车”的点从环境变量配置、运算符、数组越界到反射、动态代理、锁面试题再到 RedisTemplate 的 increment 报错、NoClassDefFoundError 这类实战炸弹。如果你也准备面试、或者打算系统回炉一遍 Java这篇可以当你的抽查清单用。复习这种事最忌讳的就是“眼睛会了手不会”。所以这篇我不会光列知识点每个章节都会给出一个可操作的自检方法要么是运行一段代码看输出要么是直接动手改一个报错。这样看完一遍你至少能知道自己哪些地方是真的懂了哪些只是有个印象。1. 复习前的准备别再对着八股文硬背1.1 带着问题去复习而不是背答案很多人复习 Java 就是打开一篇“Java 面试题大全”从头背到尾。说实话我试过效果很差。八股文里的答案往往只告诉你结论不告诉你为什么是这个结论。比如“HashMap 的初始容量是 16加载因子是 0.75”你背下来了但面试官紧接着问“为什么是 0.75如果让你设计你会选多少”你就卡住了。正确的方式是带着问题去复习。你看到一个知识点先问自己三个问题这个出现在什么场景为什么需要它如果不用会怎样拿 HashMap 举例它解决的是“数组查找快但插入删慢、链表插入删快但查找慢”的矛盾用哈希函数把 key 散列到数组槽位再用链表/红黑树处理碰撞。0.75 是时间成本和空间成本的折中——太高了碰撞多、树化频繁太低了浪费内存。这么一追问这个知识点就变成你自己的了。复习的时候还要主动关联项目经验。比如你项目里遇到过 Redis 里存了一个数字用 increment() 自增时报“not an integer or out of range”这就是绝佳的复习素材。把报错当入口反向去查 Redis 的数据类型、序列化方式、Spring Data Redis 的默认配置一套组合拳下来比背十道面试题都管用。我后文会详细拆这个案例。1.2 用学习路线图圈定“该复习什么”Java 的知识体系太庞大了初学者最容易被“学习路线”吓到老手复习则容易东一榔头西一棒子。我的做法是先画一条主线按“基础语法 → 面向对象 → 集合框架 → 并发与锁 → JVM 与内存 → 框架原理 → 项目实战”的顺序过但每一段都不求深只看自己和当前目标岗位的差距。这条主线里有几个部分是面试的“高频雷区”也是平时最容易用错的地方运算符和表达式、标识符命名规则、数组越界、集合选型、反射、动态代理、lambda 函数、排序算法、RedisTemplate 的使用、环境变量配置以及各种编译期和运行期报错。这些关键词你在热搜里搜 Java基本都能看到说明大家都在这些地方栽过跟头。这篇复习笔记就是按这条主线来组织的。复习节奏上我建议“一次一个点每个点都能动手验证”。比如今天只看反射那就写一段代码用反射去读取一个类的私有字段、调用一个私有方法再对比直接用 new 调用的区别。跑通了再换下一个点。这样的复习密度不高但留存率很高比一天刷十个专题强。2. 基础语法高频翻车点运算符、命名与字符串2.1 标识符命名规则最容易混的三个边界标识符命名规则是 Java 最基础的知识但特别容易在选择题和代码规范里翻车。规则其实就几条可以由字母、数字、下划线_、美元符号$组成不能以数字开头不能是 Java 关键字理论上没有长度限制也支持 Unicode 字符也就是说中文变量名在语法层面是合法的但强烈不建议在生产代码里这么写。这里有三个边界容易混。第一个是“关键字”和“保留字”的区别true、false、null不是关键字而是字面量但它们同样不能作为标识符使用。第二个是$符号它可以用在标识符里比如$name、my$name都是合法的但按阿里巴巴编码规范$一般只出现在 IDE 自动生成的内部类或某些框架生成的代码里手写业务代码不要用。第三个是“大小写敏感”Java 里Name和name是两个完全不同的标识符这在面试里经常被拿来挖坑。我在实际项目里还见过一个很有意思的坑lombok 生成 getter/setter 时如果字段是大写字母开头生成的 setter/getter 命名就会有歧义后面连 JSON 序列化都会受影响。这个我在第 4 节单独展开它可以算作“标识符命名规则”在工程里的一个经典后遗症。2.2 运算符的隐藏细节短路、位运算与自增运算符这块最容易丢分的是三类短路运算符、自增自减、位运算。短路与和短路或||的特点是左侧表达式已经能决定整个结果时右侧就不会执行。举个例子int i 0; if (i ! 0 (10 / i) 1) { // 不会进入到这里 } System.out.println(i); // 0因为i ! 0为 false整个表达式结果已经确定是 false10 / i根本不会被计算所以不会抛出 ArithmeticException。这是短路设计的意义也是常考题。同理||左侧为 true 时右侧不执行。非短路版本和|则会两边都算一旦右侧出现除零异常就会崩实际业务代码里我几乎不用非短路的逻辑与或除非是在位运算场景。自增自减的坑集中在i和i的区别上。前者是“先取值再加一”后者是“先加一再取值”。更隐蔽的是它们在表达式里的行为int a 5; int b a a; // 先算 a结果是5a 变为6再算 a结果是7a 变为7 // b 5 7 12这类题目笔试很喜欢出但实际工程里写这么复杂的表达式就是找骂。我自己的原则是自增自减单独成行绝不混在复杂表达式中。这既是给同事减负也是给自己排雷。位运算里值得复习的是左移、右移和无符号右移以及它们跟乘除 2 的关系。左移一位相当于乘以 2右移一位相当于整除 2但要注意负数的右移会自动补符号位-1 1结果还是-1。则不管符号位只补 0这在处理哈希散列和二进制协议时很常用。复习的时候建议随手写个循环把1 i的二进制打出来比死记结论直观得多。2.3 Java 15 的文本块多行字符串终于不用转义了老 Java 程序员写多行字符串最痛苦的莫过于拼接 SQL 或者 JSON 时满屏的\n和转义引号。Java 15 正式引入了文本块用三个双引号包起来就行String sql SELECT id, name, age FROM user WHERE age ? AND status 1 ;文本块有几个细节值得注意。第一它自动忽略开头的换行和缩进缩进量取决于最左侧非空行的位置这叫做“附带缩进”处理。第二里面不需要转义双引号写 JSON 模板特别舒服String json { name: zhangsan, age: 18, tags: [java, backend] } ;第三文本块内部的\还是有转义语义比如要写\n就还是写\n但配合\s空格和行尾的反斜杠可以控制换行行为。这些细节在复习时很容易略过去但实际编译时踩一次坑就会记住。顺便说一句如果你的项目还在用 Java 8文本块是编译不过的这也是很多人搜“java 字符串多行写法”的原因——老版本只能靠String.format或 Guava 的拼接工具凑合。3. 数组、集合与排序从使用到原理3.1 数组越界异常怎么从报错堆栈快速定位数组越界会抛ArrayIndexOutOfBoundsException这个异常本身没什么可说的难点在于从报错堆栈里快速找到“哪里越界、为什么越界”。有一次我同事排查一个线上问题日志里只看到一堆索引数字他愣是没看出问题后来才发现是一个循环边界写错了int[] arr {1, 2, 3}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); // 当 i3 时越界 }注意这里的数组长度为 3有效下标是 0、1、2但循环访问到了arr[3]。这种错误的本质是“开区间”和“闭区间”没有分清。Java 数组下标是左闭右开的用习惯i arr.length就不会踩坑。还有一种越界更隐蔽出现在集合转数组或子串截取时。比如hello.substring(0, 5)是安全的因为substring(0, 5)取的是下标 0 到 4 的字符正好是整个字符串但如果你写substring(1, 6)就会抛StringIndexOutOfBoundsException。排查这类问题我的经验是先把日志里的具体下标打出来与数组/字符串的长度做一次减法看差值是否超过 1往往一眼就能定位。越界本质上是“索引值”和“长度”的错配所有排查手段都是为了拿到这两个数字。3.2 集合框架复习容器选型是关键集合框架是 Java 面试的重头戏但复习时不要陷入“背数据结构定义”的误区。我更建议大家按“选型”的角度去复习给你一个业务场景你选哪种容器。高频选型场景我列了一个自查表你可以对着看业务场景首选容器原因按索引随机访问不关心顺序稳定性ArrayList底层数组访问 O(1)频繁头部/中部插入删除LinkedList双向链表插入删除 O(1)但需先找到位置去重且不要求顺序HashSet基于 HashMap去重 O(1)保持插入顺序的去重集合LinkedHashSet在 HashSet 基础上维护双向链表按 key 快速取 valueHashMap哈希表实现平均 O(1)需要排序的键值对TreeMap红黑树实现按键自然顺序/比较器排序线程安全、且读多写少的计数场景ConcurrentHashMap分段/桶级并发控制比 HashTable 并发度高这里复习的另一个重点是 HashMap 的底层细节数组加链表加红黑树、加载因子 0.75、扩容时容量翻倍、hash 扰动函数高 16 位异或低 16 位等。面试问“为什么 HashMap 线程不安全”不要只回答“多线程 put 可能丢数据”更精确的答案是多线程同时扩容时节点链表可能形成环在 JDK 8 之前确实会导致 get 死循环JDK 8 改为尾插法后解决了环的问题但仍然存在数据覆盖、size 计数不准确等问题。所以并发场景老老实实用ConcurrentHashMap。日常开发里我还建议大家复习一下Collection.toArray()和Arrays.asList()的坑。Arrays.asList()返回的是固定大小的内部 ArrayList不能 add 和 remove很多人在这上面踩过UnsupportedOperationException。还有toArray()里传入数组类型的写法ListString list new ArrayList(); String[] arr list.toArray(new String[0]);传new String[0]是主流推荐写法因为 JVM 会按需分配大小不一定真的用你传进去的数组但传 0 能避免无谓地分配一个大数组并且语义清晰。这个细节虽然小但我在代码 review 时经常遇到顺便提一句。3.3 冒泡排序和快速排序面试写码的基本功排序算法在真实业务里直接用Collections.sort()或list.sort()就完了但面试和计算机基础复习里仍然避不开。冒泡和快排是两个必须能手写的算法。冒泡思路简单每轮把相邻两个元素比较把较大的往后挪像气泡一样浮到末尾。标准实现长这样public static 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]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped true; } } if (!swapped) { break; // 这轮没有交换说明已经有序 } } }注意我加了一个swapped标志位这是最容易忽略的优化点如果某一轮循环里没有任何交换说明数组已经有序提前结束能省掉大量无意义的比较。冒泡排序时间复杂度最好 O(n)、平均和最坏 O(n²)稳定。快速排序则是“分治”思想选一个基准值把数组分成左边小于基准、右边大于基准再分别递归排序。经典实现public static void quickSort(int[] arr, int low, int high) { if (low high) { return; } int i low, j high; int pivot arr[(low high) 1]; while (i j) { while (arr[i] pivot) i; while (arr[j] pivot) j--; if (i j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; i; j--; } } quickSort(arr, low, j); quickSort(arr, i, high); }这个写法是“指针双向扫描法”基准值取中间位置左右两个指针往中间靠遇到该交换的交换。快排平均 O(n log n)最坏 O(n²)但通过“三点取中”或随机选基准可以尽量避免最坏情况。复习排序算法时我强烈建议不要只看代码拿几个小数组在纸上手动模拟一遍交换过程比盯着屏幕十分钟有效得多。4. Java Bean、JSON 序列化与 Redis 的经典坑4.1 大写字母开头的属性名JSON 序列化后为什么变小写了这个问题几乎每隔一段时间就会在群里被问一次。先说现象public class User { private String eCode; public String getECode() { return eCode; } public void setECode(String eCode) { this.eCode eCode; } }用 Jackson 序列化后字段名不是预期的eCode而是变成了ecode或其他形式。这是怎么回事根源在 JavaBeans 规范对属性名的推断规则。JavaBeans 规定属性名由 getter/setter 方法名推导getECode()去掉“get”后得到ECode然后应用“脱前缀”规则如果去掉前缀后的名字是ECode且前两个字符都是大写E和C则属性名保持不变还是ECode。但如果你的 getter 写成geteCode()去掉 get 后是eCode首字母已经是小写属性名就是eCode。问题在于很多 IDE 自动生成 getter 时对小写开头字段的处理规则并不统一代码生成器、框架也可能对eCode和ECode做不同的处理。最稳妥的解决方案不是去背 JavaBeans 规范而是显式指定 JSON 字段名public class User { JsonProperty(eCode) private String eCode; // ... }这样无论 getter 怎么生成序列化和反序列化都按eCode来。如果是 Fastjson可以用JSONField(name eCode)。这个问题的教训是字段命名尽量统一成小驼峰避免eCode、uID这种“首字母缩写”式的命名。早期 C 系代码习惯把缩写全大写但放到 Java 里配合 Bean 规范就会惹麻烦能避则避。4.2 RedisTemplate 的 increment() 报错排查另一个高频实战报错是使用 RedisTemplate 的increment()时Redis 返回ERR value is not an integer or out of range很多人看到这个报错第一反应是“我存的明明是数字”但 Redis 认为它不是整数。这里要对 Redis 的数据类型有清晰认识INCR命令只能作用于“字符串类型”且这个字符串必须能被解析为 64 位有符号整数。如果你用过set(key, abc)或者存了一个对象序列化后的二进制数据那执行increment(key)必然报这个错。Spring Data Redis 里还有一个更隐蔽的坑RedisTemplate 默认用的是JdkSerializationRedisSerializer。这个序列化器会把 Long 类型对象序列化成 JDK 特有的二进制格式而不是纯粹的数字字符串。当你调increment()时虽然 API 看起来像数字运算但实际发给 Redis 的 value 是二进制内容Redis 解析不了于是报not an integer or out of range。解决方向有两个要么给 RedisTemplate 显式配置 String 序列化器让 key 和 value 都以字符串方式存储要么直接改成使用StringRedisTemplate。后者内部默认就是 String 序列化做计数类的业务点赞数、库存、验证码次数最省心。Autowired private StringRedisTemplate stringRedisTemplate; // 自增 1 Long count stringRedisTemplate.opsForValue().increment(article:like:1001); // 自增 5 stringRedisTemplate.opsForValue().increment(article:like:1001, 5); // 自减 1传入负数 stringRedisTemplate.opsForValue().increment(article:like:1001, -1);4.3 使用 RedisTemplate 实现“减一”的正确姿势看到上面代码你就明白了“用 Redis 的 increment 把数减一”其实不需要专门找 decrement 方法increment(key, -1)就是减一。这是 Redis 命令设计的优雅之处一个INCRBY通过增减量控制即可实现加和减。opsForValue().decrement()在某些版本的 Spring Data Redis 里并不存在所以统一用increment带负数是最稳的。这个操作背后还有一层要注意increment的返回类型是Long。如果你项目里用的是RedisTemplateString, Object那返回值的强转要小心Long不能直接强转成Integer否则会抛ClassCastException。建议统一接收为long或Long再决定后续是转字符串还是转 int。很多人在这个细节上吃过亏我就在代码 review 里见过Integer.valueOf(...)硬转然后半夜收到报警的。此外计数类操作一定要考虑“初始值”的问题。一个 key 尚不存在时increment会把它当作 0然后执行加法所以第一次加一得到 1不需要提前 set 0。但如果你的业务语义是“记录用户当天第一次访问”那还得配合setIfAbsent或过期时间来做单纯 increment 只能解决计数解决不了“是否首次”的标记问题。5. 面向对象、反射与动态代理框架底层的三块基石5.1 面向对象复习封装、继承、多态到底在项目里怎么落地面向对象是 Java 的立身之本但复习它不能只背“封装继承多态”六个字要回到项目里看它怎么用。封装最简单的是私有字段加 getter/setter但更高级的应用是“行为封装”把一个变化点包在方法里调用方不关心实现细节。比如支付场景订单模块只依赖PaymentService.pay(orderId, amount)至于走微信还是支付宝由实现类决定这就是封装配合多态的效果。继承的复习重点是“组合优先于继承”这个原则。继承的优点是代码复用缺点是父子类耦合紧密父类一改子类就崩。经典反例是继承ArrayList去扩展一个“只读集合”结果父类的add方法直接破坏了子类的不变量。正确做法是用组合内部持有List对外暴露自己的只读方法。这个原则在 Spring 源码、Guava 里到处都是复习时找几个例子过一遍比背概念强。多态在 Java 里有三个必要条件继承、方法重写、父类引用指向子类对象。实际项目里多态最常见的落点是策略模式定义一个接口多种实现运行期选择。我写过这样一个逻辑public interface Notifier { void send(String userId, String message); } Component(smsNotifier) public class SmsNotifier implements Notifier { ... } Component(emailNotifier) public class EmailNotifier implements Notifier { ... }然后通过ApplicationContext.getBean(beanName, Notifier.class)按业务配置动态获取实现类。这样新增一种通知渠道只需新增实现类不需要改动原来的业务代码——这就是面向对象设计里“开闭原则”的实际体现。5.2 反射为什么框架离不开它反射可能是 Java 里“听过很多、写过很少、面试必问”的知识点。它的核心能力是在运行期拿到一个类的完整结构——字段、方法、构造器、注解——并且可以绕过 private 修饰符去调用它们。Spring 的依赖注入、MyBatis 的结果映射、Jackson 的序列化底层全是反射。复习反射要配套写一段代码否则很难理解它的威力。比如我现在要打印某个类所有带特定注解的字段public static ListString findAnnotatedFields(Class? clazz) { ListString fieldNames new ArrayList(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyTag.class)) { fieldNames.add(field.getName()); } } return fieldNames; }这里有个细节容易踩坑getDeclaredFields()只能拿到当前类声明的字段不包含继承来的getFields()只能拿到 public 字段且包含继承来的 public 字段。实际做 ORM 映射时通常要遍历整个继承链去收集字段。反射调用私有方法或私有字段前要调用setAccessible(true)这是为了绕过访问控制检查但从 Java 9 模块化开始如果目标类在一个未开放模块里即使setAccessible(true)也可能抛InaccessibleObjectException。这个我在后面环境报错部分会再提到因为java.applet.Applet的 NoClassDefFoundError 也跟模块化移除脱不开关系。反射的最大代价是性能。它涉及类型检查、安全检查等额外开销高频调用路径比如每秒上万次的序列化上反射会比直接调用慢一个量级。所以生产级框架里通常会做缓存把反射到的字段、方法缓存起来或者用 MethodHandle、生成字节码等方式优化。复习时记住这个结论即可反射用对了是框架基石滥用则是性能杀手。5.3 动态代理从代理模式到 Spring AOP动态代理是“代理模式”的进阶版区别在于静态代理需要为每个目标类手动编写一个代理类动态代理则在运行期自动生成代理对象。Java 里最常见的两种动态代理JDK 动态代理和 CGLIB。JDK 动态代理要求目标类必须实现接口它通过Proxy.newProxyInstance创建代理对象调用处理器实现InvocationHandlerpublic interface UserService { void save(String name); } public class UserServiceImpl implements UserService { Override public void save(String name) { System.out.println(保存用户: name); } } UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, (proxyObj, method, args) - { System.out.println(调用前输出日志); Object result method.invoke(new UserServiceImpl(), args); System.out.println(调用后输出日志); return result; } ); proxy.save(zhangsan);这段代码隐含着一个重要考点JVM 生成的代理对象为什么必须是接口类型因为 Java 是单继承代理类不可能继承多个目标类但可以实现多个接口所以 JDK 动态代理天然绑定“面向接口编程”。如果你的目标类没有接口Spring 会退而使用 CGLIB——通过生成目标类的子类来代理本质是继承所以被代理类的方法不能被 final 修饰。Spring AOP 的底层就是在“目标类是否有接口”之间做选择有接口用 JDK 动态代理没有接口用 CGLIB。这个知识点几乎是高级开发面试必问题复习时可以自己写一个小 demo分别在目标类有接口和没接口的情况下跑一遍观察生成的代理对象类型印象会深刻很多。6. 并发与锁面试题高频考点梳理6.1 常见的锁面试题到底在问什么“锁”是并发编程复习里绕不开的大头热搜里直接就有“java 锁面试题”这个词。常见面试题无非这几个synchronized 和 ReentrantLock 的区别、volatile 的作用、乐观锁和悲观锁的理解、CAS 的原理和 ABA 问题、什么是可重入锁、什么是公平锁、什么是锁升级。复习时不要每个术语都深挖源码先把“锁解决什么问题”想明白。锁解决的是多线程竞争共享资源时的数据一致性问题。最简单的例子两个线程同时对int count做count因为自增不是原子操作最终结果可能比预期小。解决办法就是加锁让同一时刻只有一个线程能执行“读-加一-写回”。但加锁也有代价线程阻塞和唤醒涉及上下文切换高并发下很重。所以锁才有这么多“变种”偏向锁、轻量级锁、重量级锁是 JDK 对 synchronized 做的优化升级路径ReentrantLock 提供更细粒度的控制和超时中断CAS 则是无锁方案靠 CPU 原子指令比较并交换。它们的终极目标都是“在保证数据一致性的前提下尽量减少阻塞”。6.2 从 synchronized 到 Lock一把锁的历史演进synchronized是老牌关键字由 JVM 提供用起来最简单自动释放锁异常也不会死锁。JDK 6 之后官方对它做了大量优化引入了锁升级机制无锁 → 偏向锁 → 轻量级锁CAS 自旋→ 重量级锁监视器锁。所以现在的 synchronized 性能已经不是早年那么“笨重”了。但 synchronized 的缺点是“太粗”不能响应中断、不能设置超时、非公平。如果你想实现“尝试获取锁拿不到就去做别的事”就得用ReentrantLockReentrantLock lock new ReentrantLock(); boolean acquired false; try { acquired lock.tryLock(2, TimeUnit.SECONDS); if (acquired) { // 拿到锁执行逻辑 } else { // 超时处理 } } finally { if (acquired) { lock.unlock(); } }注意 finally 里必须判断acquired因为如果 tryLock 失败你调 unlock 会抛IllegalMonitorStateException。这是 ReentrantLock 最容易被新手搞错的地方。它还支持公平锁new ReentrantLock(true)和多个 Condition 条件队列这些都是面试加分项。锁升级和锁策略的复习建议配合一个真实场景缓存击穿防护里我常用“先查缓存再查数据库然后重建缓存”的逻辑但并发下会同时有多个线程去查数据库于是加一个分布式锁只让一个线程重建缓存。这种场景里单机锁不够需要用 Redis 的setIfAbsent做分布式锁。复习时把“单机锁和分布式锁的选型”想清楚面试基本不会卡住。7. 环境变量、编译报错与 JVM 内存问题排查实录7.1 环境变量配置详细教程JAVA_HOME、PATH 与验证环境变量配置是 Java 入门第一道坎也是很多老手换了新电脑后还会踩坑的地方。以 Windows 为例先说要点先确认 JDK 装在哪个目录谁也别猜路径直接在文件管理器里把安装路径复制出来。然后配置三个变量JAVA_HOME指向 JDK 安装根目录比如C:\Program Files\Java\jdk-17.0.2注意不带\bin。PATH追加%JAVA_HOME%\bin注意 Windows 上多个路径用英文分号分隔追加而不是覆盖。CLASSPATHJDK 9 之后基本不需要手动配了如果某些老教程让你配.也不是不行但不配也不影响日常开发。配置完一定要重新打开一个命令行窗口旧窗口的环境变量不会刷新然后验证java -version javac -version这两个命令都能输出版本信息说明配置成功。如果java -version正常但javac报“不是内部或外部命令”大概率是 PATH 里只有C:\Program Files\Java\jre...\bin而没有 JDK 的 bin。还有一种情况是电脑里装了多个 JDKPath 里靠前的版本被优先执行导致java和javac版本不一致。我之前就遇到过java -version显示 8、javac -version显示 17 的诡异情况最后发现是 IDE 内置的 JRE 和命令行 JDK 混用了。7.2 NoClassDefFoundError: java/applet/applet 是怎么来的热搜里有一条很典型的报错uncaught exception java.lang.noclassdeffounderror: java/applet/applet光看类名就知道这是代码里用了java.applet.Applet这个类。Applet 是早期 Java 在浏览器里跑小程序的 APIJDK 9 模块化之后java.applet模块被标记为废弃JDK 11 直接移除了。如果你的项目是老项目迁移到新 JDK或者引用的某个第三方 jar 是在老 JDK 下编译、内部依赖了 Applet运行时就可能抛出NoClassDefFoundError。这里先区分两个长得像的异常ClassNotFoundException是“找不到类”通常是运行时通过 Class.forName 动态加载某个不存在的类NoClassDefFoundError是“类曾经存在但现在加载失败”比如依赖的 jar 缺失、静态初始化抛异常、类的元数据引用了已被移除的 API。遇到NoClassDefFoundError排查方向不是“去下载这个类”而是找“谁引用了它、为什么加载失败”。# 查看依赖树定位哪个 jar 引用了 applet mvn dependency:tree -Dincludes*:*如果确认是某个第三方库的代码引用了 Applet 相关类优先升级这个库的版本如果是老代码自身用了 Applet API建议把相关功能重构掉换成 Swing/JavaFX 或纯服务端实现。这条报错在新手环境里出现得不多但在老系统重构、JDK 升级时很容易遇到值得收藏。7.3 Lombok 编译警告compiler supported by lombok另一个常见编译问题IDE 里会看到类似提示java: you arent using a compiler supported by lombok, so lombok will not work这句话翻译过来就是“当前 JDK 版本太新你用的 Lombok 版本不认识这个编译器所以我罢工了。”Lombok 是靠注解处理器在编译期修改 AST抽象语法树来实现的JDK 内部编译接口一旦变动老版本 Lombok 就会失效。比如 JDK 21 刚出来时只有 Lombok 1.18.30 才支持JDK 17 至少需要 Lombok 1.18.22如果你还在用 JDK 8那么大部分 Lombok 版本都能用但也不建议用太老的版本因为会有其他 bug。解决办法简单粗暴升级 Lombok 版本或者把 JDK 降到 Lombok 支持的版本。我更推荐升级 Lombok因为老 JDK 带来的兼容性问题远不只 Lombok 一个。如果你用的是 Mavendependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency升级后还要在 IDE 里确认注解处理器已启用。IDEA 的 Settings → Build → 编译器 → Annotation Processors勾上“Enable annotation processing”否则 Lombok 即使版本匹配也可能不生效报找不到 getter/setter 方法。这个“版本匹配”问题其实在 Gradle、其他注解处理器如 MapStruct里也会遇到复习的时候可以顺便把注解处理器的原理捋一遍。7.4 OutOfMemoryError: insufficient memory 怎么排查最后说一个运行期杀手OutOfMemoryError: insufficient memory。它和常见的Java heap space不太一样insufficient memory往往意味着 JVM 向操作系统申请内存时失败了可能不只是堆不够而是整个进程可用内存不足。排查分三步走。第一步确认是哪个区域的内存问题看完整堆栈和前缀Java heap space是堆内存不足Metaspace是元空间不足unable to create new native thread是系统线程数或进程内存耗尽。第二步看 JVM 启动参数确认-Xmx、-Xms、-XX:MaxMetaspaceSize是否合理。比如一个 4G 内存的服务器跑两个 JVM 每个-Xmx2g再加上中间件和系统占用很容易触发系统级内存不足。第三步查代码里有没有大对象、集合无限增长、缓存没有淘汰策略等内存泄漏隐患。顺手贴一个常用启动参数模板java -Xms512m -Xmx1024m \ -XX:MaxMetaspaceSize256m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump \ -jar my-service.jar-XX:HeapDumpOnOutOfMemoryError很关键内存溢出时自动导出堆转储文件后面就可以用 MAT 或 VisualVM 分析是哪个对象占满了堆。我经历过一个案例某个接口往一个静态 List 里不断塞数据没有任何清理逻辑上线两周后内存稳步上涨直到 OOM。如果没有堆转储文件这种问题只能靠猜有了 dump 文件打开 MAT 的 Leak Suspects 一眼就能看到“罪魁祸首”。复习这件事我最后想分享一个自己的习惯每过一两个月就挑一个生产环境的报错当作“复习题目”不管最后花十分钟还是两小时都把这个报错涉及的知识链路完整过一遍。上面写的这些点大部分就是这么攒出来的。你与其去背一百个面试题不如认真把某一次真实的NoClassDefFoundError或者increment() 报错从头查到尾查完你记住的不只是一个结论而是一整片知识网络。