Skip to content

Language internals

Senior

The 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

  1. 01Glossary — terms used in this page
    • Invariant / covariant — a generic type is invariant if List<Dog> is unrelated to List<Animal>; arrays are covariant (Dog[] is an Animal[]).
    • PECS (Producer Extends, Consumer Super) — the rule for choosing between ? extends T and ? 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 (List instead of List<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.
    #c-glossary-terms-used-in-this-page
  2. 02String interning — the string pool, `intern()`, and why `==` silently breaks on Strings

    Java maintains a string pool (the “intern pool”) — a runtime-managed table of unique String instances, 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 even true, 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 instance
    

    Calling .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’s sys.intern(). The practical lesson is the same one Java developers coming from a language with value-equal == get burned by: == on String is 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.

    #c-string-interning-the-string-pool-intern-and-why-silently-bre
  3. 03Reflection and metaprogramming — inspecting and invoking code at runtime

    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 through Class<?> objects: every loaded class has exactly one associated Class instance, obtainable via someObject.getClass(), the SomeClass.class literal, or Class.forName("com.example.SomeClass"). From there, code can enumerate a class’s methods/fields/constructors, read or write field values — including private ones, via setAccessible(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 runtime NoSuchMethodException, 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 explicitly opens itself, 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.

    #c-reflection-and-metaprogramming-inspecting-and-invoking-code-
  4. 04Generic wildcards — `? extends T` vs `? super T`, and the PECS rule for choosing between them

    Java generics are invariant by default: a List<Dog> is not a List<Animal>, even though Dog is a subtype of Animal — the same soundness reason covered for Python’s list[Dog]/list[Animal] in the type-system concept: a mutable List<Animal> reference could have an unrelated Cat inserted into what’s actually backed by a Dog-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 of Animal” — you can safely read Animals out of it (covariant, read-only), but you can’t insert anything into it, not even a plain Animal, since the compiler doesn’t know whether the real underlying list is List<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 of Animal” — you can safely insert an Animal (or any of its subtypes) into it, since anything that’s a supertype-of-Animal list can legitimately hold an Animal, but reading from it only ever gives back Object, 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)
    

    src is ? extends T because copy only ever reads from it (for (T item : src)); dst is ? super T because copy only 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-typed copy couldn’t accept a List<Integer> for src and a List<Number> for dst at 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 unbounded List<?> — “a list of some unknown type,” equivalent to List<? 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’s toString().)

    #c-generic-wildcards-extends-t-vs-super-t-and-the-pecs-rule-for
  5. 05Generics and arrays — covariance, erasure, and heap pollution
    Object[] 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 mistake
    

    Consequences of erasure (see the wildcards concept above for ? extends / ? super and PECS):

    • new T(), new T[n], instanceof List<String> are illegal.
    • void f(List<String>) and void f(List<Integer>) clash: same erasure.
    • Primitives cannot be type arguments (List<int>), so boxing is involved.
    • @SafeVarargs and generic varargs can cause heap pollution.
    #c-generics-and-arrays-covariance-erasure-and-heap-pollution