Java网络编程杂谈(一)

网络编程基础:BIO、NIO概念解析与线程池剖析

前序

网络编程这个系列围绕着BIO、NIO以及网络框架netty来讲,主要都还是一些基础的东西,之所以称为杂谈一来是因为我在学习网络编程的时候遇到一些以前忘记的知识点想通过这些文章拾起来,二来是因为写这系列的文章想更通俗一些,当作一篇复习笔记,更多的还是自己的理解在里面(所以,本文如有错误还望不吝赐教),但我会尽量按照我学习的历程把博客写的更有条理。

参考书目:《netty权威指南(第2版)》

本文目录及大纲

1. 基础概念

在学习网络编程时会遇到很多名词,在这里先提前列举部分重要名词拿出来说明白,这对后续的学习有很大益处,其实也是在为后续学习新东西打下基础。比如掌握清除同步、异步和阻塞、非阻塞的概念对于理解IO方式非常有必要;知道什么是文件描述符(fd),才能对select\poll\epoll等支持IO多路复用的系统调用理解得不那么抽象;掌握线程池的执行策略和阻塞队列能对透彻分析线程池版BIO的弊端起到很大帮助。这里提到的都是我不熟悉或者原来理解不当的概念,还需要其他的基础知识比如TCP/IP通信协议等等就不再提了。当然,随着内容的增多也会有一些新增概念,会在后续讲到的时候详细说明。这里提高的概念都是前置知识。

1.1 同步、异步

同步与异步是指用户程序和内核交互的两种不同方式,同步是指用户程序触发IO操作后等待或者轮询的方式去查看IO是否就绪,而异步是指用户程序触发IO操作后就去做自己的事情,等到IO操作结束后用户程序会得到IO操作完成的通知。
使用同步IO,需要Java自己去完成实际的IO操作,而使用异步IO是将IO操作委托给操作系统去完成,这需要操作系统有API能支持异步IO。

1.2 阻塞、非阻塞

阻塞和非阻塞是指进程在访问数据时,对IO就绪状态采取的两种不同方式,也就是说阻塞和非阻塞是两种读写操作的方式。

在阻塞情况下调用IO操作(或者说一个IO操作是阻塞的,两种说法一个意思)表示该调用该操作的程序将会一直阻塞直到IO操作完成,中间会经过等待IO操作就绪和将数据从内核区拷贝到用户区两个过程。

当一个非阻塞的IO操作被调用时,当IO处于非就绪状态时,会立即返回一个IO状态值。IO处于非就绪状态是指例如内核区没有可读的数据(可能在等待网络传输过来等等情况)或者缓冲区不可写入(可能在等待缓冲区可写等等情况)表示IO处于非就绪状态,这是正式进行IO操作之前要经过的过程,而正式的IO操作是将数据读取或写入这个过程,一定要清除整个IO操作包括等待有IO资源和读取/写入这两个过程。

1.3 文件描述符

说到文件描述符,要先说操作系统如何管理进程的,这就不得不提到PCB(Process Control Block 进程控制块),操作系统会为每个运行的进程创建一个PCB,里面纪录这与这个进程相关的信息,比如进程ID这之类的,其中就包括文件描述符表。所以文件描述符表是每个进程都有一份的,它存在在PCB中。而文件描述符表可以看成是一个装载了很多文件描述符的结构体数组,没错,可以把文件描述符表理解为一个数组,数组中的元素就是文件描述符,每个文件描述符表示该进程打开的文件,这里的文件的含义是宽泛的,因为Linux中一切皆文件,比如一个socket也可以看成是文件。那么每个进程中都有一张纪录这该进程打开的文件的表,也就是文件描述符表。

说了文件描述符的含义,那么它具体是什么呢?其实它是一个数字或者说一个指针,指向打开文件表中的一行纪录,由打开文件表中的纪录获得该文件在i-node表中的指针,从而进入i-node表中记录着文件数据块在磁盘上的位置,这样才能真正对文件进行操作。

注意:文件描述符表是每个进程都有一份的,而打开文件表i-node表是系统级的,整个系统只有一份。

(关于Linux中的打开文件表i-node表这里就不详述了,这就扯到操作系统了……)

1.4 线程池相关知识

其实这里提到线程池主要是应为后续有线程池版BIO的弊端分析里和阻塞队列相关的知识点,所以顺道简单复习以下线程池相关内容,相关内容不做细究。

1.4.1 线程池的理解

简单理解就是,一个装了很多线程的池子,当有任务到来时,从池子中取一个线程执行该任务,这就是线程池。但里面涉及到很多,包括线程池不能无限扩大否则会系统资源耗尽,线程池的阻塞队列也不能无限扩大否则会导致大量任务堆积在队列里,造成OOM;当任务无法加入阻塞队列时有应当采取什么策略解除阻塞,这又涉及了选取饱和策略。

1.4.2 线程池的执行策略

当线程数小于corePoolSize时,不会将任务入队,而是创建新的线程执行任务;当线程数大于等于corePoolSize时,Excutor始终首选将新的任务入队,直至新任务不能加入队列时,才创建新的线程;当创建的线程数等于maximumSize时,任务被拒绝。

1.4.3 ThreadPoolExcutor参数说明

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
} 
  1. corePoolSize:核心线程数,有任务到达时才启动线程,直到运行线程数达到corePoolSize,则将任务加入队列;
  2. maximumPoolSize:最大线程数,运行线程数达到corePoolSize且阻塞队列满时,才会创建新线程,直到运行线程数达到maxmumPoolSize;
  3. keepAliveTime:除了核心线程外的线程的存活时间;
  4. unit:keepAliveTime的时间单位;
  5. workQueue:当运行线程数超过corePoolSize时用于保存任务的队列,有无界队列、有界队列、同步移交队列3中队列可选;
  6. threadFactory:创建新线程时使用的工厂;
  7. rejectedExcutionHandler:队列阻塞并且线程数达到maximumPoolSize时执行的饱和策略,有中止、抛弃、抛弃最旧和调用者执行4中策略可选;

1.4.4 Excutors创建的4中线程池特点

在这里插入图片描述
以上通过java.util.concurrent.Excutors创建的四种线程池说到底还是基于ThreadPoolExcutor方法的,只是使用了不同参数,它们的基本特性在图中归纳了,应当特别注意线程池的大小和选用的阻塞队列的特性。

1.4.5 线程池使用的阻塞队列

阻塞队列有3中类型,分别是无界队列、有界队列和同步移交队列。
无界队列:可以无限扩充的队列,比如LinkedBlockingQueue,使用链表方式存储的,这类队列可能使得大量任务堆积在队列中,容易造成OOM;

有界队列:有界队列又分为FIFO类的队列(比如ArrayBlockingQueue)和有优先级的队列(比如PriorityQueue),这类队列可以降低内存和cpu使用率、减少上下文切换但同时降低系统吞吐量。

同步移交队列:SynchronousQueue,这类队列不希望任务积压在队列中,希望将任务移交给其他线程进行处理,这个队列是一种线程间任务的移交机制。一般使用该队列意味着有另外的线程来取走任务。

1.4.6 饱和策略

饱和策略就是参数中的RejectedExecutionHandler,指当队列阻塞不能加入新任务时该如何解除阻塞。下面也用一个思维导图来简述线程池中的饱和策略。
在这里插入图片描述

2. 五种网络IO模型

《netty权威指南(第2版)》中提到过这五种网络IO模型,是基于socket通信来对几种模型做说明的,我这里理解五种模型主要还是抓住核心特点,以及根据后面的NIO内容核心理解IO复用模型。

2.1 阻塞IO模型

对于阻塞IO/非阻塞IO模型可以直接根据阻塞和非阻塞的概念来理解。

阻塞IO模型就是,系统调用直到数据包到达(等待IO就绪)且被复制到应用进程的缓冲区中(IO进行)或者发生错误时才返回,在此期间会一直等待,也就是IO进程从被调用开始到IO完成整个过成都是阻塞的;

2.2 非阻塞IO模型

