Home  >  Article  >  Java  >  An in-depth analysis of Java OutOfMemoryError

An in-depth analysis of Java OutOfMemoryError

黄舟
黄舟Original
2017-03-20 10:51:291733browse

In Java, all objects are stored in the heap. They are allocated through the new keyword, and the JVM will check whether all threads can no longer access them and recycle them. Most of the time, programmers will not be aware of it at all, and these tasks are performed quietly. However, sometimes the program hangs on the last day before release.

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

OutOfMemoryError is a very depressing exception. It usually means you're doing something wrong: saving unnecessary data for an unnecessarily long time, or processing too much data at the same time. Sometimes, these problems are not necessarily under your control. For example, some third-party libraries cache some strings, or some application servers do not clean them during deployment. Moreover, we often have nothing to do with objects that already exist in the heap.

This article analyzes the different causes of OutOfMemoryError and how you can deal with them. The following analysis is limited to the Sun Hotspot virtual machine, but most of the conclusions apply to any other JVM implementation. Most of them are based on articles online and my own experience. I have not directly done JVM development work, so the conclusions do not represent the authors of JVM. But I did encounter and solve a lot of memory-related problems.

Introduction to Garbage Collection

I have introduced the process of garbage collection in detail in this article. Simply put, the mark-sweep collect algorithm uses garbage collection roots as the starting point of scanning, scans the entire object graph, and marks all reachable objects. Objects that are not marked are cleared and recycled.

Java's garbage collection algorithm process means that if OOM occurs, it means that you are constantly adding objects to the object graph and not removing them. This is usually because you are adding a lot of objects to a collection class, such as Map, and the collection object is static. Or, this collection class is saved in the <span class="wp_keywordlink">ThreadLocal</span> object, but the corresponding Thread runs for a long time and never exits.

This is completely different from memory leaks in C and C++. In these languages, if some methods call malloc() or new, and the corresponding free() or delete is not called when the method exits, memory leaks will occur. These are leaks in the true sense. You cannot restore these memories within the scope of this process, unless you use some specific tools to ensure that each memory allocation method has its corresponding memory release operation.

In Java, the word "leak" is often misused. Because from the JVM's perspective, all memory is well managed. The problem is simply that as a programmer you don't know which objects occupy this memory. But luckily, you still have ways to find and locate them.

Before we dive in, there is one last thing you need to know about garbage collection: the JVM will do its best to release memory until OOM occurs. This means that OOM cannot be solved by simply calling System.gc(). You need to find these "leak" points and handle them yourself.

Set the heap size

Academic people like to say that the Java Language Specification does not make any conventions about the garbage collector, and you can even implement a JVM that never releases memory (which is actually meaningless). The Java Virtual Machine Specification mentions that the heap is managed by a garbage collector, but does not explain any relevant details. I just said what I just mentioned: garbage collection will happen before OOM.

In fact, the Sun Hotspot virtual machine uses a fixed size heap space and allows automatic growth between the minimum space and the maximum space. If you do not specify the minimum and maximum values, then for 'client' mode, 2Mb will be used as the minimum value and 64Mb as the maximum value by default; for 'server' mode, the JVM will determine the default value based on the currently available memory. After 2000, the default maximum heap size was changed to 64M, and it was considered large enough at the time (the default value before 2000 was 16M), but it can easily be used up for today's applications.

This means that you need to explicitly specify the minimum and maximum heap values ​​via JVM parameters:

java -Xms256m -Xmx512m MyClass

There are many rules of thumb for setting maximum and minimum values. Obviously, the maximum size of the heap should be set to be large enough to accommodate all the objects required by the entire application. However, setting it to "just big enough" is not a good idea either, as this will increase the load on the garbage collector. Therefore, for a long-running application, you generally need to keep 20%-25% of the heap space free. (Your application may require different parameter settings, GC tuning is an art and is beyond the scope of this article)

