这是Glassfish社区的一个修正,但是并没有说明文档,这里稍作分析。
修正:[2.3.x]+ fix issue #1712
问题传送门:
https://java.net/jira/browse/GLASSFISH-21211
https://java.net/jira/browse/GRIZZLY-1789
问题简介
在服务器发布应用程序提供服务,当服务运行一段时间后有可能出现FD泄露。
出现FD泄露后,服务器的状态如下
CPU使用率:明显高于平时
TCP连接:存在等待关闭的状态(CLOSE_WAIT)
问题再现
1、进入debug模式,在AbstractNIOAsyncQueueWriter#write中打一个断点(L:230),然后通过客户端发送请求“SERVER_IP:8080/xxx/test?length=12288”,这个请求会返回12288个字符“a”。
2、服务端运行到断点处,此时
messageSize=12388
bytesToReserver=12388
pendingBytes=12388
3、第一次执行F8,重新运行到断点,此时
messageSize=0
bytesToReserver=1
pendingBytes=101
4、中断SelectorRunner的运行,在SelectorRunner#run中打一个断点(L:278),第二次执行F8,重新运行到AbstractNIOAsyncQueueWriter#write中的断点,此时
messageSize =0
bytesToReserve= 1
pendingBytes =2
5、停止debug模式,此时
客户端连接状态: FIN_WAIT_2(客户端已经断开连接)
服务端连接状态: CLOSE_WAIT(服务端没有断开连接,FD泄露)
CPU使用率: 明显高于平时
问题分析
1、客户端向服务器发送请求
客户端发送请求“SERVER_IP:8080/xxx/test?length=12288”后,服务端通过TCPNIOTransportFilter中handleWrite方法获得TCPNIOAsyncQueueWriter,并调用TCPNIOAsyncQueueWriter的write方法。TCPNIOAsyncQueueWriter第二次调用write方法后,第二个数据长度为0的AsyncWriteQueueRecord没有被立即处理,所以TCPNIOAsyncQueueWriter还会调用第三次write方法。
第一次调用write方法,封装一个数据长度为12388的AsyncWriteQueueRecord,AsyncWriteQueueRecord中initialMessageSize属性值为12388。
第二次调用write方法,封装一个数据长度为0的AsyncWriteQueueRecord,AsyncWriteQueueRecord中initialMessageSize属性值为0。
第三次调用write方法,封装一个数据长度为0的AsyncWriteQueueRecord,AsyncWriteQueueRecord中initialMessageSize属性值为0。
2、调用三次write方法向写入三个AsyncWriteQueueRecord
①调用三次write方法
第一次执行F8后到达AbstractNIOAsyncQueueWriter#write中的断点,此时第一个AsyncWriteQueueRecord已经写入channel,准备写入第二个数据为0的AsyncWriteQueueRecord,这个时候中断SelectorRunner的运行。
第二次执行F8,由于SelectorRunner被中断,第二个AsyncWriteQueueRecord没有被处理,还在AsyncWriteQueueRecord队列中。此时TCPNIOAsyncQueueWriter会再次调用write方法,封装一个数据长度为0的AsyncWriteQueueRecord,所以第二次执行F8后程序再次执行到AbstractNIOAsyncQueueWriter#write中的断点。
继续运行,因为此时bytesToReserve值与pendingBytes值不相等,所以第三个数据长度为0的AsyncWriteQueueRecord也会被加入AsyncWriteQueueRecord队列,此时AsyncWriteQueueRecord队列中有两个数据长度为0的AsyncWriteQueueRecord。
②spaceInBytes增加
TCPNIOAsyncQueueWriter的write方法中,将两个数据长度为0的AsyncWriteQueueRecord添加到AsyncWriteQueueRecord队列时,spaceInBytes值增加2。
③调用processAsync方法
SelectorRunner调用TCPNIOAsyncQueueWriter的processAsync方法,将AsyncWriteQueueRecord队列中的两个AsyncWriteQueueRecord写入channel。processAsync方法会调用aggregate方法,aggregate方法能够返回队列中的AsyncWriteQueueRecord,并将返回的AsyncWriteQueueRecord从队列中删除。如果队列中有多个AsyncWriteQueueRecord,则将多个AsyncWriteQueueRecord组合为一个CompositeQueueRecord。
CompositeQueueRecord中有一个队列queue,queue中包含一组AsyncWriteQueueRecord,size属性表示所有AsyncWriteQueueRecord的数据长度总和,isEmptyRecord方法会直接返回false。
因为此时AsyncWriteQueueRecord队列中有两个数据长度为0的AsyncWriteQueueRecord,所以aggregate方法会返回包含两个AsyncWriteQueueRecord的CompositeQueueRecord,且此CompositeQueueRecord中size值为0,remaining方法返回值为0,isFinished方法返回true,isEmptyRecord方法返回false。此时TCPNIOAsyncQueueWriter的processAsync方法中变量如下
writen=0
done=true
bytesToRelease=0
bytesReleased=0
所以isComplete值为默认值false,且result为AsyncResult.EXPECTING_MORE。
3、将任务添加到延迟任务队列
TCPNIOAsyncQueueWriter的processAsync方法执行结束后将result返回到ProcessorExecutor,因为result值为AsyncResult.EXPECTING_MORE,所以ProcessorExecutor最终会调用DefaultSelectorHandler的enque方法,这个方法会创建一个DefaultSelectorHandler$RunnableTask对象,并通过ArrayDeque的offer方法将此对象添加到SelectorRunner中的延迟任务队列。
4、FD泄露
SelectorRunner中会循环调用doSelect方法,然后doSelect方法会调用SelectorHandler的preSelect方法,preSelect方法会处理延迟任务。preSelect方法会调用DefaultSelectorHandler的processPendingTaskQueue方法,在processPendingTaskQueue方法中获取延迟任务。最后会调用TCPNIOAsyncQueueWriter的processAsync方法,通过NIOConnection的getAsyncWriteQueue获取AsyncWriteQueueRecord队列。但是这个AsyncWriteQueueRecord队列中并没有元素,所以done和isComplete均为默认值。
所以result结果为AsyncResult.EXPECTING_MORE,DefaultSelectorHandler会重新创建一个延迟任务并添加到SelectorRunner的延迟任务队列。所以SelectorRunner的延迟任务队列中始终会有延迟任务存在,channel一直被使用,无法释放,从而导致FD泄露。
5、CPU使用率升高
doSelect方法调用SelectorHandler的preSelect方法后会继续调用DefaultSelectorHandler的select方法。在select方法中存在如下代码
final booleanhasPostponedTasks = !selectorRunner.getPostponedTasks().isEmpty();
if(!hasPostponedTasks) {
hasSelectedKeys =selector.select(selectTimeout) > 0;
} else {
hasSelectedKeys = selector.selectNow() >0;
}
其中selectTimeout=3000,表示阻塞等待30秒。
首先获取延迟任务队列,调用isEmpty方法判断队列中是否存在延迟任务。因为延迟任务队列中始终存在一个延迟任务,所以调用WindowsSelectorImpl的selectNow方法。这个方法不会阻塞线程等待,不论有没有获取感兴趣事件,直接返回。所以SelectorRunner线程中没有阻塞,一直在循环执行,从而导致CPU使用率升高。
问题修正
这次问题的发生是因为执行TCPNIOAsyncQueueWriter的processAsync方法时,获得了错误的written值,使得isComplete值为false,result结果为AsyncResult.EXPECTING_MORE。最终导致每处理一个SelectorRunner延迟任务队列的任务时,会有一个新的延迟任务产生,因为延迟任务队列中一直存在一个任务,所以SelectorRunner线程无阻塞持续运行,导致FD泄露且CPU使用率升高。
获取错误written值的原因是执行以下代码时,如果queueRecord属于org.glassfish.grizzly.nio.transport.TCPNIOAsyncQueueWriter$CompositeQueueRecord类型,且CompositeQueueRecord中所有的AsyncWriteQueueRecord的数据长度为0,那么调用remaining方法时获得值为0,所以不会调用write0方法,written值为0。
final intwritten = queueRecord.remaining() > 0 ? (int) write0(nioConnection,queueRecord) : 0;
然后,通过以下代码获取bytesToRelease的值,又因为CompositeQueueRecord的isEmptyRecord方法返回值恒为false,所以bytesToRelease值为0。但是TCPNIOAsyncQueueWriter的write方法中,将数据长度为0的AsyncWriteQueueRecord添加到AsyncWriteQueueRecord队列时, 增加了spaceInBytes的值。如果bytesToRelease值为0,那么spaceInBytes的值不会减少。
final booleanisEmptyRecord = queueRecord.isEmptyRecord();
final intbytesToRelease = !isEmptyRecord ? written : (isFinished ?EMPTY_RECORD_SPACE_VALUE : 0);
通过以上分析可知,本次的问题是当queueRecord对象的类型不同时,written所代表的含义不同,程序没有正确获取不同情况下的written值。
queueRecord属于org.glassfish.grizzly.asyncqueue.AsyncWriteQueueRecord类型时,written表示写入channel数据的长度。
queueRecord属于org.glassfish.grizzly.nio.transport.TCPNIOAsyncQueueWriter$CompositeQueueRecord类型时,written表示写入channel数据的长度加上数据长度为0的AsyncWriteQueueRecord个数之和(下文中使用extraBytesToRelease代替)。所以当CompositeQueueRecord中所有的AsyncWriteQueueRecord的数据长度均为0时,written值出错。
统一written含义,使用变量written表示写入channel数据的长度。因为bytesToRelease表示需要spaceInBytes需要减少的大小,所以
bytesToRelease=written+extraBytesToRelease
为了使write0方法可以同时返回written和bytesToRelease,需要对它们进行封装,使用时直接通过封装后的对象中获取。同时这样做也会增加代码的可读性,以及保证使用时可以正确获取这两个变量。