非阻塞IO模型就是,进程发起IO操作,如果IO处于非就绪状态会直接返回状态信息,一般非阻塞IO模型会轮询这个状态,直到IO就绪,然后开始IO操作,直至IO操作结束,也就是读取或写入完成;

2.3 IO复用模型

进程将一个或多个fd传递给IO多路复用的系统调用select/poll,阻塞在select操作上,select负责轮询fd,直到有fd就绪,然后在进行IO操作。注意IO复用模型中的轮询操作是交给操作系统的调用去执行的,与非阻塞IO模型的自己去轮询IO状态不一样,后来Linux使用epoll来做IO多路复用的系统调用,它相比select/poll具有很大优势,支持IO复用的系统调用select/poll/epoll的区别会在后面说。

2.4 信号驱动IO模型

当IO准备就绪就为进程生成一个信号,用过该信号回调通知应用程序调用IO操作。

2.5 异步IO模型

调用内核IO操作,内核在整个操作完成之后通知用户程序。

2.6 几点说明

  1. 对于上面几种IO模型建议看书更能理解,这里只是提出了核心点,它们其实正是更具IO过程中的等待资源阶段和正式读写阶段两个阶段的不同特点来区分的。

比如,阻塞IO就是从等待IO资源到读写完毕两个阶段都阻塞,不给用户程序任何反馈,除非这中间出现异常或者错误,否则不到IO操作结束是不会有任何返回的;

非阻塞IO是从IO操作被调用时发现没有就绪的IO资源就立刻返回一个状态给调用者,调用者就能知道IO资源还不可用,在这种模式下就会轮询这个IO资源的状态,一旦就绪就开始读写操作,直至读写完成;

IO复用模型有点像非阻塞IO模型,只是这个轮询动作由系统调用去做,一旦IO资源就绪还是需要在调用IO操作完成IO。值得注意的是,IO复用模型相比非阻塞IO模型的优势并不在于处理单个IO操作有多快(毕竟有两个系统调用,用户态和内核态的切换也是耗时耗力的),而是在于能处理大量IO操作,非阻塞IO也能但是消耗大量线程资源。所以,在处理的网络连接少或者说连接活跃数不多(活跃是指有数据交互,比如有请求)时,非阻塞IO+多线程并不会比IO复用模型差多少。

说回来,从IO操作的两个阶段来说说信号驱动IO模型是通知调用IO操作的用户何时可以进行IO,也就是告知用户IO资源何时就绪,然后由用户调用IO操作完成IO;而异步IO模型是告知用户IO操作何时完成。

  1. Linux中支持IO复用的系统调用——select/poll/epoll,select和poll差不多,最大的区别就是poll存储fd是用的链表,也就是fd个数基本不受限,其他select有的缺点poll也还是有。但是要知道的是这三个系统调用的作用都是侦测fd的状态,说白了就是侦测IO资源的状态。

select的问题:

  • fd个数受限,32位系统最多有1024个fd可用,64位系统最多可打开2048个fd;
  • 轮询fd的方式侦测fd是否就绪,导致其效率随着fd的增多而线性下降(致命缺点);
  • 大量的fd数组在内核区和用户区之间复制(把fd消息通知给用户空间),导致效率低;

epoll基本解决了select的问题:

  • fd不受限,由内存大小决定,1G内存可以打开十万个fd,也就是说可以接受成千上万个TCP连接;
  • 只对“活跃”的socket进行操作,也就是只对有数据到达的连接或者要写出的连接(或者其他被侦测的动作)进行操作,epoll根据每个fd上面的callback函数实现;
  • 使用零拷贝技术,mmap使得内核区和用户区之间的复制次数减少,其实就是内核与用户程序共享了一块区域;

BIO编程

BIO是在JDK1.4之前常用的编程方式。

BIO编程模型

在这里插入图片描述

前端一个acceptor线程接收客户端链接请求,每当一个客户端成功建立连接就为其分配一个线程处理该客户端的请求;后端线程数与客户端连接数成正比。由于线程资源昂贵,当大量客户端进行连接时,会出现服务器资源被大量消耗、线程堆栈溢出的情况;所以适合固定或少量的客户端连接,并需要服务器具有较强的处理能力

