Java中的内存泄露问题

引入

Java虚拟机(JVM)作为一种内存管理工具,可以很方便地帮我们管理和释放内存。但还是会遇到内存无法回收的情况,从而造成内存泄漏。

1. 什么是Java内存泄漏

内存泄露的标准定义是: 尽管对象不再被程序所使用,但垃圾回收器却无法将其回收的情况——因为对象仍然处于被引用的状态。 久而久之,不能被回收的内存越来越多,最终导致内存溢出OOM(OutOfMemoryError)。

在这里插入图片描述
通过上图,我们可以发现有两种类型的对象——被引用的对象和未被引用的对象。垃圾回收器(GC)只可以回收未被引用的对象。而被引用的对象是无法被垃圾回收器回收的,即使我们再也不需要使用到这些未被引用的对象了。

检测内存泄漏非常困难。我们可以通过分析一些常见的内存泄漏情况来预防内存泄漏的发生。

2. Java堆泄漏

这一小节我们把重点放在内存泄漏发生的重灾区——堆。因为堆区是用来存储新生的Java对象的地方,这里也常会有不被使用的对象未被回收。

为堆设置更小的内存是解决堆区内存泄漏的常用方法。在我们启动程序的时候,便可以调整我们需要的内存空间:

-Xms<Size> //初始化堆空间
-Xmx<Size> //最大堆空间大小

2.1 静态类型持续引用对象

静态类型的对象的引用也会导致Java内存泄漏

private Random random = new Random();
public static final ArrayList<Double> list = new ArrayList<Double>(1000000);

@Test
public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException {
    for (int i = 0; i < 1000000; i++) {
        list.add(random.nextDouble());
    }
    
    System.gc();
    Thread.sleep(10000); // to allow GC do its job
}

上面的代码可以看出,ArrayList是静态声明的,代表了它在全生命周期内都不会被JVM回收,即使是ArrayList已经完成了自己的使命。最后让线程sleep了10000毫秒,给GC提供了足够的时间用来完成自己的回收任务。
在这里插入图片描述
通过上图可以看出,在程序最开始,所有的内存空间都是空的。紧接着2s后,程序开始跑起来,for循环的进行使list里面新增越来越多的值,内存也开始上升。知道循环执行完毕

随后,GC被触发开始工作,但我们可以看到内存并未得到释放。

接下来我们看另一个例子,这个例子我们把list的声明不再设为static,而是设为局部变量。这样list便会经历一个从被创建,到执行,再到消亡的全生命周期。

@Test
public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException {
    addElementsToTheList();
    System.gc();
    Thread.sleep(10000); // to allow GC do its job
}
    
private void addElementsToTheList(){
    ArrayList<Double> list = new ArrayList<Double>(1000000);
    for (int i = 0; i < 1000000; i++) {
        list.add(random.nextDouble());
    }
}

一旦上面的方法完成执行,GC便会开始工作,我们可以注意到大概在51s,内存便得到释放了。
在这里插入图片描述

如何预防内存泄漏呢?

我们现在已经了解到内存可能发生的情形了,接下来要做的便是预防内存泄露的发生。

首先,我们需要格外注意对关键词static的使用,对任何集合或者是庞大的类进行static声明都会使其声明周期与JVM的生命周期同步,从而使其无法回收。

我们还需要注意到, 我们可能还在对已经不需要的类进行引用。

2.2 在Long String上调用String.intern()

第二种常见内存泄漏发生在对字符串的操作上。尤其是使用到String.intern()接口时。

代码如下:

@Test
public void givenLengthString_whenIntern_thenOutOfMemory()
  throws IOException, InterruptedException {
    Thread.sleep(15000);
    
    String str 
      = new Scanner(new File("src/test/resources/large.txt"), "UTF-8")
      .useDelimiter("\\A").next();
    str.intern();
    
    System.gc(); 
    Thread.sleep(15000);
}

这里尝试把一个大文件加载到内存中,然后用.intern()返回规范的格式。

intern在JVM内存中取代str,从而造成GCA无法释放足够的内存。
在这里插入图片描述
我们可以看到15s(一道竖直虚线是10s)的时候JVM内存还是很稳定的,接下来加载了一个大型文件,JVM在20s后开始执行垃圾回收。

最后str.intern()被激活,导致了内存泄漏,本来平稳的线骤然升高,造成大量的堆空间被占用,这样的内存也无法被回收。

