网络编程基础知识
什么是 Socket?
Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,它是一组接口,一般由操作系统提供。在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议处理和通信缓存管理等等都隐藏在 Socket 接口后面, 对用户来说,使用一组简单的接口就能进行网络应用编程,让Socket 去组织数据,以符合指定的协议。主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接。
客户端连接上一个服务端,就会在客户端中产生一个 socket 接口实例,服务端每接受一个客户端连接,就会产生一个 socket 接口实例和客户端的 socket 进行通信,有多个客户端连接自然就有多个 socket 接口实例。
短连接
连接->传输数据->关闭连接
传统 HTTP 是无状态的,浏览器和服务器每进行一次 HTTP 操作,就建立一次连接,但任务结束就中断连接。
也可以这样说:短连接是指 SOCKET 连接后发送后接收完数据后马上断开连接。
长连接
连接->传输数据->保持连接 -> 传输数据-> 。。。 ->关闭连接。
长连接指建立 SOCKET 连接后不管是否使用都保持连接。
什么时候用长连接,短连接?
长连接多用于操作频繁,点对点的通讯。每个 TCP 连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理速度会降低很多,所以每个操作完后都不断开,下次处理时直接发送数据包就 OK 了,不用建立 TCP 连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成 socket 错误,而且频繁的 socket 创建也是对资源的浪费。
而像 WEB 网站的 http 服务按照 Http 协议规范早期一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像 WEB 网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源。但是现在的 Http 协议,Http1.1,尤其是 Http2、Http3 已经开 始向长连接演化。
总之,长连接和短连接的选择要视情况而定。
ps: 我们已经知道在通信编程里提供服务的叫服务端,连接服务端使用服务的叫客户端。在开发过程中,如果类的名字有 Server 或者 ServerSocket 的,表示这个类是给服务端容纳网络服务用的,如果类的名字只包含 Socket 的,那么表示这是负责具体的网络读写的。
在通信编程里,我们关注的其实也就是三个事情:连接(客户端连接服务器,服务器等待和接收连接)、读网络数据、写网络数据,所有模式的通信编程都是围绕着这三件事情进行的。服务端提供 IP 和监听端口,客户端通过连接操作想服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
BIO
BIO,意为 Blocking I/O,即阻塞的 I/O。
BIO 基本上就是我们上面所说的生活场景的朴素实现。在 BIO 中类 ServerSocket 负责绑定 IP 地址,启动监听端口,等待客户连接;客户端 Socket 类的实例发起连接操作,ServerSocket接受连接后产生一个新的服务端 socket 实例负责和客户端 socket 实例通过输入和输出流进行通信。
BIO的阻塞,主要体现在两个地方:
若一个服务器启动就绪,那么主线程就一直在等待着客户端的连接,这个等待过程中主线程就一直在阻塞。
在连接建立之后,在读取到 socket 信息之前,线程也是一直在等待,一直处于阻塞的状态下的。
传统 BIO 通信模型:采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答模型,同时数据的读取写入也必须阻塞在一个线程内等待其完成。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,Java 中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死-掉-了。
为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现 1 个或多个线程处理 N 个客户端的模型(但是底层还是使用的同步阻塞 I/O),通常被称为“伪异步 I/O 模型“。
我们知道,如果使用 CachedThreadPool 线程池(不限制线程数量,如果不清楚请参考文首提供的文章),其实除了能自动帮我们管理线程(复用),看起来也就像是 1:1 的客户端:线程数模型,而使用 FixedThreadPool 我们就有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了 N:M 的伪异步 I/O 模型。
但是,正因为限制了线程数量,如果发生读取数据较慢时(比如数据量大、网络传输慢等),大量并发的情况下,其他接入的消息,只能一直等待,这就是最大的弊端。
BIO代码
BIO通信客户端:
public class Client {
public static void main(String[] args) throws IOException {
//客户端启动必备
Socket socket = null;
//实例化与服务端通信的输入输出流
ObjectOutputStream output = null;
ObjectInputStream input = null;
//服务器的通信地址
InetSocketAddress addr
= new InetSocketAddress("127.0.0.1",10001);
try{
socket = new Socket();
socket.connect(addr);//连接服务器
System.out.println("Connect Server success!!");
output = new ObjectOutputStream(socket.getOutputStream());
input = new ObjectInputStream(socket.getInputStream());
System.out.println("Ready send message.....");
/*向服务器输出请求*/
output.writeUTF("chenchen_cui");
output.flush();
//接收服务器的输出
System.out.println(input.readUTF());
}finally{
if (socket!=null) socket.close();
if (output!=null) output.close();
if (input!=null) input.close();
}
}
}
使用线程池的服务端:
public class ServerPool {
private static ExecutorService executorService
= Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
public static void main(String[] args) throws IOException {
//服务端启动必备
ServerSocket serverSocket = new ServerSocket();
//表示服务端在哪个端口上监听
serverSocket.bind(new InetSocketAddress(10001));
System.out.println("Start Server ....");
try{
while(true){
executorService.execute(new ServerTask(serverSocket.accept()));
}
}finally {
serverSocket.close();
}
}
//每个和客户端的通信都会打包成一个任务,交个一个线程来执行
private static class ServerTask implements Runnable{
private Socket socket = null;
public ServerTask(Socket socket){
this.socket = socket;
}
@Override
public void run() {
//实例化与客户端通信的输入输出流
try(ObjectInputStream inputStream =
new ObjectInputStream(socket.getInputStream());
ObjectOutputStream outputStream =
new ObjectOutputStream(socket.getOutputStream())){
//接收客户端的输出,也就是服务器的输入
String userName = inputStream.readUTF();
System.out.println("Accept client message:"+userName);
//服务器的输出,也就是客户端的输入
outputStream.writeUTF("Hello,"+userName);
outputStream.flush();
}catch(Exception e){
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
NIO
什么是NIO?
NIO 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 BIO 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。NIO 被称为 no-blocking io 或者 new io 都说得通。
和BIO的主要区别
面向流与面向缓冲
Java NIO 和 IO 之间第一个最大的区别是,IO 是面向流的,NIO 是面向缓冲区的。 Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
阻塞与非阻塞 IO
Java IO 的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO 的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
NIO三大核心组件
NIO有三大核心组件:Selector选择器、Channel管道、buffer缓冲区。
Selector
Selector的英文含义是“选择器”,也可以称为为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。
Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器(Selectors),然后使用一个单独的线程来操作这个选择器,进而“选择”通道: 这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。
Channel
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据,而且可以同时进行读写。
- 所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。
- ServerSocketChannel:应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
- ScoketChannel:TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP:端口 到 服务器IP:端口的通信连接。
通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
buffer
我们前面说过JDK NIO是面向缓冲的。Buffer就是这个缓冲,用于和NIO通道进行交互。数据是从通道读入缓冲区,从缓冲区写入到通道中的。以写为例,应用程序都是将数据写入缓冲,再通过通道把缓冲的数据发送出去,读也是一样,数据总是先从通道读到缓冲,应用程序再读缓冲的数据。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组)。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
代码演示:
客户端:
public class NioClient {
private static NioClientHandle nioClientHandle;
public static void start(){
nioClientHandle = new NioClientHandle(DEFAULT_SERVER_IP,DEFAULT_PORT);
//nioClientHandle = new NioClientHandle(DEFAULT_SERVER_IP,8888);
new Thread(nioClientHandle,"client").start();
}
//向服务器发送消息
public static boolean sendMsg(String msg) throws Exception{
nioClientHandle.sendMsg(msg);
return true;
}
public static void main(String[] args) throws Exception {
start();
Scanner scanner = new Scanner(System.in);
while(NioClient.sendMsg(scanner.next()));
}
}
public class NioClientHandle implements Runnable{
private String host;
private int port;
private volatile boolean started;
private Selector selector;
private SocketChannel socketChannel;
public NioClientHandle(String ip, int port) {
this.host = ip;
this.port = port;
try {
/*创建选择器的实例*/
selector = Selector.open();
/*创建ServerSocketChannel的实例*/
socketChannel = SocketChannel.open();
/*设置通道为非阻塞模式*/
socketChannel.configureBlocking(false);
started = true;
} catch (IOException e) {
e.printStackTrace();
}
}
public void stop(){
started = false;
}
@Override
public void run() {
try{
doConnect();
}catch(IOException e){
e.printStackTrace();
System.exit(1);
}
//循环遍历selector
while(started){
try{
//无论是否有读写事件发生,selector每隔1s被唤醒一次
selector.select(1000);
//获取当前有哪些事件可以使用
Set<SelectionKey> keys = selector.selectedKeys();
//转换为迭代器
Iterator<SelectionKey> it = keys.iterator();
SelectionKey key = null;
while(it.hasNext()){
key = it.next();
/*我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。
如果我们没有删除处理过的键,那么它仍然会在主集合中以一个激活
的键出现,这会导致我们尝试再次处理它。*/
it.remove();
try{
handleInput(key);
}catch(Exception e){
if(key != null){
key.cancel();
if(key.channel() != null){
key.channel().close();
}
}
}
}
}catch(Exception e){
e.printStackTrace();
System.exit(1);
}
}
//selector关闭后会自动释放里面管理的资源
if(selector != null)
try{
selector.close();
}catch (Exception e) {
e.printStackTrace();
}
}
//具体的事件处理方法
private void handleInput(SelectionKey key) throws IOException{
if(key.isValid()){
//获得关心当前事件的channel
SocketChannel sc = (SocketChannel) key.channel();
//连接事件
if(key.isConnectable()){
if(sc.finishConnect()){
socketChannel.register(selector,
SelectionKey.OP_READ);}
else System.exit(1);
}
//有数据可读事件
if(key.isReadable()){
//创建ByteBuffer,并开辟一个1M的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//读取请求码流,返回读取到的字节数
int readBytes = sc.read(buffer);
//读取到字节,对字节进行编解码
if(readBytes>0){
//将缓冲区当前的limit设置为position,position=0,
// 用于后续对缓冲区的读取操作
buffer.flip();
//根据缓冲区可读字节数创建字节数组
byte[] bytes = new byte[buffer.remaining()];
//将缓冲区可读字节数组复制到新建的数组中
buffer.get(bytes);
String result = new String(bytes,"UTF-8");
System.out.println("客户端收到消息:" + result);
}
//链路已经关闭,释放资源
else if(readBytes<0){
key.cancel();
sc.close();
}
}
}
}
private void doWrite(SocketChannel channel,String request)
throws IOException {
//将消息编码为字节数组
byte[] bytes = request.getBytes();
//根据数组容量创建ByteBuffer
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
//将字节数组复制到缓冲区
writeBuffer.put(bytes);
//flip操作
writeBuffer.flip();
//发送缓冲区的字节数组
/*关心事件和读写网络并不冲突*/
channel.write(writeBuffer);
}
private void doConnect() throws IOException{
/*非阻塞的连接*/
if(socketChannel.connect(new InetSocketAddress(host,port))){
socketChannel.register(selector,SelectionKey.OP_READ);
}else{
socketChannel.register(selector,SelectionKey.OP_CONNECT);
}
}
//写数据对外暴露的API
public void sendMsg(String msg) throws Exception{
doWrite(socketChannel, msg);
}
}
服务端
public class NioServer {
private static NioServerHandle nioServerHandle;
public static void main(String[] args){
nioServerHandle = new NioServerHandle(DEFAULT_PORT);
new Thread(nioServerHandle,"Server").start();
}
}
public class NioServerHandle implements Runnable{
private volatile boolean started;
private ServerSocketChannel serverSocketChannel;
private Selector selector;
/**
* 构造方法
* @param port 指定要监听的端口号
*/
public NioServerHandle(int port) {
try {
/*创建选择器的实例*/
selector = Selector.open();
/*创建ServerSocketChannel的实例*/
serverSocketChannel = ServerSocketChannel.open();
/*设置通道为非阻塞模式*/
serverSocketChannel.configureBlocking(false);
/*绑定端口*/
serverSocketChannel.socket().bind(new InetSocketAddress(port));
/*注册事件,表示关心客户端连接*/
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
started = true;
System.out.println("服务器已启动,端口号:"+port);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
while(started){
try {
/*获取当前有哪些事件*/
selector.select(1000);
/*获取事件的集合*/
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
/*我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。
如果我们没有删除处理过的键,那么它仍然会在主集合中以一个激活
的键出现,这会导致我们尝试再次处理它。*/
iterator.remove();
handleInput(key);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/*处理事件的发生*/
private void handleInput(SelectionKey key) throws IOException {
if(key.isValid()){
/*处理新接入的客户端的请求*/
if(key.isAcceptable()){
/*获取关心当前事件的Channel*/
ServerSocketChannel ssc
= (ServerSocketChannel) key.channel();
/*接受连接*/
SocketChannel sc = ssc.accept();
System.out.println("==========建立连接=========");
sc.configureBlocking(false);
/*关注读事件*/
sc.register(selector,SelectionKey.OP_READ);
}
/*处理对端的发送的数据*/
if(key.isReadable()){
SocketChannel sc = (SocketChannel) key.channel();
/*创建ByteBuffer,开辟一个缓冲区*/
ByteBuffer buffer = ByteBuffer.allocate(1024);
/*从通道里读取数据,然后写入buffer*/
int readBytes = sc.read(buffer);
if(readBytes>0){
/*将缓冲区当前的limit设置为position,position=0,
用于后续对缓冲区的读取操作*/
buffer.flip();
/*根据缓冲区可读字节数创建字节数组*/
byte[] bytes = new byte[buffer.remaining()];
/*将缓冲区可读字节数组复制到新建的数组中*/
buffer.get(bytes);
String message = new String(bytes,"UTF-8");
System.out.println("服务器收到消息:"+message);
/*处理数据*/
String result = Const.response(message);
/*发送应答消息*/
doWrite(sc,result);
}else if(readBytes<0){
/*取消特定的注册关系*/
key.cancel();
/*关闭通道*/
sc.close();
}
}
}
}
/*发送应答消息*/
private void doWrite(SocketChannel sc,String response) throws IOException {
byte[] bytes = response.getBytes();
ByteBuffer buffer = ByteBuffer.allocate(bytes.length);
buffer.put(bytes);
buffer.flip();
sc.write(buffer);
}
public void stop(){
started = false;
}
}
重要概念 SelectionKey
什么是SelectionKey
SelectionKey是一个抽象类,表示selectableChannel在Selector中注册的标识.每个Channel向Selector注册时,都将会创建一个SelectionKey。SelectionKey将Channel与Selector建立了关系,并维护了channel事件。
可以通过cancel方法取消键,取消的键不会立即从selector中移除,而是添加到cancelledKeys中,在下一次select操作时移除它.所以在调用某个key时,需要使用isValid进行校验.
在向Selector对象注册感兴趣的事件时,JAVA NIO共定义了四种:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT(定义在SelectionKey中),分别对应读、写、请求连接、接受连接等网络Socket操作。
| 操作类型 | 就绪条件及说明 |
|---|---|
| OP_READ | 当操作系统读缓冲区有数据可读时就绪。并非时刻都有数据可读,所以一般需要注册该操作,仅当有就绪时才发起读操作,有的放矢,避免浪费 CPU。 |
| OP_WRITE | 当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空闲空间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不断就绪浪费 CPU;但如果是写密集型的任务,比如文件下载等,缓冲区很可能满,注册该操作类型就很有必要,同时注意写完后取消注册。 |
| OP_CONNECT | 当 SocketChannel.connect()请求连接成功后就绪。该操作只给客户端使用。 |
| OP_READ | 当接收到一个客户端连接请求时就绪。该操作只给服务器使用。 |
服务端和客户端分别感兴趣的类型
ServerSocketChannel 和 SocketChannel 可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时 OS 会通知 channel,下表描述各种 Channel 允许注册的操作类型,Y 表示允许注册,N 表示不允许注册,其中服务器 SocketChannel 指由服务器ServerSocketChannel.accept()返回的对象。
服务器启动 ServerSocketChannel,关注 OP_ACCEPT 事件,
客户端启动 SocketChannel,连接服务器,关注 OP_CONNECT 事件
服务器接受连接,启动一个服务器的 SocketChannel,这个 SocketChannel 可以关注OP_READ、OP_WRITE 事件,一般连接建立后会直接关注 OP_READ 事件
客户端这边的客户端 SocketChannel 发现连接建立后,可以关注 OP_READ、OP_WRITE事件,一般是需要客户端需要发送数据了才关注 OP_READ 事件
连接建立后客户端与服务器端开始相互发送消息(读写),根据实际情况来关注OP_READ、OP_WRITE 事件。
Buffer 详解
重要属性
capacity
作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”.你只能往里写 capacity 个 byte、long,char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
当你写数据到 Buffer 中时,position 表示当前能写的位置。初始的 position 值为 0.当一个 byte、long 等数据写到 Buffer 后, position 会向前移动到下一个可插入数据的 Buffer 单元。position 最大可为 capacity – 1.
当读取数据时,也是从某个特定位置读。当将 Buffer 从写模式切换到读模式,position会被重置为 0. 当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置。
limit
在写模式下,Buffer 的 limit 表示你最多能往 Buffer 里写多少数据。 写模式下,limit 等于 Buffer 的 capacity。
当切换 Buffer 到读模式时, limit 表示你最多能读到多少数据。因此,当切换 Buffer 到读模式时,limit 会被设置成写模式下的 position 值。换句话说,你能读到之前写入的所有数据(limit 被设置成已写数据的数量,这个值在写模式下就是 position)
Buffer 的分配
要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有 allocate 方法(可以在堆上分配,也可以在直接内存上分配)。
分配 48 字节 capacity 的 ByteBuffer 的例子:ByteBuffer buf = ByteBuffer.allocate(48);分配一个可存储 1024 个字符的 CharBuffer:CharBuffer buf = CharBuffer.allocate(1024);
wrap 方法:把一个 byte 数组或 byte 数组的一部分包装成 ByteBuffer:
ByteBuffer wrap(byte [] array)
ByteBuffer wrap(byte [] array, int offset, int length)
直接内存
HeapByteBuffer 与 DirectByteBuffer,在原理上,前者可以看出分配的 buffer 是在 heap区域的,其实真正 flush 到远程的时候会先拷贝到直接内存,再做下一步操作;在 NIO 的框架下,很多框架会采用 DirectByteBuffer 来操作,这样分配的内存不再是在 java heap 上,经过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比HeapByteBuffer 要快速好几倍。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现。
NIO 可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
直接内存(堆外内存)与堆内存比较:
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显
Buffer 的读写
向 Buffer 中写数据,写数据到 Buffer 有两种方式:
•读取 Channel 写到 Buffer。
•通过 Buffer 的 put()方法写到 Buffer 里。
从 Channel 写到 Buffer 的例子
int bytesRead = inChannel.read(buf); //read into buffer.
通过 put 方法写 Buffer 的例子:
buf.put(127);
put 方法有很多版本,允许你以不同的方式把数据写入到 Buffer 中。例如, 写到一个指定的位置,或者把一个字节数组写入到 Buffer。在比如:
put(byte b)相对写,向 position 的位置写入一个 byte,并将 postion+1,为下次读写作准备。
flip()方法:
flip 方法将 Buffer 从写模式切换到读模式。调用 flip()方法会将 position 设回 0,并将 limit设置成之前 position 的值。换句话说,position 现在用于标记读的位置,limit 表示之前写进了多少个 byte、char 等 —— 现在能读取多少个 byte、char 等。
从 Buffer 中读取数据,从 Buffer 中读取数据有两种方式:
从 Buffer 读取数据写入到 Channel。
使用 get()方法从 Buffer 中读取数据。
从 Buffer 读取数据到 Channel 的例子:int bytesWritten = inChannel.write(buf);
使用 get()方法从 Buffer 中读取数据的例子,byte aByte = buf.get();
get 方法有很多版本,允许你以不同的方式从 Buffer 中读取数据。例如,从指定 position读取,或者从 Buffer 中读取数据到字节数组,再比如get()属于相对读,从 position 位置读取一个 byte,并将 position+1,为下次读写作准备;
使用 Buffer 读写数据常见步骤:
写入数据到 Buffer
调用 flip()方法
从 Buffer 中读取数据
调用 clear()方法或者 compact()方法,准备下一次的写入
当向 buffer 写入数据时,buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空
缓冲区:调用 clear()或 compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。
Reactor 模式类型
单线程 Reactor 模式流程:
① 服务器端的 Reactor 是一个线程对象,该线程会启动事件循环,并使用 Selector(选择器)来实现 IO 的多路复用。注册一个 Acceptor 事件处理器到 Reactor 中,Acceptor 事件处理器所关注的事件是 ACCEPT 事件,这样 Reactor 会监听客户端向服务器端发起的连接请求事件(ACCEPT 事件)。
② 客户端向服务器端发起一个连接请求,Reactor 监听到了该 ACCEPT 事件的发生并将该 ACCEPT 事件派发给相应的 Acceptor 处理器来进行处理。Acceptor 处理器通过 accept()方法得到与这个客户端对应的连接(SocketChannel),然后将该连接所关注的 READ 事件以及对应的READ事件处理器注册到Reactor中,这样一来Reactor就会监听该连接的READ事件了。
③ 当 Reactor 监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。比如,读处理器会通过 SocketChannel 的 read()方法读取数据,此时 read()操作可以直接读取到数据,而不会堵塞与等待可读的数据到来。
④ 每当处理完所有就绪的感兴趣的 I/O 事件后,Reactor 线程会再次执行 select()阻塞等待新的事件就绪并将其分派给对应处理器进行处理。
注意,Reactor 的单线程模式的单线程主要是针对于 I/O 操作而言,也就是所有的 I/O 的accept()、read()、write()以及 connect()操作都在一个线程上完成的。
但在目前的单线程 Reactor 模式中,不仅 I/O 操作在该 Reactor 线程上,连非 I/O 的业务操作也在该线程上进行处理了,这可能会大大延迟 I/O 请求的响应。所以我们应该将非 I/O的业务逻辑操作从 Reactor 线程上卸载,以此来加速 Reactor 线程对 I/O 请求的响应。
单线程 Reactor,工作者线程池
与单线程 Reactor 模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的 I/O 响应,不至于因为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
多线程主从 Reactor 模式
Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。
mainReactor 可以只有一个,但 subReactor 一般会有多个。mainReactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给 subReactor,由 subReactor 来完成和客户端的通信。
直接内存深入辨析
在所有的网络通信和应用程序中,每个 TCP 的 Socket 的内核中都有一个发送缓冲区(SO_SNDBUF)和一个接收缓冲区(SO_RECVBUF),可以使用相关套接字选项来更改该缓冲区大小。
当某个应用进程调用 write 时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),假设该套接字是阻塞的,则该应用进程将被投入睡眠。
内核将不从 write 系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个 TCP 套接字的 write 调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的 TCP 或应用进程已接收到数据。
Java 程序自然也要遵守上述的规则。但在 Java 中存在着堆、垃圾回收等特性,所以在实际的 IO 中,在 JVM 内部的存在着这样一种机制:
在 IO 读写上,如果是使用堆内存,JDK 会先创建一个 DirectBuffer,再去执行真正的写操作。这是因为,当我们把一个地址通过 JNI 传递给底层的 C 库的时候,有一个基本的要求,
就是这个地址上的内容不能失效。然而,在 GC 管理下的对象是会在 Java 堆中移动的。也就是说,有可能我把一个地址传给底层的 write,但是这段内存却因为 GC 整理内存而失效了。所以必须要把待发送的数据放到一个 GC 管不着的地方。这就是调用 native 方法之前,数据—定要在堆外内存的原因。
可见,站在网络通信的角度 DirectBuffer 并没有节省什么内存拷贝,只是 Java 网络通信里因为 HeapBuffer 必须多做一次拷贝,使用 DirectBuffer 就会少一次内存拷贝。相比没有使用堆内存的 Java 程序,使用直接内存的 Java 程序当然更快一点。
从垃圾回收的角度而言,直接内存不受 GC(新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到 HeapBuffer 要小。
堆外内存的优点和缺点
堆外内存相比于堆内内存有几个优势:
1 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
2 加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存),
然后在发送;而堆外内存相当于省略掉了这个工作。
缺点:
1 堆外内存难以控制,如果内存泄漏,那么很难排查
2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合