首先请确保已经学习了Java NIO的基础知识,包括Buffer,Channel文件通道和Socket通道,Selector。关于NIO比起I/O的好处,区别等这里就不说了。具体可以参考后面的参考链接等。
这篇博客主要以一个使用NIO传输文件的例子来学习NIO中网络的基本操作
传统的监控socket方式存在的问题
传统的监控多个socket的Java解决方案是为每个socket创建一个线程并使得线程可以在read调用时阻塞,直到数据可用。实际上这种方案有很大的弊端就是当建立很多链接需要创建很多线程,这些线程的创建管理要耗费很大的资源,也许在这个连接上只发送少量的数据,但是CPU切换也要耗费好多资源。于是为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是有一些使用场景仍然是无法解决的,如果建立的都是长连接,事实上它们并不是每时每刻都在传输数据,这时不可能创建那么多的连接。
NIO传输文件例子
下面使用NIO做了一个向服务器端上传文件的例子
服务器端代码
public class Server {
private ByteBuffer buffer = ByteBuffer.allocate(1024*1024);
//使用Map保存每个连接,当OP_READ就绪时,根据key找到对应的文件对其进行写入。若将其封装成一个类,作为值保存,可以再上传过程中显示进度等等
Map fileMap = new HashMap();
public static void main(String[] args) throws IOException{
Server server = new Server();
server.startServer();
}
public void startServer() throws IOException{
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(8888));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器已开启...");
while (true) {
int num = selector.select();
if (num == 0) continue;
Iterator it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
if (key.isAcceptable()) {
ServerSocketChannel serverChannel1 = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverChannel1.accept();
if (socketChannel == null) continue;
socketChannel.configureBlocking(false);
SelectionKey key1 = socketChannel.register(selector, SelectionKey.OP_READ);
InetSocketAddress remoteAddress = (InetSocketAddress)socketChannel.getRemoteAddress();
File file = new File(remoteAddress.getHostName() + "_" + remoteAddress.getPort() + ".txt");
FileChannel fileChannel = new FileOutputStream(file).getChannel();
fileMap.put(key1, fileChannel);
System.out.println(socketChannel.getRemoteAddress() + "连接成功...");
writeToClient(socketChannel);
}
else if (key.isReadable()){
readData(key);
}
// NIO的特点只会累加,已选择的键的集合不会删除,ready集合会被清空
// 只是临时删除已选择键集合,当该键代表的通道上再次有感兴趣的集合准备好之后,又会被select函数选中
it.remove();
}
}
}
private void writeToClient(SocketChannel socketChannel) throws IOException {
buffer.clear();
buffer.put((socketChannel.getRemoteAddress() + "连接成功").getBytes());
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
}
private void readData(SelectionKey key) throws IOException {
FileChannel fileChannel = fileMap.get(key);
buffer.clear();
SocketChannel socketChannel = (SocketChannel) key.channel();
int num = 0;
try {
while ((num = socketChannel.read(buffer)) > 0) {
buffer.flip();
// 写入文件
fileChannel.write(buffer);
buffer.clear();
}
} catch (IOException e) {
key.cancel();
e.printStackTrace();
return;
}
// 调用close为-1 到达末尾
if (num == -1) {
fileChannel.close();
System.out.println("上传完毕");
buffer.put((socketChannel.getRemoteAddress() + "上传成功").getBytes());
buffer.clear();
socketChannel.write(buffer);
key.cancel();
}
}
}
客户端模拟三个客户端同时向服务器发送文件
public class Client {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
// 模拟三个发端
new Thread() {
public void run() {
try {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.socket().connect(new InetSocketAddress("127.0.0.1", 8888));
File file = new File("E:\\" + 11 + ".txt");
FileChannel fileChannel = new FileInputStream(file).getChannel();
ByteBuffer buffer = ByteBuffer.allocate(100);
socketChannel.read(buffer);
buffer.flip();
System.out.println(new String(buffer.array(), 0, buffer.limit(), Charset.forName("utf-8")));
buffer.clear();
int num = 0;
while ((num=fileChannel.read(buffer)) > 0) {
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
}
if (num == -1) {
fileChannel.close();
socketChannel.shutdownOutput();
}
// 接受服务器
socketChannel.read(buffer);
buffer.flip();
System.out.println(new String(buffer.array(), 0, buffer.limit(), Charset.forName("utf-8")));
buffer.clear();
socketChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
};
}.start();
}
Thread.yield();
}
}
可见这里我们仅仅使用了一个线程就管理了三个连接,相比以前使用阻塞的Socket要在accept函数返回后开启线程来管理这个连接,而使用NIO我们在accept返回后,仅仅将其注册到选择器上,读操作在下次检测到有可读的键的集合时就会去处理。
NIO+线程池改进
当然使用单线程并不都是一个好主意,使用单线程的好处在于任何情况下都只有一个线程能够运行。通过消除在线程之间进行上下文切换带来的额外开销,总吞吐量可以得到提高。然而在其他情况下,尤其是对于多核CPU来说,使用单线程是对CPU的浪费,更好的策略是使用一个线程来管理选择器,监控通道的就绪状态,对于数据的处理可以使用线程池处理。
这样上面的例子改为
public class ThreadPoolServer extends Server{
private ExecutorService exec = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws IOException {
ThreadPoolServer server = new ThreadPoolServer();
server.startServer();
}
@Override
protected void readData(final SelectionKey key) throws IOException {
// 移除掉这个key的可读事件,已经在线程池里面处理
key.interestOps(key.interestOps() & (~SelectionKey.OP_READ));
exec.execute(new Runnable() {
@Override
public void run() {
ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024);
FileChannel fileChannel = fileMap.get(key);
buffer.clear();
SocketChannel socketChannel = (SocketChannel) key.channel();
int num = 0;
try {
while ((num = socketChannel.read(buffer)) > 0) {
buffer.flip();
// 写入文件
fileChannel.write(buffer);
buffer.clear();
}
} catch (IOException e) {
key.cancel();
e.printStackTrace();
return;
}
// 调用close为-1 到达末尾
if (num == -1) {
try {
fileChannel.close();
System.out.println("上传完毕");
buffer.put((socketChannel.getRemoteAddress() + "上传成功").getBytes());
buffer.clear();
socketChannel.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
// 只有调用cancel才会真正从已选择的键的集合里面移除,否则下次select的时候又能得到
// 一端close掉了,其对端仍然是可读的,读取得到EOF,返回-1
key.cancel();
return;
}
// Channel的read方法可能返回0,返回0并不一定代表读取完了。
// 工作线程结束对通道的读取,需要再次更新键的ready集合,将感兴趣的集合重新放在里面
key.interestOps(key.interestOps() | SelectionKey.OP_READ);
// 调用wakeup,使得选择器上的第一个还没有返回的选择操作立即返回即重新select
key.selector().wakeup();
}
});
}
}
几点说明
1.将通道注册到一个选择其中,会返回一个键,这个键代表了通道和选择器之间的注册关系
2.SelectionKey键包含两个集合
interest集合:指示每个通道所关心的操作,这时在注册通道时确定的,也可以使用键的带参数的interestOps方法修改。
ready集合:表示通道已经在该操作上准备好,是interest集合的子集,表示了interest集合中从上次调用select以来已经就绪的那些操作。该集合不能修改,由操作系统告诉我们。
Selector包含三个集合
已注册的键的集合(Registered key set):调用Channel的register方法
已选择的键的集合(Selected key set)
已取消的键的集合(Canceled key set):调用key的cancel方法,select的cancel方法等都会将该key放在已取消的键的集合,通道关闭时,相关的键也会自动取消
3.当调用select方法时,会执行以下操作,这是理解NIO中调用各操作的关键。
1).检查已取消的键的集合,非空,将该键从另外两个集合移除,这步完成后,已取消的键的集合为空
2).检查已注册的键的集合中的键的interest集合,对于那些操作系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:
a.该键没有出在已选择的键的集合里面:该键的ready集合被清空,当前已经就绪的操作将会被添加到ready集合中,然后将该键放入已选择的键的集合中
b.否则,键存在于选择器的已选择的键的集合中,它的ready 集合将是累积的,只会设置这次操作系统决定的操作的位,之前的设置不会变。
3).重复执行1
4).select操作返回的值是ready集合在步骤2中被修改的键的数量,而不是已准备好的所有键的个数。
从上面的选择过程可以知道一旦一个选择器将一个键添加到它的已选择的键的集合中,它就不会移除这个键,并且,一旦一个键处于已选择的键的集合中,这个键的ready 集合将只会被设置,而不会被清理。于是管理键以确保它们正确的状态信息的任务要由程序员来做。
程序中的remove方法将一个键从已选择的集合中删除,否则不会自动删除,表示已经对这个键所代表的的通道进行了处理。
当一个通道上的操作执行完想要删除该通道时,调用key的cancel方法,它将被加入到取消集合中,注册的关系不会立刻取消,键会立即失效,下一次调用select方法,就会从另两个集合中删除,通道也会被注销。
使用NIO+线程池时,在上面的程序中当有可读事件时,交由线程池去处理,这个期间要忽略该通道的read操作,即将其从interest集合删除。当线程结束给该通道的操作时,需要更新键的ready集合,将感兴趣的集合重新放在里面。调用selector的wakeup方法是为了防止如果此时阻塞在select上,他会立刻返回。
4.tcp中仍然需要告诉对端结束标志,这里调用了shutDownOutput方法(NIO的API 1.7以后才有,可以使用结束标志),这样另一端的read方法就会读到EOF,返回-1