Java中的内存泄漏和内存溢出_上

今天我们的目的很纯粹,就是来讲一下内存泄漏(memory leak)和内存溢出(out of memory)。很多时候,我们会听到这两个名词,但是系统的全面的了解却不多,所以今天我们就一起来学习学习。

JVM如何判断对象是否健在

在讲内存之前呢,我们首先要知道这么一个问题:JVM是如何知道某个对象是否还健在的呢?我们都知道Java对象主要是存放在Java堆里的,所以当我们的GC(垃圾收集器)在对Java堆进行回收前,需要先确定这些对象是否还“存活”,哪些已经“死去”(不再被引用)。

下面我们就来讲几种用来判断对象是否健在的算法

引用计数算法

对象中添加一个引用计数器,每有一个地方引用它时,计数器值就加一;当引用失效是,计数器值就减一;任何时刻计数器为零的对象就是不可以能再被使用的对象

该算法的原理简单,判定效率高,但是我们目前主流的JVM并没有选用此算法来管理内存,主要是因为使用该算法时,需要配合大量的额外处理才能确保正确的工作,例如要解决对象之间的相互循环引用的问题,我们可以通过下面的代码例子来解答为啥不用的疑惑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MemoryTest {
@Test
void testGc() {
MyTest myTest1 = new MyTest();
MyTest myTest2 = new MyTest();

myTest1.myTest = myTest2;
myTest2.myTest = myTest1;

myTest1 = null;
myTest2 = null;

// 假设在这行发生GC,test1和test2是否能被回收?
System.gc();
}

private static class MyTest {
public MyTest myTest = null;
}
}

分析代码,myTest1和myTest2对象都被设置成了null,在后面发生GC的时候,如果按照引用计数算法,这两个对象虽然都被设置成了null,但是myTest1引用了myTest2,myTest2又引用了myTest1,所以这两个对象的引用计数值都不为0,所以都不会被回收,但是真正的实际运行结果是,这两个对象都被回收了,这也说明HotSpot虚拟机并不是用引用计数法来进行的内存管理。

那JVM用的是什么算法吗?我们继续往下看…

可达性分析算法

这个算法的基本思路就是通过一一系列称为GC Roots 的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索走过的的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots 间没有任何引用链相连,或者从GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

当前主流的商用程序语言(Java、C#等),都是通过可达性分析(Reachability Analysis)算法来判断对象是否存活的。

我们可以来看下下面的图,虽然object10、object11、object12互相有关联,但是它们到GC Roots是不可达的,因此它们会被判定为可回收的对象。

可达性分析算法

在Java程序中,固定可作为GC Roots 的对象包括以下几种:

  1. 虚拟机栈中引用的对象

    比如:各个线程被调用的方法中使用到的参数、局部变量等。

  2. 本地方法栈内JNI(通常说的本地方法)引用的对象

  3. 方法区中类静态属性引用的对象

    比如:Java类的引用类型静态变量

  4. 方法区中常量引用的对象

    比如:字符串常量池(string Table) 里的引用

  5. 所有被同步锁synchronized持有的对象

  6. Java虚拟机内部的引用

    比如:基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError) ,系统类加载器。

  7. 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

  8. 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

    比如:分代收集和局部回收(Partial GC)

通过对上面两种算法的介绍,实际上在我们判断一个对象是否存在的依据,离不开这个对象是否被引用离不开关系。

在JDK1.2之前,Java对引用的概念是:如果reference类型的数据中存储的数值代表的是另外一块儿内存的地址,就称该reference数据是代表某块内存、某个对象的引用

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)4种,*这4种引用强度依次逐渐减弱

对于这四种不同的引用方式,我们做一个基本的介绍:

