一、NIO缓冲区
引言:Java NIO 是jdk1.4引入的,官方给出的定义是NEW IO ,是一种新的IO ,也可以理解为no-block io(非阻塞io),NIO 的出现和bio有相同的作用和目的,都是为了数据的输入和输出,但是方式有所不同,BIO是基于流的,而NIO 是基于通道和缓冲区的,nio具有更高的效率。
Bio和Nio的对比
通道和缓冲区 Channel 负责传输, Buffer 负责存储
Nio的两个核心:通道和缓冲区,缓冲区用于存储数据通道,通道用于连接数据源,将缓冲区从一端运输到另一端,完成数据的传输。
缓冲区(Buffer):一个用于特定基本数据类型的容器。由 java.nio 包定义的,所有缓冲区都是 Buffer 抽象类的子类。NIO 中的 Buffer 主要用于与 NIO 通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中的。
Buffer 就像一个数组,可以保存多个相同类型的数据。根据数据类型不同(boolean 除外) ,有以下 Buffer 常用子类:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
上述 Buffer 类 他们都采用相似的方法进行管理数据,只是各自管理的数据类型不同而已。都是通过如下方法获取一个 Buffer
对象:static XxxBuffer allocate(int capacity) : 创建一个容量为 capacity 的 XxxBuffer 对象。
ByteBuffer是最常用的一种类型的缓冲区,因为其他几种类型都可以转化为byte类型。
缓冲区的几个属性:
capacity、limit、position,mark 的关系为 :0 <= mark <= position <= limit <= capacity。
测试一下上面的属性申请一个10个字节的缓冲区。
- 容量 (capacity) :表示 Buffer 最大数据容量,缓冲区容量不能为负,并且创建后不能更改。就是在缓冲区allocate时申请的容量。
- 限制 (limit):第一个不应该读取或写入的数据的索引,即位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量。说白了就是存入数据的长度。
- 位置 (position):下一个要读取或写入的数据的索引。就是指向现在正在操作的位置的后一个位置。缓冲区的位置不能为负,并且不能大于其限制。
- 标记 (mark)与重置 (reset):标记是一个索引,通过 Buffer 中的 mark() 方法指定 Buffer 中一个特定的 position,之后可以通过调用 reset() 方法恢复到这个 position。
public class Demo1 {
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
System.out.println("----------------allocate------------");
System.out.println("capacity: "+byteBuffer.capacity());
System.out.println("limit: "+byteBuffer.limit());
System.out.println("position: "+byteBuffer.position());
}
}
结果
----------------allocate------------
capacity: 10
limit: 10
position: 0
此时缓冲区为:
此时的 capacity=limit,因为缓冲区没有数据所一以position指向0位置。
添加代码往缓冲区放入数据使用 put方法
//往缓存中存入数据
System.out.println("----------------put------------");
byteBuffer.put("abcde".getBytes());
System.out.println("capacity: "+byteBuffer.capacity());
System.out.println("limit: "+byteBuffer.limit());
System.out.println("position: "+byteBuffer.position());
此时又输出了
----------------put------------
capacity: 10
limit: 10
position: 5
此时的缓冲区:
在写模式下position指向了正在操作的后一个位置,也是我们即将操作的位置。
切换缓冲区为读模式:使用Buffer.flip()方法。
//切换到读mo模式
System.out.println("----------------flip------------");
byteBuffer.flip();
System.out.println("capacity: "+byteBuffer.capacity());
System.out.println("limit: "+byteBuffer.limit());
System.out.println("position: "+byteBuffer.position());
打印结果:
----------------flip------------
capacity: 10
limit: 5
position: 0
此时缓冲区:
此时position指向0位置,因为要读数据肯定是从第一个字节位置开始读。
//取数据
System.out.println("----------------get------------");
byte[] b = new byte[byteBuffer.limit()];
byteBuffer.get(b, 0, byteBuffer.limit());
System.out.println("capacity: "+byteBuffer.capacity());
System.out.println("limit: "+byteBuffer.limit());
System.out.println("position: "+byteBuffer.position());
System.out.println(new String(b));
结果:
----------------get------------
capacity: 10
limit: 5
position: 5
abcde
此时的
此时position处于limit处,从这里开始后面是不能操作的区域。
清空缓冲区(数据只是被遗忘并没有被真正的清除,position指向0索引处,再次put数据会将原来的数据覆盖)
byteBuffer.clear();
System.out.println("----------------clear------------");
System.out.println("capacity: "+byteBuffer.capacity());
System.out.println("limit: "+byteBuffer.limit());
System.out.println("position: "+byteBuffer.position());
结果:
----------------clear------------
capacity: 10
limit: 10
position: 0
此时缓冲区为:
只是将position指向索引0处,limit指向capacity。看一下源码
/**
* Clears this buffer. The position is set to zero, the limit is set to
* the capacity, and the mark is discarded.
*
* <p> Invoke this method before using a sequence of channel-read or
* <i>put</i> operations to fill this buffer. For example:
*
* <blockquote><pre>
* buf.clear(); // Prepare buffer for reading
* in.read(buf); // Read data</pre></blockquote>
*
* <p> This method does not actually erase the data in the buffer, but it
* is named as if it did because it will most often be used in situations
* in which that might as well be the case. </p>
*
* @return This buffer
*/
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
可以看出源码也是只是将几个属性所指向的位置变化,并没有对缓冲区的的内容进行任何操作。
Buffer常用的方法:
Buffer 所有子类提供了两个用于数据操作的方法:get()
与 put() 方法
获取 Buffer 中的数据
- get() :读取单个字节
- get(byte[] dst):批量读取多个字节到 dst 中
- get(int index):读取指定索引位置的字节(不会移动 position)
放入数据到 Buffer 中
- put(byte b):将给定单个字节写入缓冲区的当前位置
- put(byte[] src):将 src 中的字节写入缓冲区的当前位置
- put(int index, byte b):将指定字节写入缓冲区的索引位置(不会移动 position)
二、缓冲区的分类:非直接缓冲区和直接缓冲区别
在了解直接缓冲区和非直接缓冲区之前先说几个概念:
Java虚拟机分配的内存是物理内存,不是虚拟内存。
先看物理内存和虚拟内存的概念:
物理内存:物理内存(Physical memory)是相对于虚拟内存而言的。物理内存指通过物理内存条而获得的内存空间。
虚拟内存 : 虚拟内存则是指将硬盘的一块区域划分来作为内存。
内核地址空间和用户地址空间区别和联系:操作系统和驱动程序运行在内核空间,应用程序运行在用户空间。在电脑开机之前,内存就是一块原始的物理内存。什么也没有。开机加电,系统启动后,就对物理内存进行了划分。当然,这是系统的规定,物理内存条上并没有划分好的地址和空间范围。这些划分都是操作系统在逻辑上的划分。不同版本的操作系统划分的结果都是不一样的。为什么要划分用户空间和系统空间呢?当然是有必要的。操作系统的数据都是存放于系统空间的,用户进程的数据是存放于用户空间的。这是第一点,不同的身份,数据放置的位置必然不一样,否则大混战就会导致系统的数据和用户的数据混在一起,系统就不能很好的运行了。分开来存放,就让系统的数据和用户的数据互不干扰,保证系统的稳定性。分开存放,管理上很方便,而更重要的是,将用户的数据和系统的数据隔离开,就可以对两部分的数据的访问进行控制。这样就可以确保用户程序不能随便操作系统的数据,这样防止用户程序误操作或者是恶意破坏系统。处于用户态的程序只能访问用户空间,而处于内核态的程序可以访问用户空间和内核空间。
非直接缓冲区:我们之前说过NIO通过通道连接磁盘文件与应用程序,通过缓冲区存取数据进行双向的数据传输。物理磁盘的存取是操作系统进行管理的,与物理磁盘的数据操作需要经过内核地址空间;而我们的Java应用程序是通过JVM分配的内存空间,属于应用程序的内存空间。数据需要在内核地址空间和用户地址空间,在操作系统和JVM之间进行数据的来回拷贝,无形中增加的中间环节使得效率与后面要提的之间缓冲区相比偏低。
读操作:当有数据读取的时候,os系统现将数据先读入内核地址空间中,然后将内核空间的数据复制一份到用户地址空间中,然后再读入用户程序。
写操作:当有数据要写入的时候,现将数据通过管道写入用户地址空间中,然后将用户地址空间的数据复制一份到内核地址空间中,然后再由os写入磁盘。(复制到内核地址空间后数据什么时候写入磁盘,不是程序所决定的)
直接缓冲区:直接缓冲区则不再通过内核地址空间和用户地址空间的缓存数据的复制传递,而是在物理内存中申请了一块空间,这块空间映射到应用程序和物理磁盘,不再经过内核地址空间和用户地址空间,应用程序与磁盘之间的数据存取之间通过这块直接申请的物理内存进行,起到了中间媒介的作用。
可以通过 FileChannel 的 map() 方法获得:直接字节缓冲区可以通过 FileChannel 的 map() 方法 将文件区域直接映射到内存中来创建。该方法返回MappedByteBuffer 。 java中提供了3种内存映射模式,即:只读(readonly)、读写(read_write)、专用(private) ,对于只读模式来说,如果程序试图进行写操作,则会抛出ReadOnlyBufferException异常。 第二种的读写模式表明了通过内存映射文件的方式写或修改文件内容的话是会立刻反映到磁盘文件中去的。 专用(private)模式采用的是OS的“写时拷贝”原则,即在没有发生写操作的情况下,多个进程之间都是共享文件的同一块物理内存(进程各自的虚拟地址指向同一片物理地址),一旦某个进程进行写操作,那么将会将要写的物理内存中的文件数据单独拷贝一份到进程的私有缓冲区中,然后在私有缓冲区中对文件进行操作,不会反映到物理文件中去,只是改变的进程内存空间的副本。
allocateDirect() 工厂方法来创建:直接字节缓冲区可以通过调用此类的 allocateDirect() 工厂方法来创建。
使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,在垃圾回收时并不能用程序区控制堆外内存的回收,因为不属于虚拟机,只能是垃圾回收机制按需对堆内的DirectByteBuffer对象进行回收,回收后将与直接缓存区失去联系,也就意味着直接缓冲区被回收。
在直接缓冲区谨慎使用原因:
(1)不安全;
(2)消耗更多,因为它不是在JVM中直接开辟空间。这部分内存的回收只能依赖于垃圾回收机制,垃圾什么时候回收不受我们控制;
(3)数据写入物理内存缓冲区中,程序就失去了对这些数据的管理,即什么时候这些数据被最终写入从磁盘只能由操作系统来决定,应用程序无法再干涉。
(4)堆外空间分配比较耗时。
最后看一下jdk对于缓冲区和非缓冲区的说明:
字节缓冲区要么是直接的,要么是非直接的。如果为直接字节缓冲区,则 Java 虚拟机会尽最大努力直接在此缓冲区上执行本机 I/O 操作。也就是说,在每次调用基础操作系统的一个本机 I/O 操作之前(或之后),虚拟机都会尽量避免将缓冲区的内容复制到中间缓冲区中(或从中间缓冲区中复制内容)。
直接字节缓冲区可以通过调用此类的 allocateDirect
工厂方法来创建。此方法返回的缓冲区进行分配和取消分配所需成本通常高于非直接缓冲区。直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对应用程序的内存需求量造成的影响可能并不明显。所以,建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。
直接字节缓冲区还可以通过 mapping 将文件区域直接映射到内存中来创建。Java 平台的实现有助于通过 JNI 从本机代码创建直接字节缓冲区。如果以上这些缓冲区中的某个缓冲区实例指的是不可访问的内存区域,则试图访问该区域不会更改该缓冲区的内容,并且将会在访问期间或稍后的某个时间导致抛出不确定的异常。
字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其 isDirect
方法来确定。提供此方法是为了能够在性能关键型代码中执行显式缓冲区管理。