将 20M 文件从 30 秒压缩到 1 秒,我是如何做到的?

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

(点击上方公众号,可快速关注)

压缩 20M 文件从 30 秒到 1 秒的优化过程

有一个需求需要将前端传过来的 10 张照片,然后后端进行处理以后压缩成一个压缩包通过网络流传输出去。

之前没有接触过用 Java 压缩文件的,所以就直接上网找了一个例子改了一下用了,改完以后也能使用,但是随着前端所传图片的大小越来越大的时候,耗费的时间也在急剧增加,最后测了一下压缩 20M 的文件竟然需要 30 秒的时间。压缩文件的代码如下。

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oGECoRNBfpTibKVs2BHDVcRcRTmxVves36LMFgGXTSVSGN6SkukBppMg/640?wx_fmt=png

这里找了一张 2M 大小的图片,并且循环十次进行测试。打印的结果如下,时间大概是 30 秒。

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oXDCRcagcOb6tr4CzUYuOs6JPFiacaSqicglgicMcmVqQU86KoSsD5Ib4A/640?wx_fmt=png

第一次优化过程 - 从 30 秒到 2 秒

进行优化首先想到的是利用缓冲区 BufferInputStream。在 FileInputStream 中 read() 方法每次只读取一个字节。源码中也有说明。

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oHmXpRo5BOXGBPdKNgia3o4r7ureo5HBOSuDB4r1DtVpVh3FCfeDDv9w/640?wx_fmt=png

这是一个调用本地方法与原生操作系统进行交互,从磁盘中读取数据。每读取一个字节的数据就调用一次本地方法与操作系统交互,是非常耗时的。

例如我们现在有 30000 个字节的数据,如果使用 FileInputStream 那么就需要调用 30000 次的本地方法来获取这些数据,而如果使用缓冲区的话(这里假设初始的缓冲区大小足够放下 30000 字节的数据)那么只需要调用一次就行。

因为缓冲区在第一次调用 read() 方法的时候会直接从磁盘中将数据直接读取到内存中。随后再一个字节一个字节的慢慢返回。

BufferedInputStream 内部封装了一个 byte 数组用于存放数据,默认大小是 8192 优化过后的代码如下

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3o4LKQRb2FLyBU2OuASNONgZ8iagN1asWSlAEW79FVFSvgKekRVtP6sIQ/640?wx_fmt=png

输出

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3owhPKYKQ9xdLfkJEYZnKmIoMOhoCuTQwvZXdfjK1UHrZYy6mcrYn7Xg/640?wx_fmt=png

可以看到相比较于第一次使用 FileInputStream 效率已经提升了许多了

第二次优化过程 - 从 2 秒到 1 秒

使用缓冲区 buffer 的话已经是满足了我的需求了,但是秉着学以致用的想法,就想着用 NIO 中知识进行优化一下。

使用 Channel

为什么要用 Channel 呢?因为在 NIO 中新出了 Channel 和 ByteBuffer。正是因为它们的结构更加符合操作系统执行 I/O 的方式,所以其速度相比较于传统 IO 而言速度有了显著的提高。

Channel 就像一个包含着煤矿的矿藏,而 ByteBuffer 则是派送到矿藏的卡车。也就是说我们与数据的交互都是与 ByteBuffer 的交互。在 NIO 中能够产生 FileChannel 的有三个类。

分别是 FileInputStream、FileOutputStream、以及既能读又能写的 RandomAccessFile。

源码如下

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oXBuF66SvVH5xm5ysCgHQ5LZiavNMDwVrXZXXVhseE3RgHSsuVDbLFCg/640?wx_fmt=png

我们可以看到这里并没有使用 ByteBuffer 进行数据传输,而是使用了 transferTo 的方法。这个方法是将两个通道进行直连。

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3olBFPNhukZyibzRKdhOvw0KmAr4MUf32EjyE9jWyQOgFbD2F3EboMBuA/640?wx_fmt=png

这是源码上的描述文字,大概意思就是使用 transferTo 的效率比循环一个 Channel 读取出来然后再循环写入另一个 Channel 好。

操作系统能够直接传输字节从文件系统缓存到目标的 Channel 中,而不需要实际的 copy 阶段。copy 阶段就是从内核空间转到用户空间的一个过程可以看到速度相比较使用缓冲区已经有了一些的提高。

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oySwzy5lOyyjOt6J0AhQ9VAiadc8gvWLQiaicFBO8jCQd9iauzuSMCKH4MA/640?wx_fmt=png

内核空间和用户空间

那么为什么从内核空间转向用户空间这段过程会慢呢?首先我们需了解的是什么是内核空间和用户空间。

在常用的操作系统中为了保护系统中的核心资源,于是将系统设计为四个区域,越往里权限越大,所以 Ring0 被称之为内核空间,用来访问一些关键性的资源。Ring3 被称之为用户空间。

https://mmbiz.qpic.cn/mmbiz/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oo1PhHD12lo8L7nqC494fFUOqwAd89Jic0W4iaQBoZaxWqEXjEStIMlfQ/640?wx_fmt=other

用户态、内核态:线程处于内核空间称之为内核态,线程处于用户空间属于用户态那么我们如果此时应用程序(应用程序是都属于用户态的)需要访问核心资源怎么办呢?

