learn-tech/专栏/JVM核心技术32讲(完)/23内存分析与相关工具上篇(内存布局与分析工具).md
2024-10-16 00:01:16 +08:00

634 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
23 内存分析与相关工具上篇(内存布局与分析工具)
通过前面的课程,我们学习了“内存溢出”和“内存泄漏”的区别。
简单来说Java 中的内存溢出就是内存不够用,一般是堆内存报错,当然也可能是其他内存空间不足引起的。
下面我们详细讲解 Java 对象的内存相关知识。
Java 对象内存布局简介
请思考一个问题: 一个对象具有 100 个属性,与 100 个对象每个具有 1 个属性,哪个占用的内存空间更大?
为了回答这个问题,我们来看看 JVM 怎么表示一个对象:
说明
alignment外部对齐比如 8 字节的数据类型 long在内存中的起始地址必须是 8 字节的整数倍。
padding内部填充在对象体内一个字段所占据空间的末尾如果有空白需要使用 padding 来补齐,因为下一个字段的起始位置必须是 48 字节32bit/64bit的整数倍。
其实这两者都是一个道理,让对象内外的位置都对齐。
一个 Java 对象占用多少内存?
参考 Mindprod我们可以发现事情并不简单
JVM 具体实现可以用任意形式来存储内部数据可以是大端字节序或者小端字节序Big/Little Endian还可以增加任意数量的补齐、或者开销尽管原生数据类型primitives的行为必须符合规范。
例如JVM 或者本地编译器可以决定是否将 boolean[] 存储为 64bit 的内存块中,类似于 BitSet。JVM 厂商可以不告诉你这些细节,只要程序运行结果一致即可。
JVM 可以在栈stack空间分配一些临时对象。
编译器可能用常量来替换某些变量或方法调用。
编译器可能会深入地进行优化,比如对方法和循环生成多个编译版本,针对某些情况调用其中的一个。
当然,硬件平台和操作系统还会有多级缓存,例如 CPU 内置的 L1/L2/L3、SRAM 缓存、DRAM 缓存、普通内存,以及磁盘上的虚拟内存。
用户数据可能在多个层级的缓存中出现。这么多复杂的情况、决定了我们只能对内存占用情况进行大致的估测。
对象内存占用的测量方法
一般情况下,可以使用 Instrumentation.getObjectSize() 方法来估算一个对象占用的内存空间。
想要查看对象的实际内存布局layout、占用footprint、以及引用reference可以使用 OpenJDK 提供的 JOL 工具(Java Object Layout
对象头和对象引用
在 64 位 JVM 中,对象头占据的空间是 12-byte=96bit=64+32但是以 8 字节对齐,所以一个空类的实例至少占用 16 字节。
在 32 位 JVM 中,对象头占 8 个字节,以 4 的倍数对齐32=4*8
所以 new 出来很多简单对象,甚至是 new Object(),都会占用不少内容哈。
通常在 32 位 JVM以及内存小于 -Xmx32G 的 64 位 JVM 上(默认开启指针压缩),一个引用占的内存默认是 4 个字节。
因此64 位 JVM 一般需要多消耗 30%~50% 的堆内存。
为什么,大家可以思考一下。
包装类型、数组和字符串
包装类型比原生数据类型消耗的内存要多,详情可以参考 JavaWorld
Integer占用 16 字节8+4=12+补齐),因为 int 部分占 4 个字节。所以使用 Integer 比原生类型 int 要多消耗 300% 的内存。
Long一般占用 16 个字节8+8=16当然对象的实际大小由底层平台的内存对齐确定具体由特定 CPU 平台的 JVM 实现决定。看起来一个 long 类型的对象,比起原生类型 long 多占用了 8 个字节(也多消耗了 100%。相比之下Integer 有 4 字节的补齐,很可能是因为 JVM 强制进行了 8 字节的边界对齐。
其他容器类型占用的空间也不小。
多维数组:这是另一个惊喜。
在进行数值或科学计算时,开发人员经常会使用 int[dim1][dim2] 这种构造方式。
在二维数组 int[dim1][dim2] 中,每个嵌套的数组 int[dim2] 都是一个单独的 Object会额外占用 16 字节的空间。某些情况下,这种开销是一种浪费。当数组维度更大时,这种开销特别明显。
例如int[128][2] 实例占用 3600 字节。而 int[256] 实例则只占用 1040 字节。里面的有效存储空间是一样的3600 比起 1040 多了 246% 的额外开销。在极端情况下byte[256][1],额外开销的比例是 19 倍!而在 C/C++ 中,同样的语法却不增加额外的存储开销。
StringString 对象的空间随着内部字符数组的增长而增长。当然String 类的对象有 24 个字节的额外开销。
对于 10 字符以内的非空 String增加的开销比起有效载荷每个字符 2 字节 + 4 个字节的 length多占用了 100% 到 400% 的内存。
对齐Alignment
让我们来看看下面的示例对象:
class X { // 8 字节-指向 class 定义的引用
int a; // 4 字节
byte b; // 1 字节
Integer c = new Integer(); // 4 字节的引用
}
我们可能会认为,一个 X 类的实例占用 17 字节的空间。但是由于需要对齐paddingJVM 分配的内存是 8 字节的整数倍,所以占用的空间不是 17 字节,而是 24 字节。
当然,运行 JOL 的示例之后,会发现 JVM 会依次先排列 parent-class 的 fields然后到本 class 的字段时,也是先排列 8 字节的,排完了 8 字节的再排 4 字节的 field以此类推。当然还会 “加塞子”,尽量不浪费空间。
Java 内置的序列化,也会基于这个布局,带来的坑就是加字段后就不兼容了。只加方法不固定 serialVersionUID 也出问题。所以有点经验的都不喜欢用内置序列化,例如自定义类型存到 Redis 时。
JOL 使用示例
JOLJava Object Layout是分析 JVM 中内存布局的小工具,通过 Unsafe、JVMTI以及 Serviceability AgentSA来解码实际的对象布局、占用和引用。所以 JOL 比起基于 heap dump或者基于规范的其他工具来得准确。
JOL 的官网地址为:
http://openjdk.java.net/projects/code-tools/jol/
从示例中可以看到JOL 支持命令行方式的调用,即 jol-cli。下载页面请参考 Maven 中央仓库:
http://central.maven.org/maven2/org/openjdk/jol/jol-cli/
可下载其中的 jol-cli-0.9-full.jar 文件。
JOL 还支持代码方式调用,示例:
http://hg.openjdk.java.net/code-tools/jol/file/tip/jol-samples/src/main/java/org/openjdk/jol/samples/
相关的依赖可以在 Maven 中央仓库找到:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
具体的 jar 可以在此搜索页面:
https://mvnrepository.com/search?q=jol-core
内存泄漏
内存泄漏示例
下面展示的这个示例更具体一些。
在 Java 中,创建一个新对象时,例如 Integer num = new Integer(5),并不需要手动分配内存。因为 JVM 自动封装并处理了内存分配。在程序执行过程中JVM 会在必要时检查内存中还有哪些对象仍在使用而不再使用的那些对象则会被丢弃并将其占用的内存回收和重用。这个过程称为“垃圾收集”。JVM 中负责垃圾回收的模块叫做“垃圾收集器GC”。
Java 的自动内存管理依赖 GCGC 会一遍又一遍地扫描内存区域将不使用的对象删除。简单来说Java 中的内存泄漏,就是那些逻辑上不再使用的对象,却没有被 垃圾收集程序 给干掉。从而导致垃圾对象继续占用堆内存中,逐渐堆积,最后产生 java.lang.OutOfMemoryError: Java heap space 错误。
很容易写个 Bug 程序,来模拟内存泄漏:
import java.util.*;
public class KeylessEntry {
static class Key {
Integer id;
Key(Integer id) {
this.id = id;
}
@Override
public int hashCode() {
return id.hashCode();
}
}
public static void main(String[] args) {
Map m = new HashMap();
while (true){
for (int i = 0; i < 10000; i++){
if (!m.containsKey(new Key(i))){
m.put(new Key(i), "Number:" + i);
}
}
System.out.println("m.size()=" + m.size());
}
}
}
粗略一看可能觉得没什么问题因为这最多缓存 10000 个元素嘛
但仔细审查就会发现Key 这个类只重写了 hashCode() 方法却没有重写 equals() 方法于是就会一直往 HashMap 中添加更多的 Key
请参考:《Java hashCode equals 方法的约定及重写原则》。
随着时间推移,“cached的对象会越来越多当泄漏的对象占满了所有的堆内存GC 又清理不了就会抛出 java.lang.OutOfMemoryError: Java heap space 错误
解决办法很简单 Key 类中恰当地实现 equals() 方法即可
@Override
public boolean equals(Object o) {
boolean response = false;
if (o instanceof Key) {
response = (((Key)o).id).equals(this.id);
}
return response;
}
说实话很多时候内存泄漏但是可能功能是正常的达到一定程度才会出问题所以在寻找真正的内存泄漏原因时这种问题的隐蔽性可能会让你死掉很多很多的脑细胞
一个 Spring MVC 中的实际场景
我们曾经碰到过这样一种场景
为了轻易地兼容从 Struts2 迁移到 Spring MVC 的代码 Controller 中直接获取 request
所以在 ControllerBase 类中通过 ThreadLocal 缓存了当前线程所持有的 request 对象
public abstract class ControllerBase {
private static ThreadLocal<HttpServletRequest> requestThreadLocal = new ThreadLocal<HttpServletRequest>();
public static HttpServletRequest getRequest(){
return requestThreadLocal.get();
}
public static void setRequest(HttpServletRequest request){
if(null == request){
requestThreadLocal.remove();
return;
}
requestThreadLocal.set(request);
}
}
然后在 Spring MVC 的拦截器Interceptor实现类中在 preHandle 方法里,将 request 对象保存到 ThreadLocal 中:
/**
* 登录拦截器
*/
public class LoginCheckInterceptor implements HandlerInterceptor {
private List<String> excludeList = new ArrayList<String>();
public void setExcludeList(List<String> excludeList) {
this.excludeList = excludeList;
}
private boolean validURI(HttpServletRequest request){
// 如果在排除列表中
String uri = request.getRequestURI();
Iterator<String> iterator = excludeList.iterator();
while (iterator.hasNext()) {
String exURI = iterator.next();
if(null != exURI && uri.contains(exURI)){
return true;
}
}
// 可以进行登录和权限之类的判断
LoginUser user = ControllerBase.getLoginUser(request);
if(null != user){
return true;
}
// 未登录,不允许
return false;
}
private void initRequestThreadLocal(HttpServletRequest request){
ControllerBase.setRequest(request);
request.setAttribute("basePath", ControllerBase.basePathLessSlash(request));
}
private void removeRequestThreadLocal(){
ControllerBase.setRequest(null);
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
initRequestThreadLocal(request);
// 如果不允许操作,则返回 false 即可
if (false == validURI(request)) {
// 此处抛出异常,允许进行异常统一处理
throw new NeedLoginException();
}
return true;
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception {
removeRequestThreadLocal();
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex)
throws Exception {
removeRequestThreadLocal();
}
}
代码很长,只需要注意在 postHandle 和 afterCompletion 方法中,我们清理了 ThreadLocal 中的 request 对象。
但在实际使用过程中,业务开发人员将一个很大的对象(如占用内存 200MB 左右的 List设置为 request 的 Attributes传递到 JSP 中。
JSP 代码中可能发生了异常,则 Spring MVC 的 postHandle 和 afterCompletion 方法不会被执行。
Tomcat 中的线程调度,可能会一直调度不到那个抛出了异常的线程,于是 ThreadLocal 一直 hold 住 request。
然后随着运行时间的推移,把可用内存占满,一直在执行 Full GC但是因为内存泄漏GC 也解决不了问题,系统直接卡死。
后续的修正:通过 Filter在 finally 语句块中清理 ThreadLocal。
@WebFilter(value="/*", asyncSupported=true)
public class ClearRequestCacheFilter implements Filter{
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
ServletException {
clearControllerBaseThreadLocal();
try {
chain.doFilter(request, response);
} finally {
clearControllerBaseThreadLocal();
}
}
private void clearControllerBaseThreadLocal() {
ControllerBase.setRequest(null);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void destroy() {}
}
这个案例给我们的教训是:可以使用 ThreadLocal但必须有受控制的释放措施一般就是 try-finally 的代码形式确保任何情况下都正常的销毁掉了对象。所以说GC 其实已经帮我们处理掉了 99.99% 的对象管理了,不然我们会遇到更多类似问题。我在十年前做 C++ 开发的时候,深有体会。)
说明Spring MVC 的 Controller 中,其实可以通过 @Autowired 注入 request实际注入的是一个 HttpServletRequestWrapper 对象,执行时也是通过 ThreadLocal 机制调用当前的 request。
常规方式:直接在 controller 方法中接收 request 参数即可。不需要自己画蛇添足的去额外包装处理。
这也是我们一直推荐使用现有的框架和技术,或者别人的成功实践的原因,很多时候别人实践过的,特别是成熟的框架和项目,都是趟过很多坑的,如果我们从头造轮子,很多坑我们还是要一一趟过,这可能是不值当的。
内存 Dump 与分析
内存 Dump 分为 2 种方式:主动 Dump 和被动 Dump。
主动 Dump 的工具包括jcmd、jmap、JVisualVM 等等。具体使用请参考相关工具部分。
被动 Dump 主要是hprof以及 -XX:+HeapDumpOnOutOfMemoryError 等参数。
更多方式请参考:
https://www.baeldung.com/java-heap-dump-capture
关于 hprof 用户手册和内部格式,请参考 JDK 源码中的说明文档:
http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/raw-file/beb15266ba1a/src/share/demo/jvmti/hprof/manual.html#mozTocId848088
此外,常用的分析工具有:
jhatjhat 用来支持分析 dump 文件,是一个 HTTP/HTML 服务器,能将 dump 文件生成在线的 HTML 文件,通过浏览器查看。
MATMAT 是比较好用的、图形化的 JVM Dump 文件分析工具。
好用的分析工具MAT
1. MAT 介绍
MAT 全称是 Eclipse Memory Analyzer Tools。
其优势在于,可以从 GC root 进行对象引用分析,计算各个 root 所引用的对象有多少比较容易定位内存泄露。MAT 是一款独立的产品100MB 不到,可以从官方下载:下载地址。
2. MAT 示例
现象描述:系统进行慢 SQL 优化调整之后上线,在测试环境没有发现什么问题,但运行一段时间之后发现 CPU 跑满,下面我们就来分析案例。
先查看本机的 Java 进程:
jps -v
假设 jps 查看到的 pid 为 3826。
Dump 内存:
jmap -dump:format=b,file=3826.hprof 3826
导出完成后dump 文件大约是 3 个 G。所以需要修改 MAT 的配置参数,太小了不行,但也不一定要设置得非常大。
在 MAT 安装目录下,修改配置文件:
MemoryAnalyzer.ini
默认的内存配置是 1024MB分析 3GB 的 dump 文件可能会报错,修改如下部分:
-vmargs
-Xmx1024m
根据 Dump 文件的大小,适当增加最大堆内存设置,要求是 4MB 的倍数,例如改为:
-vmargs
-Xmx4g
双击打开 MemoryAnalyzer.exe打开 MAT 分析工具,选择菜单 File > Open File… 选择对应的 dump 文件。
选择 Leak Suspects Report 并确定,分析内存泄露方面的报告。
3. 内存报告
然后等待,分析完成后,汇总信息如下:
分析报告显示,占用内存最大的问题根源 1
占用内存最大的问题根源 2
占用内存最大的问题根源 3
可以看到,总的内存占用才 2GB 左右。问题根源 1 和根源 2每个占用 800MB问题很可能就在他们身上。
当然,根源 3 也有一定的参考价值,表明这时候有很多 JDBC 操作。
查看问题根源 1其说明信息如下
The thread org.apache.tomcat.util.threads.TaskThread
@ 0x6c4276718 http-nio-8086-exec-8
keeps local variables with total size 826,745,896 (37.61%) bytes.
The memory is accumulated in one instance of
"org.apache.tomcat.util.threads.TaskThread"
loaded by "java.net.URLClassLoader @ 0x6c0015a40".
The stacktrace of this Thread is available. See stacktrace.
Keywords
java.net.URLClassLoader @ 0x6c0015a40
org.apache.tomcat.util.threads.TaskThread
4. 解读分析
大致解读一下,这是一个(运行中的)线程,构造类是 org.apache.tomcat.util.threads.TaskThread持有了大约 826MB 的对象,占比为 37.61%。
所有运行中的线程(栈)都是 GC-Root。
点开 See stacktrace 链接,查看导出时的线程调用栈。
节选如下:
Thread Stack
http-nio-8086-exec-8
...
at org.mybatis.spring.SqlSessionTemplate.selectOne
at com.sun.proxy.$Proxy195.countVOBy(Lcom/****/domain/vo/home/residents/ResidentsInfomationVO;)I (Unknown Source)
at com.****.bi.home.service.residents.impl.ResidentsInfomationServiceImpl.countVOBy(....)Ljava/lang/Integer; (ResidentsInfomationServiceImpl.java:164)
at com.****.bi.home.service.residents.impl.ResidentsInfomationServiceImpl.selectAllVOByPage(....)Ljava/util/Map; (ResidentsInfomationServiceImpl.java:267)
at com.****.web.controller.personFocusGroups.DocPersonFocusGroupsController.loadPersonFocusGroups(....)Lcom/****/domain/vo/JSONMessage; (DocPersonFocusGroupsController.java:183)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run()V (TaskThread.java:61)
at java.lang.Thread.run()V (Thread.java:745)
其中比较关键的信息,就是找到我们自己的 package
com.****.....ResidentsInfomationServiceImpl.selectAllVOByPage
并且其中给出了 Java 源文件所对应的行号。
分析问题根源 2结果和根源 1 基本上是一样的。
当然,还可以分析这个根源下持有的各个类的对象数量。
点击根源 1 说明信息下面的 Details » 链接,进入详情页面。
查看其中的 “Accumulated Objects in Dominator Tree”
可以看到占用内存最多的是 2 个 ArrayList 对象。
鼠标左键点击第一个 ArrayList 对象,在弹出的菜单中选择 Show objects by class > by outgoing references。
打开 class_references 标签页:
展开后发现 PO 类对象有 113 万个。加载的确实有点多,直接占用 170MB 内存(每个对象约 150 字节)。
事实上,这是将批处理任务,放到实时的请求中进行计算,导致的问题。
MAT 还提供了其他信息,都可以点开看看,也可以为我们诊断问题提供一些依据。
JDK 内置故障排查工具jhat
jhat 是 Java 堆分析工具Java heap Analyzes Tool。在 JDK6u7 之后成为 JDK 标配。使用该命令需要有一定的 Java 开发经验,官方不对此工具提供技术支持和客户服务。
1. jhat 用法
jhat [options] heap-dump-file
参数:
options 可选命令行参数,请参考下面的 [Options]。
heap-dump-file 要查看的二进制 Java 堆转储文件Java binary heap dump file。如果某个转储文件中包含了多份 heap dumps可在文件名之后加上 #<number> 的方式指定解析哪一个 dumpmyfile.hprof#3
2. jhat 示例
使用 jmap 工具转储堆内存、可以使用如下方式:
jmap -dump:file=DumpFileName.txt,format=b <pid>
例如:
jmap -dump:file=D:/javaDump.hprof,format=b 3614
Dumping heap to D:\javaDump.hprof ...
Heap dump file created
其中3614 是 java 进程的 ID一般来说jmap 需要和目标 JVM 的版本一致或者兼容,才能成功导出。
如果不知道如何使用,直接输入 jmap或者 jmap -h 可看到提示信息。
然后分析时使用 jhat 命令,如下所示:
jhat -J-Xmx1024m D:/javaDump.hprof
...... 其他信息 ...
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.
使用参数 -J-Xmx1024m 是因为默认 JVM 的堆内存可能不足以加载整个 dump 文件,可根据需要进行调整。然后我们可以根据提示知道端口号是 7000接着使用浏览器访问 http://localhost:7000/ 即可看到相关分析结果。
3. 详细说明
jhat 命令支持预先设计的查询,比如显示某个类的所有实例。
还支持 对象查询语言OQLObject Query LanguageOQL 有点类似 SQL专门用来查询堆转储。
OQL 相关的帮助信息可以在 jhat 命令所提供的服务器页面最底部。
如果使用默认端口,则 OQL 帮助信息页面为:
http://localhost:7000/oqlhelp/
Java 生成堆转储的方式有多种:
使用 jmap -dump 选项可以在 JVM 运行时获取 heap dump可以参考上面的示例详情参见jmap(1)。
使用 jconsole 选项通过 HotSpotDiagnosticMXBean 从运行时获得堆转储。请参考jconsole(1) 以及 HotSpotDiagnosticMXBean 的接口描述http://docs.oracle.com/javase/8/docs/jre/api/management/extension/com/sun/management/HotSpotDiagnosticMXBean.html。
在虚拟机启动时如果指定了 -XX:+HeapDumpOnOutOfMemoryError 选项,则抛出 OutOfMemoryError 时,会自动执行堆转储。
使用 hprof 命令。请参考性能分析工具——HPROF 简介https://github.com/cncounter/translation/blob/master/tiemao*201720*hprof/20_hprof.md。
4. Options 选项介绍
-stack值为 false 或 true。关闭对象分配调用栈跟踪tracking object allocation call stack。如果分配位置信息在堆转储中不可用则必须将此标志设置为 false默认值为 true。
-refs值为 false 或 true。关闭对象引用跟踪tracking of references to objects默认值为 true。默认情况下返回的指针是指向其他特定对象的对象如反向链接或输入引用referrers or incoming references会统计/计算堆中的所有对象。
-port即 port-number。设置 jhat HTTP server 的端口号,默认值 7000。
-exclude即 exclude-file。指定对象查询时需要排除的数据成员列表文件。例如如果文件列列出了 java.lang.String.value那么当从某个特定对象 Object o 计算可达的对象列表时,引用路径涉及 java.lang.String.value 的都会被排除。
-baseline指定一个基准堆转储baseline heap dump。在两个 heap dumps 中有相同 object ID 的对象会被标记为不是新的。其他对象被标记为新的new。在比较两个不同的堆转储时很有用。
-debug值为 int 类型。设置 debug 级别0 表示不输出调试信息,值越大则表示输出更详细的 debug 信息。
-version启动后只显示版本信息就退出。
-h即-help。显示帮助信息并退出. 同 -h。
-J <flag>:因为 jhat 命令实际上会启动一个 JVM 来执行,通过 -J 可以在启动 JVM 时传入一些启动参数。例如,-J-Xmx512m 则指定运行 jhat 的 Java 虚拟机使用的最大堆内存为 512 MB。如果需要使用多个 JVM 启动参数,则传入多个 -Jxxxxxx。
参考
jmap 官方文档
jconsole 官方文档
性能分析工具——HPROF 简介
JDK 内置故障排查工具jhat 简介