JVM内存划分

1. JVM运行时数据区域

堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器

JVM运行时数据区域

1.1 Heap(堆)

对象的实例以及数组的内存都在堆上进行分配,堆是线程共享的区域,用来存放对象实例,也是垃圾回收(GC)的主要区域。开启逃逸分析后,某些未逃逸的对象可以通过标量替换的方式在栈中分配。

堆细分:

  • 新生代、老年代
  • 新生代分为:Eden区Survivor1区Survivor2区

1.2 方法区(元空间)

JVM的方法区也称为永久区,存储已被Java虚拟机加载的类信息、常量、静态变量。JDK 1.8以后取消了方法区概念,称之为元空间(MetaSpace)。

当应用中的Java类过多时(如Spring等使用动态代理的框架生成很多类),如果占用空间超出设定值,会发生元空间溢出

1.3 虚拟机栈

虚拟机栈是线程私有的,其生命周期与线程生命周期一致。每个方法执行时都会创建一个栈帧,栈帧中存放:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 返回地址

异常状况

  • 线程请求栈深度大于虚拟机允许深度:抛出 StackOverflowError
  • 虚拟机栈动态扩展时无法申请足够内存:抛出 OutOfMemoryError

1.3.1 局部变量表

一组变量值存储空间,用来存放方法参数、方法内部定义的局部变量,底层是变量槽(variable slot)

1.3.2 操作数栈

记录方法执行过程中字节码指令向操作数栈进行入栈和出栈的过程。大小在编译时确定,方法开始执行时操作数栈为空,执行过程中各种字节码指令往操作数栈中入栈和出栈

1.3.3 动态链接

字节码文件中的符号引用:

  • 一部分在类加载的解析阶段第一次使用时转化成直接引用(静态解析)
  • 另一部分在运行期间转化为直接引用(动态链接)

1.3.4 返回地址(returnAddress)

指向字节码指令地址的类型。

1.4 JIT即时编译器

Just In Time Compiler,简称JIT编译器。

为了提高热点代码执行效率,在运行时将代码编译成与本地平台相关的机器码,并进行各种层次的优化(如锁粗化等)。

1.5 本地方法栈

本地方法栈和虚拟机栈类似,不同的是虚拟机栈服务Java方法,而本地方法栈服务Native方法

在HotSpot虚拟机实现中,本地方法栈和虚拟机栈合二为一,同样会抛出StackOverflowErrorOOM异常。

1.6 PC程序计数器

Program Counter,存放下一条指令位置的指针。是较小的内存空间,线程私有

由于线程切换,CPU需要记住原线程下一条指令的位置,所以每个线程都需要自己的PC。

2. 堆内存分配策略

堆内存分配策略

2.1 分配规则

  1. 对象优先在Eden区分配

    • Eden区空间不足时,虚拟机执行Minor GC
    • 存活对象进入Survivor的From区(From区内存不足时,直接进入Old区)
  2. 大对象直接进入老年代

    • 需要大量连续内存空间的对象
    • 目的是避免在Eden区和两个Survivor区之间发生大量内存拷贝
  3. 长期存活对象进入老年代

    • 虚拟机为每个对象定义年龄(Age Count)计数器
    • 每经过一次Minor GC,年龄加1
    • 达到阈值(默认15次)时进入老年代
  4. Full GC触发条件

    • Minor GC或大对象进入老年代时,计算所需空间
    • 如小于老年代剩余空间大小,执行Full GC

2.2 动态对象年龄判定

程序从年龄最小的对象开始累加,如果累加对象大小大于Survivor区的一半,则将当前对象的age作为新阈值,年龄大于此阈值的对象直接进入老年代。

3. 创建对象步骤

流程:类加载检查 → 分配内存 → 初始化零值 → 设置对象头 → 执行init方法

3.1 类加载检查

虚拟机遇到 new 指令时,首先检查常量池中是否能定位到类的符号引用,并检查该类是否已被加载、解析和初始化过。如未执行过,必须先执行相应的类加载过程。

3.2 分配内存

