Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

06-02 1465阅读

🧑 博主简介:CSDN博客专家,历代文学网(PC端可以访问:https://literature.sinhy.com/#/literature?__c=1000,移动端可微信小程序搜索“历代文学”)总架构师,15年工作经验,精通Java编程,高并发设计,Springboot和微服务,熟悉Linux,ESXI虚拟化以及云原生Docker和K8s,热衷于探索科技的边界,并将理论知识转化为实际应用。保持对新技术的好奇心,乐于分享所学,希望通过我的实践经历和见解,启发他人的创新思维。在这里,我希望能与志同道合的朋友交流探讨,共同进步,一起在技术的世界里不断学习成长。

技术合作请加本人wx(注明来自csdn):foreast_sea

Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM



Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

文章目录

  • Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM
    • 内存溢出和内存泄露
      • 内存溢出
      • 内存泄露
      • 内存溢出后的表象
      • 内存泄露的排查
      • 内存泄露的定位
      • 内存泄露的解决
      • 小结
      • 参考资料

        Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

        OutOfMemoryError,也就是臭名昭著的 OOM(内存溢出),相信很多球友都遇到过,相对于常见的业务异常,如数组越界、空指针等,OOM 问题 更难难定位和解决。

        这篇内容就以之前碰到的一次线上内存溢出的定位、解决问题的方式展开;希望能对碰到类似问题的球友带来思路和帮助。

        主要从表现-->排查-->定位-->解决 四个步骤来分析和解决问题。

        内存溢出和内存泄露

        在 Java 中,和内存相关的问题主要有两种,内存溢出和内存泄漏。

        • 内存溢出(Out Of Memory):就是申请内存时,JVM 没有足够的内存空间。通俗说法就是去蹲坑发现坑位满了。
        • 内存泄露(Memory Leak):就是申请了内存,但是没有释放,导致内存空间浪费。通俗说法就是有人占着茅坑不拉屎。

          内存溢出

          在 JVM 的内存区域中,除了程序计数器,其他的内存区域都有可能发生内存溢出。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          大家都知道,Java 堆中存储的都是对象,或者叫对象实例,那只要我们不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么就一定会产生内存溢出。

          比如说运行下面这段代码:

          public class OOM {
              public static void main(String[] args) {
                  List list = new ArrayList();
                  while (true) {
                      list.add(new Object());
                  }
              }
          }
          

          运行程序的时候记得设置一下 VM 参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError,限制堆内存大小为 20M,并且不允许扩展,并且当发生 OOM 时 dump 出当前内存的快照。

          运行结果如下:

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          我们在讲运行时数据区的时候也曾讲过。

          内存泄露

          内存泄露是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

          简单来说,就是应该被垃圾回收的对象没有回收掉,导致占用的内存越来越多,最终导致内存溢出。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          在上图中:对象 X 引用对象 Y,X 的生命周期比 Y 的生命周期长,Y 生命周期结束的时候,垃圾回收器不会回收对象 Y。

          来看下面的例子:

          public class MemoryLeak {
              public static void main(String[] args) {
                try{
                    Connection conn =null;
                    Class.forName("com.mysql.jdbc.Driver");
                    conn =DriverManager.getConnection("url","","");
                    Statement stmt =conn.createStatement();
                    ResultSet rs =stmt.executeQuery("....");
                } catch(Exception e){//异常日志
                } finally {
                  // 1.关闭结果集 Statement
                  // 2.关闭声明的对象 ResultSet
                  // 3.关闭连接 Connection
              }
            }
          }
          

          创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。

          这样就会导致内存泄露,最终导致内存溢出。

          换句话说,内存泄露不是内存溢出,但会加快内存溢出的发生。

          内存溢出后的表象

          之前生产环境爆出的内存溢出问题会随着业务量的增长,出现的频次也越来越高。

          应用程序的业务逻辑非常简单,就是从 Kafka 中将数据消费下来,然后批量的做持久化操作。

          OOM 现象则是随着 Kafka 的消息越多,出现异常的频次就越快。由于当时还有其他工作所以只能让运维做重启,并且监控好堆内存以及 GC 情况。

          不得不说,重启大法真的好,能解决大量的问题,但不是长久之计。

          内存泄露的排查

          于是我们想根据运维之前收集到的内存数据、GC 日志尝试判断哪里出现了问题。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          结果发现老年代的内存使用就算是发生 GC 也一直居高不下,而且随着时间推移也越来越高。

          结合 jstat 的日志发现就算是发生了 FGC,老年代也回收不了,内存已经到顶。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          甚至有几台应用 FGC 达到了上百次,时间也高的可怕。

          这说明应用的内存使用肯定是有问题的,有许多赖皮对象始终回收不掉。

          内存泄露的定位

          由于生产上的内存 dump 文件非常大,达到了几十 G。也和我们生产环境配置的内存太大有关。

          所以导致想使用 MAT 分析需要花费大量时间。

          MAT 是 Eclipse 的一个插件,也可以单独使用,可以用来分析 Java 的堆内存,找出内存泄露的原因。

          因此我们就想是否可以在本地复现,这样就好定位的多。

          为了尽快的复现问题,我将本地应用最大堆内存设置为 150M。然后在消费 Kafka 那里 Mock 了一个 while 循环一直不断的生成数据。

          同时当应用启动之后利用 VisualVM 连上应用实时监控内存、GC 的使用情况。

          结果跑了 10 几分钟内存使用并没有什么问题。根据图中可以看出,每一次 GC 内存都能有效的回收,所以并没有复现问题。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          没法复现问题就很难定位。于是我们就采用了一种古老的方法——review 代码,发现生产的逻辑和我们用 while 循环 Mock 的数据还不太一样。

          果然 review 代码是保障程序性能的第一道防线,诚不欺我。大家在写完代码的时候,尽量也要团队 review 一次。

          后来查看生产日志发现每次从 Kafka 中取出的都是几百条数据,而我们 Mock 时每次只能产生一条。

          为了尽可能的模拟生产情况便在服务器上跑了一个生产者程序,一直源源不断的向 Kafka 中发送数据。

          果然不出意外只跑了一分多钟内存就顶不住了,观察下图发现 GC 的频次非常高,但是内存的回收却是相形见拙。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          同时后台也开始打印内存溢出了,这样便复现出了问题。

          内存泄露的解决

          从目前的表现来看,就是内存中有许多对象一直存在强引用关系导致得不到回收。

          于是便想看看到底是什么对象占用了这么多的内存,利用 VisualVM 的 HeapDump 功能,就可以立即 dump 出当前应用的内存情况。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          结果发现 com.lmax.disruptor.RingBuffer 类型的对象占用了将近 50% 的内存。

          看到这个包自然就想到了 Disruptor 环形队列了。

          Disruptor 是一个高性能的异步处理框架,它的核心思想是:通过无锁的方式来实现高性能的并发处理,其性能是高于 JDK 的 BlockingQueue 的。

          再次 review 代码发现:从 Kafka 里取出的 700 条数据是直接往 Disruptor 里丢的。

          这里也就能说明为什么第一次模拟数据没复现问题了。

          模拟的时候是一个对象放进队列里,而生产的情况是 700 条数据放进队列里。这个数据量就是 700 倍的差距啊。

          而 Disruptor 作为一个环形队列,在对象没有被覆盖之前是一直存在的。

          我也做了一个实验,证明确实如此。

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          我设置队列大小为 8 ,从 0~9 往里面写 10 条数据,当写到 8 的时候就会把之前 0 的位置覆盖掉,后面的以此类推(类似于 HashMap 的取模定位)。

          所以在生产环境上,假设我们的队列大小是 1024,那么随着系统的运行最终会导致 1024 个位置上装满了对象,而且每个位置都是 700 个!

          于是查看了生产环境上 Disruptor 的 RingBuffer 配置,结果是:1024*1024。

          这个数量级就非常吓人了。

          为了验证是否是这个问题,我在本地将该值设为 2 ,一个最小值试试。

          同样的 128M 内存,也是通过 Kafka 一直源源不断的取出数据。通过监控如下:

          Java 内存溢出排查优化实战:彻底干掉臭名昭著的 OOM

          跑了 20 几分钟系统一切正常,每当一次 GC 都能回收大部分内存,最终呈现锯齿状。

          这样问题就找到了,不过生产上这个值具体设置多少还得根据业务情况测试才能知道,但原有的 1024*1024 是绝对不能再使用了。

          小结

          虽然到了最后也就改了一行代码(还没改,直接修改配置),但这个排查过程我觉得是很有意义的。

          也会让大部分觉得 JVM 这样的黑盒难以下手的球友有一个直观感受。

          同时也得感叹 Disruptor 东西虽好,也不能乱用哦!

          参考资料

          • 内存泄露的排查
          • 内存溢出和内存泄露
免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

相关阅读

目录[+]

取消
微信二维码
微信二维码
支付宝二维码