Networking con Java

Networking con Java

Java è stato uno dei primi linguaggi mainstream a includere il networking nella libreria standard, con il pacchetto java.net presente fin dalla versione 1.0. Nel corso degli anni si sono aggiunti NIO con i suoi canali non bloccanti, il client HTTP moderno e, più di recente, i virtual thread, che hanno cambiato radicalmente il modo di scrivere server di rete in Java. In questo articolo vedremo come costruire server e client TCP sia con l'API classica sia con NIO, come gestire UDP, DNS e TLS, e come usare correttamente HttpClient.

Il ritorno del modello bloccante grazie ai virtual thread

Per anni la scelta in Java è stata tra due compromessi: l'API classica con ServerSocket, semplice ma limitata da un thread di piattaforma per connessione, oppure NIO con Selector, scalabile ma complessa da scrivere e da leggere. I virtual thread, introdotti come funzionalità stabile in Java 21, eliminano il dilemma: sono thread leggeri gestiti dalla JVM, e quando un virtual thread si blocca su un'operazione di I/O la JVM libera il thread di piattaforma sottostante. Si può quindi tornare al modello "un thread per connessione" anche con decine di migliaia di client.

Un server TCP con virtual thread

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class EchoServer {

    private static final int PORT = 9000;
    private static final int IDLE_TIMEOUT_MS = 30_000;

    public static void main(String[] args) throws IOException {
        // Un nuovo virtual thread per ogni connessione accettata
        try (ServerSocket serverSocket = new ServerSocket(PORT);
             ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

            serverSocket.setReuseAddress(true);
            System.out.println("Server in ascolto sulla porta " + PORT);

            while (true) {
                Socket client = serverSocket.accept();
                executor.submit(() -> handleClient(client));
            }
        }
    }

    private static void handleClient(Socket client) {
        String peer = client.getRemoteSocketAddress().toString();
        System.out.println("Nuova connessione da " + peer);

        try (client;
             BufferedReader reader = new BufferedReader(
                     new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
             PrintWriter writer = new PrintWriter(client.getOutputStream(), true, StandardCharsets.UTF_8)) {

            // Timeout di lettura: read() solleva SocketTimeoutException allo scadere
            client.setSoTimeout(IDLE_TIMEOUT_MS);

            String line;
            while ((line = reader.readLine()) != null) {
                writer.println("echo: " + line);
            }
        } catch (SocketTimeoutException e) {
            System.out.println(peer + ": timeout di inattività");
        } catch (IOException e) {
            System.out.println(peer + ": " + e.getMessage());
        } finally {
            System.out.println(peer + " disconnesso");
        }
    }
}

Il codice è identico a quello che si sarebbe scritto vent'anni fa, a parte l'executor. Questa è proprio la forza dei virtual thread: nessuna nuova API da imparare, solo un comportamento diverso del runtime. setSoTimeout() imposta il timeout di lettura; senza di esso, un client che apre la connessione e non invia nulla occuperebbe un thread per sempre.

Framing con prefisso di lunghezza

Il protocollo a righe va bene per il testo, ma per messaggi binari serve un framing esplicito, perché TCP non conserva i confini dei messaggi. DataInputStream e DataOutputStream rendono semplice il prefisso di lunghezza: writeInt() scrive in big-endian e readFully() legge esattamente il numero di byte richiesto.

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public final class FrameCodec {

    private static final int MAX_FRAME_SIZE = 16 * 1024 * 1024;

    private final DataInputStream input;
    private final DataOutputStream output;

    public FrameCodec(InputStream input, OutputStream output) {
        this.input = new DataInputStream(input);
        this.output = new DataOutputStream(output);
    }

    public synchronized void write(byte[] payload) throws IOException {
        if (payload.length > MAX_FRAME_SIZE) {
            throw new IOException("Payload troppo grande: " + payload.length + " byte");
        }

        // writeInt usa sempre il network byte order (big-endian)
        output.writeInt(payload.length);
        output.write(payload);
        output.flush();
    }

    public byte[] read() throws IOException {
        int length = input.readInt();

        // Protezione contro lunghezze negative o eccessive
        if (length < 0 || length > MAX_FRAME_SIZE) {
            throw new IOException("Lunghezza del frame non valida: " + length);
        }

        byte[] payload = new byte[length];
        // readFully continua a leggere finché il buffer non è pieno
        input.readFully(payload);
        return payload;
    }
}

Se lo stream termina prima della fine del frame, readInt() e readFully() sollevano EOFException, che il chiamante può intercettare per distinguere una chiusura della connessione da altri errori di I/O.

Un server di comandi con shutdown ordinato

Usando il codec costruiamo un server che riceve comandi testuali in frame e risponde. La gestione dello shutdown avviene tramite uno shutdown hook della JVM, che chiude il ServerSocket per sbloccare accept() e poi attende la fine delle connessioni attive.

import java.io.EOFException;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CommandServer implements AutoCloseable {

    private final ServerSocket serverSocket;
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    private final Set<Socket> clients = ConcurrentHashMap.newKeySet();
    private volatile boolean running = true;

    public CommandServer(int port) throws IOException {
        this.serverSocket = new ServerSocket(port);
    }

    public void serve() {
        System.out.println("Server comandi in ascolto sulla porta " + serverSocket.getLocalPort());

        while (running) {
            try {
                Socket client = serverSocket.accept();
                clients.add(client);
                executor.submit(() -> handle(client));
            } catch (SocketException e) {
                // accept() viene interrotto dalla chiusura del ServerSocket
                if (running) {
                    System.err.println("Errore in accept: " + e.getMessage());
                }
            } catch (IOException e) {
                System.err.println("Errore di I/O: " + e.getMessage());
            }
        }
    }

    private void handle(Socket client) {
        try (client) {
            client.setSoTimeout(30_000);
            client.setTcpNoDelay(true);

            FrameCodec codec = new FrameCodec(client.getInputStream(), client.getOutputStream());

            while (running) {
                String command = new String(codec.read(), StandardCharsets.UTF_8).trim();
                String response = dispatch(command);
                codec.write(response.getBytes(StandardCharsets.UTF_8));
            }
        } catch (EOFException e) {
            // Chiusura regolare da parte del client
        } catch (IOException e) {
            System.err.println(client.getRemoteSocketAddress() + ": " + e.getMessage());
        } finally {
            clients.remove(client);
        }
    }

    private String dispatch(String command) {
        return switch (command.toLowerCase()) {
            case "ping" -> "pong";
            case "time" -> Instant.now().toString();
            case "clients" -> String.valueOf(clients.size());
            default -> "ERR comando sconosciuto: " + command;
        };
    }

    @Override
    public void close() throws IOException {
        running = false;
        serverSocket.close();
        executor.shutdown();

        try {
            // Attesa limitata delle connessioni in corso, poi chiusura forzata
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                for (Socket client : clients) {
                    client.close();
                }
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("Server arrestato");
    }

    public static void main(String[] args) throws IOException {
        CommandServer server = new CommandServer(9000);

        Runtime.getRuntime().addShutdownHook(Thread.ofPlatform().unstarted(() -> {
            try {
                server.close();
            } catch (IOException e) {
                System.err.println("Errore durante l'arresto: " + e.getMessage());
            }
        }));

        server.serve();
    }
}

Il client: connessione con timeout

Un errore frequente è creare il socket con il costruttore new Socket(host, port), che si connette immediatamente senza timeout. Il modo corretto è creare un socket non connesso e chiamare connect() specificando il limite:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class CommandClient implements AutoCloseable {

    private final Socket socket;
    private final FrameCodec codec;

    public CommandClient(String host, int port, int connectTimeoutMs, int readTimeoutMs) throws IOException {
        socket = new Socket();

        try {
            // Timeout di connessione esplicito
            socket.connect(new InetSocketAddress(host, port), connectTimeoutMs);
            socket.setSoTimeout(readTimeoutMs);
        } catch (IOException e) {
            socket.close();
            throw e;
        }

        codec = new FrameCodec(socket.getInputStream(), socket.getOutputStream());
    }

    public String send(String command) throws IOException {
        codec.write(command.getBytes(StandardCharsets.UTF_8));
        return new String(codec.read(), StandardCharsets.UTF_8);
    }

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

    public static void main(String[] args) throws IOException {
        try (CommandClient client = new CommandClient("127.0.0.1", 9000, 3000, 5000)) {
            for (String command : new String[] {"ping", "time", "clients", "foo"}) {
                System.out.printf("%s -> %s%n", command, client.send(command));
            }
        }
    }
}

NIO: un server con Selector

Anche con i virtual thread, NIO resta rilevante: è la base di framework come Netty, ed è utile quando si vuole un controllo esplicito sul ciclo degli eventi. Un Selector sorveglia più canali e segnala quelli pronti per l'accettazione, la lettura o la scrittura.

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public class NioEchoServer {

    public static void main(String[] args) throws IOException {
        try (Selector selector = Selector.open();
             ServerSocketChannel server = ServerSocketChannel.open()) {

            server.bind(new InetSocketAddress(9000));
            server.configureBlocking(false);
            server.register(selector, SelectionKey.OP_ACCEPT);

            System.out.println("Server NIO in ascolto sulla porta 9000");

            while (true) {
                // Attesa bloccante finché almeno un canale non è pronto
                selector.select();

                Iterator<SelectionKey> keys = selector.selectedKeys().iterator();

                while (keys.hasNext()) {
                    SelectionKey key = keys.next();
                    // Le chiavi selezionate vanno rimosse manualmente
                    keys.remove();

                    if (!key.isValid()) {
                        continue;
                    }

                    if (key.isAcceptable()) {
                        accept(selector, server);
                    } else if (key.isReadable()) {
                        read(key);
                    } else if (key.isWritable()) {
                        write(key);
                    }
                }
            }
        }
    }

    private static void accept(Selector selector, ServerSocketChannel server) throws IOException {
        SocketChannel client = server.accept();

        if (client == null) {
            return;
        }

        client.configureBlocking(false);
        // Ogni client ha un proprio buffer allegato alla chiave
        client.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(4096));
        System.out.println("Connesso " + client.getRemoteAddress());
    }

    private static void read(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();

        int bytesRead;
        try {
            bytesRead = client.read(buffer);
        } catch (IOException e) {
            bytesRead = -1;
        }

        if (bytesRead == -1) {
            key.cancel();
            client.close();
            return;
        }

        // Passaggio all'interesse in scrittura per restituire i dati ricevuti
        buffer.flip();
        key.interestOps(SelectionKey.OP_WRITE);
    }

    private static void write(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();

        client.write(buffer);

        // Se il buffer non è stato scritto completamente si riproverà al prossimo giro
        if (!buffer.hasRemaining()) {
            buffer.clear();
            key.interestOps(SelectionKey.OP_READ);
        }
    }
}

Il confronto con il server basato su virtual thread è eloquente: per ottenere lo stesso comportamento servono gestione esplicita dei buffer, cambi di interesse tra lettura e scrittura e attenzione alle scritture parziali. Per la maggior parte delle applicazioni nuove, i virtual thread sono la scelta più produttiva.

UDP con DatagramChannel

Per UDP si può usare la classica DatagramSocket oppure il più moderno DatagramChannel di NIO. Ecco un server di time stamp che risponde a ogni datagramma con l'ora corrente, e un client che gestisce timeout e ritrasmissioni:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.charset.StandardCharsets;
import java.time.Instant;

public class UdpTimeServer {

    public static void main(String[] args) throws IOException {
        try (DatagramChannel channel = DatagramChannel.open()) {
            channel.bind(new InetSocketAddress(9001));
            System.out.println("Server UDP in ascolto sulla porta 9001");

            ByteBuffer buffer = ByteBuffer.allocate(1500);

            while (true) {
                buffer.clear();
                // receive restituisce l'indirizzo del mittente
                SocketAddress sender = channel.receive(buffer);
                buffer.flip();

                String request = StandardCharsets.UTF_8.decode(buffer).toString().trim();
                String reply = request + " " + Instant.now();

                channel.send(ByteBuffer.wrap(reply.getBytes(StandardCharsets.UTF_8)), sender);
            }
        }
    }
}
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;

public class UdpTimeClient {

    public static String request(String host, int port, String message, int retries, int timeoutMs) throws IOException {
        byte[] payload = message.getBytes(StandardCharsets.UTF_8);

        try (DatagramSocket socket = new DatagramSocket()) {
            socket.setSoTimeout(timeoutMs);
            InetSocketAddress target = new InetSocketAddress(host, port);

            for (int attempt = 1; attempt <= retries; attempt++) {
                socket.send(new DatagramPacket(payload, payload.length, target));

                byte[] buffer = new byte[1500];
                DatagramPacket response = new DatagramPacket(buffer, buffer.length);

                try {
                    socket.receive(response);
                    return new String(response.getData(), 0, response.getLength(), StandardCharsets.UTF_8);
                } catch (SocketTimeoutException e) {
                    // Datagramma perso o server non raggiungibile: nuovo tentativo
                    System.err.println("Tentativo " + attempt + " senza risposta");
                }
            }
        }

        throw new IOException("Nessuna risposta dopo " + retries + " tentativi");
    }

    public static void main(String[] args) throws IOException {
        System.out.println(request("127.0.0.1", 9001, "ora?", 3, 1000));
    }
}

Risoluzione DNS

InetAddress fornisce la risoluzione tramite il resolver configurato nella JVM. Va tenuto presente che la JVM mantiene una propria cache DNS: le risoluzioni riuscite restano valide per 30 secondi di default (o per sempre se è attivo un security manager in versioni datate), comportamento regolabile con la proprietà di sicurezza networkaddress.cache.ttl.

import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class DnsLookup {

    public record Resolution(String host, List<String> ipv4, List<String> ipv6, String error) {
    }

    public static Resolution resolve(String host) {
        try {
            InetAddress[] addresses = InetAddress.getAllByName(host);

            List<String> ipv4 = Arrays.stream(addresses)
                    .filter(address -> !(address instanceof Inet6Address))
                    .map(InetAddress::getHostAddress)
                    .toList();

            List<String> ipv6 = Arrays.stream(addresses)
                    .filter(Inet6Address.class::isInstance)
                    .map(InetAddress::getHostAddress)
                    .toList();

            return new Resolution(host, ipv4, ipv6, null);
        } catch (UnknownHostException e) {
            return new Resolution(host, List.of(), List.of(), "host sconosciuto");
        }
    }

    public static String reverse(String ip) throws UnknownHostException {
        // getCanonicalHostName esegue la query PTR; restituisce l'IP se non trovata
        return InetAddress.getByName(ip).getCanonicalHostName();
    }

    public static void main(String[] args) throws Exception {
        List<String> hosts = List.of("openjdk.org", "example.com", "dominio-inesistente.invalid");

        // Le risoluzioni sono bloccanti: con i virtual thread si eseguono in parallelo a costo minimo
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Resolution>> futures = hosts.stream()
                    .map(host -> executor.submit(() -> resolve(host)))
                    .toList();

            for (Future<Resolution> future : futures) {
                System.out.println(future.get());
            }
        }

        System.out.println(reverse("8.8.8.8"));
    }
}