类加载检查通过后,虚拟机为新对象分配内存。分配方式有:

  • 指针碰撞:当Java堆规整时使用
  • 空闲列表:当Java堆不规整时使用

分配方式的选择由Java堆是否规整决定,而Java堆规整与否由垃圾收集器是否带有压缩整理功能决定。

3.3 初始化零值

内存分配完成后,虚拟机将分配到的内存空间初始化为零值。这一步保证了对象实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到字段数据类型对应的零值。

3.4 设置对象头

初始化零值完成后,虚拟机对对象进行必要设置:

  • 类的实例信息
  • 元数据信息查找方式
  • 对象哈希码
  • 对象GC分代年龄
  • 对象头中根据虚拟机当前运行状态(如是否启用偏向锁)的不同设置

3.5 执行init方法

从虚拟机视角看,新对象已经产生,但从Java程序视角看,方法还未执行,所有字段都为零。除循环依赖外,执行new指令后会接着执行init方法,这样才产生真正可用的对象。

4. 对象引用类型

4.1 强引用(Strong Reference)

普通的对象引用关系就是强引用。如 Object obj = new Object(),只要强引用存在,垃圾回收器就不会回收被引用的对象。

4.2 软引用(Soft Reference)

用于维护可有可无的对象。只有在内存不足时,系统才会回收软引用对象。如果回收软引用对象后仍无足够内存,才抛出内存溢出异常。

4.3 弱引用(Weak Reference)

相比软引用更无用,拥有更短生命周期。当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。

4.4 虚引用(Phantom Reference)

形同虚设的引用,在现实场景中使用不多,主要用来跟踪对象被垃圾回收的活动。

JVM类加载过程

加载流程:加载 → 验证 → 准备 → 解析 → 初始化

JVM类加载过程

1. 加载阶段

  1. 通过类的全限定名获取定义此类的二进制字节流
  2. 将字节流代表的静态存储结构转化为方法区的运行时数据结构
  3. 在Java堆中生成代表这个类的java.lang.Class对象,作为方法区数据的访问入口

2. 验证阶段

验证字节码文件的安全性和正确性:

  • 文件格式验证:是否符合Class文件格式规范,能否被当前版本虚拟机处理
  • 元数据验证:字节码描述信息的语义分析,确保符合Java语言规范要求
  • 字节码验证:保证方法运行时不会做出危害虚拟机安全的行为
  • 符号引用验证:虚拟机将符号引用转化为直接引用时发生

3. 准备阶段

正式为类变量分配内存并设置类变量初始值的阶段,将对象初始化为零值。

4. 解析阶段

虚拟机将常量池内的符号引用替换为直接引用的过程。

常量池对比:

  • 字符串常量池:位于堆上,默认class文件的静态常量池
  • 运行时常量池:位于方法区,属于元空间

5. 初始化阶段

加载过程的最后一步,真正开始执行类中定义的Java程序代码。

6. 双亲委派机制

每个类都有对应的类加载器。系统中的ClassLoader协同工作时默认使用双亲委派模型

  1. 系统首先判断当前类是否已被加载过
  2. 已加载的类直接返回,否则尝试加载
  3. 将请求委派给父类加载器的loadClass()处理
  4. 所有请求最终传送到顶层的启动类加载器BootstrapClassLoader
  5. 父类加载器无法处理时,才由自己处理
  6. 父类加载器为null时,使用BootstrapClassLoader作为父类加载器

双亲委派机制

6.1 使用好处

  • 保证JDK核心类的优先加载
  • 避免类的重复加载,确保Java程序稳定运行
  • 保证Java核心API不被篡改

如果不使用双亲委派模型,每个类加载器加载自己的类,会导致如编写java.lang.Object类时出现多个不同的Object类。

6.2 破坏双亲委派机制

  • 自定义类加载器,重写loadClass方法
  • Tomcat加载自己目录下的class文件,不传递给父类加载器
  • Java的SPI:BootstrapClassLoader已是最上层,直接获取AppClassLoader进行驱动加载

7. Tomcat类加载机制