让你奇怪的时,设置合适的堆的最小值往往比设置合适的最大值更加重要。垃圾回收器会尽可能的保证当前的的堆大小,而不是不停的增长堆空间。这会导致应用程序不停的创建和回收大量的对象,而不是获取新的堆空间,相对于初始(最小)堆空间。Java堆会尽量保持这样的堆大小,并且会不停的运行GC以保持这样的容量。因此,我认为在生产环境中,我们最好是将堆的最小值和最大值设置成一样的。

你可能会困惑于为什么Java堆会有一个最大值上限:操作系统并不会分配真正的物理内存,除非他们真的被使用了。并且,实际使用的虚拟内存空间实际上会比Java堆空间要大。如果你运行在一个32位系统上,一个过大的堆空间可能会限制classpath中能够使用的jar的数量,或者你可以创建的线程数。

另外一个原因是,一个受限的最大堆空间可以让你及时发现潜在的内存泄露问题。在开发环境中,对应用程序的压力往往是不够的,如果你在开发环境中就拥有一个非常大得堆空间,那么你很有可能永远不会发现可能的内存泄露问题,直到进入产品环境。

在运行时跟踪垃圾回收

所有的JVM实现都提供了-verbos:gc选项,它可以让垃圾回收器在工作的时候打印出日志信息:

java -verbose:gc com.kdgregory.example.memory.SimpleAllocator
[GC 1201K->1127K(1984K), 0.0020460 secs]
[Full GC 1127K->103K(1984K), 0.0196060 secs]
[GC 1127K->1127K(1984K), 0.0006680 secs]
[Full GC 1127K->103K(1984K), 0.0180800 secs]
[GC 1127K->1127K(1984K), 0.0001970 secs]
...

Sun的JVM提供了额外的两个参数来以内存带分类输出,并且会显示垃圾收集的开始时间:

java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps com.kdgregory.example.memory.SimpleAllocator
0.095: [GC 0.095: [DefNew: 177K->64K(576K), 0.0020030 secs]0.097: [Tenured: 1063K->103K(1408K), 0.0178500 secs] 1201K->103K(1984K), 0.0201140 secs]
0.117: [GC 0.118: [DefNew: 0K->0K(576K), 0.0007670 secs]0.119: [Tenured: 1127K->103K(1408K), 0.0392040 secs] 1127K->103K(1984K), 0.0405130 secs]
0.164: [GC 0.164: [DefNew: 0K->0K(576K), 0.0001990 secs]0.164: [Tenured: 1127K->103K(1408K), 0.0173230 secs] 1127K->103K(1984K), 0.0177670 secs]
0.183: [GC 0.184: [DefNew: 0K->0K(576K), 0.0003400 secs]0.184: [Tenured: 1127K->103K(1408K), 0.0332370 secs] 1127K->103K(1984K), 0.0342840 secs]
...

从上面的输出我们可以看出什么?首先,前面的几次垃圾回收发生的非常频繁。每行的第一个字段显示了JVM启动后的时间,我们可以看到在一秒钟内有上百次的GC。并且,还加入了每次GC执行时间的开始时间(在每行的最后一个字段),可以看出垃圾搜集器是在不停的运行的。

但是在实时系统中,这会造成很大的问题,因为垃圾搜集器的执行会夺走很多的CPU周期。就像我之前提到的,这很可能是由于初始堆大小设置的太小了,并且GC日志显示了:每次堆的大小达到了1.1Mb,它就开始执行GC。如果你得系统也有类似的现象,请在改变自己的应用程序之前使用-Xms来增大初始堆大小。

对于GC日志还有一些很有趣的地方:除了第一次垃圾回收,没有任何对象是存放在了新生代(“DefNew”)。这说明了这个应用程序分配了包含大量数据的数组,在显示世界里这是很少出现的。如果在一个实时系统中出现这样的状况,我想到的第一个问题是“这些数组拿来干什么用?”。

堆转储(Heap Dumps)

