实操/排查题 面试题


1. 线上 CPU 100% 排查实战

题目: 生产环境 Java 应用 CPU 突然飙到 100%,你如何一步步定位到问题代码?

💡 答案:

排查路线分五步:

  1. top -chtop 找到 CPU 占用最高的 Java 进程 PID。
  2. top -H -p <pid> 查看该进程内 CPU 最高的线程,记录线程编号(如 16896)。
  3. 将线程号转为十六进制——printf "%x\n" 16896 得到 nid(如 0x4200)。
  4. jstack <pid> | grep -A 30 "0x4200",找到这个线程当前在执行的线程栈。如果该线程是 GC 线程(名字为 “GC task thread” 或类似),CPU 飙高可能因为内存不够导致频繁 Full GC。如果是业务线程——直接看线程堆栈指明了哪一行代码,通常可能是死循环、频繁的 String 操作、正则匹配复杂度激增导致回溯爆炸等。
  5. 结合 jstat -gcutil <pid> 1000 实时查看 GC 情况——如果 FGC 次数在飞涨,同时 CPU 高,基本确认是内存压力导致的 GC 风暴。

追问1: 如果发现是 GC 导致的 CPU 飙高,你如何进一步分析是哪些对象导致的?

GC 导致的 CPU 飙高通常是因为 Full GC 频繁,而 Full GC 频繁的根因是堆内存不足或存在内存泄漏。进一步定位:先用 jmap -histo <pid> | head -30 看堆内存中对象实例排行——哪个类占用了大量空间。然后 jmap -dump:format=b,file=heap.hprof <pid> 导出堆转储文件。用 MAT(Eclipse Memory Analyzer)或 JProfiler 打开 heap.hprof,按 retianed size 排序,看是什么对象占了大头。常见原因:缓存没有最大容量限制一直往里塞数据、ThreadLocal 没清理、大对象持有 GC Root 引用无法回收。如果看 Dominator Tree 也找不到单个大对象,可能是不停地创建中等大小的临时对象——用 jstat -gc <pid> 看 young GC 频率,如果每秒上百次 young GC 说明代码里有大量短生命周期对象创建(比如在循环里创建大 StringBuilder 或日志级别太高一直在拼接)。

追问2: top 显示 CPU 100%,但 jstack 发现所有线程都是 WAITING 状态,这是什么情况?

这种情况通常不是死锁——是进程中有大量线程等待某种 I/O 或外部资源,而 CPU 利用率实际来自内核空间(us 低但 sy 高)或 GC 线程(jstack 默认只打印 Java 线程,GC 线程的细节需要 -F 选项或在 safepoint 之外查看)。具体原因:大量线程等待数据库连接、Redis 连接——连接池满了,请求线程被 WAITING (parking)。此时 CPU 利用率来自线程上下文切换——成千上万个线程在操作系统的调度队列中等待、抢占、休眠。查看是否有线程名中包含 “GC Task Thread”——它如果 CPU 最高,那 CPU 其实被 GC 线程吃了,业务线程都在安全点等待。另一个排查角度:vmstat 1cs(context switches)列——如果并发线程数过大导致每秒十几万的上下文切换,CPU 的实际工作时间被切换占满,线程自身反而不在运行。

📌 易错点 / 加分项:

  • 排查路线要有自己的记忆锚点:top -H -p → 十六进制 → jstack 这个链条必须记牢
  • jstack -Fjstack -l 的区别:-l 打印额外的锁信息,-F 强制 dump(不用 -F 通常就够)
  • 能提到 Arthas 的 dashboardthread -n 3trace 等命令说明有实战经验

2. 线上 OOM 分析与内存泄漏定位

题目: 一个 Java 应用频繁 OOM 宕机,你如何定位是哪里导致了内存溢出?

💡 答案:

OOM 排查三部曲:收集现场 → 分析 dump → 定位根因。

  1. 第一步,配置 JVM 参数在 OOM 时自动生成堆转储——-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof,开启”GC 日志”-Xlog:gc*(JDK 9+)。
  2. 第二步,拿到 hprof 文件后,用 MAT 或 JProfiler 打开,核心分析视角:先看”Histogram”按 retianed size 排序——最大的对象是哪个?如果是某个业务类占用巨大,直接点进去看 Path to GC Roots,找到哪条引用链导致它无法被回收。然后看”Leak Suspects”——MAT 自动识别可能的泄漏对象并给出嫌疑报告。
  3. 第三步,从嫌疑对象反查到代码——看是什么线程、什么方法创建的这个对象,为什么没被释放。

