JConsole定位内存泄漏

在本文中,我们将看到Java中内存泄漏的示例代码。之后,我们将把Java应用程序连接到JConsole,比较有无内存泄漏时应用程序的内存使用情况。深入研究JConsole的内存监控工具可以让我们看到堆内存是如何划分为不同的空间的,垃圾收集器是如何有效地管理Java应用程序的内存的。

Java中的垃圾回收

在Java中,垃圾收集器(GC)负责释放未使用对象使用的内存。任何没有引用的对象都可以进行垃圾收集,如下图所示。因此,GC中的第二个对象可以被设置为空。

什么是Java内存泄漏

当程序无法释放未使用的内存时,会导致内存泄漏,这可能导致意外结果或应用程序崩溃。尽管Java中没有关于内存泄漏的正式定义,但为了便于理解,我们可以将其大致分为两类。

  • 运行代码无法访问的对象导致的内存泄漏。
  • 可通过运行代码访问但不会再次使用的对象导致的内存泄漏。

第一种内存泄漏发生在对对象的引用不再出现在正在运行的代码中,但垃圾收集器仍然无法为这些对象释放空间时。

第二种内存泄漏主要是由于程序中的错误逻辑造成的,在该程序中,对未使用对象的引用保留在运行代码中(即使该对象将不再被使用),这不允许GC回收内存。一些开发人员将此泄漏视为“实际内存泄漏”,因为将引用设置为null将允许GC回收内存。但是,如果代码已经部署,则无法将未使用对象的引用设置为null。在下面的例子中,我们将考虑这种泄漏。

Java程序内存泄漏示例

让我们考虑一个现实生活中的例子,狗收容所的狗被添加到收容所,并从收容所移除时,他们被领养。

这个 Dog.java 类有下面描述的三个变量:

microchip ID
name
/** The Unique MicroChip ID of the dog. */
    private int microChipID;
    /** The name of the dog. */
    private String name;
    /** Extra memory space for each instance to speed up the memory leak. */
    private byte[] toExpediteLeak;

下面是可能导致内存泄漏的有问题的代码段。在 overrided equals 方法中,我们通过将 microchip IDDog name 相等来比较两个 Dog 对象。由于狗的名字可以更改,因此使用它来等同于两个狗对象可能会导致意外的结果。例如,如果存储在 HashSet 中的 Dog 对象是使用旧名称存储的,并且我们尝试使用新名称删除它,那么删除该 Dog 对象将失败。

@Override
    public boolean equals(Object obj) {
        if (obj == this)
            return true;
        if (!(obj instanceof Dog))
            return false;

        Dog dog = (Dog) obj;
        return dog.microChipID == microChipID && dog.name.equals(name);
    }

这个狗狗庇护所 DogShelter.java 类负责将目前在收容所的狗的名单保存在一个HashSet中,如下所示。

/** In Memory Store containing the dogs present in the shelter. */
    private Set shelterDogs = new HashSet();

这个类公开了两个公共方法,可以用来添加和删除收容所中的狗。

public void addEntry(int microChipID, String name) {
        Dog dog = new Dog(microChipID, name);
        shelterDogs.add(dog);
    }

public void removeEntry(int microChipID) {
        Dog dog = new Dog(microChipID);
        shelterDogs.remove(dog);
    }

请注意,我们使用 Microchip IDname 添加 dog 对象,而仅使用 Microchip ID 删除 dog 对象。但是,要从集合中实际删除dog对象,我们需要同时提供 Microchip IDdog 名称,因为重写 equals 方法时使用的逻辑错误。

最后,为了触发内存泄漏,我们将在 HashSet 中连续添加和删除 Dog 对象。

