10 年 Java 老兵的面试摸底:4 道题测出你的技术短板在哪?

5 阅读14分钟

前言

最近和一位 10 年 Java 老兵进行了一场技术交流,通过 4 道覆盖 JVM、MySQL、分布式事务和线上排查的题目,发现了一个典型现象:经验丰富的工程师往往在底层原理上存在明显短板

本文将完整还原这场"摸底考试"的过程,包括每道题的解析、暴露的知识盲区分析,以及针对性的学习计划。如果你也工作多年,不妨自测一下。


第 1 题:JVM 内存模型与线程可见性

题目

下面这段代码,在某些 JVM 配置下可能永远无法终止,请解释原因,以及如何修复:

public class VisibilityTest {
    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        new Thread(() -> {
            int count = 0;
            while (running) {
                count++;
            }
            System.out.println("线程退出,count=" + count);
        }).start();

        Thread.sleep(1000);
        running = false;
        System.out.println("主线程已设置 running=false");
    }
}

问题:

  1. 为什么可能不会终止?
  2. 底层机制是什么?
  3. 至少给出 3 种修复方式,并说明各自的原理差异

解析

核心问题:可见性

Java 内存模型(JMM)规定:每个线程有自己的工作内存(可以理解为 CPU 缓存的抽象),线程读变量时优先从工作内存读,而不是每次都从主内存读。主线程修改了 running = false 写入主内存,但工作线程可能一直读自己工作内存中的旧值 true,永远感知不到变化。

底层机制

这本质是 CPU 缓存一致性问题。现代 CPU 有多级缓存(L1/L2/L3),线程可能跑在不同核心上,各自缓存了 running 的副本。如果没有触发缓存失效和刷新,工作线程所在核心的缓存行永远是旧值。

JMM 定义了 happens-before 规则来保证可见性——如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。而上面的代码中,主线程的写和工作线程的读之间没有任何 happens-before 关系。

三种修复方式

方式代码原理
volatileprivate static volatile boolean running = true;volatile 写会强制刷新到主内存,volatile 读会强制从主内存读取,同时禁止指令重排序。底层通过内存屏障(Memory Barrier)实现
AtomicBooleanprivate static AtomicBoolean running = new AtomicBoolean(true);Atomic 包内部基于 volatile + CAS,同样保证可见性,额外提供原子操作能力
synchronized在循环体内加同步块synchronized 进入时清空工作内存从主内存读,退出时将工作内存刷回主内存。但性能开销大,不推荐此场景

第 2 题:数据库索引与查询优化

题目

假设有一张用户表,数据量 2000 万行:

CREATE TABLE user (
    id BIGINT PRIMARY KEY,
    city VARCHAR(32),
    age INT,
    gender CHAR(1),
    name VARCHAR(64),
    created_at DATETIME,
    INDEX idx_city_age (city, age)
);

-- 查询语句
SELECT * FROM user WHERE city = '北京' AND age > 25 ORDER BY created_at LIMIT 100;

问题:

  1. 这条查询会走索引吗?走哪个索引?走的方式是什么(ref/range/index……)?
  2. 这个查询可能存在的性能问题是什么?
  3. 如果要优化,你会怎么改(索引层面或 SQL 层面)?

解析

1. 走哪个索引?走什么方式?

这条查询会走 idx_city_age 索引。走的方式是:

  • city = '北京'ref(等值匹配,精确定位到索引中 city 为"北京"的范围)
  • age > 25range(在 ref 的基础上继续范围扫描)

所以 EXPLAIN 中你会看到 type: rangekey: idx_city_age

2. 实际的性能问题在哪里?

问题不只是 SELECT *,而是三个问题叠加

问题一:回表idx_city_age 只包含 city、age 和主键 id,但 SELECT * 需要 name、gender、created_at 等所有字段。MySQL 必须先用索引查出符合条件的 id,再逐行回聚簇索引取完整数据。如果 city='北京' 且 age>25 的行有 10 万条,就要回表 10 万次。

