Java has two I/O subsystems: the original java.io (blocking, stream-based) and java.nio (buffers, channels, selectors, non-blocking). The modern java.nio.file package (Java 7+) replaced the clunky File class with the expressive Path and Files APIs. Understanding both is essential — java.io is simpler for sequential processing, NIO is mandatory for high-performance network servers and large file handling.


Step 1 — The java.io Stream Hierarchy

java.io splits I/O into byte streams (InputStream/OutputStream) for binary data and character streams (Reader/Writer) for text. Decorator streams wrap a base stream to add capabilities like buffering, data conversion, or compression. Always close streams — use try-with-resources.

Core java.io classes

  • FileInputStream / FileOutputStream: raw byte-level reading and writing of files.
  • BufferedInputStream / BufferedOutputStream: wraps a stream to add buffering — reduces system calls dramatically. Default buffer is 8KB.
  • DataInputStream / DataOutputStream: reads and writes Java primitives (int, double, boolean) in a portable binary format.
  • FileReader / FileWriter: character-level file I/O using the platform's default charset. Use InputStreamReader/OutputStreamWriter with explicit charset instead.
  • BufferedReader / BufferedWriter: adds buffering to character streams. BufferedReader.readLine() is the standard way to read text files line by line.
  • PrintWriter / PrintStream: convenient print/println/printf methods. System.out is a PrintStream.
BasicIO.javajava
import java.io.*;
import java.nio.charset.StandardCharsets;

public class BasicIO {

    // ============================================================
    // Writing text to a file
    // ============================================================
    static void writeTextFile(String path) throws IOException {
        // Always specify charset explicitly — never rely on platform default
        try (BufferedWriter writer = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8))) {

            writer.write("Line 1: Hello, Java I/O");
            writer.newLine(); // platform-independent line separator
            writer.write("Line 2: Buffered for performance");
            writer.newLine();
            writer.write("Line 3: Always use try-with-resources");
            // writer.flush() is NOT needed — close() calls flush() first
        } // auto-close: flushes buffer and closes file
    }

    // ============================================================
    // Reading text from a file — line by line
    // ============================================================
    static void readTextFile(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {

            String line;
            int lineNum = 1;
            while ((line = reader.readLine()) != null) { // returns null at EOF
                System.out.println(lineNum++ + ": " + line);
            }
        }
    }

    // ============================================================
    // Writing binary data with DataOutputStream
    // ============================================================
    static void writeBinaryData(String path) throws IOException {
        try (DataOutputStream dos = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(path)))) {

            dos.writeInt(42);            // writes 4 bytes (big-endian)
            dos.writeDouble(3.14159);    // writes 8 bytes
            dos.writeBoolean(true);      // writes 1 byte
            dos.writeUTF("Hello");       // writes length-prefixed modified UTF-8
        }
    }

    // ============================================================
    // Reading binary data with DataInputStream
    // ============================================================
    static void readBinaryData(String path) throws IOException {
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(path)))) {

            int    i = dis.readInt();     // must read in same order as written
            double d = dis.readDouble();
            boolean b = dis.readBoolean();
            String s = dis.readUTF();
            System.out.printf("int=%d  double=%.5f  bool=%b  str=%s%n", i, d, b, s);
        }
    }

    // ============================================================
    // Reading classpath resources (common in Spring apps)
    // ============================================================
    static void readResource(String resourceName) throws IOException {
        try (InputStream is = BasicIO.class.getClassLoader().getResourceAsStream(resourceName);
             BufferedReader reader = new BufferedReader(
                 new InputStreamReader(is, StandardCharsets.UTF_8))) {

            reader.lines().forEach(System.out::println);
        }
    }

    public static void main(String[] args) throws IOException {
        writeTextFile("output.txt");
        readTextFile("output.txt");
        writeBinaryData("data.bin");
        readBinaryData("data.bin");
    }
}

Step 2 — Object Serialization

Serialization converts a Java object to a byte stream for storage or network transfer. Deserialization reconstructs the object. A class must implement Serializable (a marker interface). The JVM generates a serialVersionUID to version the class — always declare it explicitly to control compatibility.

SerializationExample.javajava
import java.io.*;
import java.util.List;

// Must implement Serializable to be serializable
public class SerializationExample implements Serializable {

