<< Home

Chapter 17: Input, Output, and Networking

Modern software often relies on data exchange between files, devices, and remote systems. Java provides a comprehensive set of APIs for handling input and output (I/O) operations, along with powerful networking capabilities for building connected applications. From reading files and streams to communicating over sockets or HTTP, Java’s I/O and networking frameworks make it possible to create efficient and secure data-driven systems.

The core I/O libraries are built around the concept of streams; sequences of data that can be read from or written to various sources, such as files, network connections, or memory buffers. The java.io and java.nio packages provide both traditional and non-blocking approaches for working with data streams and channels, while higher-level classes simplify tasks like object serialization or file manipulation.

💡 The java.nio (“New I/O”) package introduced in Java 1.4 offers buffer-based, non-blocking I/O and selectors for scalable, high-performance applications such as web servers and real-time systems.

Networking support is equally robust. The java.net package enables communication over TCP and UDP sockets, while the modern HttpClient API provides an easy way to perform HTTP requests asynchronously. Together, these allow you to build everything from low-level network tools to full-featured web clients and distributed systems.

This chapter explores the key techniques for managing I/O and network communication in Java. You will learn how to open and use sockets, send and receive data streams, make web requests, serialize objects for transmission, and apply safe practices for handling resources and exceptions in networked environments.

⚠️ Network and file operations are prone to errors such as timeouts, permission issues, and unreachable hosts. Always use structured exception handling and clean resource management to maintain application reliability and security.

Working with sockets

Sockets provide the foundation for network communication in Java. A socket represents one endpoint of a two-way communication link between two programs running on a network. Java’s java.net package offers both high-level and low-level APIs for creating client and server sockets, allowing applications to exchange data over TCP or UDP connections.

TCP sockets

TCP (Transmission Control Protocol) provides reliable, ordered communication between applications. In Java, TCP communication is handled using the Socket and ServerSocket classes. A server listens for incoming connections on a specific port, while a client connects to that port on the server’s address.

import java.io.*;
import java.net.*;

// Simple server example
public class SimpleServer {
  public static void main(String[] args) throws IOException {
    ServerSocket server = new ServerSocket(5000);
    System.out.println("Server waiting for connection...");

    Socket client = server.accept();
    PrintWriter out = new PrintWriter(client.getOutputStream(), true);
    out.println("Hello from the server!");

    client.close();
    server.close();
  }
}
import java.io.*;
import java.net.*;

// Simple client example
public class SimpleClient {
  public static void main(String[] args) throws IOException {
    Socket socket = new Socket("localhost", 5000);
    BufferedReader in = new BufferedReader(
      new InputStreamReader(socket.getInputStream())
    );

    System.out.println("Message: " + in.readLine());
    socket.close();
  }
}

In this example, the server accepts a connection from the client, sends a text message, and then closes the socket. TCP ensures that the message arrives reliably and in the correct order.

💡 Always close sockets and streams when finished. Using try-with-resources automatically releases network resources and prevents leaks.

Using try-with-resources for sockets

Since Java 7, you can simplify socket handling using the try-with-resources construct, which automatically closes connections even if an exception occurs.

try (ServerSocket server = new ServerSocket(6000);
     Socket client = server.accept();
     PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
  out.println("Connected safely!");
}

This pattern helps ensure clean termination of network resources, reducing the risk of hanging connections or resource exhaustion.

UDP sockets

UDP (User Datagram Protocol) is a connectionless, lightweight protocol that sends data packets without guaranteeing delivery or order. It is often used in applications like games, streaming, or sensor data where speed is more important than reliability. Java supports UDP with DatagramSocket and DatagramPacket.

import java.net.*;

// UDP sender
public class UDPSender {
  public static void main(String[] args) throws IOException {
    DatagramSocket socket = new DatagramSocket();
    byte[] data = "Hello UDP".getBytes();
    InetAddress address = InetAddress.getByName("localhost");

    DatagramPacket packet = new DatagramPacket(data, data.length, address, 7000);
    socket.send(packet);
    socket.close();
  }
}
import java.net.*;