问题二:filesortORDER BY created_at 中的 created_at 不在索引中,索引本身按 (city, age) 排序,无法直接提供 created_at 的有序性。MySQL 必须把所有符合条件的行收集起来,在内存中做一次排序。如果结果集大,甚至需要磁盘临时文件辅助排序。

问题三:范围查询截断了索引age > 25 是范围条件,根据最左前缀原则,范围条件之后的列无法继续利用索引。即使你把索引改成 (city, age, created_at),created_at 也无法用于排序,因为 age 已经是范围扫描了。

3. 怎么优化?

-- 方案一:覆盖索引(消除回表)
ALTER TABLE user ADD INDEX idx_city_age_created (city, age, created_at, id, name);
-- 但这个索引列太多,实际不太现实

-- 方案二(推荐):改写 SQL,先走索引查 id,再回表取少量行
SELECT u.* FROM user u
JOIN (
    SELECT id FROM user WHERE city = '北京' AND age > 25
    ORDER BY created_at LIMIT 100
) t ON u.id = t.id;

方案二的核心思路:子查询只查 id,可以走覆盖索引(idx_city_age 能覆盖 city、age、id),不需要回表。子查询先筛选 + 排序 + LIMIT 100,拿到 100 个 id 后,外层只需回表 100 次而不是 10 万次。这就是常说的延迟关联


第 3 题:分布式一致性

题目

你的系统里有一个订单服务,用户下单后需要同时:

  1. 扣减库存(库存服务)
  2. 扣减账户余额(账户服务)
  3. 创建订单记录(订单服务自身的数据库)

三个服务各自有独立的数据库,不共享。现在的问题是:扣库存成功了、扣余额也成功了,但创建订单失败了。库存和余额已经扣了,订单却没建出来,数据不一致了。

问题:

  1. 你会用什么方案解决这个问题?说出你的思路就行
  2. 这个方案有什么代价或局限?

解析

补偿机制的核心思想

这道题对应的就是 Saga 模式的补偿方案。流程如下:

创建订单 → 扣库存 (成功) → 扣余额 (成功) → 创建订单记录 (失败)
                                              ↓ 触发补偿
                          退回余额 ← 退回库存

但这个方案有几个关键问题:

问题一:补偿本身也可能失败。 退回余额的接口调用超时了怎么办?库存服务此刻宕机了怎么办?如果补偿不成功,数据还是不一致的。所以补偿操作必须是幂等的——调用 1 次和调用 10 次效果一样,支持重试直到成功。

问题二:并发场景下的脏读。 扣完库存、还没退回的这段时间窗口内,其他请求看到的库存是扣减后的值,可能基于这个错误数据做了业务决策。

问题三:补偿顺序。 应该先退余额还是先退库存?如果先退余额成功、退库存失败,余额多了但库存少了,又是一致性问题。一般建议逆序补偿——最后执行的操作最先回退。

问题四:怎么知道要补偿? 你需要一个"协调者"来记录每一步的执行状态,失败后自动触发补偿链。这个协调者本身也要可靠(不能它自己也挂了就没人管了)。

业界主流方案对比

方案原理优点缺点适用场景
2PC / XA全局事务协调器,两阶段提交强一致性同步阻塞、性能差、单点故障传统数据库跨库事务
TCCTry-Confirm-Cancel,业务层面实现三步灵活、不锁资源业务侵入大,每个操作要写三个方法资金类高一致要求
Saga正向操作链 + 补偿链性能好、无锁最终一致性、补偿复杂长流程业务编排
本地消息表本地事务写消息表,异步投递保证最终一致实现简单、可靠有延迟、不保证实时一致大部分互联网场景
可靠消息最终一致基于 RocketMQ 事务消息解耦、高吞吐需要消息中间件支持事务消息订单→库存→支付链路

对这道题最实用的方案其实是本地消息表:

