4

内存溢出OutOfMemoryError科普系列二

 3 years ago
source link: http://javakk.com/1312.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

[收起] 文章目录

上一篇 讲了内存溢出的几种主要原因以及它和垃圾收集器的关系,这篇继续:

永久代

除了应用程序堆的年轻代和老年代之外,JVM还管理一个称为“永久代”的区域(JDK8之后换成了元空间),在该区域中它存储诸如类和字符串文本之类的对象。通常,您不会看到垃圾收集器在永久生成上工作;大多数操作发生在应用程序堆中。但是,尽管有它的名字,permgen中的对象并不总是永久存在的。例如,由appserver类加载器加载的类,一旦不再有对类加载器的任何引用,就会被清除。而在进行热部署的应用程序服务器中,这种情况可能会非常频繁地发生……除非情况并非如此:

Exception in thread "main" java.lang.<a href="http://javakk.com/tag/outofmemoryerror" title="查看更多关于 OutOfMemoryError 的文章" target="_blank">OutOfMemoryError</a>: PermGen space

在Java8中,Oracle/OpenJDKJVM删除了永久代。但是,保持类实例的基本问题仍然存在;它现在会影响主堆。所以你可能仍然觉得这一节很有用。

注意消息:它不是指应用程序堆。即使应用程序堆中有足够的空间,也可能耗尽permgen空间。通常,当您重新部署EAR或WAR时,会发生这种情况,只是表示permgen不够大,无法同时容纳旧类和新类(在使用旧类的所有请求完成之前,会保留旧类)。这种情况在开发过程中经常发生,这意味着您需要添加更多的permgen空间。

事实上,你在任何地方看到这个信息的第一步应该是增加你的永久空间。默认值(从JDK1.6开始)是64MB,但应用服务器或IDE通常需要256MB或更多。要增加空间,请使用 -XX:MaxPermSize 命令行参数:

java -XX:MaxPermSize=256m

然而,有时候事情并不是那么简单。permgen中的泄漏发生的原因与应用程序堆中的泄漏相同:在某个地方有一个对象持有不应该持有的引用。根据我的经验,这些对象往往是对实际类对象或应用程序堆中的对象的引用 java.lang.reflect 文件包,而不是类的实例。由于app-server类加载器的构造方式,通常的罪魁祸首是服务器配置。

例如,如果您使用的是Tomcat,那么就有一个共享jar的目录:shared/lib。如果您在同一台服务器上运行多个web应用程序,那么在该目录中放置一些jar是有意义的,因为它们的类只会被加载一次,并且您将减少服务器的总体内存占用。但是,如果其中一个库具有对象缓存,会发生什么情况?

答案是,在缓存释放对应用程序对象的引用之前,该应用程序的类不会被卸载。在这种情况下,解决办法是把lib库转移到war包或ear包中。但有时情况并非如此简单:JDKBean内省器缓存BeanInfo实例,并由引导类加载器加载。任何使用内省的库也会缓存这些实例,您可能不知道真正的责任落在哪里。

解决永久代问题是痛苦的。开始的方法是使用 -XX:+TraceClassLoading-XX:+TraceClassUnloading 命令行选项跟踪类的加载和卸载,查找已加载但未卸载的类。如果您添加 -XX:+TraceClassResolution 标志,您可以看到一个类访问另一个类的位置,这可以帮助您找到谁持有未卸载的类。

下面是三个选项的输出摘录。第一行显示从类路径加载的MyClassLoader。然后,因为它扩展了URLClassLoader,所以我们看到一条“RESOLVE”消息,然后是另一条RESOLVE消息,因为URLClassLoader引用了类。

[Loaded com.kdgregory.example.memory.PermgenExhaustion$MyClassLoader from file:/home/kgregory/Workspace/Website/programming/examples/bin/]
RESOLVE com.kdgregory.example.memory.PermgenExhaustion$MyClassLoader java.net.URLClassLoader
RESOLVE java.net.URLClassLoader java.lang.Class URLClassLoader.java:188

所有的信息都在那里,但是只要把共享库移到WAR/EAR中,直到问题消失,通常会更快。

