一个完备的语言可以自成体系,并不需要与外部文件打交道,不过这种说法只是理论上的,实际上任何一个语言都需要与外部文件进行数据交换。一个不能与外部进行任何交换的系统似乎用处不大,一个封闭的单细胞怕是生存不下来,广言之,“闭关锁国”真是要不得啊。
Java一生下来就有了对I/O的支持,后来在Java1.4版本中增加了一套平行的API即NIO,这些新的API都是为了提高性能而设计的。在Java7之后又有了NIO2,改进了File操作,对所有文件系统提供了统一的支持。
在具体看程序之前,我们先搞清楚一下I/O的概念,这是计算机与外部设备的输入输出,包括数据流和文件系统,如串行设备字节流字符流文件和网络,这些输入输出都统一在IO API中。对I/O的支持,就是从某个数据源获取数据,再写到某个数据目的地。运行方式如下图所示:
我们先讲IO接口。Java主要通过java.io包来支持I/O操作,能帮助你处理几乎所有的计算机的输入输出。我这里用了“几乎”两个字,是因为Java IO API这个包本身并不完备,与网络socket,internet和GUI有关的操作没有包括进来。概念上我们可以把它们视为一起的,但是后面的例子我还是重点关注文件的读写。
I/O接口大概可以分成:
字节操作接口:InputStream 和 OutputStream
字符操作接口:Writer 和 Reader
磁盘操作接口:File
回到程序代码,落实这些概念。操作“流”的程序的写法与内存对象有一些不一样,我们的思路要变一下。程序员脑袋里面可以把“流”理解成水流,各种接口相当于水管,水是一段一段流进来的,所以程序也是一节一节处理“流”的,这里有一个隐含的顺序读写模式,要记在心里。
看一个简单的例子,看怎么读取字节流,代码如下(ByteArrayTest.java):
public class ByteArrayTest { public static void main(String[] args) { byte[] buf = {1,2,3,4,5,6,7,8,9}; ByteArrayInputStream b = new ByteArrayInputStream(buf); byte[] newBuffer = new byte[6]; int num = b.read(newBuffer, 2, 4); System.out.println("Bytes read: "+num); for (int i=0;i<newBuffer.length;i++) { System.out.print(newBuffer[i]+"-"); } System.out.print("\n"); int num2 = b.read(newBuffer, 0, 5); System.out.println("Bytes read: "+num2); for (int i=0;i<newBuffer.length;i++) { System.out.print(newBuffer[i]+"-"); } } }
上面的例子通过流读取byte[]里面的数据(1,2,3,4,5,6,7,8,9)。注意代码中b.read()调用了两次,一次是b.read(newBuffer, 2, 4),第二次是b.read(newBuffer, 0, 5)。我们比较一下输出结果:
Bytes read: 4
0-0-1-2-3-4-
Bytes read: 5
5-6-7-8-9-4-
第一次从字节流里面读了四个字节,放到newBuffer的第三格开始的位置,查看newBuffer里面的内容是0-0-1-2-3-4-。第二次又从字节流里面读了五个字节,放到newBuffer的第一格开始的位置,查看newBuffer里面的内容是5-6-7-8-9-4-。从这里,可以明显看出,第二次是接着第一次继续往下读的,这就是“流”的特点。水管里的水,流过了就是过去了,不回过头来读的。
上面是字节流,我们来一个字符流,代码如下(CharArrayTest.java):
public class CharArrayTest { public static void main(String[] args) { BufferedReader br = null; BufferedWriter bw = null; try { br = new BufferedReader(new FileReader("f:\\testData.txt")); File f1 = new File("f:\\testData2.txt"); if(!f1.exists()){ f1.createNewFile(); } bw = new BufferedWriter(new FileWriter(f1)); char chars[] = new char[1024]; int n=0; while((n=br.read(chars))!= -1) { bw.write(chars,0,n); } } catch (Exception e) { e.printStackTrace(); }finally{ try{ br.close(); bw.close(); }catch(Exception e){ e.printStackTrace(); } } } }
上面的程序比较简单,从一个文本文件读出字符流,然后通过字符流写到另一个文本文件中,实现文件拷贝功能。
系统内部I/O 操作的都是字节而不是字符,那为什么要提供字符 I/O 接口呢?这是因为我们人写的程序中通常操作的数据都是以字符形式,为了程序员方便而已。不过在字符和字节的转换中,要特别注意字符集的问题,有时候会弄得人很恼火。
我们还能读取对象流,将内存对象数据与外部存储互通。
还是看一个例子,处理对象为一个Student。先定义这个类,代码如下(Student.java):
public class Student implements Serializable { private static final long serialVersionUID = 1L; private String name; private int age; public String toString() { return "Student [name=" + name + ", age=" + age + "]"; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } }
这是一个简单对象,因为要存储在外部文件中,所以要实现Serializable接口。
写一个程序存储对象,代码如下(OOSDemo.java):
public class OOSDemo { public static void main(String[] args) throws IOException { Student o = new Student(); o.setName("郭屹"); o.setAge(20); System.out.println(o.toString()); FileOutputStream fos = new FileOutputStream("f:\\student.obj"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(o); oos.close(); } }
我们用了一个ObjectOutputStream,直接将对象存储在文件中,也叫序列化。
再写一个程序,将文件中存储的对象数据读取回来,代码如下(OISDemo.java):
public class OISDemo { public static void main(String[] args) throws ClassNotFoundException, IOException { FileInputStream fis = new FileInputStream("f:\\person.obj"); ObjectInputStream ois = new ObjectInputStream(fis); Student o = (Student)ois.readObject(); System.out.println(o); ois.close(); } }
我们用了一个ObjectInputStream,直接将文件里的数据读成一个对象,也叫反序列化。 Java对象序列化帮助我们保存(持久化)指定的对象,并重新读取被保存的对象。使用Java对象序列化,在保存对象时,会把其状态保存为一组字节,反序列化的时候,再将这些字节组装成对象。对象序列化保存的是对象的”状态”,即它的成员变量,因此,对象序列化不会保存类中的静态变量,而有transient标志的变量也不会保存下来。序列化还会用于在网络两点之间传递对象。
在序列化过程中,调用是ObjectOutputStream 的 defaultWriteObject 方法以及 ObjectInputStream 的 defaultReadObject 方法。如果被序列化的类中定义了writeObject 和 readObject 方法,虚拟机会试图调用对象类里的 writeObject 和 readObject 方法,进行用户自定义的序列化和反序列化。
自定义序列化的例子可以看ArrayList<>类的源码:public class ArrayListextends AbstractListimplements List, RandomAccess, Cloneable, java.io.Serializable。它实现了Serializable,并定义了writeObject 和 readObject方法。ArrayList内部其实是用的数组:transient Object[] elementData; 按照规则,这些数据应该是保存不下来的,那么为什么实际上保存下来了呢?答案就在自定义的writeObject 和 readObject方法中,自己写代码保存了这些数据。
我们再来看一个文件处理的例子,代码如下(FileTest.java):
public class FileTest { public static void main(String[] args) { test1(); test2(); test3(); test4(); } public static void test1() { File file = new File("F:\\testData.txt"); if(file.exists()){ System.out.println(file.getAbsolutePath()); System.out.println("File Size:"+file.length()); } } public static void test2(){ File file1 = new File("f:\\iotest.txt"); if(!file1.exists()) { try { file1.createNewFile(); //create a new fie. } catch (IOException e) { e.printStackTrace(); } }else{ System.out.println("file exists."); } } public static void test3(){ File file2 = new File("f:\\testIO"); if(file2.isDirectory()) { System.out.println("文件夹存在"); }else{ file2.mkdir(); } } public static void test4(){ File f = new File("F:\\testIO"); if(f.isDirectory()) { File lists[] = f.listFiles(); for(int i=0;i<lists.length;i++) { System.out.println(lists[i].getName()); } } } }
上面程序实现了创建文件,检查文件是否存在,查看文件的绝对路径和文件大小,是否为文件夹,列出文件夹下面的文件及子目录。具体不再详述,大家看JDK文档练习即可。有一点注意的是 Java 中通常的 File 并不一定代表一个真实存在的文件对象,当你通过指定一个路径描述符时,它就会返回一个代表这个路径相关联的一个虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。这么设计的理由是Java团队认为大部分的时候应用程序员关心的是对文件的操纵而不是它在不在,只有真正读取它的时候才会检查在不在。
java.util包中还提供了一个GZip工具,蛮有用的,看一个例子,代码如下(ZipTest .java):
public class ZipTest { public static void main(String[] args) { byte[] buffer = new byte[1024]; try{ FileInputStream fis = new FileInputStream("f:/testdata/test.txt"); GZIPOutputStream gos = new GZIPOutputStream(new FileOutputStream("f:/testdata/test.gz")); int length; while ((length = fis.read(buffer)) > 0) { gos.write(buffer, 0, length); } fis.close(); gos.finish(); gos.close(); } catch (IOException e){ e.printStackTrace(); } } }
从应用程序的角度,文件I/O蛮简单的,内部的实现被屏蔽了。但是作为内部的实现来讲,还是比较复杂的。Java虚拟机根据目录和文件名创建一个 File 对象代表这个文件,同时会真正创建一个关联真实存在的磁盘文件的文件描述符 FileDescriptor,通过这个对象可以直接控制这个磁盘文件,这个对象就是真正代表一个存在的文件对象。同时要注意,我们要的是字符流,底层是字节流,系统会有一个 StreamDecoder 类将 byte 解码为 char 格式。
这里还有一个问题:系统如何从磁盘上读取一段数据并行程字节流的?解释了很久都没有看到落地。这个其实不是我们管的了,由操作系统来完成。不同的文件系统会有不同的方式管理硬盘。
我们试着往底层看一看,I/O文件操作的底层究竟是怎么工作的。
我们的应用系统是运行在操作系统之上的,操作系统在底下默默地帮我们管理计算机的计算资源存储设备和外部设备。所以针对文件的操作是交给操作系统管理的。基本上,大图景是硬件-驱动-内核空间-用户空间这么一个层次结构,概念图示如下:
在这个结构中,中心问题就是buffer的操作。当用户程序调用 read() 时,操作系统内核会对硬盘控制器发出指令从硬盘获取数据,硬盘控制器会通过DMA直接把数据从硬盘写到内核的缓冲区(不经过CPU),这个动作完成后,内核会将数据从内核缓冲区写到用户程序缓冲区。从这个过程可以清晰地看出用户程序-操作系统-硬盘控制器和硬盘的互动关系。
不过这个也带来了一个问题,程序为了拿一段数据,要复制两次,一次是在内核空间,一次是在用户空间。原因当然是应用程序与操作系统的层次关系决定的,不过这个看起来重复的操作也是有好处的,这要结合virtual memory虚拟内存来看。通过使用virtual memory,不同的地址可以映射到同一个物理内存地址,如果内核空间的地址和用户空间的地址映射到同一片物理地址,就可以实现仅仅一次复制,内核拿数据后供多个应用程序使用。
有了这些知识,能帮我们更好理解IO的各种设计方案。
既然有了IO为什么又要弄出来一个NIO呢?主要目的就是性能,特别是高并发场景下的性能。NIO把最耗时间的I/O操作交回给操作系统处理,极大地提高了性能。NIO设计出来还有一个场景上的考虑,传统I/O是同步的/阻塞的,一个线程开启I/O读写后,它要等着I/O读写完毕;NIO被设计成非阻塞的,可以一个线程处理多个I/O通道,而阻塞式模型下就需要用轮询的办法或者创建多个线程来应对。因此在一台服务器要响应多个客户端连接请求的场景,如聊天服务器或者P2P网络中,一个线程或少数线程用非阻塞式的方式处理大量连接就是一个优势。如果连接不多,要传输大量数据,阻塞式就更有优势。大家记住了,一个技术的优劣要在场景中分析才有意义。
从数据打包和传输模型的角度看,I/O接口是基于Stream流的,一个字节一个字节顺序读取处理,NIO是基于Block的,一块一块读取处理,这种方式更接近于操作系统的处理文件的方式,性能提升主要是这个引起的。并且缓冲起来,可以来回读,不再是顺序的流式处理。自然,NIO设计起来就复杂一些,要考虑缓冲区是不是包含了所有数据,是不是满了,什么时候再读,现在读到什么位置了,要写好一个程序真的不容易。
我们还是用代码说明,代码如下(NIOTest1.java):
public class NIOTest1 { public static void main(String[] args) { File aFile = null; FileInputStream fis = null; try{ aFile = new File("f:\\testdata.txt"); fis = new FileInputStream(aFile); FileChannel fileChannel = fis.getChannel(); ByteBuffer buf = ByteBuffer.allocate(1024); int bytesRead = 0; while((bytesRead=fileChannel.read(buf)) != -1) { buf.flip(); while(buf.hasRemaining()) { System.out.print((char)buf.get()); } buf.compact(); } }catch (IOException e){ e.printStackTrace(); }finally{ try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } } }
我们知道,Java I/O的API的核心是InputStream/OutputStream,而Java NIO的API的核心则是Channel/Buffer/Selector。我们稍微解释一下上面的代码。
NIO中,可以在传统的Stream之上获取channel,就是这句:FileChannel fileChannel = fis.getChannel(); 整个NIO的API的思路都是这样,感觉是再一次包装了传统I/O API。我个人觉得这是API设计的成功之处。不过要注意,NIO是面向字节的,不是面向字符的,所以以前的Reader/Writer不能直接转成Channel了。Channel其实有几种,FileChannel文件,DatagramChannel UDP数据报文,SocketChannel,和ServerSocketChannel。我本次讲座重点关注的是只是文件操作。
有了一个channel就可以操作了,核心代码就是这个while循环:
while((bytesRead=fileChannel.read(buf)) != -1) { buf.flip(); while(buf.hasRemaining()) { System.out.print((char)buf.get()); } buf.compact(); }
取数据还是用read(),读到buffer中,返回值是读到的字节数。Buffer就是一片内存区域,可写可读,还可以来回操作。它有capacity表示缓冲区的大小、position是当前的读写位置、limit是无效数据位置(也就是说limit之前的数据才是有效数据)。看一个图示就明白它们之间的关系了:
上图里面,buffer的前五个位置写了数据,position指向第一个位置准备好供人读取了。
While循环里是通过bytesRead=fileChannel.read(buf)不断地从外部读取数据到buffer中,拿到数据后的处理,先要flip()一下,这个刚开头觉得莫名其妙,不知道要翻转什么东西。还是要从图示可以看出来。开始从外部把五个字节写入缓冲区后,buffer变成下面的样子:
读写指针指到了第六个位置,这个指针位置我们是拿不到数据的,通过flip()操作,buffer就变成了前一幅图的样子,position归零了,limit也指向第一个无效位置了,那就可以拿到position和limit之间的有效数据了。
然后拿到这几个数据,拿完之后执行一句话:buf.compact();之后再下一个循环从外部读取数据。这个compact在干什么?它负责清空已读取的数据,未被读取的数据会被移动到buffer的开始位置,写入位置则紧跟着未读数据之后,即调整位置position = limit -position而limit = capacity。
这就是NIO 通过buffer读写数据的标准步骤:
把数据写入buffer;
执行flip();
从Buffer中读取数据;
执行buffer.clear()或者buffer.compact()
为什么要这么做?用一种看起来很不好理解的办法读写数据?因为buffer是可读可写的,同时channel是非阻塞的。也就是说,上面程序循环中间从buffer读数据不一定会一次读完,就会执行下一次从外部获取数据写入buffer,按照规定,写完之后,position就变成了limit的位置,而limit就设置成了capacity最后那个位置,即position = limit ,limit = capacity,所以这个时候再次读取buffer数据的时候会把第一次没有读完的数据冲掉。所以设计了这么一个compact()操作,让第一次没有读取完的数据挪动位置到buffer最开头,把position指向有效数据后头。
有些文章解释buffer的原理的时候,用了“读模式”“写模式”这样的词,这么理解也是可以的。看了很多文章,最后还是发现《Thinking in Java》里面的解释最形象:挖矿。把channel比成矿道,buffer当成矿车。矿工不会直接把煤块手工通过矿道搬上来, 而是先在矿坑里把煤铲到矿车上,用矿车把煤一车一车拉出来。
NIO除了Channel和Buffer之外,还有一个Selector,就是channel管理器。可以将多个channel以及相应的事件(如读、写、连接等)注册到selector上。Selector能监控和检测多个Channel,知道哪些通道准备好了读或写的操作。这样让一个线程管理多个channel,在大并发连接的场景就很有用。不过Selector与文件channel不相容的,因此不在本次讲座介绍。
NIO在高并发场景下性能很好,传统IO简单实用,两者各有所长,不是取代的关系,这也是我们要学习各种兵器的原因吧。并且老的IO是实现已经再底层重构过了,性能不差。NIO虽然是优秀的解决方案,但是程序复杂,很难写出一个稳定的程序来,于是许多优秀的框架如Netty应运而生,基于这些框架,应对高并发的场景就容易很多。
这种附带复杂性的设计,我自己认为是Java设计团队高估了应用程序员的水平,实际上大部分职业程序员并不专业。这些精英人物总以为别人跟它们一样,自己没有什么特别的,经常会忘记普通的应用程序员们的起点以及职业处境,一般没有什么扎实的知识基础,更糟糕的是,工作的过程就是不断地再应用层满足客户不断变化的需求,不断地再deadline之前修正bug,以致于没有时间深究某个技术。我这里不是批评程序员,而是为他们抱不平,从程序员本身来讲,这种情况下,更要设法让自己深入下去,多学多思,争取在某个技术上真正做到“精通”至少是“熟练”。现在看到很多毕业生或者工作一两年的人的简历,都声称自己精通什么什么,一大堆精通的技术,实在是贬低了精通这个词,也了损害自己的信用,实在不可取。从业二十年,看到的情况通常是高手更谦卑,低手更自信,一个自信,两个自信,三个自信。做技术,就是要扎扎实实的,知之为知之,不知为不知,有几分货说几分话,在技术面前,我们要永远保持谦卑。
最后我们用一个大文件拷贝比较一下性能,代码如下( CopyFilesExample.java):
public class CopyFilesExample { public static void main(String[] args) throws InterruptedException, IOException { File source = null; File dest = null; long start; long end; source = new File("f:\\file1.txt"); dest = new File("f:\\file1bak.txt"); start = System.nanoTime(); copyFileUsingFileStreams(source, dest); end = System.nanoTime(); System.out.println("Time taken by FileStreams Copy = " + (end - start)); source = new File("f:\\file2.txt"); dest = new File("f:\\file2bak.txt"); start = System.nanoTime(); copyFileUsingFileChannels(source, dest); end = System.nanoTime(); System.out.println("Time taken by FileChannels Copy = " + (end - start)); } private static void copyFileUsingFileStreams(File source, File dest) throws IOException { InputStream input = null; OutputStream output = null; try { input = new FileInputStream(source); output = new FileOutputStream(dest); byte[] buf = new byte[1024]; int bytesRead; while ((bytesRead = input.read(buf)) > 0) { output.write(buf, 0, bytesRead); } } finally { input.close(); output.close(); } } private static void copyFileUsingFileChannels(File source, File dest) throws IOException { FileChannel inputChannel = null; FileChannel outputChannel = null; try { inputChannel = new FileInputStream(source).getChannel(); outputChannel = new FileOutputStream(dest).getChannel(); outputChannel.transferFrom(inputChannel, 0, inputChannel.size()); } finally { inputChannel.close(); outputChannel.close(); } } }
运行结果:
Time taken by FileStreams Copy = 897335353 Time taken by FileChannel Copy = 149352406
这不是系统化的测试,只是一个演示。不过也可以看出即使不是高并发场景,简单的文件拷贝,NIO性能仍然占优。
好,对文件操作的IO和NIO,我们就讲到这里。大家看到,写程序不光是使用API,还要设法理解API背后的知识,这样才能让我们知其然还知其所以然。《朱子语类》卷九《论知行》篇中说: “不可去名上理会。须求其所以然。” “事要知其所以然。”这是对所有学者的提醒。
平时写应用代码,用API就够了,不大追究下去,而要对技术理解更深,就要往下追究,。靠平时的工作,技术是不会有大的长进的,还得要有意识地学习思考训练,动眼动手动脑。
投身技术的人,要发上等愿,“上穷碧落下黄泉”,成为自己专业领域的专家。