ホームページ  >  記事  >  Java  >  Java OutOfMemoryError の詳細な分析

Java OutOfMemoryError の詳細な分析

黄舟
黄舟オリジナル
2017-03-20 10:51:291717ブラウズ

Java では、すべてのオブジェクトはヒープに保存されます。これらは new キーワードを通じて割り当てられ、JVM はすべてのスレッドがそれらにアクセスできなくなったかどうかを確認してリサイクルします。ほとんどの場合、プログラマーはそのことにまったく気付かず、これらのタスクは静かに実行されます。ただし、場合によっては、リリース前の最終日にプログラムがハングアップすることがあります。 new关键字来进行分配,JVM会检查是否所有线程都无法在访问他们了,并且会将他们进行回收。在大多数时候程序员都不会有一丝一毫的察觉,这些工作都被静悄悄的执行。但是,有时候在发布前的最后一天,程序挂了。

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

OutOfMemoryError是一个让人很郁闷的异常。它通常说明你干了写错误的事情:没必要的长时间保存一些没必要的数据,或者同一时间处理了过多的数据。有些时候,这些问题并不一定受你的控制,比如说一些第三方的库对一些字符串做了缓存,或者一些应用服务器在部署的时候并没有进行清理。并且,对于堆中已经存在的对象,我们往往拿他们没办法。

这篇文章分析了导致OutOfMemoryError的不同原因,以及你该怎样应对这种原因的方法。以下分析仅限于Sun Hotspot虚拟机,但是大多数结论都适用于其他任何的JVM实现。它们大多数基于网上的文章以及我自己的经验。我没有直接做JVM开发的工作,因此结论并不代表JVM的作者。但是我确实曾经遇到过并解决了很多内存相关的问题。

垃圾回收介绍

我在这篇文章中已经详细介绍了垃圾回收的过程。简单的说,标记-清除算法(mark-sweep collect)以garbage collection roots作为扫描的起点,并对整个对象图进行扫描,对所有可达的对象进行标记。那些没有被标记的对象会被清除并回收。

Java的垃圾回收算法过程意味着如果出现了OOM,那么说明你在不停的往对象图中添加对象并且没有移除它们。这通常是因为你在往一个集合类中添加了很多对象,比如Map,并且这个集合对象是static的。或者,这个集合类被保存在了<span class="wp_keywordlink">ThreadLocal</span>对象中,而这个对应的Thread却又长时间的运行,一直不退出。

这与C和C++的内存泄露完全不一样。在这些语言中,如果一些方法调用了malloc()或者new,并且在方法退出的时候没有调用相应的free()或者delete,那么内存就会产生泄露。这些是真正意义上得泄露,你在这个进程范围内不可能再恢复这些内存,除非使用一些特定的工具来保证每一个内存分配方法都有其对应的内存释放操作相对应。

在java中,“泄露”这个词往往被误用了。因为从JVM的角度来说,所有的内存都是被良好管理的。问题仅仅是作为程序员的你不知道这些内存是被哪些对象占用了。但是幸运的是,你还是有办法去找到和定位它们。

在深入探讨之前,你还有最后一件关于垃圾收集的知识需要了解:JVM会尽最大的能力去释放内存,直到发生OOM。这就意味着OOM不能通过简单的调用System.gc()

java -Xms256m -Xmx512m MyClass

OutOfMemoryError は非常に憂鬱な例外です。これは通常、不必要なデータを不必要に長時間保存したり、同時に処理するデータが多すぎるなど、何か間違ったことをしていることを意味します。たとえば、一部のサードパーティ ライブラリが一部の文字列をキャッシュしたり、一部のアプリケーション サーバーが展開中にクリーンアップされなかったりする場合があります。さらに、ヒープ内にすでに存在するオブジェクトとは何の関係もないことがよくあります。

この記事では、OutOfMemoryError のさまざまな原因とその対処方法を分析します。以下の分析は Sun Hotspot 仮想マシンに限定されていますが、結論のほとんどは他の JVM 実装にも当てはまります。それらのほとんどはオンラインの記事と私自身の経験に基づいています。私は JVM 開発作業を直接行ったことがないため、結論は JVM の作成者を代表するものではありません。しかし、メモリに関する多くの問題に遭遇し、解決しました。

ガベージコレクションの紹介

ガベージコレクションのプロセスをこの記事で詳しく紹介しました。簡単に言うと、マーク スイープ コレクト アルゴリズムは、スキャンの開始点として ガベージ コレクション ルート を使用し、オブジェクト グラフ全体をスキャンして、到達可能なすべてのオブジェクトをマークします。マークされていないオブジェクトはクリアされ、リサイクルされます。