Tomcat的WebAppClassLoader故意打破JVM双亲委派机制,加载步骤

  1. 本地缓存检查:查找Tomcat是否已加载该类
  2. 系统类加载器缓存:从系统类加载器的cache中查找
  3. ExtClassLoader加载:绕开AppClassLoader,直接使用ExtClassLoader
    • ExtClassLoader的ExtClassLoader → Bootstrap ClassLoader链保证核心类不被重复加载
    • 如加载Object类:WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader
  4. findClass方法:BootstrapClassLoader加载失败时,调用自己的findClass方法
  5. AppClassLoader加载:加载失败时才使用AppClassLoader
  6. 异常抛出:所有加载器都失败时抛出异常

关键点:WebAppClassLoader绕过AppClassLoader,直接使用ExtClassLoader加载类。

JVM垃圾回收

1. 存活算法和两次标记过程

1.1 引用计数法

给对象添加引用计数器,引用时加1,失效时减1,计数器为0的对象不可用。

优点:实现简单,判定效率高
缺点:无法解决对象间相互循环引用问题,基本被抛弃

1.2 可达性分析法

以”GC Roots”(活动线程相关引用、虚拟机栈帧引用、静态变量引用、JNI引用)作为起始点,从这些节点开始向下搜索,搜索路径形成引用链。对象到GC Roots无引用链相连时证明对象不可用。

1.3 两次标记过程

对象被回收前,finalize()方法会被调用:

  1. 第一次标记:标记不在”关系网”中的对象
  2. 第二次标记
    • 对象未实现finalize():直接标记为可回收
    • 对象实现finalize():放入队列,由低优先级线程执行
    • 第二次小规模标记后,对象被真正回收

2. 垃圾回收算法

垃圾回收算法:复制算法、标记清除、标记整理、分代收集

2.1 复制算法(年轻代)

将内存分为大小相同的两块,每次使用其中一块。内存使用完后,将存活对象复制到另一块,清理使用过的空间。

优点:实现简单,内存效率高,不易产生碎片
缺点:内存压缩一半,存活对象多时效率大大降低

2.2 标记清除算法(CMS)

标记所有需要回收的对象,标记完成后统一回收。

缺点:效率低,产生大量不连续碎片,需预留空间处理浮动垃圾

2.3 标记整理算法(老年代)

标记过程同标记清除算法,让所有存活对象向一端移动,清理端边界外内存。

优点:解决了碎片问题

2.4 分代收集算法

根据各年代特点选择合适算法:

  • 新生代:采用复制算法

    • 每次垃圾回收回收大部分对象,存活对象少
    • 划分为Eden空间和两个Survivor空间(From Space、To Space)
    • 使用Eden和一个Survivor,回收时复制存活对象到另一个Survivor
  • 老年代:对象存活率高,无额外分配担保空间

    • 选择”标记清除”或”标记整理”算法

2.5 Safepoint

发生GC时,用户线程必须全部停下进行垃圾回收,此时JVM处于安全状态。如果有线程迟迟无法进入safepoint,整个JVM等待该阻塞线程,造成GC时间变长。

垃圾回收算法

2.6 MinorGC、MajorGC、FullGC

MinorGC:年轻代空间不足时发生

MajorGC:老年代的GC,通常伴随MinorGC发生

FullGC触发条件

  1. 老年代无法再分配内存
  2. 元空间不足
  3. 显式调用System.gc()
  4. CMS垃圾回收器出现promotion failure

2.7 分配策略

  • 对象优先在Eden区分配:大多数对象在新生代Eden区分配,空间不足时发起Minor GC
  • 大对象直接进入老年代:需要连续内存空间的对象(如长字符串、数组),避免在Eden区和Survivor区间大量内存复制
  • 长期存活对象进入老年代
    • Eden区出生 → Survivor区
    • 每次Minor GC年龄+1,达到阈值(默认15次)进入老年代
  • 动态对象年龄判定:Survivor区相同年龄对象空间总和大于Survivor区一半时,该年龄及以上对象直接进入老年代
  • 空间分配担保:Minor GC前检查老年代最大可用连续空间是否大于新生代所有对象空间总和

