这篇 Java IO,讲的实在是太好了

JavaIO是一个庞大的知识体系,很多人学着学着就会学懵了,包括我在内也是如此,所以本文将会从Java的BIO开始,一步一步深入学习,引出之后出现的NIO技术,对比NIO与BIO的区别,然后对NIO中重要的三个组成部分进行讲解(缓冲区、通道、选择器),最后实现一个简易的客户端与服务器通信功能。

传统的BIO

JavaIO流是一个庞大的生态环境,其内部提供了很多不同的输入流和输出流,细分下去还有字节流和字符流,甚至还有缓冲流提高IO性能,转换流将字节流转换为字符流······看到这些就已经对IO产生恐惧了,在日常开发中少不了对文件的IO操作,虽然apache已经提供了CommonsIO这种封装好的组件,但面对特殊场景时,我们仍需要自己去封装一个高性能的文件IO工具类,本文将会解析JavaIO中涉及到的各个类,以及讲解如何正确、高效地使用它们。

BIONIO和AIO的区别

我们会以一个经典的烧开水的例子通俗地讲解它们之间的区别

类型烧开水BIO一直监测着某个水壶,该水壶烧开水后再监测下一个水壶NIO每隔一段时间就看看所有水壶的状态,哪个水壶烧开水就去处理哪个水壶AIO不用监测水壶,每个水壶烧开水后都会主动通知线程说:“我的水烧开了,来处理我吧”

BIO(同步阻塞I/O)

这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是,小菠萝一直看着着这个水壶,直到这个水壶烧开,才去处理下一个水壶。线程在等待水壶烧开的时间段什么都没有做。

NIO(同步非阻塞I/O)

还拿烧开水来说,NIO的做法是小菠萝一边玩着手机,每隔一段时间就看一看每个水壶的状态,看看是否有水壶的状态发生了改变,如果某个水壶烧开了,可以先处理那个水壶,然后继续玩手机,继续隔一段时间又看看每个水壶的状态。

AIO(异步非阻塞I/O)

小菠萝觉得每隔一段时间就去看一看水壶太费劲了,于是购买了一批烧开水时可以哔哔响的水壶,于是开始烧水后,小菠萝就直接去客厅玩手机了,水烧开时,就发出“哔哔”的响声,通知小菠萝来关掉水壶。

什么是流

知识科普:我们知道任何一个文件都是以二进制形式存在于设备中,计算机就只有0和1,你能看见的东西全部都是由这两个数字组成,你看这篇文章时,这篇文章也是由01组成,只不过这些二进制串经过各种转换演变成一个个文字、一张张图片跃然屏幕上。

而流就是将这些二进制串在各种设备之间进行传输,如果你觉得有些抽象,我举个例子就会好理解一些:

IO流读写数据的特点:

顺序读写。读写数据时,大部分情况下都是按照顺序读写,读取时从文件开头的第一个字节到最后一个字节,写出时也是也如此(RandomAccessFile可以实现随机读写)

字节数组。读写数据时本质上都是对字节数组做读取和写出操作,即使是字符流,也是在字节流基础上转化为一个个字符,所以字节数组是IO流读写数据的本质。

流的分类

根据数据流向不同分类:输入流和输出流

输入流:从磁盘或者其它设备中将数据输入到进程中

输出流:将进程中的数据输出到磁盘或其它设备上保存

图示中的硬盘只是其中一种设备,还有非常多的设备都可以应用在IO流中,例如:打印机、硬盘、显示器、手机······

根据处理数据的基本单位不同分类:字节流和字符流

字节流:以字节(8bit)为单位做数据的传输

字符流:以字符为单位(1字符=2字节)做数据的传输

面对字节流和字符流,很多读者都有疑惑:什么时候需要用字节流,什么时候又要用字符流?

我这里做一个简单的概括,你可以按照这个标准去使用:

字符流只针对字符数据进行传输,所以如果是文本数据,优先采用字符流传输;除此之外,其它类型的数据(图片、音频等),最好还是以字节流传输。

根据这两种不同的分类,我们就可以做出下面这个表格,里面包含了IO中最核心的4个顶层抽象类:

数据流向/数据类型字节流字符流输入流InputStreamReader输出流OutputStreamWriter

现在看IO是不是有一些思路了,不会觉得很混乱了,我们来看这四个类下的所有成员。

[来自于cxuan的《Java基础核心总结》]

看到这么多的类是不是又开始觉得混乱了,不要慌,字节流和字符流下的输入流和输出流大部分都是一一对应的,有了上面的表格支撑,我们不需要再担心看见某个类会懵逼的情况了。

看到Stream就知道是字节流,看到Reader/Writer就知道是字符流。

这里还要额外补充一点:JavaIO提供了字节流转换为字符流的转换类,称为转换流。

转换流/数据类型字节流与字符流之间的转换(输入)字节流=字符流InputStreamReader(输出)字符流=字节流OutputStreamWriter

注意字节流与字符流之间的转换是有严格定义的:

输入流:可以将字节流=字符流

输出流:可以将字符流=字节流

为什么在输入流不能字符流=字节流,输出流不能字节流=字符流?

节点流和处理流

在这里需要额外插入一个小节讲解节点流和处理流。

节点流:节点流是真正传输数据的流对象,用于向特定的一个地方(节点)读写数据,称为节点流。例如FileInputStream

处理流:处理流是对节点流的封装,使用外层的处理流读写数据,本质上是利用节点流的功能,外层的处理流可以提供额外的功能。处理流的基类都是以Filter开头。

上图将ByteArrayInputStream封装成DataInputStream,可以将输入的字节数组转换为对应数据类型的数据。例如希望读入int类型数据,就会以2个字节为单位转换为一个数字。

JavaIO的核心类File

Java提供了File类,它指向计算机操作系统中的文件和目录,通过该类只能访问文件和目录,无法访问内容。它内部主要提供了3种操作:

访问文件的属性:绝对路径、相对路径、文件名······

文件检测:是否文件、是否目录、文件是否存在、文件的读/写/执行权限······

操作文件:创建目录、创建文件、删除文件······

上面举例的操作都是在开发中非常常用的,File类远不止这些操作,更多的操作可以直接去API文档中根据需求查找。

访问文件的属性:

API功能StringgetAbsolutePath()返回该文件处于系统中的绝对路径名StringgetPath()返回该文件的相对路径,通常与newFile()传入的路径相同StringgetName()返回该文件的文件名

文件检测:

API功能booleanisFIle()校验该路径指向是否一个文件booleanisDirectory()校验该路径指向是否一个目录booleanisExist()校验该路径指向的文件/目录是否存在booleancanWrite()校验该文件是否可写booleancanRead()校验该文件是否可读booleancanExecute()校验该文件/目录是否可以被执行

操作文件:

API功能mkdirs()递归创建多个文件夹,路径中间有可能某些文件夹不存在createNewFile()创建新文件,它是一个原子操作,有两步:检查文件是否存在、创建新文件delete()删除文件或目录,删除目录时必须保证该目录为空

多了解一些

文件的读/写/执行权限,在Windows中通常表现不出来,而在Linux中可以很好地体现这一点,原因是Linux有严格的用户权限分组,不同分组下的用户对文件有不同的操作权限,所以这些方法在Linux下会比在Windows下更好理解。下图是redis文件夹中的一些文件的详细信息,被红框标注的是不同用户的执行权限:

r(Read):代表该文件可以被当前用户读,操作权限的序号是4

w(Write):代表该文件可以被当前用户写,操作权限的序号是2

x(Execute):该文件可以被当前用户执行,操作权限的序号是1

rootroot分别代表:当前文件的所有者,当前文件所属的用户分组。Linux下文件的操作权限分为三种用户:

文件所有者:拥有的权限是红框中的前三个字母,-代表没有某个权限

文件所在组的所有用户:拥有的权限是红框中的中间三个字母

其它组的所有用户:拥有的权限是红框中的最后三个字母

JavaIO流对象

回顾流的分类有2种:

根据数据流向分为输入流和输出流

根据数据类型分为字节流和字符流

所以,本小节将以字节流和字符流作为主要分割点,在其内部再细分为输入流和输出流进行讲解。

字节流对象

字节流对象大部分输入流和输出流都是成双成对地出现,所以学习的时候可以将输入流和输出流一一对应的流对象关联起来,输入流和输出流只是数据流向不同,而处理数据的方式可以是相同的。

注意不要认为用什么流读入数据,就需要用对应的流写出数据,在Java中没有这么规定,下图只是各个对象之间的一个对应关系,不是两个类使用时必须强制关联使用。

InputStream

