背景
接到了線上機器的報警,登上服務器,發現是Java
進程掛了,看日誌報了OOM:
java.lang.OutOfMemoryError: Java heap space
問題描述
內存溢出,那當然是看dump文件了。這裏推薦大家在產線機器上都加上JVM
參數-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath={path}/{to}/{dump}
,這樣,JVM
在OOM
的時候,會自動做一個內存dump,相當於保存現場。 拿到dump文件,放到MAT裏面分析,以下是部分截圖:
不看不知道,一看嚇一跳:有一個class
(包括其reference
)竟然佔據了1.6G的內存! 這個類叫NettyHttpClientService
,是工程裏面用來提供異步Http
服務的,其實就是對Netty
做了一層包裝。這個類上線已久,之前工作得很好沒出什麼問題。最近一次上線,也沒有對這個類做什麼改動,只是新增了一處調用它的地方。
需要進一步挖掘NettyHttpClientService
這個類。
問題分析
繼續分析內存佔用情況,發現其大頭是一個ConcurrentHashMap
,裏面的每個node
都佔用了10M-20M的空間,而這個Map
裏面,已經存在了135個node
:
看來還得去代碼裏面尋找真相。找到源頭,以下是簡化版的代碼:
public class NettyHttpClientServiceImpl implements NettyHttpClientService, DisposableBean {
// channelPoolMap是一個InetSocketAddress與ChannelPool的映射關係
private AbstractChannelPoolMap<InetSocketAddress, FixedChannelPool> channelPoolMap = new AbstractChannelPoolMap<InetSocketAddress, FixedChannelPool>() {
// 構建新的大小爲200的ChannelPool
[@Override](https://my.oschina.net/u/1162528)
protected FixedChannelPool newPool(InetSocketAddress key) {
return new FixedChannelPool(bootstrap.remoteAddress(key), new NettyHttpPoolHandler(), 200);
}
};
// NettyHttpClientService的入口,參數是請求體RequestEntity,成功回調successCallback,失敗回調errorCallback
[@Override](https://my.oschina.net/u/1162528)
public Promise<SimpleResponse> get(RequestEntity bean, Consumer<SimpleResponse> successCallback, Consumer<Throwable> errorCallback) throws PlatformException {
final URL url= new URL(bean.getUrl());
// 構造遠程服務的InetSocketAddress
InetSocketAddress address = new InetSocketAddress(url.getHost(), url.getPort() == -1 ? url.getDefaultPort() : url.getPort());
Promise<SimpleResponse> promise = newPromise();
// 從ChannelPool中獲取channel
acquireChannel(address).addListener(future -> {
if (!future.isSuccess()) {
promise.tryFailure(future.cause());
return;
}
try {
// 發送request
send(bean, (Channel) future.get(), promise);
} catch (Exception e) {
promise.tryFailure(e);
}
});
// 回調
promise.addListener(future -> {
if (future.isSuccess()) {
successCallback.accept(future.get());
} else {
errorCallback.accept(future.cause());
}
});
return promise;
}
private Future<Channel> acquireChannel(InetSocketAddress address) {
Promise<Channel> promise = newPromise();
// 找到address對應的那個ChannelPool,再從ChannelPool中獲取一個Channel
channelPoolMap.get(address).acquire().addListener(future -> {
if (future.isSuccess()) {
Channel channel = (Channel) future.get();
promise.trySuccess(channel);
} else {
promise.tryFailure(future.cause());
}
});
return promise;
}
}
ChannelPool
的理念類似於線程池和數據庫連接池,爲了減少Channel
頻繁創建與銷燬的開銷。不難理解,上述設計意在爲每個遠程服務維護一個ChannelPool
,這樣每個服務之間可以做到隔離,互不影響。
代碼的大體邏輯很簡單,根據請求的地址,獲取對應的ChannelPool
,然後從中獲取一個Channel
來進行Http
交互。理論上來說,程序與幾個Http
服務有交互,那麼就會創建幾個ChannelPool
(像abc.com/v1/api1
,abc.com/v1/api2
,abc.com/v1/api3
都屬於abc.com
這個服務)。之前,程序只會去請求4個Http
服務,最近一次上線新增了1個,所以目前總共應該是5個ChannelPool
,但是從MAT
的分析結果來看,產線上存在了135個ChannelPool
!
沒有其他外部作用的情況下,產線運行出現問題,最先應該懷疑新上線的功能,所以盯上了NettyHttpClientService
新增的一處調用(feature-a.nf.com
)。 channelPoolMap
是用InetSocketAddress
作爲key
的,難道feature-a.nf.com
對應的InetSocketAddress
會存在多個值?我們嘗試多次運行InetAddress.getByName("feature-a.nf.com")
,果然每隔一段時間,域名所對應的ip就會發生變化,導致channelPoolMap
對這個服務創建了多個ChannelPool
。
爲什麼會存在這麼多個ChannelPool
的原因總算是找到了。但是,爲什麼每個Pool
的內存佔用會這麼高達到十幾二十兆呢?繼續翻看代碼以及MAT
的分析報告,找到了原因:
private void send(RequestEntity bean, Channel channel, Promise<SimpleResponse> promise) {
// 將entity放到channel的attribute中作爲上下文
channel.attr(REQUEST_ENTITY_KEY).set(bean);
HttpRequest request = createHttpRequest(bean);
QueryStringEncoder getEncoder = new QueryStringEncoder(String.valueOf(new URI(bean.getUrl())));
for (Entry<String, String> entry : bean.getHeaders().entrySet()) {
getEncoder.addParam(entry.getKey(), entry.getValue());
}
channel.writeAndFlush(request);
}
在真正發送請求的send()
方法裏,把請求體RequestEntity
作爲了一個channel
的attribute
,所以只要Channel
不釋放,那麼RequestEntity
就不會被GC
。而這個RequestEntity
不僅會存放請求的信息如請求頭,目標地址等,還會存放請求返回值。該API
的ResponseBody
大小一般是幾十K。 假設RequestEntity
最終大小是50k,每個ChannelPool
的大小是200,共有135個ChannelPool
,那麼無法被GC的內存大小爲:50k * 200 * 135 = 1.35G
,這就是內存泄漏的元兇。
至於爲什麼需要將RequestEntity
放到channel
的attribute
中,是因爲當遇到返回碼類似401或者302的時候要處理重發的邏輯,需要保存請求的上下文。 而爲什麼feature-a.nf.com
會解析對應這麼多個IP,懷疑因爲這個服務是做的4層負載均衡。
問題解決
既然找到了root cause,解決起來就簡單了。兩點:
- 用
String
類型的hostname
作爲channelPoolMap
的key,防止爲一個服務創建多個ChannelPool
。 - 在
ChannelPool
的release()
方法中,釋放Channel
對RequestEntity
的引用(清理attribute),避免內存泄漏。
總結
在使用常駐內存的類時,需要小心是否有內存泄漏的情況。如本例ChannelPool
中的Channel
使用完畢是不會釋放的,所以要謹慎使用Channel
的Attribute
。正如ThreadLocal
中引入弱引用一樣,就是因爲ThreadLocal
變量通常是常駐內存的,而且由它導致的內存泄漏常常更隱蔽,所以使用弱引用可以很好地避免絕大多數潛在的OOM
危機。