当堆内存仍然可用时发生OutOfMemoryError

正如您在permgen消息中看到的,抛出OutOfMemoryError的原因不是应用程序堆变满。以下是一些案例:

连续分配

当我描述世代堆时,我说对象将在年轻代中分配,并最终进入老年代。这是不完全正确的:如果你的对象足够大,它将被直接创建的老年代。用户定义的对象不会(不应该!)有接近触发这种行为所需的成员数的任何地方,但数组将:在JDK1.5中,大于半兆字节的数组将直接进入老年代生成。

在32位JVM上,半兆字节转换为包含131072个元素的对象[]。很大,但在企业级应用程序的可能性范围内。尤其是使用 HashMapArrayList 需要调整自身大小的。有些应用程序直接使用更大的阵列。

当没有连续的内存块来容纳数组时,就会出现问题。这是罕见的,但有几个条件,可以导致它。其中一个更常见的情况是当你接近你的堆的极限时:你可能在年轻代有足够的可用空间,但在老年代却没有足够的空间。

另一种情况是并发标记扫描(CMS)收集器,这是“服务器类”机器的默认设置。此收集器在收集垃圾后不会压缩堆,因此可能没有足够大的“洞”来进行特定的分配。同样,这种情况也很少见:我可以设想一种将CMS堆分段的方法,但我不希望这种情况在真正的服务器上发生。

线程

OutOfMemoryError的JavaDoc指示当垃圾收集器无法使内存可用时抛出它。这仅仅是事实的一半:当JVM的内部代码从操作系统接收到ENOMEM错误时,JVM也抛出OutOfMemoryError。正如Unix程序员所知,有很多地方可以获得ENOMEM,其中之一就是线程创建:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread

在运行JDK1.5的32位Linux机器上,我可以在出现此错误之前启动5550个线程。但是在应用程序堆中仍然有大量的内存。发生了什么事?

在幕后,线程是由操作系统管理的,而不是由JVM管理的,线程创建可能会由于各种原因而失败。在我的例子中,每个线程的堆栈占用大约半MB的虚拟内存,在5000个线程之后,2G进程内存空间就用完了(下面将对此进行详细介绍)。某些操作系统还可能对可以为单个进程创建的线程数施加硬限制。

再说一次,除了改变你的程序之外,真的没有别的办法解决这个问题。大多数程序没有理由创建那么多线程;它们将花费所有的时间等待操作系统对它们进行调度。但是一些服务器应用程序可以合理地创建数千个线程来服务请求,因为它们知道大多数线程将等待数据;在这种情况下,解决方案可能是NIO的channels和selectors。

Direct ByteBuffers

自JDK1.4以来,Java允许应用程序使用 bytebuffers 访问堆外的内存(以有限的方式)。虽然 ByteBuffer 对象本身非常小,但它控制的堆外内存可能不是。

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory

bytebuffer分配失败有几个原因,我在链接的文章中已经描述过。然而,通常情况下,这是因为您要么超出了虚拟内存空间(仅与32位JVM相关),要么对所有物理内存和swap进行了声明。但是,除非您只是处理对您的机器来说太大的数据,否则直接缓冲区的内存不足的原因与堆内存不足的原因是一样的:您持有一个您认为不是的引用。上面描述的堆分析技术可以帮助您找到漏洞。

提交charge超过物理内存

如前所述,JVM的独特之处在于,您可以在启动时指定其堆的最小和最大大小。这意味着JVM在运行时将改变对虚拟内存的需求。在内存受限的机器上,您可能能够启动多个JVM,即使它们的组合最大堆超过了可用的物理内存和交换。即使活动对象的总大小小于指定的堆大小,结果仍将是OutOfMemoryError。

这种情况与运行使用所有可用内存的多个C++程序没有什么不同。JVM只是欺骗了你,让你以为你可以逃脱惩罚。唯一的解决办法是购买更多的内存或运行更少的程序。在这种情况下,没有办法让JVM“快速失败”;至少在Linux上,您可以(通过 -Xms-Xmx 参数)对远远超出支持的内存进行声明。

往堆外看