InputStream是字节输入流的抽象基类,提供了通用的读方法,让子类使用或重写它们。下面是InputStream常用的重要的方法。

重要方法功能publicabstractintread()从输入流中读取下一个字节,读到尾部时返回-1publicintread(byteb[])从输入流中读取长度为个字节放入字节数组b中publicintread(byteb[],intoff,intlen)从输入流中读取指定范围的字节数据放入字节数组b中publicvoidclose()关闭此输入流并释放与该输入流相关的所有资源

还有其它一些不太常用的方法,我也列出来了。

其它方法功能publiclongskip(longn)跳过接下来的n个字节,返回实际上跳过的字节数publiclongavailable()返回下一次可读取(跳过)且不会被方法阻塞的字节数的估计值publicsynchronizedvoidmark(intreadlimit)标记此输入流的当前位置,对reset()方法的后续调用将会重新定位在mark()标记的位置,可以重新读取相同的字节publicbooleanmarkSupported()判断该输入流是否支持mark()和reset()方法,即能否重复读取字节publicsynchronizedvoidreset()将流的位置重新定位在最后一次调用mark()方法时的位置

(1)ByteArrayInputStream

ByteArrayInputStream内部包含一个buf字节数组缓冲区,该缓冲区可以从流中读取的字节数,使用pos指针指向读取下一个字节的下标位置,内部还维护了一个count属性,代表能够读取count个字节。

(2)FileInputStream

文件输入流,从文件中读入字节,通常对文件的拷贝、移动等操作,可以使用该输入流把文件的字节读入内存中,然后再利用输出流输出到指定的位置上。

(3)PipedInputStream

管道输入流,它与PipedOutputStream成对出现,可以实现多线程中的管道通信。PipedOutputStream中指定与特定的PipedInputStream连接,PipedInputStream也需要指定特定的PipedOutputStream连接,之后输出流不断地往输入流的buffer缓冲区写数据,而输入流可以从缓冲区中读取数据。

(4)ObjectInputStream

对象输入流,用于对象的反序列化,将读入的字节数据反序列化为一个对象,实现对象的持久化存储。

(5)PushBackInputStream

它是FilterInputStream的子类,是一个处理流,它内部维护了一个缓冲数组buf。

在读入字节的过程中可以将读取到的字节数据回退给缓冲区中保存,下次可以再次从缓冲区中读出该字节数据。所以PushBackInputStream允许多次读取输入流的字节数据,只要将读到的字节放回缓冲区即可。

需要注意的是如果回推字节时,如果缓冲区已满,会抛出IOException异常。

它的应用场景:对数据进行分类规整。

假如一个文件中存储了数字和字母两种类型的数据,我们需要将它们交给两种线程各自去收集自己负责的数据,如果采用传统的做法,把所有的数据全部读入内存中,再将数据进行分离,面对大文件的情况下,例如1G、2G,传统的输入流在读入数组后,由于没有缓冲区,只能对数据进行抛弃,这样每个线程都要读一遍文件。

使用PushBackInputStream可以让一个专门的线程读取文件,唤醒不同的线程读取字符:

第一次读取缓冲区的数据,判断该数据由哪些线程读取

回退数据,唤醒对应的线程读取数据

重复前两步

关闭输入流

到这里,你是否会想到AQS的Condition等待队列,多个线程可以在不同的条件上等待被唤醒。

(6)BufferedInputStream

缓冲流,它是一种处理流,对节点流进行封装并增强,其内部拥有一个buffer缓冲区,用于缓存所有读入的字节,当缓冲区满时,才会将所有字节发送给客户端读取,而不是每次都只发送一部分数据,提高了效率。

(7)DataInputStream

数据输入流,它同样是一种处理流,对节点流进行封装后,能够在内部对读入的字节转换为对应的Java基本数据类型。

(8)SequenceInputStream

将两个或多个输入流看作是一个输入流依次读取,该类的存在与否并不影响整个IO生态,在程序中也能够做到这种效果

(9)StringBufferInputStream

将字符串中每个字符的低8位转换为字节读入到字节数组中,目前已过期

InputStream总结:

InputStream是所有输入字节流的抽象基类

ByteArrayInputStream和FileInputStream是两种基本的节点流,他们分别从字节数组和本地文件中读取数据

DataInputStream、BufferedInputStream和PushBackInputStream都是处理流,对基本的节点流进行封装并增强