一个堆转储可以显示你在应用程序说使用的所有对象。从基础上讲,它仅仅反映了对象实例的数量和类文件所占用的字节数。当然你也可以将分配这些内存的代码一起dump出来,并且对比历史存货对象。但是,如果你要dump的数据信息越多,JVM的负载就会越大,因此这些技术仅仅应该使用在开发环境中。

怎样获得一个内存转储

命令行参数-XX:+HeapDumpOnOutOfMemoryError是最简单的方式生成内存转储。就像它的名字所说的,它会在内存被用完的时候(发生OOM)进行转储,这在产品环境非常好用。但是由于这个是一种事后转储(已经发生了OOM),它只能提供一种历史性的数据。它会产生一个二进制文件,你可以使用jhat来操作该文件(这个工具在JDK1.6中已经提供,但是可以读取JDK1.5产生的文件)。

你可以使用jmap(JDK1.5之后就自带了)来为一个运行中得java程序产生堆转储,可以产生一个在jhat中使用的dump文件,或者是一个存文本的统计文件。统计图可以在进行分析时优先使用,特别是你要在一段时间内多次转储堆并进行分析和对比历史数据。

从转储内容和JVM的负荷的扩展性上考虑的话,可以使用profilers。Profiles使用JVM的调试接口(debuging interface)来搜集对象的内存分配信息,包括具体的代码行和方法调用栈。这个是非常有用的:不仅仅可以知道你分配了一个数GB的数组,你还可以知道你在一个特定的地方分配了950MB的对象,并且直接忽略其他的对象。当然,这些结果肯定会对JVM有开销,包括CPU的开销和内存的开销(保存一些原始数据)。你不应该在产品环境中使用profiles。

堆转储分析:live objects

Java中的内存泄露是这样定义的:你在内存中分配了一些对象,但是并没有清除掉所有对它们的引用,也就是说垃圾搜集器不能回收它们。使用堆转储直方图可以很容易的查找这些泄露对象:它不仅仅可以告诉你在内存中分配了哪些对象,并且显示了这些对象在内存中所占用的大小。但是这种直方图最大的问题是:对于同一个类的所有对象都被聚合(group)在一起了,所以你还需要进一步做一些检测来确定这些内存在哪里被分配了。

使用jmap并且加上-histo参数可以为你产生一个直方图,它显示了从程序运行到现在所有对象的数量和内存消耗,并且包含了已经被回收的对象和内存。如果使用-histo:live参数会显示当前还在堆中得对象数量及其内存消耗,不论这些对象是否要被垃圾搜集器进行回收。

也就是说,如果你要得到一个当前时间下得准确信息,你需要在使用jmap之前强制执行一次垃圾回收。如果你的应用程序是运行在本地,最简单的方式是直接使用jconsole:在’Memory’标签下,有一个’Perform GC’的按钮。如果应用程序是运行在服务端环境,并且JMX beans被暴露了,MemoryMXBean有一个gc()操作。如果上述的两种方案都没办法满足你得要求,你就只有等待JVM自己触发一次垃圾搜集过程了。如果你有一个很严重的内存泄露问题,那么第一次major collection很可能预示着不久后就会OOM。

有两种方法使用jmap产生的直方图。其中最有效的方法,适用于长时间运行的程序,可以使用带live的命令行参数,并且在一段时间内多次使用该命令,检查哪些对象的数量在不断增长。但是,根据当前程序的负载,该过程可能会花费1个小时或者更多的时间。

另外一个更加快速的方式是直接比较当前存活的对象数量和总的对象数量。如果有些对象占据了总对象数量的大部分,那么这些对象很有可能发生内存泄露。这里有一个例子,这个应用程序已经连续几周为100多个用户提供了服务,结果列举了前12个数量最多的对象。据我所知,这个程序没有内存泄露的问题,但是像其他应用程序一样做了常规性的内存转储分析操作。

~, 510> jmap -histo 7626 | more

 num     #instances         #bytes  class name
