Java NIO 进行网络编程
本文由大关总结整理所得,不保证文中正确性,转载请标明出处!
Java NIO中在原有IO的基础上提供了新的特性,并且在Java
1.4之后所有的java基本IO类库也使用NIO进行了重写,因此,在更多的时候,仅使用原有的IO库中的操作,也可以从Java
NIO中获益。
Java
NIO主要提供了两大方面的性能提高,一方面是对文件的操作,可以使用直接映射方式,将一个文件整体映射到内存(使用虚拟内存方式),可以在同一时刻,使用多个线程,甚至是多个进程对文件的不同部分进行操作,同时为文件提供了文件锁机制,使得对文件的操作更加灵活。另外一方面,对于java网络编程的性能提升也是很明显的。在java
NIO中为服务器端提供了非阻塞式服务(异步服务),使用异步服务方式,可以大量的减少服务端的线程创建的数量,从而减少频繁切换线程空间造成的开销,提高了系统的执行性能。
下面的内容主要从网络编程出发,给出如何使用NIO这种异步服务的方式提高服务端的性能,并简要论述了性能是如何得到提升的。
1 传统的服务端连接
使用传统的方式进行搭建的服务端,在每接收一个客户端的请求,服务端就创建一个新的线程,用来处理这个请求,但是在处理过程中,这些新建的线程往往需要等待客户端的输入和其他操作,需要阻塞式等待,因此,如果一个服务器负载很重,他就需要创建大量的这种等待的线程,cpu需要花费大量的时间去查询这些阻塞线程的状态是否可用,而且每次进行查询的时候需要切换线程的状态,造成了很大的性能开销。
Demo-1中给出了这种使用传统模式搭建的交互服务模型。Demo-1中客户端先向服务端发送一条消息,服务端接收这条消息,然后在这条消息开始处添加Hello后,将原消息反馈给客户端,客户端读取服务端的反馈信息。
在Demo-1中,服务端每接收一个客户端的请求,就创建一个新的线程处理这个请求。如果线程在处理过程中需要等待客户端的输入,那么服务端就需要阻塞等待客户端的输入接收完成,因此有可能造成很多线程都在等待客户端输入,因此浪费了大量的系统资源。
Demo-1 一个回声测试的服务器与客户端
package com.upc.upcgrid.guan.chapter02;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;
import org.junit.Test;
public class SocketEchoTest {
public static final int
serverPort = 21212;
@Test
public void service() throws
IOException//服务端
{
ServerSocket server = new
ServerSocket(serverPort);
Socket socket;
try{
while(true)//不断接受请求
{
socket = server.accept();//阻塞等待新的请求到来
new OnceServer(socket).start();//创建一个单独的线程来处理请求
}
}finally{
server.close();//关闭服务
}
}
class
OnceServer extends Thread{
Socket socket;
public OnceServer(Socket socket) {
this.socket = socket;
}
public void run() {
try {
System.out.println("new request");
BufferedInputStream in = new
BufferedInputStream(socket.getInputStream());
OutputStream out = socket.getOutputStream();
//从客户端读取一条信息
Scanner scan = new Scanner(in);
String s = scan.nextLine();
System.out.println("server info : "+ s);
//向客户端写出一条信息
out.write(("hello " + s).getBytes());
out.flush();
} catch (IOException e) {
}finally{
try {
socket.close();//关闭socket
} catch (IOException e) {
}
}
}
}
@Test
public void client() throws
UnknownHostException, IOException
{//客户端
Socket socket = null;
try{
socket = new Socket("localhost",
serverPort);//创建与服务器的链接
BufferedInputStream in = new
BufferedInputStream(socket.getInputStream());
//向服务端写入一条信息
OutputStream out = socket.getOutputStream();
out.write("woshiguanxinquan\n".getBytes());
out.flush();
//从服务端获取以条反馈信息
Scanner scanner = new Scanner(in);
System.out.println(scanner.nextLine());
}finally{
socket.close();//关闭与服务端的链接
}
}
}
.2 使用java NIO改善服务器性能
使用java NIO提供的异步等待方式,可以大大降低服务器端的负载,提升服务器的连接能力,提升服务器的响应能力。
在java
NIO中提供的提升服务端性能相关的类,可分为三种类型,第一种是通道,一般可以认为通道等同于一个文件描述符,在web编程中通道可以相当于socket的InputStream和OutputStream,在非Web编程过程中,通道还可以表示管道(用于进程间通信,或者线程间通信),文件,标准输入输出等。其次是监视器(Select),监视器类似于观察者模式中的观察者,它负责观察注册到本Select的通道关心的事件(例如:socket中从客户端传送过来的数据已经准备好,可以随时读取,或者向客户端的传输已经建立,现在可以向客户端写数据了),一旦有通道变得可用,Select就会从睡眠中被唤醒,继续执行可用通道的后续操作。最后是SelectKey,就是键值,这个键值用来连接一个通道,和通道关心的事件,当Select返回后,会将准备好的通道以Key的方式反馈给服务端,服务端通过遍历这些期望事件已经发生通道(Key中关联了相应的通道,和具体发生变化的事件类型),之后执行相应通道在相应事件发生后的处理操作。
在上述的三种类型中,Select是最为复杂的,它类似于Linux中Select的功能。在Java中,Select是调用操作系统的系统函数完成事件查询和等待的。首先Select向系统注册其关切的通道和相应通道关心的事件,之后选择器进入睡眠状态(睡眠也应当是一种阻塞状态),操作系统负责监视这些通道是否有关注的事件发生,一旦其中有一个或者多个通道的注册的事件发生,操作系统会回调并唤醒睡眠的选择器,选择器从睡眠中苏醒,向操作系统查询相应通道的事件发生状态,之后对自身的通道的Key集合中相应的位进行修改,然后返回服务器提供的执行逻辑,处理这些事件发生的可用的通道。简单的说Select就是将通道和通道关心的事件交给操作系统去监视,当相应的通道事件发生,在通知服务程序,处理这些可用的通道。在这个过程中,服务器需要提供一个线程作为Select线程,这个线程在监视期间处于睡眠状态(阻塞),在监视返回阶段作为处理线程。
要想深入了解这种Select监听机制,还必须了解SelectKey,就是键值。SelectKey实际提供的是通道与关注事件的一种关联。在每个SelectKey内部有一个interest(关心)事件集,事件集包括OP_ACCEPT(服务端接收到客户端请求)、OP_CONNECT(套接字连接成功)、OP_READ(通道有数据可读)、OP_WRITE(可用向通道中写入数据)这五种可用事件的一种或多种的组合。这样一个SelectKey就可以关联一个通道和这个通道所关心的事件。并且在SelectKey中还有一个集,用来存放已经发生的事件,称为ready集,这个集在任何时候都是interest集的一个子集,Select在事件发生后,会修改相应ready集的相应位,以标识这个事件已经发生。在Select中存在三种类型的SelectKey集合,分别是已注册键集,已选择键集和已取消键集。所有向选择器注册了的SelectKey(就是通道和其所关心的事件组合),已选择键集是那些关注事件发生了的SelectKey(就是通道关心的事件已经发生,并没有被处理,释放被选择状态的键),已取消键集就是那些用户取消了的键(一般通道被关闭,或者通过SelectKey直接调用cancle的键,这些键将不会再被监视,但不是将键立即取消)。
了解这些概念后,现在让我们来看看监视器一次具体的监视过程是怎样的。
线程调用select将会出现下列事件序列:
1. 检查已取消键集,如果键集非空,将已注册键集和已选择键集中与已取消键集中的交集SelectKey删除,之后清空已取消键集。
2. 检查已注册键集的所有SelectKey的interest集(关心事件集合),调用操作系统的相关操作,将通道与关心的事件注册给操作系统,操作系统负责对所有的通道和关心的事件进行监视。线程在此被阻塞。操作系统中有相应的事件发生,如果发生的事件的SelectKey尚未添加到已选择键集中,那么键的ready集将被清空,之后将对应该键的相应事件的比特位掩码将被设置;如果发生的事件的SelectKey已在已选择键集中,则新发生的事件的相应为比特掩码被设置(有可能这个键的这个位的掩码已经被设置过了,这是一种累积过程)。
3. 重新执行步骤1,将取消的键去除。
4. 执行相应事件的操作。
下面在Demo-2中提供一个简单的文件传输服务器的例子,这个例子给出了如何在web服务端使用NIO来提高服务的可连接性。
Demo-2:使用NIO的Web文件服务。
package com.upc.upcgrid.guan.chapter02;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import
java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import
java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.Test;
public class NIOFileTrans{
public static final int PORT =
21213;//端口号
//发送缓冲和接收缓冲区大小
public static final int
BUFFER_SIZE = 1024*1024;
//线程池大小
public static final int
MAX_POOL_SIZE = 6;
public class FileTransContext implements
Runnable{
public ByteBuffer buffer;//发送缓冲
public InputStream inputStream;//发送流(读取文件流)
public boolean isFinished = false;//发送成功标识
public SocketChannel channel;//套接字通道(输出流)
//当前发送线程任务(用于查询当前线程是否执行结束)
public Future> future;
//一次发送
public boolean sendFile() throws
IOException
{
//如果当前的buffer已经传送结束,则从inputStream中读取新的buffer
if(!buffer.hasRemaining())
{
if(isFinished)//如果文件已经发送完毕,直接返回
{
return true;//发送结束
}
getNextBuffer();//从文件中获取下一段数据
}
channel.write(buffer);//向socket写出数据
return false;//文件仍未发送完毕
}
private void getNextBuffer() throws
IOException
{
//获取一段新数据
int i = inputStream.read(buffer.array());
if(i == -1)//如果读到文件末尾
{
isFinished = true;//文件输出结束
}else
{
buffer.position(0);//重新组织缓冲区
buffer.limit(i);
}
}
@Override
public void run() {
try {
sendFile();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public class ReadFileName implements Runnable
{
public SocketChannel channel;//数据将要传输的socket通道
public String fileName = "";//接收到的文件名
public ByteBuffer buffer;//输入缓冲区
public boolean isReadFinished =
false;//读取操作完成标识
public Future>
future;//用于判断当前线程状态
public SelectionKey key;//对应的选择键
public synchronized void run() {
buffer.clear();//清空缓冲区
try {
channel.read(buffer);//从通道中读取数据
buffer.flip();//将数据翻转,用于读出
if(buffer.array()[buffer.limit()-1] == -1)//接收到-1表示读到了末尾
{
fileName += new
String(buffer.array(),0,buffer.limit()-1);//组织字符串
isReadFinished = true;//读取结束
}else
{
fileName += new
String(buffer.array(),0,buffer.limit());//组织字符串
}
} catch (IOException e) {
}
}
}
@Test
public void service() throws IOException
{
ServerSocketChannel server =
ServerSocketChannel.open();//创建Server
server.socket().bind(new
InetSocketAddress(PORT));//绑定端口
server.configureBlocking(false);//设置非阻塞模式
ExecutorService pool =
Executors.newFixedThreadPool(MAX_POOL_SIZE); //创建线程池
Selector select = Selector.open(); //创建监听器
server.register(select,
SelectionKey.OP_ACCEPT);//将server的accept事件放入监听器
while(true)
{
select.select();//阻塞等待,新的事件到来
Iterator keys =
select.selectedKeys().iterator();
//Set keys =
select.selectedKeys();//获取发生的事件
//for(SelectionKey key:keys)//遍历处理事件
while(keys.hasNext())
{
SelectionKey key = keys.next();
if(key.isAcceptable())//如果是远程有请求连接
{
ServerSocketChannel ssc =
(ServerSocketChannel)key.channel();//获取服务
SocketChannel sc = ssc.accept();//创建远程的连接
sc.configureBlocking(false);//设置socket连接为非阻塞模式
sc.register(select,
SelectionKey.OP_READ);//将socket连接的读事件注册
}
if(key.isReadable())//发生了读事件
{
SocketChannel sc = (SocketChannel)key.channel();//获取socket
ReadFileName read =
(ReadFileName)key.attachment();//获取socket的环境参量
if(read == null)//如果环境参量为空,表示首次进行读取
{
read = new ReadFileName();//创建环境参量
read.buffer =
ByteBuffer.allocate(BUFFER_SIZE);//创建缓冲区
read.channel = sc;//将通道赋值到环境中
read.future = pool.submit(read);//将读取任务提交到线程池
read.key = key;//将当前key放入环境中
//注册写入事件,注意这时并没有真的可以写入信息,但是仍然需要提前注册这个事件
sc.register(select, SelectionKey.OP_WRITE);
key.attach(read);//向key添加环境属性
}else if(read.future.isDone()
&& read.isReadFinished ==
false)
{//表明,上次执行的读线程已经读取结束,但是并未读取传入信息的末尾
pool.submit(read);//再次执行新的线程,继续读取剩余数据
}
}
if(key.isWritable())//当通道可写
{
SocketChannel sc = (SocketChannel) key.channel();//获取socket
if(key.attachment() instanceof
ReadFileName)//如果环境是从客户端读文件名
{
ReadFileName read = (ReadFileName) key.attachment();//获取环境
if(read.isReadFinished)//如果读文件名操作已经结束,那么要生成写环境
{
BufferedInputStream inputStream = null; //创建文件输入流
try{
//初始化输入流
inputStream = new BufferedInputStream(new
FileInputStream(new File(read.fileName)));
}catch(IOException e)
{//出现异常要关闭socket
System.err.println("需要下载的文件不存在");
sc.close();
}
FileTransContext transContext = new
FileTransContext();//创建新的文件传输环境
transContext.buffer =
ByteBuffer.allocate(BUFFER_SIZE);
transContext.buffer.limit(0);
transContext.channel = sc;
transContext.inputStream =
inputStream;
key.attach(transContext);//将新环境与key相关
}
}else if(key.attachment() instanceof
FileTransContext)//当前环境是文件传送
{
FileTransContext context = (FileTransContext)
key.attachment();//获取环境
if(context.future == null)//第一次进行文件写操作
{
context.future = pool.submit(context);
}else if(context.future.isDone()
&&
!context.isFinished)//如果上次写操作结束,并且文件并没有传输完成
{
context.future = pool.submit(context);//进行下一个文件块的写入
}else
if(context.future.isDone())//如果上次写入已经结束,并且文件传输结束
{
sc.close();//关闭套接字
}
}
}
//keys.remove(key);//移除本次处理的键
keys.remove();
}
}
}
public static void main(String[] args)
throws IOException {
new NIOFileTrans().service();
}
@Test
public void client() throws
UnknownHostException, IOException
{
//文件要输出的位置
OutputStream out = new FileOutputStream(new
File("I:\\电影\\The.Lincoln.Lawyer.2011.R5.DVDRiP.XViD[林肯律师]test.rmvb"));
Socket socket = new Socket("localhost", PORT);
//获取文件输入流(网络服务器)
InputStream in = socket.getInputStream();
//获取网络服务器的输入流(客户端的输出流,用于输出想要获取的文件名)
OutputStream socketOut = socket.getOutputStream();
//写出需要读取的文件名
socketOut.write("I:\\电影\\The.Lincoln.Lawyer.2011.R5.DVDRiP.XViD[林肯律师].rmvb".getBytes());
socketOut.write(new byte[]{-1});//输出终止符
byte[] buffer = new
byte[BUFFER_SIZE];//新创建一个读入缓冲区
int i = 0;
while(true)
{
i = in.read(buffer);//从网络读入数据
if(i == -1)//读取到文件末尾
break;
out.write(buffer, 0, i);//向文件中写出
}
socket.close();//关闭套接字
out.close();//关闭文件
}
}
下面对demo-2中的代码进行简单分析。首先这个服务将服务器的accept事件交给了select来监听,当有客户请求到来时,服务端又将这个新的请求的读取事件交给了select事件,当客户端向服务器写入信息后,服务端即会变成可读状态,因此激发了服务端的新线程读取客户端传过来的文件名(如果文件名过长,那么将会有几个线程先后创建读取,这些子线程都是很短的,仅是读取一次缓冲),并注册了可writeable事件。writeable事件是在写出缓冲区空闲时触发的,因此在注册时,输出缓冲区即为可用,但此时文件名尚未读取结束,因此通过不同的环境来区分当前的执行状态。在整个过程中服务端使用了线程缓冲区机制,这种机制最适宜执行简短的线程任务。通过使用监听机制,在读取信息时,可用节省传统的服务端等待客户端输入和阻塞线程带来的代价,同时,写入时,仅在可写入时执行线程,节省了写入时缓冲区满阻塞等待的代价。一般在并发访问量巨大,需要减少系统中的阻塞线程数目,提高服务器的响应速度时,需要考虑使用NIO来提升服务器性能。此外,NIO技术需要与线程池技术结合使用才能达到事半功倍的效果。过于线程池可用参考我的相关博客,或者查看java的API。
此外,使用javaNIO技术编写网络服务端比较复杂,一般情况下,需要自己标记消息是否完全读取,有很多情况一条消息不能通过一次接收就完成(比如接收文件名,文件名此时很长),这样在服务端接收的时候,就需要对消息进行判断,看是否到达本条消息的末尾,当所有的消息段都到达后,在将整条消息拼接。这与传统方式不同,传统方式很简单,使用readUTF就可用读取一条消息。
在下一次提供一个使用NIO实现的echo系统。你可以查看其他博客。