Java的字符串垃圾回收:为什么会消耗这么多内存

4

已解决

我试图理解为什么我的一个单元测试会消耗如此多的内存。我所做的第一件事是运行该测试并使用VisualVM进行测量:

enter image description here

最初的平直线是由于测试开始时使用了Thread.sleep(),以便VisualVM有足够的时间启动。
测试(以及设置方法)非常简单:
@BeforeClass
private void setup() throws Exception {
    mockedDatawireConfig = mock(DatawireConfig.class);
    when(mockedDatawireConfig.getUrl()).thenReturn(new URL("http://example.domain.fake/"));
    when(mockedDatawireConfig.getTid()).thenReturn("0000000");
    when(mockedDatawireConfig.getMid()).thenReturn("0000000");
    when(mockedDatawireConfig.getDid()).thenReturn("0000000");
    when(mockedDatawireConfig.getAppName()).thenReturn("XXXXXXXXXXXXXXX");
    when(mockedDatawireConfig.getNodeId()).thenReturn("t");

    mockedVersionConfig = mock(VersionConfig.class);
    when(mockedVersionConfig.getDatawireVersion()).thenReturn("000031");

    defaultCRM = new ClientRefManager();
    defaultCRM.setVersionConfig(mockedVersionConfig);
    defaultCRM.setDatawireConfig(mockedDatawireConfig);
}

@Test
public void transactionCounterTest() throws Exception {
    Thread.sleep(15000L);
    String appInstanceID = "";
    for (Long i = 0L; i < 100000L; i++) {
        if (i % 1000 == 0) {
            Assert.assertNotEquals(defaultCRM.getAppInstanceID(), appInstanceID);
            appInstanceID = defaultCRM.getAppInstanceID();
        }
        ReqClientID r = defaultCRM.getReqClientID(); // This call is where memory use explodes.
        Assert.assertEquals(getNum(r.getClientRef()), new Long(i % 1000));
        Assert.assertEquals(r.getClientRef().length(), 14);
    }
    Thread.sleep(10000L);
}

这个测试非常简单:迭代10万次,以确保defaultCRM.getReqClientID()生成一个带有有效计数器的正确的ReqClientID对象(计数器在000-999之间),并且随机化前缀在翻转时能够正确更改。 defaultCRM.getReqClientID()是出现内存问题的地方。让我们来看一下:
public ReqClientID getReqClientID() {
    ReqClientID req = new ReqClientID();
    req.setDID(datawireConfig.getDid()); // #1
    req.setApp(String.format("%s&%s", datawireConfig.getAppName(), versionConfig.toString())); // #2
    req.setAuth(String.format("%s|%s", datawireConfig.getMid(), datawireConfig.getTid())); // #3

    Long c = counter.getAndIncrement();
    String appID = appInstanceID;
    if(c >= 999L) {
        LOGGER.warn("Counter exceeds 3-digits. Resetting appInstanceID and counter.");
        resetAppInstanceID();
        counter.set(0L);
    }
    req.setClientRef(String.format("%s%s%03dV%s", datawireConfig.getNodeId(), appID, c, versionConfig.getDatawireVersion())); // #4
    return req;
}

很简单:创建一个对象,调用一些String的设置器,计算一个递增的计数器,并在翻转时加上随机前缀。
假设我注释掉了上面编号为#1-#4的设置器(和相关的断言,以免它们失败)。现在内存使用是合理的。

enter image description here

最初我在setter组件中使用简单的字符串拼接,使用+。我改用String.format(),但这并没有产生任何效果。我还尝试过StringBuilderappend(),但也没有效果。

我还尝试了一些GC设置。特别是,我尝试了-XX:+UseG1GC-XX:InitiatingHeapOccupancyPercent=35-Xms1g -Xmx1g(请注意,在我的构建服务器上,1g仍然不合理,我希望将其降至最大256m左右)。下面是图表:

enter image description here

当使用 -Xms25m -Xmx256m 时,会导致 OutOfMemoryError。

我对此行为感到困惑,有三个原因。首先,我不理解第一个图表中未使用堆空间的极端增长。我创建了一个对象,创建了一些字符串,将这些字符串传递给对象,然后通过让它超出范围来删除对象。显然,我不希望内存完美重用,但为什么 JVM 看起来每次都要为这些对象分配更多的堆空间呢?未使用的堆空间增长得如此之快,似乎真的很不对。特别是在更积极的 GC 设置下,我期望看到 JVM 在浪费大量内存之前尝试回收这些完全未引用的对象。

其次,在第二个图表中,问题实际上在于字符串。我已经尝试阅读有关组成字符串、字面/内部化等方法的文章,但除了 +/String.format()/StringBuilder 之外,我似乎没有看到更多的替代方案,它们似乎都会产生相同的结果。我错过了一些神奇的构建字符串的方式吗?

最后,我知道100K次迭代有些过分了,我可以用2K次测试回绕,但我正试图理解JVM中正在发生的事情。

系统:OpenJDK x86_64 1.8.0_92和Hotspot x86_64 1.8.0_74。

编辑:

有几个人建议在测试中手动调用System.gc(),所以我尝试每1K次循环执行一次。这对内存使用有明显影响,并对性能产生可悲的影响:

