概述

本篇文章主要记录在生产环境上发生了内存泄漏导致系统OOM,造成了严重的后果,以致于本人吃土一个月,5555~本文主要对该问题做一个复盘,同时分析下什么是内存泄漏,如何识别你的系统中存在潜在可能的内存泄漏风险,并且哪些常见的场景可能会引发内存泄漏,需要引起我们特别注意。

事件回顾

事故背景

某商业银行项目上部署了我们的风控系统,其中一个节点挂了,同时报了OOM的错误,还好其他节点正常work,不然本人要吃土一整年了,第二天本人就被要求出差到现场分析解决该问题。

事故排查和修复

通过dump生成的堆信息,用jprofiler(后面专门出文章介绍使用)工具发现:
image2021-3-18_15-58-30.png
上面这个字符串对象特别多,几十万个,占用了大量的堆空间,根据这个字符串查询,发现是其他同事的代码中监控项使用不当导致。
代码
image2021-3-18_15-40-24.png
原因分析
监控项的tag值要求是可枚举的,不能生成的每条tag都是唯一的,这样应用不会覆盖,而是保留每一条数据,导致内存爆炸。
而这里的succList,是一个Set对象,虽然列表数量是确定的,但是每次请求过来顺序不一致,比如succList有10个对象,那么就会有10!种可能,最终撑爆内存。
修复方案
image2021-3-18_15-47-50.png
遍历succList, tag使用单条数据, 而不是把整个对象列表放进去,重新上线后监测,没有出现问题。
小结
因为监控项使用不当导致内存泄漏,最终导致OOM。其实内部复盘了下,主要开发人员对监控项不大会使用,对里面的底层原理也不甚了解,所以了解一个技术的底层实现还是非常重要的。而且,团队内部也缺少代码review,缺少生产级别数据的验证,一系列原因,最终导致出现该生产问题。

什么是内存泄漏

通俗的来说,内存泄漏就是占着茅坑不拉屎,你认为该对象用不上了,但是实际上还被程序使用,GC又无法回收,这种情况就是内存泄漏。
如何对象判断是否还在被程序使用呢?
GC的可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。
记一次内存泄漏引发的生产事故 - 图4
内存泄漏和内存溢出
内存泄漏(Memory Leak)最终的后果会导致内存溢出(Out Of Memory),但是内存溢出并不完全是内存泄漏导致,也有可能分配了一个大对象,数据库未分页查全量的数据等等。

如何发现存在内存泄漏

更关键的是我们应该如何提前发现我们的系统存在潜在的内存泄漏风险,从而规避生产出现OOM问题?

  1. 测试环境模拟测试,开启GC日志输出到文件里
  2. 通过GC Easy工具分析日志,观察下每次垃圾回收后,它的内存使用情况
  3. 如果发现回收完后,堆占用是递增的趋势,那么很有可能有内存泄漏的问题。

此外,大家还可以使用Eclipse Memory Analyzer、JProbe Profiler、JVisualVM 等工具分析程序的内存信息,凭借个人经验判断是否有内存泄漏问题(说了个寂寞,哈哈哈~~)。

内存泄漏的几种常见场景

下面通过几个案例带大家了解下常见的几种内存泄漏的场景,如果大家的程序中有类似的场景,那么就要特别注意了。

静态字段

可能导致潜在内存泄漏的第一种情况是大量使用静态变量。在Java中,静态字段的生命周期通常与应用程序一样。
简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

  1. public class MemoryLeak {
  2. static List list = new ArrayList();
  3. public void oomTests(){
  4. Object objnew Object();//局部变量
  5. list.add(obj);
  6. }
  7. }

