Skip to content

Buffer的原理和使用场景+面试题解读

Buffer的原理和使用场景+面试题解读

缓冲区(Buffer)一直是一个面试热点,Buffer和流经常一起考。我先请大家思考一个问题。缓冲区是不是流? 当然不是,流代表的是随着时间到来的数据。缓冲区,顾名思义,肯定是用来做缓冲的。为什么要做缓冲?那是因为数据到来太快处理不过来。

比如键盘缓冲区,按键太快,处理不过来,把按键先存下来。比如磁盘缓冲区,磁盘写入太多,一次写入不完,先把要写入的数据缓冲下来。比如说网络缓冲区,网卡到来的数据太多。先把这些数据存下来。之后再处理。……

比如说理发店的缓冲区,其实就是理发店的等待坐席;饭店的缓冲区,就是饭店的排号。缓冲的本质是排队,流的本质是数据,是两个风马牛不相及的两个概念。

缓冲区这部分一共3堂课:

  1. 本节帮助你弄懂缓冲区的一切
  2. 下一节:同步、异步、阻塞、非阻塞概念区分
  3. 下下节:实战中文乱码处理和阿里面试题(并发处理算词频)

本节课关联的面试题目:

  1. 缓冲区是不是流?
  2. 缓冲区的几种操作含义:flip/rewind/clear?
  3. 缓冲区设置多大?
  4. NIO的Channel比缓冲区快吗?
  5. 缓冲过程中,中文乱码如何处理?
  6. 并发分析数据更快吗?

缓冲区的概念

数据在缓冲区中排队,你先入先出的形式读写。先写入的数据先被读出来。所以看上去缓冲区像一个水管,数据流入缓冲区,然后再流出。

image-20210207111212852

缓冲区内部是用于存储数据的数据结构。可能是数组、可能是链表、甚至可能是复杂的树结构,比如哈希表、树等等都有可能。

你可能会问,数据进入缓冲区,然后再离开缓冲区到底有什么价值呢?数据有没有发生变化?又没有数据的监控和沉淀结果。此后什么都没有发生。那么价值是什么?

image-20210207111532933

核心的价值当然是缓冲了。设想一个设备高速产生数据,而处理节点想处理这些数据。你可以思考,如果处理节点处理不完,这些数据会发生什么?比如用户按键太快,应用程序响应不过来会发生什么?我们这里有一个假设,用户不会一直按键都那么快,因为用户会累。当用户按键实在太快的时候,系统就发出一声beep,用户听到警报声就停止乱按键。这声beep就是因为缓冲区已经满了,后面用户的按键就会被拒绝。我没有缓冲区呢?如果用单线程去处理用户按键。用户只要按两个键就会被拒绝一个。如果有一个大小为1024的缓冲区,用户至少要按1025个键,才会导致处理节点处理不过来,才会触发拒绝(beep)。所以缓冲区的成本很低,但是实现的效果却很好。

因此我们在实现系统的架构时。理论上,系统的对接都应该存在缓冲。一个系统将自己的输出作为另一个系统的输入,那么在处理输入的系统内,就应该有一个专门接收数据的缓冲区的设计。

image-20210207112543329

比如一个聊天服务,在处理用户发送的消息时,例如微信,一定不能马上处理这条消息。而是应该先缓冲。如果你马上处理这些消息,当并发量高的时候,总有你处理不过来的时候。而你对消息进行了缓冲有很多的好处,首先是避免了瓶颈的出现(运算性能的瓶颈。Io性能的瓶颈)。其次,有时候批量处理数据的成本更低。比如批量写入磁盘。批量发送网络请求。可以更好的利用底层的设施,比如批量写入磁盘数据就比单个一点点写入快很多很多,这些都是缓冲区的价值。

I/O的成本

在理解缓冲区的过程当中。通常我们都是结合I/O。所以你要弄清楚I/O的成本。

image-20210207104208034

上图在演示数据从设备到处理线程中的拷贝次数。比如从网卡接收到数据,一直到处理的线程经过的拷贝次数。

