背景
今天这篇文章跟大家聊聊应用程序内存泄漏相关的概念、原因以及排查和解决方案。
过完春节来公司,发现有几个项目出现了很明显的内存泄漏问题。在此之前,一直在赶新功能的开发,项目几乎每天都在上线发布新的功能,内存泄漏的问题并没有暴露出来。春节期间,项目停止了发布,这一问题便显现出来了。
项目是基于k8s部署的,有两个项目的Pod进行了自动扩容,查看Pod的内存使用情况,呈直线上升的趋势。
内存泄露场景图
于是,节后的第一件事便是进行内存泄漏问题的排查。项目中内存泄漏的问题最终找到并解决了,在此期间也调研和排查了各类内存泄漏的问题。本篇文章会对解决内存泄漏问题中涉及到的理论知识进行梳理和讲解,以便大家在遇到类似问题时可参考解决。
内存泄漏与内存溢出
在聊内存泄漏的时候,肯定要提一下内存溢出,这两者很容易混淆,但区分缺失非常明显的。
内存溢出(Out of Memory,简称OOM),通俗地来讲,就是当程序申请内存时,没有足够的内存可以使用了,也就是说程序申请的内存大于系统能够提供的内存,此时就会出现Out Of Memory的错误。
内存泄漏(Memory Leak),是指程序在申请内存后,使用完毕之后,无法释放对应的内存空间。比如,在程序运行时,申请分配一部分内存给临时变量使用,但使用完之后这部分内存没有被手动释放或无法被GC(Java中的垃圾回收)回收,就会导致此部分内存始终被占用,从而导致内存泄漏。
一次内存泄漏危害可以忽略,但内存泄漏堆积后果很严重,因为无论多少内存,迟早会被耗光。最终导致OOM(内存溢出)。
在Linux内核的操作系统中,当系统内存严重不足时,还会触发OOM Killer(Out of Memory Killer)机制,强行释放进程内存。这也是某些应用程序莫名其妙被Kill的原因之一。
内存泄漏分类
了解了内存泄漏的基本定义,再来看看内存泄漏的场景和分类。
按泄漏频次分类
如果按照泄漏的频次特性来划分,内存泄漏可分为4类:
常发性内存泄漏:发生内存泄漏的代码经常会被执行,而每次执行都会导致一定程度的内存泄漏。
偶发性内存泄漏:在某些特定环境或特定分支逻辑中才会发生内存泄漏。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。因此,测试环境和测试方法对检测内存泄漏至关重要。
一次性内存泄漏:发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有且仅有一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,此时内存泄漏只会发生一次。
隐式内存泄漏:程序在运行过程中不停地分配内存,直到结束时才释放。严格说这里并没有发生内存泄漏,因为内存最终被释放了。但是对于服务器程序来说,往往会运行几天,几周甚至几个月,不及时释放内存也可能会导致耗尽系统的内存。称这类内存泄漏为隐式内存泄漏。
站在用户的角度来看,内存泄漏的影响有限(可能会产生响应慢等情况),但当内存泄漏堆积到一定程度,耗尽系统内存时,往往会导致服务器资源的浪费(比如,开篇提到的自动扩容)、响应缓慢,甚至OOM和OOM Killer。此时,危害性就比较大了。特别是应用系统没有做自动扩容恢复等运维措施时。
对于上述4类内存泄漏,常发性内存泄漏最容易发现和解决,偶发性内存泄漏次之,最难发现和排查的当属隐式内存泄漏,而且它的危害性非常大。对于一次性内存泄漏,不会进行堆积,相对而言,影响有限。
按泄漏位置分类
根据内存泄漏在内存中的位置分为以下两类:
- 堆内存泄漏:我们经常说的内存泄漏就是堆内存泄漏,在堆上申请了资源,在使用完毕时,没有将内存释放归还给OS,从而导致该块内存无法被再次使用。
- 资源泄漏:通常指的是系统资源,比如socket,文件描述符等,这些资源在系统中都是有限制的,如果创建了而不归还,久而久之,就会耗尽资源,导致其他程序不可用。
内存泄漏的场景
以下以Java语言中的场景来进行说明。
1、被长生命周期对象持有
场景一:在Java中像HashMap、LinkedList等集合类,如果在使用时将其生命为静态变量,那么它们的生命周期将伴随整个JVM的生命周期。在这种场景下,如果持续将对象放入该类容器,而未进行相应的移除操作,便会形成一个长生命周期(与JVM一样)的对象,持有了(大量)短生命周期的对象,从而导致短生命周期的对象所占有的内存资源无法释放,从而造成内存泄漏问题。
示例如下:
public class TestStaticSet {
static List<Object> list = new ArrayList<>();
public void memoryLeakCase1() {
Object object = new Object();
list.add(object);
}
}
场景二:与上述场景类似的,在使用单例模式时,单例的静态对象也具有与JVM相同的生命周期,如果该静态类持有了外部对象的引用,也会导致外部对象无法被释放,从而造成内存泄漏。
场景三:同样是一个对象被长期持有,与上面两种情况不同的是,该对象是某个其他对象的内部类。这样,不仅被长期持有的内部类对象无法被释放,就连内部类所在的外部类对象,即便已经不再使用,也同样无法被释放。
场景四:变量的作用域不同导致的生命周期不同。比如,原本一个变量的作用域在方法内部,但如果将该变量设置为类级别的成员变量,此时,原本在方法内部使用完即可释放的内存,变为与类对象生命周期一样长。可能会造成一定程度的内存泄漏。
场景五:缓存泄漏。这个场景属于场景一的拓展场景。比如将对象放入缓存(静态集合也可以看做是缓存的容器)中,而忽略了缓存不同场景下的大小以及释放机制,从而导致一定程度的内存泄漏。
以上情况,都可以归类为由于长生命周期的对象持有了短生命周期的对象,而没有做好释放操作而导致内存泄漏情况的发生。
2、系统资源型内存泄漏
在项目实践中会涉及到各类连接性资源,比如数据库连接、网络连接、流和IO连接等。无论什么时候当我们创建一个连接或打开一个流,JVM都会分配内存给这些资源。比如,数据库链接、输入流和session对象。
忘记关闭这些资源,会阻塞内存,从而导致GC无法进行清理。特别是当程序发生异常时,没有在finally中进行资源关闭的情况。
以数据库操作为例,在对数据库进行操作时,创建的数据库连接使用完毕之后,未调用对应的close方法进行释放,便会造成两个维度的内存泄漏问题。
以数据库连接为例:
第一个维度,JVM中大量对象无法释放。在针对于数据库的操作中,像Connection
、Statement
、ResultSet
这些对象都需要显式地关闭,如果不关闭它们,这些对象不会被垃圾回收器回收,继而造成JVM内部内存的占用不断增加。这会导致Java应用程序内存的不断消耗,最终可能会导致内存溢出(OutOfMemoryError)。
第二个维度,数据库连接资源无法释放。数据库连接是一种宝贵的资源。建立和关闭数据库连接的开销很高,通常使用连接池来重复利用这些连接。如果数据库连接没有被显式关闭,就会被占用在连接池外部。这会导致连接池中的可用连接数量减少,最终可能用尽连接池,导致后续请求无法获取到可用的数据库连接,系统的数据库操作因此陷入僵局。
类似这种场景的资源型泄漏还有HTTP连接、操作本地磁盘文件等场景下的资源释放。特别是针对异常情况下的资源释放,否则会引发偶发性或隐式内存泄漏。
3、监听器和回调
内存泄漏的常见来源还有监听器和其他回调,如果客户端在对应的API中注册了回调,却没有显示的取消,那么就会造成积聚,从而引发内存泄漏。这种内存泄漏属于被动型的,类似的要处理好服务端的连接超时、资源超时释放等场景。
针对上述回调场景,需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将它们保存成为WeakHashMap中的键。
4、不当的equals方法和hashCode方法实现
当我们定义个新的类时,往往需要重写equals方法和hashCode方法。在HashSet和HashMap中的很多操作都用到了这两个方法。如果重写不得当,会造成内存泄漏的问题。
下面来看一个具体的实例:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
现在将重复的Person对象插入到Map当中。我们知道Map的key是不能重复的。
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<100; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertFalse(map.size() == 1);
}
上述代码中将Person对象作为key,存入Map当中。理论上当重复的key存入Map时,会进行对象的覆盖,不会导致内存的增长。
但由于上述代码的Person类并没有重写equals方法,因此在执行put操作时,Map会认为每次创建的对象都是新的对象,从而导致内存不断的增长。
VisualVM中显示信息如下图:
img
内存走势图
当重写equals方法和hashCode方法之后,Map当中便只会存储一个对象了,内存泄漏问题也便解决了。
5、使用ThreadLocal场景
ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定,从而实现线程安全的特性。
堆栈结构
ThreadLocal的实现中,每个Thread维护一个ThreadLocalMap映射表,key是ThreadLocal实例本身,value是真正需要存储的Object。
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统GC时,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。
如果当前线程迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
如何解决此问题?
第一,使用ThreadLocal提供的remove方法,可对当前线程中的value值进行移除;
第二,不要使用ThreadLocal.set(null) 的方式清除value,它实际上并没有清除值,而是查找与当前线程关联的Map并将键值对分别设置为当前线程和null。
第三,最好将ThreadLocal视为需要在finally块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源。
try {
threadLocal.set(System.nanoTime());
//... further processing
} finally {
threadLocal.remove();
}
内存泄漏的检测与定位
检测和定位内存泄漏的方法和场景很多,针对Java语言中的JVM内存泄露的排查,介绍几种常用的方法:
分析堆转储(Heap Dump Analysis):通过分析堆转储文件,可以查看当前JVM堆中所有对象的内存占用情况。常用的工具包括VisualVM等。
JConsole和Java Mission Control:这两个工具是Java自带的性能分析工具,可以实时监控JVM的性能指标,包括堆使用情况、垃圾收集情况等。通过这两个工具,可以快速定位内存泄露的问题。
GC日志分析:垃圾收集器的日志文件中记录了每次垃圾收集的信息,通过分析这些日志文件,可以找出哪些对象占用了大量内存并且无法被回收。
代码审查:通过仔细审查代码,特别是关注那些可能导致对象长时间被引用的代码,可以发现潜在的内存泄露问题。
小结
根据上述案例及场景的分析,我们可以看到,导致内存泄漏的场景非常多,但最终归结成一句话就是内存泄漏本身的定义:程序在申请内存后,使用完毕之后,无法释放对应的内存空间。
因此,在具体实践的过程中,针对本文所述场景以及其他涉及资源、内存使用的场景要特别留意一下,做好正常、异常逻辑下各类资源的释放操作。
当然,如果内存泄漏已经发生,在寻找内存泄漏的问题点时,除了全面定点排查项目中涉及到资源使用的情况之外,还可以结合具体的编程语言(比如,Java的VisualVM等)的内存分析工具进行来定位导致内存泄漏的地方。关于各类工具的使用及内存分析,本篇文章就不再展开。