Optimized compression of 20M files from 30 seconds to 1 second

Keywords: Java network jvm pip

There is a requirement to send 10 photos from the front end, then process them in the back end and compress them into a compressed package for output through network streaming.I haven't touched Java compressed files before, so I went online to find an example to change it. It can also be used after changing. But as the size of the pictures uploaded from the front end increases, the time consumed increases dramatically. Finally, I measured that it takes 30 seconds to compress a 20M file.The code for the compressed file is as follows.

public static void zipFileNoBuffer() {
    File zipFile = new File(ZIP_FILE);
    try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile))) {
        //start time
        long beginTime = System.currentTimeMillis();

        for (int i = 0; i < 10; i++) {
            try (InputStream input = new FileInputStream(JPG_FILE)) {
                zipOut.putNextEntry(new ZipEntry(FILE_NAME + i));
                int temp = 0;
                while ((temp = input.read()) != -1) {
                    zipOut.write(temp);
                }
            }
        }
        printInfo(beginTime);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

A 2M picture is found here and tested ten times in a cycle.The printout is as follows and takes about 30 seconds.

fileSize:20M
consum time:29599

First optimization process - from 30 seconds to 2 seconds

The first thing you want to optimize is to use the buffer BufferInputStream.The read() method in FileInputStream reads only one byte at a time.There are also instructions in the source code.

/**
 * Reads a byte of data from this input stream. This method blocks
 * if no input is yet available.
 *
 * @return     the next byte of data, or <code>-1</code> if the end of the
 *             file is reached.
 * @exception  IOException  if an I/O error occurs.
 */
public native int read() throws IOException;

This is a call to a local method to interact with the native operating system to read data from disk.It is time consuming to call a local method to interact with the operating system every byte of data read.For example, we now have 30,000 bytes of data. If you use FileInputStream, you need to call 30,000 local methods to get the data. If you use a buffer (assuming the initial buffer size is large enough to hold 30,000 bytes of data), you only need to call it once.Because the buffer reads data directly from disk into memory the first time the read() method is called.Then the next byte slowly returns one byte at a time.

BufferedInputStream encapsulates a byte array inside to hold the data. The default size is 8192

The optimized code is as follows

public static void zipFileBuffer() {
    File zipFile = new File(ZIP_FILE);
    try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile));
            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(zipOut)) {
        //start time
        long beginTime = System.currentTimeMillis();
        for (int i = 0; i < 10; i++) {
            try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(JPG_FILE))) {
                zipOut.putNextEntry(new ZipEntry(FILE_NAME + i));
                int temp = 0;
                while ((temp = bufferedInputStream.read()) != -1) {
                    bufferedOutputStream.write(temp);
                }
            }
        }
        printInfo(beginTime);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

output

------Buffer
fileSize:20M
consum time:1808

You can see that FileInputStream is much more efficient than it was when you first used it

Second optimization process - from 2 seconds to 1 second

buffer buffers are enough for my needs, but with the idea of applying what I've learned, I want to optimize it with knowledge from NIO.

Use Channel

Why use Channel?Because Channel and ByteBuffer are new in NIO.Because their structure is more in line with the way the operating system performs I/O, they are much faster than traditional IO.Channel is like a mine containing a coal mine, while ByteBuffer is a truck sent to the mine.That is to say, our interaction with data is with ByteBuffer.

There are three classes in NIO that can produce FileChannel s.FileInputStream, FileOutputStream, and RandomAccessFile, which can be read and written, respectively.

The source code is as follows

