Netty Core Components: Channels, Futures, Handlers, and Buffers

1 Data Channels: Channel

Overview: In Netty, all data flows through channels, which can be considered as "data transmission pathways"

Common Channel Methods

  • close() can be used to close the channel
  • closeFuture() handles channel closure
  • sync method synchronously waits for channel closure
  • addListener method asynchronously waits for channel closure
  • pipeline() adds handlers
  • write() writes data
  • writeAndFlush() writes and flushes data

1-1 Channel and ChannelFuture Relationship

package netty_components.channel;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringEncoder;

import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

/*Netty Channel and ChannelFuture Relationship Demonstration*/
public class ConnectionClient {
    public static void main(String[] args) throws InterruptedException {
        ChannelFuture connectionFuture = new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel socketChannel) throws Exception {
                        // Add client channel handler for string encoding
                        socketChannel.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8));
                    }
                })
                .connect(new InetSocketAddress("localhost", 8080));
        
        // connect is asynchronous and non-blocking
        connectionFuture.sync();

        /*
             Without sync, the channel converted from channelFuture would be invalid
             because the connection to the remote host hasn't been established yet
        */
        Channel activeChannel = connectionFuture.channel();    // Convert ChannelFuture to Channel
        activeChannel.writeAndFlush("hello world");
    }
}

  • The code shows that the client's ChannelFuture is an intermediate state class, and the valid Channel object must have established a connection with the remote host.

Note: Classes with Future and Promise typically work with asynchronous method calls

Example: channelFuture uses the asynchronous non-blocking connect method to establish a connection with the remote server, but must ensure the connection is established before becoming a valid channel. This is essentially synchronization of the network connection state.

1-2 ChannelFuture Result Handling

package netty_components.channel;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringEncoder;

import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

/*Netty Channel and ChannelFuture Relationship Demonstration*/
public class ConnectionClient {
    public static void main(String[] args) throws InterruptedException {
        ChannelFuture connectionFuture = new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel socketChannel) throws Exception {
                        socketChannel.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8));
                    }
                })
                .connect(new InetSocketAddress("localhost", 8080));
        
        // connect is asynchronous and non-blocking
        /*
           1) Synchronous result handling with sync (blocks calling thread until result returns)
           sync is a blocking method that only proceeds after connection establishment
        */
//        connectionFuture.sync();
//        Channel channel = connectionFuture.channel();
//        channel.writeAndFlush("Synchronous call, waiting for channel establishment");

        /*
           2) Asynchronous result handling with addListener
           The calling thread doesn't need to wait for connection establishment
           After calling, it provides a callback object as a parameter
           When the connection is established, the NIO thread calls the callback
         */
        connectionFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                Channel channel = future.channel();
                channel.writeAndFlush("Asynchronous call, callback for channel establishment");
            }
        });
    }
}

Connection Establishment Summary:

The client thread initiates a request to the remote server and needs to establish a connection. In real scenarios, the completion time of connection establishment is uncertain.

Therefore, the main thread uses the asynchronous connect method to request connection establishment. After calling connect, another NIO thread performs the connection establishment work.

The main thread can handle the NIO thread's results in two ways:

  • Synchronous call: The main thread calls the sync method to block and wait for the NIO thread's result. When results are returned, it continues execution. The channelFuture to channel conversion is done by the main thread
  • Asynchronous call: The main thread provides a callback function to the NIO thread. After the NIO thread establishes the connection, it calls the callback to handle the result, completing the channelFuture to channel conversion

1-3 ChannelFuture Connection Closure

Requirement: When a certain condition is met, the client needs to close the remote server's channel and perform some operations after closure. Since the close operation is asynchronous, we need to ensure some code definitely runs after the connection is closed

-netty satisfies this requirement through the channelFuture object

Usage example

package netty_components.channel;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
@Slf4j
public class CloseConnectionClient {
    public static void main(String[] args) throws InterruptedException {
        NioEventLoopGroup group = new NioEventLoopGroup();
        ChannelFuture connectionFuture = new Bootstrap()
                .group(group)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel socketChannel) throws Exception {
                        socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                        socketChannel.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8));
                    }
                })
                .connect(new InetSocketAddress("localhost", 8080));
        connectionFuture.sync();

        // Thread closes channel
        Channel activeChannel = connectionFuture.channel();
        Thread t = new Thread(new ChannelCloser(activeChannel, group), "input");
        t.start();

    }
}

@Slf4j
class ChannelCloser implements Runnable{
    private Channel activeChannel;
    private NioEventLoopGroup eventGroup;
    
    ChannelCloser(Channel channel, NioEventLoopGroup group){
        this.activeChannel = channel;
        this.eventGroup = group;
    }

    @SneakyThrows
    @Override
    public void run() {
        Scanner sc = new Scanner(System.in);
        while(true) {
            String line = sc.nextLine();
            if (line.equals("q")) {
                activeChannel.close();
                break;
            }
            activeChannel.writeAndFlush(line);
        }

         /*
           closeFuture handles post-connection closure operations, also with two methods:
           1) Synchronous wait for connection closure, then execute handling code
           2) Asynchronous call, where another thread completes channel closure and executes handling code
        */
        ChannelFuture closeFuture = activeChannel.closeFuture();
        // Method 1: Synchronous handling
        //        closeFuture.sync();
        //        log.debug("Code after channel closure");


        // Method 2: Asynchronous handling
        closeFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                log.debug("Code after channel closure");
                // Close channel and event loop group to terminate the application
                eventGroup.shutdownGracefully();
            }
        });
    }
}

  • The above code starts a thread to monitor console input and closes the channel connection when "q" is entered

To ensure the channel is closed, similar to channel establishment, we can use the channelFuture object to detect the closure result, which also provides synchronous and asynchronous methods:

Synchronous wait for closure: closeFuture.sync();
Asynchronous closure call: 
closeFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                log.debug("Code after channel closure");
                // Close channel and event loop group to terminate the application
                eventGroup.shutdownGracefully();
            }
});

Significance of Asynchronous Channel Establishment and Closure==========

Netty uses asynchronous operations to increase the number of connections processed per unit time. The entire connection lifecycle between client and server includes connection establishment, data transmission, and connection closure. Asynchronous operations allow each thread to handle only one stage. With multi-core CPUs supporting concurrent thread execution, this forms a pipeline that improves connection throughput.

2 Inter-thread Result Passing: Future and Promise

Interface Name Purpose
JUC Future Synchronously **wait for task completion** (success or failure) to get execution results
Netty Future Extends JUC Future, can use asynchronous methods to **wait for task completion** and get results
Netty Promise Further enhances Netty Future, can be defined separately as a container for passing results between threads

Note: Future interfaces typically work with thread pools, and Netty's thread pools usually refer to eventLoop, which is a single-thread thread pool.

2-1 JUC Future Interface Implementation

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException, ExecutionException;
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

package netty_components.future;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.*;
@Slf4j
public class JucFutureDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService service = Executors.newFixedThreadPool(2);
        Future<Integer> future  = service.submit(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                log.debug("Performing calculation");
                Thread.sleep(1000);
                return 50;
            }
        });

        log.debug("Waiting for thread pool execution result");
        log.debug("Result {}", future.get());
    }
}

Execution Result

09:12:39 [DEBUG] [main] n.c.JucFutureDemo - Waiting for thread pool execution result
09:12:39 [DEBUG] [pool-1-thread-1] n.c.JucFutureDemo - Performing calculation
09:12:40 [DEBUG] [main] n.c.JucFutureDemo - Result 50

2-2 Netty Future Interface Implementation

public interface Future<V> extends java.util.concurrent.Future<V> {
    boolean isSuccess();
    boolean isCancellable();
    Throwable cause();
    Future<V> addListener(GenericFutureListener<? extends Future<? super V>> listener);
    Future<V> addListeners(GenericFutureListener<? extends Future<? super V>>... listeners);
    Future<V> removeListener(GenericFutureListener<? extends Future<? super V>> listener);
    Future<V> removeListeners(GenericFutureListener<? extends Future<? super V>>... listeners);
    Future<V> sync() throws InterruptedException;
    Future<V> syncUninterruptibly();
    Future<V> await() throws InterruptedException;
    Future<V> awaitUninterruptibly();
    boolean await(long timeout, TimeUnit unit) throws InterruptedException;
    boolean await(long timeoutMillis) throws InterruptedException;
    boolean awaitUninterruptibly(long timeout, TimeUnit unit);
    boolean awaitUninterruptibly(long timeoutMillis);
    V getNow();
    @Override
    boolean cancel(boolean mayInterruptIfRunning);
}

package netty_components.future;
import io.netty.channel.EventLoop;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;

@Slf4j
public class NettyFutureDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        NioEventLoopGroup group = new NioEventLoopGroup();
        EventLoop eventLoop = group.next();
        Future<Integer> future = eventLoop.submit(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                log.debug("Thread calculation result");
                return 5;
            }
        });
        
        // Synchronous get blocks main thread
        /*
         log.debug("Waiting for thread pool execution result");
         log.debug("Result {}", future.get());
        */
        // Asynchronous execution through callback
        future.addListener(new GenericFutureListener<Future<? super Integer>>() {
            @Override
            public void operationComplete(Future<? super Integer> future) throws Exception {
                log.debug("Waiting for thread pool execution result");
                log.debug("Result {}", future.getNow());
            }
        });
    }
}

Execution Result

09:14:52 [DEBUG] [nioEventLoopGroup-2-1] n.c.NettyFutureDemo - Thread calculation result
09:14:52 [DEBUG] [nioEventLoopGroup-2-1] n.c.NettyFutureDemo - Waiting for thread pool execution result
09:14:52 [DEBUG] [nioEventLoopGroup-2-1] n.c.NettyFutureDemo - Result 5

2-3 Promise Interface Implementation

public interface Promise<V> extends Future<V> {
    Promise<V> setSuccess(V result);        // Set success and notify all listeners
    boolean trySuccess(V result);           // Mark success and notify all listeners
    Promise<V> setFailure(Throwable cause); // Set failure and notify all listeners
    boolean tryFailure(Throwable cause);    // Mark failure and notify all listeners
    boolean setUncancellable();
    @Override
    Promise<V> addListener(GenericFutureListener<? extends Future<? super V>> listener);
    @Override
    Promise<V> addListeners(GenericFutureListener<? extends Future<? super V>>... listeners);
    @Override
    Promise<V> removeListener(GenericFutureListener<? extends Future<? super V>> listener);
    @Override
    Promise<V> removeListeners(GenericFutureListener<? extends Future<? super V>>... listeners);
    @Override
    Promise<V> await() throws InterruptedException;
    @Override
    Promise<V> awaitUninterruptibly();
    @Override
    Promise<V> sync() throws InterruptedException;
    @Override
    Promise<V> syncUninterruptibly();
}

package netty_components.future;

import io.netty.channel.DefaultEventLoop;
import io.netty.channel.EventLoop;
import io.netty.util.concurrent.DefaultPromise;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.ExecutionException;

@Slf4j
public class NettyPromiseDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        EventLoop eventLoop = new DefaultEventLoop();
        Promise<Integer> promise = new DefaultPromise<>(eventLoop);
        new Thread(()->{
            log.debug("Starting calculation");
            try {
                Thread.sleep(1000);
                promise.setSuccess(100);
            } catch (InterruptedException e) {
                promise.setFailure(e);
                e.printStackTrace();
            }
        }).start();
        log.debug("Waiting for execution result");
        log.debug("Execution result {}", promise.get());
    }
}

Execution Result

09:25:59 [DEBUG] [main] n.c.NettyPromiseDemo - Waiting for execution result
09:25:59 [DEBUG] [Thread-0] n.c.NettyPromiseDemo - Starting calculation
09:26:00 [DEBUG] [main] n.c.NettyPromiseDemo - Execution result 100

Future and Promise Interface Summary============

Both interfaces are used for inter-process result passing. Netty's Future interface adds asynchronous callback methods compared to JUC's Future. However, Future essentially still depends on the thread's return value from submit. Promise can be defined separately, implementing the Future interface while being more flexible.

3 Network Data Processing: Handler & Pipeline

3-1 Inbound vs Outbound ChannelHandler

Handler Purpose: In actual network programming, business logic is typically implemented in handlers. Both inbound and outbound information processing occurs in handlers, and multiple handlers can form a pipeline.

Netty Provided Handlers

Name Purpose
ChannelHandler Handles events on the channel, divided into inbound and outbound handlers
  • Inbound handlers are typically subclasses of ChannelInboundHandlerAdapter, mainly used to read client data and write back results
  • Outbound handlers are typically subclasses of ChannelOutboundHandlerAdapter, mainly used to process write-back results

Handler Usage Example:

package netty_components.handler;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import lombok.extern.slf4j.Slf4j;

import java.nio.charset.StandardCharsets;

/*
    Difference between ChannelInboundHandlerAdapter and ChannelOutboundHandlerAdapter
    When external data arrives, inbound handlers are triggered, processing from the head of the doubly-linked list (same order as handler addition)
    When external data is sent out, outbound handlers are triggered, processing from the tail of the doubly-linked list (reverse order of handler addition)

    Difference: Different triggering times, inbound from pipeline head, outbound from pipeline tail
 */
@Slf4j
public class PipelineDemo {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(), new NioEventLoopGroup(2))
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast("h1", new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("1");
                                // Pass msg to next handler, equivalent to ctx.fireChannelRead
                                ByteBuf buf = (ByteBuf) msg;
                                String s = "h1" + buf.toString(StandardCharsets.UTF_8);
                                super.channelRead(ctx, s);
                            }
                        });
                        pipeline.addLast("h2", new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("2");
                                String current = "h2" + msg;
                                log.debug(current);
                                ch.writeAndFlush(ctx.alloc().buffer().writeBytes("servers..".getBytes()));
                            }
                        });

                        pipeline.addLast("h3", new ChannelOutboundHandlerAdapter(){

                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("3 " + msg);
                                super.write(ctx, msg, promise);
                            }
                        });

                        pipeline.addLast("h4", new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("4 " + msg);
                                super.write(ctx, msg, promise);
                            }
                        });
                    }
                })
                .bind(8080);
    }
}

The above code adds 2 inbound and 2 outbound handlers in sequence. The second inbound handler writes information to the channel, triggering outbound handlers. After sending two messages from the client (client1:hello1 and client:hello), the program output is:

  • Inbound messages are processed from head handlers (1,2), outbound messages from tail handlers (4,3)
16:52:21 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 1
16:52:21 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 2
16:52:21 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - h2h1client1:hello1
16:52:21 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 4 PooledUnsafeDirectByteBuf(ridx: 0, widx: 9, cap: 256)
16:52:21 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 3 PooledUnsafeDirectByteBuf(ridx: 0, widx: 9, cap: 256)
16:52:25 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 1
16:52:25 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 2
16:52:25 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - h2h1client2:hello
16:52:25 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 4 PooledUnsafeDirectByteBuf(ridx: 0, widx: 9, cap: 256)
16:52:25 [DEBUG] [nioEventLoopGroup-3-1] n.h.PipelineDemo - 3 PooledUnsafeDirectByteBuf(ridx: 0, widx: 9, cap: 256)

ChannelHandler and Pipeline Summary=

Each server channel can be seen as a doubly-linked list. Inbound information processing calls inbound handlers forward, outbound information processing calls outbound handlers backward. Channel design concept: Implements the Chain of Responsibility Pattern, creating a processing object chain (pipeline) for requests. Each node in the chain is an object (handler) that processes different requests and internally maintains the next node object. When a request is sent from the head of the chain, it passes through each node in sequence until a node processes it.

Note: Different calling objects affect outbound handler usage

ctx.channel().write(msg) // Start from tail to find outbound handler (entire pipeline tail)
ctx.write(msg)           // ctx:ChannelHandlerContext
// Find previous outbound handler from current node (handler before current in pipeline)

  • Although both methods write data to the channel, the range of outbound handler search is different, which needs attention in actual development.

3-2 Test Channel

package netty_components.handler;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelOutboundHandlerAdapter;
import io.netty.channel.ChannelPromise;
import io.netty.channel.embedded.EmbeddedChannel;
import lombok.extern.slf4j.Slf4j;

import java.nio.charset.StandardCharsets;

/*
*  Requirement: When network communication involves complex business requirements (multiple handler implementations), testing requires running both server and client programs, which is cumbersome.
*   EmbeddedChannel avoids this problem.
* */
@Slf4j
public class TestEmbeddedChannel {
    public static void main(String[] args) {
        ChannelInboundHandlerAdapter h1 = new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                log.debug("1");
                super.channelRead(ctx, msg);
            }
        };
        ChannelInboundHandlerAdapter h2 = new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                log.debug("2");
                super.channelRead(ctx, msg);
            }
        };

        ChannelOutboundHandlerAdapter h3 = new ChannelOutboundHandlerAdapter(){
            @Override
            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                log.debug("3");
                super.write(ctx, msg, promise);
            }
        };

        ChannelOutboundHandlerAdapter h4 = new ChannelOutboundHandlerAdapter(){
            @Override
            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                log.debug("4");
                super.write(ctx, msg, promise);
            }
        };

        EmbeddedChannel channel = new EmbeddedChannel(h1, h2, h3, h4);
        // Simulate inbound operation
        channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("INPUT".getBytes()));
        // Simulate outbound operation
        channel.writeOutbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("OUT".getBytes()));
    }
}

Execution Result

22:13:04 [DEBUG] [main] n.h.TestEmbeddedChannel - 1
22:13:04 [DEBUG] [main] n.h.TestEmbeddedChannel - 2
22:13:04 [DEBUG] [main] n.h.TestEmbeddedChannel - 4
22:13:04 [DEBUG] [main] n.h.TestEmbeddedChannel - 3

Summary: Netty's EmbeddedChannel allows convenient testing of complex pipelines without needing to start client and server programs for each test.

4 Data Buffering: ByteBuf

4-1 ByteBuf Dynamic Expansion

Overview: Java's native NIO API uses ByteBuffer for data buffering, while Netty uses ByteBuf, which can dynamically expand based on input data size.

ByteBuf Advantages

  • Pooling - Can reuse ByteBuf instances from the pool, saving memory and reducing memory overflow possibiliteis
  • Separate read/write pointers, no need to switch read/write modes like ByteBuffer
  • Automatic expansion capability
  • Supports chained calls for smoother usage
  • Zero-copy in many places, such as slice, duplicate, CompositeByteBuf

ByteBuffer usage reference

package netty_components.buffer;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;

import static io.netty.buffer.ByteBufUtil.appendPrettyHexDump;
import static io.netty.util.internal.StringUtil.NEWLINE;

public class TestBytebuf {
    public static void main(String[] args) {
        ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
        log(buf);
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i < 300; ++i) sb.append("a");
        buf.writeBytes(sb.toString().getBytes());
        log(buf);
    }

    private static void log(ByteBuf buffer) {
        int length = buffer.readableBytes();
        int rows = length / 16 + (length % 15 == 0 ? 0 : 1) + 4;
        StringBuilder buf = new StringBuilder(rows * 80 * 2)
                .append("read index:").append(buffer.readerIndex())
                .append(" write index:").append(buffer.writerIndex())
                .append(" capacity:").append(buffer.capacity())
                .append(NEWLINE);
        appendPrettyHexDump(buf, buffer);
        System.out.println(buf.toString());
    }
}

Execution Result

read index:0 write index:0 capacity:256        // Read pointer position, write pointer position, capacity

read index:0 write index:300 capacity:512
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000010| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000020| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000030| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000040| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000050| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000060| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000070| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000080| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000090| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000a0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000b0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000c0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000d0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000e0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|000000f0| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000100| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000110| 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 |aaaaaaaaaaaaaaaa|
|00000120| 61 61 61 61 61 61 61 61 61 61 61 61             |aaaaaaaaaaaa    |
+--------+-------------------------------------------------+----------------+

ByteBuf capacity expanded from 256 to 512.

4-2 ByteBuf Memory Types and Pooling

Features: ByteBuf supports heap and direct memory and memory pooling.

Direct Memory:

  • High allocation and deallocation cost, but high read/write efficiency. NIO buffers typically use direct memory by default.
  • Not affected by garbage collection

JVM Direct Memory

Pooling Mechanism: Pooling is essentially a resource pool that reuses resources instead of acquiring and releasing them on demand.

Pooling Concept Example: Thread pools, memory pools, connection pools, object pools

a pool is a collection of resources that are kept ready to use, rather than acquired on use and released

Enable Pooling: Reuse ByteBuf instances from the pool, using memory allocation algorithms similar to jemalloc to improve allocation efficiency

Disable Pooling: Each ByteBuf instance needs to be created, with significant overhead from repeated creation and destruction. If heap memory is used, it creates significant GC pressure.

  • Whether pooling is enabled depends on the Netty version and application platform.

Enable/Disable Pooling via JVM Parameters

-Dio.netty.allocator.type={unpooled|pooled}

package netty_components.buffer;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;

public class TestBytebuf {
    public static void main(String[] args) {
        ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
        System.out.println(buf.getClass());
        StringBuilder sb = new StringBuilder();
        for(int i = 0; i < 300; ++i) sb.append("a");
        buf.writeBytes(sb.toString().getBytes());
    }
}

Output:

class io.netty.buffer.PooledUnsafeDirectByteBuf

Outputting PooledUnsafeDirectByteBuf indicates pooling is enabled and direct memory is used.

4-3 ByteBuf Operations

Features

Two Capacities: ByteBuf requires specifying capacity on creation, with maximum capacity being the system's maximum allocatable size (theoretical maximum). Between maximum and specified capacity is the expandable capacity.

Two Pointers: Read and write pointers, both at the starting position when buf is initialized.

Space Allocation: Discarded bytes (written and read), readable bytes (written but unread), writable bytes, expandable bytes

nio.bytebuffer implementation principle

nio.bytebuffer and netty.bytebuffer Comparison

Class nio.bytebuffer netty.buffer.ByteBuf
Capacity Fixed capacity, cannot be changed after definition Set initial capacity, can dynamically expand, supports pooling
Implementation Principle Shared read/write pointers, need to switch read/write modes Separate read/write pointers

Writing

Features:

  • int type data writing supports big-endian (write high bits first) and little-endian (write low bits first), big-endian is typically used in network programming
  • Supports writting StringBuffer, StringBuilder, and nio.bytebuffer.
Method Signature Meaning Note
writeBoolean(boolean value) Write boolean value 01|00 represents true|false
writeByte(int value) Write byte value
writeShort(int value) Write short value
writeInt(int value) Write int value Big Endian, 0x250 writes as 00 00 02 50
writeIntLE(int value) Write int value Little Endian, 0x250 writes as 50 02 00 00
writeLong(long value) Write long value
writeChar(int value) Write char value
writeFloat(float value) Write float value
writeDouble(double value) Write double value
writeBytes(ByteBuf src) Write netty ByteBuf
writeBytes(byte[] src) Write byte[]
writeBytes(ByteBuffer src) Write nio ByteBuffer
int writeCharSequence(CharSequence sequence, Charset charset) Write string

Reading

buffer.readByte();                         // Read one byte from buffer, read pointer moves
// Re-read certain data
buffer.markReaderIndex();                   // Mark
buffer.readInt();                          // Read
buffer.resetReaderIndex();                  // Return to mark (can continue reading from mark)

Memory Management

ByteBuf Implementation Recovery Method
UnpooledHeapByteBuf JVM memory, just wait for GC
UnpooledDirectByteBuf Direct memory needs special methods for timely recovery, although JVM uses phantom references to ensure final recovery
PooledByteBuf Uses pooling mechanism, needs more complex rules for memory recovery

Memory management methods

buf.release();
buf.retain();

Source code different implementations recover memory through implementing protected abstract void deallocate()

Netty uses reference counting to control memory recovery. Each ByteBuf implements the ReferenceCounted interface

This interface mainly manages references

  • Each ByteBuf object has an initial count of 1
  • Calling release decreases count by 1, if count is 0, ByteBuf memory is recycled
  • Calling retain increases count by 1, meaning the caller hasn't finished using it yet, so other handlers can't cause recycling even if they call release
  • When count is 0, underlying memory is recycled, and even if the ByteBuf object exists, its methods can't be used normally

Question: When should release be called during ByteBuf usage?

Not always applicable method

ByteBuf buf = ...
try {
    ...
} finally {
    buf.release();
}

Correct approach: The last user is responsible for release. Due to the pipeline, ByteBuf usually needs to be passed to the next ChannelHandler. If release is called in finally, the pass-through is lost unless the ByteBuf is truly no longer needed.

