探讨原因
我工作两年多一点点,Java基础一般,也没有太深入学习过JVM的原理。幸运的是在工作的第二年找到了一份目前比较流行的大数据工作,也算是借着工作的机会学习,算是一个不错的选择。在工作中我主要使用Hadoop Yarn进行图像处理,因此对于服务器资源的消耗自然要高于对日志的分析(笔者对Hadoop的入门就是从日志分析开始学习的)。但由于工作经验不足,过分依赖JVM对内存的回收,导致这段时间反复的遇到java.lang.OutOfMemoryError: Java heap space的错误。起初我只想到调整map和reduce的JVM参数和物理内存,但毕竟服务器的资源是有限的,不可能一直使用这种蠢方法来解决内存溢出的问题。于是我开始上网查找有关Java内存方面的资料,临时抱佛脚。
我对JVM有了一个初步的印象。下面我简单介绍一下出问题的代码。
代码示例
代码主要目标是对MongoDB中处理好的图片导出成为zip包存到HDFS中,这里只使用了一个map生成zip包。
(注:这个功能可能使用Hadoop来处理非常多余,但由于我们业务需要处理一组图片从存储-处理-导出整理流程,因此使用Hadoop进行zip导出是有必要的,这里不过多探讨业务,只是举个例子。)
main函数主要进行简单的配置和作业提交到Hadoop集群中;map用来获取MongoDB中的图片输出到zip包中。map代码如下(已简化):
public class MongoZipMapper extends Mapper<Object, BSONObject, Text, Text> {
private FileSystem fileSystem;
private ArchiveOutputStream out;
public void setup(Context context) {
try {
Configuration conf = context.getConfiguration();
fileSystem = new Path(conf.get(FileOutputFormat.OUTDIR)).getFileSystem(conf);
String archiveName = conf.get(Constants.ARCHIVENAME);
Path outZip = new Path(archiveName + ".zip");
//指定压缩文件路径
FSDataOutputStream outputStream = fileSystem.create(outZip);
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream);
out = new ArchiveStreamFactory().createArchiveOutputStream(ArchiveStreamFactory.ZIP, bufferedOutputStream);
} catch(Exception e) {
e.printStackTrace();
}
}
public void map(Object key, BSONObject value, Context context) throws IOException, InterruptedException {
String fileName = getFileName(value); // 获取zip中条目的文件名
byte[] content = (byte[])value.get("binary"); // 读取MongoDB中的文件内容字段
ZipArchiveEntry zipArchiveEntry = new ZipArchiveEntry(fileName);
zipArchiveEntry.setSize(content.length);
out.putArchiveEntry(zipArchiveEntry);
out.write(content);
out.flush();
out.closeArchiveEntry();
context.write(new Text(fileName), new Text("OK"));
}
private String getFileName(BSONObject value) {
// ...
}
public void cleanup(Context context) {
try {
if(out!=null) {
out.finish();
out.close();
}
if(fileSystem!=null) {
fileSystem.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
乍一看上面的代码,只是导出数据,貌似没有问题。只有一个map进行zip包的输出,在准备阶段(setup)初始化所需对象,在清理阶段(cleanup)对各种资源进行清理。当我导出少于16M(存入MongoDB中的数据限制大小)的文件时,这段程序并没有出现过问题。但由于我们处理的是图片,有些图片要求分辨率很高,因此很多图片都是大于16M的,通常在40M左右。我测试了导出500张40M左右的数据,这个程序就出现了OOM的问题。后来在仔细看代码,在map方法中增加了几个语句,如下:
public void map(Object key, BSONObject value, Context context) throws IOException, InterruptedException {
String fileName = getFileName(value); // 获取zip中条目的文件名
byte[] content = (byte[])value.get("binary"); // 读取MongoDB中的文件内容字段
ZipArchiveEntry zipArchiveEntry = new ZipArchiveEntry(fileName);
zipArchiveEntry.setSize(content.length);
out.putArchiveEntry(zipArchiveEntry);
out.write(content);
out.flush();
out.closeArchiveEntry();
/*-----start-----*/
value.put("binary", new byte[0]); // 清空value中的"binary"
value = null; // 清空value
content = null; // 清空content
zipArchiveEntry = null; // 清空zipArchiveEntry
/*------end------*/
context.write(new Text(fileName), new Text("OK"));
}
第二段代码比第一段代码仅仅多了注释的四句话,全部都是置空的语句。再进行上次的500张图片导出zip包的测试,没有再发生OOM的报错。同时,我将分配的JVM参数和物理内存分别调整比原来小一半,仍然没有发生报错。
简单分析
在上文的代码中:
value.put("binary", new byte[0]);
value = null;
第一句是将value中的binary字段赋值为空byte数组,第二句将value置空。如果只对value值置空,该代码仍然会报OOM的错误,必须对value中的binary也置空才会起作用。这里置空的意思实际是告诉JVM这两个“强引用”变成“弱引用”,可以进行内存的收回。由于我没有对JVM中的一些术语和资料进行学习,这里可能用词不当。不过通过上述代码的实践得出结论:对“大对象”的及时置空是很有必要的,特别是处理图片、视频等单个数据较大时更是必要的。何况是在Hadoop中处理单个数据较大的情况,如果内存释放的及时,可以节省很多资源,明显提高程序的运行效率。好工具可以让我们写的代码运行更加高效稳定,而好的代码是从根本上增加处理效率、杜绝异常的原因。
总结
引用文中第一篇资料的话:数组和对象本身在堆中分配,即使程序运行到使用 new 产生数组或者对象的语句所在的代码块之外,数组和对象本身占据的内存不会被释放,数组和对象在没有引用变量指向它的时候,才变为垃圾,不能在被使用,但仍然占据内存空间不放,在随后的一个不确定的时间被垃圾回收器收走(释放掉)。这也是 Java 比较占内存的原因。
因此在开发Java程序时,如果能够像C、C++程序员那样有意识的释放内存,不依赖JVM,可以让写出的代码更高效稳定,也减少未来出现OOM问题的可能。
文中可能有不当之处,望指正。