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.
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)
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)
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:
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:
HttpRequest post = HttpRequest.newBuilder()
.uri(uri)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();Asynchronous:
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:
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());
}- 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). - 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.
- 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. - The fix was one
HttpClientfield, built in the constructor with aconnectTimeout, and atimeouton every request. p99 latency fell by the handshake cost, and the descriptor count went from thousands to a handful. - The rule the team wrote: an
HttpClient, like aDataSourceor anObjectMapper, 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.ofimmutable factories;Optional.ifPresentOrElse,Optional.or;Stream.takeWhile/dropWhile/ofNullable;privateinterface 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-filejava Hello.javalaunch. - Java 12–14:
String.indent,transform;Collectors.teeing; helpfulNullPointerExceptionmessages 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?
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
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
- "
varmakes 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
stripIndentat compile time; trailing spaces are dropped unless written\s. - "
HttpClient.newHttpClient()is a cheap factory likeHttpRequest.newBuilder()." It creates a pool, a selector thread and TLS state. One per application, or per distinct configuration. - "
sendthrows 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.httppackage Javadoc, and thejdk.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.