
2017年秋招季我手里攒了十几场笔试比格基地的Java卷是其中比较特殊的一份。题目总量不算夸张但覆盖面广选择题里满是平时不看书就掉坑的陷阱简答题又特别喜欢追问为什么编程题更是把冒泡排序和快速排序这种最基础的内容拿来检验代码习惯。这套卷子做下来我的感觉是它考察的其实是候选人有没有真正理解Java底层机制而不是背了多少八股文。当年我在这套卷子上栽过不少跟头后来复盘时把每个考点都重新梳理了一遍很多知识直到今天依然是Java面试的高频内容。这篇帖子就围绕比格基地2017秋招Java笔试试卷中的几类代表题型讲讲我当时是怎么分析、怎么答错的以及现在回过头来应该怎么准备。无论你是正在准备校招的Java新人还是想系统梳理一遍Java基础的开发者这份拆解应该都能给你一些启发。1. 这套试卷的出题思路为什么它像一面照妖镜1.1 从试卷结构反推比格基地想招什么样的人先说说这套卷子的整体结构。比格基地的Java笔试试卷大致分为四个部分选择题、填空题、简答题、编程题。选择题约20道填空题10道左右简答题4到5道编程题两道。从知识点占比来看基础语法与面向对象大约占30%集合框架与IO占20%JVM与内存管理占15%多线程与并发占15%算法与编程占20%。这个比例透露出一个信号比格基地想招的不是会调用API的人而是能解释清楚代码背后的运行机制的人。比如选择题里经常出现这样的选项组合A. ArrayList是线程安全的 B. Vector是线程安全的 C. HashMap允许null键 D. Hashtable不允许null值看起来每个选项都在考察集合类的基本特性但如果你只是背过结论而没有用过就无法在多个相似结论之间迅速做出区分。整套卷子的难度阶梯拉得很开。前面选择题靠基础就能拿分但到了简答题和编程题要求立刻上升开始考察排查思路、边界条件、代码风格。这是很多校招笔试的通用套路但比格基地这套卷子的特点是它把细节敏感度放在了非常重要的位置。比如数组越界会抛出什么异常这种看似送分的题实际上是在考察你是否清楚ArrayIndexOutOfBoundsException是运行时异常是否清楚它在什么情况下会发生、什么情况下会被编译器放过。对正在备考的同学来说这套卷子的最大价值在于它几乎可以当作Java校招笔试的标准考点清单来看待。把卷子里暴露出来的知识点逐个吃透再去做其他公司的笔试题会明显从容很多。1.2 看似基础的选择题暗藏哪些送命题具体说几道让我印象深刻的送命题它们看似基础实际暗坑不少。第一道是switch语句相关的选择题switch表达式可以使用哪些类型给出了byte、long、String、char等多个选项。很多人会在String和long之间纠结。正确答案其实和Java版本有关Java 7之后String可以被switch使用但long始终不行。原因是switch底层是基于int进行比较的能隐式转换为int的类型byte、short、char、int以及它们的包装类型可以用枚举和String也做了特殊支持但long超出了int范围不能隐式转换。如果平时没扣过这个细节这道题很容易选错。第二道是String相关的问题对String对象执行操作会创建几个新对象这道题牵涉到常量池、字符串不可变性和编译器优化。如果只在代码层面思考可能会漏掉StringBuilder的存在如果没有任何编译原理基础又会把运行时行为和编译期常量折叠混在一起。这道题作为讨论题出现考察得非常细。第三道是try-catch-finally相关的问题如果try块中有return语句finally块还会执行吗答案是会而且finally块在return表达式计算之后、方法返回之前执行。但如果finally块里也写了return它会覆盖try块里的返回值。这个知识点很多人在开发中不会刻意去验证但笔试一旦出现就能拉开差距。这些题有一个共同点如果平时写代码只停留在能跑就行的层面很容易掉坑。反过来如果平时注意过编译原理和运行时行为它们就是送分题。这就是照妖镜的含义。2. Java基础与面向对象从String和Integer看值传递的坑2.1 一道String传参的题为什么很多人答错比格基地这套卷子里有一道很经典的简答题写一个方法change(String str)在方法内部将str重新赋值为world然后在main方法中传入一个值为hello的String变量问调用方法后该变量的值是什么。答案肯定是hello。但很多同学会答world原因是他们把引用传递理解成了传入一个引用后对引用指向对象的修改都会反映到外部。这个理解本身不算错但String有一个特殊性质——不可变性。当你执行str world时不是修改原来那个字符串对象而是让形参str指向了一个新的字符串对象。实参变量依然指向原来的hello对象。要彻底搞懂这个问题得从JVM运行时栈的角度看。Java方法调用中传给方法的永远是值基本类型传的是数值本身引用类型传的是引用地址的副本。所以无论String还是其他对象改动形参本身都不会影响实参。但如果形参引用指向的对象是可变的比如StringBuilder那么通过形参调用append(world)会真正改变对象内容。这道题后面还跟了一个追问如果换成StringBuilder会是什么结果这就是在考察你能不能分清重新赋值和修改对象状态的区别。在真实开发里我也建议尽量不要写传入参数然后在方法内重新赋值这种代码因为太容易引起误解。正确做法是返回新值或者明确声明方法会修改对象的哪些属性。2.2 Integer缓存与的判断陷阱另一道让人印象深刻的题是Integer a 127; Integer b 127; a b 的结果是什么Integer c 128; Integer d 128; c d 的结果又是什么第一组是true第二组是false。原因是Integer内部有一个缓存区间默认是-128到127。在自动装箱时如果数值落在这个范围内Java会直接返回缓存中的对象所以a和b指向同一个引用。超出这个范围时每次装箱都会new一个新的Integer对象用比较自然就是false。这个知识点看起来细碎但在实际生产环境里踩过坑的人会深有体会。我曾见过一个线上Bug两个Integer字段用来比较版本号开发人员用比较结果数字超过127之后逻辑全部出错。排查了很久才发现不是业务问题而是对象比较方式用错了。正确做法其实很简单判断两个对象值是否相等用equals或者Objects.equals如果要比较大小用compareTo()。回到笔试试卷这道题背后的考点其实是对象比较与基本类型比较的区别。同一张卷子的填空题还考了重写equals()为什么必须重写hashCode()本质上都指向一个原则凡是涉及对象相等性判断的场景都要明确你是想比较引用还是比较值。2.3 继承与多态构造函数调用顺序还有一道填空题子类实例化时父类静态代码块、子类静态代码块、父类实例代码块、父类构造函数、子类实例代码块、子类构造函数它们的执行顺序是什么正确答案是父类静态代码块 - 子类静态代码块 - 父类实例代码块 - 父类构造函数 - 子类实例代码块 - 子类构造函数。关键点有两个静态代码块只在类加载阶段执行一次与实例化无关实例代码块在每次创建对象时都会执行位置在构造函数之前。为什么是这个顺序因为子类构造函数第一行默认会调用super()这是语法强制要求的。所以父类的初始化必然先于子类的实例部分。而静态代码块的执行发生在类加载阶段类加载是懒加载用到了才加载所以父类静态块自然先于子类静态块。我自己在笔试时犯过一个错误把父类实例代码块和构造函数顺序记反了。后来写代码验证了一下通过在每一个阶段打印日志把输出顺序完整呈现出来才彻底记住。这个方法也推荐给你遇到记不住的执行顺序问题写一段带日志的小程序跑一遍比硬背更牢固。3. 集合框架与泛型ArrayList、HashMap和排序的底层逻辑3.1 为什么不建议在循环里用list.remove(i)比格基地这套卷子里有一道很典型的简答题有一段代码在for循环中遍历ArrayList并删除满足条件的元素运行时报ConcurrentModificationException问原因和解决办法。原因在于ArrayList的迭代器内部维护了一个expectedModCount每次调用next()或remove()时都会检查实际modCount是否与期望值一致。如果在循环里直接调用list.remove(i)ArrayList的modCount会被修改但迭代器不知道下一次检查时发现不一致就抛出ConcurrentModificationException。解决办法有三种第一种是使用迭代器的remove()方法它会同步维护expectedModCount第二种是使用Java 8的removeIf()方法一行代码完成第三种是从后往前遍历删除虽然不抛异常但并推荐作为主要方案。我在实际代码里推荐优先使用removeIf()因为它语义最清晰代码量也最少。这个考点在2017年出现到了今天依然是面试高频。因为很多初级开发者在真实项目里会写出循环内remove的Bug尤其是删除多个元素时还会出现漏删问题。比如for (int i 0; i list.size(); i) { if (condition) list.remove(i); }删除一个元素后后面的元素会前移但i继续递增跳过一个元素。笔试中的坑和实际开发中的坑其实是同一个只是换了一个壳。3.2 HashMap从数组加链表问到为什么重写equals还要重写hashCodeHashMap几乎是每份Java笔试试卷都不会缺席的角色。比格基地这套卷子里考了一道选择题如果往HashMap中放一个自定义对象作为key需要重写哪些方法答案是equals()和hashCode()都要重写但更重要的是能解释原因。HashMap的存储结构是数组加链表。put一个键值对时先根据key的hashCode()计算桶的位置如果桶里已经有元素再用equals()比较key是否相同相同则覆盖不同则用链表或红黑树存储。get时也是如此先用hashCode()定位桶再用equals()在桶内查找。如果只重写equals()而不重写hashCode()两个值相等的对象可能被散列到不同的桶导致明明逻辑上相等却无法在HashMap中正确找到。如果只重写hashCode()而不重写equals()又可能出现哈希值相同但内容不同的对象被错误地当成同一个key。我在一个实际项目中遇到过类似问题。当时有个用户对象作为key只重写了equals()结果put时没有问题get时经常返回null。排查到最后发现就是hashCode()没有正确重写。笔试时如果能主动讲出这个案例比单纯背结论更有说服力。另外比格基地这套卷子还顺带考了HashMap的容量和负载因子默认初始容量是16默认负载因子是0.75扩容阈值是容量乘以负载因子。这些参数并不需要死记但要知道它们影响的是空间占用和哈希冲突概率的平衡。3.3 工具类排序Comparator和Comparable的区别这套卷子的编程题里有一道定义一个Student类包含name和score两个字段要求按score降序排序如果score相同则按name升序排序。题目提示可以自己选择排序方式实现。很多同学的思路是手写冒泡或选择排序这当然没问题。但更符合工程实践的做法是使用Collections.sort()或List.sort()配合自定义比较器。这里就牵扯出两个接口Comparable和Comparator。Comparable是让类自己实现可比较的能力需要重写compareTo()方法。类一旦实现Comparable就拥有了默认的排序规则。Comparator则是一个独立的比较器可以在不修改Student类的情况下定义多种排序规则。就这道题而言用Comparator更合适因为一个班级的学生有多种排序需求按分数、按姓名、按学号如果都用Comparable实现会让类变得非常臃肿。Java 8之后还可以直接用lambda表达式和链式调用实现ListStudent list new ArrayList(); list.sort(Comparator.comparing(Student::getScore).reversed() .thenComparing(Student::getName));这个写法语义很直观代码也简洁。如果担心性能可以先对次要条件排序再对主要条件进行稳定排序因为稳定排序能保证相同分数之间的相对顺序不变。这个技巧在实际开发中经常用到笔试时写出来也很加分。4. JVM与内存OutOfMemoryError并不是只有一种4.1 当试卷问你遇到过哪些OOM时比格基地这套卷子的简答题里有一道你遇到过哪些OutOfMemoryError分别是什么原因导致的这道题在2017年算半个加分题因为很多应届生只听说过OOM并没有真正排查过。常见的有三种。第一种是java.lang.OutOfMemoryError: Java heap space表示堆内存不足通常是创建的对象太多或者存在内存泄漏导致GC无法回收大量对象。第二种是GC overhead limit exceeded表示GC一直在执行但回收效果极差JVM为了自我保护而主动抛出异常。这种场景多发生在内存接近耗尽、大量对象频繁晋升到老年代时。第三种是Unable to create new native thread表示操作系统无法再创建新的线程一般是线程池设置过大或者系统线程数被耗尽。记住这三种类型之后再遇到线上服务突然挂了日志里有OutOfMemoryError时就不会手足无措。先判断是哪种OOM再决定是调整堆大小还是排查代码里的引用问题。比格基地考这个点说明它希望候选人不仅知道-Xmx参数还能把参数和异常现象对应起来。4.2 一个内存泄漏案例ThreadLocal和线程池的组合比格基地这套卷子没有直接考ThreadLocal但有一道扩展题问哪些使用场景会导致内存泄漏。我在备考时顺便把ThreadLocal的泄漏问题整理了一遍后来笔试简答题真的用上了。ThreadLocal的原理是每个线程内部有一个ThreadLocalMapkey是ThreadLocal对象类型是WeakReferencevalue是放入的对象。当ThreadLocal对象不再被强引用时WeakReference会被GC回收但value仍然被ThreadLocalMap强引用着。如果这个线程不销毁value就永远无法被回收这就形成了内存泄漏。在实际应用中线程池是ThreadLocal泄漏的重灾区。因为线程池中的线程是复用的线程长时间存活ThreadLocalMap里的value一直没有机会被清理。如果每个请求都在ThreadLocal里放了用户信息而忘记remove随着请求量增加内存占用会不断攀升。解决办法也很简单使用完ThreadLocal后必须调用remove()方法尤其是配合线程池使用时一定要在finally块中清理。这个习惯一开始觉得麻烦但养成之后能规避很多线上问题。笔试时如果能主动提到ThreadLocal可能因为线程池复用导致老年代不断增长最终触发OOM面试官往往会觉得你有真实排查经验。4.3 2017年还算加分项的JVM调优题现在基本是标配这套卷子的最后一道简答题问的是如果线上服务频繁Full GC你如何排查这个问题在2017年的校招里答得好的应届生不多但放到现在基本是Java后端面试的标配。常规排查链路是先通过jstat -gcutil观察各个内存区域的使用情况和GC次数确认是不是老年代增长过快。再用jmap -dump:formatb,fileheap.hprof导出堆转储文件用MAT或VisualVM分析大对象和引用链。定位到可疑类之后回到代码里检查是否有集合没有清理、ThreadLocal没有remove、数据库连接没有关闭等问题。如果临时需要顶住压力可以先把-Xmx调大或者调整-XX:NewRatio参数但这些是治标不治本最终还是要找到对象的真正来源。笔试试卷里不可能让你真的运行jmap所以关键在于把排查思路讲清楚。先怀疑内存泄漏再怀疑对象过大再检查GC配置最后再看操作系统层面是否有物理内存不足。这个思维链条本身就是完整的排障方法论。5. 多线程与并发从synchronized到volatile5.1 一道经典题两个线程交替打印奇偶数比格基地这套卷子的编程题里有一道是让两个线程交替打印1到100的奇数和偶数要求先打印奇数线程再打印偶数线程。这道题看似简单但很考验对线程通信的理解。最常见的方式是用synchronized加wait/notify。定义一个共享的锁对象和一个计数器。奇数线程获得锁后如果当前数字不是奇数就调用wait()释放锁并等待如果是奇数就打印并递增数字然后调用notifyAll()通知偶数线程。偶数线程的逻辑完全对称。这里最容易写错的地方是等待条件必须放在while而不是if里面。如果用if判断线程被唤醒后不会再次检查条件可能因为虚假唤醒或条件变量变化而打印出错误数字。这道题真正的评分点就在这个while上。能写出完整代码的人很多但能意识到while和if区别的人才算真正理解线程通信。如果还要展示更高的水平可以用Lock和Condition来实现。Condition天然支持两个等待队列可以分别唤醒奇数线程和偶数线程比synchronized加notifyAll更加精确。笔试时能写出Lock版本的实现通常会让阅卷人眼前一亮。5.2 volatile能保证什么不能保证什么另一道选择题多个线程对一个volatile修饰的int变量执行i操作最终结果一定正确吗答案是不一定。volatile保证的是可见性也就是一个线程修改了变量其他线程能立即看到。但它不保证原子性而i本身是读取-计算-写回三个动作不是原子操作。多个线程同时执行i时仍然会丢失更新。很多同学把volatile理解成线程安全的同义词这是错误的理解。它只解决了一个线程改了值另一个线程看不到的问题并没有解决多个线程同时修改的问题。更关键的是即使把变量声明为volatile如果业务逻辑包含先读后写这种依赖旧值的操作仍然需要使用synchronized、Lock或AtomicInteger。当时我为了验证这个知识点自己写了一个小实验用10个线程每个线程执行10000次i用volatile修饰i最终结果往往小于100000。把i换成AtomicInteger后结果稳定是100000。这个实验非常直观建议备考的同学也亲手跑一遍比看十遍理论有用得多。5.3 线程池参数核心线程数、最大线程数、队列比格基地这套卷子的简答题里有一道关于线程池的文字题ThreadPoolExecutor有哪几个核心参数它们之间是如何配合工作的这个问题在2017年算进阶题但放到现在已经是Java并发编程的必背内容。核心参数有七个corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。执行流程是提交任务时先判断当前线程数是否小于corePoolSize如果小于则直接创建核心线程执行否则尝试往工作队列里放任务如果队列满了再判断当前线程数是否小于maximumPoolSize如果小于则创建非核心线程执行如果已达到最大线程数则执行拒绝策略。关于拒绝策略默认的AbortPolicy会抛出RejectedExecutionExceptionDiscardPolicy会静默丢弃任务CallerRunsPolicy会让提交任务的线程自己执行。笔试时经常出现一个场景题核心线程数是2最大线程数是5队列容量是3连续提交9个任务会怎么样答案是前2个任务占用核心线程第3到第5个任务进入队列第6、7、8个任务新建3个非核心线程总数到5第9个任务到达时核心线程满了、队列满了、线程数也到了最大因此触发拒绝策略。如果用的是默认AbortPolicy就会抛出RejectedExecutionException。这个流程如果能一步一步说清楚说明你真的理解线程池而不是只背了概念。6. 算法编程题冒泡排序和快速排序的隐藏评分点6.1 手写冒泡排序不是会写就能过比格基地这套试卷的两道编程题中第一道是手写冒泡排序。题目本身很简单但要求对数组进行升序排序并且要考虑代码规范。我的建议是哪怕是最简单的排序题也要注意三个细节。第一边界条件判断。如果数组为null或长度小于2直接返回避免空指针和无效循环。第二内层循环的边界。标准写法中外层循环控制轮数内层循环控制每轮比较次数次数是length-1-i。第三代码风格。变量名使用常见约定交换元素时写清楚临时变量阅卷人一眼能看懂。进阶一点的优化是在每轮内层循环中记录是否发生了交换。如果某轮结束后没有发生任何交换说明数组已经有序可以提前终止。这个优化对接近有序的数组非常有效时间复杂度从O(n²)降到接近O(n)。笔试时如果写出来说明你不只是会背代码而是理解了排序过程的特征。public static void bubbleSort(int[] arr) { if (arr null || arr.length 2) { return; } 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 tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped true; } } if (!swapped) { break; } } }6.2 快速排序从递归到为什么一定从右边开始第二道编程题是快速排序。这道题当年让不少人栽了跟头因为代码量虽然少但非常容易在递归边界和partition过程上出错。常见的实现是挖坑法选第一个元素作为基准值用一个变量保存它然后从右边向左找比基准小的元素填入左边坑位再从左边向右找比基准大的元素填入右边坑位最后把基准值放回原位。有一个细节经常被问到为什么基准值选左边第一个元素时必须先从右边开始找答案在于挖坑法的逻辑。如果先从左往右找找到一个大元素后填到右边的坑位会破坏基准值所在位置的正确性最终基准值可能无法回到正确位置。先从右往左找是保证左右指针能够正确相遇并把基准值放回原位的关键。这个不是死记硬背的规律而是符合每次挖坑都留下一个新坑给另一侧填的逻辑。递归终止条件同样重要当start end时直接返回否则会出现无限递归或栈溢出。我见过很多同学写快排时遗漏这个判断导致局部变量越界或者排序结果错误。快速排序的时间复杂度平均是O(n log n)最坏情况下O(n²)最坏情况往往发生在数组已经有序或逆序时。为避免这种情况可以用三数取中法选择基准值或者随机选择一个元素与第一个元素交换后再执行partition。笔试中如果能提一句这个优化说明你已经不只是默写代码而是在考虑算法在实际场景中的局限性。6.3 从笔试题看算法考察趋势代码规范比跑通更重要做完比格基地这套卷子的编程题后我最大的感受是评分不完全看结果是否正确还看你的代码思路是否清晰、边界是否考虑周全、是否容易维护。这个倾向在很多公司笔试里都存在只是比格基地这套卷子表现得特别明显。一个很典型的现象很多人写冒泡排序时忘记判断空数组写快排时忘记处理start end的递归终止条件。这些代码在IDE里跑测试用例时可能因为测试数据恰好没有触发边界而蒙混过关但阅卷人一眼就能看到。另一个问题是命名随意比如用a、b、temp这样的变量名虽然不影响正确性但可读性很差。如果能在关键步骤写上一两句注释比如从右往左找比基准小的数阅卷体验会好很多。我还想提醒一点笔试时尽量不要把多个编程题的逻辑堆在一个类里。按题号拆成多个方法或类既方便自己调试也方便阅卷人找到对应逻辑。这个习惯在真实项目里同样重要模块边界清晰代码才易于维护。7. 做完这套卷子的复盘给后来者的建议7.1 时间分配选择题别恋战第一次做比格基地这套卷子时我犯了一个很典型的错误在选择题上花费了太多时间导致最后编程题时间紧张冒泡排序虽然写出来了但快排在递归边界上出了错。复盘之后我意识到笔试的时间分配直接决定成绩上限。我自己的建议是拿到卷子先快速浏览一遍估算每部分的题量和难度。选择题和填空题尽量控制在总时间的三分之一以内遇到拿不准的题目先做个标记不要立刻死磕。简答题控制在三分之一左右剩下至少三分之一留给编程题。因为编程题不仅考察正确性还考察代码规范通常需要反复检查和微调时间必须充足。如果两道编程题有难度差异先从简单的那道开始保证保底分再去挑战进阶题。7.2 回归源码和底层不要在API用法上止步这套卷子的很多题目只看API文档也能答出来但如果要答得好、答得深就得看源码。比如HashMap的扩容机制、ArrayList的自动扩容、ThreadPoolExecutor的拒绝策略这些知识点只有真正读过源码才能在笔试时写出有层次的答案。备考时可以分模块看源码。集合框架先从ArrayList、LinkedList、HashMap入手并发先从ThreadPoolExecutor、AQS、synchronized入手JVM先从类加载机制、垃圾回收器入手。每看完一个类尝试用文字或图形把它的执行流程理一遍不需要很专业只要自己能讲清楚就行。笔试时遇到为什么类问题这些积累会自然浮现出来。7.3 保持代码手感每天手写一两道算法题最后一个建议是保持写代码的手感。笔试和面试时最怕的不是不会写而是手生明明思路清晰但到纸上或编辑器里语法细节频繁出错。我自己备考期间的方法是每天固定手写一到两道算法题包括排序、链表反转、二叉树遍历、字符串处理等基础题。不要用IDE的自动补全坚持纯手写这样才能发现哪些API记不牢、哪些边界没考虑到。比格基地这套卷子里的编程题虽然难度不高但非常看重基础功。我在后来准备其他公司笔试时也发现Java校招的算法题基本不会超出数组、链表、二叉树、排序、动态规划入门这个范围。刷题不在多而在每次都把边界、复杂度、优化思路清理清楚。长期积累下来笔试时的心理状态会稳定很多。最后再分享一个小技巧做笔试题时遇到编程题不要上来就敲代码先在草稿纸上写下核心思路、关键变量、边界条件然后再动手。这套卷子的两道编程题我就是靠这个习惯在时间紧张的情况下保住了正确率。有些坑只有自己踩过才知道有多深希望这篇拆解能帮你少踩几个。