引用类型

  1. 强引用是传统“引用”的定义,指引用复制,比如:

    1
    Object obj =new Object();

    这种引用关系。无论在任何情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象

  2. 软引用是用来描述一些还有用,但非必须的对象。在系统发生内存溢出前,会先对软引用对象进行第二次回收,如果回收后还没有足够的内存,才会抛出内存溢出的异常。

    1
    2
    3
    4
    5
    Object obj = new Object();
    ReferenceQueue queue = new ReferenceQueue();
    SoftReference reference = new SoftReference(obj, queue);
    // 强引用对象滞空,保留软引用
    obj = null;
  3. 弱引用也是用来描述那些非必须的对象,但是它的强度比软引用更弱一些,弱引用的对象,只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

    弱引用和软引用的区别在于:弱引用的对象拥有更短的生命周期,只要垃圾回收器扫描到它,不管内存空间充足与否,都会回收它的内存。

    1
    2
    3
    4
    5
    Object obj = new Object();
    ReferenceQueue queue = new ReferenceQueue();
    WeakReference reference = new WeakReference(obj, queue);
    //强引用对象滞空,保留软引用
    obj = null;
  4. 虚引用也称为“幽灵引用”或“幻影引用”,它是最弱的一种引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

综上来理解的话,通过定义四种引用类型以及可达性分析算法来判断对象是否健在。但是,是不是被可达性分析算法判断为不可达对象就“非死不可”了,实际上,要真正回收一个对象,是需要至少经历两次标记过程:

  1. 如果第一次对象在进行可达性分析后发现与GC Roots 不可达,将进行第一次标记。
  2. 随后对此对象进行一次是否有必要执行finalize()方法进行筛选,假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,都视为“没有必要执行”。

如果对象被判定有必要执行finalize()方法,会将对象放置在一个名为F-Queue的队列中,并在由一条由虚拟机自动建立的、低调度的线程区执行它们的finalize()方法。但并不承诺一定会等待它们运行结束。

注意事项:任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临第二次回收,它的finalize()方法不会被再次执行。
还有一点就是Java官方已经明确声明不推荐手动调用finalize()方法了,因为它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,并且finanlize()能做的所有工作,使用try-finally或其他方式都可以做的更好、更及时。

什么是内存泄漏(memory leak)

学习完了上面的对象回收之后,我们接下来就讲一下什么是内存泄漏。

在用可达性分析算法判断对象是否是不再使用的对象时,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。

主要关注两点:

  1. 是否还被使用?是
  2. 是否还被需要?否

内存泄漏(memory leak)的理解

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。但实际情况很多时候一些不太好的实践(或疏忽)才导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

内存泄漏和内存溢出的关系

内存泄漏(memory leak)就是占着茅坑不拉屎,明明申请了内存并使用完了但是就是不释放掉。比如一共有1024M的内存,分配了512M的内存一直不回收,那么可用的内存就只有512M了,仿佛漏掉了一部分。

内存溢出(out of memory)就是申请内存的时候,没有足够的内存可以使用。通俗一点儿讲,一个厕所就三个坑,有两个站着茅坑不走的(内存泄漏),剩下最后一个坑,厕所表示接待压力很大,这时候一下子来了两个人,坑位(内存)就不够了,内存泄漏变成了内存溢出了。

所以,内存泄漏的增多,最终会导致内存溢出。

内存泄漏的分类

  1. 经常发生:发生内存泄漏的代码会被多次执行,每次执行,泄漏一块内存;
  2. 偶然发生:在某些特定情况下才会发生;
  3. 一次性:发生内存泄漏的方法只会执行一次;
  4. 隐式泄漏:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄漏,因为最终释放掉了。

常见的内存泄漏8种情况

1.静态集合类

静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的声明周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

1
2
3
4
5
6
7
public class MemoryLeak {
static List list = new ArrayList();

public void oomTests() {
Object obj = new Object();// 局部变量
list.add(obj);
}

2.各种连接使用后未关闭

如数据库连接、网络 http 连接、io 连接等等。当程序操作数据库时,首先应该建立数据库的连接 Connection,操作语句时建立 Statement 对象,获取结果集建立 ResultSet 对象,之后需要显示的调用 close 方法来关闭连接,只有关闭连接后,GC 才会对对应的未使用对象进行回收。没有及时的关闭数据库连接,会导致大量对象长期占用内存空间,导致内存泄漏

1
2
3
4
5
try{
FileInputStream fileInputStream = new FileInputStream("a.txt");
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}

3.变量的作用域不合理

如本该唯一定义在某方法的变量定义在了全局变量。一般来讲,一个变量的作用范围大于其所被使用的范围,可能发生内存泄漏,表现在存在时间大于使用时间,即使用完了但是还不能被回收,就比如下面的列子。另外,如果没有及时的将未使用的对象置 null,也有可能导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.gcoder.common.core;

/**
* 内存泄漏例子
* 这里的变量 info 在方法 receiveAndSaveInfo 中进行赋值和保存,在该方法执行完毕后本应该被 GC 回收,
* 但由于全局变量的生命周期是跟随对象的,所有当方法执行完不能被回收,可能造成内存泄漏。
*
* @author gcoder
*/
public class DemoMemoryLeak {
/**
* 消息
*/
private String info;

public void receiveAndSaveInfo() {

// 模拟接受消息
receiveInfo();

// 模拟存储消息
saveInfo();
}
}

4.内部类持有外部类

如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。

5.改变哈希值

当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露。

6.栈引起的内存泄漏

我们可以编辑一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class DemoMemoryLeak {
private static final int DEFAULT_INITIAL_CAPACITY = 16;
private Object[] elements;
private int size = 0;

public DemoMemoryLeak() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

/**
* 进栈
*
* @param o o
* @author gaohuiwu
* @date 2023-03-05
*/
public void push(Object o) {
ensureCapacity();
elements[size++] = o;
}

/**
* 出栈
*
* @return lang.Object
* @author gaohuiwu
* @date 2023-03-05
*/
public Object pop() {
if (size == 0) {
throw new EmptyStackException();
}
return elements[--size];
}

/**
* 扩容
*
* @author gaohuiwu
* @date 2023-03-05
*/
private void ensureCapacity() {
if (elements.length == size) {
elements = Arrays.copyOf(elements, size * 2 + 1);
}
}
}

这段模拟栈操作的代码存在隐蔽的内存泄漏问题。定位到pop()函数,在return语句中,当我们弹出一个元素时,只是简单的让栈顶指针(size)-1。逻辑上,栈中的这个元素已经弹出,已经没有用了。但是事实上,被弹出的元素依然存在于elements数组中,它依然被elements数组所引用,GC是无法回收被引用着的对象的。也许你期望等这整个栈失去引用(将被GC回收时),栈内的elements数组一起被GC回收。但是实际的使用过程中,又有谁能够预料到这个栈会存活多长时间。为了保险起见,我们需要在弹出一个元素的时候,就让这个元素失去引用,便于GC回收。我们只需要让Pop()函数弹出时,同时解除对弹出元素的引用即可。

7.缓存泄漏

内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘,对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用那么此map会自动丢弃此值。

8.监听器和回调

内存泄漏第三个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。需要确保回调立即被当作垃圾回收的最佳方法是只保存他的若引用,例如将他们保存成为WeakHashMap中的键。

预防内存泄漏的方法

  1. 尽量减少使用静态变量,或者使用完及时赋值为 null
  2. 明确内存对象的有效作用域,尽量缩小对象的作用域,能用局部变量处理的不用成员变量,因为局部变量弹栈会自动回收
  3. 减少长生命周期的对象持有短生命周期的引用
  4. 使用 StringBuilderStringBuffer进行字符串连接,Sting StringBuilder 以及 StringBuffer 等都可以代表字符串,其中 String 字符串代表的是不可变的字符串,后两者表示可变的字符串。如果使用多个String对象进行字符串连接运算,在运行时可能产生大量临时字符串,这些字符串会保存在内存中从而导致程序性能下降
  5. 对于不需要使用的对象手动设置 null 值,不管 GC 何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象
  6. 各种连接(数据库连接,网络连接,IO连接)操作,务必显示调用close关闭

写在最后

本文我们一起学习了jvm的回收机制和内存泄漏的一些场景和预防措施,那么下个篇幅,我们就来讲讲所谓的内存溢出~


我是Gcoder,一个热爱学习的Java跑马选手。


Java中的内存泄漏和内存溢出_上
https://gcoder5.com/2023/03/04/Java中的内存泄漏和内存溢出_上/
作者
Gcoder
发布于
2023年3月4日
许可协议