Deepen PracticeOrdered learning track

AsynchronousFileChannel and Completion-Based IO

Learn Java IO, Modern IO, Streams, Buffers, Resources, Serialization & Data Boundaries - Part 020

Production-grade guide to AsynchronousFileChannel and completion-based file IO, covering positional async reads/writes, Future and CompletionHandler APIs, executor strategy, partial completion, cancellation, bounded concurrency, lifecycle, and when async file IO helps or hurts.

13 min read2572 words
PrevNext
Lesson 2032 lesson track1927 Deepen Practice
#java#nio#asynchronousfilechannel#async-io+7 more

Part 020 — AsynchronousFileChannel and Completion-Based IO

Goal part ini: memahami AsynchronousFileChannel sebagai primitive completion-based positional file IO, bukan sebagai “lebih cepat otomatis”. Kita akan membahas kapan ia berguna, kapan blocking FileChannel lebih sederhana, bagaimana menangani partial read/write, bagaimana mengendalikan concurrency, dan bagaimana mendesain lifecycle yang aman.

Part 019 membahas selector-based non-blocking IO. Selector memberi tahu bahwa channel siap. AsynchronousFileChannel memakai model berbeda: kita mengajukan operasi, lalu menerima sinyal ketika operasi selesai.

Selector model:
  wait for readiness
  call read/write yourself
  handle partial progress

AsynchronousFileChannel model:
  submit read/write at file position
  runtime completes operation later
  handle completed byte count or failure

Namun completion tidak berarti “semua selesai sesuai niat bisnis”. Satu async write masih bisa menulis sebagian. Satu async read masih bisa membaca sebagian. State machine tetap milik aplikasi.


1. What AsynchronousFileChannel Is

AsynchronousFileChannel is for asynchronous reading, writing, and manipulating a file. It operates on file positions, not an implicit stream cursor.

Core capabilities:

  • open file with read/write options
  • read bytes into ByteBuffer from a given file position
  • write bytes from ByteBuffer to a given file position
  • get file size
  • truncate file
  • force updates to storage
  • acquire file locks asynchronously/synchronously depending on method
  • receive completion through Future or CompletionHandler

Core non-goals:

  • it is not a selector channel
  • it does not preserve record boundaries
  • it does not remove need for buffering discipline
  • it does not remove need for force/durability decisions
  • it does not guarantee faster throughput than well-designed blocking IO

Important mental model:

AsynchronousFileChannel = positional operation submission API
Application = operation graph + buffers + offsets + concurrency limits + lifecycle

2. Readiness vs Completion

Comparison:

DimensionSelector-Based NIOAsynchronousFileChannel
Triggerreadinessoperation completion
Typical channelssockets, datagrams, pipesfiles
Position modelchannel dependentexplicit file position per operation
Notificationselected keyFuture or CompletionHandler
State locationattachment/sessionattachment/context object
Partial operationmust handlemust handle
Backpressureinterest ops and queuesbounded outstanding operations

Do not mentally model AsynchronousFileChannel as “file selector”. It is a different programming model.


3. Opening an AsynchronousFileChannel

Basic open:

Path path = Path.of("data.bin");

try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(
        path,
        StandardOpenOption.READ)) {
    // submit async reads
}

Open for writing:

try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(
        path,
        StandardOpenOption.CREATE,
        StandardOpenOption.WRITE)) {
    // submit async writes
}

Open with executor:

ExecutorService ioExecutor = Executors.newFixedThreadPool(8);

try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(
        path,
        Set.of(StandardOpenOption.READ),
        ioExecutor)) {
    // completions may use the supplied executor depending on implementation
} finally {
    ioExecutor.shutdown();
}

Why supply an executor?

  • isolate file IO completion work from common/system defaults
  • bound concurrency
  • give threads meaningful names
  • apply operational controls
  • avoid unintentional coupling with unrelated async work

Thread factory example:

static ThreadFactory namedThreadFactory(String prefix) {
    AtomicInteger seq = new AtomicInteger();
    return task -> {
        Thread t = new Thread(task, prefix + "-" + seq.incrementAndGet());
        t.setDaemon(false);
        return t;
    };
}

4. Future API

The simplest API returns a Future<Integer>:

ByteBuffer buffer = ByteBuffer.allocate(8192);
Future<Integer> future = channel.read(buffer, 0L);

int bytesRead = future.get();

This is easy to understand, but calling get() blocks the current thread. If you immediately block after submitting, you may not gain much over blocking FileChannel.

Future API is useful when:

  • you submit multiple independent operations
  • you collect completions later
  • you want straightforward integration with existing blocking orchestration
  • you have explicit timeout/cancellation policy

Example: submit several reads and then collect:

record ReadTask(long position, ByteBuffer buffer, Future<Integer> future) {}

List<ReadTask> tasks = new ArrayList<>();
for (long position = 0; position < fileSize; position += CHUNK_SIZE) {
    ByteBuffer buffer = ByteBuffer.allocate(CHUNK_SIZE);
    Future<Integer> future = channel.read(buffer, position);
    tasks.add(new ReadTask(position, buffer, future));
}

for (ReadTask task : tasks) {
    int n = task.future().get();
    task.buffer().flip();
    processChunk(task.position(), task.buffer(), n);
}

But this has a dangerous flaw for large files: it submits all reads at once. Use bounded concurrency.


5. CompletionHandler API

The callback API:

channel.read(buffer, position, attachment, new CompletionHandler<Integer, Attachment>() {
    @Override
    public void completed(Integer result, Attachment attachment) {
        // operation completed successfully
    }

    @Override
    public void failed(Throwable exc, Attachment attachment) {
        // operation failed
    }
});

The attachment is your operation context. Treat it like SelectionKey.attachment() in selector code.

Example context:

record ReadContext(
        AsynchronousFileChannel channel,
        long position,
        ByteBuffer buffer,
        Consumer<ByteBuffer> onChunk,
        Consumer<Throwable> onError
) {}

Read once:

static void readOnce(ReadContext ctx) {
    ctx.channel().read(ctx.buffer(), ctx.position(), ctx,
            new CompletionHandler<Integer, ReadContext>() {
                @Override
                public void completed(Integer n, ReadContext c) {
                    if (n == -1) {
                        c.buffer().flip();
                        c.onChunk().accept(c.buffer());
                        return;
                    }
                    c.buffer().flip();
                    c.onChunk().accept(c.buffer());
                }

                @Override
                public void failed(Throwable exc, ReadContext c) {
                    c.onError().accept(exc);
                }
            });
}

Callbacks are powerful but increase control-flow complexity. Keep callback bodies small and move policy into named methods.


6. Partial Reads Still Exist

A read operation completes with an integer count:

  • positive: number of bytes read
  • zero: possible depending on operation/context
  • -1: end-of-file

If you require exactly N bytes from a position, you need readFully logic.

static CompletableFuture<ByteBuffer> readFully(
        AsynchronousFileChannel channel,
        long position,
        int byteCount) {

    ByteBuffer buffer = ByteBuffer.allocate(byteCount);
    CompletableFuture<ByteBuffer> result = new CompletableFuture<>();
    readFully0(channel, position, buffer, result);
    return result;
}

static void readFully0(
        AsynchronousFileChannel channel,
        long position,
        ByteBuffer buffer,
        CompletableFuture<ByteBuffer> result) {

    if (!buffer.hasRemaining()) {
        buffer.flip();
        result.complete(buffer);
        return;
    }

    channel.read(buffer, position, null, new CompletionHandler<Integer, Void>() {
        @Override
        public void completed(Integer n, Void ignored) {
            if (n == -1) {
                result.completeExceptionally(new EOFException(
                        "EOF before reading requested byte count"));
                return;
            }
            if (n == 0) {
                // Avoid tight recursive completion if implementation returns zero repeatedly.
                readFully0(channel, position, buffer, result);
                return;
            }
            readFully0(channel, position + n, buffer, result);
        }

        @Override
        public void failed(Throwable exc, Void ignored) {
            result.completeExceptionally(exc);
        }
    });
}

