JVM 内存模型概述
堆
堆是JVM内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Survivor和To Survivor组成。
在Java6版本中,永久代在非堆内存区;到了Java7版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了Java8,永久代被元空间取代了。 结构如下图所示:
程序计数器(Program Counter Register)
程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。
由于Java是多线程语言,当执行的线程数量超过CPU核数时,线程之间会根据时间片轮询争夺CPU资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的CPU资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
方法区(Method Area)
方法区主要是用来存放已被虚拟机加载的类相关信息 ,包括类信息
、运行时常量池
、字符串常量池
。
类信息又包括了类的版本、字段、方法、接口和父类等信息。
JVM在执行某个类的时候,必须经过加载 、链接 (包括验证
、准备
、解析
三个阶段)、初始化 。在加载类的时候,JVM会先加载class文件,而在class文件 中除了有类的版本、字段、方法和接口等描述信息
外,还有一项信息是常量池(Constant Pool Table)
,用于存放编译期间生成的各种字面量 和符号引用。
- 字面量包括字符串常量(String a="b")、声明为final的属性、以及一些基本类型的属性。
- 符号引用 则包括
类和方法的全限定名
(例如String这个类,它的全限定名就是Java/lang/String)、类引用、方法引用以及成员变量引用(例如String str="abc",其中str就是成员变量引用)等。
而当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)。
- 链接阶段
- 验证阶段的主要目的是对字节码字节流进行校验,判断其内容是否符合当前虚拟机的规范,以确保被加载的代码运行后不会对虚拟机造成损害。
- 准备阶段 主要是用于对类或接口中的 "静态变量" 分配内存空间,以及对变量设置默认的初始值。对于final static修饰的变量,直接赋值为用户的定义值。例如,
private final static int value=123
,会在准备阶段分配内存,并初始化值为123,而如果是private static int value=123
,这个阶段value的值仍然为0。- 解析:将符号引用转为直接引用的过程。我们知道,在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为JVM可以直接获取的内存地址或指针,即直接引用。
- 初始化阶段:为类的变量进行赋值。
例如,类中的一个字符串常量在class文件中时,存放在class文件常量池中的;在JVM加载完类之后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池只会存在一份。
知识小提示:Class.forName()
加载初始化,ClassLoader.loadClass()
只加载并不初始化。
方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
在HotSpot Java8虚拟机,静态变量和运行时常量池转移到了堆中(方法区),并用元空间(class metadata)存储类的元数据。
虚拟机栈(VM stack)
Java虚拟机栈是线程私有的内存空间,它和Java线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量
、操作数栈
、动态链接方法
和返回地址
等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作。
本地方法栈(Native Method Stack)
本地方法栈跟Java虚拟机栈的功能类似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用Java实现的,而是由C语言实现的。
JVM即时编译器JIT
Java 类初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。
字节码转成机器码,再执行。
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为"热点代码"。
为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。在发生调用时,会直接执行缓存的机器语言。
热点代码,直接执行机器码。
编译优化技术-方法内联
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
方法调用在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销。
JVM会自动识别热点方法,并对它们使用方法内联进行优化。但要强调一点,热点方法不一定会被JVM做内联优化,如果这个方法体太大了,JVM将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:
- 默认情况下,方法体大小小于325字节的都会进行内联,我们可以通过
-XX:MaxFreqInlineSize=N
来设置大小值; - 不经常执行的方法,默认情况下,方法大小小于35字节才会进行内联,我们也可以通过
-XX:MaxInlineSize=N
来重置大小值。
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
- 通过设置
JVM参数
来减小热点阈值 或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存 - 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
- 尽量使用
final、private、static
关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
编译优化技术-逃逸分析
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
栈上分配
在HotSpot中暂时没有实现这项优化。
在Java中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。
锁消除
StringBuffer中的append方法被Synchronized关键字修饰,会使用到锁,会导致性能下降。
但实际上,在以下代码测试中,StringBuffer和StringBuilder的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候JIT编译会对这个对象的方法锁进行锁消除。
java
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。
例如下面的代码
java
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something
}
逃逸分析后,代码会被优化为:
java
public void foo() {
id = 1;
count = 99;
...//to do something
}
可以通过设置JVM参数来开关逃逸分析、单独开关锁消除和标量替换:
bash
-XX:+DoEscapeAnalysis # 开启逃逸分析(jdk1.8默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis # 关闭逃逸分析
-XX:+EliminateLocks # 开启锁消除(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateLocks # 关闭锁消除
-XX:+EliminateAllocations # 开启标量替换(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateAllocations # 关闭标量替换
JVM 垃圾回收机制
垃圾回收算法
JDK1.7 update14 之后Hotspot虚拟机所有的回收器整理如下(以下为服务端垃圾收集器):
可以通过 jmap -heap <pid>
看到垃圾收集器类型。
bash
[localhost /]
$jmap -heap 1131
Attaching to process ID 1131, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.412-b0-internal
using thread-local object allocation.
Garbage-First (G1) GC with 4 thread(s)
Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 8589934592 (8192.0MB)
NewSize = 1363144 (1.2999954223632812MB)
MaxNewSize = 5152702464 (4914.0MB)
OldSize = 5452592 (5.1999969482421875MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 536870912 (512.0MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 2147483648 (2048.0MB)
G1HeapRegionSize = 2097152 (2.0MB)
Heap Usage:
G1 Heap:
regions = 4096
capacity = 8589934592 (8192.0MB)
used = 1019280184 (972.0613327026367MB)
free = 7570654408 (7219.938667297363MB)
11.865983065217733% used
G1 Young Generation:
Eden Space:
regions = 228
capacity = 3372220416 (3216.0MB)
used = 478150656 (456.0MB)
free = 2894069760 (2760.0MB)
14.17910447761194% used
Survivor Space:
regions = 5
capacity = 10485760 (10.0MB)
used = 10485760 (10.0MB)
free = 0 (0.0MB)
100.0% used
G1 Old Generation:
regions = 257
capacity = 1986002944 (1894.0MB)
used = 530643768 (506.0613327026367MB)
free = 1455359176 (1387.9386672973633MB)
26.719183352831926% used
39609 interned Strings occupying 4011032 bytes.
GC性能衡量指标
- 吞吐量:这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算GC的吞吐量:系统总运行时间=应用程序耗时+GC耗时。如果系统运行了100分钟,GC耗时1分钟,则系统吞吐量为99%。GC的吞吐量一般不能低于95%。
- 停顿时间:指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。
- 垃圾回收频率:多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
查看&分析GC日志
我们需要通过JVM参数预先设置GC日志,通常有以下几种JVM参数设置:
bash
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
一般是选择 -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log
这样的形式输出gc日志。
我们可以通过GCViewer工具打开日志文件,图形化界面查看整体的GC性能。
另外,GCeasy是一款非常直观的GC日志分析工具,我们可以将日志文件压缩之后,上传到GCeasy官网即可看到非常清楚的GC日志分析结果。
GC调优策略
-
降低Minor GC频率
通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此我们可以通过适当增大新生代空间来降低Minor GC的频率。
但是,如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,虽然降低了Minor GC的频率,但是会增加Minor GC的时间。
如果堆中的短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。因此,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。
-
降低Full GC的频率
减少创建大对象 ,可以降低Full GC的频率。例如,我之前碰到过一个一次性查询出60个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过Minor GC之后也会进入到老年代。这种大对象很容易产生较多的Full GC。
我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
可以通过参数
-XX:PetenureSizeThreshold
设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的好处就是可以减少新生代的垃圾回收。增大堆内存空间,在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低Full GC的频率。
-
选择合适的GC回收器
- 当我们对停顿时间 有要求,一般会选择响应速度较快的GC回收器,
CMS(Concurrent Mark Sweep)回收器
和G1回收器
都是不错的选择。 - 当我们的对系统吞吐量 有要求时,就可以选择
Parallel Scavenge
回收器来提高系统的吞吐量。
- 当我们对停顿时间 有要求,一般会选择响应速度较快的GC回收器,
优化JVM内存分配
JDK1.8是默认开启-XX:+UseAdaptiveSizePolicy
配置项的,它表示JVM将会动态调整Java堆中各个区域的大小以及进入老年代的年龄,--XX:NewRatio
和-XX:SurvivorRatio
将会失效。
在JDK1.8中,不要随便关闭UseAdaptiveSizePolicy
配置项,除非你已经对初始化堆内存/最大堆内存、年轻代/老年代以及Eden区/Survivor区有非常明确的规划了。JVM将会分配最小堆内存,年轻代和老年代按照默认比例1:2进行分配,年轻代中的Eden和Survivor则按照默认比例8:2进行分配。
现模拟一个接口,假设需要满足一个5W的并发请求,且每次请求会产生20KB对象,我们可以通过千级并发创建一个1MB对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下:
java
@RequestMapping(value = "/test1")
public String test1(HttpServletRequest request) {
List<Byte[]> temp = new ArrayList<Byte[]>();
Byte[] b = new Byte[1024*1024];
temp.add(b);
return "success";
}
使用 'apache-bench' 对系统进行压测。
可以看到,当并发数量到了一定值时,吞吐量就上不去了,响应时间也迅速增加。
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log
增加jvm参数,输出gc日志,并通过GCViewer工具打开它。
主页面显示FullGC发生了13次,右下角显示年轻代和老年代的内存使用率几乎达到了100%。而FullGC会导致stop-the-world的发生,从而严重影响到应用服务的性能。此时,我们需要调整堆内存的大小来减少FullGC的发生。
参考指标
- GC频率:高频的FullGC会给系统带来非常大的性能消耗,虽然MinorGC相对FullGC来说好了许多,但过多的MinorGC仍会给系统带来压力。
- 内存:这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加FullGC,严重的将导致CPU持续爆满,影响系统性能。
- 吞吐量:频繁的FullGC将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。
- 延时:JVM的GC持续时间也会影响到每次请求的响应时间。
具体调优方法
-
调整堆内存空间减少FullGC:通过日志分析,堆内存基本被用完了,而且存在大量FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。
java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar
(-Xms
:堆初始大小;-Xmx
:堆最大值),调大堆内存之后,我们再来测试下性能情况,发现吞吐量提高了40%左右,响应时间也降低了将近50%。再查看GC日志,发现FullGC频率降低了,老年代的使用率只有16%了。
-
调整年轻代减少MinorGC:通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些MinorGC。
java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar
,再进行压测,发现吞吐量上去了。再查看GC日志,发现MinorGC也明显降低了,GC花费的总时间也减少了。
-
设置Eden、Survivor区比例:在JVM中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor和 To Survivor区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候SurvivorRatio默认设置的比例会失效。
在JDK1.8中,默认是开启AdaptiveSizePolicy的,我们可以通过-XX:-UseAdaptiveSizePolicy关闭该项配置,或显示运行-XX:SurvivorRatio=8将Eden、Survivor的比例设置为8:2。大部分新对象都是在Eden区创建的,我们可以固定Eden区的占用比例,来调优JVM的内存分配性能。
再进行性能测试,我们可以看到吞吐量提升了,响应时间降低了。
内存持续上升,如何排查?
常量的监控和内存诊断工具
top命令
top
命令是我们在Linux下最常用的命令之一,它可以实时显示正在执行进程的CPU使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。
还可以通过top -Hp pid
查看具体线程使用系统资源情况:
vmstat命令
vmstat是一款指定采样周期和次数的功能性监测工具,不仅可以统计内存的使用情况,还可以观测到CPU的使用率、swap的使用情况。它经常被用来观察进程的上下文切换。
bash
[localhost /]
$vmstat 1 3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 873972 0 44484 0 0 13769 161061 0 1 0 0 99 0 0
0 0 0 874104 0 44484 0 0 292 21792 370586 343134 0 0 100 0 0
0 0 0 874108 0 44484 0 0 948 42760 452392 298924 0 0 100 0 0
- r:等待运行的进程数;
- b:处于非中断睡眠状态的进程数;
- swpd:虚拟内存使用情况;
- free:空闲的内存;
- buff:用来作为缓冲的内存数;
- si:从磁盘交换到内存的交换页数量;
- so:从内存交换到磁盘的交换页数量;
- bi:发送到块设备的块数;
- bo:从块设备接收到的块数;
- in:每秒中断数;
- cs :每秒上下文切换次数;
- us:用户CPU使用时间;
- sy:内核CPU系统使用时间;
- id:空闲时间;
- wa:等待I/O时间;
- st:运行虚拟机窃取的时间。
pidstat 命令
pidstat是Sysstat中的一个组件,也是一款功能强大的性能监测工具,可以通过命令:yum install sysstat
安装该监控组件。之前的top和vmstat两个命令都是监测进程的内存、CPU以及I/O使用情况,而pidstat命令则是深入到线程级别。
bash
[localhost /]
$sudo yum install sysstat
Loaded plugins: branch, fastestmirror, langpacks
alios.7u2.base.x86_64 | 2.4 kB 00:00:00
ops.7.noarch | 2.4 kB 00:00:00
ops.7.x86_64 | 2.4 kB 00:00:00
taobao.7.noarch.stable | 2.3 kB 00:00:00
taobao.7.x86_64.stable | 2.4 kB 00:00:00
(1/2): alios.7u2.base.x86_64/x86_64/primary_db | 7.8 MB 00:00:00
(2/2): ops.7.x86_64/7/x86_64/primary_db | 1.8 MB 00:00:00
Loading mirror speeds from cached hostfile
Resolving Dependencies
--> Running transaction check
---> Package sysstat.x86_64 0:10.1.5-7.1.alios7 will be updated
---> Package sysstat.x86_64 0:10.1.5-18.1.alios7 will be an update
--> Finished Dependency Resolution
...
Updated:
sysstat.x86_64 0:10.1.5-18.1.alios7
Complete!
通过pidstat -help命令,我们可以查看到有以下几个常用的参数来监测线程的性能:
bash
[localhost /]
$pidstat help
Usage: pidstat [ options ] [ <interval> [ <count> ] ]
Options are:
[ -d ] [ -h ] [ -I ] [ -l ] [ -r ] [ -s ] [ -t ] [ -U [ <username> ] ] [ -u ]
[ -V ] [ -w ] [ -C <command> ] [ -p { <pid> [,...] | SELF | ALL } ]
[ -T { TASK | CHILD | ALL } ]
常用参数:
-u
:默认的参数,显示各个进程的cpu使用情况;-r
:显示各个进程的内存使用情况;-d
:显示各个进程的I/O使用情况;-w
:显示每个进程的上下文切换情况;-p
:指定进程号;-t
:显示进程中线程的统计信息。
我们可以通过相关命令(例如ps或jps)查询到相关进程ID,再运行以下命令来监测该进程的内存使用情况,其中pidstat
的参数-p
用于指定进程ID,-r
表示监控内存的使用情况,1
表示每秒的意思,3
则表示采样次数。
bash
[localhost /]
$jps
35877 Jps
1149 jar
[localhost /]
$pidstat -p 1149 -r 1 3
Linux 4.19.91-009.ali4000.x86_64 12/16/2024 _x86_64_ (104 CPU)
05:15:12 PM UID PID minflt/s majflt/s VSZ RSS %MEM Command
05:15:13 PM 2347 1149 5226.00 0.00 26185424 11287740 89.71 java
05:15:14 PM 2347 1149 52.00 0.00 26185424 11287740 89.71 java
05:15:15 PM 2347 1149 80.00 0.00 26185424 11287740 89.71 java
Average: 2347 1149 1786.00 0.00 26185424 11287740 89.71 java
其中显示的几个关键指标的含义是:
Minflt/s
:任务每秒发生的次要错误,不需要从磁盘中加载页;Majflt/s
:任务每秒发生的主要错误,需要从磁盘中加载页;VSZ
:虚拟地址大小,虚拟内存使用KB;RSS
:常驻集合大小,非交换区内存使用KB。
如果我们需要继续查看该进程下的线程内存使用率,则在后面添加-t
指令即可:
bash
[localhost /]
$pidstat -p 1149 -r -t 1 3
Linux 4.19.91-009.x86_64 12/16/2024 _x86_64_ (104 CPU)
05:35:51 PM UID TGID TID minflt/s majflt/s VSZ RSS %MEM Command
05:35:52 PM 2347 1149 - 33.00 0.00 26185424 11289492 89.72 java
05:35:52 PM 2347 - 1149 0.00 0.00 26185424 11289492 89.72 |__java
05:35:52 PM 2347 - 1152 0.00 0.00 26185424 11289492 89.72 |__java
05:35:52 PM 2347 - 1154 0.00 0.00 26185424 11289492 89.72 |__Gang worker#0 (
05:35:52 PM 2347 - 1155 0.00 0.00 26185424 11289492 89.72 |__Gang worker#1 (
05:35:52 PM 2347 - 1156 0.00 0.00 26185424 11289492 89.72 |__Gang worker#2 (
05:35:52 PM 2347 - 1157 0.00 0.00 26185424 11289492 89.72 |__Gang worker#3 (
05:35:52 PM 2347 - 1158 0.00 0.00 26185424 11289492 89.72 |__G1 Concurrent R
05:35:52 PM 2347 - 1159 0.00 0.00 26185424 11289492 89.72 |__G1 Concurrent R
05:35:52 PM 2347 - 1160 0.00 0.00 26185424 11289492 89.72 |__G1 Concurrent R
05:35:52 PM 2347 - 1161 0.00 0.00 26185424 11289492 89.72 |__G1 Concurrent R
05:35:52 PM 2347 - 1162 0.00 0.00 26185424 11289492 89.72 |__G1 Concurrent R
05:35:52 PM 2347 - 1164 0.00 0.00 26185424 11289492 89.72 |__G1 Main Concurr
05:35:52 PM 2347 - 1165 0.00 0.00 26185424 11289492 89.72 |__Gang worker#0 (
05:35:52 PM 2347 - 1166 2.00 0.00 26185424 11289492 89.72 |__VM Thread
05:35:52 PM 2347 - 1167 0.00 0.00 26185424 11289492 89.72 |__Reference Handl
05:35:52 PM 2347 - 1168 0.00 0.00 26185424 11289492 89.72 |__Finalizer
......
jstat命令
jstat
可以监测Java应用程序的实时运行情况,包括堆内存信息以及垃圾回收信息。通过jstat -options
查看jstat有哪些操作:
bash
[localhost /]
$jstat -options
-class # 显示ClassLoad的相关信息
-compiler # 显示JIT编译的相关信息
-gc # 显示和gc相关的堆信息
-gccapacity # 显示各个代的容量以及使用情况
-gccause # 显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因
-gcmetacapacity # 显示Metaspace的大小
-gcnew # 显示新生代信息
-gcnewcapacity # 显示新生代大小和使用情况
-gcold # 显示老年代信息
-gcoldcapacity # 显示老年代的大小
-gcutil # 显示垃圾收集信息
-printcompilation # 输出JIT编译的方法信息
jstat
的功能比较多,在这里我例举一个常用功能,如何使用jstat查看堆内存的使用情况。我们可以用jstat -gc pid查看:
bash
[localhost /]
$jstat -gc 1149
S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT GCT
0.0 12288.0 0.0 12288.0 6152192.0 3325952.0 3620864.0 980306.8 144512.0 139704.4 2703 400.717 0 0.000 400.717
S0C
:年轻代中To Survivor的容量(单位KB);S1C
:年轻代中From Survivor的容量(单位KB);S0U
:年轻代中To Survivor目前已使用空间(单位KB);S1U
:年轻代中From Survivor目前已使用空间(单位KB);EC
:年轻代中Eden的容量(单位KB);EU
:年轻代中Eden目前已使用空间(单位KB);OC
:Old代的容量(单位KB);OU
:Old代目前已使用空间(单位KB);MC
:Metaspace的容量(单位KB);MU
:Metaspace目前已使用空间(单位KB);YGC
:从应用程序启动到采样时年轻代中gc次数;YGCT
:从应用程序启动到采样时年轻代中gc所用时间(s);FGC
:从应用程序启动到采样时old代(全gc)gc次数;FGCT
:从应用程序启动到采样时old代(全gc)gc所用时间(s);GCT
:从应用程序启动到采样时gc用的总时间(s)。
jstack命令
jstack
是一种线程堆栈分析工具,最常用的功能就是使用 jstack pid
命令查看线程的堆栈信息,通常会结合top -Hp pid
或 pidstat -p pid -t
一起查看具体线程的状态,也经常用来排查一些死锁的异常。
bash
[admin@nui-sus033004190120.pre.na610 /]
$jstack 1149
2024-12-16 18:36:24
Full thread dump OpenJDK 64-Bit Server VM (25.412-b0-internal mixed mode):
"http-nio-9666-exec-27" #35606 daemon prio=5 os_prio=0 tid=0x00007fda90395000 nid=0x31a24 waiting on condition [0x00007fd9bdffc000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park0(Native Method)
- parking to wait for <0x0000000544ef1d08> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at sun.misc.Unsafe.park(Unsafe.java:1038)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:216)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2087)
at java.util.concurrent.LinkedBlockingQueue.poll(LinkedBlockingQueue.java:471)
at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:90)
at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:33)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:879)
"http-nio-9666-exec-26" #35605 daemon prio=5 os_prio=0 tid=0x00007fda90394000 nid=0x31a23 waiting on condition [0x00007fd9be0fd000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park0(Native Method)
- parking to wait for <0x0000000544ef1d08> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at sun.misc.Unsafe.park(Unsafe.java:1038)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:216)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2087)
at java.util.concurrent.LinkedBlockingQueue.poll(LinkedBlockingQueue.java:471)
at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:90)
at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:33)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:879)
.......
每个线程堆栈的信息中,都可以查看到线程ID、线程的状态(wait、sleep、running 等状态)以及是否持有锁等。
nid=0x31a24
为线程ID的16进制值,java.lang.Thread.State
为线程状态
jmap命令
我们可以用jmap -heap <pid>
来查看堆内存初始化配置信息以及堆内存的使用情况:
bash
[localhost /]
$jmap -heap 1149
Attaching to process ID 1149, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.412-b0-internal
using thread-local object allocation.
Garbage-First (G1) GC with 4 thread(s)
Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 10737418240 (10240.0MB)
NewSize = 1363144 (1.2999954223632812MB)
MaxNewSize = 6442450944 (6144.0MB)
OldSize = 5452592 (5.1999969482421875MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 536870912 (512.0MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 2147483648 (2048.0MB)
G1HeapRegionSize = 2097152 (2.0MB)
Heap Usage:
G1 Heap:
regions = 5120
capacity = 10737418240 (10240.0MB)
used = 6167643792 (5881.923477172852MB)
free = 4569774448 (4358.076522827148MB)
57.44065895676613% used
G1 Young Generation:
Eden Space:
regions = 2349
capacity = 6299844608 (6008.0MB)
used = 4926210048 (4698.0MB)
free = 1373634560 (1310.0MB)
78.19573901464713% used
Survivor Space:
regions = 6
capacity = 12582912 (12.0MB)
used = 12582912 (12.0MB)
free = 0 (0.0MB)
100.0% used
G1 Old Generation:
regions = 814
capacity = 3707764736 (3536.0MB)
used = 1226753680 (1169.9234771728516MB)
free = 2481011056 (2366.0765228271484MB)
33.08607118701503% used
48518 interned Strings occupying 4906176 bytes.
我们还可以使用jmap -histo[:live] <pid>
查看堆内存中的对象数目、大小统计直方图,如果带上live
则只统计活对象:
bash
[admin@nui-sus033004190120.pre.na610 /]
$jmap -histo:live 1149
num #instances #bytes class name
----------------------------------------------
2: 2939598 94067136 java.util.HashMap$Node
3: 3396231 81509544 java.lang.String
4: 34451 27505272 [Ljava.util.HashMap$Node;
5: 262144 27262976 org.apache.logging.log4j.core.async.RingBufferLogEvent
6: 14796 20654880 [I
7: 262144 6291456 org.apache.logging.log4j.core.time.MutableInstant
8: 40716 6123728 [Ljava.lang.Object;
9: 11845 5259976 [B
10: 98521 3152672 java.util.concurrent.ConcurrentHashMap$Node
11: 25094 2773616 java.lang.Class
12: 929 2190336 [Ljava.util.WeakHashMap$Entry;
13: 35298 1694304 java.util.HashMap
14: 41675 1667000 java.lang.ref.Finalizer
15: 65654 1575696 java.util.ArrayList
16: 38729 1549160 java.util.WeakHashMap$Entry
17: 36604 1171328 java.lang.ref.WeakReference
18: 11786 1037168 java.lang.reflect.Method
19: 25180 1007200 java.util.LinkedHashMap$Entry
20: 20719 994512 com.sun.tools.javac.file.ZipFileIndex$Entry
21: 60637 970192 java.lang.Object
22: 994 866304 [Ljava.util.concurrent.ConcurrentHashMap$Node;
24: 936 614016 io.netty.util.internal.shaded.org.jctools.queues.MpscArrayQueue
26: 518 534456 [J
30: 7991 447496 java.util.LinkedHashMap
31: 2956 354720 org.springframework.boot.loader.jar.JarEntry
35: 6551 262040 java.lang.ref.SoftReference
36: 15879 254064 java.lang.Integer
......
我们可以通过jmap
命令把堆内存的使用情况dump到文件中:
bash
[localhost /]
$jmap -dump:format=b,file=/home/admin/heap.hprof 1149
Dumping heap to /home/admin/heap.hprof ...
Heap dump file created
我们可以将文件下载下来,使用 MAT工具打开文件进行分析:
实战演练
我们平时遇到的内存溢出问题一般分为两种,一种是由于大峰值下没有限流,瞬间创建大量对象而导致的内存溢出 ;另一种则是由于内存泄漏而导致的内存溢出。
使用限流,我们一般就可以解决第一种内存溢出问题,但其实很多时候,内存溢出往往是内存泄漏导致的,这种问题就是程序的BUG,我们需要及时找到问题代码。
下面模拟了一个内存泄漏导致的内存溢出案例,我们来实践一下。
ThreadLocal
的作用是提供线程的私有变量,这种变量可以在一个线程的整个生命周期中传递,可以减少一个线程在多个函数或类中创建公共变量来传递信息,避免了复杂度。但在使用时,如果ThreadLocal使用不恰当,就可能导致内存泄漏。
这个案例的场景就是ThreadLocal
,下面我们模拟对每个线程设置一个本地变量。运行以下代码,系统一会儿就发送了内存溢出异常:
bash
@RestController
public class TestController {
@RequestMapping(value = "/test0")
public String test0(HttpServletRequest request) {
ThreadLocal<Byte[]> localVariable = new ThreadLocal<Byte[]>();
localVariable.set(new Byte[4096*1024]);// 为线程添加变量
return "success";
}
}
在启动应用程序之前,我们添加HeapDumpOnOutOfMemoryError
和HeapDumpPath
这两个参数开启堆内存异常日志。
bash
java -jar -Xms1000m -Xmx4000m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heapTest.log JavaStudy-1.0-SNAPS
HOT.jar
通过 Jemeter 进行压测,触发接口报异常。
通过日志,我们很好分辨这是一个内存溢出异常。我们首先通过Linux系统命令查看进程在整个系统中内存的使用率是多少,最简单就是top
命令了。
再通过top -Hp pid
查看具体线程占用系统资源情况。
再通过jstack pid
查看具体线程的堆栈信息,可以发现该线程一直处于 RUNNABLE
状态,此时CPU使用率和负载并没有出现异常,我们可以排除死锁或I/O
阻塞的异常问题了。
我们再通过jmap
查看堆内存的使用情况,可以发现,老年代的使用率几乎快占满了,而且内存一直得不到释放:
通过以上堆内存的情况,我们基本可以判断系统发生了内存泄漏。下面我们就需要找到具体是什么对象一直无法回收,什么原因导致了内存泄漏。
我们需要查看具体的堆内存对象,看看是哪个对象占用了堆内存,可以通过jmap -histo:live pid
查看存活对象的数量:
Byte
对象占用内存明显异常,说明代码中Byte
对象存在内存泄漏,我们在启动时,已经设置了oom时的dump
文件,通过MAT打开dump
的内存日志文件,我们可以发现MAT已经提示了byte
内存异常:
再点击进入到Histogram
页面,可以查看到对象数量排序,我们可以看到Byte[]
数组排在了第一位,选中对象后右击选择with incomming reference
功能,可以查看到具体哪个对象引用了这个对象。
在这里我们就可以很明显地查看到是ThreadLoca
l这块的代码出现了问题。
在一些比较简单的业务场景下,排查系统性能问题相对来说简单,且容易找到具体原因。但在一些复杂的业务场景下,或是一些开源框架下的源码问题,相对来说就很难排查了,有时候通过工具只能猜测到可能是某些地方出现了问题,而实际排查则要结合源码做具体分析。