Java单元测试:如何为方法调用测量内存占用

48
假设我有一个进行一些重度处理的类,操作多个集合。我想要做的是确保这种操作不会导致内存溢出,或者更好的是,我想设置一个内存使用量的阈值。

假设我有一个进行一些重度处理的类,操作多个集合。我想要做的是确保这种操作不会导致内存溢出,或者更好的是,我想设置一个内存使用量的阈值。

class MyClass()
{
   public void myMethod()
   {
      for(int i=0; i<10000000; i++)
      {
         // Allocate some memory, may be several collections
      }
   }
}

class MyClassTest
{
   @Test
   public void myMethod_makeSureMemoryFootprintIsNotBiggerThanMax()
   {
      new MyClass().myMethod(); 
      // How do I measure amount of memory it may try to allocate?
   }
}

哪种方法是正确的?或者这不可能/不可行吗?


@Steve P.:获取总体内存使用量无法告诉您内存用于什么 - Enno Shioji
2
没错,但我可以设置要求,比如“该算法不得消耗超过100KB的RAM,不应取决于要处理的数据大小”。目的是通过创建显式的单元测试来强制执行这一要求。 - Sergey Makarov
但是,为什么要设置这样的要求呢?资源很便宜,Java 就是针对这一事实而设计的。在你真正遇到问题之前,你不应该担心内存消耗。除此之外,即使你找到了一种测量内存的方法,你仍然没有办法确保你正确地解释了结果,并且创建了一个真实的工作环境来产生真实的结果。你只会得到“一个数字”,并不会更明智。 - Gimby
10
如果一些好奇的开发人员试图测量两种方法,如果不在真实的工作环境中进行测试是否有害?我想不是。一些方法在任何情况下通常需要比其他方法少得多的内存/CPU资源。这样的分析至少可以提供一个大致的想法。 - prash
6
“资源便宜”这个说法有些自以为是,因为你不知道该解决方案的基础设施和人员预算。” - Alex R
8个回答

27
您可以使用分析器(例如JProfiler)查看各个类的内存使用情况。或者,就像Areo提到的那样,只需打印内存使用情况:

您可以使用分析器(例如JProfiler)查看各个类的内存使用情况。或者,就像Areo提到的那样,只需打印内存使用情况:

    Runtime runtime = Runtime.getRuntime();
    long usedMemoryBefore = runtime.totalMemory() - runtime.freeMemory();
    System.out.println("Used Memory before" + usedMemoryBefore);
        // working code here
    long usedMemoryAfter = runtime.totalMemory() - runtime.freeMemory();
    System.out.println("Memory increased:" + (usedMemoryAfter-usedMemoryBefore));

3
给更多读者的小提示:这些数值以字节为单位给出,将其除以1000000即可得到以MB为单位的数值。 - Marc_Alx
@pasha701 使用这种方法存在一个小问题,runtime.totalMemory() 给出的是 JVM 在该实例中分配的内存。在执行工作代码后,随着时间的推移,它可能会增加。在这种情况下,(usedMemoryAfter-usedMemoryBefore) 在某些情况下甚至可能给出负值。 - 95_96
1
@Marc_Alx:确切地说应该是1024*1024 == 1048576。 - MD. Mohiuddin Ahmed

27
我可以想到几种选择:
  • 通过微基准测试(即jmh)找出您的方法需要多少内存。
  • 根据启发式估计构建分配策略。有几个开源的解决方案实现了类大小估算,例如ClassSize。一个更简单的方法是利用缓存释放很少使用的对象(例如Guava的Cache)。正如@EnnoShioji所提到的,Guava的缓存具有基于内存的驱逐策略。

您还可以编写自己的基准测试来计算内存。思路是:

  1. 只运行单个线程。
  2. 创建一个新数组来存储要分配的对象。因此这些对象在GC运行期间不会被回收。
  3. System.gc()memoryBefore = runtime.totalMemory() - runtime.freeMemory()
  4. 分配您的对象。将它们放入数组中。
  5. System.gc()memoryAfter = runtime.totalMemory() - runtime.freeMemory()

这是我在我的轻量级微基准测试工具中使用的一种技术,可以以字节精度测量内存分配。


自定义方式仍然是一种近似方法。即使在调用前后运行GC,如果测试调用具有内存密集型特性,则可能在这两个调用之间调用young GC,即使将所有RAM分配为堆。此外,不同的JVM和不同的GC(例如G1与标记和扫描非常不同),因此我不知道结果会有多可靠...我喜欢自定义GC的想法。如果JMH可以做到,最好使用它(正如您所说)。 - fabien
1
在实践中,在分配内存之前多次调用System.gc()可能会有所帮助。显然,确切的行为也取决于JVM和GC。 - Christian Grün

5

要测量当前内存使用情况,请使用:

Runtime.getRuntime().freeMemory()Runtime.getRuntime().totalMemory()

这里有一个很好的例子:获取操作系统级别的系统信息

但是这种测量并不精确,但它可以给你很多信息。 另一个问题是GC是不可预测的。