Per interrogare record MX, TXT o SRV la libreria standard offre il provider DNS di JNDI, utilizzabile senza dipendenze esterne:

import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.InitialDirContext;

public class DnsRecords {

    public static List<String> query(String domain, String type) throws NamingException {
        Hashtable<String, String> environment = new Hashtable<>();
        environment.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
        // Server DNS specifico e timeout in millisecondi
        environment.put("java.naming.provider.url", "dns://1.1.1.1");
        environment.put("com.sun.jndi.dns.timeout.initial", "2000");
        environment.put("com.sun.jndi.dns.timeout.retries", "2");

        InitialDirContext context = new InitialDirContext(environment);

        try {
            Attribute attribute = context.getAttributes(domain, new String[] {type}).get(type);
            List<String> values = new ArrayList<>();

            if (attribute != null) {
                NamingEnumeration<?> enumeration = attribute.getAll();
                while (enumeration.hasMore()) {
                    values.add(enumeration.next().toString());
                }
            }

            return values;
        } finally {
            context.close();
        }
    }

    public static void main(String[] args) throws NamingException {
        System.out.println("MX: " + query("gmail.com", "MX"));
        System.out.println("TXT: " + query("openjdk.org", "TXT"));
    }
}

TLS e ispezione dei certificati

Le connessioni TLS si ottengono da una SSLSocketFactory. Dopo l'handshake, la SSLSession espone protocollo, cipher suite e catena di certificati del server:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.util.List;