enter image description here

需要翻译的内容:

首先需要注意的是,虽然使用的堆空间增长速度较慢,但它仍然是不受限制的。唯一完全停止增长的时间是循环完成后,并调用了结束的Thread.sleep()。有几个问题:

1)为什么未使用的堆空间仍然如此之高?在第一个循环迭代期间,调用了System.gc()i%1000 == 0)。这实际上导致了未使用的堆空间下降。为什么第一次调用后总堆空间从未减少?

2)粗略地说,每个循环迭代执行5次分配:inst ClientReqId和4个字符串。每个循环迭代都会忘记对所有5个对象的引用。整个测试过程中,总对象基本保持不变(只有大约±5个对象)。我仍然不明白为什么当活动对象数量保持恒定时,System.gc()不能更有效地保持已使用的堆空间恒定。

编辑2:已解决

@Jonathan问我关于mockedDatawireConfig的问题,他指引我到了正确的方向。这实际上是一个Spring @ConfigurationProperties类(即Spring从yaml加载数据到实例并将实例连接到需要它的地方)。在单元测试中,我没有使用与Spring相关的任何内容(单元测试,而不是集成测试)。在这种情况下,它只是一个带有getter和setter的POJO,但类中没有逻辑。

无论如何,单元测试使用的是该对象的模拟版本,您可以在上面的setup()中看到。我决定切换到对象的真实实例而不是模拟。这完全解决了问题!这似乎是Mockito的一些问题,可能是固有的问题,或者因为我似乎正在使用2.0.2-beta。我将进一步调查并联系Mockito开发人员,如果它确实是未知问题。

看看那个美丽的图表:

enter image description here


我的第一个猜测是,您为每个迭代创建了一个req对象,而不是重用它或从池中获取它。然后,迭代速度太快,垃圾回收无法清理未使用的对象。您是否尝试在函数中添加garbagecollect()? - avk
意图是每次创建一个新对象。这就是单元测试的目的。我尝试过 System.gc()(在每个循环和每个1K循环中都尝试了),但它没有太大的效果。问题是为什么 ClientReqID r 及其字符串在每个循环后都没有被释放。 - fandingo
关于功能正确性的一个注释:您在getRequestID中的循环逻辑不是线程安全的。我认为这是一个问题,因为您使用了原子操作。在if(c>=999)中,如果两个线程同时访问该代码,则counter.set(0L)可能会引发多次使用的问题。更好的方法是使用counter.compareAndSet(c, 0L) - Jonathan
@Jonathan:“引发异常的是这4行中的哪一行?” 它们都会引发异常。不是因为X行添加了一定量,而是更像是如果有1个活动(3个被注释掉),它会导致大幅增加,但可能是其中任何一个。取消注释更多的字符串行显然会导致更多的内存使用,但是以递减的方式进行。 - fandingo
也许 datawire.Config.getDid() 做了什么不好的事情?这是实际对象中的一个简单字符串 getter(没有逻辑),但是这个对象被 Mockito 模拟了。请参见我原来问题中的 setup()。我其实很好奇 Mockito 和 javac 会如何处理这些字面量。它们会在常量字符串池中(因为它们在编译时已知),或者 Mockito 可能会保留实例。我将尝试使用真实对象而不是模拟对象... - fandingo
显示剩余7条评论
1个回答

0

嗯,这取决于JVM的实现方式来分配堆空间。它只是看到内存消耗的巨大(而且快速!)增长,因此分配足够的堆空间以避免出现OutOfMemoryException。

你已经看到,通过调整参数可以改变这种行为。你还可以看到一旦使用量稳定,堆就不再继续增长(它在约3G处停止增长,而不是增长到约8G)。

要真正了解发生了什么,你不应该进行printf调试(即注释掉一些内容并观察发生了什么),而是使用你的IDE或其他工具来检查谁在使用你的内存。

这样做将向你展示(例如):120k个String实例占用2GiB或1.5GiB的垃圾和500MiB的字符串。
然后你就清楚地知道它是否只是一个懒散的集合(因为集合有开销),或者你是否还有一些引用仍然存在(我会说没有,因为增长停止了)。

作为一种不太正规的解决方法,你也可以在循环中添加System.gc()调用来强制进行垃圾回收,以查看它是否改善了堆使用情况(当然会牺牲CPU时间)。


不要使用VisualVM,而应该使用您的IDE或其他工具来检查谁在使用您的内存。不幸的是,VisualVM将大部分分配归因于 java.lang.Object[]short[]-- 没什么用处。我尝试添加了 System.gc(),它有一些作用,但使用的堆空间仍然增长到不合理的数量。同一时间只有1个 ClientReqID 是活动的,并且它有一些字符串被分配。虽然已使用的堆可能呈现平台期,但它仍然太高,并且给出一个恒定对象列表的情况下,它变得困惑和增加。 - fandingo
1
还有一件事情:“堆不再增长(在 ~3G 停止而不是增长到 ~8G)。”在 100K 循环期间,内存使用并没有达到平稳状态。平稳状态出现在测试结束时的 10 秒 Thread.sleep() 中(这样我就可以在 VisualVM 中进行堆转储和其他调试)。 - fandingo

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