Java のガベージ コレクション アルゴリズム プロセスでは、OOM が発生すると、オブジェクト グラフにオブジェクトが常に追加され、削除されないことになります。これは通常、Map などのコレクション クラスに多くのオブジェクトを追加しているためです。このコレクション オブジェクトは静的です。または、このコレクション クラスは <span class="wp_keywordlink">ThreadLocal</span> オブジェクトに保存されますが、対応するスレッドが長時間実行され、終了しません。

これは、C や C++ のメモリ リークとはまったく異なります。これらの言語では、一部のメソッドが malloc() または new を呼び出し、メソッドの終了時に対応する free() または delete が呼び出されない場合、メモリ リークが発生します。これらは本当の意味でのリークであり、特定のツールを使用して各メモリ割り当て方法に対応するメモリ解放操作があることを確認しない限り、このプロセスの範囲内でこれらのメモリを復元することはできません。

Java では、「リーク」という言葉がよく誤用されます。 JVM の観点から見ると、すべてのメモリが適切に管理されているためです。問題は、プログラマとしては、どのオブジェクトがこのメモリを占有しているのかわからないということです。しかし幸いなことに、それらを見つけて見つける方法はまだあります。 🎜🎜本題に入る前に、ガベージ コレクションについて最後に知っておくべきことが 1 つあります。JVM は、OOM が発生するまでメモリを解放するために最善を尽くします。これは、単に System.gc() を呼び出すだけでは OOM を解決できないことを意味します。これらの「リーク」ポイントを見つけて自分で処理する必要があります。 🎜🎜ヒープ サイズを設定します🎜🎜学者は、Java 言語仕様ではガベージ コレクターに関する規則が何も定められておらず、メモリを決して解放しない (実際には無意味である) JVM を実装することもできる、とよく言います。 Java 仮想マシンの仕様には、ヒープがガベージ コレクターによって管理されることが記載されていますが、関連する詳細については説明されていません。先ほど述べたことは、ガベージ コレクションは OOM の前に行われるということです。 🎜🎜実際、Sun Hotspot 仮想マシンは固定サイズのヒープ領域を使用し、最小領域と最大領域の間で自動的に拡張できます。 🎜最小値と最大値を指定しない場合、「クライアント」モードの場合はデフォルトで最小値として 2Mb、「サーバー」モードの場合は最大値として 64Mb が使用され、JVM はデフォルト値に基づいてデフォルト値を決定します。現在利用可能なメモリ🎜に。 2000 年以降、デフォルトの最大ヒープ サイズは 64M に変更され、当時は十分な大きさであると考えられていましたが (2000 年以前のデフォルト値は 16M)、今日のアプリケーションでは簡単に使い切ることができます。 🎜🎜これは、JVM パラメーターを介してヒープの最小値と最大値を明示的に指定する必要があることを意味します: 🎜
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]
...
🎜 最大値と最小値の設定には多くの経験則があります。明らかに、ヒープの最大サイズは、アプリケーション全体で必要なすべてのオブジェクトを収容できる十分な大きさに設定する必要があります。ただし、「十分な大きさ」に設定することも、ガベージ コレクターの負荷が増加するため、お勧めできません。したがって、長時間実行されるアプリケーションの場合は、通常、ヒープ領域の 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 の根本原因は一般にその問題点に非常に近いため、最初にヒープ トレース テクノロジを使用して分析できるということです。基本原理は、メモリ リークは一般に大量のメモリの生成に関連しているということです。これは、メモリ割り当てコードの呼び出し頻度が高すぎるため、または呼び出しごとに割り当てられるメモリが多すぎるため、リークを引き起こすコードは失敗するリスクが高いことを示しています。したがって、スタック トレースを使用して問題を特定することを優先できます。

キャッシュに関連する部分が最も疑問です

この記事でキャッシュについて何度も言及しましたが、私の数十年の Java 実務経験の中で、メモリ リークに関連するクラスはすべてキャッシュに関連していることがわかりました。キャッシュを記述するのは実際には非常に困難です。

キャッシュを使用する正当な理由はたくさんありますし、独自のキャッシュを使用する正当な理由もたくさんあります。キャッシュを使用する場合は、まず次の質問に答えてください:

  • どのオブジェクトがキャッシュに入れられますか?キャッシュするオブジェクトがすべて同じタイプである場合 (または継承関係がある場合)、さまざまなタイプに対応できるキャッシュがある場合よりも、問題を追跡する方が適切です。

  • 同時にキャッシュに入れられるオブジェクトの数はいくつですか? ProductCache に 1,000 個のオブジェクトをキャッシュさせても、メモリ分析結果では 10,000 個のオブジェクトが見つかった場合、この関係を特定するのが容易になります。このキャッシュの最大容量制限を指定すると、このキャッシュが必要とする最大メモリ量を簡単に計算できます。 ProductCache缓存1000个对象,但是在内存分析结果中发现了10000个对象,那么这之间的关系就比较好定位。如果你指定了这个缓存最多的容量上限,那么你就可以很容易的计算出这个缓存最多需要多少内存。

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

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

