【面试基础】读写多个大文件,有哪些优化方案?
面试官:假如我们对客户端的一些日志信息需要写入文件,然后把文件上传云端。在这种场景中有大量的文件读写操作,你有什么优化方案?
我:我通常都是使用BufferedInputStream处理文件,大文件处理没有遇到过还不知道怎么优化,😭...
这个问题非常考验基本功,可能日常开发,文件操作没有很频繁,根本不知道为啥要优化?对于这类IO读写问题?我们需要搞清楚一次IO读写操作的工作流程,以及性能瓶颈问题在哪里?以及当前已有的优化方案?知道这些就可以根据不同业务场景找到适合自己的优化方案。
一、IO读写操作的工作流程
我们先来理解两个概念Page Cache
和DMA
:
Page Cache
Page cache 是一种操作系统内核的功能,用于在内存中缓存磁盘上的数据页。其中文名称为页高速缓冲存储器,通常存储在系统的动态随机存取存储器(DRAM)中,它是为了加快访问速度而设计的,因为内存存取速度比磁盘存取速度快得多。当系统需要访问某个文件或数据时,如果数据页已经存在于 page cache 中,系统就可以直接从内存中读取数据,而不必访问慢速的磁盘。
DRAM和SRAM是RAM的两种主要类型,DRAM的存储位使用一个电容和一个晶体管,需要定期刷新以保持数据的稳定性,容量较大,但访问速度相对较慢。SRAM的每个存储单元使用多个晶体管来存储一个位,与DRAM相比,SRAM速度更快、不需要刷新,并且较为稳定,SRAM适用于高速缓存和寄存器组中,提供快速访问速度。成本较高,容量相对较小
当应用程序需要读取一个文件时,内核会首先检查这个文件的数据页是否已经位于 page cache
中。如果数据页已经在 page cache
中,内核会立即返回数据给应用程序,从而提高访问速度。如果数据页不在 page cache
中,内核就会从磁盘读取数据到内存中,并将其保存在 page cache
中以备后续访问。在内存中可用空间不足时,内核会使用一些替换算法(例如最近最少使用算法)来决定换出哪些数据页,以便为新的数据页腾出空间。通过使用 page cache
,系统可以在访问文件时减少磁盘I/O操作,从而提高系统性能和响应速度。此外,Page Cache
的大小并没有固定的标准大小,而是根据系统的环境和需求来动态调整。操作系统会根据可用内存和需要缓存的数据来管理 Page Cache
,以提高系统性能和响应速度。
在Linux系统中,Page Cache
维护了两个重要的队列,分别是活跃列表(Active List)和非活跃列表(Inactive List)。这两个队列帮助系统优化页面换入换出的效率,从而提高缓存管理的性能。
活跃列表(Active List):活跃列表包含了系统最近被频繁访问的页面。这些页面被认为是“热”页面,因此Linux内核会更倾向于保留它们,避免在后续的访问中频繁地将其换出到磁盘上。通过保持活跃列表中的页面,系统可以更快地响应对这些页面的后续访问请求,提高性能。
非活跃列表(Inactive List):非活跃列表包含了系统较少被访问的页面。这些页面被认为是“冷”页面,即不太可能被频繁使用。当系统需要释放内存以供其他用途时,Linux内核会将部分非活跃页面移到非活跃列表中,并在需要时将它们换出到磁盘上。这种机制允许系统在内存压力下更好地管理页面和缓存。
DMA
DMA是“Direct Memory Access(直接内存访问)”的缩写。DMA是一种计算机系统中用于数据传输的技术,允许外部设备(如网络适配器、硬盘控制器、显卡等)直接访问计算机的内存,而无需通过中央处理单元(CPU)的参与。 传统上,数据在计算机系统中的传输是由CPU负责控制的,即通过CPU将数据从一个设备传输到另一个设备或内存。这种方式会占用CPU的大量时间和处理能力,从而限制了系统的性能和专门用途设备的效率。 使用DMA技术,外部设备可以直接访问系统内存,而无需CPU的介入。DMA可以帮助提高数据传输的速度和效率,减少CPU的负担,从而释放CPU的处理能力用于主要的计算任务。DMA常用于大容量数据的传输,如文件传输、视频流处理等,以提高系统的整体性能。
在使用DMA(Direct Memory Access)进行文件读取时,CPU、内核和用户空间之间的工作情况如下:
- 用户空间请求文件读取: 用户空间应用程序通过系统调用(如read()系统调用)请求内核从文件系统中读取数据。
- 内核准备DMA传输: 当内核收到用户空间应用程序的读取请求,它会开始准备DMA传输。内核会将要读取的数据块的内存地址(缓冲区)和外设(如硬盘)之间进行DMA传输的相关配置工作。
- DMA传输数据: DMA控制器负责将文件数据从外设(如硬盘)传输到内存缓冲区,跳过了CPU这一步骤。这样,数据传输的过程中CPU可以执行其他任务,而不必等待数据传输完成。
- 数据到达内存: 一旦DMA传输完成,数据将被写入用户空间应用程序所请求的内存缓冲区中。
- 内核通知用户空间: 完成数据传输后,内核会通知用户空间应用程序数据已就绪,用户空间应用程序可继续处理数据。
工作流程
第一次拷贝,是把数据从磁盘拷贝到操作系统内核缓冲区,这次拷贝是DMA搬运的。 第二次拷贝,是把数据从操作系统内核缓冲区拷贝到用户进程的缓冲区,然后用户进程就可以使用了,这次拷贝是CPU完成的。 第三次拷贝,将用户进程的缓冲区的数据拷贝到操作系统内核缓冲区,依然是CPU完成拷贝。 第四次拷贝,把数据从操作系统内核缓冲区写入磁盘,这个过程是DMA搬运完成。
传统IO,基于 page cache
一次读写文件,要进行4次cpu copy
,4次上下文切换。传统IO在介入DMA技术以后,基于 page cache
一次读写文件,要进行2次cpu copy
,2次DMA copy
,4次上下文切换。
二、IO读写操作的性能瓶颈
我们了解基本的文件读写是在用户进程,到内核,再到磁盘上的多次复制拷贝,多次系统调用和进程切换。那么这中间的性能问题也在其中。
-
CPU拷贝 当数据从磁盘读取或写入时,需要将数据从内核缓冲区拷贝到用户空间缓冲区,然后再从用户空间缓冲区拷贝到内核缓冲区。CPU需要协调数据从内核缓冲区到用户空间缓冲区中,这个过程中涉及CPU的运算和数据拷贝,CPU在数据拷贝过程中会占用一定的计算资源,频繁的数据拷贝操作可能导致CPU资源的过度占用,从而影响CPU的处理能力,使系统整体性能下降。
-
上下文切换 在IO读写过程中,可能会涉及到多个进程,进程在切换时需要保存和恢复上下文信息。当一个进程的IO操作阻塞,操作系统会进行上下文切换,切换到另一个就绪进程执行。频繁的进程上下文切换会消耗大量的时间和资源,特别是在涉及大量IO操作的情况下,进程上下文切换可能成为系统性能的瓶颈。
-
缓冲区 缓冲区大小影响数据在IO过程中的传输效率和内存占用,若缓冲区大小设置过小,可能导致频繁的IO操作和数据拷贝,增加系统开销;反之,设置过大可能引起内存资源的浪费。 CPU拷贝、进程上下文切换、缓冲区大小等维度问题相互关联,会对IO操作的效率和系统整体性能产生综合影响。
当然还有其他性能瓶颈问题,比如磁盘访问延迟,文件系统的影响,IO操作过程中的异常处理等等,这里就不做重点说明了。
三、当前已有的优化方案
sendfile
Sendfile技术是一种优化的IO数据传输方法,其主要作用是将一个文件描述符上的数据直接发送到另一个文件描述符上,避免了数据在用户态与内核态之间来回拷贝的开销,提高了数据传输的效率。
如下代码使用系统调用sendfile()示例:
- 打开源文件和目标文件的文件描述符。
- 调用sendfile()函数,将源文件的数据传输到目标文件的文件描述符,并指定传输的字节数。
- Sendfile()函数在内核空间进行数据传输,避免了中间缓冲区的使用,提高了传输效率。
#include <fcntl.h>
#include <sys/sendfile.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int src_fd, dest_fd;
off_t offset = 0;
struct stat stat_buf;
// 打开源文件和目标文件
src_fd = open("source_file.txt", O_RDONLY);
dest_fd = open("target_file.txt", O_WRONLY|O_CREAT, 0644);
// 获取源文件的文件信息
fstat(src_fd, &stat_buf);
// 使用sendfile函数传输数据
sendfile(dest_fd, src_fd, &offset, stat_buf.st_size);
// 关闭文件描述符
close(src_fd);
close(dest_fd);
return 0;
}
示例展示了sendfile()函数的基本用法,通过该函数能够高效地将源文件的内容传输到目标文件中,减少了数据在用户态和内核态之间的拷贝次数,从而提高了IO读写效率。在实际应用中,可以根据具体需求调整参数和逻辑。 Sendfile技术常用于Web服务器的文件下载,通过sendfile函数直接将静态文件的数据传输给客户端,提高服务性能和响应速度。sendfile函数可以快速将大文件内容复制到另一个位置,也能够快速将大量数据写入日志文件,或者在进行数据库备份时,也可以使用sendfile函数将数据库文件直接传输到备份文件中,避免了数据的多次拷贝,提高备份速度。
splice
Splice 是一种高效的数据传输方法,它允许将数据从一个文件描述符(file descriptor)传输到另一个文件描述符,而无需在用户空间缓冲区保存数据,同时也能避免数据的多次复制。这种零拷贝的特性使得数据传输更加高效。
对应的系统方法是
ssize_t splice()
这个函数允许在两个文件描述符之间直接传送数据,而无需在用户空间缓冲区进行数据的拷贝。
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags)
的说明:
fd_in
: 源文件描述符,从该文件描述符中读取数据。
off_in
: 源文件描述符中的偏移量(如果为 NULL,则表示不使用偏移量)。
fd_out
: 目标文件描述符,向该文件描述符写入数据。
off_ou
t: 目标文件描述符中的偏移量(如果为 NULL,则表示不使用偏移量)。
len
: 要传送的数据字节数。
flags
: 传输标志,可以是 SPLICE_F_MOVE
进行移动数据,或 SPLICE_F_NONBLOCK
进行非阻塞操作等。
以下是一个简单的 C 代码示例,展示了 splice() 函数的基本用法:
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#define BUFSIZE 4096
int main() {
int fd_in, fd_out;
ssize_t ret;
char buf[BUFSIZE];
fd_in = open("input.txt", O_RDONLY);
fd_out = open("output.txt", O_WRONLY | O_CREAT, 0666);
if (fd_in == -1 || fd_out == -1) {
perror("Error opening file");
return 1;
}
while ((ret = splice(fd_in, NULL, fd_out, NULL, BUFSIZE, 0)) > 0) {
// 数据传输循环,直到读取完成
}
close(fd_in);
close(fd_out);
return 0;
}
该示例中的代码打开了一个输入文件和一个输出文件,然后使用 splice() 函数将输入文件的内容传输到输出文件中。
使用 Splice 技术来将数据从一个套接字(socket)传输到另一个套接字,在文件处理中,可以将大文件内容传输到另一个文件中,而无需在用户空间缓冲区保存数据,节省内存开销,Splice 技术是零拷贝操作的一种实现方式,可以避免数据在用户空间和内核空间之间的多次复制,从而提高数据传输效率..
mmap
mmap(Memory Mapped I/O)技术允许将一个文件或设备映射到进程的地址空间,使得文件或设备的内容可以直接通过内存进行读写操作,从而避免了传统的 read/write 操作中进行数据复制的开销。
void *mmap(void *addr, st_size len, int prot,int flags, int fd, off_t offset)
- addr地址指针:指定欲映射的内存区域的起始地址,通常为 0。 如果传入 0,系统会自动选择一个合适的地址;如果传入一个具体的地址,则映射的地址就是这个地址。
- length长度:指定欲映射的内存区域的长度(字节数)。
- prot访问权限:指定映射内存区域的访问权限:
PROT_READ
:可读,PROT_WRITE
:可写,PROT_EXEC
:可执行,PROT_NONE
:无权限。 - flags标志位:用来指定映射选项:
MAP_SHARED
:对映射区所做的修改会反映到被映射的文件中。MAP_PRIVATE
:对映射区所做的修改不会反映到被映射的文件中。MAP_ANONYMOUS
:映射一个匿名的映射区,不与文件关联。 - fd文件描述符:要映射的文件的文件描述符。如果使用 MAP_ANONYMOUS,可以传入 -1。
- offset偏移量:指定文件映射的起始位置。对于文件映射,表示文件中的偏移量,对于匿名映射,通常应该设置为 0。
函数 mmap() 的工作流程:
- 调用 mmap() 函数将文件或设备映射到进程的地址空间。
- 通过映射后得到的指针进行读写操作,这样读写可以直接操作内存,无需进行数据复制。
- 调用 munmap() 函数解除映射。
#include <sys/mman.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd;
char *mapped;
// 打开文件
fd = open("sample.txt", O_RDWR);
if (fd == -1) {
perror("Error opening file");
return 1;
}
// 映射文件到内存
mapped = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
perror("Error mapping memory");
close(fd);
return 1;
}
// 在内存中操作映射的数据
// (这里对映射的内存区域可以进行读写操作)
// 解除映射
if (munmap(mapped, 4096) == -1) {
perror("Error unmapping memory");
close(fd);
return 1;
}
// 关闭文件
close(fd);
return 0;
}
mmap() 方法的返回值,成功情况下返回值是新映射区的起始地址(通常为 void *
类型)。失败情况下返回值为 MAP_FAILED
,通常为 -1 或者 (void *)-1
。可以通过 errno 来获取具体的错误信息。
mmap适合在文件频繁操作的情况下,比如mmkv
其中的原理是使用mmap操作文件,或者在进程间通讯,作为进程间的共享内存。
这里只是简单示例,有兴趣的小伙伴们自己再去搜索mmap方法的详细参数意义哈。
Direct I/O
Direct I/O 是一种绕过文件系统缓存,直接进行磁盘 I/O 操作的技术。他脱离了page cache,它适用于需要绕过文件系统缓存、直接与磁盘进行数据读写的场景,如数据库系统、大型数据处理等。
通过 open()
函数打开文件时,可以指定文件的打开模式(如只读、只写、读写等)以及特定的标志(如 O_DIRECT
表示进行 Direct I/O)。当使用 O_DIRECT 标志时,文件的读写将绕过文件系统缓存,直接进行磁盘 I/O 操作。
使用示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd;
char buffer[4096];
// 打开文件并指定 O_DIRECT 标志以进行 Direct I/O
fd = open("sample.txt", O_RDWR | O_DIRECT);
if (fd == -1) {
perror("Error opening file");
return 1;
}
// 读取数据到 buffer 中
if (read(fd, buffer, sizeof(buffer)) == -1) {
perror("Error reading from file");
close(fd);
return 1;
}
// 在 buffer 中写入数据
// (这里可以写入任意数据到 buffer 中,之后可以使用 write() 写入到文件中)
// 关闭文件描述符
close(fd);
return 0;
}
Direct I/O技术的经典应用场景就是数据库管理系统了。对于读取操作,数据直接从磁盘读取到应用程序的内存空间,而不会存放在文件系统缓存中。对于写入操作,数据直接从应用程序的内存空间写入到磁盘,也不会经过文件系统缓存。
比较
名称 | cpu拷贝 | DMA拷贝 | 上下文切换 |
---|---|---|---|
传统IO(read/write) | 2 | 2 | 4 |
mmap | 1 | 2 | 4 |
sendfile | 1 | 2 | 2 |
splice | 0 | 2 | 2 |
Direct I/O | 0 | 2 | 0 |
四、java中的直接可用方案
java代码在应用进程中,基本属于用户空间,已有的优化方案大多是在用户空间的缓冲区大小控制,以此降低cpu拷贝和线程切换的次数和频率。
BufferedInputStream/BufferedOutputStream
最常见的方式是BufferedInputStream/BufferedOutputStream
方案。他比FileInputStream/FileOutputStream
优化点在于有一个buffer,一般设置为8k,当缓存区超过8K就会自动掉一次read/write
。简单示例如下:
public void bufferedIO(View view) {
new Thread() {
@Override
public void run() {
InputStream inputStream = new FileInputStream(new File(sourcepath));
if (inputStream == null) {
return;
}
inputStream = new BufferedInputStream(inputStream, 8192);
File targetFile = new File(targetPath);
BufferedOutputStream fileOutputStream = null;
try {
fileOutputStream = new BufferedOutputStream(new FileOutputStream(targetFile, true), 8192);
byte[] buffer = new byte[4096];
int count;
while ((count = inputStream.read(buffer)) > 0) {
fileOutputStream.write(buffer, 0, count);
}
//最后一步,主动将剩下的buffer写入(调用系统方法wirte)
fileOutputStream.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
if (fileOutputStream != null) {
try {
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}.start();
}
MappedByteBuffer缓冲区
java io操作中通常采用BufferedReader,BufferedInputStream等带缓冲的IO类处理大文件,不过java nio中引入了一种基于MappedByteBuffer操作大文件的方式,其读写性能极高 FileChannel提供了map方法把文件映射到虚拟内存,通常情况可以映射整个文件,如果文件比较大,可以进行分段映射。 MappedByteBuffer使用虚拟内存,因此分配(mmap)的内存大小不受JVM的-Xmx参数限制,但是也是有大小限制的。 如果当文件超出1.5G限制时,可以通过position参数重新map文件后面的内容。
MappedByteBuffer在处理大文件时的确性能很高,但也存在一些问题,如内存占用、文件关闭不确定,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高,这是为什么?通过上文对于mmap方法的介绍,我们知道:
- read()是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝;
- mmap()也是系统调用,但没有进行数据拷贝,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝。
Linux系统上java使用的是mmap,在windows上以其他系统方法。
所以,采用内存映射的读写效率要比传统的read/write性能高。
举例如下:
public void mapIO(View view) {
new Thread() {
@Override
public void run() {
InputStream inputStream = new FileInputStream(new File(sourcepath));
if (inputStream == null) {
return;
}
try {
InputStream is = getInputStream();
BufferedInputStream bis = new BufferedInputStream(is);
RandomAccessFile raf = new RandomAccessFile(targetPath,"rw");
FileChannel channel = raf.getChannel();
//缓冲区
MappedByteBuffer byteBuffer = channel.map(FileChannel.MapMode.READ_WRITE,channel.position(),is.available());
byte[] b = new byte[4096];
int length = -1;
while((length = bis.read(b)) != -1){
byteBuffer.put(b,0,length);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}.start();
}
OKIO对于JAVAIO的优化
官方的解释是这样的:Okio是一个库,是对java.io和java.nio的补充,通过这个库,我们可以更简单的使用和存储我们的数据。 Okio提供了两种新的类型,这两种类型有很多新的功能,并且使用比较简单。这两中类型分别是:ByteString和Buffer。
- ByteString是不可变的字节序列(请参考String,String是不可变的字符串)。String是基本的字符数据,ByteString相当于是String的兄弟,ByteString让处理二进制数据变得简单了。这个类是符合人们的编程习惯的,它知道怎么使用比如hax,base64,UTF-8等编码格式将它自己编码或解码。
- Buffer是一个可变的字符序列。你不需要提前设置它的大小,它在写入数据的时候会将数据放在最后,而在读取的时候会在最前面开始读取(这很类似与队列),你也不需要关心它的位置,限制,容量等等。
OKIO在读取数据时,先从Buffer对象中获取了一个Segment,然后向Segment中读取数据,每个Segment最多可以存入8K数据。这里需要提一下Buffer中数据的数据结构,Buffer中的数据是存在于一个双向链表中,链表中的每个节点都是一个Segment
以Segment作为存储结构,真实数据以类型为byte[]的成员变量data存在,并用其它变量标记数据状态,在需要时,如果可以,移动Segment引用,而非copy data数据。Segment在Segment线程池中以单链表存在以便复用,在Buffer中以双向链表存在存储数据,head指向头部,是最老的数据Segment能通过slipt()进行分割,可实现数据共享,能通过compact()进行合并。由Buffer来进行数据调度,基本遵守 “大块数据移动引用,小块数据进行copy” 的思想。
不管是读入还是写出,缓冲区的存在必然涉及copy的过程,而如果涉及双流操作,比如从一个输入流读入,再写入到一个输出流,那么这种情况下,在缓冲存在的情况下,数据走向是:
-> 从输入流读出到缓冲区
-> 从输入流缓冲区copy到 b[]
-> 将 b[] copy 到输出流缓冲区
-> 输出流缓冲区读出数据到输出流
OKIO是将两个缓冲合并成一份,OKIO核心是解决双流操作的问题.
public void okio(View view) {
new Thread() {
@Override
public void run() {
InputStream inputStream = new FileInputStream(new File(sourcepath));
if (inputStream == null) {
return;
}
File targetFile = new File(targetPath);
Source bufferSource = Okio.buffer(Okio.source(inputStream));
BufferedSink bufferSink = null;
try {
bufferSink = Okio.buffer(Okio.sink(targetFile));
while ((bufferSource.read(bufferSink.buffer(), 4096)) != -1) {
bufferSink.emitCompleteSegments();
}
bufferSink.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
bufferSource.close();
} catch (IOException e) {
e.printStackTrace();
}
if (bufferSink != null) {
try {
bufferSink.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}.start();
}
Source 对应输入流,Sink 对应输出流,Okio核心竞争力为,增强了流于流之间的互动,使得当数据从一个缓冲区移动到另一个缓冲区时,可以不经过copy能达到。
五、总结
会到最初的面试问题,我们可以总结归纳一下回答:
答:传统IO在介入DMA技术以后,基于 page cache
一次读写文件,要进行2次cpu copy
,2次DMA copy
,4次上下文切换。通过减少CPU拷贝、优化进程的上下文切换、调整合适的缓冲区大小等策略,可以有效解决IO读写过程中可能出现的性能瓶颈问题,从而提升系统的IO性能和效率。现有的系统调用函数有mmap
,适合文件频繁操作读写。sendfile
函数适合文件下载,splices
函数时候数据的传输场景,还有一种 Direct I/O
技术可以绕过文件系统,用户进程直接操作磁盘,典型应用就是基于文件的数据库。在Java io操作中通常采用BufferedReader,BufferedInputStream
等带缓冲的IO类处理大文件,不过java nio中引入了一种基于MappedByteBuffer
操作大文件的方式,其读写性能极高。此外在输入输出流同时操作的时候,还可以使用OKIO
,他将输入和输出两个缓冲合并成一份,减少系统调用次数,避免频繁I/O操作,提高读取和写入大量数据时的性能。
转载自:https://juejin.cn/post/7346977510515212299