史上最通俗Netty入门长文:基本介绍、环境搭建、动手实战

系类文章:
《Netty服务端启动源码分析(一)整体流程》
《Netty服务端启动源码分析(二)服务端Channel的端口绑定》
《Netty核心组件之NioEventLoop(一)创建》
《Netty核心组件之NioEventLoop(二)处理消息》

 

原作者江成军,原题“还在被Java NIO虐?该试试Netty了”,收录时有修订和改动。

1、阅读对象

本文适合对Netty一无所知的Java NIO网络编程新手阅读,为了做到这一点,内容从最基本介绍到开发环境的配置,再到第一个Demo代码的编写,事无巨细都用详细的图文进行了说明。

所以本文这对于新手来说帮助很大,但对于老司机来说,就没有必要了。老司机请绕道哦。

PS:是的,用Java写IM、消息推送的话,基本上都是用的Netty,所以如果你想用Java做即时通讯这类系统,学习Netty肯定没错。

学习交流:

- 即时通讯/推送技术开发交流5群:215477170[推荐]
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM
- 开源IM框架源码:github.com/JackJiang201

2、本文作者

江成军:工信部信息系统项目管理师,全栈工程师,多年丰富的JavaWEB平台、PC端软件、移动端APP开发及培训经验,擅长把复杂的事情搞简单。

3、基本常识

在了解Netty之前,我们非常有必要简要了解一下Java网络编程模型的基本常识,具体说也就是BIO、NIO和AIO这3个技术概念。

BIO、NIO和AIO这三个概念分别对应三种通讯模型:阻塞、非阻塞、非阻塞异步,具体这里就不详细写了。网上好多博客说Netty对应NIO,准确来说,应该是既可以是NIO,也可以是AIO,就看你怎么实现。

这三个概念的区别如下:

  • 1)BIO:一个连接一个线程,客户端有连接请求时服务器端就需要启动一个线程进行处理,线程开销大。
  • 2)NIO:一个请求一个线程,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到该连接有I/O请求时才启动一个线程进行处理;
  • 3)AIO:一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

通俗地概括一下就是:

  • 1)BIO是面向流的,NIO是面向缓冲区的;
  • 2)BIO的各种流是阻塞的,而NIO是非阻塞的;
  • 3)BIO的Stream是单向的,而NIO的channel是双向的。

NIO的的显著特点:事件驱动模型、单线程处理多任务、非阻塞I/O,I/O读写不再阻塞,而是返回0、基于block的传输比基于流的传输更高效、更高级的IO函数zero-copy、IO多路复用大大提高了Java网络应用的可伸缩性和实用性。基于Reactor线程模型。

限于篇幅原因,这里没办法深入展开话题,想深入了解的,可以继续阅读这几篇:

4、认识Netty

4.1 基本介绍

Netty是一个Java NIO技术的开源异步事件驱动的网络编程框架,用于快速开发可维护的高性能协议服务器和客户端。

往通俗了讲,可以将Netty理解为:一个将Java NIO进行了大量封装,并大大降低Java NIO使用难度和上手门槛的超牛逼框架。

PS:Netty的官网是 netty.io/,可以随时下载到最新的Netty源码,以及各种API文档和开发指南。

4.2 技术特征

Netty的优点,概括一下就是:

  • 1)使用简单;
  • 2)功能强大;
  • 3)性能强悍。

Netty的特点:

  • 1)高并发:基于 NIO(Nonblocking IO,非阻塞IO)开发,对比于 BIO(Blocking I/O,阻塞IO),他的并发性能得到了很大提高;
  • 2)传输快:传输依赖于零拷贝特性,尽量减少不必要的内存拷贝,实现了更高效率的传输;
  • 3)封装好:封装了 NIO 操作的很多细节,提供了易于使用调用接口。

Netty的优势:

  • 1)使用简单:封装了 NIO 的很多细节,使用更简单;
  • 2)功能强大:预置了多种编解码功能,支持多种主流协议;
  • 3)扩展性强:可以通过 ChannelHandler 对通信框架进行灵活地扩展;
  • 4)性能优异:通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优;
  • 5)运行稳定:Netty 修复了已经发现的所有 NIO 的 bug,让开发人员可以专注于业务本身;
  • 6)社区活跃:Netty 是活跃的开源项目,版本迭代周期短,bug 修复速度快。

Netty高性能表现在哪些方面?

  • 1)IO 线程模型:同步非阻塞,用最少的资源做更多的事;
  • 2)内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输;
  • 3)内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况;
  • 4)串形化处理读写:避免使用锁带来的性能开销;
  • 5)高性能序列化协议:支持 protobuf 等高性能序列化协议。

限于篇幅,Netty的详细特征就不展开了,有兴趣的可以读一读《新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析》。

5、Netty的作者

个人而言,了解一项技术,比较喜欢扒一下它的作者情况,不是八卦,只是个人习惯,希望对所使用的技术了解地更多更全面而已。

5.1 Netty的创始人

Netty的创始人是韩国人Trustin Lee,80年出生,8岁起在MSX迷你计算机上编写BASIC程序,爱好游戏编程以及使用汇编、C和C++解决编程问题,1998年获得韩国信息奥林匹克竞赛铜牌。

就读于韩国Yonsei大学计算机系期间,曾为多家公司编写高性能网络应用以及少量的web程序,毕业后,就职于Arreo通讯公司,该公司为韩国最大的移动短信提供商之一。

他现在韩国line公司工作(据他个人博客显示,他以于2020年8月底从Line离职了,具体博文 点这里),早前应用较多的Mina也是这牛人的作品。

▲ Trustin Lee 本尊

Trustin Lee大神的其它信息:

5.2 Netty现任Leader

Netty目前的项目leader是德国人Norman Maurer(之前在Redhat,全职开发Netty),也是《Netty in Action》的作者,目前是苹果公司高级工程师。

▲ Norman maurer 本尊

Norman maurer大神的其它信息:

最后,附上两位大神的同框图:

6、Netty能做什么?

学技能都是为了能够应用到实际工作中去,谁也不是为了学而学、弄着玩不是,那么Netty能做什么呢?

主要是在两个方面。

一方面:现在物联网的应用无处不在,大量的项目都牵涉到应用传感器和服务器端的数据通信,Netty作为基础通信组件、能够轻松解决之前有较高门槛的通信系统开发,你不用再为如何解析各类简单、或复杂的通讯协议而薅头发了,有过这方面开发经验的程序员会有更深刻、或者说刻骨铭心的体会。

另一方面:现在互联网系统讲究的都是高并发、分布式、微服务,各类消息满天飞(是的,IM系统、消息推送系统就是其中的典型),Netty在这类架构里面的应用可谓是如鱼得水,如果你对当前的各种应用服务器不爽,那么完全可以基于Netty来实现自己的HTTP服务器、FTP服务器、UDP服务器、RPC服务器、WebSocket服务器、Redis的Proxy服务器、MySQL的Proxy服务器等等。

7、掌握Netty有什么好处?

直接的好处是:能够有进大厂、拿高薪的机会,业内好多著名的公司在招聘高级/资深Java工程师时基本上都要求熟练掌握、或熟悉Netty。

这个名单还可以很长很长。。。

作为一个学Java的,如果没有研究过Netty,那么你对Java语言的使用和理解仅仅停留在表面水平,会点SSH,写几个MVC,访问数据库和缓存,这些只是初、中等Java程序员干的事。如果你要进阶,想了解Java服务器的深层高阶知识,Netty绝对是一个必须要过的门槛。

间接地好处是:多款开源框架中应用了Netty,掌握了Netty,就具有分析这些开源框架的基础了,也就是有了成为技术大牛的基础。

这些开源框架有哪些呢?

简单罗列一些典型的,如下:

  • 1)阿里分布式服务框架 Dubbo 的 RPC 框架;
  • 2)淘宝的消息中间件 RocketMQ
  • 3)Hadoop 的高性能通信和序列化组件 Avro 的 RPC 框架;
  • 4)开源集群运算框架 Spark
  • 5)分布式计算框架 Storm
  • 6)并发应用和分布式应用 Akka
  • 7)名单依然很长很长。。。。

8、理论知识准备

本文的下半部分,将手翅手,带你动手实现一个传输字符串的简单实例。

在开始动手之前,必要的基础概念还是要知道的,要不然代码敲下来,功能倒是实现了,但对Netty还是一头雾水,这就不是本文要达到的目的了。

本示例需要用到的基础知识主要有以下几方面的东东,这些知识点最好有一个大概的了解,要不然,看实例会有一定的困难。

  • 1)掌握Java基础;
  • 2)掌握Maven基础;
  • 3)熟悉IntelliJ IDEA集成开发工具的使用,这个工具简称IDEA;
  • 4)知道TCP、Socket的基本概念。

尤其提一下,TCP、Socket没概念的,下面这几篇一定要读一下:

大致了解一下Netty的主要组件及概念:

  • 1)I/O:各种各样的流(文件、数组、缓冲、管道。。。)的处理(输入输出);
  • 2)Channel:通道,代表一个连接,每个Client请对会对应到具体的一个Channel;
  • 3)ChannelPipeline:责任链,每个Channel都有且仅有一个ChannelPipeline与之对应,里面是各种各样的Handler;
  • 4)handler:用于处理出入站消息及相应的事件,实现我们自己要的业务逻辑;
  • 5)EventLoopGroup:I/O线程池,负责处理Channel对应的I/O事件;
  • 6)ServerBootstrap:服务器端启动辅助对象;
  • 7)Bootstrap:客户端启动辅助对象;
  • 8)ChannelInitializer:Channel初始化器;
  • 9)ChannelFuture:代表I/O操作的执行结果,通过事件机制,获取执行结果,通过添加监听器,执行我们想要的操作;
  • 10)ByteBuf:字节序列,通过ByteBuf操作基础的字节数组和缓冲区。

关于深入理解Netty的这些概念,建议有必要的话,务必详读:新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析》。

对于Netty开发,API文档和源码是最常用的资料,以下是我整理的在线阅读链接:

9、开发环境准备

开发环境准备主要有三个方面:JDK安装及环境变量设置、Maven安装及环境变量设置、IDEA安装及基本设置。

下面请逐个跟着我来傻瓜式配置和操做即可。

9.1 JDK安装及环境变量设置

JDK下载,可以从官方现在,也可以度娘上随便搜下载链接,我这里下载的是JDK8,要注意一点的是,现在从JDK的官网Oracle下载需要账号了,没账号的可下不了啦,不知道在搞什么东东。

官网下载地址:https://www.oracle.com ,截图依次如下:

下载完,一路Next安装完,在创建Java环境变量设置,[此电脑]右键-->[属性]-->[高级系统设置]-->[环境变量]-->[系统变量]。

截图如下:

Java环境变量创建完毕后,在DOS窗口执行命令:java -version,测试一下是否正常

9.2 Maven安装及环境变量设置

Maven功能很强大,但大家不用担心、本实例中仅仅是利用其便利的jar包依赖、jar包依赖传递,基本上没有任何学习成本。

jar包依赖、jar包依赖传递的概念如下图,清楚明了,都不用多做解释:

Maven是下载,解压缩后,配置环境变量后就能用,不用安装的。

下载地址:downloads.apache.org/ma

安装:下载压缩包,解压,文件夹拷贝到所想存储的位置(如C盘根目录)。

配置环境变量,和Java的环境变量配置一样的,创建MAVEN_HOME,指向Maven文件夹,再在path中添加进去就行。

如下图:

由于直接冲Maven的中央仓库中自动下载jar包较慢,一般在Maven的配置文件中,增加阿里云的公共仓库配置,这样会显著加快jar包的下载速度。

如下图所示:

上面的环境变量设置完后,通过DOS窗口中输入命令:mvn -version 进行验证是否成功,如下:

9.3 IDEA安装及基本设置

IDEA的下载和安装就不多说了,其版本分旗舰版和社区版,旗舰版收费,社区版免费,社区版不支持html、js、css等。

但对于本实例,社区版就够用了,但如果你不在乎那点银子,可以考虑旗舰版,一步到位,万一后面我们还要做WEB系统开发可以免得折腾。

其安装不用多说,一路Next就行,安装完后,在其配置里面指定一下JDK、Maven的位置就行了,如下图依次所示。

Maven指定:[File]-->[setting]-->[Build,Excution,Deployment]-->[Build Tools]-->[Maven]

JDK指定:[File]-->[Project Structure]-->[Project Setting]-->[Project]

9.4 在IDEA中创建Maven工程

新建工程:

填写包名及工程名称:

Maven配置:

生成工程,自动创建Maven的依赖文件:

在pom.xml中配置Netty依赖:

经过上面的步骤,我们的Maven工程就已经创建完毕,现在可以编写Netty的第一个程序,这个程序很简单,传输一个字符串,虽然程序很简单,但是已经能够大体上反映Netty开发通信程序的一个整体流程了。

10、开始动手代码实战

10.1 Netty开发的基本套路

Netty开发的基本套路很简洁,服务器端和客户端都是这样。

大致的套路基本如下:

Netty开发的实际代码过程,也确实并不复杂,就像下图这样,绿色的代表客户端流程、蓝色的代表服务器端流程,注意标红的部分。

实际代码过程就像下图这样:

10.2 创建客户端类

10.2.1)创建Handler:

首先创建Handler类,该类用于接收服务器端发送的数据,这是一个简化的类,只重写了消息读取方法channelRead0、捕捉异常方法exceptionCaught。

客户端的Handler一般继承的是SimpleChannelInboundHandler,该类有丰富的方法,心跳、超时检测、连接状态等等。

代码如下:

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.CharsetUtil;
/**
 * @Date: 2020/6/1 11:12
 * @Description: 通用handler,处理I/O事件
 */
@ChannelHandler.Sharable
public class HandlerClientHello extends SimpleChannelInboundHandler<ByteBuf>
{
    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception
    {
        /**
        * @Description  处理接收到的消息
        **/
        System.out.println("接收到的消息:"+byteBuf.toString(CharsetUtil.UTF_8));
    }
 
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throwsException
    {
        /**
        * @Description  处理I/O事件的异常
        **/
        cause.printStackTrace();
        ctx.close();
    }
}

代码说明:

  • 1)@ChannelHandler.Sharable:这个注解是为了线程安全,如果你不在乎是否线程安全,不加也可以;
  • 2)SimpleChannelInboundHandler:这里的类型可以是ByteBuf,也可以是String,还可以是对象,根据实际情况来;
  • 3)channelRead0:消息读取方法,注意名称中有个0;
  • 4)ChannelHandlerContext:通道上下文,代指Channel;
  • 5)ByteBuf:字节序列,通过ByteBuf操作基础的字节数组和缓冲区,因为JDK原生操作字节麻烦、效率低,所以Netty对字节的操作进行了封装,实现了指数级的性能提升,同时使用更加便利;
  • 6)CharsetUtil.UTF_8:这个是JDK原生的方法,用于指定字节数组转换为字符串时的编码格式。

10.2.2)创建客户端启动类:

客户端启动类根据服务器端的IP和端口,建立连接,连接建立后,实现消息的双向传输。

代码较简洁,如下:

import com.sun.org.apache.bcel.internal.generic.ATHROW;
import io.netty.*;
import java.net.InetSocketAddress;
 
/**
 * @Date: 2020/6/1 11:24
 * @Description: 客户端启动类
 */
public class AppClientHello
{
    private final String host;
    private fina lint port;
 
    public AppClientHello(String host, int port)
    {
        this.host = host;
        this.port = port;
    }
 
    public void run() throws Exception
    {
        /**
        * @Description  配置相应的参数,提供连接到远端的方法
        **/
        EventLoopGroup group = newNioEventLoopGroup();//I/O线程池
        try{
            Bootstrap bs = newBootstrap();//客户端辅助启动类
            bs.group(group)
                    .channel(NioSocketChannel.class)//实例化一个Channel
                    .remoteAddress(newInetSocketAddress(host,port))
                    .handler(newChannelInitializer<SocketChannel>()//进行通道初始化配置
                    {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception
                        {
                            socketChannel.pipeline().addLast(newHandlerClientHello());//添加我们自定义的Handler
                        }
                    });
 
            //连接到远程节点;等待连接完成
            ChannelFuture future=bs.connect().sync();
 
            //发送消息到服务器端,编码格式是utf-8
            future.channel().writeAndFlush(Unpooled.copiedBuffer("Hello World", CharsetUtil.UTF_8));
 
            //阻塞操作,closeFuture()开启了一个channel的监听器(这期间channel在进行各项工作),直到链路断开
            future.channel().closeFuture().sync();
 
        } finally{
            group.shutdownGracefully().sync();
        }
    }
 
    public static void main(String[] args) throws Exception
    {
        new AppClientHello("127.0.0.1",18080).run();
    }
}

由于代码中已经添加了详尽的注释,这里只对极个别的进行说明:

  • 1)ChannelInitializer:通道Channel的初始化工作,如加入多个handler,都在这里进行;
  • 2)bs.connect().sync():这里的sync()表示采用的同步方法,这样连接建立成功后,才继续往下执行;
  • 3)pipeline():连接建立后,都会自动创建一个管道pipeline,这个管道也被称为责任链,保证顺序执行,同时又可以灵活的配置各类Handler,这是一个很精妙的设计,既减少了线程切换带来的资源开销、避免好多麻烦事,同时性能又得到了极大增强。

10.3 创建服务器端类

10.3.1)创建Handler:

和客户端一样,只重写了消息读取方法channelRead(注意这里不是channelRead0)、捕捉异常方法exceptionCaught。

另外服务器端Handler继承的是ChannelInboundHandlerAdapter,而不是SimpleChannelInboundHandler,至于这两者的区别,这里不赘述,大家自行百度吧。

代码如下:

import io.netty.*;
/**
 * @Date: 2020/6/1 11:47
 * @Description: 服务器端I/O处理类
 */
@ChannelHandler.Sharable
public class HandlerServerHello extends ChannelInboundHandlerAdapter
{
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg)  throws Exception
    {
        //处理收到的数据,并反馈消息到到客户端
        ByteBuf in = (ByteBuf) msg;
        System.out.println("收到客户端发过来的消息: "+ in.toString(CharsetUtil.UTF_8));
 
        //写入并发送信息到远端(客户端)
        ctx.writeAndFlush(Unpooled.copiedBuffer("你好,我是服务端,我已经收到你发送的消息", CharsetUtil.UTF_8));
    }
 
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception
    {
        //出现异常的时候执行的动作(打印并关闭通道)
        cause.printStackTrace();
        ctx.close();
    }
}

以上代码很简洁,大家注意和客户端Handler类进行比较。

10.3.2)创建服务器端启动类:

服务器端启动类比客户端启动类稍显复杂一点,先贴出代码如下:

import io.netty.*;
import java.net.InetSocketAddress;
 
/**
 * @Date: 2020/6/1 11:51
 * @Description: 服务器端启动类
 */
public class AppServerHello
{
    private int port;
 
    public AppServerHello(int port)
    {
        this.port = port;
    }
 
    public void run() throws Exception
    {
        EventLoopGroup group = newNioEventLoopGroup();//Netty的Reactor线程池,初始化了一个NioEventLoop数组,用来处理I/O操作,如接受新的连接和读/写数据
        try{
            ServerBootstrap b = newServerBootstrap();//用于启动NIO服务
            b.group(group)
                    .channel(NioServerSocketChannel.class) //通过工厂方法设计模式实例化一个channel
                    .localAddress(newInetSocketAddress(port))//设置监听端口
                    .childHandler(newChannelInitializer<SocketChannel>() {
                        //ChannelInitializer是一个特殊的处理类,他的目的是帮助使用者配置一个新的Channel,用于把许多自定义的处理类增加到pipline上来
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {//ChannelInitializer 是一个特殊的处理类,他的目的是帮助使用者配置一个新的 Channel。
                            ch.pipeline().addLast(new HandlerServerHello());//配置childHandler来通知一个关于消息处理的InfoServerHandler实例
                        }
                    });
 
            //绑定服务器,该实例将提供有关IO操作的结果或状态的信息
            ChannelFuture channelFuture= b.bind().sync();
            System.out.println("在"+ channelFuture.channel().localAddress()+"上开启监听");
 
            //阻塞操作,closeFuture()开启了一个channel的监听器(这期间channel在进行各项工作),直到链路断开
            channelFuture.channel().closeFuture().sync();
        } finally{
            group.shutdownGracefully().sync();//关闭EventLoopGroup并释放所有资源,包括所有创建的线程
        }
    }
 
    public static void main(String[] args)  throws Exception
    {
        new AppServerHello(18080).run();
    }
}

