架构师技能3-彻底深入理解和分析Java中内存溢出OutOfMemoryError

开篇语录:以架构师的能力要求去分析每个问题,过后由表及里分析问题的本质,复盘总结经验,并把总结内容记录下来。当你解决各种各样的问题,也就积累了丰富的解决问题的经验,解决问题的能力也将自然得到极大的提升。

励志做架构师的撸码人,认知很重要,可以订阅:架构设计专栏

      java开发人员经常遇到OutOfMemoryError的问题。要解决这些问题,要有对java虚拟机的内存管理有一定的认识,甚至对linux系统也要有一定的熟悉。透过分析问题,深入挖掘问题本质,进而强迫自己学习相应基础知识。

     昨天隔壁项目的应用遇到了OutOfMemoryError:unable to create new native thread问题,再次把之前的草稿文章整理,顺便总结发出来。

如果对jvm虚拟机还不了解,请先看我之前的总结:

《java(5)-深入理解虚拟机JVM》和《java(9)-深入浅出GC垃圾回收机制

第一种OutOfMemoryError: PermGen space


1)、程序中使用了大量的jar或class,使java虚拟机装载类的空间不够,与Permanent Generation space有关。这个主要是java8之前遇到的问题,可以通过配置-XX:PermSize和-XX:MaxPermSize来设置。即PermGen space是有关非堆内存的内存溢出,

      在JDK8之前的HotSpot JVM,存放这些”永久的”的区域叫做“永久代(permanent generation)”。永久代的垃圾收集是和老年代(old generation)捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。当JVM加载的类信息容量超过了参数-XX:MaxPermSize设定的值时,应用将会报OOM的错误:java.lang.OutOfMemoryError: PermGen

      JDK8的JVM不再有PermGen。但类的元数据信息(metadata)还在,只不过不再是存储在连续的非堆空间上,而是移动到叫做“Metaspace”的本地内存(Native memory)中。

2)、在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中;可以运行如下代码,会报异常信息:java.lang.OutOfMemoryError:PermGen space

public class StringConstantPoolTest {
    public static void main(String[] args) {
        List<String> list = Lists.newArrayList();
        while (true) {
            list.add(String.valueOf(System.currentTimeMillis()).intern());
        }
    }
}

3)我们看看java8之前永久代Perm Gen非堆内存分配 -XX:PermSize例子:

       java8之前永久代是一片连续的堆空间。在JVM启动之前通过在命令行设置参数-XX:MaxPermSize来设定永久代最大可分配的内存空间:

      -XX:PermSize:设置JVM非堆内存初始值,默认是物理内存的1/64;
      -XX:MaxPermSize:设置最大非堆内存的大小,默认是物理内存的1/4。
    (还有一说:MaxPermSize缺省值和-server -client选项相关,-server选项下默认MaxPermSize为64m,-client选项下默认MaxPermSize为32m)

      永久代或者“Perm Gen”包含了JVM需要的应用元数据,这些元数据包括类的版本、字段、方法、接口等描述信息,还有运行时常量池,用于存放编译器生成的各种字面量和符号引用。注意,永久代不是Java堆内存的一部分。class文件中包括
      永久代存放JVM运行时使用的类。永久代同样包含了Java SE库的类和方法。永久代的对象在full GC时进行垃圾收集。

在jdk7设置-XX:MaxPermSize过小会导致java.lang.OutOfMemoryError: PermGen space,原因如下:PermGen space用于存放Class和Meta的信息,GC不会对PermGen space进行处理,所以如果Load很多Class的话,就会出现上述Error。这种Error在web服务器对JSP进行pre compile的时候比较常见。

动态生成类的情况比较容易出现永久代的内存溢出。们现在通过动态生成类来模拟 “PermGen space”的内存溢出:

package com.demo.test;
public class TestClass {
}

动态加载类com.demo.test.TestClass:   