最后一种情况提出了一个重要的观点:Java堆只是故事的一部分。JVM还为线程、其内部代码和工作空间、共享库、直接缓冲区和内存映射文件使用内存。在32位JVM上,这些都必须放入一个2GB的虚拟地址空间,这是一种稀缺资源(至少在服务器和后端应用程序中是如此)。在64位JVM上,虚拟地址空间实际上是无限的;物理内存是稀缺的资源。

虚拟内存没有太多问题;操作系统和JVM在管理虚拟内存方面非常聪明。通常,查看虚拟内存映射的唯一原因是识别直接缓冲区和内存映射文件使用的大块内存。但是知道虚拟记忆地图是什么样子是有用的。

要在Linux上转储虚拟内存映射,可以使用 pmap ;对于Windows,可以使用 VMMap (可能还有其他工具;我在Windows上不太做开发)。下面是Tomcat服务器的pmap输出的摘录。实际的转储文件有几百行长;这个摘录只是显示了有趣的部分。

08048000     60K r-x--  /usr/local/java/jdk-1.5/bin/java
08057000      8K rwx--  /usr/local/java/jdk-1.5/bin/java
081e5000   6268K rwx--    [ anon ]
889b0000    896K rwx--    [ anon ]
88a90000   4096K rwx--    [ anon ]
88e90000  10056K rwx--    [ anon ]
89862000  50488K rwx--    [ anon ]
8c9b0000   9216K rwx--    [ anon ]
8d2b0000  56320K rwx--    [ anon ]
...
afd70000    504K rwx--    [ anon ]
afdee000     12K -----    [ anon ]
afdf1000    504K rwx--    [ anon ]
afe6f000     12K -----    [ anon ]
afe72000    504K rwx--    [ anon ]
...
b0cba000     24K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina-ant-jmx.jar
b0cc0000     64K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina-storeconfig.jar
b0cd0000    632K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina.jar
b0d6e000    164K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/tomcat-ajp.jar
b0d97000     88K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/tomcat-http.jar
...
b6ee3000   3520K r-x--  /usr/local/java/jdk-1.5/jre/lib/i386/client/libjvm.so
b7253000    120K rwx--  /usr/local/java/jdk-1.5/jre/lib/i386/client/libjvm.so
b7271000   4192K rwx--    [ anon ]
b7689000   1356K r-x--  /lib/tls/i686/cmov/libc-2.11.1.so

转储输出为内存映射中的每个段提供四条信息:其虚拟地址、大小、权限和源(对于从文件加载的段)。其中,在资源管理方面最有趣的是权限标志,它指示段是只读的(“r-”)还是读写的(“rw”)。

我将从读写段开始。其中大多数都有名称“[anon]”,这是Linux赋予所有与文件无关的东西的名称。这里还有一些命名的读写段,与共享库相关联;我相信这些段包含这些库的静态数据区域,以及每个进程的重定位表。

由于大多数可写段都有相同的名称,因此需要一些侦查工作才能弄清楚您在看什么。Java堆将是四个相对较大的块,它们一起分配(两个用于年轻代,一个用于老年代,一个用于永久代);它们的总大小和相对大小将取决于GC和堆配置。每个Java线程都需要自己的堆栈;它们是成对分配的,您将看到相同的对在映射中重复出现(在这个映射中,对是504k/12k)。其他所有内容都对应于直接缓冲区(可能属于代码、JDK或第三方库)和运行时暂存空间(可能属于JVM或第三方库)。

读写段的主要问题是它们增加了进程的“commit charge”:进程运行所需的物理内存和/或交换量。计算机上运行的所有程序的总提交费用不能超过该计算机的物理RAM和交换。现代操作系统很好地处理了这个问题:当程序试图分配时,它们返回一个ENOMEM(较旧的系统更倾向于崩溃)。这将导致您的程序出现OutOfMemoryError,这是我在上一节中描述的情况。

只读内存段可以在进程之间共享,并使操作系统在管理物理内存时具有灵活性:它可以简单地删除只读页,知道可以根据需要从源重新加载。它们用于标准系统库(例如libc)、应用程序代码(libjvm)和内存映射文件(包括来自类路径的jar)。