3. 垃圾收集器

垃圾收集器演进

3.1 JDK1.3-1.4: Serial、ParNew(关注效率)

3.1.1 Serial

单线程收集器,只使用一个CPU或一条线程完成垃圾收集,必须暂停所有工作线程。适合客户端应用。

3.1.2 ParNew

Serial的多线程版本,使用复制算法,除多线程外行为与Serial完全相同,同样需要暂停所有工作线程。

3.2 JDK1.5: Parallel Scavenge + Serial Old/Parallel Old(关注吞吐量)

3.2.1 Parallel Scavenge(新生代)

关注吞吐量(高效率利用CPU),主要用于后台运算任务,不需太多交互。

3.2.2 Serial Old(老年代)

Serial的老年代版本,单线程收集器,使用标记-整理算法:

  • JDK1.5前与Parallel Scavenge搭配使用
  • CMS收集器的后备垃圾收集方案

3.2.3 Parallel Old(老年代)

Parallel Scavenge的老年代版本,使用多线程和标记-整理算法。

3.3 JDK1.8: CMS(关注最短垃圾回收停顿时间)

CMS是年老代垃圾收集器,目标获取最短垃圾回收停顿时间,使用多线程标记-清除算法。

3.3.1 工作阶段

  1. 初始标记:标记GC Roots直接关联的对象(STW,速度快)
  2. 并发标记:ReferenceChains跟踪过程,与用户线程并发工作
  3. 重新标记:修正并发标记期间标记变动(STW)
  4. 并发清除:清除GC Roots不可达对象,与用户线程并发工作

优点:并发收集、低停顿
缺点:对CPU敏感;无法处理浮动垃圾;产生大量空间碎片

3.4 JDK9+: G1(精准控制停顿时间,避免垃圾碎片)

面向服务器的垃圾收集器,针对多处理器及大容量内存机器。

3.4.1 核心改进

  • 基于标记-整理算法,不产生内存碎片
  • 精确控制停顿时间,低停顿垃圾回收

3.4.2 区域化回收

将堆内存划分为大小固定的独立区域,维护优先级列表,优先回收垃圾最多的区域。

3.4.3 工作阶段

  1. 初始标记:STW,使用一条初始标记线程
  2. 并发标记:与用户线程并发执行可达性分析
  3. 最终标记:STW,多条标记线程并发执行
  4. 筛选回收:STW,多条筛选回收线程并发执行

3.5 JDK11+: ZGC(超大堆内存,最小停顿时间)

3.5.1 技术特点

  • 着色指针技术: 加快标记过程
  • 读屏障: 解决GC和应用并发导致的STW问题

3.5.2 性能指标

  • 支持TB级堆内存(最大4T,JDK13最大16TB)
  • 最大GC停顿10ms
  • 对吞吐量影响不超过15%

4. 配置垃圾收集器

  • 设置内存区域上限避免溢出问题(如元空间)
  • 堆空间设置为操作系统的2/3,超过8GB优先选用G1
  • 根据老年代对象提升速度调整年轻代和老年代比例
  • 根据系统容量、访问延迟、吞吐量进行专项优化
  • 记录详细GC日志,使用GCeasy等工具定位问题

5. JVM性能调优

对应进程的JVM状态以定位问题和解决问题并作出相应的优化。

常用命令: jps、jinfo、jstat、jstack、jmap

5.1 jps:查看java进程及相关信息

jps -l     # 输出jar包路径,类全名
jps -m     # 输出main参数
jps -v     # 输出JVM参数

5.2 jinfo:查看JVM参数

jinfo 11666
jinfo -flags 11666
# 参数:Xmx、Xms、Xmn、MetaspaceSize

5.3 jstat:查看JVM运行时的状态信息,包括内存状态、垃圾回收

jstat [option] LVMID [interval] [count]

# 参数说明:
# -gc       垃圾回收堆的行为统计
# -gccapacity 各个垃圾回收代容量和空间统计
# -gcutil   垃圾回收统计概述
# -gcnew    新生代行为统计
# -gcold    年老代和永生代行为统计

