《深入分析Java Web技术内幕》——2. 深入分析Java I/O的工作机制

一、Java 的 I/O类库的基本架构

传输数据的数据格式: 基于字节操作的I/O接口、基于字符操作的I/O接口 

传输数据的方式:基于磁盘操作的I/O接口 、基于网络操作的I/O接口 

1.1 基于字节操作的I/O接口 

InputStream、OutputStream

1.2 基于字符操作的I/O接口

Write、File

产生的原因:程序中通常的操作数据都以字符出现,为了操作方便 

数据持久化或网络传输都是以字节进行,需要字符<——>字节转码,StreamDecoder是完成字节到字符解码的实现类,StreamEncoder为编码过程。

1.3 基于磁盘操作的I/O接口 

File

磁盘由操作系统管理,read()和write()为两个系统调用,只要是系统调用则存在内核空间地址和用户空间地址切换的问题。空间的复制会使访问耗时,可以使用缓存机制解决。

1.3.1 访问文件的方式

1. 标准访问文件

        读取,即应用程序调用read()接口:操作系统检查在内核的高速缓存中有没有需要的数据,如果已经有缓存了,就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。

        写入,即应用程序调用write()接口:将数据从用户地址空间复制到内核地址空间的缓存中,这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统来决定,除非显式的调用了sync同步命令。

2. 直接I/O

直接访问磁盘,不经过操作系统的内核空间缓存区

优点:减少一次从内核缓存区到应用程序缓存的数据复制

缺点:如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。

应用场景:应用程序实现数据的缓存管理——数据库管理系统,系统可以知道哪些数据应该缓存、哪些应该实效,从而将热点数据预加载,加快访问效率。

通常直接I/O和异步I/O结合使用,会有比较好的性能。

3. 同步访问

指数据的读取和写入都是同步操作的,它与标准访问文件方式不同的是,只有当数据成功写入到磁盘时才返回给应用程序成功的标志,这种访问方式的性能比较差,只有对数据安全性要求比较高的场景中才会使用。通常这种操作方式的硬件都是定制的。

4. 异步访问

这种方式是当数据的线程发出请求之后,线程会接着去处理其他的事情,而不是阻塞等待,当请求的数据返回后继续处理下面的操作,这种方式可以明显的提高应用程序的效率,但是不会改变访问文件的效率。

5. 内存映射

它是指操作系统将内存中的某一块区域与磁盘中的文件关联起来,当要访问内存中的一段数据时,转换为访问文件的某一段数据,这种方式的目的同样是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。

1.3.2 Java访问磁盘文件

文件是数据在磁盘中的唯一最小描述,文件也是操作系统和磁盘驱动器交互的最小单元,上层应用程序只能通过文件来操作磁盘上的数据。在Java中通常的File并不代表一个真实存在的文件对象,当你指定一个路径描述符时,它就会返回一个代表这个路径的虚拟对象,这可能是一个真实存在的文件或者是一个包含多个文件的目录。

如何将数据持久化到物理磁盘?

当真正要读取这个文件时,如FileInputStream类就是一个操作文件的类,当在实例化一个FileInputStream对象时,就会创建一个FileDescriptor对象,这个对象就是代表一个真正存在文件的描述对象,当我们在操作一个文件对象时就可以通过getFD()方法获取真正操作的与底层操作系统相关联的描述,可以调用FileDescriptor.sync()方法将操作系统缓存中的数据强制刷新到物理磁盘中。

如何从磁盘读取一段文本字符?

同上步骤,需要增加一步:使用StreamDecoder类将byte解码为char格式

1.3.3 Java序列化技术

Java序列化就是将一个对象转化成一个二进制表示的字节数组,通过保存或转移这些字节数组来达到数据持久化的目的,需要持久化的类必须继承java.io.Serializable接口。反序列化则是相反的过程,将这个字节数组再重新构造成对象。但是反序列化时,必须有原始类作为模板,才能将这个对象还原。

当Java的序列化遇到一些复杂情况时的说明:

① 当父类继承Serializable接口时,所有的子类都可以被序列化;

② 子类实现了Serializable接口,父类没有实现,父类中的属性不能被序列化(不报错,但数据会丢失),子类中的属性仍能正确序列化;

