问题描述
使用FUZZ测试的方法,启动N线程,不断执行如下步骤:创建连接->发送几个消息->释放连接。持续N小时后停止,观察到内存存在增长不回收现象,执行heap dump寻找内存泄露的地方,反复比较查找后可定位在SelectedSelectionKeySet:持有大量select key。
排除以下几种情况:
- 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; }
造成的影响是: 内存有一部分持有不释放,但是在正常的应用使用中,一般不会出现,因为要求断连的速度特别大,且超过了业务本身的处理速度。超过的越多,不释放的越多,但是正常情况下不会超过,即使超过也不会超过太多。