知识库

了解内存消耗

假设你已经将 Neo4j 配置为使用 4GB 堆内存和 6GB 页面缓存(Page Cache),并高枕无忧地认为在这个 12GB 内存的机器上,Java 进程不会超过 10GB,结果却发现 Neo4j 抛出了内存溢出(OOM)错误并崩溃了。底层发生了什么?为什么 Neo4j 消耗的内存比你分配的要多?这是内存泄漏还是正常行为?疑问重重!让我们尝试回答其中一些问题,以免你在面对内存问题时措手不及。

虽然内存泄漏确实可能发生,但通常情况下,较高的内存消耗是 JVM 的正常行为。为了正常运行,JVM 需要在其他几个类别中分配更多的内存。JVM 内存最重要的类别包括:

  • 堆(Heap) - 堆是存储类实例化对象或“对象”的地方。

  • 线程栈(Thread stacks) - 每个线程都有自己的调用栈。栈存储原始局部变量和对象引用,以及调用栈(方法调用列表)本身。当栈帧移出上下文时,栈会被清理,因此这里不会执行垃圾回收(GC)。

  • 元空间(Metaspace,旧版 Java 中的 PermGen) - 元空间存储对象的类定义以及一些其他元数据。

  • 代码缓存(Code cache) - JIT 编译器将生成的本地代码存储在代码缓存中,以便通过重用代码来提高性能。

  • 垃圾回收(Garbage collection) - 为了让 GC 知道哪些对象有资格被回收,它需要跟踪对象图。因此,这是内存损耗给内部簿记工作的一部分。

  • 缓冲区池(Buffer pools) - 许多库和框架会在堆外分配缓冲区以提高性能。这些缓冲区池可用于在 Java 代码和本地代码之间共享内存,或将文件区域映射到内存中。

你最终使用的内存可能出于上述列出之外的其他原因,但我只是想让你意识到,JVM 内部结构会消耗相当大一部分内存。

我需要担心所有这些吗?

让我们换个角度来看!

在配置 Neo4j 的内存时,你可能会开始遇到许多术语,如堆内(on-heap)、堆外(off-heap)、页面缓存(page cache)、直接内存(direct memory)、操作系统内存……这些意味着什么?在配置内存时应该注意什么?首先,让我们从理解这些术语开始:

  • 堆(Heap):JVM 拥有一个堆,它是运行时数据区,所有类实例和数组的内存都从中分配。对象的堆存储由自动存储管理系统(称为垃圾回收器或 GC)回收。

  • 堆外(Off-Heap):有时堆内存是不够的,特别是当我们需要在不增加 GC 停顿的情况下缓存大量数据、在不同 JVM 之间共享缓存数据,或在内存中添加持久层以抵御 JVM 崩溃时。在上述所有情况下,堆外内存都是可能的解决方案之一。由于堆外存储仍在内存中管理,因此它比堆内存储稍慢,但仍比磁盘存储快(且不受 GC 影响)。

  • 页面缓存(Page cache):页面缓存位于堆外,用于缓存 Neo4j 数据(及本地索引)。将图数据和索引缓存到内存中有助于避免高昂的磁盘访问,从而实现最佳性能。

虽然堆和堆外是通用的 Java 术语,但页面缓存指的是 Neo4j 的原生缓存。

下图展示了这一切是如何整合在一起的:

Memory consumption in Neo4j
图 1:Neo4j 中的内存消耗

如上图所示,我们可以将 Neo4j 的内存消耗分为 2 个主要区域:堆内(On-heap)和堆外(Off-heap)。

堆内是运行时数据驻留的地方,也是查询执行、图管理和事务状态1存在的地方。

将堆设置为最佳值本身是一项棘手的任务,本文并不旨在涵盖这一点,而是为了让大家对 Neo4j 的整体内存消耗有所了解。

堆外本身可以分为 3 类。我们不仅有 Neo4j 的页面缓存(负责将图数据缓存到内存中),还有 JVM 工作所需的其他所有内存(JVM 内部结构)。你看到的剩余部分是直接内存,我们稍后会讲到。

在 Neo4j 中有三个内存设置可以配置:初始堆大小(-Xms)、最大堆大小(-Xmx)和页面缓存。实际上,前两个影响相同的内存空间,因此 Neo4j 只允许你配置上述所有内存类别中的两项。不过,这些是可以设定限制的。将最大堆设置为 4GB,页面缓存设置为 4GB,可以保证这些特定组件不会超过该限制。那么,你的进程怎么会消耗超过设定值呢?一个常见的误区是:通过设置堆和页面缓存,Neo4j 的进程内存消耗就不会超过该值,但实际上 Neo4j 的内存占用空间很可能更大。

正如我们上面看到的,JVM 需要一些额外的内存才能正常工作。例如,在高并发环境中运行意味着线程栈占用的内存等于 JVM 将要处理的并发线程数乘以线程栈大小(-Xss)。

