最近在牛客上刷到一篇「腾讯天美一面」的面经,是 27 届秋招提前批期间(2026 年 7 月)的真实记录,Java 后端方向。评论区还有人问「楼主 AI 编程用的什么 skill」,能看出发帖时间很新。
这篇面经乍一看是一堆零散的八股:HashMap、ConcurrentHashMap、锁的类型、一个并发场景题、Spring 事务、动态代理。但把问题顺序摆在一起看会发现,面试官其实是顺着一条线在往下钻。从数据结构本身的线程不安全,问到怎么把它变安全,再问到安全的代价该怎么控制,最后落到业务代码里事务和锁到底该怎么写。背答案的人会在任何一个追问点卡住,理解这条线的人可以一路跟着往下讲。
把这几个问题拆开揉碎讲一遍,顺便把常见的过时说法纠正一下。
HashMap 和 ConcurrentHashMap,别再说分段锁了
这题很多人张口就是「ConcurrentHashMap 用分段锁保证线程安全」,这其实是 JDK 7 的老实现,JDK 8 之后已经彻底变了,还这么答会被追问一句就露馅。
JDK 7 的 ConcurrentHashMap 由多个 Segment 组成,每个 Segment 继承自 ReentrantLock,本质是一个小号的 HashMap,加锁粒度是整个 Segment(默认 16 个)。并发度上限就是 Segment 的数量,扩容也是分段扩,逻辑不算复杂但粒度粗。
JDK 8 把 Segment 整个砍掉了,改成跟 HashMap 一样的「数组 + 链表 + 红黑树」结构,线程安全靠 CAS + synchronized 联合实现:
- 往空桶插入节点:用 CAS 直接抢,不用加锁;
- 往非空桶插入(说明发生了哈希冲突):
synchronized锁住这个桶的首节点,别的线程只要不动这个桶就完全不受影响; - 链表长度达到 8 且数组容量已经 ≥ 64,才会把链表转成红黑树(
TREEIFY_THRESHOLD = 8,MIN_TREEIFY_CAPACITY = 64),红黑树节点数缩到 6 以下会退化回链表,避免频繁转换抖动; - 扩容时新增了一个
ForwardingNode,某个桶迁移完就在原位置放一个 ForwardingNode 当路标,这样多个线程可以并发协助扩容,而不是只能单线程一个个般; - 连
size()都不是靠一把全局锁数出来的,而是用baseCount+CounterCell[]分散计数再汇总,思路跟LongAdder一样,高并发下热点数据不要用一把锁去抢,分散开再汇总就行。
这套设计的方向很清楚:能不加锁就不加锁,必须加锁就把粒度切到最小。这也是这道题的第二问「ConcurrentHashMap 怎么平衡性能和安全」真正想听的答案,不是「因为它线程安全」这种正确的废话。

Java 里的锁,其实是好几个维度叠加的
面试官问「Java 中锁的类型有哪些」,容易答得东一榔头西一棒子。其实这是几个正交的维度,任何一把锁都能在每个维度上找到自己的位置:
- 乐观锁 vs 悲观锁 :悲观锁认为一定会冲突,先加锁再操作(
synchronized、ReentrantLock);乐观锁认为大概率不冲突,操作时用版本号或 CAS 校验,冲突了再重试(AtomicInteger、数据库乐观锁字段)。 - 公平锁 vs 非公平锁 :
ReentrantLock构造函数传true是公平锁,严格按请求顺序排队;默认非公平锁允许插队,吞吐量更高但可能饿死排队久的线程。synchronized只有非公平这一种。 - 可重入锁 vs 不可重入锁 :同一个线程能不能重复获取自己已经持有的锁。
synchronized和ReentrantLock都可重入,这也是它们俩命名里都有意无意在强调的点。 - 独占锁 vs 共享锁 :
ReentrantLock是独占的,同一时刻只有一个线程能拿到;ReentrantReadWriteLock的读锁是共享的,允许多个线程同时读,写锁独占。读多写少的场景换成读写锁能明显提升并发度。 - 偏向锁 / 轻量级锁 / 重量级锁 :这是
synchronized自身的锁升级路径,没有竞争时是偏向锁,出现竞争后升级成轻量级锁(自旋 CAS),自旋失败太多次才会膨胀成靠操作系统互斥量实现的重量级锁。要注意偏向锁在 JDK 15 默认关闭、JDK 18 直接被移除了,现在的 JVM 上synchronized基本是从轻量级锁起步。
回答的时候按维度分组说,比堆砌名词更能体现出对锁的理解程度。
统计今日登录次数,怎么保证并发安全
这是一道典型的场景题:多个请求同时给同一个用户的今日登录次数加一,怎么保证不丢计数。常见的几种方案各有取舍:
- 数据库行级锁 :
UPDATE user_login_count SET count = count + 1 WHERE user_id = ?,靠 InnoDB 的行锁天然串行化。简单可靠,但登录是高频操作,容易把这行数据变成热点,扛不住太高的 QPS。 - Redis
INCR:INCR是原子操作,天然线程安全,配合EXPIRE设到当天 24 点自动过期,性能和实现复杂度都很友好,是这道题最常见的推荐答案。 - 本地锁 / 分布式锁 + 普通变量:先加锁再读改写,逻辑直白但性能最差,分布式锁本身还有额外的网络开销,纯粹是能用但不划算的方案,一般只在没有原子操作可用、又必须跨节点互斥时才考虑。
面试官真正想看的不是背出 Redis INCR 这四个字母,而是能不能讲出为什么数据库行锁在这个场景会成为瓶颈、为什么 Redis 的单命令原子性天然适合计数这层判断依据。
Spring 事务和代理,两个最容易踩的坑
@Transactional 能生效,前提是方法调用经过了 Spring 的代理对象。两个最常见的坑都是从这句话推出来的:
- 同类自调用失效 :
A类里的a()方法内部直接调用同类的b()方法,即便b()标了@Transactional,事务也不会生效,因为这次调用是this.b(),压根没经过代理对象,AOP 切面自然拦不住。 - 默认只回滚
RuntimeException和Error:受检异常(比如自定义的Exception子类)默认不会触发回滚,除非显式配置@Transactional(rollbackFor = Exception.class)。这个坑经常出现在业务逻辑抛了自定义异常、事务却没回滚、脏数据留在库里的排查现场。
代理这块顺带把 Spring 用的两种动态代理方式讲清楚:JDK 动态代理 基于接口,用 Proxy.newProxyInstance + InvocationHandler 生成实现类,前提是目标类必须实现至少一个接口;CGLIB 基于继承,在运行时用字节码生成目标类的子类并重写方法做拦截,能代理没有接口的类,但代理不了 final 类和 final 方法。Spring Boot 2.x 开始默认全部走 CGLIB,就是为了不管有没有接口都能统一处理。
事务传播机制,7 种里真正容易混的是这两种
Spring 事务传播机制一共 7 种:REQUIRED(默认,有事务就加入,没有就新建)、SUPPORTS(有事务就加入,没有就非事务运行)、MANDATORY(必须处在事务中,否则抛异常)、REQUIRES_NEW(无论如何都新建一个事务,原事务挂起)、NOT_SUPPORTED(非事务运行,原事务挂起)、NEVER(必须非事务运行,否则抛异常)、NESTED(有事务就作为嵌套事务运行,靠保存点实现)。
真正容易被面试官抓住深挖的是 REQUIRES_NEW 和 NESTED 的区别:两者都能做到内层方法回滚不影响外层,但 REQUIRES_NEW 是彻底开了一个独立事务,内外事务互不相关,外层回滚不会牵连已经提交的内层;NESTED 本质还是同一个物理事务,内层只是打了个保存点,外层一旦整体回滚,内层的保存点也会被一起回滚掉。选错了,一旦触发回滚场景,数据一致性的表现完全不一样。
写在最后
这套问题串起来看,其实是在考一件事:能不能把线程安全从数据结构一路讲到业务代码,而不是把每个知识点当成孤立的问答对去背。备考的时候如果只是把这些问题的标准答案背下来,遇到任何一个追问都会露馅;反过来如果能讲清楚每个设计背后为什么这么做,追问越深答得越顺。我自己准备的时候习惯把这类真实面经丢进面灵 AI 的模拟面试功能里,让它接着面经里的问题往下追问,比自己对着答案背更容易发现哪里其实没吃透。