已经很久没更新博客了,惭愧。在这之前先讲一下面试可能会问到的三次握手与四次挥手,也就是Tcp如何建立连接?
假设A城市往B城市发送信件,先A发到B,B收到,在发给A,在A发给B,建立起初步通信。三次挥手是为了证明A,B的收信和发信能力是ok的,这样就证明连接是通常的。
第一次握手:当A发到B时,B收到信后,此时B城市就明白了,A城市的发信能力和B城市的收信能力是ok。
第二次握手:当B发到A时,A收到信后,此时A城市就明白了,B城市的发信能力和A城市的收信能力是ok,加上之前的发信,同时也就知道了A(自己)的发信能力和B城的收信能力是ok的,这就相当于A知道了双方都是OK的,但B还疑惑,因为它第一次虽然知道了A的发信能力个自身的收信能力是OK的,但并不不知道城B(自身)的的发信能力和A城市的收信能力如何,所以需要第三次握手。
第三次握手:A发给B 当B收到后,就知道了B(自身的发信能力)和A城市的收信能力同样是ok的,既然双方都知根知底,那就掏心掏肺喜结连理吧。即完成首次通信的建立。
大概流程可见如下图:
三次握手TCP协议 | 连接成功后,可以得出结论 |
第一次握手A-->B | A得出结论:啥也不知道,不确定自己是否发送ok B得出结论:A发 B收 ok |
第二次握手B-->A | A可以得出结论:A收 B发,加上之前的第一次发送可以推出A发 B收也ok,所以明白AB发送接受都ok B得出结论:基于第一次知道A发 B收 ok,但是并不知道自身的发送是否成功和A的接受能力 |
第三次握手A-->B | A发给B,B收到消息后就可以证明:B的发信能力和A的自收信能力ok |
第一次挥手:A发到B,告诉B我要挂了,此时B就明白了A准备要挂了。
第二次挥手:B发到A,告诉A,我还在忙,先别着急挂电话,于是A就知道B还没准备好挂电话的意思。
第三次挥手:B发到A,好了,我忙完了可以挂了,此时A知道了B想挂了,但A毕竟怕老婆,不敢先挂电话,于是就说那我挂了哟,这也就是第四次要发到B的话了。
第四次挥手:A发到B,不管此时B有没有收到,A都会等待2ms,如果时间内没收到消息则说明老婆大人先挂了,如果老婆收到了,则说明A挂了,也会挂掉电话。
还可能面试涉及的问题:
1. 什么是长连接和短连接?
在Http1.0中默认使用的是短连接,也就是说浏览器与服务器每进行一次http操作就建立一次连接,但任务结束后就中断连接,如果客户端浏览器访问的某个HTML或者其他类型的web页包含其它web资源,如图像文件、css文件;当浏览器每遇到这样一个web资源,就会建立一次http会话。
但在Http1.1起,默认使用长连接。用以保持连接特性,使用长连接的http协议会在响应头中加入这行代码:
在使用长连接的情况下,当一个网页打开完成后,客户端与服务器之间用于传输Http数据Tcp连接不会关闭,如果客户端再次访问这个服务器的网页,就会继续使用这一次建立好的连接,keep-alive不会永远保持连接,它有一个保持时间,可以在不同的服务器软件中设定这个时间。实现长连接要求客户端和服务器都必须支持长连接。
2. Http协议与Tcp Ip协议的关系?
Http的长连接与短连接实际上本质是Tcp的长连接与短连接,Http属于应用层,Tcp属于传输层,Ip属于网络层(七层网络模型)。Ip协议只要解决网络路由与寻址问题,Tcp主要解决如何在Ip层之上可靠地传递数据包,使用网络上的另一端收到客户端发出的所有包,并且顺序与发出顺序一致。
言归正传, 其实这个还得牵涉到我之前做的一个模块,当时是在负责写一个日志下载的功能,其实现就是利用B/S架构去完成的,也就是利用socket编程在结合多线程去完成这样的一个功能。设备作为服务端(日志源),浏览器作为客户端(发请求),形成一个通信,由于InputStream类read()方法是阻塞的,所以就必须利用到多线程或者线程池,每发一个请求就利用一个新的线程这样就互不干扰了,下面是我一个小demo模拟(类似于聊天)了上述业务。
客户端代码:
package NIO.clientpkg;
import java.io.PrintStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
import java.util.Scanner;
/**
*目的:客户端向服务端发送请求服务端接受请求,并回应相同的信息,一旦客户端写入bye,则结束本次操作
*/
public class Client {
public static void main(String[] args) throws Exception{
SocketAddress socketAddress = new InetSocketAddress ("localhost",9001);
Socket socket = new Socket ();
socket.connect (socketAddress,1000*5);//5second
//发送输入的数据 以打印流的方式
PrintStream out = new PrintStream (socket.getOutputStream ());
//接受服务端传来的数据,将其保存在打印流中
Scanner in = new Scanner (socket.getInputStream ());
System.out.println ("请输入你要发送给服务端的信息");
Scanner scanner = new Scanner (System.in);
while (true){
//发送消息
if (scanner.hasNext ()){ //hasNext和next都是半阻塞的方法,会一直处于等待,所以必须用一个变量来接收
//输入的消息
String str = scanner.next ();
out.println (str);
if (str.equals ("bye")){
System.out.println ("服务端发过来的消息:" + in.next ());
break;//不能立即退出,会导致客户端退出服务端还处于连接丢包
}
//接收消息
if (in.hasNext ()){
System.out.println ("服务端发过来的消息:" + in.next ());
}
}
}
socket.close ();
scanner.close ();
in.close ();
out.close ();
}
}
服务端代码:
package NIO.serverpkg;
import java.io.IOException;
import java.io.PrintStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
/**
*目的:客户端向服务端发送请求服务端接受请求,并回应相同的信息,一旦客户端写入byebye,则结束本次操作
*TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。
*TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,
*就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
*/
public class Server2 {
private static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor (10,50,5, TimeUnit.SECONDS,new LinkedBlockingQueue<> (100));
public static void main(String[] args) throws Exception{
//通常服务端在启动的时候回绑定一个众所周知的地址(ip+端口)用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动
//分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
ServerSocket serverSocket = new ServerSocket (9001);//初始化socket,对端口进行绑定,并且对端口进行监听
//这里要用一个while来接受不断发过来的请求,,但是因为accept会阻塞,故这里必须用线程
while (!serverSocket.isClosed ()){
Socket socket = serverSocket.accept ();//侦听并接受到此套接字的连接。此方法在进行连接之前一直阻塞。这应该是一个一直在进行的新线程,因为她面对的可能是诸多追求者
if (socket.isConnected ()){
System.out.println ("连接成功男朋友为:" + socket.toString ());
}
threadPoolExecutor.execute (() -> {
try {
PrintStream out = new PrintStream (socket.getOutputStream ());//发送输入的数据 以打印流的方式
Scanner in = new Scanner (socket.getInputStream());//扫描流负责接受客户端发来的请求,一直不断变化的,因为socket是不断变化的
while (true){
if (in.hasNext ()){//阻塞的方法 inputStream类的read()方法
String str = in.next ();
System.out.println ("接受到客户端发来的信息:" + str);
out.println (str);//接收到消息 并回复同等内容
if (str.equals ("bye")){
break;
}
}
}
out.close ();
in.close ();
socket.close ();
} catch (IOException e) {
e.printStackTrace ();
}
});
}
System.out.println ("结束服务端");
serverSocket.close ();
}
}
启动一个服务端和两个客户端,客户端分别向服务端发送:我是client2,我是client2_1:
题外话:
我们都知道socket是遵守tcp协议的,那如果浏览器访问我的服务端可行吗?我们测试一下:显然是不可行的因为浏览器请求是http请求,明显可以看出客户端请求是发过来了的,但是响应给浏览器确实无效的,因为协议不一致,所以只需要返回固定格式的数据(基于http协议)给客户端即可,tomcat服务器其实也是socket连接,然后对报文 请求头和体实现了一个组装。
改写代码(返回http格式):
package NIO.serverpkg;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerHttp {
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket (9001);
while (!serverSocket.isClosed ()){
Socket socket = serverSocket.accept ();
if (socket.isConnected ()){
System.out.println ("连接成功男朋友为:" + socket.toString ());
}
InputStream in = socket.getInputStream ();
BufferedReader reader = new BufferedReader(new InputStreamReader (in, "utf-8"));
String msg = "";
while ( (msg = reader.readLine () ) != ""){
System.out.println (msg);
if (msg.length () == 0){
break;
}
}
System.out.println("收到数据,来自:"+ socket.toString());
OutputStream out = socket.getOutputStream ();
out.write("HTTP/1.1 200 OK\r\n".getBytes());
out.write("Content-Length: 11\r\n\r\n".getBytes());
out.write("Hello World".getBytes());
out.flush();
}
System.out.println ("结束服务端");
serverSocket.close ();
}
}
http格式如下:
上面只是演示下而已,言归正传,多线程或者固定线程池这样设计是存在问题的,当并发高的话,线程会出现不够用的情况,而且当线程处理的业务逻辑属于耗时操作io或者长期占用不关闭连接时,必定会出现线程不够用的情况,则又会衍生性能问题,那么该如何解决呢?
正题 NIO的引入
阻塞(blocking)IO含义:资源不可用时,IO请求一直阻塞,直到反馈结果(有数据或超时)。(一般用于获取数据)
非阻塞(non-blocking)IO:资源不可用时,IO请求离开返回,返回一个不可用标识。(一般用于获取数据)
同步(syncronous)IO:应用阻塞在发送或接受数据的状态,知道数据传输或成功返回。(一般是拿到数据后进行的一个处理方式)
异步(asyncronous)IO:应用发送或接受数据后立刻返回,实际处理是异步执行的。(一般是拿到数据后进行的一个处理方式)
ServerSocket##accept方法,InputStream##read方法都是阻塞API,操作系统底层API中,默认socket操作都是阻塞的,send/recv等接口也都是阻塞的。所带来的的问题就是在处理网络IO操作时,一个线程只能处理一个网络连接。好在jdk1.4提供了新的java非阻塞ioAPI--NIO。其中里面涉及三个核心组件:Buffer缓冲区、
Buffer缓冲区
缓存区的本质是一个可以写入数据的内存块(类似数组),然后可以再次读取,此内存块包括在NIO Buffer对象中,该对象提供了一种范方法,可以更轻松的使用内存块。
使用Buffer进行数据写入与读取需要以下四个操作:
- 将数据写入缓冲区
- 调用buffer.flip(),转换为读取模式
- 缓冲区读取数据
- 调用buffer.clear()或者buffer.compact()转为写模式
Buffer三个重要属性:
- capacity容量:作为一个内存块,Buffer具有一定的固定大小,也可以称之为容量
- position位置:写入模式时代表写入模式的位置,读取模式时代表读取模式时的位置
- limit限制:写入模式,限制等于buffer的容量(不动)。读取模式下,限制等于写入的数据量(动)
举例说明:如下图初始化的时候呢,假定是一个8个字节的数组,默认初始化的时候是一个写模式,写一个字节position位置就移动一格,假设写了3个字节进去,想要读取的话,则要调用flip()方法,切换至读模式,那么读的limit就是写入的数据量,也就3了,然后position从左到右从第一个位置读到第三个位置为节点。如下图(读完之后在写注意覆盖情况)
ByteBuffer的内存类型:
ByteBuffer提供了堆内内存和堆外内存的两种实现(堆外内存的获取方式:ByteBuffer bf = ByteBuffer.allocateDirect(noBytes));
好处:1.进行文件IO或者网络IO是比heapBuffer少一次拷贝。(file/socket ---> os memory --->jvm heap)GC会移动对象内存,在写file或者socket的过程中,jvm的实现中会将数据复制到堆外,在进行写入。
2.GC范围之外,降低GC压力,但实现了自动管理,DirectBuffer中有一个cleaner对象(phatomReference),cleaner被GC前会执行clear方法,触发DirectBuffer中定义的allocateDirect
建议:1.性能确实可观的时候才是用,分配给大型长寿命;(网络传输、文件读写场景)
2.通过虚拟机参数maxDirectMemorySize大小,防止耗尽整个机器的内存
堆内内存示例:
package com.dongnaoedu.network.nio.demo;
import java.nio.ByteBuffer;
public class BufferDemo {
public static void main(String[] args) {
// 构建一个byte字节缓冲区,容量是10 堆内内存
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
// 默认写入模式,查看三个重要的指标
System.out.println(String.format("初始化:capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
byteBuffer.position(), byteBuffer.limit()));
// 写入2字节的数据
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
// 再看数据
System.out.println(String.format("写入3字节后,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
byteBuffer.position(), byteBuffer.limit()));
// 转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
System.out.println("#######开始读取");
byteBuffer.flip();
byte a = byteBuffer.get();
System.out.println(a);
byte b = byteBuffer.get();
System.out.println(b);
System.out.println(String.format("读取2字节数据后,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
byteBuffer.position(), byteBuffer.limit()));
// 继续写入3字节,此时读模式下,limit=3,position=2.继续写入只能覆盖写入一条数据
// clear()方法清除整个缓冲区。compact()方法仅清除已阅读的数据。转为写入模式
byteBuffer.compact(); // buffer : 1 , 3
byteBuffer.put((byte) 3);//从2开始 2 3 4位置为4
byteBuffer.put((byte) 4);
byteBuffer.put((byte) 5);
System.out.println(String.format("最终的情况,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
byteBuffer.position(), byteBuffer.limit()));
// rewind() 重置position为0
// mark() 标记position的位置
// reset() 重置position为上次mark()标记的位置
控制台输出:
初始化:capacity容量:10, position位置:0, limit限制:10
写入3字节后,capacity容量:10, position位置:3, limit限制:10
#######开始读取
1
2
读取2字节数据后,capacity容量:10, position位置:2, limit限制:3
最终的情况,capacity容量:10, position位置:4, limit限制:10
}
}
Channer通道
ChannelApi覆盖了整个UDP/TCP网络和文件IO,比如FileChannel、SocketChannel、ServerSocketChannel
与标准IO Stream操作的区别:在一个管道内进行读取和写入 Stream通常是单向的(input和output),可以非阻塞读取和写入操作通道,通道始终读取或写入缓冲区。
SocketChannel:
SokcketChannel用于建立tcp网络连接,类似java.net.socket
有两种socketChannel的形式:
1、客户端主动发起的服务连接 SocketChannel socketChannel = SocketChannel.open ();
2、服务端获取的新连接 SocketChannel socketChannel = serverSocketChannel.accept ();
注意点:
写:使用socketChannel .write(byteBuffer) 向通道内写数据时,可能尚未写入任何数据时就可能返回,所以需要循环调用write()
读:使用socketChannel.read (byteBuffer)读取通道内数据时,可能直接返回而根本不读取任何数据,根据返回的int判断读取多少字节
ServerSocketChannel:
ServerSocket则是监听新建立的tcp连接通道,类似ServerSocket
SocketChannel socketChannel = serverSocketChannel.accept ();如果设置为非阻塞模式,如果没有连接进来则立即返回null,所以要检查返回的socketChannel是否为null
下面是通过socketchannel,serversocketchannel,bytebuffer 单线程实现服务器的多个客户端连接
服务端代码
package com.dongnaoedu.network.humm;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Iterator;
/**
* @author Heian
* @time 19/06/16 20:00
* @copyright(C) 2019 深圳市长亮保泰
* 用途:
*/
public class ServerSocketChannel1 {
private static ArrayList<SocketChannel> SocketChannelList = new ArrayList<>();//解决无法获取多个客户端连接
public static void main(String[] args) throws Exception{
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open ();
serverSocketChannel.configureBlocking (false);//设置为非阻塞模式
serverSocketChannel.socket ().bind (new InetSocketAddress (8080));
System.out.println ("服务端启动了");
while (true){
SocketChannel socketChannel = serverSocketChannel.accept ();//非阻塞 如果没有挂起的连接则直接返回null
if(socketChannel != null){//因为是非阻塞的,没有连接返回null
//tcp请求 读取响应
System.out.println("收到新连接 : " + socketChannel.getRemoteAddress());
socketChannel.configureBlocking (false);// 默认是阻塞的,一定要设置为非阻塞
SocketChannelList.add (socketChannel);
}else {
// 没有新连接的情况下,就去处理现有连接的数据,处理完的就删除掉
Iterator<SocketChannel> iterator = SocketChannelList.iterator ();
while (iterator.hasNext ()){
SocketChannel channel = iterator.next ();//新的连接没发送消息 就会去重新遍历scoketchannnel
ByteBuffer receiveBf = ByteBuffer.allocate (1024);
if (channel.read(receiveBf) == 0) {// 等于0,代表这个通道没有数据需要处理,那就待会再处理
continue;
}
while (channel.isOpen () && channel.read (receiveBf) != -1){//按照1kb大小去读取socketchannel 的数据,没有返回0,不阻塞但不断做轮询
// 长连接情况下,需要手动判断读取数据有没有读取结束,可能数据量很大,远超过1kb (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (receiveBf.position() > 0) break;//如果读到了,就相当于会把数据写入到 receiveBf
}
if(receiveBf.position () == 0)continue;//如果没有数据则结束此次循环,终止下面的操作
receiveBf.flip ();//切换至读取模式
byte[] bytes = new byte[receiveBf.remaining ()];
ByteBuffer byteBf = receiveBf.get (bytes);
String reveiveMsg = new String (byteBf.array (),"utf-8");
System.out.println ("收到"+channel.getRemoteAddress ()+"客户端发来的消息为:" + reveiveMsg);
//响应结果 这里随便响应一个
String response = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"Hello World";
ByteBuffer sendBf = null;
if ("bye".equals (reveiveMsg)){
sendBf = ByteBuffer.wrap ("bye".getBytes ());
}else {
sendBf = ByteBuffer.wrap (response.getBytes ());
}
while (sendBf.hasRemaining ()){
channel.write (sendBf);//非阻塞 继续循环等待新的连接 或者处理同一个客户端发来的请求
}
}
}
}
// 用到了非阻塞的API, 在设计上,和BIO可以有很大的不同.继续改进
}
}
客户端代码
package com.dongnaoedu.network.humm;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;
/**
* @author Heian
* @time 19/06/16 19:37
* @copyright(C) 2019 深圳市长亮保泰
* 用途:
*/
public class SocketChannel1 {
public static void main(String[] args) throws Exception{
SocketChannel socketChannel = SocketChannel.open ();
socketChannel.configureBlocking (false);//设置为非阻塞模式
socketChannel.connect (new InetSocketAddress ("127.0.0.1",8080));
while (!socketChannel.finishConnect ()){
Thread.yield ();//没有连接则阻塞在此
}
Scanner scanner = new Scanner(System.in);
System.out.println("请输入:");
while (true){
if (scanner.hasNext ()){ //不输入就阻塞到此
String sendMsg = scanner.next ();
ByteBuffer sendBf = ByteBuffer.allocate (1024);
sendBf.put (sendMsg.getBytes ());
sendBf.flip ();
while (sendBf.hasRemaining ()){
socketChannel.write (sendBf);//发送数据 向通道写入数据
}
//读取响应数据
ByteBuffer receiveBf = ByteBuffer.allocate (1024);//默认是写模式
while (socketChannel.isConnected () && socketChannel.read (receiveBf) != -1){//非阻塞 没有值就返回0
// 长连接情况下,需要手动判断读取数据有没有读取结束,可能数据量很大,远超过1kb (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (receiveBf.position() > 0) break;//读操作会默认读到的数组存到receiveBf
}
receiveBf.flip ();//切换至读模式
byte[] bytes = new byte[receiveBf.limit()];
ByteBuffer bf= receiveBf.get (bytes);//将刚才写入的数据 按照1kb大小读取
String receiveMsg = new String (bf.array ());
System.out.println ("读取到的数据为:" + receiveMsg);
if ("bye".equals (receiveMsg)){
break;
}
}
}
socketChannel.close ();
scanner.close ();
}
}
步骤:先启动服务端,然后开启多个客户端实例,我这边开了两个。
但很明显上述依然存在性能问题,就是不断地通过轮询很浪费cpu资源,假设有100个channel,那么也就会去轮询100次,而且有的是连接上的但是没发数据过来,一直在那做轮询,所以很浪费,那么怎么解决呢?往下看
selector选择器
selector是nio的一个组件,可以检查一个或多个nio通道,并确定哪些通道可以进行读取和写入,实现单个线程管理多个通道,实现单线程管理多个通道,从而实现多个网络连接。说白了它就是一个管家,专门来管理channel通道的连接。
一个线程使用selector监听多个channel的不同事件,四个事件分别对应selectionkey的四个常量:
- connect连接(SelectionKey.OP_CONNECT)
- accept准备就绪(OP_ACCEPT)
- Read读取(OP_READ)
- Write写入(OP_WRITE)
实现一个通道处理多个通道的核心概念理解:事件驱动机制。
非阻塞的网络通道,我们只需要selector注册通道感兴趣的事件类型,线程通过监听事件来触发响应代码的执行(更底层的是操作系统的多路复用),channel对象通过注册的方式,交给Selector管家管理,管理你需要关注的事件,每一个被管理的对象就是一个key,所以会形成一个keys数组,数组元素中就有channel和SelectionKey的状态,一旦被监听到会返回另外一个SelectionKey的集合,里面每个selectionkey存储着channel和这个被管理对象的一些状态(比如是否收到一个连接selectionkey.isAcceptable(),是否有数据刻度selectionkey.isReadable(),是否有连接进来selectionkey.isConnectable()),然后遍历集合,拿到你所需的key,继续交给selector管理,继续去侦听你关注的事件。
下面是selector的大概操作流程:
package com.dongnaoedu.network.nio.demo;
import java.io.IOException;
import java.net.InetSocketAddress;
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.Set;
public class SelectorDemo {
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();//客户端永远是被动地,没有read或者write方法
Selector selector = Selector.open();// 创建Selector
serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);// serverSocketChannel注册OP_READ事件
serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 绑定端口一定要发生在注册之后,防止你启动之后有连接进来没被监听
while(true) {
int readyChannels = selector.select();// 会阻塞,直到有事件触发 调用此方法,监听才开始工作,监听所有连接进来的客户端
if(readyChannels == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();// 获取被触发的事件集合
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
SocketChannel socket = ((ServerSocketChannel) key.channel()).accept();//通过key拿到对应客户端的channel对象
socket.register(selector, SelectionKey.OP_READ);//先跳出此循环,返回上一个循环,当有新事件进来则(可能使我们刚注册的事件)
// serverSocketChannel 收到一个新连接,只能作用于ServerSocketChannel
} else if (key.isConnectable()) {
// 连接到远程服务器,只在客户端异步连接时生效
} else if (key.isReadable()) {
// SocketChannel 中有数据可以读
} else if (key.isWritable()) {
// SocketChannel 可以开始写入数据
}
// 将已处理的事件移除
keyIterator.remove();
}
}
}
}
就是假设一个客户端连接到服务端,然后交给管家管理,然后将事件指派给管家。需要明白的一点就是,同一时刻管家只会侦听同一个通道的一件事情,比如多个客户端将写很多事件交给管家,然后管家就会帮很多人去侦听它们关注的这些事件,然后在各自的客户端去遍历你自身侦听事件的集合(不知道这里说的对不对),比如,执行到写,执行写入操作完成后,你肯定要获取响应,又可以切换到读。然后管家又会去判断您这边是否允许读操作。
下面是改良后的类似聊天的额小程序,有点redis多路复用和消息通知机制的味道,效率高。
服务端:
package com.dongnaoedu.network.humm;
import java.net.InetSocketAddress;
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.Set;
/**
* 结合Selector实现非阻塞服务器
*/
public class NIOServerV2 {
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();// 1. 创建服务端的channel对象
serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式
Selector selector = Selector.open();// 2. 创建Selector
SelectionKey selectionKey = serverSocketChannel.register(selector, 0); // 3. 把服务端的channel注册到selector,注册accept事件
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 4. 绑定端口,启动服务
System.out.println("启动成功");
while (true) {
// 5. 启动selector(管家)
selector.select();// 阻塞,直到事件通知才会返回
Set<SelectionKey> selectionKeys = selector.selectedKeys();//拿到所有客户端的事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
SocketChannel socketChannel = ((ServerSocketChannel) key.channel()).accept();//强转为ServerSocketChannel
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("收到新连接:" + socketChannel);
} else if (key.isReadable()) {// 客户端连接有数据可以读时触发
try {
SocketChannel socketChannel = (SocketChannel) key.channel();// 不再是新连接,则直接强转为SocketChannel
ByteBuffer receivebf = ByteBuffer.allocateDirect(2048);
while (socketChannel.isOpen() && socketChannel.read(receivebf) != -1) {
// 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (receivebf.position() > 0) break;
}
if (receivebf.position() == 0) continue; // 如果没数据了, 则不继续后面的处理
receivebf.flip();
byte[] content = new byte[receivebf.remaining()];
receivebf.get (content);
System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress()+":" + new String (content));
// TODO 业务操作 数据库 接口调用等等 服务端类似生产者 提供数据给消费者
// 响应结果 200
String response = "HTTP/1.1 200 OK\r\n" +
"Content-Length: 11\r\n\r\n" +
"Hello World";
ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
while (buffer.hasRemaining()) {
socketChannel.write(buffer);
}
} catch (Exception e) {
e.printStackTrace();
key.cancel();
}
}
}
}
}
}
客户端:
package com.dongnaoedu.network.humm;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;
import java.util.Set;
public class NIOClientV2 {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
Selector selector = Selector.open();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_CONNECT);
socketChannel.connect(new InetSocketAddress("localhost", 8080));//非阻塞会立即返回
while (true) {
selector.select();//开启管家
Set<SelectionKey> selectionKeys = selector.selectedKeys();//可读 可写 连接成功
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
iterator.remove();
if (selectionKey.isConnectable()) {
try {
if (socketChannel.finishConnect()) {
System.out.println("连接成功-" + socketChannel);
//ByteBuffer buffer = ByteBuffer.allocateDirect(2048);
//selectionKey.attach(buffer); // attach 类似于我们发邮件中的附件 也可以不传,这里只是为了演示此功能
selectionKey.interestOps(SelectionKey.OP_WRITE);//连接成功了,将事件切换至写事件
//socketChannel.register (selector,SelectionKey.OP_WRITE,buffer); //这个也可以 上面两段代码等于这一段
}
} catch (IOException e) {
e.printStackTrace();
return;
}
} else if (selectionKey.isWritable()) {// 可以开始写数据
//ByteBuffer buf = (ByteBuffer) selectionKey.attachment();
//buf.clear();//取到这个附件,将其清空 这里没必要写 这是为了演示下
ByteBuffer sendbf = ByteBuffer.allocate (1024);
Scanner scanner = new Scanner(System.in);
System.out.print("请输入:");
String msg = scanner.next ();
//scanner.close();//这里不能关闭 具体参考https://www.cnblogs.com/qingyibusi/p/5812725.html
sendbf.put(msg.getBytes());
sendbf.flip ();//在写入数据后,一定要切换至读模式
/*
如果我不做那个flip切换到写模式,那么它默认是写模式,假设我写了一个1,那么position 就是1 limit1024,capacity也是1024,这样通过socketchannel写入通道内的就是
位置1到1024,那肯定是数据为空的,如果我切换至写,那么position就变成了0,kimit就变成了1,那socketchannel写入通道的就是0到1
*/
while (sendbf.hasRemaining()) {
socketChannel.write(sendbf);
}
selectionKey.interestOps(SelectionKey.OP_READ);// 切换到感兴趣的事件
} else if (selectionKey.isReadable()) {// 可以开始读数据
System.out.println("收到服务端响应:");
ByteBuffer receivebf = ByteBuffer.allocate(1024);
while (socketChannel.isOpen() && socketChannel.read(receivebf) != -1) {//没有数据,就不断轮询 不能说是阻塞
// 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
if (receivebf.position() > 0) break;
}
receivebf.flip();//切换至读取模式
byte[] content = new byte[receivebf.remaining()];
ByteBuffer bf = receivebf.get (content);
System.out.println("收到服务端端数据:" + socketChannel +new String(bf.array (),"utf-8"));
selectionKey.interestOps(SelectionKey.OP_WRITE);
}
}
}
}
}
总结:
BIO阻塞IO线程等待时间长,一个线程负责一个连接,线程多且利用率低,NIO非阻塞IO,线程利用率高,一个线程处理多个连接事件,性能强大,tomcat8已经移除了BIO网络处理相关代码,默认采用NIO处理网络请求,NIO为开发者提供了丰富的API,但是在网络应用中直接使用API比较繁琐,而且将性能提升光有NIO是不够的,还需要将多线程结合,而且开源社区有对NIO进行封装的框架,如Netty、Mina等,后续将继续更新。