如何避免:

  • 最大限度地减少静态变量的使用
  • 使用单例时,依赖于延迟加载对象而不是急切加载的实现

    未关闭的连接池资源

    在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、Statement或ResultSet不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

    1. public static void main(String[] args) {
    2. try{
    3. Connection conn =null;
    4. Class.forName("com.mysql.jdbc.Driver");
    5. conn =DriverManager.getConnection("url","","");
    6. Statement stmt =conn.createStatement();
    7. ResultSet rs =stmt.executeQuery("....");
    8. } catchException e){//异常日志
    9. } finally {
    10. // 1.关闭结果集 Statement
    11. // 2.关闭声明的对象 ResultSet
    12. // 3.关闭连接 Connection
    13. }
    14. }

    如何避免:

  • 始终使用finally块来关闭资源

  • 可以用sonar等工具检查代码

    改变哈希值

    改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了。
    否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。
    这也是 String 为什么被设置成了不可变类型,我们可以放心地把 String 存入 HashSet,或者把String 当做 HashMap 的 key 值;
    当我们想把自己定义的类保存到散列表的时候,需要保证对象的 hashCode 不可变。 ```java public class ChangeHashCode1 { public static void main(String[] args) {

    1. HashSet<Point> hs = new HashSet<Point>();
    2. Point cc = new Point();
    3. cc.setX(10);//hashCode = 41
    4. hs.add(cc);
    5. cc.setX(20);//hashCode = 51 此行为导致了内存的泄漏
    6. System.out.println("hs.remove = " + hs.remove(cc));//false
    7. hs.add(cc);
    8. System.out.println("hs.size = " + hs.size());//size = 2
    9. System.out.println(hs);

    }

}

class Point { int x;

  1. public int getX() {
  2. return x;
  3. }
  4. public void setX(int x) {
  5. this.x = x;
  6. }
  7. @Override
  8. public int hashCode() {
  9. final int prime = 31;
  10. int result = 1;
  11. result = prime * result + x;
  12. return result;
  13. }
  14. @Override
  15. public boolean equals(Object obj) {
  16. if (this == obj) return true;
  17. if (obj == null) return false;
  18. if (getClass() != obj.getClass()) return false;
  19. Point other = (Point) obj;
  20. if (x != other.x) return false;
  21. return true;
  22. }
  23. @Override
  24. public String toString() {
  25. return "Point{" +
  26. "x=" + x +
  27. '}';
  28. }

}

  1. **如何避免:**
  2. - 自己写的对象要重写hashCodeequals方法
  3. - 对象被加入到HashSetHashMap等容器后,尽量避免修改。
  4. <a name="bfm6s"></a>
  5. ## **引用外类的内部类**
  6. 默认情况下,每个非静态内部类都包含对其外部类的隐式引用。如果我们在应用程序中使用这个内部类对象,那么即使在我们的外部类对象不再使用了,它也不会被垃圾收集。因为内部类对象隐式地保存对外部类对象的引用,从而使其成为垃圾收集的无效候选者。在匿名类的情况下也是如此。<br />**如何避免**
  7. - 如果内部类不需要访问当前包含这个内部类的父类的成员时,请考虑将其转换为静态类
  8. **ThreadLocal使用不当**<br />ThreadLocal使我们能够将状态隔离到特定线程,从而允许我们实现线程安全。<br />一旦保持线程不再存在,ThreadLocals应该被垃圾收集。是当ThreadLocals与现代应用程序服务器一起使用时,问题就出现了。现代应用程序服务器使用线程池来处理请求而不是创建新请求(例如 ,在Apache Tomcat的情况下为Executor)。此外,他们还使用单独的类加载器。由于 应用程序服务器中的线程池在线程重用的概念上工作,因此它们永远不会被垃圾收集 - 相反,它们会被重用来处理另一个请求。现在,如果任何类创建 ThreadLocal 变量但未显式删除它,则即使在Web应用程序停止后,该对象的副本仍将保留在工作线程中,从而防止对象被垃圾回收。<br />**如何避免**
  9. - 最好手动调用ThreadLocal.remove()方法删除当前线程值,代码如下:
  10. ```java
  11. try {
  12. threadLocal.set(System.nanoTime());
  13. //... further processing
  14. }
  15. finally {
  16. threadLocal.remove();
  17. }

finalize()方法

finalize是潜在的内存泄漏问题的另一个来源。每当重写类的 finalize()方法时,该类的对象不会立即被垃圾收集。相反,GC将它们排队等待最终确定,在稍后的时间点才会发送GC。
如果用finalize()方法编写的代码不是最佳的,并且finalize队列无法跟上Java垃圾收集器,那么迟早,我们的应用程序注定要遇到 OutOfMemoryError。
如何避免

  • 我们应该总是避免使用finalize方法

    总结

    本文主要分享了自己项目中遇到的一个内存泄漏的问题,然后引出了内存泄漏的几种常见场景,希望大家能够引起重视。

    参考

    https://www.jdon.com/50632