5.4 jstack:查看JVM线程快照,定位线程卡顿原因

jstack [-l] <pid>

# 参数说明:
# -F    强制输出线程堆栈
# -m    同时输出java和本地堆栈
# -l    额外显示锁信息

5.5 jmap:查看内存信息

jmap [option] <pid>

# 参数说明:
# -heap           打印java heap摘要
# -dump:<options> 生成java堆的dump文件

6. JDK新特性

6.1 JDK8

  • 支持Lambda表达式
  • 集合的stream操作
  • 提升HashMap性能

6.2 JDK9

// Stream API中iterate方法的新重载方法
IntStream.iterate(1, i -> i < 100, i -> i + 1)
          .forEach(System.out::println);
  • 默认G1垃圾回收器

6.3 JDK10

  • 完全GC并行改善G1最坏情况等待时间

6.4 JDK11

  • ZGC(并发回收策略)4TB
  • Lambda参数的局部变量语法

6.5 JDK12

  • Shenandoah GC(GC算法)停顿时间与堆大小无关,并行关注停顿响应时间

6.6 JDK13

  • ZGC将未使用的堆内存返回给操作系统,16TB

6.7 JDK14

  • 删除CMS垃圾回收器
  • 弃用ParallelScavenge+SerialOldGC垃圾回收算法组合
  • ZGC应用到macOS和windows平台

线上故障排查

1. 硬件故障排查

如果实例出现问题,根据情况决定是否重启。出现CPU、内存飙高或OOM异常时:

故障处理步骤:隔离 → 保留现场 → 问题排查

1.1 隔离

将机器从请求列表中摘除,如设置nginx权重为0。

1.2 现场保留

瞬时态和历史态
故障排查流程

查看CPU、系统内存等,通过历史状态体现趋势性问题,依靠监控系统协作获取信息。

1.3 保留信息

(1)系统当前网络连接

ss -antp > $DUMP_DIR/ss.dump 2>&1

使用ss命令而非netstat,因为netstat在网络连接多时执行缓慢。可排查TIME_WAIT或CLOSE_WAIT等连接问题。

(2)网络状态统计

netstat -s > $DUMP_DIR/netstat-s.dump 2>&1
sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1

按协议统计输出,把握网络状态。高速模块(Redis、Kafka)容易跑满网卡,表现为网络通信缓慢。

(3)进程资源

lsof -p $PID > $DUMP_DIR/lsof-$PID.dump

查看进程打开的文件、资源使用情况、网络连接等。命令输出较慢时需耐心等待。

(4)CPU资源

mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL > $DUMP_DIR/sar-cpu.dump 2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1

输出系统CPU和负载,便于事后排查。

(5)I/O资源

iostat -x > $DUMP_DIR/iostat.dump 2>&1

输出磁盘基本性能信息,排查I/O问题。日志输出过多或磁盘问题时会异常。

(6)内存问题

free -h > $DUMP_DIR/free.dump 2>&1

展现操作系统内存概况,排查SWAP影响GC、SLAB区挤占JVM内存等问题。

(7)其他全局信息

ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1

dmesg包含死掉服务的最后线索,ps影响系统和JVM的内核配置参数。

(8)进程快照(jinfo)

jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1

输出Java基本进程信息,包括环境变量和参数配置,查看配置错误造成的JVM问题。

(9)dump堆信息

jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1

输出当前GC信息,大体判断问题,必要时借助jmap分析。

(10)堆信息

jmap $PID > $DUMP_DIR/jmap.dump 2>&1
jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null 2>&1

获取Java进程dump信息,第4个命令最有价值但文件大,需下载导入MAT等工具深入分析。

(11)JVM执行栈

jstack $PID > $DUMP_DIR/jstack.dump 2>&1
top -Hp $PID -b -n 1 -c > $DUMP_DIR/top-$PID.dump 2>&1

获取执行栈信息,还原Java进程线程情况,获取进程中所有线程的CPU信息。

(12)高级替补方案