③ 如果序列化的属性是对象,则这个对象也必须实现Serizliazble接口,否则会报错;

④ 在反序列化时,如果对象的属性有修改或删减,则修改的部分属性会丢失,但不会报错;

⑤ 在反序列化时,如果serialVersionUID被修改,则反序列化时会失败。

1.4 基于网络操作的I/O接口 

Socket

1.4.1 TCP状态转化

1.4.2 TCP三次握手

三次握手的过程如下:

① A发送同步信号SYN(Synchronization)+A的ISN序列号给B;

② B确认收到A的同步信号,并记录A的ISN到本地,命名为B的ACK(Acknowledgement);B发送同步信号SYN+B的ISN给A;

③ A确认到B的同步信号,并记录B的ISN到本地,命名为A的ACK。

TCP在传输时,连接一方的A由操作系统动态选取一个32位长的序列号(Initial Sequence Number),假设A的初始序列号为1000,以该序列号为原点,对自己将要发送的每个字节的数据进行编号,1001,1002,1003......并把自己的初始序列号ISN告诉B,让B知道什么样编号的数据是合法的,什么编号是非法的,比如900就是非法的,同时B可以对A发送的每一个字节的数据进行确认,如果A收到B的确认编号为2001,则意味着编号为1001-2000的字节,共1000个字节已经安全到达。同理,B也是类似的操作,假设B的初始序列号ISN为2000,以该序列号为原点,对自己将要发送的每个字节数据进行编号,2001,2002,2003......并把自己的初始序列号ISN告诉A,以便A可以确认B发送的每一个字节,然后,如果B收到A确认编号为4001,则意味着字节编号为2001-4000,共2000个字节已经安全到达。所以,由此可以得知,TCP三次握手,握的是通信双方数据原点的序列号。(https://blog.csdn.net/weixin_41395565/article/details/82974235

1.4.3 影响网络传输的因素

将一份数据从一个地方正确的传输到另外一个地方所需要的时间就被称之为响应时间,影响响应时间的因素有如下几点:

① 网络带宽:它是指一条物理链路在1s内能够传输的最大比特数(注意是比特不是字节,一个字节8bit);

② 传输距离:也就是数据在光纤中要走的距离,虽然光的转播速度很快,但由于数据在光纤中的移动并不是走直线的,会有一个折射率,大概是光的2/3,这个消耗的时间也就是通常我们所说的传输延时;

③ TCP拥塞控制:TCP传输是一个“停-等-停-等”的协议,传输方和接受方的步调要一致,要达到步调一致就要通过拥塞控制来调节,TCP在传输时会设定一个“窗口”,这个“窗口”的大小是由带宽和RTT(响应时间)来决定的,计算的公式是带宽(b/s)*RTT(s),通过这个值可以得出理论上最优的TCP缓冲区的大小。

4、Java Socket的工作机制

        Socket这个概念并没有对应到一个具体的实体,它描述的是计算机之间完成相互通信的一种抽象功能。大部分情况下,我们使用的都是基于TCP/IP的流Socket,它是一种稳定的通信协议。两个主机之间的应用程序通信,必须通过Socket建立连接,而建立Socket连接必须由底层TCP/IP来建立TCP连接,建立TCP连接需要底层IP来寻址网络中的主机。如何确定与主机上的哪个程序交互则有TCP或UDP的地址也就是端口号来指定。这样就可以通过一个Socket实例来唯一代表一个主机上的应用程序的通信链路。

5、建立通信链路

当客户端要与服务端通信时,客户端首先要创建一个Socket实例,操作系统将为这个Socket实例分配一个没有被使用的本地端口号,并创建一个包含本地地址、远程地址和端口号的套接字数据结构,这个数据结构将会一直保存在系统中,直到这个连接关闭。

与之对应的服务端将创建一个ServerSocket实例,这时操作系统也会为ServerSocket实例创建一个底层数据结构,在这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”,即监听所有地址,然后调用accept()方法进行阻塞状态,等待客户端发起请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字包含的地址信息和端口信息为请求的源地址和端口。这个新创建的数据结构将会关联到ServerSocket实例的一个未完成的连接数据结构列表中,这时服务端与之对应的Socket实例并没有完成创建,而是要等与客户端的三次握手完成之后,这个服务端的Socket实例才会返回,并将这个Socket实例对应的数据结构从未完成列表中移到已完成列表中,所以与ServerSocket所关联的列表中每个数据结构都代表与一个客户端建立的TCP连接。

6、数据传输

        数据传输是我们建立连接的目的,当连接建立成功时,服务端和客户端都会拥有一个Socket实例,每个实例都有一个InputStream和OutputStream,我们可以通过这两个对象来交换数据。网络I/O都是以字节流传输的,当创建Socket对象时,操作系统将会为InputStream和OutputStream分配一定大小的缓存区,数据的写入和读取都是通过这个缓存区来完成的,写入端将数据写到OutputStream对应的SendQ队列中,当数据填满时,数据将被转移到另一端InputStream的RecvQ队列中,如果这时RecvQ已经满了,那么OutputStream的write()方法将会阻塞,直到RecvQ队列有足够的空间容纳SendQ发送的数据。要注意的是,这个缓存区的大小及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能发生阻塞,所以网络I/O和磁盘I/O不同的是数据的写入和读取还要有一个协调的过程,如果两边同时传送数据可能会产生死锁。

四、NIO的工作方式

可参考链接: 攻破JAVA NIO技术壁垒

1、NIO的工作机制

        Channel和Selector它们是NIO的两个核心概念,Channel要比Socket更加具体,它代表每一个通信信道,Selector它可以轮询每个Channel的状态,还有一个Buffer类,我们可以通过Buffer来控制数据的传输。

      在应用中,我们通常会把Server端的监听连接请求的事件和处理请求的事件放在两个线程中,一个线程专门负责监听客户端的连接请求,而且是以阻塞方式执行的;另外一个线程专门负责处理请求,这个专门负责处理请求的线程才会真正采用NIO的方式,比如Web服务器Tomcat和Jetty都是采用这种方式。

下面是基于NIO工作方式的Socket请求处理方式的处理过程:

        Selector可以监听一组Channel上的I/O状态,前提是这些Channel已经注册到Selector中,Selector可以调用select()检查已经注册的通信信道上I/O是否已经准备好,如果没有通信信道状态发生变化,那么select方法会阻塞等待或在超时后返回0,如果多个信道有数据,那么它将会把这些数据分配到对应的Buffer中。所以NIO的关键是有一个线程来处理所有连接的数据交互,而每个连接的数据交互都不是阻塞方式,因此可以同时处理大量的连接请求。

2、Buffer的工作方式

Selector检测到通信信道I/O有数据传输时,通过select()去的SocketChannel,将数据读取或写入Buffer缓冲区。

可以把Buffer简单地理解为一组基本数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态:capacity, position, limit, mark:

索引说明
capacity缓冲区数组的总长度
position下一个要操作的数据元素的位置
limit缓冲区数组中不可操作的下一个元素的位置:limit<=capacity
mark用于记录当前position的前一个位置或者默认是-1

3、NIO的数据访问方式

        NIO提供了比传统的文件访问方式更好的方法,NIO有两个优化方法:一个是FileChannel.transferTo、FileChannel.transferFrom;另一个是FileChannel.map。

① FileChannel.transferXXX与传统的访问文件方式相比可以减少数据从内核到用户空间的复制,数据直接在内核空间中移动,在Linux中使用sendfile系统调用。

② FileChannel.map将文件按照一定大小块映射为内存区域,当程序访问这个内存区域时将直接操作这个文件数据,这种方式省去了数据从内核空间向用户空间复制的损耗。这种方式适合对大文件的只读性操作,如大文件的MD5校验。但是这个种方式是和操作系统底层I/O实现相关的。

五、I/O调优

1、磁盘I/O优化

① 增加缓存,减少磁盘访问次数;

② 优化磁盘的管理系统,设计最优的磁盘方式策略、磁盘寻址策略;

③ 设计合理的磁盘存储数据块,以及访问这些数据块的策略,比如我们可以给存放的数据设计索引,通过寻址索引来加快和减少磁盘的访问量,还可以采用异步和非阻塞的方式加快磁盘的访问速度;

④ 应用合理的RAID策略提升磁盘I/O

2、TCP网络参数调优

        要能够建立一个TCP连接,必须知道对方的IP和一个未被使用的端口号,由于32位的操作系统的端口号通常由两个字节来表示,也就是只有2^16=65535个,所以一台主机能够同时建立的连接数是有限的。在Linux中可以通过查看/proc/sys/net/ipv4/ip_local_port_range文件来知道当前这个主机可以使用的端口范围。

如果可以分配的端口号偏少,遇到大量并发请求时就会成为瓶颈,由于端口有限导致大量请求等待建立链接,这样性能就压不上去。如果发现有大量的TIME_WAIT时,可以设置/proc/sys/net/ipv4/tcp_fin_timeout为更小的值来快速释放请求。

3、网络I/O优化

① 减少网络交互的次数。

        要减少网络交互的次数通常需要在网络交互的两端设置缓存,如Oracle的JDBC就提供了对查询结果的缓存,在客户端和服务器端都有,可以有效减少对数据库的访问。除了设置缓存还可以合并访问请求,比如在查询数据库时,我们要查询10个ID,可以每次查一个ID,也可以一次查10个ID。再比如,在访问一个页面时通常会有多个JS和CSS文件,我们可以将多个JS文件合并在一个HTTP链接中,每个文件用逗号隔开,然后发送到后端的Web服务器。

② 减少网络传输数据量的大小。

        通常的办法是将数据压缩后再传输,比如在HTTP请求中,通常Web服务器将请求的Web页面gzip压缩后再传输给浏览器。还有就是通过设计简单的协议,尽量通过读取有用的协议头来获取有价值的信息,比如在设计代理程序时,4层代理和7层代理都是在尽量避免读取整个通信数据来获取所需要的信息。

③ 尽量减少编码。

        在网络传输中数据都是以字节形式进行传输的,但是我们要发送的数据都是字符形式的,从字符到字节必须编码,但是这个编码过程是比较费时的,所以在经过网络I/O传输时,尽量直接以字节形式发送。

④ 根据应用场景设计合适的交互方式。

a. 同步与异步

        同步就是一个任务的完成需要依赖另一个任务时,只有等待被依赖的任务完成后,依赖的任务才能完成,这是一种可靠的任务序列,要成功都成功,要失败都失败。而异步不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,只要自己完成了整个任务就算完成了,所以它是不可靠的任务序列,比如打电话和发信息。同步能够保证程序的可靠性,而异步可以提升程序的性能。

b. 阻塞与非阻塞

        阻塞就是CPU停下来等待一个慢的操作完成后,CPU才接着完成其它的工作,非阻塞就是在这个慢的操作执行时,CPU去做其它工作,等这个慢的操作完成时,CPU在完成后续的操作。虽然非阻塞的方式可以明显提高CPU的利用率,但是也可能有不好的效果,就是系统的线程切换会比较频繁。

c. 两种方式的组合

        组合的方式有四种,分别是:同步阻塞、异步阻塞、同步非阻塞、异步非阻塞,这四种方式对I/O性能都有影响,如下所示:

组合方式 性能分析
同步阻塞这种方式I/O性能一般很差,CPU大部分时间处于空闲状态
同步非阻塞这种方式通常能提升I/O性能,但是会增加CPU消耗
异步阻塞这种方式经常用于分布式数据库,比如在一个分布式数据库中,通常有一份是同步阻塞的记录,
还有2~3份会备份一起写到其他机器上,这些备记录通常都采用异步阻塞的方式来写I/O
异步非阻塞这种组合方式用起来比较复杂,只有在一些非常复杂的分布式情况下使用,集群之间的消息同步
机制一般使用这种I/O组合方式,它适合同时要传多份相同的数据到集群中的不同机器,同时数据
的传输量虽然不大却非常频繁的情况


注意:虽然异步和非阻塞能够提升I/O的性能,但是也会带来一些额外的性能成本,比如会增加线程数量从而增加CPU的消耗,同时也会导致程序设计复杂度的上升,如果设计的不合理,反而会导致性能下降。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值