    // Always declare explicitly — if you don't, Java computes one from class structure
    // Adding/removing fields changes the computed UID, breaking deserialization of old data
    private static final long serialVersionUID = 1L;

    private String name;
    private int age;
    private transient String password; // transient: NOT serialized (sensitive data, derived fields)
    private static String appVersion = "1.0"; // static fields are NOT serialized

    public SerializationExample(String name, int age, String password) {
        this.name = name;
        this.age = age;
        this.password = password;
    }

    // Custom serialization: called by ObjectOutputStream
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject(); // serialize non-transient fields normally
        // You can write additional encrypted/derived data here
        oos.writeObject("encrypted:" + password.hashCode());
    }

    // Custom deserialization: called by ObjectInputStream
    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ois.defaultReadObject(); // deserialize non-transient fields
        String encryptedPwd = (String) ois.readObject();
        // Restore derived state that wasn't directly serialized
        this.password = "[restored]";
    }

    @Override
    public String toString() {
        return "User{name='" + name + "', age=" + age + ", password='" + password + "'}";
    }

    // ============================================================
    // Serialize object to file
    // ============================================================
    static void serialize(Object obj, String path) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream(path)))) {
            oos.writeObject(obj);
        }
    }

    // ============================================================
    // Deserialize object from file
    // ============================================================
    @SuppressWarnings("unchecked")
    static <T> T deserialize(String path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream(path)))) {
            return (T) ois.readObject();
        }
    }

    public static void main(String[] args) throws Exception {
        SerializationExample user = new SerializationExample("Alice", 30, "secret123");
        System.out.println("Before: " + user);

        serialize(user, "user.ser");

        SerializationExample restored = deserialize("user.ser");
        System.out.println("After:  " + restored);
        // password will be "[restored]" — not the original value
        // appVersion (static) is not restored from file but from class definition

        // Serializing collections
        List<String> list = List.of("a", "b", "c"); // List.of() returns Serializable list
        serialize(list, "list.ser");
        List<String> restoredList = deserialize("list.ser");
        System.out.println("List: " + restoredList);
    }
}

Step 3 — Modern File API: Path and Files (java.nio.file)

The java.nio.file package (Java 7+) replaced java.io.File with the immutable Path interface and the utility class Files. Path is just a representation of a location; Files contains the actual I/O operations. This API is far more expressive, supports symbolic links, and provides atomic operations.

PathAndFiles.javajava
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.util.List;
import java.util.stream.Stream;

public class PathAndFiles {