----------------------------------------------
   1:        339186       63440816  [C
   2:         84847       18748496  [I
   3:         69678       15370640  [Ljava.util.HashMap$Entry;
   4:        381901       15276040  java.lang.String
   5:         30508       13137904  [B
   6:        182713       10231928  java.lang.ThreadLocal$ThreadLocalMap$Entry
   7:         63450        8789976  <constMethodKlass>
   8:        181133        8694384  java.lang.ref.WeakReference
   9:         43675        7651848  [Ljava.lang.Object;
  10:         63450        7621520  <methodKlass>
  11:          6729        7040104  <constantPoolKlass>
  12:        134146        6439008  java.util.HashMap$Entry

~, 511> jmap -histo:live 7626 | more

 num     #instances         #bytes  class name
----------------------------------------------
   1:        200381       35692400  [C
   2:         22804       12168040  [I
   3:         15673       10506504  [Ljava.util.HashMap$Entry;
   4:         17959        9848496  [B
   5:         63208        8766744  <constMethodKlass>
   6:        199878        7995120  java.lang.String
   7:         63208        7592480  <methodKlass>
   8:          6608        6920072  <constantPoolKlass>
   9:         93830        5254480  java.lang.ThreadLocal$ThreadLocalMap$Entry
  10:        107128        5142144  java.lang.ref.WeakReference
  11:         93462        5135952  <symbolKlass>
  12:          6608        4880592  <instanceKlassKlass>

当我们要尝试寻找内存泄露问题,可以从消耗内存最多的对象着手。这听上去很明显,但是往往它们并不是内存泄露的根源。但是,它们任然是应该最先下手的地方,在这个例子中,最占用内存的是一些char[]的数组对象(总大小是60MB,基本上没有任何问题)。但是很奇怪的是当前存货(live)的对象竟然占了历史分配的总对象大小的三分之二。

一般来说,一个应用程序会分配对象,并且在不久之后就会释放它们。如果保存一些对象的应用过长的时间,就很有可能会导致内存泄露。但是虽然是这么说的,实际上还是要具体情况具体分析,主要还是要看这个程序到底在做什么事情。字符数组对象(char[])往往和字符串对象(String)同时存在,大部分的应用程序都会在整个运行过程中一直保持着一些字符串对象的引用。例如,基于JSP的web应用程序在JSP页面中定义了很多HTML字符串表达式。这种特殊的应用程序提供HTML服务,但是它们需要保持字符串引用的需求却不一定那么清晰:它们提供的是目录服务,并不是静态文本。如果我遇到了OOM,我就会尝试找到这些字符串在哪里被分配,为什么没有被释放。

另一个需要关注的是字节数组([B)。在JDK中有很多类都会使用它们(比如BufferedInputStream),但是却很少在应用程序代码中直接看到它们。通常它们会被用作缓存(buffer),但是缓存的生命周期不会很长。在这个例子中我们看到,有一半的字节数组任然保持存活。这个是令人担忧的,并且它凸显了直方图的一个问题:所有的对象都按照它的类型被分组聚合了。对于应用程序对象(非JDK类型或者原始类型,在应用程序代码中定义的类),这不是一个问题,因为它们会在程序的一个部分被集中分配。但是字节数组有可能会在任何地方被定义,并且在大多数应用程序中都被隐藏在一些库中。我们是否应当搜索调用了new byte[]或者new ByteArrayOutputStream()的代码?

堆转储分析:相关的原因和影响分析

为了找到导致内存泄露的最终原因,仅仅考虑按照类别(class)的分组的内存占用字节数是不够的。你还需要将应用程序分配的对象和内存泄露的对象关联起来考虑。一个方法是更加深入查看对象的数量,以便将具有关联性的对象找出来。下面是一个具有严重内存问题的程序的转储信息:

num     #instances         #bytes  class name
----------------------------------------------
   1:       1362278      140032936  [Ljava.lang.Object;
   2:         12624      135469922  [B
  ...
   5:        352166       45077248  com.example.ItemDetails
  ...
   9:       1360742       21771872  java.util.ArrayList
  ...
  41:          6254         200128  java.net.DatagramPacket

如果你仅仅去看信息的前几行,你可能会去定位Object[]或者byte[],这些都是徒劳的。真正的问题出在ItemDetails和DatagramPacket上:前者分配了大量的ArrayList,进而又分配了大量的Object[];后者使用了大量的byte[]来保存从网络上接收到的数据。

第一个问题,分配了大量的数组,实际上不是内存泄露。ArrayList的默认构造函数会分配容量是10的数组,但是程序本身一般只使用1个或者2个槽位,这对于64位JVM来说会浪费62个字节的内存空间。一个更好的涉及方案是仅仅在有需要的时候才使用List,这样对每个实例来说可以节约额外的48个字节。但是,对于这种问题也可以很轻易的通过加内存来解决,因为现在的内存非常便宜。

但是对于datagram的泄露就比较麻烦(如同定位这个问题一样困难):这表明接收到的数据没有被尽快的处理掉。

为了跟踪问题的原因和影响,你需要知道你的程序是怎样在使用这些对象。不多的程序才会直接使用Object[]:如果确实要使用数组,程序员一般都会使用带类型的数组。但是,ArrayList会在内部使用。但是仅仅知道ArrayList的内存分配是不够的,你还需要顺着调用链往上走,看看谁分配了这些ArrayList。

其中一个方法是对比相关的对象数量。在上面的例子中,byte[]和DatagramPackage的关系是很明显的:其中一个基本上是另外一个的两倍。但是ArrayList和ItemDetails的关系就不那么明显了。(实际上一个ItemDetails中会包含多个ArrayList)

这往往是个陷阱,让你去关注那么数量最多的一些对象。我们有数百万的ArrayList对象,并且它们分布在不同的class中,也有可能集中在一小部分class中。尽管如此,数百万的对象引用是很容易被定位的。就算有10来个class可能会包含ArrayList,那么每个class的实体对象也会有十万个,这个是很容易被定位的。

从直方图中跟踪这种引用关系链是需要花费大量精力的,幸运的是,jmap不仅仅可以提供直方图,它还可以提供可以浏览的堆转储信息。

堆转储分析:跟踪引用链

浏览堆转储引用链具有两个步骤:首先需要使用-dump参数来使用jmap,然后需要用jhat来使用转储文件。如果你确定要使用这种方法,请一定要保证有足够多的内存:一个转储文件通常都有数百M,jhat需要好几个G的内存来处理这些转储文件。

tmp, 517> jmap -dump:live,file=heapdump.06180803 7626
Dumping heap to /home/kgregory/tmp/heapdump.06180803 ...
Heap dump file created

tmp, 518> jhat -J-Xmx8192m heapdump.06180803
Reading from heapdump.06180803...
Dump file created Sat Jun 18 08:04:22 EDT 2011
Snapshot read, resolving...
Resolving 335643 objects...
Chasing references, expect 67 dots...................................................................
Eliminating duplicate references...................................................................
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

提供给你的默认URL显示了所有加载进系统的class,但是我觉得并不是很有用。相反,我直接使用http://localhost:7000/histo/,这个地址是一个直方图的视角来进行显示,并且是按照对象数量和占用的内存空间进行排序了的。

这个直方图里的每个class的名称都是一个链接,点击这个链接可以查看关于这个类型的详细信息。你可以在其中看到这个类的继承关系,它的成员变量,以及很多指向这个类的实体变量信息的链接。我不认为这个详细信息页面非常有用,而且实体变量的链接列表很占用很多的浏览器内存。

为了能够跟踪你的内存问题,最有用的页面是’Reference by Type’。这个页面含有两个表格:入引用和出引用,他们都被引用的数量进行排序了。点击一个类的名字可以看到这个引用的信息。

你可以在类的详细信息(class details)页面中找到这个页面的链接。

堆转储分析:内存分配情况

在大多数情况下,知道了是哪些对象消耗了大量的内存往往就可以知道它们为什么会发生内存泄露。你可以使用jhat来找到所有引用了他们的对象,并且你还可以看到使用了这些对象的引用的代码。但是在有些时候,这样还是不够的。

比如说你有关于字符串对象的内存泄露问题,那么就很有可能会花费你好几天的时间去检查所有和字符串相关的代码。要解决这种问题,你就需要能够显示内存在哪里被分配的堆转储。但是需要注意的是,这种类型的堆转储会对你的应用程序产生更多的负载,因为负责转储的代理需要记录每一个new操作符

有许多交互式的程序可以做到这种级别的数据记录,但是我找到了一个更简单的方法,那就是使用内置的hprof代理来启动JVM。

java -Xrunhprof:heap=sites,depth=2 com.kdgregory.example.memory.Gobbler

hprof有许多选项:不仅仅可以用多种方式输出内存使用情况,它还可以跟踪CPU的使用情况。当它运行的时候,我指定了一个事后的内存转储,它记录了哪些对象被分配,以及分配的位置。它的输出被记录在了java.hprof.txt文件中,其中关于堆转储的部分如下:

SITES BEGIN (ordered by live bytes) Tue Sep 29 10:43:34 2009
          percent          live          alloc&#39;ed  stack class
 rank   self  accum     bytes objs     bytes  objs trace name
    1 99.77% 99.77%  66497808 2059  66497808  2059 300157 byte[]
    2  0.01% 99.78%      9192    1     27512    13 300158 java.lang.Object[]
    3  0.01% 99.80%      8520    1      8520     1 300085 byte[]
SITES END

这个应用程序没有分配多种不同类型的对象,也没有将它们分配到很多不同的地方。一般的转储有成百上千行的信息,显示了每一种类型的对象被分配到了哪里。幸运的是,大多数问题都会出现在开头的几行。在这个例子中,最突出的是64M的存活着的字节数组,并且每一个平均32K。

大多数程序中都不会一直持有这么大得数据,这就表明这个程序没有很好的抽取和处理这些数据。你会发现这常常发生在读取一些大的字符串,并且保存了substring之后的字符串:很少有人知道String.substring()后会共享原始字符串对象的字节数组。如果你按照一行一行地读取了一个文件,但是却使用了每行的前五个字符,实际上你任然保存的是整个文件在内存中。

转储文件也显示出这些数组被分配的数量和现在存活的数量完全相等。这是一种典型的泄露,并且我们可以通过搜索’trace’号来找到真正的代码:

TRACE 300157:
    com.kdgregory.example.memory.Gobbler.main(Gobbler.java:22)

好了,这下就足够简单了:当我在代码中找到指定的代码行时,我发现这些数组被存放在了ArrayList中,并且它也一直没有出作用域。但是有时候,堆栈的跟踪并没有直接关联到你写的代码上:

TRACE 300085:
    java.util.zip.InflaterInputStream.<init>(InflaterInputStream.java:71)
    java.util.zip.ZipFile$2.<init>(ZipFile.java:348)

在这个例子中,你需要增加堆栈跟踪的深度,并且重新运行你的程序。但是这里有一个需要平衡的地方:当你获取到了更多的堆栈信息,你也同时增加了profile的负载。默认地,如果你没有指定depth参数,那么默认值就会是4。我发现当堆栈深度为2的时候就可以发现和定位我程序中得大部分问题了,当然我也使用过深度为12的参数来运行程序。

另外一个增大堆栈深度的好处是,最后的报告结果会更加细粒度:你可能会发现你泄露的对象来自两到三个地方,并且它们都使用了相同的方法。

堆转储分析:位置、地点

当很多对象在分配的不久后就被丢弃时,分代垃圾搜集器就会开始运行。你可以使用同样的原则来找发现内存泄露:使用调试器,在对象被分配的地方打上断点,并且运行这段代码。在大多数时候,当它们被分配不久后就会加入到长时间存活(long-live)的集合中。

永久代

除了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.URLClassLoader
RESOLVE 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/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
...

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的错误。

The above is the detailed content of An in-depth analysis of Java OutOfMemoryError. For more information, please follow other related articles on the PHP Chinese website!

Statement:
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn