Why is Java IO so bad?

The IO (Input & Output) API is a Java API that makes it easy for developers to work with streams. Let's say we receive some data (for example, first name, middle name, last name) and we need to write it to a file — the time has come to use java.io.

Structure of the java.io library

But Java IO has its drawbacks, so let's talk about each of them in turn:

  1. Blocking access for input/output. The problem is that when a developer tries to read or write something to a file using Java IO, it locks the file and blocks access to it until the job is done.
  2. No support for virtual file systems.
  3. No support for links.
  4. Lots and lots of checked exceptions.

Working with files always entails working with exceptions: for example, trying to create a new file that already exists will throw an IOException. In this case, the application should continue running and the user should be notified why the file could not be created.


try {
	File.createTempFile("prefix", "");
} catch (IOException e) {
	// Handle the IOException
}

/**
 * Creates an empty file in the default temporary-file directory 
 * any exceptions will be ignored. This is typically used in finally blocks. 
 * @param prefix 
 * @param suffix 
 * @throws IOException - If a file could not be created
 */
public static File createTempFile(String prefix, String suffix) 
throws IOException {
...
}

Here we see that the createTempFile method throws an IOException when the file cannot be created. This exception must be handled appropriately. If we try to call this method outside a try-catch block, the compiler will generate an error and suggest two options for fixing it: wrap the method in a try-catch block or make the method that calls File.createTempFile throw an IOException (so it can be handled at a higher level).

Arriving at Java NIO and how it compares with Java IO

Java NIO, or Java Non-Blocking I/O (or sometimes Java New I/O) is designed for high-performance I/O operations.

Let's compare Java IO methods and those that replace them.

First, let's talk about working with Java IO:

InputStream class


try(FileInputStream fin = new FileInputStream("C:/codegym/file.txt")){
    System.out.printf("File size: %d bytes \n", fin.available());
    int i=-1;
    while((i=fin.read())!=-1) {
        System.out.print((char)i);
    }   
} catch(IOException ex) {
    System.out.println(ex.getMessage());
}

The FileInputStream class is for reading data from a file. It inherits the InputStream class and therefore implements all of its methods. If the file cannot be opened, a FileNotFoundException is thrown.

OutputStream class


String text = "Hello world!"; // String to write
try(FileOutputStream fos = new FileOutputStream("C:/codegym/file.txt")){
    // Convert our string into bytes
    byte[] buffer = text.getBytes();
    fos.write(buffer, 0, buffer.length);
    System.out.println("The file has been written");
} catch(IOException ex) {
    System.out.println(ex.getMessage());
}

The FileOutputStream class for writing bytes to a file. It derives from the OutputStream class.

Reader and Writer classes

