1. 程式人生 > 程式設計 >壓縮20M檔案從30秒到1秒的優化過程

壓縮20M檔案從30秒到1秒的優化過程

壓縮20M檔案從30秒到1秒的優化過程

有一個需求需要將前端傳過來的10張照片,然後後端進行處理以後壓縮成一個壓縮包通過網路流傳輸出去。之前沒有接觸過用Java壓縮檔案的,所以就直接上網找了一個例子改了一下用了,改完以後也能使用,但是隨著前端所傳圖片的大小越來越大的時候,耗費的時間也在急劇增加,最後測了一下壓縮20M的檔案竟然需要30秒的時間。壓縮檔案的程式碼如下。

 1public static void zipFileNoBuffer() {
2    File zipFile = new File(ZIP_FILE);
3    try (ZipOutputStream zipOut = new ZipOutputStream(new
 FileOutputStream(zipFile))) {
4        //開始時間
5        long beginTime = System.currentTimeMillis();
6
7        for (int i = 0; i < 10; i++) {
8            try (InputStream input = new FileInputStream(JPG_FILE)) {
9                zipOut.putNextEntry(new ZipEntry(FILE_NAME + i));
10                int temp = 0
;
11                while ((temp = input.read()) != -1) {
12                    zipOut.write(temp);
13                }
14            }
15        }
16        printInfo(beginTime);
17    } catch (Exception e) {
18        e.printStackTrace();
19    }
20}
複製程式碼

這裡找了一張2M大小的圖片,並且迴圈十次進行測試。列印的結果如下,時間大概是30秒。

1fileSize:20M

2consum time:29599
複製程式碼

第一次優化過程-從30秒到2秒

進行優化首先想到的是利用緩衝區BufferInputStream。在FileInputStreamread()方法每次只讀取一個位元組。原始碼中也有說明。

1/**
2 * Reads a byte of data from this input stream. This method blocks
3 * if no input is yet available.
4 *
5 * @return     the next of data, or <code>-1</code> if the end of the
6 *             file is reached.
7 * @exception  IOException  if an I/O error occurs.
8 */
9public native int read() throws IOException;
複製程式碼

這是一個呼叫本地方法與原生作業系統進行互動,從磁碟中讀取資料。每讀取一個位元組的資料就呼叫一次本地方法與作業系統互動,是非常耗時的。例如我們現在有30000個位元組的資料,如果使用FileInputStream那麼就需要呼叫30000次的本地方法來獲取這些資料,而如果使用緩衝區的話(這裡假設初始的緩衝區大小足夠放下30000位元組的資料)那麼只需要呼叫一次就行。因為緩衝區在第一次呼叫read()方法的時候會直接從磁碟中將資料直接讀取到記憶體中。隨後再一個位元組一個位元組的慢慢返回。

BufferedInputStream內部封裝了一個byte陣列用於存放資料,預設大小是8192

優化過後的程式碼如下

zipFileBuffernew FileOutputStream(zipFile));
4            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(zipOut)) {
5         6        try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(JPG_FILE))) {
while ((temp = bufferedInputStream.read()) != 12                    bufferedOutputStream.write(temp);
20}
複製程式碼

輸出

------Buffer
3:1808
複製程式碼

可以看到相比較於第一次使用FileInputStream效率已經提升了許多了

第二次優化過程-從2秒到1秒

使用緩衝區buffer的話已經是滿足了我的需求了,但是秉著學以致用的想法,就想著用NIO中知識進行優化一下。

使用Channel

為什麼要用Channel呢?因為在NIO中新出了ChannelByteBuffer。正是因為它們的結構更加符合作業系統執行I/O的方式,所以其速度相比較於傳統IO而言速度有了顯著的提高。Channel就像一個包含著煤礦的礦藏,而ByteBuffer則是派送到礦藏的卡車。也就是說我們與資料的互動都是與ByteBuffer的互動。

在NIO中能夠產生FileChannel的有三個類。分別是FileInputStreamFileOutputStream、以及既能讀又能寫的RandomAccessFile

原始碼如下

zipFileChannel 2     4    File zipFile =  5     6            WritableByteChannel writableByteChannel = Channels.newChannel(zipOut)) {
try (FileChannel fileChannel = new FileInputStream(JPG_FILE).getChannel()) {
new ZipEntry(i + SUFFIX_FILE));
10                fileChannel.transferTo(0, FILE_SIZE, writableByteChannel);
11            }
12        }
13        printInfo(beginTime);
14    } 15        e.printStackTrace();
16    }
17}
複製程式碼

我們可以看到這裡並沒有使用ByteBuffer進行資料傳輸,而是使用了transferTo的方法。這個方法是將兩個通道進行直連。

1This method is potentially much more efficient than a simple loop
2* that reads from this channel and writes to the target channel.  Many
3* operating systems can transfer bytes directly from the filesystem cache
4to the target channel without actually copying them. 
複製程式碼

這是原始碼上的描述文字,大概意思就是使用transferTo的效率比迴圈一個Channel讀取出來然後再迴圈寫入另一個Channel好。作業系統能夠直接傳輸位元組從檔案系統快取到目標的Channel中,而不需要實際的copy階段。

copy階段就是從核心空間轉到使用者空間的一個過程

可以看到速度相比較使用緩衝區已經有了一些的提高。

------Channel
:1416
複製程式碼

核心空間和使用者空間

