Java的I/O本质是Java应用程序与操作系统内核进行数据交互,Java的I/O底层代码实现了对操作系统指令的封装。FileInputStream的read0方法,它有native修饰,我们在代码中并不能看到它的实现原理。本文重点讲网络I/O,因为阻塞和非阻塞主要发生在网络I/O中。这里我抛出俩个问题:
我们先上代码
try (
//服务按Socket,接收客户端请求,建立连接,返回Socket,进行数据通信
ServerSocket serverSocket = new ServerSocket(6666)
) {
System.out.println("BIO Server has started ,listening on port: " + serverSocket.getLocalSocketAddress());
//死循环接收客户端请求,在serverSocket.accept()方法阻塞
while (!Thread.interrupted()) {
Socket clientSocket = serverSocket.accept();
System.out.println("Connection From " + clientSocket.getRemoteSocketAddress());
try (
Scanner scanner = new Scanner(clientSocket.getInputStream());
) {
//死循环接收客户端数据,在scanner.nextLine();方法阻塞
while (scanner.hasNext()) {
String request = scanner.nextLine();
if ("bye".equals(request)) {
break;
}
System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), clientSocket.getRemoteSocketAddress(), request));
String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), request);
clientSocket.getOutputStream().write(response.getBytes());
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
到这里我们实现了最简单的一个BI/O通信,我们运行起来跑跑试试看。windows可以用telnet,如果指令不可用,在服务管理那里打开telnet服务即可。
telnet 127.0.0.1 6666
ExecutorService executor = new ThreadPoolExecutor(0,
200, 3, TimeUnit.SECONDS,
new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
try (
ServerSocket serverSocket = new ServerSocket(7777)
) {
System.out.println("BIO Server has started ,listening on port: " + serverSocket.getLocalSocketAddress());
while (!Thread.interrupted()) {
Socket clientSocket = serverSocket.accept();
System.out.println("Connection From " + clientSocket.getRemoteSocketAddress());
executor.submit(() -> {
try (
Scanner scanner = new Scanner(clientSocket.getInputStream());
) {
while (scanner.hasNext()) {
String request = scanner.nextLine();
if ("bye".equals(request)) {
break;
}
System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), clientSocket.getRemoteSocketAddress(), request));
String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), "finish");
clientSocket.getOutputStream().write(response.getBytes());
}
} catch (Exception ex) {
ex.printStackTrace();
}
});
}
} catch (Exception e) {
e.printStackTrace();
}finally {
executor.shutdown();
}
上面代码是通过线程池的方式来实现的,线程池的参数为初始线程数为0,最大线程数为200,空闲生命周期为3秒,设置没有数据缓冲的队列,拒绝策略是抛弃处理并且抛出异常。(以上设置只是为了突出BI/O的缺点)
让我们运行起来看看,是否可以处理多个客户端请求。下面代码粗略的模拟客户端请求。
//设置并发数量
int tps = 200;
//创建线程池,初始线程数量为并发数量
ExecutorService executor = new ThreadPoolExecutor(tps, tps, 5, TimeUnit.SECONDS,
new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
//循环发起请求
for (int i = 0; i < tps; i++) {
//一秒中完成
int sleep = 1000 / tps;
TimeUnit.MILLISECONDS.sleep(sleep);
executor.submit(() -> {
try (
Socket socket = new Socket("127.0.0.1", 7777);
OutputStream os = socket.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(os, "UTF-8");
InputStream is = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(is, "UTF-8");
BufferedReader br = new BufferedReader(isr)
) {
osw.write("Hello World");
osw.flush();
socket.shutdownOutput();
String response;
while ((response = br.readLine()) != null) {
System.out.println(response);
}
} catch (Exception ex) {
ex.printStackTrace();
}
});
}
//关闭线程池
executor.shutdown();
我们打开jconsole.exe,在jdk的安装目录bin下 ,主要监控 JVM 的概览、内存、线程、类、vm概要、MBean等内容。选择对应的类名称,查看ServerSocket服务的线程消耗。
客户端请求代码执行之后,发现我们的BI/O服务不仅能够满足多客户端请求,并且服务线程最大线程数量也在可控范围,当我们将并发数量提高到1000时,他的线程数量也变化不大,我们是不是已经完美实现了高并发情况下线程数量可控与响应时间短。答案是否定的,我们来加一行代码再看看会发生什么。
//延时1秒中,再发送数据
TimeUnit.SECONDS.sleep(1);
osw.write("Hello World");
osw.flush();
我们只要在输出流执行前,暂停1秒。再次运行看看我们的服务器的线程情况。TPS先从200开始
这线程的开销就不那么美观了,一定情况下是不可接受的。在无延时的情况下,客户端建立连接,发送数据,线程完成任务后,线程释放回线程池,线程利用率高。但是如果发生延时,工作线程一直等待数据,占用线程,高并发下,必然会导致线程数量开销大幅增加。但是现实情况,网络通信中的网络时延是普遍的,不可避免的。
发送延时:主机和路由器发送数据帧所需要的时间,也就是从发送该帧的第一个比特开始,直到最后一个比特发送完毕所需要的时间。
传播延时:主要指电磁波在信道中传播一定距离所花费的时间。
处理延时:主机或网络节点(节点交换机和路由器)处理分组所花费的时间,包括对分组首部的分析,从分组提取数据部分,进行差错检验和查找合适路由等。
排队延时:指分组进入网络节点后,需先在其输入队列中排队等待处理,以及在处理完毕后再输出队列等待转发的时间。
JDK1.4后有了NIO,我们先上代码
//管道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//非阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(8888));
System.out.println("NIO Server has started ,listening on port:" + serverSocketChannel.getLocalAddress());
//观察者模式 选择器
Selector selector = Selector.open();
//每个客户端来了,就把客户端连接就注册到Selector选择器中,默认是accepted
//相当于Map
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//数组的形式实现
//缓存区
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (!Thread.interrupted()) {
int select = selector.select();
if (select == 0) {
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterators = selectionKeys.iterator();
while (iterators.hasNext()) {
SelectionKey key = iterators.next();
//判断selectionKey的channel的状态
if (key.isAcceptable()) {
ServerSocketChannel serverSocket = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocket.accept();
//客户端的来源打印出来
System.out.println("Connection From " + socketChannel.getRemoteAddress());
socketChannel.configureBlocking(false);
//修改为可读状态
socketChannel.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
//数据的交互以buffer为中间桥梁
socketChannel.read(buffer);
String request = new String(buffer.array()).trim();
System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), socketChannel.getRemoteAddress(), request));
String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), "finish");
socketChannel.write(ByteBuffer.wrap(response.getBytes()));
buffer.clear();
socketChannel.close();
}
iterators.remove();
}
}
我们把上述代码放到main线程中执行,和测试BI/O代码一样,测试下来它线程开销情况如图
它的线程开销没有变化,请求也处理完成,简直完美!看日志,发现都只有一个main线程在运行。客户端的延时增加到3s,对它也没有影响。为什么会这样呢。
这里我们引用Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”。
上面就是我们的用户进程发起一个read指令,操作系统内核空间接收到指令后,开始准备数据,用户进程就在等待内核空间,这期间线程一直在阻塞状态。当我们的应用线程需要从网卡中获取网络数据时,网络延时的越长,我们工作线程的阻塞时间就越长。
用户进程发出read操作时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,就可以去干其他事。过一会它再次发送read操作。一旦内核空间中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
我们也可以称这种IO方式为事件驱动IO(event driven IO)。单个用户进程可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。我们代码中体现主要是三个组件Selector、Channel、Buffer。
阻塞是发生在内核空间准备数据的时候,NI/O就是用户进程不会等待内核空间准备数据。虽然多了每次询问的动作,但是对于减少的阻塞时间,是十分划算的。但是如果不是高并发环境下,建议还是使用多路复用+BI/O的方式,提高CPU的利用率。