kill -3 $PID              # jstack的替补方案
gcore -o $DUMP_DIR/core $PID  # 生成core文件
jhsdb jmap --exe ${JDK}java --core $DUMP_DIR/core --binaryheap  # 处理jmap问题

1.4 内存泄漏现象

JDK9后jmap被jhsdb取代:

jhsdb jmap --heap --pid 37340
jhsdb jmap --pid 37288
jhsdb jmap --histo --pid 37340
jhsdb jmap --binaryheap --pid 37340

内存溢出表现为Old区占用持续上升,多轮GC无改善。ThreadLocal中的GC Roots,内存泄漏根本原因是对象未切断与GC Roots的联系。

2. 报表异常 | JVM调优

2.1 问题背景

报表系统频繁发生内存溢出,高峰期频繁拒绝服务。结果集字段不全,通过HttpClient循环调用其他服务接口填充数据,使用Guava做JVM内缓存但响应时间长。

2.2 初步排查

JVM资源不足:

  • 报表计算涉及几百兆内存,驻留时间长
  • 计算耗CPU,分配3GB内存不足,多人访问发生OOM
  • 升级为4C8G机器,JVM分配6GB内存,OOM消失
  • 但带来频繁GC问题和超长GC时间(平均5秒)

2.3 进一步优化

报表系统对象存活时间长,不能仅通过增加年轻代解决;增加年轻代减少老年代,CMS碎片和浮动垃圾问题导致可用空间减少。

2.3.1 第一步:调整MaxTenuringThreshold

通过分析GC日志对象年龄分布,调整参数到3。让年轻代对象尽快晋升老年代,减少MinorGC次数。

2.3.2 第二步:启用CMSScavengeBeforeRemark

CMS remark前先执行Minor GC清空新生代,配合上一步参数,对象快速晋升老年代,年轻代对象有限,MajorGC时间占用有限。

2.3.3 第三步:启用ParallelRefProcEnabled

大量弱引用GC处理时间长(10秒GC中4.5秒处理weak refs),并行处理Reference加快处理速度。

2.4 再次升级机器

效果不明显,改用8core16g机器,但带来新问题:

  • 高性能机器带来大服务吞吐量
  • 年轻代分配速率明显提高
  • MinorGC时长不可控(有时超过1秒)
  • 堆空间加大造成回收时间加长

2.5 改用G1垃圾回收器

  • 目标停顿时间200ms
  • G1适合大堆内存应用,简化调优工作
  • 通过初始堆空间、最大堆空间、最大GC暂停目标获得不错性能
  • GC更频繁但停顿时间小,应用运行平滑

2.6 代码优化

要求支持未来两年业务10到100倍增长,使用MAT分析堆快照优化代码:

  1. 查询优化:select *全量排查,只允许获取必须数据
  2. 缓存优化:Guava Cache命中率不高,改为弱引用(WeakKeys)
  3. 请求优化:限制导入文件大小,拆分超大范围查询导出请求

一系列优化后,性能提升数倍。

3. 大屏异常 | JUC调优

3.1 问题场景

JUC调优场景

数据通过HttpClient获取补全,部分服务提供商响应时间长,造成服务整体阻塞。

3.2 问题分析

  • 接口A:HttpClient访问服务2,响应100ms
  • 接口B:HttpClient访问服务3,耗时2秒
  • HttpClient有最大连接数限制,服务3迟迟不返回导致连接数达上限
  • 总结:同一服务中,一个耗时长的接口导致整体服务不可用

3.3 现象分析

jstack栈信息显示大多数线程阻塞在接口A而非接口B,具有迷惑性。原因是接口A速度快,问题发生点进入更多请求,全部阻塞并被打印。

3.4 问题验证

搭建demo工程模拟两个使用同一HttpClient的接口:

  • fast接口:访问百度,快速返回
  • slow接口:访问谷歌,阻塞超时约10秒

使用ab压测+jstack dump堆栈:

  • 过滤nio关键字,发现200个tomcat相关线程(Spring Boot默认maxThreads)
  • 大多数线程处于BLOCKED状态,说明线程等待资源超时
  • 200个中有150个是blocked的fast进程