    public static void main(String[] args) throws IOException {

        // ============================================================
        // Path construction and manipulation
        // ============================================================
        Path home   = Path.of("/home/user");           // Java 11+
        Path docs   = home.resolve("documents");        // /home/user/documents
        Path file   = docs.resolve("report.txt");       // /home/user/documents/report.txt
        Path parent = file.getParent();                 // /home/user/documents
        Path name   = file.getFileName();               // report.txt
        Path abs    = file.toAbsolutePath();            // resolve against cwd if relative
        Path norm   = Path.of("/a/b/../c").normalize(); // /a/c

        // Relativize: path from one to another
        Path rel = home.relativize(file); // documents/report.txt

        System.out.println("parent: " + parent);
        System.out.println("name:   " + name);
        System.out.println("rel:    " + rel);
        System.out.println("norm:   " + norm);

        // ============================================================
        // Reading and writing files
        // ============================================================
        Path testFile = Path.of("test.txt");

        // Write all lines at once (simple, buffered internally)
        Files.writeString(testFile, "Hello\nWorld\nJava NIO", StandardCharsets.UTF_8);

        // Read entire file as String
        String content = Files.readString(testFile, StandardCharsets.UTF_8);
        System.out.println(content);

        // Read all lines as List<String>
        List<String> lines = Files.readAllLines(testFile, StandardCharsets.UTF_8);
        System.out.println("Lines: " + lines.size());

        // Read as Stream<String> — lazy, efficient for large files
        try (Stream<String> lineStream = Files.lines(testFile, StandardCharsets.UTF_8)) {
            long count = lineStream.filter(l -> l.contains("Java")).count();
            System.out.println("Lines with 'Java': " + count);
        }

        // Append to a file
        Files.writeString(testFile, "\nAppended line",
            StandardCharsets.UTF_8,
            StandardOpenOption.APPEND); // OpenOption controls create/append/truncate behavior

        // ============================================================
        // Directory operations
        // ============================================================
        Path dir = Path.of("mydir/sub1/sub2");
        Files.createDirectories(dir); // creates ALL missing parent directories

        // Copy file
        Path copy = Path.of("test_copy.txt");
        Files.copy(testFile, copy, StandardCopyOption.REPLACE_EXISTING);

        // Move/rename file (atomic on same filesystem)
        Path moved = Path.of("test_moved.txt");
        Files.move(copy, moved, StandardCopyOption.ATOMIC_MOVE);

        // Delete
        Files.deleteIfExists(moved); // no exception if file doesn't exist
        // Files.delete(path); // throws NoSuchFileException if missing

        // ============================================================
        // Walking the directory tree
        // ============================================================
        Files.createFile(dir.resolve("a.txt"));
        Files.createFile(dir.resolve("b.java"));

        // Walk: depth-first traversal
        try (Stream<Path> walk = Files.walk(Path.of("mydir"))) {
            walk.filter(Files::isRegularFile)
                .filter(p -> p.toString().endsWith(".java"))
                .forEach(System.out::println); // prints mydir/sub1/sub2/b.java
        }

        // List: only immediate children (not recursive)
        try (Stream<Path> list = Files.list(Path.of("mydir"))) {
            list.forEach(System.out::println);
        }

        // Find: walk with BiPredicate filter
        try (Stream<Path> found = Files.find(
                Path.of("mydir"), 10, // max depth 10
                (path, attrs) -> attrs.isRegularFile() && path.toString().endsWith(".txt"))) {
            found.forEach(System.out::println);
        }

        // ============================================================
        // File metadata
        // ============================================================
        System.out.println("Exists:     " + Files.exists(testFile));
        System.out.println("Is file:    " + Files.isRegularFile(testFile));
        System.out.println("Is dir:     " + Files.isDirectory(testFile));
        System.out.println("Size bytes: " + Files.size(testFile));
        System.out.println("Last mod:   " + Files.getLastModifiedTime(testFile));
        System.out.println("Hidden:     " + Files.isHidden(testFile));

        // Cleanup
        Files.deleteIfExists(testFile);
        Files.walk(Path.of("mydir"))
             .sorted(java.util.Comparator.reverseOrder()) // delete deepest first
             .forEach(p -> { try { Files.delete(p); } catch (IOException e) { } });
    }
}

Step 4 — WatchService: File System Events

WatchService monitors a directory for file system events (create, modify, delete) without polling. The OS notifies the JVM. Used in hot-reload systems, configuration file monitoring, and build tools.

WatchServiceExample.javajava
import java.nio.file.*;
import java.io.IOException;

public class WatchServiceExample {

    public static void main(String[] args) throws IOException, InterruptedException {
        Path watchDir = Path.of("watched");
        Files.createDirectories(watchDir);

        // Create a watch service (backed by OS-level inotify/FSEvents/ReadDirectoryChanges)
        WatchService watcher = FileSystems.getDefault().newWatchService();

        // Register the directory for specific events
        watchDir.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,  // new file/dir created
            StandardWatchEventKinds.ENTRY_MODIFY,  // file modified
            StandardWatchEventKinds.ENTRY_DELETE   // file/dir deleted
        );

        System.out.println("Watching: " + watchDir.toAbsolutePath());
        System.out.println("Create/modify/delete files in that directory...");

