使用 Instrumentation 和 Phantom References 查找 JVM 内存泄

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / Java 学习路线 / 一对一提问 / 学习打卡/ 赠书活动

目前,正在 星球 内带小伙伴们做第一个项目:全栈前后端分离博客项目,采用技术栈 Spring Boot + Mybatis Plus + Vue 3.x + Vite 4手把手,前端 + 后端全栈开发,从 0 到 1 讲解每个功能点开发步骤,1v1 答疑,陪伴式直到项目上线,目前已更新了 204 小节,累计 32w+ 字,讲解图:1416 张,还在持续爆肝中,后续还会上新更多项目,目标是将 Java 领域典型的项目都整上,如秒杀系统、在线商城、IM 即时通讯、权限管理等等,已有 870+ 小伙伴加入,欢迎点击围观

在过去一年左右的时间里,人们非常关注在 JVM 中查找内存泄漏。内存泄漏会在 JVM 中造成严重破坏。它们可能是不可预测的,并导致代价高昂的性能下降,甚至在服务器重启期间停机。理想情况下,内存泄漏早在应用程序在生产环境中运行之前就被发现了。不幸的是,较低状态的测试通常不足以导致性能下降或 OutOfMemoryError 。有时,泄漏会在数周或数月的正常运行时间内慢慢窃取内存。这种类型的泄漏在生产前很难检测到,但并非不可能。本文将概述一种无需花费一分钱即可找到此类泄漏的方法!

这种方法的具体细节依赖于 PhantomReference 和仪器。 PhantomReference 并不常用,所以让我们仔细看看。在 Java 中,有三种类型的 Reference WeakReference SoftReference PhantomReference 。大多数开发人员都熟悉 WeakReferences。它们只是不阻止对其引用的对象进行垃圾回收。 SoftReferences 类似于 WeakReferences,但有时会阻止其引用对象被垃圾收集。如果在垃圾回收期间认为可用内存充足,SoftReferences 可能会阻止对象被垃圾回收。最后, PhantomReference 几乎与它们的弱兄弟和软兄弟完全不同,因为它们不是为应用程序直接保存这些引用而设计的。 PhantomReference 用作对象即将被垃圾回收的通知机制。 Javadoc 说“幻影引用最常用于以比 Java 终结机制更灵活的方式安排事前清理操作。”。我们对执行任何清理不感兴趣,但我们会记录对象何时被垃圾回收。

检测是另一个不可或缺的功能。 Instrumentation 是在 VM 加载类之前更改类的字节码的过程。它是 Java 的一项强大功能,可用于监视、分析,在我们的例子中还用于事件记录。我们将使用检测来修改应用程序类,以便在实例化对象时,我们将为它创建一个 PhantomReference 。这种内存泄漏检测机制的设计现在应该正在形成。我们将使用工具来强制类在它们创建对象时告诉我们,我们将使用 PhantomReference s 来记录它们何时被垃圾收集。最后,我们将使用数据存储来记录这些数据。这些数据将成为我们分析以确定对象是否正在泄漏的基础。

在我们继续之前,让我们跳到本文结尾处我们将能够执行的操作的屏幕截图。

此图显示已分配的对象数,但未随时间垃圾收集。运行代码是 Plumbr 自己的内存泄漏演示应用程序。 Plumbr 向 Spring 框架 Pet Clinic 示例应用程序添加了一些内存泄漏。当您看到相关的泄漏代码时,该图将更有意义:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

对于每个请求,此拦截器都会泄漏一个 ImageEntry 对象,该对象引用一个字节数组。因此,在 PetClinic 的首页仅刷新 10 次后,您就可以在图表中看到泄漏趋势。

现在,让我们开始编写一些代码。我们需要的第一个类是一个接口,当一个类被实例化时,检测字节码将调用该接口。让我们称这个类为“Recorder”并创建一个静态方法来接收对象:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