代码说明:

  • 1)EventLoopGroup:实际项目中,这里创建两个EventLoopGroup的实例,一个负责接收客户端的连接,另一个负责处理消息I/O,这里为了简单展示流程,让一个实例把这两方面的活都干了;
  • 2)NioServerSocketChannel:通过工厂通过工厂方法设计模式实例化一个channel,这个在大家还没有能够熟练使用Netty进行项目开发的情况下,不用去深究。

到这里,我们就把服务器端和客户端都写完了 ,如何运行呢,先在服务器端启动类上右键,点Run 'AppServerHello.main()'菜单运行,见下图。

然后,再同样的操作,运行客户端启动类,就能看见效果了。

11、写在最后

本文的内容就到这里结束了,希望本文能够让大家对Netty有一个整体的认识,并大概了解其开发流程。

Netty的功能很多,本文只是一个入门的介绍,如果大家对于Netty开发有兴趣,可以关注我并给我留言,我会根据关注和留言情况,陆续再撰写Netty实战开发的文章。

得到肯定和正向反馈,才有继续写下去的愿望和动力,毕竟写这种事无巨细的文章,还是挺费精力的。

附录:更多NIO异步网络编程资料

Java新一代网络编程模型AIO原理及Linux系统AIO介绍
有关“为何选择Netty”的11个疑问及解答
开源NIO框架八卦——到底是先有MINA还是先有Netty?
选Netty还是Mina:深入研究与对比(一)
选Netty还是Mina:深入研究与对比(二)
NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示
NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示
NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战
NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战
Netty 4.x学习(一):ByteBuf详解
Netty 4.x学习(二):Channel和Pipeline详解
Netty 4.x学习(三):线程模型详解
Apache Mina框架高级篇(一):IoFilter详解
Apache Mina框架高级篇(二):IoHandler详解
MINA2 线程原理总结(含简单测试实例)
Apache MINA2.0 开发指南(中文版)[附件下载]
MINA、Netty的源代码(在线阅读版)已整理发布
解决MINA数据传输中TCP的粘包、缺包问题(有源码)
解决Mina中多个同类型Filter实例共存的问题
实践总结:Netty3.x升级Netty4.x遇到的那些坑(线程篇)
实践总结:Netty3.x VS Netty4.x的线程模型
详解Netty的安全性:原理介绍、代码演示(上篇)
详解Netty的安全性:原理介绍、代码演示(下篇)
详解Netty的优雅退出机制和原理
NIO框架详解:Netty的高性能之道
Twitter:如何使用Netty 4来减少JVM的GC开销(译文)
绝对干货:基于Netty实现海量接入的推送服务技术要点
Netty干货分享:京东京麦的生产级TCP网关技术实践总结
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析
写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略
少啰嗦!一分钟带你读懂Java的NIO和经典IO的区别
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!
手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制
Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
史上最通俗Netty入门长文:基本介绍、环境搭建、动手实战 - 知乎 (zhihu.com)
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!-网络编程/专项技术区 - 即时通讯开发者社区! (52im.net)

本文原题“《NIO 入门》,作者为“Gregory M. Travis”,他是《JDK 1.4 Tutorial》等书籍的作者。


1、引言


Java NIO是Java 1.4版加入的新特性,虽然Java技术日新月异,但历经10年,NIO依然为Java技术领域里最为重要的基础技术栈,而且依据现实的应用趋势,在可以预见的未来,它仍将继续在Java技术领域占据重要位置。

网上有关Java NIO的技术文章,虽然写的也不错,但通常是看完一篇马上懵逼。接着再看!然后,会更懵逼。。。 哈哈哈!

本文作者厚积薄发,以远比一般的技术博客或技术作者更深厚的Java技术储备,为你由浅入深,从零讲解到底什么是Java NIO。本文即使没有多少 Java 编程经验的读者也能很容易地开始学习 NIO。

2、关于作者


Gregory M. Travis:技术顾问、多产的技术作家,现居纽约。他从Java语言发布的第1天起,就已经是Java程序员啦

史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_a.jpg

Gregory M. Travis是《JDK 1.4 Tutorial》一书的作者,Java程序员应该都清楚,能写好JDK Tutorial这种书籍或手册的,除了SUN(现在是Oracle)公司的Java创建者们,余下的也只有各路实打实的Java大牛们才能hold住。

3、在开始之前

 

3.1关于本教程


新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。通过定义包含数据的类,以及通过以块的形式处理这些数据,NIO 不用使用本机代码就可以利用低级优化,这是原来的 I/O 包所无法做到的。

在本教程中,我们将讨论 NIO 库的几乎所有方面,从高级的概念性内容到底层的编程细节。除了学习诸如缓冲区和通道这样的关键 I/O 元素外,您还有机会看到在更新后的库中标准 I/O 是如何工作的。您还会了解只能通过 NIO 来完成的工作,如异步 I/O 和直接缓冲区。

在本教程中,我们将使用展示 NIO 库的不同方面的代码示例。几乎每一个代码示例都是一个大的 Java 程序的一部分,您可以在本文末的附件 中下载到这个 Java 程序。在做这些练习时,我们推荐您在自己的系统上下载、编译和运行这些程序。在您学习了本教程以后,这些代码将为您的 NIO 编程努力提供一个起点。

本教程是为希望学习更多关于 Java NIO 库的知识的所有程序员而写的。为了最大程度地从这里的讨论中获益,您应该理解基本的 Java 编程概念,如类、继承和使用包。多少熟悉一些原来的 I/O 库(来自java.io.* 包)也会有所帮助。

虽然本教程要求掌握 Java 语言的工作词汇和概念,但是不需要有很多实际编程经验。除了彻底介绍与本教程有关的所有概念外,我还保持代码示例尽可能短小和简单。目的是让即使没有多少 Java 编程经验的读者也能容易地开始学习 NIO。

3.2如何运行代码


源代码归档文件(请从本文末的附件下载之)包含了本教程中使用的所有程序。每一个程序都由一个 Java 文件构成。每一个文件都根据名称来识别,并且可以容易地与它所展示的编程概念相关联。

教程中的一些程序需要命令行参数才能运行。要从命令行运行一个程序,只需使用最方便的命令行提示符。在 Windows 中,命令行提供符是 “Command” 或者 “command.com” 程序。在 UNIX 中,可以使用任何 shell。

需要安装 JDK 1.4 并将它包括在路径中,才能完成本教程中的练习。如果需要安装和配置 JDK 1.4 的帮助,请参见 参考资料 。

4、输入/输出:概念性描述

 

4.1I/O 简介


I/O ? 或者输入/输出 ? 指的是计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。它对于任何计算机系统都非常关键,因而所有 I/O 的主体实际上是内置在操作系统中的。单独的程序一般是让系统为它们完成大部分的工作。

在 Java 编程中,直到最近一直使用 流 的方式完成 I/O。所有 I/O 都被视为单个的字节的移动,通过一个称为 Stream 的对象一次移动一个字节。流 I/O 用于与外部世界接触。它也在内部使用,用于将对象转换为字节,然后再转换回对象。

NIO 与原来的 I/O 有同样的作用和目的,但是它使用不同的方式? 块 I/O。正如您将在本教程中学到的,块 I/O 的效率可以比流 I/O 高许多。

4.2为什么要使用 NIO?


NIO 的创建目的是为了让 Java 程序员可以实现高速 I/O 而无需编写自定义的本机代码。NIO 将最耗时的 I/O 操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。

4.3流与块的比较


原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。正如前面提到的,原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。

一个 面向块 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

4.4集成的 I/O


在 JDK 1.4 中原来的 I/O 包和 NIO 已经很好地集成了。 java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如, java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在更面向流的系统中,处理速度也会更快。

也可以用 NIO 库实现标准 I/O 功能。例如,可以容易地使用块 I/O 一次一个字节地移动数据。但是正如您会看到的,NIO 还提供了原 I/O 包中所没有的许多好处

5、通道和缓冲区

 

5.1概述


通道 和 缓冲区 是 NIO 中的核心对象,几乎在每一个 I/O 操作中都要使用它们。

通道是对原 I/O 包中的流的模拟。到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象。一个 Buffer 实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。

在本节中,您会了解到 NIO 中通道和缓冲区是如何工作的。

5.2什么是缓冲区?


Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。

在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,您都是将它放到缓冲区中。

缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不 仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

5.3缓冲区类型


最常用的缓冲区类型是 ByteBuffer。一个 ByteBuffer 可以在其底层字节数组上进行 get/set 操作(即字节的获取和设置)。

ByteBuffer 不是 NIO 中唯一的缓冲区类型。

事实上,对于每一种基本 Java 类型都有一种缓冲区类型:


每一个 Buffer 类都是 Buffer 接口的一个实例。 除了 ByteBuffer,每一个 Buffer 类都有完全一样的操作,只是它们所处理的数据类型不一样。因为大多数标准 I/O 操作都使用 ByteBuffer,所以它具有所有共享的缓冲区操作以及一些特有的操作。

现在您可以花一点时间运行 UseFloatBuffer.java(请从本文末的附件下载之),它包含了类型化的缓冲区的一个应用例子。

5.4什么是通道?


Channel是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。

正如前面提到的,所有数据都通过 Buffer 对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。

5.5通道类型


通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类), 而 通道 可以用于读、写或者同时用于读写。

因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。

6、从理论到实践:NIO 中的读和写

 

6.1概述


读和写是 I/O 的基本过程。从一个通道中读取很简单:只需创建一个缓冲区,然后让通道将数据读到这个缓冲区中。写入也相当简单:创建一个缓冲区,用数据填充它,然后让通道用这些数据来执行写入操作。

在本节中,我们将学习有关在 Java 程序中读取和写入数据的一些知识。我们将回顾 NIO 的主要组件(缓冲区、通道和一些相关的方法),看看它们是如何交互以进行读写的。在接下来的几节中,我们将更详细地分析这其中的每个组件以及其交互。

6.2从文件中读取


在我们第一个练习中,我们将从一个文件中读取一些数据。如果使用原来的 I/O,那么我们只需创建一个 FileInputStream 并从它那里读取。而在 NIO 中,情况稍有不同:我们首先从 FileInputStream 获取一个 Channel 对象,然后使用这个通道来读取数据。

在 NIO 系统中,任何时候执行一个读操作,您都是从通道中读取,但是您不是 直接 从通道读取。因为所有数据最终都驻留在缓冲区中,所以您是从通道读到缓冲区中。

