12 How Many File Copy Methods Does Java Have and Which Is the Most Efficient

Java有几种文件拷贝方式:

  1. 基于字节流的传统IO方式:通过InputStreamOutputStream来实现文件的读写操作。
  2. 基于字符流的传统IO方式:通过ReaderWriter来实现文件的读写操作。
  3. 使用NIO的文件拷贝方式:通过FileChannelByteBuffer来实现文件的快速拷贝。

哪一种方式最高效取决于具体的场景和需求。在大多数情况下,NIO的文件拷贝方式相较于传统IO方式更高效,尤其是在大文件或者需要处理大量文件的情况下。这是因为NIO使用了基于缓冲区的方式来进行数据的传输,可以提供更高的性能和更低的内存占用。但对于小文件拷贝等简单场景,传统IO方式可能更加简洁直观,也能满足需求。综合考虑具体的场景和需求,选择适合的文件拷贝方式是最合适的。

12 How many file copy methods does Java have and which is the most efficient #

Java has several typical implementations for file copy, such as:

Using the java.io library, directly create a FileInputStream for the source file and a FileOutputStream for the target file to perform the write operation.

public static void copyFileByStream(File source, File dest) throws
        IOException {
    try (InputStream is = new FileInputStream(source);
         OutputStream os = new FileOutputStream(dest);){
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
 }

Alternatively, using the transferTo or transferFrom methods provided by the java.nio library.

public static void copyFileByChannel(File source, File dest) throws
        IOException {
    try (FileChannel sourceChannel = new FileInputStream(source)
            .getChannel();
         FileChannel targetChannel = new FileOutputStream(dest).getChannel
                 ();){
        for (long count = sourceChannel.size() ;count>0 ;) {
            long transferred = sourceChannel.transferTo(
                    sourceChannel.position(), count, targetChannel);            sourceChannel.position(sourceChannel.position() + transferred);
            count -= transferred;
        }
    }
 }

Of course, the Java standard library already provides several implementations of Files.copy.

In terms of efficiency, it is actually related to the operating system and configuration. Overall, the NIO transferTo/From method might be faster because it can better utilize the underlying mechanisms of modern operating systems and avoid unnecessary copying and context switching.

Analysis of Key Points #

Today’s question, from the perspective of an interview, is indeed a topic that can be tested in an interview. Based on my typical answer above, the interviewer may further ask questions from a practical perspective or regarding the low-level implementation mechanism of IO. The content of this lecture, starting from interview questions, is mainly to deepen your understanding of the design and implementation of the Java IO library.

From a practical perspective, I did not explicitly state that the NIO transfer solution is always the fastest, and in reality, it may not always be the case. We can give feasible inferences based on theoretical analysis, maintain reasonable doubts, and provide a thinking approach to verify conclusions. Sometimes, what the interviewer is testing is how to turn guesses into verifiable conclusions. Thinking methods are more important than memorizing conclusions.

From a technical perspective, the following aspects are worth noting:

  • What are the differences in the underlying mechanisms of different copy methods?
  • Why can zero-copy have performance advantages?
  • Classification and usage of buffers.
  • The impact and practical choices of Direct Buffers on garbage collection, etc.

Next, let’s analyze them together.

Knowledge Expansion #

  1. Analysis of Copy Implementation Mechanism

First, let’s understand the fundamental differences between the different copy methods mentioned earlier.

Firstly, you need to understand the concept of User Space and Kernel Space in the operating system. The operating system kernel, hardware drivers, etc., run in the Kernel Space and have relatively high privileges, while the User Space is for regular applications and services. You can refer to: https://en.wikipedia.org/wiki/User_space.

When we use input/output streams for reading and writing, we actually perform multiple context switches. For example, when an application reads data, the data is first read from the disk into the kernel cache in the Kernel Space, and then switched to the User Space to read the data from the kernel cache into the user cache.

The writing operation is similar, but the steps are reversed. You can refer to the following diagram:

Therefore, this approach incurs additional overhead and may reduce IO efficiency.

On the other hand, the implementation based on NIO transferTo method uses zero-copy technology on Linux and Unix systems, where data transmission does not require User Space involvement, eliminating the overhead of context switches and unnecessary memory copies, which may improve copy performance. It is worth noting that transferTo is not only used for file copying, but can also be used for scenarios like reading disk files and then performing Socket transmission, which can also benefit from the performance and scalability improvements brought by this mechanism.

The transfer process of transferTo is as follows:

  1. Java IO/NIO Source Code Structure

In my previous typical answer, I mentioned the third method, which is that Java standard library also provides a file copying method (java.nio.file.Files.copy). If you answer this way, you should be careful because rarely is the answer to a question as simple as just calling a method. From an interview perspective, interviewers often like to ask follow-up questions until they find something you don’t know.

Actually, the answer to this question is not that straightforward because there are actually several different copy methods:

public static Path copy(Path source, Path target, CopyOption... options)
    throws IOException

public static long copy(InputStream in, Path target, CopyOption... options)
    throws IOException

public static long copy(Path source, OutputStream out) 
    throws IOException

As you can see, copy is not only used for operations between files, and no one specifies that input/output streams must be for files. These are two very useful utility methods.

For the last two copy implementations, you can directly see from the source code that they use InputStream.transferTo() internally, and you can directly look at the source code to see that the internal implementation is actually the read/write operation of the stream in the User Space. However, analyzing the process of the first method is relatively more complicated. Here is a simplified analysis, considering only file system copy processes of the same type for simplicity.

public static Path copy(Path source, Path target, CopyOption... options)
    throws IOException
{
    FileSystemProvider provider = provider(source);
    if (provider(target) == provider) {
        // same provider
        provider.copy(source, target, options); // This is the part under analysis in this article
    } else {
        // different providers
        CopyMoveHelper.copyToForeignTarget(source, target, options);
    }
    return target;
}

I have simplified the code analysis process and summarized it from a practical perspective: to improve the performance of IO operations such as copying, there are some general principles to follow:

  • In the program, use mechanisms like caching to reduce the number of IO operations (in network communication, such as TCP transmission, the window size can also be seen as a similar approach).
  • Use mechanisms like transferTo to reduce context switches and additional IO operations.
  • Minimize unnecessary conversion processes, such as encoding and decoding; object serialization and deserialization. For example, when operating on text files or network communication, if it is not necessary to use text information during the process, consider not converting binary information into strings and directly transmitting the binary information.
  1. Mastering NIO Buffer

In the previous article, I mentioned that Buffer is the basic tool for NIO data operations. Java provides Buffer implementations for each primitive data type (except boolean), so it is essential to master and use Buffer, especially when dealing with Direct Buffer, because of its special characteristics in garbage collection, etc., which require special attention.

Buffer has several basic attributes:

  • capacity, which reflects the size of the Buffer, i.e., the length of the array.
  • position, which is the starting position of the data to be operated on.
  • limit, which serves as a constraint for operations. The meaning of the limit is different when reading or writing. For example, when reading, the limit is often set to the upper limit of the data that can be accommodated, while when writing, it is set to the capacity or the maximum writable limit.

The mark is used to record the position of the previous position and is usually set to 0 by default. It is a convenience and often not necessary.

The first three attributes are the most frequently used in our daily operations. Here is a brief summary of the basic operations of Buffers:

  • We create a ByteBuffer and prepare to put data into it. The capacity of the buffer is, of course, the size of the buffer, and the position is 0. The limit is set to the size of the capacity by default.
  • When we write some bytes of data, the position will increase accordingly, but it cannot exceed the limit.
  • If we want to read the data that was written earlier, we need to call the flip method, which sets the position to 0 and the limit to the previous position.
  • If we want to read from the beginning again, we can call rewind, which keeps the limit unchanged and sets the position to 0 again.

For more detailed usage, I recommend referring to the related tutorial.

1. Direct Buffer and Garbage Collection

I will now focus on two special types of Buffers.

  • Direct Buffer: If you look at the method definition of Buffer, you will find that it defines the isDirect() method to indicate whether the Buffer is of Direct type. This is because Java provides both heap-based and off-heap (Direct) Buffers, which can be created directly using the allocate or allocateDirect methods.
  • MappedByteBuffer: It maps a file directly to a memory area of a specific size. When the program accesses this memory area, it directly operates on the file data, eliminating the overhead of transferring data from kernel space to user space. We can create MappedByteBuffers using the FileChannel.map method, which is essentially a type of Direct Buffer.

In practice, Java will try to perform only local IO operations on Direct Buffers. For many IO-intensive operations with large data volumes, this may bring significant performance advantages because:

  • The memory address of a Direct Buffer remains unchanged throughout its lifetime, allowing the kernel to safely access it, resulting in efficient IO operations.
  • It reduces the possible additional maintenance work for heap-based objects, thereby potentially improving access efficiency.

However, please note that creating and destroying Direct Buffers involves additional overhead compared to general heap-based Buffers. Therefore, it is generally recommended to use Direct Buffers in scenarios where long-term usage and large data sizes are expected.

When using Direct Buffers, we need to be aware of their impact on memory and JVM parameters. First, because they are not on the heap, parameters like Xmx cannot affect the memory usage of heap-based members used by Direct Buffers. Instead, we can set the following parameter to determine their size:

-XX:MaxDirectMemorySize=512M

From the perspective of parameter settings and memory troubleshooting, this means that when calculating the amount of memory Java can use, we need to consider not only the needs of the heap but also a series of off-heap factors such as Direct Buffers. If there is insufficient memory, it is also possible that off-heap memory usage is the cause.

In addition, most garbage collection processes do not actively collect Direct Buffers. Its garbage collection process is based on the Cleaner (an internal implementation) and PhantomReference mechanism that I introduced earlier in the series. Cleaner is not a public class and internally implements a Deallocator responsible for destruction logic. Its destruction often needs to wait until a full GC occurs, so improper use can easily lead to OutOfMemoryError.

I have a few suggestions for garbage collection of Direct Buffers:

  • In the application, call System.gc() explicitly to force triggering garbage collection.
  • Another approach is that in some frameworks that extensively use Direct Buffers, the framework may call release methods in the program itself. Netty does this, and you can refer to its implementation (PlatformDependent0) if you are interested.
  • Reuse Direct Buffers.

2. Tracking and Diagnosing Direct Buffer Memory Usage?

Usually, the standard garbage collection logs and other records do not include information about Direct Buffers, so diagnosing Direct Buffer memory usage can be troublesome. Fortunately, starting from JDK 8, we can easily use the Native Memory Tracking (NMT) feature for diagnosis. You can add the following parameter when starting the program:

-XX:NativeMemoryTracking={summary|detail}

Note that activating NMT typically causes a 5%~10% performance decrease in the JVM, so please consider it carefully.

During runtime, you can use the following commands to perform interactive comparisons:

// Print NMT information
jcmd <pid> VM.native_memory detail

// Create a baseline to compare allocation changes
jcmd <pid> VM.native_memory baseline

// Compare allocation changes
jcmd <pid> VM.native_memory detail.diff

In the output, you can find information about the memory usage of Direct Buffers under the Internal section. This is because its underlying implementation uses unsafe_allocatememory. Strictly speaking, this is not the memory used by the JVM itself, so starting from JDK 11, it is actually classified under the “other” section.

Here is a segment of the output from JDK 9, where “+” indicates allocation changes detected by the “diff” command:

-Internal (reserved=679KB +4KB, committed=679KB +4KB)
              (malloc=615KB +4KB #1571 +4)
              (mmap: reserved=64KB, committed=64KB)

Note: JVM’s off-heap memory is not limited to Direct Buffers, and the information output by NMT naturally includes much more than just Direct Buffers. I will discuss more specific memory structures in later topics.

Today, I analyzed the underlying file operation mechanisms of Java IO/NIO and how to achieve high-performance zero-copy operations. I also summarized the usage and types of Buffers and provided a detailed analysis of the lifecycle management and diagnosis of Direct Buffers.

Practice question #

Have you grasped the concept of the topic we discussed today? Please take a moment to think about it. If we need to write different segments into corresponding buffers during the process of reading from a channel (similar to splitting binary messages into message headers, message bodies, etc.), what mechanism of NIO can be used to achieve this?

Please write your thoughts on this question in the comment section. I will select the most thoughtful comments and reward you with a learning encouragement fund. Feel free to discuss with me.

Are your friends also preparing for interviews? You can “invite friends to read” and share today’s question with them. Perhaps you can help them.