CodeGym /Courses /JAVA 25 SELF /Debugging errors in asynchronous file I/O

Debugging errors in asynchronous file I/O

JAVA 25 SELF
Level 56 , Lesson 4
Available

1. Buffer errors: ByteBuffer, position and limit

Asynchronous read and write methods work with a ByteBuffer object. Unlike regular arrays, a buffer has an “internal cursor” — the position (position) and the limit (limit), which determine which bytes will be read or written. If you manage these properties incorrectly, you might get a result you don’t expect, or even break the logic entirely.

What does it look like in code?

Incorrect usage example:

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // Oops! Trying to read a string from the buffer right away:
        String str = new String(buf.array()); // This is incorrect!
        // ...
    }
    // ...
});

What’s wrong?

  • After reading, the buffer is in “write” mode: position points to the end of the data read, and limit is the buffer size. If you read from the buffer immediately, you’ll get a bunch of garbage (all 1024 bytes, even if only 10 were actually read).
  • buf.array() returns the entire backing array, not just the portion that was read. Moreover, for direct buffers (allocated via ByteBuffer.allocateDirect) array() will throw UnsupportedOperationException.

How to do it right?

Before reading data from the buffer, you need to call buffer.flip() — this switches the buffer into “read” mode:

public void completed(Integer result, ByteBuffer buf) {
    buf.flip(); // Now position = 0, limit = number of bytes read
    String str = StandardCharsets.UTF_8.decode(buf).toString();
    // ... process the string
}

Reusing the buffer

If you want to reuse the buffer for the next operation, don’t forget to call buffer.clear() or buffer.compact() after processing the data:

  • clear() — completely resets the boundaries: position=0, limit=capacity(), old data is considered garbage.
  • compact() — keeps unread bytes at the beginning of the buffer and prepares it for appending.

Nuance: if result equals -1, the end of file has been reached — there will be no further processing.

2. Parallel access: races and inconsistency

AsynchronousFileChannel allows you to run multiple operations in parallel. But if you don’t control this process, it’s easy to get “corrupted” data or a program crash.

Problem 1: Simultaneous reads into the same buffer

// Two concurrent reads into the same buffer
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);

Both reads write into the same buffer! If both complete almost simultaneously, the buffer’s contents will be unpredictable.

Problem 2: Simultaneous writes to the same file

If two threads write to the same region of a file at the same time, the result depends on which operation finishes first. This is a classic race condition, which can lead to corrupted data.

How to avoid it?

  • Use a separate buffer (ByteBuffer) for each asynchronous operation — threads won’t stomp on each other’s memory.
  • Don’t launch parallel writes to the same file range; separate offsets or synchronize access.
  • If strict ordering matters, start the next operation only after the previous one completes — for example, from completed(...) in CompletionHandler.

3. Resource leaks: forgot to close the channel

An asynchronous channel is a system resource. If you don’t close it (channel.close()), the file will remain “in use” by the system, you may get memory leaks, and on Windows — the file may also be locked for other programs.

Typical mistake:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... started an operation
// forgot to call channel.close() after all operations complete!

How to do it right?

Use try-with-resources and be sure to wait for all operations to finish before exiting the block:

CountDownLatch latch = new CountDownLatch(1);

try (AsynchronousFileChannel channel =
         AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(4096);
    channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        @Override public void completed(Integer r, ByteBuffer b) {
            // processing...
            latch.countDown();
        }
        @Override public void failed(Throwable ex, ByteBuffer b) {
            ex.printStackTrace();
            latch.countDown();
        }
    });

    latch.await(); // Wait for the asynchronous operation to complete
}
// Closing will happen automatically

4. Exception handling: ignoring errors in CompletionHandler

In asynchronous code, errors don’t “explode” in the main thread — they go to the failed(...) method of the CompletionHandler interface. If you don’t implement it or leave it empty, errors will simply disappear, and the program will behave strangely.

Example of an “invisible” error:

channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // ... process the result
    }
    @Override
    public void failed(Throwable exc, ByteBuffer buf) {
        // Oops, empty! The error got lost
    }
});

How to do it right?

@Override
public void failed(Throwable exc, ByteBuffer buf) {
    System.err.println("Error reading file: " + exc.getMessage());
    exc.printStackTrace();
    // Optionally: close the channel, update metrics, notify the user, etc.
}

5. Losing references to Future/CompletionHandler

If you start an asynchronous operation via a Future but forget to keep the reference, you won’t be able to cancel the operation or wait for it to complete. Similarly, if you use a CompletionHandler but don’t synchronize the completion of all operations, the program may terminate prematurely.

Example:

channel.read(buffer, 0, buffer, handler); // handler is anonymous, not stored anywhere
// The program exited immediately without waiting for the read to finish

How to do it right?

  • When working with Future<Integer>: keep the reference and use future.get() or future.cancel(true) if needed.
  • When using CompletionHandler: apply synchronization mechanisms (CountDownLatch, Semaphore) and properly wait for all operations to complete before closing the channel/program.

6. Encoding mistakes

Reading and writing text files requires correct handling of encodings. If you read bytes as strings blindly, you can get mojibake or lose part of the data, especially when reading a file in chunks.

Problem:

// Read the file in 1024-byte chunks, then turn into a string
String chunk = new String(buffer.array(), "UTF-8");

If a character is split between two buffers (for example, one byte of a UTF-8 character remains at the end of one buffer, and the rest — at the beginning of the next), you’ll get incorrect characters or a decoding error.

How to do it right? Use CharsetDecoder and keep the “leftovers” of incomplete characters between reads:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);

// On each completed(...):
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false — this is not end-of-input
if (cr.isError()) {
    cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();

// When no more input will arrive:
decoder.flush(charBuf);

7. Premature program termination

Asynchronous operations run in other threads. If the main thread finishes before all operations, the program will exit without waiting for the results.

Example:

// Start asynchronous read
channel.read(buffer, 0, buffer, handler);
// The main thread finished immediately — the program exited before the operation completed

How to do it right?

Use CountDownLatch, Semaphore or at least Thread.sleep(...) (for a demo) to wait for all operations to complete:

CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
    @Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // Wait for the operation to complete

8. Poor integration with ExecutorService

AsynchronousFileChannel lets you provide your own ExecutorService for handling events. If you pass a pool with too few threads or just one thread, all operations will effectively run sequentially rather than in parallel. If the pool is too large, you’ll pay extra overhead for context switching.

Example:

ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// All asynchronous operations will be effectively synchronous!

How to do it right?

  • Choose the pool size based on the real workload and the number of concurrent operations.
  • For most tasks, ForkJoinPool.commonPool() or Executors.newCachedThreadPool() are good choices.
  • Remember that the provided ExecutorService governs callback invocations (completed/failed), not the disk I/O itself.
1
Task
JAVA 25 SELF, level 56, lesson 4
Locked
Instant message sending: asynchronous write with reliable closing ✉️
Instant message sending: asynchronous write with reliable closing ✉️
1
Task
JAVA 25 SELF, level 56, lesson 4
Locked
System startup synchronization: waiting for configuration load ⚙️
System startup synchronization: waiting for configuration load ⚙️
1
Survey/quiz
Asynchronous file operations, level 56, lesson 4
Unavailable
Asynchronous file operations
Asynchronous file operations
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION