一次一次看到“java面试题”“java基础”“java环境变量配置”这些词在热搜榜上反复横跳,说实话我是有点感慨的。这个系列前面两篇已经聊了不少基础问题,这一篇我干脆换个思路——直接从热搜词里挑几个最典型、最有代表性的问题来做深度拆解。热搜词本身就是一份活生生的“问题清单”,它能告诉我们Java开发者现在到底卡在哪些地方。这篇就围绕六类高频问题展开:环境配置、String、RedisTemplate的increment报错、线程等待、动态代理、排序手撕。每一条都是我自己踩过坑或者帮别人排查过的问题,尽量讲透。
1. 从热搜词看Java学习者的“问题地图”
1.1 环境变量配置为什么年年上榜
“java环境变量配置详细教程”常年占据Java相关搜索的前列,这不是偶然。JAVA_HOME、PATH、CLASSPATH这三个变量的理解偏差,几乎能解释90%以上“我明明装了JDK但命令行javac不好使”的求助帖。
简单说下正确配置逻辑:JAVA_HOME指向JDK安装目录(比如C:\Program Files\Java\jdk-17),PATH里新增一项%JAVA_HOME%\bin,这样命令行才能找到java.exe和javac.exe。CLASSPATH在Java 9之前确实需要手动配置,模块化之后默认就是当前目录,根本不用碰它。很多旧教程还在教人配置.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar,这个操作既没必要,还可能导致类加载的坑。
我见过最离谱的一个情况是:系统里装了三套JDK,用户配了JAVA_HOME指向JDK 8,但PATH里系统变量和用户变量各写了一堆路径,结果java -version随机返回不同版本,IDE编译报错,一查才知道是PATH顺序问题。Windows下PATH是按顺序查找的,越靠前优先级越高,把%JAVA_HOME%\bin放到最前面就能锁定版本。
另一个高频翻车点是配完环境变量后,已经打开的命令行窗口不会刷新,必须重开窗口。还有一类是IDE内部自带JRE,和系统JDK不一致导致Maven编译报错,这种问题去IDE的Project Structure里把JDK切到JAVA_HOME路径就行。
1.2 版本迁移后的一串“历史遗留报错”
热搜里有两个报错特别有意思:uncaught exception java.lang.noclassdeffounderror: java/applet/applet和java: you aren't using a compiler supported by lombok。前者是JDK 9移除Applet API之后,旧程序在新版本JDK上运行时的典型报错;后者是Lombok版本太老,不兼容新版javac导致注解处理失效。
Applet这个报错,现在基本只会在学生作业或老古董系统里遇到。解决办法说白了就三个方向:改造代码去掉Applet依赖、降级到JDK 8运行、或者引入兼容层。如果项目是学校作业,我一般建议直接换JDK 8跑起来交差,业务改造反而风险更大。
Lombok那个报错在真实开发中更值得重视。它表面提示的是编译器不受支持,实际是Lombok作为注解处理器,需要在javac的注解处理阶段介入。JDK新版本升级后,老版本Lombok内部对编译器内部API的调用就会失效。我建议团队里统一升级Lombok版本,别让每个人自己装。另外,新版IDE大多默认开启了注解处理,如果发现Lombok生成的getter/setter找不到,第一件事先去看IDE的Annotation Processing设置。
1.3 “八股文”焦虑背后的学习路径问题
“java八股文”“java面试大全及答案”“java面试题”这些热词,说明大量开发者是面试导向学习的。这个现象本身没啥问题,问题是“背八股”和“真理解”之间差着十万八千里。
我的观点是:八股文可以背,但必须带着“为什么”去背。比如背了“JDK动态代理基于接口,CGLIB基于继承”,那就要追问一句“Spring Boot 2.x之后为什么默认用CGLIB”?背了“Redis的increment命令是原子的”,那就要追一句“RedisTemplate的increment报not integer or out of range是因为什么”?一旦能回答这些“为什么”,八股文就从死记硬背变成了知识框架,面试时被追问也不会慌。后面的章节,我选的这些问题都是这个思路。
2. 字符串的“坑”集中在哪:文本块、equals与拼接性能
2.1 多行字符串的写法演进
热搜里有“java字符串多行写法”,说明大家写SQL、JSON、HTML拼接时,被单行字符串折磨得不轻。Java 15之前,多行长文本只能靠\n换行符、+号拼接,又丑又容易漏换行。Java 15正式引入文本块(Text Blocks)后,用三个双引号就能直接写多行内容:
String sql = """ SELECT id, name, age FROM user WHERE status = 1 ORDER BY create_time DESC """;注意文本块的处理规则:行尾空白会被自动去除,缩进会按最小公共缩进去掉,所以写SQL时这层缩进不会带到SQL里。如果要保留特殊格式,可以用\来防止换行,\s来保留空格。这个特性在写测试数据、XML、JSON样例时特别实用。如果项目还停留JDK 11或8,那我建议要么用String.join("\n", ...),要么用Guava的Joiner,别再用+号硬拼了。
2.2 常量池与new String:==和equals的本质
==比较的是引用地址,equals比较的是内容。这个知识点几乎每个Java开发者都背过,但一旦遇到常量池相关题目,还是有一半人会答错。我拿最常见的例子:
String a = "hello"; String b = "hello"; System.out.println(a == b); // true,两个字面量指向常量池同一个对象 String c = new String("hello"); System.out.println(a == c); // false,new必走堆内存新对象 System.out.println(a.equals(c)); // true,内容相同这里的关键是:字符串字面量在编译期就会放入常量池,相同内容的字面量在运行期复用的是同一个对象。而new String("hello")会在堆上额外创建一个新对象,哪怕底层字符数组可能共享。还有一道经典的衍生题:
String s1 = "hello"; String s2 = s1 + " world"; // 运行时拼接,本质是StringBuilder.append String s3 = "hello world"; System.out.println(s2 == s3); // false所以遇到比较字符串内容,规则就一条:永远用equals,别碰==。真实项目里用==比较字符串导致线上bug的例子我见过不止一次,多发生在从Map或JSON里取值比较的时候。
2.3 字符串拼接性能的正确姿势
字符串拼接性能问题,核心在于+号在不同场景下的编译优化行为不同。单行字面量拼接,编译期直接优化成常量;单变量拼接,编译器会替换成StringBuilder.append;但循环内拼接,就惨了——每次循环都会创建一个新的StringBuilder对象:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; // 每轮都new StringBuilder }这个代码在JDK 8上,一万次的循环会创建上万个StringBuilder和中间字符串对象,GC压力直线上升。正确写法是把StringBuilder提到循环外面:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();顺带说一个冷门但有用的点:JDK 9开始字符串内部存储从char[]换成了byte[],并且有COMPACT_STRINGS优化,ASCII字符占一个字节。但StringBuilder的初始容量默认16,如果知道结果大致的长度,构造时直接给容量能减少扩容拷贝。这些细节面试不会考,但做高并发日志拼接或大批量数据组装时,性能差距肉眼可见。
3. RedisTemplate.increment()报错全解析:不是整数到底是谁的锅
3.1 问题现场:一个“减一”操作引发的告警
热搜里有一串关键词:“java中redis使用redistemplate的increment()报错不是integer or out of range”“java使用redistemplate将redis的数减一”。这两个词放一起,基本还原了一个典型事故现场。
有个业务需要把Redis里的库存做扣减,代码大概是:
redisTemplate.opsForValue().increment(stockKey, -1);结果线上日志报错:ERR value is not an integer or out of range。第一反应是“我存的时候明明是数字啊”,排查半天发现,存的时候用的是redisTemplate.opsForValue().set(stockKey, 100),读出来也正常显示100,但一到increment就炸了。
3.2 先搞清楚INCR命令的真实语义
Redis的INCR命令语义很明确:如果key不存在,会先初始化为0再执行加1;如果key存在,值必须能被解析为64位有符号整数,否则报错。INCRBY支持自定义步长,RedisTemplate的increment(k, delta)底层走的就是INCRBY。
报错里有“out of range”,一般有两种情况:值超过Long.MAX_VALUE(9223372036854775807);或者值是浮点数——注意,Redis的INCR系列命令不支持浮点,浮点得用INCRBYFLOAT。但在实际排查里,更多时候是前一个原因——值压根不是数字。
3.3 根因:序列化器把数字变成了“乱码”
默认情况下,RedisTemplate用JdkSerializationRedisSerializer做序列化。这意味着往Redis里set一个Integer或Long,实际存进去的是Java序列化后的二进制字节流。从Redis客户端看成是一串\xAC\xED\x00\x05t...开头的东西,根本不是可读的“100”。当你increment时,Redis尝试把这个二进制流解析成整数,自然直接报“not an integer”。
而StringRedisTemplate用的是StringRedisSerializer,存进去的是可读的字符串“100”,increment就能正常工作。这也是为什么很多人用StringRedisTemplate一切正常,换RedisTemplate就翻车。
修复方案有两种。如果整个项目都用字符串格式存Redis,直接统一替换成StringRedisTemplate最省事。如果部分key确实要用对象序列化,那就要针对指定key的RedisTemplate做序列化器配置:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; }这段配置的意思是把key和value都改成String序列化,这样数值类型的读写就能和命令行客户端看到一致的格式。注意,如果线上已经有历史数据是用默认序列化器存的,改完配置后旧key的读取会乱码,需要做数据迁移或先清理。
3.4 上线后的预防措施
这个问题一旦踩过,后面就好办了。我总结三个使用规范:
- 涉及计数、库存、限流这类数值操作的key,优先用
StringRedisTemplate,避免类型歧义。 - 不要把“看起来是数字的字符串”和真正的Long混用。比如存的时候存了
"100"(String),increment时会没问题,但get返回的是String,如果代码里强转Long,又会炸。要定好key的value类型规范。 - 如果确实需要对象序列化,key和value的序列化器必须明确指定,别用默认配置。
4. 多线程“等所有任务完成”的几种实现:join、CountDownLatch到CompletableFuture
4.1 Thread.join()的局限与CountDownLatch的典型场景
“java线程等待都完成”是并发编程里一个非常典型的需求。最简单的实现是Thread.join():
Thread t1 = new Thread(task1); Thread t2 = new Thread(task2); t1.start(); t2.start(); t1.join(); t2.join(); // 到这里,t1和t2都已经执行完join()的语义就是调用线程等待目标线程结束。但这个方案有两个硬伤:一是无法控制线程数量,现实中更多是用线程池;二是无法感知任务执行结果是成功还是异常。所以生产环境我很少直接用它。
线程池场景下更常见的是CountDownLatch。构造时指定计数,每个任务在finally里countDown()一次,主线程await()等待计数归零:
int taskCount = 10; CountDownLatch latch = new CountDownLatch(taskCount); ExecutorService pool = Executors.newFixedThreadPool(taskCount); for (int i = 0; i < taskCount; i++) { pool.submit(() -> { try { // 执行任务 } finally { latch.countDown(); } }); } boolean finished = latch.await(30, TimeUnit.SECONDS); if (!finished) { // 超时处理,别傻等 }有两个细节必须强调:countDown()必须放finally,否则任务抛异常会导致计数永远归不了零,主线程一直阻塞;await()一定要给超时时间,这是高并发环境的基本素养。我见过把await()裸写的代码,线上线程池一个任务卡死,结果整个请求池全部阻塞。
4.2 CyclicBarrier:栅栏模式的适用场景
CyclicBarrier和CountDownLatch经常被拿来对比。简单区分:CountDownLatch是“一个人等一群人”,等到了就放行,一次性;CyclicBarrier是“一群人互相等”,所有人都到齐后一起出发,可以循环使用。
int parties = 4; CyclicBarrier barrier = new CyclicBarrier(parties, () -> { // 所有线程到达后,先执行这个回调 });实际开发中,CyclicBarrier用得比CountDownLatch少很多,但在“多批次并行计算,每批次等待对齐后再一起进入下一轮”的场景有不可替代的作用。比如分页批量处理,每一页的多个分片都完成后再处理下一页,这时候用CyclicBarrier就很合适。
4.3 CompletableFuture.allOf与异步聚合的现代姿势
Java 8之后,处理多任务等待的主流方案已经变成了CompletableFuture。它比CountDownLatch更优雅的地方在于:不依赖外部计数器,直接用回调组合来表达“全部完成后做什么”。
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> task1()); CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> task2()); CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2); all.join(); // 阻塞等待全部完成 String result1 = future1.get(); String result2 = future2.get();有些人会问:allOf().join()和CountDownLatch有什么区别?区别在于CompletableFuture把任务执行、异常处理、结果聚合都封装在了一个异步链路里,不需要手动管理计数器。比如任务执行中抛异常,CountDownLatch只能靠finally保证计数递减,然后主线程还要额外判断每个任务是否失败;而CompletableFuture可以直接在回调里exceptionally处理,或者在get()时捕获ExecutionException拿到异常原因。
性能敏感场景还可以给supplyAsync传入自定义线程池,避免共用ForkJoinPool的公共池。一个实际项目里的批量数据聚合,我一般就这样做:一批任务丢进线程池,allOf等待所有完成,然后collect结果列表,超时通过get(timeout)控制。
5. 动态代理:从八股文到框架源码的连接点
5.1 JDK动态代理和CGLIB:两条完全不同的技术路线
动态代理是Java面试八股里的常驻选手,也是很多初学者觉得“抽象”的知识点。搞清楚它,等于打通了Spring AOP、MyBatis Mapper代理、Feign这些框架的一扇门。
JDK动态代理的工作机制:运行时创建目标接口的代理类,通过InvocationHandler拦截方法调用。核心限制是目标对象必须实现接口,否则没法生成代理类。CGLIB则不走接口路线,它直接操作字节码,生成目标类的子类来覆写方法。所以CGLIB可以代理没有接口的普通类,但final类和方法它碰不了,因为Java不允许继承final类、覆写final方法。
下表是两者的关键差异,面试问起来直接甩这张表:
| 对比项 | JDK动态代理 | CGLIB |
|---|---|---|
| 代理原理 | 基于接口生成代理类 | 基于继承生成子类 |
| 目标要求 | 必须实现接口 | 普通类即可,final类/方法不行 |
| 依赖 | JDK内置,无需额外依赖 | 需要cglib依赖(Spring已内置) |
| 性能 | 相对轻量 | 创建代理较慢,但调用性能也不错 |
5.2 Spring AOP的代理选择逻辑
Spring AOP在选择代理方式时有自己的策略。Spring Boot 2.x开始,spring.aop.proxy-target-class=true成为默认配置,意味着优先使用CGLIB。为什么要改默认值?因为很多项目里ServiceImpl压根没抽接口,JDK动态代理直接用不了。早期Spring用JDK动态代理,结果没接口的Bean切面失效,是个非常常见的踩坑点。
需要注意的是,Spring AOP默认只对public方法做代理,且自调用(this调用同类方法)不会走代理逻辑。很多人写切面统计方法耗时,结果发现内部this.xxx()调用没触发日志,就是这个原因——要想自调用也走代理,必须通过代理对象调用,或者把方法拆到另一个Bean里。
5.3 一个完整的手写JDK动态代理Demo
我每次讲动态代理都会带学生手写一遍,代码量不大,但能把机制看透:
public class TimeLogInvocationHandler implements InvocationHandler { private final Object target; public TimeLogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); try { return method.invoke(target, args); } finally { System.out.println(method.getName() + " 执行耗时: " + (System.currentTimeMillis() - start) + "ms"); } } }使用时:
UserService userService = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new TimeLogInvocationHandler(userService) ); proxy.getUserById(1L);Proxy.newProxyInstance第三个参数传InvocationHandler,代理对象每次调用接口方法时,都会进入invoke方法。method.invoke(target, args)这行就是反射调用真实对象的方法,所以在它前后加日志、加权限校验、加事务,本质都是同一套路。
再看MyBatis的Mapper:你只写了一个接口,没有实现类,但Spring注入后能直接调用——底层就是JDK动态代理生成了一个Mapper接口的代理对象,代理里根据方法名、参数、注解生成SQL并执行。理解了动态代理,这类“接口没有实现却能调用”的魔法就全通了。
6. 手撕排序算法:冒泡和快排的代码细节与边界
6.1 冒泡排序:优化点到底有没有必要
热搜里“冒泡排序java”常年在榜,说明手撕排序依然是面试的基本盘。冒泡排序本身思路很简单:每轮从头到尾比较相邻元素,大的往后挪,经过n-1轮后数组有序。经典实现,我建议加一个“是否交换过”的标志位:
public static void bubbleSort(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 tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) { break; // 本轮没有交换,数组已有序,提前结束 } } }这个标志位的价值在于:如果数组本身接近有序,时间复杂度能优化到近似O(n),直接跳出外层循环。虽然冒泡整体是O(n²),但这一点优化能在排序有序数组时省掉大量无效遍历,面试时也能展示你考虑过优化。
6.2 快速排序的三个边界坑
快速排序是面试手撕的另一个高频题。思路是分而治之:选一个基准值(pivot),把小于等于它的放左边,大于它的放右边,然后递归处理左右两部分。我用的是Lomuto分区方案的写法,代码更好记也不容易出错:
public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } private static int partition(int[] arr, int left, int right) { int pivot = arr[right]; int i = left - 1; for (int j = left; j < right; j++) { if (arr[j] <= pivot) { i++; swap(arr, i, j); } } swap(arr, i + 1, right); return i + 1; } private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; }三个边界坑,面试现场十个人里七八个会踩:
- 递归退出条件写成
left == right,漏掉left > right的情况。分区过程中,pivot左边可能一个元素都没有,此时pivotIndex - 1 < left,下一层递归的left会大于right,必须用>=判断。 - 分区时
arr[j] <= pivot的等号不能丢。丢掉等号会让等于pivot的元素全跑到右边,极端情况下左右数据量失衡,递归深度变大,性能退化。 - 基准值选最右元素时,j的循环终点是
j < right,不能写成j <= right,否则自己和自己比较没有意义。如果数组本身就是倒序,Lomuto分区会退化成O(n²),此时可以随机选pivot或者三数取中来优化。
6.3 面试官考排序,到底在考什么
手撕排序表面看是考算法,实际看的是三件事:第一,能不能写出正确、无死循环的代码——这考验边界意识和递归思维;第二,能不能说出时间复杂度和稳定性——这考验对算法本质的理解;第三,能不能根据场景选合适的排序——这考验工程经验。
比如稳定性:冒泡是稳定的,快排是不稳定的。同样是按id排序后还要保持name的顺序,就不能用快排。Java自己的Arrays.sort对基本类型数组用DualPivotQuicksort(快排变体),对对象数组用TimSort(归并排序优化版),就是因为基本类型只看值不需要稳定,对象排序经常需要保持原相对顺序。
面试时如果能把“为什么快排不稳定”讲清楚,比如Lomuto分区中交换会把相等的元素相对顺序打乱,面试官对你的评价会比背一百道题高得多。
再说回到“Java基础常见问题总结”这个系列本身。每次写这类文章,我都尽量避开那种“百度一下就知道”的答案汇编,更希望把一个问题的上下文、踩坑过程和修复思路完整呈现出来。比如RedisTemplate那个报错,如果不讲序列化器机制,你就算搜到答案也只会复制粘贴配置,下次换个场景照样翻车。基础知识的价值就在这——它不直接给你答案,但它能让你读懂每一个报错背后发生了什么。后面还有余力的话,我计划再从热搜里挑一批问题继续写(4)和(5),比如设计模式的实际落地、Java容器的源码逻辑、泛型和反射的进阶用法。有想看的主题,也可以评论区留言,我会结合自己的实践经验来写。