美文网首页开发技巧Java 杂谈java高级开发群
面试官:小伙子你知道几种内存溢出的原因和解决办法

面试官:小伙子你知道几种内存溢出的原因和解决办法

作者: 并发量就是我的发量 | 来源:发表于2020-12-23 15:36 被阅读0次

    内存不足是影响生产中Java(和其他JVM语言)应用程序的最常见问题之一。这篇文章解释了如何识别内存不足的问题,并使用一个小程序演示一些工具,可以用来找出哪些东西在占用你的内存。

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    内存问题是Java环境中不幸的一部分。如果您在Java虚拟机(JVM)上运行程序,并且没有看到上面所示的错误,那就算幸运了。对于其他人来说,这类问题太常见了,通常通过增加堆大小或尝试JVM开关的随机排列来解决,直到它们消失。

    我们倾向于在JVM中看到这类问题,因为它在固定大小的堆中运行,在JVM第一次启动时设置。作为运行应用程序的人,您需要估计应用程序所需的内存峰值,然后相应地调整JVM堆的大小。这是很难做到的,通常需要一定程度的实验。

    更复杂的是,内存不足的问题并不总是显而易见的。如果幸运的话,应用程序在内存不足时会立即崩溃。但正如它经常一瘸一拐地走着,垃圾回收器不断地试图释放一些空间。这通常会导致内存问题被误诊为应用程序性能问题:top报告应用程序JVM的CPU使用率很高,但实际上所有工作都是由垃圾收集器线程完成的。

    本文主要讨论两个主题:如何知道何时内存不足,以及如何跟踪应用程序中使用内存的位置

    识别内存不足

    如果幸运的话,你知道你的应用程序内存不足,因为它告诉你了。如果在日志文件中看到:

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

    那你就快没内存了。在这一点上,一个流行的选择是尝试使用一个JVM开关来增加JVM堆大小,比如:

    -Xmx1g
    

    希望你的问题能解决。这也许是正确的做法!如果正常使用情况下,应用程序的工作集峰值在1GB左右,那么这里没有问题:设置JVM选项,休息一天。

    对于那些还在这里的人,我们需要做更多的挖掘。一个很好的起点是使用-verbose:gc JVM开关。这将告诉垃圾回收器在每次垃圾收集运行后记录诊断信息。启用后,您将在控制台(或日志文件)中看到以下行:

    [GC 52082K->44062K(116544K), 0.0040520 secs]
     [GC 58654K->45190K(116544K), 0.0042610 secs]
     [GC 59782K->47526K(116544K), 0.0015520 secs]
     [GC 71590K->52294K(116544K), 0.0010260 secs]
     [GC 66886K->93618K(116544K), 0.0074450 secs]
     [Full GC 93618K->42708K(116544K), 0.0126130 secs]
     [GC 56599K->49312K(116544K), 0.0040300 secs]
     [GC 63904K->50976K(116544K), 0.0069590 secs]
     [GC 65568K->53248K(116544K), 0.0014260 secs]
     [GC 77056K->57888K(116544K), 0.0019720 secs]
     [Full GC 99310K->47201K(116544K), 0.0139520 secs]
     [GC 61793K->54476K(116544K), 0.0044440 secs]
     [GC 69068K->56180K(116544K), 0.0054920 secs]
     [GC 70772K->58452K(116544K), 0.0019430 secs]
     [GC 82260K->63060K(116544K), 0.0008430 secs]
     [Full GC 99119K->50845K(116544K), 0.0137490 secs]
     [GC 65437K->58441K(116544K), 0.0050000 secs]
     [GC 73033K->60161K(116544K), 0.0054100 secs]
    

    每行记录垃圾回收运行的详细信息。标记为GC的行表示JVM年轻代的垃圾收集,而带有完整GC的行表示JVM旧代的垃圾收集。

    这里我们不会描述JVM内存管理的完整工作原理,所以这里是简短的版本:年轻一代和老一代是存储对象的不同内存区域,JVM垃圾使用不同的方法收集每个区域。年轻一代垃圾回收器针对许多活得快、死得早的对象进行了优化;老一代垃圾回收器针对寿命较长的对象进行了优化,这些对象更有可能留在周围。对象在第一次被创造的时候是从年轻一代分配的,如果他们活得够长,最终会在老一代那里度过他们的日子,听收音机,抱怨年轻一代。很好。

    所以我们倾向于看到更多的GC行而不是完整的GC行,但是它们都告诉我们相同的东西。随机选取一行:

    [GC 52082K->44062K(116544K), 0.0040520 secs]
    

    这告诉我们,在垃圾收集过程中,年轻一代:

    • 堆开始时使用了52082kb。
    • 垃圾收集运行完成后,它将堆减少到44062KB(释放大约8MB)。
    • JVM堆的总提交大小为116544KB。
    • 完成垃圾收集运行大约需要4毫秒。

    内存不足的问题是什么样子的

    当应用程序遇到问题时,您将开始看到相对较多的完整GC消息。这是一个迹象,表明物体在周围停留,填满了老一代人的区域:

    [GC 85682K->73458K(104448K), 0.0010870 secs]
     [GC 88050K->75730K(116160K), 0.0017100 secs]
     [Full GC 100370K->80306K(116160K), 0.0110770 secs]
     [Full GC 95730K->81808K(116160K), 0.0256440 secs]
     [Full GC 97232K->76495K(116160K), 0.0116220 secs]
     [Full GC 90013K->77613K(116160K), 0.0114290 secs]
     [Full GC 93037K->82221K(116160K), 0.0060700 secs]
     [Full GC 91372K->82222K(116160K), 0.0081970 secs]
     [Full GC 97646K->84525K(116160K), 0.0097500 secs]
     [Full GC 98993K->87661K(116160K), 0.0202760 secs]
    

    人们通常会通过关注箭头左边的数字(堆利用率预收集)来误解垃圾收集日志。请记住,JVM的正常操作模式是累积垃圾,直到达到某个阈值,然后运行垃圾收集器来清除它。

    如果我们获取一些垃圾收集日志,并随着时间的推移,在箭头两侧绘制数字(显示垃圾收集之前和之后的堆利用率),则正常的应用程序如下所示:

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    这里我们看到一个锯齿状的模式,顶部的点表示垃圾收集之前的堆利用率,底部的点表示紧接着的堆利用率。下面的几点很重要:我们看到垃圾收集器成功地释放了内存,并将堆利用率恢复到一个相当一致的基线。

    如果我们绘制上面所示的垃圾收集日志行,情况就不那么乐观了:

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    随着时间的推移,这个应用程序似乎消耗了更多的内存。垃圾回收器在每次运行期间释放的内存有很大的变化,并且每次收集释放的内存较少。

    总而言之,如果出现以下情况,您可能会出现内存不足的问题:

    • 在日志中经常看到完整的GC消息;
    • 垃圾回收后的利用率随着时间的推移而增长。

    如果是这样的话,是时候开始追踪你的内存泄漏了。

    查找内存泄漏

    在本节中,我们将介绍一些可以用来查找代码中内存泄漏的工具。本着以假乱真的精神,这里有一个捏造的例子!这个例子有两部分:

    • GibberishGenerator生成器生成长度不等的非常长的gibberish词行。
    • MemoryLeak检查这些gibberish的行,以找到符合某些标准的第一个“好”单词,并将找到的好单词列表保存在列表中。

    代码如下:

    import java.util.ArrayList;
     import java.util.List;
     import java.util.Iterator;
     import java.util.Random;
    
     public class MemoryLeak
     {
         // Generates long lines of gibberish words.
         static class GibberishGenerator implements Iterator<String>
         {
             private int MAX_WORD_LENGTH = 20;
             private int WORDS_PER_LINE = 250000;
             private String alphabet = ("abcdefghijklmnopqrstuvwxyz" +
                                        "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
    
             public boolean hasNext() {
                 return true;
             }
    
             public String next() {
                 StringBuffer result = new StringBuffer();
                 for (int i = 0; i < WORDS_PER_LINE; i++) {
                     if (i > 0) { result.append(" "); }
                     result.append(generateWord(MAX_WORD_LENGTH));
                 }
    
                 return result.toString();
             }
    
             public void remove() {
                 // not implemented
             }
    
             private String generateWord(int maxLength) {
                 int length = (int)(Math.random() * (maxLength - 1)) + 1;
                 StringBuffer result = new StringBuffer(length);
                 Random r = new Random();
    
                 for (int i = 0; i < length; i++) {
                     result.append(alphabet.charAt(r.nextInt(alphabet.length())));
                 }
    
                 return result.toString();
             }
         }
    
         // A "good" word has as many vowels as consonants and is more than two
         // letters long.
         private static String vowels = "aeiouAEIOU";
    
         private static boolean isGoodWord(String word) {
             int vowelCount = 0;
             int consonantCount = 0;
    
             for (int i = 0; i < word.length(); i++) {
                 if (vowels.indexOf(word.charAt(i)) >= 0) {
                     vowelCount++;
                 } else {
                     consonantCount++;
                 }
             }
    
             return (vowelCount > 2 && vowelCount == consonantCount);
         }
    
         public static void main(String[] args) {
             GibberishGenerator g = new GibberishGenerator();
             List<String> goodWords = new ArrayList<String>();
    
             for (int i = 0; i < 1000; i++) {
                 String line = g.next();
                 for (String word : line.split(" ")) {
                     if (isGoodWord(word)) {
                         goodWords.add(word);
    
                         System.out.println("Found a good word: " + word);
                         break;
                     }
                 }
             }
         }
     }
    

    此代码存在内存泄漏。让我们用一个普通的JVM堆来运行它,看看会发生什么:

    $ java -verbose:gc -Xmx64m MemoryLeak
     ...
     Found a good word: EDeGOG
     [Full GC 44032K->32382K(51136K), 0.0088630 secs]
     [GC 39742K->33566K(58368K), 0.0027320 secs]
     [GC 44382K->36990K(58304K), 0.0041990 secs]
     [Full GC 45982K->44686K(58304K), 0.0224270 secs]
     Found a good word: eZjovI
     [Full GC 51071K->38076K(58304K), 0.0059460 secs]
     [GC 45436K->39228K(58304K), 0.0007690 secs]
     [GC 46588K->40412K(53440K), 0.0009190 secs]
     [Full GC 52380K->42684K(53440K), 0.0085100 secs]
     [Full GC 50044K->42684K(53440K), 0.0086750 secs]
     [Full GC 50044K->42313K(53440K), 0.0092640 secs]
     [Full GC 47351K->42313K(53440K), 0.0077370 secs]
     [GC 42313K->42313K(57984K), 0.0008850 secs]
     [Full GC 42313K->42297K(57984K), 0.0151990 secs]
     Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
         at java.util.Arrays.copyOf(Arrays.java:2882)
         at java.lang.AbstractStringBuilder.expandCapacity(AbstractStringBuilder.java:100)
         at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:390)
         at java.lang.StringBuffer.append(StringBuffer.java:224)
         at MemoryLeak$GibberishGenerator.next(MemoryLeak.java:24)
         at MemoryLeak.main(MemoryLeak.java:74)
    

    它找到几个单词,然后耗尽内存。让我们看看一些工具来了解更多。

    使用Jmap

    jmap工具是java SDK的标准工具,因此您很有可能已经拥有了它。使用jmap,您可以连接到正在运行的JVM并遍历其内存池,打印每个类的实例数和每个类正在使用的堆字节数的摘要。

    首先,获取要调试的JVM的进程ID:

    $ ps -ef | grep MemoryLeak
     mst      14979 14511 99 13:53 pts/29   00:00:03 java -verbose:gc -Xmx64m MemoryLeak
    

    然后阅读下面的警告并指示jmap附加到该JVM并遍历其堆:

    $ jmap -F -histo 14979
     Attaching to process ID 14916, please wait...
     Debugger attached successfully.
     Server compiler detected.
     JVM version is 20.12-b01
     Iterating over heap. This may take a while...
     Object Histogram:
    
     num       #instances    #bytes  Class description
     —————————————————————————————————————
     1:              952     37806240        char[]
     2:              4997    684344  * MethodKlass
     3:              4997    617648  * ConstMethodKlass
     4:              344     381264  * ConstantPoolKlass
     5:              7790    346488  * SymbolKlass
    

    警告:当jmap执行任务时,目标JVM将被挂起,所以不要在人们实际使用的应用程序上这样做!

    查看jmap输出的前几行,已经有了一个线索:在我们获取样本时,有952个字符数组占据了64MB JVM堆中的大约37MB。在列表的顶部看到大字符数组通常是一个很好的信号,表明有一些大字符串被存储在某个地方。

    运行jmap(或任何命令!)在JVM抛出OutOfMemoryError的确切时刻,是使用-XX:onAutoOfMemoryError JVM开关。您可以这样调用它:

    java -Xmx64m -XX:OnOutOfMemoryError='jmap -histo -F %p' MemoryLeak
    

    JVM的进程ID将自动替换为%p占位符。

    使用Heap Dumps堆转储

    有时候,看到jmap柱状图就足以让您意识到内存泄漏在哪里,如果最上面的条目是您自己的类之一(比如MyBrilliantCacheEntry),那么就诅咒自己,继续前进。但是,不幸的是,空间的最大用户通常是char[]或byte[],因此您所拥有的只是不太有用的信息。

    此时,堆转储可能会有所帮助。堆转储提供应用程序内存的静态快照,使用jvisualvm或Eclipse内存分析器等工具MAT,您可以检查堆转储并查看其中的内容。

    使用Jmap获取堆转储

    jmap工具可以连接到正在运行的JVM并生成堆转储。具体操作方法如下:

    $ jmap -F -dump:format=b,file=heap.bin 14979
     Attaching to process ID 16610, please wait...
     Debugger attached successfully.
     Server compiler detected.
     JVM version is 20.12-b01
     Dumping heap to heap.bin ...
     Heap dump file created
    

    这将生成一个名为堆.bin可以被几种分析工具读取。我们将在下面的部分中了解它的工作原理。

    在OutOfMemoryError上获取堆转储

    如果可以使用特殊的开关运行JVM,那么每当抛出OutOfMemoryError时,就可以要求它生成堆转储。只需运行JVM:

    java -Xmx64m \
          -XX:+HeapDumpOnOutOfMemoryError \
          -XX:HeapDumpPath=/tmp/heap.bin  \
          MemoryLeak
    

    与之前一样,这将生成一个名为heap.bin

    从核心文件生成堆转储

    通常,前两种方法中的一种足以获得堆转储,但这里有一些琐事。如果使用gdb连接到JVM进程:

    $ gdb -p 14979
     GNU gdb (GDB) 7.4.1-debian
     ...
     (gdb)
    

    您可以使用它转储标准的Unix核心文件:

    (gdb) gcore
     Saved corefile core.14979
    

    然后使用jmap从生成的核心文件创建堆转储:

    $ jmap -dump:format=b,file=heap.bin `which java` core.14979
     Attaching to core core.14979 from executable /usr/local/bin/java, please wait...
     Debugger attached successfully.
     Server compiler detected.
     JVM version is 20.12-b01
     Dumping heap to heap.bin ...
     Finding object size using Printezis bits and skipping over...
     Heap dump file created
    

    对于使用gcore命令生成的核心文件,这也可以在Solaris下工作。

    探索堆转储

    无论选择哪种方法,现在都应该有一个名为heap.bin包含应用程序出现问题时内存的快照。

    有几种工具可以用来分析堆转储。如果你觉得老古板的话,javasdk附带了jhat。对转储文件进行如下运行:

    $ jhat heap.bin
     Reading from heap.bin...
     Dump file created Tue Oct 29 14:19:49 EST 2013
     Snapshot read, resolving...
     Resolving 29974 objects...
     Chasing references, expect 5 dots.....
     Eliminating duplicate references.....
     Snapshot resolved.
     Started HTTP server on port 7000
     Server is ready.
    

    然后浏览到http://localhost:7000/以浏览堆转储。

    在很长一段时间里,jhat一直是我的目标。但是现在,jvisualvm提供的接口是我选择的武器。javasdk标准附带jvisualvm,因此您应该能够通过运行jvisualvm(或者在某些系统上仅运行visualvm)来启动它。你应该会看到这样的东西:

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    我们将加载应用程序的堆转储:

    1. 单击“文件”菜单
    2. 单击“加载”
    3. 为“文件类型”选择“堆转储”
    4. 导航到您的heap.bin文件

    您将看到堆转储在jvisualvm主窗格中打开:

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    单击Classes选项卡,我们可以看到jmap之前向我们展示的内容:很多char[]实例使用了我们所有的内存。

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    在这个屏幕上,我们可以深入查看实例本身。双击char[]的条目,您将被转移到Instances选项卡。

    面试官:小伙子你知道几种内存溢出的原因和解决办法

    在左边列表的顶部,我们看到几个实例,每个实例大约为5MB。这可是一大堆人物资料啊!看右边的窗格可以看到游戏:

    qUqCMAzhjybtUitV vgiUVYuTcROHFygG gjoYlXqOhKdQkvOqot gEQlgdcINvhqjxJ ...
    

    jvisualvm向我们展示的是七个大字符串,每个大约为5MB,每个字符串包含许多gibberish的单词。这就是我们需要的线索:程序中的某些东西将gibberish的输入行保存在内存中,阻止垃圾回收器释放它们。

    现在您知道了原因,您可能希望返回到原始代码,看看是否可以发现问题。

    String line = g.next();
                 for (String word : line.split(" ")) {
                     if (isGoodWord(word)) {
                         goodWords.add(word);
    

    我们把每行(5MB)gibberish,分成单词,检查每个单词,并保留找到的第一行。怎么了?

    这实际上是Java的一个好奇心:字符串是不可变的,因此如果两个字符串在幕后共享同一个字符数组也没有什么坏处。事实上,Java使用这种方法很好地提高了获取子字符串的速度。如果我们有两个字符串:

    String s1 = "hello world";
     String s2 = "hello world".substring(0, 5);
    

    然后,可以创建s2,而不需要额外的字符数组:它只存储对s1字符数组的引用,以及适当的偏移量和长度信息,以捕获它是原始字符串的“片段”这一事实。

    在我们的代码中,split创建了原始5MB输入行的子字符串,但该子字符串包含对原始输入行字符数组的引用。即使在原始字符串消失很久并被垃圾回收之后,它的字符数组仍然存在于子字符串中!

    我们可以通过显式克隆子字符串来修复它:

    String line = g.next();
                 for (String word : line.split(" ")) {
                     if (isGoodWord(word)) {
                         goodWords.add(new String(word));
    

    从Java1.7Update6开始,字符串行为发生了变化,这样子字符串就不会保留原来的字节数组了。这对我设计的示例来说有点打击,但找到问题的总体方法仍然有效。

    如果觉得本文对你有帮助,可以点赞关注支持一下

    相关文章

      网友评论

        本文标题:面试官:小伙子你知道几种内存溢出的原因和解决办法

        本文链接:https://www.haomeiwen.com/subject/mntunktx.html