那麼為什麼從核心空間轉向使用者空間這段過程會慢呢?首先我們需瞭解的是什麼是核心空間和使用者空間。在常用的作業系統中為了保護系統中的核心資源,於是將系統設計為四個區域,越往裡許可權越大,所以Ring0被稱之為核心空間,用來訪問一些關鍵性的資源。Ring3被稱之為使用者空間。

使用者態、核心態:執行緒處於核心空間稱之為核心態,執行緒處於使用者空間屬於使用者態

那麼我們如果此時應用程式(應用程式是都屬於使用者態的)需要訪問核心資源怎麼辦呢?那就需要呼叫核心中所暴露出的介面用以呼叫,稱之為系統呼叫。例如此時我們應用程式需要訪問磁碟上的檔案。此時應用程式就會呼叫系統呼叫的介面open方法,然後核心去訪問磁碟中的檔案,將檔案內容返回給應用程式。大致的流程如下

直接緩衝區和非直接緩衝區

既然我們要讀取一個磁碟的檔案,要廢這麼大的周折。有沒有什麼簡單的方法能夠使我們的應用直接操作磁碟檔案,不需要核心進行中轉呢?有,那就是建立直接緩衝區了。

  • 非直接緩衝區:非直接緩衝區就是我們上面所講核心態作為中間人,每次都需要核心在中間作為中轉。

  • 直接緩衝區:直接緩衝區不需要核心空間作為中轉copy資料,而是直接在實體記憶體申請一塊空間,這塊空間對映到核心地址空間和使用者地址空間,應用程式與磁碟之間資料的存取通過這塊直接申請的實體記憶體進行互動。

既然直接緩衝區那麼快,我們為什麼不都用直接緩衝區呢?其實直接緩衝區有以下的缺點。直接緩衝區的缺點:

  1. 不安全
  2. 消耗更多,因為它不是在JVM中直接開闢空間。這部分記憶體的回收只能依賴於垃圾回收機制,垃圾什麼時候回收不受我們控制。
  3. 資料寫入實體記憶體緩衝區中,程式就喪失了對這些資料的管理,即什麼時候這些資料被最終寫入從磁碟只能由作業系統來決定,應用程式無法再幹涉。

綜上所述,所以我們使用transferTo方法就是直接開闢了一段直接緩衝區。所以效能相比而言提高了許多

使用記憶體對映檔案

NIO中新出的另一個特性就是記憶體對映檔案,記憶體對映檔案為什麼速度快呢?其實原因和上面所講的一樣,也是在記憶體中開闢了一段直接緩衝區。與資料直接作互動。原始碼如下

 1//Version 4 使用Map對映檔案
2zipFileMap 3     4     5    File zipFile =  6     7            WritableByteChannel writableByteChannel = Channels.newChannel(zipOut)) {
8         9
10            zipOut.putNextEntry(11
12            //記憶體中的對映檔案
13            MappedByteBuffer mappedByteBuffer = new RandomAccessFile(JPG_FILE_PATH, "r").getChannel()
14                    .map(FileChannel.MapMode.READ_ONLY, 15
16            writableByteChannel.write(mappedByteBuffer);
17        }
18        printInfo(beginTime);
19    } 20        e.printStackTrace();
21    }
22}
複製程式碼

列印如下

---------Map
:1305
複製程式碼

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

使用Pipe

Java NIO 管道是2個執行緒之間的單向資料連線。Pipe有一個source通道和一個sink通道。其中source通道用於讀取資料,sink通道用於寫入資料。可以看到原始碼中的介紹,大概意思就是寫入執行緒會阻塞至有讀執行緒從通道中讀取資料。如果沒有資料可讀,讀執行緒也會阻塞至寫執行緒寫入資料。直至通道關閉。

1 Whether or not a thread writing bytes to a pipe will block until another
2 thread reads those bytes
複製程式碼

我想要的效果是這樣的。原始碼如下

//Version 5 使用Pip
zipFilePip() {
3
try(WritableByteChannel out = Channels.newChannel(new FileOutputStream(ZIP_FILE))) {
6        Pipe pipe = Pipe.open();
7        //非同步任務
8        CompletableFuture.runAsync(()->runTask(pipe));
10        //獲取讀通道
11        ReadableByteChannel readableByteChannel = pipe.source();
12        ByteBuffer buffer = ByteBuffer.allocate(((int) FILE_SIZE)*10);
13        while (readableByteChannel.read(buffer)>= 0) {
14            buffer.flip();
15            out.write(buffer);
16            buffer.clear();
18    }catch (Exception e){
19        e.printStackTrace();
20    }
21    printInfo(beginTime);
22
23}
24
2526runTask(Pipe pipe) {
27
28    try(ZipOutputStream zos = new ZipOutputStream(Channels.newOutputStream(pipe.sink()));
29            WritableByteChannel out = Channels.newChannel(zos)) {
30        System.out.println("Begin");
31        32            zos.putNextEntry(new ZipEntry(i+SUFFIX_FILE));
33
34            FileChannel jpgChannel = new FileInputStream(new File(JPG_FILE_PATH)).getChannel();
35
36            jpgChannel.transferTo(out);
37
38            jpgChannel.close();
39        }
40    }41        e.printStackTrace();
42    }
43}
複製程式碼

總結

  • 生活處處都需要學習,有時候只是一個簡單的優化,可以讓你深入學習到各種不同的知識。所以在學習中要不求甚解,不僅要知道這個知識也要了解為什麼要這麼做。
  • 知行合一:學習完一個知識要儘量應用一遍。這樣才能記得牢靠。

原始碼地址

參考文章