Java教程

Java NIO入门教程(一)

IO文件操作 从心出发 2020-06-05 13:59:28.0 97 0条

一.NIO概述

新IO和传统的I0有相同的目的,都是用于进行输入/输出,但新I0使用了不同的方式来处理输入/输出,新I0采用内存映射文件的方式来处理输入/输出,新IO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一-样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。

新IO中的两个核心对象是Channel (通道)和Buffer (缓冲)是,Channel是对传统的输入/输出系统的模拟,在新I0系统中所有的数据都需要通过通道传输; Channel 与传统的InputStream、 OutputStream最大的区别在于它提供了- -个map0方法,通过该map0方法可以直接将“一块数据”映射到内存中。如果说传统的输入/输出系统是面向流的处理,则新I0则是面向块的处理。

Buffer可以被理解成一-个容器, 它的本质是一个数组,发送到Channel中的所有对象都必须首先放到Buffer中,而从Channel中读取的数据也必须先放到Buffer中。此处的Buffer有点类似于前面介绍的“竹筒”,但该Buffer既可以像“竹筒”那样-次次去Channel中取水,也允许使用Channel直接将文件的某块数据映射成Buffer。

除了Channel和Buffer之外,新IO还提供了用于将Unicode字符串映射成字节序列以及逆映射操作的Charset类,也提供了用于支持非阻塞式输入/输出的Selector 类。

二.先看个例子大概了解下过程

2.1 从文件中读取
我们首先从 FileInputStream 获取一个 Channel 对象,然后使用这个通道来读取数据。

在 NIO 系统中,任何时候执行一个读操作,您都是从通道中读取,但是您不是 直接 从通道读取。因为所有数据最终都驻留在缓冲区中,所以您是从通道读到缓冲区中。

因此读取文件涉及三个步骤:(1) 从 FileInputStream 获取 Channel,(2) 创建 Buffer,(3) 将数据从 Channel 读到 Buffer 中。

现在,让我们看一下这个过程。

2.2 三个容易的步骤
第一步是获取通道。我们从 FileInputStream 获取通道:

  1. FileInputStream fin = newFileInputStream( "readandshow.txt");
  2. FileChannel fc = fin.getChannel();

下一步是创建缓冲区:

  1. ByteBuffer buffer = ByteBuffer.allocate( 1024);

最后,需要将数据从通道读到缓冲区中,如下所示:

  1. fc.read( buffer );

您会注意到,我们不需要告诉通道要读 多少数据 到缓冲区中。每一个缓冲区都有复杂的内部统计机制,它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据。我们将在 缓冲区内部细节 中介绍更多关于缓冲区统计机制的内容。

2.3 写入文件
在 NIO 中写入文件类似于从文件中读取。首先从 FileOutputStream 获取一个通道:

  1. FileOutputStream fout = newFileOutputStream( "writesomebytes.txt");
  2. FileChannel fc = fout.getChannel();

下一步是创建一个缓冲区并在其中放入一些数据 - 在这里,数据将从一个名为 message 的数组中取出,这个数组包含字符串 “Some bytes” 的 ASCII 字节(本教程后面将会解释 buffer.flip() 和 buffer.put() 调用)。

  1. ByteBuffer buffer = ByteBuffer.allocate( 1024);
  2. for(intii=0; ii<message.length; ++ii) {
  3. buffer.put( message[ii] );
  4. }
  5. buffer.flip();

最后一步是写入缓冲区中:

  1. fc.write( buffer );

注意在这里同样不需要告诉通道要写入多数据。缓冲区的内部统计机制会跟踪它包含多少数据以及还有多少数据要写入。

三.Channel 通道

Channel类似于传统的流对象,但与传统的流对象有两个主要区别。

➢Channel可以直接将指定文件的部分或全部直接映射成Buffer。

➢ 观察上面的程序可以知道我们不能直接访问Channel中的数据,包括读取、写入都不行,Channel只能与Buffer进行交互。也就是说,如果要从Channel中取得数据,必须先用Buffer 从Channel中取出一-些数据,然后让程序从Buffer中取出这些数据;如果要将程序中的数据写入Channel, - -样先让程序将数据放入Buffer中,程序再将Buffer里的数据写入Channel中。