我说过虚拟内存映射没有什么问题,但是我第一次看到“无法创建线程”错误时,它来自一个只有几十个活动线程和合理的应用程序堆的应用程序服务器。直到我查看pmap输出,我才发现原因:JVM(在Solaris上是1.2)根据未完成的线程严格按降序为线程分配虚拟地址空间。在正常操作中,当线程完成时,它们的虚拟地址空间将被重用。在这种情况下,偶尔会有一个线程出现,最终该内存区域被充分利用。

我从未知道这是JVM还是Solaris的bug。我们只是改变了使用线程的方式。但它提醒我把pmap和strace放在工具箱里。

内存映射JAR是一种优化:JAR目录位于文件的末尾,并且包含到各个条目的偏移量,因此您可以通过“批量获取”操作快速访问条目。对于类路径jar来说,这是一个巨大的成功:您不必每次需要加载类时都扫描 JarInputStream (或者使用缓存方案)。但是有一个代价:内存映射文件减少了应用程序堆和线程堆栈的可用空间。

对于典型的库jar,这不是一个问题,因为它们往往很小(JDK的)rt.jar公司大到1.6 MB)。包含大量资源(如图像)的文件是另一回事,尽管JVM似乎只在映射文件的一部分方面很聪明。但是您的程序可以显式映射任何JAR或ZIP。一如既往,强大的力量带来巨大的责任;想想你为什么要把文件映射到内存中。在32位JVM上,最好将JAR的内容解压到scratch目录中。

闭幕词

这个部分包含了其他地方不太适合的零碎内容。大部分都是我个人调试内存问题的经验。

不要被虚拟内存统计数据所误导

有一种常见的抱怨是Java是一个“内存猪”,通常可以通过指向top的“VIRT”列或Windows任务管理器的“Mem Usage”列来证明。正如上一节应该说明的那样,这个数字中有很多内容,其中一些是与其他程序(例如C库)共享的。虚拟内存映射中还有很多“空”空间:如果用 -Xms=1000m 调用JVM,虚拟大小将在开始分配对象之前超过1千兆字节。

更好的度量方法是常驻集大小:程序实际使用的物理内存页数,不包括共享页。这是顶部的“RES”列(我不认为在Windows7之前的TaskManger中有这个列)。然而,常驻集也不是衡量程序实际内存使用情况的一个很好的方法。操作系统会将物理页留在进程的内存空间中,直到它在其他地方需要它们,除非您的系统负载过重,这可能需要很长时间。

底线:总是使用提供解决这些问题所需的细节的工具来诊断Java内存问题。在看到实际的OutOfMemoryError之前不要急于下结论。

OutOfMemoryError经常在源代码附近抛出

前面我提到了“局部性”:内存泄漏通常发生在分配内存之后不久。类似的观察结果是OutOfMemoryError经常抛出到泄漏附近,堆栈跟踪(如果可用)可以作为诊断的第一步。这一观察结果背后的基本原理是,要引起注意,泄漏必须涉及大量内存。这意味着,无论是由于调用分配代码的频率,还是每次传递时分配的内存量,泄漏代码都会导致更大的失败。这不是一条铁板钉钉的规则,但它很好地发挥了作用,我注意到了堆栈的痕迹。

总是怀疑藏匿处

在本文中,我已经多次提到了缓存,这是有充分理由的:在十几年的Java工作中,我看到的大多数内存泄漏都是在一个名称中带有“Cache”的类中发现的。我写了一些。问题是缓存不容易得到正确的。

也就是说,使用缓存有很多好的理由,编写自己的缓存也有一些好的理由(尽管在大多数情况下,像EHCache这样的现成缓存是更好的选择)。如果选择使用缓存,请确保您对以下问题有答案:

1. 哪些对象进入缓存?

如果将缓存限制为单个对象类型(或继承树),那么与保存许多不同类型对象的通用缓存相比,跟踪因果关系要容易得多。

2. 在任何时间点缓存中应该有多少个对象?

