博主最近去了解了下网络编程的知识,这里做一个笔记进行记录。
首先给出一个很基础的客户端代码用于测试服务端逻辑执行情况:
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 8888);
Scanner scanner = new Scanner(System.in);
String datas = scanner.nextLine();
socket.getOutputStream().write(datas.getBytes(StandardCharsets.UTF_8));
scanner.close();
socket.close();
}
BIO(同步阻塞IO)
在jdk1.4之前,对于网络编程,java只有BIO(Blocking -IO)可用,但由于BIO是同步阻塞的,在服务器需要去接收客户端连接,及对客户端的I/O操作都会被阻塞,造成的结果就是当一个线程在等待客户端写入数据时,就无法去进行其他操作,相当于瘫痪在这,性能十分低下;
这种情况的服务器代码示例如下:
public static void main(String[] args) throws IOException {
//创建基于BIO的服务器,端口号为8888
ServerSocket serverSocket = new ServerSocket(8888);
//循环处理连接
while (true){
System.out.println("等待连接。。。");
//接收客户端连接,accept方法会阻塞,等待不到连接,线程会放弃cpu一直等在这
Socket socket = serverSocket.accept();
byte[] datas = new byte[1024];
System.out.println("等待读取数据。。。");
//读取消息,read方法同样会阻塞,读取不到数据,线程会放弃cpu一直等在这
socket.getInputStream().read(datas);
//打印消息
System.out.println("客户端数据:" + new String(datas, StandardCharsets.UTF_8));
}
}
以上模型就是一个最简单的BIO单线程服务器,他的阻塞特性,导致他在同一时刻只能为一个客户端进行服务,不支持并发,如果某个客户端连线上服务器后,一直不写入数据,那服务器对别的客户端来说就跟挂了一样,这是不可取的,所以在JDK1.4以前服务器的编写都是采用多线程的模式去编写,示例代码如下:
public static void main(String[] args) throws IOException {
//创建一个缓存线程池
ExecutorService threadPool = Executors.newCachedThreadPool();
//创建基于BIO的服务器,绑定8888端口
ServerSocket serverSocket = new ServerSocket(8888);
//循环处理连接
while (true) {
//accept方法会阻塞,等待不到连接,线程会放弃cpu一直等在这
Socket socket = serverSocket.accept();
//获取到客户端连接时,随后的I/O等操作都交给线程池去处理
threadPool.execute(() -> {
byte[] datas = new byte[1024];
try {
//读取消息
socket.getInputStream().read(datas);
//打印
System.out.println("客户端数据:" + new String(datas, StandardCharsets.UTF_8));
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
以上便是最基本的多线程BIO服务器模型,其中main线程创建完服务器后,就一直循环接收客户端连接,接收到连接后,后续的工作就交由线程池里面的工作线程去处理,由此便可实现BIO服务器对并发的支持;
以上多线程的BIO服务器虽然实现了对并发的支持,但诟病也显而易见,性能实在太差了,这样的处理方式,导致的结果就是一个客户端需要一个线程去处理,那么如果同时有1万个客户端连入,而同时只有1000个线程在于服务器进行交互,那么就相当于服务器为客户端创建的9000个线程就白白的阻塞在这。
NIO(同步非阻塞IO)
为了解决上述问题,在jdk1.4的时候引入了NIO(NON-BLOCKING-IO),是一种同步非阻塞IO,他可以在接收客户端连接,与客户端进行I/O互动的时候都不阻塞,这个特性是让人兴奋的,如此的话我们照着上述的单线程BIO服务器的代码进行简单修改就可以支持并发了!代码如下所示:
public static void main(String[] args) throws IOException {
//开启一个Nio服务
ServerSocketChannel nioServer = ServerSocketChannel.open();
//绑定8888端口
nioServer.bind(new InetSocketAddress(8888));
//设置非阻塞模式
nioServer.configureBlocking(Boolean.FALSE);
//创建一个集合,用于是收集连接上来的客户端
List<SocketChannel> socketChannelList = new ArrayList<>();
//一直循环监听客户连接并处理
while(true){
//查看是否有客户端来连接
SocketChannel socket = nioServer.accept();
//有的话添加到socketChannel集合
if (socket != null){
socketChannelList.add(socket);
}
ByteBuffer buffer = ByteBuffer.allocate(1024);
Iterator<SocketChannel> iterator = socketChannelList.iterator();
//轮询整个集合,尝试是否可以读取到数据
while (iterator.hasNext()) {
SocketChannel socketChannel = iterator.next();
//设置read非阻塞
socketChannel.configureBlocking(Boolean.FALSE);
//读取,readLenth代表读取到的数据长度,等于0代表未读取到数据,小于0代表客户端已经断开
int readLenth = socketChannel.read(buffer);
if (readLenth > 0) {
//todo 执行业务逻辑
System.out.println("数据:" + new String(buffer.array(), StandardCharsets.UTF_8));
buffer.flip();
} else if (readLenth < 0) {
//客户端断开则从List中移除对应对象,等待gc回收
iterator.remove();
socketChannel.close();
}
}
}
}
以上代码逻辑十分简单,在while循环中,先去查看是否有客户端进行连接,有的话则添加到客户端连接集合中,然后轮询整个集合,查看是否有写入操作,有的话则将读取数据进行打印,如果轮询到有断开的客户端则从集合中移除。
以上的NIO示例,不仅在单线程的情况下支持并发,并且在性能方面也是大大的得到了跃升,如果再合理的搭配线程池写出来的服务器性能也是碾压BIO的,但是!!上述代码并不是真正的NIO服务器demo,这一段代码的编写,仅是为了表达NIO服务器的工作机制,他就是通过不断轮询的方式去获取进行客户端的连接及与客户端的I/O操作的。
上述代码中,轮询是通过java语言的for循环去进行,而整个NIO服务器的重心也是在轮询这一部分,那么为了优化轮询的性能,java语言的设计师们把轮询的工作交给了OS去做,通过调用OS的epoll函数或者selector函数去做的,这可以大大的提升轮询的性能。
NIO服务器demo代码如下:
public static void main(String[] args) throws IOException {
//创建nio服务器
ServerSocketChannel nioServer = ServerSocketChannel.open();
//绑定地址
nioServer.bind(new InetSocketAddress(8888));
//设置不阻塞
nioServer.configureBlocking(Boolean.FALSE);
//获取一个os底层epoll事件的一个实例,此处用于监听某种事件,如nio的客户端接入事件,读事件,写事件
Selector selector = Selector.open();
//向nioServer中注册选择器与客户端接入事件
nioServer.register(selector, SelectionKey.OP_ACCEPT);
//数据缓存区
ByteBuffer buffer = ByteBuffer.allocate(1024);
System.out.println("nio服务器启动成功。。。");
//一直循环处理事件
while (true) {
//阻塞获取触发的事件数量,注意!! 此处可能是连接事件或者读事件
//即有客户端试图进行连接,或已建立连接的客户端试图写入数据
int count = selector.select();
//被触发的事件个数是否大于0
if (count > 0){
//获取所有事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
//遍历所有事件
if (iterator.hasNext()) {
SelectionKey key = iterator.next();
//一定要将此处取得的key(事件)移除,因为这个key已经获取到并进行处理了
iterator.remove();
//对事件进行处理
//是否可连接,即客户端连接事件
if (key.isAcceptable()){
//进行连接,获取客户端
ServerSocketChannel socketChannel = (ServerSocketChannel) key.channel();
SocketChannel channel = socketChannel.accept();
//配置非阻塞
channel.configureBlocking(Boolean.FALSE);
//注册 读事件,在selector.selectedKeys()时检查触发
channel.register(selector, SelectionKey.OP_READ);
}
//是否可读取,即读取事件
else if (key.isReadable()){
//获取客户端
SocketChannel channel = (SocketChannel) key.channel();
//进行数据读取
buffer.clear();
int readLenth = channel.read(buffer);
if (readLenth > 0){
System.out.println("已接收数据:" + new String(buffer.array(), StandardCharsets.UTF_8));
}
//此处还可以注册 写事件,
//channel.register(selector, SelectionKey.OP_WRITE);
//由于此处的channel没有进行关闭,所以一直都会是可写的,selector.selectedKeys()会一直被触发
}
}
}
}
}
可以看到,这个demo看起来复杂了不少,而且有一个很关键的对象 —> Selector类对象,为了方便理解,你可以把他想象为os系统底层的轮询函数(epoll,selector)对象,那么这段代码的逻辑就是创建好NIO服务后,向NIO服务器中注册选择器,并设置轮询是要捕获的事件(客户端连接事件,读,写事件等),捕获到事件后,在根据事件类型进行处理。
如上就是一个最基本的单线程NIO服务器了,通过与线程池的结合可以获得良好的性能,但是可以看到NIO的使用还是比较复杂,我们还需要解决nio服务器代码和业务代码的分离,当代码量多的时候,整套项目代码可能就显得非常格外繁重,而且各种可能出现的IO异常,网络异常都需要我们去处理,要把这些都做好并不是一件简单的事情。
netty
基于以上原因netty框架被设计了出来,它是JBOSS公司出品的一个异步的,基于事件驱动的高性能网络编程框架,它基于NIO,并作出了良好的封装,对可能出现的IO异常进行了规避和处理,将我们的业务代码进行了隔离,让我们将开发的重心放在业务上,因其优良的稳定性和良好的设计理念在网络编程中特别火,越来越多的程序员都习惯使用netty去进行网络开发。
首先先上一个netty的代码demo:
public class NettyServerSocket {
private Integer port;
public NettyServerSocket(Integer port) {
this.port = port;
}
public void run(){
//创建一个线程组,用于处理客户端连接
EventLoopGroup bossGroup = new NioEventLoopGroup();
//创建一个线程组,用于处理与客户端的I/O操作
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
//引导类,帮助服务启动的辅助类,可以设置 Socket参数
ServerBootstrap b = new ServerBootstrap();
//将上述创建的两个线程组设置进来
b.group(bossGroup, workerGroup)
//指定所使用的NIO传输
.channel(NioServerSocketChannel.class)
//添加channel初始化器
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
//添加一个channel处理器,为我们自定义的类,需要继承ChannelHandler
ch.pipeline().addLast(new MyNioServerHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true);
// 绑定端口,开始接收进来的连接
ChannelFuture f = b.bind(port).sync(); // (7)
// 等待服务器 socket 关闭 。
// 在这个例子中,这不会发生,但你可以优雅地关闭你的服务器。
f.channel().closeFuture().sync();
} catch (Exception e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
public static void main(String[] args) {
new NettyServerSocket(8888).run();
}
}
/**
* 业务处理器
* 父类有很多可供重写的方法
*/
class MyNioServerHandler extends ChannelInboundHandlerAdapter {
/**
* 当有客户端连入时,此方法会被调用
* @param ctx 连接上下文对象
* @throws Exception 可能出现的异常
*/
@Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
super.channelRegistered(ctx);
}
//当收到客户端消息时,此方法会被调用
public void channelRead(ChannelHandlerContext ctx,Object msg){
//强转,netty优化了反人类的ByteBuffer类,变成了较为人性化的ByteBuf
ByteBuf buf = (ByteBuf) msg;
//打印消息
System.out.println(buf.toString(CharsetUtil.UTF_8));
//回写消息
ctx.write(buf);
}
//在channelRead执行完后,此方法会被调用
public void channelReadComplete(ChannelHandlerContext ctx){
//将换区中的数据刷至传输流中,传给客户端
ctx.flush();
}
//出异常时调用,用于异常处理
public void exceptionCaught(ChannelHandlerContext ctx,Throwable cause){
//打印异常堆栈信息
cause.printStackTrace();
//关闭客户端连接,释放资源
ctx.close();
}
}
上述代码如果对netty框架不了解是不太好看懂的,因为这里不讲netty框架,所以只简单的解释以下上述代码,就是通过netty框架开启了一个nio服务,期间设置了一些初始化的参数,在这些参数中我们要关心的是那个由我们自己编写的handler,上述代码中是MyNioServerHandler,在这个handler类中可以看到,每个方法都是对于一种客户端事件的处理,连接事件,读写事件等等。
由于代码上的注释也比较清晰,这里就不做多的代码讲解了,可以看到netty框架的使用很简单,初始化一个netty服务器,然后在我们设计的handler中去编写我们的业务代码即可,很好的解开了服务器代码和业务代码的代码耦合,并且还帮我们规避了很多业务代码以外的风险,它里面封装的ByteBuf类也比NIO中反人类的ByteBuffer好用很多。
AIO (异步非阻塞IO)
AIO是JDK1.7的时候引入的,它是一个异步非阻塞的IO,它和NIO在概念上的区别就是一个异步,一个同步,这里说的形象一点,他们的差别就是,加入某个线程去read某个客户端的数据,对于NIO而言,是谁去dead,那么后续的处理也是谁干,而AIO则不一样,它会去调一个空闲的线程去干;
AIO的对于客户连接事件,读/写事件的处理跟使用Netty的时候是很像的,也是一种事件触发的方式,即我们提前写好对于这些事件的处理函数,然后当事件发生的时候就自动取调用这个函数,我们看以下代码:
public class AioServerSocket {
//此处要设置aioServer为属性,由于在handler中要使用到这个属性
public static AsynchronousServerSocketChannel serverSocketChannel;
public static void main(String[] args) throws IOException, InterruptedException {
//生成并封装一个处理线程池供nio服务器使用
AsynchronousChannelGroup threadPoolGroup = AsynchronousChannelGroup.withFixedThreadPool(2,
Executors.defaultThreadFactory());
//注册aio的服务器
serverSocketChannel = AsynchronousServerSocketChannel.open(threadPoolGroup);
//绑定端口
serverSocketChannel.bind(new InetSocketAddress(8888));
//开始接受连接
//参数一传的对象必须是参数二第二个泛型的子类,然后此对象会作为参数二对象的completed方法的参数
//参数二是一个处理器,当有客户端连接时会调用该处理器的completed方法去处理
serverSocketChannel.accept(new AioServerSocket(), new ReceivedHandler());
System.out.println("aio服务器启动了。。。");
//因为Aio是异步不阻塞的
//所以为了防止main线程结束,此处让其一直循环休眠
while (true){
Thread.sleep(1000);
}
}
}
/**
* 客户端连接处理器
*
* @author zeng wenbin
* @date Created in 2019/11/11
*/
class ReceivedHandler implements CompletionHandler<AsynchronousSocketChannel, AioServerSocket>{
//用来缓存数据
private ByteBuffer buffer = ByteBuffer.allocate(1024);
/**
* 当有客户端接入会触发此方法
*/
@Override
public void completed(AsynchronousSocketChannel result, AioServerSocket attachment) {
try {
//读取客户端数据,使用读取的处理器去执行
result.read(buffer, result, new ReadHandler(buffer));
//此处要循环调用,否则只会处理一次连接请求
AioServerSocket.serverSocketChannel.accept(attachment, this);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 出异常时会调用此方法
* @param exc
* @param attachment
*/
@Override
public void failed(Throwable exc, AioServerSocket attachment) {
try{
exc.printStackTrace();
} finally {
// 监听新的请求,递归调用
AioServerSocket.serverSocketChannel.accept(attachment, this);
}
}
}
/**
* 客户端数据读取处理器
*
* @author zeng wenbin
* @date Created in 2019/11/11
*/
class ReadHandler implements CompletionHandler<Integer, AsynchronousSocketChannel>{
private ByteBuffer buffer;
public ReadHandler(ByteBuffer buffer) {
this.buffer = buffer;
}
@Override
public void completed(Integer result, AsynchronousSocketChannel channel) {
try {
if (result < 0) {// 客户端关闭了连接
channel.close();
} else if (result == 0) {
System.out.println("空数据"); // 处理空数据
} else {
// 读取请求,处理客户端发送的数据
buffer.flip();
channel.read(buffer);
System.out.println("接收到数据:" + new String(buffer.array(), StandardCharsets.UTF_8));
}
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, AsynchronousSocketChannel channel) {
exc.printStackTrace();
try {
channel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
可以看到上述存在3个类,由于代码中的注释也写的比较细了,这里就说以下关键点,第一个类的主线程中初始化了AIO服务器,同时注册了一个客户端连接的处理器,这样在客户端连接进来的时候,他会自动异步调用客户端连接的对应的方法,然后再这个处理器的completed方法中又向客户端连接对象注册了客户数据读取处理器ReadHandler,这样在已连接的客户端进行数据写入操作时,它又会立马异步调用ReadHandler中的complated方法,这就是AIO了。
可以看到AIO也完成了很好的代码解耦,我们也可以在AIO中专心书写我们的业务代码,但总的来说AIO对于功能的划分没有netty细致,而且netty为我们封装了很多很好用的类和方法,所以现在主流是使用netty去进行开发。
而对于性能方面,AIO和NIO一样都充分调用OS参与,需要操作系统的支持,但NIO基本都是使用操作系统的epoll函数,而AIO不太一样,它在window系统中调用了一个事件触发的机制,而epoll函数的原理却依然是轮询,所以在window系统中AIO的性能是比NIO好的,但是在linux系统中是没有事件触发这种机制的,所以不管是AIO还是NIO在linux系统上都是采用epoll函数做轮询,性能可以说是一样的。
而正是由于AIO和NIO两者在linux系统上性能基本一致,所以netty框架一致都基于NIO做开发,没有转向AIO,毕竟服务器大多都是放在linux上的。