还有一些较难发现的非堆内存使用来源,例如缓冲区池。这就是直接内存。直接字节缓冲区(Direct byte buffers)对于提高性能非常重要,因为它们允许本地代码和 Java 代码在不复制数据的情况下共享数据。然而,这是昂贵的,这意味着字节缓冲区通常在创建后会被重用。因此,一些框架会将它们保留在进程生命周期中。Netty 就是一个例子。Neo4j 使用 Netty(一个用于快速开发可维护的高性能协议服务器和客户端的异步事件驱动网络应用程序框架,https://netty.java.net.cn/),它是直接内存的主要使用者。它主要用于缓冲和 IO,并被 Neo4j 中的多个组件使用。

在几乎所有情况下,你的直接内存使用量都不会增长到出现问题的水平,但在非常极端和苛刻的使用场景下(例如:拥有大量的并发访问和更新),你可能会开始看到 Neo4j 进程消耗的内存远超我们的配置,或者你可能会收到关于直接内存的内存溢出错误。

2018-11-14 09:32:49.292+0000 ERROR [o.n.b.t.SocketTransportHandler] Fatal error occurred when handling a client connection: failed to allocate 16777216 byte(s) of direct memory (used: 6442450944, max: 6442450944) failed to allocate 16777216 byte(s) of direct memory (used: 6442450944, max: 6442450944)
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 6442450944, max: 6442450944)
at io.netty.util.internal.PlatformDependent.incrementMemoryCounter(PlatformDependent.java:624)
at io.netty.util.internal.PlatformDependent.allocateDirectNoCleaner(PlatformDependent.java:578)
at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:718)
at io.netty.buffer.PoolArena$DirectArena.newChunk(PoolArena.java:707)
...

这些症状与直接内存增长有关。虽然我们不管理 Netty 使用的内存,但有一种方法可以通过 JVM 设置来限制 Neo4j(及任何 Java 进程)可以使用的直接内存:-XX:MaxDirectMemorySize。这需要与 neo4j.conf 文件中的 dbms.jvm.additional=-Dio.netty.maxDirectMemory=0 配合使用。这将强制 Netty 使用直接内存设置,从而有效地限制其增长空间。

这些是敏感设置,会影响 Neo4j 的正常功能。请勿在未咨询 Neo4j 专业人员的情况下更改这些设置。如果你在直接内存方面遇到问题,只需提交支持工单,我们将尽力为你提供建议。

索引

根据你使用的是 Lucene 索引还是本地(native)索引,它们所占用的内存将位于不同的地方。如果你使用的是 Lucene 索引,它们将位于堆外,我们无法控制它们使用的内存。在上图中,它们将与页面缓存并列,但处于一个非托管的内存块中。

如果你使用的是本地索引,它们所占用的内存将位于页面缓存内部,这意味着我们可以在一定程度上控制它们占用的内存大小。在设置页面缓存大小时,你应该将此考虑在内。

监控

现在你一定已经意识到内存配置并非小事。有什么办法可以让你的工作更轻松吗?你可以使用原生内存跟踪(Native Memory Tracking),这是一个跟踪内部内存使用情况的 JVM 功能。要启用它,你需要将以下内容添加到 neo4j.conf 文件中:

dbms.jvm.additional=-XX:NativeMemoryTracking=detail

然后获取 Neo4j 的 PID,并使用 jcmd 打印进程的原生内存使用情况:jcmd <PID> VM.native_memory summary。你将获得内存中每个类别的详细分配信息,如下所示:

$ jcmd <PID> VM.native_memory summary
Native Memory Tracking:

Total: reserved=3554519KB, committed=542799KB
-                 Java Heap (reserved=2097152KB, committed=372736KB)
                            (mmap: reserved=2097152KB, committed=372736KB)

-                     Class (reserved=1083039KB, committed=38047KB)
                            (classes #5879)
                            (malloc=5791KB #6512)
                            (mmap: reserved=1077248KB, committed=32256KB)

-                    Thread (reserved=22654KB, committed=22654KB)
                            (thread #23)
                            (stack: reserved=22528KB, committed=22528KB)
                            (malloc=68KB #116)
                            (arena=58KB #44)

-                      Code (reserved=251925KB, committed=15585KB)
                            (malloc=2325KB #3622)
                            (mmap: reserved=249600KB, committed=13260KB)

-                        GC (reserved=82398KB, committed=76426KB)
                            (malloc=5774KB #182)
                            (mmap: reserved=76624KB, committed=70652KB)

-                  Compiler (reserved=139KB, committed=139KB)
                            (malloc=9KB #128)
                            (arena=131KB #3)

-                  Internal (reserved=6127KB, committed=6127KB)
                            (malloc=6095KB #7439)
                            (mmap: reserved=32KB, committed=32KB)

-                    Symbol (reserved=9513KB, committed=9513KB)
                            (malloc=6724KB #60789)
                            (arena=2789KB #1)

-    Native Memory Tracking (reserved=1385KB, committed=1385KB)
                            (malloc=121KB #1921)
                            (tracking overhead=1263KB)

-               Arena Chunk (reserved=186KB, committed=186KB)
                            (malloc=186KB)

通常,jcmd 转储本身的作用有限。更常见的方法是获取多个转储并运行 jcmd <PID> VM.native_memory summary.diff 进行比较。

这是调试内存问题的一个强大工具。

1 从 3.5 版本开始,事务状态也可以配置为与堆分开分配。

© . This site is unofficial and not affiliated with Neo4j, Inc.