此方法的实现细节不在本文讨论范围之内。现在我们有了一个接口,我们可以处理我们的检测代码了。仪器是一个广泛的主题,但我们会将范围缩小到我们的项目。我们将使用 ASM(来自 ObjectWeb)来执行字节码操作。本指南假定您已经熟悉 ASM。如果不是,您可能需要先花一些时间复习一下。

简而言之,我们想要修改任何实例化新对象的应用程序代码,以便它将以新对象作为参数调用我们的 Recorder.record(...) 方法。为了识别“应用程序代码”,我们将允许用户提供一组正则表达式,这些表达式将指定要包含的类集和要排除的类集。我们将创建一个名为 Configuration 的 类来加载包含此信息的配置属性文件。此类将用于确定是否应检测某个类。稍后,我们还将使用它来定义其他一些属性。

检测发生在运行时加载类时。检测由打包在 jar 文件中的“代理”执行。如果您不熟悉代理,可以查看 java.lang.instrument 包的 javadoc 文档。代理的入口点是代理类。以下是代理入口点的可能方法签名:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

当在启动时使用 JVM 命令行的“-javaagent”参数指定代理时,将调用 premain 方法。如果代理附加到现有 JVM,则调用代理 主要 方法。如果在启动时应用我们的代理,它将是最有用的。可以想象,您可以在发现内存泄漏后附加代理,但它只能提供自附加以来记录的内存泄漏数据。此外,它只会检测在附加后加载的类。可以强制 JVM 重新定义类,但我们的组件不会提供此功能。

让我们调用我们的代理类 HeapsterAgent 并为上述方法分别提供一个主体:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

两个入口点的初始化过程是相同的,所以我们将在一个配置方法中介绍它们。我们将跳过大部分 配置 实现细节以专注于检测。我们希望我们的类实现 java.lang.instrument.ClassFileTransformer 接口。当 ClassFileTransformer 注册到 JVM 时,它就有机会在加载类时对其进行修改。我们的 HeapsterAgent 类现在有这个签名:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

配置 方法需要向 JVM 注册一个 HeapsterAgent 实例,以便拦截正在加载的类。这是代码:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

“inst”是 configure(…) 方法的 Instrumentation 参数。

聪明的读者可能已经在想“应用程序类加载器将如何发现新的 Recorder 类?”。这个问题有几个解决方案。我们可以使用 Instrumentation 类的 appendToBootstrapClassLoaderSearch(JarFile jarfile) 方法将相关类附加到引导类路径,其中的类应该可以被应用程序类加载器发现。但是,为了发现泄漏的类,必须检测 ClassLoader 类本身。这只能通过创建您自己的包含 java.lang.ClassLoader 的 jar 并使用 -Xbootclasspath/p 参数取代 JRE 自己的 java.lang.ClassLoader 来有效地完成。因此,我们不妨将其他支持类打包在同一个 jar 中。

现在让我们继续转换方法。该方法在 ClassFileTransformer 接口中提供。这是完整的签名:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

这是仪器魔术开始发生的地方。每次 JVM 加载类时都会调用此方法。对我们来说最重要的参数是 className classfileBuffer className 将帮助我们确定该类是否为应用程序类,而 classfileBuffer 是我们可能希望修改的字节码的字节数组。让我们来看看我们将如何消除要修改的类。显然我们只想修改应用程序类,因此我们将 className 参数与我们的包含和排除进行比较。请记住, className 是其内部格式,名称分隔符使用斜杠 (/) 而不是点 (.)。我们也不想检测我们的代理代码。我们将能够通过将 className 的包路径与我们自己的代码库进行比较来控制它。最后,在开发此代码时,我隔离了几个根本不应检测的 Oracle 类(可能还有更多)。但是,一般来说,如果您正在查找应用程序中的泄漏,您可能会忽略 java.*、javax.*、sun.* 等。我已将其中一些硬编码到转换方法中。如果您认为 Oracle 代码中存在错误,您可以随时禁用此过滤。但是,我建议您不要使用从 java.lang 等核心包中检测的 Oracle 代码。您不太可能是第一个在这些类中发现错误的人,并且您可以让 JVM 陷入无法恢复的混乱局面。