当设备收到数据的时候,首先要拷贝到操作系统内核空间。内核空间。也是内存的一部分。这部分拷贝可能是基于硬件的。在主板上有一个小型的设备,叫做DMA(Direct Memory Access),这个设备可以帮助我们将数据拷贝到内存,却不消耗CPU资源。

接下来,我们需要将内核空间的数据,拷贝到用户空间。这是因为应用程序的权限是不可以访问内核空间中的数据的。

这是对内存的一种保护策略。试想,如果应用程序可以访问内核空间,那么应用程序就可以随意的修改操作系统的任何配置,这是一件极其危险的事情。

那你可能会问,那么为什么不直接将设备的数据拷贝到用户空间呢?这是因为,多个应用可能会复用设备。比如说如果有十个线程,都需要使用同一个设备的数据,那么我们将这个设备数据,拷贝到哪个用户空间呢?因为每个进程拥有的用户空间是不同的,相当于每个进程拥有的内存区域是不互相重叠。这就是为什么需要先拷贝到内核空间。

还有一个重要的原因,我们不希望设备直接沟通用户空间,这是一种安全策略,设备直接沟通用户空间也是很危险的。因为多数的设备是经单线程的设计,多线程的访问,需要驱动程序承接。

所以上面的两次拷贝:设备到内核空间,由内核空间到用户空间,是不可省略的。接下来还要进行一次拷贝,数据会先存在于缓冲区当中,需要拷贝到处理的线程。反过,我们将数据从用户空间中写回去,写到内核空间,也需要经过缓冲区。缓冲区的数据需要读出来才能用所以这又是一次拷贝。

上面的这个模型中,每次I/O数据至少会经过三次拷贝。在这三次拷贝当中,只有第一次可以利用DMA技术,是可以不需要CPU资源的。后面两次都是消耗CPU资源的。看上去很傻:一个比特,一个比特去拷贝。但是实际情况就是如此,I/O的成本非常的高。网卡接收到一G数据,需要把一G数据经过多次拷贝,拷贝到线程去处理。

这是一个最基本的理解模型,这个模型不理解,其实是理解不了I/O。同步异步,都不重要。先要理解,I/O成本到底高在哪里?这样才好进行优化,同步、异步只是编程的模型,程序写对了,性能上并无任何差异。

这部分知识。还关联了一些高并发网络请求应该如何处理?这部分我将在后面讨论Linux和网络的那一章和大家讲解。

缓冲区的实现原理

缓冲区的实现方式就多了,通常的我们会用线性结构去实现缓冲区:比如说数组、链表。

数组实现缓冲区是最常见的。因为数组和内存形式上是统一的,都是连续紧密的数据结构。

下图中是一个常见的单向缓冲区数据结构,P代表下一个写入的位置,每次写入P向右移动。

image-20210207212934092

实现写入的代码类似:

put(int v) {
    data[P++] = v;
}

通常的,我们用3个值来控制缓冲区:

image-20210207213142226

如上图:

  • P(position),代表下一个可读写的位置
  • L(limit),代表不可读写位置的开始
  • C(Capacity),代表缓冲区的大小

有朋友可能会问:那么这个缓冲区如何读取呢?

flip操作

读取这样的缓冲区就需要进行缓冲区的翻转,也就是flip操作。

image-20210207214536307

上图中的flip操作,将position设置为0,limit设置为position的位置。这样就从一个写入缓冲区,切换到了读取缓冲区。接下来,我们就通过指针P就可以读取缓冲区的内容,而limit左边的就是有数据的。

所以flip操作的作用是帮助缓冲区在读写之间切换。

通过这个例子,你会发现,limit才缓冲区的实际数据范围,Capacity是一个物理限制。

clear操作

当缓冲区使用完可以进行清空。清空操作将缓冲区恢复到初始状态。也就是position=0,Limit=Capacity的状态。这个操作称之为clear操作,如下图所示。

image-20210207215503270

rewind操作

另外,有时候我们需要重新读取流中的数据。这个时候,就像倒带(rewind)一样,需要将position置0,其他不变。

image-20210207215629298

实战举例

