在这个城市做Java开发满三年的时候,我换了第二份工作,前面两个月陆陆续续面了十几家,从十几个人的小团队到本地规模还行的自研公司都聊过。面完之后我把被问到的问题按“问了什么、我当时怎么答、后来觉得该怎么答”整理成了一个文档,本来只是给自己复盘用的,后来陆续有朋友拿去当复习资料。这份东西慢慢变成了现在这个样子——它不是一份标准题库,更像是我在这个区域的招聘市场里,用一次次面试换来的地形图。
1-3年这个经验段位挺尴尬的。往上看,大厂的面试套路要求你有扎实的算法功底和源码级理解;往下看,一年以内的岗位又在跟你聊基础语法和能不能干活。而本地大量岗位的面试官,通常就是团队里的技术负责人或者组长本人,他们没有精力搞七八轮流程,往往一面定生死,问的东西也很“实”——你写过什么、踩过什么坑、给你的接口你能不能扛住。所以这篇内容的核心不是帮你背答案,是帮你看清楚这个区间里,对方到底想通过一个问题验证你的什么能力。适合工作一到三年、准备换环境又不想盲目刷题的朋友,也适合刚带人的小组长参考怎么问出真东西。下面我按模块拆,每块都会给出我实际被问到的原题、当时的答法,以及后来我认为更稳妥的思路。
1. 一到三年这个区间,面试官真正在盘算什么
先说一段反直觉的经历。我一开始准备面试的思路是“把所有高频八股刷熟”,结果前几场反而栽得莫名其妙——有个面试官问我HashMap,我答得很流利,从数组链表讲到红黑树,他没接话,转头问“那你项目里有用到缓存吗,怎么设计的”,我卡了十几秒。后来我明白了,基础题对一到三年的人来说,更多是筛人的门槛,不是定薪的依据。答得出来是及格,答得漂亮也不会加分太多,真正拉开差距的是后面那些跟业务绑定的追问。
1.1 三年这道坎,考的是“能不能独立收尾”
我给自己的定位做过一次梳理:一年经验的人,别人把需求和接口定义好,你把逻辑写出来、能联调通,就算合格;两年经验的人,应该能自己设计一张表、自己定一个接口的入参出参、出问题能自己定位到是SQL慢还是下游超时;三年经验的人,要求就变成了能对一个模块的稳定性负责——你会不会主动加监控、会不会考虑幂等、会不会想到数据量涨十倍之后这条SQL会怎么样。
面试官的问题其实就是围绕这三层能力去设计的。听起来是问技术,本质是在探你的责任半径。所以你会看到,同样问线程池,问一年的人可能只是“线程池有哪几个参数”,问三年的人就会变成“你们线上线程池的核心数怎么定的,出过什么问题”。这个问题没有标准答案,但能立刻分辨出你是背过还是真的配过。
提示:如果你在简历里写“负责XX模块”,就要预设好“这个模块的QPS大概多少、数据量多大、出过什么故障、你怎么处理的”这一串追问。写不清楚的模块,宁可不写。
1.2 本地中小公司的面试画像:重落地、轻推导
我面过的这些公司里,只有极少数会现场让你手写算法,更多的是给你一段代码让你说输出、给一张表让你写SQL、给一个场景让你说设计方案。有一家做本地生活服务的,面试全程四十分钟,只有十分钟在问基础,剩下半小时都在聊“如果让你做一个优惠券发放的功能,你怎么防超发”。这种问法很典型——他们要的是明天就能上手改代码的人,不是要培养一个架构师。
这带来一个很实际的影响:你准备的重心应该往“实现细节”和“故障处理”偏。比如索引,与其背B+树有几层,不如把“我这条SQL为什么没走索引、explain里看到了什么、最后怎么改的”讲清楚。后者是活的经验,前者网上一搜一大把。
1.3 一份我自己统计的高频考点分布
两个月下来我记录了大概七十多道题(有重复),按模块归了下类,占比大致是这样:
| 模块 | 出现频次占比 | 典型问法 | 追问深度 |
|---|---|---|---|
| Java基础与集合 | 约22% | HashMap原理、equals与hashCode、String特性 | 中等,常追问源码细节 |
| 并发编程 | 约18% | 线程池参数、synchronized与Lock区别、volatile | 较深,爱问线上场景 |
| MySQL | 约20% | 索引失效、事务隔离级别、慢SQL优化 | 深,几乎必考explain |
| Redis与缓存 | 约14% | 缓存三兄弟、分布式锁、数据类型选型 | 中等,重方案 |
| Spring | 约13% | Bean生命周期、循环依赖、事务失效 | 中等,重实战 |
| JVM | 约8% | 内存结构、GC、OOM排查 | 偏浅,除非岗位特殊 |
| 项目与场景设计 | 约5%但权重最高 | 幂等、限流、分页、接口设计 | 最深,决定成败 |
这张表给我的启发是:MySQL和并发这两块必须啃透,它们是性价比最高的投入;JVM不用一上来就死磕GC算法,把内存结构和一次完整的排查链路讲明白就够了;而项目部分虽然题量占比不高,但一旦答崩,前面答得再好也救不回来。
2. Java基础与集合:那些“以为会了”其实答不全的题
基础题最容易出现的一种情况是:你答出了结论,但面试官再往下一层,你就只剩“我记得是这样的”。这类题在面试里的杀伤力其实不小,因为它直接暴露你的知识是背来的还是推出来的。我吃过几次亏之后,把几个高频点重新捋了一遍,重点捋的是“为什么”。
2.1 equals与hashCode:一个必须一起重写的约定
这道题几乎每场都问。标准答法是“equals比较内容,==比较地址”,但真正能区分水平的是后半句:重写equals必须重写hashCode。为什么?因为基于哈希的集合(HashMap、HashSet)定位元素分两步,先用hashCode算出桶下标,再在桶里用equals逐个比。如果你只重写了equals,两个内容相同的对象hashCode不同,它们会被放进不同的桶里,于是集合里出现了两个“相等”的元素。
我一般会现场写这么一段来说明问题:
public class User { private String name; private int age; // 只重写equals,没重写hashCode @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return age == user.age && Objects.equals(name, user.name); } } Set<User> set = new HashSet<>(); set.add(new User("张三", 20)); set.add(new User("张三", 20)); System.out.println(set.size()); // 输出 2,而不是期望的 1反过来,只重写hashCode不重写equals也不行,虽然能落到同一个桶,但桶内比较还是走Object的地址比较,依然判不相等。所以这两个方法是绑定关系:equals认定相等的两个对象,hashCode必须相等;hashCode相等的两个对象,equals不一定相等。这是约定,不是语法强制,破坏了它,哈希集合的行为就不可预期。
顺带一个常被追问的点:为什么JDK里hashCode实现爱用31这个数?我当时的回答是“31是质数”,面试官追问“还有呢”。完整的答法是三层:一是质数能让哈希结果分布更均匀,减少冲突;二是31可以被JVM优化成移位减法,31 * i == (i << 5) - i,比乘法快;三是31不大不小,乘出来的结果不容易溢出int范围。这种题答出第一层是及格,答到第三层才是加分。
2.2 HashMap的扩容逻辑与红黑树触发条件
这题我建议一定要把几个数字记准,因为面试官特别爱在这里挖坑:默认容量16、负载因子0.75、链表转红黑树的阈值8、红黑树退化成链表的阈值6、树化的最小数组容量64。
坑主要在最后一个。很多人只知道“链表长度到8就转红黑树”,实际上还有个前置条件——数组长度必须不小于64。如果数组还是16或者32,链表已经到8了,HashMap不会树化,而是先扩容。这个设计的理由是:数组太小的时候,冲突大概率是哈希分布不均导致的,扩容比树化更划算。
扩容的过程也值得讲清楚。JDK8之后扩容是把容量翻倍,然后每个元素重新分配位置。这里有个很巧妙的优化:因为容量是2的幂,翻倍后元素的新下标只有两种可能——要么还在原位,要么在原位加上旧容量。判断依据是hash & oldCap是否为0。这就避免了JDK7里重新计算hash的开销。
至于为什么容量必须是2的幂,答案在取模运算上。正常算桶下标是hash % length,但当length是2的幂时,hash & (length - 1)和取模结果完全一致,而位运算比除法快得多。这也是为什么new HashMap<>(17)实际容量是32——它会向上取到最近的2的幂。
注意:如果面试官问“HashMap线程安全吗”,别只答“不安全”,要说出具体表现。JDK7的头插法在并发扩容时可能形成环形链表导致CPU打满;JDK8改成尾插解决了环链,但仍然存在数据覆盖和size不准的问题。结论是并发场景用ConcurrentHashMap。
2.3 字符串常量池:一道看似简单的输出题
有一次面试,面试官直接在纸上写了四行代码让我说结果:
String a = "abc"; String b = new String("abc"); String c = "a" + "b" + "c"; String d = a + "bc"; System.out.println(a == b); // false System.out.println(a == c); // true System.out.println(a == d); // false前两个好解释,第三个是因为编译期优化——"a" + "b" + "c"在编译时就折叠成了"abc",直接进常量池,所以和a指向同一个对象。第四个是变量参与拼接,编译期无法确定,实际会走StringBuilder的append,最后toString出一个新的String对象放在堆里,所以不相等。
这里可以主动补一句:如果d声明成final String d = a + "bc",结果就变成true,因为final变量在编译期能确定值,同样会被折叠。这个小细节能体现你是真的理解编译期和运行期的区别。
2.4 面向对象的几道“送分题”为什么总丢分
接口和抽象类的区别、重载和重写的区别、final的作用,这些题看起来是送分,但答得干巴巴的一样会掉分。我的经验是,讲区别的时候一定要带上使用场景。
比如接口和抽象类,标准答案是“接口是行为的抽象,抽象类是模板的抽取;接口可以多实现,类只能单继承;接口的字段默认是public static final”。但如果你补一句“我一般用抽象类来抽取多个子类的公共字段和模板方法,用接口来定义对外契约,方便后面换实现或者做Mock”,面试官的眼神会不一样。
重载和重写也一样,除了参数列表、返回值、访问权限这些规则,可以提一句“重载是编译期确定调用哪个方法,重写是运行期动态绑定”,这就把静态分派和动态分派的概念带出来了,是一个很自然的加分位。
3. 并发与JVM:追问最狠、最容易露怯的两块
并发这块是我个人觉得最难的。不是因为概念多,而是因为它的很多问题无法靠背解决——你必须真的想过“两个线程同时改一个值会发生什么”。而JVM,很多一到三年的人会觉得离自己很远,其实面试官问这个更多是在看你的故障排查能力,不是让你去写GC调优方案。
3.1 synchronized和ReentrantLock,别只背区别
这道题的标准对比表谁都会背:一个JVM关键字一个API、一个自动释放一个手动释放、一个非公平一个可公平、一个不可中断一个可中断。但真正能加分的是讲清楚synchronized在JVM里做了什么。
简单说,synchronized加在方法上,字节码里是ACC_SYNCHRONIZED标志;加在代码块上,是monitorenter和monitorexit两条指令。锁的状态记录在对象头的Mark Word里,早期JVM为了减少无竞争场景的开销,设计了锁升级:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁的思路是“大多数情况下锁总是被同一个线程拿”,所以直接在对象头里记下线程ID,下次进来比对一下就行,连CAS都省了。只有在有另一个线程来竞争时才升级。
这个升级过程是一次性的、不可逆的,所以它不是某种算法优化,而是对“绝大多数锁竞争都很轻微”这个统计现实的妥协。理解这一点,你就能明白为什么高并发场景下偏向锁反而是负担——所以后来的JDK版本干脆默认把它关了。
至于什么时候用ReentrantLock,我的回答是三个场景:需要可中断(比如防止死锁时能主动退出)、需要超时尝试(tryLock(3, TimeUnit.SECONDS))、需要多个条件队列(生产者消费者用两个Condition分开唤醒)。其他时候用synchronized就够了,代码简洁还不会忘记释放。
3.2 线程池七个参数,重点在“为什么”
线程池的参数有七个:核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。这段能背下来的人很多,但面试官通常会顺着问三个问题。
第一个:任务提交后的执行顺序是什么?答案是先看核心线程是否已满,没满就新建核心线程执行;满了就进队列;队列满了再看是否达到最大线程数,没达到就新建非核心线程;最大线程数也满了才走拒绝策略。这个顺序有个很反直觉的后果——如果用的是无界队列,那么最大线程数和拒绝策略永远不会生效,因为队列永远不满。这就是为什么Executors.newFixedThreadPool有风险,它用的是LinkedBlockingQueue,默认容量是Integer.MAX_VALUE,任务堆积起来就是OOM。
第二个:线程数怎么定?我一般这么答:CPU密集型任务,线程数设为核数加一,多出来的一个用来在偶发缺页时顶上;IO密集型任务,因为线程大部分时间在等,可以设成核数的两倍,或者按核数 × (1 + 平均等待时间 / 平均计算时间)估算。但这些都是起点,实际必须压测调整。我还会补一句“我们线上是把核心线程数和最大线程数设成一样,配合有界队列和CallerRunsPolicy,这样压力能反压到调用方,不会无声无息地堆任务”——这句是实打实的经验,比任何公式都管用。
第三个:拒绝策略有哪几种?四种:直接抛异常的AbortPolicy(默认)、用调用者线程执行的CallerRunsPolicy、丢弃最老任务的DiscardOldestPolicy、静默丢弃的DiscardPolicy。关键在于说清楚什么场景选哪个:核心链路用CallerRuns保证不丢但要接受变慢,非核心的日志上报之类的用DiscardPolicy丢了也无所谓。
3.3 volatile的两个语义和一个坑
volatile保证可见性和有序性,不保证原子性。这句话能背,但要能解释清楚为什么。
可见性来自内存语义:写volatile变量时,会把当前线程工作内存的值刷回主内存,并且在写之前插入StoreStore屏障;读volatile变量时,会从主内存重新加载,在读之后插入LoadLoad屏障。简单理解就是绕过了线程本地缓存,强制跟主内存同步。
有序性来自禁止指令重排。这一点在双重检查锁的单例里最经典:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }为什么要加volatile?因为instance = new Singleton()这行不是原子操作,它分三步:分配内存、初始化对象、把引用指向内存。编译器或CPU可能把第二步和第三步重排,导致另一个线程在第一个if判断时看到instance非null,但拿到的是一个还没初始化完成的对象。加上volatile就在写操作前后建立了屏障,禁止这种重排。
至于“不保证原子性”,最典型的例子是i++——它包含读、加、写三步,多线程下用volatile修饰i照样会丢更新。解决办法是加锁或者用AtomicInteger。
3.4 JVM这道题,落到“你排查过什么问题”上
如果面试官问JVM内存结构,按堆、方法区(JDK8之后是元空间)、虚拟机栈、本地方法栈、程序计数器分区讲就行,再补一句“堆是GC的主战场,栈是线程私有的,随方法调用进出”。这部分不太会翻车。
真正的分水岭是问到OOM排查。我建议准备一个完整的、能讲五分钟的排查故事,哪怕它是你从别人那里学来再自己验证过的。我自己的讲法是这样的:
先确认现象,是应用整体变慢还是某个接口报错,看日志里的异常类型。然后上工具:jps -l找到进程号,jstat -gcutil <pid> 1000看各区的使用率和GC频率,如果老年代每次Full GC后都降不下来,基本就是内存泄漏而不是单纯的容量不够。接着jmap -dump:format=b,file=heap.hprof <pid>导出堆快照,用MAT或者VisualVM打开,看Dominator Tree找出占内存最大的对象,再顺着引用链回溯到业务代码。线上不方便dump的话,用arthas的heapdump或者dashboard也能快速看个大概。
常见的OOM类型也要能对上号:Java heap space是堆里对象太多,通常是大查询一次性捞太多数据或者集合没清理;Metaspace是动态生成类太多,常见于反复热部署或者大量使用反射代理;unable to create new native thread是线程数超过系统限制,通常是线程池没复用或者每个请求都new一个线程。
提示:讲排查故事的时候,一定要带上“最后是怎么解决的”。比如“查到是一个把全表数据查出来再在内存里过滤的接口,改成走索引分页查询之后内存曲线就平了”。有结论的故事才有说服力。
4. MySQL和Redis:业务开发绕不开的两座山
这两块在区域市场里的考察比重大得出乎我意料。原因也很简单——大部分公司的业务量级还处在“单库单表加个Redis”的阶段,数据库和缓存就是系统稳定性的全部,面试官自然盯得紧。
4.1 索引失效的六种现场,要能对上例子
索引失效这件事,光背条目没用,得能立刻说出对应的SQL长什么样。我整理了六种最常见的:
| 失效场景 | 示例 | 底层原因 |
|---|---|---|
| 隐式类型转换 | where phone = 13800000000(phone是varchar) | 相当于对列做了函数转换,无法用索引 |
| 列上做运算或函数 | where date(create_time) = '2024-01-01' | 索引存的是原值,函数后无法比对 |
| 前导模糊匹配 | where name like '%张' | B+树按前缀有序,前缀未知无法定位 |
| 违反最左前缀 | 联合索引(a,b,c),查where b = 1 | 索引按a排序,跳过a无法定位 |
| or连接非索引列 | where a = 1 or d = 2(d无索引) | 需要全表扫描才能保证结果完整 |
| 范围查询后的列失效 | 联合索引(a,b),where a > 1 and b = 2 | a是范围,b在索引中已无序 |
这里面最容易被忽略的是第一条。很多人觉得“我字段是varchar,传数字进去MySQL会自动转,不影响”,实际上MySQL的转换规则是把字符串转成数字比较,等于在索引列上套了一层CAST,索引直接失效。这种问题在真实项目里非常多,尤其是前后端联调时参数类型没对齐。
4.2 事务隔离级别与MVCC,讲清楚“RR是怎么做到可重复读的”
四个隔离级别和对应的脏读、不可重复读、幻读问题,这是基础。MySQL的默认级别是RR(可重复读),而多数数据库默认是RC,这个差异经常被问。
真正要讲明白的是MVCC。我的理解是:每行数据除了业务字段,还有两个隐藏列——DB_TRX_ID记录最后修改它的事务ID,DB_ROLL_PTR指向undo log里的上一个版本。这样一行数据的历史版本就串成了一条版本链。
读的时候,事务会生成一个ReadView,里面记录了当前活跃的事务ID列表。沿着版本链往前找,找到第一个对当前事务“可见”的版本——判断规则是:这个版本的trx_id如果小于ReadView里的最小活跃ID,说明它在我开始之前就提交了,可见;如果大于最大ID,说明是后来才开始的,不可见;如果在中间,就看它是否在活跃列表里,在就不可见。
RC和RR的区别就在ReadView的生成时机:RC是每次SELECT都生成一个新的,所以能看到别人新提交的数据;RR是第一次SELECT时生成,之后一直复用,所以整个事务里看到的都是同一个快照。这就解释了“可重复读”是怎么实现的。
至于幻读,MVCC解决了快照读的幻读,但当前读(比如select ... for update)还是会幻读,靠的是间隙锁(Gap Lock)来堵住。这也是为什么RR级别下并发插入容易死锁——间隙锁之间是兼容的,但插入意向锁会和间隙锁冲突。
4.3 慢SQL优化,要有自己的固定套路
被问到慢SQL,我一般按这个顺序讲,显得有条理:
第一步,先定位。开慢查询日志,或者用SHOW FULL PROCESSLIST看当前卡住的语句。第二步,explain看执行计划,重点看四列:type(访问类型,从好到坏是const、eq_ref、ref、range、index、ALL,看到ALL基本就是全表扫)、key(实际用了哪个索引,为null就是没用上)、rows(预估扫描行数)、Extra(出现Using filesort或Using temporary就要注意)。
第三步才是优化。优先加或调整索引,注意联合索引的顺序要把区分度高、等值查询的列放前面。然后是改写SQL,比如避免select *、把子查询改成join、大偏移量的分页换成游标或者延迟关联:
-- 深翻页的典型写法,越往后越慢 select * from orders order by id limit 1000000, 20; -- 延迟关联,先在索引上拿到主键再回表 select o.* from orders o inner join (select id from orders order by id limit 1000000, 20) t on o.id = t.id;第四步是结构性方案,比如历史数据归档、读写分离、分库分表。这一步要谨慎提,因为很多面试官会顺势问“那你说说分库分表之后跨库分页怎么做”,容易把自己带坑里。如果没有真实做过,就老实说“我们量级还没到那一步,目前是靠归档和索引优化撑住的”,反而更可信。
4.4 缓存三大问题的答法差异,别答成一个东西
缓存穿透、击穿、雪崩这三个词经常被混着说,但它们是完全不同的问题,解决方案也不一样:
- 穿透:查一个数据库里根本不存在的数据,缓存永远不命中,请求全打到库上。方案是缓存空值(记得设短过期时间)或者上布隆过滤器提前拦掉。
- 击穿:某个热点key刚好过期,瞬间大量请求同时涌向数据库。方案是加互斥锁,只放一个请求去查库回填,其余等待;或者用逻辑过期,物理上不过期,靠业务字段判断是否刷新。
- 雪崩:大量key在同一时刻集中过期,或者缓存服务本身挂了。方案是过期时间加随机偏移、做多级缓存、对下游做熔断降级。
讲的时候把“是什么、为什么发生、怎么防”三句话讲全,比罗列一堆名词有效得多。
5. Spring框架:从“会用”到“能讲清楚它怎么转的”
Spring的题有个特点:如果你只做过CRUD,很多问题你从来没想过。但只要你能把Bean的创建过程串起来讲一遍,大部分追问都能接住。
5.1 Bean生命周期,一条主线串下来
我的记法是一条线:实例化 → 属性填充 → 初始化 → 使用 → 销毁。
实例化就是调构造方法创建对象;属性填充是依赖注入,把@Autowired的字段塞进去;初始化阶段比较热闹,依次会执行各种Aware接口的回调(比如BeanNameAware、ApplicationContextAware),然后是BeanPostProcessor的前置处理,接着是@PostConstruct注解的方法、InitializingBean的afterPropertiesSet、以及配置的init-method,最后是BeanPostProcessor的后置处理。这里最关键的是后置处理,因为AOP代理就是在这个环节生成的——AbstractAutoProxyCreator会在postProcessAfterInitialization里判断这个Bean需不需要被代理,需要的话返回一个包装后的代理对象。
销毁阶段对应@PreDestroy和DisposableBean的destroy方法。理解这条主线之后,“AOP是在哪一步生效的”“@PostConstruct和afterPropertiesSet谁先执行”这类问题就都能推出来了。
5.2 循环依赖,为什么需要三级缓存而不是两级
这是Spring最经典的深水区问题。A依赖B,B又依赖A,怎么解决?
三级缓存分别是:singletonObjects(一级,成品)、earlySingletonObjects(二级,半成品)、singletonFactories(三级,ObjectFactory)。流程是:创建A时先把自己放进三级缓存(放的是能生成A的工厂),然后填充属性发现要B,去创建B;B填充属性时发现要A,从三级缓存拿到工厂,调用工厂生成A的早期引用,放进二级缓存,同时把A从三级移除;B拿到A的引用完成创建,进入一级缓存;回到A,A再从一级缓存拿到B,完成创建。
为什么不能只用两级?关键在那个工厂上。如果A需要被AOP代理,那么B依赖的应该是代理后的A,而不是原始A。三级缓存里放的是工厂,只有在真正发生循环依赖时才会调用工厂去生成代理对象——如果没发生循环依赖,这个工厂永远不会被调用,也就不会提前创建代理,保证普通Bean的生命周期不受影响。用两级缓存的话,就意味着所有Bean一创建完就得立刻决定要不要生成代理,这会破坏正常的代理时机。
这个解释如果面试官认可,基本就说明你确实读过源码或者认真研究过。
5.3 事务失效的几种常见现场
@Transactional失效是真实项目里的高频坑。我遇到的加上听说的,主要有这几种:
方法不是public。Spring的AOP对private、protected方法不生效,注解写了也白写。同一个类内部自调用。比如A方法调B方法,两个都在同一个类里,B上有注解,这时B的调用不经过代理对象,事务不生效。解决办法是注入自己、或者把B拆到另一个Bean里。异常被自己catch了。事务管理器感知不到异常自然不会回滚。抛的是检查异常。默认只对RuntimeException和Error回滚,抛IOException之类的不回滚,得显式写rollbackFor = Exception.class。类没有被Spring管理,比如自己new出来的对象,代理都没生成。存储引擎不支持事务,比如MyISAM。
6. 项目与场景题:决定了这场面试的上限
前面那些题答得再好,项目讲不清一样过不了。我发现区域市场的面试官特别爱出场景题,因为他们要判断你在真实业务里能不能扛事。
6.1 一个不出彩的项目,怎么讲出价值
大多数人手里的项目都不“高大上”,就是一套后台管理系统、一个订单模块。这时候重点不是项目本身多牛,而是你在这个项目里解决了什么具体问题。
我用的结构是四句话:背景是什么、难点在哪里、我做了什么、结果怎么样。比如“我们原来的订单查询接口在数据涨到两百万之后响应到了三秒多,我通过explain定位到是create_time上的范围查询导致索引失效,改成联合索引加游标分页之后降到两百毫秒以内”。这种表述比“我负责订单模块开发”有力一百倍,因为它包含了一个完整的问题闭环。
注意:千万不要编造自己没做过的方案。面试官只要顺着问两层实现细节,编的东西立刻露馅,而且一旦被发现,整场面试的可信度就归零了。
6.2 幂等、限流、分布式锁,三个万能场景题的答法
接口幂等:先问清楚是前端重复提交还是网络重试。方案有几种——数据库唯一索引拦重复插入(最简单可靠)、Token机制(提交前先取一个一次性令牌)、状态机判断(订单只能从待支付变已支付,重复请求第二次状态不匹配就拦下)、分布式锁。实际项目里最常见的是唯一索引加状态机组合。
限流:单机用Guava的RateLimiter或者Semaphore,分布式用Redis的令牌桶或者滑动窗口。回答时要说清楚“限流阈值是怎么定的”——一般是压测出来的,不是拍脑袋。
分布式锁:核心是三个问题——加锁、过期、释放。加锁用SET key value NX PX 30000,一条命令保证原子性;value必须是唯一值(比如UUID),释放的时候要用Lua脚本先比对value再删,否则可能删掉别人的锁;过期时间要评估业务耗时,或者引入看门狗做续期。这些细节能说出来,说明你不是只停留在“用setnx加锁”这个层面。
6.3 反问环节,别浪费这五分钟
面试最后一般会让你提问。很多人这时候说“没什么问题”,其实挺可惜的。我会问三类:技术栈和团队规模(判断进去之后会不会是孤军奋战)、这个岗位目前最急需解决的问题是什么(能听出团队的真实痛点,也能顺势再展示一次自己)、代码评审和上线流程(判断工程规范程度)。
这些问题的答案往往比面试过程本身更能帮你判断要不要去。我面过一家,技术聊得挺好,问到上线流程时对方说“我们一般改完直接传到测试服务器上试”,我心里就有数了。
7. 我在这两个月里踩过的几个坑
第一个坑是过度准备八股,忽略项目复盘。前几场我把HashMap、ThreadLocal背得滚瓜烂熟,结果人家问“你说说ThreadLocal在项目里怎么用的、为什么会内存泄漏”,我脑子里只有“弱引用”两个字,讲不出场景。后来我把每个知识点都强行绑定一个自己的使用场景,效果立竿见影。
第二个坑是在简历里堆技术名词。我一开始写了“熟悉Redis、消息队列、分布式”,结果被追问“你们MQ用的是什么、消息积压怎么处理”,答不上来非常尴尬。后来我把简历改成了“用Redis做过接口结果的缓存,缓存key的过期时间加了随机偏移避免集中失效”,问什么答什么,反而顺畅。
第三个坑是遇到不会的题硬编。有一次被问到分布式事务,我含糊其辞地说了个“两阶段提交”,面试官继续问“那你们代码里是怎么实现的”,我就卡住了。后来我学乖了,不会就直接说“这块我只了解概念,实际项目没落地过,我的理解是……”,反而对方会换个方向问,面试节奏更舒服。
第四个坑是薪资和岗位匹配没提前对齐。有两家聊得不错,最后谈的时候发现对方能给的范围比预期低不少,前面两小时白聊。现在我一般会在第一轮结束前问一句岗位的预算区间,省得浪费彼此时间。
回过头看,一到三年这个阶段的面试,考的从来不是谁知道的名词多,而是谁能在具体场景里把问题解决掉。基础要扎实,但扎实的标准不是背得多,是讲得出为什么;项目要能讲,但能讲的标准不是项目多牛,是你自己在里面起了什么作用。我整理的那份文档最后留下来的也就二十来页,但每一页背后都对应着一次真实被问住的经历——这可能比任何一份标准题库都更管用。