前言

说到垃圾回收(Garbage Collection,GC),很多人就会自然而然地把它和 Java 联系起来。在 Java 中,程序员不需要去关心内存动态分配和垃圾回收的问题,这一切都交给了JVM 来处理。顾名思义,垃圾回收就是释放垃圾占用的空间,但垃圾回收器并不是万能的,它能够处理大部分场景下的内存清理、内存泄露以及内存优化。但它也并不是万能的,不然我们在项目实践过程中也不会出现那么多的内存泄漏的问题,很多的内存泄漏都是因为开发人员操作不当导致的。

本篇文章我们就来聊聊内存泄露的原因是什么,以及如何在应用程序中进行处理。

什么是内存泄漏

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。它的一般表现方式是程序运行时间越长,占用内存越多,最终的结果将会使应用程序耗尽内存资源,无法正常服务,导致程序崩溃,抛出 java.lang.OutOfMemoryError 异常。

在任何一个应用程序中,发生内存泄露一般是由很多原因构成。接下来我们就聊聊最常见的一些内存泄露的场景。

1、静态集合类引起内存泄漏

使用 HashMap、Vector 等集合时,最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏,简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

比如以下的代码:

static Vector vector = new Vector(5);
for (int i = 1; i<1000; i++){
Object object = new Object();
vector .add(object);
object = null;
}

在代码中循环申请 Object 的对象,并将所申请的对象放入一个 Vector 中,如果仅仅释放引用本身(object = null),那么 Vector 仍然引用该对象,所以这个对象对 GC 来说是不可回收的。因此,如果对象加入到 Vector 后,还必须从 Vector 中删除,最简单的方法就是将 Vector 对象设置为null。

解决办法:静态引用时注意应用对象置空或者少用静态引用。

2、资源未关闭或释放导致内存泄露

当我们在程序中创建或者打开一个流或者是新建一个网络连接的时候,JVM 都会为这些资源类分配内存做缓存,常见的资源类有网络连接,数据库连接以及 IO 流。如果忘记关闭这些资源,会阻塞内存,从而导致 GC 无法进行清理。特别是当程序发生异常时,没有在finally 中进行资源关闭的情况。这些未正常关闭的连接,如果不进行处理,轻则影响程序性能,重则导致 OutOfMemoryError 异常发生。

所以,最后的方式就是加上 finally,比如:

    try {
        //正常
    } catch (Throwable t) {
        //异常
    } finally {
        //关闭
    }

3、不正确的 equals() 和 hashCode()

在HashMap和HashSet这种集合中,常常用到equal()和hashCode()来比较对象,如果重写不合理,将会成为潜在的内存泄露问题。

public class ThreadTest{
	
	private String name;
    private Integer id;
    private Integer age;
 
    public ThreadTest(Integer id,String name,Integer age){
        this.name = name;
        this.id = id;
        this.age = age;
    }

	public static void main(String[] args){
		Map<ThreadTest, String> map = new HashMap<>();
		ThreadTest t1 = new ThreadTest(1,"xiaoming", 30);
		ThreadTest t2 = new ThreadTest(1,"xiaoming", 30);
		ThreadTest t3 = new ThreadTest(1,"xiaoming", 30);
	 
	      map.put(t1 , "xiaoming");
	      map.put(t2, "xiaoming");
	      map.put(t3 , "xiaoming");
	 
	      System.out.println("运行结果:"+map.entrySet().size());
	}

}

ThreadTest 类没有重写 equals 和 hashCode 方法,那 Map 的 put 方法就会调用 Object 默认的 hashCode 方法。

在这里插入图片描述
但由于上述代码的 ThreadTest 类并没有重写 equals 和 hashCode 方法,因此在执行 put 操作时,Map会认为每次创建的对象都是新的对象,从而导致内存不断的增长,会导致内存泄漏的可能。

解决的方法就是重写 equals 和 hashCode ,代码如下:

 @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof ThreadTest)) {
            return false;
        }
        ThreadTest person = (ThreadTest) o;
        return person.name.equals(name);
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }

在这里插入图片描述

重写了 hashCode 确实可以避免重复对象的加入,但是也会引入新的问题,如下代码:

public static void main(String[] args){
		Set<ThreadTest> set = new HashSet<ThreadTest>();
		ThreadTest t1 = new ThreadTest(1,"xiaoming", 30);
		ThreadTest t2 = new ThreadTest(1,"xiaoming", 30);
		ThreadTest t3 = new ThreadTest(1,"xiaoming", 30);
	 
		set.add(t1);
		set.add(t2);
		set.add(t3);
	 
	      System.out.println("删除前:"+set.size()); 
	      t1.setName("zhangsan");
	      set.remove(t1);
	      System.out.println("删除后:"+set.size());
	      set.add(t1);
	      System.out.println("新增后:"+set.size());
	}

在这里插入图片描述

从运行结果我们可以看到,很明显 set.remove(t1) 以后没有删除成功,这是因为 t1.setName(“zhangsan”) 后,会重新计算 t1 的 hashCode,并且发生了变化,所以 remove 的时候会找不到相应的 Node,这会导致业务中无用的对象被引用着,会导致内存泄漏的可能。

解决的方法就是先 remove,然后修改属性,最后再重新 add 数据进去。

4、重写了 finalize() 的类

使用 finalize() 方法会存在潜在的内存泄露问题,每当类的 finalize() 方法被重写时,该类的对象不会立即被垃圾回收。相反,GC 将它们排队等待最后确定,在以后的某个时间点进行回收。

如果 finalize() 方法重写的不合理或 finalizer 队列无法跟上 Java 垃圾回收器的速度,那么迟早,应用程序会出现 OutOfMemoryError 异常。

比如运行如下的代码:

public class ThreadTest{
	
	 @Override
	    protected void finalize() throws Throwable {
	    while (true) {
	           Thread.yield();
	      }
	 }

	public static void main(String[] args){
		while (true) {
	        for (int i = 0; i < 100000; i++) {
	        	ThreadTest force = new ThreadTest();
	        }
	   }
	}

}

为了证明这一点,我们为类重写了 finalize() 方法,并且该方法需要一点时间来执行。当此类的大量对象被垃圾回收时,在 VisualVM中的结果会是指数增长。

在这里插入图片描述
所以我们应该避免使用 finalizer() 方法。

5、使用 ThreadLocal 造成内存泄露

ThreadLocal 提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal 相当于提供了一种线程隔离,将变量与线程相绑定,从而实现线程的安全,但是使用不当,就会引起内存泄露。

一旦线程不在存在,ThreadLocal 就应该被垃圾收集,而现在线程的创建都是使用线程池,线程池有线程重用的功能,因此线程就不会被垃圾回收器回收。所以使用到 ThreadLocal 来保留线程池中线程的变量副本时,ThreadLocal 没有显示的删除时,就会一直保留在内存中,不会被垃圾回收。

解决办法是不在使用 ThreadLocal 时,调用 remove() 方法,该方法删除了此变量的当前线程值。不要使用 ThreadLocal.set(null),它只是查找与当前线程关联的 Map 并将键值对设置为当前线程为 null。

try {
    threadLocal.set(System.nanoTime());
}
finally {
    threadLocal.remove();
}

使用 try/finally 的方式,假如在运行过程中出现异常,还可以在 finally 中 remove 掉。

总结

本文主要介绍了 5 种内存泄露的场景,针对每种内存泄露的场景都提供了解决办法,但是对于内存泄露来说,不同的代码,不同的场景会出现一些不同的内存泄漏问题,我们需要了解内存泄漏的根本原因,同时掌握一些基本分析方法,以便我们能及时解决问题。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