[01] 前置学习(上)
1. Netty 简述
1.1 是什么
Netty 是由 JBOSS 提供的一个 Java 开源框架,现为 Github上的独立项目。
Netty 是一个异步的、基于事件驱动的网络应用框架,用以快速开发高性能、高可靠性的网络 IO 程序。
Netty 主要针对在 TCP 协议下,面向 Clients 端的高并发应用,或者 Peer-to-Peer 场景下的大量数据持续传输的应用。
Netty 本质是一个 NIO 框架,适用于服务器通讯相关的多种应用场景。
1.2 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 框架二次封装实现。
2. Linux IO 模型
2.1 基本概念
在正式开始讲 Linux IO 模型前,先介绍几个基本概念。
a. 用户/内核空间
我们电脑上跑着的应⽤程序,其实是需要经过操作系统,才能做⼀些特殊操作,如磁盘⽂件读写、内存的读写等等。因为这些都是⽐较危险的操作,不可以由应⽤程序乱来,只能交给底层操作系统来。
现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间 (虚拟存储空间)为 4G (2^32)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核 (kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节 (从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节 (从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。
- 内核空间:用户空间是指进程可以直接访问的内存区域。在这个空间里,进程可以存放自己的代码、数据以及堆和栈等。这些数据和代码通常是由进程本身或者其用户态库(如标准C库等)所管理和使用的。
- ⽤户空间:内核空间是操作系统核心(内核)所管理和使用的内存区域。在这个空间里,操作系统保留了对硬件、系统资源以及各种系统数据结构的直接访问权限。进程不能直接访问内核空间,而是通过系统调用(syscall)来请求操作系统代表其执行特权操作(如文件操作、网络通信等),即进程从「⽤户空间」切换到「内核空间」,完成相关操作后,再从「内核空间」切换回「⽤户空间」。
内存分配和管理:
- 用户空间与内核空间的分离:操作系统将每个进程的虚拟地址空间分为用户空间和内核空间。用户空间用于存放进程自身的数据和代码,而内核空间用于存放操作系统核心数据结构和代码。
- 内核空间的保护:内核空间对于用户态进程是不可见和不可访问的,这种分离保护了操作系统核心的完整性和安全性。用户进程无法直接访问或修改内核空间的数据,必须通过系统调用来请求操作系统执行特定任务。
b. 用户态/内核态
什么是用户态和内核态?
- 如果进程运⾏于「内核空间」,被称为进程的内核态;
- 如果进程运⾏于「⽤户空间」,被称为进程的⽤户态。
用户态/内核态详细说明:
- 用户态(User Mode)
- 权限限制:进程在用户态下执行时,其拥有的权限受到限制。通常情况下,进程只能访问自己的用户空间,不能直接访问其他进程的内存或者操作系统的核心数据结构。
- 系统资源访问:在用户态下,进程通过系统调用(syscall)来请求操作系统提供的服务或访问系统资源,如文件系统、网络、硬件设备等。系统调用会将进程从用户态切换到内核态,以便操作系统可以安全地执行特权操作并返回结果给进程。
- 安全性:用户态的限制有助于保障系统的安全性和稳定性。进程无法直接执行特权指令或访问内核空间,从而防止非授权的修改或破坏系统关键数据。
- 内核态(Kernel Mode)
- 完全访问权限:操作系统在内核态下执行,拥有对系统所有资源和硬件的完全访问权限。这包括直接访问物理内存、管理进程和线程、处理中断和异常等。
- 特权指令执行:在内核态下,操作系统可以执行特权指令,如关于内存管理的底层操作、修改硬件状态、处理中断等。这些操作是用户态进程所无法执行的,因为这些操作可能会影响整个系统的稳定性和安全性。
- 系统服务提供:操作系统在内核态下提供系统服务,响应来自用户态的系统调用请求。这些服务包括文件操作、网络通信、进程管理、内存分配等,是用户态程序所必需的。
以下是一般情况下进程从用户态切换到内核态的几种方式:
(1)系统调用
进程在用户态下执行时,如果需要访问操作系统提供的服务或者资源(如文件操作、网络通信、进程管理等),就会发起系统调用。系统调用是通过软中断(软件中断)来实现的。具体步骤如下:
- 进程通过调用指令(例如
int 0x80
或syscall
指令)触发软中断。 - CPU 检测到这个中断,并将控制权转移到预定义的中断处理程序(位于操作系统内核中)。
- 操作系统内核接收到中断,确定中断的原因是系统调用,并在内核态下执行相应的系统调用服务程序。
- 系统调用服务程序执行完毕后,将结果返回给用户态进程,并将控制权再次交还给用户态进程继续执行。
(2)异常
- 异常是一种特殊的事件,可能由于进程执行了非法指令、访问非法内存、发生了硬件故障等原因而引起。异常可以分为同步异常和异步异常:
- 同步异常:由于进程当前指令的执行导致的异常,例如除零操作、页错误等。
- 异步异常:由于外部事件引起的异常,例如硬件中断、内存错误等。
- 当进程遇到异常时,CPU 会暂停当前执行的指令,保存进程的状态(如程序计数器、栈指针等),并将控制权转移给操作系统内核中预定义的异常处理程序。
- 操作系统在内核态下处理异常,可能涉及错误修复、内存页调度、硬件故障处理等任务。
- 异常处理程序执行完成后,操作系统通常会决定是继续该进程的执行(例如处理页错误后重新加载页面),还是终止进程(例如处理严重硬件故障导致的异常)。
【小结】总体来说,操作系统为每个进程分配内存空间时,将其分为用户空间和内核空间。用户空间用于进程自身的数据和代码执行,受到权限限制;内核空间用于操作系统核心的运行和管理,拥有完全访问权限。这种分区设计有效地保障了系统的安全性、稳定性和资源隔离,同时允许操作系统提供必要的系统服务给用户态进程。
c. 进程切换/阻塞
【进程切换】为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为「进程切换」。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。总之,进程切换很耗资源。
【进程阻塞】正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程 (获得 CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。
d. CPU 上下文切换
什么是上下文?
CPU 寄存器,是 CPU 内置的容量⼩、但速度极快的内存。⽽程序计数器,则是⽤来存储 CPU 正在执⾏的指令位置、或者即将执⾏的下⼀条指令位置。它们都是 CPU 在运⾏任何任务前,必须的依赖环境,因此叫做 CPU 上下⽂。
什么是 CPU 上下⽂切换?
它是指,先把前⼀个任务的 CPU 上下⽂(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下⽂到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运⾏新任务。
⼀般我们说的上下⽂切换,就是指内核(操作系统的核⼼)在 CPU 上对进程或者线程进⾏切换。进程从⽤户态到内核态的转变,需要通过系统调⽤来完成。系统调⽤的过程,会发⽣CPU 上下⽂的切换。
CPU 寄存器⾥原来⽤户态的指令位置,需要先保存起来。接着,为了执⾏内核态代码,CPU 寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运⾏内核任务。
e. 文件描述符 fd
文件描述符(file descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。
Linux 内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个 File Descriptor。而对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。
f. 缓存 IO / DMA
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存(Page Cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用进程缓冲区。
缺点显而易见,数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
DMA(Direct Memory Access)即直接内存访问。DMA 本质上是⼀块主板上独⽴的芯⽚,允许外部设备和内存存储器之间直接进⾏ IO 数据传输,其过程不需要 CPU 的参与。
来看下 IO 流程,DMA 帮忙做了什么事情:
- ⽤户应⽤进程调⽤ read 函数,向操作系统发起 IO 调⽤,进⼊阻塞状态,等待数据返回;
- CPU 收到指令后,对 DMA 控制器发起指令调度;
- DMA 收到 IO 请求后,将请求发送给磁盘;
- 磁盘将数据放⼊磁盘控制缓冲区,并通知 DMA;
- DMA 将数据从磁盘控制器缓冲区拷⻉到内核缓冲区;
- DMA 向 CPU 发出数据读完的信号,把⼯作交换给 CPU,由 CPU 负责将数据从内核缓冲区拷⻉到⽤户缓冲区;
- ⽤户应⽤进程由内核态切换回⽤户态,解除阻塞状态。
可以发现,DMA 做的事情很清晰啦,它主要就是帮忙 CPU 转发⼀下 IO 请求,以及拷⻉数据。
2.2 各模型介绍
网络 IO 的本质是 Socket 的读取,Socket 在 Linux 系统被抽象为流,IO 可以理解为对流的操作。
所以说,当一个 read 操作发生时,它会经历两个阶段:
- 第一阶段:等待数据准备(Waiting for the data to be ready)
- 第二阶段:将数据从内核拷贝到进程中(Copying the data from the kernel to the process)
对于 Socket 流而言:
- 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区;
- 第二步:把数据从内核缓冲区复制到应用进程缓冲区。
网络应用需要处理的无非就是两大类问题,网络 IO、数据计算。相对于后者,网络 IO 的延迟给应用带来的性能瓶颈大于后者。网络 IO 的模型大致有如下几种:
- 阻塞 IO (bloking IO)
- 非阻塞 IO (non-blocking IO)
- 多路复用 IO (multiplexing IO)
- 信号驱动式 IO (signal-driven IO)
- 异步 IO (asynchronous IO)
【注】由于 signal driven IO 在实际中并不常用,所以这只提及剩下的四种 IO Model。
a. 同步阻塞 IO
(1) 场景描述
我和女友点完餐后,不知道什么时候能做好,只好坐在餐厅里面等,直到做好,然后吃完才离开。女友本想还和我一起逛街的,但是不知道饭能什么时候做好,只好和我一起在餐厅等,而不能去逛街,直到吃完饭才能去逛街,中间等待做饭的时间浪费掉了。这就是典型的阻塞。
(2) 网络模型
同步阻塞 IO 模型是最常用的一个模型,也是最简单的模型。在 Linux 中,默认情况下所有的 socket 都是 blocking。它符合人们最常见的思考逻辑。阻塞就是进程 "被" 休息,CPU 处理其它进程去了。
在这个 IO 模型中,用户空间的应用程序执行一个系统调用 (recvfrom),这会导致应用程序阻塞,什么也不干,直到数据准备好,并且将数据从内核复制到用户进程,最后进程再处理数据,在等待数据到处理数据的两个阶段,整个进程都被阻塞。不能处理别的网络 IO。调用应用程序处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。在调用 recv()/recvfrom()
函数时,发生在内核中等待数据和复制数据的过程,大致如下图:
(3) 流程描述
当用户进程调用了 recv()/recvfrom()
这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据 (对于网络 IO 来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的 UDP 包。这个时候 kernel 就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞 (当然,是进程自己选择的阻塞)。第二个阶段:当 kernel 一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。
所以,blocking IO 的特点就是在 IO 执行的两个阶段都被 block 了。
优点:
- 能够及时返回数据,无延迟;
- 对内核开发者来说这是省事了;
缺点:
- 对用户来说处于等待就要付出性能的代价了;
b. 同步非阻塞 IO
(1) 场景描述
我女友不甘心白白在这等,又想去逛商场,又担心饭好了。所以我们逛一会,回来询问服务员饭好了没有,来来回回好多次,饭都还没吃都快累死了啦。这就是非阻塞。需要不断的询问,是否准备好了。
(2) 网络模型
同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询 (polling) 方式。在这种模型中,设备是以非阻塞的形式打开的。这意味着 IO 操作不会立即完成,read 操作可能会返回一个错误代码,说明这个命令不能立即满足 (EAGAIN 或 EWOULDBLOCK)。
在网络 IO 时候,非阻塞 IO 也会进行 recvfrom 系统调用,检查数据是否准备好,与阻塞 IO 不一样,“非阻塞将大的整片时间的阻塞分成 N 多的小的阻塞,所以进程不断地有机会 ‘被’ CPU 光顾”。
也就是说非阻塞的 recvfrom 系统调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个 error。进程在返回之后,可以干点别的事情,然后再发起 recvfrom 系统调用。重复上面的过程,循环往复的进行 recvfrom 系统调用。这个过程通常被称之为“轮询”。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。
在 Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程如图所示:
(3) 流程描述
当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。
同步非阻塞方式相比同步阻塞方式:
- 优点是能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行);
- 缺点是任务完成的响应延迟增大了,因为每过一段时间才去轮询一次 read 操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
c. 多路复用 IO
(1) 场景描述
与第二个方案差不多,餐厅安装了电子屏幕用来显示点餐的状态,这样我和女友逛街一会,回来就不用去询问服务员了,直接看电子屏幕就可以了。这样每个人的餐是否好了,都直接看电子屏幕就可以了,这就是典型的 IO 多路复用。
(2) 网络模型
由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的 CPU 时间,而 “后台” 可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。如果轮询不是用户的进程,而是有人帮忙就好了(多路复用器)。这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的 (epoll 比 poll、select 效率高,做的事情是一样的)。
- select 和 poll 只会通知用户进程有 Socket 就绪,但不确定具体是哪个 Socket ,需要用户进程逐个遍历 Socket 来确认;
- epoll 则会在通知用户进程 Socket 就绪的同时,把已就绪的 Socket 写入用户空间。
I/O 复用模型会用到 select、poll、epoll 函数,这几个函数也会使进程阻塞(整一个线程专门用来调这些函数,后给这个线程冠名为多路复用器),且这些函数调用是内核级别的。
select 轮询相对非阻塞的轮询的区别在于:select 可以对多个 socket 端口进行监听,当其中任何一个 socket 的数据准好了,就能返回进行可读,然后进程再进行 recvfrom 系统调用,将数据由内核拷贝到用户进程,当然这个过程是阻塞的。
select 相对 Blocking IO 阻塞不同在于:此时的 select 不是等到 socket 数据全部到达再处理,而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。也可以理解为“非阻塞”吧。
对于多路复用,也就是轮询多个 socket。多路复用既然可以处理多个 IO,也就带来了新的问题,多个 IO 之间的顺序变得不确定了,当然也可以针对不同的编号。具体流程如下图所示:
(3) 流程描述
IO multiplexing 就是我们说的 select、poll、epoll,有些地方也称这种 IO 方式为 event driven IO。select/epoll 的好处就在于单个 process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select、poll、epoll 这些个 function 会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。
当用户进程调用了 select,那么整个进程会被 block ,而同时,kernel 会“监视”所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。
多路复用的特点是通过一种机制让一个进程能同时等待多个 IO 文件描述符,内核监视这些套接字描述符 (socketfd),其中的任意一个进入读就绪状态,select、poll、epoll 函数就可以返回。对于监视的方式,又可以分为 select、poll、epoll 三种方式。
上面的图和 blocking IO 的图其实并没有太大的不同,事实上,还更差一些。 因为这里需要使用两个 system call (select 和 recvfrom),而 blocking IO 只调用了一个 system call (recvfrom)。但是,用 select 的优势在于它可以同时处理多个 connection。
所以,如果处理的连接数不是很高的话,使用 select/epoll 的 web server 不一定比使用 multi-threading + blocking IO 的 web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
在 IO Multiplexing Model 中,实际中,对于每一个 socket,一般都设置成为 Non-Blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO 给 block。所以 IO 多路复用是阻塞在 select、epoll 这样的系统调用之上,而没有阻塞在真正的 I/O 系统调用如 recvfrom 之上。
了解了前面三种 IO 模式,在用户进程进行系统调用的时候,他们在等待数据到来的时候,处理的方式不一样:直接等待、轮询、select 或 poll 轮询,两个阶段过程:
- 第一个阶段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞;
- 第二个阶段都是阻塞的。
从整个 IO 过程来看,他们都是顺序执行的,因此可以归为同步模型(synchronous)。都是进程主动等待且向内核检查状态。
高并发的程序一般使用同步非阻塞方式,而非多线程+同步阻塞方式。要理解这一点,首先要扯到并发和并行的区别。比如去某部门办事需要依次去几个窗口,办事大厅里的人数就是并发数,而窗口个数就是并行度。也就是说并发数是指同时进行的任务数 (如同时服务的 HTTP 请求),而并行数是可以同时工作的物理资源数量 (如 CPU 核数)。通过合理调度任务的不同阶段,并发数可以远远大于并行度,这就是区区几个 CPU 可以支持上万个用户并发请求的奥秘。在这种高并发的情况下,为每个任务 (用户请求) 创建一个进程或线程的开销非常大。而同步非阻塞方式可以把多个 IO 请求丢到后台去,这就可以在一个进程里服务大量的并发 IO 请求。
IO 多路复用是同步阻塞模型还是异步阻塞模型?
同步是需要主动等待消息通知,而异步则是被动接收消息通知,通过回调、通知、状态等方式来被动获取消息。IO 多路复用在阻塞到 select 阶段时,用户进程是主动等待并调用 select 函数获取数据就绪状态消息,并且其进程状态为阻塞。所以,把 IO 多路复用归为同步阻塞模式。
d. 信号驱动式 IO
信号驱动式 I/O:首先我们允许 Socket 进行信号驱动 IO,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。过程如下图所示:
e. 异步非阻塞 IO
(1) 场景描述
女友不想逛街,而餐厅又太吵了,所以回家好好休息一下。于是我们叫外卖,打个电话点餐,然后我和女友可以在家好好休息一下,饭好了送货员送到家里来。这就是典型的异步,只需要打个电话说一下,然后可以做自己的事情,饭好了就送来了。
(2) 网络模型
相对于同步 IO,异步 IO 不是顺序执行。用户进程进行 aio_read 系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到 socket 数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO 两个阶段,进程都是非阻塞的。
Linux 提供了 AIO 库函数实现异步,但是用的很少。目前有很多开源的异步 IO 库,例如 libevent、libev、libuv。异步过程如下图所示:
(3) 流程描述
用户进程发起 aio_read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后, 首先它会立刻返回,所以不会对用户进程产生任何 block 。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存, 当这一切都完成之后,kernel 会给用户进程发送一个 signal 或执行一个基于线程的回调函数来完成这次 IO 处理过程 ,告诉它 read 操作完成了。
在 Linux 中,通知的方式是 “信号”:
如果这个进程正在用户态忙着做别的事 (例如在计算两个矩阵的乘积),那就强行打断之,调用事先注册的信号处理函数,这个函数可以决定何时以及如何处理这个异步任务。由于信号处理函数是突然闯进来的,因此跟中断处理程序一样,有很多事情是不能做的,因此保险起见,一般是把事件 “登记” 一下放进队列,然后返回该进程原来在做的事。
如果这个进程正在内核态忙着做别的事,例如以同步阻塞方式读写磁盘,那就只好把这个通知挂起来了,等到内核态的事情忙完了,快要回到用户态的时候,再触发信号通知。
如果这个进程现在被挂起了,例如无事可做 sleep 了,那就把这个进程唤醒,下次有 CPU 空闲的时候,就会调度到这个进程,触发信号通知。
异步 API 说来轻巧,做来难,这主要是对 API 的实现者而言的。Linux 的异步 IO (AIO)支持是 2.6.22 才引入的,还有很多系统调用不支持异步 IO。Linux 的异步 IO 最初是为数据库设计的,因此通过异步 IO 的读写操作不会被缓存或缓冲,这就无法利用操作系统的缓存与缓冲机制。
很多人把 Linux 的 O_NONBLOCK 认为是异步方式,但事实上这是前面讲的同步非阻塞方式。需要指出的是,虽然 Linux 上的 IO API 略显粗糙,但每种编程框架都有封装好的异步 IO 实现。操作系统少做事,把更多的自由留给用户,正是 UNIX 的设计哲学,也是 Linux 上编程框架百花齐放的一个原因。
从前面 IO 模型的分类中,我们可以看出 AIO 的动机:
同步阻塞模型需要在 IO 操作开始时阻塞应用程序。这意味着不可能同时重叠进行处理和 IO 操作。
同步非阻塞模型允许处理和 IO 操作重叠进行,但是这需要应用程序根据重现的规则来检查 IO 操作的状态。
这样就剩下异步非阻塞 IO 了,它允许处理和 IO 操作重叠进行,包括 IO 操作完成的通知。
IO 多路复用除了需要阻塞之外,select 函数所提供的功能 (异步阻塞 IO)与 AIO 类似。不过,它是对通知事件进行阻塞,而不是对 IO 调用进行阻塞。
有时我们的 API 只提供异步通知方式,例如在 node.js 里,但业务逻辑需要的是做完一件事后做另一件事,例如数据库连接初始化后才能开始接受用户的 HTTP 请求。这样的业务逻辑就需要调用者是以阻塞方式来工作。
为了在异步环境里模拟 “顺序执行” 的效果,就需要把同步代码转换成异步形式,这称为 CPS (Continuation Passing Style)变换。BYVoid 大神的 continuation.js 库就是一个 CPS 变换的工具。用户只需用比较符合人类常理的同步方式书写代码,CPS 变换器会把它转换成层层嵌套的异步回调形式。
2.3 适用场景分析
- BIO 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,但程序简单易理解;
- NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4 开始支持;
- AIO 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7 开始支持。
3. Java NIO
3.1 review BIO
review JavaSE 阶段学习过的网络编程:
/**
* 客户端补充:
* - 简单测试:CMD -> telnet -> 'ctrl+]' -> send _(要发送的字符串)_
* - BIO 是 blocking I/O 的简称,它是同步阻塞型 IO,其相关的类和接口在 java.io 下。I/O 操作都是基于「流 Stream」的操作。
* - 服务端为每一个客户端连接都分配一个线程进行处理(当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大)
*
* 服务端补充:
* - serverSocket#accept和inputStream#read都会阻塞(连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费)
*/
public class BIOServer {
private static ExecutorService threadPool = new ThreadPoolExecutor(
3,
5,
3L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(16),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
System.out.println("Server is running ...");
while (true) {
final Socket socket = serverSocket.accept();
System.out.println("connect with a client success");
threadPool.execute(() -> handle(socket));
}
}
public static void handle(Socket socket) {
byte[] bytes = new byte[1024];
try {
while (true) {
InputStream inputStream = socket.getInputStream();
int read;
while ((read = inputStream.read(bytes)) > 0) {
System.out.println(new String(bytes, 0, read));
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
弊端:
- 线程开销:客户端的并发数与后端的线程数成 1:1 的比例,线程的创建、销毁是非常消耗系统资源的,随着并发量增大,服务端性能将显著下降,甚至会发生线程堆栈溢出等错误。
- 线程阻塞:当连接创建后,如果该线程没有操作时,会进行阻塞操作,这样极大的浪费了服务器资源。
3.2 NIO 概述
Java NIO 由以下几个核心部分组成:Channels、Buffers、Selectors
虽然 Java NIO 中除此之外还有很多类和组件,但在我看来,Channel、Buffer 和 Selector 构成了核心的 API。其它组件,如 Pipe 和 FileLock,只不过是与三个核心组件共同使用的工具类。因此,在概述中我将集中在这三个组件上。其它组件会在单独的章节中讲到。
基本上,所有的 IO 在 NIO 中都从一个 Channel 开始。Channel 有点像流。数据可以从 Channel 读到 Buffer 中,也可以从 Buffer 写到 Channel 中。这里有个图示:
Java NIO 的通道类似流(Stream),但又有些不同:
- 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的;
- 通道可以异步地读写;
- 通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入;
以下是 Java NIO 里关键的 Buffer 实现:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
这些 Buffer 覆盖了你能通过 IO 发送的基本数据类型:byte、short、int、long、float、double 和 char。
Selector 是一个 Java NIO 组件,可以检查一个或多个 NIO 通道,并确定哪些通道已准备好进行读取或写入。实现单个线程可以管理多个通道,从而管理多个网络连接。
Selector 允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用 Selector 就会很方便。例如,在一个聊天服务器中。
这是在一个单线程中使用一个 Selector 处理 3 个 Channel 的图示:
要使用 Selector,得向 Selector 注册 Channel,然后调用它的 select()
方法。这个方法会一直阻塞到某个注册的 Channel 有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。
一个线程使用 Selector 监听多个 Channel 的不同事件,4 个事件分别对应 SelectionKey 四个常量:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
3.3 Buffer
Buffer 是一个对象,包含一些要写入或者读出的数据,体现了与原 I/O 的一个重要区别,在面向流的 I/O 中,数据读写是直接进入到 Stream 中,而在 NIO 中,所有数据都是用缓冲区处理的,读数据直接从缓冲区读,写数据直接写入到缓冲区。
Java NIO 中的 Buffer 用于和 NIO Channel 进行交互。如你所知,数据是从 Channel 读入缓冲区,从缓冲区写入到 Channel 中的。
缓冲区的本质是一个数组,通常是一个字节数组(ByteBuffer),也可以使用其他类型,但缓冲区又不仅仅是一个数组,它还提供了对数据结构化访问以及维护读写位置等操作。
a. 基本用法
使用 Buffer 读写数据一般遵循以下 4 个步骤:
- 写入数据到 Buffer
- 调用
flip()
方法 - 从 Buffer 中读取数据
- 调用
clear()
或compact()
方法
当向 buffer 写入数据时,buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip()
方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 buffer 的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear()
或 compact()
方法。clear()
方法会清空整个缓冲区,compact()
方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。
下面是一个使用 Buffer 的例子:
// create buffer with capacity of 1024 bytes
IntBuffer buf = IntBuffer.allocate(1024);
// write
for (int i = 0; i < buf.capacity(); i++) {
buf.put(i);
}
// make buffer ready for read
buf.flip();
while (buf.hasRemaining()) {
System.out.println(buf.get());
}
NIO 和 BIO 的比较:
- BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多;
- BIO 是阻塞的,NIO 则是非阻塞的;
- BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel(通道) 和 Buffer(缓冲区) 进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器) 用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。
b. 工作原理
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。
为了理解 Buffer 的工作原理,需要熟悉它的这 3 个属性:capacity,position,limit。
position 和 limit 的含义取决于 Buffer 处在读模式还是写模式。不管 Buffer 处在什么模式,capacity 的含义总是一样的。
(1)capacity
作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”。你只能往里写 capacity 个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续往里写数据。
(2)position
当你写数据到 Buffer 中时,position 表示当前的位置。初始的 position 值为 0,当一个 byte、long 等数据写到 Buffer 后,position 会向前移动到下一个可插入数据的 Buffer 单元。position 最大可为 capacity-1。
当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position 会被重置为 0,当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置。
(3)limit
在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等于 Buffer.capacity。
当切换 Buffer 到读模式时, limit 表示你最多能读到多少数据。因此,当切换 Buffer 到读模式时,limit 会被设置成写模式下的 position 值。换句话说,你能读到之前写入的所有数据(limit 被设置成已写数据的数量,这个值在写模式下就是 position)
以上三个属性值之间有一些相对大小的关系:0 <= position <= limit <= capacity。
【示例】如果我们创建一个新的容量大小为 10 的 ByteBuffer 对象,在初始化的时候,position 设置为 0,limit 和 capacity 被设置为 10,在以后使用 ByteBuffer 对象过程中,capacity 的值不会再发生变化,而其它两个个将会随着使用而变化。3 个属性值分别如图所示:
现在我们可以从通道中读取一些数据到缓冲区中,注意从通道读取数据,相当于往缓冲区中写入数据。如果读取 4 个字节的数据,则此时 position 的值为 4,即下一个将要被写入的字节索引为 4,而 limit 仍然是 10,如下图所示:
下一步把读取的数据写入到输出通道中,相当于从缓冲区中读取数据,在此之前,必须调用 flip() 方法,该方法将会完成两件事情:
- 把 limit 设置为当前的 position 值
- 把 position 设置为 0
由于 position 被设置为 0,所以可以保证在下一步输出时读取到的是缓冲区中的第一个字节,而 limit 被设置为当前的 position,可以保证读取的数据正好是之前写入到缓冲区中的数据(也就是定义好本次读取最后一个字节的位置:读取为从 position~limit),如下图所示:
现在调用 get() 方法从缓冲区中读取数据写入到输出通道,这会导致 position 的增加而 limit 保持不变,但 position 不会超过 limit 的值,所以在读取我们之前写入到缓冲区中的 4 个字节之后,position 和 limit 的值都为 4,如下图所示:
在从缓冲区中读取数据完毕后,limit 的值仍然保持在我们调用 flip() 方法时的值,调用 clear() 方法能够把所有的状态变化设置为初始化时的值,如下图所示:
c. 分配和读写
(1)Buffer 的分配
要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有一个 allocate 方法。下面是一个分配 48 字节 capacity 的 ByteBuffer 的例子。
ByteBuffer buf = ByteBuffer.allocate(48);
这是分配一个可存储 1024 个字符的 CharBuffer:
CharBuffer buf = CharBuffer.allocate(1024);
(2)写数据到 Buffer 有两种方式
- 从 Channel 写到 Buffer:
int bytesRead = inChannel.read(buf);
- 通过 Buffer 的 put 方法写到 Buffer 里:
buf.put(127);
put 方法有很多版本,允许你以不同的方式把数据写入到 Buffer 中。例如, 写到一个指定的位置,或者把一个字节数组写入到 Buffer。
(3)flip 方法将 Buffer 从写模式切换到读模式
调用 flip()
会将 position 设回 0,并将 limit 设置成之前 position 的值。
换句话说,position 现在用于标记读的位置(limit 表示之前写进了多少个 byte/char/...,也就是现在能读取多少个)。
(4)从 Buffer 读数据
- 从 Buffer 读取数据到 Channel:
int bytesWritten = inChannel.write(buf);
- 使用
get()
从 Buffer 中读取数据:byte aByte = buf.get();
get 方法有很多版本,允许你以不同的方式从 Buffer 中读取数据。例如,从指定 position 读取,或者从 Buffer 中读取数据到字节数组。
d. 常用方法
(1)clear()
、compact()
一旦读完 Buffer 中的数据,需要让 Buffer 准备好再次被写入。可以通过 clear()
或 compact()
来完成。
如果调用的是 clear()
则 position 将被设回 0,limit 被设置成 capacity 的值。换句话说,Buffer 被清空了。其实 Buffer 中的数据并未清除,只是这些标记告诉我们可以从哪里开始往 Buffer 里写数据(直接覆盖)。
如果 Buffer 中有一些未读的数据,调用 clear()
数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。
如果 Buffer 中仍有未读的数据,且后续还需要这些数据,但是此时想要先写些数据,那么使用 compact()
方法。
compact()
将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未读元素正后面。limit 属性依然像 clear()
方法一样,设置成 capacity。现在 Buffer 准备好写数据了,但是不会覆盖未读的数据。
(2)mark()
、reset()
通过调用 mark()
方法,可以标记 Buffer 中的一个特定 position。之后可以通过调用 reset()
方法恢复到这个 position。
(3)equals()
、compareTo()
可以使用 equals()
和 compareTo()
方法比较两个 Buffer。
equals()
当满足下列条件时,表示两个 Buffer 相等:
- 有相同的类型(byte、char、int 等);
- Buffer 中剩余的 byte、char 等的个数相等;
- Buffer 中所有剩余的 byte、char 等都相同。
如你所见,equals 只是比较 Buffer 的一部分,不是每一个在它里面的元素都比较。实际上,它只比较 Buffer 中的剩余元素(从 position 到 limit 之间的元素)。
compareTo()
比较两个 Buffer 的剩余元素(byte、char 等), 如果满足下列条件则认为一个 Buffer“小于”另一个 Buffer:
- 第一个不相等的元素小于另一个 Buffer 中对应的元素;
- 所有元素都相等,但第一个 Buffer 比另一个先耗尽(第一个 Buffer 的元素个数比另一个少)
(4)API
public abstract class Buffer {
// JDK1.4 引入的 api
public final int capacity(); // 返回此缓冲区的容量
public final int position(); // 返回此缓冲区的位置
public final Buffer position(int newPositio); // 设置此缓冲区的位置
public final int limit(); // 返回此缓冲区的限制
public final Buffer limit(int newLimit); // 设置此缓冲区的限制
public final Buffer mark(); // 在此缓冲区的位置设置标记
public final Buffer reset(); // 将此缓冲区的位置重置为以前标记的位置
public final Buffer clear(); // 清除此缓冲区,即将各个标记恢复到初始状态(后面操作会覆盖)
public final Buffer flip(); // 反转此缓冲区
public final Buffer rewind(); // 重绕此缓冲区
public final int remaining(); // 返回当前位置与限制之间的元素数
public final boolean hasRemaining(); // 告知在当前位置和限制之间是否有元素
public abstract boolean isReadOnly(); // 告知此缓冲区是否为只读缓冲区
// JDK1.6 引入的 api
public abstract boolean hasArray(); // 告知此缓冲区是否具有可访问的底层实现数组
public abstract Object array(); // 返回此缓冲区的底层实现数组
public abstract int arrayOffset(); // 返回此缓冲区的底层实现数组中第1个缓冲区元素的offset
public abstract boolean isDirect(); // 告知此缓冲区是否为直接缓冲区
}
3.4 Channel
正如上面所说,从通道读取数据到缓冲区,从缓冲区写入数据到通道。
Channel 和 Buffer 有好几种类型。下面是 Java NIO 中的一些主要 Channel 的实现,这些通道涵盖了 UDP 和 TCP 网络 IO,以及文件 IO。
实现 | 说明 |
---|---|
FileChannel | 从文件中读写数据 |
DatagramChannel | 能通过 UDP 读写网络中的数据 |
SocketChannel | 能通过 TCP 读写网络中的数据 |
ServerSocketChannel | 可以监听新进来的 TCP 连接,像 Web 服务器那样;对每一个新进来的连接都会创建一个 SocketChannel |
a. FileChannel
(1)本地文件写数据
/**
* Java NIO 中的 FileChannel 是一个连接到文件的通道。可以通过文件通道读写文件。
* FileChannel 无法设置为非阻塞模式,它总是运行在〈阻塞模式下〉。
*/
public class NIOFileChannel1 {
public static void main(String[] args) throws Exception {
String str = "Hello, 2022!";
FileOutputStream fileOutputStream = new FileOutputStream("d:\\file.txt");
FileChannel fileChannel = fileOutputStream.getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(13);
byteBuffer.put(str.getBytes());
byteBuffer.flip();
/**
* [注] FileChannel.write() 是在 while 循环中调用的。因为无法保证 write()
* 一次能向 FileChannel,写入多少字节,因此需要重复调用 write(),直到
* Buffer 中已经没有尚未写入通道的字节。
*/
while (byteBuffer.hasRemaining()) {
fileChannel.write(byteBuffer);
}
fileOutputStream.close();
fileChannel.close();
}
}
(2)本地文件读数据
public class NIOFileChannel2 {
public static void main(String[] args) throws Exception {
File file = new File("d:\\file.txt");
/**
* (1) 打开 FileChannel
* 在使用 FileChannel 之前必须先打开它。但是,我们无法直接打开一个 FileChannel,需要通过
* 使用一个 InputStream、OutputStream 或 RandomAccessFile 来获取一个 FileChannel 实例
*/
FileInputStream fileInputStream = new FileInputStream(file);
FileChannel fileChannel = fileInputStream.getChannel();
/**
* (2) 从 FileChannel 读取数据
* 调用多个 read() 方法之一从FileChannel中读取数据。
* 首先,分配一个 Buffer。从 FileChannel 中读取的数据将被读到 Buffer 中。
* 然后,调用 FileChannel.read() 将数据从FileChannel读取到 Buffer 中。
* read() 返回的 int 表示了有多少字节被读到了 Buffer 中。如果返回-1,表示到了文件末尾。
*/
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
fileChannel.read(byteBuffer);
System.out.println(new String(byteBuffer.array()));
fileInputStream.close();
fileChannel.close();
}
}
(3)通道之间的数据传输 1
public class NIOFileChannel3 {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("1.txt");
FileChannel iFileChannel = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("2.txt");
FileChannel oFileChannel = fileOutputStream.getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true) {
/**
* 如果不复位,下面 read = limit-position = 0,while 就会陷入死循环
*/
byteBuffer.clear();
int read = iFileChannel.read(byteBuffer);
/**
* ---------------------
* position: 0
* limit : 40
* ---------------------
*/
if (read == -1) {
break;
}
byteBuffer.flip();
oFileChannel.write(byteBuffer);
/**
* ---------------------
* position: 40
* limit : 40
* ---------------------
*/
}
}
}
(4)通道之间的数据传输 2
在 Java NIO 中,如果两个通道中有一个是 FileChannel,那你可以直接将数据从一个 channel 传输到另外一个 channel。
FileChannel 的 transferFrom() 方法可以将数据从源通道传输到 FileChannel 中(译者注:这个方法在 JDK 文档中的解释为将字节从给定的可读取字节通道传输到此通道的文件中)。下面是一个简单的例子:
public class NIOFileChannel4 {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("d:\\XiaMi.png");
FileChannel srcChannel = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("d:\\goodbye.png");
FileChannel destChannel = fileOutputStream.getChannel();
destChannel.transferFrom(srcChannel, 0, srcChannel.size());
srcChannel.close();
fileInputStream.close();
destChannel.close();
fileOutputStream.close();
}
}
方法的输入参数 position 表示从 position 处开始向目标文件写入数据,count 表示最多传输的字节数。如果源通道的剩余空间小于 count 个字节,则所传输的字节数要小于请求的字节数。
此外要注意,在 SoketChannel 的实现中,SocketChannel 只会传输此刻准备好的数据(可能不足 count 字节)。因此,SocketChannel 可能不会将请求的所有数据(count 个字节)全部传输到 FileChannel 中。
其他注意事项:
- ByteBuffer 支持类型化的 put 和 get,put 放入的是什么数据类型,get 就应该使用相应的数据类型来取出,否则可能有 BufferUnderflowException 异常;
- 可以将一个普通 Buffer 转成只读 Buffer,如果向只读 Buffer 中写会抛出 ReadOnlyBufferException 异常;
- NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 而如何同步到文件由 NIO 来完成;
/**
* @author 6x7
* @Description MappedByteBuffer 可让文件直接在内存(堆外内存)修改,OS 不需要拷贝一次
* @createTime 2022年02月18日
*/
public class MappedByteBufferTest {
public static void main(String[] args) throws Exception {
RandomAccessFile randomAccessFile = new RandomAccessFile("D:\\Download\\1.txt", "rw");
// 获取对应的通道
FileChannel channel = randomAccessFile.getChannel();
/**
* Maps a region of this channel's file directly into memory.
*
* param1(mode): One of the constants MapMode, according to whether the file is to
* be mapped read-only, read/write, or privately (copy-on-write)
*
* param2(position): The position within the file at which the mapped region is to
* start; must be non-negative.
*
* param3(size): The size of the region to be mapped; must be non-negative and
* no greater than Integer#MAX_VALUE.
*/
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 1024);
// DirectByteBuffer
System.out.println(mappedByteBuffer.getClass());
mappedByteBuffer.put(4, (byte) '-');
mappedByteBuffer.put(13, (byte) '!');
channel.close();
randomAccessFile.close();
}
}
b. SocketChannel
Java NIO 中的 SocketChannel 是一个连接到 TCP 网络套接字的通道。可以通过以下 2 种方式创建 SocketChannel:
- 打开一个 SocketChannel 并连接到互联网上的某台服务器;
- 一个新连接到达 ServerSocketChannel 时,会创建一个 SocketChannel。
(1)打开 SocketChannel
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
(2)关闭 SocketChannel
socketChannel.close();
(3)从 SocketChannel 读取数据
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
首先,分配一个 Buffer。从 SocketChannel 读取到的数据将会放到这个 Buffer 中。
然后,调用 SocketChannel.read()
将数据从 SocketChannel 读到 Buffer 中。read() 返回的 int 值表示读了多少字节进 Buffer 里。如果返回的是 -1,表示已经读到了流的末尾(连接关闭了)。
(4)写入 SocketChannel
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
// write() 无法保证能写多少字节到 SocketChannel
// 故重复调用 write() 直到 Buffer 没有要写的字节为止
while (buf.hasRemaining()) {
channel.write(buf);
}
(5)非阻塞模式
可以设置 SocketChannel 为非阻塞模式(Non-Blocking Mode),设置之后,就可以在异步模式下调用 connect()/read()/write() 了。
- connect():非阻塞模式下,调用 connect() 可能会在连接建立之前就返回了。为了确定连接是否建立,可以调用 finishConnect();
- write():非阻塞模式下,write() 在尚未写出任何内容时可能就返回了。所以需要在循环中调用 write();
- read():非阻塞模式下,read() 在尚未读取到任何数据时可能就返回了。所以需要关注它的 int 返回值,它会告诉你读取了多少字节。
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
while (! socketChannel.finishConnect()) {
// wait, or do something else...
}
(6)非阻塞模式与选择器
非阻塞模式与选择器搭配会工作的更好,通过将一或多个 SocketChannel 注册到 Selector,可以询问选择器哪个通道已经准备好了读取、写入等。
c. ServerSocketChannel
Java NIO 中的 ServerSocketChannel 是一个可以监听新进来的 TCP 连接的通道,就像标准 IO 中的 ServerSocket 一样。ServerSocketChannel 类在 java.nio.channels 包中。
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
// do something with socketChannel...
}
(1)打开 ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
(2)关闭 ServerSocketChannel
serverSocketChannel.close();
(3)监听新进来的连接
通过 ServerSocketChannel#accept()
监听新进来的连接。当 accept() 返回的时候,会返回一个包含新进来的连接的 SocketChannel。因此,accept() 会一直阻塞到有新连接到达。
(4)非阻塞模式
ServerSocketChannel 可以设置成非阻塞模式。在非阻塞模式下,accept() 会立刻返回,如果还没有新进来的连接,返回的将是 null。 因此,需要检查返回的 SocketChannel 是否是 null。如:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
// do something with socketChannel...
}
}
d. 分散和聚集
Java NIO 开始支持 scatter/gather,scatter/gather 用于描述从 Channel(译者注:Channel 在中文经常翻译为“通道”)中读取或者写入到 Channel 的操作。
- 分散(scatter):从 Channel 中读取是指在读操作时将读取的数据写入多个 buffer 中。因此,Channel 将从 Channel 中读取的数据“分散(scatter)”到多个 Buffer 中;
- 聚集(gather):写入 Channel 是指在写操作时将多个 buffer 的数据写入同一个 Channel,因此,Channel 将多个 Buffer 中的数据“聚集(gather)”后发送到 Channel。
scatter/gather 经常用于需要将传输的数据分开处理的场合,例如传输一个由消息头和消息体组成的消息,你可能会将消息体和消息头分散到不同的 buffer 中,这样你可以方便的处理消息头和消息体。
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.read(bufferArray);
注意 buffer 首先被插入到数组,然后再将数组作为 channel.read() 的输入参数。read() 按照 buffer 在数组中的顺序将从 channel 中读取的数据写入到 buffer,当一个 buffer 被写满后,channel 紧接着向另一个 buffer 中写。
Scattering Reads 在移动下一个 buffer 前,必须填满当前的 buffer,这也意味着它不适用于动态消息(译者注:消息大小不固定)。换句话说,如果存在消息头和消息体,消息头必须完成填充(例如 128byte),Scattering Reads 才能正常工作。
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.write(bufferArray);
buffers 数组是 write() 方法的入参,write() 方法会按照 buffer 在数组中的顺序,将数据写入到 channel,注意只有 position 和 limit 之间的数据才会被写入。因此,如果一个 buffer 的容量为 128byte,但是仅仅包含 58byte 的数据,那么这 58byte 的数据将被写入到 channel 中。因此与 Scattering Reads 相反,Gathering Writes 能较好的处理动态消息。
测试案例:
public static void main(String[] args) throws Exception {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress inetSocketAddress = new InetSocketAddress(8080);
// bind Port to Socket
serverSocketChannel.socket().bind(inetSocketAddress);
// 创建 buffer 数组
ByteBuffer[] byteBuffers = new ByteBuffer[2];
byteBuffers[0] = ByteBuffer.allocate(6);
byteBuffers[1] = ByteBuffer.allocate(7);
// 等待客户端连接 ServerSocketChannel=>SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
// 假定client发送了10个字节
int messageLength = 10;
// 循环的读取
while (true) {
int byteRead = 0;
while (byteRead < messageLength) {
long read = socketChannel.read(byteBuffers);
byteRead += read;
System.out.println("====> byteRead: " + byteRead);
// 使用流打印,看看当前这个buffer的position和limit
Arrays.asList(byteBuffers).stream().forEach(buf -> {
System.out.println("position: "+buf.position()+", limit: "+buf.limit());
});
}
// 将数据读出显示到客户端
Arrays.asList(byteBuffers).stream().forEach(buf -> buf.flip());
long byteWrite = 0;
while (byteWrite < messageLength) {
long write = socketChannel.write(byteBuffers);
byteWrite += write;
}
// 将所有的 buffer 进行 clear
Arrays.asList(byteBuffers).stream().forEach(buf -> buf.clear());
System.out.println("byteRead: " + byteRead + ", byteWrite: " + byteWrite);
}
}
telnet 127.0.0.1 8080
3.5 Selector
a. 基本说明
Selector(选择器)是 Java NIO 中能够检测一到多个 NIO 通道并能够知晓通道是否为诸如读写事件做好准备的组件(多个 Channel 以事件的方式可以注册到同一个 Selector)。这样,一个单独的线程可以管理多个 Channel,从而管理多个网络连接。
仅用单个线程来处理多个 Channel 的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。
但要记住,现代的操作系统和 CPU 在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个 CPU 有多个内核,不使用多任务可能是在浪费 CPU 能力。
- Netty 的 IO 线程 NioEventLoop 聚合了 Selector(选择器,也叫多路复用器),可以同时并发处理成百上千个客户端连接;
- 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务;
- 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道;
- 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 IO 阻塞导致的线程挂起;
- 一个 IO 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 IO 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
NIO 中的 ServerSocketChannel 功能类似 ServerSocket,SocketChannel 功能类似 Socket:
public abstract class Selector implements Closeable {
/**
* Initializes a new instance of this class.
*/
protected Selector() { }
/**
* Opens a selector.
*/
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
/**
* Tells whether or not this selector is open.
*/
public abstract boolean isOpen();
/**
* Returns the provider that created this channel.
*/
public abstract SelectorProvider provider();
/**
* Returns this selector's key set.
*/
public abstract Set<SelectionKey> keys();
/**
* Returns this selector's selected-key set.
*/
public abstract Set<SelectionKey> selectedKeys();
/**
* Selects a set of keys whose corresponding channels are ready for I/O operations.
*/
public abstract int selectNow() throws IOException;
/**
* Selects a set of keys whose corresponding channels are ready for I/O operations.
* 监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间。
*/
public abstract int select(long timeout) throws IOException;
/**
* Selects a set of keys whose corresponding channels are ready for I/O operations.
* -----------------------------------------------------------------------------------
* This method performs a blocking selection operation. It returns only after
* at least one channel is selected, this selector's method is invoked, or the current
* thread is interrupted, whichever comes first.
*/
public abstract int select() throws IOException;
/**
* Causes the first selection operation that has not yet returned to return immediately.
*/
public abstract Selector wakeup();
/**
* Closes this selector.
*/
public abstract void close() throws IOException;
}
原理简单分析:
b. 向 Selector 注册通道
通过调用 Selector.open()
方法创建一个 Selector,如下:
Selector selector = Selector.open();
为了将 Channel 和 Selector 配合使用,必须将 channel 注册到 selector 上。通过 SelectableChannel.register()
方法来实现,如下:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);
与 Selector 一起使用时,Channel 必须处于〈非阻塞模式〉下。这意味着不能将 FileChannel 与 Selector 一起使用,因为 FileChannel 不能切换到非阻塞模式,而「套接字通道」都可以。
注意 register()
方法的第二个参数。这是一个“interest 集合”,意思是在通过 Selector 监听 Channel 时对什么事件感兴趣。可以监听 4 种不同类型的事件:Connect、Accept、Read、Write。
Channel 触发了一个事件的意思是该事件已经就绪。所以,某个 Channel 成功连接到另一个服务器称为“连接就绪”、一个 ServerSocketChannel 准备好接收新进入的连接称为“接收就绪”、一个有数据可读的 Channel 可以说是“读就绪”、等待写数据的 Channel 可以说是“写就绪”。
这四种事件用 SelectionKey 的四个常量来表示:
public abstract class SelectionKey {
// 读取就绪
public static final int OP_READ = 1 << 0;
// 写入就绪
public static final int OP_WRITE = 1 << 2;
// 连接就绪
public static final int OP_CONNECT = 1 << 3;
// 准备就绪
public static final int OP_ACCEPT = 1 << 4;
// ...
}
如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
c. 通过 Selector 选择通道
当向 Selector 注册 Channel 时,register()
方法会返回一个 SelectionKey 对象,这个对象用来表示 Selector 和网络通道的注册关系(共 4 种)。
一旦向 Selector 注册了一或多个通道,就可以调用几个重载的 select()
方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()
方法会返回读事件已经就绪的那些通道。
// 阻塞到至少有 1 个通道在你注册的事件上就绪了
int select()
// 最长会阻塞 timeout 毫秒
int select(long timeout)
// 非阻塞,不管什么通道就绪都立刻返回。若前一次选择操作后没有通道变成可选择则直接返 0
int selectNow()
select() 方法返回的 int 值表示有多少通道已经就绪。亦即,自上次调用 select() 方法后有多少通道变成就绪状态。如果调用 select() 方法,因为有一个通道变成就绪状态,返回了 1,若再次调用 select() 方法,如果另一个通道就绪了,它会再次返回 1。如果对第一个就绪的 channel 没有做任何操作,现在就有两个就绪的通道。
一旦调用了 select() 方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用 selector 的 selectedKeys()
方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:Set selectedKeys = selector.selectedKeys();
可以遍历这个已选择的键集合来访问就绪的通道。如下:
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading.
} else if (key.isWritable()) {
// a channel is ready for writing.
}
keyIterator.remove();
}
这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件。
注意每次迭代末尾的 keyIterator.remove()
调用。Selector 不会自己从已选择键集中移除 SelectionKey 实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector 会再次将其放入已选择键集中。
SelectionKey.channel()
方法返回的通道需要转型成你要处理的类型,如 ServerSocketChannel 或 SocketChannel 等。
某个线程调用 select() 方法后阻塞了,即使没有通道已经就绪,也有办法让其从 select() 方法返回。只要让其它线程在第一个线程调用 select() 方法的那个对象上调用 Selector.wakeup()
方法即可。阻塞在 select() 方法上的线程会立马返回。
如果有其它线程调用了 wakeup()
方法,但当前没有线程阻塞在 select() 方法上,下个调用 select() 方法的线程会立即“醒来(wake up)”。
用完 Selector 后调用其 close()
方法会关闭该 Selector 且使注册到该 Selector 上的所有 SelectionKey 实例无效。通道本身并不会关闭。
d. SelectionKey API
当向 Selector 注册 Channel 时,register() 方法会返回一个 SelectionKey 对象。这个对象包含了一些你感兴趣的属性:
(1)interest 集合
可以通过 SelectionKey 读写 interest 集合,像这样:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
可以看到,用“位与”操作 interest 集合和给定的 SelectionKey 常量,可以确定某个确定的事件是否在 interest 集合中。
(2)ready 集合
ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个 readySet,可以这样访问 ready 集合:
int readySet = selectionKey.readyOps();
可以用像检测 interest 集合那样的方法,来检测 channel 中什么事件或操作已经就绪。但是,也可以使用以下 4 个方法,它们都会返回一个布尔类型:
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
(3)访问 Channel 和 Selector
从 SelectionKey 访问 Channel 和 Selector 很简单。如下:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
(4)附加的对象
可以将一个对象或者更多信息附着到 SelectionKey 上,这样就能方便的识别某个给定的通道。例如,可以附加与通道一起使用的 Buffer 或是包含聚集数据的某个对象。使用方法如下:
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();
还可以在用 register()
方法向 Selector 注册 Channel 的时候附加对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
e. 完整示例
在说完整示例前,先回顾下 ServerSocketChannel 和 SocketChannel。
- ServerSocketChannel 负责在服务器端监听新的客户端 Socket 连接;
- SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。
下面有一个完整的示例,打开一个 Selector,注册一个通道注册到这个 Selector 上(通道的初始化过程略去),然后持续监控这个 Selector 的四种事件(接受、连接、读、写)是否就绪。
public class NIOServer {
public static void main(String[] args) throws Exception {
// 1. 创建 ServerSocketChannel -> ServerSocket
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 2. 绑定端口 8080
serverSocketChannel.socket().bind(new InetSocketAddress(8080));
// 3. 设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 4. 得到一个 Selector
Selector selector = Selector.open();
// 5. 把 ServerSocketChannel 注册到 Selector # 关心事件为 OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 6. 循环等待客户端连接
while (true) {
if (selector.select(1000) == 0) {
System.out.println("Server waited 1000 ms, but no client connect.");
continue;
} else {
// 获取相关的 SelectionKey 集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 通过 SelectionKey 反向获取 Channel
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey selectionKey = keyIterator.next();
if (selectionKey.isAcceptable()) {
System.out.println("a connection was accepted by a ServerSocketChannel ...");
SocketChannel socketChannel = serverSocketChannel.accept();
// 指定为非阻塞
socketChannel.configureBlocking(false);
// 将 socketChannel 注册到 selector,关注事件为 OP_READ,同时给 SocketChannel 关联一个 Buffer
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
} else if (selectionKey.isConnectable()) {
System.out.println("a connection was established with a remote server ...");
} else if (selectionKey.isReadable()) {
System.out.println("a channel is ready for reading ...");
SocketChannel channel = (SocketChannel) selectionKey.channel();
// 获取到该 Channel 关联的 Buffer
ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
// channel ->data-> buffer
channel.read(buffer);
System.out.println("From Client: " + new String(buffer.array()));
} else if (selectionKey.isWritable()) {
System.out.println("a channel is ready for writing ...");
}
// 手动从集合中移除当前的 SelectionKey,防止重复操作
keyIterator.remove();
}
}
}
}
}
客户端:
public class NIOClient {
public static void main(String[] args) throws Exception {
// 得到一个网路通道,对应到服务端的一个 SocketChannel
SocketChannel socketChannel = SocketChannel.open();
// 设置非阻塞
socketChannel.configureBlocking(false);
// 提供服务器端的 IP 和 PORT
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 8080);
// 连接 NIOServer
if (!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()) {
System.out.println("因为连接需要时间,客户端不会阻塞,可以做些其他工作。");
}
}
String msg = "Hello, NIOServer!";
// Wraps a byte array into a buffer.
ByteBuffer byteBuffer = ByteBuffer.wrap(msg.getBytes());
socketChannel.write(byteBuffer);
// ---- Stop In there ----
System.in.read();
}
}
3.6 Pipe
Java NIO 管道是 2 个线程之间的单向数据连接。Pipe 有一个 source 通道和一个 sink 通道。数据会被写到 sink 通道,从 source 通道读取。
这里是 Pipe 原理的图示:
(1)创建管道
Pipe pipe = Pipe.open();
(2)向管道写数据
需要先访问 sink 通道:
Pipe.SinkChannel sinkChannel = pipe.sink();
再通过调用 SinkChannel 的 write() 方法,将数据写入 SinkChannel:
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
sinkChannel.write(buf);
}
(3)从管道读取数据
从读取管道的数据,需要访问 source 通道:
Pipe.SourceChannel sourceChannel = pipe.source();
调用 source 通道的 read() 方法来读取数据:
ByteBuffer buf = ByteBuffer.allocate(48);
// 返回的 int 值会告诉我们多少字节被读进了缓冲区
int bytesRead = sourceChannel.read(buf);
4. 补充
(1)Socket 传输网络数据的过程
(2)TCP 发送缓冲区 / TCP 接收缓冲区
在传输层,每个 Socket 对应的 TCP 连接都拥有自己的接收缓冲区和发送缓冲区。
- 【接收缓冲区】用于存储网络层发往当前 TCP 连接的分组数据,直到应用层将数据从其中取走;
- 【发送缓冲区】用于存储当前 TCP 连接即将发送给对端的分组数据,直到收到对端的接收确认。
TCP 发送缓冲区和接收缓冲区用于实现 TCP 协议的“滑动窗口”和“流量控制”功能,保证 TCP 的可靠传输。
(3)Socket 中 read/write 的语义
- SocketChannel.read(ByteBuffer dest)
- 阻塞模式下:阻塞直到“TCP 接收缓冲区”中有数据可读,并且成功将数据读取到了应用进程 ByteBuffer 中;
- 非阻塞模式下:尝试从“TCP 接收缓冲区”中读取当前可读的数据,不论是否能读取到数据均立即返回。
- SocketChannel.write(ByteBuffer src)
- 阻塞模式下:阻塞直到 ByteBuffer src 中的数据全部成功写入“TCP 发送缓冲区”中;
- 非阻塞模式下:尝试将 ByteBuffer src 中的数据写入“TCP 发送缓冲区”中,不保证数据全部写入(如果“TCP 发送缓冲区”中空间不足,也可能写入部分数据),然后立即返回。
(4)SelectionKey 的 operation 语义
- OP_READ:“TCP 接收缓冲区”中有了新的可读数据;
- OP_WRITE:“TCP 发送缓冲区”中可以写入新的数据了;
- OP_CONNECT:“TCP 三次握手”完成;
- OP_ACCEPT:新的客户端 Socket 连接建立完成,放入了服务端的“已完成连接队列”。
(5)Direct ByteBuffer 和 Non-Direct ByteBuffer
- Non-Direct ByteBuffer 的内存是分配在堆上的,可以把它想象成一个字节数组的包装类,直接由 JVM 负责垃圾收集;
- Direct ByteBuffer 是通过 JNI 在 JVM 外的内存空间分配的,该内存块并不直接由 JVM 负责垃圾回收,而是在 Direct ByteBuffer 包装类被回收时,通过 Weak Reference 机制来释放该内存块。
操作系统无法直接操作用户空间的 ByteBuffer,Socket 在调用 read/write 进行网络读写操作时,会先把用户空间的 Non-Direct ByteBuffer 转换为操作系统内核空间的临时 Direct ByteBuffer(这个过程需要进行内存拷贝),然后再进行相关的操作。
如果直接将用户进程的数据存储在 Direct ByteBuffer 中,则可以节省两次内存拷贝的时间。然后构造和析构临时 Direct ByteBuffer 的时间和代价比较长,因此一般对 Direct ByteBuffer 进行池化处理。