常见泄漏模式:

  • 一是集合对象(List、Map)作为缓存但没有容量上限,数据量不断增长最终 OOM
  • 二是 ThreadLocal 没有在 finally 中 remove,线程池复用时 ThreadLocalMap 中的值永不释放
  • 三是内部类的静态引用持有外部类引用,导致外部类(如 Activity、Controller)无法被 GC
  • 四是各种连接(数据库连接、文件流)没有正确关闭,占用直接内存

追问1: 如果发现是某个线程的 ThreadLocal 没有清理导致的内存泄漏,你会怎么定位到是哪个业务线程?

ThreadLocal 内存泄漏的定位过程:

  1. 第一步 MAT 中按 retained size 排序,找到大量业务对象(如某个 User 对象有数万个实例)——点击查看 GC Root 路径,路径显示 User <- ThreadLocalMap$Entry.value <- ThreadLocalMap <- java.lang.Thread
  2. 第二步查看具体的 ThreadLocal 实例——MAT 的”Path to GC Roots”中能看到 Thread 的 ThreadLocalMap 里有哪个 key 为 null(因为 ThreadLocal 被 GC 回收了)但其 value 还是强引用。
  3. 第三步定位业务线程——看 ThreadLocalMap 是从哪个 Thread 中出来的,线程名称通常包含了线程池名称(如 pool-3-thread-16),就能反查到是哪个线程池和哪个业务模块。
  4. 第四步代码层面——搜索全局所有使用 ThreadLocal 的地方,检查是否在 finally 中 remove。特别关注过滤器/拦截器这种”每次请求都创建但常忘记清理”的地方。

追问2: 一个应用堆内存不大但系统整体内存(RES)很大,top 看虚拟内存很高,什么原因?

堆内存不大但进程 RES 很大,常见几个原因:

  • 第一是直接内存(Direct Memory)泄漏——NIO 的 DirectByteBuffer 分配直接内存不受 JVM 堆管理,如果没有正确释放,直接内存会持续增长。排查通过 -XX:MaxDirectMemorySize 设上限、用 ByteBuf 分配计数的监控,配合 jcmd <pid> VM.native_memory summary 看直接内存实际占用。
  • 第二是 Metaspace 膨胀——动态代理、CGLIB 等会大量创建类,如果 Metaspace 没有上限它会一直增长。排查看 jstat -gc <pid> 中 M 列(Metaspace 使用量)。
  • 第三是线程栈——线程数过多(如上千个线程),每个线程有一个栈空间(默认 1MB),上千个栈就是上 GB。
  • 第四是堆外内存缓存——如使用 mmap 内存映射文件、JNI 内存、或者框架自己的堆外内存(如 Netty 的 Direct Buffer Pool)。排查用 jcmd <pid> VM.native_memory summary.diff 查看 native memory 各部分的变化。

📌 易错点 / 加分项:

  • 能用 MAT 的”Path to GC Roots”找到根因,能区分”正常多对象”和”内存泄漏”
  • 直接内存泄漏的典型错误是:分配了 DirectByteBuffer 但 GC 只回收 Java 对象不回收直接内存
  • NMT(Native Memory Tracking)是排查堆外内存问题的利器——-XX:NativeMemoryTracking=detail

3. 日志系统设计与链路追踪

题目: 你如何设计一个微服务系统的日志和链路追踪方案?一个请求跨了 5 个服务,怎么快速定位问题?

💡 答案:

微服务日志和链路追踪的核心是”将同一条请求在各个服务中的日志串联起来”。需要三个基础设施:

  • 日志收集(Filebeat + Kafka + Elasticsearch)
  • 链路追踪(OpenTelemetry / SkyWalking / Jaeger)
  • 指标监控(Prometheus + Grafana)