General rule for ByteBuf release in business code:

  • Starting point, for NIO implementation, in io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe when ByteBuf is first created and placed in pipeline
  • Don't process original ByteBuf, call ctx.fireChannelRead(msg) to pass backward, no need to release
  • Convert original ByteBuf to other Java objects, then ByteBuf is useless and must be released
  • If not calling ctx.fireChannelRead(msg) to pass backward, must release
  • Note various exceptions, if ByteBuf isn't successfully passed to the next ChannelHandler, must release
  • Assuming message passes all the way, TailContext is responsible for releasing unprocessed messages (original ByteBuf)
  • Outbound ByteBuf handling principle
  • Outbound messages eventually become ByteBuf output, passing forward, released by HeadContext after flush
  • Exception handling principle
  • Sometimes unsure how many times ByteBuf is referenced but must be completely released, can loop release until true is returned

Netty's pipeline underlying data structure is a doubly-linked list, with default head and tail handler nodes. Head and tail nodes have source code for ByteBuf memory release.

Pipeline default tail node source code

// final class TailContext extends AbstractChannelHandlerContext implements ChannelInboundHandler
       @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            onUnhandledInboundMessage(ctx, msg);
        }  
-----------------------------------------------------------------------------
        protected void onUnhandledInboundMessage(ChannelHandlerContext ctx, Object msg) {
        onUnhandledInboundMessage(msg);
        if (logger.isDebugEnabled()) {
            logger.debug("Discarded message pipeline : {}. Channel : {}.",
                         ctx.pipeline().names(), ctx.channel());
        }
    }
-------------------------------------------------------------------------------
        protected void onUnhandledInboundMessage(Object msg) {
        try {
            logger.debug(
                    "Discarded inbound message {} that reached at the tail of the pipeline. " +
                            "Please check your pipeline configuration.", msg);
        } finally {
            // Ultimately calls release method of ReferenceCountUtil
            ReferenceCountUtil.release(msg);
        }
    }
--------------------------------------------------------------------------------
    public static boolean release(Object msg) {
        if (msg instanceof ReferenceCounted) {   // Check if it's bytebuf
            return ((ReferenceCounted) msg).release();
        }
        return false;
    }

Summary: The channelRead method in the default tail handler ultimately calls release to free ByteBuf memory

Pipeline default head node HeadContext source code

  • This class implements both ChannelOutboundHandler and ChannelInboundHandler, so it can handle both inbound and outbound information
 // final class HeadContext extends AbstractChannelHandlerContext
     @Override
     public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
                unsafe.write(msg, promise);
     }
====================================================================
        @Override
        public final void write(Object msg, ChannelPromise promise) {
            assertEventLoop();

            ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            if (outboundBuffer == null) {
                // If outboundBuffer is null, channel is closed, fail future immediately
                safeSetFailure(promise, newClosedChannelException(initialCloseCause));
                // Release message now to prevent resource leak
                ReferenceCountUtil.release(msg);     // Release memory
                return;
            }

            int size;
            try {
                msg = filterOutboundMessage(msg);
                size = pipeline.estimatorHandle().size(msg);
                if (size < 0) {
                    size = 0;
                }
            } catch (Throwable t) {
                safeSetFailure(promise, t);
                ReferenceCountUtil.release(msg);     // Release memory
                return;
            }

            outboundBuffer.addMessage(msg, size, promise);
        }

Zero-copy Operations

Operation Name Description
slice Slice original ByteBuf into multiple ByteBuf without memory copy, sharing memory but maintaining independent read/write pointers, and fixed capacity cannot be written.
duplicate Uses same underlying memory as original ByteBuf, but with independent read/write pointers
CompositeByteBuf Can logically combine multiple ByteBuf without copying

Note:

  • Since these operations share memory, retain must be performed after operation to prevent other ByteBuf references from becoming invalid when original ByteBuf is released
  • Sliced ByteBuf cannot be written due to memory sharing.
package netty_components.buffer;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.util.internal.SocketUtils;

import static io.netty.buffer.ByteBufUtil.appendPrettyHexDump;
import static io.netty.util.internal.StringUtil.NEWLINE;

public class TestSliceAndDuplicate {
    public static void main(String[] args) {
        ByteBuf bf = ByteBufAllocator.DEFAULT.buffer();
        bf.writeBytes(new byte[]{1, 2, 3, 4});

        System.out.println("ByteBuf obtained from slice and duplicate operations (p1, p2, p3) shares same memory as original ByteBuf (bf):");
        ByteBuf p1 = bf.slice(0,2); log(p1);
        p1.retain();
        ByteBuf p2 = bf.slice(2,2); log(p2);
        p2.retain();
        ByteBuf p3 = bf.duplicate(); log(p3); p3.retain();
        // retain increases original ByteBuf memory reference count, preventing other references from becoming invalid when original is released

        System.out.println("Modifying p1 affects other ByteBuf due to shared memory");
        p1.setByte(0,6); log(bf);
        log(p3);
        bf.release(); p1.release(); p2.release(); p3.release();
    }

    private static void log(ByteBuf buffer) {
        int length = buffer.readableBytes();
        int rows = length / 16 + (length % 15 == 0 ? 0 : 1) + 4;
        StringBuilder buf = new StringBuilder(rows * 80 * 2)
                .append("read index:").append(buffer.readerIndex())
                .append(" write index:").append(buffer.writerIndex())
                .append(" capacity:").append(buffer.capacity())
                .append(NEWLINE);
        appendPrettyHexDump(buf, buffer);
        System.out.println(buf.toString());
    }
}

Execution result: Memory is indeed shared

ByteBuf obtained from slice and duplicate operations (p1, p2, p3) shares same memory as original ByteBuf (bf):
read index:0 write index:2 capacity:2
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 01 02                                           |..              |
+--------+-------------------------------------------------+----------------+
read index:0 write index:2 capacity:2
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 03 04                                           |..              |
+--------+-------------------------------------------------+----------------+
read index:0 write index:4 capacity:256
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 01 02 03 04                                     |....            |
+--------+-------------------------------------------------+----------------+
Modifying p1 affects other ByteBuf due to shared memory
read index:0 write index:4 capacity:256
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 06 02 03 04                                     |....            |
+--------+-------------------------------------------------+----------------+
read index:0 write index:4 capacity:256
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 06 02 03 04                                     |....            |
+--------+-------------------------------------------------+----------------+

Method to combine two ByteBuf without copying:

ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer(5);
buf1.writeBytes(new byte[]{1, 2, 3, 4, 5});
ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(5);
buf2.writeBytes(new byte[]{6, 7, 8, 9, 10});
CompositeByteBuf buf3 = ByteBufAllocator.DEFAULT.compositeBuffer();
// true means automatically increment write index when adding new ByteBuf, otherwise write index remains 0
buf3.addComponents(true, buf1, buf2);

Non-pooled ByteBuf combination

package netty_components.buffer;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;

public class TestUnpooled {
    public static void main(String[] args) {
        ByteBuf buf1 = ByteBufAllocator.DEFAULT.buffer(5);
        buf1.writeBytes(new byte[]{1, 2, 3, 4, 5});
        ByteBuf buf2 = ByteBufAllocator.DEFAULT.buffer(5);
        buf2.writeBytes(new byte[]{6, 7, 8, 9, 10});

        // Unpooled uses CompositeByteBuf underneath to avoid copying
        ByteBuf buf3 = Unpooled.wrappedBuffer(buf1, buf2);
        System.out.println(ByteBufUtil.prettyHexDump(buf3));

        ByteBuf buf4 = Unpooled.wrappedBuffer(new byte[]{1, 2, 3}, new byte[]{4, 5, 6});
        System.out.println(buf4.getClass());
        System.out.println(ByteBufUtil.prettyHexDump(buf4));
    }
}

Execution Result

         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 01 02 03 04 05 06 07 08 09 0a                   |..........      |
+--------+-------------------------------------------------+----------------+
class io.netty.buffer.CompositeByteBuf
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 01 02 03 04 05 06                               |......          |
+--------+-------------------------------------------------+----------------+

Tags: Netty channel ChannelFuture bytebuf EventLoop

Posted on Wed, 21 Oct 2026 16:09:28 +0000 by MCrosbie