Quick Answer

Java generics are checked by the compiler and then erased, so at runtime a list of String and a list of Integer are both just List. That is why you cannot write new T(), cannot write new T[10], and cannot test a type argument with instanceof, and why two methods differing only in type argument fail to compile. A bound such as T extends Comparable unlocks methods on T. Wildcards follow PECS: an extends wildcard for a parameter you read from, a super wildcard for one you write into.

The cast you no longer write

Before generics, every collection held Object and every read needed a cast that the compiler could not verify.

List names = new ArrayList();          // raw type
names.add("Aarav");
names.add(42);                         // compiles happily
String first = (String) names.get(1);  // ClassCastException at runtime

Generics move that failure forward to compile time. List<String> refuses the Integer at the add, and the compiler inserts the casts for you at every read.

Raw types still compile though, and that is the first real gotcha: mixing generic and raw code lets bad data in, and the crash surfaces a long way from the cause.

List<String> names = new ArrayList<>();
List raw = names;                 // no error, just a raw type
raw.add(42);                      // unchecked warning only

for (String n : names) {          // ClassCastException is thrown HERE
    System.out.println(n.length());
}

The list really does contain an Integer. The exception fires at the read, inside a loop that looks perfectly correct, because that is where the compiler placed the cast. This is why the "unchecked" warning is worth treating as an error rather than noise, and why you should never declare a raw List, Map or Set.

Writing your own generic type is mostly punctuation. The type parameters go after the class name and can then be used anywhere a type is allowed inside the class.

class Pair<A, B> {
    private final A first;
    private final B second;

    Pair(A first, B second) {
        this.first = first;
        this.second = second;
    }

    A first()  { return first; }
    B second() { return second; }
}

Pair<String, Integer> result = new Pair<>("Aarav", 78);

A generic method declares its own parameters before the return type, and can live on an ordinary non-generic class. The compiler infers the argument at the call site, so you almost never write it explicitly.

static <T> List<T> firstTwo(List<T> items) {
    return new ArrayList<>(items.subList(0, Math.min(2, items.size())));
}

List<String> top = firstTwo(List.of("Aarav", "Ishita", "Rohan"));

Bounded type parameters

An unbounded T gives you only the methods of Object: equals, hashCode, toString. A bound tells the compiler what T is at least, and unlocks that type's methods inside the body.

static <T extends Number> double sum(List<T> nums) {
    double total = 0;
    for (T n : nums) {
        total += n.doubleValue();   // allowed because of the bound
    }
    return total;
}

The bound that appears everywhere in real code looks recursive the first time you meet it. T extends Comparable<T> means "T is a type that can be compared with other Ts", which is what you need to write any sorting or maximum helper.

static <T extends Comparable<T>> T max(List<T> items) {
    if (items.isEmpty()) {
        throw new IllegalArgumentException("max of an empty list");
    }
    T best = items.get(0);
    for (T item : items) {
        if (item.compareTo(best) > 0) {
            best = item;
        }
    }
    return best;
}

Note that extends is used for interfaces too. There is no implements keyword in a type bound, which trips up people reading <T extends Comparable<T>> for the first time. Multiple bounds are joined with an ampersand, and a class bound, if any, must come first.

static <T extends Number & Comparable<T>> T largest(List<T> nums) { ... }

Bounds also let a base class return the correct subtype, which is how fluent builders keep their chaining working through inheritance.

abstract class Builder<T extends Builder<T>> {
    @SuppressWarnings("unchecked")
    protected T self() { return (T) this; }
}

The practical gotcha with bounds is that T extends Comparable<T> is stricter than it looks. If a class inherits compareTo from a parent, it implements Comparable<Parent>, not Comparable<Child>, and the compiler rejects it. The version that accepts subclasses is <T extends Comparable<? super T>>, which is exactly why the JDK's own signatures look so noisy. When your helper suddenly refuses a perfectly comparable type, that is the fix.

Wildcards: ? extends, ? super and PECS

The rule that surprises everyone: List<String> is not a List<Object>. Generic types are invariant.

List<String> names = new ArrayList<>();
List<Object> objects = names;   // compile error

