We've already explored PHP's questionable decisions, JavaScript's complicated relationship with logic, Python's ability to produce confidently incorrect results, and C#'s enterprise-grade surprises.
Now it's Java's turn.
Java is different.
Java is serious.
Java is strongly typed, object-oriented, carefully specified, and famously obsessed with backward compatibility.
This is a language that makes you declare what things are before you're allowed to do anything with them.
Surely, then, Java should be relatively free of surprises?
Well.
Let's talk about that.
1. String Concatenation: Apparently, + Needed a Performance Review
Let's start with something that every Java developer has written thousands of times.
String message = "Hello, " + name + "!"; Perfectly reasonable.
Now consider:
System.out.println("Total: " + 1 + 2); Output:
Total: 12 But:
System.out.println(1 + 2 + " total"); Output:
3 total Same numbers. Same operator. Different arithmetic.
Because Java evaluates these expressions from left to right, and once a string enters the expression, + becomes string concatenation.
Fine. We can live with that.
But Java has another surprise waiting.
Meet StringBuilder
Java strings are immutable.
Once created, a String cannot be modified.
So what happens when you repeatedly append characters?
String result = ""; for (int i = 0; i < 10000; i++) { result += i; } Conceptually, each iteration creates a new string containing the accumulated result plus the new value.
As the string grows, increasingly large amounts of data may need to be deal with by the jvm.
That's potentially a lot of unnecessary work.
Java developers have traditionally solved this problem with StringBuilder.
StringBuilder result = new StringBuilder(); for (int i = 0; i < 10000; i++) { result.append(i); } String finalResult = result.toString(); One mutable buffer, rather than repeatedly rebuilding an immutable string.
Much better for this kind of workload.
For years, the conventional advice was simple:
Use + for simple concatenation. Use StringBuilder when you're building strings in loops.
But Java being Java, it couldn't leave things quite that straightforward.
Plot Twist: The Compiler Was Already Helping
Before Java 9, the Java compiler typically translated ordinary string concatenation expressions into StringBuilder operations behind the scenes.
So this:
String message = "Hello, " + name + "!"; Wasn't necessarily doing the naive string-building work you might imagine.
Then Java 9 introduced a new concatenation mechanism using invokedynamic.
This allowed the runtime to select and optimize string-concatenation strategies more flexibly, without requiring the compiler to generate an explicit StringBuilder sequence.
So now we've got:
- Strings that can't be changed.
- A mutable class specifically for building strings.
- A compiler that historically generated that class automatically.
- A newer mechanism that can optimize concatenation differently.
- Performance characteristics that depend on the expression, loop structure, compiler, and runtime.
Congratulations. We've turned joining two strings into a discussion about bytecode generation.
To be fair, modern Java handles ordinary + concatenation quite well.
The real performance concern remains repeated accumulation into a growing string, particularly in loops. An explicit StringBuilder is still a sensible choice there.
The lesson: Java's simple syntax can hide considerable compiler and runtime sophistication. And performance advice from 2005 doesn't always survive contact with modern JVMs.
2. Two Integers Are Equal. Until They Aren't.
Here's one of Java's most entertaining party tricks.
Integer a = 127; Integer b = 127; System.out.println(a == b); Output:
true Okay.
Now:
Integer a = 128; Integer b = 128; System.out.println(a == b); On a typical JVM configuration:
false Wait. What?
Two integers containing the same number can compare equal or unequal depending on the number.
The trick is that Integer isn't the same thing as int.
int is a primitive value.
Integer is an object wrapper around an integer value.
And for objects, == checks reference identity, not numerical equality.
But Java caches certain Integer objects.
Specifically, Integer.valueOf() is guaranteed to cache values from -128 through 127.
Autoboxing uses this mechanism.
So when you write:
Integer a = 127; Integer b = 127; Both variables normally refer to the same cached object.
But outside the guaranteed range, Java is allowed to create separate objects.
Hence the surprising result for 128.
There's one more complication: implementations are permitted to cache additional values, so 128 isn't guaranteed to produce false everywhere.
Which makes relying on this behavior an even worse idea.
The correct comparison:
System.out.println(a.equals(b)); Or compare the underlying primitive values when appropriate.
The lesson: In Java, == can mean numerical equality or object identity depending on the types involved. And integer caching makes that distinction particularly easy to overlook.
3. Arrays.asList() Gives You a List. Terms and Conditions Apply.
Here's a perfectly ordinary bit of Java:
String[] names = {"Alice", "Bob", "Charlie"}; List<String> list = Arrays.asList(names); You now have a list.
And lists are mutable collections, right?
Let's add someone.
list.add("Dave"); Result:
java.lang.UnsupportedOperationException Excuse me?
We created a list.
We tried to add an item.
And Java objected.
Here's the explanation.
Arrays.asList() returns a fixed-size list backed by the original array.
It isn't a regular, independently resizable ArrayList.
You can replace existing elements:
list.set(0, "Zoe"); That works.
But you can't change the list's size.
No adding.
No removing.
And here's the really interesting part:
System.out.println(names[0]); Output:
Zoe Changing the list changed the original array!
Because they're backed by the same storage.
The reverse is true too. Change an element in the array, and the list reflects that change.
So Arrays.asList() gives you something that:
- Looks like a normal list.
- Lets you replace elements.
- Refuses to grow or shrink.
- Shares its underlying data with the original array.
Java also offers List.of(), which creates an unmodifiable list.
And new ArrayList<>(...), which creates a separate, resizable list.
Three ways to obtain something called a list, with three different sets of rules.
The fix when you want an independent, resizable list:
List<String> list = new ArrayList<>(Arrays.asList(names)); The lesson: In Java, knowing that something implements List doesn't tell you everything about what operations it supports.
Sometimes the interface makes a promise that the implementation politely declines to fulfill.
4. Removing an Integer Can Remove the Wrong Integer
This one is genuinely nasty.
Suppose you have a list of numbers:
List<Integer> numbers = new ArrayList<>(List.of(1, 20, 55)); Now you want to remove the number 1.
numbers.remove(1); What happens?
The list becomes:
[1, 55] You can kind of guess this one.
Our friend List has two overloaded remove() methods.
One removes an element by index.
The other removes an element by value.
Conceptually:
remove(int index) remove(Object value) And when you pass the literal integer 1, Java selects the overload accepting a primitive int.
So:
numbers.remove(1); Means:
"Remove the element at index 1."
Not:
"Remove the integer whose value is 1."
If you actually want to remove the value 1:
numbers.remove(Integer.valueOf(1)); Now Java selects the object-based overload.
Same method name.
Same apparent kind of argument.
Completely different operation.
And because both operations are perfectly valid, the compiler has absolutely nothing to complain about.
The lesson: Method overloading, autoboxing, and collection APIs can combine into a surprisingly effective footgun.
Sometimes Java's type system is so helpful it confidently does the wrong thing you explicitly asked it to do.
5. Two Decimal Numbers Can Be Equal but Not Equal
For our final example, let's involve money.
Java provides BigDecimal for decimal arithmetic where precision matters.
Unlike ordinary binary floating-point numbers, BigDecimal can represent decimal values exactly.
Very useful for financial applications.
Now consider:
BigDecimal a = new BigDecimal("2.0"); BigDecimal b = new BigDecimal("2.00"); System.out.println(a.equals(b)); Output:
false Seriously?
Two dollars is not equal to two dollars?
Now try:
System.out.println(a.compareTo(b) == 0); Output:
true So the numbers compare as equal.
But equals() says they're different.
Why?
Because BigDecimal stores both an unscaled numerical value and a scale.
2.0 and 2.00 represent the same numerical value, but they have different scales.
And BigDecimal.equals() considers scale part of equality.
compareTo() compares their numerical values instead.
That's actually defensible. Scale can matter for certain decimal calculations.
But it creates a wonderfully awkward consequence.
Consider:
Set<BigDecimal> hashSet = new HashSet<>(); hashSet.add(new BigDecimal("2.0")); hashSet.add(new BigDecimal("2.00")); System.out.println(hashSet.size()); Output:
2 Now:
Set<BigDecimal> treeSet = new TreeSet<>(); treeSet.add(new BigDecimal("2.0")); treeSet.add(new BigDecimal("2.00")); System.out.println(treeSet.size()); Output:
1 Two sets. Same values. Different answers about how many distinct elements exist.
Why?
HashSet uses equals() and hashCode() to determine uniqueness.
TreeSet uses ordering comparisons, which for BigDecimal normally means compareTo().
And BigDecimal explicitly defines those operations differently.
So Java gives you two completely legitimate definitions of uniqueness for the same numerical values.
Imagine discovering that while reconciling financial records.
The lesson: Equality, numerical comparison, and collection uniqueness aren't necessarily interchangeable. With BigDecimal, choosing the wrong one can produce very real business problems.
Java Is Predictable. You Just Need to Predict the Right Things.
Here's what makes Java interesting.
Unlike JavaScript, Java generally doesn't let values wander casually between unrelated types.
Unlike PHP, it doesn't enthusiastically reinterpret everything based on context.
And unlike Python, it tends to insist that you explain exactly what you're trying to do.
Java is explicit.
Java is disciplined.
Java is designed to make large programs easier to reason about.
And yet...
An integer can be identical to another integer until you increase it by one.
A list can refuse to let you add anything.
Removing a number can remove a completely different number.
Two decimal values can be equal according to one method and unequal according to another.
And joining strings can involve compiler transformations, runtime call sites, and an optimization discussion that lasts longer than the code itself.
None of this is random.
There are carefully documented reasons for every behavior.
But that doesn't make the surprises any less entertaining.
In fact, it makes them more interesting.
Java is a language where even the weirdness comes with a specification.
But Wait. Which Java Are We Talking About?
There's one more twist.
We've spent this article discussing Java, the programming language.
But Java is also commonly used to describe an entire development platform.
And that platform has a rather complicated family tree.
For starters, there's the JVM: the Java Virtual Machine.
The thing that executes Java bytecode.
Except there isn't just one implementation of the JVM.
There's HotSpot, the familiar JVM used by most OpenJDK distributions.
There's Eclipse OpenJ9, a separate JVM implementation with different approaches to memory management, startup performance, and garbage collection.
Then there's GraalVM, which builds on HotSpot while offering advanced compilation technology and Native Image, a way to compile Java applications into native executables that don't require a JVM at runtime.
And that's before we get into the different JDK distributions.
Oracle JDK.
Eclipse Temurin.
Amazon Corretto.
Azul Zulu.
Microsoft's Build of OpenJDK.
Those are distributions rather than entirely separate JVM implementations, but their packaging, support, licensing, and release policies can differ.
And then there's the small matter of the languages.
Because the Java Virtual Machine doesn't actually require you to write Java.
You can use Kotlin.
Scala.
Clojure.
And plenty of others.
So now we have one language, multiple JVM implementations, multiple JDK distributions, multiple languages targeting the JVM, and an alternative compilation model that can eliminate the need for a running JVM altogether.
Remember Java's original promise?
Write once, run anywhere.
Turns out the more interesting question is:
Run where, on which JVM, from which vendor, using which language?
And that's a rabbit hole worthy of its own article.
Coming next: the Java ecosystem, where the language is only the beginning of the confusion.


