Generics let you write a class or method that works with any type while still catching type errors at compile time - you have already been using them every time you write List<String>.

A generic class

public class Box<T> {
    private T content;
    public void set(T content) { this.content = content; }
    public T get() { return content; }
}

Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String value = stringBox.get(); // no casting needed

Box<Integer> intBox = new Box<>();
intBox.set(42);

T is a placeholder for "whatever type you specify when you use this class" - the compiler enforces that a Box<String> can never accidentally hold an Integer.

Life before generics

Older Java code used Object and manual casting, which pushed type errors to runtime instead of compile time:

Object obj = "Hello";
String s = (String) obj; // works, but crashes at runtime if obj wasn't actually a String

Generic methods

public static <T> T firstElement(List<T> list) {
    return list.get(0);
}

String first = firstElement(names); // T is inferred as String here

Bounded type parameters

You can restrict a generic type to only accept types that extend a particular class or interface:

public static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {
        total += n.doubleValue();
    }
    return total;
}

This accepts a List<Integer>, List<Double>, or any other numeric list, while still rejecting a List<String> at compile time.