如何认识和处理Java中内存泄漏
一、写在前面
作为一名程序猿,当我们在水群的时候,肯定会无意中听到一个词——内存泄露(memory leak),那么何为内存泄漏,java中的内存泄漏又是什么样子呢?本文在https://www.baeldung.com/java-memory-leaks基础上进行一些简单研究。如果有纰漏或者错误,恳请各位增删改补。
初见内存泄漏,我们可以理解成:无用对象无法被回收的现象就是内存泄漏。你可能觉得没什么大不了嘛,毕竟最开始写c/c++的时候总是忘记free/delete,也不怎么影响代码运行啊。可是在工程代码中,即使一次内存泄漏危害可以忽略,但内存泄漏堆积后果很严重,无论多少内存,迟早会被占光,这会导致程序:
- 应用可用的内存减少,增加了堆内存的压力
- 应用长时间连续运行性能严重下降,比如会触犯更频繁的GC
- 严重的时候可能会导致内存溢出错误,即OOM Error
- 自发或奇怪的应用程序崩溃
所以如何检测内存泄漏,如何正确高效处理内存泄漏,往往是区别程序猿的个人能力在90分还是60分的重要依据之一。
在正式介绍内存泄漏前,需要了解一些必备知识:本文以内存泄漏为主线,涉及到了许多java知识及原理并附上相关链接。
知识1:Java中的对象
- 当我们使用
new
指令生成对象时,堆内存将会为此开辟一份空间存放该对象 - 创建的对象可以被局部变量,实例变量和类变量引用。通常情况下,类变量持有的对象生命周期最长,实例变量次之,局部变量最短。
- 垃圾回收器回收非存活的对象,并释放对应的内存空间。
知识2:Java中的GC
Java 的核心优势之一是借助内置垃圾收集器(或简称GC)的自动内存管理。GC 隐式地负责分配和释放内存,因此能够处理大多数内存泄漏问题。
- 和C++不同,Java中对象的释放不需要手动完成,而是由垃圾回收器自动完成
- 垃圾回收器运行在JVM中
- GC的两种算法:引用计数和GC根节点遍历
具体内容可以查看:https://zhuanlan.zhihu.com/p/373479452
尽管GC非常聪明,但它仍非完美无缺,可能还是会在尽职尽责的开发人员的监视下,偷偷地生成大量多余对象,耗尽关键内存资源,导致整个程序失败。
知识3: Java中的引用类型
强引用、软引用、弱引用和虚引用
(73条消息) Java中的四种引用类型_张旭东0101的博客-优快云博客
二、Java中的内存泄漏模型
2.1 大量使用静态变量
Java中,静态字段的生命周期通常与正在运行的应用程序的整个生命周期相匹配(除非ClassLoader符合垃圾回收条件)
创建一个填充静态列表的简单Java程序:
public class StaticTest {
public static List<Double> list = new ArrayList<>();
public void populateList() {
for (int i = 0; i < 10000000; i++) {
list.add(Math.random());
}
Log.info("Debug Point 2");
}
public static void main(String[] args) {
Log.info("Debug Point 1");
new StaticTest().populateList();
Log.info("Debug Point 3");
}
}
在调试点1和2之间,堆内存会增加。显而易见,如果我们在调试点3离开populateList()方法,这时堆内存没有被垃圾回收。
但当我们删掉第二行的关键字static,直到调试点的第一部分与我们在*静态情况下获得的几乎相同。但是这一次我们离开 populateList() 方法后,列表的所有内存都被垃圾回收了,因为我们没有对它的任何引用。
因此,我们需要非常注意静态变量的使用。如果集合或大对象被声明为static,那么它们在应用程序的整个生命周期中都保留在内存中,从而阻塞了原本可以在其他地方使用的重要内存。
总结:
- 尽量减少使用静态变量
- 使用单例时,依赖于延迟加载对象而不是急切加载的实现
个人认为:java没有c++中的全局变量,故java的单例就是全局变量的一种更好的代替。
java单例模式:https://refactoringguru.cn/design-patterns/singleton
https://www.journaldev.com/1377/java-singleton-design-pattern-best-practices-examples
单例模式下加载:https://www.cnblogs.com/binbingg/p/14144790.html
2.2 通过未封闭的资源(各种连接)
每当我们建立新连接或打开流时(数据库连接、网络连接和IO连接),JVM都会为这些资源分配内存。
忘记关闭这些资源会阻塞内存,从而使它们远离GC。例如对数据库操作时,不再使用后,要调用close方法来释放与数据库的连接。
在任何一种情况下,资源留下的打开连接都会消耗内存,如果我们不处理它们,它们会降低性能,甚至可能导致OutOfMemoryError
总结:
- 总是使用finally块来关闭资源
- 关闭资源的代码(即使在finally块中)本身不应有任何异常
- 使用java 7+时,我们可以使用try-with-resources块
2.3 错误的hashCode()实现
在定义新类时,一个非常常见的疏忽是没有为equals()和hashCode()方法编写适当的重写方法。
HashSet 和 HashMap在许多操作中使用这些方法,如果它们没有被正确覆盖,那么它们可能成为潜在内存泄漏问题的根源。
当一个对象被存储进HashSet集合中且值被修改以后,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,将找不到对象,会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。
让我们以一个普通的 Person 类为例,并将其用作 HashMap中的键:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
现在我们将重复的Person对象插入到使用该键的Map中
请记住,一个Map不能包含重复的键:
@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作为键。由于Map不允许重复键,我们作为键插入的大量重复的Person对象不应该增加内存。
但是由于我们没有定义正确的equals()方法,重复的对象会堆积起来并增加内存,这就是为什么我们在内存中看到多个对象的原因。VisualVM 中的堆内存如下所示:
但是,如果我们正确地覆盖了 equals()和hashCode()方法,那么这个Map中将只存在一个Person对象。
让我们看一下 Person 类的equals ( )和hashCode ()的正确实现:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return person.name.equals(name);
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
return result;
}
}
在这种情况下,以下断言将是正确的:
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
Map<Person, Integer> map = new HashMap<>();
for(int i=0; i<2; i++) {
map.put(new Person("jon"), 1);
}
Assert.assertTrue(map.size() == 1);
}
正确覆盖equals()和hashCode()后,同一程序的堆内存如下所示:
总结:
- 根据经验,在定义新实体时,始终覆盖equals()和hashCode()方法
- 覆盖不仅足够,还必须以最佳方式覆盖这些方法
2.4 内部类隐式持有外部类引用
java类的初始化:(70条消息) Java 类初始化(详解)_Acherie的博客-优快云博客_java初始化
当一个变量的值是一个类对象,而不是基本数据类型时,该变量成为引用。
java中的引用和指针:(70条消息) java中引用和指针_种子选手席同学的博客-优快云博客_java指针和引用
Java内部类:
class OuterClass{
class InnerClass{
}
}
在非静态内部类(匿名类)情况下:
默认每个非静态内部类都有对其包含类的隐式引用,那么如果他们的生命周期长于外部类,且在虚拟机进行GC时,外部类仍然被this$0引用着,自然外部类就不能被回收,从而就会导致内存泄漏。
总结:
- 如果内部类不需要访问包含的类成员,将其转换为静态类
2.5 使用finalize()方法
使用java终结器 是潜在内存泄漏的来源之一。
每当一个类的 finalize()方法被覆盖时,该类的对象不会立即被垃圾回收。相反,GC 将它们排队等待最终确定,这会一直占用内存,造成内存泄漏。
此外,如果finalize()方法中编写的代码不是最优的,并且如果 finalizer 队列无法跟上 Java 垃圾收集器的速度,那么我们的应用程序迟早会遇到 OutOfMemoryError。
总结:
- 应该始终避免使用终结器
2.6 使用ThreadLocals
ThreadLocals是一种构造,它使我们能够将状态隔离到特定线程,从而使我们能够实现线程安全。
ThreadLocal的内存泄露?什么原因?如何避免? - 知乎 (zhihu.com)
使用此构造时, 每个线程都将持有对其ThreadLocal变量副本的隐式引用,并将维护自己的副本,而不是在多个线程之间共享资源,只要线程处于活动状态。
尽管有其优势,但ThreadLocal 变量的使用仍存在争议,因为如果使用不当,它们会因引入内存泄漏而臭名昭著:
随意使用线程池和随意使用线程局部变量可能会导致意外的对象保留
一旦持有线程不再活动,应该对ThreadLocals进行垃圾收集。但是当ThreadLocals与现代应用程序服务器一起使用时,问题就出现了。
现代应用程序服务器使用线程池来处理请求,而不是创建新请求(例如 Apache Tomcat 中的Executor)。此外,它们还使用单独的类加载器。
由于应用服务器中的线程池 基于线程重用的概念,它们永远不会被垃圾收集——相反,它们被重用于服务另一个请求。
现在,如果任何类创建了 ThreadLocal 变量但没有显式删除它,那么即使在 Web 应用程序停止后,该对象的副本仍将保留在工作线程中,从而防止对象被垃圾收集。
总结:
-
当不再使用 ThreadLocals时,清理它们是一个好习惯*——ThreadLocals*提供了 remove()方法,该方法删除了当前线程对该变量的值
-
不要使用 ThreadLocal.set(null)清除值 ——它实际上并没有清除值,而是会查找与当前线程关联的Map并将键值对分别设置为当前线程和null
-
好将 ThreadLocal 视为需要在 finally块中关闭的资源,以确保它始终处于关闭状态,即使在出现异常的情况下也是如此:
try { threadLocal.set(System.nanoTime()); //... further processing } finally { threadLocal.remove(); }
三、处理内存泄漏
3.1 Java分析器
使用分析器,我们可以比较不同的方法并找到可以最佳利用资源的领域:
Java VisualVM、Mission Control、JProfiler、YourKit、Netbeans Profiler。
3.2 JVM详细垃圾收集功能
要启用此功能,我们需要将以下内容添加到我们的 JVM 配置中:
-verbose:gc
3.3 使用引用对象
我们还可以借助java.lang.ref包内置的 Java 中的引用对象来处理内存泄漏。使用java.lang.ref包,我们不是直接引用对象,而是使用对对象的特殊引用,以便轻松地对它们进行垃圾收集。
3.4 基准测试
我们可以通过执行基准测试来衡量和分析 Java 代码的性能。这样,我们可以比较执行相同任务的替代方法的性能。这可以帮助我们选择更好的方法,并可以帮助我们节省内存。
访问 使用 Java进行微基准测试教程。
写在最后
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。所以程序猿们还是要踏实学习,多了解底层知识,注重代码规范,这样才能让老板发财。