1. 订单服务本地事务中:创建订单 + 写一条消息到本地消息表(状态=待发送)
   → 这一步在同一个数据库事务里,要么都成功要么都失败,天然一致

2. 后台定时任务扫描消息表,把待发送的消息投递到 MQ

3. 库存服务、余额服务消费 MQ 消息,各自执行扣减
   → 执行成功则 ACK,失败则重试

4. 如果消费端始终失败,人工介入或走补偿

核心思路是:把分布式事务拆成多个本地事务 + 异步消息,用最终一致性代替强一致性。


第 4 题:网络与运维

题目

线上有一个 Spring Boot 服务,部署在 Linux 服务器上,某天突然收到大量用户反馈接口很慢。你 SSH 登上去后,只能用 Linux 原生命令(不能装新工具),你会按什么顺序排查?说出你的排查思路和每一步要看的指标。

解析

分层排查思路

核心原则是从外到内、逐层缩小范围

接口慢 
  ↓
是服务器本身的问题还是服务的问题?
  ↓
如果是系统资源异常 → CPU / 内存 / 磁盘 / 网络 哪个是瓶颈?
如果是应用层问题 → Java 进程状态 → GC 日志 → 线程 dump → 接口耗时

第一步:看系统整体负载(10 秒内完成)

# 负载均衡值,看 1/5/15 分钟的趋势
uptime
# 如果 load 远超 CPU 核数,系统过载

# CPU、内存、交换分区的全局视图
top -bn1 | head -20

# 看 IO 是否堆积
iostat -x 1 3
# 关注 %util(磁盘忙的比例)和 await(IO 等待时间)

第二步:锁定问题进程

# 找到 Java 进程的 PID 和资源占用
ps aux | grep java

# 实时看指定进程的 CPU、内存、线程数
top -p <pid> -H
# -H 显示线程级视图,能看到哪个线程最耗 CPU

第三步:根据症状深入

如果是 CPU 高:

# 找到最耗 CPU 的线程 ID
top -p <pid> -H → 记下 TID

# 线程 ID 转十六进制
printf "%x\n" <TID>

# 导出线程栈,搜索对应线程
jstack <pid> | grep <hex_tid> -A 30
# 这里能看到线程在干什么:是死循环、GC 线程、还是业务阻塞

如果是内存高或频繁 GC:

# 看 GC 统计(每秒刷新)
jstat -gcutil <pid> 1000
# 关注 FGC(Full GC 次数)和 FGCT(Full GC 总耗时)
# 如果 FGC 频繁增长,说明内存不足或内存泄漏

# 堆内存详情
jmap -heap <pid>

# 怀疑内存泄漏时导出堆 dump
jmap -dump:format=b,file=heap.hprof <pid>
# 后续用 MAT 或 VisualVM 分析

如果是网络或连接问题:

# 看 TCP 连接状态分布
netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
# 如果 CLOSE_WAIT 大量堆积 → 对端没关闭,代码没调 close
# 如果 TIME_WAIT 大量堆积 → 短连接过多
# 如果 ESTABLISHED 很大 → 并发高或连接泄漏

# 看某个端口的连接数
netstat -ant | grep :8080 | wc -l

# 看网卡流量
sar -n DEV 1 5

第四步:应用层定位

# 如果系统资源都正常,问题在应用逻辑层面
# 1. 看 JVM 线程有没有阻塞
jstack <pid> | grep -A 5 "BLOCKED\|WAITING"

# 2. 看是否有死锁
jstack <pid> | grep -A 10 "Found one Java-level deadlock"

# 3. 如果接入了 APM(SkyWalking/Prometheus),直接看慢接口链路
# 4. 如果没有 APM,看应用日志中的耗时
grep "cost\|elapsed\|耗时" app.log | tail -50

四道题的完整诊断报告

领域题目薄弱程度
JVM 内存模型可见性问题★★★★
MySQL 索引联合索引与查询优化★★★★
分布式事务一致性方案★★★
线上排查系统化诊断★★★

