
这份“Java测验4”其实是我给自己布置的一套自测题复盘。做了这么多年Java开发我每年都会挑几套基础题来检验基本功不是为了面试前临时抱佛脚而是想看看哪些细节已经开始模糊了。测验4这套题覆盖的范围很典型面向对象、集合框架、JVM内存、Lambda与Stream、枚举、常用类、异常处理还有几个在真实项目里特别容易踩的编译运行问题。如果你正在准备Java面试或者刚学完Java基础想检验掌握程度这套题的分析应该对你有用。我在整理这套测验时并没有像网上很多“Java面试八股文”一样只给答案而是把每个考点背后的设计逻辑、容易忽略的边界情况、以及实际开发中对应的场景都梳理了一遍。因为题可以背但理解不会骗人。接下来我按测验的模块顺序把整套题的核心内容拆开讲同时把我在做题和复盘过程中踩过的坑、总结的经验一并写出来。1. 内容整体设计与思路拆解1.1 为什么用“测验”的形式做复盘很多人学Java喜欢看视频、看博客看的时候觉得“都会了”一到写代码就卡壳。我自己的经验是知识以为自己会了不算会能在限时、不查资料的情况下准确回答出来才算真正内化。所以我会定期做一套自测题模拟面试或者基础考核的场景强迫自己把脑子里的知识“输出”一遍。测验和直接看面试题集最大的区别在于测验带有时间压力和反馈闭环。我做测验4的时候给自己定了每道题最多15分钟超时就算不会。这种压力能很真实地暴露薄弱点——有些知识点你觉得自己熟可真到写答案的时候才发现连概念都说不利索。做完之后再逐题复盘比单纯刷一百道“Java面试大全”有效得多。另外测验题目的设计最好不要全是概念题。纯理论背诵没有意义我在这套测验里刻意加入了代码分析题、异常排查题、参数配置题因为这些才是工作中真正会遇到的事情。比如java: 警告: 源发行版 17 需要目标发行版 17这个编译问题几乎每个从旧版本JDK切到17的人都会碰到但它很少出现在传统面试题里。1.2 测验范围覆盖基础语法到实战陷阱这套测验4的考察范围我按照Java开发中最常用的知识域来划分。首先是基础语法层面包括标识符命名、运算符与表达式、数组边界、String与包装类其次是面向对象核心包括继承、多态、重载重写、接口与抽象类的选择然后是集合框架重点是HashMap、ArrayList、TreeMap这些高频容器的底层行为和选型再往后是JVM内存与异常处理这部分结合了OutOfMemoryError、栈溢出等实际问题的排查思路最后是Java 8之后的新特性Lambda、Stream、方法引用、枚举、Comparator.comparing这类实用工具。这个范围设计是有讲究的。我见过太多人工作三五年Spring Boot用得飞起但让他说说HashMap在JDK 8里的结构变化或者ArrayList默认容量为什么是10就说不上来。而这些恰恰是Java基础面试题里出现频率最高的部分也是很多“八股文”喜欢考的点。我的观点是八股文本身不可怕可怕的是只背结论不追问原因。所以这套测验的每个题目我都会在后面追问一个“为什么”。1.3 难度梯度从送分题到拉分题一套好的测验必须要有层次感。测验4的题目我分了三个梯度第一梯度是概念题比如“Java标识符能否以数字开头”“和equals的区别”这些是送分题用来检验基础是否扎实第二梯度是代码分析题比如给一段循环代码问输出结果、判断数组越界的位置这些需要动笔推演第三梯度是综合排查题比如“启动项目时出现OutOfMemoryError: insufficient memory怎么定位”答案不唯一考察的是实战思路。难度梯度的设计直接决定了测验的诊断价值。如果全是送分题所有知识点看起来都掌握了实际能力却未必如果全是难题挫败感太强也不利于系统复习。我建议你在给自己出题时也按这个比例设计60%基础、30%综合、10%挑战。这样既保证覆盖面又能把真正薄弱的地方逼出来。2. 核心细节解析与实操要点2.1 面向对象继承、多态、重载与重写面向对象编程Java的核心思想在这套测验里占了相当大的比重。我这套题里有这样一道题父类有一个方法public void run()子类重写后改成protected void run()问能不能编译通过。答案是编译报错因为重写时访问权限不能更低。很多人背过“重写规则”但做题时还是会选错根本原因是没有理解“里氏替换原则”——子类必须能无缝替换父类出现在任何地方如果访问权限变严外部调用父类方法的地方就访问不到子类方法了。另一个高频考点是重载和重写的区别。重载是同一个类里方法名相同、参数列表不同编译期就决定了调用哪个版本重写是子类重新实现父类方法运行期根据对象实际类型决定调用哪个版本。测验里我给了这样一段代码class Animal { public String speak() { return animal; } } class Dog extends Animal { public String speak() { return dog; } } public class Quiz4 { public static void main(String[] args) { Animal a new Dog(); System.out.println(a.speak()); } }输出结果是dog因为sleep方法被重写了JVM在运行期动态绑定到Dog的实现。这个考点几乎是所有Java面试的必问题也是检验是否理解多态最直接的样例。我还加了一个追问“如果speak是静态方法输出结果是什么”答案是animal因为静态方法属于类不存在重写只存在隐藏。这个细节能筛掉一大批背题人。在实际开发中我见过很多因为重写规则理解不到位导致的诡异Bug尤其是equals和hashCode一起重写的问题。测验4里专门有一道题问只重写equals不重写hashCode会有什么后果放到HashSet里时可能会存储两个逻辑上相等的对象因为它们哈希值不同被放到了不同的桶里。这是一个非常经典的坑也是集合框架正确使用的前提。2.2 集合框架选型对比与线程安全集合是Java里使用频率最高的类库之一测验4里我出了不少选型题。比如“已知元素需要按自然顺序排序且元素唯一应该选什么容器”答案是TreeSet。很多人下意识选HashSet因为“去重用HashSet”却忽略了“排序”这个条件。TreeSet底层是TreeMap基于红黑树实现插入时按Comparator或Comparable排序代价是插入和删除的时间复杂度为O(log n)比HashMap的O(1)慢但换来了有序性。另一个经典问题是ArrayList和LinkedList的对比。网上的Java基础面试题几乎必考这个但很多人只背“ArrayList查询快、增删慢LinkedList增删快、查询慢”这个结论其实很粗糙。真实情况是在中间插入元素LinkedList只是不需要搬移后续元素但它需要先遍历找到插入位置这部分也是O(n)而ArrayList虽然需要System.arraycopy搬移元素但批量搬移在JIT优化后并不一定慢。所以我的建议是业务代码里90%的场景用ArrayList就好LinkedList的优势场景非常窄。这套题里我专门设置了一个代码分析题让做题者判断“for循环里用LinkedList.get(i)遍历的复杂度”正确答案是O(n²)因为每次get都是从头部开始遍历。线程安全集合也是我一直强调的重点。测验中有一道题问多个线程同时往HashMap里写数据会出现什么。这个问题在JDK 7里可能导致扩容时的环形链表从而引发死循环JDK 8改成了尾插法死循环问题解决了但依然存在丢数据、覆盖等线程安全问题。正确的做法是使用ConcurrentHashMap它通过CAS加synchronized锁桶的方式实现高并发读写比给整个HashMap加锁要精细得多。2.3 异常处理与资源关闭异常处理这块测验4里考了几个特别容易混淆的细节。第一个是finally块到底什么时候不执行。结论是只有在System.exit()或JVM崩溃时finally才不会执行。很多人以为return了就不执行finally了实际上finally会在方法返回前执行。我出了一道题try { return 1; } finally { return 2; }输出是多少答案是2。因为finally里的return会覆盖try里的return这在实际代码里是极其危险的操作所以我建议无论如何不要在finally里写return。第二个是受检异常和非受检异常的选择。IOException、SQLException这类受检异常强制要求调用方处理而NullPointerException、ArrayIndexOutOfBoundsException这类RuntimeException子类不需要强制捕获。我见过很多团队在业务代码里疯狂抛出Exception导致调用方被迫写一堆空的catch块。我的理念是编程错误用非受检异常外部环境异常用受检异常但千万不要把所有异常都抛成Exception那样等于什么都没说。第三个是try-with-resources。JDK 7之后凡是实现了AutoCloseable的资源都可以写在try后面的括号里JVM会自动关闭。测验里我让做题者比较两段代码一段是传统写法里手动close()一段是try-with-resources要求说出后者能避免什么问题。答案是可以避免在关闭资源时抛出新的异常把原始异常覆盖掉。try-with-resources会保留原始异常被抑制的异常会被添加到Suppressed数组里这在排查问题时太重要了。2.4 JVM内存OutOfMemoryError与启动参数java: OutOfMemoryError: insufficient memory这个错误看到就头疼。热词里出现了它我在测验4里也专门出了一道JVM调优题。要理解这个错误得先知道JVM内存分哪几块堆内存存放对象实例栈存放局部变量和方法调用元空间存放类元数据还有直接内存等区域。常见的OOM分为三类OOM类型触发场景典型排查命令java heap space堆内存不足以分配新对象常见于大对象或内存泄漏jmap -heap pid查看堆使用情况GC overhead limit exceededGC频繁回收但回收效果差GC时间占比超过98%jstat -gcutil pid观察GC曲线insufficient memory原生内存分配失败常见于线程数过多或直接内存溢出jmap -dump结合top看进程内存我在实际项目里遇到过一种典型情况测试环境堆内存只配了512M压测时一下涌入大量请求每秒钟产生几十万个对象触发频繁Full GC最后直接报GC overhead limit exceeded。这种问题不能只看报错信息要用jstat观察GC频率再用jmap -dump:formatb,fileheap.bin pid导出堆快照用MAT分析哪些对象占据了最大空间。对于启动参数我常用的配置思路是-Xms和-Xmx设置成一样的大小避免运行期堆扩容带来的性能抖动-Xss控制线程栈大小默认1M如果应用开了大量线程可以考虑适当调小到512K但不建议再低否则容易栈溢出。-XX:MaxMetaspaceSize一定要显式设置因为元空间默认是没有上限的一旦框架动态生成大量类就会把机器内存吃光。这套题的答案是开放式的但只要能把上面这些参数和排查思路说清楚就证明JVM基础是过关的。2.5 Lambda、Stream与方法引用Java 8 引入的Lambda表达式和Stream API现在已经成为日常工作里离不开的工具。测验4里我考察了几个容易出错的点。第一个是Lambda表达式的变量捕获要求被捕获的局部变量必须是final或事实上不可变的。我出了一道代码分析题int count 0; list.forEach(x - count);答案是编译失败因为count在Lambda里被修改了。这个限制背后是Java对并发安全的设计考虑如果允许修改外部变量多线程环境下就会出现共享可变状态的问题。编译器强制要求“不可变捕获”倒逼开发者用更安全的写法。Stream的惰性求值是另一个重点。我让做题者判断下面这个例子会输出几次“filter”Stream.of(1, 2, 3, 4, 5) .filter(x - { System.out.println(filter x); return x % 2 0; }) .map(x - x * 10) .limit(2) .forEach(System.out::println);很多不熟悉Stream的人会以为先全量filter再map再limit实际上Stream是垂直求值的每个元素依次经过filter、map直到满足limit(2)就停止。所以输出是“filter 1”“filter 2”“filter 3”“filter 4”总共4次filter而不是5次。这个题非常能区分是“会用Stream”还是“理解Stream”。parallelStream我也考了一道判断题为什么并行流里有ArrayList的非线程安全操作是不安全的。因为ArrayList的内部数组在扩容时会创建新数组并复制元素多个线程同时写就可能丢失数据或者出现数组越界。解决方式要么改成线程安全的容器要么用Collectors.toConcurrentMap()这类并发收集器。实际业务里我反而建议谨慎使用并行流因为其性能受核数、数据量、拆分成本多重影响小数据量用并行流反而更慢。2.6 枚举、常用类与运算符陷阱Java枚举类型的使用是基础题里比较好的拉分点。测验里我出了一道题枚举能否定义抽象方法能每个枚举常量都可以实现。枚举天生适合做单例因为JVM保证枚举实例的全局唯一性而且序列化框架对枚举有特殊处理不会像普通单例那样在反序列化时创建新对象。switch配合枚举使用代码可读性会提升很多编译期还能检查出遗漏的分支。常用类这块重点考了String不变性、StringBuilder与StringBuffer的区别、包装类缓存。这里有个非常容易踩的坑Integer在-128到127之间会走缓存在这个范围之外才会new Integer对象。所以有这样的代码Integer a 127; Integer b 127; Integer c 128; Integer d 128; System.out.println(a b); System.out.println(c d);输出是true和false。这个题目我故意放在测验里就是为了提醒做题者判断包装类相等一定要用equals用是对缓存机制的依赖一旦超出范围就翻车。这个知识点在真实开发里很容易造成隐晦的Bug尤其是接口返回数据做比对的时候。运算符与表达式的知识点我出了一道自增的题int x 5; int y x x;问y的值。答案是12因为第一个x先取5此时x变成6第二个x先自增成7再取75加7得12。这类题看起来“面试八股”实际考察的是对表达式求值顺序的理解。我自己在写代码时除非很简单的i否则尽量避免把多个自增运算塞进一个表达式里因为可读性太差也容易改出副作用。2.7 数组边界与比较器排序数组越界异常ArrayIndexOutOfBoundsException是最常见的运行时异常之一。测验里我出了一道循环遍历的题int[] arr {1, 2, 3}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这里i arr.length就是典型的越界条件数组下标最大只能是length - 1。这个题的讨论点不是报错本身而是“如何写出防御性代码”。我建议在遍历时优先使用增强for循环或者流式遍历它们从语法层面规避了下标管理的问题。Comparator.comparing是Java 8里非常实用的排序工具尤其配合方法引用能把排序代码写得很简洁。测验里有一道题“如何把一个对象列表按年龄升序排列同时让某个特定元素排到第一个”。我用两种方案展示了实现// 先按“是否为VIP”降序让VIP排前面再按年龄升序 list.sort(Comparator.comparing(User::isVip).reversed() .thenComparing(User::getAge)); // 或者直接自定义比较器把特定id放最前 list.sort(Comparator.comparingInt((User u) - u.getId() 99 ? 0 : 1) .thenComparingInt(User::getAge));关键点是Comparator.comparing返回的是一个比较器可以用.thenComparing组合多个排序维度。这里有一个比较坑的细节Comparator.comparing(User::isVip).reversed()只反转了“是否VIP”这个维度的顺序不会反转后面.thenComparing的年龄维度。很多人误以为reversed()作用于整个链实际上它只作用于调用它的那个Comparator对象。我在测验里专门设置了一个判断题来考察这个细节因为实际写代码时太容易在这里搞错。3. 实操过程与核心环节实现3.1 从零搭建测验环境JDK安装与环境变量配置这套测验题里有一部分是编译运行类题目所以环境搭建是第一步。很多初学者卡在“Java环境变量配置”上其实流程极其简单。先下载JDK 17注意选择对应操作系统的版本。安装完成后最重要的是配置三个环境变量JAVA_HOME、PATH、CLASSPATH。在Windows上新增系统变量JAVA_HOME值为JDK安装路径比如C:\Program Files\Java\jdk-17。然后在PATH里追加%JAVA_HOME%\bin。CLASSPATH在很久以前用来指定类搜索路径现在大多数场景已经不需要手动配置了但有些老项目还在用所以我在测验里保留了一道“简述JAVA_HOME作用”的题目。在Linux或macOS上需要往~/.bashrc或~/.zshrc里加export JAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 export PATH$JAVA_HOME/bin:$PATH配置完成之后打开新终端输入java -version和javac -version如果都能正常输出版本信息说明环境没问题。这里我踩过一个坑改了环境变量之后没有重开终端导致一直用的旧版本。尤其是多个JDK版本共存时一定要在终端里执行which java确认当前用的到底是哪个路径。现实中很多人项目跑不起来排查到最后就是IDE里跑的和命令行里跑的JDK版本不一致。3.2 编写并运行测验代码命令行与IDE两种方式测验题里有一类“写出以下代码运行结果”的题我建议不要凭脑补直接跑一遍。比如这段非常经典的String比较题public class Quiz4 { public static void main(String[] args) { String s1 hello; String s2 hello; String s3 new String(hello); System.out.println(s1 s2); System.out.println(s1 s3); System.out.println(s1.equals(s3)); } }在命令行里先javac Quiz4.java编译出.class文件再java Quiz4运行。输出结果是true、false、true。原因是字符串字面量会进入常量池s1和s2指向常量池同一个对象所以为truenew String(hello)在堆上新建了一个对象和常量池对象地址不同。这个例子如果只在脑子里推理很容易漏掉“常量池”这个关键点亲手跑一次印象会深很多。使用IDE同样方便但要注意两点。第一是项目SDK版本和语言级别必须一致。IDEA里如果Project SDK选了17却把Java Language Level设置成8就会出现低版本语法无法使用的问题。第二是构建工具的选择。我建议做测验题这种小项目时直接用IDEA的普通Java工程就行不用上Maven或Gradle减少配置干扰。测验的目的是验证语言基础而不是验证构建工具熟练度。3.3 编译期常见问题源发行版17、Lombok警告与乱码在做测验4的代码题时我遇到了三个典型的编译问题。第一个是java: 警告: 源发行版 17 需要目标发行版 17。这个警告的意思是编译器认为源文件版本是17但目标发行版配置的比17低例如8。出现这个问题的原因几乎总是IDE里Project Structure的Project language level和Modules里的语言级别不一致。解决办法是把它们统一设成17同时检查Maven的pom.xml里maven-compiler-plugin的source和target配置最好加上release标签properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties第二个问题是Lombok的警告you arent using a compiler supported by lombok, so lombok will not work。这种情况通常出现在使用了较新的JDK版本而项目里的Lombok版本太老注解处理器还不支持新JDK。最常见的解决方式是把Lombok升级到适配新版JDK的版本比如JDK 17配合Lombok 1.18.30以上。如果公司强制不能升级依赖可以试试在Maven编译插件里显式指定注解处理器路径但最省心的还是升级。这个坑在真实项目里能卡住一整天我特意把它加进测验里当作一道“你如何解决”的题目。第三个是VSCode运行Java报乱码。这个问题在Windows上尤其常见本质是源码文件编码和编译运行时的编码不一致。我一般建议全部统一为UTF-8在.vscode/settings.json里设置java.debug.settings.consoleEncoding: UTF-8和java.encoding: UTF-8同时在启动时加上-Dfile.encodingUTF-8参数。乱码问题本身不难解决但排查起来很烦因为同一个问题可能由三四个不同的配置错误导致。我的经验是先把源码文件确认成UTF-8再看控制台编码最后看编译参数按顺序排查。4. 常见问题与排查技巧实录4.1 数组越界异常定位边界条件数组越界的问题我把它单列出来是因为它在测验和实战里出现的频率都太高了。下面这个场景是我从真实代码里抽出来的public static int sum(int[][] grid) { int total 0; for (int i 0; i grid.length; i) { for (int j 0; j grid[i].length; j) { total grid[i][j]; } } return total; }这里如果grid是一个不规则二维数组比如第一行有3列、第二行有2列内外层循环用到的是grid[i].length所以不会越界。但如果写成了外层for (int i 0; i grid[0].length; i)遇到列数少的行就会越界。排查思路很简单看到ArrayIndexOutOfBoundsException时先看报错信息给出的下标再回看循环边界是还是以及数组长度获取的是哪个维度的length。我还踩过Java的一个经典坑对Arrays.asList()得到的List调用remove会抛UnsupportedOperationException而不是数组越界。这是因为Arrays.asList返回的ArrayList是Arrays内部类没有重写remove方法直接调用了AbstractList的默认实现。这个坑和越界问题形似但本质不同我在测验里把它们放在相邻位置让做题者区分“下标问题”和“集合结构不支持操作”两种异常。4.2 排序结果不符合预期Comparator的细节用Comparator.comparing排序时结果不符合预期是常见问题。我遇到过一个实际案例要对用户列表排序管理员排最前然后按注册时间倒序。第一版代码如下users.sort(Comparator.comparing(User::isAdmin).reversed() .thenComparing(User::getCreatedAt).reversed());这段代码的问题在于第二个reversed()把整个链都反转了结果是普通用户排前面、注册时间升序完全反向。正确的写法是把时间字段本身取反或者先构造比较器再整体取反ComparatorUser comparator Comparator .comparing(User::isAdmin) .thenComparing(User::getCreatedAt) .reversed(); users.sort(comparator);这里reversed()作用于整个comparator才符合“管理员优先、注册时间倒序”的需求。这个细节测验里我用了一个“找出错误”的题型来考察。实际写排序代码时我强烈建议把比较器单独抽出来用变量命名说明排序意图避免在一个Expression里堆多个reversed()导致逻辑混乱。4.3 OOM排查实战从报错到定位的完整链路排查java: OutOfMemoryError: insufficient memory或者java heap space一定要有明确的方法论不能重启完事。我处理过一次线上问题流程可以供参考第一步拿到报错信息后先用jcmd pid VM.version确认JVM版本再用jstat -gcutil pid 1000每秒输出一次GC情况。如果看到Old区持续增加到接近100%且Full GC频繁基本可以判定是内存泄漏或堆配置过小。第二步用jmap -dump:live,formatb,file/tmp/heap.hprof pid导出堆快照这里加live参数会先触发一次Full GC只dump存活对象文件会小很多。第三步用MAT打开dump文件查看Dominator Tree找哪个对象集合占据了最大内存。我在这次测验里复盘出一个特别容易忽略的点很多时候OOM不是因为堆太小而是因为代码里某个List不断被添加元素且没有清理。比如缓存场景如果用了普通的HashMap当作缓存又没做容量限制时间长了必然内存爆炸。解法是使用Caffeine或Guava Cache这类自带过期和淘汰策略的本地缓存。这个背后的原理比单纯背“-Xmx调多大”要重要得多。4.4 编译运行问题速查表把这次测验和日常工作中遇到的编译运行问题整理成一个速查表方便遇到时直接对照排查现象常见原因解决方法源发行版 17 需要目标发行版 17IDE语言级别或Maven编译参数不一致统一SDK版本、语言级别、maven.compiler.source/targetLombok编译警告Lombok版本不支持当前JDK升级Lombok到兼容版本VSCode运行乱码编码不一致统一UTF-8加-Dfile.encodingUTF-8OutOfMemoryError: insufficient memory原生内存不足或进程内存限制降低线程数检查直接内存调整容器内存限制ArrayIndexOutOfBoundsException循环边界或索引计算错误检查/确认length来源UnsupportedOperationException调用不可变集合的修改方法创建新的ArrayList后再修改找不到或无法加载主类JAVA_HOME配置错误或编译输出目录不对检查环境变量重新编译这个表我一直在维护每次遇到新问题就补一行。做Java开发遇到错误不可怕怕的是每次都用“重启”来碰运气。把问题归类、记录、整理成表下一次排查速度能快一个数量级。这套测验4复盘下来我把里面每一个问题都归入了这个表之后再看一眼就能定位。我个人在实际操作中的体会是做这套测验最大的价值不是分数而是逼着自己把平时“会用但说不清”的知识点完整地讲了一遍。尤其是Comparator.comparing的链式调用、try-with-resources的抑制异常、JVM堆内存的排查手段这些在写业务代码时未必天天用但一旦碰到就是大问题。如果你正在走Java学习路线我建议每学完一个阶段就给自己出10道题限时作答然后像我这样把每道题背后的原理和坑都写出来。第一轮会很慢但第二轮、第三轮就会明显感觉到知识体系变得立体了。最后再分享一个小技巧把每道错题对应的关键词记下来比如“枚举”“OOM”“Comparator”隔两周用这些关键词重新出题能有效巩固记忆这也是我持续把测验做成系列的原因。