1. 环境变量配置:别以为这是送分题
1.1 为什么JAVA_HOME比直接配Path更重要
我在面试Java工程师的时候,第一轮必问环境变量配置,结果很多人直接说“这题我会,配个JAVA_HOME和Path就行了”。但真让他解释为什么非要搞出一个JAVA_HOME,而不是把JDK的bin目录直接塞进Path,十个里有八个答不上来。
这里面的逻辑其实很简单。JAVA_HOME是一个约定的入口变量,Maven、Tomcat、Gradle、IDEA这些工具在启动的时候,都会去找这个变量定位JDK的安装目录。如果你只改Path,那只是让命令行能执行java和javac,但Maven和Tomcat依然找不到JDK。另一个核心原因是方便切换版本,升级JDK的时候只需要改JAVA_HOME这一个变量,Path里用%JAVA_HOME%\bin引用它就行,不用到处改。
实际配置时,Windows系统要注意的是JAVA_HOME的值不要带末尾的分号,Path里使用%JAVA_HOME%\bin而不是写死具体路径。Linux和macOS则是在~/.bashrc或~/.zshrc里导出变量。很多人在服务器上配好了但不生效,基本都是一个原因:重新开的终端窗口才加载新配置,当前窗口不会自动刷新。
1.2 classpath在JDK 9之后的变化
还有一个隐藏考点很多人不知道:JDK 9之后,默认的classpath已经不再包含当前目录“.”了。早期JDK版本里,java命令运行时默认会从当前目录找类,这个行为在后来的版本中被移除。面试官如果问你“classpath和模块路径的区别”,本质上是考你对Java模块化(JPMS)的理解深度。
我不建议死背这些细节,但至少要能在实际遇到包找不到类的时候,知道先去查classpath和JAVA_HOME,而不是盲目重启或者重装JDK。
2. 集合框架:背答案容易,讲清楚设计取舍难
2.1 HashMap的关键参数为什么是0.75和2的幂
集合类是Java面试的重灾区,几乎人人都在背八股文,但真正能把"为什么"讲明白的人很少。以HashMap为例,有两个参数被问得最多:默认负载因子为什么是0.75,初始容量为什么必须是2的幂。
容量是2的幂,是为了让hash & (n - 1)这个位运算能替代取模运算计算数组下标。因为n - 1的二进制低位全是1,与hash值做与运算,结果就等价于hash % n,但位运算的性能更快。负载因子0.75不是拍脑袋定的,它是时间和空间的折中方案:阈值太高,哈希冲突概率增大,链表会变长;阈值太低,扩容太频繁,浪费空间。JDK源码注释里给出的参考是基于泊松分布,0.75的情况下链表长度达到8的概率已经极低,所以链表转红黑树的阈值设为8。
面试的时候如果能把这个推导过程讲出来,就不像是在背书,而是真有理解。
2.2 重写equals为什么必须重写hashCode
这个问题也高频出现。很多人能背出结论:“重写equals必须重写hashCode,否则HashMap等集合会出问题。”但具体出什么问题,描述不清楚。
HashMap的get和put流程是先用key的hashCode定位桶,再用equals比较链表或树里的具体元素。如果你只重写了equals但没重写hashCode,两个逻辑上相等的对象会计算出不同的hashCode,它们会被放进不同的桶里。你new了一个属性完全一样的对象去get,结果按它的hashCode找到另一个桶,永远get不到之前put的值。Map的size也会变得诡异,出现“看起来只有一个key但size大于1”的情况。
这个考点背后考察的是hashCode和equals的约定关系,不只是在HashMap里,在HashSet、Hashtable、LinkedHashMap里都一样。
2.3 ConcurrentHashMap的演进逻辑
ConcurrentHashMap也是一个必考题。JDK 7的实现是分段锁(Segment),每一段是一把ReentrantLock,锁的粒度是段;JDK 8改成了CAS + synchronized,锁的粒度是桶的头节点,并发度更高。面试官问这个变化,实际是想看你是否理解并发环境下锁粒度对性能的影响。
我面试别人的时候喜欢追加一个问题:“synchronized在JDK 8的ConcurrentHashMap里为什么性能不差?”这就要引出锁升级机制了——无锁状态下的CAS竞争不成功时才升级到轻量级锁,再升级到重量级锁,大多数场景下锁竞争并没那么激烈,synchronized的开销比想象中低很多。
3. 并发编程:从锁升级到线程池参数的完整回答链路
3.1 synchronized的锁升级过程
并发这块连着考是常态。先把synchronized的锁升级链路理清楚:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。偏向锁是针对只有一个线程反复进入同步块的情况,通过CAS在对象头Mark Word里记录线程ID,避免每次加锁都走CAS。一旦出现第二个线程竞争,偏向锁撤销并膨胀为轻量级锁,轻量级锁通过自旋+CAS抢锁,不阻塞线程。自旋超过阈值或自旋线程数过多,就膨胀为重量级锁,进入内核态阻塞等待。
这里有个容易掉坑的表述:很多人说“JDK 15之后偏向锁被废弃了”,但这并不影响你在面试中回答问题。偏向锁在JDK 15被标记为废弃,JDK 18之后默认关闭。如果面试官追问,能说出这个版本演进信息是加分项。
3.2 volatile和DCL的指令重排问题
volatile的关键字考察点是可见性和有序性。可见性是指变量修改后立即刷新到主内存,其他线程能读到最新值;有序性是指通过内存屏障禁止指令重排序。但volatile不保证原子性,比如count++这种复合操作,用volatile声明依然会有并发问题。
配合单例模式的DCL(双重检查锁)来问,是经典中的经典。instance = new Singleton()这一步在字节码层面是三步:分配内存、初始化对象、把引用赋值给变量。其中第二步和第三步有可能被CPU重排序,导致另一个线程拿到了一个已经赋值但还没完成初始化的对象,访问它的属性时空指针。加上volatile之后,通过内存屏障禁止了这个重排序,问题才解决。
3.3 线程池参数与任务提交过程的完整回答
线程池是必考题。ThreadPoolExecutor的七个参数:corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、workQueue、ThreadFactory、RejectedExecutionHandler,要能把整个执行流程串起来讲:
任务提交后,如果当前线程数小于corePoolSize,就创建核心线程执行任务;如果等于corePoolSize且队列没满,任务进入阻塞队列等待;如果队列满了且线程数小于maximumPoolSize,就创建非核心线程执行任务;如果线程数已经达到maximumPoolSize,就触发拒绝策略。
这四个步骤顺序不能乱,面试官追问生产环境怎么设参数时,我的建议是:CPU密集型任务,线程数设置为CPU核心数+1;IO密集型任务,线程数设置为CPU核心数*2左右,因为IO等待时不占用CPU,可以多开线程提高吞吐。拒绝策略默认是AbortPolicy,直接抛RejectedExecutionException,实际生产中更常用CallerRunsPolicy,让提交任务的线程自己执行,起到降速的作用。
4. JVM异常实战:NoClassDefFoundError和ClassNotFoundException能有什么区别
4.1 两个异常的本质差异
这个知识点很能区分候选人是背题还是真做过排查。ClassNotFoundException是显式加载类时抛出的,比如Class.forName()、ClassLoader.loadClass(),因为类路径里找不到对应的类。它是受检异常,编译器就会提示你要catch。
NoClassDefFoundError则完全不同,它是Error级别的错误,不是异常。它的出现场景是:类在编译时存在,但运行时JVM在链接阶段找不到这个类的定义。更隐蔽的情况是——类本身在classpath里存在,但它依赖的另一个类不存在,或者类的静态初始化块抛了异常导致类初始化失败,JVM在后续指令引用这个类时就抛NoClassDefFoundError。
4.2 一次真实的排查链路
之前遇到一个启动报错:java.lang.NoClassDefFoundError: java/applet/Applet,第一眼以为代码里用了Applet导致JVM找不到这个类。但Java 9之后Applet API已经被标记废弃并从默认模块里移除了,JDK 11之后直接删掉,所以这个类在标准JDK里确实找不到了。
完整的排查链路应该是这样的:
- 先看堆栈里报错的第一行,确认是哪个类触发的NoClassDefFoundError。
- 用
jar tf检查依赖的jar包,确认类文件是否真的存在。 - 用
mvn dependency:tree查看依赖树,排查jar包版本冲突。 - 检查这个类是否有静态初始化逻辑,是否有依赖外部服务或读取配置文件,静态块异常会导致类初始化失败。
- 确认JDK版本是否移除或者改变了这个API的位置。
实际项目中,这种报错往往不是缺jar那么简单,而是某个第三方依赖在旧JDK下编译,运行在新JDK上时引用了被移除的API。先升级依赖到兼容新JDK的版本,再考虑其他方案。
4.3 面试官想听到的回答层次
回答这类问题时,不要只说“一个是找不到类一个是没有类定义”,要给出层次感:
- ClassNotFoundException:字节码层面,类加载器主动加载时找不到。
- NoClassDefFoundError:链接阶段出问题,可能类不存在、依赖缺失、初始化失败。
- 两者都有可能出现于同一个堆栈里,通常是NoClassDefFoundError作为外层表现,ClassNotFoundException作为内部原因。
这个回答顺序能体现出你实际排查过问题,不是只背了概念。
5. Redis实战:increment()报错的序列化根因
5.1 报错现象和直接原因
Redis在高频面试里不断出现,但真正跑过业务的人会发现,实际开发中踩的坑和面试题完全是两码事。比如这个报错:ERR value is not an integer or out of range,来自RedisTemplate的increment()调用。
先说结论:这个报错不是Redis本身的问题,而是你的客户端把value的类型搞错了。Redis的INCR命令要求key对应的是一个整数字符串,比如"1"、"100",如果value不是整数或超出64位有符号整数范围,直接报错。但很多人在Spring Boot项目里用RedisTemplate往Redis里写数据,默认的序列化器是JdkSerializationRedisSerializer,它会把Java对象序列化成一串JDK二进制格式的字节数组。存进去的“数字”不是字符串"1",而是一串乱码一样的序列化字节,INCR命令拿过来一解析,发现不是整数,于是报了value is not an integer。
5.2 为什么StringRedisTemplate能解决问题
这个问题的标准解法是换用StringRedisTemplate。它内部用的是StringRedisSerializer,value会被序列化为可读的字符串。你用StringRedisTemplate去set("counter", "100"),Redis里存的就是字符串"100",再调increment("counter")就能正常加1。
但为什么很多人没有立即发现呢?因为Spring Boot的自动配置里,RedisTemplate的valueSerializer默认是JdkSerializationRedisSerializer,keySerializer默认也是它,如果你只是往里存字符串,字节序列化本身也能存能读,看起来一切正常。只有当你用一个客户端工具(比如Another Redis Desktop Manager)去看数据时,才发现value是一串base64编码或二进制乱码,根本没法读。
这里有一个实用的经验:项目里如果只用Redis存普通的key-value,建议直接统一用StringRedisTemplate,或者手动把RedisTemplate的key和value序列化器改成StringRedisSerializer/StringSerializer。尤其是做分布式锁、计数器、限流这类操作时,Redis里的value必须是符合命令要求的数据类型,序列化方式不对,各种幺蛾子都会冒出来。
5.3 计数器场景的完整代码示例
@Autowired private StringRedisTemplate stringRedisTemplate; public Long incr(String key, long delta) { // 底层执行 INCRBY key delta,返回最新的值 return stringRedisTemplate.opsForValue().increment(key, delta); }如果是Hash结构里的字段做自增,用opsForHash().increment(key, field, delta),但要注意Hash里的值同样必须是字符串形式的整数,如果写入时用的是对象序列化器,一样会踩坑。
顺便说一个并发扣减库存的实践:用Redis的DECR命令扣减库存时,先SET一个初始值,然后DECR,返回值小于0说明库存不足,需要回补并丢弃本次操作。这个方案比先GET再SET靠谱得多,因为GET和SET之间不是原子的。
6. Java动态代理:JDK与CGLIB的选择逻辑
6.1 两种代理的核心机制
动态代理在Java面试里的出现率极高,因为它直接关联Spring AOP、MyBatis Mapper、Retrofit等一堆常用框架。JDK动态代理基于接口实现,核心类是Proxy和InvocationHandler。运行时通过Proxy.newProxyInstance创建一个实现了指定接口的代理类实例,所有方法调用都会被转发到InvocationHandler.invoke()方法里。
CGLIB基于继承实现,运行时通过ASM库生成目标类的子类,重写目标类的方法,在重写的方法里执行拦截逻辑。因为用的是继承,所以CGLIB无法代理final类,也无法代理final方法。
6.2 Spring Boot到底默认用哪种
这个点是高频追问,很多人记混了。Spring Framework早期版本中,如果目标类实现了接口,默认用JDK动态代理;没有实现接口,才用CGLIB。但从Spring Boot 2.x开始,默认开启了proxy-target-class=true,也就是无论目标类是否实现接口,都优先使用CGLIB。Spring Framework 6.0之后也把这个策略统一了。
面试官如果继续追问“CGLIB和JDK代理哪个快”,深入的知识点是:JDK 8之后JDK动态代理的性能已经大幅提升,在方法调用次数少的场景下差距不明显,CGLIB创建代理类时因为要生成字节码,创建速度会更慢,但方法执行性能可能更好。实际上现代Spring项目中JDK代理和CGLIB的性能差异已经不足以成为选型依据,更关键的是看目标类是否实现了接口,以及是否需要代理类本身继承某个类。
6.3 手写一个JDK动态代理Demo
interface UserService { String findName(Long id); } class UserServiceImpl implements UserService { @Override public String findName(Long id) { return "user-" + id; } } class LogInvocationHandler implements InvocationHandler { private final Object target; LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("before method: " + method.getName()); Object result = method.invoke(target, args); System.out.println("after method: " + method.getName()); return result; } } public class ProxyDemo { public static void main(String[] args) { UserService target = new UserServiceImpl(); UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogInvocationHandler(target)); proxy.findName(1L); } }面试时把这个demo写出来,再顺着讲Spring AOP如何利用动态代理实现事务管理和切面,能拿到的分数远超单纯背书。
6.4 Lambda函数式编程和动态代理的关联
热搜词里有lambda函数,别以为这跟动态代理没关系。Java 8引入的函数式接口,配合Lambda表达式,大量简化了匿名内部类的写法。这在动态代理里体现得很明显——InvocationHandler是一个函数式接口,用Lambda改写上面的demo,代码会简洁很多。不过在实际项目里,动态代理的实现通常被框架封装好了,程序员使用Lambda的场景更多是Stream操作和函数式回调,面试时两者拆开考,但底层逻辑都是围绕接口和方法的抽象。
7. 手写冒泡排序:从能写到写好的距离
7.1 为什么冒泡排序在面试里经久不衰
很多候选人觉得冒泡排序太简单,不屑于准备,但面试官考冒泡排序的目的从来不是考排序本身,而是看你的代码规范、边界处理能力和优化意识。一个能在几分钟内写完基础版,并且主动提到优化点的人,代码功底大概率比只会背快排的人扎实。
7.2 基础版与两种优化
先写一个基础版:
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; } } } }第一种优化是标记法。如果某一轮遍历中没有发生任何交换,说明数组已经有序,直接结束循环:
public static void bubbleSortOptimized1(int[] arr) { int n = arr.length; boolean swapped; for (int i = 0; i < n - 1; i++) { 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; } } }第二种优化是记录最后一次交换的位置。每一轮排序后,最后一次交换发生的位置之后的元素已经有序,下一轮只需要遍历到这个位置即可。
第三种是鸡尾酒排序,即双向冒泡,一轮从左往右冒泡,一轮从右往左冒泡,适合大部分元素已经有序的场景。
7.3 手写代码时最容易翻车的细节
冒泡排序代码不长,但翻车点不少:
- 外层循环的边界是
n-1,不是n。因为每轮确定一个最大值的位置,最后一个元素不需要再比较。 - 内层循环的边界是
n-1-i,不是n-i-2,写错会导致数组越界或漏比较。 - 交换元素时用临时变量,不要用
a=a+b; b=a-b; a=a-b这种技巧,可读性差且可能溢出。 - 面试时先问清楚是升序还是降序,排序规则不同,比较符号就不同。
- 复杂度要能脱口而出:最好O(n)、最坏O(n²)、平均O(n²)、空间O(1)。
8. Lombok编译器报错:从现象到原理
8.1 报错信息与直接原因
最后聊一个构建工具层面的报错,热搜词里有you aren't using a compiler supported by lombok, so lombok will not work。这个报错我见过很多新人项目出现,第一反应是去改IDE设置或者重新下载lombok,但真正的原因通常是JDK版本和lombok版本不匹配。
Lombok通过JSR 269(注解处理器)机制在编译期修改AST语法树,在生成字节码之前插入getter、setter、toString等方法。JDK版本升级后,编译器的内部AST结构和注解处理时机可能发生变化,旧版Lombok无法识别新的编译环境,于是直接跳过处理逻辑,并打出这个警告。
8.2 处理这个问题的顺序
处理步骤按顺序来:
- 确认项目的JDK版本,比如JDK 11、17、21。
- 查看lombok版本,如果低于1.18.20,大概率不兼容新JDK,升级到1.18.20以上。JDK 17用户建议用1.18.24+,JDK 21用户用1.18.30+。
- Maven项目可以显式指定annotationProcessorPaths,让编译器明确使用对应版本的lombok,避免和IDE内置的注解处理器冲突。
- Gradle项目则需要在dependencies里明确声明annotationProcessor,只写compileOnly是不够的。
8.3 Maven中的配置示例
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> </dependency>如果项目里用的Maven编译插件版本较老,也可以在maven-compiler-plugin里加annotationProcessorPaths:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> </path> </annotationProcessorPaths> </configuration> </plugin>8.4 更深层的排查思路
这个问题的本质,是编译器版本和注解处理器版本的兼容性问题。类似的坑还有Lombok与MapStruct同时使用时注解处理器冲突,以及Lombok与Java Record类型一起使用时生成的equals/hashCode不兼容等。遇到这类构建层面的报错,先看版本兼容矩阵,再看编译器参数,最后才怀疑IDE缓存,这个排查顺序能省下大量时间。
我自己实际干活的时候,有一个习惯:所有用Lombok的项目,都会在pom里固定lombok版本,并且升级JDK时同步升级lombok。虽然听起来很简单,但大部分生产事故都是因为不重视这种“小依赖”的版本升级引发的。
另外,还有一个小细节给用Maven打包的人提醒一下:如果本地IDE里运行没问题,但打包时报Lombok相关的错,多半是maven-compiler-plugin版本太老,和当前JDK不兼容。把编译插件升到3.11.0以上,问题基本能解决。