前言
现在,越来越多的公司都在招聘要求中写上了熟悉javaIO,NIO编程, 这也NIO已经成为了java程序员所必须应该掌握的,平时的工作中或许不会用到NIO编程,但是掌握NIO的思想是很好的
IO编程:
在讲解NIO之前,首先要了解一下IO编程,思考一下:编写一个程序,客户端每2s给服务端发送消息,服务端接收到消息后打印出客户端的发送过来的消息
服务端代码
package test.java.test;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/**
* @author zhtttylz
*/
public class ServerIO {
public static void main(String[] args) throws IOException {
// 创建serverSocket对象,监听8989端口
ServerSocket serverSocket = new ServerSocket(8989);
// 这里使用了内部类,方便在一个代码中进行展示
new Thread(new Runnable() {
@Override
public void run() {
while(true){
try {
// 如果有连接进入,就向下进行处理,否则一直在这里进行阻塞
Socket socket = serverSocket.accept();
// 对每一个新连接新建一个线程进行处理,这里可以使用线程池来进行优化
new Thread(new Runnable() {
@Override
public void run() {
byte[] b = new byte[1024];
try {
// 从连接对象中获取输入流
InputStream inputStream = socket.getInputStream();
// 不停的检测这个连接是否有数据发送过来
while (true){
int len = 0;
while ((len = inputStream.read(b)) != -1) {
System.out.println(new String(b, 0, len));
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}).start();
}
}
服务端监听了8989端口,每次有连接进入,就新创建一个线程来进行这个连接处理,使用while循环对每个连接进来的线程进行监控,看是否发送了数据,如果发送数据就从inputstream中读取1024个字节,然后打印到控制台
客户端代码
package test.java.test;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
/**
* @author zhtttylz
*/
public class ClientIO {
public static void main(String[] args) throws IOException, InterruptedException {
Socket socket = new Socket("127.0.0.1", 8989);
for(int i = 0; i < 10; i++){
int a = i;
new Thread(new Runnable() {
@Override
public void run() {
// 通过socket获取outputStream,将内容写入,然后进行内容的传输
try {
OutputStream outputStream = socket.getOutputStream();
outputStream.write(("hello 你好啊! 我是客户端" + a).getBytes());
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(2000);
}
}
}
客户端相对来说很简单,就是每隔2s给服务端发送消息
问题
从上面可以看出来,传统的IO必须每一个新连接都需要进行线程的创建,就算可以使用线程池,也只能缓解,无法再根本上解决问题,比如如果有10000个连接,这个时候就需要10000个线程来进行连接的维护,这样会带来如下几个问题:
- 线程资源受限:线程是操作系统中非常宝贵的资源,频繁的创建while阻塞线程是非常严重的资源浪费,操作系统耗不起
- 线程切换效率低下:单机cpu核数固定,线程爆炸之后操作系统频繁进行线程切换,应用性能急剧下降。
- 除了以上两个问题,IO编程中,我们看到数据读写是以字节流为单位,效率不高
所以java在jdk1.4之后提出了NIO
JAVA NIO编程
相信NIO很多人听过并且熟悉它的概念,这里不对NIO进行深入的分析,只是阐述它相比较于传统的IO的区别,又或者可以这么说,它相比于传统的IO做了哪些优化
- 传统的对每个进程都新建一个线程进行处理,每个线程内部使用while死循环来进行数据是否发送过来的监控,传统的IO模型如图
- NIO对于每个连接不再新建线程进行处理,而是将某个连接直接绑定到一个固定的线程,以后这个线程就是这个连接的处理者,负责这个连接的读写操作 ,NIO的线程模型如图
如图所示,如果每个线程连接进来,但是并不是一直在发送数据,只是偶尔发送一次,这时,传统的io中的每个线程中的死循环(用于检测是否有数据可读)就会造成大量的资源浪费,而在NIO中,它把每个线程的循环变成了一个循环,用于检测是否有数据发送过来,这是如何做到的呢.主要还是在于多路复用器Selector,每次新来一个连接,不是新建一个线程进行处理,而是将其绑定到selector上,selector可以将绑定在上面发送了数据的连接批量检测出来进行处理,这也就是NIO和传统IO最大的区别
小结
经过上面的图解可以看出NIO可以使用一个线程监视很多的连接通道,而传统的IO必须为每个连接新建一个线程进行管理
例子
场景:公司一共有100个人,到了下班时间,员工需要回家了
- 总经理IO的处理方式:给每个员工配车,每个人开车回家,这种老板是每个员工都希望看到的,但是这种方式是很耗费公司财产的,如果员工多了,公司也很难承担的起每辆车的购买费用
- 总经理NIO的处理方式:公司买一辆大巴车,所有的员工做大巴车,将每个员工送到他们的家,这样就节省了购买车所需要的成本,只需要买一辆车就可以了,如果员工多了,一辆大巴车不够,那么久再买大巴车
这就是NIO的处理方式,一个线程绑定多个连接,对这些链接进行监控,减少了线程的开支,由于NIO中线程的个数少了,所以线程切换效率也大大提高
IO是以字节流为单位进行读取的
IO是以字节流进行读取的,而NIO内部维护了一个缓冲区,每次可以从这个缓冲区中按块进行数据的读取,类似于抓鱼,IO使用鱼竿钓鱼,每次只能钓上来一条,NIO则使用渔网,一次捞上来一堆,效率得到了很大的提升
使用NIO来对之前的项目进行改造
了解了NIO和IO的大体区别,我们就将原本的项目进行改造,这里只对服务端进行改造,如果有兴趣可以自己把客户端进行改造
原本代码的改造(请作好心里准备)
NIO服务端
package test.java.test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
/**
* @author zhtttylz
*/
public class ServerNIO {
public static void main(String[] args) throws IOException {
// 通过Selector的open方法获取selector对象
Selector selector = Selector.open();
// 同样是创建一个线程进行连接的处理
new Thread(new Runnable() {
@Override
public void run() {
try {
// 获取一个通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 将端口8989绑定到这个通道上
serverSocketChannel.socket().bind(new InetSocketAddress(8989));
// 将这个通道注册给selector,标记这个通道的状态是1<<4 也就是16(准备就绪)
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 设置为非阻塞状态
serverSocketChannel.configureBlocking(false);
// 将selector进行轮询
while (true) {
// 设置阻塞时间是1ms
if (selector.select(1) > 0) {
// 获取通道中发生了变化的连接,可能是新连接,也有可能是旧的连接发送了数据
Set<SelectionKey> set = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// 如果新进来的连接是准备状态,也就是新连接进来的连接,那么就将其设置为可读取状态,将其注册到轮询器中
SocketChannel newChannel = ((ServerSocketChannel) key.channel()).accept();
newChannel.configureBlocking(false);
newChannel.register(selector, SelectionKey.OP_READ);
} finally {
// 注意:这里要将处理过的节点进行删除
keyIterator.remove();
}
}else if(key.isReadable()){ // 如果是可以发送数据的状态
SocketChannel socketChannel = (SocketChannel)key.channel();
// 建立一个缓冲区,进行数据的读取
ByteBuffer buf = ByteBuffer.allocate(1024);
socketChannel.read(buf);
System.out.println(buf.toString());
}
}
}
}
} catch (ClosedChannelException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
如果是没有进行过NIO方面学习的,估计对里面很多的类不是很熟悉,这个例子只是进行了连接和读操作的判断,一些其余的判断并未写出,如果有兴趣可以自行进行补充
个人感受
使用java原生的类库实现NIO竟然如此麻烦和复杂,并且对于初次接触这方面的人来说很不友好(我第一次是没看懂,被自己菜醒),接下来介绍一下NIO编程的核心思想(这里不详细对每个组件进行介绍)
NIO编程的核心思想
- selector:用于轮询是否有新的连接,如果有新的连接,就要对其进行处理,那是不是可以使用两个selector,将新连接进入和旧连接发送消息分隔开呢,其实上面的服务端可以进行优化,创建两个selector,一个负责进行新连接的轮询,一个负责监控连接进来的连接是否要发送数据,在这里我们对服务端再次进行优化
package test.java.test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
/**
* @author zhtttylz
*/
public class ServerNIO {
public static void main(String[] args) throws IOException {
// 创建两个selector用来进行新连接的监控和旧连接发送消息的处理
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();
// 同样是创建一个线程进行连接的处理
new Thread(new Runnable() {
@Override
public void run() {
try {
// 获取一个通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 将端口8989绑定到这个通道上
serverSocketChannel.socket().bind(new InetSocketAddress(8989));
// 将这个通道注册给selector,标记这个通道的状态是1<<4 也就是16(准备就绪)
serverSocketChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
// 设置为非阻塞状态
serverSocketChannel.configureBlocking(false);
// 将selector进行轮询
while (true) {
// 设置阻塞时间是1ms
if (serverSelector.select(1) > 0) {
// 获取通道中发生了变化的连接,可能是新连接,也有可能是旧的连接发送了数据
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// 如果新进来的连接是准备状态,也就是新连接进来的连接,那么就将其设置为可读取状态,将其注册到轮询器中
SocketChannel newChannel = ((ServerSocketChannel) key.channel()).accept();
newChannel.configureBlocking(false);
// 注意:这里要将已经进来的管道注册到clientSelector中中
newChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
// 注意:这里要将处理过的节点进行删除
keyIterator.remove();
}
}
}
}
}
} catch (ClosedChannelException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
while (true) {
// 设置阻塞时间是1ms
if (clientSelector.select(1) > 0) {
// 获取通道中发生了变化的连接,可能是新连接,也有可能是旧的连接发送了数据
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 如果连接进来的通道发送了读操作
if(key.isReadable()){
try {
SocketChannel socketChannel = (SocketChannel)key.channel();
// 建立一个缓冲区,进行数据的读取
ByteBuffer buf = ByteBuffer.allocate(1024);
socketChannel.read(buf);
System.out.println(buf.toString());
}finally {
keyIterator.remove();
}
}
}
}
}
} catch (ClosedChannelException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
- 注意下面这一块:每次拿到新连接进来的channel,将其注册到专门处理已经连接进来的selector(clientSelector)中 ,这样就成功避免了传统IO的每一个线程就新建一个死循环进行发送数据的监控,从而节省了资源
- NIO的每次数据的读写都会以块为单位,也就是bytebuffer
小结(对NIO的吐槽)
- JDK的NIO编程需要了解很多的概念,编程复杂,对NIO入门非常不友好,编程模型不友好,ByteBuffer的api简直反人类,.
- JDK的NIO底层由epoll实现,该实现饱受诟病的空轮训bug会导致cpu飙升100%(说是修好了,但是并没有)
- 项目庞大之后,需要非常多的维护,如果成员变动,新成员根本无法很快的熟悉代码,而且自己造轮子很容易出现BUG(都是前人的血泪史)
基于以上几点,netty都对其进行了很好的解决
我们为什么要使用netty
- netty的API使用简单,开发门槛低于NIO
- 功能强大,支持各种协议的开发,如webSocket(后期会有基于netty的网页视频聊天室的讲解,敬请期待)
- 经历了大规模的应用验证。在互联网、大数据、网络游戏、企业应用、电信软件得到成功,很多著名的框架通信底层就用了Netty,比如Dubb
- 稳定,修复了NIO出现的所有Bug。比如javaNIO的空轮询bug
- Netty自带的拆包解包,异常检测等机制让你从NIO的繁重细节中脱离出来,让你只需要关心业务逻辑
Netty编程
首先需要进行maven依赖的引入,这里使用的是netty4,官方的netty5已经废弃(说是模型有问题,笔者这里并没有深入研究为什么,有兴趣的可以研究一下共同交流)
maven依赖:
<!-- https://mvnrepository.com/artifact/io.netty/netty-all -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.25.Final</version>
</dependency>
然后我们是用netty进行服务端的改造(这里只对服务端进行改造,有兴趣的话可以对客户端进行同样的改造)
netty的服务端改造
这里直接上代码
package chapter1;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
/**
* @author zhtttylz
*/
public class NettyServer {
public static void main(String[] args) {
// 创建两个线程组,分别用来对应传统NIO中的serverSelector和clientSelector
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
// 着这里绑定一个NIOSocketChannel,用于处理新连接进来的客户端的通道,类似于NIO中的ServerSocketChannel
.channel(NioServerSocketChannel.class)
// 在这里添加我们自定义的拦截器,这里为了方便观看,我直接使用内部类进行书写
.childHandler(new ChannelInitializer<NioSocketChannel>() {
protected void initChannel(NioSocketChannel ch) {
// 在这里绑定编解码器
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StringEncoder());
// 在这里添加我们的服务端逻辑,为了方便观看,同样使用的内部类
ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
System.out.println(msg);
}
});
}
});
// 绑定服务端的端口
ChannelFuture f = b.bind(8989).sync();
// 等待服务端监听端口关闭
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
// 优雅的退出,释放线程资源
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
这些代码实现了我们之前所写的所有的功能,整个代码逻辑很清晰,服务端启动,添加通道,绑定编解码器,进行业务处理(这里为了方便看,我都是使用的内部类,真实场景可以将每个内部类抽取为单独的业务代码块),到最后的关闭,是不是相对于之前的NIO感觉简洁了很多
注意:代码中的boss对应的就是我们在NIO中的serverSelector(用于监控新连接的多路复用器),works对应的自然就是clientSelector(监控已经连接进来的连接)了,相信从NIO过渡到Netty并不是一件很困难的事情
结语
如果你没有学习过netty,我相信本系列会是你从0开始的最佳资料,,如果你在工作中需要接触到网络编程,那么掌握netty是应该是属于你的刚需,我会将每一章的代码分包放在github上,如果有需要的朋友可以自行下载,同时也欢迎大家一起进行学习交流
github地址 : https://github.com/zhtttylz/Netty_code_demo (偷偷的求个赞或者star^ˇ^)