因此读取文件涉及三个步骤:(1) 从 FileInputStream 获取 Channel,(2) 创建 Buffer,(3) 将数据从 Channel 读到 Buffer 中。

现在,让我们看一下这个过程。

6.3三个容易的步骤


第一步是获取通道。我们从 FileInputStream 获取通道:

1
2
FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();


下一步是创建缓冲区:

1
ByteBuffer buffer = ByteBuffer.allocate( 1024 );


最后,需要将数据从通道读到缓冲区中,如下所示:

1
fc.read( buffer );


您会注意到,我们不需要告诉通道要读 多少数据 到缓冲区中。每一个缓冲区都有复杂的内部统计机制,它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据。我们将在 缓冲区内部细节 中介绍更多关于缓冲区统计机制的内容。

6.4写入文件


在 NIO 中写入文件类似于从文件中读取。首先从 FileOutputStream 获取一个通道:

1
2
FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();


下一步是创建一个缓冲区并在其中放入一些数据 - 在这里,数据将从一个名为 message 的数组中取出,这个数组包含字符串 "Some bytes" 的 ASCII 字节(本教程后面将会解释 buffer.flip() 和 buffer.put() 调用)。

1
2
3
4
5
6
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
 
for (int ii=0; ii<message.length; ++ii) {
     buffer.put( message[ii] );
}
buffer.flip();


最后一步是写入缓冲区中:

1
fc.write( buffer );


注意在这里同样不需要告诉通道要写入多数据。缓冲区的内部统计机制会跟踪它包含多少数据以及还有多少数据要写入。

6.5读写结合


下面我们将看一下在结合读和写时会有什么情况。我们以一个名为 CopyFile.java 的简单程序作为这个练习的基础,它将一个文件的所有内容拷贝到另一个文件中。CopyFile.java 执行三个基本操作:首先创建一个 Buffer,然后从源文件中将数据读到这个缓冲区中,然后将缓冲区写入目标文件。这个程序不断重复 ― 读、写、读、写 ― 直到源文件结束。

CopyFile 程序让您看到我们如何检查操作的状态,以及如何使用 clear() 和 flip() 方法重设缓冲区,并准备缓冲区以便将新读取的数据写到另一个通道中。

6.6运行 CopyFile 例子


因为缓冲区会跟踪它自己的数据,所以 CopyFile 程序的内部循环 (inner loop) 非常简单,如下所示:

1
2
fcin.read( buffer );
fcout.write( buffer );


第一行将数据从输入通道 fcin 中读入缓冲区,第二行将这些数据写到输出通道 fcout 。

6.7检查状态


下一步是检查拷贝何时完成。当没有更多的数据时,拷贝就算完成,并且可以在 read() 方法返回 -1 是判断这一点,如下所示:

1
2
3
4
5
int r = fcin.read( buffer );
 
if (r==-1) {
     break;
}

 

6.8重设缓冲区


最后,在从输入通道读入缓冲区之前,我们调用 clear() 方法。同样,在将缓冲区写入输出通道之前,我们调用 flip() 方法,如下所示:

1
2
3
4
5
6
7
8
9
buffer.clear();
int r = fcin.read( buffer );
 
if (r==-1) {
     break;
}
 
buffer.flip();
fcout.write( buffer );


clear() 方法重设缓冲区,使它可以接受读入的数据。 flip() 方法让缓冲区可以将新读入的数据写入另一个通道。

7、缓冲区内部细节

 

7.1概述


本节将介绍 NIO 中两个重要的缓冲区组件:状态变量和访问方法 (accessor)。

状态变量是前一节中提到的"内部统计机制"的关键。每一个读/写操作都会改变缓冲区的状态。通过记录和跟踪这些变化,缓冲区就可能够内部地管理自己的资源。

在从通道读取数据时,数据被放入到缓冲区。在有些情况下,可以将这个缓冲区直接写入另一个通道,但是在一般情况下,您还需要查看数据。这是使用 访问方法 get() 来完成的。同样,如果要将原始数据放入缓冲区中,就要使用访问方法 put()。

在本节中,您将学习关于 NIO 中的状态变量和访问方法的内容。我们将描述每一个组件,并让您有机会看到它的实际应用。虽然 NIO 的内部统计机制初看起来可能很复杂,但是您很快就会看到大部分的实际工作都已经替您完成了。您可能习惯于通过手工编码进行簿记 ― 即使用字节数组和索引变量,现在它已在 NIO 中内部地处理了。

7.2状态变量


可以用三个值指定缓冲区在任意时刻的状态:

  • position
  • limit
  • capacity


这三个变量一起可以跟踪缓冲区的状态和它所包含的数据。我们将在下面的小节中详细分析每一个变量,还要介绍它们如何适应典型的读/写(输入/输出)进程。在这个例子中,我们假定要将数据从一个输入通道拷贝到一个输出通道。

7.3Position


您可以回想一下,缓冲区实际上就是美化了的数组。在从通道读取时,您将所读取的数据放到底层的数组中。 position 变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪一个元素中。因此,如果您从通道中读三个字节到缓冲区中,那么缓冲区的 position 将会设置为3,指向数组中第四个元素。

同样,在写入通道时,您是从缓冲区中获取数据。 position 值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的 position 将被设置为5,指向数组的第六个元素。

7.4Limit


limit 变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。

position 总是小于或者等于 limit。

7.5Capacity


缓冲区的 capacity 表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ― 或者至少是指定了准许我们使用的底层数组的容量。

limit 决不能大于 capacity。

7.6观察变量


我们首先观察一个新创建的缓冲区。出于本例子的需要,我们假设这个缓冲区的 总容量 为8个字节。

Buffer 的状态如下所示:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_1.jpg

回想一下 ,limit 决不能大于 capacity,此例中这两个值都被设置为 8。我们通过将它们指向数组的尾部之后(如果有第8个槽,则是第8个槽所在的位置)来说明这点。

史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_2.jpg

position 设置为0。如果我们读一些数据到缓冲区中,那么下一个读取的数据就进入 slot 0 。如果我们从缓冲区写一些数据,从缓冲区读取的下一个字节就来自 slot 0 。

position 设置如下所示:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_3.jpg

由于 capacity 不会改变,所以我们在下面的讨论中可以忽略它。

7.7第一次读取


现在我们可以开始在新创建的缓冲区上进行读/写操作。首先从输入通道中读一些数据到缓冲区中。第一次读取得到三个字节。它们被放到数组中从 position 开始的位置,这时 position 被设置为 0。

读完之后,position 就增加到 3,如下所示:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_4.jpg

limit 没有改变。

7.8第二次读取


在第二次读取时,我们从输入通道读取另外两个字节到缓冲区中。

这两个字节储存在由 position 所指定的位置上, position 因而增加 2:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_5.jpg

limit 没有改变。

7.9flip


现在我们要将数据写到输出通道中。在这之前,我们必须调用 flip() 方法。

这个方法做两件非常重要的事:

  • 1)它将 limit 设置为当前 position;
  • 2)它将 position 设置为 0。


前一小节中的图显示了在 flip 之前缓冲区的情况。

下面是在 flip 之后的缓冲区:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_6.jpg

我们现在可以将数据从缓冲区写入通道了。 position 被设置为 0,这意味着我们得到的下一个字节是第一个字节。 limit 已被设置为原来的 position,这意味着它包括以前读到的所有字节,并且一个字节也不多。

7.10第一次写入


在第一次写入时,我们从缓冲区中取四个字节并将它们写入输出通道。

这使得 position 增加到 4,而 limit 不变,如下所示:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_7.jpg

7.11第二次写入


我们只剩下一个字节可写了。 limit在我们调用 flip() 时被设置为 5,并且 position 不能超过 limit。所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。

这使得 position 增加到 5,并保持 limit 不变,如下所示:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_8.jpg

7.12clear


最后一步是调用缓冲区的 clear() 方法。这个方法重设缓冲区以便接收更多的字节。

Clear 做两种非常重要的事情:

  • 1)它将 limit 设置为与 capacity 相同;
  • 2)它设置 position 为 0。


下图显示了在调用 clear() 后缓冲区的状态:
史上最强Java NIO入门:担心从入门到放弃的,请读这篇!_9.jpg

缓冲区现在可以接收新的数据了。

7.13访问方法


到目前为止,我们只是使用缓冲区将数据从一个通道转移到另一个通道。然而,程序经常需要直接处理数据。例如,您可能需要将用户数据保存到磁盘。在这种情况下,您必须将这些数据直接放入缓冲区,然后用通道将缓冲区写入磁盘。

或者,您可能想要从磁盘读取用户数据。在这种情况下,您要将数据从通道读到缓冲区中,然后检查缓冲区中的数据。

在本节的最后,我们将详细分析如何使用 ByteBuffer 类的 get() 和 put() 方法直接访问缓冲区中的数据。

7.14get() 方法


ByteBuffer 类中有四个 get() 方法:

  • 1)byte get();
  • 2)ByteBuffer get( byte dst[] );
  • 3)ByteBuffer get( byte dst[], int offset, int length );
  • 4)byte get( int index );


第一个方法获取单个字节。第二和第三个方法将一组字节读到一个数组中。第四个方法从缓冲区中的特定位置获取字节。那些返回 ByteBuffer 的方法只是返回调用它们的缓冲区的 this 值。

此外,我们认为前三个 get() 方法是相对的,而最后一个方法是绝对的。 相对 意味着 get() 操作服从 limit 和 position 值 ― 更明确地说,字节是从当前 position 读取的,而 position 在 get 之后会增加。另一方面,一个 绝对 方法会忽略 limit 和 position 值,也不会影响它们。事实上,它完全绕过了缓冲区的统计方法。

上面列出的方法对应于 ByteBuffer 类。其他类有等价的 get() 方法,这些方法除了不是处理字节外,其它方面是是完全一样的,它们处理的是与该缓冲区类相适应的类型。

7.15put()方法


ByteBuffer 类中有五个 put() 方法:

  • 1)ByteBuffer put( byte b );
  • 2)ByteBuffer put( byte src[] );
  • 3)ByteBuffer put( byte src[], int offset, int length );
  • 4)ByteBuffer put( ByteBuffer src );
  • 5)ByteBuffer put( int index, byte b );


第一个方法 写入(put) 单个字节。第二和第三个方法写入来自一个数组的一组字节。第四个方法将数据从一个给定的源 ByteBuffer 写入这个 ByteBuffer。第五个方法将字节写入缓冲区中特定的 位置 。那些返回 ByteBuffer 的方法只是返回调用它们的缓冲区的 this 值。

与 get() 方法一样,我们将把 put() 方法划分为 相对 或者 绝对 的。前四个方法是相对的,而第五个方法是绝对的。

上面显示的方法对应于 ByteBuffer 类。其他类有等价的 put() 方法,这些方法除了不是处理字节之外,其它方面是完全一样的。它们处理的是与该缓冲区类相适应的类型。

7.16类型化的 get() 和 put() 方法


除了前些小节中描述的 get() 和 put() 方法, ByteBuffer 还有用于读写不同类型的值的其他方法。

如下所示:

  • getByte()
  • getChar()
  • getShort()
  • getInt()
  • getLong()
  • getFloat()
  • getDouble()
  • putByte()
  • putChar()
  • putShort()
  • putInt()
  • putLong()
  • putFloat()
  • putDouble()


事实上,这其中的每个方法都有两种类型 ― 一种是相对的,另一种是绝对的。它们对于读取格式化的二进制数据(如图像文件的头部)很有用。

您可以在例子程序 TypesInByteBuffer.java 中看到这些方法的实际应用。

7.17缓冲区的使用:一个内部循环


下面的内部循环概括了使用缓冲区将数据从输入通道拷贝到输出通道的过程。

01
02
03
04
05
06
07
08
09
10
11
while (true) {
     buffer.clear();
     int r = fcin.read( buffer );
  
     if (r==-1) {
       break;
     }
  
     buffer.flip();
     fcout.write( buffer );
}


read() 和 write() 调用得到了极大的简化,因为许多工作细节都由缓冲区完成了。 clear() 和 flip() 方法用于让缓冲区在读和写之间切换。

8、关于缓冲区的更多内容

 

8.1概述


到目前为止,您已经学习了使用缓冲区进行日常工作所需要掌握的大部分内容。我们的例子没怎么超出标准的读/写过程种类,在原来的 I/O 中可以像在 NIO 中一样容易地实现这样的标准读写过程。

本节将讨论使用缓冲区的一些更复杂的方面,比如缓冲区分配、包装和分片。我们还会讨论 NIO 带给 Java 平台的一些新功能。您将学到如何创建不同类型的缓冲区以达到不同的目的,如可保护数据不被修改的 只读 缓冲区,和直接映射到底层操作系统缓冲区的 直接 缓冲区。我们将在本节的最后介绍如何在 NIO 中创建内存映射文件。

8.2缓冲区分配和包装


在能够读和写之前,必须有一个缓冲区。要创建缓冲区,您必须 分配 它。我们使用静态方法 allocate() 来分配缓冲区:

1
ByteBuffer buffer = ByteBuffer.allocate( 1024 );


allocate() 方法分配一个具有指定大小的底层数组,并将它包装到一个缓冲区对象中 ― 在本例中是一个 ByteBuffer。

您还可以将一个现有的数组转换为缓冲区,如下所示:

1
2
byte array[] = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap( array );


本例使用了 wrap() 方法将一个数组包装为缓冲区。必须非常小心地进行这类操作。一旦完成包装,底层数据就可以通过缓冲区或者直接访问。

8.3缓冲区分片


slice() 方法根据现有的缓冲区创建一种 子缓冲区 。也就是说,它创建一个新的缓冲区,新缓冲区与原来的缓冲区的一部分共享数据。

使用例子可以最好地说明这点。让我们首先创建一个长度为 10 的 ByteBuffer:

1
ByteBuffer buffer = ByteBuffer.allocate( 10 );


然后使用数据来填充这个缓冲区,在第 n 个槽中放入数字 n:

1
2
3
for (int i=0; i<buffer.capacity(); ++i) {
     buffer.put( (byte)i );
}


现在我们对这个缓冲区 分片 ,以创建一个包含槽 3 到槽 6 的子缓冲区。在某种意义上,子缓冲区就像原来的缓冲区中的一个 窗口 。

窗口的起始和结束位置通过设置 position 和 limit 值来指定,然后调用 Buffer 的 slice() 方法:

1
2
3
buffer.position( 3 );
buffer.limit( 7 );
ByteBuffer slice = buffer.slice();


片 是缓冲区的 子缓冲区 。不过, 片段 和 缓冲区 共享同一个底层数据数组,我们在下一节将会看到这一点。

8.4缓冲区份片和数据共享


我们已经创建了原缓冲区的子缓冲区,并且我们知道缓冲区和子缓冲区共享同一个底层数据数组。让我们看看这意味着什么。

我们遍历子缓冲区,将每一个元素乘以 11 来改变它。例如,5 会变成 55。

1
2
3
4
5
for (int i=0; i<slice.capacity(); ++i) {
     byte b = slice.get( i );
     b *= 11;
     slice.put( i, b );
}


最后,再看一下原缓冲区中的内容:

1
2
3
4
5
6
buffer.position( 0 );
buffer.limit( buffer.capacity() );
  
while (buffer.remaining()>0) {
     System.out.println( buffer.get() );
}


结果表明只有在子缓冲区窗口中的元素被改变了:

01
02
03
04
05
06
07
08
09
10
11
$ java SliceBuffer
0
1
2
33
44
55
66
7
8
9


缓冲区片对于促进抽象非常有帮助。可以编写自己的函数处理整个缓冲区,而且如果想要将这个过程应用于子缓冲区上,您只需取主缓冲区的一个片,并将它传递给您的函数。这比编写自己的函数来取额外的参数以指定要对缓冲区的哪一部分进行操作更容易。

8.5只读缓冲区


只读缓冲区非常简单 ― 您可以读取它们,但是不能向它们写入。可以通过调用缓冲区的 asReadOnlyBuffer() 方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区(并与其共享数据),只不过它是只读的。

只读缓冲区对于保护数据很有用。在将缓冲区传递给某个对象的方法时,您无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以 保证 该缓冲区不会被修改。

不能将只读的缓冲区转换为可写的缓冲区。

8.6直接和间接缓冲区


另一种有用的 ByteBuffer 是直接缓冲区。 直接缓冲区 是为加快 I/O 速度,而以一种特殊的方式分配其内存的缓冲区。

实际上,直接缓冲区的准确定义是与实现相关的。

Sun(现在是Oracle) 的文档是这样描述直接缓冲区的:

给定一个直接字节缓冲区,Java 虚拟机将尽最大努力直接对它执行本机 I/O 操作。也就是说,它会在每一次调用底层操作系统的本机 I/O 操作之前(或之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区中(或者从一个中间缓冲区中拷贝数据)。


您可以在例子程序 FastCopyFile.java(请从文末附件中下载之) 中看到直接缓冲区的实际应用,这个程序是 CopyFile.java 的另一个版本,它使用了直接缓冲区以提高速度。

还可以用内存映射文件创建直接缓冲区。

8.7内存映射文件 I/O


内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快得多。

内存映射文件 I/O 是通过使文件中的数据神奇般地出现为内存数组的内容来完成的。这其初听起来似乎不过就是将整个文件读到内存中,但是事实上并不是这样。一般来说,只有文件中实际读取或者写入的部分才会送入(或者 映射 )到内存中。

内存映射并不真的神奇或者多么不寻常。现代操作系统一般根据需要将文件的部分映射为内存的部分,从而实现文件系统。Java 内存映射机制不过是在底层操作系统中可以采用这种机制时,提供了对该机制的访问。

尽管创建内存映射文件相当简单,但是向它写入可能是危险的。仅只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。

8.8将文件映射到内存


了解内存映射的最好方法是使用例子。在下面的例子中,我们要将一个 FileChannel (它的全部或者部分)映射到内存中。为此我们将使用 FileChannel.map() 方法。

下面代码行将文件的前 1024 个字节映射到内存中:

1
MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE, 0, 1024 );


map() 方法返回一个 MappedByteBuffer,它是 ByteBuffer 的子类。因此,您可以像使用其他任何 ByteBuffer 一样使用新映射的缓冲区,操作系统会在需要时负责执行行映射。

9、分散和聚集

 

9.1概述


分散/聚集 I/O 是使用多个而不是单个缓冲区来保存数据的读写方法。

一个分散的读取就像一个常规通道读取,只不过它是将数据读到一个缓冲区数组中而不是读到单个缓冲区中。同样地,一个聚集写入是向缓冲区数组而不是向单个缓冲区写入数据。

分散/聚集 I/O 对于将数据流划分为单独的部分很有用,这有助于实现复杂的数据格式。

9.2分散/聚集 I/O


通道可以有选择地实现两个新的接口: ScatteringByteChannel 和 GatheringByteChannel

一个 ScatteringByteChannel 是一个具有两个附加读方法的通道:

1
2
long read( ByteBuffer[] dsts );
long read( ByteBuffer[] dsts, int offset, int length );


这些 long read() 方法很像标准的 read 方法,只不过它们不是取单个缓冲区而是取一个缓冲区数组。

在 分散读取 中,通道依次填充每个缓冲区。填满一个缓冲区后,它就开始填充下一个。在某种意义上,缓冲区数组就像一个大缓冲区。

9.3分散/聚集的应用


分散/聚集 I/O 对于将数据划分为几个部分很有用。例如,您可能在编写一个使用消息对象的网络应用程序,每一个消息被划分为固定长度的头部和固定长度的正文。您可以创建一个刚好可以容纳头部的缓冲区和另一个刚好可以容难正文的缓冲区。当您将它们放入一个数组中并使用分散读取来向它们读入消息时,头部和正文将整齐地划分到这两个缓冲区中。

我们从缓冲区所得到的方便性对于缓冲区数组同样有效。因为每一个缓冲区都跟踪自己还可以接受多少数据,所以分散读取会自动找到有空间接受数据的第一个缓冲区。在这个缓冲区填满后,它就会移动到下一个缓冲区。

9.4聚集写入


聚集写入 类似于分散读取,只不过是用来写入。它也有接受缓冲区数组的方法:

1
2
long write( ByteBuffer[] srcs );
long write( ByteBuffer[] srcs, int offset, int length );


聚集写对于把一组单独的缓冲区中组成单个数据流很有用。为了与上面的消息例子保持一致,您可以使用聚集写入来自动将网络消息的各个部分组装为单个数据流,以便跨越网络传输消息。

从例子程序 UseScatterGather.java(请从文末附件中下载之) 中可以看到分散读取和聚集写入的实际应用。

