
这一篇是Java基础常见问题系列的第三篇。前面两篇聊了不少语法细节和集合框架的坑结果后台收到最多的留言反而是“环境配置翻车”“报错看不懂”“面试被问住”这几类。所以这篇我换个思路不再按语法书目录走而是把热搜词里大家真正高频搜索的问题挑出来分成环境与编译期、核心API、异常排查、面试与算法四个板块逐个拆开揉碎讲清楚。不管你是刚装好JDK准备写第一个Hello World还是工作两三年后回头补基础这篇都适合花二十分钟认真过一遍。所有问题都是实际开发里真会遇到的每一条背后都有可复现的报错信息或者可验证的代码看完能直接用不是那种背完就忘的八股问答。1. 环境与编译期问题装对JDK只是开始1.1 JAVA_HOME与PATH为什么配了环境变量还是报错很多新手第一次装JDK网上搜了一篇教程照着配完环境变量打开命令行敲java -version有输出以为就万事大吉了。结果没过多久用IDEA建项目时提示找不到JDK或者写了一个启动脚本双击运行直接报错这时候才发现环境变量没那么简单。先搞清楚JAVA_HOME和PATH各自是干什么的。JAVA_HOME是一个自定义变量它指向JDK的安装根目录比如C:\Program Files\Java\jdk-17。JDK本身以及Tomcat、Maven、Gradle这些工具启动时都要靠这个变量找到JDK的位置。而PATH是系统用来找可执行文件的路径集合你在命令行里敲java系统会按顺序遍历PATH列出的每个目录找到名为java.exe的文件就执行。所以PATH里必须包含%JAVA_HOME%\bin否则系统找不到java命令。配完了还报错最常见的原因有三个。第一个是PATH的系统变量和用户变量冲突。Windows系统的环境变量分“用户变量”和“系统变量”两栏两者最终会合并生效但顺序上有讲究用户变量在前系统变量在后。如果你在用户变量里配了一个旧版本JDK的路径又在系统变量里配了新版JDK最后生效的可能是用户变量里那个旧的。解决办法很简单打开命令行执行where java它会列出所有找到的java.exe及其路径一眼就能看出实际用的是哪个。第二个是变量顺序问题。有人把JAVA_HOME删了直接在PATH里写死绝对路径C:\Program Files\Java\jdk-17\bin这样单独看没问题。但你日后升级JDK版本比如从17升到21就得再改一遍PATH而且如果你装了多个JDK或者有其他软件往PATH里塞了Java路径就很容易乱。标准做法还是先设置JAVA_HOME然后PATH里写%JAVA_HOME%\bin这样以后切版本只改JAVA_HOME一处。第三个是配置了但没生效。环境变量修改后已经打开的命令行窗口不会自动刷新需要重开一个新的命令行窗口。IDEA如果是在修改之前启动的也要重启IDEA。这个原因最蠢也最容易踩。再补充一个高频场景本机装了多个JDK比如一个8用在老项目一个17用在新项目。这时候只靠改JAVA_HOME来实现切换并不总是有效因为PATH里如果存在JDK8自带的jre路径或者之前装过Oracle官网版的Java注册表里也留了版本信息某些软件会去读注册表而不是读JAVA_HOME。我的建议是日常开发统一用JAVA_HOME切换必要时配合where java验证如果真的需要频繁切换并希望更省心可以借助开源的sdkman或者Windows下的SDKMAN-like工具来管理多版本JDK不过基础思路还是理解环境变量的优先级。1.2 Lombok与编译器版本冲突一句话背后的连锁反应这两年JDK的发布节奏变成半年一个大版本很多团队还停在JDK8但总有喜欢追新的人把开发环境的JDK升到了17、21甚至更新。升完JDK之后一个高频报错就出现了java: you arent using a compiler supported by lombok, so lombok will not work.这句话从字面看是“你用的编译器Lombok不支持”但很多人不理解为什么javac换了个版本Lombok就罢工了。这里要交代一下Lombok的工作原理。Lombok不是普通的jar包它在编译阶段通过Java的注解处理器Annotation Processor机制介入编译过程在抽象语法树AST上做修改往类里注入getter、setter、builder等方法。JDK的编译器javac内部实现对Lombok来说就是一套特定的APIJDK版本一变这张API就可能有变动Lombok没跟上就必须拒绝工作否则硬往AST里塞代码指不定编译出什么畸形字节码。所以这个问题的本质是Lombok版本太老不支持当前JDK版本。解决办法也直接——升级Lombok。以Maven项目为例引入的依赖坐标保持不变但版本号需要跟着JDK版本走dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.36/version scopeprovided/scope /dependency以我的经验JDK17用Lombok 1.18.20以上基本没问题JDK21建议直接用1.18.30以上再保守一点就选最新的release版本。Gradle项目的话在dependencies里同步调整版本即可。排查这个报错时还有几个容易忽略的细节。我遇到过升级了Lombok版本、重启了IDEA仍然报同样错误的情况后来发现是IDEA的Build选项里勾选了Delegate IDE build/run actions to Maven本质上是Maven的编译进程还在用旧的注解处理器。这时候要执行一次mvn clean compile看Maven是不是能正常编译。如果Maven能过而IDEA不能过检查IDEA的Settings → Build → Compiler → Annotation Processors是否勾选了Enable annotation processing。Lombok要在IDE里生效这一步必须打开。注意有些项目会同时在pom.xml里显式引入lombok-maven-plugin这个老插件只在少数场景有用绝大多数情况下不需要升级到新版IDE和Lombok版本就能解决。2. 核心API与常用类平时写代码最容易栽的地方2.1 String、StringBuilder、StringBuffer不只是“拼接效率”那点事Java面试里String相关的题永远不过时但很多人背了“String不可变、StringBuffer线程安全、StringBuilder线程不安全”就以为掌握全场了一上手写代码照样掉坑。先说一个最典型的错误在循环里用拼接字符串。String result ; for (int i 0; i 10000; i) { result i ,; }这段代码在语法上没有任何问题但性能很差。原因是String类是不可变的每次都会创建新的字符串对象旧的字符串对象变成垃圾等待回收。循环一万次就会创建一万个中间字符串对象。你可能说编译器不是会优化成StringBuilder吗确实javac会将单个表达式优化成new StringBuilder().append()...toString()但这是在单条语句内部跨循环迭代时每一次都会创建独立的StringBuilder照样有性能损耗。正确写法是用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i).append(,); } String result sb.toString();这里.append(i).append(,)连续调用是StringBuilder的链式调用设计每步返回同一个对象从头到尾只创建一个StringBuilder实例。至于StringBuffer它的所有公开方法几乎都加了synchronized所以在单线程环境下的性能比StringBuilder差多线程环境下两个类也都不适合作为共享可变状态来使用。换句话说StringBuffer的“线程安全”在绝大多数场景是个伪需求。我在实际项目中基本只在一种情况用StringBuffer面试Demo里为了展示线程安全。日常开发统一用StringBuilder就好。还有一个衍生问题值得讲为什么String设计成不可变网上答案很多我挑几个关键的。第一字符串常量池需要靠不可变性来保证安全性同一个字符串字面量被多个变量引用如果其中一处修改了内容其他引用就全乱了。第二作为HashMap的key不可变保证了hashCode的稳定性如果key的hashCode变化HashMap就再也找不到这个键了。第三String对象会被网络传输、文件读写大量使用不可变让这些场景更安全避免意外修改。面试时如果被问到“String可以继承吗”答案是不可以String是final的。这个设计不是为了防程序员而是为了确保字符串的不可变性和安全性不被破坏还有编译器的各种优化可以放心假设String对象的值不会变。2.2 包装类缓存与equals比较100和1000的区别来看一段代码Integer a 100; Integer b 100; System.out.println(a b); // true Integer c 1000; Integer d 1000; System.out.println(c d); // false同样是用比较两个Integer为什么一个输出true一个输出false原因是Java在Integer类内部维护了一个缓存数组默认缓存了-128到127之间的所有Integer对象。当你通过自动装箱创建Integer对象时如果值在缓存范围内直接返回缓存中已有的同一个对象所以100 100是true。值超过127缓存中不存在就得新建对象所以1000 1000用了两个不同对象比较的是引用地址自然是false。这个缓存机制在Java面试里几乎必考。JDK的源码里有个IntegerCache内部类其中low默认-128high默认127high可以通过启动参数-XX:AutoBoxCacheMax1000调大。除了Integer其他整数包装类也有类似机制Byte、Short、Long缓存范围固定为-128到127Character缓存0到127。浮点型Float、Double没有缓存因为整数范围内的缓存数量有限而浮点数理论上无限。基于这个原理开发中用包装对象做比较时必须记住一个规矩只要是比较两个包装类对象的值一律用equals()或者转成基本类型再用。比如判断两个Integer是否相等写成a.equals(b)或者a.intValue() b.intValue()。Java 7之后还可以用Objects.equals(a, b)内部会做空指针判断比a.equals(b)更严谨因为后者在a为null时直接空指针。再延伸一个相关知识点自动装箱Autoboxing和自动拆箱Unboxing。Integer a 100本质是Integer a Integer.valueOf(100)int x a本质是x a.intValue()。在循环或者高频运算里如果反复在Integer和int之间转换会额外产生对象创建的开销。比如写Long sum 0L; for (long i 0; i 100000; i) { sum i; }由于sum是Long包装类型每次sum i都要先拆箱成long做加法再装箱回Long循环十万次就产生十万次装箱拆箱性能明显不如直接用基本类型long sum。2.3 循环中删除集合元素ConcurrentModificationException的真相下面这段代码跑起来必抛ConcurrentModificationExceptionListString list new ArrayList(); list.add(A); list.add(B); list.add(C); for (String s : list) { if (B.equals(s)) { list.remove(s); } }很多人没搞懂为什么不能在foreach里删除元素。实际上foreach语法糖的背后是迭代器Iterator它除了hasNext()和next()之外内部还有一个modCount的校验机制。集合每次做结构性修改增、删都会让modCount加一。迭代器初始化时保存了当时的expectedModCount每次调用next()都会检查两者的值不一致就抛ConcurrentModificationException。这个设计就是为了尽早发现问题结构已经变了迭代器却不知道再往下取元素就可能出现脏数据。list.remove(s)直接修改了集合的modCount迭代器里保存的expectedModCount没变两个数对不上下次循环进入next()就炸了。正确的删除方式有三个。最传统的是手动使用迭代器IteratorString iterator list.iterator(); while (iterator.hasNext()) { String s iterator.next(); if (B.equals(s)) { iterator.remove(); } }这里iterator.remove()会在更新集合状态的同时同步修改迭代器内部的expectedModCount所以不会抛异常。Java 8之后更推荐直接用removeIflist.removeIf(s - B.equals(s));removeIf在Collection接口的默认方法里实现内部已经帮你处理好了迭代器和modCount的同步一行代码解决问题。还有一种写法是用普通for循环从后往前遍历用下标删除for (int i list.size() - 1; i 0; i--) { if (B.equals(list.get(i))) { list.remove(i); } }这种写法不通过迭代器所以不会碰modCount校验但只适用于ArrayList这类有随机访问能力的List。它有个隐患每次remove(i)都会触发一次数组元素搬移如果删除的元素很多性能并不好。比起removeIf这种写法只适合在需要精确控制下标的老代码里用。注意removeIf是基于Lambda表达式的API如果面试官问“为什么用removeIf不会抛异常”你可以直接指出它内部使用了Iterator的一个不可见实例并通过expectedModCount同步机制保证了一致性。3. 异常与报错排查看到堆栈别慌先定位类型3.1 NoClassDefFoundError与ClassNotFoundException两个“找不到类”的区别异常堆栈里出现java.lang.NoClassDefFoundError: java/applet/Applet这类信息时刚开始排查的人很容易一头雾水是不是classpath配错了类文件不在其实这个具体报错里的关键不是“找不到类”这件事本身而是“找不到哪个类”——java.applet.Applet是JDK 9之前就存在、JDK 9开始被逐步废弃、最终在新版本JDK中移除了一个组件。如果老项目或者老依赖还在引用它在JDK 9以上的环境里运行就会报这个错误。但NoClassDefFoundError和ClassNotFoundException是两种不同的异常弄清两者的区别是Java异常知识里的高频考点。ClassNotFoundException是一个Exception受检异常它出现在程序主动通过反射加载某个类的时候比如Class.forName(com.example.MyClass)但classpath里根本没有这个类。这是“运行时才发现的缺失”代码编译时不报错因为你传入的类名是字符串。NoClassDefFoundError是一个Error它出现在类已经在编译期存在但运行时加载时找不到。最常见的情形是编译某个类时依赖的另一个类还在后来把那个类从classpath里删了或者打包时漏打了运行时就能看到这个Error。还有一种典型场景是静态初始化失败比如类的静态代码块抛异常类初始化失败下次再用到这个类时也会抛NoClassDefFoundError。看问题的方法也很直接。第一看异常类型是Exception还是ErrorError意味着问题几乎不可恢复不是靠try-catch能接住的。第二看后面跟的类名是哪个去项目里全局搜索这个类确认它是否还在。这里可能有个反直觉的点搜代码里找不到某类不代表classpath里没有它有可能藏在某个依赖jar包里。第三关注当前JDK版本定位到JDK版本变迁引发的类移除问题这种最容易在升级JDK之后集中爆发。回到java/applet/Applet这个具体报错如果你确定项目本身没有写Applet相关代码几乎可以判定是某个老依赖传递引用了Applet类。排查时可以在命令行里用jdeps分析jar包依赖关系找出谁在引用Appletjdeps --jdkinternals --recursive your-app.jar这个命令会列出jar包中所有对JDK内部API的引用包括已移除的模块好用得很。找到引用方之后要么升级那个老依赖的版本要么在代码层面把Applet相关调用去掉。注意有时候引用隐藏在反射代码里jdeps也扫不出来那就只能全局搜Class.forName和Applet关键字了。3.2 Redis的increment()报错“is not integer or out of range”用Spring Data Redis操作Redis时经常有人碰到这个异常ERR value is not an integer or out of range触发代码往往是redisTemplate.opsForValue().increment(someKey);第一反应是我没存过任何非数字内容到这个key啊为什么Redis说“不是整数”要理解这个报错得先看Redis本身的INCR命令。INCR在Redis里是一个原子自增命令它要求key当前存储的值能被解释为一个十进制整数而且这个整数在64位有符号整数的范围内。如果key不存在Redis会先把它初始化为0再自增返回1。但如果key的值是字符串或者其他无法解析为整数的格式Redis直接拒绝执行并返回上面的错误。那为什么value看起来是正常的Redis还是拒绝呢我遇到过两类典型场景。第一类是key已经被其他业务逻辑写入过非字符串格式的值。比如你用的同一个Redis数据库某个地方用set存了这个key的旧值内容类似abc或JSON串你的increment()再去执行自然失败。这种问题的排查思路很简单先到Redis里看这个key当前的数据类型和值。redis-cli TYPE someKey GET someKeyTYPE返回string、list、hash这些类型如果类型不是stringINCR用不了。如果类型是string再GET看看具体内容基本一眼能看出问题。第二类场景更隐蔽和Spring Data Redis的序列化机制有关。RedisTemplate默认使用JdkSerializationRedisSerializer把key和value都做Java序列化存入Redis时value前面会带一串二进制头而StringRedisTemplate使用StringRedisSerializer存进去的就是纯字符串。如果你在同一个项目里混用了两种Template操作了同一个key就会出现“存进去的是乱码读出来格式对不上”的情况increment()拿到的值不是合法整数直接报错。这种坑排查起来相当费劲因为你主观上觉得这个key只有你写的代码在操作。解决这类问题有两个思路。一是统一序列化器同一个key的读写不要混用不同的Template。二是对特定业务场景直接指定用ValueOperations之前先检查类型或做防御性删除if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { redisTemplate.opsForValue().set(key, 0); } redisTemplate.opsForValue().increment(key);这里有个细节要注意hasKey判断的是key不存在而不是“值不是数字”。如果key存在但值不是数字这种防御性初始化帮不上忙。真正常规做法是从业务逻辑上保证这个key只会被增加数字在写入数据之前先清洗掉历史脏数据可以用redisTemplate.delete(key)先清理再正常自增。注意Spring Data Redis的increment()方法还支持传入增量步长比如increment(key, 5)但如果value本身不是数字照样会抛同样的错。排查时优先看Redis里的真实数据和数据类型不要一头扎进Java代码里找bug。4. 面试与经典算法八股文背后的原理4.1 冒泡排序两种写法与优化空间冒泡排序是个经典的入门排序算法但别因为它简单就轻视。很多面试官喜欢从冒泡排序切入看你代码风格、变量命名、边界条件处理以及是不是只会默写。冒泡排序的核心思想是每一轮迭代比较相邻元素如果顺序不对就交换最大的元素像气泡一样逐步“冒”到数组末尾。经过n-1轮后整个数组有序。最基础的写法public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }内层循环的n - 1 - i是关键第i轮结束后数组末尾的i个元素已经是全局最大的i个它们已经就位不再需要参与后续比较。这个边界条件写错的人特别多常见错误是写成n - 1导致多算了好几轮无效比较影响不大但有失精确。优化版是在每一轮里记录是否发生了交换。如果某一轮从头走到尾都没有任何一次交换说明数组已经有序可以提前终止public static void bubbleSortOptimized(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }加了swapped标记之后最好情况数组已经有序的时间复杂度从O(n²)降为O(n)只需要跑一轮就能退出。平均和最坏情况还是O(n²)因为总体上它仍然需要反复比较相邻元素。空间复杂度O(1)是原地排序。面试时如果让你用冒泡排序记得说清楚它的优缺点。优点是实现简单、稳定相同元素相对位置不变、原地排序。缺点是慢数据量大时明显顶不住。实际开发中没人会用冒泡排大数组面试官考察它更多是想确认你对基础算法的理解深度。比如你提到优化版冒泡利用了“是否有交换”的标记这就能和“最好情况O(n)”的复杂度分析串起来比单纯默写代码强很多。其实冒泡排序和Java标准库的Arrays.sort()差距有多大呢Arrays.sort()对基本类型数组用双轴快排对对象数组用TimSort大数据量下性能完全不在一个量级。手写排序在业务代码里基本没有存在意义要排序直接调库就行。算法的价值在于理解思维方法这在面试之外才是最重要的收获。4.2 动态代理JDK动态代理与CGLIB的区别动态代理是Spring AOP的底层基础面试频率极高。问法通常是“说说JDK动态代理和CGLIB的区别”或者“Spring AOP默认用哪种代理方式”。先明确概念。代理模式的目标是给某个对象提供一个代理对象由代理对象控制对原对象的访问。调用方不直接操作目标对象而是通过代理代理可以在调用前后插入额外逻辑。静态代理在编译期就确定代理关系一个接口要写一个代理类。动态代理则是在运行期动态生成代理类。JDK动态代理要求目标对象必须实现至少一个接口。它通过java.lang.reflect.Proxy类在运行时为接口生成代理类调用Proxy.newProxyInstance()传入类加载器、接口数组和一个InvocationHandler。代理对象的方法调用会被转发到InvocationHandler.invoke()方法所以可以在invoke里做增强逻辑。下面的代码是一个简化示例public interface UserService { void addUser(String name); } public class UserServiceImpl implements UserService { Override public void addUser(String name) { System.out.println(添加用户: name); } } UserService target new UserServiceImpl(); UserService proxy (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxyObj, method, args) - { System.out.println(方法调用前记录日志); Object result method.invoke(target, args); System.out.println(方法调用后收尾); return result; }); proxy.addUser(张三);CGLIBCode Generation Library的做法不同它通过生成目标类的一个子类来创建代理对象不需要接口。因为目标类被继承被final修饰的类和方法都无法被CGLIB代理。CGLIB底层依赖ASM字节码操作库在运行期直接生成新的字节码类。Spring Boot默认的代理策略是目标类有接口就用JDK动态代理没有接口就用CGLIB。从Spring Boot 2.x开始即使对象有接口默认也更倾向于用CGLIB来避免某些边界问题不过这属于框架细节不同版本行为有差异。两者的核心对比如下对比项JDK动态代理CGLIB代理目标条件必须实现接口不要求接口不能代理final类原理运行期生成接口的代理实现类运行期生成目标类的子类依赖JDK内置无需额外依赖需要asm等字节码库被final修饰的方法可以正常工作无法被代理性能较高生成代理类轻量稍重创建代理时需生成子类字节码实际工作中最常见的动态代理应用场景就是Spring AOP。比如你给一个Service方法加TransactionalSpring容器里的bean不再是原来的UserServiceImpl对象而是它的代理对象。调用方拿到的引用指向代理代理在执行业务方法前开启事务方法抛异常时回滚正常结束时提交。理解了这套机制后面排查事务失效问题就多了一把钥匙如果某个类里的私有方法加了Transactional或者一个方法内部直接this调用同类的另一个方法这些都不会经过代理对象事务注解自然无效。最后提一个面试爱问的延伸点为什么Spring的注解驱动事务只对public方法生效因为JDK动态代理和CGLIB代理只能在方法被外部调用时拦截而this内部调用不会经过代理同时CGLIB生成子类时private方法不会被子类覆盖final方法也不行。所以Spring要求事务方法必须是public且从外部调用才能被增强。我个人觉得动态代理这部分不用背源码核心就是把代理“在调用链上插入逻辑”这个思想想明白。代理对象和被代理对象的关系就像中介和房东租客以为自己在跟房东打交道其实合同走的是中介。中间那层中介能帮你做验证、记账、协调还能随时决定这单生意要不要做。理解了这个场景后面看各种框架的拦截器、过滤器时都会顺畅很多。