// UDP receiver
public class UDPReceiver {
  public static void main(String[] args) throws IOException {
    DatagramSocket socket = new DatagramSocket(7000);
    byte[] buffer = new byte[256];

    DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
    socket.receive(packet);

    String message = new String(packet.getData(), 0, packet.getLength());
    System.out.println("Received: " + message);
    socket.close();
  }
}

Unlike TCP, UDP does not establish a connection. Each packet (datagram) is sent independently, making it faster but less reliable.

⚠️ UDP packets can be lost, duplicated, or arrive out of order. Always design UDP-based systems to handle missing or corrupted data gracefully.

Ports and addressing

Every socket communicates through a specific IP address and port number. Common ports include 80 for HTTP and 443 for HTTPS, but user applications typically use higher-numbered ports (above 1024). Java’s InetAddress class provides methods to resolve hostnames and retrieve local network information.

InetAddress local = InetAddress.getLocalHost();
System.out.println("Local host: " + local.getHostName());
System.out.println("IP address: " + local.getHostAddress());

Understanding addressing and port management is essential for ensuring that your application connects correctly and avoids conflicts with other services.

HTTP clients

While sockets provide low-level network access, most modern applications communicate using higher-level protocols such as HTTP. Java’s HttpClient API, introduced in Java 11, offers a powerful and convenient way to send HTTP requests and process responses using a fluent, asynchronous design. It replaces older classes like HttpURLConnection with a cleaner, more flexible interface.

Creating a basic HTTP client

The HttpClient class represents a reusable, thread-safe client that can send requests and handle responses. You can create one using its builder pattern:

import java.net.http.*;
import java.net.URI;

public class SimpleRequest {
  public static void main(String[] args) throws Exception {
    HttpClient client = HttpClient.newHttpClient();

    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://example.com"))
      .build();

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

    System.out.println("Status: " + response.statusCode());
    System.out.println("Body:\n" + response.body());
  }
}

This example performs a simple GET request and prints the HTTP status code and response body. The BodyHandlers utility provides predefined ways to process the response, such as reading it as text or writing it to a file.

💡 The HttpClient class automatically supports HTTPS, redirects, and HTTP/2 where available, without requiring extra configuration.

Sending asynchronous requests

To keep programs responsive, you can perform HTTP requests asynchronously using the sendAsync() method. This returns a CompletableFuture that completes when the response arrives.

import java.net.http.*;
import java.net.URI;
import java.util.concurrent.*;

public class AsyncExample {
  public static void main(String[] args) throws Exception {
    HttpClient client = HttpClient.newHttpClient();

    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://example.com/data"))
      .build();

    CompletableFuture<HttpResponse<String>> future =
      client.sendAsync(request, HttpResponse.BodyHandlers.ofString());

    future.thenAccept(response -> {
      System.out.println("Status: " + response.statusCode());
      System.out.println("Response: " + response.body());
    });

    future.join();
  }
}

Because sendAsync() integrates with CompletableFuture, you can chain multiple asynchronous operations, such as transforming or saving the response once it arrives.

Custom headers and POST requests

HTTP requests often need additional headers or body data, especially when working with APIs. You can add these easily using the HttpRequest builder.

import java.net.http.*;
import java.net.URI;

public class PostExample {
  public static void main(String[] args) throws Exception {
    HttpClient client = HttpClient.newHttpClient();

    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://httpbin.org/post"))
      .header("Content-Type", "application/json")
      .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"Robin\"}"))
      .build();

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

    System.out.println("Response: " + response.body());
  }
}

The BodyPublishers class provides several methods for sending content, including ofString(), ofFile(), and ofInputStream().

⚠️ Always set the correct Content-Type header when sending data to a web service. Mismatched types may cause errors or unexpected behavior.

Handling timeouts and redirects

You can customize client behavior using HttpClient.Builder to set timeouts, redirect policies, and proxy configurations.

HttpClient client = HttpClient.newBuilder()
  .connectTimeout(java.time.Duration.ofSeconds(5))
  .followRedirects(HttpClient.Redirect.NORMAL)
  .build();

