能看到这篇博客的人估计已经对BIO和NIO有了一定的了解
采用不同的IO对系统的性能来说影响也是不容小觑的。
下面我们来看普通的BIO的通信方式。
1.阻塞IO(BIO)
1.普通的单线程的通信方式
/*
*采用单个线程进行侦听并且进行事件的处理
*/
public class ServerTest {
public static void main(String[] args) {
ServerSocket server;
boolean goon = true;
byte[] bytes = new byte[1024];
try {
server = new ServerSocket(54188);
while(goon) {
Socket socket = server.accept();
DataInputStream dis = new DataInputStream(socket.getInputStream());
dis.read(bytes);
dealMessage(bytes);
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
dos.write(bytes);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void dealMessage(byte[] value) {
//TODO
//处理数据
}
}
弱点:在不考虑多线程的情况下,BIO无法处理多个客户端的请求,并且会有两处阻塞,accept和read。
2.普通的多线程的通信方式
服务端代码:
public class ServerTest {
public static void main(String[] args) {
ServerSocket server;
boolean goon = true;
byte[] bytes = new byte[1024];
try {
server = new ServerSocket(54188);
while(goon) {
Socket socket = server.accept();
//侦听到一个链接,利用线程池开启一个线程,进行后续的read 和 write操作
new ThreadPool().Execute(new Commucation(socket) {
@Override
public void dealMessage(String message) {
// TODO 进行数据的处理
}
});
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
进行数据的读写类,实现Runnable接口
public abstract class Commucation implements Runnable{
private Socket socket;
private DataInputStream dis;
private DataOutputStream dos;
private volatile boolean goon;
Commucation(Socket socket) {
this.socket = socket;
goon = true;
try {
dis = new DataInputStream(socket.getInputStream());
dos = new DataOutputStream(socket.getOutputStream());
} catch (IOException e) {
e.printStackTrace();
}
}
public boolean isGoon() {
return goon;
}
public void sendMessage(String message) {
try {
dos.writeUTF(message);
} catch (IOException e) {
//TODO
//进行socket的关闭,和资源的回收
}
}
public abstract void dealMessage(String message);
@Override
public void run() {
while (goon) {
String message = null;
try {
message = dis.readUTF();//不管是对端异常掉线还是自己关闭自己都会触发异常
dealMessage(message);
} catch (IOException e) {
//TODO
//处理异常掉线的问题
}
}
}
}
一般的多线程的方法也是我们采用BIO最常用的一种线程模式,虽然解决了单线程BIO无法处理并发的弱点但问题时,但是每个链接需要独立的线程/进程单独处理,我们知道在创建新线程的时候,每个新线程会消耗系统资源,并且每个线程拥有自己的数据结构栈,消耗系统内存。
并且一个线程阻塞的时候,jvm会保存其状态,并且在上下文切换时恢复阻塞线程的状态,随着线程数的增加,会导致系统花费更多的时间来处理上下文的切换和线程的管理,更少的时间来处理链接服务。
上例虽然采用了线程池的方式,其优点是可以将线程重复利用,缺点是如果创建的线程数量较少,客户端可能等待很长的时间才能获取服务,线程池大小经过设置之后,比较固定,不能够进行动态的调整。
对于上边的BIO的阻塞情况,我们可能会采用进一步的方法进行解决就是采用非阻塞的IO,将普通的ServerSocket替换为ServerSocketChannel 将Socket替换为ScoketChannel,并且设置通道为非阻塞模式,采用轮询的方式进行设计。
采用单个线程对轮询表进行轮询。
3.非阻塞IO(NIO)
服务端主线程负责侦听
//采用单个线程记性轮询,侦听到便产生一个socketChannel,加入到轮询表,
public class ServerTest {
public static void main(String[] args) {
ServerSocketChannel serverSocketChannel;
boolean goon = true;
try {
serverSocketChannel= ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(54188));
serverSocketChannel.configureBlocking(false);//设置为非阻塞
Handler handler = new Handler();
new Thread(handler).start();
while(goon) {
//serverSocketChannel设置了非阻塞模式,当侦听不到链接的时候,也不会进行阻塞
//而是进行循环侦听
SocketChannel socket = serverSocketChannel.accept();
handler.addChannels(socket);
}
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
Handler 进行轮询并且进行数据的处理
public class Handler implements Runnable{
private List<SocketChannel> socketList;
private volatile boolean goon;
private ByteBuffer buffer;
public Handler() {
socketList = new CopyOnWriteArrayList<SocketChannel>();
goon = true;
buffer = ByteBuffer.allocate(1024);
}
public void addChannels(SocketChannel socketChannel) {
try {
socketChannel.configureBlocking(false);
} catch (IOException e) {
e.printStackTrace();
}
socketList.add(socketChannel);
}
@Override
public void run() {
while(goon) {
for(SocketChannel one : socketList) {
try {
//因为设置了非阻塞模式,通过read进行读取的时候不会阻塞
//当没有东西可读时返回值为0
int length = one.read(buffer);
if(length != 0) {
dealMessage(buffer);
}else {
//表明没有消息传输
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public void dealMessage(ByteBuffer message) {
//TODO
//进行数据的处理
}
}
其实我们在使用socket对象的时候,其实socket(套接字)只是一个引用,这个套接字的对象实际上是放在操作系统内核中,当我们采用write进行写入字节数组的时候,是将字节数组拷贝到内核区套接字对象的writeBuffer中,内核网络模块会有单独的线程负责将writeBuffer的数据拷贝到网卡及硬件。
同样,服务器内核的网络模块也会有单独线程不停的将收到的数据拷贝到套接字的readBuffer中,等待用户层来取,最终服务器的用户程序通过socket引用的read方法将readBuffer中的数据拷贝到用户程序内存中。
所以其实对于每次的write和read都会进行一次系统调用。这个系统调用就是判断是否有数据到达,如果到达就将到达的数据拷贝到套接字的buffer中。
优点:每次发起的IO请求能够立即返回,不用阻塞等待,相比之前的两种线程的模式来说,实时性较好,
缺点:虽说比前面的线程设计方案看起来更好,只采用了两个线程完成,但本身依旧存在缺点,轮询的期间,不管是read还是write,虽然不需要急进行阻塞,都要不断的访问内核,比较耗费CPU的资源,系统资源利用率较低,而且当连接数量很大时,轮询的效率比较低。
其实真正的NIO解决方法不会采用代码层的轮询方式,就是采用java代码写一个轮询的方案,而是将轮询部分的代码交给操作系统级别,主动感知socket。那就是采用一个系统的调用方法(select)。
4.IO复用
IO复用指的是应用程序只会阻塞在系统提供的方法select上,当多个通道有可读的情况的时候,select才会返回,应用程序再通过recvfrom进行系统调用进行从内核空间的数据拷贝到用户空间。
在使用的过程中,我们需要将多个SocketChannel注册到selector选择器上,并且为这些通道标记根据什么事件可以触发通道的响应,然后采用select方法进行轮询监测,一旦哪个通道的事件被触发,select即直接返回,我们可以根据selector得到注册在其上并且有数据可读的通道,然后我们通过得到的通道进行数据读取,并且处理。
我们写的Java程序其本质在轮询每个Socket的时候也需要去调用系统函数,那么轮询一次调用一次,会造成不必要的上下文切换开销。
Select会将请求从用户态空间全量复制一份到内核态空间,在内核态空间来判断每个请求是否准备好数据,完全避免频繁的上下文切换。所以效率是比我们直接在应用层写轮询要高的。
简单的Reactor模型举例IO复用
服务器端测试类:
public class serverTest {
public static void main(String[] args) {
try {
new Thread(new Reactor(54188)).start();
} catch (IOException e) {
e.printStackTrace();
}
}
}
Reactor类:
public class Reactor implements Runnable{
final Selector selector;//相当于管理了很多个通道,也管理的侦听的通道。
final ServerSocketChannel serverSocket;//侦听通道。
public Reactor(int port) throws IOException {
selector = Selector.open();
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
serverSocket.configureBlocking(false);
//注册感兴趣的事情。
SelectionKey sk = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
sk.attach(new Acceptor());
//当serverScoketChannel触发侦听响应的时候,调用Acceptor类中的方法进行侦听链接,并处理
}
class Acceptor implements Runnable{//此时的这个Acceptor是特殊的handler
@Override
public void run() {
SocketChannel channel;
try {
channel = serverSocket.accept();
if(channel != null) {
//表明有客户端的链接
//创建一个与客户端对应的通道,并将此通道注册到selector上
new Handler(selector, channel);
//对于侦听的客户端产生channel,但只是产生一个对象
//并没有开启线程,并将这个channel注册到选择器上。
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}//侦听链接的客户端,并且生成一个通道
}
}
public void dispatch(SelectionKey key) {//进行事件的分发的处理
Runnable handle = (Runnable)key.attachment();
if(handle != null) {
handle.run();//进行执行方法;
}
}
@Override
public void run() {
//将侦听的channel,和普通的channel都注册到selector上,针对不同的key来进行处理
while(!Thread.interrupted()) {
try {
selector.select();//会进行阻塞等待,知道系统侦听到通道有响应的事件发生。
Set<SelectionKey> selected = selector.selectedKeys();
Iterator<SelectionKey> it = selected.iterator();
System.out.println(selected.size());
while(it.hasNext()) {
//通过注册的通道进行,分发,
//对于侦听来说,就是侦听到一个用户,然后创建通道,
//对于用户的发送信息来说,就是普通的通道的处理过程
dispatch(it.next());
//需要进行遍历过的键进行删除,
//如果对于侦听的serverSocketChannel来说,selector检测其触发了链接的响应,则会建立对应通道的key,之后通过可以会真正执行accept,
//如果没有进行删除的话,这个通道的键会保留在key的集合中
//那么当有别的通道有对应响应事件的时候,就会开始进行key集合的循环处理,那么这时就算serverScoketChannel没有链接的响应事件
//同样也会去执行accept,多此一举
it.remove();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
Handler类:
public class Handler implements Runnable{
private static final int LENGTH = 64;
final SocketChannel channel;
final SelectionKey sk;
ByteBuffer input = ByteBuffer.allocate(LENGTH);
ByteBuffer output = ByteBuffer.allocate(LENGTH);
public Handler(Selector selector, SocketChannel c) throws IOException {
channel = c;
c.configureBlocking(false);
sk = channel.register(selector, SelectionKey.OP_READ);
sk.attach(this);
//相当于给通道绑定一个事件处理的类也就是一个handler的对象,当通道有数据,触发读响应的时候
//会通过主线程进行dispatch分发,处理事件响应通道中的信息,那么就是执行this中的run方法,进行数据的处理,同样就是handler的功能。
}
public void dealMessage() {
//TODO
//对缓冲区的的数据进行处理
//这里对消息一个简单的输出
System.out.println(new String(input.array()));
byte[] bytes = "欢迎你客户端".getBytes();
output = ByteBuffer.wrap(bytes);
sk.interestOps(SelectionKey.OP_WRITE);
return;
}
@Override
public void run() {
try {
if(sk.isReadable()) {
read();
}
if(sk.isValid() && sk.isWritable()){
send();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void read() throws IOException{//对于读写来说是先读后写,
try {
int length = channel.read(input);
if(length < 0) {
channel.close();
}else {
dealMessage();
}
} catch (IOException e) {
channel.close();
}
}
private void send() throws IOException {
channel.write(output);
if(!output.hasRemaining()) {
sk.interestOps(SelectionKey.OP_READ);
}
}
}
客户端测试类:
public class ClientDemo {
private static final String SERVER = "127.0.0.1";
private static final int PORT = 54188;
private static final int LENGTH = 64;
public static void main(String[] args) throws IOException {
byte[] argument = "你好,服务端".getBytes();
SocketChannel scoketChannels = SocketChannel.open();
scoketChannels.configureBlocking(false);
if(!scoketChannels.connect(new InetSocketAddress(SERVER, PORT))) {
while(!scoketChannels.finishConnect()) {
System.out.println("等待中链接中....");
}
}
ByteBuffer writeBuffer = ByteBuffer.wrap(argument);//将数组包装在缓冲区中
ByteBuffer readBuffer = ByteBuffer.allocate(LENGTH);//分配一个新的字节缓冲区
int readed = -1;
while(readed <= 0) {
if(writeBuffer.hasRemaining()) {//判断缓冲区中是否拥有可发送的字节
scoketChannels.write(writeBuffer);
}
//当没有接受到数据的时候返回值为0
readed = scoketChannels.read(readBuffer);
}
System.out.println("Received : " + new String(readBuffer.array()));
scoketChannels.close();
}
}
和阻塞IO相比,都需要阻塞等待,但是IO复用,同时关注了多个socketChannel,节省了线程的开销,和非阻塞IO来对比的话,因为非阻塞IO采用的是代码层面的的轮询,所有对于每个通道来说都需要进行read判断,每一轮的轮询中的每一个通道都会都会进行一次系统调用,可能存在很多通道没有数据可读,同样也需要进行系统调用。
对于IO复用来说,通过select内部轮询的方式,我们只需要对注册在selector上的有数据可读的通道进行read操作真正的读取数据,而不用每个通道都进行读取。
其实上例是简单的Reactor模型,应用了IO复用,可以基于一个阻塞对象,同时在多个事件描述上等待就绪,而不是使用多个线程,这样可以大大节省系统资源。
此博文只是一个简单的开头,之后会继续进行学习NIO和AIO,并进行总结写出。