        // Monitor in a separate thread so main app is not blocked
        Thread watchThread = new Thread(() -> {
            try {
                while (true) {
                    // poll(): non-blocking; take(): blocking; poll(timeout): timed blocking
                    WatchKey key = watcher.take(); // blocks until an event occurs

                    for (WatchEvent<?> event : key.pollEvents()) {
                        WatchEvent.Kind<?> kind = event.kind();

                        // OVERFLOW means events were lost (too many events at once)
                        if (kind == StandardWatchEventKinds.OVERFLOW) {
                            System.out.println("WARNING: some events were lost");
                            continue;
                        }

                        // The context is the filename that triggered the event
                        @SuppressWarnings("unchecked")
                        WatchEvent<Path> ev = (WatchEvent<Path>) event;
                        Path filename = ev.context(); // just the filename, not full path
                        Path fullPath = watchDir.resolve(filename);

                        System.out.printf("Event: %-20s -> %s%n",
                            kind.name(), fullPath);

                        // Example: reload config when config.properties changes
                        if (kind == StandardWatchEventKinds.ENTRY_MODIFY
                                && filename.toString().equals("config.properties")) {
                            System.out.println("Config changed — reloading...");
                            // reloadConfig(fullPath);
                        }
                    }

                    // CRITICAL: reset the key — without this, no further events are delivered
                    boolean valid = key.reset();
                    if (!valid) {
                        System.out.println("Watch key no longer valid (directory deleted?)");
                        break;
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                System.out.println("Watcher thread interrupted");
            }
        });
        watchThread.setDaemon(true);
        watchThread.start();

        // Simulate file events for the demo
        Thread.sleep(500);
        Files.writeString(watchDir.resolve("hello.txt"), "created");
        Thread.sleep(200);
        Files.writeString(watchDir.resolve("hello.txt"), "modified",
            StandardOpenOption.APPEND);
        Thread.sleep(200);
        Files.delete(watchDir.resolve("hello.txt"));

        Thread.sleep(500);
        watcher.close();
        Files.deleteIfExists(watchDir);
    }
}

Step 5 — NIO Channels and Buffers

NIO (New I/O) introduces a different I/O model: data moves between channels and buffers. A Buffer is a fixed-size memory block; a Channel is a bidirectional conduit to a file or socket. The key difference from java.io: NIO channels can be non-blocking, and you can transfer data between channels directly without copying to userspace.

ChannelBufferExample.javajava
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class ChannelBufferExample {

    // ============================================================
    // ByteBuffer fundamentals
    // ============================================================
    static void bufferDemo() {
        // Allocate a buffer with 64-byte capacity
        ByteBuffer buf = ByteBuffer.allocate(64);
        // ByteBuffer.allocateDirect(64) — off-heap memory: faster for channel I/O, no GC

        // Buffer has three key positions:
        // capacity: total size (never changes)
        // limit:    end of readable/writable data
        // position: current read/write cursor

        // --- Writing into buffer ---
        buf.putInt(42);        // writes 4 bytes, position moves to 4
        buf.putDouble(3.14);   // writes 8 bytes, position moves to 12
        buf.put("Hi".getBytes(StandardCharsets.UTF_8)); // writes 2 bytes, position 14

        // --- Switch from write mode to read mode ---
        buf.flip(); // sets limit=position, position=0
        // Now: position=0, limit=14, ready to read 14 bytes

        int i = buf.getInt();     // reads 4 bytes, position=4
        double d = buf.getDouble(); // reads 8 bytes, position=12
        byte[] text = new byte[2];
        buf.get(text);             // reads 2 bytes, position=14
        System.out.printf("int=%d  double=%.2f  text=%s%n", i, d, new String(text));

        // compact(): preserve unread bytes, switch back to write mode
        buf.compact(); // copies unread bytes to start, position=remaining, limit=capacity

        // clear(): reset without erasing data (position=0, limit=capacity)
        buf.clear();

        // rewind(): re-read same data (position=0, limit unchanged)
        // mark() / reset(): save and restore position
    }

    // ============================================================
    // FileChannel: reading and writing files via NIO
    // ============================================================
    static void fileChannelDemo() throws IOException {
        Path file = Path.of("channel_demo.txt");

        // Writing with FileChannel
        try (FileChannel fc = FileChannel.open(file,
                StandardOpenOption.CREATE,
                StandardOpenOption.WRITE,
                StandardOpenOption.TRUNCATE_EXISTING)) {

            ByteBuffer buf = ByteBuffer.wrap(
                "Hello from FileChannel!\nSecond line\n".getBytes(StandardCharsets.UTF_8));

            while (buf.hasRemaining()) {
                fc.write(buf); // write returns bytes written — loop in case of partial write
            }
            fc.force(true); // flush to disk (fsync) — true=also sync metadata
        }

        // Reading with FileChannel
        try (FileChannel fc = FileChannel.open(file, StandardOpenOption.READ)) {
            ByteBuffer buf = ByteBuffer.allocate(1024);
            StringBuilder sb = new StringBuilder();

            int bytesRead;
            while ((bytesRead = fc.read(buf)) != -1) { // -1 = EOF
                buf.flip();
                sb.append(StandardCharsets.UTF_8.decode(buf));
                buf.clear();
            }
            System.out.println("Read: " + sb);
        }

        // Channel-to-channel transfer (zero-copy: data stays in kernel space)
        Path dest = Path.of("channel_copy.txt");
        try (FileChannel src  = FileChannel.open(file, StandardOpenOption.READ);
             FileChannel dst  = FileChannel.open(dest,
                 StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long transferred = src.transferTo(0, src.size(), dst);
            System.out.println("Transferred " + transferred + " bytes");
        }

        // Cleanup
        Files.deleteIfExists(file);
        Files.deleteIfExists(dest);
    }

    // ============================================================
    // Memory-mapped files: map file into virtual memory
    // OS pages the data in on demand — fastest for large random-access files
    // ============================================================
    static void memoryMappedFile() throws IOException {
        Path file = Path.of("mapped.bin");
        int size = 1024 * 1024; // 1MB

        try (FileChannel fc = FileChannel.open(file,
                StandardOpenOption.CREATE,
                StandardOpenOption.READ,
                StandardOpenOption.WRITE)) {

            // Map the file into memory
            MappedByteBuffer mapped = fc.map(
                FileChannel.MapMode.READ_WRITE, // read+write mapping
                0,                             // offset in file
                size                           // length to map
            );

            // Write directly to memory (changes reflect in file automatically)
            for (int i = 0; i < 1000; i++) {
                mapped.putInt(i * 4, i * i); // absolute put at specific offset — no flip needed
            }

            // Read back
            System.out.println("mapped[10] = " + mapped.getInt(10 * 4)); // 100

            mapped.force(); // flush to disk
        }

        Files.deleteIfExists(file);
    }

    public static void main(String[] args) throws IOException {
        bufferDemo();
        fileChannelDemo();
        memoryMappedFile();
    }
}

Step 6 — Non-Blocking Network I/O with Selector

Traditional java.net (ServerSocket/Socket) is one-thread-per-connection: blocking, doesn't scale to thousands of simultaneous connections. NIO Selector lets one thread monitor many channels for I/O readiness events, then handle only those that are ready. This is the foundation of high-performance servers like Netty.

NioServer.javajava
import java.io.IOException;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Set;

public class NioServer {

    public static void main(String[] args) throws IOException {

        // ============================================================
        // Single-thread NIO echo server
        // Handles multiple clients without creating a thread per client
        // ============================================================

        // Open a selector — the OS-level multiplexer
        Selector selector = Selector.open();

        // Open a server socket channel (non-blocking)
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false); // MUST be non-blocking for Selector
        serverChannel.bind(new InetSocketAddress(8080));

        // Register server channel with selector — interested in ACCEPT events
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("NIO Echo Server listening on port 8080");

        ByteBuffer buffer = ByteBuffer.allocate(256);

        while (true) {
            // select(): blocks until at least one channel is ready
            // selectNow(): non-blocking, returns 0 immediately if nothing ready
            // select(timeout): timed block
            int readyCount = selector.select(); // blocks here
            if (readyCount == 0) continue;

            // Get the set of keys with ready channels
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iter = selectedKeys.iterator();

            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove(); // CRITICAL: remove from selected set after handling

                if (!key.isValid()) continue;

                if (key.isAcceptable()) {
                    // Server channel is ready to accept a new connection
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel client = server.accept(); // non-blocking: returns immediately
                    if (client != null) {
                        client.configureBlocking(false);
                        // Register client for READ events
                        client.register(selector, SelectionKey.OP_READ);
                        System.out.println("Accepted: " + client.getRemoteAddress());
                    }

                } else if (key.isReadable()) {
                    // A client channel has data ready to read
                    SocketChannel client = (SocketChannel) key.channel();
                    buffer.clear();
                    int bytesRead;
                    try {
                        bytesRead = client.read(buffer);
                    } catch (IOException e) {
                        // Client disconnected unexpectedly
                        key.cancel();
                        client.close();
                        continue;
                    }

                    if (bytesRead == -1) {
                        // Client closed connection gracefully
                        System.out.println("Client disconnected: " + client.getRemoteAddress());
                        key.cancel();
                        client.close();
                        continue;
                    }

                    // Echo back what we received
                    buffer.flip();
                    String received = StandardCharsets.UTF_8.decode(buffer).toString().trim();
                    System.out.println("Received: " + received);

                    buffer.rewind(); // rewind to re-read what we just read
                    while (buffer.hasRemaining()) {
                        client.write(buffer); // echo back
                    }

                    // To shut down client: send "quit"
                    if ("quit".equalsIgnoreCase(received)) {
                        key.cancel();
                        client.close();
                    }

                } else if (key.isWritable()) {
                    // Channel is ready to write (use only when you have data pending to write)
                    // Register OP_WRITE only when you have data to send, unregister when done
                    // to avoid tight selector loop when there is nothing to write
                    System.out.println("Channel writable: " + key.channel());
                }

                // If server should stop:
                // serverChannel.close();
                // selector.close();
                // break;
            }
        }
    }
}