友情提示。所有的实战建议大家都看一下视频。视频里的讲解深度一行一行带你敲的感觉和文档是不一样的。

我用下面这段程序,生成一个有10亿个英文单词的文件。你会看到我这里用了BufferedOutputStream 去包裹FileOutputStream,这样每次写入都会将内容先写入一个1024*1024的缓冲区中。缓冲区满了,才会将内容写入磁盘。

当所有的内容都写入完毕之后,这个时候缓冲区可能还会剩余一些数据。因此最后需要调用flush 方法将剩余的数据也写入磁盘。

@Test
public void gen() throws IOException {

    Random r = new Random();
    var bufferSize = 1024*1024;
    var fileName = "word";
    var fout = new BufferedOutputStream(new FileOutputStream(fileName), bufferSize);
    //        var fout = new FileOutputStream(fileName);

    var start = System.currentTimeMillis();
    for(int i = 0; i < 100000000; i++) {
        for (int j = 0; j < 5; j++) {
            fout.write(97 + r.nextInt(5));
        }
        fout.write(' ');
    }
    fout.flush();
    fout.close();
    System.out.println(System.currentTimeMillis() - start);
}

你可以尝试调节bufferSize 观察不同缓冲区大小的写入速度。上面的程序会比没有缓冲区的写入快很多倍。这是因为,写入磁盘的操作速度很慢,写入内存的速度很快,所以应该尽量将数据写入内存,向磁盘一次多写入一些数据。如果没有缓冲区,上面的程序,每次会上磁盘中写入一个字符,这样做是非常慢的。

下面我们没有缓冲区进行读取,你会发现速度非常的慢。

@Test
public void read_test() throws IOException {
    var fileName = "word";
    var in = new FileInputStream(fileName);


    var start = System.currentTimeMillis();
    int b;
    var sb = new StringBuilder();
    while((b = in.read()) != -1) {
        sb.appendCodePoint(b);
    }
    var end = System.currentTimeMillis();
    System.out.println((end - start) + "ms " + sb.length());
    in.close();
}

好几十秒都无法完成。

接下来我们用缓冲区去改进。用BufferedInputStream 去包裹FileInputStream

@Test
public void read_test_withBuffer() throws IOException {
    var fileName = "word";
    var in = new BufferedInputStream(new FileInputStream(fileName));

    var chb = CharBuffer.allocate(1024*1024);
    var start = System.currentTimeMillis();
    int b = -1;
    var sb = new StringBuilder();
    var bytes = new byte[1024];
    while((b = in.read(bytes)) != -1) {
        //            sb.appendCodePoint(b);
    }
    var end = System.currentTimeMillis();
    System.out.println((end - start) + "ms " + sb.length());
    in.close();
}

上面程序在我的机器上,可以在1s 以内完成。这就是有缓冲区和没有缓冲区的性能差异,核心是因为读写磁盘的速度慢,读写内存的速度快。当我们读写磁盘的时候,就尽量每次多读或者多写一些数据。这也是为什么如果有很多条Sql语句在插入数据,应该尽量合并成一条批量操作的原因。

使用NIO读写

另外也可以使用Java的NIO进行处理。NIO的"N",是New的意思,就是新IO技术的意思。 所以它不是某个具体的技术,是一系列新I/O技术的集合。

比如说NIO提供了Channel处理读、写。Channel是对I/O的抽象,可以双向工作。一方面Channel连接设备,一方面Channel连接Buffer。要读取(接收)数据的时候。可以从Channel中读取数据到Buffer。在写入(发送)数据的时候,可以将数据写入Buffer,提供给Channel。

所以从这个角度去看,Channel是一种只接收Buffer进行读写的API。如果没有Channel,用户可以使用Buffer,也可以不使用Buffer。尽管不使用Buffer会很慢,有性能问题,但是用户可以这样做。可是,用户用了Channel,就必须用Buffer。所以Channel是一种规范化的l/O接口。

image-20210207231138622