That looks unnecessarily strict until you compare it with arrays, which are covariant and therefore unsafe.

Object[] arr = new String[2];   // compiles
arr[0] = 42;                    // ArrayStoreException at runtime

Arrays push the check to runtime; generics made it a compile error. If List<String> were assignable to List<Object>, someone could add an Integer through the second reference and there is no runtime check to stop them, because the type argument is gone by then.

The price of invariance is wildcards, which you need whenever a method should accept a family of types.

static double total(List<? extends Number> nums) {   // producer: you read from it
    double t = 0;
    for (Number n : nums) {
        t += n.doubleValue();
    }
    return t;
}

total(List.of(1, 2, 3));       // List<Integer> accepted
total(List.of(1.5, 2.5));      // List<Double> accepted

Inside that method you can read every element as a Number, but you cannot add anything except null. The compiler does not know whether the real list is a List<Integer> or a List<Double>, so no value is guaranteed to fit. ? super is the mirror image.

static void addRolls(List<? super Integer> sink) {
    sink.add(101);              // fine: an Integer fits any supertype-of-Integer list
    Object o = sink.get(0);     // reads come back only as Object
}

The mnemonic is PECS: Producer Extends, Consumer Super. If the parameter produces values for you to read, use ? extends. If it consumes values you hand to it, use ? super. A copy method needs both at once, which is the clearest demonstration of the rule.

static <T> void copy(List<? extends T> source, List<? super T> destination) {
    for (T item : source) {
        destination.add(item);
    }
}

Two habits worth adopting. Put wildcards on parameters, not on return types: a method returning List<? extends Number> forces every caller to carry the wildcard for no benefit. And keep the three "unknown" forms straight. A raw List means no checking at all and should never be written. List<?> means a list of some specific unknown type, which you can read as Object and cannot add to. List<Object> means a list declared to hold exactly Object, which accepts anything but which a List<String> cannot be passed to.

Type erasure: what the JVM actually sees

Generics are a compile-time feature. The compiler checks your types, inserts the casts, and then erases the type arguments. At runtime there is only List.

List<String>  a = new ArrayList<>();
List<Integer> b = new ArrayList<>();

System.out.println(a.getClass() == b.getClass());   // true
System.out.println(a.getClass().getName());         // java.util.ArrayList

An unbounded T erases to Object; T extends Number erases to Number. So <T> void print(T t) becomes void print(Object t) in the class file, and the caller's cast is inserted at the call site.

Why it was done this way is worth knowing, because it is a frequent interview follow-up. Generics arrived after Java already had a huge body of compiled libraries, and those class files had to keep running unchanged on the same JVM alongside new generic code. Erasure bought that compatibility, and the price is that the type argument is not available at runtime. Note the deliberate contrast: C++ templates are instantiated separately for each type at compile time, and C# reifies generics in the runtime, so both keep information Java throws away.

Here are the consequences, roughly in the order you will meet them.

  • instanceof cannot test a type argument. o instanceof List<String> is a compile error; only o instanceof List<?> is allowed.
  • You cannot write new T() or new T[10]. There is no T at runtime to construct.
  • Two methods that differ only in a type argument clash: void save(List<String>) and void save(List<Integer>) fail with "have the same erasure".
  • A generic class cannot have a static field of type T, because T belongs to an instance while a static field is shared by all of them.
  • catch (T e) is illegal, and a generic class cannot extend Throwable, because catch matching happens at runtime.
  • Casting to a generic type does nothing at runtime. (List<String>) obj compiles with a warning and checks only that the object is a List.

When you genuinely need the type at runtime, pass it explicitly as a class token. This is the pattern every serialisation library uses.

static <T> List<T> parseAll(List<String> rows, Class<T> type) {
    List<T> out = new ArrayList<>();
    for (String row : rows) {
        out.add(type.cast(parseOne(row)));
    }
    return out;
}

One clarification, because "erasure deletes everything" is a common overstatement. Generic signatures of fields, methods and superclasses are kept in the class file as metadata, which is how a framework can discover that a field is declared List<Student> through reflection. What is gone is the type of the individual object sitting on the heap.

