今天终于解决一个困扰很久的问题,将解决这个问题的关键过程记录一下。 这个问题描述如下:程序使用线程池将文件上传到第三方的ftp。但是程序运行一段时间后,线程池的线程全部变得不可用,导致文件无法上传到第三方的ftp。通过jstack命令导出所有的线程,发现所有出问题的线程的状态如下,所有的线程都被卡死在读取socket的代码上:
"pool-4-thread-33" #166 prio=5 os_prio=0 tid=0x00007fbf20007800 nid=0x106f runnable [0x00007fbf11bd9000]
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.read(BufferedReader.java:182)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
at org.apache.commons.net.io.CRLFLineReader.readLine(CRLFLineReader.java:58)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:314)
at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:294)
at org.apache.commons.net.ftp.FTP.getReply(FTP.java:692)
at org.apache.commons.net.ftp.FTPClient.completePendingCommand(FTPClient.java:1813)
at org.apache.commons.net.ftp.FTPClient._storeFile(FTPClient.java:672)
at org.apache.commons.net.ftp.FTPClient.__storeFile(FTPClient.java:624)
at org.apache.commons.net.ftp.FTPClient.storeFile(FTPClient.java:1976)
at cn.com.jtang.recordlogfetch.core.ftp.FtpHandler.uploadFile(FtpHandler.java:50)
at cn.com.jtang.recordlogfetch.core.ftp.FtpService$1.call(FtpService.java:40)
at cn.com.jtang.recordlogfetch.core.ftp.FtpService$1.call(FtpService.java:35)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
Locked ownable synchronizers:
- <0x00000006c7c10440> (a java.util.concurrent.ThreadPoolExecutor$Worker)
本系统使用ftp的jar包是:
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.3</version>
</dependency>
初步网络搜索解决问题 - 失败
初步判断是超时时间设置的问题,通过网络搜索,根据别人的博客关于网络超时的设置,但是没有从根本上解决这个问题,偶尔还会出现。
JDK的bug
在搜索过程,发现老JDK有类似的bug: SocketInputStream.socketRead0 can hang even with soTimeout set ,查询JDK版本,大于jdk8u131,排除掉
理解Ftp的文件上传原理:
没有快速解决的方法,只能从原理上解决问题。 根据commons-net的ftp的官方文档和源码理解ftp工作原理,简单整理如下: ftp client和ftp server进行文件上传会建立2条连接(控制连接和数据传输连接),而不是我之前想当然认为的一条连接。原理如下:
- a. Ftp client 通过FTPClient的connect()方法先和 ftp server 进行连接,Ftp client 主动连接到ftp server的21端口,这条连接称为控制连接,用于ftp的命令的交互。
- b. Ftp client 发送账号和密码完成登录操作
- c. Ftp client 通过FTPClient的storeFile() 实现文件的上传。在此方法中,ftp client 会先和ftp server建立数据传输连接。ftp工作模式有两种:主动模式和被动模式(两种模式见这里)。我们使用的是主动模式:ftp client会启动一个ServerSocket等待ftp server来连接,成功建立连接后,双方就可以使用这个连接进行文件的传输。完成传输后,此连接被关闭。然后调用 completePendingCommand()方法,此方法会尝试从控制连接中获取ftp的回复码
FTPClient的storeFile()关键代码如下:
FtpClient.java
protected boolean _storeFile(String command, String remote, InputStream local)
throws IOException
{
// 通过主动模式打开一个数据传输连接
Socket socket = _openDataConnection_(command, remote);
// 文件传输的代码略
…
// 传输成功后关闭连接
output.close(); // ensure the file is fully written
socket.close(); // done writing the file
// Get the transfer response
boolean ok = completePendingCommand();
return ok;
}
结合原理和线程栈,我们判断线程在获取回复码的过程中,线程被阻塞了,排除JDK的问题,问题还是定位到超时时间上
"pool-4-thread-33" #166 prio=5 os_prio=0 tid=0x00007fbf20007800 nid=0x106f runnable [0x00007fbf11bd9000]
java.lang.Thread.State: RUNNABLE
// 阻塞在SocketInputStream.socketRead处
at java.net.SocketInputStream.socketRead0(Native Method)
at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
at java.net.SocketInputStream.read(SocketInputStream.java:171)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.read(BufferedReader.java:182)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
// 线程被阻塞了
at org.apache.commons.net.io.CRLFLineReader.readLine(CRLFLineReader.java:58)
- locked <0x00000006c7c21020> (a java.io.InputStreamReader)
at org.apache.commons.net.ftp.FTP.__getReply(FTP.java:314)
设置合理的超时时间:
上面分析后,发现问题还是定位到超时时间上。 org.apache.commons.net.ftp.FTPClient设置超时的方法有很多:
- ○ setSoTimeout()
- ○ setConnectTimeout()
- ○ setDefaultTimeout()
- ○setControlKeepAliveReplyTimeout()
- ○ setDataTimeout()
那么这么多超时设置,究竟哪个启作用呢?首先当然是读方法说明,读完了,还是无法理解如何使用。
由于所有的连接都使用jdk的java.net.Socket,我们先查看一下此类和超时时间有关的方法:
- ○ connect(SocketAddress endpoint, int timeout) :此方法int参数用于配置连接建立超时的时间
- ○ void setSoTimeout(int timeout) :此方法设置从Socket中读取数据的超时时间
通过阅读源码,我们得出如果结论: 数据传输连接超时时间设置:由于ftp使用主动模式,所以这个连接不需要设置连接建立超时时间,只需要设置数据读取超时时间,使用的值为__dataTimeout,此值使用setDataTimeout()方法设置 创建数据传输连接的代码:
protected Socket _openDataConnection_(String command, String arg)
throws IOException
{
// 省略不相关的代码
..
Socket socket;
// 创建 ServerSocket
ServerSocket server = _serverSocketFactory_.createServerSocket(getActivePort(), 1, getHostAddress());
try {
if (__dataTimeout >= 0) {
server.setSoTimeout(__dataTimeout);
}
socket = server.accept();
// 这里的soTimeout的值由 __dataTimeout 决定
if (__dataTimeout >= 0) {
socket.setSoTimeout(__dataTimeout);
}
// 省略不相关的代码
..
} finally {
server.close();
}
}
// 省略不相关的代码
..
return socket;
}
// __dataTimeout 由setDataTimeout()决定
public void setDataTimeout(int timeout)
{
__dataTimeout = timeout;
}
控制连接超时时间设置:查看控制连接建立的代码,可知连接建立超时时间为connectTimeout决定,此值通过setConnectTimeout方法设置; 数据读取超时时间由_timeout_决定,此值通过setDefaultTimeout()方法设置
org.apache.commons.net. SocketClient 源码:
public void connect(InetAddress host, int port)
throws SocketException, IOException
{
// 连接超时使用的 connectTimeout
_socket_.connect(new InetSocketAddress(host, port), connectTimeout);
_connectAction_();
}
protected void _connectAction_() throws IOException
{
// 数据读取超时时间,由_timeout_决定
_socket_.setSoTimeout(_timeout_);
_input_ = _socket_.getInputStream();
_output_ = _socket_.getOutputStream();
}
// connectTimeout由setConnectTimeout方法设置
public void setConnectTimeout(int connectTimeout) {
this.connectTimeout = connectTimeout;
}
// _timeout_由setDefaultTimeout方法设置
public void setDefaultTimeout(int timeout)
{
_timeout_ = timeout;
}
超时设置总结如下:
// 设置控制连接连接超时的时间
○ setConnectTimeout()
// 设置控制连接数据读取超时的时间
○ setDefaultTimeout()
// 设置数据传输连接的数据读取超时的时间
○ setDataTimeout()
// 用于设置控制连接的心跳频率:
○ setControlKeepAliveReplyTimeout()
// 控制连接建立后,重新设置数据读取超时时间,一般我们不需要设置,给jar包内部使用
○ setSoTimeout()
解决方法: 设置合理的超时时间:解决以上问题
// 设置超时时间
ftp.setConnectTimeout(10 * 1000);
ftp.setDefaultTimeout(15 * 1000);
ftp.setDataTimeout(15 * 1000);
ftp.connect(ftpUrl, ftpPort);
ftp.login(username, password);
int reply = ftp.getReplyCode();
if (!FTPReply.isPositiveCompletion(reply)) {
log.error("[ftp]connection error");
ftp.disconnect();
return isSuccess;
}
log.info("ftp服务连接成功:" + storeFilename);
isSuccess = ftp.storeFile(storeFilename, sourceIn);
sourceIn.close();
解决问题的教训
搜索可以帮助我们解决一些问题,但是对一些复杂的问题,这类问题网上资料又少,即使有文章的作者语焉不详,甚至文章本身就有理解上的错误,再加上你不了解问题的原理,那你就没法解决这个问题。 如果我们要解决类似的问题,大招是通过官网+读源码方式理解背后的原理,那么一些难搞的问题基本能解决。 对于类似上文的超时时间,第三方连接相关的类在java.net.Socket的基础上包了一层,如果我们要灵活通过第三方类设置超时时间,首先我们需要理解java.net.Socket里的哪些地方需要设置超时时间,然后再跟踪源码哪些地方调用Socket的设置超时的方法,然后再进行回溯,则一定能找到设置超时时间的方法。这样即使第三方有很多设置超时的方法,也不会难住我们。