Redirect policies can be set to NEVER, NORMAL, or ALWAYS, depending on how you want the client to handle redirection responses (such as HTTP 301 or 302).

Downloading files

To download large files efficiently, you can use a file-based body handler that streams data directly to disk instead of keeping it in memory.

import java.nio.file.*;
import java.net.http.*;
import java.net.URI;

public class DownloadExample {
  public static void main(String[] args) throws Exception {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://example.com/file.zip"))
      .build();

    HttpResponse<Path> response = client.send(
      request,
      HttpResponse.BodyHandlers.ofFile(Paths.get("file.zip"))
    );

    System.out.println("Downloaded to: " + response.body());
  }
}

The BodyHandlers.ofFile() method allows efficient file downloads without consuming excessive memory, which is especially useful for large content transfers.

💡 The HttpClient API offers a modern, declarative way to perform web communication, fully supporting asynchronous pipelines and modern HTTP/2 features with minimal code.

Reading URLs and streams

Before the modern HttpClient API, the java.net package provided the URL and URLConnection classes for working with online resources. These remain useful for simple tasks such as reading a text file or JSON feed from a web address. They operate using familiar stream-based I/O, allowing data to be processed line by line or byte by byte.

Reading text from a URL

The URL class represents an address to a resource, and its openStream() method provides an InputStream for reading data directly. This makes it easy to access text-based content like web pages or configuration files.

import java.net.*;
import java.io.*;

public class URLReader {
  public static void main(String[] args) {
    try {
      URL url = new URL("https://example.com");
      try (BufferedReader in = new BufferedReader(
             new InputStreamReader(url.openStream()))) {

        String line;
        while ((line = in.readLine()) != null) {
          System.out.println(line);
        }
      }
    } catch (IOException e) {
      System.err.println("Error: " + e.getMessage());
    }
  }
}

This example reads a web page line by line and prints it to the console. The try-with-resources block ensures that the stream is closed automatically when finished.

💡 Use buffering with BufferedReader or BufferedInputStream when reading from URLs or files. It improves performance by reducing the number of read operations.

Working with URLConnection

For more control, such as setting headers or handling metadata, you can use URLConnection. This class lets you configure connection parameters before opening the input or output streams.

import java.net.*;
import java.io.*;

public class ConnectionExample {
  public static void main(String[] args) throws IOException {
    URL url = new URL("https://example.com/data.json");
    URLConnection conn = url.openConnection();

    conn.setRequestProperty("User-Agent", "JavaClient/1.0");
    try (BufferedReader reader = new BufferedReader(
           new InputStreamReader(conn.getInputStream()))) {

      String line;
      while ((line = reader.readLine()) != null) {
        System.out.println(line);
      }
    }
  }
}

The URLConnection class works with both HTTP and file URLs, allowing code reuse across different data sources with minimal changes.

Reading binary data

When working with images, PDFs, or other binary content, use byte streams instead of character streams. The InputStream interface provides methods for reading raw byte data efficiently.

import java.net.*;
import java.io.*;
import java.nio.file.*;

public class BinaryDownload {
  public static void main(String[] args) throws IOException {
    URL url = new URL("https://example.com/image.jpg");
    try (InputStream in = url.openStream()) {
      Files.copy(in, Paths.get("image.jpg"), StandardCopyOption.REPLACE_EXISTING);
    }
    System.out.println("Download complete.");
  }
}

This approach copies the content of the URL directly to a file using the Files.copy() method, ideal for handling binary data without manually managing buffers.

⚠️ Avoid reading large binary files fully into memory. Always stream them to disk or process them in chunks to prevent out-of-memory errors.

Reading from local and classpath resources

The same stream-based approach applies to local files or classpath resources. The FileInputStream class reads from disk, while ClassLoader.getResourceAsStream() reads from packaged files inside JARs.

// Reading a file from disk
try (InputStream in = new FileInputStream("config.txt")) {
  in.transferTo(System.out);
}

// Reading a resource from classpath
try (InputStream in = MyClass.class.getResourceAsStream("/data/config.txt")) {
  if (in != null) in.transferTo(System.out);
}

