内存溢出与内存泄漏

  • 堆内存溢出(OutOfMemoryError: Java heap space)
内存溢出是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出

会导致 JVM 内存溢出的一些场景:

  1. JVM 启动参数堆内存值设定的过小
  2. 内存中加载的数据量过于庞大(一次性从 Mysql、Redis 取出过多数据)
  3. 对象的引用没有及时释放,使得JVM不能回收
  4. 代码中存在死循环或循环产生过多重复的对象实体
内存溢出问题一般分为两种,一种是由于大峰值下瞬间创建大量对象而导致的内存溢出;另一种则是由于内存泄漏而导致的内存溢出。第一种问题可通过限流来处理,第二种问题需要分析程序是否存在 Bug

  • 内存泄漏(Memory Leak)
内存泄漏是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

会导致 Java 内存泄漏的一些场景

  1. 过度使用静态成员属性(static fields)
  2. 忘记关闭已打开的资源链接(unclosed Resources)
  3. 没有正确的重写 equals 和 hashcode 方法(HashMap HashSet)

哪些对象该被回收?一般有可达性分析和引用计数两种算法来识别可回收的对象:

  • 可达性分析
目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
  • 引用计数的循环引用问题
可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。

GC Roots 我们可以暂时理解为由堆外指向堆内的引用

Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列 GC Roots 出发,边标记边探索所有被引用的对象。

一般而言,GC Roots 包括(但不限于)如下几种:

  1. Java 方法栈桢中的局部变量;
  2. 已加载类的静态变量;
  3. JNI handles;
  4. 已启动且未停止的 Java 线程。

jmap 命令

  • jmap -heap

查看堆内存初始化配置信息已经堆内存使用情况,垃圾收集器类型

jmap -heap pid
  • jmap -histo

统计各个类的实例数目以及占用内存,并按照内存使用量从多至少的顺序排列

jmap -histo:live pid
  • jmap -dump

把堆内存的使用情况 dump 到文件中,live 只保存堆中的存活对象

jmap -dump:live,format=b,file=dump.bin pid
Heap dump 是Java进程在特定时间点的一个内存快照,快照包含在dump时间点java堆中的对象和类的信息。 通常在dump时, 会触发一个完整的GC, 故而Heap Dump文件中只包含哪些未被回收的对象的信息

  • jmap 参数详细说明

image.png

