实操/排查题 面试题


1. 线上 CPU 100% 排查实战

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

追问1:如果发现是 GC 导致的 CPU 飙高,你如何进一步分析是哪些对象导致的? 追问2:top 显示 CPU 100%,但 jstack 发现所有线程都是 WAITING 状态,这是什么情况?

💡 答案:

主问题: 排查路线分五步。第一步:用 top -chtop 找到 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 飙高通常是因为 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: 这种情况通常不是死锁——是进程中有大量线程等待某种 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 宕机,你如何定位是哪里导致了内存溢出?

追问1:如果发现是某个线程的 ThreadLocal 没有清理导致的内存泄漏,你会怎么定位到是哪个业务线程? 追问2:一个应用堆内存不大但系统整体内存(RES)很大,top 看虚拟内存很高,什么原因?

💡 答案:

主问题: 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 内存泄漏的定位过程:第一步 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 很大,常见几个原因。第一是直接内存(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 个服务,怎么快速定位问题?

追问1:TraceId 的生成和传递是怎么实现的?服务 A 如果调用服务 B 用的是 MQ 异步消息,TraceId 怎么传过去? 追问2:日志量大到一定程度,存储和分析怎么优化?采样策略和冷热分离怎么做?

💡 答案:

主问题: 微服务日志和链路追踪的核心是”将同一条请求在各个服务中的日志串联起来”。需要三个基础设施:日志收集(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: 跨 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),无文件落地,如何排查和修复?

追问1:Filter 型内存马和 Servlet 型内存马在注入原理上有什么不同? 追问2:如何从架构层面防御内存马的注入?

💡 答案:

主问题: 内存马的核心特点是”只存在于内存中、无磁盘文件”。排查内存马需要几个步骤:一是通过 jps 获取可疑进程 PID;二是用 jmap -histo <pid> 看 Filter/Servlet 相关类的实例数量是否异常——比如 ApplicationFilterChain 或自定义 Filter 实现类的实例数量远多于正常数量;三是排查关键的 JVM MBean——Filter、Servlet 的注册信息在 JMX 中可查。更直接的方式是用 Arthas 等工具——用 sc *.filter.* 搜索所有 Filter 类,检查是否有异常的类名(如 ShellFilter、随机字符串命名的类)。修复措施:首先隔离机器(从负载均衡摘除)防止横向扩散,然后提取 JVM 内存 dump 给安全团队分析具体的注入 payload 和利用链,最后重启服务清除内存马。注意:重启后日志中出现的异常堆栈(如反序列化异常、框架注入点)可能是内存马注入的入口,从中排查漏洞点。

追问1: 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 的注册就很容易对比发现。

📌 易错点 / 加分项:

  • 内存马的根因是代码执行权限过大,不是”如何清除马”,而是”为什么马能注入”
  • 不要只关注 Fastjson——Log4j JNDI、Spring Cloud Gateway SpEL 注入也是一类高发入口
  • RASP 有性能损耗(通常 5-10%),但在安全敏感的场景下是可以接受的