package com.demo.test.web;
     
    /**
     * Created by huangguisu on 2019/7/10.
     */
     
    import java.io.File;
    import java.net.URL;
    import java.net.URLClassLoader;
    import java.util.ArrayList;
     import java.util.List;
     
    public class PermGenOom{
        public static void main(String[] args) {
            URL url = null;
            List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();
            try {
                url = new File("/tmp").toURI().toURL();
                URL[] urls = {url};
                while (true){
                    ClassLoader loader = new URLClassLoader(urls);
                    classLoaderList.add(loader);
                    loader.loadClass("com.demo.test.TestClass");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

使用JDK1.7执行,指定的 PermGen 区-XX:MaxPermSize的大小为 8M:

第二种OutOfMemoryError:  Java heap space


1)、发生这种问题的原因是java虚拟机创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已经用满。通过增加Java虚拟机中Xms(初始堆大小)和Xmx(最大堆大小)参数的大小。如 -Xms4G -Xmx4G 这个跟实际情况来设定。

2)在JDK7.0版本,字符串常量池被移到了堆中了。可以运行如下代码,会报异常信息:java.lang.OutOfMemoryError: Java heap space

public class StringConstantPoolTest {
    public static void main(String[] args) {
        List<String> list = Lists.newArrayList();
        while (true) {
            list.add(String.valueOf(System.currentTimeMillis()).intern());
        }
    }
}

3)、在JDK8.0版本,字符串常量池放到元空间,运行如下代码,也会报异常信息:java.lang.OutOfMemoryError: Java heap space :

public class StringConstantPoolTest {
    public static void main(String[] args) {
        List<String> list = Lists.newArrayList();
        while (true) {
            list.add(String.valueOf(System.currentTimeMillis()).intern());
        }
    }
}

看看java.lang.OutOfMemoryError: Java heap space例子:

package com.demo.test.web;
import java.util.ArrayList;
import java.util.List;
public class HeapOom {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<byte[]>();
        int i = 0;
        boolean flag = true;
        while (flag){
            try {
                i++;
                list.add(new byte[1024 * 1024]);//每次增加一个1M大小的数组对象
            }catch (Throwable e){
                e.printStackTrace();
                flag = false;
                System.out.println("count="+i);//记录运行的次数
            }
        }
    }
}
 

我们设置堆内存的大小为16M,当运行到第15次,当无法申请空间时会抛出OutOfMemoryError:

第三种 java8 java.lang.OutOfMemoryError: Metadata space 


java8的Metaspace 容量默认情况下,Metaspace类元数据只受可用的本地内存限制(容量取决于是32位或是64位操作系统的可用虚拟内存大小)。
新参数(MaxMetaspaceSize)用于限制本地内存分配给类元数据的大小。如果没有指定这个参数,元空间会在运行时根据需要动态调整。
如果设置-XX:MaxMetaspaceSize,当,Metaspace被耗尽;与JDK1.7运行时非常相似,报错:ERROR: java.lang.OutOfMemoryError: Metadata space。

这种情况一般不会遇到,很少人会手动设置java8的MaxMetaspaceSize参数。

第四种、StackOverflowError栈溢出


java方法被执行的时候都会同时创建栈帧(Stack Frame).用于存储局部变量表、操作数栈、动态链接,方法出口等信息。方法被调用到执行完成对的过程,就是相应对于栈帧在JVM从入栈到出栈的过程。当线程请求的栈深度大于虚拟机所允许的深度是出现错误:StackOverflowError。

      Java栈由栈帧组成,一个帧对应一个方法调用。虚拟机栈是一个LIFO的栈: 调用方法时压入栈帧,方法返回时弹出栈帧并抛弃。Java栈的主要任务是存储方法参数、局部变量、中间运算结果,并且提供部分其它模块工作需要的数据。
    我们通过递归方法来测试栈的深度和栈溢出:

public class StackOverflowError {
    //使用计数器计算栈的深度
    private static int index = 1;
 
    //没有结束条件的递归导致死递归
    public void recursiveCall(){
        index++;
        recursiveCall();
    }
 