输出 GC 日志

  • 当堆内存空间溢出时输出堆的内存快照
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/java/logs/
  • 输出 GC 详细日志并指定日志路径
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 以基准时间的形式输出GC的时间戳(JVM 启动时间为起点的相对时间)
-XX:+PrintGCDateStamps 以日期的形式输出GC的时间戳(2013-05-04 T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:/data/java/logs/usercenter/gc.log 日志文件的输出路径

Eclipse MAT

Eclipse MAT(Memory Analyzer Tool)是一个强大的基于Eclipse的内存分析工具,可以帮助我们找到内存泄露,减少内存消耗

MAT 计算对象占据内存分为 Shallow Heap 和 Retained Heap 两种方式

  • Shallow heap
对象自身所占据的内存
  • Retained heap
当对象不再被引用时,垃圾回收器所能回收的总内存,包括对象自身所占据的内存,以及仅能够通过该对象引用到的其他对象所占据的内存

MAT 包含了两个比较重要的视图 直方图(histogram)和支配树(Dominator Tree

  • 直方图
MAT 的直方图类似 jmap -histo 命令输出的结果,能够展示各个类的实例数目以及这些实例的 Shallow heap 总和,并且可以根据 Shallow 或 Retained heap 从大到小排序
  • 支配树
支配树是由一些对象组成的图,在支配树中每个对象都是它子节点的直接支配者
  • 支配者
如果从入口节点到 b 节点的所有路径都要经过 a 节点,那么 a 支配(dominate)b
如果从 a 节点到 b 节点的所有路径中不存在支配 b 的其他节点,那么 a 直接支配(immediate dominate)b

注意点:对象的引用型字段未必对应支配树中的父子节点关系。假设对象 a 拥有两个引用型字段,分别指向 b 和 c。而 b 和 c 各自拥有一个引用型字段,但都指向 d。如果没有其他引用指向 b、c 或 d,那么 a 直接支配 b、c 和 d,而 b(或 c)和 d 之间不存在支配关系


使用 List objects 查看引用关系图

  • with outgoing references
查看当前对象持有的外部对象引用(在对象关系图中为从当前对象指向外的箭头)
  • with incoming references
查看当前对象被哪些外部对象所引用(在对象关系图中为指向当前对象的箭头)

  • Paths to GC Roots
可以通过该功能反向列出该对象到 GC Roots 的引用路径,这个路径解释了为什么当前对象还能存活,对分析内存泄露很有帮助,这个查询只能针对单个对象使用

image.png

  • Merge Shortest Paths to GC roots
通过下图我们可以看到 Context 直接支配 CtEntry,只要释放 Context 的引用,CtEntry 占用的内存就可以被回收

image.png


  • 线程视图(Thread Overview)
可以看到线程对象/线程栈信息、线程名、Shallow Heap、Retained Heap、类加载器、是否Daemon线程等信息

image.png

Sentinel 内存泄漏 bug 导致 full GC

image.png

sentinel-apache-dubbo-adapter Entry leak cause full GC #1416

这个问题首次出现在 Sentinel 1.7.1 版本,且只在如下场景发生:

A,B,C 三个应用,A 是服务消费者,B 是服务提供者并且是服务消费者,C 是服务提供者
A 请求 B 提供的服务,B 在处理 A 请求的同时调用 C 提供的服务
A(consumer) -> B(provider and consumer) -> C(provider)

B 应用 dubbo 业务线程池配置如下:

<dubbo:protocol name="dubbo" port="20077" threadpool="cached" threads="200"/>

cached 类型代表缓存线程池,如果空闲一分钟会自动删除,需要时重建(如果不间断每分钟都有请求需要处理,那么部分线程会一直存活)

  • RpcContext 和 Context

这里涉及到的两个 context 分别是 Dubbo 框架的 rcpContext,另外一个是 Sentinel 的 context

RpcContext 用来存储一些临时状态,这些状态会随着每次 request 的发送或者接收而改变
Sentinel context 也是用来存储一些调用的元数据
  • Entry 和 CtEntry
SphU#entry() 方法每次调用都会返回一个 Entry,Entry 存储着当前的调用信息
CtEntry 在 Entry 的基础上增加了 parent 和 child 的链式关联

image.png

  • Context 和 CtEntry 相互引用,应该释放哪个引用才会释放内存 ?
  • 为什么会存在 CtEntry 递归引用堆积的问题,并最终指向了同一个 Context ?
  • 为什么 providerFilter 没有正常执行 ContextUtil.exit() ?
  • 为什么 consumerFilter 不需要执行 ContextUtil.exit() ?

public class SentinelDubboConsumerFilter extends BaseSentinelDubboFilter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        Entry interfaceEntry = null;
        Entry methodEntry = null;
        RpcContext rpcContext = RpcContext.getContext();
        try {
            String methodResourceName = DubboUtils.getResourceName(invoker, invocation, DubboConfig.getDubboConsumerPrefix());
            String interfaceResourceName = DubboConfig.getDubboInterfaceGroupAndVersionEnabled() ? invoker.getUrl().getColonSeparatedKey()
                    : invoker.getInterface().getName();
            InvokeMode invokeMode = RpcUtils.getInvokeMode(invoker.getUrl(), invocation);

            if (InvokeMode.SYNC == invokeMode) {
                interfaceEntry = SphU.entry(interfaceResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.OUT);
                rpcContext.set(DubboUtils.DUBBO_INTERFACE_ENTRY_KEY, interfaceEntry);
                methodEntry = SphU.entry(methodResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.OUT, invocation.getArguments());
            } else {
                // should generate the AsyncEntry when the invoke model in future or async
                interfaceEntry = SphU.asyncEntry(interfaceResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.OUT);
                rpcContext.set(DubboUtils.DUBBO_INTERFACE_ENTRY_KEY, interfaceEntry);
                methodEntry = SphU.asyncEntry(methodResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.OUT, 1, invocation.getArguments());
            }
            rpcContext.set(DubboUtils.DUBBO_METHOD_ENTRY_KEY, methodEntry);
            return invoker.invoke(invocation);
        } catch (BlockException e) {...}
    }
}
public class SentinelDubboProviderFilter extends BaseSentinelDubboFilter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        // Get origin caller.
        String application = DubboUtils.getApplication(invocation, "");
        RpcContext rpcContext = RpcContext.getContext();
        Entry interfaceEntry = null;
        Entry methodEntry = null;
        try {
            String methodResourceName = DubboUtils.getResourceName(invoker, invocation, DubboConfig.getDubboProviderPrefix());
            String interfaceResourceName = DubboConfig.getDubboInterfaceGroupAndVersionEnabled() ? invoker.getUrl().getColonSeparatedKey()
                    : invoker.getInterface().getName();
            // Only need to create entrance context at provider side, as context will take effect
            // at entrance of invocation chain only (for inbound traffic).
            ContextUtil.enter(methodResourceName, application);
            interfaceEntry = SphU.entry(interfaceResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.IN);
            rpcContext.set(DubboUtils.DUBBO_INTERFACE_ENTRY_KEY, interfaceEntry);
            methodEntry = SphU.entry(methodResourceName, ResourceTypeConstants.COMMON_RPC, EntryType.IN, invocation.getArguments());
            rpcContext.set(DubboUtils.DUBBO_METHOD_ENTRY_KEY, methodEntry);
            return invoker.invoke(invocation);
        } catch (BlockException e) {...}
    }
}
public abstract class BaseSentinelDubboFilter extends ListenableFilter {
    static void traceAndExit(Throwable throwable, URL url) {
        Entry interfaceEntry = (Entry) RpcContext.getContext().get(DubboUtils.DUBBO_INTERFACE_ENTRY_KEY);
        Entry methodEntry = (Entry) RpcContext.getContext().get(DubboUtils.DUBBO_METHOD_ENTRY_KEY);
        if (methodEntry != null) {
            Tracer.traceEntry(throwable, methodEntry);
            methodEntry.exit();
            RpcContext.getContext().remove(DubboUtils.DUBBO_METHOD_ENTRY_KEY);
        }
        if (interfaceEntry != null) {
            Tracer.traceEntry(throwable, interfaceEntry);
            interfaceEntry.exit();
            RpcContext.getContext().remove(DubboUtils.DUBBO_INTERFACE_ENTRY_KEY);
        }
        if (CommonConstants.PROVIDER_SIDE.equals(url.getParameter(CommonConstants.SIDE_KEY))) {
            ContextUtil.exit();
        }
    }
}

其他 JVM 监控和诊断工具

其他 JVM 监控和诊断指令

  • jstat
可用来打印目标 Java 进程的性能数据,以-gc为前缀的子命令,它们将打印垃圾回收相关的数据
  • jstack
可以用来打印目标 Java 进程中各个线程的栈轨迹,以及这些线程所持有的锁
  • jcmd

参考资料:

  1. Memory Analyzer Concepts
  2. MAT 中文翻译
  3. Eclipse Memory Analyzer Tool 的使用
  4. Understanding Memory Leaks in Java

sixsixfly
18 声望0 粉丝