同样,如果您知道ProductCache应该只包含1000个条目,但是在柱状图中看到10000个Product实例,那么因果关系之间的联系更容易跟踪。更重要的是,如果考虑到最大大小,那么可以轻松计算缓存对象需要多少内存。

3. 驱逐策略是什么?

每个缓存都应该有一个显式逐出策略,用于控制对象在缓存中保留的时间。如果没有适当的策略,那么对象可能会在程序需要它们之后很长时间保留在缓存中—使用内存并向垃圾收集器添加负载(请记住:标记阶段需要与活动对象成比例的时间)。

4. 我是否会保留对缓存对象的其他长期引用?

缓存在频繁执行的代码中工作得最好,运行时间不长,但需要从源代码中检索昂贵的对象。如果您创建了一个对象,并且只要您的程序运行就需要一个对它的引用,那么就不需要将该对象放入缓存中(尽管池可能是一个好主意,可以控制实例的数量)。

注意对象寿命

对象通常分为两类:一类是程序运行时的寿命,另一类是仅能满足单个请求的寿命。保持这两个对象的独立性并知道哪一个是易于调试的关键:您只需要查看您知道是长寿命的对象。

一种方法是在程序启动时显式初始化所有的长寿命对象,不管是否需要它们。另一个更好的方法是使用像Spring这样的依赖注入框架。这不仅意味着所有长寿命的对象都将在bean配置中找到( 永远不要使用类路径扫描! ),很容易找到所有使用这些Bean的地方。

注意实例变量被误用为方法参数

在几乎所有情况下,方法中分配的对象都应该在该方法结束时丢弃(当然不包括返回值)。只要使用局部变量来保存所有这些对象,这个规则就很容易遵循。然而,使用实例变量来保存方法数据是很有诱惑力的,特别是对于调用许多其他方法的方法,这样就避免了对大型参数列表的需要。

这样做不一定会造成泄漏。随后的方法调用应该重新分配变量,从而允许收集对象。但它会不必要地(有时会极大地)增加内存需求,并使调试更加困难(因为内存中有意外的对象)。从一般的设计角度来看,当我看到这样的代码时,它总是表示方法真的希望成为自己的类。

J2EE:不要滥用session

session对象的存在是为了在web请求之间保存特定于用户的数据;这是一种绕过HTTP的无状态特性的方法。太多的时候,它最终只是一个临时的缓存。例如,一个电子商务程序员可能在会话中存储一个产品对象,合理化用户将浏览同一产品的多个页面。

这不会变成真正的泄漏,因为servlet容器最终会使用户的会话超时。但是它不必要地增加了应用程序的内存占用,这同样糟糕(不管是什么原因导致OutOfMemoryError,应用程序都是死机)。而且很难调试:正如我在上面提到的混合内容缓存,没有明确的对象存放位置指示。

小心垃圾收集过多

虽然OutOfMemoryError很糟糕,但持续运行的垃圾回收器要糟糕得多:它需要CPU周期,而这些周期本应进入应用程序。调整垃圾收集器是一门艺术,而且我没有花很多时间去做。

64位处理器和廉价的内存不会让你的问题消失

本文最初是在32位处理器成为主流时编写的,因此包含了许多关于JVM的各个部分如何共享2GB虚拟地址空间的警告。大多数警告很快就会过时:当你可以去一家办公用品商店花500美元(2011年初)购买一台内存为6gig、虚拟地址空间实际上无限的PC时,对每一个字节都大惊小怪似乎不会有什么好处。

但这并不意味着你不应该关注你的内存足迹。大量的堆和大量的活动对象会变成一个“ GC定时炸弹 ”,垃圾收集器会花费大量的时间来完成它的工作。即使使用并发GC,您的应用程序也可能在等待释放内存以分配更多内存时被阻塞。

有时候你只需要更多的内存

正如我在一开始所说的,JVM是我所知道的唯一一个迫使您为数据设置最大大小的现代编程环境。因此,有时您会认为存在漏洞,但实际上您只需增加堆的大小。解决内存问题的第一步应该始终是增加可用内存。如果你有一个真正的泄漏,你会得到一个OutOfMemoryError无论你提供多少内存。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK