Build CoreOrdered learning track

NIO Buffers, Channels, and Byte-Oriented Design

Learn Java Networking - Part 010

Deep dive into Java NIO Buffer and Channel fundamentals for network programming, including ByteBuffer lifecycle, SocketChannel, ServerSocketChannel, direct buffers, byte order, partial I/O, and zero-copy mental models.

13 min read2500 words
PrevNext
Lesson 1032 lesson track0718 Build Core
#java#networking#nio#bytebuffer+3 more

Part 010 — NIO Buffers, Channels, and Byte-Oriented Design

Target utama bagian ini: membangun mental model yang benar tentang Buffer, ByteBuffer, dan Channel, supaya saat masuk ke Selector dan non-blocking server, kamu tidak sekadar menghafal flip()/clear(), tetapi memahami invariant byte movement yang sedang terjadi.

Java NIO sering terasa sulit bukan karena API-nya banyak, tetapi karena ia memaksa kita berpikir seperti kernel boundary: data bergerak dari channel ke buffer, lalu dari buffer ke parser, lalu dari encoder ke buffer, lalu dari buffer ke channel.

Kalau blocking InputStream terasa seperti “read data”, NIO lebih eksplisit:

read from channel into buffer
switch buffer from write-mode to read-mode
consume bytes from buffer
preserve unconsumed bytes if needed
write bytes from buffer into channel
handle partial writes

Bagian ini adalah pondasi untuk Part 011 dan Part 012.


1. Kaufman Deconstruction

Skill “Java NIO network programming” dipecah menjadi 9 sub-skill:

Sub-skillYang harus dikuasai
Buffer statecapacity, position, limit, remaining
Buffer mode transitionclear, flip, compact, rewind, mark/reset
Byte-oriented protocolParse data yang datang parsial dan bertahap
Channel semanticsread/write bisa partial dan return value penting
SocketChannel lifecycleopen, connect, finishConnect, read, write, close
ServerSocketChannel lifecyclebind, accept, blocking vs non-blocking
Direct vs heap bufferTrade-off GC, native memory, syscall boundary
Byte orderBig-endian/network byte order vs little-endian internal format
Zero-copy mental modelKapan data bisa dipindah lebih efisien dan kapan tidak

Deliberate practice: implement length-prefixed protocol dengan ByteBuffer, lalu paksa read/write parsial.


2. Why NIO Exists

Classic java.io memberi stream abstraction. Itu nyaman untuk thread-per-connection blocking model.

NIO memberi abstraction yang lebih dekat ke OS I/O primitives:

  • buffer eksplisit,
  • channel eksplisit,
  • selectable non-blocking channel,
  • scatter/gather I/O,
  • memory-mapped file,
  • direct buffer,
  • integration dengan selector.

Untuk networking, NIO penting karena:

  • satu thread dapat mengelola banyak connection,
  • aplikasi bisa mengontrol buffer dan backpressure lebih eksplisit,
  • partial read/write menjadi visible,
  • cocok untuk protocol parser state machine,
  • dapat mengurangi allocation jika dikelola benar.

Tetapi NIO bukan otomatis lebih cepat. NIO yang salah bisa lebih lambat dan lebih rapuh daripada blocking socket sederhana.


3. Core Mental Model: Channel ↔ Buffer

Channel adalah sumber/tujuan I/O. Buffer adalah staging area di memory.

Tidak ada channel.read() langsung menjadi object domain. Selalu ada byte boundary.

Invariant:

Network I/O is byte movement first, protocol interpretation second.


4. Buffer State: Capacity, Position, Limit

Buffer punya tiga angka utama:

FieldMakna
capacityUkuran total buffer, tidak berubah setelah dibuat
positionIndex operasi berikutnya
limitBatas operasi saat ini
remaining()limit - position

Saat menulis ke buffer:

capacity = total size
position = where next byte will be put
limit = capacity

Saat membaca dari buffer:

position = where next byte will be read
limit = end of meaningful data

Diagram:

[0................position........limit........capacity]

Masalah utama pemula NIO adalah lupa bahwa position dan limit punya makna berbeda tergantung mode buffer.


5. flip(): Dari Write Mode ke Read Mode

Contoh:

ByteBuffer buffer = ByteBuffer.allocate(8);

buffer.put((byte) 'O');
buffer.put((byte) 'K');

buffer.flip();

while (buffer.hasRemaining()) {
    System.out.println((char) buffer.get());
}

Sebelum flip():

capacity = 8
position = 2
limit    = 8
content  = O K _ _ _ _ _ _

Setelah flip():

capacity = 8
position = 0
limit    = 2
content  = O K _ _ _ _ _ _

flip() artinya:

limit = position
position = 0

Rule:

After writing bytes into a buffer, call flip() before reading them.


6. clear(): Siap Menulis Ulang, Bukan Menghapus Data

clear() tidak menghapus isi array/memory. Ia hanya mengubah state:

position = 0
limit = capacity

Contoh:

buffer.clear();

Artinya: “anggap semua byte lama tidak penting; buffer siap ditulis dari awal”.

Bug umum: mengira clear() menghapus data sensitif. Untuk secret material, kamu harus overwrite eksplisit.


7. compact(): Menyelamatkan Byte yang Belum Diproses

Network parser sering menerima data parsial:

[length=10][only 4 bytes payload arrived]

Kamu tidak boleh discard bytes itu. compact() memindahkan remaining bytes ke awal buffer, lalu buffer siap ditulis lagi setelahnya.

buffer.compact();

Sebelum compact dalam read mode:

[consumed consumed remaining remaining _ _ _ _]
                  ^position          ^limit

Setelah compact:

[remaining remaining _ _ _ _ _ _]
                    ^position        ^limit=capacity

Rule:

Use compact() when a protocol parser cannot complete a frame and must preserve unconsumed bytes before the next read.


8. Buffer Lifecycle for Network Read

Canonical loop untuk blocking channel:

int n = channel.read(buffer);     // write bytes into buffer
if (n == -1) {
    channel.close();
    return;
}

buffer.flip();                   // switch to read mode
parseAvailableFrames(buffer);     // consume complete frames
buffer.compact();                // preserve incomplete frame bytes

State flow:

Jika parser menghabiskan semua bytes, clear() juga bisa dipakai. Jika masih ada partial frame, pakai compact().


9. ByteBuffer Primitive Methods

ByteBuffer bisa membaca/menulis primitive:

ByteBuffer buffer = ByteBuffer.allocate(16);

buffer.putInt(42);
buffer.putLong(1000L);

buffer.flip();

int code = buffer.getInt();
long value = buffer.getLong();

Ini berguna untuk binary protocol, tetapi hati-hati:

  • getInt() butuh 4 bytes remaining.
  • getLong() butuh 8 bytes remaining.
  • Jika frame parsial, jangan panggil primitive getter sebelum memastikan remaining() cukup.
  • Byte order harus disepakati.

Defensive parser:

if (buffer.remaining() < Integer.BYTES) {
    return NeedMoreData.INSTANCE;
}
int length = buffer.getInt();

10. Byte Order dan Network Protocol

Default ByteBuffer adalah big-endian. Banyak network protocol memakai “network byte order”, yaitu big-endian.

Tetapi beberapa custom binary protocol memakai little-endian untuk efisiensi atau interoperability.

Set byte order eksplisit:

import java.nio.ByteBuffer;
import java.nio.ByteOrder;

ByteBuffer buffer = ByteBuffer.allocate(32);
buffer.order(ByteOrder.BIG_ENDIAN);

Rule:

Protocol byte order is part of the contract. Never leave it implicit in a serious binary protocol.

Header spec harus menyatakan:

All multi-byte integer fields are encoded as unsigned big-endian values.

Atau:

All numeric fields are little-endian to match protocol X.

11. Heap Buffer vs Direct Buffer

Heap buffer

ByteBuffer heap = ByteBuffer.allocate(8192);

Karakteristik:

  • Backed by Java heap array.
  • Bisa diakses dengan array() jika supported.
  • Allocation murah dibanding direct.
  • GC melihat object-nya langsung.
  • Saat I/O native, JVM/OS mungkin perlu copy ke native memory.

Direct buffer

ByteBuffer direct = ByteBuffer.allocateDirect(8192);

Karakteristik:

  • Memory di luar Java heap.
  • Cocok untuk I/O native path.
  • Allocation/deallocation lebih mahal.
  • Bisa mengurangi copy pada beberapa path.
  • Tetap ada Java object wrapper.
  • Native memory pressure perlu dimonitor.

Decision matrix:

SituasiPilihan awal
Small temporary parsing bufferHeap
High-throughput socket read/write reusable bufferDirect candidate
Buffer sering dialokasikan per requestHeap or pooled direct, jangan allocate direct terus-menerus
Perlu akses array langsungHeap
Large file/network transferDirect/channel transfer candidate

Rule:

Direct buffer is a resource strategy, not a magic speed switch.


12. Native Memory dan GC Pressure

Direct buffer mengurangi tekanan heap payload, tetapi menambah tekanan native memory.

Failure mode:

  • Heap terlihat aman.
  • Native memory naik.
  • Allocation direct buffer makin lambat.
  • Process terkena OOM native atau container memory limit.

Production checklist:

  • Jangan allocate direct buffer per message.
  • Reuse direct buffer pada connection/event-loop scope.
  • Batasi max connection × buffer size.
  • Monitor direct memory/native memory jika runtime mendukung.
  • Pahami container memory limit.

Contoh sizing kasar:

10_000 connections × 64 KiB read buffer = ~640 MiB buffer memory

Ini belum termasuk write queue, TLS buffers, object overhead, dan application state.


13. Channel Basics

Channel adalah abstraction untuk entity yang bisa melakukan I/O operation.

Untuk networking:

ChannelFungsi
SocketChannelTCP client/accepted connection
ServerSocketChannelTCP listening socket
DatagramChannelUDP socket
AsynchronousSocketChannelAsync completion-based TCP
AsynchronousServerSocketChannelAsync TCP accept

Channel bisa blocking atau non-blocking jika subclass-nya selectable.

channel.configureBlocking(false);

Non-blocking artinya operasi tidak menunggu sampai bisa selesai penuh.


14. SocketChannel Blocking Client

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;

public final class BlockingSocketChannelClient {
    public static void main(String[] args) throws Exception {
        try (SocketChannel channel = SocketChannel.open()) {
            channel.connect(new InetSocketAddress("example.com", 80));

            ByteBuffer request = StandardCharsets.US_ASCII.encode(
                    "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"
            );

            while (request.hasRemaining()) {
                channel.write(request);
            }

            ByteBuffer response = ByteBuffer.allocate(8192);
            while (channel.read(response) != -1) {
                response.flip();
                System.out.print(StandardCharsets.UTF_8.decode(response));
                response.clear();
            }
        }
    }
}

Perhatikan:

  • write() bisa tidak menulis semua bytes, maka loop hasRemaining() wajib.
  • read() return -1 berarti remote closed stream.
  • Decode response HTTP seperti ini hanya demo; parser HTTP real jauh lebih kompleks.

15. Partial Write adalah Realitas

Bug umum:

channel.write(buffer); // assume all bytes written

Yang benar:

while (buffer.hasRemaining()) {
    channel.write(buffer);
}

Pada blocking channel, loop ini biasanya selesai. Pada non-blocking channel, loop seperti ini bisa spin jika write() return 0. Untuk non-blocking, kamu perlu write queue dan selector interest OP_WRITE.

Rule:

write() returning successfully does not mean the whole buffer was written. Check hasRemaining().


16. Partial Read adalah Realitas

Bug umum pada length-prefixed protocol:

ByteBuffer header = ByteBuffer.allocate(4);
channel.read(header);
header.flip();
int length = header.getInt(); // may fail or read incomplete header

Blocking channel pun tidak wajib memenuhi seluruh buffer dalam satu read.

Pola aman:

static boolean readFully(SocketChannel channel, ByteBuffer buffer) throws java.io.IOException {
    while (buffer.hasRemaining()) {
        int n = channel.read(buffer);
        if (n == -1) {
            return false;
        }
    }
    return true;
}

Untuk non-blocking, jangan loop menunggu penuh. Simpan state, lanjut saat channel readable lagi.


17. ServerSocketChannel Blocking Server

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;

public final class BlockingNioEchoServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress("0.0.0.0", 9090));

            while (true) {
                try (SocketChannel client = server.accept()) {
                    ByteBuffer buffer = ByteBuffer.allocate(4096);

                    while (client.read(buffer) != -1) {
                        buffer.flip();
                        while (buffer.hasRemaining()) {
                            client.write(buffer);
                        }
                        buffer.clear();
                    }
                }
            }
        }
    }
}

Ini server blocking single-client-at-a-time. Bagus untuk memahami channel, buruk untuk production.

Masalah:

  • Selama satu client aktif, client lain tidak diproses.
  • Slow client memblokir accept berikutnya.
  • Tidak ada timeout.
  • Tidak ada protocol framing.
  • Tidak ada graceful shutdown.

Part berikutnya akan memakai Selector agar satu thread bisa multiplex banyak channel.


18. Non-Blocking Connect Lifecycle

Untuk SocketChannel non-blocking:

SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
boolean connected = channel.connect(new InetSocketAddress("example.com", 80));

if (!connected) {
    // register OP_CONNECT with selector
}

Saat channel connect-ready:

if (channel.finishConnect()) {
    // now connected; can register OP_READ/OP_WRITE
}

State machine:

Common pitfall: lupa memanggil finishConnect() lalu langsung read/write.


19. Scattering Read dan Gathering Write

NIO mendukung membaca ke beberapa buffer dan menulis dari beberapa buffer.

Gathering write

Cocok untuk header + body tanpa menggabungkan copy manual:

ByteBuffer header = ByteBuffer.allocate(4);
ByteBuffer body = StandardCharsets.UTF_8.encode("hello");

header.putInt(body.remaining());
header.flip();

ByteBuffer[] buffers = { header, body };
while (header.hasRemaining() || body.hasRemaining()) {
    channel.write(buffers);
}

Scattering read

Bisa membaca header dan body ke buffer terpisah, tetapi harus tetap hati-hati partial read.

ByteBuffer header = ByteBuffer.allocate(4);
ByteBuffer body = ByteBuffer.allocate(1024);
ByteBuffer[] buffers = { header, body };
channel.read(buffers);

Gathering write sering lebih berguna untuk network protocol encoder karena menghindari copy header+payload ke satu array besar.


20. Byte-Oriented Protocol Parser

Misal protocol:

4-byte length prefix, followed by payload bytes

Parser harus menangani:

  • header belum lengkap,
  • body belum lengkap,
  • beberapa frame dalam satu buffer,
  • frame terlalu besar,
  • invalid length,
  • partial frame saat connection close.

Contoh parser sederhana:

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public final class LengthPrefixedParser {
    private static final int MAX_FRAME_SIZE = 1024 * 1024;

    public static List<String> parseFrames(ByteBuffer buffer) {
        List<String> frames = new ArrayList<>();

        while (true) {
            buffer.mark();

            if (buffer.remaining() < Integer.BYTES) {
                buffer.reset();
                break;
            }

            int length = buffer.getInt();
            if (length < 0 || length > MAX_FRAME_SIZE) {
                throw new IllegalArgumentException("invalid frame length: " + length);
            }

            if (buffer.remaining() < length) {
                buffer.reset();
                break;
            }

            byte[] payload = new byte[length];
            buffer.get(payload);
            frames.add(new String(payload, StandardCharsets.UTF_8));
        }

        return frames;
    }
}

Usage:

int n = channel.read(readBuffer);
if (n == -1) {
    closeConnection();
    return;
}

readBuffer.flip();
List<String> frames = LengthPrefixedParser.parseFrames(readBuffer);
readBuffer.compact();

mark/reset dipakai agar jika frame belum lengkap, parser kembali ke awal frame dan menunggu byte tambahan.


21. Write Queue Design

Untuk non-blocking network server, kamu tidak bisa langsung menulis semua response.

Per connection butuh write queue:

import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Queue;

final class ConnectionState {
    final ByteBuffer readBuffer = ByteBuffer.allocateDirect(64 * 1024);
    final Queue<ByteBuffer> pendingWrites = new ArrayDeque<>();
    boolean closingAfterWrites;
}

Flush logic:

static void flush(java.nio.channels.SocketChannel channel, ConnectionState state) throws java.io.IOException {
    while (!state.pendingWrites.isEmpty()) {
        ByteBuffer head = state.pendingWrites.peek();
        channel.write(head);

        if (head.hasRemaining()) {
            return; // socket send queue full; try again later
        }

        state.pendingWrites.remove();
    }
}

Invariant:

Outbound data belongs to the connection state until the channel has actually consumed every byte.


22. Backpressure Starts at ByteBuffer

Backpressure bukan konsep abstrak saja. Di NIO, ia terlihat saat:

  • write queue membesar,
  • write() return 0,
  • read buffer sering penuh,
  • parser tidak cukup cepat,
  • downstream lambat,
  • memory per connection naik.

Policy yang harus dipilih:

SituationPossible policy
Write queue exceeds limitClose connection, drop low-priority message, apply app-level throttle
Read buffer full without complete frameReject frame as too large or protocol violation
Client sends faster than process rateStop reading temporarily or close
Server broadcasts to slow clientsPer-client queue limit and disconnect slow consumers

NIO membuat hal ini eksplisit. Itu kekuatannya, sekaligus sumber kompleksitasnya.


23. Zero-Copy Mental Model

“Zero-copy” sering dipakai terlalu longgar. Yang penting adalah memahami di mana copy terjadi.

Simplified normal path:

Potential optimized path with transfer:

Java API yang relevan:

  • FileChannel.transferTo(...)
  • FileChannel.transferFrom(...)

Tetapi zero-copy bukan selalu berlaku:

  • TLS bisa memaksa data masuk ke encryption path.
  • Transformasi payload butuh user-space processing.
  • OS/filesystem/NIC support berbeda.
  • Small payload overhead bisa lebih besar dari manfaat.

Rule:

Optimize copies only after you know the data path and have measured it.


24. Character Encoding: Jangan Decode Terlalu Awal

Network membawa bytes. Text hanyalah interpretasi.

Buruk:

String chunk = StandardCharsets.UTF_8.decode(buffer).toString();

Jika UTF-8 multibyte character terpotong antar read, decode chunk-by-chunk bisa rusak.

Lebih aman:

  • Parse frame sebagai bytes dulu.
  • Setelah frame lengkap, decode payload.
  • Untuk streaming text, pakai CharsetDecoder yang bisa menjaga state antar chunk.

Protocol rule:

Decode text at message boundary, not arbitrary network read boundary.


25. Resource Ownership

Channel dan buffer punya ownership.

Pertanyaan desain:

  • Siapa yang menutup channel?
  • Siapa yang memiliki buffer?
  • Apakah buffer boleh dishare antar thread?
  • Kapan buffer dikembalikan ke pool?
  • Apakah pending write buffer immutable setelah enqueue?

Rule aman:

One connection state owns its read buffer.
Outbound ByteBuffer must not be mutated after enqueue unless ownership is exclusive.
Channel close must release connection state and queued buffers.

Untuk direct buffer pooling, ownership makin penting. Double release atau reuse sebelum write selesai bisa menyebabkan data korup.


26. Thread Safety

Banyak channel aman untuk operasi tertentu oleh beberapa thread, tetapi desain network server biasanya lebih mudah jika satu connection dimiliki oleh satu event loop.

Model yang disarankan untuk NIO selector server:

one event loop thread owns many channels
application workers may produce responses
responses are handed back to event loop queue
only event loop mutates channel interest ops and per-connection I/O buffers

Ini mengurangi race pada:

  • SelectionKey interest ops,
  • write queue,
  • connection close,
  • buffer reuse,
  • partial frame state.

27. Common NIO Buffer Bugs

BugGejalaPenyebabFix
Lupa flip()Parser melihat 0 data atau data salahBuffer masih write modeflip() sebelum read dari buffer
Pakai clear() saat frame parsialProtocol desyncPartial bytes dibuangcompact()
Tidak cek partial writeResponse terpotongwrite() tidak menulis semua bytesLoop/queue sampai buffer habis
Tidak cek partial readHeader/body korupread() tidak memenuhi bufferState machine/readFully sesuai mode
Allocate direct per requestLatency spike/native memory pressureDirect allocation mahalReuse/pool/bound
Decode UTF-8 per chunkKarakter rusakMultibyte char terpotongDecode setelah frame lengkap
Shared mutable ByteBufferData antar connection tercampurOwnership tidak jelasPer-connection buffer/immutable payload
Tidak limit frame sizeOOMLength prefix dipercaya mentahMAX_FRAME_SIZE
Write queue tidak dibatasiMemory leak under slow clientTidak ada backpressureQueue limit/drop/close

28. Production Buffer Sizing

Buffer sizing bukan angka ajaib. Ia harus mengikuti protocol dan traffic.

Faktor:

  • max frame size,
  • typical frame size,
  • connection count,
  • TLS overhead jika ada,
  • direct vs heap memory,
  • read burst behavior,
  • write queue policy,
  • GC target,
  • container memory limit.

Contoh:

readBuffer = 64 KiB
maxConnections = 20,000
readBufferMemory = ~1.25 GiB

Jika ini terlalu besar:

  • kurangi read buffer,
  • gunakan adaptive buffer,
  • batasi max connection,
  • pisahkan large-payload protocol,
  • gunakan streaming/backpressure,
  • pool buffer hanya untuk active reads.

Jangan menentukan buffer size tanpa memory budget.


29. Mini Project: Length-Prefixed TCP Echo with SocketChannel

Build:

  • blocking ServerSocketChannel,
  • one thread per accepted client untuk sementara,
  • protocol: 4-byte big-endian length + UTF-8 payload,
  • MAX_FRAME_SIZE = 1 MiB,
  • read buffer dengan flip/compact,
  • write loop yang menangani partial write,
  • graceful close saat remote EOF,
  • reject invalid length.

Acceptance criteria:

  • Bisa menerima satu frame.
  • Bisa menerima frame yang datang dalam banyak read.
  • Bisa menerima beberapa frame dalam satu read.
  • Bisa menolak frame terlalu besar.
  • Tidak decode UTF-8 sebelum frame lengkap.
  • Tidak assume write() menulis semua bytes.

Test manual:

# gunakan custom client Java, bukan netcat, karena protocol binary length-prefix

Extension:

  • Tambahkan metrics per connection.
  • Tambahkan idle timeout.
  • Tambahkan write queue limit.
  • Migrasikan ke selector di Part 011.

30. Readiness Checklist

Sebelum masuk Selector:

  • Bisa menjelaskan capacity, position, limit tanpa melihat dokumentasi.
  • Bisa menjelaskan kapan memakai flip, clear, dan compact.
  • Tidak assume read/write selalu complete.
  • Bisa menulis parser length-prefixed yang tahan partial read.
  • Bisa menulis write loop yang tahan partial write.
  • Paham direct buffer bukan magic performance switch.
  • Paham ownership buffer dan write queue.
  • Bisa menghitung memory budget dari connection count × buffer size.
  • Bisa menjelaskan kenapa decode text harus di message boundary.

31. Ringkasan

NIO networking adalah byte-oriented programming. API-nya memaksa kamu mengelola state yang sebelumnya disembunyikan oleh stream abstraction.

Poin inti:

  • Channel memindahkan bytes; buffer menyimpan bytes sementara.
  • flip() mengubah buffer dari write mode ke read mode.
  • clear() membuang logical content; compact() menjaga remaining bytes.
  • Read dan write bisa partial.
  • Protocol parser harus stateful.
  • Direct buffer adalah trade-off native memory dan I/O path.
  • Write queue dan buffer limit adalah fondasi backpressure.
  • Zero-copy harus dipahami sebagai data path, bukan slogan.

Part berikutnya akan memakai semua konsep ini untuk membangun Selector, event loop, readiness model, dan non-blocking I/O yang benar.


References

Lesson Recap

You just completed lesson 10 in build core. 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.