public static void zipFileChannel() {
    //start time
    long beginTime = System.currentTimeMillis();
    File zipFile = new File(ZIP_FILE);
    try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile));
            WritableByteChannel writableByteChannel = Channels.newChannel(zipOut)) {
        for (int i = 0; i < 10; i++) {
            try (FileChannel fileChannel = new FileInputStream(JPG_FILE).getChannel()) {
                zipOut.putNextEntry(new ZipEntry(i + SUFFIX_FILE));
                fileChannel.transferTo(0, FILE_SIZE, writableByteChannel);
            }
        }
        printInfo(beginTime);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

We can see that instead of using ByteBuffer for data transfer, the method of transferTo is used here.This method is to connect the two channels directly.

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

This is the description on the source code, which roughly means that transferTo is more efficient than looping one Channel to read it out and then writing to another.The operating system can directly transfer bytes from the file system cache to the target Channel without requiring the actual copy stage.

The copy phase is the process of moving from kernel space to user space

You can see some improvements in speed compared to using buffers.

------Channel
fileSize:20M
consum time:1416

Kernel and user space

So why is the transition from kernel space to user space slow?The first thing we need to know is what is kernel space and user space.In order to protect the core resources of the system in common operating systems, the system is designed into four zones, the more permissions you have in them, so Ring0 is called the kernel space to access some critical resources.Ring3 is called user space.

User state, kernel state: Thread in kernel space is called kernel state, thread in user space is user state

So what if the application (which is all user-friendly) needs access to the core resources at this point?That requires calling the interfaces exposed in the kernel to invoke, known as system calls.For example, at this point our application needs to access files on disk.The application then calls the open method of the interface called by the system, and the kernel accesses the files on disk to return the contents of the files to the application.The general process is as follows

Direct and non-direct buffers

Since we're going to read a file from a disk, we're going to scrape that much hassle.Is there any simple way for our applications to directly manipulate disk files without requiring the kernel to transfer?Yes, it's time to create a direct buffer.

  • Non-direct buffers: Non-direct buffers are the kernel states we talked about above as intermediaries, which require the kernel to be in the middle each time.

  • Direct Buffer: A direct buffer does not require kernel space as transit copy data, but instead requests a space in physical memory that maps to both the kernel address space and the user address space through which access to data between applications and disks interacts.

Since direct buffers are so fast, why don't we all use direct buffers?In fact, direct buffers have the following drawbacks.Disadvantages of direct buffers:

  1. Unsafe
  2. It consumes more because it does not directly open up space in the JVM.This part of memory recycling can only depend on the garbage collection mechanism, and when the garbage is recycled is out of our control.
  3. When data is written to the physical memory buffer, the program loses management of the data, that is, when the data is ultimately written from disk can only be determined by the operating system, and the application can no longer interfere.

In summary, the transferTo method allows us to open a direct buffer directly.So performance improves a lot by comparison

Use memory mapped files

Another new feature in NIO is memory mapped files. Why are memory mapped files fast?In fact, as mentioned above, a direct buffer is also created in memory.Interact directly with the data.The source code is as follows

//Version 4 uses Map mapping files
public static void zipFileMap() {
    //start time
    long beginTime = System.currentTimeMillis();
    File zipFile = new File(ZIP_FILE);
    try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(zipFile));
            WritableByteChannel writableByteChannel = Channels.newChannel(zipOut)) {
        for (int i = 0; i < 10; i++) {

            zipOut.putNextEntry(new ZipEntry(i + SUFFIX_FILE));

            //In-memory mapping file
            MappedByteBuffer mappedByteBuffer = new RandomAccessFile(JPG_FILE_PATH, "r").getChannel()
                    .map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE);

            writableByteChannel.write(mappedByteBuffer);
        }
        printInfo(beginTime);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

Print as follows

---------Map
fileSize:20M
consum time:1305

You can see that the speed is similar to that of using Channel.

Use Pipe

The Java NIO pipeline is a one-way data connection between two threads.Pipe has a source channel and a sink channel.Where the source channel reads data and the sink channel writes data.You can see the description in the source code, which roughly means that the write thread will block until the read thread reads from the channel.If there is no data to read, the read thread will also block the write thread from writing data.Until the channel is closed.

 Whether or not a thread writing bytes to a pipe will block until another
 thread reads those bytes

The effect I want is this.The source code is as follows

//Version 5 uses Pip
public static void zipFilePip() {

    long beginTime = System.currentTimeMillis();
    try(WritableByteChannel out = Channels.newChannel(new FileOutputStream(ZIP_FILE))) {
        Pipe pipe = Pipe.open();
        //Asynchronous Tasks
        CompletableFuture.runAsync(()->runTask(pipe));

        //Get Read Channel
        ReadableByteChannel readableByteChannel = pipe.source();
        ByteBuffer buffer = ByteBuffer.allocate(((int) FILE_SIZE)*10);
        while (readableByteChannel.read(buffer)>= 0) {
            buffer.flip();
            out.write(buffer);
            buffer.clear();
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    printInfo(beginTime);

}

//Asynchronous Tasks
public static void runTask(Pipe pipe) {

    try(ZipOutputStream zos = new ZipOutputStream(Channels.newOutputStream(pipe.sink()));
            WritableByteChannel out = Channels.newChannel(zos)) {
        System.out.println("Begin");
        for (int i = 0; i < 10; i++) {
            zos.putNextEntry(new ZipEntry(i+SUFFIX_FILE));

            FileChannel jpgChannel = new FileInputStream(new File(JPG_FILE_PATH)).getChannel();

            jpgChannel.transferTo(0, FILE_SIZE, out);

            jpgChannel.close();
        }
    }catch (Exception e){
        e.printStackTrace();
    }
}

summary

  • Everywhere in life you need to learn, sometimes just a simple optimization, which allows you to learn a variety of different knowledge.So in learning, you need to know not only this knowledge but also why you want to do it.
  • Integration of knowledge and practice: After learning a knowledge, try to apply it once.So you can remember to be strong.

Source Address

Reference Article

Posted by ade1982 on Thu, 15 Aug 2019 21:19:54 -0700