Caution: recursive callback chains can become hard to reason about. For production, consider a small state object and explicit scheduling if completion can happen inline or very quickly.


7. Partial Writes Still Exist

A write operation completes with number of bytes written. It may write fewer bytes than the buffer has remaining.

writeFully:

static CompletableFuture<Long> writeFully(
        AsynchronousFileChannel channel,
        long position,
        ByteBuffer source) {

    CompletableFuture<Long> result = new CompletableFuture<>();
    writeFully0(channel, position, source, 0L, result);
    return result;
}

static void writeFully0(
        AsynchronousFileChannel channel,
        long position,
        ByteBuffer source,
        long totalWritten,
        CompletableFuture<Long> result) {

    if (!source.hasRemaining()) {
        result.complete(totalWritten);
        return;
    }

    channel.write(source, position, null, new CompletionHandler<Integer, Void>() {
        @Override
        public void completed(Integer n, Void ignored) {
            if (n < 0) {
                result.completeExceptionally(new EOFException("unexpected negative write result"));
                return;
            }
            if (n == 0) {
                writeFully0(channel, position, source, totalWritten, result);
                return;
            }
            writeFully0(channel, position + n, source, totalWritten + n, result);
        }

        @Override
        public void failed(Throwable exc, Void ignored) {
            result.completeExceptionally(exc);
        }
    });
}

Invariant:

If your logical operation requires all bytes to be written,
then one AsynchronousFileChannel.write call is not enough as a correctness boundary.

8. Buffer Ownership During Outstanding Operations

When a buffer is passed to async read/write, do not mutate or reuse it until the operation completes.

Bad:

ByteBuffer buffer = ByteBuffer.allocate(8192);
channel.read(buffer, 0L);
buffer.clear(); // bug: operation may still be using it

Correct:

ByteBuffer buffer = ByteBuffer.allocate(8192);
Future<Integer> future = channel.read(buffer, 0L);
int n = future.get();
buffer.flip();

With callbacks:

channel.read(buffer, 0L, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer n, ByteBuffer b) {
        b.flip();
        process(b);
    }

    @Override
    public void failed(Throwable exc, ByteBuffer b) {
        release(b);
    }
});

Ownership invariant:

Outstanding async operation owns the buffer's mutable state.
Application regains ownership only in completion/failure/cancellation handling.

This is one of the most important review rules for async file IO.


9. Positional IO and Ordering

AsynchronousFileChannel operations specify file position explicitly:

channel.read(buffer, 1024L, attachment, handler);
channel.write(buffer, 4096L, attachment, handler);

This is powerful for parallel chunk IO, but it removes implicit ordering guarantees at the application level.

If two writes target overlapping regions, your application must define what happens.

write A: position 0, length 100
write B: position 50, length 100

This is a data race at the file-content level unless intentionally coordinated.

Design rules:

  • independent chunks may be written in parallel
  • overlapping writes require serialization
  • append-style file formats need an allocation/offset coordinator
  • metadata commit should happen after data writes complete
  • durability should be applied at commit boundary, not random operation boundary

10. Bounded Concurrency

The most common async IO mistake is unbounded submission.

Bad:

for (long pos = 0; pos < fileSize; pos += CHUNK_SIZE) {
    channel.read(ByteBuffer.allocate(CHUNK_SIZE), pos, null, handler);
}

For a 100 GiB file, this can allocate huge memory and enqueue too many outstanding operations.

Use a semaphore or work window.

final class AsyncChunkReader {
    private final AsynchronousFileChannel channel;
    private final int chunkSize;
    private final int maxInFlight;
    private final AtomicLong nextPosition = new AtomicLong();
    private final long fileSize;
    private final AtomicInteger inFlight = new AtomicInteger();
    private final CompletableFuture<Void> done = new CompletableFuture<>();
    private final AtomicReference<Throwable> failure = new AtomicReference<>();

    AsyncChunkReader(AsynchronousFileChannel channel, long fileSize,
                     int chunkSize, int maxInFlight) {
        this.channel = channel;
        this.fileSize = fileSize;
        this.chunkSize = chunkSize;
        this.maxInFlight = maxInFlight;
    }

    CompletableFuture<Void> start() {
        for (int i = 0; i < maxInFlight; i++) {
            submitNext();
        }
        return done;
    }

    private void submitNext() {
        if (failure.get() != null) {
            completeIfIdle();
            return;
        }

        long position = nextPosition.getAndAdd(chunkSize);
        if (position >= fileSize) {
            completeIfIdle();
            return;
        }

        int size = (int) Math.min(chunkSize, fileSize - position);
        ByteBuffer buffer = ByteBuffer.allocate(size);
        inFlight.incrementAndGet();

        channel.read(buffer, position, position, new CompletionHandler<Integer, Long>() {
            @Override
            public void completed(Integer n, Long pos) {
                try {
                    if (n == -1) {
                        // EOF may be acceptable at file end; here it is unexpected for a planned region.
                        throw new EOFException("unexpected EOF at position " + pos);
                    }
                    buffer.flip();
                    process(pos, buffer);
                } catch (Throwable t) {
                    failure.compareAndSet(null, t);
                } finally {
                    inFlight.decrementAndGet();
                    submitNext();
                }
            }

            @Override
            public void failed(Throwable exc, Long pos) {
                failure.compareAndSet(null, exc);
                inFlight.decrementAndGet();
                submitNext();
            }
        });
    }

    private void completeIfIdle() {
        if (inFlight.get() == 0) {
            Throwable t = failure.get();
            if (t == null) done.complete(null);
            else done.completeExceptionally(t);
        }
    }

    private void process(long position, ByteBuffer buffer) {
        // Application-specific chunk processing.
    }
}

This example is intentionally simplified. A production version needs better partial-read handling and cancellation. But the key design is the bounded window.


11. Chunked Parallel Read Pattern

Good use case: read independent fixed-size chunks from a large immutable file.

Suitable when:

  • file is stable/immutable during read
  • chunks are independent
  • order is not required or can be reconstructed
  • memory budget is bounded
  • downstream processing is not slower than read rate, or has backpressure

Not suitable when:

  • file format requires sequential parsing
  • chunk boundary can split records without a parser plan
  • downstream is slow and queue is unbounded
  • disk is already saturated by other workloads
  • operation order matters strongly

12. Ordered Output From Parallel Reads

Parallel reads complete out of order. If output order matters, you need reorder buffering.

final class OrderedCollector {
    private final Map<Long, ByteBuffer> completed = new HashMap<>();
    private long nextExpectedPosition;

    void complete(long position, ByteBuffer chunk) {
        completed.put(position, chunk);
        drainReady();
    }

    private void drainReady() {
        while (true) {
            ByteBuffer next = completed.remove(nextExpectedPosition);
            if (next == null) {
                return;
            }
            emit(nextExpectedPosition, next);
            nextExpectedPosition += next.limit();
        }
    }

    private void emit(long position, ByteBuffer chunk) {
        // write/process in file order
    }
}

This introduces a memory risk: if an early chunk is delayed, many later chunks may accumulate. Bounded in-flight window is required.


13. Async Write Pattern: Staged File Assembly

Use case: write independent file regions, then publish a manifest or marker.

1. allocate output file path in staging directory
2. write independent chunks at fixed offsets
3. wait for all chunk writes to complete
4. force file content if durability matters
5. write/force metadata or manifest
6. atomic move staging file into final location

Sketch:

CompletableFuture<?>[] writes = chunks.stream()
        .map(chunk -> writeFully(channel, chunk.position(), chunk.buffer()))
        .toArray(CompletableFuture[]::new);

