轉自 https://www.jianshu.com/p/5841df465eb9
我們來聊聊GZIPOutputStream
和 GZIPInputStream
, 如果不關閉流會引起的問題,以及GZIPStream
申請和釋放堆外內存的流程, Let's do it!
引子
在我的工程里面又一個工具類 ZipHelper
用來壓縮和解壓 String
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; /** * 用來壓縮和解壓字符串 */ public class ZipHelper { // 壓縮 public static String compress(String str) throws Exception { if (str == null || str.length() == 0) { return str; } ByteArrayOutputStream out = new ByteArrayOutputStream(); GZIPOutputStream gzip = new GZIPOutputStream(out); gzip.write(str.getBytes()); gzip.close(); return out.toString("ISO-8859-1"); } // 解壓縮 public static String uncompress(String str) throws Exception { if (str == null || str.length() == 0) { return str; } ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayInputStream in = new ByteArrayInputStream(str.getBytes("ISO-8859-1")); GZIPInputStream gunzip = new GZIPInputStream(in); byte[] buffer = new byte[1024]; int n; while ((n = gunzip.read(buffer)) >= 0) { out.write(buffer, 0, n); } return out.toString(); } }
最近服務出現了占用swap空間的問題,初步定位為內存泄漏,最后通過分析定位到是 Native 方法Java_java_util_zip_Inflater_init
一直在申請內存(關於分析方法可以查閱這篇博客內存泄露分析實戰)但是沒有釋放,很有可能就是流沒有關閉造成的,而這部分代碼最大的問題就是沒有在finally
里面去關閉流,於是乎我打算改造這部分代碼,利用 try-with-resource 語法糖,然后代碼就被修改成了這樣:
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; /** * Created by jacob. * * 用來壓縮和解壓字符串 */ public class ZipHelper { /** * 壓縮字符串 * * @param str 待壓縮的字符串 * @return 壓縮后的字符串 * @throws Exception 壓縮過程中的異常 */ public static String compress(String str) throws Exception { if (str == null || str.length() == 0) { return str; } // ByteArrayOutputStream 和 ByteArrayInputStream 是一個虛擬的流, // JDk源碼中關閉方法是空的, 所以無需關閉, 為了代碼整潔,還是放到了try-with-resource里面 try (ByteArrayOutputStream out = new ByteArrayOutputStream(); GZIPOutputStream gzip = new GZIPOutputStream(out)) { gzip.write(str.getBytes()); // gzip.finish(); return out.toString("ISO-8859-1"); } } /** * 解壓字符串 * * @param str 待解壓的字符串 * @return 解壓后的字符串 * @throws Exception 解壓過程中的異常 */ public static String uncompress(String str) throws Exception { if (str == null || str.length() == 0) { return str; } try (ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayInputStream in = new ByteArrayInputStream(str.getBytes("ISO-8859-1")); GZIPInputStream gunzip = new GZIPInputStream(in)) { byte[] buffer = new byte[1024]; int n; while ((n = gunzip.read(buffer)) >= 0) { out.write(buffer, 0, n); } return out.toString(); } } }
是不是順眼多了吶,可是這樣的代碼可以壓縮的,在解壓的時候會報錯。一開始我以為是解壓的代碼出現了問題,最后才發現是因為壓縮的時候沒有成功壓縮,導致解壓的時候無法解壓。報以下錯誤
Exception in thread "main" java.io.EOFException: Unexpected end of ZLIB input stream at java.util.zip.InflaterInputStream.fill(InflaterInputStream.java:240) at java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158) at java.util.zip.GZIPInputStream.read(GZIPInputStream.java:117) at java.io.FilterInputStream.read(FilterInputStream.java:107) at coderbean.ZipHelper.uncompress(ZipHelper.java:52) at coderbean.Main.main(Main.java:12)
好好的代碼怎么會突然壓縮失敗,后來發現的問題是在GZIPOutputStream
中,在close()
方法中會主動調用finish()
方法。
/** * Writes remaining compressed data to the output stream and closes the * underlying stream. * @exception IOException if an I/O error has occurred */ public void close() throws IOException { if (!closed) { finish(); if (usesDefaultDeflater) def.end(); out.close(); closed = true; } }
在下面的方法中才會將壓縮后的數據輸出到輸入流,由於原來的代碼會調用 close()
方法,從而間接調用了 finish()
方法。那我我們的try-with-resource
到底出了什么問題,其實問題就在於執行close()
的時間。
/** * Finishes writing compressed data to the output stream without closing * the underlying stream. Use this method when applying multiple filters * in succession to the same output stream. * 在該方法中才會將壓縮后的數據輸出到輸入流,由於原來的代碼會調用 close()方法,從而 * 間接調用了 finish() 方法。 * @exception IOException if an I/O error has occurred */ public void finish() throws IOException { if (!def.finished()) { def.finish(); while (!def.finished()) { int len = def.deflate(buf, 0, buf.length); if (def.finished() && len <= buf.length - TRAILER_SIZE) { // last deflater buffer. Fit trailer at the end writeTrailer(buf, len); len = len + TRAILER_SIZE; out.write(buf, 0, len); return; } if (len > 0) out.write(buf, 0, len); } // if we can't fit the trailer at the end of the last // deflater buffer, we write it separately byte[] trailer = new byte[TRAILER_SIZE]; writeTrailer(trailer, 0); out.write(trailer); } }
try-with-resource
執行時機和條件
try-with-resource
是在 JDK7 中新增加的語法糖(其實就是抄的C#),用來自動執行流的關閉操作,只要該類實現了AutoCloseable
的close()
方法。
package java.lang; public interface AutoCloseable { /** * @throws Exception if this resource cannot be closed */ void close() throws Exception; }
實現了這個接口之后,我們可以將會在try
代碼塊執行結束之后自動關閉流
try(/* 在此處初始化資源 */){ // do something } //在代碼塊執行結束前最后一步關閉流
由於在GZIPOutputStream
執行了finish()
方法或者close()
方法之后才會真正的將壓縮后的數據寫入流,在上文我改造的代碼中並沒有首先執行finish()
方法,而是直接在try
代碼塊執行完之后關閉了流 GZIPOutputStream
, 由於close()
方法執行在out.toString("ISO-8859-1")
之后,因此壓縮並沒有真正的被執行,然而對於ZipHelper.compress()
方法並沒有感知,而是返回了沒有壓縮成功的字符串,從而造成在解壓的時候報錯。
為什么會引起的堆外內存泄漏
通過最開始的代碼我們可以看出,在沒有發生異常的情況下,compress()
方法是可以正常的關閉流的,所以內存泄露的根源應該是在uncompress()
方法,通過跟蹤GZIPInputStream
的構造函數和close()
應該很快就能找到答案。
下面是申請堆外內存和釋放堆外內存的過程調用圖,可以對比代碼參考

由於篇幅的原因就不將JDK源碼注釋一同貼上來了,感興趣的同學可以按圖索驥,找到對應的注釋。
//java.util.zip.GZIPInputStream.java public class GZIPInputStream extends InflaterInputStream { public GZIPInputStream(InputStream in) throws IOException { this(in, 512); //調用下面的構造函數 } public GZIPInputStream(InputStream in, int size) throws IOException { super(in, new Inflater(true), size); //新建 Inflater 對象 usesDefaultInflater = true; readHeader(in); } public void close() throws IOException { if (!closed) { super.close(); //這里的父類是java.util.zip.InflaterInputStream eos = true; closed = true; } } }
//java.util.zip.Inflater.java public class Inflater { public Inflater(boolean nowrap) { zsRef = new ZStreamRef(init(nowrap)); } /** * Closes the decompressor and discards any unprocessed input. * This method should be called when the decompressor is no longer * being used, but will also be called automatically by the finalize() * method. Once this method is called, the behavior of the Inflater * object is undefined. */ public void end() { synchronized (zsRef) { long addr = zsRef.address(); zsRef.clear(); if (addr != 0) { end(addr); buf = null; } } } // 此處調用了 Native 方法 private native static long init(boolean nowrap); private native static void end(long addr); }
//java.util.zip.InflaterInputStream.java public class InflaterInputStream extends FilterInputStream { /** * Closes this input stream and releases any system resources associated * with the stream. * @exception IOException if an I/O error has occurred */ public void close() throws IOException { if (!closed) { if (usesDefaultInflater) inf.end(); in.close(); closed = true; } } }
openJDK 中 JVM 關於這個本地方法的實現
JNIEXPORT jlong JNICALL
Java_java_util_zip_Inflater_init(JNIEnv *env, jclass cls, jboolean nowrap) { //此處使用 calloc 申請了堆外內存 z_stream *strm = calloc(1, sizeof(z_stream)); if (strm == NULL) { JNU_ThrowOutOfMemoryError(env, 0); return jlong_zero; } else { const char *msg; int ret = inflateInit2(strm, nowrap ? -MAX_WBITS : MAX_WBITS); switch (ret) { case Z_OK: return ptr_to_jlong(strm); case Z_MEM_ERROR: free(strm); JNU_ThrowOutOfMemoryError(env, 0); return jlong_zero; default: msg = ((strm->msg != NULL) ? strm->msg : (ret == Z_VERSION_ERROR) ? "zlib returned Z_VERSION_ERROR: " "compile time and runtime zlib implementations differ" : (ret == Z_STREAM_ERROR) ? "inflateInit2 returned Z_STREAM_ERROR" : "unknown error initializing zlib library"); free(strm); JNU_ThrowInternalError(env, msg); return jlong_zero; } } } JNIEXPORT void JNICALL Java_java_util_zip_Inflater_end(JNIEnv *env, jclass cls, jlong addr) { if (inflateEnd(jlong_to_ptr(addr)) == Z_STREAM_ERROR) { JNU_ThrowInternalError(env, 0); } else { free(jlong_to_ptr(addr)); //此處釋放堆外內存 } }
參考
作者:黃小豆Jacob
鏈接:https://www.jianshu.com/p/5841df465eb9
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。