Why you cannot create a generic array

T[] items = new T[10]; does not compile, and the reason is the collision between the two rules above: arrays check every store at runtime using their element type, while generics have no type left at runtime to check against.

// if this were allowed, here is what would happen
T[] items = new T[10];      // erased to new Object[10]
Object[] alias = items;
alias[0] = "hello";         // no ArrayStoreException: element type is Object
T first = items[0];         // ClassCastException, far from the real mistake

The array's own safety check would be silently useless, so the language forbids the creation instead. For the same reason new List<String>[10] is illegal.

The standard workaround, and the one the JDK itself uses inside ArrayList, is to hold an Object[] internally and cast on the way out.

class Box<T> {
    private final Object[] items;
    private int size;

    Box(int capacity) {
        this.items = new Object[capacity];
    }

    void add(T item) {
        items[size++] = item;
    }

    @SuppressWarnings("unchecked")
    T get(int i) {
        if (i >= size) throw new IndexOutOfBoundsException(i);
        return (T) items[i];
    }
}

The cast is unchecked, but it is genuinely safe here because the array is private and nothing except add can put anything into it. The suppression is a claim you are making, so keep it on the smallest possible scope and only where you can justify it.

The one thing you must not do is expose that array as a T[].

@SuppressWarnings("unchecked")
T[] toArray() {
    return (T[]) items;      // compiles, and blows up in the caller
}

String[] names = box.toArray();   // ClassCastException: Object[] is not a String[]

The exception is thrown at the assignment in the caller, not inside toArray, which makes for a memorable half hour with a debugger. This is exactly why the real collection API asks the caller for an array instead, so that the runtime element type comes from outside.

String[] names = list.toArray(new String[0]);

If you must build the array yourself, do it reflectively from a class token, which gives you a real String[] rather than an Object[] in disguise.

@SuppressWarnings("unchecked")
static <T> T[] newArray(Class<T> type, int size) {
    return (T[]) java.lang.reflect.Array.newInstance(type, size);
}

The same hole shows up with varargs. A method declared void of(T... items) actually receives an Object[] that the compiler creates, and that array can be handed out or stored with the wrong element type. That is what the "possible heap pollution" warning means, and @SafeVarargs is your promise that the method only reads from the array, never writes into it, and never lets it escape.

Frequently Asked Questions

Why can I not create an array of a generic type? Because arrays check the type of every element you store at runtime, and generics erase their type argument before runtime. A new T[10] would really be an Object[10], so the array's own store check would accept anything and the failure would surface as a ClassCastException at some unrelated read. Java forbids the creation instead, and the usual workaround is a private Object[] with an unchecked cast on the way out.
What does type erasure mean in one sentence? The compiler uses your type arguments to check the code and insert casts, then removes them, so the class file contains a plain List rather than a list of String. It was done so that pre-generics libraries and new generic code could run on the same JVM without recompiling everything. The visible consequences are that you cannot use type arguments with instanceof, cannot instantiate T, and cannot overload on type argument alone.
What is the difference between a wildcard list, a list of Object, and a raw List? A raw List switches off generic checking altogether and will let anything in, which is why it produces unchecked warnings. An unbounded wildcard list is a list of some specific but unknown type: you may read elements as Object and may not add anything except null. A list declared to hold Object is concrete, so it accepts any element, but a list of String cannot be passed where it is expected, because generics are invariant.
When do I need a super wildcard instead of an extends wildcard? Use super when the parameter is a destination you write into, for example a list you are copying results into or a Comparator that must handle a supertype. Use extends when the parameter is a source you only read from. The mnemonic is PECS, Producer Extends Consumer Super. A method that both reads and writes the same collection cannot use a wildcard at all and needs a plain type parameter.
Are Java generics the same as C++ templates? No. A C++ template is instantiated separately for every type used, so the compiler generates distinct code and the type is fully known at runtime, at the cost of larger binaries. Java compiles one version and erases the type argument, so there is no runtime type information for the argument and no code duplication. C# sits in between by reifying generics in its runtime, which is why C# can do things such as creating an array of T that Java cannot.