设计的关键要素:每个服务在请求入口(Filter/Interceptor/Middleware)生成或接收 TraceId + SpanId。TraceId 在整个请求链中保持唯一且不变——由最上游服务(如网关)生成,通过 HTTP Header(如 X-Trace-Id)向下游传递。SpanId 在每个调用层级生成,表示本服务内的一个操作。日志中精确记录 TraceId,查询 ES 时按 TraceId 分组就能看到完整的请求链路。“跨 5 个服务”的定位通过 TraceId 串联:在 Tracing 平台(如 Jaeger UI)输入 TraceId 就能看到树形调用链——哪个服务耗时长、哪里报错、依赖关系一目了然。

追问1: TraceId 的生成和传递是怎么实现的?服务 A 如果调用服务 B 用的是 MQ 异步消息,TraceId 怎么传过去?

跨 HTTP 的 TraceId 传递比较直观——通过标准 HTTP Header X-Trace-Id 或 W3C Trace-Context 的 traceparent 头传下去。但 MQ 异步消息传递 TraceId 需要特殊处理——消息中间件本身不主动携带 HTTP Header。解决办法是:在生产者发消息时,把 TraceId 塞入消息的自定义属性(如 RocketMQ 的 userProperties、Kafka 的 Header)。消费者收到消息后从属性中取出 TraceId 并设回当前线程的日志上下文(MDC 中),这样消费者侧产生的所有日志也都能带同一个 TraceId。更完整的做法是使用 OpenTelemetry 的 Context Propagation——它的 Propagator 接口定义了跨消息传递 Trace 上下文的标准化方式,对 Kafka、RabbitMQ 等都有现成的支持,只需在代码里注入 propagator 然后 context.inject() + context.extract()

追问2: 日志量大到一定程度,存储和分析怎么优化?采样策略和冷热分离怎么做?

日志量大到一定程度后的优化分两个层面。

第一是存储成本优化——ES 集群引入冷热分离架构:热节点用 SSD 存近 1-3 天的日志(查询频繁),冷节点用 HDD 存 3-30 天的日志(低频查询)。超过 30 天的日志压缩归档到对象存储,ES 用 snapshot 机制备份到 S3 或 MinIO。

第二是查询优化——不需要所有日志都进 ES,而是按日志级别过滤(DEBUG/INFO 在本地,WARN/ERROR 进 ES),或对 QPS 极低的接口做全量采样、极高频的接口做 1% 采样。采样策略需要区分正常流量和异常流量——建议使用”自适应采样”:正常流量采样 1-10%,错误流量 100% 采样,保证排查问题时不会漏关键信息。此外还有索引分片策略:按天分索引(log-2026-05-04),每个索引的 shard 数和 replica 数需要根据数据量调整——日均 50GB 以下的日志不需要太多 shard。

📌 易错点 / 加分项:

  • TraceId 和 SpanId 的区别——尤其是一对多的关系:一个 TraceId 有多个 SpanId
  • OpenTelemetry 是如今 Tracing 的标准,Trace 和 Metrics 和 Logs 三合一
  • MDC(Mapped Diagnostic Context)是 Java 日志系统中传递上下文的关键——通过 ThreadLocal 实现

4. Java 内存马与安全攻防

题目: 线上遇到了一个 Java 应用被注入内存马(Memory Webshell),无文件落地,如何排查和修复?

💡 答案:

内存马的核心特点是”只存在于内存中、无磁盘文件”。排查内存马需要几个步骤:

  • 一是通过 jps 获取可疑进程 PID
  • 二是用 jmap -histo <pid> 看 Filter/Servlet 相关类的实例数量是否异常——比如 ApplicationFilterChain 或自定义 Filter 实现类的实例数量远多于正常数量
  • 三是排查关键的 JVM MBean——Filter、Servlet 的注册信息在 JMX 中可查。更直接的方式是用 Arthas 等工具——用 sc *.filter.* 搜索所有 Filter 类,检查是否有异常的类名(如 ShellFilter、随机字符串命名的类)

修复措施:首先隔离机器(从负载均衡摘除)防止横向扩散,然后提取 JVM 内存 dump 给安全团队分析具体的注入 payload 和利用链,最后重启服务清除内存马。注意:重启后日志中出现的异常堆栈(如反序列化异常、框架注入点)可能是内存马注入的入口,从中排查漏洞点。