    public static void main(String[] args) {
        StackOverflowError stackOverflowError = new StackOverflowError();
        try {
            stackOverflowError.recursiveCall();
        }catch (Throwable e){
            System.out.println("Stack deep : "+index);
            e.printStackTrace();
        }
    }
 
}

指定线程栈大小为-Xss128k,运行结果:

  如果调整线程栈大小-Xss256k, 深度也是不一样的:


 

第五种OutOfMemoryError:unable to create new native thread


1、错误现象:

 2、错误原因

在网上看到的文章大致总结的原因如下:

1)服务器剩余内存不足(非JVM内存),不能创建新的线程。

能创建的线程数的具体计算公式如下:

Number of threads= (MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize)
MaxProcessMemory         指的是一个进程的最大内存
JVMMemory                JVM内存
ReservedOsMemory         保留的操作系统内存
ThreadStackSize          线程栈的大小

2)超出系统用户最大进程限制:
通过以下命令可以查看最大进程限制配置max user processes.(注意,不同用户的最大进程限制配置max user processes可能不一样)详细解读在我之前的总结文章limit资源限制ulimit 详解》https://guisu.blog.csdn.net/article/details/46126249

ulimit -a 查看资源:

3、错误分析

1)首先通过ps -efL |grep pid |wc -l 查看当前应用的线程数。

ps -efL |grep pid看具体的线程数:

如果应用程序部署在实机而不是docker,可以直接查看,如果是docker,进入容器内部,命令ps -efL参数L不一定有,但是可以在宿主主机上查看具体java应用进程,然后再通过ps -efL |grep pid查看线程数。

 2)检查系统的资源限制max user processes:

这个需要先查看应用的具体是哪个用户,比如应用所在的用户是appuser:

 然后切换到su appuser, 查看系统资源限制ulimit -a:

 3)、如果系统资源限制没有问题,就是查看系统的使用内存情况。

4、我们的实际情况

由于我们应用使用k8s部署,进入容器后无法执行ps -efL |grep pid,而且又是线上问题,需要尽快解决。我们只好先重启服务。

 然后把一些截图和错误日志保留下来:

1)、系统分析

从上面的截图事后分析,明显是系统资源不足,create gc thread,out of system resources(超过了系统资源)。

但是我们在容器内执行ulimit -a,看到的max user processes是32768. 程序的线程数不太可能到达3万多。

 2)代码分析

我们分析代码,报错的地方确实有new thread。new thread创建大量线程后,如果线程处理某个业务很慢没有及时回收,那么肯定存在短时间内创建大量的线程。这个肯定是需要通过线程池来优化处理。

3) 问题的本质:

 重启服务后,最后我们和运维人员一块分析检查,进入到宿主主机,查看当前应用总线程数才101,运维人员最后检查pod的各种参数,最后发现Kubelet 开启 PodPidsLimit 功能。

配置 Kubelet 的 –pod-max-pids=3000 选项,即容器内允许的最大进程数为 3000 个。

问题本质原因就是:我们应用开启了大量线程,超过容器限定PodPidsLimit数量3000个,而不是ulimit -u的资源限制。

第六种OutOfMemoryError:oom-killer


1、Linux内核OOM killer机制

linux oom-killer的机制: (oom就是out of memory,内存用尽)linux为了避免内存用尽,导致系统的卡死,会唤醒oom_killer,找出/proc/pid/oom_score值最大的进程将之kill掉,从而释放内存,来保证整个系统的的正常运行

oom机制:

按照oom_score 给进程排序, oom_score越大, 进程就越容易被系统杀死, oom_score值的范围是-17—15, -17表示禁止oom因此这是内存不足导致的,解决方法通常有以下几种:
在服务器上直接增加内存条(还得停机,云服务器直接扩充内存)
查找占据大量内存的服务进行清理或迁移(无需停机,但是需要准备迁移的目标)
调整服务的内存占用大小(无需停机,无需迁移;如果实例较多,仍是杯水车薪,即使调整了,服务也会容易被kill)'

具体参考源码:

内核检测到系统内存不足、挑选并杀掉某个进程的过程可以参考内核源代码linux/mm/oom_kill.c,当系统内存不足的时候,out_of_memory()被触发,然后调用select_bad_process()选择一个”bad”进程杀掉。如何判断和选择一个”bad进程呢?linux选择”bad”进程是通过调用oom_badness(),挑选的算法和想法都很简单很朴实:最bad的那个进程就是那个最占用内存的进程。

什么时候触发?

内核在触发OOM机制时会调用到out_of_memory()函数,此函数的调用顺序如下:

__alloc_pages  //内存分配时调用

    |-->__alloc_pages_nodemask

       |--> __alloc_pages_slowpath

           |--> __alloc_pages_may_oom

              | --> out_of_memory   //触发

以上函数__alloc_pages_may_oom()在调用之前会先判断oom_killer_disabled的值,如果有值,则不会触发OOM机制;

布尔型变量oom_killer_disabled定义在文件mm/page_alloc.c中,并没有提供外部接口更改此值,但是在内核中此值默认为0,表示打开OOM-kill。Linux中内存都是以页的形式管理的,所以不管是怎么申请内存,都会调用alloc_page()函数,最终调用到函数out_of_memory(),触发OOM机制。

【1】内核监测到系统内存不足时,该函数被触发执行

bool out_of_memory(struct oom_control *oc)

【2】选择一个“最坏的”进程

static void select_bad_process(struct oom_control *oc)

【3】杀掉进程

2、查看系统日志方法:

运行grep -i  'killed process' /var/log/messages 命令,结果如下:

也可运行dmesg命令 : dmesg |grep oom

总结


1、在问题中成长和深入学习。

遇到问题时最好的学习机会,通过透过问题看本质则是由虚到实,往深层次地挖掘,最后能形成底层技术深度加固。要真正的精通一门技术,最终还要通过实践来深入。问题是最好的实践。就像游泳教练,必定游泳水平好,因为这些都是实践性很强的工作。书上学来终觉浅,绝知此事要躬行。

在实践中,遇到问题,不仅只解决问题,还要对问题刨根问底,深入挖掘问题发生的根本原因,这样可以系统性地修复问题,从而使其永久消失。从问题本身着手,沿着因果关系链条,顺藤摸瓜,穿越不同的抽象层面,直至找出原有问题的根本原因.

我们中国古代以来就有“打破沙锅问到底”的习惯;“打破沙锅问到底”是一句俗语,形象表达了锲而不舍、不断探索的精神,这是人们常挂在嘴边的一句口头禅。

我们遇到问题,从外到里,逐层分析:
1、问题表象是什么
2、直接原因是什么?
3、中间原因是什么?
4、根本原因是什么?

真正的解决问题必须找出问题的根本原因,如果只解决问题,而不深入问题背后的根本原因,这门技术也是不够深入。

比如:

问题:java应用出现超时抖动?

1)解决:Java应用出现FGC. (增大 -Xmx  -Xms 内存设置)

2)、直接原因:流量激增?  长时间运行? 代码问题导致占用内存对象不释放?。。。

3)、中间原因:流量没有做预警? 研发人员能力问题?

4)、根本原因:JVM虚拟机的基本原理?GC机制? JVM性能监控?......
 

2、理解本质

将世界万物理解为原子,将整个互联网理解成0和1,这倒的确是非常本质了,不过并不能解答任何问题。从问题看本质,实质上是一个从表层逐步深入的过程。遇到问题要打破砂锅问到底,了解最终引发问题的根本原因,最后形成高效解决问题的能力:解决问题和绕开问题。

3、善于总结、不断反思。

每一次的总结和反思,只要足够深刻、足够深入骨髓,乃至触及灵魂和价值观,都可以是一次浴火重生。总结是深刻反思的过程,总结是自我检视、自我完善的过程,由“经事”而“长智”的过程,正是“吃一堑长一智”。

毛主席说:“我是靠总结经验吃饭的。”不断总结、善于总结才能不断进步、不断提高。
 

  • 10
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hguisu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值