public class CertificateInspector {

    public record CertificateInfo(String host, String protocol, String cipher, String subject,
                                  String issuer, Instant expires, long daysLeft) {
    }

    public static CertificateInfo inspect(String host, int port) throws IOException {
        SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();

        try (SSLSocket socket = (SSLSocket) factory.createSocket()) {
            socket.connect(new InetSocketAddress(host, port), 5000);
            socket.setSoTimeout(5000);

            SSLParameters parameters = socket.getSSLParameters();
            parameters.setServerNames(List.of(new SNIHostName(host)));
            parameters.setProtocols(new String[] {"TLSv1.3", "TLSv1.2"});
            // Verifica del nome host rispetto al certificato, come farebbe un browser
            parameters.setEndpointIdentificationAlgorithm("HTTPS");
            socket.setSSLParameters(parameters);

            socket.startHandshake();

            SSLSession session = socket.getSession();
            X509Certificate certificate = (X509Certificate) session.getPeerCertificates()[0];
            Instant expires = certificate.getNotAfter().toInstant();

            return new CertificateInfo(
                    host,
                    session.getProtocol(),
                    session.getCipherSuite(),
                    certificate.getSubjectX500Principal().getName(),
                    certificate.getIssuerX500Principal().getName(),
                    expires,
                    Duration.between(Instant.now(), expires).toDays()
            );
        }
    }

    public static void main(String[] args) {
        for (String host : List.of("openjdk.org", "expired.badssl.com")) {
            try {
                System.out.println(inspect(host, 443));
            } catch (IOException e) {
                System.out.println(host + ": " + e.getMessage());
            }
        }
    }
}

Senza setEndpointIdentificationAlgorithm("HTTPS"), un SSLSocket verifica la catena di certificati ma non controlla che il certificato corrisponda all'host contattato: è un'omissione sottile che può aprire la porta ad attacchi man-in-the-middle.

HttpClient: richieste sincrone, asincrone e concorrenti

Il java.net.http.HttpClient, standard da Java 11, supporta HTTP/1.1 e HTTP/2, richieste sincrone e asincrone e un pool di connessioni condiviso. Un'istanza è thread-safe e va riutilizzata in tutta l'applicazione.

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ResilientHttpClient {

    private static final Set<Integer> RETRYABLE_STATUS = Set.of(429, 502, 503, 504);

    private final HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            // Timeout per stabilire la connessione
            .connectTimeout(Duration.ofSeconds(3))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

    public HttpResponse<String> get(String url, int maxRetries) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                // Timeout complessivo fino alla ricezione degli header di risposta
                .timeout(Duration.ofSeconds(10))
                .header("Accept", "application/json")
                .GET()
                .build();

        IOException lastError = null;

        for (int attempt = 0; attempt <= maxRetries; attempt++) {
            try {
                HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

                if (!RETRYABLE_STATUS.contains(response.statusCode()) || attempt == maxRetries) {
                    return response;
                }
            } catch (HttpTimeoutException e) {
                lastError = e;
            } catch (IOException e) {
                lastError = e;
            }

            // Backoff esponenziale con jitter
            long delay = (long) (200 * Math.pow(2, attempt) + Math.random() * 200);
            Thread.sleep(delay);
        }

        throw lastError != null ? lastError : new IOException("Tentativi esauriti per " + url);
    }

    public static void main(String[] args) throws Exception {
        ResilientHttpClient http = new ResilientHttpClient();

        List<String> urls = List.of(
                "https://api.github.com/repos/openjdk/jdk",
                "https://httpbin.org/status/503",
                "https://httpbin.org/delay/1"
        );

        // Richieste concorrenti con codice bloccante, grazie ai virtual thread
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<HttpResponse<String>>> futures = urls.stream()
                    .map(url -> executor.submit(() -> http.get(url, 2)))
                    .toList();

            for (int i = 0; i < urls.size(); i++) {
                try {
                    HttpResponse<String> response = futures.get(i).get();
                    System.out.printf("%s -> %d (%s)%n", urls.get(i), response.statusCode(), response.version());
                } catch (Exception e) {
                    System.out.printf("%s -> errore: %s%n", urls.get(i), e.getCause().getMessage());
                }
            }
        }
    }
}