Using consistent stream handling across URLs, files, and embedded resources helps maintain a unified data-access strategy throughout your application.

Serialization over networks

Serialization is the process of converting an object’s state into a format that can be stored or transmitted, then reconstructed later. In Java, serialization is most commonly achieved using the Serializable interface, which marks a class as capable of being converted into a byte stream. This mechanism can be used to send complex data structures over a network or save them to a file.

The Serializable interface

Any class that implements java.io.Serializable can be serialized automatically by Java’s I/O system. No methods need to be defined; just the marker interface itself is enough. However, all fields within the object must also be serializable, or they must be marked transient to be skipped during the process.

import java.io.*;

class Message implements Serializable {
  private String text;
  private int id;

  public Message(String text, int id) {
    this.text = text;
    this.id = id;
  }

  public String toString() {
    return "Message[" + id + "]: " + text;
  }
}

This class can now be serialized and transmitted over a network stream or saved to disk using ObjectOutputStream and ObjectInputStream.

Sending objects across sockets

Java’s object streams make it simple to send and receive serialized objects over TCP connections. On the server side, you write an object to an ObjectOutputStream; on the client side, you read it back with an ObjectInputStream.

import java.io.*;
import java.net.*;

// Server sending a serialized object
public class ObjectServer {
  public static void main(String[] args) throws IOException {
    try (ServerSocket server = new ServerSocket(8000);
         Socket client = server.accept();
         ObjectOutputStream out =
           new ObjectOutputStream(client.getOutputStream())) {

      Message msg = new Message("Hello client", 1);
      out.writeObject(msg);
      out.flush();
    }
  }
}
import java.io.*;
import java.net.*;

// Client receiving a serialized object
public class ObjectClient {
  public static void main(String[] args) throws Exception {
    try (Socket socket = new Socket("localhost", 8000);
         ObjectInputStream in =
           new ObjectInputStream(socket.getInputStream())) {

      Message msg = (Message) in.readObject();
      System.out.println("Received: " + msg);
    }
  }
}

This pattern allows entire object hierarchies to be sent between programs with minimal code. The only requirement is that the classes involved implement Serializable.

💡 Always declare a serialVersionUID constant in serializable classes. This ensures version compatibility across serialized objects when classes evolve over time.
private static final long serialVersionUID = 1L;

Transient fields

Some data should not be serialized; for example, sensitive information or temporary resources like open file handles or network connections. You can exclude these fields by marking them transient.

class UserSession implements Serializable {
  private String username;
  private transient String password;
}

When deserialized, transient fields are set to their default values (such as null or 0), since they are not written to the stream.

Customizing serialization

You can control how objects are serialized by defining private methods writeObject() and readObject() within your class. These are called automatically during serialization and deserialization, allowing you to encrypt data, validate integrity, or modify the format.

private void writeObject(ObjectOutputStream out) throws IOException {
  out.defaultWriteObject();
  out.writeLong(System.currentTimeMillis());  // Add timestamp
}

private void readObject(ObjectInputStream in)
    throws IOException, ClassNotFoundException {
  in.defaultReadObject();
  long timestamp = in.readLong();
  System.out.println("Deserialized at: " + timestamp);
}

Alternative serialization formats

Although Java’s built-in serialization is convenient, it is primarily suited for trusted systems where both sender and receiver use the same class definitions. For cross-platform communication or public APIs, it is better to use standardized formats such as JSON, XML, or binary protocols like Protocol Buffers.

import com.fasterxml.jackson.databind.ObjectMapper;

public class JsonExample {
  public static void main(String[] args) throws Exception {
    ObjectMapper mapper = new ObjectMapper();
    Message msg = new Message("JSON hello", 2);

    String json = mapper.writeValueAsString(msg);
    System.out.println(json);
  }
}

Using formats like JSON ensures interoperability between different programming languages and platforms, making them ideal for distributed systems and RESTful services.

⚠️ Never deserialize data from untrusted sources using Java’s built-in serialization. It can lead to security vulnerabilities such as remote code execution. Always validate or use safe serialization libraries when working across networks.

Security and exception hygiene

When working with I/O and networking in Java, maintaining security and robust error handling is essential. Networked applications operate in unpredictable environments. Connections can fail, input can be malformed, and malicious data can exploit poorly secured systems. Good exception hygiene and defensive programming practices are key to building reliable and secure communication systems.

Validating input and avoiding injection

Always validate and sanitize incoming data, whether it comes from user input, a network stream, or a file. Never assume that external data is well-formed or trustworthy. For example, when parsing URLs or parameters, verify expected formats and ranges before use.

import java.net.*;

public class SafeURL {
  public static void main(String[] args) {
    try {
      String input = "https://example.com";
      URI uri = new URI(input);

      if (!"https".equalsIgnoreCase(uri.getScheme())) {
        throw new IllegalArgumentException("Only HTTPS is allowed");
      }

      System.out.println("Validated: " + uri);
    } catch (Exception e) {
      System.err.println("Invalid input: " + e.getMessage());
    }
  }
}

Failing to check input can lead to command injection, directory traversal, or denial-of-service attacks. Restrict access to trusted protocols and sanitize strings used in file paths or system commands.

💡 Always validate both the structure and intent of user-supplied data. Defensive input handling protects your application before network or file access even begins.

Using encryption and secure protocols

When transmitting sensitive information, always use secure connections such as HTTPS or SSL/TLS. Java’s SSLSocket and HttpsURLConnection classes support encrypted communication channels that protect data in transit from interception or tampering.

import javax.net.ssl.HttpsURLConnection;
import java.net.*;
import java.io.*;

public class SecureConnection {
  public static void main(String[] args) throws Exception {
    URL url = new URL("https://example.com/secure");
    HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();

    try (BufferedReader in = new BufferedReader(
           new InputStreamReader(conn.getInputStream()))) {
      System.out.println("Response code: " + conn.getResponseCode());
      in.lines().forEach(System.out::println);
    }
  }
}

Modern Java versions verify SSL certificates automatically. However, avoid disabling verification (for example, with custom trust managers) unless absolutely necessary for controlled testing environments.

⚠️ Never bypass SSL certificate checks in production code. Doing so completely defeats the purpose of secure transport and exposes users to man-in-the-middle attacks.

Exception hygiene

Most I/O and networking operations can throw checked exceptions such as IOException, SocketTimeoutException, or UnknownHostException. Always handle these appropriately to ensure that failures do not leave resources open or applications in an unstable state.

try (Socket socket = new Socket("example.com", 80);
     PrintWriter out = new PrintWriter(socket.getOutputStream(), true)) {
  out.println("GET / HTTP/1.1");
} catch (IOException e) {
  System.err.println("Connection failed: " + e.getMessage());
}

The try-with-resources statement ensures that sockets and streams are closed automatically, even if exceptions occur. Always log errors meaningfully but avoid exposing sensitive internal details in messages sent to users or clients.

Timeouts and resource limits

Set timeouts for network operations to prevent hanging connections and resource exhaustion. Most network classes in Java provide timeout settings through constructors or configuration methods.

Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 80), 3000);  // 3-second timeout
socket.setSoTimeout(5000);  // 5-second read timeout

Time limits ensure that threads are not blocked indefinitely due to network latency or unreachable hosts. Similarly, use thread pools, bounded queues, and memory limits to control system load under heavy traffic.

Secure serialization and deserialization

Deserializing untrusted data is one of the most common security vulnerabilities in networked applications. Java’s native serialization mechanism can instantiate arbitrary classes, leading to potential remote code execution if input is malicious. To mitigate this, never deserialize objects from unknown sources, or restrict allowed classes with an ObjectInputFilter.

import java.io.*;

public class SafeDeserialization {
  public static void main(String[] args) throws Exception {
    ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.example.*;!*");
    try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.ser"))) {
      in.setObjectInputFilter(filter);
      Object obj = in.readObject();
      System.out.println("Read: " + obj);
    }
  }
}

This approach ensures that only classes from trusted packages are allowed during deserialization, greatly reducing attack surface.

💡 Combine careful exception handling, timeouts, and strong input validation for maximum resilience. A secure I/O layer forms the foundation of every reliable Java application.