深入解析OutOfMemoryError(下)

来源:互联网 发布:51单片机支持24v电源吗 编辑:程序博客网 时间:2024/05/01 22:37

永久代

除了JVM中的新生代和老年代外,JVM还管理着一片叫‘永久代’的区域,它存储了class信息和字符串表达式等对象。通常,你不会观察到永久代中的垃圾回收;大多数的垃圾回收发生在应用程序堆中。但是不像它的名字,在永久代中的对象不会是永久不变的。举个例子,被应用程序classloader加载的class,当不再被classloader引用时就会被清理掉。当应用程序服务被频繁的热部署时就可能会发生:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

这一这个信息:这个不管应用程序堆的事。当应用程序堆中还有很多空间时,也有可能用完永久代的空间。通常,这发生在重新部署EAR和WAR文件时,并且永久代还不够大到可以同时容纳新的class信息和老的class信息(老的class会一直被保存着直到所有的请求在使用完它们)。当在运行处于开发状态的应用时更容易发生。

解决永久代错误的第一个方法就是增大永久大的空间,你可以使用-XX:MaxPermSize命令行参数。默认是64M,但是web应用程序或者IDE一般都需要256M。

java -XX:MaxPermSize=256m

但是在通常情况下并不是这么简单的。永久代的内存泄露一般都和在应用堆中的内存泄露原因一样:在一些地方的对象引用了并不该再引用的对象。以我的经验,很有可能有些对象直接引用了一些Class对象,或者在java.lang.reflect包下面的对象,而不是某些类的实例对象。正式因为web引用的classloader的组织方式,通常罪魁祸首都出现在服务的配置当中。

例如,你使用了Tomcat,并且有一个目录里面有很多共享的jars:shared/lib。如果你在一个容器里同时运行好几个web应用,将一些公用的jar放在这个目录是很有道理的,因为这样的话这些class仅仅被加载一次,可以减少内存的使用量。但是,如果其中的一些库具有对象缓存的话,会发生什么事情呢?

答案是这些被缓存了的对象的类永远不会被卸载,直到缓存释放了这些对象。解决方案就是将这些库移动到WAR或者EAR中。但是在某些时候情况也不会像这么简单:JDKs bean introspector会缓存住由root classloader加载的BeanInfo对象。并且任何使用了反射的库也会缓存这些对象,这样就导致你不能直到真正的问题所在。

解决永久代的问题通常都是比较痛苦的。一般可以先考虑加上-XX:+TraceClassLoading和-XX:+TraceClassUnloading命令行选项以便找出那些被加载了但是没有被卸载的类。如果你加上了-XX:+TraceClassResolution命令行选项,你还可以看到哪些类访问了其他类,但是没有被正常卸载。

这里有针对这三个选项的一个实例。第一行显示了MyClassLoader类从classpath中被加载了。因为它又从URLClassLoader继承,因此我们看到了接下来的’RESOLVE’消息,紧跟着又是一条’RESOLVE’消息,说明Class类也被解析了。

