記一次由Netty的ChannelPool導致內存泄漏的排查經歷

背景

接到了線上機器的報警,登上服務器,發現是Java進程掛了,看日誌報了OOM:

java.lang.OutOfMemoryError: Java heap space

問題描述

內存溢出,那當然是看dump文件了。這裏推薦大家在產線機器上都加上JVM參數-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath={path}/{to}/{dump},這樣,JVMOOM的時候,會自動做一個內存dump,相當於保存現場。 拿到dump文件,放到MAT裏面分析,以下是部分截圖: dump1

不看不知道,一看嚇一跳:有一個class(包括其reference)竟然佔據了1.6G的內存! 這個類叫NettyHttpClientService,是工程裏面用來提供異步Http服務的,其實就是對Netty做了一層包裝。這個類上線已久,之前工作得很好沒出什麼問題。最近一次上線,也沒有對這個類做什麼改動,只是新增了一處調用它的地方。

需要進一步挖掘NettyHttpClientService這個類。

問題分析

繼續分析內存佔用情況,發現其大頭是一個ConcurrentHashMap,裏面的每個node都佔用了10M-20M的空間,而這個Map裏面,已經存在了135nodedump2

看來還得去代碼裏面尋找真相。找到源頭,以下是簡化版的代碼:

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,這樣每個服務之間可以做到隔離,互不影響。 channel pool

代碼的大體邏輯很簡單,根據請求的地址,獲取對應的ChannelPool,然後從中獲取一個Channel來進行Http交互。理論上來說,程序與幾個Http服務有交互,那麼就會創建幾個ChannelPool(像abc.com/v1/api1abc.com/v1/api2abc.com/v1/api3都屬於abc.com這個服務)。之前,程序只會去請求4Http服務,最近一次上線新增了1個,所以目前總共應該是5ChannelPool,但是從MAT的分析結果來看,產線上存在了135ChannelPool

沒有其他外部作用的情況下,產線運行出現問題,最先應該懷疑新上線的功能,所以盯上了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作爲了一個channelattribute,所以只要Channel不釋放,那麼RequestEntity就不會被GC。而這個RequestEntity不僅會存放請求的信息如請求頭,目標地址等,還會存放請求返回值。該APIResponseBody大小一般是幾十K。 假設RequestEntity最終大小是50k,每個ChannelPool的大小是200,共有135ChannelPool,那麼無法被GC的內存大小爲:50k * 200 * 135 = 1.35G,這就是內存泄漏的元兇。

至於爲什麼需要將RequestEntity放到channelattribute中,是因爲當遇到返回碼類似401或者302的時候要處理重發的邏輯,需要保存請求的上下文。 而爲什麼feature-a.nf.com會解析對應這麼多個IP,懷疑因爲這個服務是做的4層負載均衡。

問題解決

既然找到了root cause,解決起來就簡單了。兩點:

  1. String類型的hostname作爲channelPoolMap的key,防止爲一個服務創建多個ChannelPool
  2. ChannelPoolrelease()方法中,釋放ChannelRequestEntity的引用(清理attribute),避免內存泄漏。

總結

在使用常駐內存的類時,需要小心是否有內存泄漏的情況。如本例ChannelPool中的Channel使用完畢是不會釋放的,所以要謹慎使用ChannelAttribute。正如ThreadLocal中引入弱引用一樣,就是因爲ThreadLocal變量通常是常駐內存的,而且由它導致的內存泄漏常常更隱蔽,所以使用弱引用可以很好地避免絕大多數潛在的OOM危機。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章