Language internals
SeniorThe string pool and interning, reflection & metaprogramming, and generics — bounded wildcards and PECS, array covariance, type erasure, and heap pollution.
Created 29 September 2026 · Updated 23 October 2026
5 concepts · detail hidden
01Glossary — terms used in this page
#c-glossary-terms-used-in-this-page- Invariant / covariant — a generic type is invariant if
List<Dog>is unrelated toList<Animal>; arrays are covariant (Dog[]is anAnimal[]). - PECS (Producer Extends, Consumer Super) — the rule for choosing between
? extends Tand? super T. - Type erasure — removal of generic type arguments at compile time, so the runtime sees only the raw class.
- Raw type — a generic class used without type arguments (
Listinstead ofList<String>), which switches off generic checking. - Heap pollution — a variable of a parameterised type referring to an object that is not of that type, usually via raw types or generic varargs.
- JPMS (Java Platform Module System) — the Java 9 module system that restricts reflective access across module boundaries.
- Invariant / covariant — a generic type is invariant if
02String interning — the string pool, `intern()`, and why `==` silently breaks on Strings
#c-string-interning-the-string-pool-intern-and-why-silently-breJava maintains a string pool (the “intern pool”) — a runtime-managed table of unique
Stringinstances, living on the regular heap since Java 7 (it lived in PermGen before that). Every String literal ("hello") is automatically interned at class-load time, so every occurrence of the same literal text — even across different classes — refers to the exact same object;"a" + "b" == "ab"is eventrue, because the compiler constant-folds that concatenation into a single literal at compile time. Constructing a String at runtime instead —new String("hello"), or concatenating anything that isn’t a compile-time constant — allocates a distinct object on the heap outside the pool, even though it’s.equals()-equal to the interned version.String a = "hello"; String b = "hello"; String c = new String("hello"); a == b // true — same interned literal from the string pool a == c // false — c is a separate heap object outside the pool a.equals(c) // true — same content a == c.intern() // true — intern() returns the canonical pooled instanceCalling
.intern()explicitly on any String looks it up in the pool (inserting it if not already present) and returns the single canonical instance for that content — deliberately deduping large volumes of repeated-content strings to save memory, exactly the technique and tradeoff behind Python’ssys.intern(). The practical lesson is the same one Java developers coming from a language with value-equal==get burned by:==onStringis a plain identity comparison, not a value comparison, so it can appear to “work” purely by coincidence on literals (which happen to share pool identity) and then silently break the moment a String is built at runtime instead — always use.equals()for content comparison, and reserve==for the rare case identity is genuinely what’s being asked.03Reflection and metaprogramming — inspecting and invoking code at runtime
#c-reflection-and-metaprogramming-inspecting-and-invoking-code-Reflection is the ability of running code to inspect and manipulate its own structure — classes, methods, fields, constructors — at runtime rather than everything being fixed and known at compile time. Java’s reflection API (
java.lang.reflect) is entered throughClass<?>objects: every loaded class has exactly one associatedClassinstance, obtainable viasomeObject.getClass(), theSomeClass.classliteral, orClass.forName("com.example.SomeClass"). From there, code can enumerate a class’s methods/fields/constructors, read or write field values — includingprivateones, viasetAccessible(true), which explicitly bypasses the normal access-control check — and invoke methods dynamically by name rather than through a statically-known call site.import java.lang.reflect.Method; class Greeter { private String greet(String name) { return "Hello, " + name; } } Class<?> clazz = Greeter.class; Method method = clazz.getDeclaredMethod("greet", String.class); method.setAccessible(true); // bypass the private access check Object result = method.invoke(new Greeter(), "World"); System.out.println(result); // "Hello, World"Real production uses lean almost entirely on this same capability: dependency-injection frameworks (Spring) constructing and wiring objects whose concrete types aren’t known until runtime, from configuration or annotations; ORMs (Hibernate) mapping database columns directly onto private fields without requiring public setters; test frameworks (JUnit) discovering and invoking
@Test-annotated methods by name; serialization libraries (Jackson, Gson) reading and writing arbitrary object graphs generically, field by field. The real costs are equally concrete: reflective calls are measurably slower than a direct call (no JIT inlining/devirtualization the way an ordinary call site gets, plus real lookup/access-check overhead on every invocation); reflection defeats a lot of what the compiler would otherwise catch (a typo’d method name surfaces as a runtimeNoSuchMethodException, not a compile error); and it can quietly break encapsulation in ways that make refactoring dangerous, since renaming a “private” field gives no compiler warning about reflective code elsewhere that still references it by string name. Since Java 9, the module system (JPMS) pushes back on this directly — reflective access into a module’s internals is restricted by default unless that module explicitlyopensitself, specifically to stop reflection from reaching as freely into “private” implementation details across an entire classpath as it previously could.Metaprogramming beyond raw reflection shows up in two other common forms: annotations processed at compile time by an annotation processor (Lombok generating boilerplate getters/setters/constructors, Dagger generating dependency-injection wiring code) let a build-time tool alter or generate code from declarative markers, rather than either hand-writing it or reaching for fully dynamic runtime reflection; and dynamic proxies (
java.lang.reflect.Proxy) let code generate, at runtime, an object implementing a given set of interfaces whose every method call is routed through a single handler — the mechanism behind much of what a framework-generated wrapper object actually is (a lazy-loading proxy, or an AOP-style logging/transaction wrapper) without hand-writing a dedicated wrapper class per interface.04Generic wildcards — `? extends T` vs `? super T`, and the PECS rule for choosing between them
#c-generic-wildcards-extends-t-vs-super-t-and-the-pecs-rule-forJava generics are invariant by default: a
List<Dog>is not aList<Animal>, even thoughDogis a subtype ofAnimal— the same soundness reason covered for Python’slist[Dog]/list[Animal]in the type-system concept: a mutableList<Animal>reference could have an unrelatedCatinserted into what’s actually backed by aDog-only list underneath. Bounded wildcards let a specific call site opt into a narrower, genuinely safe form of variance without making the whole generic type variant.List<? extends Animal>means “a list of some unknown, specific subtype ofAnimal” — you can safely readAnimals out of it (covariant, read-only), but you can’t insert anything into it, not even a plainAnimal, since the compiler doesn’t know whether the real underlying list isList<Dog>,List<Cat>, or something else, and any insert could violate that hidden, more specific type.List<? super Animal>means “a list of some unknown supertype ofAnimal” — you can safely insert anAnimal(or any of its subtypes) into it, since anything that’s a supertype-of-Animallist can legitimately hold anAnimal, but reading from it only ever gives backObject, since that’s the only type the compiler can guarantee about its elements.The standard mnemonic for choosing between them is PECS — Producer Extends, Consumer Super: if a parameter is a data source your method only reads from (it produces values out to you), use
? extends T; if it’s a data sink your method only writes into (it consumes values from you), use? super T.// copies every element from src into dst static <T> void copy(List<? extends T> src, List<? super T> dst) { for (T item : src) { dst.add(item); } } List<Integer> ints = List.of(1, 2, 3); List<Number> nums = new ArrayList<>(); copy(ints, nums); // src produces Integers (read-only), dst consumes as Numbers (write-only)srcis? extends Tbecausecopyonly ever reads from it (for (T item : src));dstis? super Tbecausecopyonly ever writes into it (dst.add(item)). This is exactly the shape the standard library itself uses —Collections.copy(List<? super T> dest, List<? extends T> src)is the same method. Without bounded wildcards, a single generically-typedcopycouldn’t accept aList<Integer>forsrcand aList<Number>fordstat all, precisely because of the invariance described above — wildcards are the mechanism that lets a generic method stay usable across related-but-not-identical type arguments while the compiler still verifies every individual read and write is actually type-safe. (An unboundedList<?>— “a list of some unknown type,” equivalent toList<? extends Object>— is the right choice for a method that doesn’t care about the element type at all, e.g. one that only calls.size()or prints each element’stoString().)05Generics and arrays — covariance, erasure, and heap pollution
#c-generics-and-arrays-covariance-erasure-and-heap-pollutionObject[] objs = new String[1]; objs[0] = 42; // compiles; ArrayStoreException at runtime (arrays are covariant) List<Object> lst = new ArrayList<String>(); // compile error (generics are invariant)Because generic arguments are erased, the JVM cannot check them at runtime:
List<String> strings = new ArrayList<>(); List raw = strings; // raw type: unchecked warning raw.add(123); // allowed at runtime String s = strings.get(0); // ClassCastException here, far from the real mistakeConsequences of erasure (see the wildcards concept above for
? extends/? superand PECS):new T(),new T[n],instanceof List<String>are illegal.void f(List<String>)andvoid f(List<Integer>)clash: same erasure.- Primitives cannot be type arguments (
List<int>), so boxing is involved. @SafeVarargsand generic varargs can cause heap pollution.