>  기사  >  Java  >  Java OutOfMemoryError에 대한 심층 분석

Java OutOfMemoryError에 대한 심층 분석

黄舟
黄舟원래의
2017-03-20 10:51:291729검색

Java에서는 모든 객체가 힙에 저장됩니다. new 키워드를 통해 할당되며 JVM은 모든 스레드가 더 이상 액세스할 수 없는지 확인하고 재활용합니다. 대부분의 경우 프로그래머는 이를 전혀 인식하지 못하고 이러한 작업은 조용히 수행됩니다. 그러나 때때로 프로그램이 출시되기 전 마지막 날에 중단되는 경우가 있습니다.

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

OutOfMemoryError는 매우 우울한 변칙입니다. 이는 일반적으로 뭔가 잘못된 일을 하고 있음을 의미합니다. 불필요한 데이터를 불필요한 시간 동안 저장하거나 동시에 너무 많은 데이터를 처리하는 것입니다. 경우에 따라 이러한 문제를 반드시 사용자가 제어할 수 있는 것은 아닙니다. 예를 들어 일부 타사 라이브러리가 일부 문자열을 캐시하거나 일부 응용 프로그램 서버가 배포 중에 정리되지 않습니다. 게다가 이미 힙에 존재하는 객체와 아무 관련이 없는 경우도 많습니다.

이 글에서는 OutOfMemoryError이 발생하는 다양한 원인과 대처 방법을 분석합니다. 다음 분석은 Sun Hotspot 가상 머신으로 제한되지만 대부분의 결론은 다른 JVM 구현에도 적용됩니다. 대부분은 온라인 기사와 내 경험을 바탕으로 작성되었습니다. JVM 개발 작업을 직접 수행한 것이 아니므로 결론이 JVM 작성자를 대표하지 않습니다. 하지만 저는 메모리와 관련된 많은 문제를 접하고 해결했습니다.

가비지 수집 소개

이 글에서는 가비지 수집 과정을 자세히 소개했습니다. 간단히 말해서, mark-sweep 수집 알고리즘은 garbage collection roots을 스캔 시작점으로 사용하여 전체 객체 그래프를 스캔하고 도달 가능한 모든 객체를 표시합니다. 표시되지 않은 개체는 지워지고 재활용됩니다.

Java의 가비지 컬렉션 알고리즘 프로세스는 OOM이 발생하면 객체 그래프에 객체를 지속적으로 추가하고 제거하지 않는다는 의미입니다. 이는 일반적으로 Map과 같은 컬렉션 클래스에 많은 개체를 추가하고 컬렉션 개체가 정적이기 때문입니다. 또는 이 컬렉션 클래스가 <code><span class="wp_keywordlink">ThreadLocal</span>ThreadLocal

개체에 저장되지만 해당 스레드가 오랫동안 실행되고 종료되지 않습니다.

C나 C++의 메모리 누수와는 완전히 다릅니다. 이러한 언어에서는 일부 메서드가 malloc() 또는 new를 호출하고 해당 메서드가 종료될 때 해당 free() 또는 delete가 호출되지 않으면 메모리 누수가 발생합니다. 이는 진정한 의미에서 누수입니다. 각 메모리 할당 방법에 해당하는 메모리 해제 작업이 있는지 확인하기 위해 특정 도구를 사용하지 않는 한 이 프로세스의 범위 내에서 이러한 메모리를 복원할 수 없습니다.

자바에서는 '누출'이라는 단어가 오용되는 경우가 많습니다. 왜냐하면 JVM의 관점에서는 모든 메모리가 잘 관리되기 때문입니다. 문제는 프로그래머로서 어떤 객체가 이 메모리를 차지하고 있는지 모른다는 것입니다. 하지만 다행히도 여전히 해당 항목을 찾고 찾을 수 있는 방법이 있습니다.

System.gc()자세히 살펴보기 전에 가비지 수집에 대해 마지막으로 알아야 할 사항이 있습니다. JVM은 OOM이 발생할 때까지 메모리를 해제하기 위해 최선을 다할 것입니다. 즉, 단순히

을 호출하는 것만으로는 OOM을 해결할 수 없으며 이러한 "누수" 지점을 찾아 직접 처리해야 합니다.

힙 크기 설정

학자들은 Java 언어 사양이 가비지 컬렉터에 대한 어떠한 규칙도 정하지 않는다고 말하고 싶어합니다. 메모리를 절대 해제하지 않는 JVM을 구현할 수도 있습니다(실제로는 그렇지 않습니다). 모두) 의미가 없습니다. Java Virtual Machine 사양에서는 힙이 가비지 수집기에 의해 관리된다고 언급하지만 관련 세부 정보는 지정하지 않습니다. 방금 언급한 내용을 말했습니다. OOM 이전에 가비지 수집이 발생합니다.

