☰
大厂Java面试:从并发编程到分布式事务的实战解析
2026/10/11 6:15:26 网站建设 项目流程

很多准备跳槽的朋友一听到“互联网大厂Java面试”就自动把它等同于背八股文:HashMap、ConcurrentHashMap、JVM调优、Redis缓存、Spring事务……这些确实是基础,但现在的面试风向早就变了。与其说是在考“你记住了什么”,不如说在考“你会不会用”。尤其是带着支付、交易、金融属性的部门,面试官几乎每一轮都在追问:怎么保证数据一致性?怎么防止重复支付?怎么设计一个高并发扣减库存的接口?这些看似是场景题,其实是在验证你把Java核心知识落到真实业务闭环里的能力。

这篇文章我想结合自己这几年既当面试官、也被别人面试的经历,聊一套更实在的备考思路。我会把Java求职面试里必须掌握的高频技术点,和支付金融场景里最常出现的考点串起来讲,同时给出能直接写进简历、能当场讲给面试官听的项目案例。无论你是刚开始准备跳槽的初中级工程师,还是想往交易链路方向深入的同学,这套整理方式应该都值得参考。

1. 大厂Java面试的底层逻辑:八股文是入场券,场景题才是分水岭

1.1 光会背“HashMap原理”远远不够

我面试过很多候选人,HashMap原理倒背如流:数组加链表、红黑树、扩容因子0.75、线程不安全。但当我追问一句“你项目里哪个地方用了HashMap,为什么不用ConcurrentHashMap”时,很多人就愣住了。

面试官真正想确认的,是你有没有在真实业务里做过技术选型。比如支付系统里渠道配置每天早上热更新,多线程并发读,这时候你用HashMap就会出问题,因为扩容期间读线程可能读到空桶;用ConcurrentHashMap就合适,因为JDK8之后是Node数组加CAS和synchronized,读操作几乎不加锁。如果你再能补一句“配置量不大,但变更频繁的场合,我更倾向用CopyOnWriteArrayList做事件监听器列表,读多写少很合适”,就说明你有过选型对比的过程。

举一个经典的自测题:HashMap的默认负载因子为什么是0.75?如果只回答“官方给的”,面试官会觉得你是看过的,但说不出所以然;如果你能推导:负载因子太高会导致链表过长、树化提前,查询变慢;太低会导致频繁扩容、浪费内存,0.75是在时间与空间开销上做的一个折中。这就有“推导感”了。再深一层,为什么容量固定为2次幂?因为要能用hash & (cap - 1)替代取模,位运算更快,所以HashMap在设置初始容量时会执行tableSizeFor,把容量调整到最近的2次幂。这就是面试加分的地方。

1.2 项目深挖的本质:把每个技术点串成“场景-方案-为什么”

大厂面试第二轮开始基本都是项目深挖。面试官会拿简历上任何一个技术点往深了问。举一个最常见的连环炮:

简历写着“使用Redis缓存订单信息”,面试官可能会这样问:

  • 缓存击穿、缓存穿透、缓存雪崩怎么区分?
  • 缓存更新你用的是Cache Aside,为什么不用Read Through?
  • 你是先更新数据库再删缓存,还是先删缓存再更新数据库?
  • 如果先更新数据库再删缓存,删缓存失败了怎么办?
  • 有没有考虑过订阅MySQL binlog异步删除缓存?

简历写着“用了MQ做异步通知”,面试官会追问:

  • 为什么用RocketMQ而不是Kafka?
  • 消息会丢吗?怎么保证不丢?
  • 消费端重复消费了怎么办?
  • 消费失败重试几次?重试还失败怎么办?
  • 有没有死信队列?死信里的消息怎么处理?

这些问题的背后,是想看你在真实环境里有没有考虑过极端情况,而不是停留在“能跑就行”。所以准备大厂Java面试的第一步,不是刷更多题,而是把简历上的每个技术点整理成一条完整的“场景-方案-为什么”链条。下面这张表是我自己备考时用的框架,你也可以照做。

高频考点原理/机制支付金融场景映射
HashMap/ConcurrentHashMap哈希桶、CAS、扩容渠道配置缓存、订单维度缓存
synchronized/AQS锁升级、等待队列扣库存接口、交易防并发
JMM与volatile可见性、禁止指令重排DCL单例、状态标志位
JVM GC分代回收、STW批量交易是否产生大量对象
Spring事务传播传播行为、嵌套事务本地消息表事务边界

1.3 把“八股文”变成“推演题”

拿JVM类加载来举例子。大多数人都能背出双亲委派模型,但面试官一追问就露馅:

问:为什么Tomcat要打破双亲委派?答:因为不同webapp需要各自的类加载器来隔离类库,如果都用父加载器加载,多个应用之间会互相污染。

问:SPI为什么也打破了双亲委派?答:Driver接口是Java标准库用根/平台加载器加载的,但具体实现类往往在应用Classpath里,父加载器看不到子加载器的类,所以ServiceLoader要用线程上下文类加载器来加载实现类。

能把这“两个打破”讲清楚,说明不是死背书,而是理解加载机制背后的分工。类似的,synchronized的锁升级过程也是一样,无锁、偏向锁、轻量级锁、重量级锁,你为什么需要这些状态?因为锁竞争程度不一样,用CAS自旋、用内存屏障的代价也不一样,把对象头Mark Word的状态流转画出来比背定义更有说服力。

2. 支付金融场景最核心的考点:分布式事务与最终一致性

2.1 为什么支付系统不能只靠一个数据库事务

先想一个最简单的下单流程:用户下单、扣减库存、生成支付单、给账户加积分。如果不是单体应用全在一个库里,就会涉及多个服务协同。订单服务里的MySQL事务能保证订单表状态一致,但没办法让库存服务和账户服务一起回滚。

所以支付、交易这类场景首先要做一致性语义的拆分。强一致方案比如2PC和XA,在数据库层面能保证跨库原子提交,但2PC的prepare阶段有同步阻塞,协调者宕机还要选主,互联网高并发场景下很少直接使用。更多时候我们选“最终一致”,也就是允许某一小段时间不同服务之间不一致,但通过异步消息、定时对账、补偿任务,做到最终收敛。

面试中,如果你能先说出“强一致和最终一致是两种不同的代价体系”,再给出选型判断标准,就已经超过一大半候选人了。判断标准主要有三条:资金操作是否直接涉及可变资产;用户是否能接受秒级延迟;系统是否有对账兜底机制。像账户扣款这种核心资金变动,一般用TCC加对账;像积分发放、短信通知这种非关键路径,本地消息表加MQ就足够了。

方案一致性类型适用场景缺点
本地消息表最终一致内部服务状态同步、异步通知消息表和业务表耦合,需要幂等消费
RocketMQ事务消息最终一致高吞吐异步化,跨服务事件通知依赖中间件可靠性和反查配置
TCC业务最终一致,资源锁定余额、账户、积分等核心资产代码复杂,空回滚、悬挂难处理
2PC/XA强一致单库或小规模跨库场景同步阻塞,性能差,互联网少用

2.2 本地消息表和RocketMQ事务消息的执行细节

本地消息表的思路,是把“业务操作”和“消息记录”放在同一个本地事务里。例如用户支付成功后需要给账户服务增加流水,创建支付结果表和“待发送消息表”时,一定要放在同一个@Transactional里:

@Transactional public void handlePaid(OrderPaidEvent event) { // 业务表更新 orderMapper.updateStatus(event.getOrderNo(), "PAID"); // 消息表写入 outboxMapper.insert(new OutboxMessage(event.getOrderNo(), "ACCOUNT_ADD_FLOW", payload)); }

事务提交后,后台任务扫描状态为“待发送”的消息,投递到MQ,收到确认后把消息状态改成“已发送”。这个方案最关键的认知是:投递和标记成功之间永远有窗口期,所以不要追求“只发一次”,而是接受“至少一次投递”,让消费者端做幂等。宁可让下游多处理几次,也不能让消息莫名其妙地丢失。

RocketMQ事务消息的思路类似,但把消息表下沉到MQ内部。先发送半消息,订单服务执行本地事务,根据事务结果向Broker提交commit或rollback。如果本地事务已经提交成功但Broker没有收到commit,RocketMQ会反向调用你实现的checkLocalTransaction方法,让你去查数据库确认本地事务状态。这里有个常被忽略的坑:反查回调里不能直接查内存,应用重启后内存状态就没了,一定要查订单表是否存在,才能正确返回COMMIT或ROLLBACK。

2.3 TCC的Try、Confirm、Cancel与三个经典问题

TCC特别适合资金类的可靠操作。拿余额支付举例:

Try:冻结订单金额,新增冻结流水,流水状态为“冻结” Confirm:把冻结流水转成扣账流水,余额真正扣减,流水状态变为“成功” Cancel:解冻,流水状态变为“失败”,余额不变

三个步骤都要支持幂等,任何一个步骤都可能被重复调用。真正落地过TCC的人,一定知道这三个问题:

  • 空回滚:Try没执行成功,但Cancel到了。Cancel里不能直接抛“查不到冻结流水”的错误,要记录一个“空回滚”标记,然后当作成功处理。
  • 幂等:Confirm和Cancel可能被调度多次,需要靠事务ID作为唯一键防重。
  • 悬挂:Cancel先于Try到达。如果Cancel发现自己对应Try还没执行,要把这个事务标记成“已取消”,后面Try执行时发现状态已取消,直接忽略,不能把该冻结的钱再冻结一遍。

很多候选人能说出Try、Confirm、Cancel三个词,但能把这三个坑说透的人很少。面试官问TCC,本质上是在探测你有没有写过真正的补偿型代码,而不是只会背概念。

2.4 幂等设计:防重表、唯一键、状态机缺一不可

支付回调是重复请求最典型的场景。上游因为网络超时可能重发多次,而且顺序还可能颠倒。比如支付成功的通知先到达,支付中的通知后到达,你不能用后到的“支付中”把订单状态回退。

我在实际项目里用的是“唯一索引+状态机”双保险。

第一层,在支付流水表上建唯一索引:

CREATE UNIQUE INDEX uk_order_channel ON t_payment_flow(order_no, channel);

处理逻辑是尝试插入支付流水,如果插入冲突,说明这个回调之前已经处理过,直接返回“已处理”。第二层,更新订单状态时带上当前状态条件:

UPDATE t_order SET status = 'PAID', paid_time = #{now} WHERE order_no = #{orderNo} AND status = 'WAIT_PAY';

如果影响行数为0,说明状态已经被其他请求改掉了,当前请求属于重复或乱序。这两层叠加之后,哪怕Redis缓存全挂了,数据库依然能兜底。这也是我在面试时最想听到的回答——把防重设计讲成一套分层防线,而不是只丢出一个“我用Redis做了幂等”。

3. 高并发下的Java并发编程:从锁升级到库存防超卖

3.1 synchronized和ReentrantLock:面试官想听“为什么”

Java并发是大厂必考模块。最常问的就是synchronized锁升级:无锁、偏向锁、轻量级锁、重量级锁。

面试官会问为什么需要这么多种锁状态。答案是为了降低锁的成本。用一个对象做锁,一开始没有竞争时,偏向锁只需要在对象头Mark Word里记录线程ID;出现竞争时升级成轻量级锁,使用CAS自旋,线程停留在用户态;自旋超过一定次数,升级成重量级锁,进入操作系统内核的mutex阻塞队列。这个演进过程,本质上是在“CPU空转开销”和“线程上下文切换开销”之间做权衡。

ReentrantLock比synchronized灵活在哪里?支持公平锁、可中断、可超时、多Condition条件队列。我一般会反问自己一个问题:如果用synchronized实现一个阻塞队列,条件变量怎么处理?synchronized只有一个等待池,没法分别唤醒生产者、消费者。ReentrantLock则可以创建两个Condition,比如notFull和notEmpty,生产者等待notFull,消费者等待notEmpty,这样唤醒更精准。这就是AQS的设计意图:一个同步队列(CLH)加多个条件队列,把线程等待和唤醒的细节抽象出来。

3.2 volatile、CAS与ABA问题

volatile在面试里经常和单例DCL放到一起考。DCL(双重检查锁)里单例对象为什么必须用volatile修饰?因为创建对象不是一个原子操作,可以拆成三行伪指令:

memory = allocate(); // 1. 分配内存 instance = memory; // 2. 设置引用指向内存 ctorInstance(memory); // 3. 执行构造方法(按实际指令重排可能发生在第2步前)

如果这个引用没有volatile禁止指令重排,另一个线程可能在第二步完成后看到instance非空,直接拿去使用,但对象还没有构造完成,使用就会出问题。volatile保证了可见性和有序性,但不保证原子性。所以多线程自增变量还要用AtomicInteger或者LongAdder。

CAS本身有个经典问题叫ABA:线程1把库存从10扣到8,再因为取消订单回补到10,线程2读到的还是10,它以为没人改过,继续扣减到9,结果实际库存可能已经被其他线程改成别的值了。解决思路就是加版本号,AtomicStampedReference内部维护对象引用和版本戳,类比数据库乐观锁的version字段。在防超卖场景下,我们最后使用的就是带条件的SQL更新,本质上也是CAS思想。

3.3 扣减库存接口的演进:从SQL到Redis Lua

“秒杀下库存不会被扣超”是Java面试里的高频场景题。我的答题框架是按方案演进来讲,既体现广度也体现深度。

最基础的方案是同步扣减:

UPDATE t_stock SET stock = stock - 1 WHERE id = #{id} AND stock >= 1;

这条SQL本身能防超卖,因为update行锁和stock >= 1条件可以保证扣减不会变成负数。问题在于秒杀下所有请求都打同一行,数据库行锁竞争会导致连接池被打满,后面请求全部排队,延迟飙升。所以高并发场景一般会把库存预热到Redis,用Lua脚本做原子扣减:

if redis.call('exists', KEYS[1]) == 1 then local stock = tonumber(redis.call('get', KEYS[1])) if stock > 0 then redis.call('decrby', KEYS[1], ARGV[1]) return 1 end return 0 end return -1

Redis是单线程执行Lua脚本,整个扣减过程不会被打断,不需要额外加分布式锁,性能比先GET再DECR好得多。扣减成功后再发MQ异步落库,数据库从“每单实时扣”变成了“批量异步扣”,扛量能力完全不同。

但这里有个坑,Redis不是绝对可靠的存储,主从切换可能会丢数据,所以Redis预扣只能作为流量削峰层,最终库存数据还是要靠异步落库和对账任务校准。面试时把这层认知讲出来,面试官会觉得你不仅知道Redis快,还知道Redis不可靠,这就是“实战感”。

4. JVM与性能调优:线上OOM和CPU飙升的正确排查链路

4.1 JVM内存划分和GC Roots不能只背定义

JVM这块,面试官经常让候选人讲讲内存区域,然后追问“哪些区域会OOM”,再追到GC Roots。我的建议是不要把每个区域背一遍就完事,而是把“对象从出生到被回收”的完整过程讲出来。

对象一般优先分配在年轻代Eden区,当Eden空间不足时触发Minor GC;年龄达到阈值进入老年代;大对象干脆直接进入老年代。老年代空间不足时触发Full GC,FullGC往往会伴随STW,停顿时间不可忽略。GC Roots是什么呢?是可达性分析的起点,包括栈帧中的局部变量、静态变量、常量池引用、JNI引用。从这些根出发遍历对象图,能到达的对象就是存活的,不能到达的就可以回收。

现在很多候选人已经知道“对象不一定在堆上分配”了,但如果你能进一步讲出逃逸分析和栈上分配,比如“如果对象没有逃逸出方法,JVM可能把它分配到线程栈上,方法结束自动销毁,减少GC压力”,这个深度就很够用了。

4.2 Full GC频繁和OOM的排查步骤

面试里经常给一个虚拟场景:支付系统突然Full GC频繁,线上怎么排查?我给你一条我实际用的链路:

  1. 用top找到CPU高的Java进程,记下PID。
  2. 用jstat连续看GC情况:
jstat -gcutil <PID> 1000 10

关注YGC频率、FGC次数、老年代使用率。如果老年代一直上涨,说明有大对象或内存泄漏趋势。 3. 用jmap导堆转储:

jmap -dump:format=b,file=heap.hprof <PID>

生产环境要小心,堆很大时会卡顿,最好先降流量或选低峰期执行。 4. 用MAT打开hprof,看Dominator Tree,找到最大的对象,再查它被谁引用。常见问题包括:批量查询把大量订单一次性加载进内存、日志框架异步缓冲池没有及时刷盘、ThreadLocal里存了大对象但没remove。 5. 如果jstat看着正常但CPU很高,就要用jstack抓线程栈。先通过top找到占用CPU高的线程ID,转成十六进制,再到jstack里搜nid:

printf '%x\n' <线程ID>

如果发现大量线程在同一个自旋循环里,或者大量线程阻塞在GC上,问题就逐渐清楚了。

4.3 一个支付网关CPU飙升的复盘

我们之前做过一个支付网关,某个下午CPU突然打满。用jstack一看,大量线程阻塞在一次第三方HTTP调用上。表面看是外部接口变慢,但真正的问题是我们的连接池maxTotal配得太小,超时时间又设置得太长,导致线程全部挤在获取连接的地方。更隐蔽的是,连接池内置的重试逻辑在服务端不返回时还会继续循环,加剧CPU消耗。

那次修复改动了几处:将第三方调用改造成异步回调,并给不同渠道设置线程池隔离;增加熔断降级,比如连续失败一定次数快速返回失败;同时把连接池的maxWait减少,让线程快速失败而不是无限等待。这个案例说明,JVM性能调优不只是看堆和GC,线程池参数、连接池参数、外部依赖的超时熔断机制,都可能成为系统的隐形炸弹。面试时能把这样的案例讲清楚,往往比罗列工具命令更打动人。

5. 场景设计题实战:用“支付回调防重服务”串起所有知识点

5.1 拿到场景题,先列边界,不要急着写代码

面试官常给一个开放题:支付渠道回调偶尔延迟、会重复、甚至乱序,你要怎么设计这个回调接口,保证业务只被正确执行一次?

很多候选人第一反应是“加个Redis分布式锁”,这是误区。分布式锁只能处理并发请求场景,锁过期后依然可能重复处理,更没法处理已经处理完成之后又来的迟到请求。正确的打开方式是先回答“我要保证什么”,一句话说清:同一个订单的支付成功事件只能被业务执行一次,但接口可以重复返回成功。

然后是分层设计:

  • 入口层:先做签名校验和来源校验,验签失败直接拒绝,防止伪造回调。
  • 数据层:用支付流水表唯一索引去重,同一个订单号加渠道的组合不能重复插入。
  • 业务层:用订单状态机更新,UPDATE ... WHERE status = 'WAIT_PAY',影响行数为0就说明已经处理过。
  • 队列层:如果业务是异步化的,消息消费者要按订单号Hash到同一个队列分区,保证同一订单消息有序;消费逻辑里还必须幂等,因为消息重试机制本身会带来重复投递。

5.2 订单状态机怎么落

我习惯把状态机画成文字流程来讲。订单有这几个核心状态:待支付、支付中、支付成功、支付失败、关单。

待支付 -> 支付中 -> 支付成功 -> 支付失败 待支付 -> 关单

回调到达时,先从Redis查状态做快速判断;查不到或状态为待支付,再走到数据库做更新。数据库更新成功之后,同步写支付流水表;如果重复回调到达,发现状态已经变成支付成功,直接返回一个固定的成功报文,让上游停止重试。如果回调里带的金额和订单金额不一致,状态要置为支付失败,同时触发告警并进入人工核查列表,不能自动重试覆盖原状态。

5.3 怎么讲出“踩过坑”的差异化

同样的场景题,普通候选人会把分层方案说一遍,看起来挑不出毛病,但也不出彩。高分候选人会补两句自己踩过的坑,比如:

“我之前用Redis做防重键,结果Redis key设置了过期时间,极端情况下第一次处理还没完成,key到了过期时间,第二次请求就进来了。后来我改成数据库唯一索引加状态机做最终判据,Redis只做前置检查,不再依赖它做幂等。”

“还有一次,我在Spring事务里直接发送MQ消息,结果事务回滚了,消息却已经发出去,下游消费逻辑又没法感知回滚。后来封装了一个事务同步器,把发消息的代码放到注册的afterCommit里执行,或者用Spring的TransactionSynchronizationManager,才避免这个坑。”

这两句话看起来轻描淡写,但能让面试官明显感觉到你是真的设计过回调处理流程,而不是背了一套标准答案。

5.4 把追问提前准备好

场景题之后,面试官大概率还会继续追问:

  • Redis挂了怎么办?还能防重吗?——最终判据必须回到数据库唯一索引,因为Redis不是持久化存储。
  • 消息丢了怎么补?——本地消息表或者RocketMQ事务消息,再配合定时任务扫描对账数据。
  • 数据库缓存不一致能接受吗?——读多写少场景下,用短暂过期和版本号更新,极端不一致由对账任务修复。
  • 这个接口的QPS上限怎么估算?——如果压测时单行UPDATE能撑住每秒几千操作,就直接数据库扛;如果超过,引入Redis预扣减和队列削峰。

把这些追问和答案一起准备到,面试时就算被连环炮追问,心态也会稳很多。

其实面试到最后,比的就是谁愿意再多想一步。技术点大家都能背,但“在支付这种钱不能出错的地方,你考虑过多少边界条件”才是真正让人印象深刻的地方。我也见过一些背景不算亮眼的候选人,靠一套扎实的支付回调防重方案拿到了offer。所以别急着刷更多的题,先把这几个核心场景吃透,可能比什么都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询