CompletableFuture<Void> allWrites = CompletableFuture.allOf(writes);

allWrites.thenRun(() -> {
    try {
        channel.force(true);
    } catch (IOException e) {
        throw new CompletionException(e);
    }
});

Do not mark the file as complete before all writes and commit actions finish.


14. Durability with AsynchronousFileChannel

AsynchronousFileChannel has force(boolean metaData), similar in purpose to FileChannel.force.

Durability questions are the same as Part 012:

  • Do we need file content durable?
  • Do we need metadata durable?
  • Do we rely on atomic rename?
  • Do we need parent directory durability?
  • What happens after process crash?
  • What happens after OS crash?

Async operation completion does not imply crash durability.

write completion => bytes accepted by OS/runtime path
force completion  => request to force updates to storage
atomic move       => namespace transition, not necessarily complete storage-barrier story

Safe publish pattern:

Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");

try (AsynchronousFileChannel ch = AsynchronousFileChannel.open(
        tmp,
        StandardOpenOption.CREATE,
        StandardOpenOption.TRUNCATE_EXISTING,
        StandardOpenOption.WRITE)) {

    writeFully(ch, 0L, content).join();
    ch.force(true);
}

Files.move(tmp, finalPath,
        StandardCopyOption.ATOMIC_MOVE,
        StandardCopyOption.REPLACE_EXISTING);

For the strongest crash-consistency requirements, revisit directory fsync constraints from Part 012 and the deployment filesystem behavior.


15. Cancellation and Timeouts

The Future API exposes cancellation:

Future<Integer> future = channel.read(buffer, position);
boolean cancelled = future.cancel(true);

But cancellation semantics can be implementation-specific in practical effect. You still need a failure policy if operation completes after timeout or if cancellation is not effective immediately.

Timeout wrapper:

static <T> CompletableFuture<T> withTimeout(
        CompletableFuture<T> original,
        Duration timeout,
        ScheduledExecutorService scheduler) {

    CompletableFuture<T> timeoutFuture = new CompletableFuture<>();
    ScheduledFuture<?> scheduled = scheduler.schedule(
            () -> timeoutFuture.completeExceptionally(new TimeoutException()),
            timeout.toMillis(),
            TimeUnit.MILLISECONDS);

    return original.applyToEither(timeoutFuture, Function.identity())
            .whenComplete((r, t) -> scheduled.cancel(false));
}

Timeout policy choices:

PolicyMeaning
fail logical operation onlyignore late completion but keep channel
close channelforce outstanding operations to fail eventually
cancel futurebest-effort cancellation
retry at same offsetonly safe if operation is idempotent and buffer ownership is clear
quarantine outputif partial write may have occurred

For writes, timeout is especially tricky because partial data may already be on disk.


16. Close Semantics

If a channel is closed while operations are outstanding, those operations should fail, commonly with asynchronous close-related failure.

Design rule:

A component that owns an AsynchronousFileChannel must coordinate:
  - no new submissions after closing begins
  - outstanding operation accounting
  - buffer release after completion/failure
  - channel close
  - executor shutdown if executor is owned

Lifecycle state:

enum LifecycleState {
    OPEN,
    CLOSING,
    CLOSED
}

Submission gate:

final class AsyncFileComponent implements AutoCloseable {
    private final AsynchronousFileChannel channel;
    private final AtomicReference<LifecycleState> state =
            new AtomicReference<>(LifecycleState.OPEN);

    CompletableFuture<ByteBuffer> read(long position, int size) {
        if (state.get() != LifecycleState.OPEN) {
            return CompletableFuture.failedFuture(
                    new ClosedChannelException());
        }
        return readFully(channel, position, size);
    }

    @Override
    public void close() throws IOException {
        if (state.compareAndSet(LifecycleState.OPEN, LifecycleState.CLOSING)) {
            channel.close();
            state.set(LifecycleState.CLOSED);
        }
    }
}