Step 7 — Network Basics: Socket and HttpClient

For traditional blocking network I/O, java.net.Socket is the foundation. For HTTP specifically, the modern java.net.http.HttpClient (Java 11+) replaces the old HttpURLConnection with a clean async API that supports HTTP/2 and WebSocket.

NetworkExamples.javajava
import java.io.*;
import java.net.*;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.net.URI;
import java.util.concurrent.CompletableFuture;

public class NetworkExamples {

    // ============================================================
    // TCP Echo Server using blocking java.net
    // ============================================================
    static void startEchoServer(int port) {
        new Thread(() -> {
            try (ServerSocket server = new ServerSocket(port)) {
                System.out.println("Echo server listening on " + port);
                while (true) {
                    Socket client = server.accept(); // blocks for each new connection
                    // Spawn a thread per client (not scalable but simple)
                    new Thread(() -> handleClient(client)).start();
                }
            } catch (IOException e) {
                System.out.println("Server stopped: " + e.getMessage());
            }
        }, "echo-server").start();
    }

    static void handleClient(Socket client) {
        try (client;
             BufferedReader in  = new BufferedReader(
                 new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
             PrintWriter out = new PrintWriter(
                 new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8), true)) {

            System.out.println("Client connected: " + client.getInetAddress());
            String line;
            while ((line = in.readLine()) != null) {
                System.out.println("Server got: " + line);
                out.println("ECHO: " + line); // auto-flushed (PrintWriter with autoFlush=true)
            }
        } catch (IOException e) {
            System.out.println("Client error: " + e.getMessage());
        }
    }