BIO代码实现

这里BIO的示例代码来自参考书目。
服务端:

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

public class TimeServer {
    public static void main(String[] args) throws IOException {
        int port = 9090;
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(port);
            System.out.println("The time server is start in port : " + port);
            Socket socket = null;
            while (true){
                socket = serverSocket.accept();
                new Thread(new TimeServerHandler(socket)).start();
            }
        }finally {
            if(serverSocket != null){
                System.out.println("The time server close");
                serverSocket.close();
                serverSocket = null;
            }
        }
    }
}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Date;

public class TimeServerHandler implements Runnable {
    private Socket socket;

    public TimeServerHandler(Socket socket){
        this.socket = socket;
    }

    @Override
    public void run() {
        BufferedReader in = null;
        PrintWriter out = null;
        try {
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            out = new PrintWriter(socket.getOutputStream(),true);

            String currentTime = null;
                String body = null;
                while (true){
                    body = in.readLine();
                    if(body == null)
                        break;
                    System.out.println("The time server receive order : " + body);
                    currentTime = "QUERY TIME ORDER".equals(body) ? new Date(System.currentTimeMillis()).toString():"BAD QUERY";
                    out.println(currentTime);
                }
        }catch (Exception e){
            if(in != null){
                try {
                    in.close();
                }catch (IOException e1){
                    e1.printStackTrace();
                }
            }
            if(out != null){
                out.close();
                out = null;
            }
            if(this.socket != null){
                try {
                    this.socket.close();
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
                this.socket = null;
            }
        }
    }
}

客户端

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public class TimeClient {
    public static void main(String[] args){
        int port = 9090;
        Socket socket = null;
        BufferedReader in = null;
        PrintWriter out = null;

        try {
            socket =new Socket("127.0.0.1",9090);
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            out = new PrintWriter(socket.getOutputStream(),true);
            out.println("QUERY TIME ORDER");
            System.out.println("send order 2 server succeed.");
            String resp = in.readLine();
            System.out.println("Now is : " + resp);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if(out!=null){
                out.close();
                out = null;
            }

            if(in != null){
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                in = null;
            }
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            socket = null;
        }
    }
}

编程实现过程为:首先在服务器端启动一个ServerSocket来监听网络请求,客户端启动Socket发起网络请求,默认情况下ServerSocket会建立一个线程来处理该请求;如果服务器没有线程可用,客户端则会阻塞等待或被拒绝;

这里要说明的是,其实整个BIO编程说起来就是:ServerSocket调用阻塞方法accept(),该方法会一直阻塞,除非有客户端请求建立连接,这是该方法返回一个与该客户端连接的socket对象,拿到这个socket对象,然后通过socket对象拿到输入流和输出流才能进行后续的读写操作。

BIO为什么是同步阻塞的

从代码可以看出,BIO编程主要还是通过流来进行IO操作,也就是InputStream和OutputStream两个类。而读取InputStream和写入OutputStream的方法都是同步阻塞的方法。

具体来说,使用InputStream流读取数据时,当对方发送数据慢或者网络较慢时,从输入流读取数据一方的通信线程会被长时间阻塞。如果对方要60s才能将数据完全发送,那么读取方的线程也会同步阻塞60s,在此期间收到的其它消息都只能进入消息队列排队(消息队列:TCP缓冲区)。

当调用OutputStream的write方法写输出流时,它将被阻塞直到所有的数据全部写入完毕(或者异常)。

根据TCP/IP的通信原理,当消息的接收方处理缓慢时,也就是接收方有大量数据在TCP缓冲区中还没来得及处理,这时会限制发送方的TCP window size大小,直到为0,而双方处于Keep-Alive,由于采用同步阻塞的写入方法这会使得发送方阻塞,直到window size 不为0。

总结

这篇先说到这里吧,还有好多没写,其实自己用思维导图总结起来不是很多,但因为是写博客中间要串联,不能向自己写笔记那样只顾自己复习能看懂,这就导致篇幅过大了。后面再将线程池版BIO和NIO相关内容。本来以为很快就能写完这篇博客,没想到啊没想到……

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值