Socket 编程中的BIO和NIO

Socket 编程

Internet中应用嘴广泛的网络应用编程接口实现与3种底层协议接口:

  • 数据报类型套接字SCOK_DGRAM(面向UDP接口)
  • 流式套接字SOCK_STREAM(面向TCP接口)
  • 原始套接字SOCK_RAW(面向网络层协议接口IP、ICMP等)

主要socket API及其调用过程 :

创建套接字 --> 端点绑定 --> 发送数据 --> 接收数据 --> 释放套接字

Socket API 函数定义

listen()、accept()函数只能用于服务器端;

connect()函数只能用于客户端;

socket()、bind()、send()、recv()、sendto()、recvfrom()、close() 客户端和服务端通用。

BIO和NIO

  • 阻塞(blocking)IO: 资源不可用时,IO请求一直阻塞,直到反馈结果(游数据或者超时)。
  • 非阻塞(non-blocking)IO :资源不可用时,IO请求离开返回,返回数据标志资源不可用。
  • 同步(synchronous)IO:应用阻塞在发送或接收数据状态,直到数据成功传输或失败返回。
  • 异步(asynchronous)IO:应用发送或接收到数据后立刻返回,实际处理是异步执行的。

阻塞和非阻塞是获取资源的方式,同步/异步是程序设计如何处理资源的逻辑设计。代码中使用的API:ServerSocket#accept、InputStream#read都是阻塞API。操作系统底层API中,默认使用操作都是Blocking型,send/recv等接口都是阻塞的。

BIO

阻塞I/O 模型

  • 阻塞I/O(blocking I/O)模型,进程调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区中或者发生错误才返回。进程从调用recvfrom开始到它返回的整段时间内是被阻塞的。

阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回,阻塞导致在处理网络I/O时,一个线程只能处理一个网络连接,造成资源极大的浪费。

Socket 编程中的BIO和NIO_第1张图片

传统Socket阻塞案例代码

​ 网络编程的基本模型是C/S模型,即两个进程间的通信。
​ 服务端提供IP和监听端口,客户端通过连接操作想服务端监听的地址发起连接请求,通过三次握手连接,如果连接成功建立,双方就可以通过套接字进行通信。
​ 传统的同步阻塞模型开发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功后,双方通过输入和输出流进行同步阻塞式通信。
​ 简单的描述一下BIO的服务端通信模型:采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理没处理完成后,通过输出流返回应答给客户端,线程销毁。即典型的一请求一应答通宵模型。

public class BIOClient {
   private static Charset charset = Charset.forName("UTF-8");

   public static void main(String[] args) throws Exception {
      Socket s = new Socket("localhost", 8080);
      OutputStream out = s.getOutputStream();
	  Scanner scanner = new Scanner(System.in);
	  System.out.println("请输入:");
 	  String msg = scanner.nextLine();
	  out.write(msg.getBytes(charset)); // 阻塞,写完成
	  scanner.close();
	  s.close();
   }
}
public class BIOServer{
	public static void main(String[] args) throws Exception {
	    ServerSocket serverSocket = new ServerSocket(8080);
	    System.out.println("服务器启动成功");
	    while (!serverSocket.isClosed()) {
	        Socket request = serverSocket.accept();// 阻塞
	        System.out.println("收到新连接 : " + request.toString());
	        try {
	            // 接收数据、打印
	            InputStream inputStream = request.getInputStream(); // net + i/o
	            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
	            String msg;
	            while ((msg = reader.readLine()) != null) { // 没有数据,阻塞
	                if (msg.length() == 0) {
	                    break;
	                }
	                System.out.println(msg);
	            }
	            System.out.println("收到数据,来自:"+ request.toString());
	        } catch (IOException e) {
	            e.printStackTrace();
	        } finally {
	            try {
	                request.close();
	            } catch (IOException e) {
	                e.printStackTrace();
	            }
	        }
	    }
	    serverSocket.close();
	}
}	

该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,Java中的线程也是比较宝贵的系统资源,线程数量快速膨胀后,系统的性能将急剧下降,随着访问量的继续增大,系统最终就死-掉-了。

伪异步I/O

为了改进这种一连接一线程的模型,我们可以使用线程池来管理这些线程,实现1个或多个线程处理N个客户端的模型(但是底层还是使用的同步阻塞I/O),通常被称为“伪异步I/O模型”。

private static ExecutorService threadPool = Executors.newCachedThreadPool();

    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("tomcat 服务器启动成功");
        while (!serverSocket.isClosed()) {
            Socket request = serverSocket.accept();
            System.out.println("收到新连接 : " + request.toString());
            threadPool.execute(() -> {
                try {
                    // 接收数据、打印
                    InputStream inputStream = request.getInputStream();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
                    String msg;
                    while ((msg = reader.readLine()) != null) { // 阻塞
                        if (msg.length() == 0) {
                            break;
                        }
                        System.out.println(msg);
                    }
                    System.out.println("收到数据,来自:"+ request.toString());
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        request.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        serverSocket.close();
    }

以上代码并不能支持数据返回给浏览器,虽然浏览器可以请求到对应对应的服务,但是并不支持数据返回,因为没有加入http协议

加入 http协议 ,返回http内容
 private static ExecutorService threadPool = Executors.newCachedThreadPool();

    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("服务器启动成功");
        while (!serverSocket.isClosed()) {
            Socket request = serverSocket.accept();
            System.out.println("收到新连接 : " + request.toString());
            threadPool.execute(() -> {
                try {
                    // 接收数据、打印
                    InputStream inputStream = request.getInputStream();
                    BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "utf-8"));
                    String msg;
                    while ((msg = reader.readLine()) != null) {
                        if (msg.length() == 0) {
                            break;
                        }
                        System.out.println(msg);
                    }

                    System.out.println("收到数据,来自:"+ request.toString());
                    // 响应结果 200
                    OutputStream outputStream = request.getOutputStream();
                    outputStream.write("HTTP/1.1 200 OK\r\n".getBytes());
                    outputStream.write("Content-Length: 11\r\n\r\n".getBytes());
                    outputStream.write("Hello World".getBytes());
                    outputStream.flush();
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        request.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        serverSocket.close();
    }

只需要在返回的时候加入http对应的返回协议即可返回数据给浏览器

Socket 编程中的BIO和NIO_第2张图片Socket 编程中的BIO和NIO_第3张图片Socket 编程中的BIO和NIO_第4张图片

NIO

  • 在 JDK 1. 4 中 新 加入 了 NIO( New Input/ Output) 类, 引入了一种基于通道和缓冲区的 I/O 方式,这是一个面向块的I/O系统,系统以块的方式处理处理,每一个操作在一步中产生或者消费一个数据库,按块处理要比按字节处理数据快的多,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了在 Java 堆和 Native 堆中来回复制数据。

  • NIO 是一种同步非阻塞的 IO 模型。同步是指线程不断轮询 IO 事件是否就绪,非阻塞是指线程在等待 IO 的时候,可以同时做其他任务。同步的核心就是 Selector,Selector 代替了线程本身轮询 IO 事件,避免了阻塞同时减少了不必要的线程消耗;非阻塞的核心就是通道和缓冲区,当 IO 事件就绪时,可以通过写道缓冲区,保证 IO 的成功,而无需线程阻塞式地等待。

  • 在NIO中有几个核心对象需要掌握:缓冲区(Buffer)、通道(Channel)、选择器(Selector)。

Buffer 缓冲区

  • 缓冲区本质上是一个可以写入数据的内存块(类似数组),然后可以再次读取。此内存块包含在 NIO Buffer对象中,该对象提供了一组方法可以更轻松的使用内存块。
  • 相比较直接对数组的操作,Buffer API更容易操作和管理。

使用Buffer进行数据的读取和写入,需要进行如下四个步骤:

  1. 将数据写入缓冲区
  2. 调用buffer.flip(),转换为读取模式
  3. 缓冲区读取数据
  4. 调用buffer.clear()或buffer.compact()清除缓冲区
Buffer中的四个属性:
  1. 容量(Capacity):缓冲区能够容纳数据的最大数量,这个容量在缓冲区创建时被设定,并且永远不会改变。
  2. 限制(Limit):写入模式时限制等于Buffer的容量,读取模式时等于写入的数据量。
  3. 位置(Position):下一个要被读或写元素的索引,位置会由相应的get()或者put()函数变更。
  4. 标记(Mark):用于记录当前 position 的前一个位置或者默认是 0
缓冲区存取数据流程

存数据时position会++,当停止数据读取的时候
调用flip()将写转换为读,此时limit=position,position=0
读取数据时position++,一直读取到limit
clear() 清空 buffer ,准备再次被写入 (position 变成 0 , limit 变成 capacity) 。
Socket 编程中的BIO和NIO_第5张图片

Buffer中常用的方法
  1. flip()将写转换为读
  2. compact()仅清除已阅读的数据。转为写入模式
  3. clear() 清空整个缓存区
  4. rewind() 重置position为0
  5. mark() 标记position的位置
  6. reset() 重置position为上次mark()标记的位置
 public static void main(String[] args) {
        // 构建一个byte字节缓冲区,容量是4
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);
        // 默认写入模式,查看三个重要的指标
        System.out.println(String.format("初始化:capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));
        // 写入2字节的数据
        byteBuffer.put((byte) 1);
        byteBuffer.put((byte) 2);
        byteBuffer.put((byte) 3);
        // 再看数据
        System.out.println(String.format("写入3字节后,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));

        // 转换为读取模式(不调用flip方法,也是可以读取数据的,但是position记录读取的位置不对)
        System.out.println("#######开始读取");
        byteBuffer.flip();
        byte a = byteBuffer.get();
        System.out.println(a);
        byte b = byteBuffer.get();
        System.out.println(b);
        System.out.println(String.format("读取2字节数据后,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));

        // 继续写入3字节,此时读模式下,limit=3,position=2.继续写入只能覆盖写入一条数据
        // clear()方法清除整个缓冲区。compact()方法仅清除已阅读的数据。转为写入模式
        byteBuffer.compact(); // buffer : 1 , 3
        byteBuffer.put((byte) 3);
        byteBuffer.put((byte) 4);
        byteBuffer.put((byte) 5);
        System.out.println(String.format("最终的情况,capacity容量:%s, position位置:%s, limit限制:%s", byteBuffer.capacity(),
                byteBuffer.position(), byteBuffer.limit()));

        // rewind() 重置position为0
        // mark() 标记position的位置
        // reset() 重置position为上次mark()标记的位置

    }

Buffer 常见类型: ByteBuffer 、 MappedByteBuffer 、 CharBuffer 、 DoubleBuffer 、 FloatBuffer 、 IntBuffer 、 LongBuffer 、 ShortBuffer 。

Buffer内存类型

Buffer为性能关键性代码提供了直接内存(direct堆外)和堆外内存(heap堆)两种实现。
堆外内存的获取方式:ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4);

好处
  1. 进行网络IO或者文件IO时比heapBuffer少一次拷贝。(filer/socket -->OS Memory -->java heap)GC会移动对象内存,在写file或者socke过程中,JVM的现实中会把数据移动到堆外,再进行读写。
  2. GC范围之外降低了GC压力,但实现了自动管理,DirectByteBuffer 中有一个Cleaner对象,Cleaner被GC前会执行clear方法,触发DirectByteBuffer 中定义的Deallocator。
建议
  1. 性能确实可观时才去使用;分配给大型、长寿命;(网络传输、文件读写等场景)
  2. 通过虚拟机参数(MaxDirectMemorySize)设置限制大小,以免整个机器的内存被耗尽。

Channel 通道

通道是 I/O 传输发生时通过的入口,而缓冲区是这些数 据传输的来源或目标。对于离开缓冲区的传输,您想传递出去的数据被置于一个缓冲区,被传送到通道。对于传回缓冲区的传输,一个通道将数据放置在您所提供的缓冲区中。
Socket 编程中的BIO和NIO_第6张图片Channel涵盖了TCP/UDP网络和文件IO主要实现有:

FileChannel

DatagramChannel

SocketChannel

ServerSocketChannel

和标准的IO Stream操作区别:

  1. 在一个通道内可以读取和写入数据,IO Stream常常时单向的(Input和 Output)
  2. 可以非阻塞的读取和写入通道
  3. 通道始终读取或写入缓冲区
SocketChannel

SocketChannel用于建立TCP网络链接,类似java.net.Socket。有两种创建SocketChannel形式:

  1. 客户端读懂发起和服务器的连接。
  2. 服务器获取新的连接。
 		//客户端主动发起连接的方式
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);//设置为非阻塞模式
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080));
        socketChannel.write(buffer);//发起请求数据--向通道写入数据    
        int byteesRead = socketChannel.read(buffer);//读取服务端返回-读取缓冲区的数据
        socketChannel.close();
write写:write()在尚未写入任何内容时就能返回了,需要在循环中调用write();
read读:read()方法可能直接返回根本不需要去读任何数据,根据返回的int值判断读取了多少字节。
serverSocketChannel
	serverSocketChannel可以监听建立新的TCP连接通道,类似ServerSocket
		  // 创建网络服务端
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式
        serverSocketChannel.socket().bind(new InetSocketAddress(8080)); // 绑定端口
        System.out.println("启动成功");
        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept(); // 获取新tcp连接通道
            if (socketChannel != null) {
                // tcp请求 读取/响应
            }
        }

serverSocketChannel.accept():如果该通道处于非阻塞模式,那么如果没有挂起的连接,该方法会立即返回null。必须检查SocketChannel是否为null。

Selector 选择器

Selector是一个java NIO的组件,可以检查一个或者多个NIO的通道,并确定那些事准备好进行读取或者写入。实现单个线程可以管理多个通道,从而管理多个网络连接。
Socket 编程中的BIO和NIO_第7张图片
一个Selector可以监听多个channel的不同事件:
四个事件分别对应SelectorKey四个常量。

  1. Connect连接(SelectorKey.OP_CONNECT)
  2. Accept准备就绪(OP_ACCEPT)
  3. Read读取(OP_READ)
  4. Write 写入(OP_WRITE)
    Selector实现这样的效果核心概念是:事件驱动机制
    非阻塞的网络通道下开发者通过Selector注册对于通道感兴趣的事件类型,线程通过监听事件来触发相应的代码执行。(拓展:更底层是操作系统的多路复用机制)
   public static void main(String[] args) throws Exception {
        // 1. 创建网络服务端ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false); // 设置为非阻塞模式

        // 2. 构建一个Selector选择器,并且将channel注册上去
        Selector selector = Selector.open();
            SelectionKey selectionKey = serverSocketChannel.register(selector, 0, serverSocketChannel);// 将serverSocketChannel注册到selector
        selectionKey.interestOps(SelectionKey.OP_ACCEPT); // 对serverSocketChannel上面的accept事件感兴趣(serverSocketChannel只能支持accept操作)

        // 3. 绑定端口
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));

        System.out.println("启动成功");

        while (true) {
            // 不再轮询通道,改用下面轮询事件的方式.select方法有阻塞效果,直到有事件通知才会有返回
            selector.select();
            // 获取事件
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            // 遍历查询结果e
            Iterator<SelectionKey> iter = selectionKeys.iterator();
            while (iter.hasNext()) {
                // 被封装的查询结果
                SelectionKey key = iter.next();
                iter.remove();
                // 关注 Read 和 Accept两个事件
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.attachment();
                    // 将拿到的客户端连接通道,注册到selector上面
                    SocketChannel clientSocketChannel = server.accept(); // mainReactor 轮询accept
                    clientSocketChannel.configureBlocking(false);
                    clientSocketChannel.register(selector, SelectionKey.OP_READ, clientSocketChannel);
                    System.out.println("收到新连接 : " + clientSocketChannel.getRemoteAddress());
                }

                if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.attachment();
                    try {
                        ByteBuffer requestBuffer = ByteBuffer.allocate(1024);
                        while (socketChannel.isOpen() && socketChannel.read(requestBuffer) != -1) {
                            // 长连接情况下,需要手动判断数据有没有读取结束 (此处做一个简单的判断: 超过0字节就认为请求结束了)
                            if (requestBuffer.position() > 0) break;
                        }
                        if(requestBuffer.position() == 0) continue; // 如果没数据了, 则不继续后面的处理
                        requestBuffer.flip();
                        byte[] content = new byte[requestBuffer.limit()];
                        requestBuffer.get(content);
                        System.out.println(new String(content));
                        System.out.println("收到数据,来自:" + socketChannel.getRemoteAddress());
                        // TODO 业务操作 数据库 接口调用等等

                        // 响应结果 200
                        String response = "HTTP/1.1 200 OK\r\n" +
                                "Content-Length: 11\r\n\r\n" +
                                "Hello World";
                        ByteBuffer buffer = ByteBuffer.wrap(response.getBytes());
                        while (buffer.hasRemaining()) {
                            socketChannel.write(buffer);
                        }
                    } catch (IOException e) {
                        // e.printStackTrace();
                        key.cancel(); // 取消事件订阅
                    }
                }
            }
            selector.selectNow();
        }

NIO和BIO对比

Socket 编程中的BIO和NIO_第8张图片

NIO和多线程结合的改进方案

Socket 编程中的BIO和NIO_第9张图片

小结

NIO为开发者提供了功能丰富及强大的IO处理的API,但是在应用于网络应用开发的过程中,直接使用JDK提供的API,比较繁琐,而且想要将性能提升光有NIO是不够的还要将多线程技术与之结合起来。
因为网络编程本身的复杂性,以及JDK API开发使用难度较高,所以在开源社区中,涌出很多对JDK NIO 进行封装、增强后的网络框架,列如Netty 、Mina等。

你可能感兴趣的:(网易微专业)