追问1: Filter 型内存马和 Servlet 型内存马在注入原理上有什么不同?

Filter 型内存马通过在运行中动态注册 Filter 实现。攻击者通过某种 RCE(如 Fastjson 反序列化、Spring Boot Actuator 未授权 JMX 操作、Log4j JNDI 注入等)获取了代码执行能力,然后调用 ApplicationContextServletContext 反射构建一个新的 Filter 对象并注册到 FilterChain 中——之后所有 HTTP 请求都会经过这个恶意 Filter,攻击者可以执行任意命令。

Servlet 型内存马是通过 ServletContext.addServlet() 动态注册一个新的 Servlet 并映射 URL 路径。

两者区别:Filter 对所有请求都生效(基于 /* 映射),更隐蔽;Servlet 只在特定路径生效,相对容易被发现。Filter 型的注入点通常是 Spring 的 ApplicationContext 或嵌入式 Tomcat 的 StandardContext

追问2: 如何从架构层面防御内存马的注入?

防御内存马从以下几个方面入手:

  • 第一是入口防御——RASP(Runtime Application Self-Protection)在 JVM 层面监控敏感 API(如 defineClassaddFilter、反射获取敏感类加载器),阻断异常的动态注册操作。
  • 第二是容器加固——禁用不必要的 Actuator 端点、严格限制 JMX 端口、用 Security Manager 限制 JVM 级别的操作(虽然现在很少用 SM)。
  • 第三是依赖安全——定期检查依赖库中已知的反序列化漏洞(Fastjson、Jackson、Xstream 等)。
  • 第四是运行态监控——通过 Java Agent 或 JVMTI 在关键 API 上插桩,检测到异常的类加载或 Filter/Servlet 动态注册行为时告警。
  • 第五是基线建设——每次发布后产出当前应用注册的 Filter/Servlet 列表作为基线,后续异常过滤器和 Servlet 的注册就很容易对比发现。

📌 易错点 / 加分项:

  • 内存马的根因是代码执行权限过大,不是”如何清除马”,而是”为什么马能注入”

5. 接口响应慢排查全流程

题目: 线上某个核心接口 P99 突然从 50ms 飙升到 2s,你会按照什么思路排查?

💡 答案:

排查路线按”从外到内”逐层排查:

  1. 确认影响面:监控大盘看是单个接口慢还是全局慢。单个接口慢往下查接口,全局慢考虑系统资源或依赖问题。
  2. 依赖排查:看接口调用了哪些下游——数据库、Redis、MQ、其他微服务。用链路追踪看哪个 Span 耗时久。数据库慢查 SHOW PROCESSLIST 看锁等待或慢查询。Redis 慢看 slowlog。
  3. 自身代码排查:用 Arthas 的 trace 追踪方法调用链路——trace com.xxx.Controller.processOrder,直接看到每个子方法耗时占比。加 '#cost > 100' 过滤耗时 > 100ms 的调用。
  4. GC 检查jstat -gcutil <pid> 1000 看 FGC 是否频繁。接口慢伴随 GC STW 频繁说明可能是 GC 停顿导致。
  5. 系统资源:CPU 打满用 top -H -p <pid> 找高 CPU 线程 → jstack。内存不足导致频繁 swap 用 free -m
  6. 流量变化:是否秒杀/促销导致流量突增,线程池和连接池被打满。观察 HikariCP 连接池使用率、Tomcat 线程池活跃数。

📌 易错点 / 加分项:

  • 不要上来就重启——先保留现场(jstack、jmap、Arthas trace),重启后证据全丢
  • 同时拉两组对比数据——慢的时候和正常时候的系统指标对比
  • 链路追踪的 Span 采样率可能限制排查——先临时调到 100% 采样再复现

6. 数据库死锁排查

题目: 线上报出 “Deadlock found when trying to get lock” 错误,你怎么排查和解决?

💡 答案:

排查步骤:

  1. 定位死锁日志SHOW ENGINE INNODB STATUS 输出中的 LATEST DETECTED DEADLOCK 段落,包含死锁涉及的两个事务、各自的 SQL、持有的锁和等待的锁。
  2. 反查业务代码:根据日志中的 SQL 反查是哪个业务接口,确认两个事务操作的表和索引情况——死锁几乎都发生在索引上。
  3. 分析死锁原因:最常见模式——两个事务以相反顺序更新相同行。另一种——间隙锁冲突,RR 隔离级别下 Insert 需要插入意向锁,如果间隙被其他事务的间隙锁覆盖就死锁。

解决策略:统一加锁顺序——所有事务按相同顺序更新资源(如按 ID 升序);缩小事务范围——不相关操作移出事务;降隔离级别——业务允许时从 RR 降到 RC,去掉间隙锁,死锁概率大幅下降;用乐观锁替代——版本号 CAS 替代行锁。

📌 易错点 / 加分项:

  • InnoDB 的死锁检测是自动的——一旦检测到死锁,自动回滚成本小的那个事务
  • MySQL 8.0 的 performance_schema.data_locks 比 5.7 的 INNODB_LOCKS 更详尽
  • 不要在事务中调用外部服务或 RPC——事务锁持有的时间就是整个调用的时间

7. 微服务雪崩排查与容灾

题目: 微服务架构中某个非核心服务 B 挂了,导致核心服务 A 也跟着连锁故障,这种现象叫什么?你怎么在架构层面和代码层面防止它?

💡 答案:

这种现象叫级联故障(雪崩效应)。传播路径:服务 B 挂掉 → 服务 A 调用 B 时大量线程阻塞等待超时 → A 的线程池被打满 → A 无法处理其他请求 → A 也挂掉 → 调用 A 的服务 C 也挂掉 → 连锁扩散。

防止雪崩需要多层防线:

  • 熔断:当 B 的错误率或慢调用比例超过阈值,熔断器打开,直接返回 fallback 结果不再调用 B。半开后试探性放少量请求,B 恢复则关闭熔断。
  • 降级:对非核心依赖做降级处理。A 调 B 获取推荐商品,B 挂了直接返回空列表。降级开关通过配置中心动态控制。
  • 线程隔离:对每个下游依赖分配独立的线程池——B 的线程池满了不影响调用 C。
  • 超时控制:每个远程调用都设合理的超时时间,超时后快速失败释放线程。
  • 限流:在网关层或 RPC 框架层对每个接口做 QPS 限流,超出阈值的请求直接拒绝。

📌 易错点 / 加分项:

  • 熔断器有”时间窗口”概念——默认统计近 10 秒内的调用结果
  • Sentinel 的线程隔离是信号量隔离而非线程池隔离——性能更高但无法处理异步超时
  • 熔断和限流的区别——熔断是”下游坏了我不调了”,限流是”上游太猛我不接了”

8. 容器化环境 OOM 排查

题目: K8s 中 Java 应用 Pod 频繁被 OOMKilled,堆内存配置为 -Xmx2g,Pod 内存 limit 设为 3Gi。为什么会 OOM?排查思路是什么?

💡 答案:

Pod OOMKilled 的根本原因是”容器内所有进程的内存总和 + page cache”超过了 K8s 的 memory limit。堆内存只是 Java 进程的一部分。除堆(-Xmx)之外还有:Metaspace(默认无上限)、线程栈(每个线程默认 1MB,1000 个线程 = 1GB)、直接内存(-XX:MaxDirectMemorySize 默认等于 -Xmx,Netty/GRPC 大量使用)、JIT 编译缓存、GC 数据结构、NMT 等。

排查思路:

  1. jcmd <pid> VM.native_memory summary 看 Native Memory Tracker 各部分分布(需 -XX:NativeMemoryTracking=detail)。
  2. 重点看”Thread”区域(线程栈)和”Internal”区域(直接内存)。Thread 大说明线程数过多,Internal 大排查 Netty/DirectByteBuffer 泄漏。
  3. jstat -gc <pid> 看 Metaspace 使用量(MU/MC 列),对比 MaxMetaspaceSize。
  4. 容器内 cat /sys/fs/cgroup/memory/memory.usage_in_bytes 看实际使用量 vs limit。

解决方案:调大 Pod 的 memory limit,或限制非堆部分——-XX:MaxMetaspaceSize=256m-Xss256k-XX:MaxDirectMemorySize=512m,确保堆 + Metaspace + 线程栈 + 直接内存 + JVM overhead < Pod limit。

📌 易错点 / 加分项:

  • -Xmx 和 Pod limit 的黄金比例 1:1.5 到 1:2——Pod 4G 则 -Xmx 最多 2.5G
  • JDK 10+ 默认开启 -XX:+UseContainerSupport——JVM 会感知容器 cgroup 限制,但不自动限制非堆内存
  • OOMKilled 是被 K8s 直接 kill 的,来不及 dump——需要提前配置监控和告警

9. 热点账户/热点库存问题排查

题目: 秒杀或高并发转账场景下,某一个账号成为热点,导致数据库行锁等待严重,你怎么排查和解决?

💡 答案:

热点账户问题的本质是单行锁竞争——所有并发操作都在同一行上争抢行锁,数据库连接全部排队。

排查方式:

  1. SHOW PROCESSLIST 看到大量连接状态为 “updating”,且 Info 列是相同 SQL(或操作同一行的不同 SQL),确认是行锁热点。
  2. SELECT * FROM performance_schema.data_lock_waits(8.0)找阻塞链——谁是锁持有者、谁在排队。

解决策略(从简单到复杂):

  • 限制并发:应用层用 Semaphore 限制同一个 key 的并发度。
  • 排队 + 合并:把同一账号操作放入消息队列,单线程消费串行处理。或用 Redis 做预扣(DECR 原子操作),减少数据库压力。
  • 分桶/分槽:把热点行分散到多个”影子行”。比如库存从 1 行拆为 10 个影子行,每次随机选一个扣减,10 行平分热点压力。
  • 拆分读写:读余额用 Redis 缓存,写走数据库 + 异步同步。
  • 换存储模型:极高并发热点用 Redis 原子扣减 + 异步持久化到 MySQL。

📌 易错点 / 加分项:

  • 分桶方案会导致”总库存有余但单个桶没余”——需要做桶间负载均衡或允许重试到其他桶
  • 不是所有热点都需要解决——如果 QPS 只有几百,MySQL 行锁完全够用
  • InnoDB 的死锁检测机制本身在热点场景下也会成为瓶颈

10. 日志系统与排障实战

题目: 线上出了一个生产 Bug,日志不够全没法定位。你会怎么设计一套完善的日志方案?关键信息怎么打印、怎么存储、怎么查询?

💡 答案:

完善的日志方案需要覆盖四个维度:什么请求、什么时间、什么结果、为什么是这个结果。

关键信息打印:请求入口——TraceId、URL、请求参数(敏感字段脱敏)、来源 IP。中间步骤——调用下游服务名、方法、参数、响应耗时。异常——完整堆栈、当前方法入参、用户上下文。出口——响应状态码、耗时、响应体大小。日志级别分工:DEBUG 详程(生产关)、INFO 关键节点、WARN 可恢复异常(熔断触发、重试成功)、ERROR 需人工介入。

存储:本地日志文件(按大小和时间滚动切割,保留 N 天)→ Filebeat 采集 → Kafka 削峰 → Logstash 消费 → Elasticsearch。ES 热数据(近 3 天)SSD、冷数据(3-30 天)HDD,超 30 天归档对象存储。

查询:Kibana 按 TraceId 搜索——一个 TraceId 串联整个请求在所有微服务中的日志,按时间升序排列完整看到请求生命周期。没有 TraceId 时按”时间范围 + 错误关键字 + 接口名”组合搜索。

📌 易错点 / 加分项:

  • 日志不要打印大对象——JSON 序列化消耗大量 CPU 和内存,用 toString 或限制序列化大小
  • MDC(Mapped Diagnostic Context)是 Logback 的 ThreadLocal 上下文——TraceId 就用 MDC 透传
  • 异步日志(Log4j2 AsyncLogger 或 Logback AsyncAppender)大幅减少日志对业务线程的影响
  • 不要只关注 Fastjson——Log4j JNDI、Spring Cloud Gateway SpEL 注入也是一类高发入口
  • RASP 有性能损耗(通常 5-10%),但在安全敏感的场景下是可以接受的