PipiedInputStream用于多线程通信,可以与其它线程公用一个管道,读取管道中的数据。

ObjectInputStream用于对象的反序列化,将对象的字节数据读入内存中,通过该流对象可以将字节数据转换成对应的对象

OutputStream

OutputStream是字节输出流的抽象基类,提供了通用的写方法,让继承的子类重写和复用。

方法功能publicabstractvoidwrite(intb)将指定的字节写出到输出流,写入的字节是参数b的低8位publicvoidwrite(byteb[])将指定字节数组中的所有字节写入到输出流当中publicvoidwrite(byteb[],intoff,intlen)指定写入的起始位置offer,字节数为len的字节数组写入到输出流当中publicvoidflush()刷新此输出流,并强制写出所有缓冲的输出字节到指定位置,每次写完都要调用publicvoidclose()关闭此输出流并释放与此流关联的所有系统资源

OutputStream中大多数的类和InputStream是对应的,只不过数据的流向不同而已。从上面的图可以看出:

OutputStream是所有输出字节流的抽象基类

ByteArrayOutputStream和FileOutputStream是两种基本的节点流,它们分别向字节数组和本地文件写出数据

DataOutputStream、BufferedOutputStream是处理流,前者可以将字节数据转换成基本数据类型写出到文件中;后者是缓冲字节数组,只有在缓冲区满时,才会将所有的字节写出到目的地,减少了IO次数。

PipedOutputStream用于多线程通信,可以和其它线程共用一个管道,向管道中写入数据

ObjectOutputStream用于对象的序列化,将对象转换成字节数组后,将所有的字节都写入到指定位置中

PrintStream在OutputStream基础之上提供了增强的功能,即可以方便地输出各种类型的数据(而不仅限于byte型)的格式化表示形式,且PrintStream的方法从不抛出IOEception,其原理是写出时将各个数据类型的数据统一转换为String类型,我会在讲解完

字符流对象

字符流对象也会有对应关系,大多数的类可以认为是操作的数据从字节数组变为字符,类的功能和字节流对象是相似的。

Reader

Reader是字符输入流的抽象基类,它内部的重要方法如下所示。

重要方法方法功能publicintread()将读入的字符存入指定的字符缓冲区中publicintread()读取一个字符publicintread(charcbuf[])读入字符放入整个字符数组中abstractpublicintread(charcbuf[],intoff,intlen)将字符读入字符数组中的指定范围中

还有其它一些额外的方法,与字节输入流基类提供的方法是相同的,只是作用的对象不再是字节,而是字符。

Reader是所有字符输入流的抽象基类

CharArrayReader和StringReader是两种基本的节点流,它们分别从读取字符数组和字符串数据,StringReader内部是一个String变量值,通过遍历该变量的字符,实现读取字符串,本质上也是在读取字符数组

PipedReader用于多线程中的通信,从共用地管道中读取字符数据

BufferedReader是字符输入缓冲流,将读入的数据放入字符缓冲区中,实现高效地读取字符

InputStreamReader是一种转换流,可以实现从字节流转换为字符流,将字节数据转换为字符

Writer

Reader是字符输出流的抽象基类,它内部的重要方法如下所示。

重要方法方法功能publicvoidwrite(charcbuf[])将cbuf字符数组写出到输出流abstractpublicvoidwrite(charcbuf[],intoff,intlen)将指定范围的cbuf字符数组写出到输出流publicvoidwrite(Stringstr)将字符串str写出到输出流,str内部也是字符数组publicvoidwrite(Stringstr,intoff,intlen)将字符串str的某一部分写出到输出流abstractpublicvoidflush()刷新,如果数据保存在缓冲区,调用该方法才会真正写出到指定位置abstractpublicvoidclose()关闭流对象,每次IO执行完毕后都需要关闭流对象,释放系统资源

Writer是所有的输出字符流的抽象基类

CharArrayWriter、StringWriter是两种基本的节点流,它们分别向Char数组、字符串中写入数据。StringWriter内部保存了StringBuffer对象,可以实现字符串的动态增长

PipedWriter可以向共用的管道中写入字符数据,给其它线程读取。

BufferedWriter是缓冲输出流,可以将写出的数据缓存起来,缓冲区满时再调用flush()写出数据,减少IO次数。

PrintWriter和PrintStream类似,功能和使用也非常相似,只是写出的数据是字符而不是字节。