10、文件锁定

 

10.1概述


文件锁定初看起来可能让人迷惑。它 似乎 指的是防止程序或者用户访问特定文件。事实上,文件锁就像常规的 Java 对象锁 ― 它们是 劝告式的(advisory) 锁。它们不阻止任何形式的数据访问,相反,它们通过锁的共享和获取赖允许系统的不同部分相互协调。

您可以锁定整个文件或者文件的一部分。如果您获取一个排它锁,那么其他人就不能获得同一个文件或者文件的一部分上的锁。如果您获得一个共享锁,那么其他人可以获得同一个文件或者文件一部分上的共享锁,但是不能获得排它锁。文件锁定并不总是出于保护数据的目的。例如,您可能临时锁定一个文件以保证特定的写操作成为原子的,而不会有其他程序的干扰。

大多数操作系统提供了文件系统锁,但是它们并不都是采用同样的方式。有些实现提供了共享锁,而另一些仅提供了排它锁。事实上,有些实现使得文件的锁定部分不可访问,尽管大多数实现不是这样的。

在本节中,您将学习如何在 NIO 中执行简单的文件锁过程,我们还将探讨一些保证被锁定的文件尽可能可移植的方法。

10.2锁定文件


要获取文件的一部分上的锁,您要调用一个打开的 FileChannel 上的 lock() 方法。注意,如果要获取一个排它锁,您必须以写方式打开文件。

1
2
3
RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );


在拥有锁之后,您可以执行需要的任何敏感操作,然后再释放锁:

1
lock.release();


在释放锁后,尝试获得锁的其他任何程序都有机会获得它。

本小节的例子程序 UseFileLocks.java 必须与它自己并行运行。这个程序获取一个文件上的锁,持有三秒钟,然后释放它。如果同时运行这个程序的多个实例,您会看到每个实例依次获得锁。

10.3文件锁定和可移植性


文件锁定可能是一个复杂的操作,特别是考虑到不同的操作系统是以不同的方式实现锁这一事实。

下面的指导原则将帮助您尽可能保持代码的可移植性:

  • 1)只使用排它锁;
  • 2)将所有的锁视为劝告式的(advisory)。

 

11、连网和异步 I/O

 

11.1概述


连网是学习异步 I/O 的很好基础,而异步 I/O 对于在 Java 语言中执行任何输入/输出过程的人来说,无疑都是必须具备的知识。NIO 中的连网与 NIO 中的其他任何操作没有什么不同 ― 它依赖通道和缓冲区,而您通常使用 InputStream 和 OutputStream 来获得通道。

本节首先介绍异步 I/O 的基础 ― 它是什么以及它不是什么,然后转向更实用的、程序性的例子

11.2异步 I/O


异步 I/O 是一种 没有阻塞地 读写数据的方法。通常,在代码进行 read() 调用时,代码会阻塞直至有可供读取的数据。同样, write() 调用将会阻塞直至数据能够写入。

另一方面,异步 I/O 调用不会阻塞。相反,您将注册对特定 I/O 事件的兴趣 ― 可读的数据的到达、新的套接字连接,等等,而在发生这样的事件时,系统将会告诉您。

异步 I/O 的一个优势在于,它允许您同时根据大量的输入和输出执行 I/O。同步程序常常要求助于轮询,或者创建许许多多的线程以处理大量的连接。使用异步 I/O,您可以监听任何数量的通道上的事件,不用轮询,也不用额外的线程。

我们将通过研究一个名为 MultiPortEcho.java(请从文末附件下载之) 的例子程序来查看异步 I/O 的实际应用。这个程序就像传统的 echo server,它接受网络连接并向它们回响它们可能发送的数据。不过它有一个附加的特性,就是它能同时监听多个端口,并处理来自所有这些端口的连接。并且它只在单个线程中完成所有这些工作。

11.3Selectors


本节的阐述对应于 MultiPortEcho 的源代码中的 go() 方法的实现,因此应该看一下源代码,以便对所发生的事情有个更全面的了解。

异步 I/O 中的核心对象名为 Selector。Selector 就是您注册对各种 I/O 事件的兴趣的地方,而且当那些事件发生时,就是这个对象告诉您所发生的事件。

所以,我们需要做的第一件事就是创建一个 Selector:

1
Selector selector = Selector.open();


然后,我们将对不同的通道对象调用 register() 方法,以便注册我们对这些对象中发生的 I/O 事件的兴趣。register() 的第一个参数总是这个 Selector。

11.4打开一个 ServerSocketChannel


为了接收连接,我们需要一个 ServerSocketChannel。事实上,我们要监听的每一个端口都需要有一个 ServerSocketChannel 。

对于每一个端口,我们打开一个 ServerSocketChannel,如下所示:

1
2
3
4
5
6
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking( false );
  
ServerSocket ss = ssc.socket();
InetSocketAddress address = new InetSocketAddress( ports[ii] );
ss.bind( address );


第一行创建一个新的 ServerSocketChannel ,最后三行将它绑定到给定的端口。第二行将 ServerSocketChannel 设置为 非阻塞的 。我们必须对每一个要使用的套接字通道调用这个方法,否则异步 I/O 就不能工作。

11.5选择键


下一步是将新打开的 ServerSocketChannels 注册到 Selector上。为此我们使用 ServerSocketChannel.register() 方法,如下所示:

1
SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT );


register() 的第一个参数总是这个 Selector。第二个参数是 OP_ACCEPT,这里它指定我们想要监听 accept 事件,也就是在新的连接建立时所发生的事件。这是适用于 ServerSocketChannel 的唯一事件类型。

请注意对 register() 的调用的返回值。 SelectionKey 代表这个通道在此 Selector 上的这个注册。当某个 Selector 通知您某个传入事件时,它是通过提供对应于该事件的 SelectionKey 来进行的。SelectionKey 还可以用于取消通道的注册。

11.6内部循环


现在已经注册了我们对一些 I/O 事件的兴趣,下面将进入主循环。使用 Selectors 的几乎每个程序都像下面这样使用内部循环:

1
2
3
4
5
6
7
8
9
int num = selector.select();
  
Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();
  
while (it.hasNext()) {
     SelectionKey key = (SelectionKey)it.next();
     // ... deal with I/O event ...
}


首先,我们调用 Selector 的 select() 方法。这个方法会阻塞,直到至少有一个已注册的事件发生。当一个或者更多的事件发生时, select() 方法将返回所发生的事件的数量。

接下来,我们调用 Selector 的 selectedKeys() 方法,它返回发生了事件的 SelectionKey 对象的一个 集合 。

我们通过迭代 SelectionKeys 并依次处理每个 SelectionKey 来处理事件。对于每一个 SelectionKey,您必须确定发生的是什么 I/O 事件,以及这个事件影响哪些 I/O 对象。

11.7监听新连接


程序执行到这里,我们仅注册了 ServerSocketChannel,并且仅注册它们“接收”事件。为确认这一点,我们对 SelectionKey 调用 readyOps() 方法,并检查发生了什么类型的事件:

1
2
3
4
5
6
if ((key.readyOps() & SelectionKey.OP_ACCEPT)
     == SelectionKey.OP_ACCEPT) {
  
     // Accept the new connection
     // ...
}


可以肯定地说, readOps() 方法告诉我们该事件是新的连接。

11.8接受新的连接


因为我们知道这个服务器套接字上有一个传入连接在等待,所以可以安全地接受它;也就是说,不用担心 accept() 操作会阻塞:

1
2
ServerSocketChannel ssc = (ServerSocketChannel)key.channel();
SocketChannel sc = ssc.accept();


下一步是将新连接的 SocketChannel 配置为非阻塞的。而且由于接受这个连接的目的是为了读取来自套接字的数据,所以我们还必须将 SocketChannel 注册到 Selector上,如下所示:

1
2
sc.configureBlocking( false );
SelectionKey newKey = sc.register( selector, SelectionKey.OP_READ );


注意我们使用 register() 的 OP_READ 参数,将 SocketChannel 注册用于 读取 而不是 接受 新连接。

11.9删除处理过的 SelectionKey


在处理 SelectionKey 之后,我们几乎可以返回主循环了。但是我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。如果我们没有删除处理过的键,那么它仍然会在主集合中以一个激活的键出现,这会导致我们尝试再次处理它。我们调用迭代器的 remove() 方法来删除处理过的 SelectionKey:

1
it.remove();


现在我们可以返回主循环并接受从一个套接字中传入的数据(或者一个传入的 I/O 事件)了。

11.10传入的 I/O


当来自一个套接字的数据到达时,它会触发一个 I/O 事件。这会导致在主循环中调用 Selector.select(),并返回一个或者多个 I/O 事件。这一次, SelectionKey 将被标记为 OP_READ 事件,如下所示:

1
2
3
4
5
6
} else if ((key.readyOps() & SelectionKey.OP_READ)
     == SelectionKey.OP_READ) {
     // Read the data
     SocketChannel sc = (SocketChannel)key.channel();
     // ...
}


与以前一样,我们取得发生 I/O 事件的通道并处理它。在本例中,由于这是一个 echo server,我们只希望从套接字中读取数据并马上将它发送回去。关于这个过程的细节,请参见 参考资料 中的源代码 (MultiPortEcho.java)。

11.11回到主循环


每次返回主循环,我们都要调用 select 的 Selector()方法,并取得一组 SelectionKey。每个键代表一个 I/O 事件。我们处理事件,从选定的键集中删除 SelectionKey,然后返回主循环的顶部。

这个程序有点过于简单,因为它的目的只是展示异步 I/O 所涉及的技术。在现实的应用程序中,您需要通过将通道从 Selector 中删除来处理关闭的通道。而且您可能要使用多个线程。这个程序可以仅使用一个线程,因为它只是一个演示,但是在现实场景中,创建一个线程池来负责 I/O 事件处理中的耗时部分会更有意义。

12、字符集

 

12.1概述


根据 Sun(现在是Oracle) 的文档,一个 Charset 是“十六位 Unicode 字符序列与字节序列之间的一个命名的映射”。实际上,一个 Charset 允许您以尽可能最具可移植性的方式读写字符序列。

Java 语言被定义为基于 Unicode。然而在实际上,许多人编写代码时都假设一个字符在磁盘上或者在网络流中用一个字节表示。这种假设在许多情况下成立,但是并不是在所有情况下都成立,而且随着计算机变得对 Unicode 越来越友好,这个假设就日益变得不能成立了。

