当前位置: 首页 > 后端技术 > Java

深入剖析JavaIO(一)概述

时间:2023-04-01 17:30:22 Java

1.简介说到I/O,想必大家都不陌生。内存或其他外围设备之间的输入和输出。比如我们常用的SD卡、U盘、移动硬盘等存储文件的硬件设备,当我们将它们插入电脑的usb硬件接口时,我们就可以从电脑读取或写入设备中的信息。该过程涉及I/O操作。当然,涉及I/O的操作不仅限于硬件设备的读写,还包括网络数据的传输。例如,我们在电脑上使用浏览器来搜索互联网上的信息。这个过程也涉及到I/O操作。无论是从磁盘读写文件,还是在网络中传输数据,都可以说I/O主要是处理人机交互和机器对机器交互,获取和交换信息的一套解决方案。在Java的IO系统中,有近80个类,位于java.io包下。感觉很复杂,不过这些类大致可以分为四组:基于字节操作的I/O接口:基于字符操作的InputStream和OutputStreamI/O接口:Writer和Reader基于磁盘操作的I/O接口:File基于网络操作的I/O接口:Socket前两组主要是传输不同数据格式的数据,并将它们分组;后两组主要以不同方式分组传输数据。Socket类虽然不在java.io包下,但我们还是把它们分在了一起,因为I/O的核心问题要么是数据格式影响I/O操作,要么是传输方式影响I/O操作,即什么样的数据写到哪里?I/O只是人与机器之间或机器与机器之间交互的一种手段。除了自己能够完成这个交互功能之外,我们关注的重点是如何提高它的运行效率。数据格式和传输方式是影响效率的最关键因素。本文后面也将基于这两点进行深入分析。2、基于字节操作的接口基于字节的输入输出操作接口分别为:InputStream和OutputStream。2.1.字节输入流InputStream输入流的类继承层次如下图所示:输入流可以根据数据节点类型和处理方式分为若干个子类,如下图所示:OutputStream输出流也类似。2.2.字节输出流OutputStream输出流的类继承层次如下图所示:输出流还可以根据数据节点类型和处理方式分为若干个子类,如下图:我就不介绍了这里详细介绍每个子类如何使用类,有兴趣的朋友可以查看JDKAPI文档,笔者会在后面的文章中详细介绍。这里我只想重点说一下。无论是输入还是输出,都可以结合操作数据的方式,每个处理流类并不是只对固定节点的流进行操作,比如下面的输出方法://将文件输出流打包成序列化输出流,再将序列化输出流打包到缓冲区中OutputStreamout=newBufferedOutputStream(newObjectOutputStream(newFileOutputStream(newFile("fileName")));另外,必须指定输出流最后写到哪里,要么写到硬盘,要么写到网络,从图中可以看出,写的网络其实就是写文件,但是在写网络的时候,数据需要通过底层操作系统发送到其他电脑,而不是写到本地硬盘上3.基于字符操作的接口,是否是磁盘或者网络传输,最小的存储单位是字节,不是字符,所以I/O操作是字节而不是字符,但是为什么会有操作字符的I/O接口呢?这是因为我们的程序通常操作数据为字符形式。为了程序运行的方便,提供了直接写字符的I/O接口,仅此而已。基于字符的输入输出操作界面分别为:Reader和Writer。下图是字符输入输出接口。/O操作接口涉及的类结构图。3.1.字符输入流Reader输入流的类继承层次如下图所示:同理,输入流也可以根据数据节点类型和处理方式分为若干个子类。如下图所示:3.2.字符输出流Writer输出流的类继承层次如下图所示:同理,输出流可以根据数据节点类型和处理方式分为若干个子类,如下图所示:Reader或者Writer类,他们只是定义了读取或者写入数据字符的方式,也就是说要么读要么写,但是并没有规定数据应该写到哪里,写到哪里就是我们要讨论的稍后基于磁盘或网络的工作方式。4、字节和字符的转换我们刚刚提到不管是磁盘还是网络传输,最小的存储单位都是字节,而不是字符。设计字符的原因是为了让程序操作更方便,那么如何将字符转换成字节或将字节转换成字符呢?InputStreamReader和OutputStreamWriter是转换的桥梁。4.1.输入流转换过程输入流字符解码相关类结构的转换过程如下图所示:从图中可以看出,InputStreamReader类是字节到字符的转换桥梁,其中StreamDecoder指的是解码操作类,Charset指的是字符集。InputStream到Reader的过程需要指定编码字符集,否则会使用操作系统默认的字符集,可能会出现乱码。StreamDecoder是完成字节到字符解码的实现类。打开源码部分,InputStream到Reader的转换过程publicclassInputStreamReaderextendsReader{privatefinalStreamDecodersd;/***创建一个使用默认字符集的InputStreamReader。**@paraminInputStream*/publicInputStreamReader(InputStreamin){super(in);尝试{sd=StreamDecoder.forInputStreamReader(in,this,(String)null);//##检查锁定对象}catch(UnsupportedEncodingExceptione){//默认编码应该始终可用thrownewError(e);}}4.2。输出流转换过程输出流转换过程也类似,如下图所示:字符到字节的编码过程由OutputStreamWriter类完成,编码过程由StreamEncoder完成。源码部分,Writer到OutputStream的转换过程:publicclassOutputStreamWriterextendsWriter{privatefinalStreamEncoderse;公共OutputStreamWriter(OutputStream输出){超级(输出);尝试{se=StreamEncoder.forOutputStreamWriter(out,this,(String)null);}catch(UnsupportedEncodingExceptione){thrownewError(e);}}5.基于磁盘操作的接口前面介绍了JavaI/O操作接口。这些接口主要定义了如何操作数据以及介绍操作数据格式。方式:字节流和字符流。另一个关键问题是将数据写入何处。其中一种主要的处理方式是将数据持久化到物理磁盘。我们知道,磁盘上对数据唯一最小的描述就是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,而文件也是操作系统与磁盘驱动器交互的最小单位。在JavaI/O体系结构中,File类是唯一代表磁盘文件本身的对象。File类定义了一些与平台无关的操作文件的方法,包括检查文件是否存在、创建、删除文件、重命名文件、判断文件的读写权限是否存在、设置和查询文件的最新修改时间,ETC。。值得注意的是,Java中常用的File并不代表真正的文件对象。当您指定路径描述符时,它将返回一个表示路径的虚拟对象。这可能是一个真实的文件对象。文件或包含多个文件的目录。例如读取一个文件的内容,程序如下:publicstaticvoidmain(String[]args)throwsIOException{StringBuffersb=newStringBuffer();char[]chars=newchar[1024];FileReaderf=newFileReader("文件名");while(f.read()>0){sb.append(chars);}sb.toString();}以上述程序为例,从硬盘中读取一个文本字符,操作流程如下:我们再来看看源码执行过程。当我们传入一个指定的文件名创建一个File对象,通过FileReader读取文件内容时,会自动创建一个FileInputStream对象来读取文件内容,也就是我们上面提到的读取文件的字节流。publicclassFileReaderextendsInputStreamReader{/***创建一个新的FileReader,给定要读取的*文件的名称。**@paramfileName要从中读取的文件的名称*@exceptionFileNotFoundException如果指定的文件不存在,*是目录而不是常规文件,*或由于某些其他原因无法打开以供*读取。*/publicFileReader(StringfileName)throwsFileNotFoundException{super(newFileInputStream(fileName));}紧接着会创建一个FileDescriptor对象,它实际上代表了一个已有文件对象的描述。您可以通过FileInputStream对象调用getFD()方法来获取与底层操作系统关联的文件描述。publicclassFileInputStreamextendsInputStream{/*文件描述*/privatefinalFileDescriptorfd;/*文件路径*/privatefinal字符串路径;publicFileInputStream(Filefile)throwsFileNotFoundException{Stringname=(file!=null?file.getPath():null);SecurityManager安全=System.getSecurityManager();如果(安全!=null){security.checkRead(名称);}if(name==null){thrownewNullPointerException();}if(file.isInvalid()){thrownewFileNotFoundException("无效文件路径");}fd=新文件描述符();fd.attach(这个);路径=名称;打开(名称);}由于我们需要读取的是字符格式,所以需要StreamDecoder类将byte解码为char格式。至于如何从磁盘驱动器中读取一段数据,操作系统会帮我们完成。六、基于网络操作的接口继续说说数据写到哪里的另一种处理方法:将数据写到互联网上,供其他电脑访问。6.1.Socket简介在现实中,Socket的概念并没有具体的实体。它是描述计算机之间相互通信的抽象定义。例如,Socket可以比作两个城市之间的交通工具。有了它,你就可以在城市之间来回穿梭。而且,交通工具多种多样,每种交通工具也有相应的交通规则。socket也是一样,种类也很多。大多数时候我们使用基于TCP/IP的流套接字,这是一种稳定的通信协议。基于Socket通信的典型应用场景如下图所示:主机A的应用程序要与主机B的应用程序进行通信,必须通过Socket建立连接,而Socket连接的建立必须要求底层TCP/IP协议来建立TCP连接。6.2.建立通信链路我们知道网络层使用的IP协议可以帮助我们根据IP地址找到目标主机,但是一台主机上可能运行着多个应用程序,那么如何与指定的应用程序进行通信就必须通过TCP或UPD的地址,即指定的端口号。这样,一个Socket实例就可以代表一个应用程序在唯一主机上的通信链路。为了准确的向目标投递数据,TCP协议采用三次握手策略,如下图所示:其中,SYN的全称是SynchronizeSequenceNumbers,意为同步序号,即TCP/IP建立连接时使用的握手信号。ACK的全称是Acknowledgecharacter,即确认字符,表示发送的数据已经确认无误接收。当客户端与服务器建立正常的TCP网络连接时,客户端首先发送SYN报文,服务器响应SYN+ACK表示收到报文,最后客户端响应ACK报文。这样就可以在客户端和服务器之间建立可靠的TCP连接,实现客户端和服务器之间的数据传输。简单流程如下:发送方-(发送带有SYN标志的数据包)->接收方(第一次握手);receiver–(sendpacketwithSYN+ACKflag)–>sender(secondhandshake)第一次握手);发送方——(发送带有ACK标志的数据包)——>接收方(第三次握手);完成三次握手后,客户端应用程序和服务器应用程序就可以开始传输数据了。数据传输是我们建立连接的主要目的,如何通过Socket传输数据呢?6.3.数据传输当客户端要与服务器进行通信时,客户端首先要创建一个Socket实例,操作系统默认会为这个Socket实例分配一个未使用的本地端口号,并创建一个包含本地和远程地址的套接字和端口号的socket数据结构,它将一直保留在系统中,直到连接关闭。/***Client*/publicclassClient{publicstaticvoidmain(String[]args)throwsIOException{Socketsocket=newSocket("127.0.0.1",9090);//向服务器PrintStream发送数据ps=newPrintStream(newBufferedOutputStream(socket.getOutputStream()));//读取服务器返回的数据BufferedReaderbr=newBufferedReader(newInputStreamReader(socket.getInputStream()));ps.println("你好!!");ps.flush();字符串信息=br.readLine();System.out.println(信息);ps.close();br.close();}}相应的服务器也会创建一个ServerSocket实例。ServerSocket的创建相对简单。只要指定的端口号没有被占用,一般的实例创建就会成功。同时操作系统也会为ServerSocket实例创建一个底层数据结构。这个数据结构包括指定的监听端口号,包含监听地址的通配符,通常是*,表示监听所有地址。之后,当accept()方法被调用时,就会进入阻塞状态,等待客户端的请求。/***Server*/publicclassServerTest{publicstaticvoidmain(String[]args)throwsIOException{//初始化服务器端口9090ServerSocketserverSocket=newServerSocket(9090);System.out.println("服务器已启动,端口号为9090...");//开启循环监听while(true){//等待客户端连接Socketaccept=serverSocket.accept();//将字节流转换为字符流,读取获取客户端发送的数据BufferedReaderbr=newBufferedReader(newInputStreamReader(accept.getInputStream()));//逐行读取客户端的数据Strings=br.readLine();System.out.println("服务端收到客户端的信息:"+s);我们先启动服务端程序,然后运行客户端,服务端接收到客户端发送的信息,服务端打印结果如下:握手方式建立成功,操作系统底层已经帮我们实现了TCP/IP的握手过程!当连接建立成功后,服务端和客户端都会有一个Socket实例,每个Socket实例都有一个InputStream和一个OutputStream。前面我们说过,网络I/O是以字节流的方式传输的,而Socket就是通过这两个对象来交换数据的。Socket对象创建时,操作系统会分别为InputStream和OutputStream分配一定大小的缓冲区,数据的写入和读取都是通过这个缓冲区完成的。写端向OutputStream对应的SendQ队列写入数据。当队列满时,数据会被发送到另一端InputStream的RecvQ队列。如果此时RecvQ满了,OutputStream的write方法就会阻塞,直到RecvQ队列有足够的空间容纳SendQ发送的数据。值得注意的是,缓存区的大小以及写入端和读取端的速度极大地影响了这个连接的数据传输效率。由于存在阻塞的可能,网络I/O和磁盘I/O都在数据写入的过程中。还有一个配合阅读的过程。如果双方同时传输数据,可能会出现死锁问题。如何提高网络IO传输效率,保证数据传输的可靠性,成为工程师们亟待解决的问题。6.4.IO工作模式在计算机中,IO传输数据有三种工作模式,分别是BIO、NIO和AIO。下一期我们会一一分析这三种IO的特点和原理。7.小结本文阐述了很多内容,从Java基础I/O类库结构入手,主要介绍了IO传输格式和传输方式,以及磁盘I/O和网络I/O的基本工作方式/欧。