실제로 Sun Hotspot 가상 머신은 고정된 크기의 힙 공간을 사용하며 최소 공간과 최대 공간 사이에서 자동 증가를 허용합니다. 최소값과 최대값을 지정하지 않으면 기본적으로 '클라이언트' 모드의 경우 2Mb가 최소값으로 사용되고 '서버' 모드의 경우 64Mb가 최대값으로 사용되며 JVM이 기본값을 결정합니다. 현재 사용 가능한 메모리를 기준으로 한 값입니다

. 2000년 이후에는 기본 최대 힙 크기가 64M으로 변경되었으며 당시에는 충분히 큰 것으로 간주되었지만(2000년 이전의 기본값은 16M) 오늘날의 애플리케이션에서는 쉽게 사용할 수 있습니다.

이는 JVM 매개변수를 통해 최소 및 최대 힙 크기를 명시적으로 지정해야 함을 의미합니다.

java -Xms256m -Xmx512m MyClass

최대값 및 최소값 설정에 대한 경험상 많은 규칙이 있습니다. 당연히 힙의 최대 크기는 전체 애플리케이션에 필요한 모든 개체를 수용할 수 있을 만큼 크게 설정되어야 합니다. 그러나 "충분히 큰 크기"로 설정하는 것도 좋은 생각이 아닙니다. 이렇게 하면 가비지 수집기의 로드가 증가하기 때문입니다. 따라서 장기 실행 애플리케이션의 경우 일반적으로 힙 공간의 20%-25%를 여유 공간으로 유지해야 합니다. (애플리케이션에 따라 다른 매개변수 설정이 필요할 수 있습니다. GC 튜닝은 예술이며 이 기사의 범위를 벗어납니다.)

让你奇怪的时,设置合适的堆的最小值往往比设置合适的最大值更加重要。垃圾回收器会尽可能的保证当前的的堆大小,而不是不停的增长堆空间。这会导致应用程序不停的创建和回收大量的对象,而不是获取新的堆空间,相对于初始(最小)堆空间。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이 발생하는 지점에 매우 가까운 경우가 많습니다.

메모리 누수는 일반적으로 메모리 할당 직후에 발생합니다. 비슷한 결론은 OOM의 근본 원인이 일반적으로 발생 지점에 매우 가깝고 힙 추적 기술을 사용하여 이를 먼저 분석할 수 있다는 것입니다. 기본 원칙은 메모리 누수가 일반적으로 대량의 메모리 생성과 연관되어 있다는 것입니다. 이는 메모리 할당 코드가 너무 자주 호출되거나 각 호출에 너무 많은 메모리를 할당하기 때문에 누수를 일으키는 코드가 실패할 위험이 더 높다는 것을 보여줍니다. 따라서 스택 추적을 사용하여 문제를 찾는 것이 우선순위일 수 있습니다.

캐싱과 관련된 부분이 가장 의문스럽습니다

이 기사에서 캐싱에 대해 여러 번 언급했습니다. 수십 년 동안 Java를 사용하면서 메모리 누수는 다음과 관련이 있다는 것을 알게 되었습니다. 클래스 항목은 캐싱과 관련이 있습니다. 캐싱은 실제로 작성하기가 매우 어렵습니다.

캐싱을 사용하는 데는 수많은 이유가 있고, 자체 캐시를 사용하는 데에는 많은 이유가 있습니다. 캐싱을 사용하기로 결정한 경우 먼저 다음 질문에 대답하십시오.

  • 어떤 개체가 캐시에 저장되나요? 캐시하려는 개체가 모두 동일한 유형(또는 상속 관계가 있는 경우)인 경우 다양한 유형을 수용할 수 있는 캐시가 있는 것보다 문제를 추적하는 것이 더 좋습니다.

  • 동시에 캐시에 몇 개의 개체가 저장됩니까? ProductCache 1,000개의 개체를 캐시하도록 했는데 메모리 분석 결과에서 10,000개의 개체가 발견되면 이들 사이의 관계를 찾기가 더 쉽습니다. 이 캐시의 최대 용량 제한을 지정하면 이 캐시에 필요한 최대 메모리 양을 쉽게 계산할 수 있습니다.

  • 만료 및 삭제 정책은 무엇인가요? 모든 캐시에는 그 안에 있는 개체의 인벤토리 주기를 제어하기 위해 명시적인 제거 정책이 필요합니다. 명시적인 제거 전략을 지정하지 않으면 일부 개체는 실제로 필요한 것보다 오래 살고 더 많은 메모리를 차지하며 가비지 수집기의 로드를 증가시킬 수 있습니다(기억: 마킹 단계에 필요한 시간과 살아남은 객체의 수).

  • 은 캐시 외부의 이러한 라이브 객체에 대한 참조를 보유합니까? 캐싱에 대한 가장 좋은 애플리케이션 시나리오는 호출이 빈번하고 호출 시간이 짧으며 캐시된 개체를 얻는 데 비용이 많이 드는 경우입니다. 객체를 생성해야 하고 애플리케이션 수명 전체에 걸쳐 해당 객체를 참조해야 하는 경우 객체를 캐시에 넣을 필요가 없습니다(아마도 풀링 기술을 사용하여 총 객체 수를 표시할 수 있음). 사물).