    // ============================================================
    // TCP Client
    // ============================================================
    static void runClient(int port) throws IOException {
        try (Socket socket = new Socket("localhost", port);
             PrintWriter out  = new PrintWriter(
                 new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
             BufferedReader in = new BufferedReader(
                 new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {

            out.println("Hello from client");
            System.out.println("Client received: " + in.readLine()); // ECHO: Hello from client
        }
    }

    // ============================================================
    // Modern HTTP client (Java 11+)
    // ============================================================
    static void httpClientExamples() throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)      // prefer HTTP/2
            .connectTimeout(Duration.ofSeconds(10))  // connection timeout
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

        // --- Synchronous GET ---
        HttpRequest getRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/get"))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(30))
            .GET()
            .build();

        HttpResponse<String> response = client.send(getRequest,
            HttpResponse.BodyHandlers.ofString());

        System.out.println("Status: " + response.statusCode());
        System.out.println("Headers: " + response.headers().map());
        System.out.println("Body (first 100): " + response.body().substring(0, 100));

        // --- Synchronous POST with JSON body ---
        String jsonBody = "{\"name\":\"Alice\",\"age\":30}";
        HttpRequest postRequest = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/post"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();

        HttpResponse<String> postResponse = client.send(postRequest,
            HttpResponse.BodyHandlers.ofString());
        System.out.println("POST status: " + postResponse.statusCode());

        // --- Asynchronous GET (non-blocking) ---
        CompletableFuture<HttpResponse<String>> asyncResponse =
            client.sendAsync(getRequest, HttpResponse.BodyHandlers.ofString());

        asyncResponse
            .thenApply(HttpResponse::body)
            .thenAccept(body -> System.out.println("Async body length: " + body.length()))
            .exceptionally(ex -> {
                System.err.println("Request failed: " + ex.getMessage());
                return null;
            });

        asyncResponse.get(); // wait for demo — in production, chain further
    }

    public static void main(String[] args) throws Exception {
        startEchoServer(9090);
        Thread.sleep(200);
        runClient(9090);
        httpClientExamples();
    }
}

Step 8 — Compression and Zip Archives

Java has built-in support for GZIP compression and ZIP archives through java.util.zip. These are decorator streams that wrap other streams, so they compose naturally with all the buffered and file I/O patterns seen earlier.

CompressionExample.javajava
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.zip.*;

public class CompressionExample {

    // ============================================================
    // GZIP: compress and decompress a single file/stream
    // ============================================================
    static void gzipWrite(String text, Path gzFile) throws IOException {
        try (GZIPOutputStream gzip = new GZIPOutputStream(
                new BufferedOutputStream(Files.newOutputStream(gzFile)))) {
            gzip.write(text.getBytes(StandardCharsets.UTF_8));
        }
        System.out.println("GZIP file size: " + Files.size(gzFile) + " bytes");
    }

    static String gzipRead(Path gzFile) throws IOException {
        try (GZIPInputStream gzip = new GZIPInputStream(
                new BufferedInputStream(Files.newInputStream(gzFile)))) {
            return new String(gzip.readAllBytes(), StandardCharsets.UTF_8);
        }
    }

    // ============================================================
    // ZIP: multiple files in one archive
    // ============================================================
    static void createZip(Path zipFile, Path... filesToZip) throws IOException {
        try (ZipOutputStream zos = new ZipOutputStream(
                new BufferedOutputStream(Files.newOutputStream(zipFile)))) {

            zos.setLevel(Deflater.BEST_COMPRESSION); // 0-9, default=6

            for (Path file : filesToZip) {
                // ZipEntry defines the name inside the archive
                ZipEntry entry = new ZipEntry(file.getFileName().toString());
                zos.putNextEntry(entry);
                Files.copy(file, zos); // stream file contents into ZIP
                zos.closeEntry();
            }
        }
        System.out.println("ZIP created: " + zipFile + " (" + Files.size(zipFile) + " bytes)");
    }

    static void extractZip(Path zipFile, Path outputDir) throws IOException {
        Files.createDirectories(outputDir);

        try (ZipInputStream zis = new ZipInputStream(
                new BufferedInputStream(Files.newInputStream(zipFile)))) {

            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                Path outPath = outputDir.resolve(entry.getName());

                // ZIP SLIP protection: prevent path traversal attacks
                if (!outPath.normalize().startsWith(outputDir.normalize())) {
                    throw new SecurityException("ZIP entry outside output dir: " + entry.getName());
                }

                if (entry.isDirectory()) {
                    Files.createDirectories(outPath);
                } else {
                    Files.createDirectories(outPath.getParent());
                    Files.copy(zis, outPath, StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Extracted: " + outPath);
                }
                zis.closeEntry();
            }
        }
    }

    // ============================================================
    // Modern approach: ZipFile API for random access
    // ============================================================
    static void readSpecificEntry(Path zipFile, String entryName) throws IOException {
        try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(zipFile.toFile())) {
            ZipEntry entry = zf.getEntry(entryName);
            if (entry != null) {
                try (InputStream is = zf.getInputStream(entry)) {
                    System.out.println("Entry '" + entryName + "': " +
                        new String(is.readAllBytes(), StandardCharsets.UTF_8));
                }
            } else {
                System.out.println("Entry not found: " + entryName);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        // GZIP demo
        String data = "Hello compression! ".repeat(100);
        Path gz = Path.of("data.gz");
        System.out.println("Original size: " + data.length() + " bytes");
        gzipWrite(data, gz);
        System.out.println("Decompressed: " + gzipRead(gz).length() + " chars");

        // ZIP demo
        Path f1 = Path.of("file1.txt");
        Path f2 = Path.of("file2.txt");
        Files.writeString(f1, "Content of file 1");
        Files.writeString(f2, "Content of file 2 with more data".repeat(10));

        Path zip = Path.of("archive.zip");
        createZip(zip, f1, f2);
        extractZip(zip, Path.of("extracted"));
        readSpecificEntry(zip, "file1.txt");

        // Cleanup
        Files.deleteIfExists(gz); Files.deleteIfExists(f1);
        Files.deleteIfExists(f2); Files.deleteIfExists(zip);
        Files.walk(Path.of("extracted"))
             .sorted(java.util.Comparator.reverseOrder())
             .forEach(p -> { try { Files.delete(p); } catch (IOException e) { } });
    }
}