public void addAndRemoveRandomEntries(int entriesCount) {
        Random rand = new Random();
        String[] commonDogNames = { "Buddy", "Coco", "Charlie", "Cooper", "Maggie" };

        for (int i = 0; i <= entriesCount; i++) {
            /** Generate a random dog name from the list of common dog names. */
            String randomDogName = commonDogNames[rand.nextInt(commonDogNames.length)];
            /** First add and then remove the entry from the HashSet. */
            addEntry(i, randomDogName);
            removeEntry(i);
            System.out.printf("Successfuly removed entry for %s with unique id %d.\n", randomDogName, i);
            try {
                /**
                 * Sleep before adding & removing new entry so that we can see the memory grow
                 * in JConsole.
                 */
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

让我们运行 main 方法,看看我们的程序是如何在几秒钟内崩溃的

public static void main(String[] args) {
        DogShelter shelter = new DogShelter();
        shelter.addAndRemoveRandomEntries(1000);
    }

如何使用JConsole监控Java应用程序的内存使用

在运行 main 方法之后,打开JConsole并将其连接到本地Java进程。

将JConsole连接到本地Java进程之后,如果单击Memory选项卡,您将看到如下所示的窗口。让我们深入讨论一下内存工具的每个特性。

堆VS非堆空间

在JConsole窗口的右下角,我们可以看到几个代表当前内存使用百分比的条形图。内存空间分为堆空间和非堆空间。

堆空间

使用new关键字创建的对象会落在堆空间中。在一个典型的Java程序中,由于不断地创建和删除新对象,我们需要一种机制来恢复未使用对象的内存。垃圾收集器负责实际释放这些未使用对象的内存,为了有效地执行此过程,堆内存被划分为3个不同的空间。

1. Eden 伊甸园空间-新创建的对象出现在伊甸园空间中。

2. Survivor 幸存者空间–伊甸园空间中在垃圾收集中幸存的对象被提升到幸存者空间。

3. Old Gen–在幸存者空间中的垃圾收集中存活很长时间的对象被提升为Old Gen或Tenured Generation。

GC在老年代空间中运行的频率较低,因为老年代的对象生活在该空间中。而GC在Eden空间上运行得更频繁,因为新对象更容易被取消引用。堆内存的这种分离允许有效的内存管理。

非堆空间

非堆空间分为元空间、代码缓存和压缩类空间。

Metaspace存储描述用户类的元数据。所以所有与类相关的数据,比如静态方法和原语类型变量都存储在这里。在Java8之前,使用PermGen代替元空间。与PermGen相比,元空间具有以下优点:

  • 元空间使用本机内存,而不是像PermGen那样使用堆。
  • 元空间不再具有固定的大小,它会自动增加到某个限制(可以使用JVM参数指定)。而PermGen的大小是固定的。
  • 当内存达到MaxMetaspaceSize时,会触发垃圾回收以删除未使用的类定义和装入器。

代码缓存空间用于存储本机代码,以便Just-In-Time(JIT)编译器更快地执行Java程序。

了解JConsole内存图表

在JConsole窗口的顶部,您可以看到有多个内存图表。让我们分析所有堆内存图表,并比较有无内存泄漏的Java应用程序中的内存使用情况。需要注意的是,下面显示的内存图表模式是特定于这个特定Java应用程序的。

堆内存图表

堆内存图表显示了在Eden空间、幸存者空间和老年代空间中使用的组合内存。堆内存图表本身足以监视Java应用程序的内存,并可能检测内存泄漏。

内存泄漏

  • 在程序执行过程中,随着内存泄漏操作的不断重复,堆内存不断增长。
  • 如果内存泄漏的操作重复多次,程序最终将崩溃并出现 OutOfMemoryError

内存泄漏已修复

  • 堆内存的使用随着操作的多次重复而增加,并且一旦GC触发,未使用的对象所使用的内存就会被回收。
  • 如果将完全相同的操作重复多次,我们可以看到,在GC循环完成后,内存使用量几乎在同一个位置(~10mb)增加。

Eden 伊甸园空间图

内存泄漏

Dog
Dog

内存泄漏已修复

  • 内存图表与堆内存图表几乎相同,因为 Dog 对象被创建,引用被立即删除,因此它们可以进行垃圾收集。

Survivor 幸存者空间图

内存泄漏

  • 直到时间14:16,在伊甸园空间中幸存下来的狗对象被转移到幸存者空间。
  • 在14:16之后,狗对象直接从老年代空间分配内存,因此幸存者空间的内存下降。

内存泄漏已修复

  • GC从伊甸园空间本身回收内存,因此狗对象不会转移到幸存者空间,我们几乎一直在使用内存。

老年代空间图

内存泄漏

  • 直到14:16,我们看到,由于狗的对象被从伊甸园空间转移到幸存者空间,最后转移到老年代空间,步幅增加。
  • 14:16之后,我们看到内存几乎持续增长,因为Dog对象直接从老年代空间分配内存。
  • 内存可达2.6GB。

内存泄漏已修复

  • 即使内存图表看起来与内存泄漏的图表相似。然而,我们可以看到老年代空间的内存几乎没有达到6mb。

假设您有一个事件触发了一个覆盖5000多行代码的操作,并且您怀疑它有内存泄漏。检查所有代码并尝试确定您的代码是否存在内存泄漏将是非常具有挑战性和耗时的。但是,如果我们的应用程序使用jconsole可以很容易地找到主要的内存泄漏。

1. 运行Java应用程序并将JConsole连接到它。

2. 重复您怀疑内存泄漏的操作。例如,如果您怀疑向数据库中添加条目的操作可能存在内存泄漏,那么请编写一个测试脚本,该脚本将在一段时间内重复相同的操作,在此期间您可以检测到可能的内存泄漏。

3. 每隔几分钟单击JConsole窗口右上角的“perform gc”。例如,当您的操作在后台连续运行时,您可以请求在10分钟内每分钟执行一次GC。

4. 分析如下所示的结果,以确定Java应用程序是否存在内存泄漏。

结果分析

要分析结果,请在GC操作完成后连接所有点(从步骤3开始)。

如果连接的直线/曲线正在增加或具有正斜率,则会发生内存泄漏。

但是,如果连接的直线/曲线不增加,则可能没有内存泄漏。

步骤4中的行将与内存泄漏的大小成比例增加。如果内存泄漏很小,则曲线将缓慢增加。但是,如果内存泄漏很大,则线将以更大的斜率增加。因此,如果内存泄漏以千字节为单位,则使用此技术可能无法看到它。