如何预防呢?

我们一定要记住Interned String是被储存在永久代(Java7的版本)里的,如果我们想要对超大字符串进行操作,我们便需要增大原空间的内存大小。

-XX:MaxPermSize=<size>

第二种解决方法是使用Java8,永久代被原空间取代了,即使使用interned string也不会发生OOM内存泄漏的情况。
在这里插入图片描述
当然,假如你使用的是Java8以前的版本,你也可以不适用.intern()来避免内存泄漏。

2.3. 未封闭流

忘记关闭流也是一种导致内存泄漏发生的常见情况。 Java7由于引入了try-with-resources以后部分解决了流未能关闭导致的内存泄漏。

@Test(expected = OutOfMemoryError.class)
public void givenURL_whenUnclosedStream_thenOutOfMemory()
  throws IOException, URISyntaxException {
    String str = "";
    URLConnection conn 
      = new URL("http://norvig.com/big.txt").openConnection();
    BufferedReader br = new BufferedReader(
      new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
    
    while (br.readLine() != null) {
        str += br.readLine();
    } 
    
    //
}

我们看一下当从URL加载大型文件会发生什么
在这里插入图片描述
可以看到,堆内存使用随时间增加而增加,这也是因为未能关闭流造成的。

严格来说,一个未能关闭的流会导致两种类型的泄漏,一种是低层资源泄漏,一种是内存泄漏。

低层资源泄漏是OS层面的资源,例如文件描述符,打开连接等的泄漏。JVM会跟踪记录这些重要的资源,进一步也就导致了内存泄漏。

如何预防呢?

我们要么记得手动关闭流,要么使用Java8中引入的自动关闭流特性。

try (BufferedReader br = new BufferedReader(
  new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
    // further implementation
} catch (IOException e) {
    e.printStackTrace();
}

例如以上的情况,BufferedReader会在try语句末尾自动被关闭,不需要我们自己去手动显式关闭。

2.4 未关闭连接

这种情况和上一种未关闭流的情况类似,主要的不同点在于这里是对未关闭的连接的处理(例如数据库,FTP服务器等)。同样,错误的实现方法也可能导致内存泄漏。

@Test(expected = OutOfMemoryError.class)
public void givenConnection_whenUnclosed_thenOutOfMemory()
  throws IOException, URISyntaxException {
    
    URL url = new URL("ftp://speedtest.tele2.net");
    URLConnection urlc = url.openConnection();
    InputStream is = urlc.getInputStream();
    String str = "";
    
    //
}

从上面我们呢可以看到,URL连接仍处于打开状态,可预见的结果就是会导致内存泄漏。
在这里插入图片描述
GC不能回收这些没有使用的但仍被引用的内存。我们可以清晰地看到在1min地时候,GC操作数骤然下降,造成了堆内存空间占用不断增加,从而产生了OOM。

如何预防呢?

答案很简单咯——要从小养成好习惯,记得饭前洗手 使用完连接后要及时关闭。

2.5. 把没有hashCode()和equals()的类加到HashSet里面

一个可能导致内存泄漏的简单但非常常见的情况是:将HashSet 与没有hashCode()或equals()实现的对象一起使用。

具体来说,当我们开始把重复的对象添加到集合中时,内存使用会不断增长,而不是像往常一样集合会忽略重复项。并且在添加后重复项后,我们也将无法删除这些对象。

我们创建一个不包含equals或hashCode的简单类

public class Key {
    public String key;
    
    public Key(String key) {
        Key.key = key;
    }
}

接下来试试往集合里不断假如这个类的实例

@Test(expected = OutOfMemoryError.class)
public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory()
  throws IOException, URISyntaxException {
    Map<Object, Object> map = System.getProperties();
    while (true) {
        map.put(new Key("key"), "value");
    }
}

在执行的时候便会发生如下的情况
在这里插入图片描述
我们注意到在01:40的时候,GC开始停止工作,操作数下降几乎4倍,内存泄漏开始发生。

如何预防呢?

解决方法很简单——使用实现了hashCode()和equals()的类。

3. 总结

内存泄漏一般很难发现,一般在程序发生大负载的情况下容易出现内存泄漏。我们可以模拟实际的工作生产环境,重现可能发生的内存泄漏情况,从而防患于未然。

  • 5
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值