1. 先说个反直觉的事:挂在“背过的题”上的人,比挂在新题上的多得多
这些年我帮人改简历、做模拟面试,看过太多准备了厚厚一沓网上流传的“Java面试题整理”的候选人。说实话,能把题背得滚瓜烂熟的人不少,但真正能通过面试的,往往不是背得最熟的那一个。
一个很典型的场景:面试官问“HashMap底层原理”,候选人答完红黑树、扩容、负载因子,一切完美。紧接着追问一句“那HashMap在并发场景下除了丢数据,还可能有什么问题?”——卡住了。或者是“你刚说到了CAS,那CAS的ABA问题在什么场景下真会出事故?你们项目里遇到过吗?”——沉默了。
面试题整理这个东西,真正的价值不是让你背答案,而是帮你搭建一个知识网络。同样是看八股文,有人把它当题库刷,有人把它当线索去追溯原理,最后在面试中的表现天差地别。这篇文章我想聊的,不是再给你贴一份XX道题的清单,而是聊聊我是怎么重新梳理Java面试知识体系的,以及哪些题是高频中的高频,哪些角落是大多数人容易忽略、但面试官偏爱的考点。
不管你是准备校招、社招还是转岗,这篇文章适合那种“题都见过,但说不深”的人——我帮你梳理的不是题本身,是题目背后的学习路径。
2. JVM这道大题:别只背运行时数据区,面试官考的是“为什么这么设计”
2.1 内存区域:背出五块区域只是及格,能解释“谁该进堆、谁该进栈”才是分水岭
几乎每份Java面试题整理里都有“JVM运行时数据区有哪些”这道题。但说实话,能完整说出方法区、堆、虚拟机栈、本地方法栈、程序计数器的人,至少占面试者的六成。真正拉开差距的,是下面这类追问。
比如面试官会问:“为什么程序计数器是唯一不会OOM的区域?”这个问题不是在考记忆,而是在考你是否理解程序计数器的本质——它只是记录当前线程执行的字节码行号,这个“行号”能有多大?撑死了就是一个整数的大小,不存在动态扩展的需求,当然不会OOM。反观堆和方法区,它们是所有线程共享的、对象和类元数据的存放地,理论上可以无限增长,所以才有GC和OOM的可能。
再比如,“Java的方法参数是传值还是传引用?”这种基础题,放到JVM语境下就成了:“一个对象作为参数传入方法,方法里改了属性,外面能看到吗?”答案当然能,因为传递的是引用(本质是对象地址的拷贝),但这个“地址”存在虚拟机栈的局部变量表里,对象本身存在堆里。理解了这个,你才算真正把基础语法和JVM内存模型串起来了。
我复习JVM的时候,习惯画一个“谁创建谁回收”的对应表:
- 程序计数器:线程创建时分配,线程销毁时回收,无GC。
- 虚拟机栈:每个方法调用创建一个栈帧,方法结束即销毁。
- 堆:几乎所有对象实例都在这里分配,由GC统一管理。
- 方法区/元空间:类元数据、常量、静态变量,JDK8之后用元空间替代了永久代,直接使用本地内存。
这里有一个很多人会忽略的细节:JDK8为什么要把永久代换成元空间?因为永久代的大小是固定的,靠JVM参数调优,容易OOM。而元空间直接使用本地内存,默认情况下只受物理内存限制,对“类加载特别多”的应用(比如动态生成大量代理类的框架)更友好。这个设计思路,面试官也很爱听。
2.2 垃圾回收:G1和ZGC的参数背得再熟,不如想明白“什么时候该用哪种”
JVM考得最狠的不是运行时数据区,是垃圾回收。尤其是G1和ZGC的对比题,几乎每个中大厂的二面都会碰到。
不少人能背出“G1把堆划分为Region,可预测停顿;ZGC基于染色指针和读屏障,停顿不超过10ms”——但如果你只是背到这里,面试官大概率会追问一句:“那你们项目的线上服务,堆多大?用的哪个回收器?Region默认多大?有没有调过停顿时间?”
这一问,大部分人就暴露了。我的建议是,复习GC的时候,别贪多,先把三个核心问题想通:
第一,G1到底解决了什么问题。CMS的痛点在于并发标记阶段会和业务线程抢CPU,且会产生浮动垃圾,最要命的是它基于“标记-清除”,会产生内存碎片。G1最大的贡献是引入了“可预测停顿模型”,通过-XX:MaxGCPauseMillis参数指定目标停顿时间,然后根据每个Region的回收价值和回收成本,来决定优先回收哪些Region。
第二,Region大小的计算逻辑。G1的Region大小在堆初始化时就已经确定了,最小1MB,最大32MB,实际是取整到2的N次幂。如果堆大小是8GB,默认Region大小是4MB。这个值决定了Region的数量,进而影响G1的回收粒度。这些细节,网上很多整理里是没有的,但恰恰是面试官区分“背题”和“真懂”的试金石。
第三,ZGC的适用条件。ZGC的低停顿确实惊艳,但它有两个代价:一是CPU占用率明显上升(因为每个指针都要处理染色指针相关的操作),二是内存占用更大。所以如果你们的服务是CPU密集型的、内存又比较紧张,ZGC未必是最优解。反过来,如果是大堆(几十GB甚至上百GB)、对停顿极度敏感(比如券商交易系统),那ZGC几乎是必选。
我在实际项目里用到的组合是:8GB~16GB堆,用了G1,暂停目标设了200ms;更大的堆场景,才开始考虑ZGC。面试时把这一层权衡说出来,比单纯背参数强得多。
2.3 类加载机制:双亲委派不是考点,考点是“什么时候会破坏双亲委派”
“双亲委派模型”也是Java面试题整理里的常客。但说实话,能画出示意图的人太多了,真正能讲清楚“为什么要有双亲委派”和“什么场景会破坏它”的人很少。
双亲委派的核心价值是防止核心类库被篡改。比如你自己写了一个java.lang.String,如果按双亲委派加载,这个类会先交给Bootstrap ClassLoader尝试加载——它发现rt.jar里已经有java.lang.String了,就直接返回系统的String,你自己写的那个根本不会被加载。这就保证了核心API的安全和统一。
但很多事情都有例外。JDBC就是个典型的破坏双亲委派的场景。为什么?因为JDBC的DriverManager是rt.jar里的类,由Bootstrap ClassLoader加载。但MySQL的驱动jar包是放在应用classpath里的,由AppClassLoader加载。麻烦在于:DriverManager加载时,根本找不到MySQL驱动——因为父加载器看不到子加载器的类。所以JDBC引入了线程上下文类加载器(Thread Context ClassLoader),用“线程的类加载器”去加载驱动包,相当于子加载器反向请求父加载器“帮我加载一个我这边能看到的类”,这就是对双亲委派的破坏。
类似的场景还有Tomcat。一个Tomcat里跑多个Web应用,每个应用可能依赖不同版本的Spring或第三方库,如果都用双亲委派,很容易冲突。Tomcat就为每个Web应用创建独立的WebAppClassLoader,优先加载自己WEB-INF/classes下的类,加载不到才交给父加载器——这也是“更爱自己,再找长辈”的典型。
我把这两个例子讲透以后,面试官基本不会再追问类加载机制的其他问题了,因为这个深度已经远超背题水平。
3. 并发包这一块:从synchronized到AQS,中间隔着一整个“锁的进化史”
3.1 synchronized的优化路线,就是Java并发面试的半壁江山
去面试Java岗位,十次有九次会遇到“synchronized和ReentrantLock的区别”——但如果你只是答“synchronized是JVM层面的,ReentrantLock是API层面的,后者可以中断、可以超时、可以公平锁”,那其实只到了及格线。
真正有分量的回答,要从synchronized在JDK层面的演变讲起。早期synchronized是重量级锁,直接依赖操作系统的互斥量,线程阻塞和唤醒都要切换内核态,性能差。所以JDK6之后做了大量优化,引入了锁升级机制:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
这里面有三个关键点值得深挖:
一是偏向锁。它是JDK6引入的,针对“一个线程反复进入同一同步代码块”的场景做的优化。Mark Word里记录持有锁的线程ID,下次这个线程再来的时候,直接无条件进入,不需要做CAS。但当出现竞争时,偏向锁就要撤销,一旦撤销成本也不低,所以JDK15之后开始废弃偏向锁,因为现代应用大多是多线程高竞争场景,偏向锁反而拖累性能。
二是轻量级锁。它的本质是“用CAS替代互斥”,当第二个线程来竞争时,先在当前线程的栈帧里创建锁记录(Lock Record),然后尝试用CAS把Mark Word替换成指向锁记录的指针。如果成功,就拿到了锁;如果失败,说明真的竞争激烈,就膨胀成重量级锁。
三是锁消除和锁粗化。这两个是JIT编译器的优化,不需要你写代码,但理解了它们,你在写代码时会更自觉地避免不必要的加锁。比如JIT发现一个局部对象根本不会被其他线程访问,就会把它的synchronized块直接去掉——这就是锁消除。反过来,如果一个循环里反复加同一把锁,JIT会把锁的范围扩大,一次性锁住整个循环——这是锁粗化。
讲完这些,你甚至可以再补一句:“所以现在日常业务代码里,我很少手动用ReentrantLock,synchronized经过了这么多轮优化,性能上完全不虚,只有需要可中断、可超时、或者公平性控制的场景才会用Lock。”——这句话一出来,面试官心里给你打的分数就已经不一样了。
3.2 AQS是并发包的底座,锁、信号量、CountDownLatch全建立在它之上
如果面试官问完synchronized,发现你答得不错,下一题大概率会跟进:“那你了解AQS吗?”这不是为了为难你,而是为了看你对整个JUC包的理解是否成体系。
AQS的全称是AbstractQueuedSynchronizer,它是JUC包最核心的基类。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock,底层全部依赖它。AQS的核心设计是三块:一个volatile的state变量、一个CLH变体队列、以及模板方法模式。
state变量代表同步状态,比如ReentrantLock里state=0表示未加锁,加锁一次state+1,同一个线程重入就继续+1,解锁时-1,减到0就释放锁。这个“重入次数记录”正是synchronized和ReentrantLock的一个核心区别——synchronized是隐式重入,ReentrantLock是显式重入,两者都支持重入,但实现机制不同。
CLH队列是用来存放等待线程的。那些没抢到锁的线程,会被包装成Node节点,通过CAS方式加入队列尾部,然后通过LockSupport.park()挂起。这比早期synchronized直接阻塞线程要高效得多,因为park/unpark是JVM层面的操作,不需要切换到内核态。
模板方法模式体现在哪里呢?AQS把“获取锁”和“释放锁”的框架搭好了,但具体的判断逻辑留给子类实现。tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这四个方法,就是让子类去填的钩子方法。所以ReentrantLock和Semaphore的公平/非公平逻辑各不相同,但队列的管理、线程的park/unpark都是AQS统一完成的。
我复习AQS的时候,做过一个最简单的自定义锁demo——实现一个互斥锁,只重写tryAcquire和tryRelease,然后用两个线程去争抢。当你亲手写一遍,才能真正理解AQS为什么能成为Java并发包的基石。
3.3 线程池:七个参数背得出来不难,难的是“拒绝策略怎么选”背后的运营思维
Executors、ThreadPoolExecutor,这几乎是必考题。七个参数——核心线程数、最大线程数、空闲存活时间、时间单位、阻塞队列、线程工厂、拒绝策略——背出来不难。难的是几个追问。
第一个追问:“核心线程数和最大线程数为什么要分开?”答案是:核心线程数是为了保证常驻处理能力,最大线程数是为了应对突发流量。在突发流量到来时,先让队列堆积,队列满了才创建额外线程到最大线程数,如果还是处理不过来,才触发拒绝策略。这个设计本质上是让大家在“保吞吐”和“控风险”之间做权衡。
第二个追问:“阻塞队列选有界还是无界?”这是一个特别容易踩坑的点。Executors.newFixedThreadPool用的就是无界队列LinkedBlockingQueue,看着省事,但一旦任务积压,队列可以无限增长,最坏情况直接把内存打爆。所以我在实际项目中,从来只用ThreadPoolExecutor自定义,队列要有界,拒绝策略要明确。
第三个追问:“拒绝策略你选哪个?”默认的AbortPolicy会抛出RejectedExecutionException,如果线上没有兜底,这就是一个潜在的故障点。我见过的做法是:核心业务选CallerRunsPolicy,让提交任务的线程自己把这个任务执行掉,相当于一个天然的节流阀;日志、统计类任务选DiscardPolicy或DiscardOldestPolicy,丢就丢了,不影响主流程。
有一次线上事故让我印象很深:某个核心服务高峰期线程池满,触发AbortPolicy直接抛异常,调用方没有捕获,导致整批请求失败。后来我们把拒绝策略改成CallerRunsPolicy,虽然高峰期接口响应会变慢,但至少请求不会失败。这个案例我后来经常在面试里讲——因为它能说明白,你对线程池的理解到底是停留在背参数,还是真的做过线上调优。
4. 框架题别裸背:Spring和MyBatis的高频题,要往原理上靠
4.1 Spring Bean的生命周期,从“记住流程”到“讲出三级缓存设计初衷”
Spring部分最高频的题无非这么几个:IOC和AOP是什么、Bean的生命周期、循环依赖怎么解决、事务失效的场景。这些题的坑在于答好了很加分,答不好很容易露怯。
先说Bean的生命周期。网上流程图一大堆,从BeanDefinition到实例化、属性填充、初始化、销毁,一级级列出来。但如果你只是背流程,面试官很容易追问:“Bean的Aware接口是在哪一步调用的?BeanPostProcessor的postProcessBeforeInitialization和postProcessAfterInitialization分别在哪个节点触发?”这一追,背题的人就慌了,因为流程是死记硬背的,没有理解“生命周期各阶段分别解决了什么问题”。
我的复习思路是:把生命周期拆成几个问题来理解。实例化之后为什么要属性填充?因为依赖注入要在这里完成。填充之后为什么要走Aware?因为Bean需要感知到容器和自身名称等信息。为什么要有BeanPostProcessor?因为SpringAOP的代理对象创建,就是在这里通过AbstractAutoProxyCreator的postProcessAfterInitialization完成的。理解到这一层,Spring的Bean生命周期就不再是死知识,而是“为了解决什么需求而设计出来的阶段性回调”。
再说循环依赖。Spring解决setter循环依赖是靠三级缓存:一级缓存存放成品Bean,二级缓存存放早期暴露的裸Bean,三级缓存存放ObjectFactory。很多人会问:为什么三级缓存里不直接放实例,而是放ObjectFactory?因为如果这个Bean需要被AOP代理,代理对象的创建时机是在早期暴露之后才确定的,直接放实例会导致代理失效。三级缓存里的ObjectFactory能在getEarlyBeanReference时按需返回代理对象,从而保证循环依赖场景下也能拿到代理后的Bean。
这个解释是循环依赖题目的核心,能答出来的人,说明真的看过三级缓存的源码设计。
4.2 MySQL索引、事务隔离级别、MVCC:这三块是Java后端面试的基本盘
如果说JVM和并发是Java面试的“特色菜”,那MySQL和缓存基本上是“必吃菜”。我在整理Java面试题的时候,发现最容易丢分的其实是数据库部分——因为很多纯Java背景的开发者,对数据库的理解停在会写SQL层面,一被问到底层原理就露馅。
索引方面,常考的是B+树为什么适合作为索引结构。相对B树,B+树有两个明显的优势:一是非叶子节点只存索引不存数据,所以同样的页可以存放更多键值,树更矮,IO次数更少;二是叶子节点用链表串起来,范围查询特别高效,直接顺着链表扫就行。这些点不难,但真正能答好的人,需要理解“磁盘IO预读”和“页存储”这两个底层机制。
事务隔离级别和MVCC几乎是每一场面试必问的。尤其是“MySQL默认隔离级别为什么是RR(可重复读)”和“MVCC是怎么实现快照读的”这两道题。MVCC的核心是隐藏字段+undo log版本链+ReadView。每条记录除了业务字段,还有trx_id(最近修改它的事务id)和roll_pointer(指向undo log里上一个版本)。快照读的时候,通过ReadView判断当前事务能看见哪个版本的数据。
这里有一个理解难点:为什么RR级别下,快照读不会出现幻读,但当前读会?因为RR级别下,ReadView是事务第一次查询时创建的,后续查询一直用同一个ReadView,所以看到的数据是固定的。但如果用SELECT ... FOR UPDATE这种当前读,它会走最新版本的记录,所以还是可能看到新插入的记录,在没有间隙锁的情况下就会幻读。所以InnoDB在RR级别下,通过间隙锁+临键锁来锁住范围,防止幻读。理解了这一层,MySQL的隔离级别题目基本就拿下了。
我再补充一个重要考点:索引失效的场景。这个在面试中出现频率极高。典型的包括:对索引列使用了函数或计算,比如where id+1=5;隐式类型转换导致索引失效;like前置通配符导致无法使用索引;OR连接的条件里有一个非索引列。这些场景背下来不难,但关键是理解背后的原理——B+树索引是基于有序排列的,一旦列值经过函数或计算,原有的有序性就被破坏了,索引自然就用不上了。这个原理想清楚,面试时即使遇到没背过的场景,也能推理出来。
4.3 MyBatis和Spring事务:两个高频坑位,值得单独拿出来说
MyBatis的题不算多,但有几道值得准备。一是“MyBatis里#{}和${}的区别”,这题几乎必考。#{}是预编译,会生成PreparedStatement,用占位符?替代参数,可以防止SQL注入;${}是直接拼接字符串,存在注入风险,只在表名、列名等动态结构场景下才用,而且必须对传入值做严格校验。
二是“MyBatis的一级缓存和二级缓存”。一级缓存是SqlSession级别的,默认开启;二级缓存是Mapper级别的,需要手动开启。这里有个坑:如果Session关闭后一级缓存失效,但二级缓存里存的是对象,如果项目里用了多线程并发访问同一个Mapper,缓存数据存在并发安全风险。所以很多MyBatis项目实际上是禁止二级缓存的,尤其是多表关联查询场景,缓存失效很难控制。
三是“MyBatis的插件原理”。动态代理+拦截器链,通过InvocationHandler代理Executor、StatementHandler等核心对象,可以在SQL执行前后做手脚。分布式分库分表中间件、多租户数据隔离,很多都是基于MyBatis插件实现的。这个题能展开讲,说明你读过MyBatis的插件机制源码,在面试中很加分。
Spring事务是另外一个重灾区。事务失效的经典场景有七八种,最常考的有:方法自调用导致@Transactional失效(因为走的是this调用,没经过代理对象);方法不是public权限;异常被catch吞掉了;数据库引擎不支持事务(比如MyISAM)。面试时能被问到“异常被catch住为什么事务会失效”,是因为事务拦截器只在抛出RuntimeException、Error或者标记了rollbackFor的异常时才回滚。如果异常被try/catch捕获了,Spring根本感知不到,自然不会回滚。
这个问题的根本解法是:异常要么向上抛,要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。我在项目里就见过很多因为catch了异常导致数据没回滚的事故,所以每次在面试里讲这个,都有特别的感染力。
5. 分布式题是分水岭:Redis分布式锁、数据一致性,面试官要的是“工程取舍”
5.1 Redis分布式锁:别再答“setnx加过期时间”了,那只是第一层
说到分布式锁,现在Java面试题整理里满天飞的都是“用SETNX加过期时间、用Redisson的看门狗续期”。但我知道很多面试官已经对这个答案免疫了,他们会接着问:“那Redisson的看门狗原理是什么?续期失败怎么办?主从切换会丢锁吗?”
先拆看门狗。Redisson的锁默认leaseTime是30秒,拿到锁之后会启动一个定时任务,每隔三分之一的时间(10秒)去把锁的过期时间重置回30秒,只要持有锁的线程还在运行,锁就不会自动过期。这就是看门狗机制。但看门狗能续期,前提是持有锁的进程还活着,如果进程挂了,定时任务自然停了,锁最终会过期释放——这个设计就是为了防死锁。
再看主从切换丢锁。如果你的锁是在Redis主节点上加的,主节点还没来得及把写同步到从节点,主节点就挂了。这时候从节点被提升为主节点,新的主节点根本没有这把锁的数据,另一个线程就可以趁机加同一把锁了。这在双11大促、订单状态流转这类强一致的场景下,是绝对不能接受的。
所以Redisson提供了RedLock算法:向多个独立的Redis节点依次加锁,超过半数成功才算加锁成功。RedLock在理论上是解决了单点问-题,但真正实现时,需要每个节点都是完全独立的,不能有主从复制,这在成本和可用性之间要做权衡。这也是为什么业界对RedLock一直有争论——Martin Kleppmann和Redisson的作者都写过文章互怼。
我在实际项目里的做法是:对一致性要求没那么极端的业务,用Redisson普通锁+看门狗就够了;对资金类、状态机流转这类强一致业务,要么引入RedLock,要么直接换ZooKeeper。ZooKeeper的临时顺序节点天然支持公平锁和Watch机制,主节点挂了子节点会自动删除,可靠性比Redis强,代价是性能差一些、运维复杂一些。
5.2 数据一致性:本地消息表、事务消息、Seata,面试官想听你怎么选型
“面试里怎么保证数据一致性”这个词组的搜索量一直很高,说明这也是个让大家头疼的问题。分布式事务的知识点其实不多,但每个点都值得你有一个清晰的选型逻辑。
分布式事务的核心矛盾,是多个服务各自持有独立的数据库,没办法在跨进程、跨数据库的情况下做到传统意义上的ACID强一致。于是有了各种折中方案。
本地消息表是最原始但最容易理解的方案:本地事务里写业务数据和消息表,然后把消息发到MQ,下游消费。关键在于“本地事务”的原子性——业务数据和消息必须同时成功或同时失败。这样就算MQ挂了,消息还在本地表里,可以定时任务重发。
事务消息是RocketMQ提供的进阶方案,把本地消息表搬到了MQ内部。发送事务消息时,先半消息投递到MQ,然后本地执行事务,根据执行结果提交或回滚半消息。如果MQ长时间没等到提交或回滚,会主动回查事务状态。这样应用层就不需要自己维护消息表了,逻辑上更清爽。
Seata的AT模式和TCC模式又是另一类思路。AT模式通过全局事务管理器协调,用undo_log实现自动补偿,侵入性最小,但性能开销和脏读风险需要评估。TCC模式需要业务方自己实现Try、Confirm、Cancel三个方法,侵入性强,但控制力强、性能也好。
面试时候我最常听到的答案是“我们用了Seata的AT模式”——但你得能说清楚,为什么用AT而不是TCC或Saga?有没有为Seata配置全局锁?全局事务超时设置多少?回滚失败怎么处理?这些追问才是真正的考核点。我的体会是,分布式事务没有银弹,每个方案都有自己的适用边界,把边界说清楚,比把方案背下来更容易打动面试官。
5.3 缓存穿透、击穿、雪崩:一个比一个细,别把三者答混
每次谈到Redis面试题,缓存穿透、缓存击穿、缓存雪崩这“三兄弟”几乎必考。很多整理资料把三者的区别列得很清楚,但面试现场我见过不少候选人把穿透和击穿讲混了。这里我用自己的方式再捋一遍。
- 缓存穿透:请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。解决思路:对查不到的数据也做一个空值缓存(过期时间短一些);或者用布隆过滤器,在缓存前面先过滤掉一定不存在的key。
- 缓存击穿:某一个热点key在缓存失效的瞬间,有大量并发请求同时打到数据库。解决思路:互斥锁(Redis setnx加锁,只让一个请求去查DB回填缓存,其余阻塞等待);或者把这个热点key的过期时间加长,甚至设置“逻辑过期”。
- 缓存雪崩:大量key在同一时间段集中失效,导致请求全部落到数据库。解决思路:缓存过期时间增加随机值,打散过期时间;部署多级缓存;或者对数据库做限流降级。
三者对比着看就清楚了:穿透是“查不到”导致每次都要去DB,击穿是“单个热点key失效瞬间”导致大量并发涌向DB,雪崩是“大批key同时失效”导致DB瞬间压力暴涨。
这里我还想提一个经常被忽略的点:缓存预热。很多系统上线后第一次遭遇高并发,就是因为缓存里是空的,所有请求直接打到数据库。正确的做法是上线前或版本发布时把热点数据主动加载到Redis中,比如用一个定时任务提前把首页Banner、商品库存等数据刷进缓存,做到“数据在事故之前已经在缓存里了”。
6. 场景题和手写题的底层逻辑:面试官出题不是为了考你记性,是为了看你思维
6.1 手写单例模式、生产者消费者、LRU:这三道题背后考察的是什么
很多Java面试题整理里都有“手写单例”“生产者消费者”“LRU缓存”这三道题,你看起来很基础,但实际能写好的人不多。面试官还真不是想考你会不会写代码,而是想看三件事:第一,你写代码有没有工程意识;第二,遇到并发需求能不能想清楚线程安全性;第三,边界条件处理得到不到位。
单例模式就是个典型的例子。最简单的是饿汉式,但如果你直接写饿汉式,面试官大概率会问“万一这个类初始化开销很大,你不希望应用启动的时候加载怎么办?”于是你写懒汉式。写懒汉式加synchronized,又被问性能问题。于是你写双重检查锁,还被问“这个instance为什么必须加volatile?”——因为new一个对象在指令层面不是原子的,可能发生指令重排,别的线程拿到一个半初始化对象。写到这里,你的答案才算完整。
生产者消费者考察的是线程协作。用synchronized+wait/notify、用ReentrantLock+Condition、还是用BlockingQueue,三种实现方式难度不同,背后体现的是你对Java并发工具的理解深度。最简单的思路是直接用ArrayBlockingQueue,但面试官一般会让你不用BlockingQueue实现一次,这就考到了wait/notify和Condition的用法。
LRU缓存考察的是数据结构设计能力。很多人直接答LinkedHashMap,但你要能解释LinkedHashMap的accessOrder=true时为什么就能实现最近最少使用淘汰。更进阶的版本是手写HashMap+双向链表的结构,并说清楚为什么读和写都是O(1)复杂度——HashMap保证O(1)找到节点,双向链表保证O(1)删除和移动节点。
这三道题,说实话不是三个月突击能练出来的,需要平时编码就带着这种思考。但如果你系统地过一遍,把它们背后的考点(指令重排、线程协作、数据结构与算法)锚住,面试时的底气会完全不同。
6.2 场景设计题:秒杀系统、订单状态机、超卖问题,怎么拆解才显水平
场景设计题是Java面试中区分度最大的题型。它没有标准答案,面试官看的是你面对一个模糊问题时,怎么拆解、怎么判断、怎么表达。
我举一个几乎必考的场景:“设计一个秒杀系统,你怎么防超卖?”
- 第一步,先界定问题域。秒杀的瓶颈不在数据库,在流量入口。所以先聊削峰:前端限流、验证码、答题、CDN静态化、消息队列削峰,都是手段。
- 第二步,聊库存防超卖。最简单的方案是数据库乐观锁:update stock set stock=stock-1 where id=? and stock>0。但这在高并发下性能堪忧,所以会引出Redis预扣库存方案,即先把库存加载到Redis,通过Lua脚本原子扣减。
- 第三步,聊一致性。Redis扣减成功不等于订单创建成功,需要异步订单创建和最终对账。还要考虑多副本部署时Redis库存的同步问题。
- 第四步,聊兜底。万一Redis宕机了怎么办?MQ积压了怎么办?需要对Redis做高可用,对MQ做consumer扩容和告警监控。
整个回答应该从“流量控制”到“库存控制”再到“最终一致性”逐层展开,每一步都要说出“为什么这样做”和“做了以后有什么潜在问题”。我见过不少候选人一上来就说用MQ、用Redis,但没有结构,听着就乱。按这个四步框架来说,既全面又有深度,容易给面试官留下好印象。
另一个高频题是订单状态机。状态机的设计要回答三个问题:状态怎么流转(CREATE → PAID → SHIPPED → COMPLETED,以及各分支状态)、谁触发状态变更(用户、定时任务、支付回调)、并发情况下状态变更怎么保证安全(乐观锁version字段或者Redis分布式锁)。这个问题本质上考的是你对业务状态建模的能力,很多公司都会在架构师或高级工程师的面试中问到。
6.3 面试前两周的复习策略:用“题-点-网”三层法把碎片知识串起来
聊了这么多具体的知识点,最后我还是想说说复习策略本身。很多人拿到一份面试题整理,就开始从头背到尾,背到JVM部分发现忘了前面的集合,背到Spring又觉得并发忘了。原因很简单:把知识点当孤岛记,而没有构建网络。
我用的方法是“题-点-网”三层复习法:
- 题(第一周):先把核心题目过一遍,不求深入,但求知道考什么。相当于先画一张地图。
- 点(第二周):针对自己薄弱的题目,逐个深入。比如HashMap底层没搞懂,就把源码打开,一行行看它的put、resize、get方法;synchronized的锁升级没理解透,就写一个多线程demo,用jstack看线程状态。
- 网(第三周/冲刺周):把所有考点串起来,形成知识树。比如“HashMap”可以串出散列表、哈希冲突、扰动函数、扩容机制、红黑树,然后从红黑树串到“平衡树与时间复杂度”,从并发环境下的HashMap串到ConcurrentHashMap和CAS。
最后一轮复习最重要的其实是模拟面试。找朋友或者同事,当面答一遍高频题,重点不是答对,而是练习“听到问题后快速组织语言的能力”。我见过太多人知识点都懂,但一开口就乱了,因为他们从来没有在“被问”的状态下练过输出。这是很多背题党最常见的死因——肚子里有货,说不出来。
另外一个实用小技巧:把自己在面试中被问到过、但当场没答上来的题目,单独记录到一个文档里,每个周末复盘一次。这些题目是你的知识漏洞地图,比任何网上的面试题资料都值钱。我当年就是这样,把每一场面试里答得不好的题记录下来,下次面试前只复习这份文档,效率极高,针对性极强。
最后分享一个我一直坚持的观点:面试题整理的终点不是背完,而是想明白。当你开始问自己“为什么这样设计”“换个场景还成立吗”“如果让我来实现我会怎么做”这些问题时,你才真正开始吃透Java这门语言和它背后的整个技术体系。祝准备面试的各位都能拿到心仪的offer。