总体画像:10 年经验,框架运用和业务开发能力强,但计算机体系基础知识(JMM、索引结构、分布式理论、系统级排查)存在明显短板。这是典型的"经验型工程师"——靠积累解决问题,缺乏从底层原理推导的能力。


针对性学习计划

第一优先级:JVM 原理

学习路径:

  1. JMM 内存模型:主内存与工作内存、happens-before 八大规则、volatile/ynchronized/final 的内存语义
  2. GC 体系:分代模型、常见收集器(CMS → G1 → ZGC)的适用场景和调优参数、GC 日志阅读
  3. 类加载机制:双亲委派、打破双亲委派的场景(SPI/Tomcat)、运行时数据区结构

推荐资源:

  • 《深入理解 Java 虚拟机》周志明——第 2、3、7 章精读
  • jstat -gcutiljstackjmap 三个命令在自己的开发环境实际跑一遍,模拟 CPU 飙高和内存泄漏并排查

检验标准: 能解释 volatile 的内存屏障实现、能看懂 GC 日志判断是否需要调优、能从 jstack 输出定位到业务代码行


第二优先级:MySQL 索引与查询优化

学习路径:

  1. 索引结构:B+ 树为什么适合数据库、聚簇索引 vs 二级索引、索引的物理存储
  2. 索引使用规则:最左前缀原则、覆盖索引、索引下推(ICP)、索引跳跃扫描
  3. 查询优化实战:EXPLAIN 每个字段的含义、filesort 触发条件与优化、慢查询分析流程

推荐资源:

  • 《高性能 MySQL》第 5、7 章
  • 自己建表造数据,用 EXPLAIN 分析各种查询,对比加索引前后的执行计划变化

检验标准: 看到 EXPLAIN 输出能判断是否走了索引、是否回表、是否 filesort,并给出优化方案


第三优先级:分布式理论

学习路径:

  1. CAP 与 BASE:为什么分布式只能选 CP 或 AP、最终一致性的含义
  2. 分布式事务方案:2PC、TCC、Saga、本地消息表、可靠消息最终一致的原理和取舍
  3. 共识算法:Paxos/Raft 的核心思想(不需要手写实现,理解选举和日志复制即可)

推荐资源:

  • 《分布式系统设计》Martin Kleppmann——数据复制和一致性章节
  • Seata 官方文档的 TCC 和 AT 模式源码导读

检验标准: 给定一个业务场景,能选择合适的分布式事务方案并说出理由


第四优先级:线上排查体系化

学习路径:

  1. Linux 排查命令:top/htop、iostat、sar、netstat/tcpdump 的实战用法
  2. JDK 工具链:jps/jstat/jstack/jmap/jinfo 的组合使用场景
  3. Arthas:阿里开源的 Java 诊断工具,线上热更新、方法耗时追踪、GC 观察一站式解决

推荐资源:

  • Arthas 官方文档 + 在线教程(直接在实验环境练)
  • 自己制造故障场景(死循环、内存泄漏、线程死锁),用命令排查

检验标准: 给定一个线上故障现象,能在 5 分钟内定位到根因


每日学习节奏建议

时间段内容时长
早上读一个底层原理章节,做笔记30 分钟
中午用题库自测 3-5 道题,错题标记15 分钟
晚上碎片时间看一篇技术博客或源码解析20 分钟
周末实操演练(模拟故障排查 / EXPLAIN 实战 / Arthas 练习)1-2 小时

按这个节奏,4 个方向大约 2-3 个月可以补到面试不虚的水平。最关键的第一优先级 JVM,建议先集中 3 周啃下来,后面的方向学起来会更快,因为很多底层原理是相通的。


结语

技术的深度决定了职业发展的上限。工作经验固然重要,但如果只停留在"会用"层面,很容易被替代。希望这篇文章能帮你发现自己的知识盲区,制定有针对性的学习计划。

记住:真正的资深工程师,不仅知道怎么做,更知道为什么这样做。