我拿到凹凸科技2017秋招Java工程师笔试卷的时候,第一反应是有点意外。这套卷子没有像很多大厂那样搞一堆偏题怪题,整体走的是"基础扎实度+代码硬功夫"的路线,但恰恰是这种看似朴素的卷子,最容易暴露一个Java开发者的真实水平。那份卷子我做完之后对了对答案,选择题错了三道,编程题虽然都写出来了,但有两处不够优雅的地方,复盘之后收获很大。今天就把这套卷子相关的知识点、题目思路和我踩过的坑完整拆开讲讲,希望能帮你少走一些弯路。
这套卷子覆盖的知识面很典型:Java基础语法、面向对象、集合框架、JVM基础、并发编程、Lambda表达式、还有手写排序算法。不管你是正准备秋招的应届生,还是想检验自己Java功底的在职开发者,这份复盘都值得一看。我尽量按真实笔试的节奏来还原,每道题背后涉及的原理也会一并说透。
1. 试卷总览与考察风向:2017秋招的java岗到底在考什么
1.1 试卷结构与我的做题顺序
这份卷子整体分为四块:单选题(大概15道)、多选题(5道)、简答题(3道)、编程题(2道)。
我拿到卷子后的做题顺序是:先花10分钟快速扫一遍所有题目,心里预估一下每道题的时间成本。选择题里那些一眼就能看出答案的优先做,拿不准的先标记跳过;简答题放在选择题之后集中作答;最后留足40分钟给编程题。很多人笔试失败不是因为不会,而是因为时间分配失衡,在简答题上磨太久,结果编程题没时间写完,这是最亏的。
整体来看,这套卷子里基础语法和面向对象相关题目占比最高,大约能占到40%左右;集合和JVM内容占30%;并发和Lambda占20%;剩下10%是零散的综合题。这个比例其实也反映了2017年前后Java岗位面试的整体风向——基础为王,框架反而考得不多。
1.2 当年java笔试的命题特点
2017年这个时间节点很有意思。Java 8已经发布三四年了,Lambda表达式和Stream API已经进入生产环境,但很多学校的课程还停留在Java 6/7的时代,导致不少应届生在笔试里遇到Lambda相关的题目直接懵住。同时,Spring Boot开始普及,微服务概念开始热起来,但笔试考察的重点依然在语言本身,而不是框架。
这套卷子还有一个特点:很喜欢考"看起来简单、实则暗藏坑"的题目。比如运算符优先级、Integer缓冲池、ArrayList扩容机制这类,都是那种"你觉得自己会,但一不留神就错"的知识点。这提醒我们一件事——刷题不能只盯着高深的技术,基础语法和JDK源码层面的细节同样值得反复咀嚼。
2. 基础语法与面向对象部分:最不该丢分的送分题
2.1 标识符、运算符与枚举的那些坑
卷子第一道单选题就很经典:下面哪个是合法的Java标识符?
选项里有2variable、my-variable、$variable、class这类迷惑项。正确答案是$variable。原因是Java标识符只能以字母、$、_开头,不能以数字开头,不能包含短横线,也不能是关键字。class虽然是关键字但有些人会忽略,my-variable里的短横线会被识别成减号,这两个都是高频错误选项。
这道题本身不难,但它背后其实是一个更值得注意的点:编码规范。很多公司有自己的命名规范,比如阿里规约就明确禁止用$作为变量名开头,因为$在有些代码生成工具和内部类机制中有特殊含义。笔试考的是合法性,实际工程里考的是规范性,这是两码事。
还有一道运算符优先级的题,核心是int a = 1; int b = a++ + ++a;问b的值。答案是4。我当时差点选了3,因为很多人会简单算成1 + 2 = 3,但实际上a++是先返回a当前值1,然后a变成2;++a是先把a变成3,再返回3。所以结果是1 + 3 = 4。这类题没有捷径,最好考前把优先级表过一遍,特别是自增自减、三目运算符、位运算这些容易混淆的地方。
枚举类型也出了一道题:定义一个枚举Color { RED, GREEN, BLUE },问Color.valueOf("green")会怎样。正确答案是抛出IllegalArgumentException,因为枚举的valueOf是大小写敏感的,"green"和GREEN不匹配。这个知识点在switch语句配合枚举使用时特别容易踩坑,尤其是从数据库或前端传值过来做枚举转换的场景。
2.2 面向对象三座大山:重载、重写与多态
简答题里有一道很经典:重载(Overload)和重写(Override)有什么区别?多态的实现原理是什么?
重载是编译期多态,同一个类里方法名相同、参数列表不同;重写是运行期多态,子类对父类方法重新实现,方法签名必须一致,返回值类型可以协变,访问修饰符不能比父类更严格,不能抛出比父类更宽泛的受检异常。
多态的实现原理这个点,得往深了说。JVM在类加载的时候,会给每个类在方法区生成一个方法表(vtable),里面存着该类的实例方法实际入口地址。当代码里写Animal a = new Dog(); a.shout();的时候,编译期只验证Animal类型有没有shout()方法,运行期通过a引用指向的实际对象类型,去Dog类的方法表里找对应的入口。这就是动态绑定的过程。
我当时在试卷上把这个过程写得很细,然后补了一句:正是因为方法表的存在,方法调用的开销比字段访问大,所以在热点代码里尽量减少不必要的多态调用。这个补充其实挺加分的,因为它显示出我不只知道概念,还思考过性能层面的影响。
2.3 数组越界与异常处理:看似简单实则高频
编程题里有一道:用for循环遍历数组并打印每个元素,要求写出代码。这题考察的核心是ArrayIndexOutOfBoundsException。
很多人随手就会写成这样:
int[] arr = {1, 2, 3, 4, 5}; for (int i = 0; i <= arr.length; i++) { System.out.println(arr[i]); }这里的问题就是<=导致了越界。正确应该是<。还有一个常见错误是前置判断应该先判断数组是否为空,否则空指针一样炸。我当时把边界判断写成了防御式编程的样子:
public void printArray(int[] arr) { if (arr == null || arr.length == 0) { return; } for (int i = 0; i < arr.length; i++) { System.out.println(arr[i]); } }异常处理那题问的是try-catch-finally中finally的执行时机,以及如果finally里也有return会怎样。答案是:只要JVM不退出,finally一定会执行,但如果在try或catch中执行了System.exit(),finally就不会执行;如果finally里有return,它会覆盖try或catch里的return。
这个知识点在真实项目里太容易踩坑了,尤其是资源关闭场景。我见过一个线上问题,某团队在finally里关闭连接后顺手加了个return false,结果把try里判断成功的return true给覆盖了,导致接口永远返回失败。
3. 集合框架与JVM:笔试中的分水岭
3.1 HashMap的底层原理与加载因子
这套卷子的多选题里有一道HashMap相关的题,选项涉及:底层数据结构、加载因子、扩容机制、线程安全性。这题几乎是把HashMap的核心考点一网打尽。
Java 7和Java 8的HashMap实现差异很大。Java 7是数组+链表,哈希冲突时用头插法;Java 8改成了数组+链表+红黑树,链表长度超过8且数组长度超64时转红黑树,冲突插入方式也改成了尾插法。为什么改?因为头插法在多线程扩容时可能出现环形链表,导致get操作死循环,Java 8的尾插法能缓解这个问题,但HashMap依然不是线程安全的。
加载因子0.75是时间和空间的一个折中。简单说,加载因子越大,空间利用率越高,但哈希冲突概率变大,查询变慢;加载因子越小,越费内存,但冲突少、查询快。0.75是大量实验统计下来的一个经验值。扩容是变成原来的两倍,并且所有元素要重新计算哈希、重新分布,代价很高,所以如果能预估数据量,初始化的时候最好直接指定容量。
我在这道题上栽了一个跟头——多选题里有个选项说"HashMap允许null键和null值",我当时犹豫了一下没选,因为脑子里突然闪过Hashtable不允许null,结果把HashMap也带进去了。实际上HashMap是允许null键和null值的,Hashtable和ConcurrentHashMap才不允许。这种对比型考点,考前一定要单独整理一份对照表。
3.2 JVM内存区域与垃圾回收的必考套路
简答题第二道是JVM相关:描述JVM运行时数据区,以及各个区域可能抛出的异常。
这题的覆盖范围其实很大,但核心是这样一张逻辑图:程序计数器、虚拟机栈、本地方法栈、堆、方法区。程序计数器是唯一不会OOM的区域;虚拟机栈和本地方法栈会抛出StackOverflowError和OutOfMemoryError;堆和方法区都会OOM。
题目里专门问到了StackOverflowError的场景,那就是递归调用没有终止条件或者递归太深。我在试卷上写了一个实际例子:
public void recurse() { recurse(); }这个没等递归多少次就会栈溢出。我调过的一个线上案例是,某个导出功能里递归遍历目录树,目录层级深到一定程度直接栈溢出,最后改成非递归的方式才解决。
垃圾回收这里考的是可达性分析。为什么不用引用计数?因为引用计数解决不了循环引用的问题——两个对象互相引用,但已经不是GC Roots可达了,引用计数始终是1,永远无法回收。可达性分析从GC Roots出发,往下扫描对象引用链,没有被链到的对象会被标记为可回收。GC Roots包括虚拟机栈中引用的对象、方法区中静态属性引用的对象、常量引用的对象、JNI引用的对象等。
3.3 你被"栈"这个词骗过吗——那些容易混淆的运行时概念
这套卷子有一道选择题问:int[] arr = new int[10]中,arr存在哪里?数组对象存在哪里?答案是:arr这个引用变量在虚拟机栈上(如果是局部变量),数组对象本身在堆上。
这道题迷惑性很强,因为很多人看到"数组"就条件反射地觉得数组应该在栈上,但数组也是对象,对象实例都是分配在堆上的。那为什么局部变量arr在栈上?因为局部变量是方法的内部状态,方法调用结束就销毁,放到栈里最合适。这个区分在内存分析时特别重要——new出来的都在堆里,栈里只放引用。
还有一个容易混淆的知识点:String和new String()的区别。String s1 = "abc";在Java 7之后,"abc"这个字面量会去堆里的字符串常量池找,如果已有就复用;String s2 = new String("abc");无论如何都会在堆里创建一个新对象。笔试里经常拿==来比较这两个,s1 == s2结果是false。但String的equals比较的才是内容,所以equals结果是true。这道题我倒是没做错,但我见过不少人在评论区争论不休,所以还是值得单独拎出来提醒一下。
4. 编程题实战:排序算法与字符串处理
4.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 temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }但如果只是写这个,只能拿基础分。我当时加了一个优化:设置一个swapped标志位,如果某一轮遍历下来没有任何交换,说明数组已经有序,直接跳出循环。
public static void bubbleSortOptimized(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 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; } } }这个优化在数组接近有序的时候,能把时间复杂度从O(n^2)直接降到O(n)。面试官看到这一步,通常会觉得你不是死记硬背,而是理解了这个算法的本质。再往上还可以做鸡尾酒排序(双向冒泡),但笔试里到这个程度已经够用了。
我在试卷上写代码的时候,特意在注释里标注了"最优情况O(n),最坏情况O(n^2)",这其实是在向阅卷人传递一个信号:我清楚这个算法的复杂度边界。
4.2 快速排序的实现与优化:从"能跑"到"优雅"
第二道编程题是快速排序。这题我当时写了十几个方法调用链,写完发现有点长,但胜在思路清晰。
快排的核心是分治:选一个基准值,把小于等于基准的放左边,大于基准的放右边,然后递归处理左右两个子区间。我采用的是经典的双指针(挖坑法):
public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivot = arr[left]; int i = left; int j = right; while (i < j) { while (i < j && arr[j] >= pivot) { j--; } if (i < j) { arr[i] = arr[j]; i++; } while (i < j && arr[i] <= pivot) { i++; } if (i < j) { arr[j] = arr[i]; j--; } } arr[i] = pivot; quickSort(arr, left, i - 1); quickSort(arr, i + 1, right); }快排有个特别经典的坑:当数组已经有序时,如果每次基准都选第一个元素,快排会退化成O(n^2)。我在试卷上专门提了一句,说可以采用"三数取中"来选基准(取左端、中间、右端三个数的中位数),或者随机选基准,来尽量避免最坏情况。
关于递归深度,JDK里Arrays.sort()对基本类型用的是双轴快排,对对象类型用的是TimSort。笔试的时候不用扯这么深,但如果你能顺带提一句"工业界的排序实现会针对小数组转用插入排序,因为插入排序在小数据量下常数因子更小",会是一个很好的加分项。
4.3 字符串反转与回文判断这类"小大题"
除了排序,卷子还有一道字符串相关的编程题:判断一个字符串是否是回文字符串,同时要求忽略大小写和非字母数字字符。
我的思路是用双指针,从两端往中间走,遇到非字母数字就跳过,然后比较忽略大小写后的字符是否相等。核心代码是这样:
public static boolean isPalindrome(String s) { if (s == null) { return false; } int left = 0; int right = s.length() - 1; while (left < right) { while (left < right && !Character.isLetterOrDigit(s.charAt(left))) { left++; } while (left < right && !Character.isLetterOrDigit(s.charAt(right))) { right--; } if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) { return false; } left++; right--; } return true; }这道题考察的点很综合:字符串遍历、指针移动、Character类的方法使用、边界条件处理。我见过很多人只处理了字母,忘记了数字也算有效字符;还有人做成了先过滤再反转的写法,代码长了效率还差。笔试里这类字符串题目看起来不大,但其实是区分度的关键——细节控的人在这里拿分,粗糙的人在这里丢分。
5. 并发与Lambda:面试官真正想听的部分
5.1 线程安全的几种实现方式
简答题第三道是关于线程安全的:说出几种Java中实现线程安全的方式,并说明各自的适用场景。
我当时写了几种,从易到难排了一遍:
synchronized关键字:JVM内置锁。可以锁方法、锁代码块、锁对象。优点是简单可靠,缺点是锁的粒度不好控制,竞争激烈时性能会下降。volatile关键字:保证可见性和有序性,但不保证原子性。适合状态标志位的场景。volatile最经典的例子就是boolean flag做线程间的运行状态控制。Lock接口与ReentrantLock:比synchronized更灵活,支持公平锁、非公平锁、可中断、可以设置超时、可以用Condition做精确的通知等待。- 原子类(
AtomicInteger等):基于CAS实现的无锁线程安全。 ThreadLocal:每个线程独享一份变量副本,以空间换时间,避免竞争。- 不可变对象:
String就是一个经典例子,状态不可变,天然线程安全。
每一种的适用场景说起来都有很多话。比如synchronized适合临界区代码量很少、并发冲突不高的场景;ReentrantLock适合需要尝试获取锁、超时等待的场景;ThreadLocal在Spring的@Transactional里就大量使用,因为每个数据库连接需要绑定到当前线程。
这道题我写得很满,因为并发知识是面试官区分"会用Java"和"懂Java"的核心分水岭。笔试往往只是敲门砖,后面技术面还会继续深挖,所以这一块值得花大力气准备。
5.2 从匿名内部类到Lambda表达式:Java 8的灵魂考点
卷子里有一道编程题很好玩:有一个List<String>,要求过滤掉长度小于等于3的字符串,然后把剩下的转成大写,最后按字典序排序输出。
Java 7时代的写法会长这样:
List<String> result = new ArrayList<>(); for (String s : list) { if (s.length() > 3) { result.add(s.toUpperCase()); } } Collections.sort(result);但2017年的笔试明确要求用Java 8的Lambda和Stream API来写。正确是这么写:
List<String> result = list.stream() .filter(s -> s.length() > 3) .map(String::toUpperCase) .sorted() .collect(Collectors.toList());这个写法一出来,代码量立刻少了一大半。像我之前说的,2017年刚好卡在Java 8普及的节点上,很多应届生还停留在传统写法,能够在笔试里熟练用Stream的,会给面试官留下很深的印象。
Lambda表达式的原理也值得说一下。Lambda其实是"函数式接口"的匿名实现类的一种简化写法。所谓函数式接口,就是只有一个抽象方法的接口,比如Runnable、Comparator、Function等。Lambda表达式在编译后会被翻译成一个被invokedynamic指令调用的动态方法,而不是简单地在编译期生成匿名内部类。这也是为什么Lambda在性能上通常比匿名内部类要好一些——它延迟到运行期才生成真正的实现,有机会复用同一个实现实例。
还有一个高频考点:Lambda表达式捕获的局部变量必须是"事实上不可变的"(effectively final)。也就是说,在Lambda里引用一个局部变量,这个变量在初始化后不能再被修改。我当时在试卷里写了一个反例,用一个int[]数组来"变相"修改值,就是这个思路:
int[] counter = {0}; list.forEach(item -> counter[0]++);这种写法能跑通,但它违反了effectively final的本意,真实项目里不太推荐。更好的做法是用AtomicInteger或者直接使用Stream的count()。
6. 复盘与应对策略:这套卷子给我的三点教训
6.1 知识体系上的盲区
做完这套卷子,我最大的感受是:笔试题目本身不算难,但覆盖面很广,任何一个知识点掌握得不牢,都会在选择题里露出马脚。我那三道错题,一道是HashMap的null键值问题,一道是运算符优先级,还有一道是JVM方法区的运行时常量池位置。这三道题恰好对应了我当时的三大盲区:集合类的细节对比、基础语法的边界情况、JVM规范里的精确划分。
后来我针对这些盲区做了三件事:把Java集合框架里每个类的特性做了一组对比表格,把JVM运行时数据区画了十几遍直到烂熟于心,把网上的Java基础面试题汇总重新刷了两遍。这个过程虽然枯燥,但效果非常明显——之后我笔试的正确率提升了一大截。
6.2 时间分配与答题策略
还有一个很有意思的复盘发现:选择题我用了20分钟,简答题用了30分钟,编程题用了40分钟,最后剩下10分钟检查。这个节奏是合理的,编程题时间占比一定要最高,因为手写代码是最能拉开差距的环节。
检查的时候我重点做了一件事:把所有写过的for循环边界都重新过一遍。为什么?因为笔试中80%的编程题低级错误都是边界问题:<=写成<,或者反过来;i + 1忘了加;数组长度为0的时候没有特殊处理。只要把边界条件检查一遍,正确率就能从"能跑"提升到"稳过"。
6.3 给后面备考人的建议
如果你正在准备Java笔试,我的经验是:
基础语法、面向对象、集合、JVM、并发、Java 8新特性,这六块是绝对的主干,搞定了它们,大多数公司笔试题的60%~70%你都能从容应对。编程题方面,排序算法要能盲写,字符串处理题要熟练使用双指针技巧,边界条件必须养成直觉性检查的习惯。
更重要的是,笔试不只是考知识储备,还在考你在时间压力下的抗压能力。平时练习的时候,建议给自己卡时间,模拟真实笔试的场景。我后来每次练习都会开着计时器,一开始很紧张,练多了就习惯了。这个习惯让我在真正的笔试里心态稳了很多,看到不会的题也不会慌,先跳过,回头再来处理。
最后说一句,笔试其实是一个很诚实的检验方式——你平时积累了多少,考场上就会暴露多少。与其指望考前突击,不如把功夫下在平时,吃透每一个基础知识点,多动手写代码,这样无论拿到什么卷子,心里都有底。