传统网络编程存在的问题
传统网络编程存在以下问题:
- 线程创建开销:在Java中,创建线程需要调用操作系统API,这会消耗资源和时间。
- 内存占用高:线程本身占用内存,创建过多线程会导致内存资源紧张。
- CPU使用率高:线程在等待时需要进行上下文切换,这会增加CPU的负担。
改进,使用线程池:
- 优点:可以减少线程频繁创建、销毁的开销。
- 缺点:线程池中的线程可能会因为客户端处理时间长而被阻塞,导致资源浪费。
再改进,使用 NIO 网络通信非阻塞编程。
- 优势:NIO 网络编程也是使用线程池,但是它是非阻塞的。这就解决了
线程池版网络编程
中的阻塞问题。
BIO、NIO和AIO的区别?
BIO、NIO和AIO是Java中网络编程的三种不同的I/O模型,它们各自有不同的特点和适用场景。以下是它们的主要区别:
-
BIO(Blocking I/O,阻塞I/O):在BIO模型中,服务器为每个客户端连接都创建一个线程来处理,这意味着每个线程都是阻塞的,即在等待I/O操作完成时,线程会被挂起,直到操作完成。
-
优点是模型简单,易于理解和使用。
-
缺点是当客户端数量增多时,线程数量也会线性增加,这会导致资源消耗大,性能下降,特别是在高并发场景下。
-
-
NIO(Non-blocking I/O,非阻塞I/O):NIO是Java 1.4版本引入的,它支持面向缓冲区的I/O操作,可以更高效地处理数据。NIO使用缓冲区(Buffer)和通道(Channel)来进行数据的读写,而不是使用传统的流(Stream)。
-
它支持非阻塞模式,可以通过Selector来管理多个Channel,一个线程可以同时处理多个Channel的I/O请求。
-
优点是减少了线程的创建和销毁,提高了资源利用率和系统吞吐量。
-
-
AIO(Asynchronous I/O,异步I/O):AIO是Java 7版本引入的,它支持真正的异步I/O操作。
-
在AIO模型中,I/O操作是异步的,当发起一个I/O请求后,系统会立即返回,不会阻塞当前线程,当I/O操作完成时,系统会通过回调函数来通知应用程序。
-
优点是进一步提升了性能,减少了线程的阻塞时间,提高了系统的响应速度。
-
NIO 如何实现同步非阻塞的?
NIO 包含3个核心组件:Channel(通道)
、Buffer(缓冲区)
、Selector(选择器)监管者
。
Channel
Channel:NIO 中的 Channel 是通信的管道,类似于InputStream、OutputStream
。用于读取和写入数据。因为Channel
没有方向性,所以Buffer为了区分读写,引入了读模式、写模式进行区分。
Channel
本身不能直接访问数据,只能与Buffer
进行交互,所有的数据都是通过Buffer
进行交互的。
Buffer
Channel
读取或者写入的数据,都要写到Buffer
中,才可以被程序操作。因为Channel
没有方向性,所以Buffer
为了区分读写,引入了读模式、写模式进行区分。
- 最常用的是
ByteBuffer
。
NIO包中有多种Channel
的实现:
FileChannel
:用于读取、写入、映射和操作文件的通道。DatagramChannel
:能通过UDP读写网络中的数据。SocketChannel
:能通过TCP读写网络中的数据。ServerSocketChannel
:可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel
。
半包、粘包
在NIO中,Buffer
是NIO中存放数据的地方。假设在客户端发送了三句话,服务端接收数据时,计算机无法理解文本本身的含义,它只会按照Buffer
设置的容量来截断数据,此时就会出现粘包、半包问题。比如下图:
- 粘包(包含完整的一句话,但又包含了其他句子的部分):第一次读取的数据为
Hi sunshuai\nI l
,发现此次接收的数据包含了第一句、第二句的部分,这种情况就是粘包
。 - 半包(一句话只读取到了部分):第二次读取时,对于第二句
I love you\n
,本次只读取了ove you\n
;对于第三句Do you love me?\n
只读取了Do you
。像这种Buffer中包含第二句的结尾和第三句的开头,这就是半包
。
处理办法:
- 将句子进行特殊处理,比如以
\n
作为结束标志。在读取的时候读取到\n
就知道是一句完整的话。 - 使用
compact()
方法 进行处理,把第一次没有读取完的数据,向前移动和后面的内容进行整合。
Seletor(选择器)循环监听事件
在不使用Seletor时,需要不断的进行while()
循环来进行监听客户端的连接、数据发送等动作。
而使用Seletor之后,Seletor就相当于一个监管者,它负责监控是否有客户端发送连接、客户端是否发送数据了。如果有,再去执行代码,这样就不用一直傻傻的where
死循环了。
Seletor
并不是时时刻刻都处于监管状态,而是达到某些特殊的状态时才会触发监管。这些特殊状态有(一般只在服务端使用):ACCEPT
:即 服务端同意了客户端的连接,他们建立连接的时候。===》ServerSocketChannel
READ
、WRITE
:IO 通信的读写。===》SocketChannel 时
Selector 的主要操作:
-
创建监管者:
Selector selector = Selector.open();
-
指定被监控对象:(这里会监控
serverSocketChannel
的ACCEPT 连接状态
) -
SelectionKey selectionKey = serverSocketChannel.register(selector, 0, null);
-
selectionKey.interestOps(SelectionKey.OP_ACCEPT);
-
进入阻塞等待模式:
selector.select();
-
只有当监控到了 有实际的
连接
或者读写
操作 ,才会处理。
reactor 模式
上面的 selector 作为监管者,监控服务端的ServerSocketChannel
、(所有)客户端的SocketChannel
【多路复用】。对服务端进行读、写操作的监控
。
会发现,selector 既要监控客户端的连接动作,还要监控 读、写动作,效率比较低。reactor 将连接器、读写操作分别使用不同线程来负责,这样效率就会提升。
- 主线程:负责连接器工作;
- 从(子)线程:负责与客户端进行读写的交互操作。
零拷贝
NIO 之所以快,主要是它使用了零拷贝。接下来讲讲零拷贝为什么快。
普通的IO操作
-
首先,要知道一个Java 程序在内存中是分为:用户区(存储用户程序用到的何种数据)、内核区(内核空间为内核保留,是与操作系统打交道的区域)。具体到 java 程序中,
JVM 中的内存
对应用户区域的地址空间
、操作系统中的内存
就对应内核区的地址空间
。 -
而Java程序在IO操作时,时需要4次数据拷贝的:
-
读取文件:【两次数据拷贝】
-
先调用驱动程序将 物理硬盘中的文件数据 拷贝到 操作系统的高速页缓存中;
-
然后再将 高速页缓存 中文件数据 拷贝到 用户地址空间的应用缓存 中。
-
-
通过网络 发送数据:【两次数据拷贝】
-
先将用户地址应用缓存的数据 写入(拷贝)至 操作系统的 socket 缓存中;
-
然后再将socket 缓存中的数据 写入(拷贝)至 网卡中。
-
-
改进:内存映射
在NIO中,可以调用内存映射相关的API。内存映射就是可以将 JVM 中的堆内存 与 操作系统内存 之间映射,此时 JVM 中 java 程序就直接操作的是 OS(操作系统)中的内存。
不足::内存映射只是优化了 读取 的操作。而写操作依然需要经过 JVM 内存 --> 操作系统内存 --> 网卡。
再改进:零拷贝
零拷贝是指没有 JVM 参与的拷贝。只有操作系统与硬件之间的拷贝,零拷贝是 linux 内核实现的功能。具体如下:
- linux2.1中的零拷贝
sendFile()
:物理硬盘---》
高速页缓存--》
socket 缓存--》
网卡。进行了3次拷贝。 - linux2.4中的零拷贝
sendFile()
:物理硬盘---》
高速页缓存--》
网卡。只用2次拷贝。
因此,零拷贝速度快。
Netty
Netty是一个NIO客户服务器框架,它能够快速和容易地开发网络应用(如协议服务器和客户端)。它大大精简了网络编程(如TCP和UDP套接字服务器)。
Netty 是基于JAVA NIO类库,其架构特点是:异步非阻塞
、基于事件驱动
、高性能、高可靠性和高可定制性。
- 事件驱动:是指服务端监控不同的事件,如
accept
、read
、write
。事件驱动的核心是依附于Selector
来实现的。 - 异步:是指 Worker 处理客户端的请求(IO 操作)时,让其他线程来完成具体的任务,而 Worker 本身可以继续接收其他客户端的请求。 ==》这种设计思路提升了效率。
Netty的用途:在分布式系统中,通常是跨进程交互的,需要通过网络通信。NIO虽然可以进行网络通信,但是NIO存在以下问题:
- API复杂难用,比如
Buffer
的“指针”需要来回切换。 - 存在半包、粘包问题。
- 网络拥塞需要自己解决。
Netty 的心跳机制
TCP中也有类似的机制:保活机制。
-
首先,开启保活机制。
-
探测过程:
- 如果在一段时间内没有数据传输,TCP 会发送一个保活探测报文给对端。这个探测报文通常是一个简单的 ACK 报文,不包含实际的数据。
- 如果对端正常存活,它会回复一个 ACK 报文。这样,连接就被认为是正常的,保活计时器会被重置。
- 如果对端没有回复,TCP 会在一定的时间间隔后再次发送探测报文。经过多次尝试后,如果仍然没有收到回复,TCP 就会认为对端已经断开连接,并关闭连接。
为什么有了TCP保活机制,还需要 Netty 的心跳机制呢?
答:
- 因为 TCP 保活机制 是作用在运输层的,心跳机制 是作用在 应用层上的。
- 如果运输层没问题,但应用层有问题就检测不出来。例如连接的双方网络正常(TCP 保活机制确保了双方是正常的),但是程序本身出现了问题(如死锁),导致双方无法通信。
- 因此才有了 Netty 的心跳机制。它作用在应用层,也就是说如果心跳机制检测双方处于活跃状态,就说明在 应用层、运输层 连接的双方都是活跃的。
Netty 的内存管理
Netty 中的内存相关是通过ByteBuf
来实现的。Netty 中的ByteBuf
是对 NIO 的 ByteBuffer
封装,Netty 网络通信过程中,底层数据是存储在ByteBuf
中。
ByteBuf
的特点:
- 可以自动扩容;
- 提供读写的指针,方便操作。(
ByteBuffer
中没有读写指针,所以才需要进行 读模式、写模式的切换) - 引入了内存的池化(类似于 连接池、线程池)
- 对
ByteBuf
的操作中,引入了 零拷贝。(netty 中的零拷贝 不是不占用内存,而是尽可能的少占用内存)
- 创建
ByteBuf
如果不指定大小,netty 会默认分配256
字节。ByteBuf
最大可为Integer.MAX_VALUE
。- 在
ByteBuf
中一个字符占 1 字节;一个 Int 类型占 4 字节。
ByteBuf 扩容规律
- 当容量小于 64 时,扩容规律为
4 的 n 次方
。(其实也就是:4、16、64); - 当容量超过 64 时,扩容规律为
原有大小*2
。但不可超过ByteBuf
的最大值Integer.MAX_VALUE
。
ByteBuf 的内存结构
ByteBuffer
中需要来回切换 读、写模式。在ByteBuf
中没有读、写模式了,不用来回切换。而是使用读、写指针。
ByteBuf 的内存释放
ByteBuf 的内存释放:①使用池化技术时,内存释放是将内存放回内存池中,并没有销毁;②不使用池化技术时,如果使用堆内存(而没使用直接内存),内存释放是否立即销毁 需要看 GC 的处理。
由于 Netty 在处理内存释放时,考虑到内存释放的情况复杂,Netty 让编程人员使用时,设计了统一的内存释放的接口。它是通过使用RefrenceCounted
接口实现的。RefrenceCounted
引用计数器,当ByteBuf
的引用计数器为 0 时,表示ByteBuf
可以被回收。
Netty 和 Tomcat 的区别?
-
功能定位:
- Netty:是一个异步事件驱动的网络应用程序框架,主要用于构建高性能的网络通信服务器和客户端。
- Tomcat:是一个 Java Web 应用服务器,主要用于运行 Java Servlet 和 JavaServer Pages(JSP)等 Web 应用程序。
-
处理协议:
- Netty:可以处理多种网络协议,不仅限于 HTTP,还包括自定义协议等。
- Tomcat:主要针对 HTTP 协议进行处理,也可以支持其他协议(可以通过插件等方式扩展),但对其他协议的支持相对较弱。
-
使用场景:
- Netty:适用于需要自定义网络通信协议、对性能要求极高的场景,如金融交易系统的通信中间件、游戏服务器等。
- Tomcat:主要用于部署和运行 Java Web 应用,如企业内部管理系统、电子商务网站等。