23 内存分析与相关工具上篇(内存布局与分析工具)

通过前面的课程,我们学习了“内存溢出”和“内存泄漏”的区别。

简单来说,Java 中的内存溢出就是内存不够用,一般是堆内存报错,当然也可能是其他内存空间不足引起的。

下面我们详细讲解 Java 对象的内存相关知识。

Java 对象内存布局简介

请思考一个问题: 一个对象具有 100 个属性,与 100 个对象每个具有 1 个属性,哪个占用的内存空间更大?

为了回答这个问题,我们来看看 JVM 怎么表示一个对象:

742441.png

说明

一个 Java 对象占用多少内存?

参考 Mindprod,我们可以发现事情并不简单:

例如:JVM 或者本地编译器可以决定是否将 boolean[] 存储为 64bit 的内存块中,类似于 BitSet。JVM 厂商可以不告诉你这些细节,只要程序运行结果一致即可。

当然,硬件平台和操作系统还会有多级缓存,例如 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

其他容器类型占用的空间也不小。

多维数组:这是另一个惊喜。

在进行数值或科学计算时,开发人员经常会使用 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++ 中,同样的语法却不增加额外的存储开销。

String:String 对象的空间随着内部字符数组的增长而增长。当然,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 字节的空间。但是由于需要对齐(padding),JVM 分配的内存是 8 字节的整数倍,所以占用的空间不是 17 字节,而是 24 字节。

当然,运行 JOL 的示例之后,会发现 JVM 会依次先排列 parent-class 的 fields,然后到本 class 的字段时,也是先排列 8 字节的,排完了 8 字节的再排 4 字节的 field,以此类推。当然,还会 “加塞子”,尽量不浪费空间。

Java 内置的序列化,也会基于这个布局,带来的坑就是加字段后就不兼容了。只加方法不固定 serialVersionUID 也出问题。所以有点经验的都不喜欢用内置序列化,例如自定义类型存到 Redis 时。

JOL 使用示例

JOL(Java Object Layout)是分析 JVM 中内存布局的小工具,通过 Unsafe、JVMTI,以及 Serviceability Agent(SA)来解码实际的对象布局、占用和引用。所以 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 的自动内存管理依赖 GC,GC 会一遍又一遍地扫描内存区域,将不使用的对象删除。简单来说,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。

更多方式请参考:

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

此外,常用的分析工具有:

好用的分析工具: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 并确定,分析内存泄露方面的报告。

bd3d81d4-d928-4081-a2f7-96c11de76178.png

3. 内存报告

然后等待,分析完成后,汇总信息如下:

07acbdb7-0c09-40a5-b2c3-e7621a36870f.png

分析报告显示,占用内存最大的问题根源 1:

345818b9-9323-4025-b23a-8f279a99eb84.png

占用内存最大的问题根源 2:

07bbe993-5139-416a-9e6d-980131b649bf.png

占用内存最大的问题根源 3:

7308f1b5-35aa-43e0-bbb4-05cb2e3131be.png

可以看到,总的内存占用才 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”:

b5ff6319-a5d9-426f-99ef-19bd100fd80a.png

可以看到占用内存最多的是 2 个 ArrayList 对象。

鼠标左键点击第一个 ArrayList 对象,在弹出的菜单中选择 Show objects by class –> by outgoing references。

6dbbb72d-ec2b-485f-bc8e-9de044b21b7d.png

打开 class_references 标签页:

28fe37ed-36df-482a-bc58-231c9552638d.png

展开后发现 PO 类对象有 113 万个。加载的确实有点多,直接占用 170MB 内存(每个对象约 150 字节)。

事实上,这是将批处理任务,放到实时的请求中进行计算,导致的问题。

MAT 还提供了其他信息,都可以点开看看,也可以为我们诊断问题提供一些依据。

JDK 内置故障排查工具:jhat

jhat 是 Java 堆分析工具(Java heap Analyzes Tool)。在 JDK6u7 之后成为 JDK 标配。使用该命令需要有一定的 Java 开发经验,官方不对此工具提供技术支持和客户服务。

1. jhat 用法

jhat [options] heap-dump-file

参数:

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 命令支持预先设计的查询,比如显示某个类的所有实例。

还支持 对象查询语言(OQL,Object Query Language),OQL 有点类似 SQL,专门用来查询堆转储。

OQL 相关的帮助信息可以在 jhat 命令所提供的服务器页面最底部。

如果使用默认端口,则 OQL 帮助信息页面为:

http://localhost:7000/oqlhelp/

Java 生成堆转储的方式有多种:

4. Options 选项介绍

参考