在本节中,我们将看一下如何使用 Charsets 以适合现代文本格式的方式处理文本数据。这里将使用的示例程序相当简单,不过,它触及了使用 Charset 的所有关键方面:为给定的字符编码创建 Charset,以及使用该 Charset 解码和编码文本数据。

12.2编码/解码


要读和写文本,我们要分别使用 CharsetDecoder 和 CharsetEncoder。将它们称为 编码器 和 解码器 是有道理的。一个 字符 不再表示一个特定的位模式,而是表示字符系统中的一个实体。因此,由某个实际的位模式表示的字符必须以某种特定的 编码 来表示。

CharsetDecoder 用于将逐位表示的一串字符转换为具体的 char 值。同样,一个 CharsetEncoder 用于将字符转换回位。

在下一个小节中,我们将考察一个使用这些对象来读写数据的程序。

12.3处理文本的正确方式


现在我们将分析这个例子程序 UseCharsets.java。这个程序非常简单 ― 它从一个文件中读取一些文本,并将该文本写入另一个文件。但是它把该数据当作文本数据,并使用 CharBuffer 来将该数句读入一个 CharsetDecoder 中。同样,它使用 CharsetEncoder 来写回该数据。

我们将假设字符以 ISO-8859-1(Latin1) 字符集(这是 ASCII 的标准扩展)的形式储存在磁盘上。尽管我们必须为使用 Unicode 做好准备,但是也必须认识到不同的文件是以不同的格式储存的,而 ASCII 无疑是非常普遍的一种格式。

事实上,每种 Java 实现都要求对以下字符编码提供完全的支持:

  • US-ASCII
  • ISO-8859-1
  • UTF-8
  • UTF-16BE
  • UTF-16LE
  • UTF-16

 

12.4示例程序


在打开相应的文件、将输入数据读入名为 inputData 的 ByteBuffer 之后,我们的程序必须创建 ISO-8859-1 (Latin1) 字符集的一个实例:

1
Charset latin1 = Charset.forName( "ISO-8859-1" );


然后,创建一个解码器(用于读取)和一个编码器 (用于写入):

1
2
CharsetDecoder decoder = latin1.newDecoder();
CharsetEncoder encoder = latin1.newEncoder();


为了将字节数据解码为一组字符,我们把 ByteBuffer 传递给 CharsetDecoder,结果得到一个 CharBuffer:

1
CharBuffer cb = decoder.decode( inputData );


如果想要处理字符,我们可以在程序的此处进行。但是我们只想无改变地将它写回,所以没有什么要做的。

要写回数据,我们必须使用 CharsetEncoder 将它转换回字节:

1
ByteBuffer outputData = encoder.encode( cb );


在转换完成之后,我们就可以将数据写到文件中了。

13、结束语


正如您所看到的, NIO 库有大量的特性。在一些新特性(例如文件锁定和字符集)提供新功能的同时,许多特性在优化方面也非常优秀。

在基础层次上,通道和缓冲区可以做的事情几乎都可以用原来的面向流的类来完成。但是通道和缓冲区允许以 快得多 的方式完成这些相同的旧操作 ― 事实上接近系统所允许的最大速度。

不过 NIO 最强大的长度之一在于,它提供了一种在 Java 语言中执行进行输入/输出的新的(也是迫切需要的)结构化方式。随诸如缓冲区、通道和异步 I/O 这些概念性(且可实现的)实体而来的,是我们重新思考 Java 程序中的 I/O过程的机会。这样,NIO 甚至为我们最熟悉的 I/O 过程也带来了新的活力,同时赋予我们通过和以前不同并且更好的方式执行它们的机会。

微信扫一扫关注!

1、引言


Netty 是一个广受欢迎的异步事件驱动的Java开源网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。

本文基于 Netty 4.1 展开介绍相关理论模型,使用场景,基本组件、整体架构,知其然且知其所以然,希望给大家在实际开发实践、学习开源项目方面提供参考。

本文作者的其它文章《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》、《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》、《IM开发基础知识补课(六):数据库用NoSQL还是SQL?读这篇就够了!》也写的很好,有兴趣的读者可以一并看看。

关于作者:
陈彩华(caison),从事服务端开发,善于系统设计、优化重构、线上问题排查工作,主要开发语言是 Java,微信号:hua1881375。

2、相关资料


Netty源码在线阅读:


Netty在线API文档:


有关Netty的其它精华文章:


3、JDK 原生 NIO 程序的问题


JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下:

  • 1)NIO 的类库和 API 繁杂,使用麻烦:你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
  • 2)需要具备其他的额外技能做铺垫:例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
  • 3)可靠性能力补齐,开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
  • 4)JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。

4、Netty 的特点


Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

Netty的主要特点有:

  • 1)设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池;真正的无连接数据报套接字支持(自 3.1 起)。
  • 2)使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。
  • 3)高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
  • 4)安全:完整的 SSL/TLS 和 StartTLS 支持。
  • 5)社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入。

5、Netty 常见使用场景


Netty 常见的使用场景如下:

  • 1)互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用。典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信。
  • 2)游戏行业:无论是手游服务端还是大型的网络游戏,Java 语言得到了越来越广泛的应用。Netty 作为高性能的基础通信组件,它本身提供了 TCP/UDP 和 HTTP 协议栈。
  • 非常方便定制和开发私有协议栈,账号登录服务器,地图服务器之间可以方便的通过 Netty 进行高性能的通信。
  • 3)大数据领域:经典的 Hadoop 的高性能通信和序列化组件 Avro 的 RPC 框架,默认采用 Netty 进行跨界点通信,它的 Netty Service 基于 Netty 框架二次封装实现。

有兴趣的读者可以了解一下目前有哪些开源项目使用了 Netty的Related Projects

6、Netty 高性能设计


Netty 作为异步事件驱动的网络,高性能之处主要来自于其 I/O 模型和线程处理模型,前者决定如何收发数据,后者决定如何处理数据。

6.1I/O 模型



用什么样的通道将数据发送给对方,BIO、NIO 或者 AIO,I/O 模型在很大程度上决定了框架的性能。

【阻塞 I/O】:

传统阻塞型 I/O(BIO)可以用下图表示:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_1.jpg


特点如下:
  • 每个请求都需要独立的线程完成数据 Read,业务处理,数据 Write 的完整操作问题。
  • 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  • 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费。

【I/O 复用模型】:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_2.jpg

在 I/O 复用模型中,会用到 Select,这个函数也会使进程阻塞,但是和阻塞 I/O 所不同的是这两个函数可以同时阻塞多个 I/O 操作。

而且可以同时对多个读操作,多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。

Netty 的非阻塞 I/O 的实现关键是基于 I/O 复用模型,这里用 Selector 对象表示:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_3.jpg

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端连接。

当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。

线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。

由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。

一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

【基于 Buffer】:

传统的 I/O 是面向字节流或字符流的,以流式的方式顺序地从一个 Stream 中读取一个或多个字节, 因此也就不能随意改变读取指针的位置。

在 NIO 中,抛弃了传统的 I/O 流,而是引入了 Channel 和 Buffer 的概念。在 NIO 中,只能从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。

基于 Buffer 操作不像传统 IO 的顺序操作,NIO 中可以随意地读取任意位置的数据。

6.2线程模型


数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,线程模型的不同,对性能的影响也非常大。

【事件驱动模型】:

通常,我们设计一个事件处理模型的程序有两种思路:

  • 1)轮询方式:线程不断轮询访问相关事件发生源有没有发生事件,有发生事件就调用事件处理逻辑;
  • 2)事件驱动方式:发生事件,主线程把事件放入事件队列,在另外线程不断循环消费事件列表中的事件,调用事件对应的处理逻辑处理事件。事件驱动方式也被称为消息通知方式,其实是设计模式中观察者模式的思路。

以 GUI 的逻辑处理为例,说明两种逻辑的不同:

  • 1)轮询方式:线程不断轮询是否发生按钮点击事件,如果发生,调用处理逻辑。
  • 2)事件驱动方式:发生点击事件把事件放入事件队列,在另外线程消费的事件列表中的事件,根据事件类型调用相关事件处理逻辑。

这里借用 O'Reilly 大神关于事件驱动模型解释图:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_4.jpg

主要包括 4 个基本组件:

  • 1)事件队列(event queue):接收事件的入口,存储待处理事件;
  • 2)分发器(event mediator):将不同的事件分发到不同的业务逻辑单元;
  • 3)事件通道(event channel):分发器与处理器之间的联系渠道;
  • 4)事件处理器(event processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作。

可以看出,相对传统轮询模式,事件驱动有如下优点:

  • 1)可扩展性好:分布式的异步架构,事件处理器之间高度解耦,可以方便扩展事件处理逻辑;
  • 2)高性能:基于队列暂存事件,能方便并行异步处理事件。

【Reactor 线程模型】:

Reactor 是反应堆的意思,Reactor 模型是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。

服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式,即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一。

Reactor 模型中有 2 个关键组成:

  • 1)Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • 2)Handlers:处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_5.jpg

取决于 Reactor 的数量和 Hanndler 线程数量的不同,Reactor 模型有 3 个变种:

  • 1)单 Reactor 单线程;
  • 2)单 Reactor 多线程;
  • 3)主从 Reactor 多线程。

可以这样理解,Reactor 就是一个执行 while (true) { selector.select(); …} 循环的线程,会源源不断的产生新的事件,称作反应堆很贴切。

篇幅关系,这里不再具体展开 Reactor 特性、优缺点比较,有兴趣的读者可以参考我之前另外一篇文章:《高性能网络编程(五):一文读懂高性能网络编程中的I/O模型》、《高性能网络编程(六):一文读懂高性能网络编程中的线程模型》。

【Netty 线程模型】:

Netty 主要基于主从 Reactors 多线程模型(如下图)做了一定的修改,其中主从 Reactor 多线程模型有多个 Reactor:

  • 1)MainReactor 负责客户端的连接请求,并将请求转交给 SubReactor;
  • 2)SubReactor 负责相应通道的 IO 读写请求;
  • 3)非 IO 请求(具体逻辑处理)的任务则会直接写入队列,等待 worker threads 进行处理。

这里引用 Doug Lee 大神的 Reactor 介绍——Scalable IO in Java 里面关于主从 Reactor 多线程模型的图:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_6.jpg

