每年三四月份都是Java工程师跳槽和校招的集中期,贝壳找房作为房产交易平台里技术投入比较重的一家,它的春招笔试卷在业内一直有不错的参考价值。我最近正好完整刷了一遍贝壳找房2023年春招Java工程师笔试卷2,整体感受是:题目不偏、不怪,但覆盖面很广,基础题占了大头,同时穿插了几道需要真正理解原理才能答对的“拦路虎”。这份试卷对正在准备Java实习或校招的同学来说,是一份很值得用来查漏补缺的实战材料。
这份卷子整体分为四大部分:单选题、多选题、SQL题、编程题。 从考点分布来看,Java基础语法、集合框架、JVM内存模型、并发编程、Spring框架、MySQL索引与事务、Redis缓存、分布式一致性这些是绝对的高频区。尤其是Java并发和JVM相关题目,几乎每场大厂笔试都会出现,贝壳也不例外。下面我就结合这份试卷的题目,把每一类的核心考点、解题思路和容易踩的坑完整拆一遍。
1. 试卷整体风格与考点布局
先别急着做题,把整张卷子的考察逻辑摸清楚,比盲目刷题有用得多。贝壳这套笔试卷的命题风格可以总结为三句话:基础题考深度、框架题考场景、编程题考边界。
1.1 题型配比与分值分布
从题型结构来看,50道选择题(含单选和多选)占了大概60%的分值,剩下40%集中在两道SQL题和两道编程题上。选择题里,Java基础(集合、String、异常、泛型)大约占15题,JVM和并发各自占8-10题,Spring和MySQL各占5题左右,Redis和分布式加起来占剩下的部分。这个配比其实反映了大厂对Java工程师的核心预期:基础知识必须扎实,同时对真实生产环境里常用的组件(MySQL、Redis、Spring)要有场景化理解,而不是停留在背概念。
很多人会忽略多选题。贝壳这套卷子的多选题共10道左右,每道题的选项数量从4到6个不等,少选得一半分、多选或错选不得分。这种评分规则意味着你不仅要能判断“哪个选项对”,还要能判断“哪个选项不对”,对知识的准确度要求比单选题高一个档次。我们平时复习的时候很容易只记结论不记限定条件,比如“HashMap是线程不安全的”,这句话本身没错,但放到多选里,如果选项说“HashMap在单线程下是线程安全的”,很多人就会犹豫。实际上HashMap在单线程下不存在竞争,自然会被认为是线程安全的,但严格来说它并没有做任何线程安全保证——这种细微差别就是多选题的命题空间。
1.2 高频考点明细表
根据我对这套试卷以及近年来贝壳、链家系其他技术岗笔试题的对比分析,考点分布基本落在下表这些方向:
| 考察模块 | 核心知识点 | 出题形式 | 出现频率 |
|---|---|---|---|
| Java基础 | String、包装类缓存、异常体系、泛型擦除 | 单选 | 极高 |
| 集合框架 | HashMap底层、ConcurrentHashMap分段、ArrayList扩容 | 单选/多选 | 极高 |
| JVM | 内存区域划分、GC算法、类加载双亲委派 | 单选/多选 | 高 |
| 并发编程 | synchronized与ReentrantLock、volatile语义、AQS原理 | 单选/多选 | 高 |
| Spring | Bean生命周期、事务传播机制、循环依赖 | 单选 | 中高 |
| MySQL | 索引失效场景、事务隔离级别、MVCC | 单选/SQL题 | 高 |
| Redis | 缓存穿透与击穿、持久化、分布式锁 | 单选/编程 | 中高 |
| 分布式 | 幂等性、接口设计、消息队列 | 编程/设计 | 中 |
这里有一个值得注意的点:选择题目里几乎没有出现“手写单例模式”“判断输出结果”这类烂大街的题,而是更多换成了一种变体——给一段代码,问这段代码在什么情况下会出问题,或者这段代码能承受多大的并发量。这是贝壳系笔试的一个特色,它考察的不是你会不会写一个知识点,而是你知不知道这个知识点在生产环境里为什么重要。
2. 核心考题深度解析:Java基础与集合框架
这一部分是整张卷子的地基,也是很多自认为“基础不错”的同学真正翻车的地方。我把其中最有代表性的几类题目拿出来,逐个拆解背后的逻辑。
2.1 String与包装类的“送命题”变形
试卷里有一道这样的题:
String s1 = "abc"; String s2 = new String("abc"); String s3 = s2.intern(); System.out.println(s1 == s2); System.out.println(s1 == s3);这题考察的是字符串常量池与intern方法的语义。我估计大部分人能答对:第一个输出false,第二个输出true。但贝壳随后追问了一个变体:
Integer a = 127; Integer b = 127; Integer c = 128; Integer d = 128; System.out.println(a == b); System.out.println(c == d);这道变体的价值在于:Integer的valueOf方法在-128到127之间有缓存,所以a == b是true,而c == d是false。很多人在这个标准答案上没问题,但再变一下就会露馅:
Integer e = new Integer(127); Integer f = new Integer(127); System.out.println(e == f); System.out.println(e == a);无论值是否在缓存范围内,只要使用了new关键字,就会在堆上创建新对象,所以e == f是false,e == a也不能为true。这套追问的意义在于:大厂已经不再满足于考察你是否知道Integer缓存,而是考察你是否真正理解对象引用和缓存机制之间的关系。换成Long、Short、Byte也是同样的规律,但Float和Double没有缓存实现,因为浮点数的相等性判断本身就没有广泛使用==的必要——这一点在选择题选项里出现过,属于较冷门的知识点。
再往深一层,String的intern()方法在JDK 7以后把字符串常量池移动到了堆中,这样做的好处是常量池不再是固定大小,可以随着堆扩容;坏处是intern()方法如果被滥用,会加重堆内存的占用和GC的负担。如果面试官顺着这题继续追问“什么场景下不建议使用intern”,你要能说出“在大量动态生成相似字符串的系统中,intern会造成堆中重复字符串的持久化引用,阻碍垃圾回收”这个层面的理解。这些都是刷题背结论无法覆盖的深度,却是笔试卷上拉开差距的关键。
2.2 HashMap的底层原理与并发问题
贝壳在选择题中直接考察了HashMap的底层数据结构变化历程。JDK 1.7中HashMap采用数组+链表的结构,JDK 1.8改为数组+链表+红黑树,当链表长度超过8且数组容量大于等于64时,链表会树化为红黑树。这个“8”和“64”两个阈值,几乎年年考,但真正容易被忽略的是为什么是8而不是7或9。
这里有一个统计学背景:在随机哈希码的情况下,链表节点数达到8的概率约为千万分之六,这个概率已经足够低,说明大多数时候链表长度不会超过8。但如果出现了超过8的情况,说明要么哈希函数设计有问题导致冲突严重,要么是有人恶意构造哈希碰撞企图发起Hash攻击。红黑树的结构能在O(log n)的复杂度下完成查找,把最坏情况从O(n)优化到O(log n),从安全角度讲也具备一定防御意义。不过要注意,如果数组容量小于64,即使链表超过8也不会树化,而是优先扩容数组,因为扩容能让元素重新散列、降低冲突,比直接树化更高效。
这题的延伸是“put操作在什么时候会触发扩容”。很多人的答案是“元素个数超过负载因子乘以数组容量时”,这个没错,但不够完整。真正的扩容触发点有两个:一是size > threshold,其中threshold = capacity * loadFactor;二是在树化之前,如果tab.length < MIN_TREEIFY_CAPACITY(64),也会先扩容而不是树化。这两个触发条件缺一不可。另外,并行环境下HashMap会出现的典型问题包括:JDK 1.7中的头插法在多线程扩容时可能形成环形链表,导致get操作死循环;JDK 1.8中虽然改成了尾插法避免了环形链表问题,但数据丢失、size不准确的问题依然存在。所以HashMap从头到尾都不适合在多线程环境下使用,要使用ConcurrentHashMap而不是用Collections.synchronizedMap包一层应付了事。
2.3 ArrayList扩容机制与AbstractList的快速失败
还有一道题,考察的是ArrayList扩容后的新容量。默认情况下,ArrayList第一次添加元素时容量从0扩容到10,之后每次扩容为原来的1.5倍,也就是newCapacity = oldCapacity + (oldCapacity >> 1)。如果用户通过构造函数指定了初始容量,比如new ArrayList<>(15),那么第一次添加元素时不会触发扩容,而是等到第16个元素添加时才触发,新容量变成22(15的1.5倍)。
这个考点本身不难,但“快速失败机制”这个概念让不少人在判断题上栽了跟头。ArrayList的Iterator在迭代过程中如果检测到modCount发生变化,会立即抛出ConcurrentModificationException。这里需要注意一个细节:for-each循环的本质就是iterator,所以以下代码必然抛异常:
List<String> list = new ArrayList<>(); list.add("a"); list.add("b"); for (String s : list) { if (s.equals("a")) { list.remove(s); } }很多人会想当然认为“只删一个元素不会出问题”,但modCount从2变成3,迭代器维护的期望值还是2,检测到不一致就抛异常。正确的删除方式是使用Iterator.remove()方法,因为该方法会同步更新期望的modCount。这一题在试卷里是以判断改错形式出现的,考察的正是基础代码的边界意识。我在实际生产中确实遇到过线上服务因为类似代码导致批量任务失败的case,后来整个团队都约定:遍历时删除一律用迭代器。这已经不是笔试考点的问题,而是工程规范问题。
3. JVM与并发编程:笔试中的“分水岭”
JVM和并发是Java笔试里最能拉开区分度的两个板块,也是贝壳这套卷子的压舱石。没有背熟这两块,选择题正确率很难超过80%。
3.1 JVM运行时内存区域与GC回收的判定逻辑
试卷中有道简答式选择题,给了四个内存区域:堆、虚拟机栈、本地方法栈、程序计数器,问“哪个区域在特定条件下不会抛出OutOfMemoryError”。答案是程序计数器。因为程序计数器是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。这个冷门知识点考得相当细节,如果能答对,说明基础是真的扎实。
更经典的还有Java堆的划分。JDK 8以后,方法区被移除了永久代的概念,改成了元空间(Metaspace),使用本地内存来存放类的元数据。这里有一个常见误区:很多人把“字符串常量池在JDK 7之后移到了堆中”和“运行时常量池在元空间中”混淆。实际上,运行时常量池依然属于方法区的一部分,JDK 8里就位于Metaspace,而字符串常量池在JDK 7开始就已经移到了堆中,两者位置不同、存储内容不同、回收机制也不同。选择题里专门有一道题考察了这个区别,四个选项分别是“字符串常量池在元空间”“运行时常量池在堆中”“字符串常量池在堆中”“运行时常量池在方法区中”,标准答案是后两者。
GC方面,判断对象是否存活的标准是GC Roots是否可达。所谓GC Roots包括:虚拟机栈中引用的对象、本地方法栈中引用的对象、方法区中的静态属性引用对象和常量引用对象、被synchronized加锁的对象等。不可达并不等于立即被回收,还需要经过两次标记过程,第一次标记后判断是否有必要执行finalize(),如果对象覆盖了finalize()且未被激活,会进入F-Queue队列等待虚拟机自动调用。这一块在试卷里出成了多选题:哪些对象可以作为GC Roots?正确选项是“虚拟机栈中局部变量表引用的对象”和“方法区中类静态属性引用的对象”,而“被final修饰的常量”需要看它是否指向对象且存放在常量池中,属于常量引用,也可以算GC Root——这个选项有很多人漏选。
关于GC算法,新生代使用复制算法,老年代使用标记-清除或标记-整理。为什么新生代不能直接用标记-清除?因为标记-清除会产生内存碎片,而新生代的GC频率高、存活对象比例低,复制算法的代价相对可控。为什么老年代不能用复制算法?因为老年代对象存活率高,复制算法需要额外空间来存放存活对象,成本太高。所以JVM设计者根据对象生命周期特征选择了不同的回收策略,这不是拍脑袋决定的,背后是空间利用率和GC效率之间的权衡。
3.2 synchronized与volatile的现代视角
并发编程题里,有一道非常典型的:
class Counter { private int count = 0; public synchronized void increment() { count++; } }问:这个increment方法能保证线程安全吗?如果count用volatile修饰后能保证线程安全吗?
第一个问题答案是“能”。因为synchronized在进入和退出同步代码块时,会分别执行加锁和解锁操作,锁的获取会强制刷新工作内存中的变量值,锁的释放会把修改同步回主内存,所以count++这个“读-改-写”操作在同步块内是原子性、可见性都得到保证的。第二个问题答案是“不能”。volatile只能保证可见性和有序性,但不能保证原子性,而count++是复合操作,即使都看到了最新值,依然存在多个线程“同时读到同一个旧值,各自加1后再写回”的场景,所以最终结果会小于预期值。这个基础题不会有人答错,但以下几道变体可能才是真正的陷阱:
volatile boolean flag = false; // 线程A flag = true; // 线程B while (!flag) { }这道题考察volatile在“状态标志”场景下的正确用法。因为对flag的写入是单一赋值操作,不依赖当前值,所以不存在原子性问题,volatile的可见性和有序性能够保证线程B一定能看到线程A的修改。volatile也不能完全禁止重排序,它通过内存屏障禁止的是特定指令的重排序,而不是所有重排序。JMM中关于volatile的重排序规则表是:如果第一个操作是volatile读,则不管第二个操作是什么都不能重排序;如果第二个操作是volatile写,则不管第一个操作是什么都不能重排序;如果第一个操作是volatile写、第二个操作是volatile读,则不能重排序。这个规则表在多选题中出现过,解释起来就是:volatile读之后不能有普通变量写排到它前面,volatile写之前不能有普通变量读写排到它后面。
更深入的并发机制是AQS(AbstractQueuedSynchronizer)。ReentrantLock、CountDownLatch、Semaphore和ReentrantReadWriteLock都基于AQS实现。AQS的核心是一个volatile int state状态字段和一个CLH变体队列。以ReentrantLock为例,state表示锁的重入次数,当前线程获取锁时如果state == 0则CAS尝试置为1表示获得锁,如果当前线程已经持锁,则state加1,释放时依次减1直到0。CLH队列中的等待节点通过前驱节点的waitStatus来判断是否需要阻塞。这一套机制看起来抽象,但笔试里真正考的是“公平锁与非公平锁的差异”:非公平锁在获取锁时会先尝试一次插队CAS,如果失败再排队;公平锁则严格按排队顺序获取。所以非公平锁在竞争激烈时可能导致某些线程长时间饥饿,但它的吞吐量通常更高,因为它减少了线程唤醒造成的上下文切换。
3.3 线程池参数设计与拒绝策略
线程池是并发题里性价比最高的一道必考题,这在贝壳试卷里同样没有缺席。题目给出了一个典型场景:一个系统每秒接收500个请求,每个请求处理耗时200ms,要求不堆积请求,问如何设置核心线程数。
计算公式是:并发线程数 = 每秒请求数 × 平均响应时间。500 × 0.2 = 100个并发线程数。但这只是理论值,在实际生产环境中还需要考虑CPU核数、IO等待占比和任务队列的长度。如果是CPU密集型任务,线程数设为CPU核数 + 1比较合适;如果是IO密集型任务,线程数可以设为CPU核数 × (1 + 平均等待时间 / 平均计算时间)。比如一个服务部署在4核机器上,IO等待时间占比80%,计算时间占比20%,那么线程数理论上可以设置为4 × (1 + 0.8 / 0.2) = 20。
拒绝策略的选择也是一道多选题的考点。AbortPolicy是默认策略,直接抛RejectedExecutionException;CallerRunsPolicy让提交任务的线程自己执行该任务,相当于是把压力倒推给调用方,这是一种天然的背压机制;DiscardOldestPolicy丢弃队列中最旧的未处理任务,适合允许丢弃任务的应用;DiscardPolicy直接丢弃新任务,比较粗暴但不会抛异常。实际工作中,对于不可丢失的任务,我通常选择CallerRunsPolicy,因为它的行为最可控——任务不会丢,只是执行速度会受限于调用方线程的执行能力。而AbortPolicy容易被忽略,一旦触发就直接抛异常,如果没有配套的监控报警,很容易造成业务静默失败。
4. MySQL与SQL题:不只是写出来,还要写对
贝壳这套笔试卷的SQL题不算难,但非常典型。一道是“查询每个部门工资最高的员工”,另一道是“统计各状态订单数并排序”。如果你只会写GROUP BY而不会窗口函数,第二题也许能勉强通过,但要拿到满分还有点悬。
4.1 经典“每组TopN”查询的三种写法
题目给出了员工表:
CREATE TABLE employee ( id INT PRIMARY KEY, name VARCHAR(50), department VARCHAR(50), salary DECIMAL(10, 2) ); -- 查询每个部门工资最高的员工信息我推荐的写法是使用窗口函数,简洁且通用:
SELECT department, name, salary FROM ( SELECT department, name, salary, RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rk FROM employee ) t WHERE rk = 1;这里用RANK()而不是ROW_NUMBER(),是因为存在同分情况时,工资相同的人应该同时被列出。如果业务方只想取一个人,那应该用ROW_NUMBER(),同时要明确告诉业务方“相同工资取谁是不确定的”——这个决定不能由SQL隐含完成,需要显式加二级排序条件。
如果不支持窗口函数(比如面试官限定MySQL 5.7),可以用关联子查询:
SELECT e.department, e.name, e.salary FROM employee e WHERE e.salary = ( SELECT MAX(salary) FROM employee WHERE department = e.department );这种写法对索引要求比较高,大表情况下性能堪忧,但笔试试卷里把逻辑写对即可。我自己更推崇的第三种方式是用LEFT JOIN,但它在理解上不如前两种直观,这里不展开。
4.2 索引失效场景与优化方向
SQL题之外,试卷的选择题部分还有不少MySQL索引的题,这里几乎必考索引失效的场景列表:
- 对索引列使用函数或表达式计算:
WHERE YEAR(create_time) = 2023会导致索引失效 - 隐式类型转换:
WHERE phone = 13800138000如果phone字段是varchar类型,会发生隐式转换 - 左模糊查询:
LIKE '%abc'索引失效 - 联合索引不满足最左前缀原则:
(a, b, c)联合索引,查询条件只有b时索引失效 OR连接非索引列:WHERE a = 1 OR b = 2如果b不是索引列,整个查询无法走索引
在这一题之上,贝壳加了一个追问:如果SQL里的OR连接的是同一个索引列,WHERE a = 1 OR a = 2会走索引吗?答案是会的,因为MySQL可以将它优化为a IN (1, 2)的形式,进而走索引。但如果两个条件列不同,优化器就很难处理了。这个细节一般人不注意,但在多选题里作为选项出现时非常容易被误判。
事务隔离级别和MVCC也是必考项。MySQL默认的REPEATABLE READ隔离级别下,可以防止脏读和不可重复读,但不能完全防止幻读。InnoDB通过间隙锁(Gap Lock)和next-key lock在特定场景下解决了幻读问题,但前提是查询条件能利用索引。MVCC的核心是三个隐藏列:DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID,以及ReadView机制。REPEATABLE READ下,ReadView只在第一次读取时创建,后续复用;READ COMMITTED下,每次读取都会生成新的ReadView。这就是为什么REPEATABLE READ能保证可重复读而READ COMMITTED不能的关键。理解这一层逻辑比死记硬背隔离级别表格有用得多,因为在多选项里,命题人往往会把一个错误的结论伪装成正确表述,比如“REPEATABLE READ下所有事务都不会出现幻读”——严格来说这句话不正确,因为它忽略了当前读(加锁读)的情况。
5. 编程题与场景设计:从“能跑”到“能上线”
贝壳这套卷子的编程题有两道,一道是“实现LRU缓存”,另一道是“设计一个线程安全的计数器”。这两道题都不难,但想拿满分不容易,因为它们对代码的健壮性和并发安全有隐含要求。
5.1 LRU缓存的高效实现:LinkedHashMap
LRU(最近最少使用)缓存是面试里的常客。最直接的写法是继承LinkedHashMap,重写removeEldestEntry方法:
import java.util.LinkedHashMap; import java.util.Map; public class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity = capacity; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }代码本身非常精简,但理解accessOrder参数是关键。构造函数第三个参数传true表示按访问顺序排序,每次get或put都会把对应的Entry移动到链表尾部,所以链表头部就是最久未被访问的元素。默认传false表示按插入顺序排序,那个不是LRU语义。
如果要求手写实现而不使用LinkedHashMap,那就需要自己维护一个双向链表加HashMap的组合。注意这里的实现细节:链表节点需要同时保存key和value,因为在链表超过容量需要淘汰时,我们要通过节点的key去HashMap中删除对应条目。如果节点只存value,淘汰时就要遍历HashMap才能找到key,复杂度变成O(n),不合格。这一处是考察“数据结构设计的闭环性”的关键点,也是阅卷时区分高分答案和普通答案的地方。
5.2 线程安全计数器的三种方法
第二道编程题要求实现一个线程安全的计数器,并提供increment()和getCount()两个方法。基础版本用synchronized关键字:
public class SafeCounter { private long count = 0; public synchronized void increment() { count++; } public synchronized long getCount() { return count; } }这个写法没问题,但只能算及格。更优的解法是使用AtomicLong:
import java.util.concurrent.atomic.AtomicLong; public class AtomicCounter { private final AtomicLong count = new AtomicLong(0); public void increment() { count.incrementAndGet(); } public long getCount() { return count.get(); } }AtomicLong通过CAS(比较并交换)实现线程安全,在低竞争场景下性能比synchronized好,但在高竞争场景下,CAS会频繁自旋,导致CPU占用高。JDK 8中引入了LongAdder,它通过分段累加的思路在高并发场景下进一步提升了性能。把三种方案的特点写清楚,同时结合并发量给出选型建议,才说明你真懂并发编程。此外要指出一点:如果increment被大量调用而getCount几乎不调用,LongAdder是首选;如果读写都很频繁,就需要做压测来确认选型,不能拍脑袋。
5.3 分布式场景设计:秒杀系统防超卖
编程题之外,卷子最后还有一道开放式设计题,要求设计一个商品秒杀系统,防止商品超卖。这类题目在笔试中出现通常不要求完整代码,但需要写出核心思路和关键命令。
我给出的方案包含三层防线:
- 应用层:用户请求先进入Redis使用
DECR或Lua脚本扣减库存,库存不足直接返回“已售罄” - 数据库层:
UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock > 0,利用行锁保证最终一致性 - 幂等层:同一用户同一商品只能下单一次,通过唯一订单号或用户ID加商品ID的唯一索引来约束
Redis扣减库存的Lua脚本核心如下:
if redis.call('GET', KEYS[1]) == false then return 0 end local stock = tonumber(redis.call('GET', KEYS[1])) if stock > 0 then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 end return 0为什么用Lua脚本?因为Redis保证Lua脚本内的多条命令在单线程中执行,不会被其他客户端命令插入,本质上是原子操作。如果不使用Lua脚本,而是先GET再DECR,在并发场景下两个请求可能同时GET到库存剩余1,然后各自DECR,最终库存变成-1,就是超卖。脚本把“判断库存”和“扣减库存”合并为一个原子操作,从架构上杜绝了这个问题。
数据库层的UPDATE ... WHERE stock > 0也很关键,它利用行锁的互斥特性来保证即使Redis被绕过或者Redis数据丢失,数据库最终也不会出现负库存。这个设计里还有一个细节:更新结果影响行数为0时,说明库存不足,需要回滚事务并返回失败,不能简单地以“执行SQL未报错”来判定成功。
6. 常见问题与备考策略
最后总结一下这套卷子暴露出来的几类典型问题和对应的备考策略,这部分比单纯对答案更有参考价值。
6.1 概念混淆型错误
贝壳这套卷子命题人非常喜欢在概念边界上做文章。典型例子包括:
- 把“字符串常量池在JDK 7后移入堆”与“运行时常量池在元空间”搞混
- 把
volatile能保证可见性和有序性误认为能保证原子性 - 把
ConcurrentHashMap的弱一致性误认为强一致性——它的size()方法和isEmpty()方法返回的都是近似值,多个线程并发写入时不能保证获取到准确的实时大小 - 把
ThreadLocal的内存泄漏原因记错——ThreadLocal中Entry的key是弱引用、value是强引用,当ThreadLocal对象被外部释放后,key会被回收但value仍然被堆中的Entry强引用,如果不调用remove(),value就永远无法被回收。这个考点在很多八股文里讲不透,但大厂就是喜欢考
针对这类问题,我建议建立一张“易混淆知识点对照表”,把成对出现的概念放在一起横向对比,而不是单独记忆。每次刷完题,把自己做错的概念写进表格里,考前重点复习就可以了。
6.2 手写代码不规范
LRU缓存和线程安全计数器这种题,代码本身不复杂,但很多人写得不够完善:没有考虑容量为0或负数时的异常处理,没有指定泛型,没有考虑到removeEldestEntry的参数类型应该是Map.Entry<K, V>而不是裸的Entry。阅卷系统一般不会执行代码,而是人工评审,代码的风格规范和边界处理直接决定评分档位。
我建议平时刷题时养成三个习惯:第一,所有自定义类都加上必要的构造校验;第二,涉及集合框架的代码显式声明泛型;第三,多线程代码必须明确指出哪些方法是线程安全的,哪些不是。这三个习惯在笔试中会让你的答案明显高于平均水平。
6.3 做题节奏分配不合理
50道选择题加2道SQL题加2道编程题,90分钟的考试时间非常紧张。计算下来,选择题平均每题只有1分钟多一点的时间,编程题至少要留30分钟。如果一道选择题卡了3分钟以上,应该立刻标记跳过,不要恋战。这一条说着容易做着难,但高分选手的共同特征就是基于确定性来分配时间,而不是被单题难度裹挟。
6.4 一些值得长期坚持的备考方法
关于备考资料的选取,Java八股文是起点,但远远不够。笔试题目越来越偏向“原理+场景”的组合考察,单纯背结论已经很难拿到高分。我的建议是每复习一个知识点,就用“是什么—为什么—什么时候用—什么时候不能用”四段式结构来整理笔记,然后配套找1-2道对应的真题进行验证。这样做的效果比盲目刷300道题要好得多。
此外,强烈建议把笔试中写过的代码保存下来,面试时直接作为项目中的技术选型参考。比如LRU缓存的实现,我在实际项目中就真的用在了用户会话管理上;线程安全计数器的LongAdder方案,我也用在了某个高并发埋点服务的统计逻辑中。笔试和工程实践之间的距离,没有大家想象中那么远。你对试卷上每道题的思考深度,最终会通过代码质量和系统稳定性体现出来。