3.JAVA BIO深入剖析

1.JAVA BIO深入剖析

1.1 Java BIO 基本介绍

  • Java BIO 就是传统的 java io 编程,其相关的类和接口在 java.io
  • BIO(blocking I/O) : 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器).
  • 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write 。
  • 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  • 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费

1.2 Java BIO 工作机制

BIO编程流程

image.png

0.服务器端启动一个 ServerSocket,注册端口,调用accpet方法监听客户端的Socket连接。

1.客户端启动Socket和服务器进行通信,默认情况下服务端需要跟每个客户建立一个线程与之通讯。

1.3 传统的BIO编程实例回顾

网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端提供位置信息(绑定IP地址和端口),客户端通过连接操作向服务端监听的端口地址发起连接请求,基于TCP协议下进行三次握手连接,连接成功后,双方通过网络套接字(Socket)进行通信。

传统的同步阻塞模型开发中,服务端ServerSocket负责绑定IP地址,启动监听端口。

客户端Socket负责发起连接操作。

连接成功后,双方通过输入和输出流进行同步阻塞式通信。

基于BIO模式下的通信,客户端 - 服务端是完全同步,完全耦合的。  

服务端案例如下

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 服务端
*/
public class ServerDemo {
   public static void main(String[] args) throws Exception {
       System.out.println("==服务器的启动==");
       // (1)注册端口
       ServerSocket serverSocket = new ServerSocket(8888);
       //(2)开始在这里暂停等待接收客户端的连接,得到一个客户端到服务端的Socket管道
       Socket socket = serverSocket.accept();
       //(3)从Socket管道中得到一个字节输入流。
       InputStream is = socket.getInputStream();
       //(4)把字节输入流包装成自己需要的流进行数据的读取。
       BufferedReader br = new BufferedReader(new InputStreamReader(is));
       //(5)读取数据
       String line ;
       while((line = br.readLine())!=null){
           System.out.println("服务端收到:"+line);
      }
  }
}

客户端案例如下

import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
/**
   目标: Socket网络编程。
   Java提供了一个包:java.net下的类都是用于网络通信。
   Java提供了基于套接字(端口)Socket的网络通信模式,我们基于这种模式就可以直接实现TCP通信。
   只要用Socket通信,那么就是基于TCP可靠传输通信。
   功能1:客户端发送一个消息,服务端接口一个消息,通信结束!!
   创建客户端对象:
       (1)创建一个Socket的通信管道,请求与服务端的端口连接。
       (2)从Socket管道中得到一个字节输出流。
       (3)把字节流改装成自己需要的流进行数据的发送
   创建服务端对象:
       (1)注册端口
       (2)开始等待接收客户端的连接,得到一个端到端的Socket管道
       (3)从Socket管道中得到一个字节输入流。
       (4)把字节输入流包装成自己需要的流进行数据的读取。
   Socket的使用:
       构造器:public Socket(String host, int port)
       方法: public OutputStream getOutputStream():获取字节输出流
              public InputStream getInputStream() :获取字节输入流
   ServerSocket的使用:
       构造器:public ServerSocket(int port)
   小结:
       通信是很严格的,对方怎么发你就怎么收,对方发多少你就只能收多少!!
*/
public class ClientDemo {
   public static void main(String[] args) throws Exception {
       System.out.println("==客户端的启动==");
       // (1)创建一个Socket的通信管道,请求与服务端的端口连接。
       Socket socket = new Socket("127.0.0.1",8888);
       // (2)从Socket通信管道中得到一个字节输出流。
       OutputStream os = socket.getOutputStream();
       // (3)把字节流改装成自己需要的流进行数据的发送
       PrintStream ps = new PrintStream(os);
       // (4)开始发送消息
       ps.println("我是客户端,我想约你吃小龙虾!!!");
       ps.flush();
  }
}

小结

  • 在以上通信中,服务端会一直等待客户端的消息,如果客户端没有进行消息的发送,服务端将一直进入阻塞状态。
  • 同时服务端是按照行获取消息,意味着客户端也必须按照行进行消息的发送,否则服务端将进入等待消息的阻塞状态!

1.4 BIO模式下多发和多收消息

在1.3的案例中,只能实现客户端发送消息,服务端接收消息,并不能实现反复的收消息和反复的发消息,我们只需要在客户端案例中,加上反复按照行发送消息的逻辑即可!案例代码如下:

