Membuka koneksi TCP itu mahal: sistem operasi menjalankan handshake tiga arah, server mengalokasikan memori untuk sesi baru, dan jaringan ditempuh dua kali sebelum satu byte data terkirim. Bayangkan aplikasi yang memproses ribuan permintaan per detik — jika setiap permintaan membuka koneksi baru, sebagian besar tenaga habis untuk seremoni pembukaan, bukan untuk data. Solusinya sudah lama menjadi standar: keep-alive membuat koneksi bertahan setelah satu permintaan selesai, dan pool menyimpannya untuk dipakai ulang. HttpClient modern menjalankan keduanya secara otomatis di balik layar, dengan satu syarat sederhana namun sering dilanggar: satu objek client dipakai untuk banyak permintaan. Chapter ini membuktikan efeknya lewat server penghitung yang kita tulis sendiri, lalu membahas kerelaan HttpClient terhadap pemakaian bersama antar thread.

Keep-Alive di Balik Layar #

Protokol HTTP/1.1 menganggap koneksi persisten secara default: setelah respons lengkap terkirim, koneksi boleh dipakai untuk permintaan berikutnya tanpa pembukaan ulang. Penanda selesai respons adalah header Content-Length yang tepat — klien tahu persis di mana body berakhir dan kapan socket boleh dikembalikan ke pool. Header Connection: close (dari klien maupun server) menyatakan kebalikannya: setelah respons ini, koneksi ditutup. Pada praktiknya kita hampir tidak pernah menyentuh header ini secara manual; HttpClient mengelolanya, dan keputusan kita yang berpengaruh hanyalah apakah objek client yang sama dipakai lagi atau dibuang.

Membuktikan keep-alive membutuhkan server yang bisa menghitung dua hal berbeda: berapa kali koneksi TCP dibuka, dan berapa permintaan HTTP yang lewat. Kita tulis server HTTP mini secara manual di atas ServerSocket — tanpa framework — sehingga koneksi dan permintaan terlihat telanjang:

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public class HitungKoneksi {
    public static void main(String[] args) throws Exception {
        // Server HTTP tulisan tangan untuk menghitung koneksi vs permintaan
        AtomicInteger koneksi = new AtomicInteger();
        AtomicInteger permintaan = new AtomicInteger();
        AtomicReference<Socket> sambunganAktif = new AtomicReference<>();

        ServerSocket pintu = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
        Thread pegawai = new Thread(() -> {
            try {
                while (true) {
                    Socket s = pintu.accept();
                    sambunganAktif.set(s);
                    koneksi.incrementAndGet();
                    try (s;
                         BufferedReader masuk = new BufferedReader(new InputStreamReader(
                                 s.getInputStream(), StandardCharsets.ISO_8859_1));
                         PrintWriter keluar = new PrintWriter(new OutputStreamWriter(
                                 s.getOutputStream(), StandardCharsets.ISO_8859_1))) {
                        while (true) {
                            String baris = masuk.readLine();
                            if (baris == null) {
                                break; // klien menutup koneksi
                            }
                            if (!baris.isEmpty()) {
                                continue; // lewati baris header permintaan
                            }
                            permintaan.incrementAndGet();
                            // Content-Length yang tepat membuat koneksi layak dipakai ulang
                            keluar.print("HTTP/1.1 200 OK\r\n");
                            keluar.print("Content-Type: text/plain\r\n");
                            keluar.print("Content-Length: 3\r\n\r\n");
                            keluar.print("hai");
                            keluar.flush();
                        }
                    } catch (IOException e) {
                        // koneksi ditutup dari luar
                    }
                }
            } catch (IOException e) {
                // ServerSocket ditutup — pegawai selesai
            }
        });
        pegawai.start();

        try {
            HttpClient klien = HttpClient.newHttpClient();
            String url = "http://127.0.0.1:" + pintu.getLocalPort() + "/";
            for (int i = 1; i <= 3; i++) {
                HttpResponse<String> res = klien.send(
                        HttpRequest.newBuilder(URI.create(url)).GET().build(),
                        HttpResponse.BodyHandlers.ofString());
                System.out.println("permintaan " + i + " : status " + res.statusCode()
                        + ", body " + res.body());
            }
            System.out.println("koneksi dibuka  : " + koneksi.get());
            System.out.println("permintaan total: " + permintaan.get());
        } finally {
            pintu.close();
            Socket s = sambunganAktif.get();
            if (s != null) {
                s.close();
            }
            pegawai.join();
        }
    }
}

Output yang Dihasilkan:

text
permintaan 1 : status 200, body hai
permintaan 2 : status 200, body hai
permintaan 3 : status 200, body hai
koneksi dibuka  : 1
permintaan total: 3

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 33-37 (pintu.accept() di dalam while (true)): loop luar mewakili siklus hidup koneksi — setiap accept() berarti satu koneksi TCP baru, dan penghitung koneksi dinaikkan di sana. Loop dalam membaca permintaan HTTP satu per satu pada koneksi yang sama.
  2. Baris 46-50 (baris.isEmpty()): satu permintaan HTTP berakhir pada baris kosong — batas antara head dan body. Setiap baris kosong berarti satu permintaan selesai dibaca, sehingga permintaan bertambah.
  3. Baris 53-57 (balasan manual): respons ditulis mentah sesuai format HTTP — baris status, header, baris kosong, lalu body hai sepanjang tepat 3 byte sesuai Content-Length: 3. Angka yang cocok inilah kunci keep-alive: klien tahu body selesai tanpa perlu koneksi ditutup.
  4. Baris 66-74 (HttpClient.newHttpClient() lalu tiga send()): satu client mengirim tiga permintaan berurutan ke origin yang sama. Hasil akhirnya bukti yang kita cari: koneksi dibuka: 1, permintaan total: 3 — ketiga permintaan menumpang satu koneksi yang sama.

Bila setiap permintaan memakai client baru, jumlah koneksi akan sama dengan jumlah permintaan — itulah biaya tersembunyi dari kebiasaan “buat client di dalam method”. Pada beban tinggi, kebiasaan tersebut berlipat menjadi ribuan handshake, ribuan socket TIME_WAIT di sistem operasi, dan tundaan yang terasa oleh pengguna.

Satu Client untuk Banyak Thread #

HttpClient dirancang untuk dipakai bersama. Seluruh operasinya — sinkron maupun asinkron — aman dipanggil dari banyak thread sekaligus, dan pool koneksi internalnya dibagi secara otomatis: dua thread yang memanggil origin yang sama bisa menumpang satu koneksi bergilir atau memakai dua koneksi paralel sesuai kebutuhan. Program berikut membagikan satu client ke lima permintaan berurutan lalu tiga thread yang berjalan bersamaan:

import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class KlienDipakaiUlang {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/sapa/", exchange -> {
            String nama = exchange.getRequestURI().getPath().substring("/sapa/".length());
            byte[] body = ("halo " + nama).getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        server.start();

        try {
            HttpClient klien = HttpClient.newHttpClient(); // SATU instance untuk semuanya

            // Lima permintaan berurutan lewat client yang sama
            List<String> nama = List.of("Budi", "Siti", "Dewi", "Rina", "Ali");
            List<String> hasilUrut = new ArrayList<>();
            for (String n : nama) {
                HttpRequest req = HttpRequest.newBuilder()
                        .uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort()
                                + "/sapa/" + n))
                        .GET().build();
                HttpResponse<String> res = klien.send(req, HttpResponse.BodyHandlers.ofString());
                hasilUrut.add(res.body());
            }
            for (int i = 0; i < hasilUrut.size(); i++) {
                System.out.println("urutan " + (i + 1) + " : " + hasilUrut.get(i));
            }

            // Tiga thread memakai client yang sama secara bersamaan
            List<String> tugas = List.of("Budi", "Siti", "Dewi");
            List<String> hasilParalel =
                    new ArrayList<>(Collections.nCopies(tugas.size(), null));
            List<Thread> pegawai = new ArrayList<>();
            for (int i = 0; i < tugas.size(); i++) {
                int idx = i;
                Thread t = new Thread(() -> {
                    try {
                        HttpRequest req = HttpRequest.newBuilder()
                                .uri(URI.create("http://127.0.0.1:"
                                        + server.getAddress().getPort()
                                        + "/sapa/" + tugas.get(idx)))
                                .GET().build();
                        HttpResponse<String> res = klien.send(req,
                                HttpResponse.BodyHandlers.ofString());
                        hasilParalel.set(idx, res.body());
                    } catch (Exception e) {
                        hasilParalel.set(idx, "galat " + e.getClass().getSimpleName());
                    }
                });
                pegawai.add(t);
                t.start();
            }
            for (Thread t : pegawai) {
                t.join();
            }
            for (int i = 0; i < hasilParalel.size(); i++) {
                System.out.println("thread  " + (i + 1) + " : " + hasilParalel.get(i));
            }
        } finally {
            server.stop(0);
        }
    }
}

Output yang Dihasilkan:

text
urutan 1 : halo Budi
urutan 2 : halo Siti
urutan 3 : halo Dewi
urutan 4 : halo Rina
urutan 5 : halo Ali
thread  1 : halo Budi
thread  2 : halo Siti
thread  3 : halo Dewi

Dua detail desain pada program ini menjamin hasil yang terurut. Kelompok berurutan menyimpan setiap body ke hasilUrut lebih dulu, lalu mencetak semuanya setelah lima respons diterima. Kelompok paralel memakai pola yang sama ditambah Collections.nCopies sebagai kerangka list berukuran tetap — setiap thread menulis pada slot indeksnya sendiri, join() menunggu semua selesai, dan pencetakan terakhir mengikuti urutan indeks, bukan urutan kedatangan thread. Kedua pola ini adalah cara standar menjaga determinisme output pada kode konkuren.

Koneksi yang efisien kini terkelola. Satu lapisan lagi menentukan aman atau tidaknya percakapan di jaringan terbuka: enkripsi. Chapter berikutnya masuk ke TLS — handshake, sertifikat, dan truststore — beserta cara HttpClient memvalidasi identitas server sebelum percaya pada jawabannya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

HitungKoneksi.java

Unduh berkas
java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public class HitungKoneksi {
    public static void main(String[] args) throws Exception {
        // Server HTTP tulisan tangan untuk menghitung koneksi vs permintaan
        AtomicInteger koneksi = new AtomicInteger();
        AtomicInteger permintaan = new AtomicInteger();
        AtomicReference<Socket> sambunganAktif = new AtomicReference<>();

        ServerSocket pintu = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
        Thread pegawai = new Thread(() -> {
            try {
                while (true) {
                    Socket s = pintu.accept();
                    sambunganAktif.set(s);
                    koneksi.incrementAndGet();
                    try (s;
                         BufferedReader masuk = new BufferedReader(new InputStreamReader(
                                 s.getInputStream(), StandardCharsets.ISO_8859_1));
                         PrintWriter keluar = new PrintWriter(new OutputStreamWriter(
                                 s.getOutputStream(), StandardCharsets.ISO_8859_1))) {
                        while (true) {
                            String baris = masuk.readLine();
                            if (baris == null) {
                                break; // klien menutup koneksi
                            }
                            if (!baris.isEmpty()) {
                                continue; // lewati baris header permintaan
                            }
                            permintaan.incrementAndGet();
                            // Content-Length yang tepat membuat koneksi layak dipakai ulang
                            keluar.print("HTTP/1.1 200 OK\r\n");
                            keluar.print("Content-Type: text/plain\r\n");
                            keluar.print("Content-Length: 3\r\n\r\n");
                            keluar.print("hai");
                            keluar.flush();
                        }
                    } catch (IOException e) {
                        // koneksi ditutup dari luar
                    }
                }
            } catch (IOException e) {
                // ServerSocket ditutup — pegawai selesai
            }
        });
        pegawai.start();

        try {
            HttpClient klien = HttpClient.newHttpClient();
            String url = "http://127.0.0.1:" + pintu.getLocalPort() + "/";
            for (int i = 1; i <= 3; i++) {
                HttpResponse<String> res = klien.send(
                        HttpRequest.newBuilder(URI.create(url)).GET().build(),
                        HttpResponse.BodyHandlers.ofString());
                System.out.println("permintaan " + i + " : status " + res.statusCode()
                        + ", body " + res.body());
            }
            System.out.println("koneksi dibuka  : " + koneksi.get());
            System.out.println("permintaan total: " + permintaan.get());
        } finally {
            pintu.close();
            Socket s = sambunganAktif.get();
            if (s != null) {
                s.close();
            }
            pegawai.join();
        }
    }
}

KlienDipakaiUlang.java

Unduh berkas
java
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class KlienDipakaiUlang {
    public static void main(String[] args) throws Exception {
        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/sapa/", exchange -> {
            String nama = exchange.getRequestURI().getPath().substring("/sapa/".length());
            byte[] body = ("halo " + nama).getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(body);
            }
        });
        server.start();

        try {
            HttpClient klien = HttpClient.newHttpClient(); // SATU instance untuk semuanya

            // Lima permintaan berurutan lewat client yang sama
            List<String> nama = List.of("Budi", "Siti", "Dewi", "Rina", "Ali");
            List<String> hasilUrut = new ArrayList<>();
            for (String n : nama) {
                HttpRequest req = HttpRequest.newBuilder()
                        .uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort()
                                + "/sapa/" + n))
                        .GET().build();
                HttpResponse<String> res = klien.send(req, HttpResponse.BodyHandlers.ofString());
                hasilUrut.add(res.body());
            }
            for (int i = 0; i < hasilUrut.size(); i++) {
                System.out.println("urutan " + (i + 1) + " : " + hasilUrut.get(i));
            }

            // Tiga thread memakai client yang sama secara bersamaan
            List<String> tugas = List.of("Budi", "Siti", "Dewi");
            List<String> hasilParalel =
                    new ArrayList<>(Collections.nCopies(tugas.size(), null));
            List<Thread> pegawai = new ArrayList<>();
            for (int i = 0; i < tugas.size(); i++) {
                int idx = i;
                Thread t = new Thread(() -> {
                    try {
                        HttpRequest req = HttpRequest.newBuilder()
                                .uri(URI.create("http://127.0.0.1:"
                                        + server.getAddress().getPort()
                                        + "/sapa/" + tugas.get(idx)))
                                .GET().build();
                        HttpResponse<String> res = klien.send(req,
                                HttpResponse.BodyHandlers.ofString());
                        hasilParalel.set(idx, res.body());
                    } catch (Exception e) {
                        hasilParalel.set(idx, "galat " + e.getClass().getSimpleName());
                    }
                });
                pegawai.add(t);
                t.start();
            }
            for (Thread t : pegawai) {
                t.join();
            }
            for (int i = 0; i < hasilParalel.size(); i++) {
                System.out.println("thread  " + (i + 1) + " : " + hasilParalel.get(i));
            }
        } finally {
            server.stop(0);
        }
    }
}