背景
最近同事做的分词器项目遇到Memory Leak问题,应用会把Jieba的C++版本作为so库提供给一个ES的分词器wrapper使用,以避免Java和C++版本Jieba在分词上的差异。上线了两天,碰到了问题,让我帮忙看下。我查了一下相关的Java文档,有几篇极有价值,摘录和翻译于此。有需要的同学自取。
什么是Java内存泄露
Java相较于其他语言、最核心的改进就是具有内存管理功能的虚拟机JVM。Java的垃圾回收器(GC)会帮助我们进行内存的分配和释放。
但是,这并不意味着Java应用中不会发生内存泄露。
在这篇文章中,我们将描述最通用类型的内存泄露,分析原因并看看怎样避免和检测泄露的发生。
1. Java内存泄露具体指什么?
内存泄露是指对象不再被应用使用,但是GC却不能将其从内存中释放。这往往是因为对象本身还被引用(referenced)。随着越来越多的内存被占用,程序最终导致严重的OOM错误(OutOfMemoryError)。
请看下面的图表
这里有两种对象类型--referenced和unreferenced. GC只能回收unreferenced的对象。而referenced的对象几遍应用不再使用,GC也不能回收。
检测泄露不容易,通常常用动态检测的方式,下面我们看看一些内存泄露的通用场景。
2. Java堆泄露
我们首先看看经典的内存泄露场景--对象被连续创建但是没有释放。
通过把堆内存的大小设置得小一些可以帮助更快的复现问题。
-Xms<size>
-Xmx<size>
参数分别指定堆的最小和最大大小。
2.1 静态的域成员持有对象引用
看下面的例子
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的生命周期中不会被释放,哪怕计算操作已经全部完成。我们调用了一个Thread.sleep(10000) 来让GC有机会进行全部的回收操作。
让我们用profiler测试并分析下JVM:
注意到第2秒的时候,循环过程运行结束,所有的东西都加载到list中了。
然后,GC被触发,接着,你可以看到,list并没有被回收,内存消耗也没有降低。从图中可以看出,分配的资源是61MB,Eden中的占用了6.5MB,Survivor空间中是2.5MB,Old Generation是21MB。
下面我们把静态的ArrayList换成局部变量,看看结果如何:
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的回收动作
在第50秒钟,可以看到大量的内存被回收。注意观察Eden部分和Old Generation部分。
如何避免?
首先,我们需要特别注意使用static,声明集合或者重型的对象为static,将把它的生命周期跟JVM本身的声明周期绑定在一起,这回使得对象本身无法被回收。
2.2. 调用String.intern()
第二种频繁引起内存泄露的场景就是String操作--具体就是String.intern()调用。
看下面的例子
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()方法将它从heap栈区放到常量池中。
intern调用将把str字符串放到JVM的常量内存池,在这个位置的对象,GC无法进行释放。
从这张图中我们可以看到15秒后内存趋于稳定。最终导致高内存使用,而无法释放。
如何避免?
interned后的字符串对象被存储在JVM的PermGen区域--如果应用需要在大字符串上进行各种操作,我们可能需要加载permanent generation的大小;
-XX:MaxPermSize=<size>
第二个解决方案就是使用Java 8– PermGen 空间被Metaspace 替代– 这就不会在使用String的Intern方法时引起OutOfMemoryError 错误。
2.3. 未关闭的流
大多数程序员都会忘记关闭打开的流。在Java 7中引入了try-with-resource语句,这在一定程度上解决了这个问题。
为什么是一定程度上? 因为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();
}
//
}
看看内存profile的时候会是什么样子
可以看到,堆内存使用一直在增高,这与打开的流没有正常关闭相关。
进一步看,可以指导,不关闭打开的流将引起两种类型的泄露--一种是底层资源的泄露(主要指文件描述符,打开链接数等),一种是内存泄露。
如何避免?
我们要么手动关闭流,要么就利用Java8的自动关闭语句。
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
// further implementation
} catch (IOException e) {
e.printStackTrace();
}
BufferedReader将在try语句的末尾自动关闭,这样就不需要在finally块中显式调用。
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 = "";
//
}
URLConnection仍旧打开着,这将导致一个内存泄露:
注意GC不能释放引用的内存,即便已经不再使用了。1分钟后,GC操作数迅速减少,堆内存继续增加,最后导致内存泄露OOM.
如何避免?
显式的关闭链接.
2.5. 对要放入HashSet或者HashMap中作为Key存在的对象添加HashCode() and equals() 方法
还有一种非常普遍的内存泄露场景就是对要放入HashSet中的对象,缺少hashCode或者equals方法
具体的,当我们将重复的对象放入集合中的时候--这将导致集合增长,而不是忽略重复对象。这也将导致内存泄露。
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");
}
}
这将导致下面的情况
注意GC在1分40秒时无法进行内存回收,
如何避免?
简单的办法就是实现hashCode()和equals()实现。
这里有个简单有用的工具,就是利用Project Lombok,他提供了很多默认的标签实现。@EqualsAndHashCode.
引用
- Understanding Memory Leaks in Java | Baeldung.
- Understand and Prevent Memory Leaks in a Java Application