OutputStreamWriter将字符流转换为字节流,将字符写出到指定位置

字节流与字符流的转换

从任何地方把数据读入到内存都是先以字节流形式读取,即使是使用字符流去读取数据,依然成立,因为数据永远是以字节的形式存在于互联网和硬件设备中,字符流是通过字符集的映射,才能够将字节转换为字符。

所以Java提供了两种转换流:

InputStreamReader:从字节流转换为字符流,将字节数据转换为字符数据读入到内存

OutputStreamWriter:从字符流转换为字节流,将字符数据转换为字节数据写出到指定位置

接下来我们再继续学习NIO知识,NIO是当下非常火热的一种IO工作方式,它能够解决传统BIO的痛点:阻塞。

BIO如果遇到IO阻塞时,线程将会被挂起,直到IO完成后才唤醒线程,线程切换带来了额外的开销。

BIO中每个IO都需要有对应的一个线程去专门处理该次IO请求,会让服务器的压力迅速提高。

我们希望做到的是当线程等待IO完成时能够去完成其它事情,当IO完成时线程可以回来继续处理IO相关操作,不必干干的坐等IO完成。在IO处理的过程中,能够有一个专门的线程负责监听这些IO操作,通知服务器该如何操作。所以,我们聊到IO,不得不去接触NIO这一块硬骨头。

新潮的NIO

我们来看看BIO和NIO的区别,BIO是面向流的IO,它建立的通道都是单向的,所以输入和输出流的通道不相同,必须建立2个通道,通道内的都是传输0101001···的字节数据。

而在NIO中,不再是面向流的IO了,而是面向缓冲区,它会建立一个通道(Channel),该通道我们可以理解为铁路,该铁路上可以运输各种货物,而通道上会有一个缓冲区(Buffer)用于存储真正的数据,缓冲区我们可以理解为一辆火车。

通道(铁路)只是作为运输数据的一个连接资源,而真正存储数据的是缓冲区(火车)。即通道负责传输,缓冲区负责存储。

理解了上面的图之后,BIO和NIO的主要区别就可以用下面这个表格简单概括。

BIONIO面向流(Stream)面向缓冲区(Buffer)单向通道双向通道阻塞IO非阻塞IO选择器(Selectors)

缓冲区(Buffer)

缓冲区是存储数据的区域,在Java中,缓冲区就是数组,为了可以操作不同数据类型的数据,Java提供了许多不同类型的缓冲区,除了布尔类型以外,其它基本数据类型都有对应的缓冲区数组对象。

缓冲区解释ByteBuffer存储字节数据的缓冲区CharBuffer存储字符数据的缓冲区ShortBuffer存储短整型数据的缓冲区IntBuffer存储整型数据的缓冲区LongBuffer存储长整型数据的缓冲区FloatBuffer存储单精度浮点型数据的缓冲区DoubleBuffer存储双精度浮点型数据的缓冲区

分配一个缓冲区的方式都高度一致:使用allocate(intcapacity)方法。

例如需要分配一个1024大小的字节数组,代码就是下面这样子。

ByteBufferbyteBuffer=(1024);

缓冲区读写数据的两个核心方法:

put():将数据写入到缓冲区中

get():从缓冲区中读取数据

缓冲区的重要属性:

capacity:缓冲区中最大存储数据的容量,一旦声明则无法改变

limit:表示缓冲区中可以操作数据的大小,limit之后的数据无法进行读写。必须满足limit=capacity

position:当前缓冲区中正在操作数据的下标位置,必须满足position=limit

mark:标记位置,调用reset()将position位置调整到mark属性指向的下标位置,实现多次读取数据

缓冲区为高效读写数据而提供的其它辅助方法:

flip():可以实现读写模式的切换,我们可以看看里面的源码

publicfinalBufferflip(){limit=position;position=0;mark=-1;returnthis;}

调用flip()会将可操作的大小limit设置为当前写的位置,操作数据的起始位置position设置为0,即从头开始读取数据。

rewind():可以将position位置设置为0,再次读取缓冲区中的数据

clear():清空整个缓冲区,它会将position设置为0,limit设置为capacity,可以写整个缓冲区

我们来看一个简单的例子