transform 方法的最后一部分是实际的转换。这是重要的代码:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

如果完全限定的类名以“java”或“sun”开头,我们返回 null。返回 null 是您的代理人表达“我对转换此类不感兴趣”的方式。接下来我们通过调用 isAgentClass(…) 检查 className 是否与代理类匹配。这是实现:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

您会在上面的代码片段中注意到,我已将 ASM 类的基本包名称从 org.objectweb.asm 更改为 ca.discotek.rebundled.org.objectweb.asm 。代理类将在引导类路径上可用。如果我没有更改代理的 ASM 类的包名称空间,则在 JVM 中运行的其他工具或应用程序可能会无意中使用代理的 ASM 类。

transform 方法的其余部分是相当基本的 ASM 操作。然而,我们现在需要仔细看看 HeapsterTransformer 类是如何工作的。您可能会猜到, HeapsterTransformer 扩展了 ClassVisitor 类并覆盖了访问方法:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

它记录类名和超类名以备后用。

visitMethod 方法也被覆盖:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

它强制访问我们自己的名为 HeapsterMethodVisitor MethodVisitor HeapsterMethodVisitor 将需要添加一些局部变量,以便它成为 LocalVariableSorter 的 子类。构造函数参数包括方法名称,它记录下来供以后使用。 HeapsterMethodVisitor 覆盖的其他方法是: visitMethodInsn visitTypeInsn visitIntInsn 。有人可能认为我们可以在看到对构造函数 ( <init> ) 的调用时添加代码,从而在 visitMethodInsn 中完成这一切,但不幸的是,事情并没有那么简单。首先,让我们回顾一下我们要完成的工作。我们希望在每次实例化应用程序对象时进行记录。这可以通过多种方式发生。最明显的一个是通过“新”操作。但是 Class.newInstance() 或者当 ObjectInputStream 通过 readObject 方法反序列化时呢?这些方法不使用“new”运算符。另外,数组呢?创建数组不是 visitMethodInsn 指令,但我们也想记录它们。不用说,组装代码以捕获所有这些事件是很棘手的。

我们先来看看 visitMethodInsn 方法。这是第一个声明:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

Opcodes.INVOKESPECIAL 指示正在调用构造函数或静态初始化程序。我们只关心对构造函数的调用。此外,我们不关心所有的构造函数调用。具体来说,我们只关心对第一个构造函数的调用,而不关心从构造函数到其超类构造函数的构造函数链调用。这就是为什么更早记录 超类 名称很重要的原因。我们使用一个名为 isIgnorableConstructorCall 的方法来确定我们是否要检测:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

第一个 if 语句检查构造函数是否正在调用同一类中的另一个构造函数(例如 this(...))。第二行检查是否正在对类型 java.lang.Object 调用构造函数调用。使用 ASM 时,任何超类为 java.lang.Object 的 类,超类都将指定为 null 。这可以防止 NullPointerException 在第三行发生,我们检查是否正在调用对超类的构造函数调用(例如 super(...) )。类型为 java.lang.Object 的对象将具有空超类类型。

现在我们已经确定了可以忽略哪些构造函数,让我们回到 visitMethodInsn 。构造函数调用完成后,我们可以记录对象:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

第一行与原始字节码指令相同。第二行调用 addRecorderDotRecord() 。此方法包含调用我们的 Recorder 类的字节码。我们将多次重复使用它,因此它有自己的方法。这是代码:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

如果您了解 ASM,这一切看起来应该相当简单,但是有一个无法解释的遗漏对于字节码专家来说应该是显而易见的。 Java是基于堆栈的。当我们调用原始方法时:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

…它将新对象从堆栈中弹出。但是 addRecorderDotRecord 的指令期望新对象仍然在堆栈上。当它完成时,它将弹出新对象。这没有意义,因为我们还没有检查其余的重写方法。让我们跳到 visitTypeInsn(…) 。这是上半部分:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