Production implementations also track in-flight operations and wait or fail fast depending on shutdown mode.


17. Error Taxonomy

Async file IO errors should be classified, not just logged.

ErrorTypical MeaningResponse
EOFExceptionexpected bytes missingfail parse/read operation
ClosedChannelExceptionchannel closed before/during operationcoordinate lifecycle or fail request
AsynchronousCloseExceptionclose interrupted outstanding operationexpected during shutdown, abnormal otherwise
NoSuchFileExceptionpath missingclassify as input/config/dependency error
AccessDeniedExceptionpermission/lock/policy issuefail operation; surface operational detail
FileSystemExceptionprovider-specific FS failureinclude path/reason; retry only if safe
IOExceptionbroad IO failureclassify by context
OutOfMemoryErrorbuffer over-allocation or memory pressurereduce in-flight/window/buffer size

Callback failure handling should preserve operation context:

record OperationContext(
        Path path,
        long position,
        int expectedBytes,
        String operationName
) {}

Then wrap errors with context:

static IOException enrich(Throwable error, OperationContext ctx) {
    return new IOException(
            ctx.operationName() + " failed at " + ctx.path()
                    + " position=" + ctx.position()
                    + " expectedBytes=" + ctx.expectedBytes(),
            error);
}

18. Completion Handler Design Rules

Bad callback:

@Override
public void completed(Integer n, Ctx ctx) {
    parseHugeFile(ctx.buffer());
    callRemoteService();
    writeDatabase();
}

Problems:

  • completion thread is blocked
  • IO completion throughput drops
  • hidden coupling to downstream systems
  • error handling becomes unclear

Better:

@Override
public void completed(Integer n, Ctx ctx) {
    if (n < 0) {
        ctx.result().completeExceptionally(new EOFException());
        return;
    }

    ctx.buffer().flip();
    ctx.cpuExecutor().execute(() -> {
        try {
            Parsed parsed = parse(ctx.buffer());
            ctx.result().complete(parsed);
        } catch (Throwable t) {
            ctx.result().completeExceptionally(t);
        }
    });
}

Rule:

Completion handler should complete IO state and hand off heavy work.
It should not become the business workflow engine.

19. Integrating With CompletableFuture

AsynchronousFileChannel predates CompletableFuture, but wrapping it can simplify composition.

static CompletableFuture<Integer> readAsync(
        AsynchronousFileChannel channel,
        ByteBuffer buffer,
        long position) {

    CompletableFuture<Integer> cf = new CompletableFuture<>();
    channel.read(buffer, position, null, new CompletionHandler<Integer, Void>() {
        @Override
        public void completed(Integer result, Void attachment) {
            cf.complete(result);
        }

        @Override
        public void failed(Throwable exc, Void attachment) {
            cf.completeExceptionally(exc);
        }
    });
    return cf;
}

Then:

CompletableFuture<ByteBuffer> chunk = readAsync(channel, buffer, position)
        .thenApply(n -> {
            if (n == -1) throw new CompletionException(new EOFException());
            buffer.flip();
            return buffer;
        });

Caution:

  • thenApply may run on completion thread.
  • use thenApplyAsync with a chosen executor for heavier work.
  • preserve buffer ownership boundaries.
  • do not hide partial read/write requirements behind too-simple wrappers.

20. Virtual Threads and Blocking File IO

Modern Java makes blocking code more scalable with virtual threads, but file IO has different characteristics from network socket IO. Many OS/filesystem operations still consume kernel resources and storage bandwidth regardless of Java thread model.

Decision framing:

Use CaseReasonable Choice
simple sequential file read/writeFiles or FileChannel with clear blocking code
many independent file operations with simple logicvirtual threads + bounded executor may be clearer
large positional chunk IO with bounded in-flight operationsAsynchronousFileChannel can be useful
latency-sensitive network serverselector/framework or virtual-thread design depending on constraints
complex streaming parsersequential blocking may be easier and safer
custom storage engine region IOFileChannel, mmap, or FFM depending on access pattern

Do not choose async file IO because it sounds modern. Choose it when the operation graph benefits from overlapping independent file operations and when you can bound the complexity.


21. When AsynchronousFileChannel Helps

Good fits:

  1. Parallel random reads from large immutable files.
  2. File serving where chunks can be read independently.
  3. Index/data file access by known offsets.
  4. Background prefetch pipelines.
  5. Large staged file assembly from independent chunks.
  6. Systems that already use completion-based orchestration.
  7. Workloads where blocking file threads are expensive or hard to isolate.

Poor fits:

  1. Tiny files where overhead dominates.
  2. Strictly sequential parsing.
  3. Write-once small config files.
  4. Codebase without async discipline.
  5. Cases where you immediately block on every Future.
  6. Workloads bottlenecked by storage throughput, not thread blocking.
  7. Systems without clear memory and in-flight limits.

22. Async File Reader: A Bounded Design Sketch

A production-style shape:

public final class BoundedAsyncFileReader implements AutoCloseable {
    private final AsynchronousFileChannel channel;
    private final Semaphore permits;
    private final int chunkSize;

    public BoundedAsyncFileReader(Path path, int maxInFlight, int chunkSize,
                                  ExecutorService executor) throws IOException {
        this.channel = AsynchronousFileChannel.open(
                path,
                Set.of(StandardOpenOption.READ),
                executor);
        this.permits = new Semaphore(maxInFlight);
        this.chunkSize = chunkSize;
    }

    public CompletableFuture<ByteBuffer> readChunk(long position, int size) {
        CompletableFuture<ByteBuffer> result = new CompletableFuture<>();

        try {
            permits.acquire();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.failedFuture(e);
        }

        ByteBuffer buffer = ByteBuffer.allocate(Math.min(size, chunkSize));
        channel.read(buffer, position, null, new CompletionHandler<Integer, Void>() {
            @Override
            public void completed(Integer n, Void ignored) {
                try {
                    if (n == -1) {
                        result.completeExceptionally(new EOFException());
                        return;
                    }
                    buffer.flip();
                    result.complete(buffer);
                } finally {
                    permits.release();
                }
            }

            @Override
            public void failed(Throwable exc, Void ignored) {
                try {
                    result.completeExceptionally(exc);
                } finally {
                    permits.release();
                }
            }
        });

        return result;
    }

    @Override
    public void close() throws IOException {
        channel.close();
    }
}

This still reads once, not necessarily fully. Depending on contract, wrap it with readFully.


23. Async Write Safety: Idempotency and Partial Output

Retries are not automatically safe.

Suppose a write times out:

write bytes [0..8191] at position 0
application times out
operation may later complete partially or fully
application retries different content at position 0

Now file content may be ambiguous unless you coordinate carefully.

Safer write design:

  • write immutable content for a region
  • retry same bytes at same position only if idempotent
  • avoid overlapping writes
  • verify content length/checksum if needed
  • commit with manifest after all regions complete
  • publish final file atomically

For append-like semantics, do not let many operations guess append offsets. Allocate offsets centrally:

AtomicLong nextOffset = new AtomicLong();

long allocate(int length) {
    return nextOffset.getAndAdd(length);
}

Then write at allocated positions.


24. Testing Async File IO

Tests must force non-happy paths.

Test cases:

  • read empty file
  • read exact chunk size
  • read final partial chunk
  • read beyond EOF
  • write zero bytes
  • write large buffer
  • close while operation outstanding
  • fail opening missing path
  • access denied path if feasible
  • bounded in-flight never exceeds configured limit
  • cancellation/timeout policy
  • partial read/write wrappers using fake channel or adapter

For deterministic tests, isolate operation orchestration from actual JDK channel where possible:

interface AsyncPositionalReader {
    void read(ByteBuffer dst, long position,
              CompletionHandler<Integer, ReadRequest> handler,
              ReadRequest request);
}

