I/O模型说明
I/O模型:用什么样的通信模式和架构进行数据的传输和接收,很大程度上决定了程序通信的性能。java共支持3种网络编程的I/O模型:BIO、NIO、AIO。
I/O模型
java BIO
同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务端就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销。客户端越多,服务端就需要启动更多的线程来满足每个客户端的通信需求,同时,每个客户端在发送或者接收数据的时候都是同步的,意思就是即使当前线程处于等待状态,该线程也需要等待且不能做其他事情。由此,BIO模式下需要创建很多线程,势必会带来很大的系统开销。
简单示意图:
java NIO
同步非阻塞,服务器实现模式为一个线程处理多个请求,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理。
简单示意图:
java AIO
异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理,一般适用于连接数较多且连接时间较长的应用。
适用场景
- BIO方式适用于连接数比较小且固定的架构,这种方式对服务器资源要求高,并发局限于应用中,程序简单易理解。
- NIO方式适用于连接数多且连接比较短的架构,比如聊天服务器、弹幕系统、服务器间通讯等。编程较为复杂。
- AIO方式适用于连接数多且连接比较长的架构,比如相册服务器,充分调用OS参与并发操作,编程较为复杂。
BIO
BIO 基本介绍
- BIO就是传统的java io编程,其相关的类和接口在java.io包下
- BIO(blocking I/O):同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务端就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销,可以通过线程池机制来改善。
BIO工作机制
BIO编程实例
- 服务端代码
/**
* 服务端
*/
public class Server {
public static void main(String[] args) {
try {
//
ServerSocket ss = new ServerSocket(2323);
System.out.println("=== 服务端启动成功 ===");
// 等待客户端连接,程序会在这行代码阻塞
Socket socket = ss.accept();
// 获取输入流
InputStream is = socket.getInputStream();
// 获取缓冲输入字符流
BufferedReader bis = new BufferedReader(new InputStreamReader(is));
// 读取数据
String msg;
while ((msg = bis.readLine()) != null) {
System.out.println("接收到客户端发送消息:" + msg);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
- 客户端代码
/**
* 客户端
*/
public class Client {
public static void main(String[] args) {
//
try {
// 创建socket对象
Socket s = new Socket("127.0.0.1", 2323);
// 获取输出流
OutputStream os = s.getOutputStream();
// 将字节输出流包装成打印流
PrintWriter pw = new PrintWriter(os);
pw.println("hello world!");
pw.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 执行结果
=== 服务端启动成功 ===
接收到客户端发送消息:hello world!
java.net.SocketException: Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:210)
at java.net.SocketInputStream.read(SocketInputStream.java:141)
at sun.nio.cs.StreamDecoder.readBytes(StreamDecoder.java:284)
at sun.nio.cs.StreamDecoder.implRead(StreamDecoder.java:326)
at sun.nio.cs.StreamDecoder.read(StreamDecoder.java:178)
at java.io.InputStreamReader.read(InputStreamReader.java:184)
at java.io.BufferedReader.fill(BufferedReader.java:161)
at java.io.BufferedReader.readLine(BufferedReader.java:324)
at java.io.BufferedReader.readLine(BufferedReader.java:389)
at com.fzkj.bio.Server.main(Server.java:25)
Process finished with exit code 0
在上面的示例中,实现了一个简单的客户端向服务端发送数据的例子。从结果来看,服务端虽然接收到了客户端发送的消息,但是却抛出了一个Connnection reset
的异常。前面我们说过,BIO
模式下的通信,服务端会为每一个客户端分配一个线程去处理请求,在这个示例中使用的是main线程,我们在服务端读取数据的时候使用的是一个while
循环获取,而客户端的数据却只发送了一次就退出了,所以服务端在进入第二次等待数据的时候,等来的却是客户端的断开,就抛出了异常。
小结
- 在以上通信中,服务端会一直等待客户端的消息,如果客户端没有进行消息的发送,服务端将一直进入阻塞状态。
- 同时服务端是按照行获取数据的(本例中),这意味着客户端也必须按照行发送数据,否则服务端将进入等待消息的阻塞状态。
改进
在上面的案例中,客户端只是发送了一条消息,下面就改进一下让客户端和服务端可以一直保持通信。
服务端不需要改,因为本来就是在一直接收客户端消息。只需要将客户端进行小小的改动即可。
- 客户端改进
Scanner sc = new Scanner(System.in);
while (true) {
System.out.println("输入要发送的消息:");
String msg = sc.nextLine();
pw.println(msg);
pw.flush();
}
BIO模式下连接多个客户端
上述案例中,一个服务端只能接收一个客户端的通信请求,究其原因是我们在服务端只接收了一个连接请求,就是那个accept
方法。那么如何让服务端可以接收多个客户端请求呢?这时就需要引入多线程了,为每个连接请求分配一个线程去处理。
- 服务端代码示例
/**
* 实现服务端可以接收多个客户端的连接请求
* 思路:
* 1、应该一直调用accept方法监听
* 2、当有连接请求就新开一个线程去处理
*/
public class Server {
public static void main(String[] args) {
startServer();
}
public static void startServer() {
try {
// 1、监听端口
ServerSocket ss = new ServerSocket(2323);
System.out.println("==== 服务端启动成功 ====");
while(true) {
// 等待客户端连接
Socket socket = ss.accept();
// 交由一个新的线程去处理
new Thread(new ServerReaderThread(socket)).start();
}
}catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 处理客户端请求
*/
class ServerReaderThread implements Runnable {
private Socket socket;
public ServerReaderThread(Socket socket){
this.socket = socket;
}
@Override
public void run() {
try {
// 获取输入流
InputStream is = socket.getInputStream();
// 包装成缓冲字符输入流
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String msg;
// 接收消息
while((msg = br.readLine()) != null) {
System.out.println("接收到客户端 " + socket.getLocalPort() + " 的消息:" + msg);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 客户端代码示例
public class Client {
public static void main(String[] args) {
try {
// 1、创建socket连接对象,请求与服务端连接
Socket socket = new Socket("127.0.0.1", 2323);
// 2、 获取一个打印流
PrintStream ps = new PrintStream(socket.getOutputStream());
Scanner sc = new Scanner(System.in);
while(true) {
System.out.print("输入要发送的内容:");
String msg = sc.nextLine();
ps.println(msg);
ps.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
小结
- 每个socket接收到都会创建一个线程,线程的竞争和切换上下文会影响性能。
- 每个线程都会占用栈空间和CPU资源。
- 并不是每个socket都进行IO操作,无意义的线程处理
- 客户端的并发访问增加时,服务端的线程数量也会增加 ,访问量越大,系统将会发生线程栈溢出,线程创建失败等问题,从而导致进程假死,不能提供服务。
伪异步I/O编程
接着我们采用一个伪异步I/O的通信框架,采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task交给后端的线程池进行处理。
- 服务端
/**
* 实现伪异步通信架构
*/
public class Server {
public static void main(String[] args) {
try {
// 1、注册端口
ServerSocket ss = new ServerSocket(2323);
// 2、创建线程池对象
ServerSocketPoolHandler ssp = new ServerSocketPoolHandler(3, 10);
while(true) {
Socket s = ss.accept();
// 把socket封装成可执行的任务对象,传递给线程池处理
ssp.execute(new ServerRunnbleTarget(s));
}
}catch (Exception e){
e.printStackTrace();
}
}
}
- 客户端
/**
* 客户端
*/
public class Client {
public static void main(String[] args) {
Socket socket = null;
try {
// 1、创建socket连接对象,请求与服务端连接
socket = new Socket("127.0.0.1", 2323);
// 2、 获取一个打印流
PrintStream ps = new PrintStream(socket.getOutputStream());
Scanner sc = new Scanner(System.in);
while(true) {
System.out.print("输入要发送的内容:");
String msg = sc.nextLine();
ps.println(msg);
ps.flush();
}
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
- 任务对象封装类
/**
* 任务对象
*/
public class ServerRunnbleTarget implements Runnable{
private Socket socket;
public ServerRunnbleTarget(Socket socket){
this.socket = socket;
}
@Override
public void run() {
try {
// 创建输入流
InputStream is = socket.getInputStream();
// 包装为字符输入流
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String msg;
while((msg = reader.readLine()) != null) {
System.out.println("接收到客户端消息:" + msg);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
- 线程池
/**
* 服务端socket处理线程池
*/
public class ServerSocketPoolHandler {
private ExecutorService executorService;
/**
* public ThreadPoolExecutor(int corePoolSize, // 核心数
* int maximumPoolSize, // 最大线程数量
* long keepAliveTime,
* TimeUnit unit,
* BlockingQueue<Runnable> workQueue)
* @param maxThread
* @param queueSize
*/
public ServerSocketPoolHandler(int maxThread, int queueSize){
executorService = new ThreadPoolExecutor(2, maxThread, 120, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(queueSize));
}
/**
* 交由线程池
* @param target
*/
public void execute(Runnable target) {
executorService.execute(target);
}
}
小结
- 伪异步io采用了线程池实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然是采用同步阻塞模型,因此无法从根本上解决问题。
- 如果单个消息处理得缓慢,获取服务器线程池中的全部线程都被阻塞,那么后续socket的io消息都将在队列中排队。
基于BIO
模式下的文件上传
- 服务端代码
/**
* 接收客户端任意类型文件,并保存到磁盘
*/
public class Server {
public static void main(String[] args) {
try{
ServerSocket ss = new ServerSocket(2323);
while(true){
Socket s = ss.accept();
new Thread(new ServerReadThread(s)).start();
}
}catch(Exception e){
e.printStackTrace();
}
}
}
- 客户端代码
/**
* 实现客户端上传任意类型的文件数据给服务端
*/
public class Client {
public static void main(String[] args) {
Socket socket = null;
try (
InputStream is = new FileInputStream("E:\\img.py");
){
// 请求连接服务端
socket = new Socket("127.0.0.1",2323);
// 把字节输出流包装成一个数据输出流
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
// 先发送文件后缀
dos.writeUTF(".py");
// 再发送文件数据
byte[] buffer = new byte[1024];
int len;
while((len = is.read(buffer)) > 0) {
dos.write(buffer, 0, len);
}
dos.flush();
socket.shutdownInput(); // 告诉服务端发送完毕
}catch(Exception e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
- 处理线程
/**
* 处理客户端请求的线程
*/
public class ServerReadThread implements Runnable {
private Socket socket;
public ServerReadThread(Socket socket){
this.socket = socket;
}
@Override
public void run() {
OutputStream os = null;
try {
// 得到一个数据输入流
DataInputStream dis = new DataInputStream(socket.getInputStream());
// 读取文件后缀
String suffix = dis.readUTF();
// 定义一个文件输出流把客户端传过来的文件保存到磁盘
os = new FileOutputStream("E:\\upload\\"
+ UUID.randomUUID().toString() + suffix);
byte[] buffer = new byte[1024];
int len;
while((len = dis.read(buffer)) > 0){
os.write(buffer, 0, len);
}
os.flush();
System.out.println("文件保存成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
BIO
模式下端口转发思想
需要实现一个客户端的消息可以发送给所有客户端接收。
实现思路:
- 需要接收消息
- 在服务端定义一个在线的
socket
集合 - 分配不同线程处理
- 客户端需要接收消息
这个被称为端口转发思想。