苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》
写在开头
我国宋代禅宗大师青原行思在《三重境界》中有这样一句话:“ 参禅之初,看山是山,看水是水;禅有悟时,看山不是山,看水不是水;禅中彻悟,看山仍然山,看水仍然是水。”
作为一名Java Developer,在面对Java并发编程的时候,有过哪些的疑惑与不解 ?对于Java领域中的线程机制与多线程,你都做了哪些功课?是否和我一样,在看完《Java编程思想》和《Java并发编程实战》之后,依旧一头雾水,不知其迹?那么,希望你看完此篇文章之后,对你有所帮助。
从一定程度上说,Java并发编程之路,实则是一条“看山是山,看山不是山,看山还是山”的修行之路。大多数情况下,当我们觉得有迹可循到有迹可寻时,何尝不是陷入了另外一个“怪圈”之中?
从搭载Linux系统上的服务器程序来说,使用Java编写的是”单进程-多线程"程序,而用C++语言编写的,可能是“单进程-多线程”程序,“多进程-单线程”程序或者是“多进程-多线程”程序。其中,“多进程-多线程”程序是”单进程-多线程"程序和“多进程-单线程”程序的组合体。
相对于操作系统内核来说,Java程序属于应用程序,只能在这一个进程里面,一般我们都是直接利用JDK提供的API开发多个线程实现并发。
而C++直接运行在Linux系统上,可以直接利用Linux系统提供的强大的进程间通信(Inter-Process Communication,IPC),很容易创建多个进程实现并发程序,并实现进程间通信。
但是,多线程的开发难度远远高于单线程的开发,主要是需要处理线程间的通信,需要对线程并发做控制,需要做好线程间的协调工作。
对于固定负载情况下,在描述和研究计算并发系统处理能力,以及描述并行处理效果的加速比,一直有一个比较著名的计算公式:
就是我们熟知的阿姆达尔定律(Amdahl"s Law),在这个公式中,
[1]. P:指的是程序中可并行部分的程序在单核上执行的时间占比。一般用作表示可改进性能的部件原先运行占用的时间与系统整体运行需要的时间的比值,取值范围是0 ≤ P ≤ 1。
[2]. S:指的是处理器的个数(总核心数)。一般用作表示升级加速比,可改进部件原先运行速度与改进后的部件速度的比值,取值范围是S ≥ 1。
[3]. Slatency(s):指的是程序在S个处理器相对在单个处理器(单核)中速度提升比率。一般用作表示整个任务的提速比。
根据这个公式,我们可以依据可确定程序中可并行代码的比例,来决定我们实际工作中增加处理器(总核心数)所能带来的速度提升的上限。
无论是C++开发者在Linux系统中使用的pthread,还是Java开发者使用的java.util.concurrent(JUC)库,这些线程机制的都需要一定的线程I/O模型来做理论支撑。
所以,接下来,我们就让我们一起探讨和揭开Java领域中的线程I/O模型的神秘面纱,针对那些盘根错落的枝末细节,才能让我们更好地了解和正确认识ava领域中的线程机制。
关健术语
本文用到的一些关键词语以及常用术语,主要如下:
- 阿姆达尔定律(Amdahl 定律): 用于确定并发系统中性能瓶颈部件在采用措施提示性能后,此部件对系统性能提示的改进程度,即系统加速比。
- 任务(Task): 表示一个程序需要被完成工作内容,与线程非一对一对应的关系,是一个相对概念。
- 并发(Concurrent): 表示至少一个任务或者若干 个任务同一个时间段内被执行,但是不是顺序执行,大多数都是以交替的方式被执行。
- 并行(Parallel): 表示至少一个任务或者若干 个任务同一个时刻被执行。主要是指一个并行连接通过多个通道在同一时间内传播多个数据流。
- 串行(Serial): 表示至多一个任务或者只有一个 个任务同一个时刻被执行。主要是指在同一时间内只连接传输一个数据流。
- 内核线程(Kernel Thread): 表示由内核管理的线程,处于操作系统内核空间。用户应用程序通过API和系统调用(system call)来访问线程工具。
- 应用线程(Application Thread): 表示不需要内核支持而在用户应用程序中实现的线程,处于应用程序空间,也称作用户线程。主要是由JVM管理的线程和JVM自己携带的JVM线程。
- 上下文切换(Context Switch): 一般是指任务切换, 或者CPU寄存器切换。当多任务内核决定运行另外的任务时, 它保存正在运行任务的当前状态, 也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中, 入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器, 并开始下一个任务的运行过程。在Java领域中,线程有生命周期,其上下文信息的保存和恢复的过程。
- 线程安全(Thread Safe): 一段操作共享数据的代码能够保证同一个时间内被多个线程执行而依然保证其数据的正确性的考量。
基本概述
Java领域中的线程主要分为Java层线程(Java Thread) ,JVM层线程(JVM Thread),操作系统层线程(Kernel Thread)。
对于Java领域中,从一定程度上来说,由于Java程序并不直接运行在Linux系统上,而是运行在JVM(Java 虚拟机)上,而一个JVM实例是一个Linux进程,每一个JVM都是一个独立的“沙盒”,JVM之间相互独立,互不通信。
按照操作系统和应用程序两个层次来说,线程主要可以分为内核线程(Kernel Thread) 和应用线程(Application Thread)。
其中,在Java领域中的线程主要分为Java层线程(Java Thread) ,JVM层线程(JVM Thread),操作系统层线程(Kernel Thread)。
一般来说,我们把应用线程看作更高层面的线程,而内核线程需要向应用线程提供支持。由此可见,内核线程和应用线程之间存在一定的映射关系。
因此,从线程映射关系来看,不同的操作系统可能采用不同的映射方式,我们把这些映射关系称为线程的映射,或者可以说作线程映射理论模型(Thread Mappered Theory Model )。
在Java领域中,对于文件的I/O操作,提供了一系列的I/O功能API,主要基于基于流模型实现。我们把这些流模型的设计,称作为I/O流模型(I/O Stream Model )。
其中,Java对照操作系统内核以及网络通信I/O中的传统BIO来说,提供并支持了NIO和AIO的功能API设计,我们把这些设计,称作为线程I/O参考模型(Thread I/O Reference Model )。
另外,对于NIO和AIO还参考了一定的设计模式来实现,我们把这些基于设计模式的设计,称作为线程设计模式模型(Thread I/O Design Pattern Model )。
综上所述,在Java领域中,我们在学习和掌握Java并发编程的时候,可以按照:线程映射理论模型->I/O流模型->线程I/O参考模型->线程设计模式模型->线程价值模型等脉络来一一进行对比分析。
一. Java 领域中的线程映射理论模型
Java 领域中的线程映射模型主要有内核级线程模型(Kernel-Level Thread ,KLT)、应用级线程模型(Application-Level Thread ,ALT)、混合两级线程模型(Mixture-Level Thread ,MLT)等3种模型。
从Java线程映射类型来看,主要有线程一对一(1:1)映射,线程多对多(M:1)映射,线程多对多(M:N)映射等关系。
对应到线程模型来说,线程一对一(1:1)映射对应着内核线程(Kernel-Level Thread ,KLT),线程多对多(M:1)映射对应着应用级线程(Application-Level Thread,ALT),线程多对多(M:N)映射对应着混合两级线程(Mixture-Level Thread ,MLT)。
因此,Java领域中实现多线程主要有3种模型:内核级线程模型、应用级线程模型、混合两级线程模型。它们之间最大的差异就在于线程与内核调度实体( Kernel Scheduling Entity,简称KSE)之间的对应关系上。
顾名思义,内核调度实体就是可以被内核的调度器调度的对象,因此称为内核级线程,是操作系统内核的最小调度单元。
综上所述,接下来,我们来详细讨论Java 领域中的线程映射理论模型。
1. 应用级线程模型
应用级线程模型主要是指(Application-Level Thread ,ALT),就是多个用户线程映射到同一个内核线程上,用户线程的创建、调度、同步的所有操作全部都是由用户空间的线程来完成的。
在Java领域中,应用级线程主要是指Java语言编写应用程序的Java 线程(Java Thread)和JVM虚拟机中JVM线程(JVM Thread)。
在应用级线程模型下,完全建立在用户空间的线程库上,不依赖于系统内核,用户线程的创建、同步、切换和销毁等操作完全在用户态执行,不需要切换到内核态。
其中,用户进程使用系统内核提供的接口——轻量级进程(Light Weight Process,LWP)来使用系统内核线程。
在此种线程模型下,由于一个用户线程对应一个LWP,因此某个LWP在调用过程中阻塞了不会影响整个进程的执行。
但是各种线程的操作都需要在用户态和内核态之间频繁切换,消耗太大,速度相对用户线程模型来说要慢。
2. 内核级线程模型
内核级线程模型主要是指(Kernel-Level Thread ,KLT),用户线程与内核线程建立了一对一的关系,即一个用户线程对应一个内核线程,内核负责每个线程的调度。
在Linux中,对于内核级线程,操作系统会为其创建一套栈:用户栈+内核栈,其中用户栈工作在用户态,内核栈工作在内核态,在发生系统调用时,线程的执行会从用户栈切换到内核栈。
在内核级线程模型下,完全依赖操作系统内核提供的内核线程来实现多线程。线程的切换调度由系统内核完成,系统内核负责将多个线程执行的任务映射到各个CPU中去执行。
其中,glibc中的pthread_create方法主要是创建一个OS内核级线程,我们不深入细节,主要是为该线程分配了栈资源;需要注意的是这个栈资源对于JVM而言是堆外内存,因此堆外内存的大小会影响JVM可以创建的线程数。
在JVM概念中,JVM栈用来执行Java方法,而本地方法栈用来执行native方法;但需要注意的是JVM只是在概念上区分了这两种栈,而并没有规定如何实现。
在HotSpot中,则是将JVM栈与本地方法栈二合一,使用核心线程的用户栈来实现(因为JVM栈和本地方法栈都是属于用户态的栈),即Java方法与native方法都在同一个用户栈中调用,而当发生系统调用时,再切换到核心栈运行。
这种设计的好处是线程的各种操作以及切换消耗很低;
但是线程的所有操作都需要在用户态实现,线程的调度实现起来异常复杂,并且系统内核对ULT无感知,如果线程阻塞则会引起整个进程的阻塞。
3. 混合两级线程模型
混合两级线程模型主要是指(Mixture-Level Thread ,MLT),是应用级线程模型和内核级线程模型等两种模型的混合版本,用户线程仍然是在用户态中创建,用户线程的创建、切换和销毁的消耗很低,用户线程的数量不受限制。
对于混合两级线程模型,是应用级线程模型和内核级线程模型等两种模型的混合版本,主要是充分吸收前面两种线程模型的优点且尽量规避它们的缺点。
在此模型下用户线程与内核线程是多对多(M : N,通常M >= N)的映射模型。主要是维护一个轻量级进程(Light Weight Process,LWP),在用户线程和内核线程之间充当桥梁,就可以使用操作系统提供的线程调度和处理器映射功能。
一般来说,Java虚拟机使用的线程模型是基于操作系统提供的原生线程模型来实现的,Windows系统和Linux系统都是使用的内核线程模型,而Solaris系统支持混合线程模型和内核线程模型两种实现。
还有,Java线程内存模型中,可以将虚拟机内存划分为两部分内存:主内存和线程工作内存,主内存是多个线程共享的内存,线程工作内存是每个线程独享的内存。方法区和堆内存就是主内存区域,而虚拟机栈、本地方法栈以及程序计数器则属于每个线程独享的工作内存。
Java内存模型规定所有成员变量都需要存储在主内存中,线程会在其工作内存中保存需要使用的成员变量的拷贝,线程对成员变量的操作(读取和赋值等)都是对其工作内存中的拷贝进行操作。各个线程之间不能互相访问工作内存,线程间变量的传递需要通过主内存来完成。
二. Java 领域中的I/O流模型
Java 领域中的I/O模型主要指Java 领域中的I/O模型大致可以分为字符流I/O模型,字节流I/O模型以及网络通信I/O模型。
在编程语言的I/O类库中常使用流(Stream)这个概念,代表了任何有能力产出数据的数据源对象或者是有能力接收数据的接收端对象。
流是个抽象的概念,是对输入输出设备的高度抽象,一般来说,编程语言都会涉及输入流和输出流两部分。
一定意义上来说,输入流可以看作一个输入通道,输出流可以看作一个输出通道,其中:
- 输入流是相对程序而言的,外部传入数据给程序需要借助输入流。
- 输出流是相对程序而言的,程序把数据传输到外部需要借助输出流。
由于,“流”模型屏蔽了实际的I/O设备中处理数据的细节,这就意味着我们只需要根据相关的基础API的功能和设计,便可实现数据处理和交互。
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分:
第一,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
其中,Java类库中的I/O类分成输入和输出两部分,主要是对应着实现我们与计算机操作交互时的一种规范和约束,但是对于不同的数据有着不同的实现。
综上所述,Java 领域中的I/O模型大致可以分为字符流I/O模型,字节流I/O模型以及网络通信I/O模型等3类。
1. 字节流I/O模型
字节流I/O模型是指在I/O操作,数据传输过程中,传输数据的最基本单位是字节的流,按照8位传输字节为单位输入/输出数据。
在Java 领域中,对字节流的类通常以stream结尾,对于字节数据的操作,提供了输入流(InputStream)、输出流(OutputStream)这样式的设计,是用于读取或写入字节的基础API,一般常用于操作类似文本或者图片文件。
2. 字符流I/O模型
字符流I/O模型是指在I/O操作,数据传输过程中,传输数据的最基本单位是字符的流,按照16位传输字符为单位输入/输出数据。
在Java 领域中,对字符流的类通常以reader和writer结尾,对于字节数据的操作,提供了输入流(Reader)、输出流(Writer)这样式的设计,是用于读取或写入字节的基础API,一般常用于类似从文件中读取或者写入文本信息。
3. 网络通信I/O模型
网络通信I/O模型是指java.net 下,提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 等IO 类库,实现网络通信同样是 IO 行为。
在Java领域中,NIO提供了与传统BIO模型中的Socket和ServerSocket相对应的SocketChannel和ServerSocketChannel两种不同的套接字通道实现。SocketChannel可以看作是 socket 的一个完善类,除了提供 Socket 的相关功能外,还提供了许多其他特性,如后面要讲到的向选择器注册的功能。
其中,新增的SocketChannel和ServerSocketChannel两种通道都支持阻塞和非阻塞两种模式。
三. Java 领域中的线程I/O参考模型
在Java领域中,我们对照线程概念(单线程和多线程)来说,可以分为Java 线程-阻塞I/O模型和Java 线程-非阻塞I/O模型两种。
由于阻塞与非阻塞主要是针对于应用程序对于系统函数调用角度来限定的,从阻塞与非阻塞的意义上来说,I/O可以分为阻塞I/O和非阻塞I/O两种大类。其中:
- 阻 塞 I/O : 进行I/O操作时,使当前线程进入阻塞状态,从具体应用程序来看,如果当一次I/O操作(Read/Write)没有就绪或者没有完成,则函数调用则会一直处于等待状态。
- 非阻塞I/O:进行I/O操作时,使当前线程不进入阻塞状态,从具体应用程序来看,如果当一次I/O操作(Read/Write)即使没有就绪或者没有完成,则函数调用立即返回结果,然后由应用程序轮询处理。
而同步与异步主要正针对应用程序对于系统函数调用后,其I/O操作中读/写(Read/Write)是由谁完成来限定的,I/O可以分为同步I/O和异步I/O两种大类。其中:
- 同步I/O: 进行I/O操作时,可以使当前线程进入进入阻塞或或非阻塞状态,从具体应用程序来看,如果当一次I/O操作(Read/Write)都是托管给应用程序来完成。
- 异步I/O: 进行I/O操作时,可以使当前线程进入进入非阻塞状态,从具体应用程序来看,如果当一次I/O操作(Read/Write)都是托管给操作系统来完成,完成后回调或者事件通知应用程序。
由此可见,按照这些个定义可以知道:
- 当程序在执行I/O操作时,经典的网络I/O操作(Read/Write)场景,主要可以分为阻塞I/O,非阻塞I/O,单线程以及多线程等场景。
- 异步I/O一定是非阻塞I/O,不存在是异步还阻塞的情况;同步I/O可能存在阻塞或或非阻塞的情况,还有可能是I/O线程多路复用的情况。
因此,我们可以对其线程I/O模型来说,I/O可以分为同步-阻塞I/O和同步-非阻塞I/O,以及异步I/O等3种,其中I/O多路复用属于同步-阻塞I/O。
综上所所述,,在Java领域中,我们对照线程概念(单线程和多线程)来说,可以分为Java 线程-阻塞I/O模型和Java 线程-非阻塞I/O模型两种。接下来,我们就详细地来探讨一下。
(一). Java 线程阻塞I/O模型
Java 线程-阻塞I/O模型主要可以分为单线程阻塞I/O模型和多线程阻塞I/O模型。
从一个服务器处理客户端连接来说,单线程情况下,一般都是以一个线程负责处理所有客户端连接的I/O操作(Read/Write)操作。
程序在执行I/O操作,一般都是从内核空间复制数据,但内核空间的数据可能需要很长的时间去准备数据,由此很有可能导致用户空间产生阻塞。
其产生阻塞的过程,主要如下:
- 应用程序发起I/O操作(Read/Write)之后,进入阻塞状态,然后提交给操作系统内核完成I/O操作。
- 当内核没有准备数据,需要不断从网络中读取数据,一旦准备就绪,则将数据复制到用户空间供应用程序使用。
- 应用程序从发起读取数据操作到继续执行后续处理的这段时间,便是我们说的阻塞状态。
由此可见,引入Java线程的概念,我们可以把Java 线程-阻塞I/O模型主要可以分为单线程阻塞I/O模型和多线程阻塞I/O模型。
1. 单线程阻塞I/O模型
单线程阻塞I/O模型主要是指对于多个客户端访问时,只能同时处理一个客户端的访问,并且在I/O操作上是阻塞的,线程会一直处于等待状态,直到当前线程中前一个客户端访问结束后,才继续开始下一个客户端的访问。
单线程阻塞I/O模型是最简单的服务器模型,是Java Developer面对网络编程最基础的模型。
由于对于多个客户端访问时,只能同时处理一个客户端的访问,并且在I/O操作上是阻塞的,线程会一直处于等待状态,直到当前线程中前一个客户端访问结束后,才继续开始下一个客户端的访问。
也就意味着,客户端的访问请求需要一个一个排队等待,只提供一问一答的服务机制。
这种模型的特点,主要在于单线程和阻塞I/O。其中:
- 单线程 :指的是服务器端只有一个线程处理客户端的请求,客户端连接与服务器端的处理线程比例关系为N:1,无法同时处理多个连接,只能串行方式连接处理。
- 阻塞I/O:服务器在I/O操作(Read/Write)操作时是阻塞的,主要表现在读取客户端数据时,需要等待客户端发送数据并且把操作系统内核中的数据复制到用户空间中的用户线程中,完成后才解除阻塞状态;同时,数据回写客户端要等待用户进程把数据写入到操作系统系统内核后才解除阻塞状态。
综上所述,单线程阻塞I/O模型最明显的特点就是服务机制简单,服务器的系统资源开销小,但是并发能力低,容错能力也低。
2. 多线程阻塞I/O模型
多线程阻塞I/O模型主要是指对于多个客户端访问时,利用多线程机制为每一个客户端的访问分配独立线程,实现同时处理,并且在I/O操作上是阻塞的,线程不会一直处于等待状态,而是并发处理客户端的请求访问。
多线程阻塞I/O模型是针对于单线程阻塞I/O模型的缺点,对其进行多线程化改进,使之能对于多个客户端的请求访问实现并发响应处理。
也就意味着,客户端的访问请求不需要一个一个排队等待,利用多线程机制为每一个客户端的访问分配独立线程。
这种模型的特点,主要在于多线程和阻塞I/O。其中:
- 多线程 :指的是服务器端至少有一个线程或者若干个线程处理客户端的请求,客户端连接与服务器端的处理线程比例关系为M:N,并发同时处理多个连接,可以并行方式连接处理。但客户端连接与服务器处理线程的关系是一对一的。
- 阻塞I/O:服务器在I/O操作(Read/Write)操作时是阻塞的,主要表现在读取客户端数据时,需要等待客户端发送数据并且把操作系统内核中的数据复制到用户空间中的用户线程中,完成后才解除阻塞状态;同时,数据回写客户端要等待用户进程把数据写入到操作系统系统内核后才解除阻塞状态。
综上所述,多线程阻塞I/O模型最明显的特点就是支持多个客户端并发响应,处理能力得到极大提高,有一定的并发能力和容错能力,但是服务器资源消耗较大,且多线程之间会产生线程切换成本,结构也比较复杂。
(二). Java 线程非阻塞I/O模型
Java 线程-非阻塞I/O模型主要可以分为应用层I/O多路复用模型和内核层I/O多路复用模型,以及内核回调事件驱动I/O模型。
从一个服务器处理客户端连接来说,多线程情况下,一般都是至少一个线程或者若干个线程负责处理所有客户端连接的I/O操作(Read/Write)操作。
非阻塞I/O模型与阻塞I/O模型,相同的地方在于是程序在执行I/O操作,一般都是从内核空间和应用空间复制数据。
与之不同的是,非阻塞I/O模型不会一直等到内核空间准备好数据,而是立即返回去做其他的事,因此不会产生阻塞。其中:
应用程序中的用户线程包含一个缓冲区,单个线程会不断轮询客户端,以及不断尝试进行I/O(Read/Write)操作。
一旦内核准好数据,应用程序中的用户线程就会把数据复制到用户空间使用。
由此可见,我们可以把Java 线程-非阻塞I/O模型主要可以分为应用层I/O多路复用模型和内核层I/O多路复用模型,以及内核回调事件驱动I/O模型。
1. 应用层I/O多路复用模型
应用层I/O多路复用模型主要是指当多个客户端向服务器发出请求时,服务器会将每一个客户端连接维护到一个socket列表中,应用程序中的用户线程会不断轮询sockst列表中的客户端连接请求访问,并尝试进行读写。
应用层I/O多路复用模型最大的特点就是,不论有多少个socket连接,都可以使用应用程序中的用户线程的一个线程来管理。
这个线程负责轮询socket列表,不断进行尝试进行I/O(Read/Write)操作,其中:
- I/O(Read)操作:如果成功读取数据,则对数据进行处理。反之,如果失败,则下一个循环再继续尝试。
- I/O(Write)操作:需要先尝试把数据写入指定的socket,直到调用成功结束。反之,如果失败,则下一个循环再继续尝试。
这种模型,虽然很好地利用了阻塞的时间,使得批处理能提升。但是由于不断轮询sockst列表,同时也需要处理数据的拼接。
2. 内核层I/O多路复用模型
内核层I/O多路复用模型主要是指当多个客户端向服务器发出请求时,服务器会将每一个客户端连接维护到一个socket列表中,操作系统内核不断轮询sockst列表,并把遍历结果组织罗列成一系列的事件,并驱动事件返回到应用层处理,最后托管给应用程序中的用户线程按照需要处理对应的事件对象。
内核层I/O多路复用模型与应用层I/O多路复用模型,最大的不同就是,轮询sockst列表是操作系统内核来完成的,有助于检测效率。
操作系统内核负责轮询socket列表的过程,其中:
- 首先,最主要的就是将所有连接的标记为可读事件和可写事件列表,最后传入到应用程序的用户空间处理。
- 然后,操作系统内核复制数据到应用层的用户空间的用户线程,会随着socket数量的增加,也会形成不小的开销。
- 另外,当活跃连接数比较少时,内核空间和用户空间会存在很多无效的数据副本,并且不管是否活跃,都会复制到用户空间的应用层。
3. 内核回调事件驱动I/O模型
内核回调事件驱动I/O模型主要是指当多个客户端向服务器发出请求时,服务器会将每一个客户端连接维护到一个socket列表中,操作系统内核不断轮询sockst列表,利用回调函数来检测socket列表是否可读可写的一种事件驱动I/O机制。
不论是内核层的轮询sockst列表,还是应用层的轮询sockst列表,通过循环遍历的方式来检测socket列表是否可读可写的操作方式,其效率都比较低效。
为了寻求一种高效的机制来优化循环遍历方式,因此,提出了会回调函数事件驱动机制。其中,主要是:
- 内核空间:当客户端往socket发送数据时,内核中socket都对应着一个回调函数,内核就可以直接从网卡中接收数据后,直接调用回调函数。
- 应用空间:回调函数会维护一个事件列表,应用层则获取事件即可以得到感兴趣的事件,然后进行后续操作。
一般来说,内核回调事件驱动的方式主要有2种:
- 第一种:利用可读列表(ReadList)和可写列表(WriteList)来标记读事件(Read-Event)/写事件(Write-Event)来进行I/O(Read/Write)操作。
- 第二种:利用在应用层中直接指定socket感兴趣的事件,通过维护事件列表(EventList)再来进行I/O(Read/Write)操作。
综上所述,这两种方式都是有操作系统内核维护客户端中的所有连接,再通过回调函数不断更新事件列表,应用空间中的应用层的用户线程只需要根据轮询遍历事件列表即可知道是否进行I/O(Read/Write)操作。
由此可见,这种方式极大地提高了检测效率,也增强了数据处理能力。
特别指出,在Java领域中,非阻塞I/O的实现完全是基于操作系统内核的非阻塞I/O,Java把操作系统中的非阻塞I/O的差异最大限度的屏蔽并提供了统一的API,JDK自己会帮助我们选择非阻塞I/O的实现方式。
一般来说,在Linux系统中,只要支持epoll,JDK会优先选择epoll来实现Java的非阻塞I/O。
(三). Java 线程异步I/O模型
Java 线程异步I/O模型主要是指异步非阻塞模型(AIO模型), 需要操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作。
对于非阻塞I/O模型(NIO)来说,异步I/O模型的工作机制来说,与之不同的是采用“订阅(Subscribe)-通知(Notification)”模式,主要如下:
- 订阅(Subscribe): 用户线程通过操作系统调用,向内核注册某个IO操作后,即应用程序向操作系统注册IO监听,然后继续做自己的事情。
- 通知(Notification):当操作系统发生IO事件,并且准备好数据后,即内核在整个IO操作(包括数据准备、数据复制)完成后,再主动通知应用程序,触发相应的函数,执行后续的业务操作。
在异步IO模型中,整个内核的数据处理过程中,包括内核将数据从网络物理设备(网卡)读取到内核缓存区、将内核缓冲区的数据复制到用户缓冲区,用户程序都不需要阻塞。
由此可见,异步I/O模型(AIO模型)需要依赖操作系统的支持,CPU资源开销比较大,最大的特性是异步能力,对socket和I/O起作用,适合连接数目比较多以及连接时间长的系统架构。
一般来说,在操作系统里,异步IO是指Windows系统的IOCP(Input/Output Completion Port),或者C++的网络库asio。
在Linux系统中,aio虽然是异步IO模型的具体实现,但是由于不成熟,现在大部分还是依据是否支持epoll等,来模拟和封装epoll实现的。
在Java领域中,支持异步I/O模型(AIO模型)是Jdk 1.7版本开始的,基于CompletionHandler接口来实现操作完成回调,其中分别有三个新的异步通道,AsynchronousFileChannel,AsynchronousSocketChannel和AsynchronousServerSocketChannel。
但是,对于支持异步编程模式是在Jdk 1.5版本就已经存在,最典型的就是基于Future模型实现的Executor和FutureTask。
由于Future模型存在一定的局限性,在JDK 1.8 之后,对Future的扩展和增强实现又新增了一个CompletableFuture。
由此可见,在Java领域中,对于异步I/O模型提供了异步文件通道(AsynchronousFileChannel)和异步套接字通道(AsynchronousSocketChannel和AsynchronousServerSocketChannel)的实现。 其中:
- 首先,对于异步文件通道的实现,提供两种方式获取操作结果:
- 通过java.util.concurrent.Future类來表示异步操作的结果:
- 在执行异步操作的时候传入一个java.nio.channels.CompletionHandler接口的实现类作为操作完成的回调。
- 其次,对于异步套接字通道的是实现:
- 异步Socket Channel是被动执行对象,不需要像 NIO编程那样创建一个独立的 I/O线程来处理读写操作。
- 对于AsynchronousServerSocketChannel和AsynchronousSocketChannel 都由JDK底层的线程池负责回调并驱动读写操作。
- 异步套接字通道是真正的异步非阻塞I/O,它对应UNIX网络编程中的事件驱动I/O (AIO),它不需要通过多路复用器(Selector)对注册的通道进行轮询操作即可实现异步读写, 从而简化了 NIO的编程模型。
综上所述,对于在Java领域中的异步IO模型,我们在使用的时候,需要依据实际业务场景需要而进行选择和考量。
⚠️[特别注意]:
[1].IOCP: 输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步I/O操作的应用程序编程接口。
[2].epoll: Linux系统中I/O多路复用实现方式的一种,主要是(select,poll,epoll)。都是同步I/O,同时也是阻塞I/O。
[3].Future: 属于Java JDK 1.5 版本支持的编程异步模型,在包java.util.concurrent.下面。
[4].CompletionHandler: 属于Java JDK 1.7 版本支持的编程异步I/O模型,在包java.nio.channels.下面。
[5].CompletableFuture: 属于Java JDK 1.8 版本对Future的扩展和增强实现编程异步I/O模型,在java.util.concurrent.下面。
四. Java 领域中的线程设计模型
Java 领域中的线程设计模型最典型就是基于Reactor模式设计的非阻塞I/O模型和 基于Proactor 模式设计的异步I/O模型和基于Promise模式的Promise模型。
在Java领域中,对于并发编程的支持,不仅提供了线程机制,也引入了多线程机制,还有许多同步和异步的实现。
单从设计原则和实现来说,都采用了许多设计模式,其中多线程机制最常见的就是线程池模式。
对于非阻塞I/O模型,主要采用基于Reactor模式设计,而异步I/O模型,主要采用基于Proactor 模式设计。
当然,还有基于Promise模式的异步编程模型,不过这算是一个特例。
综上所述,Java 领域中的线程设计模型最典型就是基于Reactor模式设计的非阻塞I/O模型和 基于Proactor 模式设计的异步I/O模型和基于Promise模式的Promise模型。
1. 多线程非阻塞I/O模型
多线程非阻塞I/O模型是针对于多线程机制而设计的,根据CPU的数量来创建线程数,并且能够让多个线程并行执行的非阻塞I/O模型。
现在的计算机大多数都是多核CPU的,而且操作系统都提供了多线程机制,但是我们也没有办法抹掉单线程的优势。
单线程最大的优势就是一个CPU只负责一个线程,对于多线程中出现的疑难杂症,它都可以避免,而且编码简单。
在一个线程对应一个CPU的情况下,如果多核计算机中 只执行一个线程,那么就只有一个CPU工作,无法充分发挥CPU和优势,且资源也无法充分利用。
因此,我们的程序则可以根据CPU的数量来创建线程数,N个CPU对应多个N个线程,便可以充分利用多个CPU。同时也保持了单线程的特点,相当于多个线程并行执行而不是并发执行。
在多核计算机时代,多线程和非阻塞都是提升服务器处理性能的利器。一般我们都是将客户端连接按照分组分配给至少一个线程或者若干线程,每个线程负责处理对应组的连接。
在Java领域中,最常见的多线程阻塞I/O模型就是基于Reactor模式的Reactor模型。
2. 基于Reactor模式的Reactor模型
Reactor模型是指在事件驱动的思想上,基于Reactor的工作模式而设计的非阻塞I/O模型(NIO 模型)。一定程度上来说,可以说是主动模式I/O模型。
对于Reactor模式,我特意在网上查询了一下资料,查询的结果都是无疾而终,解释更是五花八门的。最后,参考一些资料整理得出结论。
引用一下Doug Lea大师在文章“Scalable IO in Java”中对Reactor模式的定义:
Reactor模式由Reactor线程、Handlers处理器两大角色组成,两大角色的职责分别如下:
- Reactor线程的职责:负责响应IO事件,并且分发到Handlers处理器。
- Handlers处理器的职责:非阻塞的执行业务处理逻辑。
个人理解,Reactor模式是指在事件驱动的思想上,通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。其中,基本思想有两个:
基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。
总体来说,Reactor模式有点类似事件驱动模式。在事件驱动模式中,当有事件触发时,事件源会将事件分发到Handler(处理器),由Handler负责事件处理。Reactor模式中的反应器角色类似于事件驱动模式中的事件分发器(Dispatcher)角色。
具体来说,在Reactor模式中有Reactor和Handler两个重要的组件:
- Reactor:负责查询IO事件,当检测到一个IO事件时将其发送给相应的Handler处理器去处理。其中,IO事件就是NIO中选择器查询出来的通道IO事件。
- Handler:与IO事件(或者选择键)绑定,负责IO事件的处理,完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写到通道等。
从Reactor的代码实现上来看,实现Reactor模式需要实现以下几个类:
- EventHandler:事件处理器,可以根据事件的不同状态创建处理不同状态的处理器。
- Handler:可以理解为事件,在网络编程中就是一个Socket,在数据库操作中就是一个DBConnection。
- InitiationDispatcher:用于管理EventHandler,分发event的容器,也是一个事件处理调度器,Tomcat的Dispatcher就是一个很好的实现,用于接收到网络请求后进行第一步的任务分发,分发给相应的处理器去异步处理,来保证吞吐量。
- Demultiplexer:阻塞等待一系列的Handle中的事件到来,如果阻塞等待返回,即表示在返回的Handler中可以不阻塞的执行返回的事件类型。这个模块一般使用操作系统的select来实现。在Java NIO中用Selector来封装,当Selector.select()返回时,可以调用Selector的selectedKeys()方法获取Set,一个SelectionKey表达一个有事件发生的Channel以及该Channel上的事件类型。
接下来,我们便从具体的常见来一一探讨一下Reactor模式下的各种线程模型。
从一定意义上来说, 基于Reactor模式的Reactor模型是非阻塞I/O模型。
2.0. 单Reactor单线程模型
单Reactor单线程模型主要是指将服务端的整个处理事件分为若干个事件,Reactor 通过事件检测机制把若干个事件Handler分发给不同的处理器去处理。简单来说,Reactor和Handle都放入一个线程中执行。
在实际工作中,若干个客户端连接访问服务端,假如会有接收事件(Accept Event),读事件(Read Event),写事件(Write Event),以及执行事件(Process Event)等,其中,:
- Reactor 模型则把这些事件都分发到各自的处理器。
- 整个过程,只要有等待处理的事件存在,Reactor 线程模型不断往后续执行,而且不会阻塞,所以效率很高。
由此可见,单Reactor单线程模型具有简单,没有多线程,没有进程通信。但是从性能上来说,无法发挥多核的极致,一个Handler卡死,导致当前进程无法使用,IO和CPU不匹配。
在Java领域中,对于一个单Reactor单线程模型的实现,主要需用到SelectionKey(选择键)的几个重要的成员方法:
- void attach(Object o):将对象附加到选择键。可以将任何Java POJO对象作为附件添加到SelectionKey实例。
- Object attachment():从选择键获取附加对象。与attach(Object o)是配套使用的,其作用是取出之前通过attach(Object o)方法添加到SelectionKey实例的附加对象。这个方法同样非常重要,当IO事件发生时,选择键将被select方法查询出来,可以直接将选择键的附件对象取出。
因此,在Reactor模式实现中,通过attachment()方法所取出的是之前通过attach(Object o)方法绑定的Handler实例,然后通过该Handler实例完成相应的传输处理。
综上所述,在Reactor模式中,需要将attach和attachment结合使用:
- 在选择键注册完成之后调用attach()方法,将Handler实例绑定到选择键。
- 当IO事件发生时调用attachment()方法,可以从选择键取出Handler实例,将事件分发到Handler处理器中完成业务处理。
从一定意义上来说,单Reactor单线程模型是基于单线程的Reactor模式。
2.1. 单Reactor多线程模型
单Reactor多线程模型是指采用多线程机制,将服务端的整个处理事件分为若干个事件,Reactor 通过事件检测机制把若干个事件Handler分发给不同的处理器去处理。
单Reactor多线程模型是基于单线程的Reactor模式的结构,将其利用线程池机制改进多线程模式。
相当于,Reactor对于接收事件(Accept Event),读事件(Read Event),写事件(Write Event),以及执行事件(Process Event)等分发到各自的处理器时:
- 首先,对于耗时的任务引入线程池机制,事件处理器自己不执行任务,而是交给线程池来托管,避免了耗时的操作。
- 其次,虽然Reactor只有一个线程,但是也保证了Reactor的高效。
在Java领域中,对于一个单Reactor多线程模型的实现,主要可以从升级Handler和升级Reactor来改进:
- 升级Handler:既要使用多线程,又要尽可能高效率,则可以考虑使用线程池。
- 升级Reactor:可以考虑引入多个Selector(选择器),提升选择大量通道的能力。
总体来说,多线程版本的Reactor模式大致如下:
- 将负责数据传输处理的IOHandler处理器的执行放入独立的线程池中。这样,业务处理线程与负责新连接监听的反应器线程就能相互隔离,避免服务器的连接监听受到阻塞。
- 如果服务器为多核的CPU,可以将反应器线程拆分为多个子反应器(SubReactor)线程;同时,引入多个选择器,并且为每一个SubReactor引入一个线程,一个线程负责一个选择器的事件轮询。这样充分释放了系统资源的能力,也大大提升了反应器管理大量连接或者监听大量传输通道的能力。
由此可见,单Reactor单线程模型具有充分利用的CPU的特点,但是进程通信,复杂,Reactor承放了太多业务,高并发下可能成为性能瓶颈。
从一定意义上来说,单Reactor多线程模型是基于多线程的Reactor模式。
2.2. 主从Reactor多线程模型
主从Reactor多线程模型采用多个Reactor 的机制,将服务端的整个处理事件分为若干个事件,Reactor 通过事件检测机制把若干个事件Handler分发给不同的处理器去处理。每一个Reactor对应着一个线程。
采用多个Reactor实例的机制:
-主Reactor:负责建立连接,建立连接后的句柄丢给从Reactor。
-从Reactor: 负责监听所有事件进行处理。
相当于,Reactor对于接收事件(Accept Event),读事件(Read Event),写事件(Write Event),以及执行事件(Process Event)等分发到各自的处理器时:
- 由于接收事件是针对于服务器端而言的,连接接收的工作统一由连接处理器完成,则连接处理器把接收到的客户端连接均匀分配到所有的实例中去。
- 每一个Reactor 实例负责处理分配到该Reactor 实例的客户端连接,完成连接时的读写操作和其他逻辑操作。
由此可见,主从Reactor多线程模型中Reactor实例职责分工明确,具有一定分摊压力的效能,我们常见Nginx/Netty/Memcached等就是采用这中模型。
从一定意义上来说,主从Reactor多线程模型是基于多实例的Reactor模式。
2. 基于Proactor模式的Proactor模型
Proactor 模型是指在事件驱动的思想上,基于Proactor 的工作模式而设计的异步I/O模型(AIO 模型),一定程度上来说,可以说是被动模式I/O模型。
无论是 Reactor,还是 Proactor,都是一种基于事件分发的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。
相对于Reactor来说,Proactor 模型处理读取操作的主要流程:
- 应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件。
- 事件分离器等待读取操作完成事件。
- 在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作,并将读取的内容放入用户传递过来的缓存区中。
- 事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。
由此可见,Proactor中写入操作和读取操作基本一致,只不过监听的事件是写入完成事件而已。
在Java领域中,异步IO(AIO)是在Java JDK 7 之后引入的,都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作。
其中,从对于Proactor模式的设计来看,Proactor 模式的工作流程:
- Proactor Initiator: 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核。
- Asynchronous Operation Processor :负责处理注册请求,并处理 I/O 操作。
- Asynchronous Operation Processor :完成 I/O 操作后通知 Proactor。
- Proactor :根据不同的事件类型回调不同的 Handler 进行业务处理。
- Handler: 完成业务处理,其中是通过CompletionHandler表示完成后处理器。
从一定意义上来说, 基于Proactor模式的Proactor模型是异步IO。
3. 基于Promise模式的Promise模型
Promise模型是基于Promise异步编程模式,客户端代码调用某个异步方法所得到的返回值仅是一个凭据对象,凭借该对象,客户端代码可以获取异步方法相应的真正任务的执行结果的一种模型。
Promise 模式是开始一个任务的执行,并得到一个用于获取该任务执行结果的凭据对象,而不必等待该任务执行完毕就可以继续执行其他操作。
从Promise 模式的工作机制来看,主要如下:
- 当我们开始一个任务的执行,并得到一个用于获取该任务执行结果的凭据对象,而不必等待该任务执行完毕就可以继续执行其他操作。
- 等到我们需要该任务的执行结果时,再调用凭据对象的相关方法来获取。
由此可以确定的是,Promise 模式既发挥了异步编程的优势——增加系统的并发性,减少不必要的等待,又保持了同步编程的简单性。
从Promise 模式技术实现来说,主要职责角色如下:
- Promisor:负责对外暴露可以返回 Promise 对象的异步方法,并启动异步任务的执行,主要利用compute方法启动异步任务的执行,并返回用于获取异步任务执行结果的凭据对象。
- Promise :负责包装异步任务处理结果的凭据对象。负责检测异步任务是否处理完毕、返回和存储异步任务处理结果。
- Result :负责表示异步任务处理结果。具体类型由应用决定。
- TaskExecutor:负责真正执行异步任务所代表的计算,并将其计算结果设置到相应的 Promise 实例对象。
在Java领域中,最典型的就是基于Future模型实现的Executor和FutureTask。
由于Future模型存在一定的局限性,在JDK 1.8 之后,对Future的扩展和增强实现又新增了一个CompletableFuture。
当然,Promise模式在前端技术JavaScript中Promise有具体的体现,而且随着前端技术的发展日趋成熟,对于这种模式的运用早已日臻化境。
写在最后
在Java领域中,Java领域中的线程主要分为Java层线程(Java Thread) ,JVM层线程(JVM Thread),操作系统层线程(Kernel Thread)。
从Java线程映射类型来看,主要有线程一对一(1:1)映射,线程多对多(M:1)映射,线程多对多(M:N)映射等关系。
因此,Java 领域中的线程映射模型主要有内核级线程模型(Kernel-Level Thread ,KLT)、应用级线程模型(Application-Level Thread ,ALT)、混合两级线程模型(Mixture-Level Thread ,MLT)等3种模型。
在Java领域中,我们对照线程概念(单线程和多线程)来说,可以分为Java 线程-阻塞I/O模型和Java 线程-非阻塞I/O模型两种。其中,
- Java 线程-阻塞I/O模型: 主要可以分为单线程阻塞I/O模型和多线程阻塞I/O模型。
- Java 线程-非阻塞I/O模型:主要可以分为应用层I/O多路复用模型和内核层I/O多路复用模型,以及内核回调事件驱动I/O模型。
特别指出,在Java领域中,非阻塞I/O的实现完全是基于操作系统内核的非阻塞I/O,JDK会依据操作系统内核支持的非阻塞I/O方式来帮助我们选择实现方式。
综上所述,在Java领域中,并发编程中的线程机制以及多线程的控制,在实际开发过程中,需要依据实际业务场景来考虑和衡量,这需要我们对其有更深的研究,才可以得心应手。
在讨论编程模型的时候,我们提到了像基于Promise模式和基于Thread Pool 模式的这样的设计模式的概念,这也是一个我们比较容易忽略的概念,如果有兴趣的话,可以自行进行查询相关资料进行了解。
最后,祝福大家在Java并发编程的“看山是山,看山不是山,看山还是山”的修行之路上,“拨开云雾见天日,守得云开见月明”,早日达到有迹可循到有迹可寻的目标!
版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。