Java基础面试核心:集合、JVM、多线程与高频报错实战解析
发布时间:2026/9/9 18:27:45 作者:尧图编辑部 阅读量:1,286

这个系列的第三篇本来想按老规矩把八股文再捋一遍结果整理目录的时候发现大家问得最多的往往不是某个知识点本身而是几个特别容易翻车的运行时环境和常见报错。比如有人分不清NoClassDefFoundError和ClassNotFoundException有人被Lombok的编译器警告卡了一下午还有人用RedisTemplate做自增统计结果Redis返回“value is not an integer or out of range”。所以这一篇我不打算只堆面试题而是把这些真实场景揉进去讲清楚底层逻辑。适合正在准备Java面试的朋友也适合刚入行想系统补一遍基础、想弄明白“为什么”而不是“是什么”的同学。内容上我会从集合框架的底层原理讲起再聊异常处理、JVM基础、多线程这几个大块最后补充几个高频报错排查和一个很多人忽略的环境变量问题。每部分都会配上我实际踩过的坑和面试官真正想听的回答思路。1. 集合框架面试官最爱问的底层原理和扩容细节集合这部分几乎是每一场Java面试的必考题但很多人栽在同一个地方背了HashMap是数组加链表、默认容量16、负载因子0.75却解释不清为什么要这么设计。面试官一旦追问“为什么是0.75而不是0.5或者1.0”场面经常冷下来。这一节我把几个最核心的点拆开讲。1.1 HashMap的存储逻辑从hash到红黑树HashMap的底层结构在JDK 1.8之后是“数组 链表 红黑树”。当你往里面put一个键值对时第一步是计算key的hash值然后把hash的高16位和低16位做异或处理。这个操作叫扰动函数目的是让高位信息也参与到低位计算中减少哈希碰撞的概率。第二步是用(n - 1) hash去定位数组下标这里n是当前数组长度并且必须是2的幂。为什么要保证容量是2的幂因为(n - 1) hash等价于hash对n取模但位运算比取模快得多。只有n是2的幂时n - 1的二进制才会是全1的形态这样低位才能完整保留hash的特征。所以就算你初始化时传了一个不是2的幂的容量HashMap内部也会通过tableSizeFor方法把它调整成最接近的2的幂。当不同key算出来的下标相同时链表的长度就会增加当链表长度达到8且数组长度达到64时链表会转成红黑树。注意这里有两个条件很多人只记了长度8却漏了数组长度64导致代码在特定场景下没有触发树化排查半天找不到原因。至于为什么选8官方的注释里给了一个泊松分布的参考数据简单理解就是正常情况下链表长度达到8的概率极低低到可以用数学期望忽略不计所以8是一个时间和空间上都很平衡的阈值。1.2 扩容机制与并发环境下的变化HashMap的默认负载因子是0.75意思是当元素个数超过容量 * 0.75时就要扩容。这个值不是随便定的太高会导致冲突增加、查询变慢太低会浪费空间0.75是官方在大量实验基础上选出的一个折中值。扩容时数组长度翻倍所有元素需要重新计算下标并搬移位置这个过程叫rehash。在JDK 1.7里并发put时扩容可能产生环形链表导致get死循环这是老生常谈的并发问题。JDK 1.8改成尾插法后环形链表的问题被解决了但这不代表HashMap能在并发环境下随便用。并发put仍然会导致数据覆盖、size不准确等问题。所以并发场景应该用ConcurrentHashMap而不是在HashMap外面套一层synchronized。ConcurrentHashMap在JDK 1.8里已经放弃了分段锁的设计改为CAS加synchronized锁的粒度从段细化到单个数组节点并发度大幅提升。面试时如果被问到“ConcurrentHashMap怎么保证线程安全”核心回答是插入时对数组节点做CASCAS失败再对链表头节点加synchronized扩容时用转移节点协助多线程迁移。这个回答比单纯说“分段锁”要高级也更贴合现在的版本。1.3 ArrayList扩容与快速失败机制ArrayList的扩容逻辑比HashMap简单得多但依然是面试官喜欢挖细节的地方。默认初始容量是10当容量不够时会扩容为原来的1.5倍也就是oldCapacity (oldCapacity 1)。扩容时会创建一个新数组再把旧元素复制过去这个过程如果发生在循环里性能损耗会非常大。所以如果提前能估算出元素数量直接调用new ArrayList(预期大小)能省掉很多次扩容拷贝。快速失败机制也是集合框架的高频考点。用ArrayList做遍历时如果中途有另一个线程调用了add或remove迭代器会抛出ConcurrentModificationException。原理是迭代器内部维护了一个modCount计数器每次结构性修改都会让modCount加一迭代器在读取元素时会检查当前modCount是否和初始化时一致。这个机制解决不了并发一致性问题只能“快速暴露问题”它的名字fail-fast就是这个意思。真正的并发场景建议用CopyOnWriteArrayList它通过写时复制来避免冲突适合读多写少的情况。2. 异常处理能把异常讲明白的人不多异常这块很多人以为背一下Exception和Error的区别就够了但实际面试中面试官更想听的是你怎么设计异常、怎么避免吞异常、怎么让异常信息跨层传递时还保持可读性。这也是项目代码里最容易暴露水平的地方。2.1 Checked与Unchecked的边界Java把异常分成两大类编译期异常Checked Exception和运行期异常RuntimeException/Unchecked Exception。前者像IOException、SQLException编译器强制你处理后者像NullPointerException、ArrayIndexOutOfBoundsException编译器不提醒但运行时会炸。很多人不理解为什么Spring的JdbcTemplate要把SQLException翻译成DataAccessException其实就是为了把受检异常变成不受检异常。受检异常强制的catch或者throws会严重侵入业务代码让每一层方法签名都带着“throws SQLException”而且底层异常细节暴露给上层并不安全。Spring的翻译思路是统一异常体系让业务层只需要关心自己期望处理的异常其他的一路向上抛最后由全局异常处理器兜底。2.2 try-with-resources和finally的取舍我见过很多老代码用finally来关闭流写法大概是先判断非空再调用close还要在catch里处理close的异常。这种写法最大的问题是如果try块里抛出异常同时close也抛出异常close的异常会覆盖原始异常导致真正的问题被掩盖。JDK 7引人的try-with-resources就是用来解决这个问题的。只要资源实现了AutoCloseable接口就可以写在try后面的括号里程序结束时会自动close。当你既有关闭异常又有业务异常时JVM会把业务异常设为原始异常关闭异常作为被抑制异常保存下来可以通过getSuppressed()查看。这个细节很能体现一个人的基本功面试时主动提到它效果比单纯说“try-with-resources可以自动关流”好得多。try (BufferedReader reader new BufferedReader(new FileReader(test.txt))) { String line reader.readLine(); // 业务处理 } catch (IOException e) { // 这里拿到的异常信息是完整的不会因为关闭失败被覆盖 log.error(读取文件失败, e); }2.3 异常设计包装、传递与日志的平衡很多人写代码喜欢在每个方法里catch异常然后log一遍等最后排查问题时发现日志里打出十几条一模一样的堆栈根本不知道源头在哪。我个人的习惯是底层方法不catch除非你确实有办法处理需要跨层传递时用自定义异常包装但一定要保留原始异常作为cause否则堆栈信息会断掉。常见的技术面试题“什么是异常链”考察的就是这个。异常链就是异常从底层一层层向上抛时每一层都用new BusinessException(xxx, e)这种方式把原始异常挂在后面最终在最外层能看到完整的调用链而不是只剩一句“系统繁忙”。还有一点很多人会忽略不要catch了异常后什么都不做。哪怕只是log.debug一下也比完全吞掉强。但更忌讳的是catch里打日志然后又throw一个不包含原始异常的新异常这样既打了两遍日志又丢掉了最关键的底层细节。我在code review时看到这种写法基本都会让改掉。3. JVM与类加载基础中的基础JVM相关的题目在面试中出现的概率极高但很多人只是在背概念比如“堆存对象、栈存引用、方法区存类信息”真被问到具体场景就答不上来了。这里我不打算把《深入理解Java虚拟机》全书复述一遍只挑几个面试和日常排查中用得最多的点。3.1 运行时数据区的分工与常见误解JVM的运行时数据区可以简单分成五块程序计数器、虚拟机栈、本地方法栈、堆、方法区。程序计数器是唯一不会OOM的区域用来记录当前线程执行到哪一行字节码。虚拟机栈对应每个线程每调用一个方法就会压入一个栈帧栈帧里有局部变量表、操作数栈、动态链接、方法出口。很多人以为“局部变量都存栈上”其实严格来说栈里存的是基本类型变量和引用类型变量的引用对象本身在堆里。方法区在JDK 8之后被“元空间”取代它的一个重要变化是字符串常量池移动到了堆中而类的元数据、运行时常量池等仍然在元空间。这导致一个很经典的问题为什么JDK 7之后String.intern()的结果和之前不一样。因为字符串常量池在永久代时你可以设置-XX:MaxPermSize触发PermGen OOM移到堆之后字符串常量池被普通堆管理频繁intern大量字符串就会变成堆内存的问题。3.2 类加载过程与双亲委派模型类加载分成加载、验证、准备、解析、初始化五个阶段面试中常问的是准备阶段和初始化阶段的区别准备阶段会为静态变量分配内存并设置默认值比如static int a 10这个阶段a的值是0真正赋值为10要等到初始化阶段因为初始化阶段才执行clinit方法。双亲委派模型建议用一句话概括一个类加载器收到加载请求时先不自己加载而是把请求委派给父加载器每一层都这样做直到父加载器无法加载时才由自己加载。这样做的目的有两个第一是防止核心API被篡改比如你自定义一个java.lang.String类加载时会先让Bootstrap ClassLoader去加载rt.jar里的String你的实现根本没机会生效第二是避免类重复加载同一个类不会出现两份不同的字节码。很多框架整合时会打破双亲委派比如Tomcat为了隔离不同web应用会自己先加载WebApp下的类。这个问题如果被问到“为什么要打破双亲委派”可以从容器隔离和热部署的角度回答。3.3 一次NoClassDefFoundError的排查实录NoClassDefFoundError是我工作中遇到频率最高的类加载问题之一。很多人把它和ClassNotFoundException混在一起其实两者完全不一样。ClassNotFoundException是显式调用Class.forName或ClassLoader.loadClass时类路径下压根找不到对应的类文件属于被检查的异常而NoClassDefFoundError是编译期这个类存在运行时JVM在加载其他类时发现某个依赖类缺失或者某个静态初始化块抛了异常导致类加载失败。我遇到过最典型的一个场景是老项目里有一段代码引用了java.applet.AppletJDK 8下跑得好好的升级到JDK 9以后启动直接报Uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet。原因是JDK 9引入模块化之后Applet相关API被从默认的java.se模块中移除了老代码在运行时找不到这个类于是抛出NoClassDefFoundError但很多人第一反应是去项目里找这个类怎么也找不到因为问题根源在JDK版本兼容性上。排查顺序建议是先看完整堆栈确定哪个类加载失败再用jdeps检查项目依赖里的JAR是否引用了已移除的API然后确认JDK版本和项目编译版本是否一致。如果是生产环境千万不要直接升级JDK版本就完事要结合项目的第三方依赖逐一验证。4. 多线程与并发高并发面试的敲门砖多线程是Java基础里最庞杂的一块从Thread到synchronized、volatile、Lock、ThreadLocal、线程池每一个都能往外延伸出大量问题。这一节我只挑三个最核心的考点synchronized的底层机制、volatile的作用、线程池的参数执行逻辑。4.1 synchronized的三种用法和锁升级过程synchronized是Java内置的关键字可以用来修饰实例方法、静态方法和代码块。修饰实例方法时锁的是当前实例对象修饰静态方法时锁的是当前类的Class对象修饰代码块时锁的是括号里指定的对象。JDK 6之后synchronized做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级过程。简单记忆刚开始无竞争时是偏向锁只有一个线程反复进入同步块锁会记录这个线程的ID避免每次加锁都走CAS当有另一个线程来竞争时偏向锁撤销并升级为轻量级锁轻量级锁用CAS和自旋来获取锁适合锁持有时间很短的场景竞争进一步加剧时就升级为重量级锁也就是依赖操作系统的互斥量此时线程会被挂起和唤醒性能消耗最大。面试官追问“为什么重量级锁慢”你要能答出因为涉及用户态和内核态的切换。这是操作系统层面的开销不是JVM能控制的所以高并发场景下要尽量减少synchronized持有时间。4.2 volatile的可见性和禁止重排序volatile是最容易被误解的关键字。它的两个核心作用保证变量的可见性以及禁止指令重排序。它并不保证原子性比如volatile int count多个线程执行count依然会丢数据因为count在字节码层面是读、加、写三步操作不是原子的。可见性的底层原理是对一个volatile变量执行写操作时JVM会插入内存屏障强制把当前线程工作内存中的变量值刷新到主内存读操作时会让其他线程工作内存中的该变量失效必须重新从主内存读取。禁止重排序的作用则主要体现在单例模式的双重检查锁里public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }如果不加volatileinstance new Singleton()这行代码在CPU级别的执行顺序可能被重排成先分配内存、赋值给引用、再执行构造函数。如果另一个线程在构造函数执行完之前读到了非空的instance就会拿到一个构造不完整的对象。但这又和“对象的半初始化”有关很多人只背结论不知道为什么我这里说清楚加volatile就是告诉JVM引用赋值操作不能越过内存屏障重排到构造方法之前。4.3 线程池核心参数与执行顺序线程池是Java并发编程里实务性最强的一块也是面试必问。核心类ThreadPoolExecutor有七个参数很多人只记得corePoolSize、maximumPoolSize、workQueue但问起执行顺序就乱了。正确的执行逻辑是提交任务时如果当前线程数小于核心线程数创建新线程执行如果当前线程数已经达到核心线程数任务进入阻塞队列等待如果阻塞队列也满了当前线程数小于最大线程数创建非核心线程执行如果当前线程数已经达到最大线程数执行拒绝策略。这里最容易被绕晕的是“队列满才创建新线程”而不是“核心线程和最大线程之间还差多少就补多少”。keepAliveTime针对的是非核心线程空闲超过这个时间就会被回收unit是时间单位threadFactory用来定制线程名和是否守护线程handler是拒绝策略。拒绝策略一共有四种AbortPolicy直接抛异常是默认策略CallerRunsPolicy由提交任务的线程自己执行适合不想丢失任务的场景DiscardPolicy默默丢弃DiscardOldestPolicy丢弃队列里最旧的任务然后重新尝试提交。5. 几个特别容易翻车的运行时问题排查实录从搜索热词里可以看出现在很多人搜Java基础题已经不局限于“什么是继承、什么是多态”这种纯概念题了更多的困扰来自实际运行时报错。这一节我专门挑三个近期高频出现的问题来写。5.1 Lombok编译警告You arent using a compiler supported by lombok这个报错我最近看到特别多完整提示是java: You arent using a compiler supported by lombok, so lombok will not work with your project。出现这个问题的原因通常是你使用的JDK版本比较新而项目里引入的Lombok版本太老Lombok的注解处理器还不认识当前JDK的编译器。解决方式分三步第一把Lombok升级到支持对应JDK的版本比如JDK 16以上推荐1.18.20JDK 21推荐1.18.30第二在IDE里确认安装了Lombok插件并开启Annotation ProcessingIDEA里位置在Settings - Build - Compiler - Annotation Processors勾选Enable annotation processing第三检查Maven或Gradle依赖里是否还有旧的Lombok传递依赖被带进来最好用mvn dependency:tree看一下。这个报错还有一个坑有时候升级了Lombok还是报错但错误信息来自Maven编译阶段那就要检查pom.xml里Maven Compiler Plugin的source和target版本是不是和JDK版本不匹配。比如用JDK 17编译却把source/target设为1.8某些注解处理路径会出现怪异问题。5.2 RedisTemplate调用increment()value is not an integer or out of range有人用RedisTemplateString, Object去执行redisTemplate.opsForValue().increment(key)结果Redis报ERR value is not an integer or out of range。这个问题看着像value格式不对实际原因往往是序列化器的问题。RedisTemplate默认用的是JdkSerializationRedisSerializerkey和value都会经过Java序列化。序列化之后字符串类型的key在Redis里存的是带二进制头部信息的东西当你对同一个key执行INCR命令时Redis端看到的是一个字节数组不是整数于是直接拒绝。而StringRedisTemplate默认用StringRedisSerializervalue以明文字符串存储Redis才能正确解析为整数。解决方式有两种第一如果不需要复杂对象直接用StringRedisTemplate来执行increment第二如果要用RedisTemplate把key和value的序列化器都改成StringRedisSerializer。我习惯在配置类里统一初始化一个RedisTemplate Bean专门指定key和hashKey用String序列化器这样能避开大部分误用问题。5.3 Java 8的lambda三个容易让人懵的细节lambda表达式是Java 8引入后最常用的新特性但使用中还是有几个细节经常被问到。第一lambda表达式里引用的外部局部变量必须是effectively final也就是变量值一旦赋值就不能再修改。这个限制的主要原因是lambda本质上是一个匿名内部类它捕获外部变量时是通过方法参数传递的副本而不是直接操作原始变量。既然操作的是副本就必须保证原始变量永远不会变化否则语义就乱了。第二lambda里处理受检异常很别扭。如果你在lambda体里调一个会抛IOException的方法编译器会要求你用try-catch包起来因为Runnable和Function这类函数式接口的方法声明不包含throws。解决办法是包装成自定义的Unchecked函数式接口或者直接在lambda内部try-catch然后转成RuntimeException抛出。第三stream的peek方法不是forEach。peek是中间操作它不会触发流的遍历只有遇到终止操作如collect、forEach时才会执行。很多人调试时想在peek里打印一些信息结果发现不打印以为代码没执行其实项目里没有触发流消费。6. 手写题与机制题冒泡排序和动态代理这类代码题怎么答现在的Java面试特别是一线大厂越来越喜欢让候选人现场手写代码而且不满足于“能写出来”还要追问优化和底层机制。这里挑两个高频例子展开。6.1 冒泡排序能写出优化版本才是加分项冒泡排序几乎是所有面试准备者都会背的算法但真正被问到时很多人只写了最原始的版本完全没有体现优化意识。最早的冒泡排序版本是这样的public static void bubbleSort(int[] arr) { for (int i 0; i arr.length - 1; i) { 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; } } } }优化点至少有两个。第一每轮结束后如果发现没有任何交换说明数组已经有序可以直接break。第二记录最后一轮发生交换的位置这个位置之后的元素已经有序下一轮只需要遍历到这个位置之前。这两种优化在随机大数组上虽然改变不了O(n²)的时间复杂度但在近乎有序的数据上性能提升非常明显。面试时主动写出这两点比背十道LeetCode印象更深。6.2 动态代理JDK代理与CGLIB的本质差异动态代理是Spring AOP的底层基础也是Java核心机制里被问到“绕不开”的题。很多人只会说“JDK动态代理基于接口CGLIB基于继承”但再往下问“为什么Spring Boot默认用CGLIB”就卡住了。JDK动态代理的用法是实现InvocationHandler接口通过Proxy.newProxyInstance生成代理对象。它生成的代理类已经实现了目标接口所以只能代理接口类型。如果你的业务类没有实现任何接口JDK代理就无能为力。CGLIB的原理是生成目标类的子类通过继承来代理方法调用。因为它用的是继承所以final方法不能被代理final类也不能被代理。Spring在早期版本里如果目标对象实现了接口就优先用JDK代理否则用CGLIBSpring Boot 2.x之后默认就改成CGLIB了因为CGLIB的性能在近几个版本已经追平甚至超过JDK代理而且不需要强制要求业务类实现接口使用起来更省心。面试时如果能补一句“JDK代理每次调用要走InvocationHandler的invokeCGLIB通过FastClass机制缓存方法索引调用效率更高”会显得你真的研究过机制而不是只背结论。6.3 双检锁与枚举单例手写单例的扩展点手写单例也是面试高频题。最简单的写法是饿汉式但考虑到懒加载很多人会写双重检查锁。这部分已经在volatile小节讲过但面试官还喜欢追问“为什么不用synchronized修饰整个getInstance方法”。答案是锁的粒度太大每次调用都会串行执行性能损耗严重。双检锁只是在实例为空的时候加锁初始化完成后再进来就直接返回已有的对象。还有一个冷门的考点是用枚举实现单例。enum Singleton { INSTANCE }这种写法天然线程安全而且可以防止反序列化破坏单例。大多数人在项目里不会真的这样写但面试时能提出来说明你对单例的各种实现方式都有过思考。7. 环境变量配置与八股文之外的复习建议写到最后这一节想聊一件看起来很小、但很多人会卡住的事情Java环境变量配置。别笑这个真的经常出现在搜索热词里。很多人装完JDK第一时间就去改环境变量结果怎么配都不对。7.1 JAVA_HOME、PATH和CLASSPATH的角色环境变量配置只需要三步先确认JDK安装目录然后在系统变量里新建JAVA_HOME值为JDK安装路径比如C:\Program Files\Java\jdk-17接着在PATH变量里新增%JAVA_HOME%\bin最后打开新终端执行java -version验证。这里有两个常见误区第一是只配了PATH没配JAVA_HOME导致很多基于Java的中间件找不到JDK第二是还要配CLASSPATH这个变量在JDK 9之后已经基本没有配置的必要了旧教程里让人配CLASSPATH.;%JAVA_HOME%\lib那是早期版本为了找dt.jar和tools.jar的遗留做法现在再配反而可能引发类加载问题。注意修改完环境变量后一定要新开一个命令行窗口再执行java -version因为已经打开的控制台不会自动刷新环境变量。这个问题我见过太多次不是没配好是没刷新。7.2 从八股文到实战面试怎么准备才算有效关于“Java基础面试题”和“Java面试八股文”这类搜索词我的看法是八股文可以背但不能只靠背。面试官现在问问题的方式早就变了同样的知识点他会包装在场景里问你。比如不直接问“HashMap和Hashtable有什么区别”而是问你“有一万次并发写操作该用哪个集合”这时候你就要能联想到ConcurrentHashMap的CAS加synchronized设计。我自己准备面试时的习惯是每看到一个知识点就问自己三个问题——它解决什么问题不解决什么问题底层是怎么实现的比如看到volatile就接着想原子性怎么解决、什么时候会用到它、内存屏障是什么。这样把一个知识点往外扩一圈面试时不管怎么追问都能接住。这个系列整理到第三篇我最大的感受是Java基础看着知识点多其实脉络很清晰核心就是集合、异常、JVM、多线程外加几个高频框架机制。把这些基础吃透了面试和日常开发都能稳不少。最后再分享一个小技巧复习时准备一个自己的报错记录文档遇到NoClassDefFoundError、increment()报错这类问题就写进去时间长了你会发现面试官问的很多问题其实都是你踩过的坑的变体。