实操/排查题 面试题
1. 线上 CPU 100% 排查实战
❓ 题目: 生产环境 Java 应用 CPU 突然飙到 100%,你如何一步步定位到问题代码?
💡 答案:
排查路线分五步:
- 用
top -c或htop找到 CPU 占用最高的 Java 进程 PID。 - 用
top -H -p <pid>查看该进程内 CPU 最高的线程,记录线程编号(如 16896)。 - 将线程号转为十六进制——
printf "%x\n" 16896得到nid(如 0x4200)。 jstack <pid> | grep -A 30 "0x4200",找到这个线程当前在执行的线程栈。如果该线程是 GC 线程(名字为 “GC task thread” 或类似),CPU 飙高可能因为内存不够导致频繁 Full GC。如果是业务线程——直接看线程堆栈指明了哪一行代码,通常可能是死循环、频繁的 String 操作、正则匹配复杂度激增导致回溯爆炸等。- 结合
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 1 看 cs(context switches)列——如果并发线程数过大导致每秒十几万的上下文切换,CPU 的实际工作时间被切换占满,线程自身反而不在运行。
📌 易错点 / 加分项:
- 排查路线要有自己的记忆锚点:
top -H -p→ 十六进制 →jstack这个链条必须记牢 jstack -F和jstack -l的区别:-l 打印额外的锁信息,-F 强制 dump(不用 -F 通常就够)- 能提到 Arthas 的
dashboard、thread -n 3、trace等命令说明有实战经验
2. 线上 OOM 分析与内存泄漏定位
❓ 题目: 一个 Java 应用频繁 OOM 宕机,你如何定位是哪里导致了内存溢出?
💡 答案:
OOM 排查三部曲:收集现场 → 分析 dump → 定位根因。
- 第一步,配置 JVM 参数在 OOM 时自动生成堆转储——
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof,开启”GC 日志”-Xlog:gc*(JDK 9+)。 - 第二步,拿到 hprof 文件后,用 MAT 或 JProfiler 打开,核心分析视角:先看”Histogram”按 retianed size 排序——最大的对象是哪个?如果是某个业务类占用巨大,直接点进去看 Path to GC Roots,找到哪条引用链导致它无法被回收。然后看”Leak Suspects”——MAT 自动识别可能的泄漏对象并给出嫌疑报告。
- 第三步,从嫌疑对象反查到代码——看是什么线程、什么方法创建的这个对象,为什么没被释放。
常见泄漏模式:
- 一是集合对象(List、Map)作为缓存但没有容量上限,数据量不断增长最终 OOM
- 二是 ThreadLocal 没有在 finally 中 remove,线程池复用时 ThreadLocalMap 中的值永不释放
- 三是内部类的静态引用持有外部类引用,导致外部类(如 Activity、Controller)无法被 GC
- 四是各种连接(数据库连接、文件流)没有正确关闭,占用直接内存
追问1: 如果发现是某个线程的 ThreadLocal 没有清理导致的内存泄漏,你会怎么定位到是哪个业务线程?
ThreadLocal 内存泄漏的定位过程:
- 第一步 MAT 中按 retained size 排序,找到大量业务对象(如某个 User 对象有数万个实例)——点击查看 GC Root 路径,路径显示
User <- ThreadLocalMap$Entry.value <- ThreadLocalMap <- java.lang.Thread。 - 第二步查看具体的 ThreadLocal 实例——MAT 的”Path to GC Roots”中能看到 Thread 的 ThreadLocalMap 里有哪个 key 为 null(因为 ThreadLocal 被 GC 回收了)但其 value 还是强引用。
- 第三步定位业务线程——看 ThreadLocalMap 是从哪个 Thread 中出来的,线程名称通常包含了线程池名称(如
pool-3-thread-16),就能反查到是哪个线程池和哪个业务模块。 - 第四步代码层面——搜索全局所有使用
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 注入等)获取了代码执行能力,然后调用 ApplicationContext 或 ServletContext 反射构建一个新的 Filter 对象并注册到 FilterChain 中——之后所有 HTTP 请求都会经过这个恶意 Filter,攻击者可以执行任意命令。
Servlet 型内存马是通过 ServletContext.addServlet() 动态注册一个新的 Servlet 并映射 URL 路径。
两者区别:Filter 对所有请求都生效(基于 /* 映射),更隐蔽;Servlet 只在特定路径生效,相对容易被发现。Filter 型的注入点通常是 Spring 的 ApplicationContext 或嵌入式 Tomcat 的 StandardContext。
追问2: 如何从架构层面防御内存马的注入?
防御内存马从以下几个方面入手:
- 第一是入口防御——RASP(Runtime Application Self-Protection)在 JVM 层面监控敏感 API(如
defineClass、addFilter、反射获取敏感类加载器),阻断异常的动态注册操作。 - 第二是容器加固——禁用不必要的 Actuator 端点、严格限制 JMX 端口、用 Security Manager 限制 JVM 级别的操作(虽然现在很少用 SM)。
- 第三是依赖安全——定期检查依赖库中已知的反序列化漏洞(Fastjson、Jackson、Xstream 等)。
- 第四是运行态监控——通过 Java Agent 或 JVMTI 在关键 API 上插桩,检测到异常的类加载或 Filter/Servlet 动态注册行为时告警。
- 第五是基线建设——每次发布后产出当前应用注册的 Filter/Servlet 列表作为基线,后续异常过滤器和 Servlet 的注册就很容易对比发现。
📌 易错点 / 加分项:
- 内存马的根因是代码执行权限过大,不是”如何清除马”,而是”为什么马能注入”
5. 接口响应慢排查全流程
❓ 题目: 线上某个核心接口 P99 突然从 50ms 飙升到 2s,你会按照什么思路排查?
💡 答案:
排查路线按”从外到内”逐层排查:
- 确认影响面:监控大盘看是单个接口慢还是全局慢。单个接口慢往下查接口,全局慢考虑系统资源或依赖问题。
- 依赖排查:看接口调用了哪些下游——数据库、Redis、MQ、其他微服务。用链路追踪看哪个 Span 耗时久。数据库慢查
SHOW PROCESSLIST看锁等待或慢查询。Redis 慢看 slowlog。 - 自身代码排查:用 Arthas 的
trace追踪方法调用链路——trace com.xxx.Controller.processOrder,直接看到每个子方法耗时占比。加'#cost > 100'过滤耗时 > 100ms 的调用。 - GC 检查:
jstat -gcutil <pid> 1000看 FGC 是否频繁。接口慢伴随 GC STW 频繁说明可能是 GC 停顿导致。 - 系统资源:CPU 打满用
top -H -p <pid>找高 CPU 线程 →jstack。内存不足导致频繁 swap 用free -m。 - 流量变化:是否秒杀/促销导致流量突增,线程池和连接池被打满。观察 HikariCP 连接池使用率、Tomcat 线程池活跃数。
📌 易错点 / 加分项:
- 不要上来就重启——先保留现场(jstack、jmap、Arthas trace),重启后证据全丢
- 同时拉两组对比数据——慢的时候和正常时候的系统指标对比
- 链路追踪的 Span 采样率可能限制排查——先临时调到 100% 采样再复现
6. 数据库死锁排查
❓ 题目: 线上报出 “Deadlock found when trying to get lock” 错误,你怎么排查和解决?
💡 答案:
排查步骤:
- 定位死锁日志:
SHOW ENGINE INNODB STATUS输出中的LATEST DETECTED DEADLOCK段落,包含死锁涉及的两个事务、各自的 SQL、持有的锁和等待的锁。 - 反查业务代码:根据日志中的 SQL 反查是哪个业务接口,确认两个事务操作的表和索引情况——死锁几乎都发生在索引上。
- 分析死锁原因:最常见模式——两个事务以相反顺序更新相同行。另一种——间隙锁冲突,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 等。
排查思路:
jcmd <pid> VM.native_memory summary看 Native Memory Tracker 各部分分布(需-XX:NativeMemoryTracking=detail)。- 重点看”Thread”区域(线程栈)和”Internal”区域(直接内存)。Thread 大说明线程数过多,Internal 大排查 Netty/DirectByteBuffer 泄漏。
jstat -gc <pid>看 Metaspace 使用量(MU/MC 列),对比 MaxMetaspaceSize。- 容器内
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. 热点账户/热点库存问题排查
❓ 题目: 秒杀或高并发转账场景下,某一个账号成为热点,导致数据库行锁等待严重,你怎么排查和解决?
💡 答案:
热点账户问题的本质是单行锁竞争——所有并发操作都在同一行上争抢行锁,数据库连接全部排队。
排查方式:
SHOW PROCESSLIST看到大量连接状态为 “updating”,且 Info 列是相同 SQL(或操作同一行的不同 SQL),确认是行锁热点。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%),但在安全敏感的场景下是可以接受的