Opcode.NEW 作为参数的 visitTypeInsn 将紧接着调用对象的构造函数。此外,JVM 规范不允许您在对象初始化之前调用其他方法。通过先使用 visitTypeInsn 然后再使用 visitMethodInsn ,我们可以添加对堆栈上对象的额外引用,该对象可以用作 Recorder.record(...) 方法的参数。

现在让我们看一下 visitMethodInsn 的 else-if 语句。方法 newInstance clone readObject 是可以在不使用“new”运算符的情况下实例化对象的特殊方法。当我们遇到这些方法时,我们在堆栈上创建对象引用的副本(使用 addDup() ),然后调用我们的 Recorder.record(…) 方法,该方法将从堆栈中弹出我们的重复对象引用。这是 addDup() 方法:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

我们已经部分检查了 visitTypeInsn 方法,但现在让我们回顾一下它的全部内容:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

此方法的第一行确保执行原始指令。我们已经讨论了 if 语句,它用于在我们添加对 Recorder.record(…) 的调用之前复制使用“new”运算符实例化的对象。 else-if 语句处理创建非原始类型的一维数组。在这种情况下,我们在堆栈上添加一个重复的数组对象引用,然后调用 Recorder.record(…) 将其弹出。

接下来我们有 visitMultiANewArrayInsn


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

这种方法相当容易理解。第一行创建一个新的多维数组。第二行将重复的引用压入堆栈,第三行调用我们的 Recorder.record(…) 方法,该方法将重复的引用从堆栈中弹出。

最后,我们有 visitIntInsn:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

此方法处理创建基元数组和多维数组的字节码操作。 if 语句标识这些操作,它的主体确保执行原始指令,然后在堆栈上复制数组对象引用,然后我们调用 Recorder.record(…) 将其弹出。

现在让我们换个思路,查看 ASM 代码以生成我们的自定义 java.lang.ClassLoader 方法。如前所述,我们需要定义自己的 java.lang.ClassLoader 来记录定义的类。有一个 ClassLoaderGenerator 类,它完成从目标 JRE 的 rt.jar 文件中提取 java.lang.ClassLoader 类的繁重工作,但让我们深入了解 ClassLoaderClassVisitor 中的 ASM 代码。此类中的大部分代码都不是特别有趣。让我们直接进入其 MethodVistor 类的 visitMethodInsn 方法:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

第 3 行调用原始指令。第 5-6 行的 if 语句将指令标识为 defineClass 方法。 defineClass 方法(即 defineClass0 defineClass1 defineClass2 )是返回 java.lang.Class 对象的本机方法。通过捕获这些调用,我们可以捕获创建类的时间。第 10-13 行创建一个局部变量来存储 java.lang.Class 对象,创建对 Recorder.record(…) 的 调用,并将 Class 放回堆栈。仅供参考 在其他 ASM 代码中,我使用 dup 指令复制引用,但是当我运行代码时它不合作,这导致我改用局部变量。

我们现在已经涵盖了所有必需的仪器。另一个要记录的主要概念是 PhantomReference 的使用。我们已经讨论过 PhantomReference 如何在对象被垃圾回收时通知我们,但这如何帮助我们跟踪内存泄漏?如果我们使用 PhantomReference s 来引用每个应用程序对象,如果对象定期被垃圾收集,我们就可以将对象消除为泄漏对象。剩余的对象集成为我们的候选集。如果我们可以观察到特定类型的对象数量增加的趋势,那么我们很可能已经确定了泄漏。您应该注意到,这些趋势在主要垃圾收集之外持续存在,甚至更有可能出现漏洞。但是,这段代码此时不考虑垃圾回收。

我们现在将返回 Recorder 类来检查 PhantomReference 功能。记录方法有以下代码:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

第一行引用一个名为 dataStore 的变量。数据存储是一个实现细节。我已经实现了一个内存数据存储,但我想专注于 PhantomReference s,所以我们暂时忽略这些细节。 dataStore BufferedDataStore 的一个实例,它具有以下方法签名:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