3
这是Netty的一个示例,与您类似:MemoryAwareThreadPoolExecutor。Guava的cache class也有基于大小的淘汰机制。您可以查看这些源代码并复制它们正在做的事情。特别是,这是Netty如何估算对象大小的方法。实质上,您将估算方法中生成的对象的大小并保持计数。
获取整体内存信息(例如可用/已使用堆的数量)将帮助您决定为该方法分配多少内存使用量,但无法跟踪单个方法调用使用了多少内存。

话虽如此,但您真正需要这个的情况非常罕见。在大多数情况下,通过限制给定时刻可以存在多少对象(例如使用有界队列)来限制内存使用量是足够好的,并且要简单得多。


2
由于Java在处理过程中可能会分配大量短暂的对象,这些对象随后将在垃圾回收期间被收集,因此这个问题有点棘手。在被接受的答案中,我们无法确定垃圾回收是否在任何给定时间运行。即使我们引入一个循环结构,带有多个System.gc()调用,在我们的方法调用之间可能会运行垃圾回收。
更好的方法是使用https://cruftex.net/2017/03/28/The-6-Memory-Metrics-You-Should-Track-in-Your-Java-Benchmarks.html中建议的某种变体,触发System.gc(),但我们还等待报告的GC计数增加:
long getGcCount() {
    long sum = 0;
    for (GarbageCollectorMXBean b : ManagementFactory.getGarbageCollectorMXBeans()) {
        long count = b.getCollectionCount();
        if (count != -1) { sum += count; }
    }
    return sum;
}

long getReallyUsedMemory() {
    long before = getGcCount();
    System.gc();
    while (getGcCount() == before);
    return getCurrentlyAllocatedMemory();
}

long getCurrentlyAllocatedMemory() {
    final Runtime runtime = Runtime.getRuntime();
    return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
}

这仍然只是您的代码在特定时间实际分配的内存的近似值,但该值通常更接近于人们通常感兴趣的值。

1

估算内存使用最简单的方法是使用Runtime类中的方法。

我建议不要依赖它,只用于近似估计。理想情况下,您应该仅记录此信息并自行分析,而不使用它来自动化测试或代码。

可能它并不是非常可靠,但在封闭环境(如单元测试)中,它可能会给您接近实际的估计。
特别是在调用System.gc()垃圾回收器时,不能保证它会在我们期望的时间运行(这只是对GC的建议),freeMemory方法存在精度限制,如https://dev59.com/GWQm5IYBdhLWcg3wuhHS#17376879所述,还可能存在更多注意事项。

解决方案:

private static final long BYTE_TO_MB_CONVERSION_VALUE = 1024 * 1024;

@Test
public void memoryUsageTest() {
  long memoryUsageBeforeLoadingData = getCurrentlyUsedMemory();
  log.debug("Used memory before loading some data: " + memoryUsageBeforeLoadingData + " MB");
  List<SomeObject> somethingBigLoadedFromDatabase = loadSomethingBigFromDatabase();
  long memoryUsageAfterLoadingData = getCurrentlyUsedMemory();
  log.debug("Used memory after loading some data: " + memoryUsageAfterLoadingData + " MB");
  log.debug("Difference: " + (memoryUsageAfterLoadingData - memoryUsageBeforeLoadingData) + " MB");
  someOperations(somethingBigLoadedFromDatabase);
}

private long getCurrentlyUsedMemory() {
  System.gc();
  return (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / BYTE_TO_MB_CONVERSION_VALUE;
}

1
这并不是非常准确的,因为System.gc()只是一个“请求”,并不会立即发生。你永远不知道gc真正会在什么时候运行。 - R.A
@R.A 是的,你说得对。我在答案中也提到了这个方法。这种方法很简单,不需要任何额外的工具,但不幸的是它可能并不准确。我曾经遇到过这样的情况,看起来它能够正常工作,但那只是一个非常简单的应用程序,我的发现仅基于我的观察,并没有证明它的准确性。 - luke

1

这是一个运行内存使用情况的示例代码,它在单独的线程中运行。由于GC可能会在进程运行时随时触发,因此它将每秒记录内存使用情况并报告最大内存使用情况。

runnable 是需要测量的实际进程,runTimeSecs 是预期进程运行时间。这是为了确保计算内存的线程不会在实际进程之前终止。

public void recordMemoryUsage(Runnable runnable, int runTimeSecs) {
    try {
        CompletableFuture<Void> mainProcessFuture = CompletableFuture.runAsync(runnable);
        CompletableFuture<Void> memUsageFuture = CompletableFuture.runAsync(() -> {


            long mem = 0;
            for (int cnt = 0; cnt < runTimeSecs; cnt++) {
                long memUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
                mem = memUsed > mem ? memUsed : mem;
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            ;
            System.out.println("Max memory used (gb): " + mem/1000000000D);
        });

        CompletableFuture<Void> allOf = CompletableFuture.allOf(mainProcessFuture, memUsageFuture);
        allOf.get();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

0
许多其他答案警告GC是不可预测的。然而,自Java 11以来,JVM中已经包含了Epsilon垃圾收集器,它不执行GC。
指定以下命令行选项以启用它:
-XX:+UnlockExperimentalVMOptions 
-XX:+UseEpsilonGC

这样你就可以确保垃圾回收不会干扰内存计算。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接