객체의 라이프사이클에 주의하세요

일반적으로 객체는 두 가지 범주로 나눌 수 있습니다. 한 유형은 전체 프로그램의 라이프사이클을 유지하면서 살아남습니다. 다른 하나는 살아 있고 단일 요청만 처리합니다. 오랫동안 살아남을 것이라고 생각되는 물체에만 관심을 기울이면 된다는 점을 이해하는 것이 중요합니다.

한 가지 방법은 즉시 사용 여부에 관계없이 프로그램이 시작될 때 수명이 긴 모든 개체를 초기화하는 것입니다. 또 다른 방법은 과 같은 종속성 주입 프레임워크 Spring를 사용하는 것입니다. 이를 통해 빈구성 파일에서 모든 장기 개체를 쉽게 찾을 수 있을 뿐만 아니라(전체 클래스 경로를 검색하지 않고도) 이러한 개체가 사용되는 위치를 명확하게 알 수 있습니다.

메서드 매개변수에서 잘못 사용된 개체 찾기

대부분의 시나리오에서 메서드에 할당된 개체는 메서드가 종료될 때 정리됩니다(반환된 개체 제외). 이 규칙은 로컬 변수를 사용하여 이러한 객체를 저장할 때 따르기 쉽습니다. 그러나 엔터티 변수는 여전히 이러한 개체를 저장하는 데 사용되며, 특히 메서드에서 많은 수의 다른 메서드가 호출되는 경우 주로 과도하고 번거로운 메서드 매개 변수 전달을 피하기 위해 사용됩니다.

꼭 누출이 발생하는 것은 아닙니다. 후속 메서드 호출에서는 이러한 변수를 다시 할당하여 이전에 생성된 개체를 재활용할 수 있습니다. 그러나 이로 인해 불필요한 메모리 오버헤드가 발생하고 디버깅이 더욱 어려워집니다. 하지만 디자인 관점에서 보면 이런 코드를 볼 때 이 메서드를 분리하여 독립적인 클래스를 구성하는 것을 고려할 것입니다.

J2EE: 세션 남용 금지

세션 개체는 주로 HTTP 프로토콜이 상태 비저장이기 때문에 여러 요청 간에 사용자 관련 데이터를 저장하고 공유하는 데 사용됩니다. 때로는 캐싱을 위한 임시 솔루션이 되기도 합니다.

웹 컨테이너가 일정 시간이 지나면 사용자 세션을 무효화하므로 유출이 반드시 발생한다는 의미는 아닙니다. 그러나 이는 전체 프로그램의 메모리 사용량을 크게 증가시키므로 이는 좋지 않습니다. 그리고 디버그하기가 매우 어렵습니다. 앞서 언급했듯이 개체가 보유하고 있는 다른 개체가 무엇인지 확인하기가 어렵습니다.

과도한 가비지 수집에 주의하세요

OOM이 나쁘더라도 가비지 수집을 지속적으로 실행하는 것은 더욱 좋지 않습니다. 프로그램에 속해야 하는 CPU 시간을 훔치게 됩니다.

때때로 더 많은 메모리가 필요할 수도 있습니다

처음에 말했듯이 JVM은 최신 프로그래밍 환경에서 데이터의 최대 크기(메모리 상한)를 지정할 수 있는 유일한 도구입니다. 따라서 메모리 누수가 있다고 생각하지만 실제로는 힙 크기를 늘리면 되는 경우가 많습니다. 메모리 문제를 해결하는 가장 좋은 첫 번째 단계는 메모리 제한을 늘리는 것입니다. 메모리 누수 문제가 발생하면 아무리 많은 메모리를 추가하더라도 결국에는 OOM 오류가 발생하게 됩니다.

위 내용은 Java OutOfMemoryError에 대한 심층 분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.