Modern Java in Production
ID
Lesson 6 of 9 · 12m

The small costs that add up

String concatenation in a loop, what text blocks do with your indentation, where var stops working, and what the garbage collector is actually optimising for.

None of this is exotic. It is the set of small facts that separate code that survives a load test from code that reads identically and does not.

+= on a String inside a loop

String out = "";
for (String s : lines) {
out += s; // a new String every iteration
}

Strings are immutable, so each += allocates a new one and copies everything so far. Over n lines that is quadratic work and n discarded objects. The compiler optimises concatenation within one expression; it cannot hoist a builder out of a loop it cannot see the shape of.

StringBuilder out = new StringBuilder();
for (String s : lines) out.append(s);
String result = out.toString();

StringBuilder grows one buffer. Same output, linear work. Use String.join or Collectors.joining when you are just gluing a collection together — clearer, and it does the same thing underneath.

The corollary: a + b + c in one expression is fine. It is the loop that hurts.

Text blocks and incidental indentation

String query = """
SELECT id, status
FROM orders
WHERE tenant = ?
""";

The compiler strips the common leading whitespace — the minimum indentation across all lines, including the closing delimiter’s line. So the block above yields lines with no leading spaces, and the indentation you used to line it up with surrounding code costs nothing.

Two consequences worth knowing. The closing """ participates in that calculation: putting it further left than the content adds indentation back to every line. And a trailing newline is included unless you end the last line with \.

Where var stops

var infers from the initialiser, so it needs one, and it needs the type to be denotable:

var count = 0; // fine
var list = new ArrayList<String>(); // fine
var x; // no initialiser — will not compile
var n = null; // nothing to infer
void f(var arg) {} // not for parameters
class C { var field = 1; } // not for fields
var[] arr = {1, 2}; // not for array types

It is a local-variable feature: locals, for indices, try-with-resources variables, and lambda parameters (all of them or none). Not fields, not parameters, not return types — those are the ones other people read to understand your code, which is why the language refuses.

What the collector is for

G1 is a throughput-and-pause-balance collector: it aims to keep pauses under a target while collecting concurrently, and it will happily use more CPU and heap to do so. It is the sensible default for a service.

What that means in practice: the number to watch is not “how much memory is used” but pause time and allocation rate. A service allocating hard enough to keep G1 busy will show as CPU spent in GC threads, not as an out-of-memory. And an OutOfMemoryError in a long-running service is almost never a heap that is too small — it is something retaining objects that should have been garbage, and the fix is a heap dump, not -Xmx.

ZGC is the alternative when the pause target really is single-digit milliseconds; it trades more CPU and memory overhead for that. Do not switch collectors on a hunch — measure the pause distribution first, because the answer is usually that GC was not the problem.

And the one that is not about performance at all

List.of("a", "b") returns an immutable list, not an ArrayList. Calling add on it throws UnsupportedOperationException at runtime, with a stack trace pointing at code that looks perfectly reasonable. When something needs to be mutable, say so: new ArrayList<>(List.of("a", "b")).


The measurements behind these, and the ones that turned out not to matter: Modern Java Production Patterns.