Java 为Channel 接口提供 了DatagramChannel. FileChannel, Pipe.SinkChannel,Pipe.SourceChannel,SelectableChannel.,ServerSocketChannel,SocketChannel 等实现类,根据这些Channel的名字不难发现,新I0里的Channel是按功能来划分的,例如ServerSocketChannel,SocketChannel 用于支持TCP网络通信的Channel;而DatagramChannel则是用于支持UDP网络通信的Channel。

所有的Channel都不应该通过构造器来直接创建,而是通过传统的节点InputStream、 OutputStream的getChannel方法来返回对应的Channel,不同的节点流获得的Channel不一样 。例如,FileInputStream、FileOutputStream的getChannel(返回的 是FileChannel, 而PipedInputStream 和PipedOutputStream 的getChannel(返回的是Pipe.SinkChannel、Pipe.SourceChannel。

Channel中最常用的三类方法是map()、read()和 write(),其中map(方法用于将Channel对应的部分或全部数据映射成ByteBuffer;而read(或write(方法都有一系列重载形式,这些方法用于从Buffer中读取数据或向Buffer中写入数据。

map方法的方法签名为: MappedByteBuffer map(FileChannel.MapMode mode, long position, longsize),第一个参数执行映射时的模式,分别有只读、读写等模式;而第二个、第三个参数用于控制将Channel的哪些数据映射成ByteBuffer。如下面例子

  1. public class FileChannelTest{
  2. public static void main (String[] args){
  3. File f = new File ("FileChannelTest. java") ;
  4. try{
  5. // 用FileInputStream流创建 FileChannel
  6. FileChannel inChannel = new FileInputStream (f).getchannel();
  7. //以文件输出流创建FileChannel,用以控制输出
  8. FileChannel outChannel = new FileOutputStream ("a. txt").getchannel() )
  9. //将FileChannel里的全部数据映射成ByteBuffer
  10. MappedByteBuffer buffer = inChannel.map (FileChannel.MapMode.READ_ONLY , 0 , f.length());// ①
  11. //使用GBK的字符集来创建解码器
  12. Charset charset = Charset. forName ("GBK") ;
  13. //直接将buffer里的数据全部输出
  14. outChannel. write (buffer) ;// ②
  15. //再次调用buffer的clear()方法,复原limit、position的位置
  16. buffer.clear() ;
  17. CharsetDecoder decoder = charset. newDecoder() ;
  18. //使用解码器将ByteBuffer转换成CharBuffer
  19. CharBuffer charBuffer = decoder . decode (buffer) ;
  20. System. out println(charBuffer) ;
  21. }
  22. catch (IOException ex){
  23. ex.printStackTrace() ;
  24. }
  25. }
  26. }

上面程序中的两行粗体字代码分别使用FileInputStream、 FileOutputStream 来获取FileChannel,虽然FileChannel既可以读取也可以写入,但FileInputStream获取的FileChannel只能读,而FileOutputStream获取的FileChannel只能写。程序中①号代码处直接将指定Channel中的全部数据映射成ByteBuffer,然后程序中②号代码处直接将整个ByteBuffer的全部数据写入一个输出FileChannel 中,这就完成了文件的复制。

程序后面部分为了能将FileChannelTest.java 文件里的内容打印出来,使用了Charset 类和CharsetDecoder类将ByteBuffer转换成CharBuffer.关于Charset和CharsetDecoder下面 将会有更详细的介绍。

如果读者习惯了传统I0的“用竹筒多次重复取水”的过程,或者担心Channel对应的文件过大,使用map(方法—.次将所有的文件内容映射到内存中引起性能下降,也可以使用Channel和Buffer传统的“用竹筒多次重复取水”的方式。如下程序所示。

  1. public class ReadFile{
  2. public static void main (String[] args)throws IOException{
  3. try(
  4. FileInputStream fis = new FileInputStream ("ReadFile.java") ;11IJkt-↑FileChannel
  5. FileChannel fcin = fis. getchannel() )
  6. {
  7. //定义一个ByteBuffer对象,用于重复取水
  8. ByteBuffer bbuff = ByteBuffer.lallocate(256) ;//将FileChannel中的数据放入ByteBuffer中
  9. while (fcin.read (bbuff)!= -1 ){
  10. //锁定Buffer的空白区
  11. bbuff.flip() ;
  12. Charset charset =Charset. forName ("GBK") ;
  13. CharsetDecoderdecoder= charset .newDecoder() ;
  14. CharBuffercbuff= decoder . decode (bbuff) ;
  15. System.out. print (cbuff) ;
  16. //将Buffer初始化,为下一次读取数据做准备
  17. bbuff.clear() ;
  18. }
  19. }
  20. }
  21. }

上面代码虽然使用FileChannel 和Buffer来读取文件,但处理方式和使用InputStream、byte[]来读取文件的方式几乎- -样, 都是采用“用竹简多次重复取水”的方式。但因为Buffer提供了fip(和clear0两个方法,所以程序处理起来比较方便,每次读取数据后调用flip0方法将没有数据的区域“封印”起来,避免程序从Buffer中取出null值;数据取出后立即调用clear(方法将Buffer的position设0,为下- -次读取数据做准备。

buffer.clear() ; 调用buffer的clear()方法,主要是复原limit、position的位置,接下来讨论Buffer会说到

四.Buffer 缓冲区

Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。

在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,您都是将它放到缓冲区中。

从内部结构上来看,Buffer 就像一个数组, 它可以保存多个类型相同的数据。Buffer 是一个抽象类,其最常用的子类是ByteBuffer,它可以在底层字节数组上进行get/set 操作。除了ByteBuffer 之外,对应于其他基本数据类型( boolean除外)都有相应的Buffer 类: CharBuffer、ShortBuffer. IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。

上面这些Buffer 类,除了ByteBuffer 之外,它们都采用相同或相似的方法来管理数据,只是各自管理的数据类型不同而已。这些Buffer类都没有提供构造器,通过使用如下方法来得到-一个Buffer对象。

4.1 在Buffer中有三个重要的概念:容量(capacity)、 界限(limit) 和位置(position)。

➢容量(capacity):缓冲区的容量(capacity) 表示该Buffer的最大数据容量,即最多可以存储多少数据。缓冲区的容量不可能为负值,创建后不能改变。

➢界限(limit): 第-一个不应该被读出或者写入的缓冲区位置索引。也就是说,位于limit 后的数据既不可被读,也不可被写。

➢位置(position):用于指明下一个可以被读出的或者写入的缓冲区位置索引(类似于I0流中的记录指针)。当使用Buffer 从Channel中读取数据时,position 的值恰好等于已经读到了多少数据。当刚刚新建-一个 Buffer 对象时,其position 为0;如果从Channel中读取了2个数据到该Buffer中,则position 为2,指向Buffer中第3个(第1个位置的索引为0)位置。

Buffer的主要作用就是装入数据,然后输出数据,开始时Buffer的position为0, limit为capacity,程序可通过put(方法向Buffer中放入一些数据(或者从Channel中获取-一些数据),每放入一些数据,Buffer 的position 相应地向后移动一些位置。

当Buffer装入数据结束后,调用Buffer的flip方法,该方法将limit设置为position 所在位置,并将position设为0,这就使得Buffer的读写指针又移到了开始位置。也就是说,Buffer 调用flip方法之后,Buffer为输出数据做好准备;当Buffer输出数据结束后,Buffer 调用clear方法,clear方法不是清空Buffer的数据,它仅仅将position置为0,将limit置为capacity,这样为再次向Buffer 中装入数据做好准备。.

除了这些移动position、limit、mark的方法之外,Buffer 的所有子类还提供了两个重要的方法: put()和get()方法,用于向Buffer中放入数据和从Buffer 中取出数据。当使用put()和get(方法放入、取出数据时,Buffer既支持对单个数据的访问,也支持对批量数据的访问(以数组作为参数)。

4.2 访问方法
到目前为止,我们只是使用缓冲区将数据从一个通道转移到另一个通道。然而,程序经常需要直接处理数据。例如,您可能需要将用户数据保存到磁盘。在这种情况下,您必须将这些数据直接放入缓冲区,然后用通道将缓冲区写入磁盘。

或者,您可能想要从磁盘读取用户数据。在这种情况下,您要将数据从通道读到缓冲区中,然后检查缓冲区中的数据。

在本节的最后,我们将详细分析如何使用 ByteBuffer 类的 get() 和 put() 方法直接访问缓冲区中的数据。

4.2.1 get() 方法
ByteBuffer 类中有四个 get() 方法:

  1. 1byte get();
  2. 2ByteBuffer get( byte dst[] );
  3. 3ByteBuffer get( byte dst[], int offset, int length );
  4. 4byte get( int index );

第一个方法获取单个字节。第二和第三个方法将一组字节读到一个数组中。第四个方法从缓冲区中的特定位置获取字节。那些返回 ByteBuffer 的方法只是返回调用它们的缓冲区的 this 值。

此外,我们认为前三个 get() 方法是相对的,而最后一个方法是绝对的。 相对 意味着 get() 操作服从 limit 和 position 值 ― 更明确地说,字节是从当前 position 读取的,而 position 在 get 之后会增加。另一方面,一个 绝对 方法会忽略 limit 和 position 值,也不会影响它们。事实上,它完全绕过了缓冲区的统计方法。

上面列出的方法对应于 ByteBuffer 类。其他类有等价的 get() 方法,这些方法除了不是处理字节外,其它方面是是完全一样的,它们处理的是与该缓冲区类相适应的类型。

4.2.2 put()方法
ByteBuffer 类中有五个 put() 方法:

  1. 1ByteBuffer put( byte b );
  2. 2ByteBuffer put( byte src[] );
  3. 3ByteBuffer put( byte src[], int offset, int length );
  4. 4ByteBuffer put( ByteBuffer src );
  5. 5ByteBuffer put( int index, byte b );

第一个方法 写入(put) 单个字节。第二和第三个方法写入来自一个数组的一组字节。第四个方法将数据从一个给定的源 ByteBuffer 写入这个 ByteBuffer。第五个方法将字节写入缓冲区中特定的 位置 。那些返回 ByteBuffer 的方法只是返回调用它们的缓冲区的 this 值。

与 get() 方法一样,我们将把 put() 方法划分为 相对 或者 绝对 的。前四个方法是相对的,而第五个方法是绝对的。

上面显示的方法对应于 ByteBuffer 类。其他类有等价的 put() 方法,这些方法除了不是处理字节之外,其它方面是完全一样的。它们处理的是与该缓冲区类相适应的类型。

4.3 类型化的 get() 和 put() 方法
除了前些小节中描述的 get() 和 put() 方法, ByteBuffer 还有用于读写不同类型的值的其他方法。

如下所示:

  1. getByte()
  2. getChar()
  3. getShort()
  4. getInt()
  5. getLong()
  6. getFloat()
  7. getDouble()
  8. putByte()
  9. putChar()
  10. putShort()
  11. putInt()
  12. putLong()
  13. putFloat()
  14. putDouble()

事实上,这其中的每个方法都有两种类型 ― 一种是相对的,另一种是绝对的。它们对于读取格式化的二进制数据(如图像文件的头部)很有用。

4.4 缓冲区分配和包装
在能够读和写之前,必须有一个缓冲区。要创建缓冲区,您必须 分配 它。我们使用静态方法 allocate() 来分配缓冲区:

  1. ByteBuffer buffer = ByteBuffer.allocate( 1024);

allocate() 方法分配一个具有指定大小的底层数组,并将它包装到一个缓冲区对象中 ― 在本例中是一个 ByteBuffer。

您还可以将一个现有的数组转换为缓冲区,如下所示:

  1. bytearray[] = newbyte[1024];
  2. ByteBuffer buffer = ByteBuffer.wrap( array );

本例使用了 wrap() 方法将一个数组包装为缓冲区。必须非常小心地进行这类操作。一旦完成包装,底层数据就可以通过缓冲区或者直接访问。

4.5 缓冲区分片
slice() 方法根据现有的缓冲区创建一种 子缓冲区 。也就是说,它创建一个新的缓冲区,新缓冲区与原来的缓冲区的一部分共享数据。

使用例子可以最好地说明这点。让我们首先创建一个长度为 10 的 ByteBuffer:

  1. ByteBuffer buffer = ByteBuffer.allocate( 10);

然后使用数据来填充这个缓冲区,在第 n 个槽中放入数字 n:

  1. for(inti=0; i<buffer.capacity(); ++i) {
  2. buffer.put( (byte)i );
  3. }

现在我们对这个缓冲区 分片 ,以创建一个包含槽 3 到槽 6 的子缓冲区。在某种意义上,子缓冲区就像原来的缓冲区中的一个 窗口 。

窗口的起始和结束位置通过设置 position 和 limit 值来指定,然后调用 Buffer 的 slice() 方法:

  1. buffer.position( 3);
  2. buffer.limit( 7);
  3. ByteBuffer slice = buffer.slice();

片 是缓冲区的 子缓冲区 。不过, 片段 和 缓冲区 共享同一个底层数据数组,我们在下一节将会看到这一点。

4.6 缓冲区份片和数据共享
我们已经创建了原缓冲区的子缓冲区,并且我们知道缓冲区和子缓冲区共享同一个底层数据数组。让我们看看这意味着什么。

我们遍历子缓冲区,将每一个元素乘以 11 来改变它。例如,5 会变成 55。

  1. for(inti=0; i<slice.capacity(); ++i) {
  2. byteb = slice.get( i );
  3. b *= 11;
  4. slice.put( i, b );
  5. }

最后,再看一下原缓冲区中的内容:

  1. buffer.position( 0);
  2. buffer.limit( buffer.capacity() );
  3. while(buffer.remaining()>0) {
  4. System.out.println( buffer.get() );
  5. }

结果表明只有在子缓冲区窗口中的元素被改变了:

$ java SliceBuffer

  1. 0
  2. 1
  3. 2
  4. 33
  5. 44
  6. 55
  7. 66
  8. 7
  9. 8
  10. 9

缓冲区片对于促进抽象非常有帮助。可以编写自己的函数处理整个缓冲区,而且如果想要将这个过程应用于子缓冲区上,您只需取主缓冲区的一个片,并将它传递给您的函数。这比编写自己的函数来取额外的参数以指定要对缓冲区的哪一部分进行操作更容易。

五.字符集和charset

前面已经提到:计算机里的文件、数据、图片文件只是一种表面现象,所有文件在底层都是二进制文件,即全部都是字节码。图片、音乐文件暂时先不说,对于文本文件而言,之所以可以看到一个个的字符,这完全是因为系统将底层的二进制序列转换成字符的缘故。在这个过程中涉及两个概念:编码( Encode) 和解码(Decode), 通常而言,把明文的字符序列转换成计算机理解的二进制序列 (普通人看不懂)称为编码,把二进制序列转换成普通人能看懂的明文字符串称为解码。

六.文件锁

文件锁在操作系统中是很平常的事情,如果多个运行的程序需要并发修改同一个文件时,程序之间需要某种机制来进行通信,使用文件锁可以有效地阻止多个进程并发修改同-一个文件,所以现在的大部分操作系统都提供了文件锁的功能。

文件锁控制文件的全部或部分字节的访问,但文件锁在不同的操作系统中差别较大,所以早期的JDK版本并未提供文件锁的支持。从JDK 1.4的NIO开始,Java 开始提供文件锁的支持。

在NIO中,Java 提供了FileLock 来支持文件锁定功能,在FileChannel中提供的lock()/tryLock0方法可以获得文件锁FileLock 对象,从而锁定文件。lock()和 tryLock0方法存在区别:当lock()试图锁定某个文件时,如果无法得到文件锁,程序将一直阻塞; 而tryLock()是 尝试锁定文件,它将直接返回而不是阻塞,如果获得了文件锁,该方法则返回该文件锁,否则将返回null。

如果FileChannel 只想锁定文件的部分内容,而不是锁定全部内容,则可以使用如下的lock()或tryLock(方法。

➢lock(long position, long size, boolean shared):对文件从position开始,长度为size 的内容加锁,该方法是阻塞式的。

➢tryLock(long position, long size, boolean shared):非阻塞式的加锁方法。参数的作用与上一一个方法类似。

当参数shared为true 时,表明该锁是一一个共享锁, 它将允许多个进程来读取该文件,但阻止其他进程获得对该文件的排他锁。当shared 为false 时,表明该锁是一- -个排他锁,它将锁住对该文件的读写。程序可以通过调用FileLock的isShared来判断它获得的锁是否为共享锁
处理完文件后通过FileLock的release()方法释放文件锁。下面程序示范了使用FileLock锁定文件的示例。

  1. public class FileLockTest{
  2. public static void main(String[] args)throws Exception{
  3. try(
  4. //使用FileOutputStream获取FileChannel
  5. FileChannel channel= new FileOutputStream("a.txt"). getChannel() )
  6. {
  7. //使用非阻塞式方式对指定文件加锁
  8. FileLock lock = channel. tryLock() ;//程序暂停10s
  9. Thread.sleep(10000) ;
  10. //释放锁
  11. lock.release() ;
  12. }
  13. }
  14. }

上面程序中的第一行粗体字代码用于对指定文件加锁,接着程序调用Thread.sleep(10000)暂停了10秒后才释放文件锁(如程序中第二行粗体字代码所示),因此在这10 秒之内,其他程序无法对a.txt 文件进行修改。

暗锚,解决锚点偏移

文章评论

嘿,来试试登录吧!