Il client offre anche sendAsync(), che restituisce un CompletableFuture. Con i virtual thread, però, la versione sincrona eseguita in parallelo è spesso più leggibile e altrettanto efficiente, ed è più semplice da combinare con ritentativi e gestione delle eccezioni.

Buone pratiche

  • Usare i virtual thread per i server basati su socket: si ottiene la scalabilità di NIO con la semplicità del codice bloccante.
  • Connettersi sempre con connect(address, timeout) e impostare setSoTimeout() per le letture.
  • Riutilizzare un'unica istanza di HttpClient, configurando sia connectTimeout sia il timeout della singola richiesta.
  • Abilitare la verifica del nome host sugli SSLSocket con setEndpointIdentificationAlgorithm("HTTPS").
  • Conoscere la cache DNS della JVM e regolarne il TTL in ambienti dove gli indirizzi cambiano spesso, come Kubernetes.

Conclusioni

Il networking in Java ha attraversato diverse ere, dall'I/O bloccante delle origini a NIO, fino al ritorno del modello bloccante reso scalabile dai virtual thread. Oggi la piattaforma offre il meglio dei due mondi: codice semplice e lineare per la maggior parte dei casi, e API di basso livello per chi ha bisogno di controllo totale. Unendo a queste API timeout espliciti, framing robusto e una configurazione TLS corretta, si ottengono servizi di rete affidabili e performanti.