[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.URLClassLoaderRESOLVE java.net.URLClassLoader java.lang.Class URLClassLoader.java:188

所有的信息都在这里的,但是通常情况下将一些共享库移动到WAR/EAR中往往可以很快速的解决问题。

当堆内存还有空间时发生的OutOfMemoryError

就像你刚才看到的关于永久代的消息,也许应用程序堆中还有空闲空间,但是也任然可能会发生OOM。这里有几个例子:

连续的内存分配

当我描述分代的堆空间时,我一般会说对象会首先被分配在新生代,然后最终会被移动到老年代。但这不是绝对正确的:如果你的对象足够大,那么它就会直接被分配在老年代。一般用户自己定义的对象是不会(也不应该)达到这个临界值,但是数组却却有可能:在JDK1.5中,当数组的对象超过0.5M的时候就会被直接分配到老年代。

在32位机器上,0.5M换算成Object[]数组的话就可以包含131,072个元素。这已经是很大的了,但是在企业级的应用中这是很有可能的。特别是当使用了HashMap时,它经常需要重新resize自己(里面的数组数据结构)。一些应用程序可能还需要更大的数组。

当没有连续的堆空间来存放这些数组对象时(就算在垃圾回收并且对内存进行了紧凑之后),问题就产生了。这很少见,但是如果当前的程序已经很接近堆空间的上限时,这就变得很有可能了。增大堆空间上限是最好的解决方案,但是你也许可以试试事先分配好你的容器的大小。(后面的小对象可以不需要连续的内存空间)

线程

JavaDoc中对OOM的描述是,当垃圾搜集器不能在释放更多的内存空间时,JVM会抛出OOM。这里只对了一半:当JVM的内部代码收到来自操作系统的ENOMEM错误时,JVM也会抛出OOM。Unix程序员一般都知道,这里有很多地方可以收到ENOMEN错误,创建线程的过程是其中之一:

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

在我的32位Linux系统中,使用JDK1.5,我可以最多开启5,550个线程直到抛出异常。但是实际上在堆中任然有很多空闲空间,这是怎么回事呢?

在这个场景的背后,线程实际上是被操作系统所管理,而不是JVM,创建线程失败的可能原因有很多很多。在我的例子中,每一个线程都需要占用大概0.5M的虚拟内存作为它的栈空间,在5000个线程被创建之后,大约就有2G的内存空间被占用。有些操作系统就强制制定了一个进程所能创建的线程数的上限。

最后,针对这个问题没有一个解决方案,除非更换你的应用程序。大多数程序是不需要创建这么多得线程的,它们会将大部分的时间都浪费在等待操作系统调度上。但是有些服务程序需要创建数千个线程去处理请求,但是它们中得大多数都是在等待数据。针对这种场景,NIO和selector就是一个不错的解决方案。

Direct ByteBuffers

从JDK1.4之后Java允许程序程序使用bytebuffers来访问堆外的内存空间(受限)。虽然ByteBuffer对象本身很小,但是堆外的内存可不一定很小:

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

这里有多个原因会导致bytebuffer分配失败。通常情况下,你可能超过了最多的虚拟内存上限(仅限于32位系统),或者超过了所有物理内存和交换区内存的上限。除非你是在以很简单的方式处理超过你的机器内存上限的数据,否则你在使用direct buffer产生OOM的原因和你使用堆的原因基本上是一样的:你保持着一些你不该引用的数据。前面介绍的堆分析技术可以帮助你找到泄露点。

申请的内存超过物理内存

就像我前面提到的,你在启动一个JVM时,你需要指定堆的最小值和最大值。这就意味着,JVM会在运行期动态改变它对虚拟内存的需求。在一个内存受限的机器上,你可以同时运行多个JVM,甚至它们所有指定的最大值之和大于了物理内存和交换区的大小。当然,这就有可能会导致OOM,就算你的程序中存活的对象大小小于你指定的堆空间也是一样的。

这种情况和跑多个C++程序使用完所有的物理内存的原因是一样的。使用JVM可能会让你产生一种假象,以为不会出现这种问题。唯一的解决方案是购买更多的内存,或者不要同时跑那么多程序。没有办法让JVM可以’快速失败’;但是在Linux上你可以申请比总内存更多的内存。

堆外内存的使用

最后一个需要注意的问题是:Java中得堆仅仅是所占用内存的一部分。JVM还会为它所创建的线程、内部代码、工作空间、共享库、direct buffer、内存映射文件分配内存。在32位的JVM中,这所有的内存都需要被映射到2G的虚拟内存空间中,这是非常有限的(特别是对于服务端或者后端应用程序)。在64位的JVM中,虚拟内存基本没存在什么限制,但是实际的物理内存(含交换区)可能会很稀缺。

一般来说,虚拟内存不会造成什么大问题;操作系统和JVM可以很好的管理它们。通常情况下,你需要查看虚拟内存的映射情况主要是为了direct buffer所使用的大块的内存或者是内存映射文件。但是你还是很有必要知道什么是虚拟内存的映射。

要查看在Linux上的虚拟内存映射情况可以使用pmap;在Windows中可以使用VMMap。下面是使用pmap来dump的一个Tomcat应用。实际的dump文件有好几百行,所展示的部分仅仅是比较有意思的部分:

08048000     60K r-x--  /usr/local/java/jdk-1.5/bin/java08057000      8K rwx--  /usr/local/java/jdk-1.5/bin/java081e5000   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.jarb0cc0000     64K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina-storeconfig.jarb0cd0000    632K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/catalina.jarb0d6e000    164K r-xs-  /usr/local/java/netbeans-5.5/enterprise3/apache-tomcat-5.5.17/server/lib/tomcat-ajp.jarb0d97000     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.sob7253000    120K rwx--  /usr/local/java/jdk-1.5/jre/lib/i386/client/libjvm.sob7271000   4192K rwx--    [ anon ]b7689000   1356K r-x--  /lib/tls/i686/cmov/libc-2.11.1.so...

dump文件展示给你了关于虚拟内存映射的4个部分:虚拟内存地址,大小,权限,源(从文件加载的部分)。最有意思的部分是它的权限部分,它表示了该内存段是否是只读的(r-)还是读写的(rw)。

我会从读写段开始分析。所有的段都具有名字”[ anon ]“,它在Linux中说明了该段不是由文件加载而来。这里还有很多被命名的读写段,它们和共享库关联。我相信这些库都具有每个进程的地址表。

因为所有的读写段都具有相同的名字,一次要找出出问题的部分需要花费一点时间。对于Java堆,有4个相关的大块内存被分配(新生代有2个,老年代1个,永久代1个),他们的大小由GC和堆配置来决定。

其他问题

这部分的内容并不是对所有地方都适用。大部分都是我解决问题的过程中总结的实际经验。

不要被虚拟内存的统计信息所误导

有很多抱怨说Java是’memory hog’,经常被top命令的’VIRT’部分和Windows任务管理器的’Mem Usage’列所证实。需要澄清的是,有太多的东西都不会算进这个统计信息中,有些还是与其他程序共享的(比如说C的库)。实际上也有很多‘空’的区域在虚拟内存映射空间中:如果你适用-Xms1000m来启动JVM,就算你还没有开始分配对象,虚拟内存的大小也会超过1000m。

一个更好的测量方法是使用驻留集的大小:你的应用程序真正使用的物理内存的页数,不包含共享页。这就是top命令中得’RES’列。但是,驻留集并不是对你的程序所需使用的总内存最好的测量方法。操作系统只有在你的程序真正需要使用它们的时候才会将它们放进进程的内存空间中,一般来说是在你的系统处于高负载的情况下才会出现,这会花费一段较长的时间。

最后:始终使用工具来提供所需的详细信息来分析Java中的内存问题。并且只有当出现OOM的时候才考虑下结论。

OOM的罪魁祸首经常离它的抛出点很近

内存泄露一般在内存被分配之后不久发生。一个相似的结论是,OOM的根源一般都离它的抛出点很近,可以使用堆跟踪技术来首先进行分析。其基本原理是,内存泄露一般和产生大量的内存相关联。这说明了,导致泄露的代码具有更高的失败风险率,不管是因为其内存分配代码被调用的过于频繁,还是因为每次调用都分配的过大的内存。因此,可以优先考虑使用栈跟踪来定位问题。

和缓存相关的部分最值得怀疑

我在这篇文章中提到缓存了很多次:在我数十年的Java工作经历中发现,和内存泄露相关的类进场都是和缓存相关的。实际上缓存是很难编写的。

使用缓存有很多很多很好的理由,并且使用自己写的缓存也有很多好的理由。如果你确定要使用缓存,请先回答下面的问题:

  • 哪些对象会被放进缓存?如果你所要缓存的对象都是同一种类型(或者具有继承关系),那么相比一个可以容纳各种类型的缓存来说更好跟踪问题。

  • 有多少对象会被同时放进缓存?如果你像让ProductCache缓存1000个对象,但是在内存分析结果中发现了10000个对象,那么这之间的关系就比较好定位。如果你指定了这个缓存最多的容量上限,那么你就可以很容易的计算出这个缓存最多需要多少内存。

  • 过期和清除策略是什么?每一个缓存为了控制存在于其中的对象的存货周期,都需要一个明确的驱逐策略。如果你没有指定一个明确的驱逐策略,那么有些对象就很有可能比它真正需要的存活周期要长,占用更多的内存,加重垃圾搜集器的负载(记住:在标记阶段需要的时间和存活对象的数量成正比)。

  • 是否会在缓存之外同时持有这些存活对象的引用?缓存最好的应用场景是,调用频繁,并且调用时间很短,并且所缓存的对象的获取代价很大。如果你需要创建一个对象,并且在整个应用程序的生命周期中都需要引用这个对象,那么就没有必要将这个对象放入缓存(也许使用池技术可以显示总得对象数量)。

注意对象的生命周期

一般来说对象可以被划分为两类:一类是伴随着整个程序的生命周期而存活;另外一来是仅仅存活并服务于一个单一的请求。搞清楚这个非常重要,你仅仅需要关心你认为是长时间存活的对象。

一种方法是在程序启动的时候全部初始化好所有长时间(long-lived)存活的对象,不管他们是否要立刻被用到。另外一个方法是使用依赖注入框架,比如Spring。这不仅仅可以很方便的bean配置文件中找到所有long-lived的对象(不需要扫描整个classpath),还可以很清楚的知道这些对象在哪里被使用。

查找在方法参数中被错误使用的对象

在大部分场景中,在一个方法中被分配的对象都会在方法退出的时候被清理掉(除开被返回的对象)。当你都是用局部变量来保存这些对象的时候,这个规则很容易被遵守。但是,有时候任然会使用实体变量来保存这些对象,特别是在方法中会调用大量其他方法的时候,主要是为了避免过多和麻烦的方法参数传递。

这样做不是一定会产生泄漏。后续的方法调用会重新对这些变量进行赋值,这样就可以让之前被创建的对象被回收。但是这样导致不必要的内存开销,并且让调试更加困难。但是从设计的角度出发,当我看到这样的代码时,我就会考虑将这个方法单独提出来形成一个独立的类。

J2EE:不要滥用session

session对象是用来在多个请求之间保存和共享用户相关的数据,主要是因为HTTP协议是无状态的。有时候它便成了一个用于缓存的临时性解决方案。

这也不是说一定就会产生泄漏,因为web容器会在一段时间后让用户的session失效。但是它却显著提高了整个程序的内存占用量,这是很糟糕的。并且它非常难调试:就像我之前提到的,很难看出对象被哪些其他的对象所持有。

小心过量的垃圾搜集

虽然OOM很糟糕,但是如果不停的执行垃圾搜集将会更加糟糕:它会抢走本该属于你的程序的CPU时间。

有些时候你仅仅是需要更多的内存

就像我在开头的地方所说的,JVM是唯一的一个让你指定你的数据最大值(内存上限)的现代编程环境。因此,会有很多时候让你以为发生了内存泄露,但是实际上你仅仅需要增加你的堆大小。解决内存问题的第一步最好还是先增加你的内存上限。如果你真的遇到了内存泄露问题,那么无论你增加了多少内存,你最后都还是会得到OOM的错误。

0 0
原创粉丝点击