var, text blocks and the HTTP client

Local type inference done responsibly, multi-line strings without escapes, and the standard HTTP client that replaced HttpURLConnection.

10 min read Modern Java: 8 to 25

Three additions between Java 10 and 15 that changed how everyday code reads and what it needs from libraries. var removes repeated type names; text blocks remove the string-concatenation ladder for SQL and JSON; HttpClient removes the reason to pull in a third-party HTTP library. None of them changes the language much; each hides a small piece of machinery worth knowing, because var is not dynamic typing, a text block's indentation is decided by an algorithm, and an HttpClient that is created per request is a connection-pool leak.

var: local variable type inference (Java 10)

java
var users = new ArrayList<User>();          // ArrayList<User>
var count = 0;                              // int
var stream = users.stream().filter(User::isActive);   // Stream<User>
for (var entry : map.entrySet()) { ... }    // Map.Entry<K, V>

The type is still static and fixed at compile time; var only asks the compiler to write it. Use it when the type is obvious from the right-hand side or when the type name is noise (Map.Entry<String, List<Order>>). Do not use it when the right-hand side hides the type (var result = service.process(input)) or when the inferred type is not what you want (var list = new ArrayList<>() infers ArrayList<Object>).

Restrictions: local variables only (not fields, parameters or return types); an initialiser is required; var x = null is illegal; var cannot infer a lambda's type (var f = x -> x fails, since a lambda has no type without a target); it is a reserved type name, not a keyword, so var var = 1 compiles.

The inferred type is the declared type of the initialiser, not its run-time class, and generic arguments are inferred the way a diamond would be: var list = List.of(1, 2) is List<Integer>, and var x = 1L is long. One useful consequence: var can hold a type you cannot write, an anonymous class with extra members or an intersection type, and a var declared from a List.of(1, "a") has the type List<Serializable & Comparable<…>> that no one would type by hand.

Text blocks (Java 15)

java
String query = """
        SELECT u.id, u.email
        FROM users u
        WHERE u.status = 'ACTIVE'
          AND u.created_at > ?
        """;
 
String json = """
        {
          "name": "%s",
          "roles": ["admin", "user"]
        }
        """.formatted(name);

Rules: opens with """ followed by a newline; incidental indentation is the common leading whitespace of all non-blank lines plus the closing delimiter, and is removed; the position of the closing """ therefore controls it (put it on its own line at the column you want as the margin); a trailing newline is included unless the closing delimiter is on the last content line; \ at the end of a line joins it with the next; \s is a space that survives stripping.

The stripping is String.stripIndent(), applied by the compiler: it finds the minimum indentation across the lines (blank lines excluded, the closing-delimiter line included), removes that many characters from each, then strips trailing whitespace from every line, which is why a text block cannot end a line with meaningful spaces unless you write \s. Escape sequences are processed after stripping, so \t and \n in the source are unaffected by it. The whole thing happens at compile time; a text block is a constant in the class file, identical to the equivalent concatenation, and """…""" can be used anywhere a string literal can, including in switch labels.

Where they earn their keep: SQL, JSON test fixtures, HTML snippets, multi-line messages. One thing they do not do is interpolate: a text block is a literal, and \{name} inside it is a compile error, since string templates were withdrawn. Pair a block with formatted() for placeholders, and keep SQL parameters as ? binds rather than formatting values in, for the same injection reason as before. formatted() (Java 15) is String.format as an instance method, and reads better after a block.

HttpClient (Java 11)

Before Java 11 the choice was HttpURLConnection (awkward) or a library (Apache HttpClient, OkHttp). java.net.http.HttpClient is a modern client with HTTP/2, async, and sane defaults:

java
HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();                                       // reuse: it holds a connection pool
 
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.example.com/users/42"))
        .timeout(Duration.ofSeconds(10))
        .header("Accept", "application/json")
        .GET()
        .build();
 
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
    User u = mapper.readValue(response.body(), User.class);
}

POST with a body:

java
HttpRequest post = HttpRequest.newBuilder()
        .uri(uri)
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json))
        .build();

Asynchronous:

java
CompletableFuture<HttpResponse<String>> future =
        client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
future.thenApply(HttpResponse::body).thenAccept(this::process);

Under the hood: what an HttpClient owns

One HttpClient holds a connection pool keyed by origin, an HTTP/2 stream multiplexer per connection, a selector thread that runs the non-blocking I/O for every request on the client, an Executor for callbacks (a cached thread pool by default, or one you supply), and an SSLContext. send is sendAsync(...).get(): even a "blocking" call is the selector thread doing the I/O and the caller parked on a future. That is why the client is meant to be built once and shared: it is thread-safe, and each instance costs a thread, a pool and a TLS session cache.

There are two timeouts and they are different. connectTimeout on the builder bounds the TCP handshake; timeout on the request bounds the whole exchange until the response headers arrive. There is no default for either — an unset request timeout waits forever for a server that accepted the connection and never answered, which in a service means a thread held forever per hung call. Body reading is not bounded by either; BodyHandlers.ofString accumulates until the server closes or the content-length is reached.

HTTP/2 is negotiated by default (ALPN over TLS, upgrade over cleartext) and falls back to 1.1. With HTTP/2, concurrent requests to one host share a single connection as multiplexed streams; with 1.1, the pool opens up to jdk.httpclient.connectionPoolSize connections. A 2xx, 4xx or 5xx are all normal responses — the client never throws on status code; only I/O and timeouts throw (HttpTimeoutException, ConnectException, IOException). Checking statusCode() is your job.

Since Java 21, HttpClient is AutoCloseable: close() waits for in-flight requests, shutdownNow() does not. On virtual threads, send parks the virtual thread rather than a platform one, so the "one thread per hung call" cost becomes one cheap stack, but the hung call is still hung.

Walkthrough: the service that ran out of file descriptors

A payment service called a partner API through a helper:

PartnerClient.javajava
public PartnerResponse charge(ChargeRequest r) throws IOException, InterruptedException {
    var request = HttpRequest.newBuilder(uri).POST(json(r)).build();
    var response = HttpClient.newHttpClient().send(request, BodyHandlers.ofString());   // new client per call
    return parse(response.body());
}
  1. Each call built a new HttpClient: a new selector thread, a new connection pool, a new TLS handshake with the partner (about 30 ms and a full round trip each).
  2. The client was never closed, and the selector thread kept a reference to it, so nothing was collected until the thread ended, which by default is after the client had been idle for a while. Under load, thousands of clients were alive at once.
  3. Each held one or more open sockets. The process hit the file-descriptor limit; new connections, including to the database, failed with Too many open files. The payment path went down because of the metrics path.
  4. The fix was one HttpClient field, built in the constructor with a connectTimeout, and a timeout on every request. p99 latency fell by the handshake cost, and the descriptor count went from thousands to a handful.
  5. The rule the team wrote: an HttpClient, like a DataSource or an ObjectMapper, is infrastructure, created once, injected everywhere.

The second finding from the same incident: there had been no request timeout, so when the partner's load balancer accepted connections and stalled, every request thread parked forever. Two problems, one line of code.

Other additions worth knowing

  • Java 9: List.of, Set.of, Map.of immutable factories; Optional.ifPresentOrElse, Optional.or; Stream.takeWhile/dropWhile/ofNullable; private interface methods; the module system (module-info.java), which most applications ignore and every library must at least tolerate.
  • Java 11: String.isBlank, strip, lines, repeat; Files.readString/writeString; Path.of; single-file java Hello.java launch.
  • Java 12–14: String.indent, transform; Collectors.teeing; helpful NullPointerException messages that name the null expression (Cannot invoke "User.getName()" because "user" is null).
  • Java 16: Stream.toList(), Stream.mapMulti.

Try it yourself

What does the block contain?

java
String s = """
      Hello
        World  \s
    End""";
System.out.println("[" + s + "]");
Answer

[ Hello\n World \nEnd]. The minimum indentation is 4 (from End, which also carries the closing delimiter), so 4 spaces come off every line: Hello keeps 2, World keeps 4. Trailing-whitespace stripping runs before escapes are processed, and at that point the line ends in the two characters \s, not in spaces, so the two spaces before it are not trailing and survive; then \s becomes a third space. Without the \s, those two spaces would have been removed. No trailing newline, because the closing """ is on the last content line.

Which infer, and to what?

(a) var a = List.of(1, 2.0); (b) var b = null; (c) var c = () -> 1; (d) var d = new Object() { int n = 3; }; d.n++; (e) var e = 1; e = "x";

Answer

(a) compiles; List<Number & Comparable<…>>, an intersection you could not write. (b) error, no type to infer. (c) error, a lambda needs a target type. (d) compiles and works: d has the anonymous class's type, so n is accessible, which is impossible with an explicit type. (e) error: e is int, var does not make it dynamic. Two of five compile, and (d) is the one people are surprised by.

Find both bugs

java
HttpResponse<String> r = HttpClient.newHttpClient().send(
        HttpRequest.newBuilder(uri).GET().build(), BodyHandlers.ofString());
return mapper.readValue(r.body(), Result.class);
Answer

A client per call (pool, selector thread and handshake each time; descriptors leak until the idle thread exits) and no timeout of any kind, so a stalled server parks the caller forever. A third: the status code is never checked, so a 503 with an HTML error page reaches readValue and throws a parsing exception that blames JSON. Share the client, set connectTimeout and timeout, branch on statusCode().

Misconceptions

  • "var makes Java dynamically typed." The type is inferred once at compile time from the initialiser's declared type and never changes.
  • "A text block's margin is the indentation in the source." It is the minimum indentation across the lines, closing delimiter included, removed by stripIndent at compile time; trailing spaces are dropped unless written \s.
  • "HttpClient.newHttpClient() is a cheap factory like HttpRequest.newBuilder()." It creates a pool, a selector thread and TLS state. One per application, or per distinct configuration.
  • "send throws on a 500." It throws only for I/O and timeouts; every status code is a normal response.
  • "There are sensible default timeouts." There are none. Unset means unbounded.

Going deeper

  • JEP 286 (var) and the OpenJDK "Style Guidelines for Local Variable Type Inference".
  • JEP 378 (text blocks) and the "Programmer's Guide to Text Blocks", which specifies the indentation algorithm with examples.
  • java.net.http package Javadoc, and the jdk.httpclient.* system properties list for pool size, keep-alive and HTTP/2 settings.
  • JEP 321 (HTTP Client) for the design goals, and the HttpClient.close() Javadoc (Java 21).
  • Helpful NPEs: JEP 358.
Progress is saved on this device and to your account when signed in.