Then inject a fake that completes:

  • immediately
  • later
  • with partial count
  • with EOF
  • with failure
  • out of order

This lets you test your state machine without depending on OS timing.


25. Observability Without Repeating Observability Series

For async file IO, minimum internal counters:

MetricWhy It Matters
outstanding operationsdetects unbounded submission
queued logical requestsdetects upstream pressure
operation latencydetects storage slowdown
bytes read/writtenthroughput
completion failures by exception classoperational classification
cancellation/timeoutsdeadline pressure
buffer allocation bytesmemory pressure
force latencydurability cost

Log operation context on failure:

operation=readFully path=/data/index.bin position=1048576 expectedBytes=4096 exception=EOFException

Avoid logging per successful chunk at high volume.


26. Production Review Checklist

API Choice

  • Is async file IO justified over blocking FileChannel/Files?
  • Are operations independent enough to benefit from overlap?
  • Is file access positional rather than naturally sequential?
  • Is code complexity acceptable for the benefit?

Correctness

  • Are partial reads handled where full reads are required?
  • Are partial writes handled where full writes are required?
  • Are overlapping writes prevented or intentionally coordinated?
  • Is EOF handled explicitly?
  • Are file positions computed safely?
  • Is publish/commit separated from data write completion?

Resource Management

  • Are outstanding operations bounded?
  • Are buffers bounded and released after completion/failure?
  • Are buffers not mutated while operation is outstanding?
  • Is executor ownership clear?
  • Is channel close coordinated with in-flight operations?

Failure Handling

  • Are exceptions enriched with path/position/operation context?
  • Is timeout policy defined?
  • Is cancellation policy defined?
  • Are retries idempotent?
  • Is partial output quarantined or recoverable?

Durability

  • Is force used only at meaningful commit boundaries?
  • Is metadata durability considered when required?
  • Is atomic move used for publish when appropriate?
  • Is recovery behavior defined after crash?

27. Practice: Build an Async Chunk Copier

Build a file copier using AsynchronousFileChannel.

Requirements:

  1. Input and output paths.
  2. Chunk size configurable.
  3. Max in-flight chunks configurable.
  4. Use positional reads and writes.
  5. Handle partial reads and partial writes.
  6. Preserve output order by writing at the same offsets.
  7. Do not submit more than max in-flight operations.
  8. On any failure, close channels and delete/quarantine temp output.
  9. Write to temp file first.
  10. After all writes complete, call force(true).
  11. Publish with atomic move if supported.
  12. Validate final size.

Stretch goals:

  • compute checksum while copying
  • report progress
  • support cancellation
  • simulate partial read/write in tests
  • compare with FileChannel.transferTo and blocking copy

Review question:

Does your copier remain correct if:
  - final chunk is smaller than chunk size?
  - read completes partially?
  - write completes partially?
  - chunk 10 completes before chunk 2?
  - output publish fails?
  - process crashes before atomic move?

28. Baeldung-Style Summary

AsynchronousFileChannel gives Java a completion-based API for positional file IO. It is useful when you have independent file regions, bounded outstanding operations, and a clear operation graph.

The core invariants:

Completion is not full logical completion.
One read/write may be partial.
Buffers are owned by outstanding operations.
Positions are explicit and must not overlap accidentally.
Concurrency must be bounded.
Async completion does not imply durability.
Close/cancel/retry policies must be explicit.

Used well, AsynchronousFileChannel is a strong primitive for large-file and random-access workflows. Used casually, it creates callback complexity without improving correctness or performance.


29. References

  • Java SE 25 API — AsynchronousFileChannel
  • Java SE 25 API — AsynchronousChannel
  • Java SE 25 API — CompletionHandler
  • Java SE 25 API — ByteBuffer
  • Java SE 25 API — StandardOpenOption
  • Java SE 25 API — Path
  • Java SE 25 API — Files
Lesson Recap

You just completed lesson 20 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.