特别说明的是:虽然 Netty 的线程模型基于主从 Reactor 多线程,借用了 MainReactor 和 SubReactor 的结构。但是实际实现上 SubReactor 和 Worker 线程在同一个线程池中:
1
2
3
4
5
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap server = new ServerBootstrap();
server.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)

上面代码中的 bossGroup 和 workerGroup 是 Bootstrap 构造方法中传入的两个对象,这两个 group 均是线程池:

  • 1)bossGroup 线程池则只是在 Bind 某个端口后,获得其中一个线程作为 MainReactor,专门处理端口的 Accept 事件,每个端口对应一个 Boss 线程;
  • 2)workerGroup 线程池会被各个 SubReactor 和 Worker 线程充分利用。

【异步处理】:

异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。

调用者并不能立刻获得结果,而是通过 Future-Listener 机制,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果。

当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。

常见有如下操作:

  • 1)通过 isDone 方法来判断当前操作是否完成;
  • 2)通过 isSuccess 方法来判断已完成的当前操作是否成功;
  • 3)通过 getCause 方法来获取已完成的当前操作失败的原因;
  • 4)通过 isCancelled 方法来判断已完成的当前操作是否被取消;
  • 5)通过 addListener 方法来注册监听器,当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则理解通知指定的监听器。

例如下面的代码中绑定端口是异步操作,当绑定操作处理完,将会调用相应的监听器处理逻辑:
1
2
3
4
5
6
7
serverBootstrap.bind(port).addListener(future -> {
       if (future.isSuccess()) {
           System.out.println(new Date() + ": 端口[" + port + "]绑定成功!");
       } else {
           System.err.println("端口[" + port + "]绑定失败!");
       }
   });

相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住, 直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量。

7、Netty框架的架构设计


前面介绍完 Netty 相关一些理论,下面从功能特性、模块组件、运作过程来介绍 Netty 的架构设计。

7.1功能特性


新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_7.jpg

Netty 功能特性如下:

  • 1)传输服务:支持 BIO 和 NIO;
  • 2)容器集成:支持 OSGI、JBossMC、Spring、Guice 容器;
  • 3)协议支持:HTTP、Protobuf、二进制、文本、WebSocket 等一系列常见协议都支持。还支持通过实行编码解码逻辑来实现自定义协议;
  • 4)Core 核心:可扩展事件模型、通用通信 API、支持零拷贝的 ByteBuf 缓冲对象。

7.2模块组件


【Bootstrap、ServerBootstrap】:

Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件,Netty 中 Bootstrap 类是客户端程序的启动引导类,ServerBootstrap 是服务端启动引导类。

【Future、ChannelFuture】:

正如前面介绍,在 Netty 中所有的 IO 操作都是异步的,不能立刻得知消息是否被正确处理。

但是可以过一会等它执行完成或者直接注册一个监听,具体的实现就是通过 Future 和 ChannelFutures,他们可以注册一个监听,当操作执行成功或失败时监听会自动触发注册的监听事件。

【Channel】:

Netty 网络通信的组件,能够用于执行网络 I/O 操作。Channel 为用户提供:

  • 1)当前网络连接的通道的状态(例如是否打开?是否已连接?)
  • 2)网络连接的配置参数 (例如接收缓冲区大小)
  • 3)提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成。
  • 4)调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方。
  • 5)支持关联 I/O 操作与对应的处理程序。

不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应。

下面是一些常用的 Channel 类型:

  • NioSocketChannel,异步的客户端 TCP Socket 连接。
  • NioServerSocketChannel,异步的服务器端 TCP Socket 连接。
  • NioDatagramChannel,异步的 UDP 连接。
  • NioSctpChannel,异步的客户端 Sctp 连接。
  • NioSctpServerChannel,异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。

【Selector】:

Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。

当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel 。

【NioEventLoop】:

NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用 NioEventLoop 的 run 方法,执行 I/O 任务和非 I/O 任务:

  • I/O 任务,即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由 processSelectedKeys 方法触发。
  • 非 IO 任务,添加到 taskQueue 中的任务,如 register0、bind0 等任务,由 runAllTasks 方法触发。

两种任务的执行时间比由变量 ioRatio 控制,默认为 50,则表示允许非 IO 任务执行的时间与 IO 任务的执行时间相等。

【NioEventLoopGroup】:

NioEventLoopGroup,主要管理 eventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程。

【ChannelHandler】:

ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。

ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类:

  • ChannelInboundHandler 用于处理入站 I/O 事件。
  • ChannelOutboundHandler 用于处理出站 I/O 操作。

或者使用以下适配器类:

  • ChannelInboundHandlerAdapter 用于处理入站 I/O 事件。
  • ChannelOutboundHandlerAdapter 用于处理出站 I/O 操作。
  • ChannelDuplexHandler 用于处理入站和出站事件。

【ChannelHandlerContext】:

保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。

【ChannelPipline】:

保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作。

ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互。

下图引用 Netty 的 Javadoc 4.1 中 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常如何处理 I/O 事件。

I/O 事件由 ChannelInboundHandler 或 ChannelOutboundHandler 处理,并通过调用 ChannelHandlerContext 中定义的事件传播方法。

例如:ChannelHandlerContext.fireChannelRead(Object)和 ChannelOutboundInvoker.write(Object)转发到其最近的处理程序。

新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_8.jpg

入站事件由自下而上方向的入站处理程序处理,如图左侧所示。入站 Handler 处理程序通常处理由图底部的 I/O 线程生成的入站数据。

通常通过实际输入操作(例如 SocketChannel.read(ByteBuffer))从远程读取入站数据。

出站事件由上下方向处理,如图右侧所示。出站 Handler 处理程序通常会生成或转换出站传输,例如 write 请求。

I/O 线程通常执行实际的输出操作,例如 SocketChannel.write(ByteBuffer)。

在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_9.jpg

一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。

入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰。

8、Netty框架的工作原理


典型的初始化并启动 Netty 服务端的过程代码如下:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static void main(String[] args) {
       // 创建mainReactor
       NioEventLoopGroup boosGroup = new NioEventLoopGroup();
       // 创建工作线程组
       NioEventLoopGroup workerGroup = new NioEventLoopGroup();
 
       final ServerBootstrap serverBootstrap = new ServerBootstrap();
       serverBootstrap
                // 组装NioEventLoopGroup
               .group(boosGroup, workerGroup)
                // 设置channel类型为NIO类型
               .channel(NioServerSocketChannel.class)
               // 设置连接配置参数
               .option(ChannelOption.SO_BACKLOG, 1024)
               .childOption(ChannelOption.SO_KEEPALIVE, true)
               .childOption(ChannelOption.TCP_NODELAY, true)
               // 配置入站、出站事件handler
               .childHandler(new ChannelInitializer<NioSocketChannel>() {
                   @Override
                   protected void initChannel(NioSocketChannel ch) {
                       // 配置入站、出站事件channel
                       ch.pipeline().addLast(...);
                       ch.pipeline().addLast(...);
                   }
   });
 
       // 绑定端口
       int port = 8080;
       serverBootstrap.bind(port).addListener(future -> {
           if (future.isSuccess()) {
               System.out.println(new Date() + ": 端口[" + port + "]绑定成功!");
           } else {
               System.err.println("端口[" + port + "]绑定失败!");
           }
       });
}

基本过程描述如下:
  • 1)初始化创建 2 个 NioEventLoopGroup:其中 boosGroup 用于 Accetpt 连接建立事件并分发请求,workerGroup 用于处理 I/O 读写事件和业务逻辑。
  • 2)基于 ServerBootstrap(服务端启动引导类):配置 EventLoopGroup、Channel 类型,连接参数、配置入站、出站事件 handler。
  • 3)绑定端口:开始工作。

结合上面介绍的 Netty Reactor 模型,介绍服务端 Netty 的工作架构图:
新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析_19.jpg

Server 端包含 1 个 Boss NioEventLoopGroup 和 1 个 Worker NioEventLoopGroup。

NioEventLoopGroup 相当于 1 个事件循环组,这个组里包含多个事件循环 NioEventLoop,每个 NioEventLoop 包含 1 个 Selector 和 1 个事件循环线程。

每个 Boss NioEventLoop 循环执行的任务包含 3 步:

  • 1)轮询 Accept 事件;
  • 2)处理 Accept I/O 事件,与 Client 建立连接,生成 NioSocketChannel,并将 NioSocketChannel 注册到某个 Worker NioEventLoop 的 Selector 上;
  • 3)处理任务队列中的任务,runAllTasks。任务队列中的任务包括用户调用 eventloop.execute 或 schedule 执行的任务,或者其他线程提交到该 eventloop 的任务。

每个 Worker NioEventLoop 循环执行的任务包含 3 步:

  • 1)轮询 Read、Write 事件;
  • 2)处理 I/O 事件,即 Read、Write 事件,在 NioSocketChannel 可读、可写事件发生时进行处理;
  • 3)处理任务队列中的任务,runAllTasks。

其中任务队列中的 Task 有 3 种典型使用场景:

① 用户程序自定义的普通任务:
1
2
3
4
5
6
ctx.channel().eventLoop().execute(new Runnable() {
   @Override
   public void run() {
       //...
   }
});

② 非当前 Reactor 线程调用 Channel 的各种方法:
例如在推送系统的业务线程里面,根据用户的标识,找到对应的 Channel 引用,然后调用 Write 类方法向该用户推送消息,就会进入到这种场景。最终的 Write 会提交到任务队列中后被异步消费。

③ 用户自定义定时任务:
1
2
3
4
5
6
ctx.channel().eventLoop().schedule(new Runnable() {
   @Override
   public void run() {
 
   }
}, 60, TimeUnit.SECONDS);

9、本文小结


现在推荐使用的主流稳定版本还是 Netty4,Netty5 中使用了 ForkJoinPool,增加了代码的复杂度,但是对性能的改善却不明显,所以这个版本不推荐使用,官网也没有提供下载链接。

Netty 入门门槛相对较高,是因为这方面的资料较少,并不是因为它有多难,大家其实都可以像搞透 Spring 一样搞透 Netty。

在学习之前,建议先理解透整个框架原理结构,运行过程,可以少走很多弯路。

新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析-网络编程/专项技术区 - 即时通讯开发者社区! (52im.net)


楼主您好 , 图中这个地方 应该是 NioEventLoop 吧

netty全过程图解(最详细清晰版)_netty工作流程_”PANDA的博客-CSDN博客

 

posted @ 2023-07-05 09:13  CharyGao  阅读(1582)  评论(0编辑  收藏  举报