3.5 解决方案

  1. fast和slow争抢连接资源,通过线程池限流或熔断处理
  2. slow线程不总是slow,加入监控
  3. 使用CountDownLatch控制线程执行顺序逻辑

4. 接口延迟 | SWAP调优

4.1 问题现象

服务实例经常卡顿,高并发情况下每停顿1秒,几万用户请求感到延迟。

4.2 问题排查

对比其他实例CPU、内存、网络、I/O资源,区别不大,怀疑硬件问题。

对比GC日志发现:

  • Minor GC和Major GC时间比其他实例长得多
  • GC发生时,vmstat的si、so飙升严重
  • free命令确认SWAP分区使用比例非常高

4.3 根本原因

详细分析/proc/meminfo内存分布,slabtop命令显示异常,dentry(目录高速缓冲)占用非常高。

问题定位:运维工程师删除日志时执行命令:

find / | grep "xxx.log"

寻找删除日志文件位置,老服务器文件太多,扫描后文件信息缓存到slab区。服务器开启swap,物理内存占满后未立即释放cache,导致每次GC都要和硬盘交互。

4.4 解决方案

关闭SWAP分区

swap是性能场景的万恶之源,建议禁用。高并发场景下SWAP让人体验其”魔鬼性”一面:进程不死,但GC时间长让人无法忍受。

5. 内存溢出 | Cache调优

5.1 案例分析

线上故障,重新启动后使用jstat发现Old区持续增长。使用jmap导出堆栈,MAT分析发现巨大HashMap对象(同事做缓存用),无界缓存无超时或LRU策略,未重写key的hashCode和equals方法,对象无法取出导致堆内存持续上升。改用Guava Cache并设置弱引用后故障消失。

文件处理器应用读取写入文件异常时,close方法未放在finally块,造成文件句柄泄漏,产生严重内存泄漏。

5.2 概念区分

  • 内存溢出:结果,内存空间不足、配置错误等因素
  • 内存泄漏:原因,错误编程方式导致对象未被回收、未及时切断与GC Roots的联系

5.3 常见内存泄漏示例

5.3.1 HashMap缓存泄漏

HashMap做缓存但无超时或LRU策略,造成数据越来越多。

5.3.2 自定义对象Key泄漏

未重写Key类的hashCode和equals方法,造成放入HashMap的所有对象都无法取出,与外界失联。

// leak example
import java.util.HashMap;
import java.util.Map;

public class HashMapLeakDemo {
    public static class Key {
        String title;
        public Key(String title) {
            this.title = title;
        }
    }
    
    public static void main(String[] args) {
        Map<Key, Integer> map = new HashMap<>();
        map.put(new Key("1"), 1);
        map.put(new Key("2"), 2);
        map.put(new Key("3"), 2);
        Integer integer = map.get(new Key("2"));
        System.out.println(integer);  // 输出:null
    }
}

即使提供equals和hashCode方法,也要小心,尽量避免使用自定义对象作为Key。

5.3.3 文件句柄泄漏

文件处理应用读取写入异常时,close方法未放在finally块,造成文件句柄泄漏。

6. CPU飙高 | 死循环

6.1 问题现象

线上应用运行一段时间后CPU飙升,怀疑业务逻辑计算量大或触发死循环(如HashMap高并发死循环),但排查后发现是GC问题。

6.2 排查步骤

  1. 查找CPU最多进程

    top  # 使用Shift+P按CPU使用率排序,记录pid
  2. 查找CPU最多线程

    top -Hp $PID  # 记录线程ID
  3. 转换线程ID进制

    printf %x $tid  # 十进制转十六进制
  4. 查看线程栈

    jstack $pid >$pid.log
  5. 查找问题线程

    less $pid.log  # 查找十六进制tid的线程上下文

6.3 问题根源

jstack日志搜索DEAD关键字找到CPU使用最多的线程id,问题是堆已满但未发生OOM,GC进程持续回收但效果一般,造成CPU升高应用假死。

需要dump内存,使用MAT等工具进一步分析具体原因。