What a Java variable actually holds

References, equality, and why == is not equals.

Java Is Verbose on Purpose, and the Purpose Is Other People

Java is the language programmers most enjoy complaining about while continuing to use it. The complaints are familiar: too much ceremony, too many words to say a simple thing, a culture of factories and builders and abstract providers. Every one of those complaints is accurate as a description. Almost none of them is accurate as a criticism, because they describe a trade that was made deliberately and for reasons that have held up. Java was designed for software written by large groups of people over long periods, where the person maintaining a class has never met the person who wrote it and cannot ask. In that setting, the cost of reading dominates the cost of writing by an enormous margin, and a language that makes you state things explicitly is buying something real with those extra words. The question is not whether Java is verbose. It is. The question is whether what it buys is worth the cost for the thing you are building, and for a surprising amount of software the answer is yes. The JVM is the actual product It is worth separating the language from the platform, because the platform is what has made Java durable. The Java Virtual Machine compiles to bytecode, which runs anywhere a JVM exists. More importantly, the JVM watches your program while it runs and optimises the parts that are actually hot — inlining methods, eliminating bounds checks, compiling interpreted code to native instructions, and undoing those decisions if the assumptions stop holding. This is just-in-time compilation, and it means long-running Java programs often reach speeds that a naive ahead-of-time compiler cannot, because the JVM has information a compiler never had: what the program really does with real data. The practical consequence catches people benchmarking. A Java program is slow for the first few seconds and then becomes fast. Measuring the first second tells you about the interpreter, not the program. The garbage collector is the other half. Modern collectors are concurrent and generational, tuned over decades, and they handle allocation rates that would be unthinkable to manage by hand. You pay in memory and in occasional pauses, and you get freedom from an entire class of memory bug. Static typing, checked at compile time, and what that is worth Every variable has a type known before the program runs, and the compiler refuses code where the types do not line up. The value is not that this catches clever bugs — it mostly does not. The value is that it catches the dull ones, in bulk, instantly, without a test being written. A renamed method, a changed signature, a field that no longer exists, an argument in the wrong position: all of these fail to compile, which in a codebase of several hundred thousand lines is the difference between a refactor being feasible and being a six-week risk. It is also what makes the tooling possible. Java's refactoring tools are genuinely reliable — rename a method across a million lines and it works — and they are reliable precisely because the compiler can prove what refers to what. Everything lives in a class, and the consequences of that Java has no free functions. Every method belongs to a class, which is the source of a good deal of the ceremony people object to, including the famous requirement that a program's entry point be a static method inside a class. Recent versions have softened the edges, and the language now has records, which remove most of the boilerplate from classes that mostly carry data: public record Order(String id, BigDecimal total, Instant placedAt) {} That one line produces a constructor, accessors, equals, hashCode and toString, all correct. The equivalent class written by hand was sixty lines, and the equals and hashCode were wrong often enough to be a recognised interview question. Records, sealed interfaces, switch expressions, pattern matching, text blocks and var for local inference have collectively changed how modern Java reads. Code written today is substantially less verbose than the code that formed the language's reputation, and the gap continues to close. Interfaces, and programming against them An interface is a contract: method signatures with no implementation. A class declares that it satisfies one, and callers can depend on the contract rather than on any particular implementation. public interface PaymentGateway { Receipt charge(Money amount, Card card); } Code that accepts a PaymentGateway works with the real one in production and a fake one in tests, with no change and no mocking framework required. This is the mechanism behind dependency injection, and it is the single most important design habit in Java: depend on interfaces, accept dependencies through the constructor, and let something else decide which implementation gets passed in. A class that constructs its own dependencies is a class that cannot be tested in isolation and cannot be reconfigured. Most of what a dependency injection framework does is automate wiring you could do by hand; the discipline is independent of the framework. Checked exceptions, the experiment nobody repeated Java is nearly alone in having checked exceptions: failures that the compiler forces you to either catch or declare. The intent was sound. A method that can fail should say so, and the caller should be made to decide. In practice two things went wrong. Developers wrapped everything in a catch block that logged and continued, which is worse than not catching at all. And the declarations propagated up through every layer, so a change deep in the stack rippled through signatures that had no interest in the failure. No major language since has adopted the idea, which is about as clear a verdict as language design produces. The pragmatic modern style is to use unchecked exceptions for programming errors and genuinely unrecoverable conditions, reserve checked ones for failures a caller can realistically handle, and never write an empty catch block. Collections, and picking from the framework Java's collections framework is large, consistent and worth learning properly because the choice has real consequences. ArrayList is backed by an array: fast indexed access, cheap appends, expensive insertion at the front. LinkedList is the opposite and is almost never the right answer in practice, because walking pointers defeats the CPU cache badly enough to lose even where the theory favours it. HashMap gives constant-time lookup with no ordering. LinkedHashMap preserves insertion order. TreeMap keeps keys sorted and costs logarithmic time. HashSet, LinkedHashSet and TreeSet mirror the same three trade-offs. The habit worth forming is the same as in any language: when a loop searches a list inside another loop, a map or a set turns the inner scan into a lookup. In Java the fix is one declaration. Streams, and when they help The streams API brought functional-style data processing to Java. List<String> emails = users.stream() .filter(User::isActive) .map(User::getEmail) .sorted() .toList(); That reads as a description of the result. Streams are lazy, so nothing happens until a terminal operation runs, and they can be parallelised by changing one word. The honest caveats: parallel streams are frequently slower than sequential ones for small collections, because the coordination costs more than the work saved. Debugging a long stream is harder than stepping through a loop. And a stream that grows past four or five operations is usually clearer as a loop with a name on each step. Use them where they describe a transformation; abandon them where they describe a procedure. null, and the mistake its inventor apologised for Any reference in Java can be null, and dereferencing one produces the NullPointerException that every Java developer has met. Optional was introduced as a better way to express "there may be no value here" in a return type. It helps when used for that purpose and hurts when used as a field type or a parameter, where it adds a wrapper without adding information. The more effective habits are structural: return empty collections rather than null, validate arguments at the boundary and fail fast with a clear message, and prefer designs where a half-built object cannot exist. Helpful NullPointerException messages in recent versions now name the exact expression that was null, which removes most of the guesswork that made them so frustrating. Concurrency, and the parts to reach for first Java has real threads with shared memory, which means real data races. The language provides synchronized and volatile at the low level, and most code should not use either. The higher-level tools in java.util.concurrent are almost always the right answer: ExecutorService for running tasks on a pool rather than managing threads, ConcurrentHashMap for shared state, AtomicInteger and friends for counters, and CompletableFuture for composing asynchronous work. Virtual threads, added recently, change the economics significantly. A traditional thread costs about a megabyte of stack, so a server could hold thousands. A virtual thread costs very little, so millions are feasible — which means the straightforward blocking style of code, one thread per request, becomes viable again at scale. That is a genuine simplification: the reason people adopted callback-based and reactive styles was thread cost, and if the cost disappears so does much of the motivation. The build is part of the language in practice Maven and Gradle manage dependencies, compilation, testing and packaging. They are not optional in any real project, and the dependency tree they produce is worth understanding — transitive dependencies mean your application contains libraries you never chose, and a vulnerability in one of them is your problem. Running a dependency audit is a five-minute task that most teams do not do, and it is the single highest-value security habit available in the ecosystem. Where Java is the right answer Java suits systems that are large, long-lived and maintained by teams. Backend services, enterprise integration, Android, data infrastructure, anything where the code will outlive the people who wrote it. It is a poor fit for short scripts, for quick prototypes where ceremony outweighs the benefit, and for anything with a hard start-up time requirement, since JVM warm-up is real. Native compilation projects have improved that considerably, at the cost of giving up some dynamic features. The honest summary is that Java optimises for the long run. On a weekend project that is overhead. On a system that five people will maintain for eight years, it is exactly the right bet, and the verbosity people complain about is the price of the explicitness that makes the eighth year survivable. Generics, erasure, and the thing that surprises people Java added generics to let collections carry a type, so a list of strings is distinct from a list of integers and the compiler enforces it. The implementation detail worth knowing is erasure: the type parameter exists at compile time and is removed before the program runs. At run time a list of strings and a list of integers are the same class. This was done for backward compatibility, so that code written before generics kept working, and it has consequences that look arbitrary until you know the reason. You cannot create an array of a generic type. You cannot ask whether an object is a list of strings, only whether it is a list. Two methods differing only in their type parameter clash, because after erasure they have the same signature. And a generic type can be defeated by older code passing in whatever it likes, which is why you occasionally see an unchecked warning that cannot be fixed, only suppressed. The practical advice is to use generics on collections and your own container types, keep the type parameters few, and resist wildcards until a concrete problem demands them. Code with three bounded wildcards in a signature is usually solving a problem that could have been avoided. Equality, hashing, and the contract people break Two methods decide whether Java thinks objects are the same, and they have a contract that must be honoured together. equals defines equality. hashCode returns a number used to place the object in a hash bucket. The contract: if two objects are equal, they must return the same hash code. The converse is not required — unequal objects may collide. Break it and the failure is subtle rather than loud. An object stored in a hash map becomes unfindable, because the lookup computes a hash, goes to that bucket, and the object is in a different one. The map still contains it; you simply cannot get it out. No exception is raised. The related trap is mutability. Change a field used in hashCode after the object is in a map and it is lost in the same way. This is the practical argument for immutable keys, and the reason records, which generate both methods correctly from their components, are such a useful addition. What to practise

  1. Convert a data-carrying class to a record and count the lines you deleted.
  2. Take a class that constructs its own dependencies and change it to accept them through the constructor. Then write a test for it without a mocking framework.
  3. Replace a nested loop that searches a list with a HashMap lookup and measure both at realistic size.
  4. Write the same transformation as a loop and as a stream, and decide honestly which you would rather maintain.
  5. Run a dependency audit on an existing project. The result is usually sobering and always useful. Java rewards the developer who thinks about the reader. Its defaults push you toward explicit contracts, stated types and visible dependencies — and while that is more to type, it is considerably less to work out later.

java references equals and hashcode records sealed types switch expressions streams concurrency virtual threads memory leaks