比如下面这段程序,用channel.read 就可以读取数据进buff非常的方便。为了实现这个过程,NIO专门读取文件的channel——FileChannel。除了专门读取文件的channel,用来读取网络请求、发送响应的Channel——SocketChannel。

@Test
public void read_test_nio() throws IOException {

    var fileName = "word";
    var fin = new FileInputStream(fileName);
    fin.getChannel();

    var reader = new InputStreamReader(fin, StandardCharsets.UTF_8);
    var channel = new FileInputStream(fileName).getChannel();

    var buff = ByteBuffer.allocate(1024*1024);
    System.out.println(channel.size());

    var start = System.currentTimeMillis();

    while(channel.read(buff) != -1) {
        buff.flip();
        buff.clear();
    }
    System.out.format("%dms, %d \n", System.currentTimeMillis() - start, channel.size());
}

全部示例程序(讲解见视频)

package coding.buffer;

import org.junit.Test;

import java.io.*;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.IntBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.Channel;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

public class BufferExamples {

    @Test
    public void gen() throws IOException {

        Random r = new Random();
        var bufferSize = 1024*1024;
        var fileName = "word";
        var fout = new BufferedOutputStream(new FileOutputStream(fileName), bufferSize);
//        var fout = new FileOutputStream(fileName);

        var start = System.currentTimeMillis();
        for(int i = 0; i < 100000000; i++) {
            for (int j = 0; j < 5; j++) {
                fout.write(97 + r.nextInt(5));
            }
            fout.write(' ');
        }
        fout.flush();
        fout.close();
        System.out.println(System.currentTimeMillis() - start);
    }

    @Test
    public void read_test() throws IOException {
        var fileName = "word";
        var in = new FileInputStream(fileName);


        var start = System.currentTimeMillis();
        int b;
        var sb = new StringBuilder();
        while((b = in.read()) != -1) {
            sb.appendCodePoint(b);
        }
        var end = System.currentTimeMillis();
        System.out.println((end - start) + "ms " + sb.length());
        in.close();
    }


    @Test
    public void read_test_withBuffer() throws IOException {
        var fileName = "word";
        var in = new BufferedInputStream(new FileInputStream(fileName));

        var chb = CharBuffer.allocate(1024*1024);
        var start = System.currentTimeMillis();
        int b = -1;
        var sb = new StringBuilder();
        var bytes = new byte[1024];
        while((b = in.read(bytes)) != -1) {
            sb.appendCodePoint(b);
        }
        var end = System.currentTimeMillis();
        System.out.println((end - start) + "ms " + sb.length());
        in.close();
    }

    @Test
    public void read_test_nio() throws IOException {

        var fileName = "word";
        var fin = new FileInputStream(fileName);
        fin.getChannel();

        var reader = new InputStreamReader(fin, StandardCharsets.UTF_8);
        var channel = new FileInputStream(fileName).getChannel();

        var buff = ByteBuffer.allocate(1024*1024);
        System.out.println(channel.size());

        var start = System.currentTimeMillis();

        while(channel.read(buff) != -1) {
            buff.flip();
            buff.clear();
        }
        System.out.format("%dms, %d \n", System.currentTimeMillis() - start, channel.size());
    }



    @Test
    public void test_async_read() throws IOException, ExecutionException, InterruptedException {

        var fileName = "word";
        var channel = AsynchronousFileChannel.open(Path.of(fileName), StandardOpenOption.READ);

        var buf = ByteBuffer.allocate(1024);
        Future<Integer> operation = channel.read(buf, 0);
        var numReads = operation.get();
        buf.flip();
        var chars = new String(buf.slice().array());
        System.out.println(chars);
    }
}

总结

缓冲区的作用是缓冲。系统的衔接没有缓冲,是危险的。网络、磁盘的I/O没有缓冲是低效的。在高速和低速之间提供一片缓冲区,可以提高效率,这是因为批量处理的效率高于逐个处理;也更安全,有缓冲可以避免突然遇到计算能力瓶颈。所以我们在设计系统的时候要注重缓冲区的衔接,遇到高并发——交易、聊天、搜索、服务对接……先设置缓冲区,再思考如何去处批量处理。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我