此方法将新实例化的对象作为参数以及对象创建的时间。该方法返回一个 long 值,表示对象类型的唯一标识符。下一步是创建 PhantomReference 。我们将 ReferenceQueue 传递给 PhantomReference 的构造函数,该构造函数将其注册为我们希望在垃圾收集时收到通知的对象。最后,我们将引用及其关联的类 ID 存储在映射中。查看监听队列的代码后,这些行将更有意义


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

这是一个在 Recorder 类中定义的类。它是一个守护线程,这意味着即使它还活着,它也不会阻止 JVM 退出。 run 方法包含一个 while 循环,它将永远运行,除非调用 stop 方法来更改 alive 属性。 ReferenceQueue.remove() 方法会阻塞,直到有要删除的 PhantomReference 为止。一旦 PhantomReference 出现,我们从地图中查找 classId 。然后我们通过调用 dataStore objectGcEvent 方法记录事件。

我们现在已经介绍了如何检测应用程序类以插入 Recorder.record(…) 方法、如何为这些对象创建 PhantomReference 以及如何响应它们的垃圾收集事件。我们现在可以记录对象的创建时间和垃圾回收时间。建立此核心功能后,您可以通过多种方式实施检漏仪。此代码库使用内存数据存储。这种类型的数据源消耗与您的应用程序相同的堆空间来存储数据,因此不建议用于长期泄漏检测(换句话说,它本身就是内存泄漏!)。长期检测的一个更明智的选择是将数据存储在真实的数据库中。

该检漏仪的另一个方面是识别泄漏的方法。一些检漏仪可能会告诉您“我发现了泄漏!”,但这个不会。它为您提供了一个顶级泄漏候选者的图表,并允许用户评估哪些对象实际上是泄漏的。这意味着您必须主动发现泄漏。但是,可以很容易地改进此代码。您可以开发一种算法来隔离内存泄漏,以响应方式通知用户。还有其他可能的改进。该工具的用户界面是最有可能泄漏的对象的折线图。当有足够的内存时,对象计数攀升是很正常的。因此,一项改进是在图表上记录和绘制主要垃圾收集。知道潜在泄漏的对象在垃圾回收中幸存下来是内存泄漏的一个很好的指标。

这个项目中有很多代码我们没有涉及。例如,没有涵盖查找趋势、绘制数据图表或如何查询数据的代码。本文的目的是演示如何收集内存泄漏数据,这使得不相关的代码超出了范围。但是,我们将讨论另外一个主题,即如何配置和运行该软件。

目标 JVM 中代理使用的数据存储类型由配置文件中的 数据存储类 属性确定。目前,只有内存中实现 ca.discotek.heapster.datastore.MemoryDataStore ,用于存储泄漏数据。事实上,这是一个糟糕的想法,因为它本身就是一个漏洞。它没有驱逐政策,最终会导致 OutOfMemoryError 。当 MemoryDataStore 初始化时,它会设置一个服务器套接字,客户端可以使用它来请求数据。 MemoryDataStore 使用配置文件获取服务器端口号。它还使用它来设置日志级别(您可能不需要调整,但有效值为 trace info warn error 。)。 inclusion 属性是一个 Java 语言正则表达式,用于指定要检测内存泄漏的应用程序类。您还可以指定 排除 属性以从 包含 属性中排除名称空间。

要连接到您的服务器,您需要运行一个客户端。有一个通用的 ca.discotek.heapster.client.gui.ClientGui 类,它在配置文件中查找 客户端类 属性。它实例化一个实例并使用它与服务器通信。由于我们的代理配置为使用 MemoryDataStore 类,我们希望我们的客户端通过 ca.discotek.heapster.client.MemoryClient 连接到 MemoryDataStore 服务器。 MemoryClient 类在配置文件中查找服务器端口。为了使我的配置简单,我将服务器和客户端属性都放在一个 test.cfg 配置文件中。如果您的目标 JVM 与您的客户端位于不同的机器上,则您必须有单独的配置文件。这是我一直在使用的:
我创建了一个 ca.discotek.heapster.client.MemoryClient 类。本客户端使用配置文件查找端口


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

inclusion 属性指定名为 LeakTester 的测试应用程序的名称空间,它可以以各种方式创建各种类型的对象。这是一个屏幕截图:

