前两天整理电脑,翻出一份2017年秋招时收藏的凹凸科技Java工程师笔试卷。这份卷子虽然年份早了点,但里面的考点和命题思路放在今天依然很能打——Java基础、集合、并发、JVM、SQL、手写算法,几乎覆盖了校招笔试的所有高频模块。如果你正在准备Java岗位的校招或者社招笔试,这篇拆解可以直接当复习提纲用。
先说下这份卷子的整体感觉:题量中等,难度适中偏上,基础题占了六成左右,剩下四成是拉开差距的进阶题。不追求偏题怪题,但对"理解深度"有明确要求。比如同一道题,背过答案的人能拿一半分,真正理解原理的人才能拿满分。这种出题风格其实很符合互联网公司的实际诉求——招进来的人不但要能干活,还得能解决没遇到过的问题。
我按照试卷的模块顺序,把核心考点、解题思路、容易踩的坑逐个拆开讲,每一块都附上当时实际验证过的经验。
1. 试卷整体结构与技术栈定位
1.1 2017年Java校招考察重点回顾
2017年这个时间点很特殊。Java 8已经普及了两三年,Lambda表达式和Stream API逐步进入生产环境,但很多学校教学还停留在Java 5/6的语法层面。Spring Boot开始流行,微服务概念正在升温,不过大部分校招笔试还没有大规模考查Spring Cloud这类框架,重点仍然是"语言本身"和"计算机基础"。
这也解释了为什么这张卷子花了大量篇幅在Java核心机制上。面试官默认你学校没教过真正的工程实践,所以笔试的目的不是考你会不会用某个框架,而是看你有没有扎实的语言功底和自学习惯。换句话说,一个能把HashMap原理讲清楚、能徒手写快排、能说清JVM内存划分的应届生,在当年的市场上就是很有竞争力的候选人。
1.2 试卷模块设计与分值逻辑
从整体结构来看,试卷分为五个模块:
| 模块 | 考察方向 | 大致分值占比 |
|---|---|---|
| Java基础语法与面向对象 | 语言特性、继承多态、异常处理 | 25% |
| 集合框架与泛型 | 常用集合实现原理、选择依据 | 20% |
| 多线程与并发 | synchronized、线程安全、锁机制 | 15% |
| JVM与内存管理 | 内存区域、GC、类加载 | 15% |
| SQL与数据库 | 索引、事务、SQL编写 | 15% |
| 手写算法与代码 | 排序、查找、单例模式 | 10% |
这个分值分布很有代表性。基础语法和集合加起来占了接近一半,说明出题人最关心的是日常编码中使用频率最高的部分。JVM和并发虽然分值不算最高,但往往是决定排名的关键——因为大部分考生在这两块都比较薄弱。
1.3 准备这份试卷需要的基础环境
如果你想拿这份卷子来自测,建议把环境准备好再动手。我当时用的还是JDK 8,IDE是Eclipse(那会儿IntelliJ IDEA虽然在圈内口碑很好,但学校里面用的人还不算多)。现在自测的话直接用JDK 8或JDK 11、IDEA社区版就够了,不需要额外装什么。
自测的时候有一条建议:不要边查资料边做题。笔试考的是你在有限时间、有限资源下的真实水平,查着写着会把"不会"伪装成"会"。我的习惯是先把整张卷子做完,再统一对照知识点复盘,这样暴露出来的问题才是最真实的。
2. 核心基础题型解析:从Java语法到面向对象
2.1 基础语法与数据类型的"送分题"也要小心
试卷开头通常是几道基础语法题,看起来人畜无害,实则暗藏杀机。举个例子,题目问int和Integer的区别,以及如下代码的输出结果:
Integer a = 127; Integer b = 127; Integer c = 128; Integer d = 128; System.out.println(a == b); System.out.println(c == d);第一反应可能是"对象比较当然都是false",但如果真这么答就掉坑里了。Java的Integer缓存机制对 -128 到 127 之间的值做了缓存,所以a == b是true,而c == d是false。这个知识点在当年属于高频陷阱题,放到今天依然是面试八股文里的常客。
再比如考察String的不可变性:
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));s1 == s2为true是因为字符串常量池的复用;s1 == s3为false是因为new强制创建了新对象;s1.equals(s3)为true是因为equals比较的是内容。这一组题考察的就是你对常量池、对象创建机制的理解深度,靠死记硬背容易翻车。
2.2 面向对象:继承、多态与重写重载的区分
面向对象部分是笔试的重头戏,几乎每年必考。题目通常会给一段多层继承的代码,让你判断某个方法调用会输出什么。这种题看起来烦,但只要抓住两条主线就能解:一是看静态类型决定哪些方法可以调用,二是看实际对象类型决定调用哪个版本的方法。
重写与重载的区别也是高频考点。我当年总结的答题模板是:重写发生在父子类之间,方法签名必须一致,返回类型可以协变,访问修饰符不能更严格;重载发生在同一个类内部,方法名相同但参数列表必须不同,与返回类型无关。写这类题的时候要注意细节,比如重写方法能不能抛出更宽泛的异常——不能,只能抛出更窄或同级的异常。
还有一道我印象很深的题,关于初始化和构造顺序,代码如下:
class Parent { static { System.out.println("Parent static"); } { System.out.println("Parent block"); } public Parent() { System.out.println("Parent constructor"); } } class Child extends Parent { static { System.out.println("Child static"); } { System.out.println("Child block"); } public Child() { System.out.println("Child constructor"); } }执行new Child()的输出顺序是:Parent static → Child static → Parent block → Parent constructor → Child block → Child constructor。这个顺序背后的逻辑是:静态成员属于类,类加载时就要初始化,且父类优先;实例成员属于对象,新建对象时先执行父类的初始化块和构造器,再执行子类的。这种题在当年基本属于必考题,放到今天依然是Java基础面试的核心内容。
2.3 字符串与包装类的隐晦考点
字符串相关的题目除了==和equals的经典比较之外,还有一个容易被忽略的点:String是不可变对象,每次拼接都会产生新的字符串对象。如果在一个循环里做字符串拼接,性能会很差。正确做法是使用StringBuilder或StringBuffer,前者非线程安全但性能更好,后者线程安全但代价是额外的同步开销,单线程环境下没有必要用它。
包装类的考点除了前面提到的缓存机制,还有自动装箱和拆箱的底层原理。Integer i = 100实际上调用的是Integer.valueOf(100),而int j = i调用的是i.intValue()。理解这一层之后,很多奇怪的面试题都能迎刃而解。比如下面这段代码,输出结果是什么:
Integer i = null; int j = i;这行代码会抛出NullPointerException,因为自动拆箱在底层调用i.intValue()时,对象已经是null,直接空指针。这种"看起来能编译,跑起来就炸"的题,考的恰恰是你对语法糖背后的真实机制有没有概念。
3. 集合与并发:笔试拉开差距的关键模块
3.1 HashMap原理与哈希冲突处理
集合框架部分是整张卷子含金量最高的模块,而HashMap又是绝对的重点。2017年的笔试卷直接问到了HashMap的底层存储结构。答案不难,但需要说得足够细:数组加链表,JDK 8之后链表长度超过阈值8时会转为红黑树,默认初始容量16,负载因子0.75,当元素数量超过容量 × 负载因子时触发扩容,扩容后容量翻倍。
这里有一个高频延伸问题:为什么负载因子默认是0.75而不是1或者0.5。0.5的话空间浪费太严重,1的话哈希冲突概率增大、链表变长、查找效率下降。0.75是时间与空间的折中,这个值不是拍脑袋决定的,而是JDK源码作者在工程实践中权衡后的结果。能说到这一步,面试官通常就会觉得你是真懂,而不是背了八股文。
哈希冲突的处理方式也值得展开。HashMap使用的是链地址法,即冲突的元素放到同一个链表中。而ThreadLocal使用的是开放地址法,冲突之后往后探测下一个空位。这两种方案没有绝对的好坏,关键看使用场景。笔试题如果问到这个层面,目的是考察你是否理解同一个问题在不同场景下的不同解法。
3.2 ArrayList与LinkedList的选择依据
ArrayList和LinkedList的对比也是高频送分题,但很多人只背了结论"数组快、链表增删快",没有真正理解背后的原因。ArrayList底层是动态数组,随机访问的时间复杂度是O(1),但中间插入和删除需要移动元素,复杂度是O(n);LinkedList底层是双向链表,头尾插入删除是O(1),但随机访问需要遍历,复杂度是O(n)。
笔试里还有个容易踩坑的细节:LinkedList中间插入真的比ArrayList快吗?分情况。如果插入位置在链表中间,你得先遍历找到那个位置,这部分是O(n);找到之后插入才是O(1)。所以总体还是O(n),和ArrayList同级别,只是常数项不同。这个细节我在实际面试中问过别人,也被人问过,能说清楚的人确实不多。
3.3 多线程基础与synchronized的考察方式
并发这块,卷子没有直接问你"什么是线程安全",而是给了一段多线程操作共享变量的代码,让你判断输出的值是多少。这类题的核心考点是i++不是原子操作——它包含"读取→修改→写入"三个步骤,在多线程环境下会出现数据覆盖。
synchronized的考察集中在三个使用位置:修饰实例方法、修饰静态方法、修饰代码块。它们的锁对象分别是:当前实例、当前类的Class对象、括号里指定的对象。很多人能背出这三个结论,但到了代码题里还是会出错,原因是没有建立起"锁对象是谁"这个思维习惯。
volatile也是高频考点,主要考察两点:可见性和禁止指令重排。但要注意,volatile不能保证原子性,所以经典的volatile int i在多线程i++场景下依然不是线程安全的。这个知识点在笔试里经常作为判断题出现,记住就得分。
3.4 手写单例模式的多种写法
笔试卷的手写代码部分,单例模式几乎是必考题。因为它的实现方式足够多,每一版都能折射出你对并发机制的理解层次。我在那张卷子上写了三种写法:饿汉式、懒汉式(双重检查锁)、静态内部类。
// 饿汉式:类加载时就创建实例,简单但可能造成资源浪费 public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } } // 懒汉式(双重检查锁) 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; } } // 静态内部类:既懒加载又线程安全 public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }写双重检查锁的时候,volatile关键字不能省。原因在于instance = new Singleton()在字节码层面不是一步操作,而是先分配内存、再初始化对象、最后把引用指向内存地址。如果没有volatile禁止指令重排,另一个线程可能拿到一个还没初始化完成的对象。这个问题我在后来的实际面试中也经常用来考察候选人的并发功底。
4. JVM与内存:高频面试点与排查思路
4.1 内存区域划分与OutOfMemoryError排查思路
JVM部分是很多人的失分重灾区,因为学校里讲得少,工程中又不容易直接接触。但这块恰恰是区分"会用Java"和"懂Java"的分水岭。试卷上的题目很直接:画出JVM运行时数据区,并说明每个区域的作用。
标准答法包含五个区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区。其中堆是所有线程共享的,存放对象实例;虚拟机栈是线程私有的,存放栈帧,每个方法调用对应一个栈帧入栈出栈;方法区存放类信息、常量、静态变量,JDK 8之后被元空间取代。
搜热词里看到很多人搜java: outofmemoryerror: insufficient memory,这个确实是个高频问题。笔试中通常不会让你现场排查,但会问"堆内存溢出和栈内存溢出有什么区别"。堆溢出一般是创建了太多无法回收的对象,可以通过-Xmx调大堆空间或者优化代码解决;栈溢出通常是递归调用太深,比如没有正确退出条件的递归函数,会导致StackOverflowError。还有一个冷门但常被问到的点:OutOfMemoryError是错误不是异常,属于Error体系而不是Exception体系,所以用try-catch捕获它是典型的错误姿势。
4.2 GC与垃圾回收器的基本认知
GC相关的题目通常是问"你了解哪些垃圾回收算法"。标记-清除、标记-复制、标记-整理这三种必须能说清区别。标记-清除会产生内存碎片;标记-复制不会产生碎片但浪费空间;标记-整理解决了碎片问题,但移动对象需要暂停用户线程。
垃圾回收器方面,2017年笔试卷考到了CMS和G1的区别,放到今天看已不算新。回答的要点是:CMS关注低停顿,使用标记-清除算法,会产生碎片,JDK 9之后标记为废弃;G1面向大堆,把堆划分为多个Region,可以预测停顿时间,JDK 9之后成为默认收集器。如果还想加分,可以提一下JDK 11引入的ZGC,最大停顿时间不超过10ms。
这一块我建议不要贪多,把最主流的几个概念掌握扎实即可。面试官问到GC,通常不是真想让你比较所有收集器的性能数据,而是想看你对"什么时候触发GC、GC时其他线程怎么办"有没有清晰的认知。你越镇定、答得越有条理,得分就越高。
4.3 类加载机制与双亲委派模型
类加载机制的考点集中在双亲委派模型。题目会问:一个类加载请求过来之后,JVM是怎么处理的。标准流程是自底向上检查类是否已加载,自顶向下尝试加载类。也就是说,一个类加载器收到加载请求后,先把这个请求委派给父加载器,父加载器再往上委派,直到最顶层的启动类加载器。如果父加载器加载不了,才轮到子加载器自己尝试。
双亲委派模型的意义在于保证类的唯一性。比如java.lang.String这个类,无论哪个类加载器去加载,最后都会委派给启动类加载器,这样就保证了你写的代码里String和JDK自带的String是同一个类。如果打破这个模型,可能出现同一个类被不同加载器加载,导致instanceof判断失败之类的诡异问题。
这块内容如果你已经理解,能解释为什么自定义类加载器要遵循双亲委派模型;如果没理解,背答案也只能撑到第一层追问。我的建议是找个简单的自定义类加载器例子跑一遍,看看加载过程打印的日志,比干背十遍书都管用。
5. 数据库与SQL:笔试中的工程实战区
5.1 索引原理与SQL优化基础
数据库部分对Java工程师来说属于"必会但不求深入"的领域。笔试卷不会让你写特别复杂的SQL,更常见的是给你一张表,问你怎么建索引、为什么这样建。这背后考察的是对索引底层数据结构(B+树)的理解。
B+树的几个特性值得记清楚:所有数据都存在叶子节点,非叶子节点只存索引键;叶子节点之间通过指针相连,方便范围查询;树的高度通常只有3到4层,所以千万级数据的查询也只需要几次磁盘I/O。记清楚这三个特性,就能解释为什么MySQL的InnoDB引擎选择B+树而不是哈希索引或红黑树。
SQL优化那块经常出现的一条规则:不要在索引列上使用函数或计算。比如WHERE YEAR(create_time) = 2017会让索引失效,改成WHERE create_time >= '2017-01-01' AND create_time < '2018-01-01'才能走索引。这种细节笔试不一定直接考,但面试追问时很常见。
5.2 事务ACID与隔离级别
事务的ACID四个特性——原子性、一致性、隔离性、持久性——算是送分题,但要能说清底层的实现机制才显得有水平。原子性和持久性是通过redo log和undo log实现的,隔离性是通过锁和MVCC实现的,一致性是前三者共同作用的结果。能讲到这个层次,说明你不仅仅是记住了几个缩写。
隔离级别有四个级别,从上到下依次是:读未提交、读已提交、可重复读、串行化。MySQL默认是REPEATABLE READ,Oracle默认是READ COMMITTED。每个级别能解决的并发问题也不一样:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不可能 | 可能 | 可能 |
| 可重复读 | 不可能 | 不可能 | 可能(InnoDB通过间隙锁解决) |
| 串行化 | 不可能 | 不可能 | 不可能 |
这个表是笔试常考的,不仅要记结论,还要能举例子说明什么是脏读、什么是不可重复读、什么是幻读。脏读是读到了别人未提交的数据;不可重复读是同一个查询在同一个事务中读了两次,结果不一样;幻读是查询某个范围的数据时,另一个事务插入了新数据,导致两次查询到的条数不一样。
5.3 经典SQL编写技巧
手写SQL的题目,这张卷子考了分组聚合和多表关联。写这类题的通用思路是先读清楚题意,确定要查哪些字段、从哪些表取、有哪些过滤条件、要不要分组排序,然后再动笔写。
有一个容易忽略的小技巧:GROUP BY之后出现的字段,要么是被分组的字段,要么是聚合函数包起来的字段,MySQL的ONLY_FULL_GROUP_BY模式会强制检查这一点。还有WHERE和HAVING的区别——WHERE是分组前过滤,HAVING是分组后过滤,WHERE里不能使用聚合函数,HAVING可以。这些规则看着简单,实际写题的时候特别容易条件反射地写错。
还有一个细节值得注意:多表关联时,如果只是为了过滤条件而不需要展示某张表的字段,优先用EXISTS而不是IN。在数据量大时,EXISTS的提前终止特性可以让查询更快。当然,MySQL的优化器在特定版本做过优化,两种情况不一定有绝对差异,但笔试里写出这种优化思路本身就能加分。
6. 手写代码与算法:从冒泡到快排的实战
6.1 排序算法的基础考察逻辑
算法部分在这张卷子里占比不算特别高,但必有一道手写排序题。题目通常是二选一:冒泡排序和快速排序,让你任选一个实现。我当时选了快速排序,原因很简单:快排的代码体现出的逻辑层次比冒泡高很多,而且写的时候可以顺带展示对递归、分治、指针移动这些基础概念的理解。
冒泡排序的写法大家都会,双层循环加交换。不过笔试中如果只写出来基础版,拿不到加分。我在试卷上补充说明:当某一轮没有发生任何交换时,说明序列已经有序,可以提前终止外层循环。这个优化虽然改变不了最坏情况的时间复杂度,但能体现你写代码时考虑过边界和性能。
6.2 快速排序手写实现与边界处理
快速排序的写法有很多种,我推荐写最经典的双指针挖坑法,代码清晰,不容易出错:
public class QuickSort { public static void quickSort(int[] arr, int left, int right) { if (left >= right) { return; } int pivot = arr[left]; int i = left, j = right; while (i < j) { while (i < j && arr[j] >= pivot) { j--; } arr[i] = arr[j]; while (i < j && arr[i] <= pivot) { i++; } arr[j] = arr[i]; } arr[i] = pivot; quickSort(arr, left, i - 1); quickSort(arr, i + 1, right); } }写这题有几个容易忽略的细节。边界条件是left >= right时直接返回,漏掉这个就会无限递归;while (i < j && arr[j] >= pivot)里>=和<=不能随手写成>和<,否则遇到重复元素会死循环;左右指针交替填坑时要保证arr[i] = arr[j]的时候i < j,这两个循环的条件顺序不能反过来。
还有一道和排序紧密相关的经典题是二分查找,也值得重点准备:
public static int binarySearch(int[] arr, int target) { int left = 0, right = arr.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }这里用left + (right - left) / 2而不是(left + right) / 2,是为了防止两个大数相加超出int范围导致溢出。这个细节被问过无数次,笔试时写上能显得更专业。
6.3 经典手写题:反转链表与字符串处理
除了排序和查找,笔试卷还出现了两道高频手写题。第一道是反转单链表:
public class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; } } public static ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }这道题的要点是:在改变curr.next指向之前,必须先保存原来的next节点,否则链表就断了。很多人在白板上写这道题时,思路对但总是把next节点的保存时机写错,我建议动手画三个节点的链表,一步步模拟指针移动,比自己空想高效得多。
第二道是字符串去重或者统计字符出现次数。这类题本质上是在考你对哈希表(HashMap)或数组下标的灵活运用。比如统计一个字符串中每个字符出现的次数,标准做法是遍历字符串,用HashMap<Character, Integer>记录每个字符的出现次数。如果题目限定只包含小写字母,可以用一个长度26的int[]数组代替HashMap,性能更好且代码更简洁。能写出这种优化,说明你有数据结构选型的意识,而不仅仅是会调API。
7. 常见问题与避坑实录
7.1 答题时间分配与卷面策略
做了这么多年Java笔试,我的体会是:大部分人在基础题上浪费了太多时间,导致后面的算法题没时间做。这份卷子的题量正常来说90分钟可以完成,我的建议是前40分钟解决所有选择题和填空题,给手写代码和SQL留足时间。
基础题里如果遇到不确定的,先标个记号跳过,不要恋战。卷子是按点给分,一道两分的题啃十分钟,不如把时间留给一道十分的手写题。我当年做完试卷还剩下十分钟,用来检查了一遍选择题的答案,改对了两个粗心错的选项,收益比抠一道不会的题大得多。
7.2 常见"隐形失分点"与应对方式
很多人在笔试中失分不是不会,而是不注意答题规范和细节。我总结几个印象深刻的失分点:
第一,代码题没有写方法签名,只写了方法体。改卷的人不知道你写的方法是什么参数、什么返回值,想给分都难。我在实际面试中也见过不少候选人犯同样的错误,白板题一定要先写清楚方法签名,再写实现。
第二,命名不规范。临时变量叫a、b、c的问题不大,但方法名和核心变量名最好能见名知意。规范命名在笔试中还占了印象分的比重,好的命名习惯会让面试官对你另眼相看。
第三,SQL题没有考虑去重。查询结果如果可能出现重复行,记得加DISTINCT;多表关联时如果一张表对另一张表是一对多关系,记得检查结果有没有重复膨胀。这种错误在真实业务里会导致线上bug,在笔试里则是明显的扣分点。
第四,手写算法的复杂度分析没有写。题目要求"写一个排序算法",如果你写完代码顺手标注了时间复杂度O(n log n)和最坏情况O(n²),这种主动的行为会给面试官留下好印象。
7.3 笔试之后的准备方向
笔试通过只是第一步,紧接着的面试往往会围绕你卷子上的内容展开追问。我在实际招聘过程中观察到,很多候选人笔试不错,但一问细节就露馅,原因就是备考时背了太多八股文,只记住了结论,没有理解推导过程。
打个比方,你能说出HashMap的默认容量是16,但面试官追问"为什么是16而不是15或14",你就得明白这是因为容量强制要求是2的幂次方,而16在性能和空间之间取得了很好的平衡。当你能把这种"为什么"讲清楚,面试官才会认为你是真的在用脑子写代码,而不是在背题库。
另外一个常被忽略的准备方向是动手做一个小项目。哪怕是几百行的学生管理系统,只要能完整跑起来,就会对你理解Java的核心概念有很大帮助。我见过太多人把ArrayList、HashMap的源码背得很熟,但真到写代码的时候连数据模型都建不好,这是典型的纸上谈兵。
最后再分享一个实际心得:笔试结束之后,不管感觉好坏,第一时间把不会的题记录下来,回头逐一搞懂。我当年整理了一份错题清单,后来面试时发现很多题都是笔试考点的变形。你花在复盘上的时间,回报率远比多做十套新题高得多。这套方法我后来也带过几个实习生,坚持做的人最后都拿到了不错的offer。