注意对象的生命周期

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

一种方法是在程序启动的时候全部初始化好所有长时间(long-lived)存活的对象,不管他们是否要立刻被用到。另外一个方法是使用依赖注入框架,比如Spring

有効期限と削除のポリシーとは何ですか?すべてのキャッシュには、そこに存在するオブジェクトのインベントリ サイクルを制御するために、明示的なエビクション ポリシーが必要です。明示的なエビクション戦略を指定しない場合、一部のオブジェクトは実際に必要な期間よりも長く存続し、より多くのメモリを占有し、ガベージ コレクターの負荷が増加する可能性があります (マーキング フェーズに必要な時間は、マーキング フェーズに必要な時間と生き残ったオブジェクトの数)。

これらのライブオブジェクトへの参照をキャッシュの外に保持しますか?キャッシュに最適なアプリケーション シナリオは、呼び出しが頻繁で呼び出し時間が短く、キャッシュされたオブジェクトの取得にコストがかかる場合です。 オブジェクトを作成

し、アプリケーションのライフサイクル全体を通じてこのオブジェクトを参照する必要がある場合は、次のようにします。このオブジェクトをキャッシュに入れる必要はありません (オブジェクトの総数を表示するためにプーリング テクノロジを使用する可能性があります)。

🎜🎜オブジェクトのライフサイクルに注意してください🎜🎜 一般に、オブジェクトは 2 つのカテゴリに分類できます。1 つのタイプはプログラム全体のライフサイクルとともに存続し、もう 1 つのタイプは単一のアスクのみを処理します。 。長期間存続すると思われるオブジェクトのみを考慮する必要があることを理解することが重要です。 🎜🎜 1 つの方法は、すぐに使用されるかどうかに関係なく、プログラムの開始時にすべての長期オブジェクトを初期化することです。もう 1 つの方法は、Spring などの依存関係挿入
フレームワークを使用することです。 。これにより、Bean 構成ファイル内のすべての長期オブジェクトを (クラスパス全体をスキャンせずに) 簡単に見つけられるだけでなく、これらのオブジェクトが使用されている場所も明確になります。 🎜🎜メソッドのパラメーターで誤って使用されているオブジェクトを見つける🎜🎜 ほとんどのシナリオでは、メソッドに割り当てられたオブジェクトは、メソッドの終了時にクリーンアップされます (返されたオブジェクトを除く)。ローカル変数を使用してこれらのオブジェクトを保存すると、このルールに従うのが簡単になります。ただし、特にメソッド内で他の多数のメソッドが呼び出される場合、主に過剰で面倒なメソッド パラメーターの受け渡しを避けるために、これらのオブジェクトを保存するためにエンティティ変数が依然として使用されることがあります。 🎜🎜これを行うと必ず漏れが発生するわけではありません。後続のメソッド呼び出しではこれらの変数が再割り当てされ、以前に作成されたオブジェクトをリサイクルできるようになります。ただし、これにより不必要なメモリ オーバーヘッドが発生し、デバッグがより困難になります。しかし、設計の観点からそのようなコードを見た場合、このメソッドを分離して独立したクラスを形成することを検討します。 🎜

J2EE: session を悪用しないでください

セッション オブジェクトは、主に HTTP プロトコルがステートレスであるため、複数のリクエスト間でユーザー関連のデータを保存および共有するために使用されます。場合によっては、キャッシュの一時的な解決策となることがあります。

Web コンテナは一定期間が経過するとユーザーのセッションを無効にするため、リークが確実に発生するという意味ではありません。ただし、プログラム全体のメモリ使用量が大幅に増加するため、問題があります。そして、デバッグは非常に困難です。前に述べたように、オブジェクトが保持している他のオブジェクトを確認するのは困難です。

過剰なガベージ コレクションに注意してください

OOM は悪いものですが、ガベージ コレクションを実行し続けるとさらに悪くなります。プログラムに属するはずの CPU 時間が奪われてしまいます。

もっと多くのメモリが必要な場合もあります

最初に述べたように、JVM はデータの最大サイズ (メモリの上限) を指定できる唯一の最新のプログラミング環境です。したがって、メモリ リークがあると思われる場合がよくありますが、実際にはヒープ サイズを増やす必要があるだけです。メモリの問題を解決するための最良の最初のステップは、メモリ制限を増やすことです。メモリ リークの問題が発生した場合、メモリをどれだけ追加しても、最終的には OOM エラーが発生します。

以上がJava OutOfMemoryError の詳細な分析の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。