为了覆盖 JVM 的 java.lang.ClassLoader ,我们将生成我们自己的引导程序 jar 并使用 -Xbootclasspath/p JVM 标志在引导类路径的末尾插入我们的引导程序 jar。我们将不得不为目标 JVM 的不同 JRE 版本执行此任务。如果您将 JRE X 中生成的 ClassLoader 类与 JRE Y 一起使用,则版本之间可能存在内部 API 更改,这会破坏兼容性。

假设您已经下载了 内存泄漏分发包 并将其提取到 /temp/heapster 。我们还假设您的目标 JVM JRE 版本是 1.6.0_05。首先,将创建目录 /temp/heapster/1.6.0_05 来存放我们即将生成的 jar。接下来我们将运行以下命令:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

第二个和第三个程序参数指定目标 JVM 的 rt.jar 的位置以及要存储生成的 jar 的位置。此命令将在 /temp/heapster/1.6.0_05 中生成一个 heapster-classloader.jar

假设您想要运行与该项目捆绑在一起的 LeakTester 应用程序,您将运行以下命令:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }

接下来,让我们运行客户端:


 public class LeakingInterceptor extends HandlerInterceptorAdapter {

static List<ImageEntry> lastUsedImages = Collections.synchronizedList(new LinkedList<ImageEntry>());

private final byte[] imageBytes;

public LeakingInterceptor(Resource res) throws IOException { imageBytes = FileCopyUtils.copyToByteArray(res.getInputStream()); }

@Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { byte[] image = new byte[imageBytes.length]; System.arraycopy(imageBytes, 0, image, 0, image.length); lastUsedImages.add(new ImageEntry(image)); return true; } }


您现在应该看到一个与 上面的屏幕截图 类似的窗口。但是,该屏幕截图使用的是 Plumbr 示例应用程序,而不是我的 LeakTester 应用程序。如果您想查看 Plumbr 示例应用程序的图表,您可以执行以下操作:

  1. 按照他们的说明运行 Plumbr 示例应用程序。
  2. 在编辑器中打开 demo/start.bat 文件。
  3. 在靠近底部的 Java 命令行中,将 -agentlib:plumbr -javaagent:..\..\plumbr.jar -Xbootclasspath/p:/temp/heapster/1.6.0_05/heapster-classloader.jar -javaagent:/ temp/heapster/discotek-heapster-agent.jar=/temp/heapster/config/test.cfg
  4. 保存您的更改。
  5. 在编辑器中打开 /temp/heapster/config/test.cfg。
  6. 包含 属性更改为 inclusion=.*petclinic.*
  7. 保存您的更改。
  8. 像以前一样运行 Plumbr 示例应用程序。
  9. 使用与我们在 LeakTester 场景中使用的完全相同的命令行启动 ClientGui。

请注意,您可以通过两种方式使用 Plumbr 演示生成流量:1. 使用 create_usage.bat 通过 JMeter 驱动流量,或 2. 在浏览器中打开应用程序 (http://localhost:18080)。我建议您使用浏览器,这样您就可以控制流量并观察每次页面刷新的结果。

本文介绍了如何使用检测和 PhantomReferences 发现内存泄漏。它并不意味着是一个完整的产品。可以添加以下功能来改进项目:

  1. 在图表上指示主要的垃圾收集
  2. 允许在实例化泄漏对象以揭示有问题的源代码时收集堆栈跟踪
  3. 将实例化和垃圾收集数据存储在数据库中,以避免应用程序本身成为内存泄漏
  4. ClientGui 可以绘制可用堆和永久生成图(类似于 JConsole)(这可能有助于交叉引用对象图)
  5. 提供一种清除实例化和垃圾收集数据的机制

如果您喜欢这篇文章并想阅读更多内容,请参阅 Discotek.ca 上的其他字节码工程文章或 在 Twitter 上关注 Discotek.ca, 以便在我的下一篇关于如何检测类以收集性能统计信息的文章准备就绪时收到通知!

资源