I/O模型简介
I/O模型:
I/O操作需要内核系统调用来完成,系统调用需要Cpu来调度,而Cpu的访问速度相对于I/O来说比较快,所以Cpu不得不浪费Cpu时间来等待慢速I/O操作.
通过多进程方式来充分利用CPU资源,当还是希望让Cpu花费少的时间在I/O操作的调度上,这样就可以有更多的Cpu来完成I/O操作.
很多技术和策略都围绕如何让高速的Cpu和慢速的I/O设备更好的协调工作.
I/O操作主要是网络数据的接收和发送,以及磁盘文件的访问.归纳为多种模型称为I/O模型,本质区别在于Cpu的参与方式.
PIO和DMA:
慢速I/O设备和内存之间传输方式
PIO:磁盘和内存之间的数据传输需要Cpu控制,读取磁盘文件到内存的时候,数据需要经过Cpu存储转发.
DMA:可以不经过Cpu而直接进行磁盘和内存的数据交换,DMA模式下,Cpu只要向DMA控制器下达指令,让DMA控制器来处理数据的传输即可.DMA控制器通过系统总线来传输数据,传输
完毕后再通知Cpu.这样减低了Cpu占有率.
同步阻塞I/O:
I/O等待例子很多,比如web服务器等待用户请求.比如服务器与浏览器建立TCP链接后,需要等待用户发送HTTP请求.
I/O等待不可避免,有等待就会有阻塞.阻塞指的是当前发起I/O操作的进程被阻塞,并不是Cpu被阻塞,实际上没有什么能让Cpu阻塞,它只知道干活.
同步阻塞I/O指当前进程调用I/O操作的系统调用或者库函数时候,比如accept,send,recv等,进程便暂停,等待I/O操作完成后再继续运行.这样的I/O模型可以和多进程结合有效利用Cpu资源.
同步非阻塞I/O:
在同步阻塞I/O中,进程实际上等待的时间包括二部分,一个是等待数据的就绪,另外一个是等待数据的复制.对于网络I/O来说,前者的时间可能更长.
同步非阻塞I/O的调用不会等待数据的就绪,如果数据不可读写,则立即告诉进程.这种非阻塞I/O结合反复轮询来尝试数据是否就绪,防止进程被阻塞.最大的好处便在于可以在同一个进程里
同时处理多个I/O操作.但是由于一直在多次轮询查看数据是否就绪,花费大量Cpu时间.非阻塞I/O一般只针对网络I/O有效,对于磁盘I/O非阻塞I/O不产生效果.
多路I/O就绪通知:
web服务器同时处理大量文件描述符必不可少.由于要对socket检查是否可以接收的数据,则会浪费太多的Cpu时间.
多路I/O就绪通知出现,提供了对文件描述符就绪检查的高性能方案.允许进程通过一种方法来同时监视所有文件描述符.并可以快速获得所有就绪的文件描述符.然后只针对这些描述符进行数据访问.
I/O就绪通知只是帮助快速获得就绪的文件描述符.当得知数据就绪后,就访问数据本身而言.仍然需要选择阻塞和非阻塞的访问方式.多路I/O就绪通知有多种实现.
select:
通过一个select系统调用来监视包含多个文件描述符的数组.当select返回后,该数组中就绪的文件描述符会被内核修改标志位.使得进程可以获得这些文件描述符从而进行读写操作.
select的缺点
1)单个进程能够监视的文件描述符的数量存在最大限制.
2)select维护的存储大量文件描述符的数据结构,随着文件描述符量的增大,复制的开销也线性增长.
3)网络响应时间的延迟使得大量Tcp链接处于非活跃状态,但是select会对所有socket进行一次性扫描,也浪费了一定开销.
poll:
没有最大文件描述符的限制.select和poll将就绪的文件描述符告诉进程后,如果进程没有对其进行I/O操作,下次调用select和poll的时候将再次报告这件文件描述符.所以不会丢失就绪的
消息,该方式称为水平触发.
sigio:
sigio不是每次都告诉我们文件描述符是否就绪,而是高速文件描述符刚刚变为就绪,只说一次,称呼为边缘触发.
/dev/poll:
使用虚拟的/dev/poll设备,可以将有监视的文件描述符数组写入这个设备,然后通过ioctl来等待时间通知.没有提供直接的内核支持,所以性能不是很稳定.
/dev/epoll:
在/dev/epool的基础上增加了内存映射.
epoll:
本质的改进是epoll基于事件的就绪通知,通过callback的机制来激活文件描述符.
内存映射:
linux内核提供一种访问文件的特殊方式.可以将内存中某块地址空间和制定的磁盘文件相关联.从而对这块内存的访问转换为对磁盘文件的访问.
大部分情况下,使用内存映射可以提高磁盘I/O访问的性能,因为无须read或者write系统调用,而是通过mmap系统调用建立内存和磁盘文件的关联,然后像访问内存一样只有访问文件.
直接I/O:
linux2.6中,内存映射和直接访问文件没有本质区别.因为数据从进程用户态内存空间到磁盘需要经过二次复制,及在磁盘与内核缓存区之间以及在内核缓存区与用户态内存空间.
引入内核缓存区的目的在于提高磁盘文件的访问性能.比如进程读取磁盘文件的时,如果文件已经在内核缓冲区中,那么就无须再访问磁盘.而进程需要向文件中写入数据的时候.则
直接写到内核缓存区便告诉进程已经成功写入,写入磁盘是通过一定策略进行延迟.
为了充分提高性能,系统绕过内核缓存区,由自己在用户空间实现管理I/O缓存区,比如数据库的缓存机制和写延迟机制.
Linux提供了对这种需求的支持,在open系统调用的时候增加参数选项O_DIRECT.用它打开的文件便可以通过内核缓存区的直接访问,有效避免了Cpu和内存的多余时间开销.
Sendfile:
引入Sendfile在于内核希望请求的处理尽量在内核完成,减少内核态的切换以及用户数据复制的开销,Linux通过系统调用提高这种机制,它可以将磁盘文件的特定部分直接传送到客户端的Socket
描述符,所以一般使用在网卡传输,减少cpu和内存的开销.
异步I/O:
同步和异步,阻塞和非阻塞容易被混用,其实完全不一样,修饰的对象也不同
===================================================================================================================================
Java I/O底层工作原理
本文中讨论有:
缓存处理和内核vs用户空间
缓冲与缓冲的处理方式,是所有I/O操作的基础。术语“输入、输出”只对数据移入和移出缓存有意义。任何时候都要把它记在心中。通常,进程执行操作系统的I/O请求包括数据从缓冲区排出(写操作)和数据填充缓冲区(读操作)。这就是I/O的整体概念。在操作系统内部执行这些传输操作的机制可以非常复杂,但从概念上讲非常简单。我们将在文中用一小部分来讨论它。
上图显示了一个简化的“逻辑”图,它表示块数据如何从外部源,例如一个磁盘,移动到进程的存储区域(例如RAM)中。首先,进程要求其缓冲通过read()系统调用填满。这个系统调用导致内核向磁盘控 制硬件发出一条命令要从磁盘获取数据。磁盘控制器通过DMA直接将数据写入内核的内存缓冲区,不需要主CPU进一步帮助。当请求read()操作时,一旦磁盘控制器完成了缓存的填 写,内核从内核空间的临时缓存拷贝数据到进程指定的缓存中。
有一点需要注意,在内核试图缓存及预取数据时,内核空间中进程请求的数据可能已经就绪了。如果这样,进程请求的数据会被拷贝出来。如果数据不可用,则进程被挂起。内核将把数据读入内存。
虚拟内存
你可能已经多次听说过虚拟内存了。让我再介绍一下。
所有现代操作系统都使用虚拟内存。虚拟内存意味着人工或者虚拟地址代替物理(硬件RAM)内存地址。虚拟地址有两个重要优势:
- 多个虚拟地址可以映射到相同的物理地址。
- 一个虚拟地址空间可以大于实际可用硬件内存。
在上面介绍中,从内核空间拷贝到最终用户缓存看起来增加了额外的工作。为什么不告诉磁盘控制器直接发送数据到用户空间的缓存呢?好吧,这是由虚拟内存实现的。用到了上面的优势1。
通过将内核空间地址映射到相同的物理地址作为一个用户空间的虚拟地址,DMA硬件(只能访问物理内存地址)可以填充缓存。这个缓存同时对内核和用户空间进程可见。
这就消除了内核和用户空间之间的拷贝,但是需要内核和用户缓冲区使用相同的页面对齐方式。缓冲区必须使用的块大小的倍数磁盘控制器(通常是512字节的磁盘扇区)。操作系统将其内存地址空间划分为页面,这是固定大小的字节组。这些内存页总是磁盘块大小的倍数和通常为2倍(简化寻址)。典型的内存页面大小是1024、2048和4096字节。虚拟和物理内存页面大小总是相同的。
内存分页
为了支持虚拟内存的第2个优势(拥有大于物理内 存的可寻址空间)需要进行虚拟内存分页(通常称为页交换)。这种机制凭借虚拟内存空间的页可以持久保存在外部磁盘存储,从而为其他虚拟页放入物理内存提供了空间。本质上讲,物理内存担当了分页区域的缓存。分页区是磁盘上的空间,内存页的内容被强迫交换出物理内存时会保存到这里。
调整内存页面大小为磁盘块大小的倍数,让内核可以直接发送指令到磁盘控制器硬件,将内存页写到磁盘或者在需要时重新加载。事实证明,所有的磁盘I/O操作都是在页面级别上完成的。这是数据在现代分页操作系统上在磁盘与物理内存之间移动的唯一方式。
现代CPU包含一个名为内存管理单元(MMU)的子系统。这 个设备逻辑上位于CPU与物理内存之间。它包含从虚拟地址向物理内存地址转化的映射信息。当CPU引用一个内存位置时,MMU决定哪些页需要驻留(通常通过移位或屏蔽地址的某些位)以及转化虚拟页号到物理页号(由硬件实现,速度奇快)。
面向文件、块I/O
文件I/O总是发生在文件系统的上下文切换中。文件系统跟磁盘是完全不同的事物。磁盘按段存储数据,每段512字节。它是硬件设备,对保存的文件语义一无所知。它们只是提供了一定数量的可以保存数据的插槽。从这方面来说,一个磁盘的段与 内存分页类似。它们都有统一的大小并且是个可寻址的大数组。
另一方面,文件系统是更高层抽象。文件系统是安排和翻译保存磁盘(或其它可随机访问,面向块的设备)数据的一种特殊方法。你写的代码几乎总是与文件系统交互,而不与磁盘直接交互。文件系统定义了文件名、路径、文件、文件属性等抽象。
一个文件系统组织(在硬盘中)了一系列均匀大小的数据块。有些块保存元信息,如空闲块的映射、目录、索引等。其它块包含实际的文件数据。单个文件的元信息描述哪些块包含文件数据、数据结束位置、最后更新时间等。当用户进程发送请求来读取文件数据时,文件系统实现准确定位数据在磁盘上的位置。然后采取行动将这些磁盘扇区放入内存中。
文件系统也有页的概念,它的大小可能与一个基本内存页面大小相同或者是它的倍数。典型的文件系统页面大小范围从2048到8192字节,并且总是一个基本内存页面大小的倍数。
分页文件系统执行I/O可以归结为以下逻辑步骤:
- 确定请求跨越了哪些文件系统分页(磁盘段的集合)。磁盘上的文件内容及元数据可能分布在多个文件系统页面上,这些页面可能是不连续的。
- 分配足够多的内核空间内存页面来保存相同的文件系统页面。
- 建立这些内存分页与磁盘上文件系统分页的映射。
- 对每一个内存分页产生分页错误。
- 虚拟内存系统陷入分页错误并且调度pagins(页面调入),通过从磁盘读取内容来验证这些页面。
- 一旦pageins完成,文件系统分解原始数据来提取请求的文件内容或属性信息。
需要注意的是,这个文件系统数据将像其它内存页一样被缓存起来。在随后的I/O请求中,一些数据或所有文件数据仍然保存在物理内存中,可以直接重用不需要从磁盘重读。
文件锁定
文件加锁是一种机制,一个进程可以阻止其它进程访问一个文件或限制其它进程访问该文件。虽然名为“文件锁定”,意味着锁定整个文件(经常做的)。锁定通常可以在一个更细粒度的水平。随着粒度下降到字节级,文件的区域通常会被锁定。锁与特定文件相关联,起始于文件的指定字节位置并运行到指定的字节范围。这一点很重要,因为它允许多个进程协作访问文件的特定区域而不妨碍别的进程在文件其它位置操作。
文件锁有两种形式:共享和独占。多个共享锁可以同时在相同的文件区域有效。另一方面,独占锁要求没有其它锁对请求的区域有效。
流I/O
并非所有的I/O是面向块的。还有流I/O,它是管道的原型,必须顺序访问I/O数据流的字节。常见的数据流有TTY(控制台)设备、打印端口和网络连接。
数据流通常但不一定比块设备慢,提供间歇性输入。大多数操作系统允许在非阻塞模式下工作。允许一个进程检查数据流的输入是否可用,不必在不可用时发生阻塞。这种管理允许进程在输入到达时进行处理,在输入流空闲时可以执行其他功能。
比非阻塞模式更进一步的是有条件的选择(readiness selection)。它类似于非阻塞模式(并且通常建立在非阻塞模式基础上),但是减轻了操作系统检查流是否就绪准的负担。操作系统可以被告知观察流集合,并向进程返回哪个流准备好的指令。这种能力允许进程通过利用操作系统返回 的准备信息,使用通用代码和单个线程复用多个活动流。这种方式被广泛用于网络服务器,以便处理大量的网络连接。准备选择对于大容量扩展是至关重要的。
===================================================================================================================================
Java高效率的读取大文件
在内存中读取
读取文件行的标准方式是在内存中读取,Guava 和Apache Commons IO都提供了如下所示快速读取文件行的方法:
Files.readLines(new File(path), Charsets.UTF_8);
FileUtils.readLines(new File(path));
这种方法带来的问题是文件的所有行都被存放在内存中,当文件足够大时很快就会导致程序抛出OutOfMemoryError 异常。
例如:读取一个大约1G的文件:
@Test
public void givenUsingGuava_whenIteratingAFile_thenWorks() throws IOException {
String path = ...
Files.readLines(new File(path), Charsets.UTF_8);
}
这种方式开始时只占用很少的内存:(大约消耗了0Mb内存)
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 128 Mb
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 116 Mb
然而,当文件全部读到内存中后,我们最后可以看到(大约消耗了2GB内存):
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 2666 Mb
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 490 Mb
这意味这一过程大约耗费了2.1GB的内存——原因很简单:现在文件的所有行都被存储在内存中。
把文件所有的内容都放在内存中很快会耗尽可用内存——不论实际可用内存有多大,这点是显而易见的。
此外,我们通常不需要把文件的所有行一次性地放入内存中——相反,我们只需要遍历文件的每一行,然后做相应的处理,处理完之后把它扔掉。所以,这正是我们将要做的——通过行迭代,而不是把所有行都放在内存中。
3、文件流
现在让我们看下这种解决方案——我们将使用java.util.Scanner类扫描文件的内容,一行一行连续地读取:
FileInputStream inputStream = null;
Scanner sc = null;
try {
inputStream = new FileInputStream(path);
sc = new Scanner(inputStream, "UTF-8");
while (sc.hasNextLine()) {
String line = sc.nextLine();
// System.out.println(line);
}
// note that Scanner suppresses exceptions
if (sc.ioException() != null) {
throw sc.ioException();
}
} finally {
if (inputStream != null) {
inputStream.close();
}
if (sc != null) {
sc.close();
}
}
这种方案将会遍历文件中的所有行——允许对每一行进行处理,而不保持对它的引用。总之没有把它们存放在内存中:(大约消耗了150MB内存)
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Total Memory: 763 Mb
[main] INFO org.baeldung.java.CoreJavaIoUnitTest - Free Memory: 605 Mb
4、Apache Commons IO流
同样也可以使用Commons IO库实现,利用该库提供的自定义LineIterator:
LineIterator it = FileUtils.lineIterator(theFile, "UTF-8");
try {
while (it.hasNext()) {
String line = it.nextLine();
// do something with line
}
} finally {
LineIterator.closeQuietly(it);
}
由于整个文件不是全部存放在内存中,这也就导致相当保守的内存消耗:(大约消耗了150MB内存)
[main] INFO o.b.java.CoreJavaIoIntegrationTest - Total Memory: 752 Mb
[main] INFO o.b.java.CoreJavaIoIntegrationTest - Free Memory: 564 Mb
5、结论
这篇短文介绍了如何在不重复读取与不耗尽内存的情况下处理大文件——这为大文件的处理提供了一个有用的解决办法。
现给出部分程序示例代码
</pre><pre name="code" class="java">import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class Test {
public static void main(String[] args) {
try {
FileInputStream fis=new FileInputStream("/home/tobacco/test/res.txt");
int sum=0;
int n;
long t1=System.currentTimeMillis();
try {
while((n=fis.read())>=0){
sum+=n;
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
try {
FileInputStream fis=new FileInputStream("/home/tobacco/test/res.txt");
BufferedInputStream bis=new BufferedInputStream(fis);
int sum=0;
int n;
long t1=System.currentTimeMillis();
try {
while((n=bis.read())>=0){
sum+=n;
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
MappedByteBuffer buffer=null;
try {
buffer=new RandomAccessFile("/home/tobacco/test/res.txt","rw").getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 1253244);
int sum=0;
int n;
long t1=System.currentTimeMillis();
for(int i=0;i<1253244;i++){
n=0x000000ff&buffer.get(i);
sum+=n;
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
测试文件为一个大小为1253244字节的文件。测试结果:
sum:220152087 time:1464 sum:220152087 time:72 sum:220152087 time:25
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
public class Test {
public static void main(String[] args) {
try {
FileInputStream fis=new FileInputStream("/home/tobacco/test/res.txt");
int sum=0;
int n;
long t1=System.currentTimeMillis();
try {
while((n=fis.read())>=0){
//sum+=n;
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
try {
FileInputStream fis=new FileInputStream("/home/tobacco/test/res.txt");
BufferedInputStream bis=new BufferedInputStream(fis);
int sum=0;
int n;
long t1=System.currentTimeMillis();
try {
while((n=bis.read())>=0){
//sum+=n;
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
MappedByteBuffer buffer=null;
try {
buffer=new RandomAccessFile("/home/tobacco/test/res.txt","rw").getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 1253244);
int sum=0;
int n;
long t1=System.currentTimeMillis();
for(int i=0;i<1253244;i++){
//n=0x000000ff&buffer.get(i);
//sum+=n;
}
long t=System.currentTimeMillis()-t1;
System.out.println("sum:"+sum+" time:"+t);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
测试结果:
sum:0 time:1458 sum:0 time:67 sum:0 time:8
由此可见,将文件部分或者全部映射到内存后进行读写,速度将提高很多。
这是因为内存映射文件首先将外存上的文件映射到内存中的一块连续区域,被当成一个字节数组进行处理,读写操作直接对内存进行操作,而后再将内存区域重新映射到外存文件,这就节省了中间频繁的对外存进行读写的时间,大大降低了读写时间。