用户程序进行IO的读写,依赖于底层的IO读写,基本上会用到底层的read&write两大系统调用。在不同的操作系统中,IO读写的系统调用的名称可能完全不一样,但是基本功能是一样的。
read系统调用并不是直接从物理设备把数据读取到内存中,write系统调用也不是直接把数据写入到物理设备。上层应用无论是调用操作系统的read还是write,都会涉及缓冲区。**具体来说,调用操作系统的read,是把数据从内核缓冲区复制到进程缓冲区;而调用系统调用的write,是把数据从进程缓冲区复制到内核缓冲区。**因为外部设备的读写设计到操作系统的中断,引入缓冲区可以减少频繁地与设备之间的物理交换,操作系统会对内核缓冲区进行监控,等待缓冲区达到一定的数量的时候(由内核决定,用户程序无需关心),再进行IO设备的中断处理,集中执行物理设备的实际IO操作。
也就是说上层程序的IO操作,实际上不是物理设备级别的读写,而是缓存的复制。read&write两大系统调用,都不负责数据在内核缓冲区和物理设备(如磁盘)之间的交换。这项底层的读写交换,是由操作系统内核来完成的,即使不调用read&write,当有数据到达网卡时软中断也会将其拷贝到内核缓冲区。
在1.4版本之前,Java IO类库是阻塞IO,从1.4版本开始引进了新的IO库,称为Java New IO类库,简称为Java NIO。New IO类库的目标就是让Java支持非阻塞IO,弥补了原本面向流的OIO(Old IO)同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的IO。
Java NIO由以下三个核心组件组成:
Channel(通道)
Buffer(缓冲区)
Selector(选择器)
在Java中,NIO和OIO的区别主要体现在三个方面:
OIO是面向流的,NIO是面向缓冲区的
OIO的操作是阻塞的,而NIO的操作是非阻塞的
OIO没有选择器概念,而NIO有选择器的概念(IO多路复用)
NIO的Buffer类是一个抽象类,位于java.nio包中,提供了一组更加有效的方法,用来进行写入和读取的交替访问,本质上是一个内存块(数组),既可以写入数据,也可以从中读取数据。
需要强调的是Buffer类是一个非线程安全类。
在NIO这种有8种缓冲区类,分别为ByteBuffer、CharBuffer、ShortBufffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer、MappedByteBuffer。前7种Buffer类型覆盖了能在IO中传输的所有Java基本数据类型,第8种数据类型MappedByteBuffer是专门用于内存映射的一种ByteBuffer类型。实际上使用最多的还是ByteBuffer二进制字节缓冲区类型。
Buffer类在其内部有一个对应类型的数组(如ByteBuffer的byte[]数组)作为内存缓冲区,为了记录读写的状态和位置,Buffer类提供了一些重要的属性,其中有三个重要的成员属性:
capacity:容量(一旦初始化就不能再改变)
position:读写位置
limit:读写的限制
在使用Buffer之前,我们首先需要获取Buffer子类的实例对象,并且分配内存空间。获取一个Buffer实例对象并不是使用子类的构造器new来创建一个实例对象,而是调用子类的allocate()方法,该方法需要传入一个int类型的参数,表示缓冲区的容量。
public static void main(String[] args) throws IOException {
CharBuffer buffer = CharBuffer.allocate(20);
System.out.println("缓冲区的capacity:" + buffer.capacity());
System.out.println("缓冲区的position:" + buffer.position());
System.out.println("缓冲区的limit:" + buffer.limit());
}
缓冲区的capacity:20
缓冲区的position:0
缓冲区的limit:20
在调用allocate方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象。要写入缓冲区,需要调用put方法。
put方法只有一个参数,即为所需要写入的对象,数据类型要求与缓冲区的类型保持一致。
向缓冲区写入数据之后是不可以直接从缓冲区中读取数据的,因为此时缓冲区还处于写模式,如果需要读取数据,还需要将缓冲区转换成读模式。那么此时就需要使用flip()方法进行翻转。flip()方法的作用就是将写入模式翻转成读取模式。
对于flip()方法的从写入到读取转换的规则:
flip()的作用是将写入模式转换为读取模式,那么如何将缓冲区切换成读取模式呢?
一般来说可以通过调用clear()清空或者compact()压缩方法,它们可以将缓冲区转换为写模式。
调用flip方法将缓冲区切换成读取模式之后就可以开始从缓冲区中进行数据读取了。
get方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。
读取操作会改变刻度位置position的值,而limit值不会改变,如果position值和limit的值相等,表示所有数据读取完成,position只想了一个没有数据的元素位置,已经不能再读了,此时再读会抛出BufferUnderflowException异常。
在读完之后不可以立即进行写入操作,必须调用clear或compact方法清空或者压缩缓冲区才能编程写入模式,让其重新可写。
已经读完的数据如果需要再读一遍,可以调用rewin()方法,rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
rewind()方法主要是调整了缓冲区的position属性,具体的调整规则如下:
rewind()方法与flip很像是,区别在于rewind不会影响limit属性值,而flip会重设limit属性值。
mark方法的作用是将当前的position的值保存起来,放在mark属性中,让mark属性记住这个临时位置,之后可以调用reset方法将mark的值恢复到position中。
在读取模式下调用clear方法将缓冲区切换为写入模式,此方法会将position清零,limit设置为capacity最大容量值。
NIO中一个连接就是用一个Channel(通道)来表示,一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等,除此之外Java NIO的通道还可以更加细化,例如对应不同的网络传输协议类型,在Java中都有不同的NIO Channel实现。
Channel主要有四种重要的类型:
FileChannel是专门操作文件的通道,它是阻塞模式的,不能设置为非阻塞模式。具体的操作如下:
获取通道
读取通道
写入通道
关闭通道:channel.close()
强制刷新到磁盘:
在NIO中设计网络连接的通道有两个,一个是SocketChannel负责连接传输,一个是ServerSocketChannel负责连接的监听。
NIO中SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道与OIO中的ServerSocket对应。
ServerSocketChannel应用于服务器端,而SocketChannel同时处于服务器端和客户端。换句话说,对于一个连接,两端都有一个负责传输的SocketChannel传输通道。
这两种Channel都可以通过configureBlocking()方法设置是否为阻塞模式。在阻塞模式下,connect连接、read、write操作都是同步阻塞的,效率上和Java旧的OIO的面向流的阻塞式读写操作相同。
获取通道
读取通道和写入通道同样为read和write
关闭通道:在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1),然后再调用close方法关闭套接字
和Socket套接字的TCP传输协议不同,UDP协议不是面向连接的协议。使用UDP协议时只要知道服务器的IP和端口就可以直接向对方发送数据。
Selector选择器的使命是完成IO的多路复用。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO状况。选择器和通道的关系,是监控和被监控的关系。
选择器提供了独特的API,能够选出(select)所监控的通道拥有哪些已经准备好的、就绪的IO操作事件。
通道和选择器之间的关系,通过register(注册)的方式完成,调用通道的register(Selector sel, int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个指定通道注册到的选择器实例,第二个指定选择器要监控的IO事件类型。可供选择器监控的通道IO事件类型包括以下四种:
如果选择器要监控通道的多种事件,可以用“按位或”运算符来实现。
并不是所有的通道都是可以被选择器监控或选择的。比方说FileChannel文件通道选择器就不能被选择器复用。
判断一个通道能否被选择器监控或选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果继承了则可以被选择,否则不能被选择。该抽象类中定义了register()、configureBlocking()、isBlocking()等方法。
通道和选择器的监控关系注册成功后就可以选择就绪时间。具体的选择工作由选择器的select()方法来完成。通过该方法,选择器可以不断地选择通道中发生操作的就绪状态,返回注册过的感兴趣的那些IO事件(函数放回的是感兴趣的IO事件的数量,)。也就是说一旦通道中发生了我们在选择器中注册过的IO事件,就会被选择器选中并放入SelectionKeys选择间的集合中。SelectionKey选择键不仅可以获得通道的IO事件类型,还可以获得发生IO事件所在的通道,此外还可以获得选出选择键的选择器实例。使用方式:
public static void main(String[] args) throws IOException {
try (Selector selector = Selector.open()) {
while (selector.select() > 0) {
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
if (selectionKey.isAcceptable()) {
} else if (selectionKey.isReadable()) {
} else if (selectionKey.isWritable()) {
} else if (selectionKey.isConnectable()) {
}
}
}
}
}