publicClassMain{publicstaticvoidmain(String[]args){//分配内存大小为11的整型缓存区IntBufferbuffer=(11);//往buffer里写入2个整型数据for(inti=0;i2;++i){intrandomNum=newSecureRandom().nextInt();(randomNum);}//将Buffer从写模式切换到读模式();("position"+()+"limit"+()+"capacity"+());//读取buffer里的数据while(()){(());}("position"+()+"limit"+()+"capacity"+());}}

执行结果如下图所示,首先我们往缓冲区中写入2个数据,position在写模式下指向下标2,然后调用flip()方法切换为读模式,limit指向下标2,position从0开始读数据,读到下标为2时发现到达limit位置,不可继续读。

整个过程可以用下图来理解,调用flip()方法以后,读出数据的同时position指针不断往后挪动,到达limit指针的位置时,该次读取操作结束。

通道(Channel)

上面我们介绍过,通道是作为一种连接资源,作用是传输数据,而真正存储数据的是缓冲区,所以介绍完缓冲区后,我们来学习通道这一块。

通道是可以双向读写的,传统的BIO需要使用输入/输出流表示数据的流向,在NIO中可以减少通道资源的消耗。

通道类都保存在包下,我们日常用到的几个重要的类有4个:

IO通道类型具体类文件IOFileChannel(用于文件读写、操作文件的通道)TCP网络IOSocketChannel(用于读写数据的TCP通道)、ServerSocketChannel(监听客户端的连接)UDP网络IODatagramChannel(收发UDP数据报的通道)

可以通过getChannel()方法获取一个通道,支持获取通道的类如下:

文件IO:FileInputStream、FileOutputStream、RandomAccessFile

TCP网络IO:Socket、ServerSocket

UDP网络IO:DatagramSocket

示例:文件拷贝案例

我们来看一个利用通道拷贝文件的例子,需要下面几个步骤:

打开原文件的输入流通道,将字节数据读入到缓冲区中

打开目的文件的输出流通道,将缓冲区中的数据写到目的地

关闭所有流和通道(重要!)

这是一张小菠萝的照片,它存在于d:\小菠萝\文件夹下,我们将它拷贝到d:\小菠萝分身\文件夹下。

publicclassTest{/**缓冲区的大小*/publicstaticfinalintSIZE=1024;publicstaticvoidmain(String[]args)throwsIOException{//打开文件输入流FileChannelinChannel=newFileInputStream("d:\小菠萝\小菠萝.jpg").getChannel();//打开文件输出流FileChanneloutChannel=newFileOutputStream("d:\小菠萝分身\小菠萝-拷贝.jpg").getChannel();//分配1024个字节大小的缓冲区ByteBufferdsts=(SIZE);//将数据从通道读入缓冲区while((dsts)!=-1){//切换缓冲区的读写模式();//将缓冲区的数据通过通道写到目的地(dsts);//清空缓冲区,准备下一次读();}();();}}

我画了一张图帮助你理解上面的这一个过程。

BIO和NIO拷贝文件的区别

这个时候就要来了解了解操作系统底层是怎么对IO和NIO进行区别的,我会用尽量通俗的文字带你理解,可能并不是那么严谨。

操作系统最重要的就是内核,它既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保护内核的安全,操作系统将底层的虚拟空间分为了用户空间和内核空间,其中用户空间就是给用户进程使用的,内核空间就是专门给操作系统底层去使用的。

接下来,有一个Java进程希望把小菠萝这张图片从磁盘上拷贝,那么内核空间和用户空间都会有一个缓冲区

这张照片就会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张图片字节数据拷贝到用户进程的缓冲区中保存下来,对应着下面这幅图

然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到Socket缓冲区中,最终操作系统再将Socket缓冲区的数据写到磁盘的指定位置上。

这一轮操作下来,我们数数经过了几次数据的拷贝?4次。有2次是内核空间和用户空间之间的数据拷贝,这两次拷贝涉及到用户态和内核态的切换,需要CPU参与进来,进行上下文切换。而另外2次是硬盘和内核空间之间的数据拷贝,这个过程利用到DMA与系统内存交换数据,不需要CPU的参与。

导致IO性能瓶颈的原因:内核空间与用户空间之间数据过多无意义的拷贝,以及多次上下文切换

操作状态用户进程请求读取数据用户态-内核态操作系统内核返回数据给用户进程内核态-用户态用户进程请求写数据到硬盘用户态-内核态操作系统返回操作结果给用户进程内核态-用户态

操作系统的零拷贝

所以,操作系统出现了一个全新的概念,解决了IO瓶颈:零拷贝。零拷贝指的是内核空间与用户空间之间的零次拷贝。

零拷贝可以说是IO的一大救星,操作系统底层有许多种零拷贝机制,我这里仅针对JavaNIO中使用到的其中一种零拷贝机制展开讲解。

在JavaNIO中,零拷贝是通过用户空间和内核空间的缓冲区共享一块物理内存实现的,也就是说上面的图可以演变成这个样子。


这时,无论是用户空间还是内核空间操作自己的缓冲区,本质上都是操作这一块共享内存中的缓冲区数据,省去了用户空间和内核空间之间的数据拷贝操作。

现在我们重新来拷贝文件,就会变成下面这个步骤:

用户进程通过系统调用read()请求读取文件到用户空间缓冲区(第一次上下文切换),用户态-核心态,数据从硬盘读取到内核空间缓冲区中(第一次数据拷贝)

系统调用返回到用户进程(第二次上下文切换),此时用户空间与内核空间共享这一块内存(缓冲区),所以不需要从内核缓冲区拷贝到用户缓冲区

用户进程发出write()系统调用请求写数据到硬盘上(第三次上下文切换),此时需要将内核空间缓冲区中的数据拷贝到内核的Socket缓冲区中(第二次数据拷贝)

由DMA将Socket缓冲区的内容写到硬盘上(第三次数据拷贝),write()系统调用返回(第四次上下文切换)

整个过程就如下面这幅图所示。

图中,需要CPU参与工作的步骤只有第③个步骤,对比于传统的IO,CPU需要在用户空间与内核空间之间参与拷贝工作,需要无意义地占用2次CPU资源,导致CPU资源的浪费。

下面总结一下操作系统中零拷贝的优点:

降低CPU的压力:避免CPU需要参与内核空间与用户空间之间的数据拷贝工作

减少不必要的拷贝:避免用户空间与内核空间之间需要进行数据拷贝

上面的图示可能并不严谨,对于你理解零拷贝会有一定的帮助,关于零拷贝的知识点可以去查阅更多资料哦,这是一门大学问。

选择器(Selectors)

选择器是提升IO性能的灵魂之一,它底层利用了多路复用IO机制,让选择器可以监听多个IO连接,根据IO的状态响应到服务器端进行处理。通俗地说:选择器可以监听多个IO连接,而传统的BIO每个IO连接都需要有一个线程去监听和处理。

图中很明显的显示了在BIO中,每个Socket都需要有一个专门的线程去处理每个请求,而在NIO中,只需要一个Selector即可监听各个Socket请求,而且Selector并不是阻塞的,所以不会因为多个线程之间切换导致上下文切换带来的开销。

在JavaNIO中,选择器是使用Selector类表示,Selector可以接收各种IO连接,在IO状态准备就绪时,会通知该通道注册的Selector,Selector在下一次轮询时会发现该IO连接就绪,进而处理该连接。

Selector选择器主要用于网络IO当中,在这里我会将传统的BIOSocket编程和使用NIO后的Socket编程作对比,分析NIO为何更受欢迎。首先先来了解Selector的基本结构。

重要方法方法解析open()打开一个Selector选择器intselect()阻塞地等待就绪的通道intselect(longtimeout)最多阻塞timeout毫秒,如果是0则一直阻塞等待,如果是1则代表最多阻塞1毫秒intselectNow()非阻塞地轮询就绪的通道

在这里,你会看到select()和它的重载方法是会阻塞的,如果用户进程轮询时发现没有就绪的通道,操作系统有两种做法:

一直等待直到一个就绪的通道,再返回给用户进程

立即返回一个错误状态码给用户进程,让用户进程继续运行,不会阻塞

这两种方法对应了同步阻塞IO和同步非阻塞IO,这里读者的一点小的观点,请各位大神批判阅读

了解了选择器之后,它的作用就是:监听多个IO通道,当有通道就绪时选择器会轮询发现该通道,并做相应的处理。那么IO状态分为很多种,我们如何去识别就绪的通道是处于哪种状态呢?在Java中提供了选择键(SelectionKey)。

选择键(SelectionKey)

在Java中提供了4种选择键:

_READ:套接字通道准备好进行读操作

_WRITE:套接字通道准备好进行写操作

_ACCEPT:服务器套接字通道接受其它通道

_CONNECT:套接字通道准备完成连接

在SelectionKey中包含了许多属性

channel:该选择键绑定的通道

selector:轮询到该选择键的选择器

readyOps:当前就绪选择键的值

interesOps:该选择器对该通道感兴趣的所有选择键

选择键的作用是:在选择器轮询到有就绪通道时,会返回这些通道的就绪选择键(SelectionKey),通过选择键可以获取到通道进行操作。

简单了解了选择器后,我们可以结合缓冲区、通道和选择器来完成一个简易的聊天室应用。

示例:简易的客户端服务器通信

我们在服务器端会开辟两个线程

Thread1:专门监听客户端的连接,并把通道注册到客户端选择器上

Thread2:专门监听客户端的其它IO状态(读状态),当客户端的IO状态就绪时,该选择器会轮询发现,并作相应处理

publicclassNIOServer{SelectorserverSelector=();SelectorclientSelector=();publicstaticvoidmain(String[]args)throwsIOException{NIOServerserver=nweNIOServer();newThread(()-{try{//对应IO编程中服务端启动ServerSocketChannellistenerChannel=();().bind(newInetSocketAddress(3333));(false);(serverSelector,_ACCEPT);();}catch(IOExceptionignored){}}).start();newThread(()-{try{();}catch(IOExceptionignored){}}).start();}}//监听客户端连接publicvoidacceptListener(){while(true){if((1)0){SetSelectionKeyset=();IteratorSelectionKeykeyIterator=();while(()){SelectionKeykey=();if(()){try{//(1)每来一个新连接,注册到clientSelectorSocketChannelclientChannel=((ServerSocketChannel)()).accept();(false);(clientSelector,_READ);}finally{//从就绪的列表中移除这个();}}}}}}//监听客户端的IO状态就绪publicvoidclientListener(){while(true){//批量轮询是否有哪些连接有数据可读if((1)0){SetSelectionKeyset=();IteratorSelectionKeykeyIterator=();while(()){SelectionKeykey=();//判断该通道是否读就绪状态if(()){try{//获取客户端通道读入数据SocketChannelclientChannel=(SocketChannel)();ByteBufferbyteBuffer=(1024);(byteBuffer);();(().toString()+"Server端接收到来自Client端的消息:"+().decode(byteBuffer).toString());}finally{//从就绪的列表中移除这个();(_READ);}}}}}}

在客户端,我们可以简单的输入一些文字,发送给服务器

publicclassNIOClient{publicstaticfinalintCAPACITY=1024;publicstaticvoidmain(String[]args)throwsException{ByteBufferdsts=(CAPACITY);SocketChannelsocketChannel=(newInetSocketAddress("127.0.0.1",3333));(false);Scannersc=newScanner();while(true){Stringmsg=();(());();(dsts);();}}}

下图可以看见,在客户端给服务器端发送信息,服务器接收到消息后,可以将该条消息分发给其它客户端,就可以实现一个简单的群聊系统,我们还可以给这些客户端贴上标签例如用户姓名,聊天等级······,就可以标识每个客户端啦。在这里由于篇幅原因,我没有写出所有功能,因为使用原生的NIO实在是不太便捷。

我相信你们都是直接滑下来看这里的,我在写这段代码的时候也非常痛苦,甚至有点厌烦Java原生的NIO编程。实际上我们在日常开发中很少直接用NIO进行编程,通常都会用Netty,Mina这种服务器框架,它们都是很好地NIO技术,对Java原生的NIO进行了上层的封装、优化,简化开发难度,但是在学习框架之前,我们需要了解它底层原生的技术,就像SpringAOP的动态代理,SpringIOC容器的Map容器存储对象,Netty底层的NIO基础······

总结

NIO的三大板块基本上都介绍完了,我没有做过多详细的API介绍,我希望能够通过这篇文章让你们对以下内容有所认知

JavaIO体系的组成部分:BIO和NIO

BIO的基本组成部分:字节流,字符流,转换流和处理流

NIO的三大重要模块:缓冲区(Buffer),通道(Channel),选择器(Selector)以及它们的作用

NIO与BIO两者的对比:同步/非同步、阻塞/非阻塞,在文件IO和网络IO中,使用NIO相对于使用BIO有什么优势

免责声明:本文章如果文章侵权,请联系我们处理,本站仅提供信息存储空间服务如因作品内容、版权和其他问题请于本站联系