服务端代码如下

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 服务端
*/
public class ServerDemo {
   public static void main(String[] args) throws Exception {
       
       System.out.println("==服务器的启动==");
       //(1)注册端口
       ServerSocket serverSocket = new ServerSocket(8888);
       //(2)开始在这里暂停等待接收客户端的连接,得到一个端到端的Socket管道
       Socket socket = serverSocket.accept();
       //(3)从Socket管道中得到一个字节输入流。只要客户端有写入操作,is就能读取到。
       //在while循环的时候会立刻读取到
       InputStream is = socket.getInputStream();
       //(4)把字节输入流包装成 自己需要的流进行数据的读取。
       BufferedReader br = new BufferedReader(new InputStreamReader(is));
       //(5)读取数据
       String line ;
       while((line = br.readLine())!=null){
           System.out.println("服务端收到:"+line);
      }
  }
}

客户端代码如下

import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
import java.util.Scanner;
/**
   目标: Socket网络编程。
   功能1:客户端可以反复发消息,服务端可以反复收消息
   小结:
       通信是很严格的,对方怎么发你就怎么收,对方发多少你就只能收多少!!
*/
public class ClientDemo {
   public static void main(String[] args) throws Exception {
       System.out.println("==客户端的启动==");
       // (1)创建一个Socket的通信管道,请求与服务端的端口连接。
       Socket socket = new Socket("127.0.0.1",8888);
       // (2)从Socket通信管道中得到一个字节输出流。
       OutputStream os = socket.getOutputStream();
       // (3)把字节流改装成自己需要的流进行数据的发送
       PrintStream ps = new PrintStream(os);
       // (4)开始发送消息
       Scanner sc = new Scanner(System.in);
       while(true){
           System.out.print("请说:");
           String msg = sc.nextLine();
           ps.println(msg);
           ps.flush();
      }
  }
}

小结

  • 本案例中确实可以实现客户端多发多收
  • 但是服务端只能处理一个客户端的请求,因为服务端是单线程的。一次只能与一个客户端进行消息通信。

1.5 BIO模式下接收多个客户端

概述

在上述的案例中,一个服务端只能接收一个客户端的通信请求,那么如果服务端需要处理很多个客户端的消息通信请求应该如何处理呢,此时我们就需要在服务端引入线程了,也就是说客户端每发起一个请求,服务端就创建一个新的线程来处理这个客户端的请求,这样就实现了一个客户端一个线程的模型,图解模式如下:

image.png

服务端案例代码

/**
   服务端
   1.每接收到一个客户端的连接就启动一个线程
   2.将客户端连接作为参数传入线程中
   3.监听客户端连接的输入流
   4.空转输出输入流内容
*/
public class ServerDemo {
   public static void main(String[] args) throws Exception {
       System.out.println("==服务器的启动==");
       // (1)注册端口
       ServerSocket serverSocket = new ServerSocket(7777);
       while(true){
           //(2)开始在这里暂停等待接收客户端的连接,得到一个端到端的Socket管道
           Socket socket = serverSocket.accept();
           new ServerReadThread(socket).start();
           System.out.println(socket.getRemoteSocketAddress()+"上线了!");
      }
  }
}
class ServerReadThread extends Thread{
   private Socket socket;
   public ServerReadThread(Socket socket){
       this.socket = socket;
  }
   @Override
   public void run() {
       try{
           //(3)从Socket管道中得到一个字节输入流。
           InputStream is = socket.getInputStream();
           //(4)把字节输入流包装成自己需要的流进行数据的读取。
           BufferedReader br = new BufferedReader(new InputStreamReader(is));
           //(5)读取数据
           String line ;
           while((line = br.readLine())!=null){
             System.out.println("服务端收到:"+
                               socket.getRemoteSocketAddress()+":"+line);
          }
      }catch (Exception e){
           System.out.println(socket.getRemoteSocketAddress()+"下线了!");
      }
  }
}

客户端案例代码

/**
   目标: Socket网络编程。
   功能1:客户端可以反复发,一个服务端可以接收无数个客户端的消息!!
   小结:
        服务器如果想要接收多个客户端,那么必须引入线程,一个客户端一个线程处理!!
*/
public class ClientDemo {
   public static void main(String[] args) throws Exception {
       System.out.println("==客户端的启动==");
       // (1)创建一个Socket的通信管道,请求与服务端的端口连接。
       Socket socket = new Socket("127.0.0.1",7777);
       // (2)从Socket通信管道中得到一个字节输出流。
       OutputStream os = socket.getOutputStream();
       // (3)把字节流改装成自己需要的流进行数据的发送
       PrintStream ps = new PrintStream(os);
       // (4)开始发送消息
       Scanner sc = new Scanner(System.in);
       while(true){
           System.out.print("请说:");
           String msg = sc.nextLine();
           ps.println(msg);
           ps.flush();
      }
  }
}

小结

  • 1.每个Socket接收到,都会创建一个线程,线程的竞争、切换上下文影响性能;
  • 2.每个线程都会占用栈空间和CPU资源;
  • 3.并不是每个socket都进行IO操作,无意义的线程处理;
  • 4.客户端的并发访问增加时。服务端将呈现1:1的线程开销,访问量越大,系统将发生线程栈溢出,线程创建失败,最终导致进程宕机或者僵死,从而不能对外提供服务。

1.6 使用BIO模拟NIO

概述:BIO + 线程池

在上述案例中:客户端的并发访问增加时。服务端将呈现1:1的线程开销,访问量越大,系统将发生线程栈溢出,线程创建失败,最终导致进程宕机或者僵死,从而不能对外提供服务。

接下来我们采用一个伪异步I/O的通信框架,采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable线程任务接口)交给后端的线程池中进行处理。JDK的线程池维护一个消息队列和N个活跃的线程,对消息队列中Socket任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

但是如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续socket的i/o消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时。

图示如下:

image.png

线程池处理类

// 线程池处理类
public class HandlerSocketThreadPool {
// 线程池
private ExecutorService executor;
//核心线程数 队列数量
public HandlerSocketThreadPool(int maxPoolSize, int queueSize){
//核心线程数默认是3
this.executor = new ThreadPoolExecutor(
3,
maxPoolSize,
120L,
TimeUnit.SECONDS,
new ArrayBlockingQueue(queueSize) );
}
public void execute(Runnable task){
this.executor.execute(task);
}
}

服务端源码分析

/***
   服务端
   0.创建一个线程池
   1.每接收到一个客户端的连接就启动一个线程
   2.将客户端连接作为参数传入线程中
   3.监听客户端连接的输入流
   4.空转输出输入流内容
***/  
public class Server {
  public static void main(String[] args) {
     try {
        System.out.println("----------服务端启动成功------------");
        ServerSocket ss = new ServerSocket(9999);
        // 一个服务端只需要对应一个线程池
        HandlerSocketThreadPool handlerSocketThreadPool =
              new HandlerSocketThreadPool(3, 1000);
        // 客户端可能有很多个
        while(true){
           Socket socket = ss.accept() ; // 阻塞式的!
           System.out.println("有人上线了!!");
           // 每次收到一个客户端的socket请求,都需要为这个客户端分配一个
           // 独立的线程 专门负责对这个客户端的通信!!
           handlerSocketThreadPool.execute(new ReaderClientRunnable(socket));
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
  }
}
class ReaderClientRunnable implements Runnable{
  private Socket socket ;
  public ReaderClientRunnable(Socket socket) {
     this.socket = socket;
  }
  @Override
  public void run() {
     try {
        // 读取一行数据
        InputStream is = socket.getInputStream() ;
        // 转成一个缓冲字符流
        Reader fr = new InputStreamReader(is);
        BufferedReader br = new BufferedReader(fr);
        // 一行一行的读取数据
        String line = null ;
        while((line = br.readLine())!=null){ // 阻塞式的!!
           System.out.println("服务端收到了数据:"+line);
        }
    } catch (Exception e) {
        System.out.println("有人下线了");
    }
  }
}

客户端源码分析

public class Client {
  public static void main(String[] args) {
     try {
        // 1.简历一个与服务端的Socket对象:套接字
        Socket socket = new Socket("127.0.0.1", 9999);
        // 2.从socket管道中获取一个输出流,写数据给服务端
        OutputStream os = socket.getOutputStream() ;
        // 3.把输出流包装成一个打印流
        PrintWriter pw = new PrintWriter(os);
        // 4.反复接收用户的输入
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String line = null ;
        while((line = br.readLine()) != null){
           pw.println(line);
           pw.flush();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
  }
}

小结

  • 伪异步io采用了线程池实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然是采用的同步阻塞模型,因此无法从根本上解决问题。

  • 如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续socket的i/o消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时。

比如核心线程数和最大线程数都设置1,但是客户端有2个。客户端A来连接的时候,如果它一直不下线,客户端B是一直无法被处理的,这个时候使用BIO+线程池就会陷入和1.5案例一样的困境:如果其它的客户端不下线,必须1个客户端对应1个线程,其它的客户端只能在队列中等待。除非客户端A下线释放线程池中的线程,这个时候客户端B才有被处理的可能。

  • 采用 多线程+ 阻塞IO 可以达到NIO类似的效果,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。

    而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。

    多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态是通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。

到底什么是多路IO复用,后续再讲。