netty analyst(1)-one netty bug for memory leak

问题描述

使用FUZZ测试的方法,启动N线程,不断执行如下步骤:创建连接->发送几个消息->释放连接。持续N小时后停止,观察到内存存在增长不回收现象,执行heap dump寻找内存泄露的地方,反复比较查找后可定位在SelectedSelectionKeySet:持有大量select key。

issue_3

排除以下几种情况:

  • GC时机未到:手工触发GC之后,内存仍然持有不释放;
  • 抓取heapdump的时机是刚好处理select keys的过程中,所以导致仍然有很多待处理的情况:排除这种情况,因为select key中间穿插了一些null, 而处理代码中,遇到null会认为结束,所以这意味着null之后的N多select key以后也永远无法处理。

final SelectionKey k = selectedKeys[i];
            if (k == null) {
                break;
            }

问题原因

NIO的基本处理步骤包括两步:

  • selector寻出所有就绪的通道事件(例如读、写等)
  • 按顺序处理所有就绪的通道事件
@Override
protected void run() {
for (;;) {

……
select(oldWakenUp); //select出所有就绪事件

……

cancelledKeys = 0;
needsToSelectAgain = false;
……

processSelectedKeys(); //处理

}

在处理所有就绪事件时,有一个优化:select again:

在“选出”和处理“完”所有selectedKeys之间的时间段内,如果cancel的keys超过了256(测试中不断释放连接触发),那么直接放弃之后仍需要处理的select keys, 直接重新做select和具体的处理。

void cancel(SelectionKey key) {
        key.cancel();
        cancelledKeys ++;
        if (cancelledKeys >= CLEANUP_INTERVAL) {  //cancel keys > 256
            cancelledKeys = 0;
            needsToSelectAgain = true;  //enable select again.
        }
}

    private void processSelectedKeysOptimized(SelectionKey[] selectedKeys) {
        for (int i = 0;; i ++) {
            final SelectionKey k = selectedKeys[i];
            if (k == null) {
                break;
            }
            selectedKeys[i] = null;

            final Object a = k.attachment();
 	      ……
            processSelectedKey(k, (AbstractNioChannel) a);

            if (needsToSelectAgain) {
                for (;;) {
                    if (selectedKeys[i] == null) {
                        break;
                    }
                    selectedKeys[i] = null;
                    i++;
                }

                selectAgain(); 

                selectedKeys = this.selectedKeys.flip();
                i = -1;
            }
        }
    }

此时面临的问题是:

Select Again之前那些尚未处理的select key如何处理,因为后面有select again过程,所以这部分未处理的select key无需保留,假设保留,存在的后果是:

当select again时,SelectedSelectionKeySet(包含keysA和keysB)此时已经使用另外一个keys,假设之前使用的是keysA,则使用keysB.使用keysB添加所有的select key并且处理完之后,下次添加则使用keysA. 此时如果添加的新的key的size<上轮keysA之前已消费的容量,则keysA中存在3段内容:新的key, null(或许N个),老的key。此时再重新消费keysA时,老的key仍然存在,因为遇到null就跳出了。

    @Override
    public boolean add(SelectionKey o) {
        if (o == null) {
            return false;
        }

        if (isA) {
            int size = keysASize;
            keysA[size ++] = o;
            keysASize = size;
            if (size == keysA.length) {
                doubleCapacityA();
            }
        } else {
            int size = keysBSize;
            keysB[size ++] = o;
            keysBSize = size;
            if (size == keysB.length) {
                doubleCapacityB();
            }
        }

        return true;
    }

因此必须要将select again之前未处理的selected key全部移除。而原有的代码因为一个简单的错误,并没有办法删除所有的keys.

            if (k == null) {
                break;
            }
            selectedKeys[i] = null;  //消费一个,置Null一个
 	      ……

            if (needsToSelectAgain) {
                for (;;) {
                    if (selectedKeys[i] == null) {  //此时肯定是null,所以直接跳出,未删除后面的keys.
                        break;
                    }
                    selectedKeys[i] = null;
                    i++;
                }

 

解决方案

略微修改即可:

            if (needsToSelectAgain) {
                for (;;) {
                    i++;  //把i++移动到此
                    if (selectedKeys[i] == null)
                        break;
                    }
                    selectedKeys[i] = null;
                }

造成的影响是: 内存有一部分持有不释放,但是在正常的应用使用中,一般不会出现,因为要求断连的速度特别大,且超过了业务本身的处理速度。超过的越多,不释放的越多,但是正常情况下不会超过,即使超过也不会超过太多。