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; // finevar list = new ArrayList<String>(); // fine
var x; // no initialiser — will not compilevar n = null; // nothing to infervoid f(var arg) {} // not for parametersclass C { var field = 1; } // not for fieldsvar[] arr = {1, 2}; // not for array typesIt 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.
Discussion
Loading comments…