在后台,hh.ru包含在Docker容器中运行的大量Java服务。 在他们的运营过程中,我们遇到了许多非同寻常的问题。 在许多情况下,为了深入了解该解决方案,我不得不在Google上搜索了很长时间,阅读了OpenJDK的源代码,甚至还介绍了生产中的服务。 在本文中,我将尝试传达在此过程中获得的知识的精髓。
CPU限制
我们曾经生活在具有CPU和内存限制的kvm虚拟机中,然后转向Docker,在cgroup中设置了类似的限制。 而我们遇到的第一个问题正是CPU限制。 我必须马上说,这个问题不再与Java 8和Java≥10的最新版本有关。如果您紧跟时代的步伐,可以放心地跳过此部分。
因此,我们在容器中启动了一个小型服务,并发现它会产生大量线程。 否则,CPU消耗的时间远远超过预期,超时将是徒劳的。 或者这是另一种真实情况:在一台机器上,服务正常启动,而在另一台具有相同设置的服务上,它崩溃了,并被OOM杀手er了。
事实证明,该解决方案非常简单-只是Java没有看到
--cpus
设置的
--cpus
的限制,并认为主机可以访问主机的所有内核。 并且可能有很多(在我们的标准设置中-80)。
库将线程池的大小调整为可用处理器的数量-因此线程数量巨大。
Java本身以相同的方式扩展GC线程的数量,因此CPU消耗和超时-服务开始使用分配给它的配额的最大份额在垃圾收集上花费大量资源。
同样,在某些情况下,库(尤其是Netty)可以将片外内存的大小调整为CPU的数量,这导致在功能更强大的硬件上运行时超出容器设置的限制的可能性很大。
首先,随着此问题的显现,我们尝试使用以下工作回合:
-尝试使用几个服务
libnumcpus-一个允许您通过设置不同数量的可用处理器来“欺骗” Java的库;
-明确指出GC线程数,
-明确设置直接字节缓冲区的使用限制。
但是,当然,带着这样的拐杖走来走去不是很方便,而没有所有这些问题的Java 10(然后是Java 11)的迁移是一个真正的解决方案。 公平地说,值得一提的是,在八个版本中,2018年10月发布的
191更新也一切都很好。 到那时,这对我们已经无关紧要了,我也希望你也这样做。
这是一个示例,其中更新Java版本不仅可以带来道德上的满足,还可以通过简化操作和提高服务性能的形式带来切实的利润。
Docker和服务器类机器
因此,在Java 10中,考虑了默认的cgroups限制,出现了
-XX:ActiveProcessorCount
和
-XX:+UseContainerSupport
选项(并被
-XX:+UseContainerSupport
移植到Java 8)。 现在一切都很棒。 还是不行
转移到Java 10/11之后的一段时间,我们开始注意到一些奇怪之处。 由于某些原因,在某些服务中,GC图形看起来好像没有使用G1:
稍微地说,这有点意外,因为我们可以肯定地知道G1是从Java 9开始的默认收集器。与此同时,某些服务中也没有这样的问题-G1正常打开了。
我们开始理解并偶然发现了
一件有趣的事情 。 事实证明,如果Java在少于3个处理器上运行并且内存限制小于2 GB,那么它将认为自己是客户端,并且不允许使用SerialGC以外的任何东西。
顺便说一句,这仅影响
GC的
选择,与-client / -server和JIT编译选项无关。
显然,当我们使用Java 8时,它没有考虑docker限制,并认为它具有很多处理器和内存。 升级到Java 10之后,许多设置了较低限制的服务突然开始使用SerialGC。 幸运的是,这很简单-通过显式设置
-XX:+AlwaysActAsServerClassMachine
。
CPU限制(再次是)和内存碎片
查看监视中的图形,我们以某种方式注意到该容器的“驻留集合大小”太大-高达最大臀部大小的三倍。 在某些下一个棘手的机制中会出现这种情况吗,该机制会根据系统中的处理器数量进行扩展,并且不知道docker的局限性吗?
事实证明,该机制一点都不棘手-它是glibc中众所周知的malloc。 简而言之,glibc使用所谓的arenas分配内存。 创建时,每个线程都被分配了一个竞技场。 当使用glibc的线程想要在本机堆中分配一定数量的内存以满足其需要并调用malloc时,则会在分配给它的竞技场中分配该内存。 如果竞技场服务多个线程,那么这些线程将竞争它。 竞技场越多,竞争越少,但是分散性就更大,因为每个竞技场都有自己的自由区列表。
在64位系统上,竞技场的默认数量设置为8 * CPU数量。 显然,这对我们来说是巨大的开销,因为并非所有CPU都可用于该容器。 而且,对于基于Java的应用程序,与竞技场的竞争并不那么重要,因为大多数分配都是在Java堆中完成的,因此可以在启动时完全为其分配内存。
malloc的此功能及其解决方案已广为人知-使用环境变量
MALLOC_ARENA_MAX
显式指示舞台的数量。 任何容器都非常容易做到。 这是为我们的主要后端指定
MALLOC_ARENA_MAX = 4
的效果:
RSS图表上有两个实例:在一个实例(蓝色)中,我们打开
MALLOC_ARENA_MAX
;在另一个
MALLOC_ARENA_MAX
(红色)中,我们重新启动。 区别是显而易见的。
但是在那之后,有一个合理的愿望要弄清楚Java通常在内存上花费多少。 是否可以在Java上以300-400兆字节的内存限制运行微服务,并且不担心它会从Java-OOM掉落或不会被系统OOM杀手杀死?
我们处理Java-OOM
首先,您需要为OOM是不可避免的事实做好准备,并且需要正确处理它们-至少可以节省髋臼。 奇怪的是,即使这项简单的工作也有其细微差别。 例如,臀部翻斗不会被覆盖-如果已经保存了具有相同名称的臀部翻斗,那么将不会创建新的臀部翻斗。
Java可以
自动将转储序列号和进程ID添加到文件名,但这对我们没有帮助。 序列号没有用,因为它是OOM,而不是常规请求的臀部转储-应用程序在它之后重新启动,重置计数器。 而且进程ID不适合,因为在docker中,它始终是相同的(通常是1)。
因此,我们来到了这个选项:
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:HeapDumpPath=/var/crash/java.hprof
-XX:OnOutOfMemoryError="mv /var/crash/java.hprof /var/crash/heapdump.hprof"
它非常简单,并且经过一些改进,您甚至可以教它不仅存储最新的转储信息,而且对于我们的需求而言,这绰绰有余。
Java OOM不是我们唯一要面对的事情。 每个容器在其占用的内存上都有限制,可以超过限制。 如果发生这种情况,那么容器将被系统OOM杀手杀死并重新启动(我们使用
restart_policy: always
)。 自然,这是不可取的,我们想学习如何正确设置JVM使用的资源限制。
优化内存消耗
但是在设置限制之前,您需要确保JVM不会浪费资源。 我们已经通过限制CPU数量和变量
MALLOC_ARENA_MAX
来减少内存消耗。 还有其他“几乎免费”的方法吗?
事实证明,还有一些技巧可以节省一些内存。
第一种是使用
-Xss
(或
-XX:ThreadStackSize
)
-XX:ThreadStackSize
,该
-XX:ThreadStackSize
控制线程的堆栈大小。 64位JVM的默认值为1 MB。 我们发现512 KB就足够了。 因此,以前从未捕获过StackOverflowException,但我承认这并不适合所有人。 而且从中获利很小。
第二个是
-XX:+UseStringDeduplication
(启用了G1 GC)。 由于额外的处理器负载,它允许您通过折叠重复的行来节省内存。 内存和CPU之间的权衡仅取决于特定的应用程序和重复数据删除机制本身的设置。 阅读
扩展坞并在您的服务中进行测试,我们有此选项尚未找到其应用程序。
最后,一种不适合所有人(但适合我们)的方法是使用
jemalloc而不是本机malloc。 与来自glibc的malloc相比,此实现旨在减少内存碎片和更好的多线程支持。 对于我们的服务,与
MALLOC_ARENA_MAX=4
malloc相比,jemalloc所提供的内存收益要
MALLOC_ARENA_MAX=4
,而不会显着影响性能。
其他选项,包括Alexei Shipilev在
JVM Anatomy Quark#12:Native Memory Tracking中描述的选项,似乎很危险,或导致性能明显下降。 但是,出于教育目的,我建议阅读本文。
同时,让我们继续下一个主题,最后,尝试学习如何限制内存消耗并选择正确的限制。
限制内存消耗:堆,非堆,直接内存
为了正确执行所有操作,您需要记住Java中通常包含哪些内存。 首先,让我们看一下可以通过JMX监视其状态的池。
当然,第一个是
臀部 。 很简单:我们
-Xmx
,但是怎么做对呢? 不幸的是,这里没有通用的配方,这完全取决于应用程序和负载配置文件。 对于新服务,我们从相对合理的堆大小(128 MB)开始,并在必要时增加或减小堆大小。 为了支持现有的内存,可以使用内存消耗和GC指标图进行监视。
与
-Xmx
同时,我们设置
-Xms == -Xmx
。 我们没有内存超卖的情况,因此,为了我们的利益,该服务将使用我们为其提供的最大资源。 此外,在普通服务中,我们包括
-XX:+AlwaysPreTouch
和“透明大页面”机制:
-XX:+UseTransparentHugePages -XX:+UseLargePagesInMetaspace
。 但是,在启用THP之前,请仔细阅读
文档并测试该选项在很长时间内的服务行为。 不排除在RAM不足的机器上出现意外情况(例如,我们必须在测试台上关闭THP)。
接下来
是非堆 。 非堆内存包括:
-元空间和压缩类空间,
-代码缓存。
按顺序考虑这些池。
当然,每个人都听说过
Metaspace ,我不会详细讨论。 它存储类元数据,方法字节码等。 实际上,Metaspace的使用直接取决于加载的类的数量和大小,您可以像启动髋关节一样,仅通过启动应用程序并通过JMX删除指标来确定它。 默认情况下,Metaspace不受任何限制,但是使用
-XX:MaxMetaspaceSize
很容易做到这一点。
压缩的类空间是Metaspace的一部分,并在启用
-XX:+UseCompressedClassPointers
选项时显示(默认情况下,对于小于32 GB的堆启用此功能,也就是说,它可以提供真实的内存增益)。 该池的大小可以受
-XX:CompressedClassSpaceSize
选项的限制,但并没有多大意义,因为Metaspace中包含Compressed Class Space,并且Metaspace和Compressed Class Space的锁定内存总量最终限制为一个
-XX:MaxMetaspaceSize
。
顺便说一句,如果您查看JMX读数,那么非堆内存的
总和将计算为元空间,压缩类空间和代码缓存的
总和 。 实际上,您只需要总结Metaspace和CodeCache。
因此,在非堆中仅保留了
代码缓存 -由JIT编译器编译的代码存储库。 默认情况下,其最大大小设置为240 MB,对于小型服务,它的大小比所需大小大数倍。 可以使用
-XX:ReservedCodeCacheSize
选项设置代码缓存的大小。 正确的大小只能通过运行应用程序并在典型的负载配置文件下进行跟踪来确定。
重要的是不要在这里犯错误,因为代码缓存不足会从缓存中删除冷的和旧的代码(默认情况下启用
-XX:+UseCodeCacheFlushing
),这反过来会导致更高的CPU消耗和性能下降。 。 如果在代码缓存溢出时抛出OOM,那就太好了,为此,甚至还有
-XX:+ExitOnFullCodeCache
,但是,不幸的是,它仅在JVM的
开发版本中可用。
JMX中有关信息的最后一个池是
直接内存 。 默认情况下,它的大小不受限制,因此为其设置某种限制很重要-至少它会像Netty这样积极使用直接字节缓冲区的库。 使用
-XX:MaxDirectMemorySize
设置限制并不难,而且再次,只有进行监视才能帮助我们确定正确的值。
那么到目前为止我们能得到什么呢?
Java进程内存=
堆+元空间+代码缓存+直接内存=
-Xmx +
-XX:MaxMetaspaceSize +
-XX:ReservedCodeCacheSize +
-XX:MaxDirectMemorySize
让我们尝试在图表上绘制所有内容,并将其与RSS docker容器进行比较。
上面的行是容器的RSS,它是JVM内存消耗的一半半,我们可以通过JMX对其进行监视。
进一步挖掘!
限制内存消耗:本机内存跟踪
当然,除了堆,非堆和直接内存外,JVM还使用了一大堆其他内存池。
-XX:NativeMemoryTracking=summary
标志将帮助我们
-XX:NativeMemoryTracking=summary
它们
-XX:NativeMemoryTracking=summary
。 通过启用此选项,我们将能够获取有关JVM已知但JMX中不可用的池的信息。 您可以在
文档中阅读有关使用此选项的更多信息。
让我们从最显而易见的开始-
线程栈占用的内存。 NMT为我们的服务产生以下内容:
线程(保留= 32166KB,已提交= 5358KB)
(线程#52)
(堆栈:保留= 31920KB,已提交= 5112KB)
(malloc = 185KB#270)
(舞台= 61KB#102)
顺便说一句,它的大小也可以在没有本机内存跟踪的情况下找到,使用jstack并深入到
/proc/<pid>/smaps
。 Andrey Pangin为此设计了一个
特殊的实用程序 。
共享类空间的大小甚至更容易评估:
共享的类空间(保留的= 17084KB,已提交的= 17084KB)
(mmap:保留= 17084KB,已提交= 17084KB)
这是类数据共享机制,
-Xshare
和
-XX:+UseAppCDS
-Xshare
。 在Java 11中,默认情况下
-Xshare
选项设置为auto,这意味着,如果您具有
$JAVA_HOME/lib/server/classes.jsa
(位于官方OpenJDK docker映像中),它将加载内存映射-欧姆在启动JVM时加快了启动时间。 因此,如果您知道jsa归档文件的大小,则可以轻松确定Shared Class Space的大小。
以下是本机
垃圾收集器结构:
GC(保留= 42137KB,已提交= 41801KB)
(malloc = 5705KB#9460)
(mmap:保留= 36432KB,已提交= 36096KB)
Alexey Shipilev在本机内存跟踪手册中已经提到过,它们占据了堆大小的4%到5%,但是在我们设置的小堆(最大几百兆)中,开销达到了堆大小的50%。
符号表可以占用很多空间:
符号(保留= 16421KB,已提交= 16421KB)
(malloc = 15261KB#203089)
(竞技场= 1159KB#1)
它们存储方法的名称,签名以及指向实习字符串的链接。 不幸的是,似乎只有在使用事实内存跟踪后才能估计符号表的大小。
还剩下什么? 根据本机内存跟踪,很多事情:
编译器(保留= 509KB,已提交= 509KB)
内部(保留= 1647KB,已提交= 1647KB)
其他(保留= 2110KB,已提交= 2110KB)
竞技场块(保留= 1712KB,已提交= 1712KB)
日志记录(保留= 6KB,已提交= 6KB)
参数(保留= 19KB,已提交= 19KB)
模块(预留= 227KB,已提交= 227KB)
未知(保留= 32KB,已提交= 32KB)
但是,所有这些都占用了相当多的空间。
不幸的是,许多提到的内存区域既不能被限制也不能被控制,如果可能的话,配置将变成地狱。 甚至监视它们的状态也不是一件容易的事,因为包含本机内存跟踪会稍微降低应用程序的性能,并且在关键服务中的生产环境中启用它并不是一个好主意。
但是,为了感兴趣,让我们尝试在图形上反映本机内存跟踪报告的所有内容:
还不错! 剩下的区别是内存的碎片/分配(因为我们使用jemalloc,这非常小)或本机libs分配的内存的开销。 我们仅使用其中之一来有效存储前缀树。
因此,对于我们的需求而言,限制我们的能力就足够了:堆,元空间,代码缓存,直接内存。 对于其他所有内容,我们会根据实际测量的结果确定一些合理的基础。
处理完CPU和内存后,我们继续进行下一个应用程序可以竞争的资源-磁盘。
Java和驱动器
有了它们,一切都非常糟糕:它们很慢,并且可能导致应用程序出现明显的迟钝。 因此,我们尽可能将Java与磁盘解除绑定:
- 我们通过UDP将所有应用程序日志写入本地系统日志。 这就留下了一些必要的日志会丢失的机会,但是,正如实践所示,这种情况很少见。
- 我们将在tmpfs中编写JVM日志,为此,我们只需要使用
/dev/shm
将docker安装到所需的位置。
如果我们在syslog或tmpfs中写日志,并且应用程序本身除了向后转储以外不向磁盘写任何东西,那么事实证明带磁盘的故事是否已被关闭?
当然不是
我们关注停止世界暂停的持续时间的图表,我们看到了一个可悲的图画-主机上的停止世界暂停是几百毫秒,而在一个主机上,它们可以达到一秒:
不用说这会对应用程序产生负面影响? 例如,这里的图表反映了根据客户的服务响应时间:
这是一项非常简单的服务,大部分情况下都会提供缓存的答案,那么从95%开始的这种令人讨厌的计时在哪里? 其他服务也有类似的情况,此外,在建立从连接池到数据库的连接,执行请求等时,超时以令人羡慕的恒定性不断下降。
驱动器与它有什么关系? -你问。 事实证明,这与它有很大关系。
对问题的详细分析表明,由于线程长时间进入安全点这一事实,导致出现了较长的STW暂停。 读取JVM代码后,我们意识到在安全点上的线程同步期间,JVM可以通过内存映射写入文件
/tmp/hsperfdata*
,并向其中导出一些统计信息。 诸如
jstat
和
jps
类的实用程序都使用
jstat
jps
。
使用
-XX:+PerfDisableSharedMem
选项在同一台计算机上禁用它并...
码头踏板池指标稳定:
(, ):
, , , .
?
Java- , , , .
Nuts and Bolts , . , . , , JMX.
, . .
statsd JVM, (heap, non-heap ):
, , .
— , , , , ? . () -, , RPS .
: , . . ammo-
. . . :
.
, . , , - , , .
总结
, Java Docker — , . .