The FileReader class lets us read character data from streams, and the FileWriter class is used to write character streams. The following code shows how to write and read from a file:


        String fileName = "c:/codegym/Example.txt";

        // Create a FileWriter object
        try (FileWriter writer = new FileWriter(fileName)) {

            // Write content to file
            writer.write("This is a simple example\nin which we\nwrite to a file\nand read from a file\n");
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Create a FileReader object
        try (FileReader fr = new FileReader(fileName)) {
            char[] a = new char[200]; // Number of characters to read
            fr.read(a); // Read content into an array
            for (char c : a) {
                System.out.print(c); // Display characters one by one
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

Now let's talk about Java NIO:

Channel

Unlike the streams used in Java IO, Channel is two-way interface, that is, it can both read and write. A Java NIO channel supports asynchronous data flow in both blocking and non-blocking modes.


RandomAccessFile aFile = new RandomAccessFile("C:/codegym/file.txt", "rw");
FileChannel inChannel = aFile.getChannel();

ByteBuffer buf = ByteBuffer.allocate(100);
int bytesRead = inChannel.read(buf);

while (bytesRead != -1) {
  System.out.println("Read: " + bytesRead);
  buf.flip();
	  while(buf.hasRemaining()) {
	      System.out.print((char) buf.get());
	  }
  buf.clear();
  bytesRead = inChannel.read(buf);
}
aFile.close();

Here we used a FileChannel. We use a file channel to read data from a file. A file channel object can only be created by calling the getChannel() method on a file object — there is no way to directly create a file channel object.

In addition FileChannel, we have other channel implementations:

  • FileChannel — for working with files

  • DatagramChannel — a channel for working over a UDP connection

  • SocketChannel — a channel for working over a TCP connection

  • ServerSocketChannel contains a SocketChannel and is similar to how a web server works

Please note: FileChannel cannot be switched to non-blocking mode. Java NIO's non-blocking mode lets you request read data from a channel and receive only what is currently available (or nothing at all if there is no data available yet). That said, SelectableChannel and its implementations can be put in non-blocking mode using the connect() method.

Selector

Java NIO introduced the ability to create a thread that knows which channel is ready to write and read data and can process that particular channel. This ability is implemented using the Selector class.

Connecting channels to a selector


Selector selector = Selector.open();
channel.configureBlocking(false); // Non-blocking mode
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

So we create our Selector and connect it to a SelectableChannel.

To be used with a selector, a channel must be in non-blocking mode. This means that you cannot use FileChannel with a selector, since FileChannel cannot be put into non-blocking mode. But socket channels will work fine.

Here let's mention that in our example SelectionKey is a set of operations that can be performed on a channel. The selection key lets us know the status of a channel.

Types of SelectionKey

  • SelectionKey.OP_CONNECT signifies a channel that is ready to connect to the server.

  • SelectionKey.OP_ACCEPT is a channel that is ready to accept incoming connections.

  • SelectionKey.OP_READ signifies a channel that is ready to read data.

  • SelectionKey.OP_WRITE signifies a channel that is ready to write data.

Buffer

The data is read into a buffer for further processing. A developer can move back and forth on the buffer, which gives us a little more flexibility when processing data. At the same time, we need to check whether the buffer contains the amount of data required for correct processing. Also, when reading data into a buffer be sure that you don't destroy the existing data that has not yet been processed.


ByteBuffer buf = ByteBuffer.allocate (2048); 
int bytesRead = channel.read(buf);
buf.flip(); // Change to read mode
while (buf.hasRemaining()) { 
	byte data = buf.get(); // There are methods for primitives 
}

buf.clear(); // Clear the buffer - now it can be reused

Basic properties of a buffer:

Basic attributes
capacity The buffer size, which is the length of the array.
position The starting position for working with data.
limit The operating limit. For read operations, the limit is the amount of data that can be read, but for write operations, it is the capacity or quota available for writing.
mark The index of the value to which the position parameter will be reset when the reset() method is called.

Now let's talk a little about what's new in Java NIO.2.

Path

Path represents a path in the file system. It contains the name of a file and a list of directories that define the path to it.


Path relative = Paths.get("Main.java");
System.out.println("File: " + relative);
// Get the file system
System.out.println(relative.getFileSystem());

Paths is a very simple class with a single static method: get(). It was created solely to get a Path object from the passed string or URI.


Path path = Paths.get("c:\\data\\file.txt");

Files

Files is a utility class that lets us directly get the size of a file, copy files, and more.


Path path = Paths.get("files/file.txt");
boolean pathExists = Files.exists(path);

FileSystem

FileSystem provides an interface to the file system. FileSystem works like a factory for creating various objects (Path, PathMatcher, Files). It helps us access files and other objects in the file system.


try {
      FileSystem filesystem = FileSystems.getDefault();
      for (Path rootdir : filesystem.getRootDirectories()) {
          System.out.println(rootdir.toString());
      }
  } catch (Exception e) {
      e.printStackTrace();
  }

Performance test

For this test, let's take two files. The first is a small text file, and the second is a large video.

We'll create a file and add a few words and characters:

% touch text.txt

Our file occupies a total of 42 bytes in memory:

Now let's write code that will copy our file from one folder to another. Let's test it on the small and large files in order to compare the speed of IO and NIO and NIO.2.

Code for copying, written using Java IO:


public static void main(String[] args) {
        long currentMills = System.currentTimeMillis();
        long startMills = currentMills;
        File src = new File("/Users/IdeaProjects/testFolder/text.txt");
        File dst = new File("/Users/IdeaProjects/testFolder/text1.txt");
        copyFileByIO(src, dst);
        currentMills = System.currentTimeMillis();
        System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
    }

    public static void copyFileByIO(File src, File dst) {
        try(InputStream inputStream = new FileInputStream(src);
            OutputStream outputStream = new FileOutputStream(dst)){

            byte[] buffer = new byte[1024];
            int length;
            // Read data into a byte array and then output to an OutputStream
            while((length = inputStream.read(buffer)) > 0) {
                outputStream.write(buffer, 0, length);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

And here's the code for Java NIO:


public static void main(String[] args) {
        long currentMills = System.currentTimeMillis();
        long startMills = currentMills;

        File src = new File("/Users/IdeaProjects/testFolder/text.txt");
        File dst = new File("/Users/IdeaProjects/testFolder/text2.txt");
        // Code for copying using NIO
        copyFileByChannel(src, dst);
        currentMills = System.currentTimeMillis();
        System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
    }

    public static void copyFileByChannel(File src, File dst) {
        // 1. Get a FileChannel for the source file and the target file
        try(FileChannel srcFileChannel  = new FileInputStream(src).getChannel();
            FileChannel dstFileChannel = new FileOutputStream(dst).getChannel()){
            // 2. Size of the current FileChannel
            long count = srcFileChannel.size();
            while(count > 0) {
                /**=============================================================
                 * 3. Write bytes from the source file's FileChannel to the target FileChannel
                 * 1. srcFileChannel.position(): the starting position in the source file, cannot be negative
                 * 2. count: the maximum number of bytes transferred, cannot be negative
                 * 3. dstFileChannel: the target file
                 *==============================================================*/
                long transferred = srcFileChannel.transferTo(srcFileChannel.position(),
                        count, dstFileChannel);
                // 4. After the transfer is complete, change the position of the original file to the new one
                srcFileChannel.position(srcFileChannel.position() + transferred);
                // 5. Calculate how many bytes are left to transfer
                count -= transferred;
            }

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Code for Java NIO.2:


public static void main(String[] args) {
  long currentMills = System.currentTimeMillis();
  long startMills = currentMills;

  Path sourceDirectory = Paths.get("/Users/IdeaProjects/testFolder/test.txt");
  Path targetDirectory = Paths.get("/Users/IdeaProjects/testFolder/test3.txt");
  Files.copy(sourceDirectory, targetDirectory);

  currentMills = System.currentTimeMillis();
  System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
}

Let's start with the small file.

The execution time for Java IO was 1 millisecond on average. By running the test several times, we get results from 0 to 2 milliseconds.

Execution time in milliseconds: 1

The execution time for Java NIO is much longer. The average time is 11 milliseconds. The results ranged from 9 to 16. This is because Java IO works differently than our operating system. IO moves and processes files one by one, but the operating system sends the data in one big chunk. NIO performed poorly because it is buffer-oriented, not stream-oriented like IO.

Execution time in milliseconds: 12

And let's also run our test for Java NIO.2. NIO.2 has improved file management compared to Java NIO. This is why the updated library produces such different results:

Execution time in milliseconds: 3

Now let's try to test our big file, a 521 MB video. The task will be exactly the same: copy the file to another folder. Look!

Results for Java IO:

Execution time in milliseconds: 1866

And here is the result for Java NIO:

Execution time in milliseconds: 205

Java NIO handled the file 9 times faster in the first test. Repeated tests showed approximately the same results.

And we'll also try our test on Java NIO.2:

Execution time in milliseconds: 360

Why this result? Simply because it doesn't make much sense for us to compare performance between them, since they serve different purposes. NIO is more abstract low-level I/O, while NIO.2 is oriented toward file management.

Summary

We can safely say that Java NIO is significantly more efficient when working with files thanks to use inside of blocks. Another plus is that the NIO library is divided into two parts: one for working with files, another for working with the network.

Java NIO.2's new API for working with files offers many useful features:

  • far more useful file system addressing using Path,

  • significantly improved handling of ZIP files using a custom file system provider,

  • access to special file attributes,

  • many convenient methods, such as reading an entire file with a single statement, copying a file with a single statement, etc.

It's all about files and file systems, and it's all pretty high level.

The reality today is that Java NIO accounts for roughly 80-90% of work with files, although Java IO's share is still significant.

💡 P.S. These tests were run on a MacBook Pro 14" 16/512. Test results may differ based on the operating system and workstation specifications.