那就需要调用内核中所暴露出的接口用以调用,称之为系统调用。例如此时我们应用程序需要访问磁盘上的文件。此时应用程序就会调用系统调用的接口 open 方法,然后内核去访问磁盘中的文件,将文件内容返回给应用程序。

大致的流程如下https://mmbiz.qpic.cn/mmbiz/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oxibrpjQBS58y6rUqre4NVnsgQPwYBb0YVo9iaIBU1Pic9pw8qmTWK9kfg/640?wx_fmt=other

直接缓冲区和非直接缓冲区

既然我们要读取一个磁盘的文件,要废这么大的周折。有没有什么简单的方法能够使我们的应用直接操作磁盘文件,不需要内核进行中转呢?有,那就是建立直接缓冲区了。

非直接缓冲区:非直接缓冲区就是我们上面所讲内核态作为中间人,每次都需要内核在中间作为中转。

https://mmbiz.qpic.cn/mmbiz/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3o97zblTK7lHo3Kvnx93FkQX2yO5fPciaPFtzSa3icN5iagkMYHH7aTFLzQ/640?wx_fmt=other

直接缓冲区:直接缓冲区不需要内核空间作为中转 copy 数据,而是直接在物理内存申请一块空间,这块空间映射到内核地址空间和用户地址空间,应用程序与磁盘之间数据的存取通过这块直接申请的物理内存进行交互。

https://mmbiz.qpic.cn/mmbiz/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3obuEItXQ3UF5vfuvY9UbVr1cxIPKKTYQNia7npNylkUEcpo09YWfHnBg/640?wx_fmt=other

既然直接缓冲区那么快,我们为什么不都用直接缓冲区呢?其实直接缓冲区有以下的缺点。

直接缓冲区的缺点:

  1. 不安全
  2. 消耗更多,因为它不是在 JVM 中直接开辟空间。这部分内存的回收只能依赖于垃圾回收机制,垃圾什么时候回收不受我们控制。
  3. 数据写入物理内存缓冲区中,程序就丧失了对这些数据的管理,即什么时候这些数据被最终写入从磁盘只能由操作系统来决定,应用程序无法再干涉。

综上所述,所以我们使用 transferTo 方法就是直接开辟了一段直接缓冲区。所以性能相比而言提高了许多

使用内存映射文件

NIO 中新出的另一个特性就是内存映射文件,内存映射文件为什么速度快呢?其实原因和上面所讲的一样,也是在内存中开辟了一段直接缓冲区。与数据直接作交互。

源码如下

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3ottBhLvy1icvFhp2TusTPib0Qrp1rMdjvkibnqTwUpc3XRGFfGQxdvQg5w/640?wx_fmt=png

打印如下

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oVCKymueZJUan8xXj5HmNEJhzC7OMac9NO1CEe7ZsajJ8CR8uOzSebg/640?wx_fmt=png

可以看到速度和使用 Channel 的速度差不多的。

使用 Pipe

Java NIO 管道是 2 个线程之间的单向数据连接。Pipe 有一个 source 通道和一个 sink 通道。

其中 source 通道用于读取数据,sink 通道用于写入数据。可以看到源码中的介绍,大概意思就是写入线程会阻塞至有读线程从通道中读取数据。

如果没有数据可读,读线程也会阻塞至写线程写入数据。直至通道关闭。Whether or not a thread writing bytes to a pipe will block until another thread reads those bytes 我想要的效果是这样的。

源码如下:

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3osGWRibkzcicpnxU0zfzYJNiaZHWYbpoVKZvxgxHhwUEicOwPAib424DckcA/640?wx_fmt=png

https://mmbiz.qpic.cn/mmbiz/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oHk9u5qDr7IKdtIRIpVCfuWXf0pW8CKEIkE592hcTPNRQLP9H33WHEQ/640?wx_fmt=other

https://mmbiz.qpic.cn/mmbiz_png/CvQa8Yf8vq2leqEkrAowaUbhOWxib2l3oj13y39YviauGTsfQyhQC4sdUC8ganEiaxttUYVxxWMfqFuol9ZApYX2g/640?wx_fmt=png

源码地址 https://gith ub.com/modouxiansheng/Doraemon

https://github.com/modouxiansheng/Doraemon

总结

生活处处都需要学习,有时候只是一个简单的优化,可以让你深入学习到各种不同的知识。所以在学习中要不求甚解,不仅要知道这个知识也要了解为什么要这么做。

作者:不学无数的程序员

链接:https://www.jianshu.com/p/25b328753017

推荐程序员必备微信号

程序员内参

微信号:

programmer0001

推荐理由:

在这里,我们分享程序员相关技术,职场生活,行业热点资讯。不定期还会分享 IT 趣文和趣图。这里属于我们程序员自己的生活,工作和娱乐空间。

 ▼长按下方↓↓↓二维码识别关注

https://mmbiz.qpic.cn/mmbiz_jpg/jNmCBexQlC5gnia32IvcYplYSZb4dOCfAYSDD4ASA6D1tVkoY69dqnb97cQ99Y6jqlyKo0FxVgvW45sQRGXiclNg/640?wx_fmt=jpeg