Plan de estudio Java 2026
Módulo 01 crítico Días 1–3 ≈ 10 h de estudio activo

Java Core y POO con criterio

Todo lo que Spring esconde sigue siendo Java. Este módulo es el más largo del plan a propósito: cubre lo que se pregunta en el 90 % de las entrevistas técnicas y lo que provoca el 90 % de los bugs en producción. No es un repaso de sintaxis, es un módulo sobre criterio: por qué String es inmutable y qué te compra esa decisión, qué ocurre exactamente cuando llamas a map.put(...), cuándo la herencia es un error, por qué double no vale para dinero, qué hace el JIT mientras tu benchmark te miente y cómo se lee un volcado de memoria. Base: Java 21 LTS, con lo relevante de Java 25.

Progreso de este módulo0 / 0
Cómo leer este módulo: las secciones 1 a 5 son los cimientos y no se saltan; si no entiendes el paso por valor de referencias o el contrato de equals, todo lo demás se convierte en memorización. Las secciones 6 a 10 (genéricos, colecciones, Optional, funcional, streams) son las que más aparecen en las pruebas de código. Las 11 y 12 (excepciones, E/S y fechas) son las que separan un prototipo de un servicio que aguanta. La 13 (la JVM por dentro) es la que te distingue en una entrevista senior. Las 14 y 15 son el criterio de diseño. Y el cierre —errores, preguntas, ejercicios y autoevaluación— es lo que conviertes en práctica el mismo día.
Estudio activo, no lectura pasiva: abre jshell en una terminal y ejecuta cada bloque de código de este módulo, incluidos los que están marcados como incorrectos. Un ConcurrentModificationException que has provocado tú a las 10 de la mañana no se olvida; uno que has leído, sí. Cada sección tiene ejemplos completos y compilables por ese motivo.

1 · Cómo funciona Java: del fuente a la JVM

Casi nadie que lleve tres años escribiendo Java sabe explicar con precisión qué pasa entre pulsar “ejecutar” y ver un println en la consola. Y es una de las primeras preguntas de cualquier entrevista, porque la respuesta revela si entiendes la plataforma o solo el lenguaje.

1.1 JDK, JRE y JVM: tres cosas distintas

La confusión clásica. Son tres capas concéntricas, cada una dentro de la anterior:

PiezaQué esQué contieneCuándo la necesitas
JVM
Java Virtual Machine
Una máquina abstracta que ejecuta bytecode. Es una especificación (la JVMS), no un programa concreto. Cargador de clases, verificador, intérprete, compilador JIT, gestor de memoria y recolector de basura. Siempre: es lo que corre tu programa. HotSpot es la implementación de referencia; OpenJ9 y GraalVM son otras.
JRE
Java Runtime Environment
La JVM más las bibliotecas estándar necesarias para ejecutar. JVM + java.base, java.sql, java.net.http… (los módulos del JDK). Solo para ejecutar. Desde Java 11 ya no se distribuye por separado: se genera a medida con jlink.
JDK
Java Development Kit
El JRE más las herramientas de desarrollo. JRE + javac, javadoc, jshell, javap, jcmd, jlink, jpackage, jfr Para compilar, diagnosticar y empaquetar. Es lo que instalas en tu máquina y en la imagen de CI.

Y una cuarta pieza que ya no puedes ignorar en 2026: la distribución. “Java” no lo distribuye una sola empresa. El código fuente es OpenJDK; encima hay builds con distinta política de soporte:

DistribuciónQuién la haceLicencia y soporteNota práctica
Eclipse TemurinAdoptiumGPLv2+CE, gratis, actualizaciones LTS durante añosLa opción por defecto sensata para desarrollo y producción. Es la que verás en la mayoría de Dockerfile.
Oracle JDKOracleNFTC: gratis para cualquier uso durante un año tras la siguiente versión; luego, comercialCuidado: en empresa suele requerir contrato. Es la causa número uno de auditorías de licencias sorpresa.
Amazon CorrettoAWSGratis, con soporte a largo plazoBien integrada en AWS (Lambda, Elastic Beanstalk).
Azul Zulu / ZingAzulZulu gratis; Zing comercial (C4, sin pausas)Zing interesa si tienes requisitos de latencia extremos.
Red Hat / Microsoft build of OpenJDKRed Hat / MicrosoftGratis, soporte ligado a sus plataformasHabituales en OpenShift y Azure respectivamente.
GraalVMOracle / comunidadCommunity gratisAñade el compilador Graal y native-image. Ver el módulo 11.
Gestiona versiones, no instaladores. Vas a necesitar 17, 21 y 25 a la vez. Usa SDKMAN! en Linux y macOS o winget/scoop en Windows. En un proyecto, declara la versión en el pom.xml y en un fichero .sdkmanrc: así el JDK deja de ser “lo que tenga instalado cada uno”.
# Instalar y cambiar de JDK sin pelearte con el PATH
curl -s "https://get.sdkman.io" | bash
sdk list java                       # todas las versiones y distribuciones disponibles
sdk install java 21.0.5-tem         # Temurin 21 LTS
sdk install java 25-tem             # Java 25
sdk use java 21.0.5-tem             # solo en esta terminal
sdk default java 21.0.5-tem         # por defecto en el sistema

# Qué tengo delante exactamente
java -version                       # versión del runtime (a stderr, ojo al pipe)
javac -version                      # versión del compilador (pueden diferir)
java -XshowSettings:properties -version 2>&1 | grep -E 'java.home|version'

1.2 De .java a .class: qué hace javac y qué no

Java es un lenguaje compilado a un formato intermedio e interpretado (y luego recompilado) en tiempo de ejecución. Ese doble paso es la razón del famoso “write once, run anywhere” y también de que el rendimiento de Java sea contraintuitivo: arranca lento y se vuelve rápido.

  Hola.java  ──javac──▶  Hola.class  ──java──▶  ┌──────────── JVM ────────────┐
  (fuente,               (bytecode,             │ 1. ClassLoader: lee bytes   │
   texto UTF-8)           binario               │ 2. Verificador: valida      │
                          portable)             │ 3. Enlazado + inicialización│
                                                │ 4. Intérprete ejecuta       │
                                                │ 5. JIT compila lo caliente  │
                                                │    a código máquina nativo  │
                                                └─────────────────────────────┘

Lo que javac hace, y conviene saber porque explica muchas sorpresas:

Transformación del compiladorQué escribesQué queda en el .class
Inferencia de tipos con varvar lista = new ArrayList<String>();ArrayList<String> explícito. var no existe en el bytecode.
AutoboxingInteger i = 5;Integer.valueOf(5)
Concatenación de cadenas"a" + x + "b"invokedynamic a StringConcatFactory (Java 9+); antes, StringBuilder.
Constantes static finalstatic final int MAX = 100;El valor 100 se copia en cada punto de uso (constant folding).
for-eachfor (String s : lista)Un bucle con Iterator; sobre arrays, un bucle con índice.
GenéricosList<String>List + casts insertados. Ver la sección 6.
Lambdasx -> x * 2Un método sintético privado + invokedynamic a LambdaMetafactory.
recordrecord P(int x) {}Clase final con campos privados, accesores, equals, hashCode y toString.
Clases internas no estáticasclass Interna { }Clase aparte con una referencia sintética this$0 al exterior.
Switch sobre Stringswitch (s) { case "a" -> …}Dos switch: uno sobre hashCode() y otro con equals.

Lo que javac no hace: optimizar. Casi nada. No desenrolla bucles, no hace inlining, no elimina código muerto más allá de lo trivial. Toda la optimización real ocurre en el JIT, en tiempo de ejecución, con información que el compilador no tiene (qué ramas se toman de verdad, qué tipos llegan). Por eso no tiene sentido “optimizar a mano” pensando en el compilador: piensa en el JIT, y mide (sección 13).

La trampa de las constantes: si una biblioteca declara public static final int VERSION = 3; y tú compilas contra ella, el 3 queda copiado en tu .class. Si actualizas el JAR a una versión donde vale 4 y no recompilas tu código, tu programa seguirá diciendo 3, sin ningún error. Es un bug real y desconcertante. Solución: si el valor puede cambiar, expónlo con un método (static int version()), no como constante de compilación.

1.3 Leer bytecode con javap: el mejor detector de mentiras

javap viene con el JDK y desmonta un .class. Es la herramienta que zanja discusiones: en lugar de opinar sobre qué hace el compilador, lo miras. Ejemplo canónico, la concatenación en un bucle:

public class Concat {
    // Versión "ingenua": ¿crea un StringBuilder por iteración?
    static String malo(String[] partes) {
        String r = "";
        for (String p : partes) r += p;
        return r;
    }

    // Versión explícita
    static String bueno(String[] partes) {
        StringBuilder sb = new StringBuilder();
        for (String p : partes) sb.append(p);
        return sb.toString();
    }
}
javac Concat.java
javap -c -p Concat.class          # -c: bytecode; -p: incluye miembros privados
  static java.lang.String malo(java.lang.String[]);
    Code:
       0: ldc           #7    // String   (la cadena vacía)
       2: astore_1
       3: aload_0
       4: astore_2
       ...
      13: aload_1
      14: aload_3
      15: invokedynamic #9,  0  // makeConcatWithConstants:(String;String;)String;
      20: astore_1              //  ^^^ DENTRO del bucle: una concatenación por vuelta
      ...
      27: goto          9

  static java.lang.String bueno(java.lang.String[]);
    Code:
       0: new           #12   // class java/lang/StringBuilder   ← UNA sola vez
       3: dup
       4: invokespecial #14   // StringBuilder."<init>":()V
       ...
      18: invokevirtual #15   // StringBuilder.append:(String;)StringBuilder;
      ...
      31: invokevirtual #19   // StringBuilder.toString:()String;

Ahí está la prueba: en malo, la llamada a makeConcatWithConstants está dentro del bucle. Cada iteración construye una cadena nueva copiando todo lo anterior: coste cuadrático, O(n²). En bueno hay un único StringBuilder que crece de forma amortizada. El compilador no convierte uno en otro, por mucho que se repita el mito.

Instrucción de bytecodeQué significaCuándo la verás
invokevirtualLlamada a método de instancia con despacho dinámico.Lo normal: obj.metodo(). Es la base del polimorfismo.
invokestaticLlamada a método static, resuelta en compilación.Integer.valueOf(...), Math.max(...).
invokespecialConstructores, super.metodo() y métodos private: sin despacho dinámico.new, <init>.
invokeinterfaceComo invokevirtual pero sobre una interfaz.lista.add(...) cuando la variable es List.
invokedynamicEl sitio de llamada se enlaza la primera vez, en tiempo de ejecución.Lambdas, referencias a métodos, concatenación de cadenas, pattern matching.
checkcastComprobación de tipo en ejecución.Los que inserta el compilador por el borrado de genéricos.
getfield / putfieldLeer o escribir un campo de instancia.Acceso a atributos.
ldcCargar una constante del pool de constantes.Literales de cadena, números grandes, referencias a clases.
# Otras vistas útiles de javap
javap java.lang.String              # firma pública: la API sin abrir la documentación
javap -p -c -v MiClase.class        # -v: pool de constantes, versión del formato, atributos
javap -v MiClase.class | grep major # 65 = Java 21, 69 = Java 25 (major = versión + 44)
javap -s MiClase.class              # descriptores JNI de los tipos (útil con reflexión)
El número mágico de la versión. Cuando veas UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0, la traducción es: “el .class se compiló con Java 21 y lo intentas ejecutar con Java 17”. La tabla es major = versión + 44: 52→Java 8, 55→11, 61→17, 65→21, 69→25.

1.4 java: cómo se carga y se arranca un programa

Al ejecutar java -cp . Hola, la JVM hace esta secuencia. Sabérsela te ahorra horas depurando NoClassDefFoundError y ExceptionInInitializerError:

FaseQué ocurreError típico si falla
1. CargaEl class loader localiza Hola.class en el classpath o el module path y lee sus bytes.ClassNotFoundException (búsqueda explícita), NoClassDefFoundError (estaba al compilar y falta al ejecutar).
2. VerificaciónSe comprueba que el bytecode es coherente: la pila cuadra, no hay saltos ilegales, los tipos son válidos. Es lo que hace la JVM segura frente a bytecode manipulado.VerifyError (casi siempre, un generador de bytecode roto o un agent mal escrito).
3. PreparaciónSe reserva memoria para los campos static con sus valores por defecto (0, false, null).
4. ResoluciónLas referencias simbólicas del pool de constantes se resuelven a referencias directas. En HotSpot es perezosa: al primer uso.NoSuchMethodError, NoSuchFieldError (típico del JAR hell: compilaste con una versión y ejecutas con otra).
5. InicializaciónSe ejecutan los inicializadores static y los bloques static { }, una sola vez, con garantía de seguridad entre hilos.ExceptionInInitializerError: algo lanzó dentro de un bloque static. La causa real está en getCause().
6. EjecuciónSe invoca public static void main(String[]). El intérprete empieza; el JIT entra después.NoSuchMethodError: main si la firma no es exacta.
// Hola.java — un programa completo con las tres formas de arrancar
public class Hola {

    static final int CONSTANTE = 42;          // valor copiado en compilación
    static final java.util.List<String> CACHE;  // requiere inicialización explícita

    static {
        // Bloque static: se ejecuta una vez, al inicializar la clase.
        // Si algo lanza aquí, verás ExceptionInInitializerError y la clase
        // queda marcada como "erroneous": cualquier uso posterior falla con
        // NoClassDefFoundError, lo que despista muchísimo.
        CACHE = java.util.List.of("uno", "dos");
        System.out.println("Clase Hola inicializada");
    }

    public static void main(String[] args) {
        System.out.println("Hola desde Java " + Runtime.version().feature());
        System.out.println("Argumentos: " + java.util.Arrays.toString(args));
        System.out.println("Constante: " + CONSTANTE + ", caché: " + CACHE);
    }
}
# 1) Clásico: compilar y ejecutar
javac -d target/classes Hola.java
java -cp target/classes Hola uno dos

# 2) Fichero único, sin compilar (JEP 330, Java 11+): ideal para scripts y pruebas
java Hola.java uno dos

# 3) Varios ficheros fuente en memoria (JEP 458, Java 22+): sin build tool
java Principal.java            # compila también las clases del mismo directorio

# Truco: un script ejecutable en Java, con shebang
cat > saluda <<'EOF'
#!/usr/bin/env java --source 21
public class Saluda {
    public static void main(String[] a) { System.out.println("¡Hola!"); }
}
EOF
chmod +x saluda && ./saluda
Java 21+ simplifica el main. Con clases implícitas y métodos de instancia (JEP 445/463/477, definitivo en Java 25) un programa mínimo es literalmente void main() { IO.println("hola"); }, sin class, sin static y sin String[] args. Está pensado para aprender y para scripts; en una aplicación real seguirás escribiendo la clase completa. Detalle en el módulo 02.

1.5 Versiones, LTS y el proceso JEP

Desde Java 9, hay una versión cada seis meses, en marzo y septiembre, salga lo que salga: si una funcionalidad no está lista, se cae del tren, no se retrasa la versión. Cada dos años, una versión se marca como LTS (soporte a largo plazo) y recibe parches durante años.

VersiónFechaLTSLo que trajo y todavía usas a diario
82014Lambdas, streams, Optional, java.time, métodos default. El punto de partida del Java moderno… y el legado que aún migra media industria.
92017NoMódulos (JPMS), List.of, jshell, strings compactos, StringConcatFactory.
112018var en lambdas, HttpClient, String.strip/lines/repeat, Files.readString, ejecución de un solo fichero.
172021record, sealed, switch con flechas, text blocks, instanceof con patrón, NPE con mensaje útil.
212023Hilos virtuales, pattern matching para switch, patrones de registro, colecciones secuenciadas, ZGC generacional.
252025Scoped values, flexible constructor bodies, ficheros fuente compactos, mejoras de perfilado y AOT (class loading & linking).

Un JEP (JDK Enhancement Proposal) es el documento que describe cada cambio: motivación, alternativas descartadas, riesgos y detalle técnico. Son la mejor fuente que existe sobre Java, mucho mejor que cualquier tutorial, y están escritos para leerse. Una funcionalidad grande pasa por fases:

Regla de decisión sobre versiones. Para un proyecto nuevo en 2026: Java 25 LTS. Para un proyecto existente en 17: migra a 21 (es casi indoloro y te da hilos virtuales). Si estás en Java 8, ese es tu riesgo técnico número uno: sin parches de seguridad razonables, sin hilos virtuales, sin record, y con un salto obligatorio de javax a jakarta cada vez más caro cuanto más tardes. El plan de migración está en el módulo 02.

1.6 Las herramientas del JDK que sí vas a usar

Vienen instaladas, no cuestan nada y casi nadie las conoce. En una entrevista, mencionar jcmd y jfr con criterio te separa del resto de candidatos inmediatamente.

HerramientaPara quéEjemplo que deberías tener en los dedos
jshellREPL. Probar una idea en 10 segundos sin crear un proyecto.jshell --enable-preview
javapDesensamblar bytecode y ver firmas.javap -c -p Clase.class
jpsListar procesos Java con su PID y su clase principal.jps -lvm
jcmdLa navaja suiza. Sustituye a casi todas las demás.jcmd <pid> help
jstackVolcado de hilos (thread dump): bloqueos, hilos colgados.jstack <pid> > hilos.txt
jmapVolcado de memoria (heap dump) e histograma de objetos.jmap -histo:live <pid> | head -30
jstatEstadísticas de GC en vivo, sin instrumentar nada.jstat -gcutil <pid> 1s
jfrAnalizar grabaciones de Java Flight Recorder desde la terminal.jfr summary perfil.jfr
jdepsAnalizar dependencias entre clases y módulos. Imprescindible al migrar.jdeps --multi-release 21 -s app.jar
jlinkCrear un runtime mínimo con solo los módulos que usas.jlink --add-modules java.base --output rt
jpackageEmpaquetar como instalador nativo (.deb, .msi, .dmg).jpackage --input target --main-jar app.jar
jdeprscanBuscar usos de API obsoleta antes de subir de versión.jdeprscan --release 21 app.jar
jarsigner / keytoolFirmas y almacenes de claves (ver módulo 10).keytool -list -keystore cacerts
# ---------- jshell: el laboratorio ----------
$ jshell
jshell> var l = new java.util.ArrayList<Integer>(java.util.List.of(3,1,2))
l ==> [3, 1, 2]
jshell> l.sort(null); l
$3 ==> [1, 2, 3]
jshell> /vars                    # variables declaradas
jshell> /methods                 # métodos declarados
jshell> /imports                 # imports activos (trae los habituales de serie)
jshell> /open guion.jsh          # cargar un fichero de comandos
jshell> /save sesion.jsh         # guardar lo que has escrito
jshell> /edit                    # abrir un editor para lo escrito
jshell> /exit

# Con classpath, para probar contra tus dependencias
jshell --class-path target/classes:$(ls ~/.m2/**/jackson-databind-*.jar | head -1)

# ---------- jcmd: todo lo demás ----------
jcmd -l                                   # procesos (como jps)
jcmd <pid> help                           # ¡lee esto una vez en tu vida!
jcmd <pid> VM.version                     # versión y flags de compilación de la JVM
jcmd <pid> VM.flags -all                  # TODOS los flags efectivos (cientos)
jcmd <pid> VM.system_properties           # propiedades del sistema
jcmd <pid> VM.uptime
jcmd <pid> VM.native_memory summary       # requiere -XX:NativeMemoryTracking=summary
jcmd <pid> Thread.print                   # thread dump (equivale a jstack)
jcmd <pid> GC.heap_info                   # ocupación por generación
jcmd <pid> GC.class_histogram             # cuántas instancias de cada clase
jcmd <pid> GC.heap_dump /tmp/heap.hprof   # volcado completo para MAT/VisualVM
jcmd <pid> GC.run                         # sugerir un GC (no lo fuerza)
jcmd <pid> JFR.start name=perf settings=profile duration=120s filename=/tmp/p.jfr
jcmd <pid> JFR.dump name=perf filename=/tmp/parcial.jfr
jcmd <pid> Compiler.queue                 # qué está compilando el JIT ahora
jcmd <pid> Compiler.codecache             # ocupación del code cache
Por qué jcmd y no las herramientas sueltas. jstack, jmap y jinfo están en modo de mantenimiento: Oracle recomienda jcmd desde Java 8. Usa un solo comando, con la misma sintaxis, para hilos, memoria, JFR y flags. Y funciona sin reiniciar la aplicación, que es justo lo que necesitas a las 3 de la mañana.

1.7 Un proyecto mínimo que sí es profesional

Nadie compila con javac a mano más allá de un ejemplo. Estos son los dos esqueletos que debes poder escribir de memoria. Fíjate en que ambos son cortos: si tu pom.xml tiene 400 líneas, casi siempre es que estás repitiendo lo que el parent ya te da.

mi-app/
├── pom.xml                     (o build.gradle.kts)
├── .sdkmanrc                   java=21.0.5-tem   ← todos usamos el mismo JDK
├── .editorconfig               fin de línea, codificación, sangrado
├── README.md
└── src
    ├── main
    │   ├── java/com/ejemplo/app/App.java
    │   └── resources/application.yaml
    └── test
        ├── java/com/ejemplo/app/AppTest.java
        └── resources/           datos de prueba
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.ejemplo</groupId>
  <artifactId>mi-app</artifactId>
  <version>1.0.0-SNAPSHOT</version>

  <properties>
    <!-- Una sola línea fija el JDK del compilador Y del bytecode generado. -->
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <junit.version>5.11.3</junit.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.2</version>
      </plugin>
    </plugins>
  </build>
</project>
release, no source + target. Con <source>11</source><target>11</target> compilas con la sintaxis de 11 pero contra la API del JDK que tengas instalado. Resultado: tu código compila en tu máquina con JDK 21 y explota en producción con JDK 11 con un NoSuchMethodError, porque has usado List.of(...).reversed() sin saberlo. <release>11</release> valida también la API. Es un fallo tan común que merece la pena revisarlo hoy en tu proyecto.
// build.gradle.kts equivalente, con toolchain: Gradle descarga el JDK si falta
plugins { java }

group = "com.ejemplo"
version = "1.0.0-SNAPSHOT"

java {
    toolchain { languageVersion = JavaLanguageVersion.of(21) }
}

repositories { mavenCentral() }

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
    testLogging { events("passed", "skipped", "failed") }
}
# El ciclo de vida de Maven, en orden y sin misterio
mvn validate      # comprueba el proyecto
mvn compile       # target/classes
mvn test          # ejecuta los tests unitarios (surefire)
mvn package       # empaqueta: target/mi-app-1.0.0-SNAPSHOT.jar
mvn verify        # tests de integración (failsafe) y comprobaciones de calidad
mvn install       # copia el artefacto a ~/.m2/repository
mvn deploy        # sube al repositorio remoto

# Los comandos que de verdad usas a diario
mvn -q clean verify                    # -q: silencioso, solo errores y salida de tests
mvn -o test                            # offline: sin tocar la red
mvn dependency:tree -Dincludes=com.fasterxml.jackson   # ¿de dónde sale esta versión?
mvn versions:display-dependency-updates                # qué se puede actualizar
mvn -T 1C clean verify                 # paralelizar: 1 hilo por núcleo
mvn help:effective-pom                 # el pom real, con todo lo heredado resuelto
El comando que más te va a salvar: mvn dependency:tree. El 90 % de los NoSuchMethodError, ClassNotFoundException y “funciona en mi máquina” son conflictos de versiones transitivas. El árbol te dice qué versión ha ganado y por qué; con -Dverbose te muestra también las descartadas (omitted for conflict with…). En Gradle, el equivalente es ./gradlew dependencyInsight --dependency jackson-databind.

Comprobación rápida de la sección 1

2 · Tipos, memoria y referencias

Aquí está el origen de una proporción enorme de los errores sutiles de Java: comparar wrappers con ==, esperar que un método “cambie” la variable del llamante, sumar dinero con double o desbordar un int sin que nadie avise. Ninguno de esos cuatro fallos lanza una excepción: devuelven un resultado incorrecto en silencio, que es lo peor que puede hacer un programa.

2.1 Los ocho primitivos, con sus rangos y su tamaño

TipoBitsRangoPor defectoCuándo usarlo
boolean1 lógico (1 byte en un array, 4 en la pila)true / falsefalseCondiciones. No hay conversión implícita desde números: if (1) no compila, y eso es bueno.
byte8−128 … 1270Datos binarios, buffers de E/S. Siempre con signo, lo que sorprende: (byte) 200 vale −56.
short16−32.768 … 32.7670Casi nunca. Solo por compatibilidad con formatos binarios o protocolos.
char160 … 65.535 (sin signo)'\u0000'Una unidad de código UTF-16, no un carácter. Ver 2.8.
int32−2.147.483.648 … 2.147.483.6470El tipo entero por defecto. Índices, contadores, identificadores pequeños.
long64±9,22 · 10180LMilisegundos de época, identificadores de base de datos, contadores que crecen, céntimos.
float32≈ ±3,4 · 1038, 7 dígitos de precisión0.0fPrácticamente nunca en aplicaciones de negocio. Gráficos y datos científicos masivos donde la memoria importa.
double64≈ ±1,8 · 10308, 15–17 dígitos0.0dMagnitudes físicas, medias, porcentajes aproximados. Nunca dinero.

Todo lo demás en Java es una referencia a un objeto en el montón (heap). Esa distinción determina cómo se copia y cómo se compara:

int a = 5;
int b = a;              // copia del VALOR: b es independiente
b++;                    // a sigue valiendo 5

int[] x = {1, 2, 3};
int[] y = x;            // copia de la REFERENCIA: mismo array
y[0] = 99;              // x[0] también es 99 ahora

// Literales legibles: el guion bajo (Java 7+) no cambia el valor
long poblacionMundial = 8_100_000_000L;   // la L es obligatoria: sin ella, desborda int
int mascaraHex        = 0xFF_FF_FF;
int enBinario         = 0b1010_1010;
int enOctal           = 0755;              // ¡cuidado: el 0 inicial es octal, vale 493!
El cero inicial es octal. int permisos = 0755; no vale setecientos cincuenta y cinco, vale 493. Es una herencia de C que ha causado bugs reales en código de permisos de ficheros y de códigos postales (08001 no compila siquiera, porque el 8 no es un dígito octal). Si el valor es decimal y empieza por cero, es una cadena, no un número.

2.2 Wrappers, autoboxing y lo que cuesta de verdad

Cada primitivo tiene una clase envolvente (wrapper) porque los genéricos no admiten primitivos: no existe List<int>. El compilador convierte automáticamente entre unos y otros (autoboxing y unboxing), y esa comodidad tiene tres costes que debes conocer.

PrimitivoWrapperCaché de instanciasTamaño aproximado en memoria
booleanBooleanSiempre: TRUE y FALSE16 bytes (o compartido)
byteByteTodos los valores (−128…127)16 bytes
shortShort−128 … 12716 bytes
charCharacter0 … 12716 bytes
intInteger−128 … 127 (ajustable con un flag)16 bytes frente a 4 del primitivo
longLong−128 … 12716 bytes frente a 8
floatFloatNinguna16 bytes
doubleDoubleNinguna16 bytes

Los tres costes, en orden de importancia práctica:

  1. NullPointerException invisible. Un wrapper puede ser null; un primitivo, no. Al desempaquetar un null revienta, y el punto del fallo es una línea donde no ves ninguna llamada a método.
  2. Comparación equivocada. == compara referencias, así que funciona “por casualidad” con valores pequeños y falla con los grandes. Es el bug perfecto: pasa los tests y falla en producción.
  3. Coste de memoria y de tiempo. Un Integer ocupa cuatro veces más que un int y añade una indirección, lo que destroza la localidad de caché. En bucles calientes se nota.
import java.util.HashMap;
import java.util.Map;

public class TrampasDeBoxing {

    public static void main(String[] args) {

        // ---------- 1. NPE invisible ----------
        Map<String, Integer> stock = new HashMap<>();
        stock.put("teclado", 3);
        // int unidades = stock.get("ratón");   // ← NPE: get devuelve null y se desempaqueta
        int unidades = stock.getOrDefault("ratón", 0);   // así, siempre
        System.out.println("unidades = " + unidades);

        // Peor todavía, porque nadie sospecha de un operador ternario:
        Integer talla = null;
        boolean condicion = false;
        // int t = condicion ? 1 : talla;      // ← NPE aunque la rama "mala" ni se use... 
        // El ternario promueve a int los dos brazos, así que desempaqueta SIEMPRE.

        // ---------- 2. La caché de Integer ----------
        Integer i1 = 127, i2 = 127;
        Integer i3 = 128, i4 = 128;
        System.out.println(i1 == i2);        // true  → misma instancia del caché
        System.out.println(i3 == i4);        // false → dos objetos distintos
        System.out.println(i3.equals(i4));   // true  → así se comparan valores
        System.out.println(i3.intValue() == i4.intValue()); // true → o desempaquetando

        // Ojo: mezclar wrapper y primitivo SÍ desempaqueta, y entonces == compara valores
        int primitivo = 128;
        System.out.println(i3 == primitivo); // true  → aquí == compara int con int

        // ---------- 3. El coste ----------
        long t0 = System.nanoTime();
        Long sumaLenta = 0L;                            // ← Long, no long
        for (int i = 0; i < 10_000_000; i++) sumaLenta += i;   // 10M de objetos basura
        long t1 = System.nanoTime();

        long sumaRapida = 0L;                           // ← primitivo
        for (int i = 0; i < 10_000_000; i++) sumaRapida += i;
        long t2 = System.nanoTime();

        System.out.printf("Long   : %d ms%n", (t1 - t0) / 1_000_000);
        System.out.printf("long   : %d ms%n", (t2 - t1) / 1_000_000);
        // Órdenes de magnitud de diferencia. Y esto es solo una medición
        // aproximada: para medir bien, JMH (sección 13.9).
    }
}
Reglas que no admiten excepción:
  • Nunca compares wrappers con ==. Usa equals o Objects.equals(a, b) si alguno puede ser null.
  • En acumuladores, contadores, índices y bucles calientes, usa primitivos.
  • Si una API devuelve un wrapper, comprueba el null antes de asignarlo a un primitivo.
  • Integer.valueOf(...) (o el autoboxing) en lugar de new Integer(...), que además está obsoleto y marcado para eliminación desde Java 9.
Por qué existe la caché de −128 a 127. La especificación del lenguaje (JLS 5.1.7) obliga a que Integer.valueOf devuelva la misma instancia para valores entre −128 y 127, porque son abrumadoramente los más frecuentes y permite ahorrar millones de objetos. El límite superior es configurable con -Djava.lang.Integer.IntegerCache.high=1000, lo que hace el problema peor: código que funciona en un entorno y falla en otro. No lo toques; arregla el ==.

2.3 Java pasa todo por valor (y por eso confunde)

La afirmación completa y correcta es: Java siempre pasa argumentos por valor; lo que ocurre es que el valor de una variable de tipo objeto es la referencia. Se copia la referencia, no el objeto. Consecuencia: puedes mutar el objeto que recibes, pero no puedes hacer que la variable del llamante apunte a otro sitio.

   ANTES de llamar a mutar(lista)          ANTES de llamar a reasignar(lista)
   ┌──────────────┐                        ┌──────────────┐
   │ llamante     │                        │ llamante     │
   │  lista ──────┼──┐                     │  lista ──────┼──┐
   └──────────────┘  │                     └──────────────┘  │
   ┌──────────────┐  │  ┌──────────────┐   ┌──────────────┐  │  ┌──────────────┐
   │ mutar        │  └─▶│ ArrayList    │   │ reasignar    │  └─▶│ ArrayList    │
   │  lista ──────┼────▶│  ["a"]       │   │  lista ──────┼────▶│  ["a"]       │
   └──────────────┘     └──────────────┘   └──────────────┘     └──────────────┘

   DESPUÉS de lista.add("b")               DESPUÉS de lista = new ArrayList<>()
   ┌──────────────┐     ┌──────────────┐   ┌──────────────┐     ┌──────────────┐
   │ llamante     │────▶│ ArrayList    │   │ llamante ────┼────▶│ ArrayList    │
   │ mutar        │────▶│  ["a","b"]   │   │              │     │  ["a"]       │  ← intacto
   └──────────────┘     └──────────────┘   └──────────────┘     └──────────────┘
   Los dos ven el cambio: es el                  ┌──────────────┐  ┌──────────────┐
   MISMO objeto.                                 │ reasignar ───┼─▶│ ArrayList[] │
                                                 └──────────────┘  └──────────────┘
                                                 Solo la copia local apunta al nuevo.
import java.util.ArrayList;
import java.util.List;

public class PasoPorValor {

    static void mutar(List<String> lista)     { lista.add("añadido"); }   // sí se ve fuera
    static void reasignar(List<String> lista) { lista = new ArrayList<>(); lista.add("x"); } // no
    static void incrementar(int n)            { n++; }                    // no se ve fuera
    static void cambiar(StringBuilder sb)     { sb.append("!"); }         // sí (es mutable)
    static void cambiar(String s)             { s = s + "!"; }            // no (es inmutable)

    public static void main(String[] args) {
        List<String> lista = new ArrayList<>(List.of("original"));
        mutar(lista);      System.out.println(lista);   // [original, añadido]
        reasignar(lista);  System.out.println(lista);   // [original, añadido]  ← sin cambios

        int n = 1; incrementar(n); System.out.println(n);   // 1

        var sb = new StringBuilder("hola"); cambiar(sb); System.out.println(sb);  // hola!
        String s = "hola";                  cambiar(s);  System.out.println(s);   // hola
    }
}
Cómo responder en una entrevista. Si te preguntan “¿Java pasa por valor o por referencia?”, la respuesta que suena a profesional es: «Siempre por valor. Para los objetos, el valor que se copia es la referencia, así que el método puede modificar el estado del objeto apuntado pero no puede reasignar la variable del llamante. Es lo que en C++ sería pasar un puntero por valor». Y remata con el ejemplo del swap: en Java no puedes escribir un swap(a, b) que funcione con dos variables del llamante; necesitas devolver un valor, usar un array o un objeto contenedor.

2.4 var: inferencia local, no tipado dinámico

var (Java 10) solo dice “deduce el tipo de la expresión de la derecha”. El tipo sigue siendo estático, fijo y comprobado en compilación; en el bytecode no queda rastro de var. Solo se puede usar en variables locales, en el índice de un for, en los recursos de un try-with-resources y en los parámetros de una lambda (Java 11+).

// ✅ Cuando el tipo es obvio por la derecha: quita ruido
var clientes = new ArrayList<Cliente>();
var entrada  = new HashMap<String, List<Pedido>>();          // evita repetir 40 caracteres
var ahora    = LocalDateTime.now();
for (var e : entrada.entrySet()) { /* Map.Entry<String, List<Pedido>> */ }
try (var in = Files.newBufferedReader(ruta)) { /* ... */ }

// ✅ Con tipos anónimos o intersecciones, es la única forma de nombrarlos
var comparador = new Comparator<String>() {
    @Override public int compare(String a, String b) { return a.length() - b.length(); }
};

// ❌ Cuando esconde información que el lector necesita
var resultado = servicio.procesar(peticion);   // ¿qué devuelve? Hay que ir a mirar
var x = 0;                                     // int... ¿seguro que querías int y no long?
var lista = obtenerDatos();                    // el nombre no dice nada y el tipo tampoco

// ❌ Errores de compilación: var necesita un inicializador con tipo deducible
// var a;                       // sin inicializador
// var b = null;                // null no tiene tipo
// var c = { 1, 2, 3 };         // el literal de array no basta; usa new int[]{1,2,3}
// var d = (x) -> x + 1;        // una lambda no tiene tipo por sí sola

// ⚠️ Sorpresa clásica: var e inferencia de tipos numéricos
var i = 1;          // int
var l = 1L;         // long
var d2 = 1.0;       // double
var f = 1.0f;       // float
var ch = 'a';       // char, no int
var b2 = (byte) 1;  // byte, gracias al cast explícito
SituaciónCon varRecomendación
new explícito a la derechavar m = new HashMap<String,Integer>();Úsalo. No se pierde nada y se gana legibilidad.
Llamada a un método con nombre reveladorvar pedido = repo.buscarPedido(id);Aceptable si el nombre del método dice el tipo.
Llamada genérica o de fábricavar r = fabrica.crear();Evítalo. Escribe el tipo.
Cadena larga de streamsvar r = lista.stream()...collect(...);Escribe el tipo: es donde el lector más lo necesita.
Interfaz frente a implementaciónvar l = new ArrayList<>(); deduce ArrayList, no ListSi quieres programar contra la interfaz, decláralo: List<X> l = new ArrayList<>();
Campos, parámetros y retornosNo compilaY está bien: la API pública debe ser explícita.

2.5 Conversiones, promoción numérica y desbordamientos silenciosos

Java hace conversiones implícitas que no pierden información (ampliación) y exige un cast explícito para las que sí pueden perderla (reducción). El problema es que un cast explícito no comprueba nada: trunca sin avisar.

Ampliación implícita (segura, sin cast):
   byte → short → int → long → float → double
             char ↗
Reducción explícita (requiere cast, PUEDE PERDER DATOS):
   double → float → long → int → char/short → byte
public class Conversiones {
    public static void main(String[] args) {

        // ---------- Promoción numérica en las operaciones ----------
        byte b1 = 10, b2 = 20;
        // byte suma = b1 + b2;      // ← NO compila: b1 + b2 se promueve a int
        byte suma = (byte) (b1 + b2);
        short s = 1;
        s += 1;              // sí compila: los operadores compuestos hacen el cast implícito
        // s = s + 1;        // no compila: aquí es una asignación normal desde un int

        // ---------- Desbordamiento silencioso: el bug de los 5 minutos ----------
        int maximo = Integer.MAX_VALUE;
        System.out.println(maximo + 1);            // -2147483648  ¡sin excepción!

        // El caso real: milisegundos de un mes en int
        int msPorDiaMal = 24 * 60 * 60 * 1000;     // 86_400_000 → cabe
        int msPorMesMal = 30 * 24 * 60 * 60 * 1000; // DESBORDA: da 1_702_967_296
        long msPorMesBien = 30L * 24 * 60 * 60 * 1000;  // la L al principio lo arregla
        System.out.println(msPorMesMal + " vs " + msPorMesBien);

        // La trampa más frecuente: multiplicar ints y ASIGNAR a long no sirve de nada,
        // porque la multiplicación ya se hizo en int antes de ampliar.
        long malCalculado = 1_000_000 * 1_000_000;      // -727379968
        long bienCalculado = 1_000_000L * 1_000_000;    // 1000000000000

        // ---------- Detección de desbordamiento: Math.*Exact ----------
        try {
            Math.addExact(Integer.MAX_VALUE, 1);        // lanza ArithmeticException
        } catch (ArithmeticException e) {
            System.out.println("Desbordamiento detectado: " + e.getMessage());
        }
        // Familia completa: addExact, subtractExact, multiplyExact, incrementExact,
        // decrementExact, negateExact, absExact, toIntExact.
        long grande = 3_000_000_000L;
        // int truncado = (int) grande;              // silenciosamente -1294967296
        // int seguro   = Math.toIntExact(grande);   // ArithmeticException: integer overflow

        // ---------- División entera y módulo con negativos ----------
        System.out.println(7 / 2);        //  3   ← división ENTERA, no 3.5
        System.out.println(7 / 2.0);      //  3.5 ← basta con que un operando sea double
        System.out.println(-7 / 2);       // -3   ← trunca hacia cero
        System.out.println(-7 % 2);       // -1   ← el resto en Java hereda el signo del dividendo
        System.out.println(Math.floorMod(-7, 2));  // 1 ← lo que quieres para índices circulares
        System.out.println(Math.floorDiv(-7, 2));  // -4 ← redondeo hacia menos infinito

        // 1 / 0 lanza ArithmeticException; 1.0 / 0 NO: da Infinity
        System.out.println(1.0 / 0);      // Infinity
        System.out.println(-1.0 / 0);     // -Infinity
        System.out.println(0.0 / 0);      // NaN
    }
}
El desbordamiento no lanza excepción. Es la decisión de diseño de Java que más bugs silenciosos ha causado. Dos reglas prácticas: (1) si un cálculo puede acercarse a 2·109, escribe el primer literal como long (30L * 24 * 60 * 60 * 1000); (2) en cálculos de negocio donde un desbordamiento sería catastrófico —importes, cantidades, saldos— usa Math.addExact y compañía, que fallan rápido en lugar de dar un número absurdo.

2.6 Coma flotante: por qué double no vale para dinero

float y double siguen el estándar IEEE 754: representan números en binario, como una suma de potencias de dos. Y 0,1 no es una suma finita de potencias de dos, igual que 1/3 no tiene representación decimal finita. El resultado es que 0.1 no es exactamente 0,1, y los errores se acumulan.

import java.math.BigDecimal;
import java.math.RoundingMode;

public class ComaFlotante {
    public static void main(String[] args) {

        System.out.println(0.1 + 0.2);                 // 0.30000000000000004
        System.out.println(0.1 + 0.2 == 0.3);          // false
        System.out.println(1.03 - 0.42);               // 0.6100000000000001
        System.out.println(4.35 * 100);                // 434.99999999999994  → (int) da 434
        System.out.println(0.1f + 0.2f);               // 0.3  (float MIENTE al imprimir)
        System.out.println((double)(0.1f + 0.2f));     // 0.30000001192092896  ← la verdad

        // Suma de un céntimo cien veces
        double total = 0;
        for (int i = 0; i < 100; i++) total += 0.01;
        System.out.println(total);                     // 1.0000000000000007
        System.out.println(total == 1.0);              // false

        // Comparar doubles: NUNCA con ==, siempre con una tolerancia (epsilon)
        double esperado = 1.0;
        boolean iguales = Math.abs(total - esperado) < 1e-9;
        System.out.println("iguales con epsilon: " + iguales);   // true

        // ---------- La forma correcta para dinero ----------
        BigDecimal precio = new BigDecimal("19.99");   // ¡DESDE STRING!
        BigDecimal iva    = new BigDecimal("0.21");
        BigDecimal conIva = precio.multiply(BigDecimal.ONE.add(iva))
                                  .setScale(2, RoundingMode.HALF_UP);
        System.out.println(conIva);                    // 24.19

        // El error que arruina todo el esfuerzo: construir desde double
        System.out.println(new BigDecimal(0.1));
        // 0.1000000000000000055511151231257827021181583404541015625
        System.out.println(BigDecimal.valueOf(0.1));   // 0.1  (usa Double.toString por dentro)
        System.out.println(new BigDecimal("0.1"));     // 0.1  ← la forma canónica

        // equals compara TAMBIÉN la escala; compareTo compara el valor
        BigDecimal a = new BigDecimal("1.0");
        BigDecimal c = new BigDecimal("1.00");
        System.out.println(a.equals(c));               // false  ← escalas 1 y 2
        System.out.println(a.compareTo(c) == 0);       // true   ← esto es lo que quieres
        System.out.println(a.hashCode() == c.hashCode()); // false → cuidado en HashSet

        // La división EXIGE escala y redondeo, o lanza ArithmeticException
        BigDecimal tres = new BigDecimal("3");
        // BigDecimal.ONE.divide(tres);                // ArithmeticException: non-terminating
        System.out.println(BigDecimal.ONE.divide(tres, 4, RoundingMode.HALF_UP)); // 0.3333
    }
}
Estrategia para dineroCómoVentajasInconvenientes
BigDecimal
recomendado por defecto
Construir desde String, escala fija, RoundingMode explícito, comparar con compareTo. Exacto, escala controlada, soporta cualquier moneda, casa con NUMERIC(19,4) en base de datos. Verboso (no hay operadores), más lento y con más memoria. equals traicionero.
Enteros de la unidad mínima
céntimos en long
Guardar 1999 en lugar de 19.99; formatear solo al mostrar. Rápido, exacto, trivial de sumar y de guardar. Lo usan muchas pasarelas de pago (Stripe). Hay que recordar la escala en todas partes; monedas con 0 o 3 decimales (JPY, KWD) complican.
Tipo de dominio Dinero
lo que hace un profesional
Un record con importe y moneda que encapsula la aritmética y prohíbe mezclar monedas. Imposible sumar euros con dólares por accidente; el redondeo vive en un solo sitio. Hay que escribirlo (30 líneas) o usar Joda-Money / JavaMoney (JSR 354).
double Ninguna que compense. Descuadres de céntimos que aparecen en la contabilidad meses después y son imposibles de reconstruir.
// El tipo de dominio que deberías tener en cualquier aplicación con importes.
// Un record te da equals, hashCode y toString; el constructor canónico compacto
// es el lugar perfecto para validar y normalizar.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;

public record Dinero(BigDecimal importe, Currency moneda) implements Comparable<Dinero> {

    public Dinero {                                  // constructor compacto
        Objects.requireNonNull(importe, "importe");
        Objects.requireNonNull(moneda, "moneda");
        // Normalizamos la escala a los decimales propios de la moneda:
        // EUR y USD → 2, JPY → 0, KWD → 3. Así el equals del record se vuelve fiable.
        importe = importe.setScale(moneda.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
    }

    public static Dinero euros(String cantidad) {
        return new Dinero(new BigDecimal(cantidad), Currency.getInstance("EUR"));
    }

    public Dinero mas(Dinero otro)   { exigirMismaMoneda(otro); return new Dinero(importe.add(otro.importe), moneda); }
    public Dinero menos(Dinero otro) { exigirMismaMoneda(otro); return new Dinero(importe.subtract(otro.importe), moneda); }
    public Dinero por(int cantidad)  { return new Dinero(importe.multiply(BigDecimal.valueOf(cantidad)), moneda); }

    /** Reparto sin perder ni ganar céntimos: los restos se distribuyen uno a uno. */
    public Dinero[] repartir(int partes) {
        if (partes <= 0) throw new IllegalArgumentException("partes debe ser > 0");
        int decimales = moneda.getDefaultFractionDigits();
        BigDecimal base = importe.divide(BigDecimal.valueOf(partes), decimales, RoundingMode.DOWN);
        BigDecimal repartido = base.multiply(BigDecimal.valueOf(partes));
        long centimosSobrantes = importe.subtract(repartido).movePointRight(decimales).longValueExact();

        Dinero[] resultado = new Dinero[partes];
        BigDecimal unidad = BigDecimal.ONE.movePointLeft(decimales);
        for (int i = 0; i < partes; i++) {
            BigDecimal parte = i < centimosSobrantes ? base.add(unidad) : base;
            resultado[i] = new Dinero(parte, moneda);
        }
        return resultado;
    }

    private void exigirMismaMoneda(Dinero otro) {
        if (!moneda.equals(otro.moneda))
            throw new IllegalArgumentException("No se puede operar %s con %s".formatted(moneda, otro.moneda));
    }

    @Override public int compareTo(Dinero otro) { exigirMismaMoneda(otro); return importe.compareTo(otro.importe); }
    @Override public String toString() { return "%s %s".formatted(importe, moneda.getCurrencyCode()); }
}

// Uso:
//   var factura = Dinero.euros("100.00");
//   var partes  = factura.repartir(3);   // [33.34, 33.33, 33.33] → suman exactamente 100.00
//   factura.mas(Dinero.euros("21.00"));  // 121.00 EUR
//   factura.mas(new Dinero(BigDecimal.TEN, Currency.getInstance("USD"))); // IllegalArgument
El truco del reparto. Dividir 100 € entre 3 y quedarse con 33,33 tres veces pierde un céntimo. Ese céntimo, multiplicado por millones de operaciones, es un problema contable real. La solución canónica es la de arriba: reparte a la baja y distribuye los céntimos sobrantes de uno en uno. Es la primera pregunta que hacen en entrevistas de fintech.

2.7 char, Unicode y por qué “longitud” es ambiguo

Un char en Java son 16 bits: una unidad de código UTF-16, no un carácter. Java se diseñó cuando se creía que 65.536 valores bastarían para todo el texto del mundo. No bastaron. Hoy Unicode define más de 1,1 millones de puntos de código, y los que están por encima de 65.535 —emojis, escrituras históricas, algunos ideogramas— necesitan dos char (un par subrogado o surrogate pair).

public class Unicode {
    public static void main(String[] args) {

        String texto = "Añó";                 // caracteres latinos con tilde: 1 char cada uno
        String emoji = "Hola 👋";             // la mano son DOS chars (par subrogado)
        String bandera = "🇪🇸";                // ¡cuatro chars: dos puntos de código regionales!
        String acentoCompuesto = "e\u0301";    // 'e' + tilde combinante = se ve como "é"

        System.out.println(texto.length());        // 3
        System.out.println(emoji.length());        // 7  ← "Hola " son 5 + 2 del emoji
        System.out.println(emoji.codePointCount(0, emoji.length())); // 6 ← puntos de código
        System.out.println(bandera.length());      // 4
        System.out.println(acentoCompuesto.length()); // 2, pero se dibuja como 1

        // Iterar mal: parte los emojis por la mitad
        for (char c : emoji.toCharArray()) System.out.print("[" + c + "]");
        System.out.println();     // [H][o][l][a][ ][?][?]  ← basura

        // Iterar bien: por puntos de código
        emoji.codePoints().forEach(cp -> System.out.print("[" + new String(Character.toChars(cp)) + "]"));
        System.out.println();     // [H][o][l][a][ ][👋]

        // Detectar y saltar pares subrogados a mano (cuando necesitas el índice)
        for (int i = 0; i < emoji.length(); ) {
            int cp = emoji.codePointAt(i);
            i += Character.charCount(cp);      // 1 o 2
        }

        // char es un número con signo... no: SIN signo, y se promueve a int al operar
        char a = 'a';
        System.out.println(a + 1);             // 98  ← int, no 'b'
        System.out.println((char) (a + 1));    // b
        System.out.println('A' + 'B');         // 131 ← suma de códigos, sorpresa habitual
        System.out.println("" + 'A' + 'B');    // AB  ← con una cadena delante, concatena

        // Normalización: comparar texto que "se ve igual"
        String compuesto = "café";                        // é como un solo punto de código
        String descompuesto = "cafe\u0301";               // e + acento combinante
        System.out.println(compuesto.equals(descompuesto));  // false
        System.out.println(java.text.Normalizer.normalize(compuesto,  java.text.Normalizer.Form.NFC)
                   .equals(java.text.Normalizer.normalize(descompuesto, java.text.Normalizer.Form.NFC))); // true
    }
}
Tres reglas de texto que evitan incidentes reales:
  • Nunca uses substring para truncar texto de usuario a un número de caracteres: puedes partir un emoji en dos y generar un carácter inválido que rompe el JSON, el índice de Elasticsearch o la columna de la base de datos. Trunca por puntos de código o, mejor, por BreakIterator.
  • Especifica siempre el charset. new String(bytes), getBytes() y new FileReader(f) usan el juego de caracteres por defecto de la plataforma. Desde Java 18 ese defecto es UTF-8 (JEP 400), lo que arregló años de bugs, pero si mantienes código antiguo o corres en Java 17 pon StandardCharsets.UTF_8 explícito.
  • Normaliza antes de comparar o indexar texto que venga de fuentes distintas (formularios web, iOS, importaciones CSV): NFC para almacenar, y para búsquedas insensibles a acentos, NFD + eliminar marcas diacríticas.

2.8 Qué ocupa un objeto en memoria (y por qué importa)

Saber estimar el tamaño de un objeto te permite responder a preguntas del tipo “¿caben diez millones de estos en 2 GB de heap?” sin adivinar. En HotSpot de 64 bits con punteros comprimidos (activados por defecto si el heap es menor de 32 GB):

ElementoCosteComentario
Cabecera de objeto12 bytes (8 de mark word + 4 de klass pointer)Con -XX:-UseCompressedOops son 16.
AlineaciónHasta múltiplo de 8Por eso el mínimo real de un objeto vacío son 16 bytes.
Referencia a objeto4 bytes (comprimida) u 8El límite de 32 GB de heap viene de aquí: cruzarlo hace las referencias de 8 bytes y puedes acabar con menos capacidad útil.
Cabecera de array16 bytes (12 + 4 de longitud)Más el contenido, alineado.
Integer16 bytes12 de cabecera + 4 del int.
Long24 bytes12 + 8 + 4 de relleno.
String ASCII de n caracteres≈ 40 + n bytesDesde Java 9 los strings son compactos: byte[] con LATIN1 si cabe, UTF-16 si no. Antes eran 40 + 2n.
Entrada de HashMap≈ 32 bytes + clave + valorMás el hueco del array de buckets. Un HashMap con 1 M de entradas ronda los 80–100 MB con claves e importes pequeños.
ArrayList de n elementos≈ 40 + 4n bytes + los objetosFrente a un int[]: 16 + 4n, sin objetos. De ahí la diferencia brutal entre List<Integer> e int[].
// Estimación práctica sin librerías: 10 millones de enteros, dos representaciones.
public class CosteMemoria {
    public static void main(String[] args) {
        int n = 10_000_000;

        // Opción A: int[]  → 16 + 4n ≈ 40 MB
        int[] array = new int[n];
        for (int i = 0; i < n; i++) array[i] = i;
        medir("int[]");

        // Opción B: List<Integer> → 40 + 4n (array de refs) + n * 16 (objetos) ≈ 200 MB
        // Además cada acceso es un salto de puntero: adiós a la caché de la CPU.
        var lista = new java.util.ArrayList<Integer>(n);
        for (int i = 0; i < n; i++) lista.add(i);
        medir("List<Integer>");
    }

    static void medir(String etiqueta) {
        Runtime r = Runtime.getRuntime();
        System.gc();                                     // sugerencia, no garantía
        long usada = (r.totalMemory() - r.freeMemory()) / (1024 * 1024);
        System.out.printf("%-15s heap usado ≈ %d MB%n", etiqueta, usada);
    }
}
// Ejecuta con:  java -Xmx1g -XX:+UseSerialGC CosteMemoria
// Y para medirlo de verdad, con jol-core:
//   System.out.println(org.openjdk.jol.info.ClassLayout.parseInstance(obj).toPrintable());
La herramienta seria para esto es JOL (Java Object Layout, org.openjdk.jol:jol-core), del propio equipo de OpenJDK. Te imprime el reparto exacto de bytes de una instancia, incluido el relleno. Es la única forma de contestar “¿cuánto ocupa esto?” sin inventar.

Comprobación rápida de la sección 2

3 · String: inmutabilidad, pool y rendimiento

String es la clase que más aparece en cualquier programa Java, la más preguntada en entrevistas y la que esconde más decisiones de diseño interesantes. Si entiendes por qué es inmutable, entenderás también por qué deberías hacer inmutables tus clases de valor.

3.1 Por qué String es inmutable

No es un capricho. La inmutabilidad de String compra cinco cosas concretas:

VentajaQué permite exactamenteQué pasaría si fuera mutable
Compartir sin copiar El pool de cadenas puede reutilizar el mismo objeto para todos los literales iguales del programa. Cambiar una cadena en un sitio la cambiaría en todos: caos absoluto.
Seguridad entre hilos gratis Se puede pasar una cadena entre hilos sin sincronización ni copias defensivas. Cada uso compartido necesitaría un synchronized o una copia.
hashCode cacheable Se calcula una vez y se guarda en un campo. Por eso HashMap<String, …> es rápido. Habría que recalcularlo en cada consulta, o el mapa se corrompería al mutar una clave.
Clave de mapa fiable Una cadena guardada como clave nunca “se pierde” dentro del HashMap. Todos los mapas del JDK serían inseguros con claves de texto.
Seguridad Una ruta de fichero, una URL o un nombre de clase validados no pueden cambiarse después de la comprobación. Ataque clásico TOCTOU: validas /tmp/seguro.txt y el atacante lo cambia a /etc/passwd entre la validación y el uso.

Por dentro, desde Java 9 (JEP 254, Compact Strings), un String es un byte[] más un byte de codificación: si todos los caracteres caben en LATIN-1, se guarda un byte por carácter; si no, dos (UTF-16). Fue una de las mejoras de memoria más grandes de la historia del JDK sin cambiar una línea de código de usuario: los strings son la mitad de la memoria de una aplicación típica.

// Estructura interna simplificada (java.lang.String)
public final class String implements CharSequence, Comparable<String> {
    private final byte[] value;   // los datos: LATIN1 (1 byte) o UTF16 (2 bytes)
    private final byte coder;     // 0 = LATIN1, 1 = UTF16
    private int hash;             // caché del hashCode; 0 significa "sin calcular"
    private boolean hashIsZero;   // para distinguir "hash vale 0" de "no calculado"
    // ...
}
«¿Es String realmente inmutable si tiene un campo hash no final?» Buena pregunta de entrevista avanzada. Sí lo es en el sentido observable: el campo hash es una caché perezosa (benign data race). Si dos hilos lo calculan a la vez, ambos obtienen el mismo valor, así que la carrera es inofensiva. Es un patrón legítimo y documentado, pero solo funciona porque el valor calculado es determinista.

3.2 El pool de cadenas e intern()

El string pool es una tabla hash nativa de la JVM donde viven los literales. Cuando el cargador de clases resuelve un literal "java", mira si ya está en el pool: si está, devuelve esa referencia; si no, la añade. Los strings creados en tiempo de ejecución —con new, con concat, leyendo de un fichero, deserializando JSON— no pasan por el pool.

public class Pool {
    public static void main(String[] args) {

        String s1 = "java";                  // literal → va al pool
        String s2 = "java";                  // literal igual → MISMA referencia
        String s3 = new String("java");      // objeto nuevo en el heap
        String s4 = s3.intern();             // devuelve la referencia del pool
        String s5 = "ja" + "va";             // constante de compilación → pool
        String parte = "ja";
        String s6 = parte + "va";            // concatenación EN EJECUCIÓN → objeto nuevo
        final String parteFinal = "ja";
        String s7 = parteFinal + "va";       // final + literal → constante → pool

        System.out.println(s1 == s2);        // true
        System.out.println(s1 == s3);        // false
        System.out.println(s1 == s4);        // true
        System.out.println(s1 == s5);        // true   ← el compilador la plegó
        System.out.println(s1 == s6);        // false  ← se construyó en ejecución
        System.out.println(s1 == s7);        // true   ← final permite el plegado
        System.out.println(s1.equals(s6));   // true   ← SIEMPRE compara así

        // Sorpresa: ¿cuántos objetos String crea esta línea?
        String x = new String("hola");
        // DOS: el literal "hola" en el pool (si no estaba) y el objeto nuevo del new.
        // Por eso `new String("literal")` es siempre un error: no aporta nada y gasta el doble.
    }
}
Expresión¿Va al pool?Por qué
"texto"Literal: lo internaliza el cargador de clases.
"tex" + "to"Expresión constante: javac la evalúa y deja un solo literal.
final String a = "tex"; a + "to"a es una constante de compilación.
String a = "tex"; a + "to"Noa no es constante: la concatenación se hace en ejecución.
new String("texto")No (el objeto)El new obliga a crear una instancia distinta. El literal sí está en el pool.
sb.toString()NoSe construye en ejecución.
s.intern()Añade al pool si falta y devuelve la referencia canónica.
"texto".substring(0, 5)NoDesde Java 7, substring copia el array. Devuelve un objeto nuevo… salvo que sea la cadena completa, que se devuelve tal cual.
¿Cuándo usar intern()? Casi nunca, y solo con una medición delante. El caso legítimo: procesas millones de registros y muchos campos repiten los mismos pocos valores (códigos de país, categorías, nombres de columna). Internalizarlos reduce muchísimo la memoria. Los inconvenientes: el pool vive en el heap desde Java 7 pero su tabla hash es de tamaño fijo (por defecto 65.536 buckets, ajustable con -XX:StringTableSize), y llenarla degrada las inserciones. Alternativa mucho mejor y sin efectos globales: un HashMap<String,String> propio como diccionario de canonicalización, o Map.computeIfAbsent sobre un ConcurrentHashMap.
# Ver el estado real del pool: ¿merece la pena internalizar?
java -XX:+PrintStringTableStatistics -jar app.jar
# StringTable statistics:
# Number of buckets       :     65536 =    524288 bytes, each 8
# Number of entries       :    182347 =   4376328 bytes, each 24
# Average bucket size     :     2.782            ← por encima de ~5, degrada
# Maximum bucket size     :        11

# Deduplicación de strings SIN tocar el código: la hace el propio G1
java -XX:+UseG1GC -XX:+UseStringDeduplication -jar app.jar
# G1 detecta durante el GC arrays de bytes idénticos entre Strings distintos y los
# comparte. Suele ahorrar entre un 5 % y un 15 % del heap en aplicaciones de negocio.

3.3 Concatenar: qué hace el compilador y qué debes hacer tú

Desde Java 9, a + b compila a invokedynamic contra StringConcatFactory, que genera en tiempo de ejecución un method handle optimizado que calcula el tamaño exacto y copia una sola vez. Es más rápido que un StringBuilder escrito a mano para una concatenación suelta. Lo que sigue siendo terrible es concatenar dentro de un bucle: el invokedynamic se ejecuta una vez por iteración y cada vuelta copia todo lo acumulado.

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class Concatenar {

    public static void main(String[] args) {
        List<String> partes = IntStream.range(0, 50_000).mapToObj(i -> "p" + i).toList();

        // ❌ O(n²): 50.000 copias de una cadena que crece. Segundos, y basura a raudales.
        long t0 = System.nanoTime();
        String malo = "";
        for (String p : partes) malo += p;
        long t1 = System.nanoTime();

        // ✅ O(n): un único buffer que crece de forma amortizada
        StringBuilder sb = new StringBuilder(partes.size() * 6);   // capacidad estimada
        for (String p : partes) sb.append(p);
        String bueno = sb.toString();
        long t2 = System.nanoTime();

        // ✅ O(n) y más legible si solo unes con un separador
        String unido = String.join("", partes);
        long t3 = System.nanoTime();

        // ✅ Con streams, cuando ya vienes de un pipeline
        String recolectado = partes.stream().collect(Collectors.joining(", ", "[", "]"));
        long t4 = System.nanoTime();

        System.out.printf("+= en bucle      : %6d ms%n", (t1 - t0) / 1_000_000);
        System.out.printf("StringBuilder    : %6d ms%n", (t2 - t1) / 1_000_000);
        System.out.printf("String.join      : %6d ms%n", (t3 - t2) / 1_000_000);
        System.out.printf("Collectors.join  : %6d ms%n", (t4 - t3) / 1_000_000);
        System.out.println(malo.length() + " " + bueno.length() + " " + unido.length()
                           + " " + recolectado.length());
    }
}
// Salida típica en un portátil (los números exactos no importan, el orden sí):
//   += en bucle      :   4120 ms
//   StringBuilder    :      3 ms
//   String.join      :      2 ms
//   Collectors.join  :      4 ms
NecesidadUsaPor qué
Unir 2–5 trozos en una expresión"a" + b + "c"El más legible y, desde Java 9, el más rápido. No lo cambies por un StringBuilder.
Construir en un bucle o condicionalmenteStringBuilderÚnico buffer. Dale capacidad inicial si sabes el tamaño aproximado.
Unir una colección con separadorString.join o Collectors.joiningExpresa la intención y gestiona los separadores sin el clásico if (i > 0).
Plantilla con formato"%s tiene %d".formatted(a, b)Más legible que la concatenación con muchos huecos. Ojo: es bastante más lento que +; no lo pongas en un bucle caliente.
Mensaje de loglog.info("Pedido {} para {}", id, cliente)Con SLF4J, los {} solo se sustituyen si el nivel está activo. Ver sección 15.
Texto multilíneaText block """…"""Sin \n ni comillas escapadas.
Concurrencia real sobre el mismo bufferStringBufferCasi nunca: si dos hilos escriben en el mismo buffer, el diseño ya es sospechoso.

3.4 StringBuilder frente a StringBuffer

StringBuilderStringBufferString
MutableNo
SincronizadoNoSí (todos los métodos synchronized)No hace falta
VelocidadRápidoAlgo más lento (aunque el JIT elimina el bloqueo si no hay contención)
DesdeJava 5Java 1.0Java 1.0
CuándoPor defecto, siempreSolo si de verdad varios hilos comparten el mismo bufferPara el resultado final y para pasarlo por ahí
// StringBuilder: los métodos que de verdad usarás
StringBuilder sb = new StringBuilder(256);      // capacidad inicial: evita realocaciones
sb.append("Pedido ").append(4711).append('\n') // append está sobrecargado para todo
  .append(String.format("%-20s %8.2f%n", "Teclado", 49.9));
sb.insert(0, "=== ").append(" ===");           // insertar en cualquier posición
sb.replace(0, 3, "***");                       // reemplazar por rango
sb.deleteCharAt(sb.length() - 1);              // borrar el último
sb.setLength(0);                               // ← REUTILIZAR el buffer, sin crear otro
System.out.println(sb.isEmpty());              // true (Java 15+)

// Invertir una cadena: el único caso donde StringBuilder es la respuesta directa
String invertida = new StringBuilder("Hola").reverse().toString();  // "aloH"
// Cuidado: reverse() rompe los pares subrogados si hay emojis.

// Capacidad y crecimiento: el buffer arranca en 16 y duplica + 2 al llenarse.
// Si vas a escribir 1 MB, dar la capacidad de golpe evita ~17 copias del array.
var grande = new StringBuilder(1_000_000);

3.5 Los métodos de String que debes conocer (incluidos los modernos)

MétodoDesdeQué hace y cuándo lo quieres
isEmpty()6length() == 0. No detecta espacios.
isBlank()11Vacía o solo espacios en blanco Unicode. Es la que quieres para validar entradas.
strip() / stripLeading() / stripTrailing()11Como trim() pero con la definición Unicode de espacio en blanco. trim() solo quita caracteres ≤ U+0020 y deja espacios finos, no separables, etc.
repeat(n)11"-".repeat(60) en lugar de un bucle.
lines()11Stream<String> de líneas, respetando \n, \r\n y \r. Perfecto con text blocks.
chars() / codePoints()8/9IntStream de unidades de código o de puntos de código.
formatted(args)15"%s".formatted(x), versión de instancia de String.format. Encadena mucho mejor.
stripIndent(), translateEscapes()15Utilidades para trabajar con text blocks construidos dinámicamente.
indent(n)12Sangra todas las líneas. Útil para generar informes o YAML.
describeConstable()12Interoperabilidad con constantes dinámicas. No la necesitarás.
transform(f)12Aplica una función a la cadena. Permite encadenar sin anidar: s.strip().transform(this::normalizar).
split(regex, límite)1.4Ojo: el argumento es una expresión regular. Para partir por un punto necesitas split("\\.").
matches, replaceAll1.4También regex. Para texto literal, replace (sin All) y contains.
compareTo / compareToIgnoreCase1.0Orden lexicográfico por punto de código. No es orden alfabético en español: usa Collator.
equalsIgnoreCase1.0Cuidado con las mayúsculas dependientes de la localización (el famoso caso de la i turca).
join8String.join(", ", coleccion).
valueOf1.0Convierte cualquier cosa a cadena, incluido null"null" (a diferencia de toString(), que lanzaría NPE).
import java.text.Collator;
import java.util.List;
import java.util.Locale;

public class MetodosString {
    public static void main(String[] args) {

        // ---------- Validación de entradas ----------
        String entrada = "  \t Rodríguez \u00A0";        // con espacio no separable al final
        System.out.println("[" + entrada.trim()  + "]");  // [Rodríguez  ] ← queda el U+00A0
        System.out.println("[" + entrada.strip() + "]");  // [Rodríguez]   ← correcto
        System.out.println("   ".isEmpty());              // false
        System.out.println("   ".isBlank());              // true  ← usa esta

        // ---------- split y sus trampas ----------
        System.out.println(List.of("a.b.c".split("\\.")));      // [a, b, c]
        System.out.println("a,b,,".split(",").length);           // 2 ← ¡descarta los vacíos finales!
        System.out.println("a,b,,".split(",", -1).length);       // 4 ← con límite negativo, todos
        System.out.println(List.of("uno; dos ;tres".split("\\s*;\\s*")));  // [uno, dos, tres]

        // ---------- Orden alfabético en español ----------
        var nombres = new java.util.ArrayList<>(List.of("Ávila", "Zamora", "Ãngel", "ñu", "nube"));
        nombres.sort(null);                                       // orden por punto de código
        System.out.println(nombres);   // los acentos y la ñ acaban al final: MAL

        nombres.sort(Collator.getInstance(new Locale("es", "ES")));
        System.out.println(nombres);   // orden alfabético español correcto

        // ---------- Mayúsculas y el caso turco ----------
        System.out.println("TITLE".toLowerCase());                     // depende del Locale!
        System.out.println("TITLE".toLowerCase(new Locale("tr")));     // tıtle ← i sin punto
        System.out.println("TITLE".toLowerCase(Locale.ROOT));          // title ← determinista
        // REGLA: para identificadores, claves, cabeceras HTTP y comparaciones internas,
        // usa SIEMPRE Locale.ROOT. Para texto que ve el usuario, el Locale de la petición.

        // ---------- format y formatted ----------
        String linea = "%-12s %6.2f €  (%3d uds)".formatted("Teclado", 49.9, 12);
        System.out.println(linea);
        System.out.printf(Locale.of("es", "ES"), "%,.2f%n", 1234567.891);  // 1.234.567,89
        System.out.printf(Locale.US,             "%,.2f%n", 1234567.891);  // 1,234,567.89

        // ---------- transform: encadenar sin anidar ----------
        String limpio = "  HOLA, MUNDO  "
                .strip()
                .toLowerCase(Locale.ROOT)
                .transform(s -> s.replace(",", ""))
                .transform(s -> s.substring(0, 1).toUpperCase(Locale.ROOT) + s.substring(1));
        System.out.println(limpio);      // Hola mundo
    }
}

3.6 Text blocks: texto multilínea sin ruido

Los bloques de texto (Java 15) eliminan el infierno de \n y \" en SQL, JSON, HTML y mensajes largos. Tres reglas que hay que saber: el sangrado se calcula respecto a la línea menos sangrada (incluida la de cierre), las líneas terminan en \n normalizado, y \ al final de línea une líneas sin salto.

// ❌ Antes: SQL ilegible y propenso a errores de espacios
String sqlViejo = "SELECT p.id, p.total, c.nombre\n" +
                  "FROM pedido p\n" +
                  "JOIN cliente c ON c.id = p.cliente_id\n" +
                  "WHERE p.estado = 'PAGADO'\n" +
                  "  AND p.creado_en >= ?\n" +
                  "ORDER BY p.creado_en DESC";

// ✅ Ahora: se lee como SQL, y se puede copiar y pegar a psql
String sql = """
        SELECT p.id, p.total, c.nombre
        FROM   pedido p
        JOIN   cliente c ON c.id = p.cliente_id
        WHERE  p.estado = 'PAGADO'
          AND  p.creado_en >= ?
        ORDER  BY p.creado_en DESC
        """;

// JSON sin escapar ni una comilla
String json = """
        {
          "id": 4711,
          "cliente": { "nombre": "Ana", "vip": true },
          "lineas": [
            { "sku": "SKU-000123", "uds": 2, "precio": 49.90 }
          ]
        }
        """;

// El sangrado se mide desde la línea MENOS sangrada, incluida la de cierre.
// Con la comilla de cierre pegada a la izquierda, se conserva todo el sangrado:
String conSangrado = """
            uno
              dos
""";                          // → "            uno\n              dos\n"

// Con \ al final: una sola línea larga, legible en el fuente
String urlLarga = """
        https://api.ejemplo.com/v1/pedidos\
        ?estado=PAGADO\
        &desde=2026-01-01\
        &limite=100""";

// Con \s: preservar espacios al final de una línea (se comerían si no)
String tabla = """
        Nombre    Precio\s
        Teclado    49.90\s
        """;

// Interpolación: Java NO la tiene. Se usa formatted:
String saludo = """
        Hola %s,
        tu pedido %d está en camino.
        """.formatted("Ana", 4711);
Uso favorito de los text blocks: los tests. Un JSON esperado, un cuerpo de petición o una tabla de datos escritos como bloque de texto se leen de un vistazo y el diff del control de versiones es limpio. En combinación con lines() y strip(), los test fixtures dejan de ser un muro de escapes: DATOS.lines().filter(l -> !l.isBlank()).map(l -> l.split("\\s*\\|\\s*")).

3.7 Rendimiento comparado: los números que importan

Operación (1 millón de veces, orden de magnitud)Coste relativoNota
a + b con dos variables×1 (referencia)Desde Java 9, invokedynamic con tamaño exacto: difícil de mejorar.
sb.append(a).append(b)≈ ×1,1Igual o algo peor para casos pequeños. Solo gana en bucles.
String.format("%s%s", a, b)≈ ×20–30Parsea la plantilla en cada llamada. Nunca en un bucle caliente ni en un log de debug.
s.equals(t)×1Compara longitud primero; con strings compactos, comparación de arrays vectorizada.
s.matches("regex")≈ ×50Compila la regex en cada llamada. Usa un static final Pattern.
s.split(",")≈ ×5Optimizado para separadores de un solo carácter sin metacaracteres; con regex real, mucho peor.
s.hashCode() (repetido)≈ ×0,05Cacheado: la segunda vez es gratis. Esta es la razón de que String sea buena clave.
s.intern()≈ ×100Llamada nativa con bloqueo en la tabla del pool. No lo pongas en un camino caliente.
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexBienHecha {

    // ✅ Compilar UNA vez. Pattern es inmutable y seguro entre hilos.
    private static final Pattern EMAIL =
            Pattern.compile("^[\\w.+-]+@[\\w-]+\\.[\\w.]{2,}$");
    private static final Pattern IBAN_ES =
            Pattern.compile("^ES\\d{22}$");
    private static final Pattern ESPACIOS = Pattern.compile("\\s+");

    static boolean esEmail(String s) {
        return s != null && EMAIL.matcher(s).matches();      // Matcher NO es seguro entre hilos
    }

    static String normalizarEspacios(String s) {
        return ESPACIOS.matcher(s.strip()).replaceAll(" ");
    }

    // ❌ Esto compila la expresión regular un millón de veces
    static boolean esEmailLento(String s) {
        return s.matches("^[\\w.+-]+@[\\w-]+\\.[\\w.]{2,}$");
    }

    public static void main(String[] args) {
        System.out.println(esEmail("ana@ejemplo.com"));          // true
        System.out.println(normalizarEspacios("  hola    mundo ")); // "hola mundo"

        // Extraer grupos con nombre: mucho más legible que grupos numerados
        Pattern fecha = Pattern.compile("(?<anio>\\d{4})-(?<mes>\\d{2})-(?<dia>\\d{2})");
        Matcher m = fecha.matcher("Vence el 2026-03-15 sin remedio");
        if (m.find()) {
            System.out.printf("%s/%s/%s%n", m.group("dia"), m.group("mes"), m.group("anio"));
        }
    }
}
ReDoS: una regex puede tumbar tu servicio. Expresiones con cuantificadores anidados como (a+)+$ o (\\w+\\s?)*$ tienen retroceso exponencial: con una entrada de 30 caracteres cuidadosamente elegida, el hilo se queda minutos al 100 % de CPU. Es un ataque de denegación de servicio real y frecuente. Reglas: no construyas regex a partir de entradas del usuario, evita cuantificadores anidados, limita la longitud de la entrada antes de aplicar la regex, y para validaciones sencillas usa código normal (startsWith, indexOf, length) que siempre es lineal. Más detalle en el módulo 10.

Comprobación rápida de la sección 3

4 · POO de verdad: encapsulación, herencia y diseño

“Encapsulación, herencia, polimorfismo y abstracción” es la respuesta de manual y no dice nada. Lo que se pregunta de verdad en una entrevista es cuándo usar cada cosa y qué se rompe cuando eliges mal. Esta sección va de eso: los cuatro pilares aplicados a decisiones concretas, con el código antes y después.

4.1 Encapsulación: proteger invariantes, no esconder campos

Encapsular no es “poner los campos private y generar getters y setters”. Eso es dejar los campos públicos con pasos extra. Encapsular es garantizar que el objeto nunca está en un estado inválido: identificas los invariantes de la clase y no ofreces ninguna forma de violarlos.

// ❌ Modelo anémico: la clase no protege nada. Cualquiera puede dejarla inconsistente.
public class CuentaMal {
    private String iban;
    private BigDecimal saldo;
    private boolean bloqueada;

    public String getIban() { return iban; }
    public void setIban(String iban) { this.iban = iban; }          // ¿cambiar el IBAN?
    public BigDecimal getSaldo() { return saldo; }
    public void setSaldo(BigDecimal saldo) { this.saldo = saldo; }   // saldo negativo, null...
    public boolean isBloqueada() { return bloqueada; }
    public void setBloqueada(boolean b) { this.bloqueada = b; }
}

// La lógica termina desparramada por los servicios, repetida y sin garantías:
//   if (!cuenta.isBloqueada() && cuenta.getSaldo().compareTo(importe) >= 0) {
//       cuenta.setSaldo(cuenta.getSaldo().subtract(importe));      ← ¿y si alguien olvida el if?
//   }
// ✅ La clase defiende sus invariantes. Es IMPOSIBLE dejarla mal por fuera.
import java.math.BigDecimal;
import java.util.Objects;

public class Cuenta {

    private final String iban;              // identidad: nunca cambia
    private BigDecimal saldo;               // invariante: nunca null, nunca negativo
    private boolean bloqueada;

    public Cuenta(String iban, BigDecimal saldoInicial) {
        this.iban = validarIban(iban);
        this.saldo = exigirNoNegativo(saldoInicial);
        this.bloqueada = false;
    }

    /** Operación de negocio, no un setter: el nombre dice la intención. */
    public void retirar(BigDecimal importe) {
        exigirActiva();
        exigirPositivo(importe);
        if (saldo.compareTo(importe) < 0) {
            throw new SaldoInsuficiente(iban, importe, saldo);
        }
        saldo = saldo.subtract(importe);
    }

    public void ingresar(BigDecimal importe) {
        exigirActiva();
        exigirPositivo(importe);
        saldo = saldo.add(importe);
    }

    public void bloquear(String motivo) {
        Objects.requireNonNull(motivo, "hace falta un motivo para bloquear");
        this.bloqueada = true;
    }

    // Consultas: solo lo que el exterior necesita saber
    public String iban()        { return iban; }
    public BigDecimal saldo()   { return saldo; }      // BigDecimal es inmutable: seguro devolverlo
    public boolean estaActiva() { return !bloqueada; }

    // --- validaciones privadas: un único sitio para cada regla ---
    private void exigirActiva() {
        if (bloqueada) throw new IllegalStateException("Cuenta bloqueada: " + iban);
    }
    private static BigDecimal exigirPositivo(BigDecimal importe) {
        if (importe == null || importe.signum() <= 0)
            throw new IllegalArgumentException("El importe debe ser positivo: " + importe);
        return importe;
    }
    private static BigDecimal exigirNoNegativo(BigDecimal importe) {
        if (importe == null || importe.signum() < 0)
            throw new IllegalArgumentException("Saldo inicial inválido: " + importe);
        return importe;
    }
    private static String validarIban(String iban) {
        if (iban == null || !iban.matches("^ES\\d{22}$"))
            throw new IllegalArgumentException("IBAN español inválido: " + iban);
        return iban;
    }
}
Señal de mala encapsulaciónPor qué es un problemaQué hacer
Setters para todos los camposCualquier código puede dejar el objeto inconsistente, y no sabes quién lo hizo.Métodos con nombre de operación de negocio. Si un campo no debe cambiar nunca, final.
Devolver la colección internaEl llamante puede añadir o borrar elementos saltándose tus validaciones.List.copyOf(...) o Collections.unmodifiableList(...) al salir.
Guardar la colección que te pasanEl llamante conserva una referencia y puede mutarla después.Copia defensiva al entrar (ver 5.6).
Toda la lógica en el servicioSe repite en cada caso de uso y se olvida en alguno.Mueve la regla a la entidad. El servicio orquesta, la entidad decide.
Campos protectedConvierte el estado interno en API pública para todas las subclases, presentes y futuras.private + método protected si de verdad la subclase lo necesita.
Constructor vacío + setters obligatoriosExiste un intervalo en el que el objeto es inválido; y nada obliga a completarlo.Constructor con todo lo necesario, o builder que valide en build().
«Pero JPA/Jackson necesitan un constructor vacío y setters». Es la objeción habitual y tiene respuesta. JPA necesita un constructor sin argumentos, pero puede ser protected, y puede acceder a los campos directamente (@Access(FieldAccess.FIELD)): no hacen falta setters públicos. Jackson sabe usar constructores con @JsonCreator o el parameter names module, y desde la versión 2.12 entiende record de forma nativa. Y sobre todo: una entidad de persistencia y un DTO de API no tienen que ser la misma clase. El DTO puede ser un record plano y la entidad, un objeto de dominio bien encapsulado. Detalle en los módulos 04 y 05.

4.2 Herencia frente a composición: por qué la herencia se rompe

La herencia crea el acoplamiento más fuerte que existe en programación orientada a objetos: la subclase depende de los detalles de implementación de la superclase, no solo de su API. Y esos detalles pueden cambiar en la siguiente versión sin que nadie lo considere un cambio incompatible. El ejemplo canónico (de Effective Java) es demoledor:

import java.util.Collection;
import java.util.HashSet;
import java.util.List;

// ❌ Un HashSet que cuenta cuántos elementos se han intentado añadir. Parece trivial.
public class ConjuntoInstrumentado<E> extends HashSet<E> {

    private int intentosDeAnadir = 0;

    @Override public boolean add(E e) {
        intentosDeAnadir++;
        return super.add(e);
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        intentosDeAnadir += c.size();
        return super.addAll(c);
    }

    public int intentosDeAnadir() { return intentosDeAnadir; }

    public static void main(String[] args) {
        var s = new ConjuntoInstrumentado<String>();
        s.addAll(List.of("uno", "dos", "tres"));
        System.out.println(s.intentosDeAnadir());   // ¿3? NO: imprime 6
    }
}

¿Por qué 6? Porque HashSet.addAll está implementado llamando a add en un bucle. Tu addAll suma 3, llama a super.addAll, y este llama a tu add sobrescrito tres veces, que suma 1 cada vez. El contador se dobla. Y lo peor: ese comportamiento no está documentado como parte del contrato, así que podría cambiar en cualquier versión del JDK y romper tu clase sin que tú toques nada.

// ✅ Composición + delegación: solo dependes de la API pública de Set, no de cómo esté hecha.
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

public class ConjuntoContador<E> implements Set<E> {

    private final Set<E> delegado;         // "tiene un", no "es un"
    private int intentosDeAnadir = 0;

    public ConjuntoContador(Set<E> delegado) { this.delegado = delegado; }

    @Override public boolean add(E e) {
        intentosDeAnadir++;
        return delegado.add(e);
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        intentosDeAnadir += c.size();
        return delegado.addAll(c);          // sea como sea addAll, no reentra en nuestro add
    }

    public int intentosDeAnadir() { return intentosDeAnadir; }

    // El resto, delegación pura (el IDE la genera en dos teclas)
    @Override public int size()                  { return delegado.size(); }
    @Override public boolean isEmpty()           { return delegado.isEmpty(); }
    @Override public boolean contains(Object o)  { return delegado.contains(o); }
    @Override public Iterator<E> iterator()      { return delegado.iterator(); }
    @Override public Object[] toArray()          { return delegado.toArray(); }
    @Override public <T> T[] toArray(T[] a)      { return delegado.toArray(a); }
    @Override public boolean remove(Object o)    { return delegado.remove(o); }
    @Override public boolean containsAll(Collection<?> c) { return delegado.containsAll(c); }
    @Override public boolean retainAll(Collection<?> c)   { return delegado.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c)   { return delegado.removeAll(c); }
    @Override public void clear()                { delegado.clear(); }
    @Override public boolean equals(Object o)    { return delegado.equals(o); }
    @Override public int hashCode()              { return delegado.hashCode(); }
    @Override public String toString()           { return delegado.toString(); }
}

// Ventaja añadida: funciona con CUALQUIER Set, no solo con HashSet.
//   new ConjuntoContador<>(new TreeSet<>());
//   new ConjuntoContador<>(ConcurrentHashMap.newKeySet());
//   new ConjuntoContador<>(EnumSet.noneOf(Estado.class));
Herencia (extends)Composición (delegar)
Relación“es un”“tiene un” / “usa un”
AcoplamientoMáximo: dependes de la implementación del padreMínimo: dependes solo de una interfaz
Se decide enCompilación: fijo para siempreEjecución: puedes cambiar el colaborador
MultiplicidadUna sola superclaseTantos colaboradores como quieras
TestabilidadDifícil: arrastras todo el padreFácil: inyectas un doble de prueba
Reutilizar códigoTentador y peligrosoEl mecanismo correcto
Cuándo es adecuadaJerarquías de tipos estables y cerradas que tú controlas: sealed, excepciones, clases abstract pensadas para extender y documentadas como talesEl resto de los casos, que son casi todos
El test de Liskov en una frase. Antes de escribir extends, pregúntate: «¿puedo sustituir el padre por el hijo en cualquier sitio sin que nada se rompa?». Si la respuesta necesita un “sí, pero…”, no es herencia. Los ejemplos clásicos de fallo: Cuadrado extends Rectangulo (un rectángulo permite cambiar ancho y alto por separado; un cuadrado no), ListaInmutable extends ArrayList (lanza UnsupportedOperationException en la mitad de los métodos) y Pingüino extends Ave si Ave tiene volar().

Y si de verdad diseñas una clase para ser heredada, tienes tres obligaciones (también de Effective Java): documentar qué métodos llaman a qué métodos sobrescribibles (self-use), no llamar nunca a un método sobrescribible desde el constructor, y proporcionar hooks protected pensados. Si no vas a hacer las tres, declara la clase final:

// ❌ El bug clásico: llamar a un método sobrescribible desde el constructor.
class Padre {
    Padre() {
        inicializar();          // se ejecuta ANTES de que el constructor del hijo asigne sus campos
    }
    void inicializar() { }
}

class Hijo extends Padre {
    private final String nombre = "hijo";       // ¡todavía no asignado cuando se llama!
    private final java.util.List<String> datos = new java.util.ArrayList<>();

    @Override void inicializar() {
        System.out.println("nombre = " + nombre);   // imprime null
        datos.add("x");                             // NullPointerException
    }
}
// Orden real de inicialización:
//   1. campos static del padre → 2. constructor del padre (¡aquí se llama a inicializar!)
//   3. inicializadores de campos del hijo → 4. cuerpo del constructor del hijo

4.3 Polimorfismo y despacho dinámico

El polimorfismo de subtipo es la razón por la que existe la orientación a objetos: escribes el código contra un tipo abstracto y la JVM decide en ejecución qué implementación llamar. Ese mecanismo se llama despacho dinámico y lo implementa la instrucción invokevirtual consultando la tabla de métodos virtuales de la clase real del objeto.

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

public interface CalculoImpuesto {
    BigDecimal calcular(BigDecimal base);

    // Fábrica estática: el punto único donde se elige la implementación
    static CalculoImpuesto para(String pais) {
        return switch (pais) {
            case "ES" -> new IvaEspanol();
            case "PT" -> new IvaPortugues();
            default   -> new SinImpuesto();
        };
    }
}

final class IvaEspanol implements CalculoImpuesto {
    @Override public BigDecimal calcular(BigDecimal base) { return base.multiply(new BigDecimal("0.21")); }
}
final class IvaPortugues implements CalculoImpuesto {
    @Override public BigDecimal calcular(BigDecimal base) { return base.multiply(new BigDecimal("0.23")); }
}
final class SinImpuesto implements CalculoImpuesto {
    @Override public BigDecimal calcular(BigDecimal base) { return BigDecimal.ZERO; }
}

// ---------- El código cliente no conoce ninguna implementación concreta ----------
class Facturacion {
    BigDecimal total(BigDecimal base, String pais) {
        return base.add(CalculoImpuesto.para(pais).calcular(base));   // invokeinterface
    }
}

// ---------- Versión aún mejor: un mapa, cero if y cero switch ----------
class FacturacionConMapa {
    private final Map<String, CalculoImpuesto> porPais;
    FacturacionConMapa(Map<String, CalculoImpuesto> porPais) { this.porPais = Map.copyOf(porPais); }

    BigDecimal total(BigDecimal base, String pais) {
        var impuesto = porPais.getOrDefault(pais, new SinImpuesto());
        return base.add(impuesto.calcular(base));
    }
}
// En Spring, ese mapa lo construye el contenedor: inyecta Map<String, CalculoImpuesto>
// y las claves son los nombres de los beans. Añadir un país = añadir una clase.
El JIT y el polimorfismo: mucha gente evita las interfaces “por rendimiento”. No lo hagas. Si en un punto de llamada concreto la JVM observa que casi siempre llega el mismo tipo (llamada monomórfica), el JIT hace inlining y el coste desaparece; con dos tipos (bimórfica) todavía optimiza bien. Solo cuando llegan muchos tipos distintos (megamórfica, más de dos) hay un coste real, y suele ser despreciable frente a lo demás. Diseña por claridad y mide después.

4.4 Sobrecarga y sobrescritura: se eligen en momentos distintos

Es la diferencia que más candidatos confunden. La sobrecarga se resuelve en compilación por el tipo declarado; la sobrescritura, en ejecución por el tipo real. De ahí salen resultados que parecen imposibles:

Sobrecarga (overloading)Sobrescritura (overriding)
Qué esVarios métodos con el mismo nombre y distintos parámetros, en la misma clase o heredadosRedefinir en la subclase un método de la superclase con la misma firma
Cuándo se decideCompilación, por el tipo estático del argumentoEjecución, por el tipo dinámico del objeto
FirmaDebe cambiar (número, tipo u orden de parámetros)Idéntica (o con retorno covariante)
Tipo de retornoPuede ser distintoEl mismo o un subtipo
VisibilidadLibreIgual o más amplia; nunca más restrictiva
ExcepcionesLibresNo puede añadir checked nuevas ni más amplias
static / private / finalSe pueden sobrecargarNo se pueden sobrescribir (con static se “oculta”, que no es lo mismo)
AnotaciónNinguna@Override: ponla siempre, es el compilador validando tu intención
import java.util.ArrayList;
import java.util.List;

public class SobrecargaVsSobrescritura {

    // --- Sobrecarga: se elige en COMPILACIÓN ---
    static String describir(Object o) { return "Object"; }
    static String describir(String s) { return "String"; }
    static String describir(Integer i) { return "Integer"; }

    // --- Sobrescritura: se elige en EJECUCIÓN ---
    static class Animal { String sonido() { return "..."; } }
    static class Perro extends Animal { @Override String sonido() { return "guau"; } }

    public static void main(String[] args) {

        Object comoObject = "soy una cadena";
        String comoString = "soy una cadena";

        System.out.println(describir(comoObject));   // "Object"  ← el tipo DECLARADO manda
        System.out.println(describir(comoString));   // "String"
        System.out.println(describir(null));         // "String"  ← el más específico aplicable
        // Si hubiera describir(StringBuilder) además de describir(String), describir(null)
        // NO compilaría: ambigüedad. Es un olor a diseño confuso.

        Animal animal = new Perro();
        System.out.println(animal.sonido());         // "guau"   ← el tipo REAL manda

        // La trampa favorita de las entrevistas: remove(int) frente a remove(Object)
        List<Integer> lista = new ArrayList<>(List.of(10, 20, 30));
        lista.remove(1);                             // remove(int índice) → borra el 20
        System.out.println(lista);                   // [10, 30]
        lista.remove(Integer.valueOf(30));           // remove(Object) → borra el valor 30
        System.out.println(lista);                   // [10]
        // Con List<Long> es peor: lista.remove(1) no compila... y lista.remove(1L) sí,
        // porque no hay remove(long). Genera bugs difíciles de ver en revisiones.

        // Otra: la sobrecarga con varargs siempre pierde
        System.out.println(elegir(1, 2));            // "dos int" (no "varargs")
    }

    static String elegir(int a, int b)     { return "dos int"; }
    static String elegir(int... valores)   { return "varargs"; }
}
No sobrecargues métodos con el mismo número de parámetros si los tipos pueden confundirse (Object/String, int/Integer, long/Long, interfaces relacionadas). Es la recomendación de Effective Java y evita una clase entera de bugs: el lector cree que se llama a un método y se llama a otro. Si necesitas varias formas de construir o de operar, usa nombres distintos: desdeTexto, desdeId, desdeFichero. Es más largo de escribir y muchísimo más claro de leer.

4.5 static, final y el orden de inicialización

ModificadorEn un campoEn un métodoEn una clase
static Pertenece a la clase, no a la instancia. Una única copia. Estado global: úsalo solo para constantes. No recibe this; no se puede sobrescribir (se oculta). Solo en clases anidadas: significa “sin referencia a la instancia exterior”. Debería ser tu opción por defecto.
final No se puede reasignar. El objeto apuntado sí puede mutar. Además da garantías de visibilidad entre hilos. No se puede sobrescribir. Facilita el inlining del JIT. No se puede extender. String, Integer, los record y los enum lo son.
static final Constante. Si es un primitivo o un String, se pliega en compilación.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class StaticYFinal {

    // ✅ Constante de verdad: inmutable y compartida sin riesgo
    static final int MAXIMO_INTENTOS = 3;
    static final List<String> ESTADOS = List.of("NUEVO", "PAGADO", "ENVIADO");   // inmutable

    // ❌ FALSA constante: la referencia es final, el contenido es mutable y GLOBAL
    static final List<String> CACHE_MALA = new ArrayList<>();
    // Cualquiera puede hacer CACHE_MALA.add(...) desde cualquier hilo. Es a la vez
    // un bug de concurrencia, una fuga de memoria y un estado global sin dueño.

    // ❌ Otra falsa constante: array mutable
    static final int[] LIMITES = {10, 20, 30};
    // LIMITES[0] = 99;   ← compila perfectamente

    // ✅ Mapa constante de verdad
    static final Map<String, Integer> PRIORIDAD = Map.of("ALTA", 1, "MEDIA", 2, "BAJA", 3);

    // --- Orden de inicialización, la pregunta trampa ---
    static { System.out.println("3. bloque static"); }
    static int estatico = imprimir("2. campo static");

    int instancia = imprimir("5. campo de instancia");
    { System.out.println("6. bloque de instancia"); }

    StaticYFinal() { System.out.println("7. constructor"); }

    static int imprimir(String s) { System.out.println(s); return 0; }

    public static void main(String[] args) {
        System.out.println("1. main empieza (la clase ya se inicializó antes)");
        new StaticYFinal();
        new StaticYFinal();     // los static NO se repiten; los de instancia sí
    }
}
// Orden real de la salida:
//   2. campo static          ← los static, en orden de aparición en el fuente
//   3. bloque static
//   1. main empieza
//   5. campo de instancia    ← por cada new: campos, bloques, y al final el constructor
//   6. bloque de instancia
//   7. constructor
//   5. / 6. / 7. otra vez
static es estado global disfrazado. Un campo static mutable es un singleton sin control: complica los tests (el estado se filtra entre ellos), rompe la concurrencia (nadie sincroniza) y provoca fugas de memoria (nunca se recolecta, porque la clase vive mientras viva su class loader). Regla: static final con valores inmutables, sí; static mutable, prácticamente nunca. Si necesitas una instancia compartida, que la gestione el contenedor de inyección de dependencias, que sabe crearla, configurarla y sustituirla en los tests.

4.6 Clases internas, anónimas y locales

TipoSintaxisAcceso al exteriorCuándo usarla
Anidada estáticastatic class Nodo { }Solo a los miembros staticLa opción por defecto. Agrupar una clase auxiliar donde se usa: Map.Entry, un nodo de una estructura, un builder.
Interna (no estática)class Iterador { }A todo, incluido this del exteriorSolo si de verdad necesita la instancia exterior: un iterador propio, una vista. Riesgo de fuga de memoria.
LocalDeclarada dentro de un métodoA las variables locales effectively finalRara. Cuando la clase solo tiene sentido dentro de un algoritmo largo.
Anónimanew Interfaz() { … }Igual que la localCuando necesitas estado o sobrescribir varios métodos. Si es una interfaz funcional, usa una lambda.
import java.util.AbstractList;
import java.util.Comparator;
import java.util.List;
import java.util.function.Supplier;

public class TiposDeClases {

    private String nombre = "exterior";

    // 1. Anidada ESTÁTICA: no arrastra referencia al exterior. Casi siempre lo correcto.
    static class Punto {
        final int x, y;
        Punto(int x, int y) { this.x = x; this.y = y; }
    }

    // 2. Interna NO estática: tiene un campo sintético this$0 hacia TiposDeClases
    class Vista {
        String describir() { return "vista de " + nombre; }   // accede al campo exterior
    }

    void ejemplos() {
        int limite = 10;                 // effectively final: no se reasigna

        // 3. Clase LOCAL
        class Contador {
            int valor;
            boolean cabe() { return valor < limite; }   // captura 'limite'
        }
        var c = new Contador();

        // 4. Clase ANÓNIMA con estado: aquí una lambda NO sirve
        Supplier<Integer> secuencia = new Supplier<>() {
            private int siguiente = 0;                  // ← estado propio
            @Override public Integer get() { return siguiente++; }
        };
        System.out.println(secuencia.get() + ", " + secuencia.get() + ", " + secuencia.get());

        // 4b. Clase anónima sobrescribiendo VARIOS métodos: tampoco vale una lambda
        var soloLectura = new AbstractList<String>() {
            @Override public String get(int i) { return "elemento " + i; }
            @Override public int size() { return limite; }
        };
        System.out.println(soloLectura);

        // 5. Con una interfaz funcional, la lambda gana siempre
        Comparator<String> porLongitudAnonima = new Comparator<>() {
            @Override public int compare(String a, String b) { return a.length() - b.length(); }
        };
        Comparator<String> porLongitudLambda = Comparator.comparingInt(String::length);
        System.out.println(porLongitudAnonima.compare("ab", "abc") + " "
                         + porLongitudLambda.compare("ab", "abc"));
        System.out.println(c.cabe() + " " + new Vista().describir() + " " + new Punto(1,2).x);
    }

    public static void main(String[] args) { new TiposDeClases().ejemplos(); }
}
La fuga de memoria de las clases internas. Una clase interna no estática mantiene una referencia fuerte a su instancia exterior. Si guardas esa instancia interna en algún sitio de vida larga —una caché estática, un listener registrado, una cola— el objeto exterior y todo lo que él referencia no se podrá recolectar nunca. Es una causa clásica de OutOfMemoryError en aplicaciones que parecen no guardar nada. La regla es trivial: si la clase anidada no necesita el exterior, declárala static. Todos los analizadores estáticos lo avisan; hazles caso.

4.7 Lambdas frente a clases anónimas: no son azúcar sintáctico

Clase anónimaLambda
Qué genera el compiladorUna clase real: Exterior$1.classUn método privado sintético + invokedynamic. No genera una clase por lambda.
Significado de thisLa instancia anónimaLa instancia envolvente (léxico). Esta es la diferencia que más sorprende.
Estado propioSí: puede tener camposNo: solo captura variables effectively final
MétodosCualquier númeroUno: solo interfaces funcionales
Coste de arranqueSe carga y verifica una clase másSe enlaza en el primer uso; luego, coste cero
Tamaño del jar y arranque+1 fichero .class por cada unaSin ficheros extra, pero enlazado perezoso (importa en serverless; ver módulo 11)
Sombrear variablesPuede declarar una variable con el nombre de una del métodoNo puede: comparte el ámbito léxico
DepuraciónTraza legible: Exterior$1.metodoTraza con nombres sintéticos: lambda$procesar$0
public class LambdaVsAnonima {

    private String nombre = "objeto exterior";

    void demostrar() {

        Runnable conClaseAnonima = new Runnable() {
            private String nombre = "clase anónima";     // puede tener su propio campo
            @Override public void run() {
                System.out.println(this.nombre);                    // "clase anónima"
                System.out.println(LambdaVsAnonima.this.nombre);    // "objeto exterior"
            }
        };

        Runnable conLambda = () -> {
            // No puedo declarar 'nombre' aquí: colisionaría con el ámbito del método.
            System.out.println(this.nombre);      // "objeto exterior": this es el envolvente
        };

        conClaseAnonima.run();
        conLambda.run();

        // Captura: la variable debe ser "effectively final"
        int contador = 0;
        Runnable malo = () -> System.out.println(contador);
        // contador++;         // ← si descomentas esto, la lambda deja de compilar

        // El truco (feo) para "mutar" desde una lambda, y por qué casi nunca lo quieres:
        var acumulador = new int[1];                     // o AtomicInteger
        java.util.stream.IntStream.rangeClosed(1, 5).forEach(i -> acumulador[0] += i);
        System.out.println(acumulador[0]);               // 15
        // Mejor: usar reduce o sum, que expresan la intención y funcionan en paralelo
        System.out.println(java.util.stream.IntStream.rangeClosed(1, 5).sum());   // 15
        malo.run();
    }

    public static void main(String[] args) { new LambdaVsAnonima().demostrar(); }
}
Por qué la captura debe ser effectively final. Porque la lambda copia el valor de la variable local, y las variables locales viven en la pila del hilo que las creó. Si la lambda se ejecuta más tarde, o en otro hilo, esa pila puede haber desaparecido. Permitir la mutación exigiría convertir la variable en un objeto del heap compartido (como hace JavaScript con las clausuras) y abriría la puerta a carreras de datos invisibles. Java eligió la opción segura y la deja explícita: si necesitas estado mutable compartido, dilo con un AtomicInteger o un objeto, y así el lector lo ve.

4.8 Interfaces modernas: default, static y private

Hasta Java 7 una interfaz solo podía declarar métodos abstractos y constantes. Añadir un método a una interfaz publicada rompía todas las implementaciones del mundo. Java 8 introdujo default exactamente para poder añadir Collection.stream() sin romper el ecosistema entero.

public interface Notificador {

    // 1. Abstracto: el contrato que hay que implementar
    void enviar(String destino, String mensaje);

    // 2. default: comportamiento heredado que se puede sobrescribir.
    //    Su razón de ser es la EVOLUCIÓN de la interfaz sin romper implementaciones.
    default void enviarUrgente(String destino, String mensaje) {
        enviar(destino, formatearUrgente(mensaje));
    }

    default void enviarATodos(java.util.Collection<String> destinos, String mensaje) {
        destinos.forEach(d -> enviar(d, mensaje));
    }

    // 3. static: utilidades y fábricas que pertenecen al concepto, no a una instancia.
    //    Antes vivían en una clase "NotificadorUtils" que nadie sabía dónde buscar.
    static Notificador noOperativo() { return (destino, mensaje) -> { }; }

    static Notificador consola() {
        return (destino, mensaje) -> System.out.printf("[%s] %s%n", destino, mensaje);
    }

    // 4. private: código compartido entre los métodos default, SIN exponerlo (Java 9+).
    //    Antes había que hacerlo público o duplicarlo.
    private static String formatearUrgente(String mensaje) {
        return "[URGENTE] " + mensaje.strip();
    }

    // 5. Constantes: siempre public static final, aunque no lo escribas
    int LONGITUD_MAXIMA = 160;
}

// Combinar decoradores con default es elegante:
interface NotificadorConReintentos extends Notificador {
    int MAX_INTENTOS = 3;

    default void enviarConReintentos(String destino, String mensaje) {
        RuntimeException ultimo = null;
        for (int intento = 1; intento <= MAX_INTENTOS; intento++) {
            try { enviar(destino, mensaje); return; }
            catch (RuntimeException e) { ultimo = e; }
        }
        throw new IllegalStateException("Fallaron " + MAX_INTENTOS + " intentos", ultimo);
    }
}

Los métodos default introducen una forma limitada de herencia múltiple de comportamiento (nunca de estado). Cuando dos interfaces aportan el mismo método default, el compilador exige que resuelvas el conflicto a mano:

interface A { default String saludar() { return "A"; } }
interface B { default String saludar() { return "B"; } }

// ❌ No compila: "class C inherits unrelated defaults for saludar() from types A and B"
// class C implements A, B { }

// ✅ Resolución explícita con la sintaxis Interfaz.super.metodo()
class C implements A, B {
    @Override public String saludar() { return A.super.saludar() + B.super.saludar(); }
}

// Reglas de precedencia cuando hay conflicto (en este orden):
//   1. La CLASE gana siempre a cualquier interfaz.
//   2. La subinterfaz más específica gana a la más general.
//   3. Si están al mismo nivel, error de compilación: decides tú.
interface Base { default String quien() { return "Base"; } }
interface Derivada extends Base { @Override default String quien() { return "Derivada"; } }
class Usa implements Base, Derivada { }     // compila: Derivada es más específica → "Derivada"

4.9 Clase abstracta o interfaz: la tabla de decisión

CriterioInterfazClase abstracta
Estado (campos de instancia)No puede tener
ConstructorNoSí (llamado por las subclases)
MultiplicidadUna clase implementa muchasSolo una superclase: gasta “la bala”
Métodos con cuerpodefault, static, privateCualquiera
Visibilidad de los miembrospublic implícito (o private para helpers)Cualquiera, incluido protected
CamposSolo public static finalCualquiera
Se puede añadir un método despuésSí, si es defaultSí, si no es abstracto
Interfaz funcional / lambdaSí, si tiene un solo método abstractoNunca
ExpresaUna capacidad: “esto se puede comparar, cerrar, serializar”Una identidad parcial: “esto es un tipo de X y comparte estado y algoritmo”
Úsala paraDefinir contratos, puertos de la arquitectura hexagonal, tipos de estrategia, todo lo que se inyecteTemplate Method: un algoritmo fijo con huecos; código común real entre implementaciones
// El patrón que combina las dos y que verás por todo el JDK y por todo Spring:
// INTERFAZ para el contrato + CLASE ABSTRACTA "Base"/"Abstract" para el trabajo común.

public interface RepositorioPedidos {                 // el contrato: lo que ve el dominio
    java.util.Optional<Pedido> porId(String id);
    void guardar(Pedido pedido);
    java.util.List<Pedido> deCliente(String clienteId);
}

/** Trabajo común de verdad: validación, medición y trazas. Los huecos son abstractos. */
public abstract class RepositorioPedidosBase implements RepositorioPedidos {

    private final java.util.concurrent.atomic.AtomicLong consultas =
            new java.util.concurrent.atomic.AtomicLong();

    // Template Method: estructura fija, pasos variables
    @Override public final java.util.Optional<Pedido> porId(String id) {
        if (id == null || id.isBlank()) throw new IllegalArgumentException("id vacío");
        consultas.incrementAndGet();
        long t0 = System.nanoTime();
        try {
            return buscar(id);                                   // ← el hueco
        } finally {
            registrar("porId", System.nanoTime() - t0);
        }
    }

    protected abstract java.util.Optional<Pedido> buscar(String id);   // lo implementa cada uno

    protected void registrar(String operacion, long nanos) {           // hook opcional
        // por defecto no hace nada; las subclases pueden medir
    }

    public long consultasRealizadas() { return consultas.get(); }
}

// Así, una implementación en memoria para tests es cinco líneas:
class RepositorioEnMemoria extends RepositorioPedidosBase {
    private final java.util.Map<String, Pedido> datos = new java.util.concurrent.ConcurrentHashMap<>();
    @Override protected java.util.Optional<Pedido> buscar(String id) {
        return java.util.Optional.ofNullable(datos.get(id));
    }
    @Override public void guardar(Pedido p) { datos.put(p.id(), p); }
    @Override public java.util.List<Pedido> deCliente(String clienteId) {
        return datos.values().stream().filter(p -> p.clienteId().equals(clienteId)).toList();
    }
}
Regla de decisión en una frase: define siempre el contrato como interfaz; añade una clase abstracta solo cuando descubras código idéntico repetido en dos o más implementaciones. Nunca al contrario: no empieces por la jerarquía. Y si el código común no necesita estado, un método default o una clase de utilidades static son más flexibles que una superclase.

4.10 record: portadores de datos sin ceremonia

Un record (Java 16) declara una clase final e inmutable cuyo estado es exactamente su lista de componentes. El compilador genera el constructor canónico, los accesores, equals, hashCode y toString. Sustituye a los cientos de líneas de POJO con Lombok que todos hemos escrito, y sin dependencias.

import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Objects;

// Una línea sustituye a ~90 líneas de POJO con getters, equals, hashCode y toString.
public record LineaPedido(String sku, int unidades, BigDecimal precioUnitario) {

    // 1. Constructor compacto: valida y NORMALIZA antes de asignar los campos.
    //    Es el sitio canónico para los invariantes.
    public LineaPedido {
        Objects.requireNonNull(sku, "sku");
        if (sku.isBlank())  throw new IllegalArgumentException("sku vacío");
        if (unidades <= 0)  throw new IllegalArgumentException("unidades debe ser > 0: " + unidades);
        Objects.requireNonNull(precioUnitario, "precioUnitario");
        if (precioUnitario.signum() < 0) throw new IllegalArgumentException("precio negativo");
        sku = sku.strip().toUpperCase(java.util.Locale.ROOT);          // normalización
        precioUnitario = precioUnitario.setScale(2, java.math.RoundingMode.HALF_UP);
    }

    // 2. Métodos derivados: comportamiento, no solo datos. Un record NO es un DTO tonto.
    public BigDecimal importe() { return precioUnitario.multiply(BigDecimal.valueOf(unidades)); }

    // 3. Constructores adicionales: deben delegar en el canónico
    public LineaPedido(String sku, int unidades) { this(sku, unidades, BigDecimal.ZERO); }

    // 4. Fábricas estáticas con nombre revelador
    public static LineaPedido de(String sku, int unidades, String precio) {
        return new LineaPedido(sku, unidades, new BigDecimal(precio));
    }

    // 5. "Mutación" funcional: devuelve una copia modificada
    public LineaPedido conUnidades(int nuevas) { return new LineaPedido(sku, nuevas, precioUnitario); }
}

// Un record puede contener otros records: modelo de dominio expresivo y compacto
public record Direccion(String calle, String ciudad, String cp, String pais) { }

public record Pedido(String id, String clienteId, Direccion envio,
                     List<LineaPedido> lineas, Instant creado) {

    public Pedido {
        // ⚠️ IMPORTANTE: un record es "shallowly immutable". La LISTA hay que copiarla,
        // o el llamante conserva una referencia mutable a nuestro estado interno.
        lineas = List.copyOf(lineas);      // copia inmutable: ahora sí es inmutable de verdad
    }

    public BigDecimal total() {
        return lineas.stream().map(LineaPedido::importe)
                     .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public int unidadesTotales() { return lineas.stream().mapToInt(LineaPedido::unidades).sum(); }
}
AspectoQué te da un recordDetalle que hay que saber
Clasefinal, extiende implícitamente java.lang.RecordNo puede extender otra clase; puede implementar interfaces.
Camposprivate final, uno por componenteNo se pueden añadir campos de instancia adicionales. Sí campos static.
Accesoressku(), no getSku()Se pueden sobrescribir (por ejemplo, para devolver una copia defensiva).
equals/hashCodeGenerados sobre todos los componentesUsan equals de cada componente. Con BigDecimal o arrays, ojo (ver sección 5).
toStringLineaPedido[sku=ABC, unidades=2, …]Cuidado: incluye todos los campos. Sobrescríbelo si hay datos sensibles.
InmutabilidadSuperficial (shallow)Copia las colecciones y las fechas mutables en el constructor compacto.
SerializaciónCompatible con SerializableSe serializa por componentes y se deserializa por el constructor canónico: las validaciones se aplican. Es mucho más seguro que la serialización normal.
FrameworksJackson 2.12+, Spring, JPA (como @Embeddable o proyección)No sirve como @Entity: JPA necesita mutabilidad e identidad. Sí como DTO y como proyección de consulta.
Pattern matchingDeconstrucción con patrones de registroLa combinación record + sealed + switch es lo mejor del Java moderno.
Dos trampas de record que aparecen en producción:
  • Datos sensibles en toString. Un record Credenciales(String usuario, String contrasena) imprime la contraseña en cada log que lo incluya. Sobrescribe toString para enmascararla, o mejor todavía, no metas secretos en String (usa char[] y bórralo).
  • Componentes mutables. record Config(Map<String,String> propiedades) no es inmutable: quien te pasó el mapa puede seguir modificándolo. Copia en el constructor compacto (Map.copyOf) y, si el componente es un array, copia también en el accesor.

4.11 sealed y pattern matching: jerarquías cerradas y exhaustivas

Una clase o interfaz sealed declara exactamente qué tipos pueden extenderla. Eso permite algo muy potente: que el compilador sepa que la lista de casos está completa y verifique la exhaustividad de un switch. Cuando añadas un caso nuevo, el compilador te señalará todos los sitios que hay que actualizar. Es la forma de conseguir en Java lo que en Kotlin son las sealed classes y en Rust los enum con datos.

import java.math.BigDecimal;

// La jerarquía está cerrada: nadie más puede implementar Pago.
public sealed interface Pago permits Tarjeta, Transferencia, Efectivo, Bizum { }

public record Tarjeta(String numeroEnmascarado, String titular, int cuotas) implements Pago { }
public record Transferencia(String iban, String concepto) implements Pago { }
public record Efectivo(BigDecimal entregado) implements Pago { }
public record Bizum(String telefono) implements Pago { }

public class ProcesadorPagos {

    // ✅ switch EXHAUSTIVO: no hace falta 'default'. Si añades un tipo de Pago,
    //    este método deja de compilar y el compilador te lleva al sitio exacto.
    public BigDecimal comision(Pago pago, BigDecimal importe) {
        return switch (pago) {
            case Tarjeta t when t.cuotas() > 1 ->               // guarda con 'when'
                    importe.multiply(new BigDecimal("0.025"));
            case Tarjeta t        -> importe.multiply(new BigDecimal("0.015"));
            case Transferencia tr -> new BigDecimal("0.35");
            case Efectivo e       -> BigDecimal.ZERO;
            case Bizum b          -> BigDecimal.ZERO;
        };
    }

    // Patrones de REGISTRO: deconstruyen el record y enlazan sus componentes directamente
    public String describir(Pago pago) {
        return switch (pago) {
            case Tarjeta(String num, String titular, int cuotas) when cuotas > 1 ->
                    "%s a %d plazos (%s)".formatted(titular, cuotas, num);
            case Tarjeta(String num, String titular, _) ->
                    "%s al contado (%s)".formatted(titular, num);       // _ = componente ignorado
            case Transferencia(String iban, _) -> "Transferencia desde " + iban;
            case Efectivo(BigDecimal entregado) -> "Efectivo: " + entregado + " €";
            case Bizum(String tel)              -> "Bizum del " + tel;
        };
    }

    // Patrones anidados: se puede deconstruir en profundidad
    record Envio(Direccion destino, Pago pago) { }
    record Direccion(String ciudad, String pais) { }

    public boolean esEnvioNacionalConTarjeta(Envio envio) {
        return switch (envio) {
            case Envio(Direccion(_, String pais), Tarjeta _) when "ES".equals(pais) -> true;
            default -> false;
        };
    }

    // instanceof con patrón: elimina el cast redundante (Java 16+)
    public String etiqueta(Object o) {
        if (o instanceof Pago p && comision(p, BigDecimal.TEN).signum() > 0) return "con comisión";
        if (o instanceof String s && !s.isBlank()) return s.strip();     // s ya es String
        if (o instanceof Number n) return "número " + n.doubleValue();
        return "desconocido";
    }
}
Modificador de las subclases permitidasSignificado
finalCierra la rama. Es lo que son los record implícitamente.
sealedSigue restringiendo: la rama continúa con otra lista permits.
non-sealedReabre esa rama concreta: cualquiera puede extenderla. Úsalo con cuidado, porque destruye la exhaustividad.
Sin permitsSi las subclases están en el mismo fichero, se infiere la lista. Muy cómodo para jerarquías pequeñas.

¿Cuándo sealed y cuándo polimorfismo clásico? La regla, expresada como una pregunta: ¿qué cambia más a menudo, la lista de tipos o la lista de operaciones?

Polimorfismo clásico (método en la interfaz)

  • Añadir un tipo nuevo es trivial: una clase más, nada que tocar.
  • Añadir una operación obliga a editar todas las implementaciones.
  • La jerarquía puede ser abierta y extensible por terceros.
  • Elígelo cuando el comportamiento pertenece al propio tipo: Notificador, CalculoImpuesto, ValidadorDeReglas.

sealed + switch

  • Añadir una operación es trivial: un método con un switch, en cualquier sitio.
  • Añadir un tipo rompe la compilación en todos los switchy eso es una ventaja: no se olvida ninguno.
  • La jerarquía es cerrada y conocida.
  • Elígelo cuando el conjunto de casos es finito y las operaciones son externas al dominio: árboles de expresiones, resultados de una operación, estados de una máquina, mensajes de un protocolo.
// Caso de uso perfecto de sealed: el resultado de una operación que puede fallar
// de varias formas distintas, cada una con sus datos. Sin excepciones y sin null.
public sealed interface ResultadoPago {
    record Aceptado(String referencia, java.time.Instant momento) implements ResultadoPago { }
    record Rechazado(String codigo, String motivo) implements ResultadoPago { }
    record RequiereAutenticacion(String urlRedireccion) implements ResultadoPago { }
    record ErrorTecnico(String detalle, Throwable causa) implements ResultadoPago { }
}

// El llamante NO PUEDE olvidarse de un caso: el compilador se lo impide.
class ControladorPago {
    String responder(ResultadoPago resultado) {
        return switch (resultado) {
            case ResultadoPago.Aceptado(String ref, var momento) ->
                    "201 Created; referencia=" + ref;
            case ResultadoPago.Rechazado(String codigo, String motivo) ->
                    "402 Payment Required; " + codigo + ": " + motivo;
            case ResultadoPago.RequiereAutenticacion(String url) ->
                    "303 See Other; Location: " + url;
            case ResultadoPago.ErrorTecnico(String detalle, Throwable causa) ->
                    "502 Bad Gateway; " + detalle;
        };
    }
}
Cuidado con null en un switch con patrones. Un switch clásico lanza NullPointerException si el selector es null. Con patrones puedes tratarlo explícitamente con case null -> … o combinarlo: case null, default -> …. Si no pones ninguno de los dos, el comportamiento sigue siendo lanzar NPE, por compatibilidad. Ser explícito siempre es mejor que confiar en el comportamiento por defecto.

4.12 Enums con estado, comportamiento y estrategia

Un enum en Java no es una lista de constantes numéricas: es una clase completa con instancias fijas. Puede tener campos, constructor, métodos, implementar interfaces y hasta sobrescribir métodos por constante. Es, con diferencia, la herramienta más subutilizada del lenguaje.

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;

public enum EstadoPedido {

    // Cada constante lleva sus datos y sus transiciones válidas
    BORRADOR   ("Borrador",   false, "PAGADO", "CANCELADO"),
    PAGADO     ("Pagado",     true,  "PREPARANDO", "REEMBOLSADO"),
    PREPARANDO ("Preparando", true,  "ENVIADO", "REEMBOLSADO"),
    ENVIADO    ("Enviado",    true,  "ENTREGADO", "DEVUELTO"),
    ENTREGADO  ("Entregado",  true),
    CANCELADO  ("Cancelado",  false),
    REEMBOLSADO("Reembolsado",false),
    DEVUELTO   ("Devuelto",   false);

    private final String etiqueta;
    private final boolean facturable;
    private final java.util.Set<String> siguientes;

    EstadoPedido(String etiqueta, boolean facturable, String... siguientes) {
        this.etiqueta = etiqueta;
        this.facturable = facturable;
        this.siguientes = java.util.Set.of(siguientes);
    }

    public String etiqueta()     { return etiqueta; }
    public boolean esFacturable(){ return facturable; }
    public boolean esFinal()     { return siguientes.isEmpty(); }

    /** Máquina de estados dentro del propio enum: imposible una transición ilegal. */
    public boolean puedePasarA(EstadoPedido destino) { return siguientes.contains(destino.name()); }

    public EstadoPedido pasarA(EstadoPedido destino) {
        if (!puedePasarA(destino))
            throw new IllegalStateException("Transición inválida: %s → %s".formatted(this, destino));
        return destino;
    }

    /** Búsqueda tolerante desde texto externo (API, CSV, base de datos). */
    private static final Map<String, EstadoPedido> POR_NOMBRE =
            Arrays.stream(values()).collect(Collectors.toUnmodifiableMap(
                    e -> e.name().toUpperCase(java.util.Locale.ROOT), e -> e));

    public static Optional<EstadoPedido> desde(String texto) {
        return texto == null ? Optional.empty()
             : Optional.ofNullable(POR_NOMBRE.get(texto.strip().toUpperCase(java.util.Locale.ROOT)));
    }
}
// Enum como ESTRATEGIA: cada constante implementa el comportamiento a su manera.
// Es el patrón Strategy sin una sola clase extra, y con seguridad de tipos total.
public enum Operacion {
    SUMA("+")   { @Override public double aplicar(double a, double b) { return a + b; } },
    RESTA("-")  { @Override public double aplicar(double a, double b) { return a - b; } },
    MULTIPLICA("*") { @Override public double aplicar(double a, double b) { return a * b; } },
    DIVIDE("/") {
        @Override public double aplicar(double a, double b) {
            if (b == 0) throw new ArithmeticException("división por cero");
            return a / b;
        }
    };

    private final String simbolo;
    Operacion(String simbolo) { this.simbolo = simbolo; }

    public abstract double aplicar(double a, double b);      // ← método abstracto en un enum
    @Override public String toString() { return simbolo; }

    private static final java.util.Map<String, Operacion> POR_SIMBOLO =
            java.util.Arrays.stream(values())
                    .collect(java.util.stream.Collectors.toUnmodifiableMap(o -> o.simbolo, o -> o));

    public static Operacion desdeSimbolo(String s) {
        Operacion op = POR_SIMBOLO.get(s);
        if (op == null) throw new IllegalArgumentException("Operación desconocida: " + s);
        return op;
    }
}
// Uso:  Operacion.desdeSimbolo("*").aplicar(6, 7)  →  42.0

// Versión más moderna con una lambda por constante, si no necesitas más que eso:
enum OperacionLambda {
    SUMA((a, b) -> a + b),
    RESTA((a, b) -> a - b);

    private final java.util.function.DoubleBinaryOperator fn;
    OperacionLambda(java.util.function.DoubleBinaryOperator fn) { this.fn = fn; }
    public double aplicar(double a, double b) { return fn.applyAsDouble(a, b); }
}
Buena práctica con enumsPor qué
Usa EnumMap y EnumSet en lugar de HashMap/HashSetInternamente son un array y un campo de bits: mucho más rápidos y con orden natural garantizado. Ver sección 7.
Nunca persistas ordinal()Reordenar las constantes o insertar una en medio corrompe todos los datos guardados. Persiste name() (o un código propio explícito).
Con JPA, @Enumerated(EnumType.STRING)El valor por defecto es ORDINAL, que es exactamente el error anterior. Es uno de los bugs más caros de arreglar.
Añade un código propio si el nombre puede cambiarSepara el identificador externo (contrato) del nombre interno (refactorizable).
valueOf lanza si no existeEnvuélvelo en un método que devuelva Optional para datos externos.
Un enum puede implementar una interfazPermite mezclar constantes de varios enums bajo un mismo contrato.
Enum como singletonLa forma más segura de implementarlo: la JVM garantiza una única instancia, incluso frente a serialización y reflexión. Ver sección 14.

4.13 Inmutabilidad y clases de valor

Una clase inmutable es aquella cuyo estado observable no cambia después de la construcción. Es la decisión de diseño con mejor relación entre esfuerzo y bugs evitados de toda la programación orientada a objetos: elimina de un plumazo las carreras de datos, las copias defensivas por todas partes y los objetos que “alguien” modificó.

Las cinco reglas para hacer una clase inmutable de verdad:

  1. La clase, final (o con constructor privado y fábricas), para que nadie añada estado mutable heredando.
  2. Todos los campos, private final.
  3. Ningún método que modifique el estado. Las “modificaciones” devuelven una instancia nueva.
  4. Copia defensiva al entrar: si el constructor recibe una colección, un array o un Date, cópialo.
  5. Copia defensiva al salir: si un accesor devuelve algo mutable, devuelve una copia o una vista inmutable.
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

public final class Itinerario {                          // 1. final

    private final String codigo;                         // 2. private final
    private final LocalDate salida;                      //    java.time ya es inmutable
    private final List<String> escalas;                  //    ¡la lista NO lo es!
    private final int[] tarifas;                         //    ¡un array NUNCA lo es!

    public Itinerario(String codigo, LocalDate salida, List<String> escalas, int[] tarifas) {
        this.codigo  = Objects.requireNonNull(codigo);
        this.salida  = Objects.requireNonNull(salida);
        this.escalas = List.copyOf(escalas);             // 4. copia inmutable al entrar
        this.tarifas = tarifas.clone();                  // 4. copia del array
    }

    public String codigo()      { return codigo; }
    public LocalDate salida()   { return salida; }
    public List<String> escalas() { return escalas; }    // 5. ya es inmutable: seguro
    public int[] tarifas()      { return tarifas.clone(); }  // 5. copia al salir, obligatorio

    // 3. "Mutación" funcional: nueva instancia
    public Itinerario conEscala(String nueva) {
        var copia = new ArrayList<>(escalas);
        copia.add(nueva);
        return new Itinerario(codigo, salida, copia, tarifas);
    }

    public Itinerario retrasado(int dias) {
        return new Itinerario(codigo, salida.plusDays(dias), escalas, tarifas);
    }
}

// ❌ El error sutil que rompe la inmutabilidad y que se cuela en las revisiones:
final class MalHecha {
    private final List<String> items;
    MalHecha(List<String> items) { this.items = items; }            // ← guarda la referencia ajena
    List<String> items() { return items; }                          // ← la entrega tal cual

    // Demostración del agujero:
    //   var original = new ArrayList<String>(List.of("a"));
    //   var obj = new MalHecha(original);
    //   original.add("b");        ← el "inmutable" ha cambiado por la espalda
    //   obj.items().add("c");     ← y también desde fuera
}

// ⚠️ unmodifiableList NO es copia: es una VISTA de solo lectura.
final class CasiBien {
    private final List<String> items;
    CasiBien(List<String> items) { this.items = Collections.unmodifiableList(items); }
    // Si el llamante modifica su lista original, esta "vista inmutable" cambia también.
    // Para que sea correcto: Collections.unmodifiableList(new ArrayList<>(items))
    // o directamente List.copyOf(items), que hace las dos cosas.
    List<String> items() { return items; }
}
Construcción¿Copia?¿Admite null?Al modificar
List.of(a, b)Sí (crea la lista)No: lanza NPEUnsupportedOperationException
List.copyOf(otra)NoUnsupportedOperationException
Collections.unmodifiableList(otra)No: es una vistaUnsupportedOperationException, pero cambia si cambia la original
stream().toList() (admite nulos)UnsupportedOperationException
stream().collect(Collectors.toList())Modificable (normalmente ArrayList), pero no está garantizado
Arrays.asList(a, b)No: envuelve el arrayset funciona (y modifica el array); add/remove lanzan
new ArrayList<>(otra)Modificable
Inmutable por defecto, mutable por excepción. Empieza toda clase nueva con los campos final y sin setters. Solo añade mutabilidad cuando tengas un motivo concreto (una entidad JPA, un acumulador en un algoritmo, un buffer). Vas a descubrir que la necesitas mucho menos de lo que creías, y que los tests se vuelven triviales porque no hay que preparar ni restaurar estado. Y para “modificar” objetos inmutables con muchos campos, un builder con método toBuilder() o un patrón with…() es todo lo que necesitas.

4.14 SOLID aplicado, con el antes y el después

SOLID no es un examen que se aprueba recitando cinco palabras: son cinco olores concretos con su refactorización concreta. Aquí está cada uno con el código que lo incumple y el que lo cumple.

// ═══════════════════════════════════════════════════════════════════════════
// S — Single Responsibility: una clase, una razón para cambiar
// ═══════════════════════════════════════════════════════════════════════════

// ❌ Esta clase cambia si cambia el cálculo, si cambia el formato del informe,
//    si cambia el proveedor de correo o si cambia la base de datos. Cuatro razones.
class GestorNominaMal {
    double calcular(Empleado e) { /* reglas fiscales */ return 0; }
    String generarPdf(Empleado e) { /* maquetación */ return ""; }
    void enviarPorCorreo(String pdf) { /* SMTP */ }
    void guardarEnBd(Empleado e, double importe) { /* SQL */ }
}

// ✅ Cuatro colaboradores, cada uno con una razón para cambiar. El servicio orquesta.
interface CalculadoraNomina { java.math.BigDecimal calcular(Empleado e); }
interface GeneradorInforme  { byte[] generar(Empleado e, java.math.BigDecimal importe); }
interface EnvioCorreo       { void enviar(String destino, byte[] adjunto); }
interface RepositorioNomina { void guardar(Empleado e, java.math.BigDecimal importe); }

record ServicioNomina(CalculadoraNomina calculadora, GeneradorInforme informe,
                      EnvioCorreo correo, RepositorioNomina repositorio) {
    void procesar(Empleado e) {
        var importe = calculadora.calcular(e);
        repositorio.guardar(e, importe);
        correo.enviar(e.email(), informe.generar(e, importe));
    }
}
// ═══════════════════════════════════════════════════════════════════════════
// O — Open/Closed: abierto a extensión, cerrado a modificación
// ═══════════════════════════════════════════════════════════════════════════

// ❌ Cada nuevo método de envío obliga a EDITAR este método y volver a probarlo todo.
class CalculoPorteMal {
    double porte(String transportista, double kg) {
        if ("SEUR".equals(transportista))        return 3.50 + kg * 0.40;
        else if ("MRW".equals(transportista))    return 4.00 + kg * 0.35;
        else if ("CORREOS".equals(transportista))return 2.90 + kg * 0.55;
        else throw new IllegalArgumentException(transportista);
    }
}

// ✅ Cada transportista es una implementación. Añadir uno = añadir una clase.
interface Transportista {
    String codigo();
    java.math.BigDecimal porte(double kg);
}

record Seur() implements Transportista {
    public String codigo() { return "SEUR"; }
    public java.math.BigDecimal porte(double kg) { return tarifa("3.50", "0.40", kg); }
    static java.math.BigDecimal tarifa(String fijo, String porKg, double kg) {
        return new java.math.BigDecimal(fijo)
                .add(new java.math.BigDecimal(porKg).multiply(java.math.BigDecimal.valueOf(kg)))
                .setScale(2, java.math.RoundingMode.HALF_UP);
    }
}

class CalculoPorte {
    private final java.util.Map<String, Transportista> porCodigo;
    CalculoPorte(java.util.List<Transportista> disponibles) {
        this.porCodigo = disponibles.stream()
                .collect(java.util.stream.Collectors.toUnmodifiableMap(Transportista::codigo, t -> t));
    }
    java.math.BigDecimal porte(String codigo, double kg) {
        var t = porCodigo.get(codigo);
        if (t == null) throw new IllegalArgumentException("Transportista desconocido: " + codigo);
        return t.porte(kg);
    }
}
// En Spring: inyecta List<Transportista> y el contenedor te da todos los beans.
// Añadir un transportista nuevo es crear una clase con @Component. Cero ediciones.
// ═══════════════════════════════════════════════════════════════════════════
// L — Liskov: el subtipo debe poder sustituir al supertipo sin sorpresas
// ═══════════════════════════════════════════════════════════════════════════

// ❌ Violación clásica: el subtipo rompe una expectativa del supertipo
class Rectangulo {
    protected int ancho, alto;
    void setAncho(int a) { this.ancho = a; }
    void setAlto(int a)  { this.alto = a; }
    int area() { return ancho * alto; }
}
class Cuadrado extends Rectangulo {
    @Override void setAncho(int a) { this.ancho = a; this.alto = a; }   // rompe la expectativa
    @Override void setAlto(int a)  { this.ancho = a; this.alto = a; }
}
// Este test pasa con Rectangulo y FALLA con Cuadrado, aunque "un cuadrado es un rectángulo":
//   r.setAncho(5); r.setAlto(4); assertEquals(20, r.area());   → con Cuadrado da 16

// ✅ Modela el concepto, no la taxonomía escolar. Y hazlo inmutable.
sealed interface Figura {
    double area();
    record Rect(double ancho, double alto) implements Figura {
        public double area() { return ancho * alto; }
        public Rect conAncho(double a) { return new Rect(a, alto); }
    }
    record Cuadrado(double lado) implements Figura {
        public double area() { return lado * lado; }
    }
    record Circulo(double radio) implements Figura {
        public double area() { return Math.PI * radio * radio; }
    }
}
// ═══════════════════════════════════════════════════════════════════════════
// I — Interface Segregation: mejor varias interfaces pequeñas que una gorda
// ═══════════════════════════════════════════════════════════════════════════

// ❌ Toda implementación está obligada a lidiar con métodos que no le corresponden
interface DispositivoMal {
    void imprimir(byte[] doc);
    void escanear();
    void enviarFax(String numero);
    void grapar();
}
// Una impresora doméstica acaba así, y eso viola también Liskov:
//   public void enviarFax(String n) { throw new UnsupportedOperationException(); }

// ✅ Capacidades independientes; cada dispositivo declara solo lo que sabe hacer
interface Imprimible { void imprimir(byte[] doc); }
interface Escaneable { byte[] escanear(); }
interface Faxeable   { void enviarFax(String numero, byte[] doc); }

class ImpresoraDomestica implements Imprimible {
    public void imprimir(byte[] doc) { /* ... */ }
}
class Multifuncion implements Imprimible, Escaneable, Faxeable {
    public void imprimir(byte[] doc) { /* ... */ }
    public byte[] escanear() { return new byte[0]; }
    public void enviarFax(String numero, byte[] doc) { /* ... */ }
}
// ═══════════════════════════════════════════════════════════════════════════
// D — Dependency Inversion: el dominio define la abstracción; la infraestructura obedece
// ═══════════════════════════════════════════════════════════════════════════

// ❌ El caso de uso depende de detalles: PostgreSQL, SendGrid, Stripe. Intestable
//    sin base de datos y sin internet, y atado a esos proveedores para siempre.
class ServicioPedidosMal {
    private final PostgresPedidoDao dao = new PostgresPedidoDao();     // new = acoplamiento
    private final SendGridCliente correo = new SendGridCliente();
    void confirmar(String id) { /* usa dao y correo directamente */ }
}

// ✅ El dominio declara los PUERTOS que necesita, en su propio lenguaje.
//    La infraestructura escribe los ADAPTADORES. Es la base de la arquitectura
//    hexagonal del módulo 08.
interface PedidoRepositorio {                     // puerto de salida (dominio)
    java.util.Optional<Pedido> porId(String id);
    void guardar(Pedido pedido);
}
interface NotificadorCliente {                    // puerto de salida (dominio)
    void pedidoConfirmado(Pedido pedido);
}

final class ConfirmarPedido {                     // caso de uso: cero dependencias técnicas
    private final PedidoRepositorio repositorio;
    private final NotificadorCliente notificador;

    ConfirmarPedido(PedidoRepositorio repositorio, NotificadorCliente notificador) {
        this.repositorio = java.util.Objects.requireNonNull(repositorio);
        this.notificador = java.util.Objects.requireNonNull(notificador);
    }

    void ejecutar(String pedidoId) {
        var pedido = repositorio.porId(pedidoId)
                .orElseThrow(() -> new PedidoNoEncontrado(pedidoId));
        repositorio.guardar(pedido);
        notificador.pedidoConfirmado(pedido);
    }
}
// El test unitario no necesita ni Docker ni red: dos implementaciones en memoria de
// diez líneas y ya puedes probar todas las reglas de negocio en milisegundos.
PrincipioOlor que lo delataRefactorización
Single responsibilityNombres con “Manager”, “Helper”, “Utils”, “y”; clases de 800 líneas; un cambio de formato te obliga a tocar el cálculo.Extraer clase; separar cálculo, presentación y persistencia.
Open/closedUn if/else if o un switch sobre un tipo que crece con cada requisito.Sustituir el condicional por polimorfismo (interfaz + Map) o por sealed si el conjunto es cerrado.
LiskovUnsupportedOperationException en una subclase; if (x instanceof Subtipo) repartido por el código; una subclase que endurece precondiciones.Rediseñar la jerarquía; preferir composición; modelar con sealed + record.
Interface segregationImplementaciones llenas de métodos vacíos o que lanzan; interfaces de 15 métodos.Partir la interfaz por capacidades; interfaces de rol.
Dependency inversionnew de clases de infraestructura dentro del dominio; imposibilidad de testear sin base de datos.Extraer un puerto (interfaz) en el dominio; inyectar el adaptador por constructor.
SOLID mal aplicado es peor que no aplicarlo. Una interfaz por clase “por si acaso”, cinco niveles de abstracción para leer un fichero, o una fábrica de fábricas no son buen diseño: son coste sin beneficio. El criterio es el cambio real. Si en dos años solo ha habido una implementación y ningún indicio de que vaya a haber otra, la interfaz sobra. Aplica los principios cuando el dolor aparece o cuando sabes con certeza que va a aparecer, no de forma preventiva y universal.

Comprobación rápida de la sección 4

5 · Los contratos de Object: equals, hashCode y compañía

Toda clase Java hereda de Object once métodos, y tres de ellos —equals, hashCode y toString— están pensados para que los sobrescribas. Si los implementas mal, no obtienes un error de compilación ni una excepción: obtienes colecciones que pierden objetos en silencio. Es el tema más preguntado de todo Java Core.

5.1 El contrato de equals al completo

equals define la igualdad lógica. La implementación de Object compara identidad (this == o), lo que casi nunca es lo que quieres para una clase de valor. El contrato tiene cinco cláusulas, y las tres del medio son las que se rompen en la práctica:

PropiedadQué exigeCómo se rompe en la vida real
Reflexivax.equals(x) es trueCasi imposible de romper. Solo con implementaciones absurdas.
SimétricaSi x.equals(y) entonces y.equals(x)Comparando con un tipo distinto: una clase que se considera igual a un String, pero String no la considera igual a ella. También al usar getClass() en un lado e instanceof en el otro.
TransitivaSi x=y e y=z, entonces x=zAñadiendo un campo en una subclase. Es el motivo por el que las clases de valor deben ser final.
ConsistenteRepetir la comparación da el mismo resultado si no cambia nadaComparando con campos mutables, con la hora actual o con recursos externos (una consulta a base de datos dentro de equals: sí, se ha visto).
No nulax.equals(null) es false, nunca lanzaOlvidando la comprobación. instanceof ya devuelve false con null, así que usarlo lo resuelve gratis.
// ❌ ROMPE LA SIMETRÍA: mezcla tipos
public final class PuntoRoto {
    private final int x, y;
    public PuntoRoto(int x, int y) { this.x = x; this.y = y; }

    @Override public boolean equals(Object o) {
        if (o instanceof PuntoRoto p) return x == p.x && y == p.y;
        if (o instanceof String s) return s.equals(x + "," + y);     // ← el desastre
        return false;
    }
    // new PuntoRoto(1,2).equals("1,2")  → true
    // "1,2".equals(new PuntoRoto(1,2))  → false     ¡ASIMÉTRICO!
    // Consecuencia: lista.contains(...) da resultados distintos según el orden interno.
}
// ❌ ROMPE LA TRANSITIVIDAD: una subclase añade un campo al equals
class Punto {
    final int x, y;
    Punto(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        if (!(o instanceof Punto p)) return false;
        return x == p.x && y == p.y;
    }
    @Override public int hashCode() { return java.util.Objects.hash(x, y); }
}

class PuntoColor extends Punto {
    final String color;
    PuntoColor(int x, int y, String color) { super(x, y); this.color = color; }

    @Override public boolean equals(Object o) {
        if (!(o instanceof PuntoColor p)) return false;
        return super.equals(o) && color.equals(p.color);
    }
}
// var rojo  = new PuntoColor(1, 1, "rojo");
// var plano = new Punto(1, 1);
// var azul  = new PuntoColor(1, 1, "azul");
//   plano.equals(rojo)  → true      (Punto no mira el color)
//   rojo.equals(plano)  → false     ← ASIMÉTRICO
//   rojo.equals(plano) && plano.equals(azul) pero rojo != azul  ← INTRANSITIVO
//
// No hay forma de arreglarlo manteniendo la herencia: es un teorema, no un descuido.
// Solución: composición (PuntoColor TIENE un Punto y un color) o clases final.
getClass() != o.getClass()!(o instanceof Tipo)
Simetría con subclasesGarantizada: una subclase nunca es igual a la clase baseSe puede romper si la subclase redefine equals
LiskovLa viola: una subclase que no añade estado debería ser igualLa respeta
Proxies (Hibernate, Spring AOP, Mockito)Falla: el proxy es de una subclase generada y nunca será igual a la entidadFunciona
RecomendaciónSolo con clases final o si el equals debe ser estricto por tipo exactoUsa esto, junto con la clase final. Es lo que generan los record y lo que necesitan las entidades JPA.

5.2 hashCode: el contrato y qué pasa si lo incumples

El contrato tiene solo dos cláusulas, y una es unidireccional:

  1. Si a.equals(b) es true, obligatoriamente a.hashCode() == b.hashCode().
  2. El recíproco no se exige: dos objetos distintos pueden compartir hash (es una colisión, perfectamente legal, solo cuesta rendimiento).

Incumplir la primera cláusula es el bug de manual: el objeto “desaparece” de un HashSet o de un HashMap. Aquí está la demostración ejecutable de las dos formas de romperlo:

import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;

public class RomperHashCode {

    // ❌ CASO 1: equals sin hashCode
    static final class SinHash {
        final String id;
        SinHash(String id) { this.id = id; }
        @Override public boolean equals(Object o) {
            return o instanceof SinHash s && id.equals(s.id);
        }
        // hashCode heredado de Object: basado en la identidad → dos objetos "iguales"
        // caen en buckets distintos y el HashSet nunca los compara con equals.
    }

    // ⚠️ CASO 2: hashCode basado en un campo MUTABLE
    static final class ConCampoMutable {
        String nombre;                      // ← mutable
        ConCampoMutable(String nombre) { this.nombre = nombre; }
        @Override public boolean equals(Object o) {
            return o instanceof ConCampoMutable c && Objects.equals(nombre, c.nombre);
        }
        @Override public int hashCode() { return Objects.hash(nombre); }
    }

    // ✅ CASO 3: bien hecho
    static final class Correcto {
        private final String id;
        Correcto(String id) { this.id = Objects.requireNonNull(id); }
        @Override public boolean equals(Object o) {
            return o instanceof Correcto c && id.equals(c.id);
        }
        @Override public int hashCode() { return id.hashCode(); }
    }

    public static void main(String[] args) {

        // --- Caso 1: equals dice que sí, el HashSet dice que no ---
        var conjunto1 = new HashSet<SinHash>();
        conjunto1.add(new SinHash("A"));
        System.out.println(new SinHash("A").equals(new SinHash("A")));  // true
        System.out.println(conjunto1.contains(new SinHash("A")));       // FALSE (!)
        conjunto1.add(new SinHash("A"));
        System.out.println(conjunto1.size());                            // 2 duplicados "iguales"

        // --- Caso 2: el objeto se pierde DENTRO del conjunto ---
        var conjunto2 = new HashSet<ConCampoMutable>();
        var objeto = new ConCampoMutable("original");
        conjunto2.add(objeto);
        System.out.println(conjunto2.contains(objeto));                  // true

        objeto.nombre = "cambiado";                                      // mutamos la clave
        System.out.println(conjunto2.contains(objeto));                  // FALSE
        System.out.println(conjunto2.size());                            // 1 → ahí sigue...
        System.out.println(conjunto2.iterator().next().nombre);          // "cambiado" → visible
        System.out.println(conjunto2.remove(objeto));                    // false → ¡NO SE PUEDE BORRAR!
        // El objeto está en el bucket del hash antiguo; las búsquedas van al nuevo.
        // Es una fuga de memoria: un elemento inalcanzable pero retenido para siempre.

        // --- Caso 3: comportamiento esperado ---
        var conjunto3 = new HashSet<Correcto>();
        conjunto3.add(new Correcto("A"));
        conjunto3.add(new Correcto("A"));
        System.out.println(conjunto3.size());                            // 1
        System.out.println(conjunto3.contains(new Correcto("A")));       // true

        // --- Un hashCode constante es LEGAL pero convierte el mapa en una lista ---
        record MalRendimiento(int v) { @Override public int hashCode() { return 1; } }
        var mapa = new HashMap<MalRendimiento, Integer>();
        for (int i = 0; i < 100_000; i++) mapa.put(new MalRendimiento(i), i);
        long t0 = System.nanoTime();
        mapa.containsKey(new MalRendimiento(99_999));
        System.out.printf("Búsqueda con hash constante: %d µs%n", (System.nanoTime()-t0)/1000);
        // Todos en el mismo bucket: desde Java 8 se convierte en árbol rojo-negro,
        // así que es O(log n) en vez de O(n)... si las claves son Comparable.
        // Si no lo son, es O(n) puro.
    }
}
Nunca uses como clave de un HashMap (ni elemento de un HashSet) un objeto cuyos campos de equals/hashCode puedan cambiar mientras está dentro de la colección. Si ocurre, el elemento queda en un bucket incorrecto: no se encuentra, no se borra y no se recolecta. Los dos casos que verás en producción son entidades JPA con equals basado en el id (que es null antes de persistir y luego cambia) y objetos de dominio con setters. Solución: claves inmutables, siempre.

5.3 Cómo se implementa correctamente, paso a paso

import java.util.Arrays;
import java.util.Objects;

public final class Articulo {                       // 1. final: garantiza la simetría

    private final String sku;                       // clave de negocio, inmutable
    private final String nombre;                    // no forma parte de la identidad
    private final int[] codigosBarras;              // array: OJO con equals y hashCode
    private final double peso;                      // double: OJO con NaN y -0.0

    public Articulo(String sku, String nombre, int[] codigosBarras, double peso) {
        this.sku = Objects.requireNonNull(sku, "sku");
        this.nombre = Objects.requireNonNull(nombre, "nombre");
        this.codigosBarras = codigosBarras.clone();      // copia defensiva
        this.peso = peso;
    }

    @Override public boolean equals(Object o) {
        // 2. Atajo de identidad: gratis y acelera el caso más común en colecciones
        if (this == o) return true;
        // 3. instanceof con patrón: cubre el null y hace el cast en un paso
        if (!(o instanceof Articulo otro)) return false;
        // 4. Compara los campos SIGNIFICATIVOS, los más discriminantes primero
        return sku.equals(otro.sku)
            && Double.compare(peso, otro.peso) == 0          // ← NO uses == con double
            && Arrays.equals(codigosBarras, otro.codigosBarras)  // ← NO uses equals con arrays
            && nombre.equals(otro.nombre);
    }

    @Override public int hashCode() {
        // 5. Los MISMOS campos que en equals, en el mismo orden conceptual
        int resultado = Objects.hash(sku, nombre, peso);
        resultado = 31 * resultado + Arrays.hashCode(codigosBarras);
        return resultado;
    }

    // 6. toString útil para depurar: identifica el objeto, no vuelca la novela
    @Override public String toString() {
        return "Articulo[sku=%s, nombre=%s, peso=%.3f]".formatted(sku, nombre, peso);
    }

    public int[] codigosBarras() { return codigosBarras.clone(); }   // copia al salir
}
Tipo de campoEn equalsEn hashCodePor qué
int, long, char, booleana == bLong.hashCode(v), o directoComparación directa, sin sorpresas.
float / doubleFloat.compare / Double.compareDouble.hashCode(v)NaN != NaN con ==, y 0.0 == -0.0 pero tienen hash distinto. compare los trata de forma consistente.
ObjetoObjects.equals(a, b)Objects.hashCode(a)Gestiona el null por ti.
ArrayArrays.equalsArrays.hashCodearray.equals() compara identidad, no contenido. Con arrays anidados, deepEquals/deepHashCode.
BigDecimalcompareTo(x) == 0 si 1.0 y 1.00 deben ser igualesNormaliza primero: stripTrailingZeros() o setScale fijoBigDecimal.equals compara también la escala. Es la incoherencia más frecuente.
ColecciónObjects.equalsObjects.hashCodeList, Set y Map ya definen equals por contenido. Pero un List como campo de hashCode obliga a recorrerla entera: cuidado con el coste.
Campo derivadoExcluirloExcluirloSi se calcula a partir de otros, no aporta y solo cuesta tiempo.
id de base de datosDelicado (ver aviso)DelicadoEs null antes de persistir. Prefiere una clave de negocio o un UUID generado en el constructor.
El número 31. La fórmula clásica hash = 31 * hash + campo usa 31 porque es primo (reduce colisiones sistemáticas) e impar, y porque 31 * i se compila como (i << 5) - i: un desplazamiento y una resta, más rápido que una multiplicación. Hoy Objects.hash(...) hace lo mismo por ti, con una salvedad: crea un array de Object (varargs) y hace autoboxing de los primitivos. En un método hashCode que se llame millones de veces por segundo, escribe la fórmula a mano; en el 99 % de los casos, usa Objects.hash y duerme tranquilo.

5.4 equals en record y en entidades JPA

// ✅ Un record te da equals y hashCode sobre TODOS los componentes, sin escribir nada.
public record Coordenada(double latitud, double longitud) { }
// equals usa Double.compare por componente y hashCode combina todos: correcto por defecto.

// ⚠️ Pero si un componente es un array, el record hereda el problema:
public record Paquete(String id, byte[] contenido) { }
//   new Paquete("A", new byte[]{1}).equals(new Paquete("A", new byte[]{1}))  → FALSE
// El equals generado usa Objects.equals(contenido, otro.contenido), que en arrays es ==.
// Solución: sobrescribir explícitamente.

public record PaqueteCorrecto(String id, byte[] contenido) {
    public PaqueteCorrecto { contenido = contenido.clone(); }         // copia al entrar
    @Override public byte[] contenido() { return contenido.clone(); } // copia al salir
    @Override public boolean equals(Object o) {
        return o instanceof PaqueteCorrecto p
            && id.equals(p.id)
            && java.util.Arrays.equals(contenido, p.contenido);
    }
    @Override public int hashCode() {
        return 31 * id.hashCode() + java.util.Arrays.hashCode(contenido);
    }
}

// ⚠️ Y con BigDecimal, el equals del record compara la escala:
public record Importe(java.math.BigDecimal valor) { }
//   new Importe(new BigDecimal("1.0")).equals(new Importe(new BigDecimal("1.00")))  → FALSE
// Solución: normalizar en el constructor compacto.
public record ImporteCorrecto(java.math.BigDecimal valor) {
    public ImporteCorrecto {
        valor = valor.setScale(2, java.math.RoundingMode.HALF_EVEN);   // escala canónica
    }
}
// ---------- El equals de una entidad JPA: el caso más traicionero ----------
import jakarta.persistence.*;
import java.util.Objects;
import java.util.UUID;

@Entity
public class Cliente {

    // ❌ NO uses una clave generada por la base de datos para equals/hashCode:
    //    es null hasta el flush, y cambia después. Si metes la entidad en un Set
    //    antes de persistirla, se queda perdida dentro.
    //
    // ✅ Genera el identificador en el constructor, en Java. Se llama "UUID asignado"
    //    y elimina el problema de raíz: el id nunca cambia y nunca es null.
    @Id
    @Column(columnDefinition = "uuid")
    private UUID id = UUID.randomUUID();

    @Column(nullable = false, unique = true)
    private String email;

    protected Cliente() { }                       // requerido por JPA; protected basta

    public Cliente(String email) { this.email = Objects.requireNonNull(email); }

    // instanceof, NO getClass(): Hibernate crea proxies de subclases para el lazy loading,
    // y con getClass() una entidad nunca sería igual a su propio proxy.
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Cliente otro)) return false;
        return id.equals(otro.id);
    }

    // Constante o basado solo en el id inmutable. NUNCA en campos que cambian.
    @Override public int hashCode() { return id.hashCode(); }

    @Override public String toString() { return "Cliente[id=%s, email=%s]".formatted(id, email); }

    public UUID id() { return id; }
    public String email() { return email; }
}
Regla práctica para entidades: si puedes, usa un UUID (v7 si te preocupa la fragmentación del índice) generado en el constructor. Si estás obligado a usar un id autoincremental, entonces equals debe basarse en una clave de negocio natural e inmutable (el email, el NIF, el SKU) o, si no existe ninguna, deja el equals por identidad de Object y no metas entidades sin persistir en conjuntos. Más contexto en el módulo 05.

5.5 toString: para humanos que depuran

// La implementación de Object es inútil: "com.ejemplo.Pedido@1b6d3586"
// (nombre de la clase + hashCode en hexadecimal). Sobrescríbela SIEMPRE en las clases
// que vayas a ver en un log, un depurador o un mensaje de error.

public final class Pedido {
    private final String id;
    private final String clienteEmail;
    private final java.util.List<String> lineas;
    private final String tarjeta;                   // ⚠️ dato sensible

    // ✅ Formato conciso, identificable y con lo justo para depurar
    @Override public String toString() {
        return "Pedido[id=%s, cliente=%s, lineas=%d]".formatted(id, enmascarar(clienteEmail), lineas.size());
    }

    /** Nunca dejes datos personales o secretos en un toString: acaban en los logs,
        en el sistema de trazas y en el gestor de incidencias. Es un problema de RGPD. */
    private static String enmascarar(String email) {
        int arroba = email.indexOf('@');
        return arroba <= 1 ? "***" : email.charAt(0) + "***" + email.substring(arroba);
    }

    Pedido(String id, String clienteEmail, java.util.List<String> lineas, String tarjeta) {
        this.id = id; this.clienteEmail = clienteEmail;
        this.lineas = java.util.List.copyOf(lineas); this.tarjeta = tarjeta;
    }
}
Regla de toStringMotivo
Incluye el identificador y los campos que distinguen la instanciaEs lo que buscarás en el log a las 3 de la mañana.
Nunca contraseñas, tokens, tarjetas, DNI ni direcciones completasLos logs se copian, se envían y se retienen. Es una fuga de datos.
No vuelques colecciones enterasUn toString de 20.000 elementos convierte un log en un incidente de disco.
Sin lógica que pueda fallarUn toString que lanza excepciones rompe el depurador y los mensajes de error, y es imposible de diagnosticar.
Nunca lo parseesNo es un formato: es documentación para humanos. Si necesitas un formato, haz un método explícito.
Que no dispare consultas perezosasUn toString de una entidad JPA que recorre una relación LAZY provoca N+1 consultas o un LazyInitializationException.

5.6 Comparable frente a Comparator

Comparable<T>Comparator<T>
Métodoint compareTo(T otro)int compare(T a, T b)
Dónde viveDentro de la claseFuera: es un objeto aparte
CuántosUno: el orden naturalTantos como quieras
Requiere modificar la claseNo: sirve para clases ajenas
Interfaz funcionalNo (conceptualmente es del objeto)Sí: se escribe como lambda
UsaCuando hay un orden evidente e indiscutible: fechas, importes, versionesCuando el orden depende del caso de uso, o hay varios
import java.util.Comparator;
import java.util.List;

record Empleado(String nombre, String departamento, int edad, java.math.BigDecimal salario) { }

public class Ordenaciones {
    public static void main(String[] args) {
        var plantilla = new java.util.ArrayList<>(List.of(
                new Empleado("Ana",   "IT",     34, new java.math.BigDecimal("52000")),
                new Empleado("Bruno", "Ventas", 45, new java.math.BigDecimal("41000")),
                new Empleado("Clara", "IT",     29, new java.math.BigDecimal("52000")),
                new Empleado("David", "IT",     41, new java.math.BigDecimal("61000"))));

        // Comparador compuesto y legible: se lee como una frase
        var porDeptoYSalario = Comparator
                .comparing(Empleado::departamento)
                .thenComparing(Empleado::salario, Comparator.reverseOrder())
                .thenComparing(Empleado::nombre);
        plantilla.sort(porDeptoYSalario);
        plantilla.forEach(System.out::println);

        // Con primitivos, usa las variantes especializadas: evitan el autoboxing
        plantilla.sort(Comparator.comparingInt(Empleado::edad));            // no comparing()
        plantilla.sort(Comparator.comparingInt(Empleado::edad).reversed());

        // Nulos: nullsFirst / nullsLast envuelven a otro comparador
        Comparator<String> conNulos = Comparator.nullsLast(Comparator.naturalOrder());
        var conHuecos = new java.util.ArrayList<>(java.util.Arrays.asList("b", null, "a"));
        conHuecos.sort(conNulos);
        System.out.println(conHuecos);                     // [a, b, null]

        // Orden natural e inverso
        var numeros = new java.util.ArrayList<>(List.of(3, 1, 2));
        numeros.sort(Comparator.naturalOrder());
        numeros.sort(Comparator.reverseOrder());
        numeros.sort(null);        // null significa "orden natural": legal pero poco claro

        // ⚠️ CUIDADO: reversed() invierte TODO el comparador acumulado hasta ese punto
        var ordenA = Comparator.comparing(Empleado::departamento)
                               .thenComparing(Empleado::nombre).reversed();  // invierte AMBOS
        var ordenB = Comparator.comparing(Empleado::departamento)
                               .thenComparing(Comparator.comparing(Empleado::nombre).reversed());
        System.out.println(ordenA.compare(plantilla.get(0), plantilla.get(1)));
        System.out.println(ordenB.compare(plantilla.get(0), plantilla.get(1)));
    }
}
El contrato de compareTo también se puede romper, y cuando ocurre Collections.sort lanza IllegalArgumentException: Comparison method violates its general contract!. Las tres reglas: debe ser antisimétrico (sgn(a.compareTo(b)) == -sgn(b.compareTo(a))), transitivo, y consistente con la igualdad que declara. La causa habitual del error es un comparador escrito como (a, b) -> a.valor() - b.valor() que desborda con valores grandes: con a = 2_000_000_000 y b = -2_000_000_000, la resta da un negativo y el orden se vuelve incoherente. Usa siempre Integer.compare(a, b) o Comparator.comparingInt.

Y un detalle que separa a los candidatos: el orden natural debería ser consistente con equals. Si no lo es, un TreeSet y un HashSet se comportan distinto con los mismos datos, porque el primero usa compareTo y el segundo, equals:

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.TreeSet;

public class InconsistenciaTreeSet {
    public static void main(String[] args) {
        var uno    = new BigDecimal("1.0");
        var unoDos = new BigDecimal("1.00");

        System.out.println(uno.equals(unoDos));         // false → escalas distintas
        System.out.println(uno.compareTo(unoDos) == 0); // true  → mismo valor

        var hash = new HashSet<BigDecimal>();
        hash.add(uno); hash.add(unoDos);
        System.out.println("HashSet: " + hash.size());  // 2 → usa equals

        var tree = new TreeSet<BigDecimal>();
        tree.add(uno); tree.add(unoDos);
        System.out.println("TreeSet: " + tree.size());  // 1 → usa compareTo
        // La misma colección lógica con dos tamaños distintos. Es una fuente de bugs
        // desconcertantes cuando alguien "optimiza" cambiando HashSet por TreeSet.
    }
}

5.7 clone() y por qué debes evitarlo

Cloneable es, con consenso amplio (incluido el de Josh Bloch, que diseñó buena parte de las colecciones), un error de diseño del JDK. Es una interfaz vacía que no declara clone(); lo que hace es cambiar el comportamiento del método protected de Object. Los problemas:

Problema de clone()Consecuencia
La copia se hace sin llamar al constructorSe saltan todas las validaciones e invariantes. Un objeto imposible de construir sí se puede clonar.
Es una copia superficialLos campos que son referencias se comparten con el original: mutas la copia y cambias el original.
Los campos final no se pueden reasignar en cloneEs incompatible con el diseño inmutable.
Cloneable no declara clone()No puedes llamar a obj.clone() a través de la interfaz: hay que hacer cast o reflexión.
Lanza CloneNotSupportedException (checked)try/catch ceremonial en un método que no puede fallar.
Contrato con las subclases mal definidoUna subclase que no implemente clone correctamente devuelve un objeto de la clase equivocada.
// ❌ El problema de la copia superficial, en 15 líneas
class Equipo implements Cloneable {
    String nombre;
    java.util.List<String> jugadores = new java.util.ArrayList<>();

    @Override public Equipo clone() {
        try { return (Equipo) super.clone(); }       // copia superficial
        catch (CloneNotSupportedException e) { throw new AssertionError(e); }
    }
}
// var a = new Equipo(); a.nombre = "A"; a.jugadores.add("Ana");
// var b = a.clone();
// b.jugadores.add("Bruno");
// System.out.println(a.jugadores);   → [Ana, Bruno]  ¡el original ha cambiado!

// ✅ ALTERNATIVA 1: constructor de copia. Explícito, respeta final, valida.
final class EquipoBien {
    private final String nombre;
    private final java.util.List<String> jugadores;

    EquipoBien(String nombre, java.util.List<String> jugadores) {
        this.nombre = java.util.Objects.requireNonNull(nombre);
        this.jugadores = java.util.List.copyOf(jugadores);        // copia profunda de la lista
    }

    /** Constructor de copia: legible, sin excepciones y sin casts. */
    EquipoBien(EquipoBien otro) { this(otro.nombre, otro.jugadores); }

    /** Fábrica de copia: aún más legible en el punto de llamada. */
    static EquipoBien copiaDe(EquipoBien otro) { return new EquipoBien(otro); }

    /** "Mutación" funcional: la forma idiomática en Java moderno. */
    EquipoBien con(String jugador) {
        var lista = new java.util.ArrayList<>(jugadores);
        lista.add(jugador);
        return new EquipoBien(nombre, lista);
    }
}

// ✅ ALTERNATIVA 2: un record, que ya es inmutable (con copia en el constructor compacto)
record EquipoRecord(String nombre, java.util.List<String> jugadores) {
    EquipoRecord { jugadores = java.util.List.copyOf(jugadores); }
    EquipoRecord con(String jugador) {
        var lista = new java.util.ArrayList<>(jugadores);
        lista.add(jugador);
        return new EquipoRecord(nombre, lista);
    }
}
Cuándo sí verás clone(): con arrays. array.clone() es la forma idiomática y eficiente de copiar un array, no tiene ninguno de los problemas anteriores (es superficial, pero eso es exactamente lo que se espera) y está optimizada en la JVM. Para copias con cambio de tamaño, Arrays.copyOf y Arrays.copyOfRange. Para el resto de las clases: constructor de copia o fábrica.

5.8 Objects: la clase de utilidades que resuelve el null

import java.util.Objects;
import java.util.function.Supplier;

public class UtilidadesObjects {

    // 1. Validación en el límite: falla rápido, en el constructor, con un mensaje útil.
    //    La excepción se lanza DONDE está el error, no 40 líneas más allá.
    record Reserva(String hotel, java.time.LocalDate entrada, int noches) {
        Reserva {
            Objects.requireNonNull(hotel, "hotel no puede ser null");
            Objects.requireNonNull(entrada, "entrada no puede ser null");
            if (noches < 1) throw new IllegalArgumentException("noches debe ser >= 1: " + noches);
        }
    }

    public static void main(String[] args) {
        String a = null, b = null, c = "x";

        // 2. equals y hashCode seguros con null
        System.out.println(Objects.equals(a, b));         // true  (null == null)
        System.out.println(Objects.equals(a, c));         // false (sin NPE)
        System.out.println(Objects.hashCode(a));          // 0     (en lugar de NPE)
        System.out.println(Objects.hash(a, c, 42));       // combina varios campos

        // 3. toString seguro
        System.out.println(Objects.toString(a));          // "null"
        System.out.println(Objects.toString(a, "(vacío)")); // "(vacío)" ← con valor por defecto

        // 4. Valores por defecto
        System.out.println(Objects.requireNonNullElse(a, "defecto"));       // "defecto"
        System.out.println(Objects.requireNonNullElseGet(a, () -> costoso())); // perezoso

        // 5. Comprobaciones booleanas: legibles dentro de un filter o un if
        System.out.println(Objects.isNull(a));            // true
        System.out.println(Objects.nonNull(c));           // true
        // Uso idiomático: lista.stream().filter(Objects::nonNull)

        // 6. Comprobación de índices (Java 9+): mensajes de error mucho mejores
        int[] datos = new int[10];
        try {
            Objects.checkIndex(15, datos.length);
        } catch (IndexOutOfBoundsException e) {
            System.out.println(e.getMessage());   // "Index 15 out of bounds for length 10"
        }
        // También: checkFromToIndex(from, to, length) y checkFromIndexSize(from, size, length)

        // 7. Comparación con un comparador, tolerante a null
        System.out.println(Objects.compare("a", "b", java.util.Comparator.naturalOrder()));  // -1

        // 8. Identidad real de un objeto, aunque haya sobrescrito hashCode
        System.out.println(Objects.toIdentityString(c));   // java.lang.String@1b6d3586
        System.out.println(System.identityHashCode(c));    // el hash de identidad original
    }

    static String costoso() { return "calculado"; }
}
El patrón que más bugs evita: Objects.requireNonNull en todos los constructores y en los métodos públicos que reciben datos de fuera. Cuesta una línea y convierte un NullPointerException misterioso a 500 ms y tres capas de distancia en un fallo inmediato con el nombre del parámetro culpable. Es la aplicación más rentable del principio fail fast. Desde Java 14, los NPE de la JVM ya dicen exactamente qué expresión era nula (helpful NullPointerExceptions), pero validar en el límite sigue siendo mejor: el error aparece antes de que el objeto inválido se guarde en algún sitio.

Comprobación rápida de la sección 5

6 · Genéricos: por qué existen y qué se pierde en el borrado

Los genéricos (Java 5) mueven al compilador los errores de tipo que antes explotaban en ejecución. Pero se implementaron con una restricción brutal: compatibilidad binaria total con el código anterior. De ahí sale el borrado de tipos (type erasure) y todas sus rarezas.

6.1 Antes y después

// ---------- Java 1.4: todo era Object y los errores salían en ejecución ----------
java.util.List lista = new java.util.ArrayList();
lista.add("texto");
lista.add(42);                                  // nadie se queja
String s = (String) lista.get(1);               // ClassCastException EN EJECUCIÓN

// ---------- Java 5+: el compilador se encarga ----------
java.util.List<String> segura = new java.util.ArrayList<>();
segura.add("texto");
// segura.add(42);                              // ← ERROR DE COMPILACIÓN
String t = segura.get(0);                       // sin cast: el compilador lo pone por ti

// Los tres beneficios, en orden de valor:
//   1. Errores en compilación, no en producción.
//   2. Sin casts: el código dice lo que hace.
//   3. Documentación ejecutable: la firma dice qué acepta y qué devuelve.

6.2 Borrado de tipos: qué queda en el bytecode

El compilador comprueba los tipos y luego los borra: sustituye cada parámetro de tipo por su límite superior (Object si no tiene límite) e inserta los casts necesarios. En ejecución, List<String> y List<Integer> son la misma clase.

// Lo que escribes                        Lo que queda tras el borrado
class Caja<T> {                          class Caja {
    private T valor;                          private Object valor;
    void poner(T v) { valor = v; }            void poner(Object v) { valor = v; }
    T sacar() { return valor; }               Object sacar() { return valor; }
}                                        }

class CajaAcotada<T extends Number> {    class CajaAcotada {
    private T valor;                          private Number valor;      // ← el límite
    T sacar() { return valor; }               Number sacar() { return valor; }
}

// En el punto de llamada, el compilador inserta el cast:
//   Caja<String> c = new Caja<>();
//   String s = c.sacar();     →     String s = (String) c.sacar();   // checkcast
Consecuencia del borradoLo que no compila o fallaSolución
No hay información de tipo en ejecuciónif (x instanceof List<String>)instanceof List<?>, o pasa un Class<T> (token de tipo).
No se puede instanciar el parámetro de tiponew T(), new T[10]Pasa un Supplier<T>, un Class<T> o un IntFunction<T[]>.
No se puede sobrecargar por el argumento de tipovoid f(List<String>) junto a void f(List<Integer>)Nombres distintos, o un parámetro extra que desambigüe.
Los miembros static no ven el parámetro de la claseclass C<T> { static T campo; }Declara el método genérico por su cuenta: static <U> U f(U x).
No se puede capturar un tipo genérico de excepcióncatch (T e)Captura Exception y comprueba con instanceof.
Los primitivos no valen como argumento de tipoList<int>List<Integer>, o mejor int[] / IntStream. Project Valhalla lo arreglará algún día.
Deserializar genéricos necesita ayudamapper.readValue(json, List.class) pierde el tipo del elementonew TypeReference<List<Pedido>>() {} (Jackson) o ParameterizedTypeReference (Spring).
Firmas que colisionan tras el borradoclass C implements Comparable<A>, Comparable<B>Imposible: tras el borrado son la misma interfaz. Rediseña.
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.List;

public class BorradoEnAccion {
    public static void main(String[] args) throws Exception {

        List<String> cadenas = new ArrayList<>();
        List<Integer> enteros = new ArrayList<>();

        // La MISMA clase en ejecución
        System.out.println(cadenas.getClass() == enteros.getClass());   // true
        System.out.println(cadenas.getClass().getName());               // java.util.ArrayList

        // Se puede colar cualquier cosa usando un tipo crudo
        List crudo = cadenas;                    // aviso "unchecked", no error
        crudo.add(42);                           // ¡acepta un Integer en un List<String>!
        System.out.println(cadenas.size());      // 1
        try {
            String s = cadenas.get(0);           // ClassCastException AQUÍ, no al añadir
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("Explota al LEER, no al escribir: " + e.getMessage());
        }

        // Lo que SÍ sobrevive al borrado: la información de tipos de las FIRMAS.
        // Está en el atributo Signature del .class y se lee por reflexión. Por eso
        // funcionan Jackson, Spring y la inyección de Map<String, Bean>.
        var campo = Contenedor.class.getDeclaredField("elementos");
        var tipo = (ParameterizedType) campo.getGenericType();
        System.out.println(tipo);                                 // java.util.List<java.lang.String>
        System.out.println(tipo.getActualTypeArguments()[0]);     // class java.lang.String
    }

    static class Contenedor { private List<String> elementos = new ArrayList<>(); }
}
«¿Entonces los genéricos se pierden del todo?» No. Se borran de las instancias, pero no de las declaraciones: campos, parámetros, retornos y cláusulas extends/implements conservan su información genérica en los metadatos del .class. Eso permite que Jackson deserialice una List<Pedido> cuando el destino está declarado así, o que Spring inyecte una List<Transportista>. Lo que no se puede es preguntarle a un objeto ya creado por su argumento de tipo.

6.3 Tipos crudos: nunca, y este es el motivo

// ❌ Tipo crudo: renuncia a TODA la comprobación de tipos, no solo en ese punto
List crudo = new ArrayList();
crudo.add("a"); crudo.add(1); crudo.add(new Object());     // todo vale

// ⚠️ Y lo peor: contagia. Al usar un tipo crudo, los genéricos de esa expresión
// se borran, incluso los que no tienen nada que ver:
List<String> ok = new ArrayList<>();
List contagiado = ok;
// contagiado.add(42);         ← corrompe 'ok' sin ningún error de compilación

// ✅ Si de verdad no te importa el tipo, usa el comodín: List<?>
static int contar(List<?> cualquiera) {
    for (Object o : cualquiera) { /* se puede LEER como Object */ }
    // cualquiera.add("x");    ← error de compilación: el compilador te protege
    return cualquiera.size();
}
List (crudo)List<Object>List<?>
Comprobación de tiposNingunaCompletaCompleta
Acepta un List<String>No: los genéricos no son covariantes
Se puede añadirCualquier cosaCualquier ObjectSolo null
Se puede leer comoObjectObjectObject
Cuándo usarloNunca, salvo en instanceof y literales de claseCuando de verdad guardas cosas heterogéneasMétodos que solo leen o consultan, sin importar el tipo

6.4 Comodines y PECS: la regla que hay que memorizar

Los genéricos son invariantes: List<String> no es un subtipo de List<Object>. Y tiene que ser así: si lo fuera, podrías meter un Integer en una lista de String a través de la referencia general. Los comodines devuelven, de forma controlada, la flexibilidad que la invarianza quita.

          ┌──────────────────────────────────────────────────────────────┐
          │   PECS:  Producer → Extends   ·   Consumer → Super           │
          └──────────────────────────────────────────────────────────────┘

  ? extends T   PRODUCTOR: de aquí LEO. Todo lo que salga es al menos un T.
                No puedo escribir, porque no sé cuál es el tipo exacto.
                  List<? extends Number> puede ser List<Integer> o List<Double>
                  Number n = lista.get(0);      ✔ leer, sí
                  lista.add(1);                 ✘ escribir, no (solo null)

  ? super T     CONSUMIDOR: aquí ESCRIBO. Acepta cualquier T o subtipo de T.
                Al leer solo puedo garantizar Object.
                  List<? super Integer> puede ser List<Integer>, List<Number>
                  o List<Object>
                  lista.add(42);                ✔ escribir, sí
                  Integer i = lista.get(0);     ✘ leer como Integer, no
                  Object   o = lista.get(0);    ✔

  ?             Ni leer con tipo ni escribir: solo tamaño, iterar como Object, clear().
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;

public class Pecs {

    // ✅ PECS aplicado: 'origen' produce (extends), 'destino' consume (super).
    // Con esta firma, copiar(List<Integer>, List<Object>) compila. Sin comodines,
    // solo aceptaría List<Number> a List<Number>: casi inútil.
    static <T> void copiar(List<? extends T> origen, List<? super T> destino) {
        for (T elemento : origen) destino.add(elemento);
    }

    // Así están declaradas de verdad las firmas del JDK. Fíjate en los comodines:
    //   boolean Collection.addAll(Collection<? extends E> c)         ← produce E
    //   void    Collections.copy(List<? super T> dest, List<? extends T> src)
    //   T       Collections.max(Collection<? extends T> c, Comparator<? super T> cmp)
    //   Stream<R> Stream.map(Function<? super T, ? extends R> mapper)
    //   void    Iterable.forEach(Consumer<? super T> action)

    // Ejemplo real: un método que suma cualquier colección de números
    static double sumar(Collection<? extends Number> numeros) {
        double total = 0;
        for (Number n : numeros) total += n.doubleValue();
        return total;
    }

    // Ejemplo real: un comparador que sirva también para supertipos.
    // Con Comparator<? super T> puedes ordenar una List<Empleado> con un
    // Comparator<Persona>, que es lo natural.
    static <T> T maximo(List<? extends T> lista, java.util.Comparator<? super T> cmp) {
        if (lista.isEmpty()) throw new IllegalArgumentException("lista vacía");
        T mejor = lista.get(0);
        for (T candidato : lista) if (cmp.compare(candidato, mejor) > 0) mejor = candidato;
        return mejor;
    }

    public static void main(String[] args) {
        List<Integer> enteros = List.of(1, 2, 3);
        List<Object> destino = new ArrayList<>();
        copiar(enteros, destino);                       // ✔ gracias a PECS
        System.out.println(destino);                     // [1, 2, 3]
        System.out.println(sumar(List.of(1, 2.5, 3L)));   // 6.5
        System.out.println(maximo(List.of("b", "a", "c"), java.util.Comparator.naturalOrder()));

        // Función que transforma: consume T (super) y produce R (extends)
        Function<Object, Integer> longitud = o -> o.toString().length();
        List<String> palabras = List.of("hola", "mundo");
        System.out.println(palabras.stream().map(longitud).toList());   // [4, 5]
    }
}
Cómo recordar PECS sin memorizarlo: piensa en la dirección de los datos. Si el parámetro es una fuente de la que sacas cosas, es un productor: extends. Si es un destino al que metes cosas, es un consumidor: super. Y si haces las dos cosas con el mismo parámetro (por ejemplo, List.sort, que lee y escribe en la misma lista), no puedes usar comodín: necesitas el tipo exacto T. Regla de oro para tus APIs: usa comodines en los parámetros y nunca en los retornos, porque un comodín en el retorno obliga a todos tus usuarios a usar comodines también.

6.5 Métodos genéricos y límites

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Supplier;

public class MetodosGenericos {

    // 1. Método genérico básico: el <T> va ANTES del tipo de retorno
    static <T> List<T> deElementos(T... elementos) { return List.of(elementos); }

    // 2. Con límite superior: T tiene que ser Comparable consigo mismo o con un supertipo.
    //    La firma "<T extends Comparable<? super T>>" es la del JDK y es la correcta:
    //    permite ordenar una List<Empleado> cuando Comparable está en Persona.
    static <T extends Comparable<? super T>> T maximo(Collection<? extends T> c) {
        var it = c.iterator();
        if (!it.hasNext()) throw new IllegalArgumentException("colección vacía");
        T mejor = it.next();
        while (it.hasNext()) { T x = it.next(); if (x.compareTo(mejor) > 0) mejor = x; }
        return mejor;
    }

    // 3. Límites MÚLTIPLES: la clase primero, después las interfaces, con &
    static <T extends Number & Comparable<T>> T mayorNumero(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }

    // 4. Varios parámetros de tipo relacionados: la firma documenta la transformación
    static <K, V, R> Map<K, R> transformarValores(Map<K, V> original,
                                                  Function<? super V, ? extends R> f) {
        var resultado = new java.util.LinkedHashMap<K, R>();
        original.forEach((k, v) -> resultado.put(k, f.apply(v)));
        return resultado;
    }

    // 5. Tipo recursivo (patrón "self type"): permite que un builder heredable
    //    devuelva siempre el tipo del hijo. Es la forma correcta de encadenar en jerarquías.
    abstract static class ConstructorBase<T extends ConstructorBase<T>> {
        protected String nombre;
        @SuppressWarnings("unchecked")
        public T nombre(String n) { this.nombre = n; return (T) this; }
    }
    static class ConstructorPedido extends ConstructorBase<ConstructorPedido> {
        private int unidades;
        public ConstructorPedido unidades(int u) { this.unidades = u; return this; }
        public String construir() { return nombre + " x" + unidades; }
    }

    // 6. Fábrica genérica: sustituye al imposible "new T()"
    static <T, C extends Collection<T>> C copiarEn(Collection<? extends T> origen,
                                                   Supplier<C> fabrica) {
        C destino = fabrica.get();
        destino.addAll(origen);
        return destino;
    }

    public static void main(String[] args) {
        System.out.println(maximo(List.of(3, 9, 4)));                        // 9
        System.out.println(mayorNumero(3.5, 2.1));                           // 3.5
        System.out.println(transformarValores(Map.of("a", 1, "b", 2), v -> v * 10));
        System.out.println(new ConstructorPedido().nombre("Teclado").unidades(2).construir());
        System.out.println(copiarEn(List.of(3, 1, 2), java.util.TreeSet::new));  // [1, 2, 3]
        System.out.println(deElementos("x", "y"));
        System.out.println(new ArrayList<>(List.of(1)).size());
    }
}

6.6 Arrays y genéricos no se llevan bien

Los arrays son covariantes (String[] es un Object[]) y comprueban el tipo en ejecución. Los genéricos son invariantes y comprueban en compilación. Son dos modelos opuestos, y por eso new T[] no existe.

public class ArraysVsGenericos {
    public static void main(String[] args) {

        // ❌ La covarianza de los arrays permite este error, que compila sin avisos
        Object[] objetos = new String[2];       // legal: String[] ES un Object[]
        try {
            objetos[0] = 42;                     // ArrayStoreException EN EJECUCIÓN
        } catch (ArrayStoreException e) {
            System.out.println("ArrayStoreException: " + e.getMessage());   // java.lang.Integer
        }

        // ✅ Con genéricos, el mismo error se detecta al compilar:
        // List<Object> lista = new ArrayList<String>();   ← no compila. Mejor así.

        // ❌ No se puede crear un array del parámetro de tipo
        // T[] array = new T[10];                 ← "generic array creation"
        // List<String>[] arrays = new List<String>[10];   ← tampoco

        // ✅ Los tres apaños legítimos:
        // 1) Un array de Object con cast, encapsulado y con @SuppressWarnings justificado
        var pila = new PilaSimple<String>(4);
        pila.meter("a"); pila.meter("b");
        System.out.println(pila.sacar());        // b

        // 2) Un IntFunction<T[]> que el llamante proporciona (lo que hace Stream.toArray)
        String[] copia = java.util.stream.Stream.of("x", "y").toArray(String[]::new);
        System.out.println(java.util.Arrays.toString(copia));

        // 3) Reflexión con un token de tipo
        String[] reflexivo = crearArray(String.class, 3);
        System.out.println(reflexivo.length);
    }

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

    /** Así lo hace ArrayList por dentro: Object[] interno y cast al leer. */
    static class PilaSimple<E> {
        private Object[] elementos;             // ← Object[], NO E[]
        private int tamano;

        PilaSimple(int capacidad) { elementos = new Object[capacidad]; }

        void meter(E e) {
            if (tamano == elementos.length)
                elementos = java.util.Arrays.copyOf(elementos, tamano * 2);
            elementos[tamano++] = e;
        }

        // El cast es seguro porque solo entran E por meter(). Se documenta y se silencia
        // el aviso en el ÁMBITO MÁS PEQUEÑO posible: una variable local, no el método entero.
        E sacar() {
            if (tamano == 0) throw new java.util.EmptyStackException();
            @SuppressWarnings("unchecked") E resultado = (E) elementos[--tamano];
            elementos[tamano] = null;            // evita retener el objeto: sin fuga de memoria
            return resultado;
        }
    }
}
ArrayColección genérica
VarianzaCovariante (String[] es Object[])Invariante
ComprobaciónEn ejecución (ArrayStoreException)En compilación
Información de tipo en ejecución: es reificadoNo: se borra
TamañoFijoDinámico
PrimitivosSí: int[]No: hay que envolver
APIPobre (Arrays como utilidad externa)Riquísima (streams, Collections)
Cuándo usarloRendimiento con primitivos, buffers de E/S, interoperabilidadPor defecto, siempre

6.7 Class<T>, tokens de tipo y contenedores heterogéneos

Cuando de verdad necesitas el tipo en ejecución, la solución es pasarlo explícitamente como un Class<T>. Se llama token de tipo y es el patrón que usan Spring, Jackson y todo el ecosistema.

import java.util.HashMap;
import java.util.Map;

public class TokensDeTipo {

    // ---------- 1. Token de tipo simple: recuperar el tipo perdido ----------
    static <T> T convertir(Object valor, Class<T> tipo) {
        if (!tipo.isInstance(valor))
            throw new IllegalArgumentException("Se esperaba %s y llegó %s"
                    .formatted(tipo.getSimpleName(), valor.getClass().getSimpleName()));
        return tipo.cast(valor);              // cast seguro, comprobado en ejecución
    }

    // ---------- 2. Contenedor heterogéneo con seguridad de tipos ----------
    // Un mapa donde cada clave lleva su propio tipo de valor. Es como funciona
    // AnnotatedElement.getAnnotation o los atributos de una petición HTTP bien hechos.
    static class Contexto {
        private final Map<Class<?>, Object> valores = new HashMap<>();

        <T> void poner(Class<T> tipo, T valor) {
            valores.put(java.util.Objects.requireNonNull(tipo), tipo.cast(valor));
        }

        <T> T obtener(Class<T> tipo) { return tipo.cast(valores.get(tipo)); }
    }

    // ---------- 3. Super type token: capturar un tipo GENÉRICO completo ----------
    // El truco: una subclase anónima conserva su superclase genérica en los metadatos,
    // así que se puede leer con getGenericSuperclass(). Es exactamente lo que hacen
    // TypeReference de Jackson y ParameterizedTypeReference de Spring.
    abstract static class ReferenciaTipo<T> {
        private final java.lang.reflect.Type tipo;
        protected ReferenciaTipo() {
            var superClase = (java.lang.reflect.ParameterizedType) getClass().getGenericSuperclass();
            this.tipo = superClase.getActualTypeArguments()[0];
        }
        public java.lang.reflect.Type tipo() { return tipo; }
    }

    public static void main(String[] args) {
        System.out.println(convertir("hola", String.class).toUpperCase());
        try { convertir(42, String.class); }
        catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }

        var ctx = new Contexto();
        ctx.poner(String.class, "usuario-42");
        ctx.poner(Integer.class, 7);
        String usuario = ctx.obtener(String.class);      // sin cast en el punto de uso
        int intentos = ctx.obtener(Integer.class);
        System.out.println(usuario + " / " + intentos);

        // Captura del tipo genérico completo, incluido el argumento
        var ref = new ReferenciaTipo<java.util.List<String>>() { };
        System.out.println(ref.tipo());     // java.util.List<java.lang.String>

        // Uso real con Jackson y con Spring:
        //   var lista = mapper.readValue(json, new TypeReference<List<Pedido>>() {});
        //   var resp  = restClient.get().uri("/pedidos").retrieve()
        //                   .body(new ParameterizedTypeReference<List<Pedido>>() {});
    }
}

6.8 Heap pollution, varargs genéricos y @SafeVarargs

Heap pollution es la situación en la que una variable de tipo parametrizado apunta a un objeto que no es de ese tipo. El compilador te avisa con unchecked warning, y si lo ignoras el ClassCastException aparece en un sitio que no tiene nada que ver con el error.

import java.util.Arrays;
import java.util.List;

public class VarargsGenericos {

    // ⚠️ Todo método con varargs de tipo genérico crea internamente un T[],
    // que es imposible de crear con seguridad. El compilador avisa en la DECLARACIÓN
    // ("Possible heap pollution from parameterized vararg type") y en cada LLAMADA.
    @SafeVarargs   // ← promesa: "este método no expone el array y no escribe en él"
    static <T> List<T> deElementos(T... elementos) {
        return List.of(elementos);      // solo lee: es seguro
    }

    // ❌ El contraejemplo que demuestra por qué el aviso existe: aquí el array SE ESCAPA
    static <T> T[] peligroso(T... elementos) { return elementos; }

    // ❌ Y aquí se contamina el heap sin un solo cast visible
    static void contaminar(List<String>... listas) {
        Object[] array = listas;                       // covarianza de arrays
        array[0] = List.of(42);                        // ¡un List<Integer> en un List<String>[]!
        String s = listas[0].get(0);                   // ClassCastException aquí
    }

    public static void main(String[] args) {
        System.out.println(deElementos("a", "b"));     // sin avisos: @SafeVarargs

        // El array devuelto por 'peligroso' es un Object[] en realidad:
        Object[] objetos = peligroso("a", "b");
        System.out.println(objetos.getClass().getSimpleName());   // String[] aquí, pero...
        // Si el llamante mezcla tipos, el tipo inferido es el supertipo común y
        // asignarlo a String[] revienta con ClassCastException.

        try { contaminar(List.of("a")); }
        catch (ClassCastException e) { System.out.println("Heap pollution: " + e.getMessage()); }

        System.out.println(Arrays.asList(1, 2, 3));     // Arrays.asList también es @SafeVarargs
    }
}
Aviso del compiladorQué significaQué hacer
unchecked castUn cast a un tipo genérico que la JVM no puede verificar.Convéncete de que es seguro, encapsúlalo y pon @SuppressWarnings("unchecked") en el ámbito más pequeño posible, con un comentario que explique por qué es seguro.
unchecked callLlamada a un método de un tipo crudo.Parametriza el tipo. Nunca silencies esto.
unchecked conversionAsignas un tipo crudo a uno parametrizado.Igual: parametriza.
possible heap pollutionVarargs de tipo genérico.Si el método no guarda ni devuelve el array y solo lo lee, anótalo con @SafeVarargs (solo vale en métodos static, final, private o constructores). Si lo devuelve, cambia la firma para aceptar una List.
Compila siempre con los avisos activados y trátalos como errores. Añade a tu maven-compiler-plugin los argumentos -Xlint:all y -Werror. Los avisos de genéricos no son ruido: son el compilador diciéndote «aquí ya no puedo garantizarte nada». En una base de código con miles de avisos, el que importa pasa desapercibido para siempre.
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.13.0</version>
  <configuration>
    <release>21</release>
    <compilerArgs>
      <arg>-Xlint:all,-processing</arg>   <!-- todos los avisos menos el ruido de los procesadores -->
      <arg>-Werror</arg>                  <!-- un aviso rompe la compilación -->
      <arg>-parameters</arg>              <!-- conserva los nombres de los parámetros (Jackson, Spring) -->
    </compilerArgs>
  </configuration>
</plugin>

Comprobación rápida de la sección 6

7 · Colecciones a fondo

El Java Collections Framework es la biblioteca que más vas a usar en tu vida y la que más se pregunta. No basta con saber que ArrayList es una lista: hay que saber qué estructura de datos hay debajo, cuál es el coste de cada operación y qué pasa cuando eliges mal.

7.1 La jerarquía completa

                             Iterable<E>
                                  │
                            Collection<E>
            ┌─────────────────────┼─────────────────────┐
          List<E>              Set<E>                Queue<E>
            │                     │                      │
            │              ┌──────┴──────┐         ┌─────┴──────┐
            │          SortedSet<E>  (Sequenced-  Deque<E>   (BlockingQueue<E>)
            │              │          Set, J21)      │
            │        NavigableSet<E>                 │
            │                                        │
  ArrayList ·············· HashSet ············· ArrayDeque
  LinkedList (también Deque)  LinkedHashSet       LinkedList
  Vector (obsoleto)          TreeSet             PriorityQueue
  CopyOnWriteArrayList       EnumSet             ConcurrentLinkedDeque
  List.of(...) inmutable     ConcurrentHashMap.newKeySet()   LinkedBlockingQueue
                             CopyOnWriteArraySet             ArrayBlockingQueue
                                                             DelayQueue, SynchronousQueue

                    Map<K,V>   ← ¡NO extiende Collection!
                       │
            ┌──────────┼───────────────┬────────────────────┐
     SortedMap<K,V>  HashMap    LinkedHashMap     (SequencedMap, Java 21)
            │        Hashtable(obs.)  EnumMap
     NavigableMap<K,V>  IdentityHashMap  WeakHashMap
            │        ConcurrentHashMap  Map.of(...) inmutable
        TreeMap      ConcurrentSkipListMap
InterfazContratoGarantiza
CollectionUn grupo de elementosAñadir, borrar, consultar, iterar, tamaño
ListSecuencia con índiceOrden posicional, duplicados permitidos, get(i)
SetConjunto sin duplicadosUnicidad según equals. Sin orden, salvo implementaciones concretas
SortedSet / SortedMapOrdenado por comparaciónfirst, last, headSet, tailSet, subSet
NavigableSet / NavigableMapNavegación por proximidadfloor, ceiling, higher, lower, descendingSet
QueueCola: se saca por un extremooffer/poll/peek (devuelven null/false) y add/remove/element (lanzan)
DequeCola doblemente terminadaOperaciones en los dos extremos: sirve como pila y como cola
MapAsociación clave→valorClaves únicas. No es una Collection: sus vistas sí lo son
SequencedCollection Java 21Colección con orden de encuentro definidoaddFirst, addLast, getFirst, getLast, reversed()
Novedad importante de Java 21: colecciones secuenciadas. Hasta entonces, obtener el primer elemento era distinto en cada colección: list.get(0), deque.peekFirst(), sortedSet.first(), y en un LinkedHashSet no había forma sin iterar. Java 21 unifica todo con SequencedCollection, SequencedSet y SequencedMap: getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast() y reversed(), que devuelve una vista invertida sin copiar. Detalle completo en el módulo 02.
import java.util.*;

public class ColeccionesSecuenciadas {
    public static void main(String[] args) {

        SequencedCollection<String> lista = new ArrayList<>(List.of("b", "c"));
        lista.addFirst("a");
        lista.addLast("d");
        System.out.println(lista);                    // [a, b, c, d]
        System.out.println(lista.getFirst() + " " + lista.getLast());     // a d
        System.out.println(lista.reversed());         // [d, c, b, a]  ← vista, no copia

        // Antes de Java 21, obtener el último de un LinkedHashSet requería iterarlo entero
        SequencedSet<String> conjunto = new LinkedHashSet<>(List.of("uno", "dos", "tres"));
        System.out.println(conjunto.getLast());       // tres
        System.out.println(conjunto.reversed());      // [tres, dos, uno]

        SequencedMap<String, Integer> mapa = new LinkedHashMap<>();
        mapa.put("a", 1); mapa.put("b", 2);
        mapa.putFirst("z", 0);                        // inserta al principio
        System.out.println(mapa);                     // {z=0, a=1, b=2}
        System.out.println(mapa.firstEntry());        // z=0
        System.out.println(mapa.pollFirstEntry());    // z=0, y lo elimina
        System.out.println(mapa.reversed());          // {b=2, a=1}
    }
}

7.2 ArrayList frente a LinkedList: la medición real

Es la pregunta de entrevista más clásica y la que más gente responde mal, porque la respuesta teórica de los libros de estructuras de datos no coincide con la realidad de una CPU moderna. Vamos con la teoría primero y la medición después.

OperaciónArrayListLinkedListQuién gana en la práctica
get(i)O(1)O(n)ArrayList, sin discusión
add(e) al finalO(1) amortizadoO(1)ArrayList: mejor localidad de caché
add(0, e) al principioO(n) (copia el array)O(1)ArrayDeque, mejor que las dos
remove(i) por el medioO(n) (copia)O(n) (recorrido) + O(1)Casi siempre ArrayList: System.arraycopy es una operación vectorizada brutalmente rápida
Borrar mientras iterasO(n) por elementoO(1) con Iterator.removeLinkedList en teoría; removeIf sobre ArrayList en la práctica
Recorrer todoRápido: memoria contiguaLento: un salto de puntero por elementoArrayList, hasta 10× más rápido
Memoria por elemento4 u 8 bytes (referencia)~40 bytes (nodo con cabecera + 2 punteros + referencia)ArrayList
Como cola o pilaMalAceptableArrayDeque, que gana a las dos
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

/**
 * Medición aproximada (para medir en serio, JMH: sección 13.9).
 * Ejecuta con:  java -Xmx2g ComparativaListas
 */
public class ComparativaListas {

    static final int N = 200_000;

    public static void main(String[] args) {
        calentar();

        System.out.printf("%-34s %12s %12s%n", "Operación", "ArrayList", "LinkedList");
        System.out.println("-".repeat(60));

        medir("add al final (n=200.000)",
              () -> { var l = new ArrayList<Integer>(); for (int i=0;i<N;i++) l.add(i); },
              () -> { var l = new LinkedList<Integer>(); for (int i=0;i<N;i++) l.add(i); });

        var arrayList = nuevaArrayList();
        var linkedList = nuevaLinkedList();

        medir("get(i) aleatorio (100.000 veces)",
              () -> accesoAleatorio(arrayList),
              () -> accesoAleatorio(linkedList));

        medir("recorrido completo con for-each",
              () -> recorrer(arrayList),
              () -> recorrer(linkedList));

        medir("add(0, e) 20.000 veces",
              () -> { var l = nuevaArrayList();  for (int i=0;i<20_000;i++) l.add(0, i); },
              () -> { var l = nuevaLinkedList(); for (int i=0;i<20_000;i++) l.add(0, i); });

        medir("removeIf de los pares",
              () -> nuevaArrayList().removeIf(x -> x % 2 == 0),
              () -> nuevaLinkedList().removeIf(x -> x % 2 == 0));
    }

    static List<Integer> nuevaArrayList() {
        var l = new ArrayList<Integer>(N); for (int i=0;i<N;i++) l.add(i); return l;
    }
    static List<Integer> nuevaLinkedList() {
        var l = new LinkedList<Integer>(); for (int i=0;i<N;i++) l.add(i); return l;
    }
    static long accesoAleatorio(List<Integer> l) {
        var r = new java.util.Random(42); long suma = 0;
        for (int i=0;i<100_000;i++) suma += l.get(r.nextInt(l.size()));
        return suma;
    }
    static long recorrer(List<Integer> l) { long s = 0; for (int x : l) s += x; return s; }

    static void medir(String etiqueta, Runnable conArray, Runnable conLinked) {
        long a = cronometrar(conArray), b = cronometrar(conLinked);
        System.out.printf("%-34s %10d ms %10d ms%n", etiqueta, a, b);
    }
    static long cronometrar(Runnable r) {
        long t0 = System.nanoTime(); r.run(); return (System.nanoTime() - t0) / 1_000_000;
    }
    static void calentar() { for (int i=0;i<3;i++) { nuevaArrayList(); nuevaLinkedList(); } }
}
Resultado típico (portátil con Java 21, órdenes de magnitud, no números exactos):

Operación                            ArrayList   LinkedList
------------------------------------------------------------
add al final (n=200.000)                    3 ms        9 ms
get(i) aleatorio (100.000 veces)            2 ms    12.400 ms   ← 6.000× peor
recorrido completo con for-each             1 ms        4 ms
add(0, e) 20.000 veces                    980 ms        2 ms    ← aquí sí gana
removeIf de los pares                       4 ms       11 ms    ← ¡gana ArrayList!
La conclusión, que es la respuesta correcta en una entrevista: usa ArrayList por defecto, siempre. LinkedList solo gana en inserciones y borrados en la cabeza de la lista, y para eso ArrayDeque es mejor que las dos (array circular: O(1) en los extremos y con buena localidad de caché). El motivo de fondo es que la teoría de complejidad ignora la jerarquía de memoria: recorrer un array es una lectura secuencial que el prefetcher de la CPU predice perfectamente, mientras que recorrer una lista enlazada es un fallo de caché por nodo. En el propio JDK, LinkedList no se usa en ningún sitio relevante, y hasta su autor original ha dicho públicamente que se arrepiente de haberla escrito.
// ¿Cómo crece un ArrayList? Es útil saberlo para dimensionar bien.
//   Capacidad inicial por defecto: 10 (perezosa: el array vacío hasta el primer add)
//   Al llenarse: nuevaCapacidad = viejaCapacidad + (viejaCapacidad >> 1)   → ×1,5
//   10 → 15 → 22 → 33 → 49 → 73 → 109 → 163 → 244 → 366 ...
//
// Cada crecimiento hace un Arrays.copyOf: reserva un array nuevo y copia todo.
// Si vas a meter un millón de elementos, eso son ~35 copias y el doble de memoria pico.

var sinDimensionar = new java.util.ArrayList<Integer>();          // 35 realocaciones
var dimensionada   = new java.util.ArrayList<Integer>(1_000_000);  // 0 realocaciones

// Y si sabes el tamaño final y no vas a modificar la lista:
var inmutable = java.util.stream.IntStream.range(0, 1_000_000).boxed().toList();

// trimToSize() devuelve la memoria sobrante si la lista se queda quieta:
var lista = new java.util.ArrayList<Integer>(1_000_000);
lista.add(1);
lista.trimToSize();      // el array pasa de 1.000.000 a 1 posición

7.3 HashMap por dentro: la pregunta casi garantizada

Explicar bien qué ocurre en un map.put(clave, valor) es, probablemente, la pregunta que más veces te van a hacer en tu carrera. Esta es la respuesta completa, paso a paso:

  map.put("teclado", 3)
    │
    ├─ 1. h = clave.hashCode()                    → p. ej. 0x7A4F_2C81
    │
    ├─ 2. MEZCLA (spread):  h ^ (h >>> 16)
    │      Mueve los bits altos a la zona baja. Necesario porque el índice solo usa
    │      los bits bajos: sin esto, dos claves que solo difieren en los bits altos
    │      colisionarían siempre.
    │
    ├─ 3. índice = (n - 1) & h    donde n = tamaño de la tabla (siempre potencia de 2)
    │      Es un AND en lugar de un módulo: mucho más rápido. Esta es la razón de que
    │      la tabla tenga siempre tamaño potencia de dos.
    │
    ├─ 4. ¿El bucket está vacío?  → se crea el nodo y listo. O(1)
    │
    ├─ 5. ¿Está ocupado? → COLISIÓN. Se recorre la lista/árbol del bucket comparando
    │      primero el hash (rápido) y luego con equals (lento):
    │         · si encuentra una clave igual  → reemplaza el valor y devuelve el viejo
    │         · si no                         → añade el nodo al final
    │
    ├─ 6. TREEIFY: si el bucket llega a 8 nodos Y la tabla tiene ≥ 64 posiciones,
    │      la lista se convierte en un ÁRBOL ROJO-NEGRO → O(log n) en el peor caso.
    │      (Si la tabla es pequeña, se redimensiona en lugar de convertir a árbol.)
    │      Si el bucket baja a 6 nodos, vuelve a lista (UNTREEIFY_THRESHOLD).
    │
    └─ 7. RESIZE: si size > capacidad × factorDeCarga (0,75 por defecto), la tabla
           DUPLICA su tamaño y se redistribuyen todas las entradas. Como la capacidad
           es potencia de dos, cada entrada se queda en su bucket o se va al
           bucket + capacidadVieja: no hay que recalcular el hash.
Constante internaValorQué controla
DEFAULT_INITIAL_CAPACITY16Tamaño inicial de la tabla (perezoso: se crea en el primer put).
DEFAULT_LOAD_FACTOR0,75Compromiso entre memoria y colisiones. Bajarlo gasta más memoria; subirlo aumenta las colisiones.
TREEIFY_THRESHOLD8Nodos en un bucket para convertirlo en árbol.
UNTREEIFY_THRESHOLD6Nodos para volver a lista. La histéresis evita oscilar.
MIN_TREEIFY_CAPACITY64Tamaño mínimo de tabla para permitir árboles; por debajo, se redimensiona.
MAXIMUM_CAPACITY230Límite duro de la tabla.
import java.util.*;

public class HashMapPorDentro {
    public static void main(String[] args) {

        // ---------- Dimensionar bien: evita los resize ----------
        int esperados = 10_000;
        // Fórmula clásica: capacidad = esperados / factorDeCarga + 1
        var mapaViejo = new HashMap<String, Integer>((int) (esperados / 0.75f) + 1);
        // Java 19+: hay un método que hace la cuenta por ti y es más claro
        var mapaNuevo = HashMap.<String, Integer>newHashMap(esperados);
        System.out.println(mapaViejo.size() + " " + mapaNuevo.size());

        // ---------- El API moderno de Map: aprende estos ocho métodos ----------
        Map<String, List<String>> porInicial = new HashMap<>();

        // 1. computeIfAbsent: crear la lista solo si no existe. Adiós al if/put.
        for (String palabra : List.of("agua", "aire", "barco", "casa", "cielo")) {
            porInicial.computeIfAbsent(palabra.substring(0, 1), k -> new ArrayList<>())
                      .add(palabra);
        }
        System.out.println(porInicial);      // {a=[agua, aire], b=[barco], c=[casa, cielo]}

        // 2. merge: acumular. El clásico contador de frecuencias en una línea.
        Map<String, Integer> frecuencias = new HashMap<>();
        for (String p : "el gato y el perro y el pez".split(" ")) {
            frecuencias.merge(p, 1, Integer::sum);
        }
        System.out.println(frecuencias);     // {y=2, el=3, gato=1, perro=1, pez=1}

        // 3. getOrDefault: nunca más un null que se desempaqueta y revienta
        System.out.println(frecuencias.getOrDefault("dragón", 0));    // 0

        // 4. putIfAbsent: rellenar valores por defecto sin sobrescribir
        frecuencias.putIfAbsent("dragón", 0);

        // 5. compute / computeIfPresent: transformar el valor existente
        frecuencias.computeIfPresent("el", (k, v) -> v * 10);          // 30
        frecuencias.compute("nuevo", (k, v) -> v == null ? 1 : v + 1);  // crea con 1

        // 6. forEach: iteración legible sin entrySet
        frecuencias.forEach((k, v) -> System.out.printf("%s=%d ", k, v));
        System.out.println();

        // 7. replaceAll: transformar todos los valores en el sitio
        frecuencias.replaceAll((k, v) -> v + 100);

        // 8. entrySet como vista MODIFICABLE: cambia el mapa original
        for (var e : frecuencias.entrySet()) {
            if (e.getValue() > 105) e.setValue(105);      // setValue escribe en el mapa
        }
        System.out.println(frecuencias);

        // ---------- Diferencia clave: computeIfAbsent frente a merge ----------
        // computeIfAbsent: cuando el valor es un CONTENEDOR que hay que crear una vez.
        // merge: cuando el valor es un AGREGADO que se combina con el nuevo.
        // Si devuelves null desde compute/merge, la entrada SE ELIMINA del mapa:
        frecuencias.merge("pez", 1, (viejo, nuevo) -> null);
        System.out.println(frecuencias.containsKey("pez"));            // false

        // ---------- Nulos: HashMap sí, ConcurrentHashMap no ----------
        var conNulos = new HashMap<String, String>();
        conNulos.put(null, "valor de la clave null");     // legal: va al bucket 0
        conNulos.put("clave", null);                       // legal
        System.out.println(conNulos.get(null));
        // Problema: get devuelve null tanto si la clave no está como si su valor es null.
        // Hay que distinguir con containsKey. Por eso ConcurrentHashMap los prohíbe:
        // en un mapa concurrente esa ambigüedad es una condición de carrera esperando.
        try {
            new java.util.concurrent.ConcurrentHashMap<String, String>().put("k", null);
        } catch (NullPointerException e) {
            System.out.println("ConcurrentHashMap no admite valores null");
        }
    }
}
HashMap no es seguro entre hilos, y el fallo no es solo “datos incorrectos”. Antes de Java 8, un resize concurrente podía crear un ciclo en la lista enlazada de un bucket: el hilo siguiente que buscara ahí se quedaba en un bucle infinito al 100 % de CPU. Java 8 cambió el algoritmo de redistribución y ese caso concreto ya no ocurre, pero sigue siendo posible perder actualizaciones, ver tamaños incoherentes o provocar un ConcurrentModificationException. Si varios hilos escriben, usa ConcurrentHashMap. Detalle completo en el módulo 03.

7.4 LinkedHashMap y una caché LRU en seis líneas

LinkedHashMap es un HashMap con una lista doblemente enlazada que recuerda el orden. Ese orden puede ser de inserción (por defecto) o de acceso, y esa segunda opción, combinada con un método hook, te da una caché LRU completa casi gratis.

import java.util.LinkedHashMap;
import java.util.Map;

public class CacheLru<K, V> extends LinkedHashMap<K, V> {

    private final int capacidadMaxima;

    public CacheLru(int capacidadMaxima) {
        // capacidad inicial, factor de carga, y accessOrder = true:
        // cada get() mueve la entrada al final, así que la primera es la MENOS usada.
        super(Math.max(16, capacidadMaxima * 4 / 3 + 1), 0.75f, true);
        this.capacidadMaxima = capacidadMaxima;
    }

    /** El hook: LinkedHashMap lo llama tras cada inserción. Si devuelves true,
        elimina la entrada más antigua según el orden configurado. */
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> masAntigua) {
        return size() > capacidadMaxima;
    }

    public static void main(String[] args) {
        var cache = new CacheLru<String, String>(3);
        cache.put("a", "1"); cache.put("b", "2"); cache.put("c", "3");
        System.out.println(cache.keySet());     // [a, b, c]

        cache.get("a");                          // 'a' pasa a ser la más reciente
        System.out.println(cache.keySet());     // [b, c, a]

        cache.put("d", "4");                     // se desaloja la menos usada: 'b'
        System.out.println(cache.keySet());     // [c, a, d]

        // Orden de INSERCIÓN (accessOrder = false): útil para respuestas JSON estables
        var ordenado = new LinkedHashMap<String, Integer>();
        ordenado.put("zeta", 1); ordenado.put("alfa", 2); ordenado.put("beta", 3);
        System.out.println(ordenado);           // {zeta=1, alfa=2, beta=3} ← se respeta
    }
}
Esta caché no es segura entre hilos ni tiene expiración por tiempo. Para producción usa Caffeine, que además de LRU/LFU adaptativo te da maximumSize, maximumWeight, expireAfterWrite, expireAfterAccess, recarga asíncrona, estadísticas de hit ratio y protección contra estampidas. La caché escrita a mano es un ejercicio excelente y una mala decisión en producción. En Spring, @Cacheable con Caffeine son cuatro líneas de configuración (módulo 04).

7.5 TreeMap y TreeSet: cuando el orden y los rangos importan

Son árboles rojo-negros (árboles binarios de búsqueda autoequilibrados): O(log n) garantizado en todas las operaciones, orden mantenido en todo momento y —lo que casi nadie aprovecha— consultas por rango y por proximidad.

import java.time.LocalDate;
import java.util.*;

public class ArbolesOrdenados {
    public static void main(String[] args) {

        // ---------- Navegación: lo que hace único a NavigableMap ----------
        NavigableMap<Integer, String> tarifas = new TreeMap<>();
        tarifas.put(0,   "gratis");
        tarifas.put(100, "básica");
        tarifas.put(500, "premium");
        tarifas.put(2000,"empresa");

        // "¿Qué tarifa aplica a un consumo de 750?" → la entrada con la clave más grande ≤ 750
        System.out.println(tarifas.floorEntry(750));        // 500=premium   ← ¡esto es oro!
        System.out.println(tarifas.ceilingEntry(750));      // 2000=empresa
        System.out.println(tarifas.lowerKey(500));          // 100  (estrictamente menor)
        System.out.println(tarifas.higherKey(500));         // 2000 (estrictamente mayor)
        System.out.println(tarifas.firstEntry() + " " + tarifas.lastEntry());

        // Vistas por rango: NO copian, son ventanas sobre el mapa original
        System.out.println(tarifas.headMap(500));            // {0=gratis, 100=básica}
        System.out.println(tarifas.tailMap(500, true));      // {500=premium, 2000=empresa}
        System.out.println(tarifas.subMap(100, true, 500, true));
        System.out.println(tarifas.descendingMap());         // orden inverso

        // Consumir como cola de prioridad: extraer y eliminar los extremos
        var copia = new TreeMap<>(tarifas);
        System.out.println(copia.pollFirstEntry());          // 0=gratis, y lo borra

        // ---------- TreeSet con comparador propio ----------
        record Version(int mayor, int menor, int parche) implements Comparable<Version> {
            @Override public int compareTo(Version o) {
                return Comparator.comparingInt(Version::mayor)
                        .thenComparingInt(Version::menor)
                        .thenComparingInt(Version::parche)
                        .compare(this, o);
            }
            @Override public String toString() { return mayor + "." + menor + "." + parche; }
        }
        var versiones = new TreeSet<>(List.of(
                new Version(1, 2, 0), new Version(2, 0, 0), new Version(1, 10, 3)));
        System.out.println(versiones);                       // [1.2.0, 1.10.3, 2.0.0]
        System.out.println(versiones.last());                // 2.0.0  ← la más reciente
        System.out.println(versiones.headSet(new Version(2,0,0)));   // anteriores a la 2.0.0

        // ---------- El caso de uso real: buscar por intervalos de fechas ----------
        NavigableMap<LocalDate, String> precios = new TreeMap<>();
        precios.put(LocalDate.of(2026, 1, 1),  "19.99");
        precios.put(LocalDate.of(2026, 6, 1),  "24.99");
        precios.put(LocalDate.of(2026, 11, 1), "29.99");
        LocalDate consulta = LocalDate.of(2026, 8, 15);
        System.out.println("Precio vigente: " + precios.floorEntry(consulta).getValue()); // 24.99
        // Resolver esto con una List y un bucle son 10 líneas y O(n). Con TreeMap,
        // una llamada y O(log n). Es la estructura que casi nadie recuerda que existe.

        // ---------- ⚠️ Trampa: el comparador define la igualdad, no equals ----------
        var insensible = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
        insensible.addAll(List.of("Hola", "HOLA", "hola"));
        System.out.println(insensible.size());               // 1 (!)
        System.out.println(new HashSet<>(List.of("Hola","HOLA","hola")).size());  // 3

        // ⚠️ Y no admiten null como clave/elemento (necesitan compararlo)
        try { new TreeSet<String>().add(null); }
        catch (NullPointerException e) { System.out.println("TreeSet no admite null"); }
    }
}
HashMapLinkedHashMapTreeMap
EstructuraTabla hash + listas/árbolesTabla hash + lista doblemente enlazadaÁrbol rojo-negro
OrdenNinguno (y puede cambiar entre versiones del JDK)Inserción o accesoNatural o del comparador
get/put/removeO(1) medioO(1) medioO(log n) garantizado
IteraciónO(capacidad), no O(tamaño)O(tamaño): sigue la listaO(tamaño), en orden
Memoria por entradaBaja+2 referencias+3 referencias y un color
Claves nullUna permitidaUna permitidaNo
Rangos y proximidadNoNo: floor, ceiling, subMap
RequierehashCode + equalsIgualComparable o un Comparator
Úsalo cuandoBúsqueda por clave y nada másNecesitas orden estable o una LRUNecesitas orden, rangos o el vecino más cercano

7.6 ArrayDeque y PriorityQueue: las dos infravaloradas

ArrayDeque es un array circular con dos punteros. Es la mejor pila y la mejor cola no concurrente del JDK, y sustituye a las dos clases que todo el mundo usa por costumbre: Stack (obsoleta, sincronizada y con el orden de iteración al revés de lo que esperas) y LinkedList.

import java.util.*;

public class ColasYPilas {
    public static void main(String[] args) {

        // ---------- Como PILA (LIFO): sustituye a Stack ----------
        Deque<String> pila = new ArrayDeque<>();
        pila.push("primero");        // = addFirst
        pila.push("segundo");
        pila.push("tercero");
        System.out.println(pila.peek());    // tercero  (= peekFirst, no elimina)
        System.out.println(pila.pop());     // tercero  (= removeFirst)
        System.out.println(pila);           // [segundo, primero] ← itera desde la cima

        // ⚠️ Por qué NO Stack: extiende Vector, está sincronizada, y su iteración va
        // del FONDO a la CIMA, al contrario de lo que sugiere el concepto de pila.
        var stackViejo = new Stack<String>();
        stackViejo.push("a"); stackViejo.push("b");
        System.out.println(stackViejo);      // [a, b]  ← al revés que ArrayDeque

        // ---------- Como COLA (FIFO) ----------
        Queue<String> cola = new ArrayDeque<>();
        cola.offer("t1"); cola.offer("t2"); cola.offer("t3");
        System.out.println(cola.poll());     // t1
        System.out.println(cola.peek());     // t2

        // Dos familias de métodos: una lanza excepción, la otra devuelve un valor especial
        //   añadir:  add (lanza IllegalStateException)  |  offer (devuelve false)
        //   quitar:  remove (NoSuchElementException)    |  poll  (devuelve null)
        //   mirar:   element (NoSuchElementException)   |  peek  (devuelve null)
        // Con una cola sin límite las dos son equivalentes; con una acotada, elige a conciencia.

        // ---------- Ventana deslizante: el uso avanzado del Deque ----------
        // Máximo de cada ventana de tamaño k, en O(n) total. Sale en entrevistas.
        int[] datos = {1, 3, -1, -3, 5, 3, 6, 7};
        System.out.println(Arrays.toString(maximosPorVentana(datos, 3)));  // [3,3,5,5,6,7]

        // ---------- PriorityQueue: montículo binario, NO está ordenada ----------
        record Tarea(String nombre, int prioridad) { }
        var pendientes = new PriorityQueue<Tarea>(Comparator.comparingInt(Tarea::prioridad));
        pendientes.addAll(List.of(new Tarea("informe", 5), new Tarea("incidencia", 1),
                                  new Tarea("correo", 3)));

        System.out.println(pendientes);      // ¡NO está ordenada! Es un montículo:
                                             // solo se garantiza que el primero es el mínimo
        while (!pendientes.isEmpty()) System.out.print(pendientes.poll().nombre() + " ");
        System.out.println();                // incidencia correo informe ← al SACAR sí ordena

        // Top-K eficiente: montículo de tamaño K, O(n log k) y memoria O(k).
        // Es lo correcto cuando n es enorme y k pequeño: no cabe ordenar todo.
        int k = 3;
        var topK = new PriorityQueue<Integer>(k);          // min-heap
        for (int x : new int[]{7, 2, 9, 4, 11, 1, 8}) {
            topK.offer(x);
            if (topK.size() > k) topK.poll();              // fuera el más pequeño
        }
        System.out.println(new TreeSet<>(topK).descendingSet());   // [11, 9, 8]
    }

    static int[] maximosPorVentana(int[] datos, int k) {
        var indices = new ArrayDeque<Integer>();           // guarda índices, no valores
        var resultado = new int[datos.length - k + 1];
        for (int i = 0; i < datos.length; i++) {
            while (!indices.isEmpty() && indices.peekFirst() <= i - k) indices.pollFirst();
            while (!indices.isEmpty() && datos[indices.peekLast()] <= datos[i]) indices.pollLast();
            indices.offerLast(i);
            if (i >= k - 1) resultado[i - k + 1] = datos[indices.peekFirst()];
        }
        return resultado;
    }
}
NecesitasUsaNo uses
Pila (LIFO)ArrayDeque con push/popStack (obsoleta), LinkedList
Cola (FIFO)ArrayDeque con offer/pollLinkedList
Cola por prioridadPriorityQueueUna List que reordenas
Cola entre hilosArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueueArrayDeque con synchronized
Cola con retardoDelayQueue, ScheduledExecutorServiceUn hilo que duerme y consulta
Cola sin bloqueoConcurrentLinkedQueue, ConcurrentLinkedDeque

7.7 EnumMap y EnumSet: gratis y casi nadie los usa

Si la clave es un enum, hay implementaciones especializadas que son órdenes de magnitud mejores. EnumMap es simplemente un array indexado por ordinal(): cero hashing, cero colisiones, orden natural garantizado. EnumSet es un campo de bits en un long (o varios si hay más de 64 constantes): las operaciones de conjunto se convierten en operaciones de bits.

import java.util.*;

public class EnumColecciones {

    enum Dia { LUN, MAR, MIE, JUE, VIE, SAB, DOM }

    public static void main(String[] args) {

        // ---------- EnumMap: array por debajo ----------
        Map<Dia, String> horario = new EnumMap<>(Dia.class);
        horario.put(Dia.MIE, "Gimnasio");
        horario.put(Dia.LUN, "Reunión de equipo");
        horario.put(Dia.VIE, "Retrospectiva");
        System.out.println(horario);
        // {LUN=Reunión de equipo, MIE=Gimnasio, VIE=Retrospectiva}
        // Fíjate: el orden es el de DECLARACIÓN del enum, no el de inserción. Siempre.

        // ---------- EnumSet: campo de bits ----------
        var laborables = EnumSet.range(Dia.LUN, Dia.VIE);
        var finde      = EnumSet.of(Dia.SAB, Dia.DOM);
        var todos      = EnumSet.allOf(Dia.class);
        var ninguno    = EnumSet.noneOf(Dia.class);
        var noLaborables = EnumSet.complementOf(laborables);

        System.out.println(laborables);              // [LUN, MAR, MIE, JUE, VIE]
        System.out.println(noLaborables);            // [SAB, DOM]
        System.out.println(finde.equals(noLaborables));  // true

        // Operaciones de conjunto: bajo el capó son AND, OR y XOR de bits
        var union = EnumSet.copyOf(laborables); union.addAll(finde);
        var interseccion = EnumSet.copyOf(todos); interseccion.retainAll(finde);
        System.out.println(union.size() + " " + interseccion);
        System.out.println(ninguno.isEmpty());

        // ---------- Uso idiomático: permisos y banderas ----------
        // Sustituye a los "int con máscaras de bits" del código antiguo, con seguridad de tipos
        enum Permiso { LEER, ESCRIBIR, BORRAR, ADMINISTRAR }
        record Usuario(String nombre, Set<Permiso> permisos) {
            Usuario { permisos = permisos.isEmpty()
                    ? Collections.emptySet() : Collections.unmodifiableSet(EnumSet.copyOf(permisos)); }
            boolean puede(Permiso p) { return permisos.contains(p); }   // O(1) sobre bits
        }
        var ana = new Usuario("Ana", EnumSet.of(Permiso.LEER, Permiso.ESCRIBIR));
        System.out.println(ana.puede(Permiso.LEER) + " " + ana.puede(Permiso.BORRAR));

        // ---------- Tabla de doble entrada: EnumMap de EnumMap ----------
        // Máquina de estados o matriz de transiciones, legible y muy compacta
        var transiciones = new EnumMap<Dia, EnumSet<Dia>>(Dia.class);
        transiciones.put(Dia.VIE, EnumSet.of(Dia.SAB));
        System.out.println(transiciones);
    }
}
Cuándo no usar EnumMap: si el mapa se rellena desde datos externos y las claves pueden no existir (un enum nuevo en la base de datos), necesitas la tolerancia de un HashMap<String, …> con validación. Y ojo con la serialización a JSON: el orden del EnumMap depende del orden de declaración, así que reordenar las constantes cambia la salida de tu API. Si el orden es parte del contrato, documéntalo.

7.8 Colecciones inmutables: tres familias que no son lo mismo

import java.util.*;
import java.util.stream.*;

public class Inmutables {
    public static void main(String[] args) {

        // ---------- 1. Fábricas de Java 9+: List.of, Set.of, Map.of ----------
        List<String> lista = List.of("a", "b", "c");
        Set<String> conjunto = Set.of("x", "y");
        Map<String, Integer> mapa = Map.of("uno", 1, "dos", 2);      // hasta 10 pares
        Map<String, Integer> grande = Map.ofEntries(                  // más de 10
                Map.entry("uno", 1), Map.entry("dos", 2), Map.entry("tres", 3));

        // Características que hay que conocer:
        try { lista.add("d"); }
        catch (UnsupportedOperationException e) { System.out.println("Inmutable de verdad"); }
        try { List.of("a", null); }
        catch (NullPointerException e) { System.out.println("NO admiten null"); }
        try { Set.of("a", "a"); }
        catch (IllegalArgumentException e) { System.out.println("Set.of rechaza duplicados"); }

        // ⚠️ El orden de iteración de Set.of y Map.of es DELIBERADAMENTE ALEATORIO
        // entre ejecuciones de la JVM (usa un SALT distinto cada vez). Es para que no
        // escribas código que dependa de un orden que nadie te ha prometido.
        System.out.println(Set.of("a","b","c","d","e"));   // orden distinto en cada ejecución

        // ---------- 2. copyOf: copia + inmutabilidad ----------
        var mutable = new ArrayList<>(List.of("p", "q"));
        var copiada = List.copyOf(mutable);       // copia REAL: desacoplada del original
        mutable.add("r");
        System.out.println(mutable + " vs " + copiada);   // [p, q, r] vs [p, q]

        // ---------- 3. Collections.unmodifiableXxx: VISTA, no copia ----------
        var origen = new ArrayList<>(List.of("p", "q"));
        var vista = Collections.unmodifiableList(origen);
        origen.add("r");
        System.out.println(vista);                 // [p, q, r] ← ¡la "inmutable" ha cambiado!
        // Es una envoltura de solo lectura, no una garantía de inmutabilidad.
        // Correcto:  Collections.unmodifiableList(new ArrayList<>(origen))
        //            o directamente List.copyOf(origen)

        // ---------- 4. stream().toList() frente a collect(toList()) ----------
        List<Integer> conToList = Stream.of(3, 1, 2).toList();               // INMUTABLE
        List<Integer> conCollect = Stream.of(3, 1, 2).collect(Collectors.toList());  // mutable
        List<Integer> conUnmod  = Stream.of(3, 1, 2)
                .collect(Collectors.toUnmodifiableList());                    // inmutable
        conCollect.add(4);                        // funciona
        System.out.println(conToList + " " + conCollect + " " + conUnmod);
        // Diferencia sutil pero importante: toList() SÍ admite nulos en el stream;
        // Collectors.toUnmodifiableList() los rechaza con NPE.

        // ---------- 5. Arrays.asList: la trampa clásica ----------
        var deArray = Arrays.asList("a", "b", "c");
        deArray.set(0, "z");                       // ✔ funciona: escribe en el array subyacente
        try { deArray.add("d"); }                  // ✘ tamaño fijo
        catch (UnsupportedOperationException e) { System.out.println("asList: tamaño fijo"); }

        // Y la peor de todas: con un array de PRIMITIVOS
        int[] primitivos = {1, 2, 3};
        List<int[]> uno = Arrays.asList(primitivos);          // ¡una lista de UN elemento!
        System.out.println(uno.size());                       // 1
        List<Integer> tres = Arrays.stream(primitivos).boxed().toList();   // así sí
        System.out.println(tres.size());                      // 3
        System.out.println(mapa.size() + " " + grande.size() + " " + conjunto.size());
    }
}

7.9 Tabla de complejidades: la chuleta que hay que interiorizar

EstructuraAcceso por índice/claveInsertarBorrarBuscar valorMemoriaOrden
ArrayListO(1)O(1)* al final, O(n) en medioO(n)O(n)BajaInserción
LinkedListO(n)O(1) en extremosO(1) con iteradorO(n)Alta (~40 B/elem.)Inserción
ArrayDequeO(1) extremosO(1)* extremosO(1) extremosO(n)BajaInserción
CopyOnWriteArrayListO(1)O(n)O(n)O(n)Alta al escribirInserción
HashMap/HashSetO(1) medio, O(log n) peorO(1) medioO(1) medioO(n)MediaNinguno
LinkedHashMap/SetO(1) medioO(1) medioO(1) medioO(n)Media-altaInserción o acceso
TreeMap/TreeSetO(log n) garantizadoO(log n)O(log n)O(n)Media-altaComparación
EnumMap/EnumSetO(1) real (array/bits)O(1)O(1)O(n)MínimaDeclaración
PriorityQueueO(1) el mínimoO(log n)O(log n) el mínimo, O(n) otroO(n)BajaSolo el primero
ConcurrentHashMapO(1) medioO(1) medioO(1) medioO(n)MediaNinguno
ConcurrentSkipListMapO(log n)O(log n)O(log n)O(n)AltaComparación
List.of / Map.ofO(1)O(n)MínimaInserción (List) / aleatorio (Set/Map)

* amortizado: una operación puntual puede ser O(n) por el redimensionado, pero el coste medio por operación es constante.

7.10 Iteradores y ConcurrentModificationException

Las colecciones del JDK son fail-fast: llevan un contador interno de modificaciones (modCount) y, si detectan que la colección ha cambiado durante una iteración, lanzan ConcurrentModificationException. No es una garantía —es una ayuda para depurar— pero salta casi siempre y es una de las excepciones que más veces vas a ver.

import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

public class ModificacionDuranteIteracion {
    public static void main(String[] args) {

        // ---------- ❌ El error clásico ----------
        var lista = new ArrayList<>(List.of("a", "borrame", "b", "c"));
        try {
            for (String s : lista) {                     // for-each usa un Iterator
                if (s.equals("borrame")) lista.remove(s);   // modifica por fuera del iterador
            }
        } catch (ConcurrentModificationException e) {
            System.out.println("CME: modificar la lista mientras se itera");
        }

        // ⚠️ El caso que "funciona por casualidad" y confunde a todo el mundo:
        // si borras el PENÚLTIMO elemento, hasNext() devuelve false antes de comprobar
        // el modCount y el bucle termina sin excepción... con un elemento sin procesar.
        var trampa = new ArrayList<>(List.of("a", "b"));
        for (String s : trampa) if (s.equals("a")) trampa.remove(s);
        System.out.println(trampa);      // [b] — sin excepción, pero por pura suerte

        // ---------- ✅ Solución 1: Iterator.remove() ----------
        var conIterador = new ArrayList<>(List.of("a", "borrame", "b"));
        for (var it = conIterador.iterator(); it.hasNext(); ) {
            if (it.next().equals("borrame")) it.remove();     // el iterador actualiza modCount
        }
        System.out.println(conIterador);   // [a, b]

        // ---------- ✅ Solución 2: removeIf (la más legible) ----------
        var conRemoveIf = new ArrayList<>(List.of("a", "borrame", "b"));
        conRemoveIf.removeIf(s -> s.equals("borrame"));
        System.out.println(conRemoveIf);   // [a, b]

        // ---------- ✅ Solución 3: construir una lista nueva ----------
        var original = List.of("a", "borrame", "b");
        var filtrada = original.stream().filter(s -> !s.equals("borrame")).toList();
        System.out.println(filtrada);

        // ---------- ✅ Solución 4: ListIterator para insertar o reemplazar ----------
        var conListIterator = new ArrayList<>(List.of("a", "b"));
        for (var it = conListIterator.listIterator(); it.hasNext(); ) {
            String s = it.next();
            if (s.equals("a")) { it.set("A"); it.add("nuevo"); }   // reemplaza e inserta
        }
        System.out.println(conListIterator);   // [A, nuevo, b]

        // ---------- Mapas: iterar sobre entrySet y borrar con el iterador ----------
        var mapa = new HashMap<>(Map.of("a", 1, "b", 2, "c", 3));
        mapa.entrySet().removeIf(e -> e.getValue() > 2);     // la vista es modificable
        System.out.println(mapa);                             // {a=1, b=2}
        mapa.values().removeIf(v -> v == 1);                  // también por valores
        System.out.println(mapa);                             // {b=2}

        // ---------- Colecciones que NO lanzan CME ----------
        // 1. CopyOnWriteArrayList: el iterador ve una FOTO del momento de crearlo
        var cow = new CopyOnWriteArrayList<>(List.of("a", "b"));
        for (String s : cow) cow.add("z");        // no lanza, pero el bucle NO ve los nuevos
        System.out.println(cow.size());            // 4: se añadieron 2 sin bucle infinito

        // 2. ConcurrentHashMap: iteradores "weakly consistent". Reflejan algunos cambios
        //    y no lanzan. Es el comportamiento correcto para un mapa concurrente.
        var chm = new java.util.concurrent.ConcurrentHashMap<>(Map.of("a", 1));
        for (var e : chm.entrySet()) chm.put("nuevo" + e.getValue(), 9);
        System.out.println(chm.size());
    }
}
Tipo de iteradorComportamientoQuién lo usa
Fail-fastLanza ConcurrentModificationException si detecta modificación estructural externa.ArrayList, HashMap, TreeMap, HashSet… todas las no concurrentes.
Fail-safe / instantáneaItera sobre una copia del momento de creación. No ve cambios posteriores, no lanza.CopyOnWriteArrayList, CopyOnWriteArraySet.
Débilmente consistenteVe los elementos existentes al crearse y puede ver algunos cambios posteriores. Nunca lanza y nunca ve el mismo elemento dos veces.ConcurrentHashMap, ConcurrentLinkedQueue, ConcurrentSkipListMap.

7.11 Cómo elegir la estructura: árbol de decisión

¿Necesitas asociar CLAVE → VALOR?
├── SÍ  →  es un Map
│         ¿La clave es un enum?                        →  EnumMap
│         ¿Varios hilos escriben?                      →  ConcurrentHashMap
│         ¿Necesitas rangos, floor/ceiling o orden?    →  TreeMap
│         ¿Necesitas orden de inserción o una LRU?     →  LinkedHashMap
│         ¿Se construye una vez y no cambia?           →  Map.of / Map.copyOf
│         Si no                                        →  HashMap
│
└── NO  →  ¿Admites DUPLICADOS?
          ├── NO  →  es un Set
          │          ¿enum?                            →  EnumSet
          │          ¿orden y rangos?                  →  TreeSet
          │          ¿orden de inserción?              →  LinkedHashSet
          │          ¿concurrente?                     →  ConcurrentHashMap.newKeySet()
          │          ¿inmutable?                       →  Set.of / Set.copyOf
          │          Si no                             →  HashSet
          │
          └── SÍ  →  ¿Se accede por ÍNDICE o se recorre?
                     ├── SÍ  →  ¿muchas lecturas y casi ninguna escritura, entre hilos?
                     │          →  CopyOnWriteArrayList
                     │          ¿inmutable?             →  List.of / stream().toList()
                     │          Si no                   →  ArrayList
                     │
                     └── NO  →  se saca por un extremo: es una Queue/Deque
                                ¿por prioridad?         →  PriorityQueue
                                ¿entre hilos, con espera?→ ArrayBlockingQueue / LinkedBlockingQueue
                                ¿entre hilos, sin bloqueo?→ ConcurrentLinkedDeque
                                Si no                   →  ArrayDeque
Caso de uso realEstructuraPor qué
Resultado de una consulta que se devuelve por una APIstream().toList()Inmutable, tamaño exacto, imposible de mutar por accidente en una capa superior.
Contar aparicionesHashMap + merge(k, 1, Integer::sum)Una línea y O(1) por elemento. Con streams: groupingBy(f, counting()).
Comprobar pertenencia de millones de elementosHashSetO(1) frente a O(n) de List.contains. Es la optimización que más veces vas a aplicar.
Eliminar duplicados manteniendo el ordenLinkedHashSetUnicidad más orden de aparición, en una construcción.
Tarifa/precio vigente en una fechaTreeMap.floorEntryO(log n) y una sola línea, frente a un bucle O(n).
Los 10 más vendidos de 50 millonesPriorityQueue de tamaño 10O(n log 10) y memoria O(10): ordenar todo sería imposible.
Deshacer/rehacerDos ArrayDequePilas puras, O(1) en los extremos.
Lista de listenersCopyOnWriteArrayListSe lee en cada evento y se escribe al arrancar. Nunca lanza CME al notificar.
Caché en memoria con límiteCaffeine (o LinkedHashMap LRU)Desalojo, expiración y métricas. Una caché sin límite es una fuga de memoria.
Permisos y banderasEnumSetOperaciones de bits, memoria mínima, seguridad de tipos.
Comparar identidad, no igualdadIdentityHashMapUsa == en lugar de equals. Útil para detectar ciclos al serializar.
Caché que no debe impedir el GCWeakHashMapLas claves se recolectan si nadie más las referencia. Cuidado: no es una caché de propósito general.
La optimización que más veces vas a aplicar en tu carrera: un bucle que hace lista.contains(x) dentro de otro bucle. Eso es O(n·m) y con 10.000 elementos en cada colección son 100 millones de comparaciones. Convertir la colección interior en un HashSet antes del bucle lo convierte en O(n+m). Es un cambio de dos líneas que a menudo pasa de 40 segundos a 40 milisegundos, y aparece constantemente en código real.
// ❌ O(n·m): con 10.000 × 10.000 son 100 millones de equals
List<String> activos = cargarActivos();          // 10.000 elementos
List<String> todos = cargarTodos();              // 10.000 elementos
var inactivos = new ArrayList<String>();
for (String u : todos) {
    if (!activos.contains(u)) inactivos.add(u);   // ← O(n) EN CADA ITERACIÓN
}

// ✅ O(n+m): una sola pasada para construir el índice y otra para filtrar
Set<String> indiceActivos = new HashSet<>(activos);       // O(n)
var rapido = todos.stream().filter(u -> !indiceActivos.contains(u)).toList();   // O(m)

// ✅ O(n+m) sin streams, y con los conjuntos del JDK haciendo el trabajo
var diferencia = new HashSet<>(todos);
diferencia.removeAll(new HashSet<>(activos));    // ojo: removeAll(List) sería O(n·m)
Un detalle que casi nadie conoce: Collection.removeAll(otra) llama a otra.contains(...) por cada elemento. Si otra es una List, cada contains es O(n) y el total es O(n·m). Si pasas un HashSet, es O(n). Lo mismo ocurre con retainAll y con containsAll. Convertir el argumento a Set es una optimización invisible y enorme.

Comprobación rápida de la sección 7

8 · Optional y la ausencia de valor

Optional es la clase peor entendida del JDK. Se diseñó con un propósito muy concreto y estrecho, y la mitad del código que la usa la usa mal. Merece la pena entender exactamente para qué se hizo.

8.1 Para qué se diseñó (y para qué no)

Palabras de Brian Goetz, arquitecto del lenguaje: «Optional se pensó para proporcionar un mecanismo limitado a las bibliotecas de retorno de valores cuando había una necesidad clara de representar “sin resultado”, y donde usar null era abrumadoramente propenso a errores». Es decir: es un tipo de retorno, no un tipo de campo ni de parámetro.

Uso¿Correcto?Por qué
Tipo de retorno de un método donde “no hay valor” es normalEl caso de diseño. La firma documenta que puede no haber resultado y el compilador obliga a tratarlo.
Retorno de una operación de stream (findFirst, max, reduce)Un stream puede estar vacío. Sin Optional habría que devolver null o lanzar.
Campo de una clase o entidadNoAñade una indirección y 16 bytes por instancia, no es Serializable, y los frameworks de persistencia y JSON no lo manejan bien. Usa el campo anulable y devuelve Optional desde el accesor.
Parámetro de un métodoNoObliga a los llamantes a envolver (Optional.of(x)) y admite tres estados: null, vacío y con valor. Usa sobrecarga o pasa el valor anulable.
Colección vacíaNoOptional<List<T>> es un antipatrón. Devuelve una lista vacía: es la forma canónica de “sin elementos”.
Componente de un record de APINoSe serializa como {"present":true,"value":…} o falla, según el mapper. Usa un campo anulable y documenta.
Clave o valor de un MapNomap.get ya devuelve null si no hay clave. Usa Optional.ofNullable(map.get(k)) en el borde.

8.2 El API completo, con el uso idiomático de cada método

import java.util.List;
import java.util.Optional;

public class ApiOptional {

    record Direccion(String ciudad, String cp) { }
    record Cliente(String email, Direccion direccion) { }

    // ---------- Creación ----------
    void crear() {
        Optional<String> conValor = Optional.of("x");              // NPE si el valor es null
        Optional<String> puedeSerNulo = Optional.ofNullable(null);  // vacío si es null
        Optional<String> vacio = Optional.empty();
        System.out.println(conValor + " " + puedeSerNulo + " " + vacio);
        // Regla: of() cuando SABES que no es null (falla rápido si te equivocas);
        //        ofNullable() en la frontera con código antiguo o con un Map.
    }

    // ---------- Transformación: la parte que de verdad importa ----------
    String ciudadDe(Optional<Cliente> cliente) {
        return cliente
                .map(Cliente::direccion)          // Optional<Direccion> (vacío se propaga)
                .map(Direccion::ciudad)           // Optional<String>
                .filter(c -> !c.isBlank())        // descarta si está en blanco
                .map(String::strip)
                .orElse("Desconocida");           // valor por defecto
    }

    // flatMap: cuando la función YA devuelve un Optional (si no, quedaría anidado)
    Optional<String> cpDe(Optional<Cliente> cliente) {
        return cliente.flatMap(this::buscarCp);   // con map sería Optional<Optional<String>>
    }
    Optional<String> buscarCp(Cliente c) { return Optional.ofNullable(c.direccion()).map(Direccion::cp); }

    // ---------- Consumo ----------
    void consumir(Optional<Cliente> cliente) {
        // Valor por defecto directo
        String email1 = cliente.map(Cliente::email).orElse("sin-email@ejemplo.com");

        // Valor por defecto PEREZOSO: solo se calcula si hace falta. Úsalo si es costoso.
        String email2 = cliente.map(Cliente::email).orElseGet(this::emailPorDefectoCostoso);

        // Excepción con contexto: el uso más frecuente en la capa de servicio
        Cliente obligatorio = cliente.orElseThrow(() -> new IllegalStateException("sin cliente"));

        // orElseThrow() sin argumentos lanza NoSuchElementException (Java 10+).
        // Es preferible a get(), que es engañoso y está desaconsejado.
        Cliente conNoSuch = cliente.orElseThrow();

        // Acción condicional
        cliente.ifPresent(c -> System.out.println("Encontrado: " + c.email()));

        // Dos ramas sin if (Java 9+)
        cliente.ifPresentOrElse(
                c -> System.out.println("Procesando " + c.email()),
                () -> System.out.println("Nada que procesar"));

        // Alternativa perezosa: primer Optional no vacío (Java 9+)
        Optional<Cliente> conRespaldo = cliente
                .or(() -> buscarEnCache())
                .or(this::buscarEnRemoto);

        // Convertir a stream: encadena con pipelines y filtra vacíos de golpe (Java 9+)
        List<Cliente> encontrados = List.of(cliente, buscarEnCache()).stream()
                .flatMap(Optional::stream)         // los vacíos desaparecen
                .toList();

        System.out.println(email1 + email2 + obligatorio + conNoSuch + conRespaldo + encontrados);
    }

    String emailPorDefectoCostoso() { return "calculado@ejemplo.com"; }
    Optional<Cliente> buscarEnCache()  { return Optional.empty(); }
    Optional<Cliente> buscarEnRemoto() { return Optional.empty(); }
}
MétodoDevuelveCuándo
map(f)Optional<R>La función devuelve un valor normal.
flatMap(f)Optional<R>La función ya devuelve un Optional: evita el anidamiento.
filter(p)Optional<T>Convierte en vacío si no cumple el predicado.
or(sup)Optional<T>Cadena de respaldos: caché → base de datos → remoto.
orElse(v)TValor por defecto ya calculado (una constante).
orElseGet(sup)TValor por defecto costoso: solo se evalúa si el Optional está vacío.
orElseThrow(sup)TLa ausencia es un error del caso de uso: 404, validación fallida.
ifPresent(c)voidEfecto secundario solo si hay valor.
ifPresentOrElse(c, r)voidDos ramas sin escribir un if.
stream()Stream<T>Aplanar una colección de Optional descartando los vacíos.
isPresent() / isEmpty()booleanSolo cuando de verdad necesitas el booleano; no como paso previo a get().
get()TEvítalo. Usa orElseThrow(), que dice lo mismo pero no engaña.

8.3 Los antipatrones, con su corrección

import java.util.List;
import java.util.Optional;

public class AntipatronesOptional {

    record Cliente(String email) { }
    Optional<Cliente> buscar(String id) { return Optional.empty(); }

    void ejemplos(String id) {

        // ❌ 1. isPresent + get: es un if con dos pasos extra y ninguna ventaja
        Optional<Cliente> opt = buscar(id);
        if (opt.isPresent()) { procesar(opt.get()); }
        // ✅
        opt.ifPresent(this::procesar);

        // ❌ 2. get() sin comprobar: exactamente igual de peligroso que un null,
        //     pero con una excepción menos informativa (NoSuchElementException)
        // Cliente c = buscar(id).get();
        // ✅
        Cliente c = buscar(id).orElseThrow(() -> new ClienteNoEncontrado(id));

        // ❌ 3. orElse con una llamada costosa: SE EVALÚA SIEMPRE, incluso si hay valor
        Cliente d = buscar(id).orElse(crearClienteCostoso());     // ← siempre lo crea
        // ✅
        Cliente e = buscar(id).orElseGet(this::crearClienteCostoso);

        // ❌ 4. Optional de una colección
        // Optional<List<Cliente>> buscarTodos();
        // ✅  Devuelve List.of() vacía: el consumidor no necesita un caso especial
        List<Cliente> todos = buscarTodos();
        for (Cliente x : todos) { /* funciona igual con lista vacía */ }

        // ❌ 5. Optional como parámetro
        // void registrar(String nombre, Optional<String> apodo)
        // ✅  Sobrecarga, o acepta el valor anulable y documenta
        registrar("Ana");
        registrar("Ana", "Anita");

        // ❌ 6. Devolver null desde un método que devuelve Optional (sí, ocurre)
        // ✅  Optional.empty(); NUNCA null. Un Optional null es lo peor de los dos mundos.

        // ❌ 7. Optional en un campo o en un DTO serializado
        // record RespuestaApi(String id, Optional<String> nota) { }
        // ✅  record RespuestaApi(String id, String nota)  con nota anulable y @JsonInclude

        // ❌ 8. Encadenar isPresent en cascada: la escalera de la muerte
        // if (a.isPresent() && a.get().b().isPresent() && ...)
        // ✅  map/flatMap: la cadena se corta sola en el primer vacío

        // ❌ 9. Optional para controlar el flujo en lugar de excepciones cuando SÍ hay error
        //     Si la ausencia es un fallo (base de datos caída), no es "sin valor": lanza.

        // ⚠️ 10. Optional NO protege de nada en tiempo de ejecución si lo ignoras.
        //     Su valor es que la FIRMA obliga al lector y al compilador a pensar.
        System.out.println(c + " " + d + " " + e);
    }

    void procesar(Cliente c) { }
    Cliente crearClienteCostoso() { System.out.println("¡creando!"); return new Cliente("x@y.z"); }
    List<Cliente> buscarTodos() { return List.of(); }
    void registrar(String nombre) { registrar(nombre, null); }
    void registrar(String nombre, String apodo) { }
    static class ClienteNoEncontrado extends RuntimeException {
        ClienteNoEncontrado(String id) { super("Cliente no encontrado: " + id); }
    }
}
La alternativa cuando Optional no encaja: si necesitas distinguir varias formas de “no hay resultado” (no existe / sin permiso / servicio caído), Optional se queda corto porque solo tiene un estado vacío sin información. Ahí es donde brilla un sealed interface con un record por caso, como el ResultadoPago de la sección 4.11. Y para números primitivos existen OptionalInt, OptionalLong y OptionalDouble, que evitan el boxing (los devuelven IntStream.max() y compañía).

9 · Programación funcional en Java

Java 8 no convirtió Java en un lenguaje funcional: le añadió funciones como valores. Eso basta para cambiar radicalmente cómo se escribe código de transformación de datos, pero conviene entender la maquinaria, porque las interfaces funcionales del JDK son 43 y hay que saber elegir.

9.1 Las interfaces funcionales del JDK

Una interfaz funcional es una interfaz con un solo método abstracto (SAM). Puede tener tantos default y static como quiera. La anotación @FunctionalInterface no es obligatoria, pero úsala: el compilador verifica la restricción y documenta la intención.

InterfazMétodoEntrada → SalidaUso típico
Supplier<T>get()() → TFábrica, valor perezoso, orElseGet.
Consumer<T>accept(t)T → ()forEach, callbacks, efectos secundarios.
BiConsumer<T,U>accept(t,u)(T,U) → ()Map.forEach.
Function<T,R>apply(t)T → Rmap, transformaciones.
BiFunction<T,U,R>apply(t,u)(T,U) → RMap.merge, Map.compute.
UnaryOperator<T>apply(t)T → TList.replaceAll, normalizaciones.
BinaryOperator<T>apply(t,t)(T,T) → Treduce, Collectors.toMap con función de fusión.
Predicate<T>test(t)T → booleanfilter, removeIf, validaciones.
BiPredicate<T,U>test(t,u)(T,U) → booleanComparaciones de dos argumentos.
Runnablerun()() → ()Tareas sin resultado, hilos.
Callable<V>call()() → V, throwsTareas en un ExecutorService que pueden lanzar.
Comparator<T>compare(a,b)(T,T) → intOrdenación.
— Variantes primitivas: evitan el autoboxing y son las que debes usar con números —
IntPredicate, LongPredicate, DoublePredicatetestint → booleanFiltros sobre IntStream.
IntFunction<R>, IntUnaryOperator, IntBinaryOperatorapply…int → …Transformaciones numéricas.
ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T>applyAsInt…T → intmapToInt, comparingInt, summingInt.
IntSupplier, IntConsumergetAsInt, accept… → intGeneradores y sumideros primitivos.
ObjIntConsumer<T>accept(t, i)(T,int) → ()Acumuladores en collect de tres argumentos.
import java.util.function.*;
import java.util.List;

public class InterfacesFuncionales {

    // Definir la tuya cuando el nombre añade significado. Compara:
    //   Function<Pedido, BigDecimal>      ← ¿qué calcula?
    //   CalculadoraDescuento              ← se explica solo
    @FunctionalInterface
    interface CalculadoraDescuento {
        java.math.BigDecimal calcular(java.math.BigDecimal base, int antiguedadMeses);

        // Los default permiten composición específica del dominio
        default CalculadoraDescuento limitadoA(java.math.BigDecimal maximo) {
            return (base, meses) -> calcular(base, meses).min(maximo);
        }

        static CalculadoraDescuento sinDescuento() {
            return (base, meses) -> java.math.BigDecimal.ZERO;
        }
    }

    public static void main(String[] args) {

        // ---------- Composición de Function: andThen y compose ----------
        Function<String, String> quitarEspacios = String::strip;
        Function<String, String> aMinusculas = s -> s.toLowerCase(java.util.Locale.ROOT);
        Function<String, Integer> longitud = String::length;

        // andThen: primero this, después el argumento (izquierda → derecha)
        Function<String, Integer> normalizarYMedir =
                quitarEspacios.andThen(aMinusculas).andThen(longitud);
        System.out.println(normalizarYMedir.apply("  HOLA  "));       // 4

        // compose: primero el argumento, después this (derecha → izquierda)
        Function<String, Integer> equivalente = longitud.compose(aMinusculas).compose(quitarEspacios);
        System.out.println(equivalente.apply("  HOLA  "));            // 4

        // identity: útil como valor neutro en toMap y en reducciones
        System.out.println(Function.<String>identity().apply("igual"));

        // ---------- Composición de Predicate: and, or, negate ----------
        Predicate<String> noVacio = s -> s != null && !s.isBlank();
        Predicate<String> corto = s -> s.length() <= 10;
        Predicate<String> valido = noVacio.and(corto);
        Predicate<String> invalido = valido.negate();
        System.out.println(valido.test("hola") + " " + invalido.test(""));

        // Predicate.not: legible en referencias a métodos (Java 11+)
        List<String> conTexto = List.of("a", " ", "b").stream()
                .filter(Predicate.not(String::isBlank)).toList();
        System.out.println(conTexto);                                  // [a, b]

        // Predicate.isEqual: comparación por equals como predicado
        System.out.println(Predicate.isEqual("x").test("x"));           // true

        // ---------- Composición de Consumer: andThen ----------
        Consumer<String> registrar = s -> System.out.println("LOG: " + s);
        Consumer<String> auditar = s -> System.out.println("AUDIT: " + s);
        registrar.andThen(auditar).accept("pedido creado");             // ejecuta los dos

        // ---------- Composición del dominio ----------
        CalculadoraDescuento porAntiguedad = (base, meses) ->
                base.multiply(java.math.BigDecimal.valueOf(Math.min(meses, 24) * 0.005));
        var acotada = porAntiguedad.limitadoA(new java.math.BigDecimal("50"));
        System.out.println(acotada.calcular(new java.math.BigDecimal("1000"), 36));  // 50

        // ---------- Variantes primitivas: por qué importan ----------
        ToIntFunction<String> longitudPrimitiva = String::length;       // sin boxing
        Function<String, Integer> longitudConBoxing = String::length;   // crea un Integer
        System.out.println(longitudPrimitiva.applyAsInt("abc") + longitudConBoxing.apply("abc"));
    }
}

9.2 Los cuatro tipos de referencia a método

TipoSintaxisLambda equivalenteEjemplo
1. Método staticClase::metodox -> Clase.metodo(x)Integer::parseInt, Math::abs
2. Método de instancia de un objeto concretoobjeto::metodox -> objeto.metodo(x)System.out::println, this::procesar
3. Método de instancia de un objeto arbitrario del tipoClase::metodo(a, b) -> a.metodo(b)String::length, String::compareTo
4. ConstructorClase::new() -> new Clase()ArrayList::new, String[]::new
import java.util.*;
import java.util.function.*;
import java.util.stream.*;

public class ReferenciasAMetodos {
    public static void main(String[] args) {

        var palabras = List.of("melocotón", "kiwi", "sandía");

        // TIPO 1: método estático
        Function<String, Integer> aEntero = Integer::parseInt;
        System.out.println(aEntero.apply("42") + 1);
        System.out.println(Stream.of("1","2","3").map(Integer::parseInt).mapToInt(i -> i).sum());

        // TIPO 2: instancia concreta (el receptor está fijado)
        Consumer<Object> imprimir = System.out::println;
        palabras.forEach(imprimir);
        var prefijo = "fruta: ";
        Function<String, String> conPrefijo = prefijo::concat;      // receptor = prefijo
        System.out.println(conPrefijo.apply("kiwi"));

        // TIPO 3: instancia arbitraria (el receptor es el PRIMER argumento)
        Function<String, Integer> longitud = String::length;        // s -> s.length()
        BiFunction<String, String, Boolean> empiezaPor = String::startsWith;  // (a,b) -> a.startsWith(b)
        System.out.println(longitud.apply("kiwi") + " " + empiezaPor.apply("kiwi", "ki"));
        System.out.println(palabras.stream().map(String::toUpperCase).toList());

        // TIPO 4: constructor
        Supplier<List<String>> fabricaLista = ArrayList::new;
        Function<Integer, List<String>> conCapacidad = ArrayList::new;   // elige el constructor
        IntFunction<String[]> fabricaArray = String[]::new;
        System.out.println(fabricaLista.get().size() + " " + conCapacidad.apply(10).size());
        System.out.println(Arrays.toString(palabras.stream().toArray(fabricaArray)));

        // Constructor de un record en un collect: muy idiomático
        record Fruta(String nombre, int letras) { }
        var frutas = palabras.stream().map(p -> new Fruta(p, p.length())).toList();
        System.out.println(frutas);

        // ⚠️ Ambigüedad entre el tipo 2 y el tipo 3: si una clase tiene un método
        // estático y otro de instancia con el mismo nombre y firma compatible,
        // la referencia no compila. Es raro, pero cuando pasa desconcierta.

        // ⚠️ Trampa clásica: una referencia a método evalúa el RECEPTOR al crearse
        var sb = new StringBuilder("a");
        Supplier<String> conReferencia = sb::toString;   // 'sb' queda capturado ahora
        sb.append("b");
        System.out.println(conReferencia.get());          // "ab" ← el objeto sí puede mutar
    }
}

9.3 Efectos secundarios y funciones puras: por qué importa

Una función es pura si su resultado depende solo de sus argumentos y no modifica nada fuera de ella. En Java nada te obliga a escribir funciones puras, pero tres cosas dejan de funcionar cuando no lo son: el paralelismo, el razonamiento local y los tests.

import java.util.*;
import java.util.stream.*;

public class EfectosSecundarios {
    public static void main(String[] args) {

        var datos = IntStream.rangeClosed(1, 10_000).boxed().toList();

        // ❌ Efecto secundario en un stream: funciona en secuencial y FALLA en paralelo
        var acumulador = new ArrayList<Integer>();
        datos.parallelStream()
             .filter(n -> n % 2 == 0)
             .forEach(acumulador::add);        // ArrayList NO es seguro entre hilos
        System.out.println("Con forEach paralelo: " + acumulador.size());
        // Resultado: un número aleatorio menor que 5.000, o un
        // ArrayIndexOutOfBoundsException, o un elemento null. Corrupción silenciosa.

        // ✅ Recolectar es la operación correcta: el framework gestiona la combinación
        var correcto = datos.parallelStream().filter(n -> n % 2 == 0).toList();
        System.out.println("Con toList paralelo: " + correcto.size());     // 5000, siempre

        // ❌ Estado mutable compartido en un map: resultado impredecible
        var contador = new int[1];
        var conEfecto = datos.parallelStream().map(n -> { contador[0]++; return n * 2; }).toList();
        System.out.println("Contador con carrera: " + contador[0] + " (debería ser 10000)");

        // ✅ Si necesitas contar, cuenta con el framework
        System.out.println("count(): " + datos.parallelStream().map(n -> n * 2).count());
        System.out.println(conEfecto.size());

        // ⚠️ El caso legítimo de forEach: E/S y logging, donde el efecto ES el objetivo.
        // Pero entonces usa forEachOrdered si el orden importa, y no lo paralelices
        // sin pensar: el orden de un forEach paralelo no está definido.
        Stream.of("a", "b", "c").parallel().forEachOrdered(System.out::print);
        System.out.println();
    }
}
PrácticaPor qué
Las lambdas de map, filter y reduce deben ser purasEl framework puede ejecutarlas en cualquier orden, en cualquier hilo y las veces que le convenga.
No mutes la fuente del stream durante el pipelineComportamiento indefinido: ConcurrentModificationException en el mejor caso, resultados incorrectos en el peor.
Usa collect en lugar de forEach + addEl recolector sabe combinar resultados parciales; tu ArrayList, no.
Lambdas cortas: una expresión, sin bloques largosSi tu lambda tiene diez líneas, extrae un método con nombre y usa una referencia. Se lee mejor y se puede testear.
Evita capturar estado mutableLos arrays de un elemento y los AtomicInteger para “esquivar” effectively final son una señal de que la operación elegida no es la correcta.
No lances excepciones checked desde una lambda estándarLas interfaces del JDK no las declaran. Envuélvelas o define tu propia interfaz funcional que sí las declare.
// El problema de las excepciones checked en lambdas, y las dos soluciones
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.util.function.Function;

public class LambdasYExcepciones {

    // ❌ No compila: Function.apply no declara IOException
    // List<String> contenidos = rutas.stream().map(p -> Files.readString(p)).toList();

    // ✅ SOLUCIÓN 1: envolver en una unchecked, con un método auxiliar reutilizable
    static String leerSinChecked(Path p) {
        try { return Files.readString(p); }
        catch (IOException e) { throw new UncheckedIOException2(p, e); }
    }
    static class UncheckedIOException2 extends RuntimeException {
        UncheckedIOException2(Path p, IOException e) { super("Error leyendo " + p, e); }
    }

    // ✅ SOLUCIÓN 2: tu propia interfaz funcional que declara la excepción,
    //    más un adaptador que la convierte. Útil si se repite mucho.
    @FunctionalInterface
    interface FuncionQuePuedeFallar<T, R> {
        R aplicar(T t) throws Exception;

        static <T, R> Function<T, R> envuelta(FuncionQuePuedeFallar<T, R> f) {
            return t -> {
                try { return f.aplicar(t); }
                catch (RuntimeException e) { throw e; }
                catch (Exception e) { throw new RuntimeException(e); }
            };
        }
    }

    public static void main(String[] args) {
        List<Path> rutas = List.of(Path.of("/etc/hostname"));
        System.out.println(rutas.stream().map(LambdasYExcepciones::leerSinChecked).toList());
        System.out.println(rutas.stream()
                .map(FuncionQuePuedeFallar.envuelta(Files::readString)).toList());
    }
}

Comprobación rápida de las secciones 8 y 9

10 · Streams: el pipeline completo

Un stream no es una colección: es una secuencia de elementos con un canal de procesamiento perezoso. No almacena datos, no se puede reutilizar, y no ejecuta nada hasta que llega una operación terminal. Entender esas tres propiedades explica el 90 % de las sorpresas.

10.1 Anatomía de un pipeline y la pereza

   FUENTE          OPERACIONES INTERMEDIAS (perezosas)         TERMINAL (dispara todo)
  ┌───────┐   ┌────────┬────────┬────────┬────────┐          ┌──────────────┐
  │ List  │──▶│ filter │  map   │ sorted │ limit  │─────────▶│ collect/count│
  └───────┘   └────────┴────────┴────────┴────────┘          └──────────────┘
              devuelven Stream, NO ejecutan nada              devuelve un valor
                                                              o un efecto

  Propiedades clave:
   · Pereza: sin terminal, nada se ejecuta. Un pipeline sin collect/forEach es un no-op.
   · Fusión vertical: cada elemento recorre TODAS las etapas antes de pasar al siguiente.
     No hay una pasada por operación: hay una sola pasada por elemento.
   · Cortocircuito: limit, findFirst, anyMatch paran en cuanto pueden.
   · Un solo uso: reutilizar un stream lanza IllegalStateException.
import java.util.List;
import java.util.stream.Stream;

public class Pereza {
    public static void main(String[] args) {

        var palabras = List.of("melocotón", "kiwi", "sandía", "uva", "chirimoya");

        // 1. Un pipeline sin operación terminal NO EJECUTA NADA
        System.out.println("--- sin terminal ---");
        palabras.stream()
                .filter(p -> { System.out.println("  filtro: " + p); return p.length() > 4; })
                .map(p -> { System.out.println("  mapeo: " + p); return p.toUpperCase(); });
        System.out.println("(no se ha impreso nada)");

        // 2. Con terminal: fusión vertical. Fíjate en el ORDEN de la salida.
        System.out.println("--- con terminal ---");
        var resultado = palabras.stream()
                .filter(p -> { System.out.println("  filtro: " + p); return p.length() > 4; })
                .map(p -> { System.out.println("  mapeo: " + p); return p.toUpperCase(); })
                .toList();
        System.out.println(resultado);
        // filtro: melocotón / mapeo: MELOCOTÓN / filtro: kiwi / filtro: sandía / mapeo: SANDÍA ...
        // Cada elemento atraviesa TODO el pipeline antes de pasar al siguiente.
        // Consecuencia práctica: filtra ANTES de mapear y ahorrarás trabajo.

        // 3. Cortocircuito: con limit(2) solo se procesa lo necesario
        System.out.println("--- con cortocircuito ---");
        var dos = palabras.stream()
                .filter(p -> { System.out.println("  filtro: " + p); return p.length() > 4; })
                .limit(2)
                .toList();
        System.out.println(dos);      // no llega a evaluar "chirimoya"

        // 4. Un stream es de UN SOLO USO
        var stream = palabras.stream();
        System.out.println(stream.count());
        try { stream.count(); }
        catch (IllegalStateException e) {
            System.out.println("IllegalStateException: stream has already been operated upon or closed");
        }
        // Si necesitas recorrer dos veces, guarda un Supplier<Stream> o una colección:
        java.util.function.Supplier<Stream<String>> fabrica = palabras::stream;
        System.out.println(fabrica.get().count() + " " + fabrica.get().findFirst().orElseThrow());
    }
}

10.2 Todas las formas de crear un stream

import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.*;

public class CrearStreams {
    public static void main(String[] args) throws IOException {

        // ---------- Desde colecciones y arrays ----------
        Stream<String> deColeccion = List.of("a", "b").stream();
        Stream<String> deArray = Arrays.stream(new String[]{"a", "b"});
        IntStream dePrimitivos = Arrays.stream(new int[]{1, 2, 3});
        Stream<String> deValores = Stream.of("a", "b", "c");
        Stream<String> vacio = Stream.empty();

        // ---------- Desde un Map (a través de sus vistas) ----------
        var mapa = Map.of("a", 1, "b", 2);
        Stream<Map.Entry<String,Integer>> deEntradas = mapa.entrySet().stream();
        Stream<String> deClaves = mapa.keySet().stream();

        // ---------- Rangos numéricos: preferibles a los bucles for con índice ----------
        IntStream rango = IntStream.range(0, 10);            // 0..9  (fin excluido)
        IntStream cerrado = IntStream.rangeClosed(1, 10);    // 1..10 (fin incluido)
        System.out.println(cerrado.sum());                    // 55

        // ---------- Generadores INFINITOS: necesitan limit() o takeWhile() ----------
        // iterate con función: cada elemento se calcula a partir del anterior
        System.out.println(Stream.iterate(1, n -> n * 2).limit(10).toList());
        // [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]

        // iterate con predicado (Java 9+): es un bucle for funcional, y es finito
        System.out.println(Stream.iterate(1, n -> n <= 100, n -> n * 3).toList());
        // [1, 3, 9, 27, 81]

        // generate: sin estado, para valores independientes
        System.out.println(Stream.generate(Math::random).limit(3).map(d -> Math.round(d*100)).toList());
        System.out.println(Stream.generate(() -> "x").limit(4).collect(Collectors.joining()));

        // ---------- Desde texto ----------
        Stream<String> lineas = """
                primera línea
                segunda línea
                tercera línea""".lines();
        System.out.println(lineas.count());
        IntStream caracteres = "hola".chars();               // ojo: IntStream, no CharStream
        System.out.println("hola".chars().mapToObj(c -> (char) c).toList());

        // Partir texto: mucho mejor que split() si vas a procesar en pipeline
        Stream<String> campos = Pattern.compile("\\s*,\\s*").splitAsStream("a , b,c");
        System.out.println(campos.toList());                 // [a, b, c]

        // ---------- Desde ficheros: SIEMPRE con try-with-resources ----------
        Path ruta = Path.of("/etc/hostname");
        if (Files.exists(ruta)) {
            try (Stream<String> deFichero = Files.lines(ruta)) {
                System.out.println(deFichero.findFirst().orElse("(vacío)"));
            }
            // Files.lines mantiene el fichero abierto: sin try-with-resources, fuga de
            // descriptores. Es el único caso donde un stream es un recurso cerrable.
        }
        try (Stream<Path> arbol = Files.walk(Path.of("."), 2)) {
            System.out.println(arbol.filter(Files::isRegularFile).limit(3).toList());
        }

        // ---------- Concatenar y aplanar ----------
        System.out.println(Stream.concat(Stream.of("a"), Stream.of("b")).toList());
        // Con más de dos, mejor aplanar que anidar concat:
        System.out.println(Stream.of(List.of("a"), List.of("b"), List.of("c"))
                .flatMap(List::stream).toList());

        // ---------- Desde un Optional (Java 9+) ----------
        System.out.println(Optional.of("valor").stream().toList());

        // ---------- Desde un iterador o iterable ajeno ----------
        Iterable<String> iterable = List.of("x", "y");
        System.out.println(StreamSupport.stream(iterable.spliterator(), false).toList());

        // ---------- Números aleatorios ----------
        System.out.println(new Random(42).ints(5, 1, 7).boxed().toList());  // 5 tiradas de dado
        System.out.println(deColeccion.count() + " " + deArray.count() + " "
                + dePrimitivos.count() + " " + deValores.count() + " " + vacio.count()
                + " " + deEntradas.count() + " " + deClaves.count() + " " + rango.count()
                + " " + caracteres.count());
    }
}

10.3 Operaciones intermedias

OperaciónQué haceCon estadoNota
filter(p)Conserva los que cumplenNoPonla lo antes posible: reduce el trabajo de todo lo demás.
map(f)Transforma 1→1NoLa operación más usada.
flatMap(f)Transforma 1→N y aplanaNoPara estructuras anidadas. La función devuelve un Stream.
mapMulti(c) 16Como flatMap pero empujando a un consumidorNoMás rápido cuando la mayoría de elementos produce 0 o 1 salida: evita crear un Stream por elemento.
distinct()Elimina duplicados por equalsNecesita memoria O(n). Sobre un stream infinito, no termina.
sorted() / sorted(c)OrdenaConsume todo el stream antes de emitir nada. Es una barrera para la pereza.
limit(n)Primeros nCortocircuito. Convierte un stream infinito en finito.
skip(n)Descarta los primeros nCon limit, paginación en memoria.
takeWhile(p) 9Mientras cumpla; para en el primer falloSobre datos ordenados es muchísimo más eficiente que filter.
dropWhile(p) 9Descarta mientras cumpla; luego todoIdeal para saltar cabeceras de un fichero.
peek(c)Efecto lateral y sigueNoSolo para depurar. Nunca en producción: puede omitirse si el pipeline se optimiza.
mapToInt/Long/DoubleA stream primitivoNoEvita el boxing: úsalo antes de sumar o promediar.
boxed()De primitivo a objetoNoNecesario para collect y toList.
gather(g) 22+Operación intermedia definida por tiDependeGatherers: ventanas, plegado con estado, scan. Lo que faltaba en el API.
import java.util.*;
import java.util.stream.*;

public class Intermedias {
    record Pedido(String id, String estado, java.time.LocalDate fecha, double total) { }

    public static void main(String[] args) {
        var pedidos = List.of(
                new Pedido("P1", "PAGADO",    java.time.LocalDate.of(2026,1,10), 120.50),
                new Pedido("P2", "BORRADOR",  java.time.LocalDate.of(2026,1,12),  45.00),
                new Pedido("P3", "PAGADO",    java.time.LocalDate.of(2026,2,1),  310.75),
                new Pedido("P4", "CANCELADO", java.time.LocalDate.of(2026,2,15),  80.00),
                new Pedido("P5", "PAGADO",    java.time.LocalDate.of(2026,3,1),    9.99));

        // filter + map + sorted: el pipeline canónico
        System.out.println(pedidos.stream()
                .filter(p -> p.estado().equals("PAGADO"))
                .sorted(Comparator.comparingDouble(Pedido::total).reversed())
                .map(Pedido::id)
                .toList());                                  // [P3, P1, P5]

        // flatMap: aplanar estructuras anidadas
        var porCliente = Map.of("ana", List.of("P1","P2"), "luis", List.of("P3"));
        System.out.println(porCliente.values().stream().flatMap(List::stream).sorted().toList());

        // flatMap para el producto cartesiano
        var tallas = List.of("S","M");
        var colores = List.of("rojo","azul");
        System.out.println(tallas.stream()
                .flatMap(t -> colores.stream().map(c -> t + "-" + c))
                .toList());                                  // [S-rojo, S-azul, M-rojo, M-azul]

        // takeWhile / dropWhile sobre datos ORDENADOS: cortan, no filtran
        var ordenados = List.of(1, 3, 5, 8, 9, 11);
        System.out.println(ordenados.stream().takeWhile(n -> n % 2 == 1).toList());  // [1, 3, 5]
        System.out.println(ordenados.stream().dropWhile(n -> n % 2 == 1).toList());  // [8, 9, 11]
        System.out.println(ordenados.stream().filter(n -> n % 2 == 1).toList());     // [1,3,5,9,11]
        // Diferencia clave: takeWhile PARA en el primer 8; filter recorre todo.

        // Saltar la cabecera de un fichero CSV con dropWhile
        var csv = List.of("# comentario", "# otro", "id,total", "1,10", "2,20");
        System.out.println(csv.stream().dropWhile(l -> l.startsWith("#")).skip(1).toList());

        // distinct sobre objetos: usa equals, así que necesita un equals correcto
        System.out.println(Stream.of("a","b","a","c").distinct().toList());   // [a, b, c]

        // "distinct por una propiedad": no existe en el API. Dos formas idiomáticas:
        var unicosPorEstado = pedidos.stream()
                .collect(Collectors.toMap(Pedido::estado, p -> p, (a, b) -> a,
                                          LinkedHashMap::new))
                .values();
        System.out.println(unicosPorEstado.stream().map(Pedido::id).toList());

        // Paginación en memoria: skip + limit (para datos ya cargados; en base de datos,
        // paginación keyset, ver módulo 06)
        System.out.println(pedidos.stream().skip(2).limit(2).map(Pedido::id).toList());

        // peek SOLO para depurar
        var conTrazas = pedidos.stream()
                .peek(p -> System.out.println("  entra: " + p.id()))
                .filter(p -> p.total() > 100)
                .peek(p -> System.out.println("  pasa el filtro: " + p.id()))
                .count();
        System.out.println("count con peek: " + conTrazas);
        // ⚠️ Con count() sobre una fuente de tamaño conocido, la JVM puede OMITIR
        // todo el pipeline y devolver el tamaño directamente. Si eso ocurre, tus peek
        // no se ejecutan. Es la razón por la que peek no sirve para lógica de negocio.
    }
}

10.4 Operaciones terminales

OperaciónDevuelveCortocircuitaNota
toList() 16List<T> inmutableNoLa forma moderna. Admite nulos, a diferencia de toUnmodifiableList.
collect(c)Lo que diga el CollectorNoLa operación más potente. Ver 10.5.
toArray() / toArray(T[]::new)Object[] / T[]NoUsa siempre la versión con generador para tener el tipo correcto.
forEach(c) / forEachOrdered(c)voidNoPara efectos (E/S, logs). En paralelo, forEach no garantiza el orden.
reduce(...)Optional<T> o TNoTres sobrecargas. Ver 10.6.
count()longNoPuede omitir el pipeline si el tamaño se conoce y no hay filter.
min(c) / max(c)Optional<T>NoCon un comparador. En primitivos, sin argumentos.
anyMatch(p)booleanPara en el primer true.
allMatch(p)booleanPara en el primer false. Con stream vacío devuelve true (vacuidad).
noneMatch(p)booleanCon stream vacío devuelve true.
findFirst()Optional<T>Respeta el orden. En paralelo cuesta más que findAny.
findAny()Optional<T>Cualquiera. En paralelo, más rápido.
sum(), average(), summaryStatistics()primitivo / Optional / statsNoSolo en streams primitivos.
iterator()Iterator<T>Escape hacia código imperativo cuando el pipeline no encaja.

10.5 Collectors: el catálogo completo

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.*;

public class CollectorsCompleto {

    record Empleado(String nombre, String depto, String pais, int edad, BigDecimal salario) { }

    static final List<Empleado> PLANTILLA = List.of(
            new Empleado("Ana",   "IT",     "ES", 34, new BigDecimal("52000")),
            new Empleado("Bruno", "Ventas", "ES", 45, new BigDecimal("41000")),
            new Empleado("Clara", "IT",     "PT", 29, new BigDecimal("48000")),
            new Empleado("David", "IT",     "ES", 41, new BigDecimal("61000")),
            new Empleado("Elena", "Ventas", "PT", 52, new BigDecimal("55000")),
            new Empleado("Félix", "RRHH",   "ES", 38, new BigDecimal("39000")));

    public static void main(String[] args) {

        // ---------- 1. A colecciones ----------
        List<String> nombres = PLANTILLA.stream().map(Empleado::nombre).toList();
        Set<String> paises = PLANTILLA.stream().map(Empleado::pais).collect(Collectors.toSet());
        // A una implementación concreta: tercer argumento o toCollection
        TreeSet<String> ordenados = PLANTILLA.stream().map(Empleado::depto)
                .collect(Collectors.toCollection(TreeSet::new));
        System.out.println(nombres + " " + paises + " " + ordenados);

        // ---------- 2. toMap ----------
        Map<String, BigDecimal> salarioPorNombre = PLANTILLA.stream()
                .collect(Collectors.toMap(Empleado::nombre, Empleado::salario));

        // ⚠️ toMap LANZA IllegalStateException si hay claves duplicadas.
        // Es el error más frecuente con Collectors y aparece solo con datos reales.
        try {
            PLANTILLA.stream().collect(Collectors.toMap(Empleado::depto, Empleado::nombre));
        } catch (IllegalStateException e) {
            System.out.println("Duplicate key: " + e.getMessage());
        }
        // ✅ Con función de fusión (merge): tú decides qué hacer con el conflicto
        Map<String, String> unoPorDepto = PLANTILLA.stream()
                .collect(Collectors.toMap(Empleado::depto, Empleado::nombre,
                                          (existente, nuevo) -> existente));   // el primero gana
        Map<String, String> todosPorDepto = PLANTILLA.stream()
                .collect(Collectors.toMap(Empleado::depto, Empleado::nombre,
                                          (a, b) -> a + ", " + b));            // concatenar
        // Con implementación concreta (para conservar el orden):
        Map<String, String> ordenado = PLANTILLA.stream()
                .collect(Collectors.toMap(Empleado::nombre, Empleado::depto,
                                          (a, b) -> a, LinkedHashMap::new));
        System.out.println(unoPorDepto + " " + todosPorDepto + " " + ordenado.keySet());

        // ⚠️ Y toMap NO ADMITE valores null: lanza NPE. Si el valor puede faltar,
        // usa un valor centinela o recolecta a un HashMap con collect de 3 argumentos.

        // ---------- 3. groupingBy: el más usado ----------
        Map<String, List<Empleado>> porDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto));

        // Con recolector secundario ("downstream"): aquí está la potencia real
        Map<String, Long> cuantosPorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto, Collectors.counting()));

        Map<String, List<String>> nombresPorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto,
                         Collectors.mapping(Empleado::nombre, Collectors.toList())));

        Map<String, BigDecimal> costePorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto,
                         Collectors.reducing(BigDecimal.ZERO, Empleado::salario, BigDecimal::add)));

        Map<String, Optional<Empleado>> mejorPagadoPorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto,
                         Collectors.maxBy(Comparator.comparing(Empleado::salario))));

        // Con maxBy el Optional molesta; collectingAndThen lo desenvuelve:
        Map<String, Empleado> mejorLimpio = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto,
                         Collectors.collectingAndThen(
                                 Collectors.maxBy(Comparator.comparing(Empleado::salario)),
                                 Optional::orElseThrow)));

        // Con implementación de mapa concreta (para orden alfabético de las claves)
        TreeMap<String, Long> ordenadoPorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto, TreeMap::new, Collectors.counting()));

        System.out.println(porDepto.keySet() + " " + cuantosPorDepto + " " + nombresPorDepto);
        System.out.println(costePorDepto + " " + mejorPagadoPorDepto.get("IT").isPresent());
        System.out.println(mejorLimpio.get("IT").nombre() + " " + ordenadoPorDepto);

        // ---------- 4. groupingBy ANIDADO: agrupar por dos niveles ----------
        Map<String, Map<String, List<String>>> porPaisYDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::pais,
                         Collectors.groupingBy(Empleado::depto,
                         Collectors.mapping(Empleado::nombre, Collectors.toList()))));
        System.out.println(porPaisYDepto);
        // {ES={IT=[Ana, David], RRHH=[Félix], Ventas=[Bruno]}, PT={IT=[Clara], Ventas=[Elena]}}

        // Alternativa cuando el anidamiento se vuelve ilegible: agrupar por un record clave
        record Clave(String pais, String depto) { }
        Map<Clave, Long> porClave = PLANTILLA.stream()
                .collect(Collectors.groupingBy(e -> new Clave(e.pais(), e.depto()),
                                               Collectors.counting()));
        System.out.println(porClave.size());

        // ---------- 5. partitioningBy: exactamente dos grupos, siempre presentes ----------
        Map<Boolean, List<String>> seniorsYNo = PLANTILLA.stream()
                .collect(Collectors.partitioningBy(e -> e.edad() >= 40,
                         Collectors.mapping(Empleado::nombre, Collectors.toList())));
        System.out.println(seniorsYNo);
        // Diferencia con groupingBy sobre un boolean: partitioningBy garantiza que
        // las claves true y false EXISTEN aunque su lista esté vacía. Con groupingBy,
        // un grupo vacío simplemente no aparece, y eso obliga a getOrDefault.

        // ---------- 6. joining ----------
        System.out.println(PLANTILLA.stream().map(Empleado::nombre)
                .collect(Collectors.joining(", ", "[", "]")));

        // ---------- 7. Estadísticas y agregados ----------
        System.out.println(PLANTILLA.stream().collect(Collectors.summarizingInt(Empleado::edad)));
        // IntSummaryStatistics{count=6, sum=239, min=29, average=39,833333, max=52}
        System.out.println(PLANTILLA.stream().collect(Collectors.averagingInt(Empleado::edad)));
        System.out.println(PLANTILLA.stream().collect(Collectors.summingInt(Empleado::edad)));
        System.out.println(PLANTILLA.stream().collect(Collectors.counting()));

        // ---------- 8. teeing: dos recolectores en UNA sola pasada (Java 12+) ----------
        record Resumen(long total, double edadMedia) { }
        Resumen resumen = PLANTILLA.stream().collect(Collectors.teeing(
                Collectors.counting(),
                Collectors.averagingInt(Empleado::edad),
                Resumen::new));
        System.out.println(resumen);
        // Sin teeing harían falta dos pasadas o un reduce complicado. Es perfecto
        // para calcular mínimo y máximo, o total y media, sobre un stream de un solo uso.

        // ---------- 9. flatMapping y filtering (Java 9+): en el downstream ----------
        record Proyecto(String nombre, List<String> tecnologias) { }
        var proyectos = List.of(new Proyecto("web", List.of("java","react")),
                                new Proyecto("api", List.of("java","postgres")));
        Map<Integer, Set<String>> tecnologiasPorLongitud = proyectos.stream()
                .collect(Collectors.groupingBy(p -> p.nombre().length(),
                         Collectors.flatMapping(p -> p.tecnologias().stream(),
                                                Collectors.toSet())));
        System.out.println(tecnologiasPorLongitud);

        // filtering: filtra DENTRO del grupo, así que los grupos vacíos SÍ aparecen.
        // Con filter() antes del groupingBy, un departamento sin nadie se pierde.
        Map<String, List<String>> jovenesPorDepto = PLANTILLA.stream()
                .collect(Collectors.groupingBy(Empleado::depto,
                         Collectors.filtering(e -> e.edad() < 35,
                         Collectors.mapping(Empleado::nombre, Collectors.toList()))));
        System.out.println(jovenesPorDepto);   // {IT=[Ana, Clara], RRHH=[], Ventas=[]}

        // ---------- 10. Recolector propio con collect de tres argumentos ----------
        // (proveedor, acumulador, combinador). Útil cuando ningún Collector encaja.
        StringBuilder concatenado = PLANTILLA.stream().collect(
                StringBuilder::new,
                (sb, e) -> sb.append(e.nombre()).append(';'),
                StringBuilder::append);
        System.out.println(concatenado);
        System.out.println(salarioPorNombre.size());
    }
}
NecesidadCollector
Lista inmutablestream().toList()
Índice clave→objetotoMap(k, v, (a,b)->a)
AgrupargroupingBy(clasificador)
Contar por grupogroupingBy(c, counting())
Sumar por grupogroupingBy(c, summingInt(f)) o reducing con BigDecimal
Extraer un campo por grupogroupingBy(c, mapping(f, toList()))
El máximo por grupo, sin OptionalgroupingBy(c, collectingAndThen(maxBy(cmp), Optional::orElseThrow))
Dos grupos garantizadospartitioningBy(predicado)
Dos agregados en una pasadateeing(c1, c2, fusion)
Aplanar dentro de un grupogroupingBy(c, flatMapping(f, toSet()))
Filtrar sin perder grupos vacíosgroupingBy(c, filtering(p, toList()))
Unir en una cadenajoining(sep, prefijo, sufijo)
Todas las estadísticassummarizingInt/Long/Double(f)
Resultado inmutabletoUnmodifiableList/Set/Map
Recolector concurrentegroupingByConcurrent + parallelStream (solo si el orden no importa)

10.6 reduce y la asociatividad

reduce combina los elementos en un solo valor. Tiene tres sobrecargas y una condición que casi nadie verifica: la función de combinación debe ser asociativa, es decir (a op b) op c == a op (b op c). Si no lo es, el resultado en paralelo será distinto —y erróneo— aunque en secuencial parezca correcto.

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.*;

public class Reducciones {
    public static void main(String[] args) {

        var numeros = List.of(1, 2, 3, 4, 5);

        // ---------- Sobrecarga 1: solo el acumulador → Optional ----------
        Optional<Integer> suma1 = numeros.stream().reduce(Integer::sum);
        System.out.println(suma1.orElse(0));        // 15  (Optional porque puede estar vacío)

        // ---------- Sobrecarga 2: identidad + acumulador → T ----------
        int suma2 = numeros.stream().reduce(0, Integer::sum);
        System.out.println(suma2);                  // 15  (sin Optional: 0 si está vacío)
        // La identidad debe cumplir: f(identidad, x) == x. Para la suma es 0, para el
        // producto 1, para concatenar "". Si eliges mal, el resultado sale desplazado.

        // ---------- Sobrecarga 3: identidad + acumulador + combinador ----------
        // Necesaria cuando el tipo del resultado es DISTINTO del tipo de los elementos.
        int totalCaracteres = Stream.of("hola", "mundo", "java")
                .reduce(0,                              // identidad (Integer)
                        (acc, s) -> acc + s.length(),   // acumulador: (Integer, String) → Integer
                        Integer::sum);                  // combinador: (Integer, Integer) → Integer
        System.out.println(totalCaracteres);        // 13
        // El combinador solo se usa en PARALELO, para fusionar resultados parciales.

        // ---------- Reducciones con BigDecimal ----------
        var importes = List.of(new BigDecimal("10.50"), new BigDecimal("20.25"));
        System.out.println(importes.stream().reduce(BigDecimal.ZERO, BigDecimal::add));  // 30.75

        // ---------- ❌ Operación NO asociativa: la resta ----------
        System.out.println(numeros.stream().reduce(0, (a, b) -> a - b));           // -15
        System.out.println(numeros.parallelStream().reduce(0, (a, b) -> a - b));   // ¡varía!
        // Secuencial: ((((0-1)-2)-3)-4)-5 = -15
        // Paralelo:   (0-1-2) combinado con (0-3-4-5) según cómo se parta → otro número.
        // La resta no es asociativa, así que reduce no es válido. Igual que la división,
        // la exponenciación o cualquier función que dependa del orden.

        // ---------- ⚠️ Y ojo con la identidad en paralelo ----------
        // Si la identidad no es neutra, se aplica UNA VEZ POR SUBTAREA:
        System.out.println(numeros.stream().reduce(100, Integer::sum));          // 115
        System.out.println(numeros.parallelStream().reduce(100, Integer::sum));  // ¡más de 115!
        // Cada partición empieza en 100. Con 4 hilos, sumas 400 en lugar de 100.

        // ---------- ❌ reduce con acumulación mutable: casi siempre un error ----------
        var malo = Stream.of("a", "b", "c")
                .reduce(new StringBuilder(), StringBuilder::append, StringBuilder::append);
        System.out.println(malo);      // funciona por casualidad en secuencial
        // El problema: se está MUTANDO la identidad compartida. En paralelo se corrompe.
        // ✅ Para acumulación mutable existe collect, que está diseñado para eso:
        System.out.println(Stream.of("a","b","c").collect(Collectors.joining()));

        // ---------- Reducciones útiles del día a día ----------
        System.out.println(numeros.stream().reduce(1, (a, b) -> a * b));               // 120
        System.out.println(Stream.of("java","kotlin").reduce("", (a,b) -> a.isEmpty() ? b : a+"/"+b));
        System.out.println(numeros.stream().max(Comparator.naturalOrder()).orElseThrow());
        // Pero para sumar, contar y promediar usa siempre el stream primitivo:
        System.out.println(numeros.stream().mapToInt(Integer::intValue).sum());        // 15
    }
}
reducecollect
AcumulaciónInmutable: cada paso crea un valor nuevoMutable: se va rellenando un contenedor
Coste con muchos elementosPuede ser O(n²) si el tipo es inmutable y grande (concatenar cadenas)O(n): un solo contenedor que crece
ParalelismoRequiere asociatividad e identidad neutraEl Collector declara su combinador y sus características
Úsalo paraSumas, productos, máximos, plegado de valoresConstruir listas, mapas, cadenas, agrupaciones

10.7 Streams primitivos: IntStream, LongStream, DoubleStream

import java.util.*;
import java.util.stream.*;

public class StreamsPrimitivos {
    record Producto(String sku, int unidades, double precio) { }

    public static void main(String[] args) {
        var productos = List.of(new Producto("A", 3, 19.99),
                                new Producto("B", 1, 149.00),
                                new Producto("C", 7,  4.50));

        // Sin boxing: mapToInt / mapToDouble antes de agregar
        int totalUnidades = productos.stream().mapToInt(Producto::unidades).sum();
        double facturacion = productos.stream()
                .mapToDouble(p -> p.unidades() * p.precio()).sum();
        System.out.printf("%d uds, %.2f €%n", totalUnidades, facturacion);

        // Estadísticas de una sola pasada: count, sum, min, max y average juntos
        IntSummaryStatistics stats = productos.stream().mapToInt(Producto::unidades)
                                                       .summaryStatistics();
        System.out.println(stats);
        System.out.println(stats.getAverage() + " " + stats.getMax());

        // average() y max() devuelven OptionalDouble/OptionalInt: el stream puede estar vacío
        OptionalDouble media = productos.stream().mapToDouble(Producto::precio).average();
        System.out.println(media.orElse(0));

        // Volver a objetos con boxed(), o directamente con mapToObj
        List<Integer> lista = IntStream.rangeClosed(1, 5).boxed().toList();
        List<String> etiquetas = IntStream.rangeClosed(1, 3).mapToObj(i -> "fila " + i).toList();
        System.out.println(lista + " " + etiquetas);

        // range y rangeClosed sustituyen al for clásico y son más difíciles de equivocar
        System.out.println(IntStream.range(0, 5).boxed().toList());        // [0,1,2,3,4]
        System.out.println(IntStream.rangeClosed(0, 5).boxed().toList());  // [0,1,2,3,4,5]

        // Iterar con índice: el API no lo ofrece directamente. La forma idiomática:
        var nombres = List.of("Ana", "Bruno", "Clara");
        IntStream.range(0, nombres.size())
                 .mapToObj(i -> (i + 1) + ". " + nombres.get(i))
                 .forEach(System.out::println);

        // Convertir entre primitivos
        System.out.println(IntStream.of(1,2,3).asLongStream().sum());        // 6L
        System.out.println(IntStream.of(1,2,3).asDoubleStream().average().orElseThrow());
        System.out.println(DoubleStream.of(1.7, 2.2).mapToInt(d -> (int) d).sum());  // 3

        // ⚠️ Trampa: "hola".chars() devuelve un IntStream, no un Stream<Character>
        System.out.println("hola".chars().boxed().toList());     // [104, 111, 108, 97]
        System.out.println("hola".chars().mapToObj(c -> (char) c).toList());  // [h, o, l, a]

        // ⚠️ Y count() sobre un IntStream devuelve long, así que cuidado al dividir:
        long n = IntStream.of(1,2,3).count();
        System.out.println(IntStream.of(1,2,3).sum() / (double) n);   // 2.0, no 2
    }
}

10.8 mapMulti y gatherers: lo que faltaba

Durante diez años, el API de streams tuvo un hueco evidente: no se podían escribir operaciones intermedias propias. Podías crear un Collector (terminal) pero no una operación con estado en medio del pipeline, así que agrupar por ventanas o hacer un scan obligaba a salir del stream. Los gatherers (JEP 485, definitivos en Java 24) lo resuelven.

import java.util.*;
import java.util.stream.*;

public class GatherersYMapMulti {

    record Nodo(String nombre, List<Nodo> hijos) { }

    public static void main(String[] args) {

        // ---------- mapMulti (Java 16): 1 → 0..N empujando a un consumidor ----------
        // Es más eficiente que flatMap cuando la mayoría de elementos produce 0 o 1
        // salida, porque no crea un Stream intermedio por elemento.
        List<Object> mezcla = List.of(1, "dos", 3, List.of(4, 5), "seis");
        List<Integer> soloEnteros = mezcla.stream()
                .<Integer>mapMulti((elemento, consumidor) -> {
                    if (elemento instanceof Integer i) consumidor.accept(i);
                    else if (elemento instanceof List<?> l)
                        l.forEach(x -> { if (x instanceof Integer i) consumidor.accept(i); });
                })
                .toList();
        System.out.println(soloEnteros);                       // [1, 3, 4, 5]

        // Variante primitiva sin boxing: mapMultiToInt
        System.out.println(mezcla.stream()
                .mapMultiToInt((e, c) -> { if (e instanceof Integer i) c.accept(i); })
                .sum());                                        // 4

        // Recorrido recursivo de un árbol: el caso donde mapMulti brilla
        var arbol = new Nodo("raíz", List.of(
                new Nodo("a", List.of(new Nodo("a1", List.of()))),
                new Nodo("b", List.of())));
        System.out.println(aplanar(arbol).map(Nodo::nombre).toList());   // [raíz, a, a1, b]

        // ---------- Gatherers (Java 22 preview, 24 definitivo) ----------
        // Los cinco integrados cubren casi todo lo que faltaba:
        var numeros = IntStream.rangeClosed(1, 10).boxed().toList();

        // 1. windowFixed(n): ventanas fijas, sin solaparse. Para procesar por lotes.
        // System.out.println(numeros.stream().gather(Gatherers.windowFixed(3)).toList());
        //   → [[1,2,3], [4,5,6], [7,8,9], [10]]

        // 2. windowSliding(n): ventanas deslizantes. Medias móviles, diferencias.
        // System.out.println(numeros.stream().gather(Gatherers.windowSliding(2)).toList());
        //   → [[1,2], [2,3], [3,4], ...]

        // 3. fold(identidad, acumulador): reducción a un solo valor, como operación intermedia
        // 4. scan(identidad, acumulador): sumas acumuladas, saldos corrientes
        //   → [1, 3, 6, 10, 15, ...]

        // 5. mapConcurrent(n, f): mapeo con hasta n tareas concurrentes usando
        //    hilos virtuales. Ideal para llamadas de E/S en paralelo con límite.

        // Hasta que puedas usarlos, el equivalente manual de windowFixed:
        System.out.println(porLotes(numeros, 3));
    }

    static Stream<Nodo> aplanar(Nodo n) {
        return Stream.concat(Stream.of(n), n.hijos().stream().flatMap(GatherersYMapMulti::aplanar));
    }

    /** windowFixed a mano: útil para enviar por lotes a una API o a la base de datos. */
    static <T> List<List<T>> porLotes(List<T> origen, int tamano) {
        return IntStream.range(0, (origen.size() + tamano - 1) / tamano)
                .mapToObj(i -> origen.subList(i * tamano,
                                              Math.min(origen.size(), (i + 1) * tamano)))
                .toList();
    }
}
Antes de los gatherers, y todavía hoy en Java 21: para procesar por lotes usa subList como en el ejemplo, o Lists.partition de Guava. Para una suma acumulada, un bucle normal es más claro que cualquier acrobacia con streams. Reconocer cuándo el stream no es la herramienta adecuada es parte del criterio; los gatherers amplían el territorio, no lo hacen infinito.

10.9 Paralelismo: cuándo ayuda y cuándo destroza el servicio

parallelStream() parece magia: una llamada y usas todos los núcleos. En la práctica, en un servidor de aplicaciones, casi siempre empeora las cosas. Merece la pena entender por qué.

import java.util.concurrent.*;
import java.util.stream.*;

public class Paralelismo {
    public static void main(String[] args) throws Exception {

        // ---------- 1. TODOS los streams paralelos comparten un único pool ----------
        System.out.println("Paralelismo del pool común: " + ForkJoinPool.commonPool().getParallelism());
        // = núcleos - 1. Con 8 núcleos, 7 hilos. Y es COMPARTIDO por toda la JVM:
        // si una petición HTTP lanza un parallelStream lento, bloquea a todas las demás.

        // ---------- 2. Cuándo AYUDA: mucho cálculo, en memoria, sin bloqueo ----------
        long t0 = System.nanoTime();
        long primosSec = IntStream.rangeClosed(2, 2_000_000).filter(Paralelismo::esPrimo).count();
        long t1 = System.nanoTime();
        long primosPar = IntStream.rangeClosed(2, 2_000_000).parallel()
                                  .filter(Paralelismo::esPrimo).count();
        long t2 = System.nanoTime();
        System.out.printf("Secuencial: %d ms (%d primos)%n", (t1-t0)/1_000_000, primosSec);
        System.out.printf("Paralelo  : %d ms (%d primos)%n", (t2-t1)/1_000_000, primosPar);
        // Aquí sí: cálculo puro, fuente divisible (un rango), sin E/S. Suele ir 4-6× mejor.

        // ---------- 3. Cuándo NO ayuda: poco trabajo por elemento ----------
        long t3 = System.nanoTime();
        long s1 = IntStream.rangeClosed(1, 1_000_000).asLongStream().sum();
        long t4 = System.nanoTime();
        long s2 = IntStream.rangeClosed(1, 1_000_000).parallel().asLongStream().sum();
        long t5 = System.nanoTime();
        System.out.printf("Suma secuencial: %d µs / paralela: %d µs%n",
                (t4-t3)/1000, (t5-t4)/1000);
        System.out.println(s1 == s2);
        // El coste de partir, planificar y fusionar supera el trabajo. A veces es peor.

        // ---------- 4. Aislar el pool: la única forma de acotar el daño ----------
        try (var pool = new ForkJoinPool(4)) {
            long resultado = pool.submit(() ->
                    IntStream.rangeClosed(2, 500_000).parallel()
                             .filter(Paralelismo::esPrimo).count()).get();
            System.out.println("En pool propio: " + resultado);
        }
        // Un stream paralelo lanzado DENTRO de una tarea de otro ForkJoinPool usa ese pool.
        // Es un truco documentado y la forma correcta de no contaminar el commonPool.

        // ---------- 5. Ajustar el pool común (solo si sabes lo que haces) ----------
        // -Djava.util.concurrent.ForkJoinPool.common.parallelism=16
    }

    static boolean esPrimo(int n) {
        if (n < 2) return false;
        for (int i = 2; (long) i * i <= n; i++) if (n % i == 0) return false;
        return true;
    }
}
FactorFavorable al paralelismoDesfavorable
Tamaño de los datosDecenas de miles de elementos o másCientos: el coste de coordinación domina
Coste por elementoAlto (cálculo intensivo, parseo, criptografía)Trivial (sumar, comparar)
Fuente (spliterator)ArrayList, arrays, IntStream.range: se parten en O(1) y por la mitadLinkedList, Files.lines, iterate: se parten mal o no se parten
OperacionesSin estado: filter, map, reduce asociativoCon estado y orden: sorted, distinct, limit, findFirst
BloqueoNinguno: solo CPUCualquier E/S: HTTP, base de datos, ficheros. Ocupas hilos del pool común esperando
Contexto de ejecuciónUn proceso por lotes, una utilidad de línea de comandosUn servidor web: ya tienes paralelismo por petición; añadir más solo genera contención
RecoleccióntoList, reduce, groupingByConcurrentforEach con efectos, colecciones no seguras entre hilos
Nunca hagas E/S dentro de un parallelStream. Es el error más caro de esta sección. El ForkJoinPool.commonPool tiene núcleos − 1 hilos y lo comparte toda la JVM. Si lanzas 200 llamadas HTTP dentro de un parallelStream, ocupas los 7 hilos esperando en la red, y cualquier otro stream paralelo de la aplicación —incluidos los de bibliotecas de terceros— se queda encolado. Para paralelizar E/S, la respuesta correcta en 2026 son los hilos virtuales con StructuredTaskScope o un ExecutorService dedicado (módulo 03).

10.10 Los errores frecuentes con streams

import java.util.*;
import java.util.stream.*;

public class ErroresStreams {
    record Persona(String nombre, String ciudad) { }

    public static void main(String[] args) {

        // ❌ 1. Reutilizar un stream
        var s = Stream.of(1, 2, 3);
        System.out.println(s.count());
        try { s.forEach(System.out::println); }
        catch (IllegalStateException e) { System.out.println("1) stream ya consumido"); }

        // ❌ 2. toMap con valores null → NullPointerException
        var personas = List.of(new Persona("Ana", "Madrid"), new Persona("Bruno", null));
        try {
            personas.stream().collect(Collectors.toMap(Persona::nombre, Persona::ciudad));
        } catch (NullPointerException e) { System.out.println("2) toMap no admite null"); }
        // ✅ Alternativa: filtra antes, o usa un valor centinela, o collect a mano:
        var conNulos = personas.stream().collect(HashMap::new,
                (m, p) -> m.put(p.nombre(), p.ciudad()), HashMap::putAll);
        System.out.println(conNulos);

        // ❌ 3. toMap con claves duplicadas → IllegalStateException
        var conDuplicados = List.of(new Persona("Ana", "Madrid"), new Persona("Ana", "Vigo"));
        try { conDuplicados.stream().collect(Collectors.toMap(Persona::nombre, Persona::ciudad)); }
        catch (IllegalStateException e) { System.out.println("3) clave duplicada"); }

        // ❌ 4. Modificar la fuente durante el pipeline
        var lista = new ArrayList<>(List.of(1, 2, 3));
        try { lista.stream().forEach(n -> { if (n == 1) lista.add(4); }); }
        catch (ConcurrentModificationException e) { System.out.println("4) fuente modificada"); }

        // ❌ 5. Usar peek para lógica de negocio
        // El javadoc lo dice: "el comportamiento de peek puede variar" y la JVM puede
        // eliminarlo. Si el efecto importa, ponlo en map o en forEach.

        // ❌ 6. sorted() sobre un stream infinito: no termina nunca
        // Stream.iterate(1, n -> n + 1).sorted().findFirst();   ← cuelga la JVM

        // ❌ 7. Un stream para algo que un bucle hace mejor
        // Buscar el índice de un elemento, salir con break y un estado, romper con
        // return desde dentro: todo eso es más claro con un bucle.

        // ❌ 8. findFirst() en paralelo cuando findAny() bastaba
        // findFirst obliga a respetar el orden y anula parte de la ventaja del paralelismo.

        // ❌ 9. Olvidar el try-with-resources con Files.lines
        // Es el único stream que es un recurso: si no lo cierras, filtras descriptores.

        // ❌ 10. Encadenar diez operaciones en una sola expresión ilegible
        // ✅ Extrae variables intermedias con nombre y métodos privados con nombre.
        //    Un pipeline se lee de arriba abajo: una operación por línea, siempre.

        // ⚠️ 11. allMatch/noneMatch sobre un stream VACÍO devuelven true
        System.out.println(Stream.<Integer>empty().allMatch(n -> n > 100));   // true
        System.out.println(Stream.<Integer>empty().anyMatch(n -> n > 100));   // false
        // Es correcto matemáticamente (cuantificación vacía) y sorprendente en una
        // validación: "todos los pedidos están pagados" es true si no hay pedidos.

        // ⚠️ 12. El orden de las operaciones cambia el coste
        var palabras = IntStream.range(0, 100_000).mapToObj(i -> "p" + i).toList();
        long t0 = System.nanoTime();
        palabras.stream().sorted().filter(p -> p.endsWith("7")).count();   // ordena 100.000
        long t1 = System.nanoTime();
        palabras.stream().filter(p -> p.endsWith("7")).sorted().count();   // ordena 10.000
        long t2 = System.nanoTime();
        System.out.printf("sorted-filter: %d ms / filter-sorted: %d ms%n",
                (t1-t0)/1_000_000, (t2-t1)/1_000_000);
        // Regla: filtra lo antes posible; ordena y mapea lo más tarde posible.
    }
}

10.11 Stream o bucle: el criterio

Usa un stream cuando…

  • La operación es una transformación de datos: filtrar, mapear, agrupar, agregar.
  • El pipeline se lee como una descripción del qué: «de los pedidos pagados, agrupa por cliente y suma el total».
  • Necesitas groupingBy, partitioningBy, joining o teeing: escribirlos a mano son 15 líneas propensas a error.
  • Trabajas con datos inmutables y quieres un resultado inmutable.
  • Vas a paralelizar cálculo puro sobre muchos datos.

Usa un bucle cuando…

  • Necesitas break, continue o return con lógica compleja.
  • Hay que mutar varias variables o estructuras a la vez.
  • Necesitas el índice y el elemento anterior o siguiente.
  • Hay excepciones checked por medio: en un bucle son naturales; en una lambda, ceremonia.
  • Es un camino ultracaliente y has medido que el stream cuesta (por el enlazado y el boxing).
  • El pipeline resultante necesitaría un comentario para entenderse.
import java.util.*;
import java.util.stream.*;

public class StreamOBucle {
    record Pedido(String id, String cliente, double total, boolean pagado) { }

    // ✅ CASO 1: agregación. El stream gana por goleada.
    static Map<String, Double> totalPorClienteStream(List<Pedido> pedidos) {
        return pedidos.stream()
                .filter(Pedido::pagado)
                .collect(Collectors.groupingBy(Pedido::cliente,
                         Collectors.summingDouble(Pedido::total)));
    }
    static Map<String, Double> totalPorClienteBucle(List<Pedido> pedidos) {
        var resultado = new HashMap<String, Double>();
        for (Pedido p : pedidos) {
            if (!p.pagado()) continue;
            resultado.merge(p.cliente(), p.total(), Double::sum);
        }
        return resultado;
    }   // No está mal, pero el stream expresa mejor la intención y es más corto.

    // ✅ CASO 2: búsqueda con salida temprana y varias condiciones. El bucle gana.
    static Optional<Pedido> primerImpagadoGrandeBucle(List<Pedido> pedidos, double umbral) {
        for (Pedido p : pedidos) {
            if (p.pagado()) continue;
            if (p.total() < umbral) continue;
            if (p.cliente().startsWith("TEST")) continue;   // regla que aparecerá mañana
            return Optional.of(p);
        }
        return Optional.empty();
    }
    // Con stream también se puede, y aquí está igual de bien:
    static Optional<Pedido> primerImpagadoGrandeStream(List<Pedido> pedidos, double umbral) {
        return pedidos.stream()
                .filter(p -> !p.pagado() && p.total() >= umbral)
                .filter(p -> !p.cliente().startsWith("TEST"))
                .findFirst();
    }

    // ❌ CASO 3: el índice y el elemento anterior. El bucle es claramente mejor.
    static List<Double> diferenciasBucle(List<Double> serie) {
        var diffs = new ArrayList<Double>(serie.size());
        for (int i = 1; i < serie.size(); i++) diffs.add(serie.get(i) - serie.get(i - 1));
        return diffs;
    }
    // La versión con stream necesita IntStream.range y dos get: menos legible.
    static List<Double> diferenciasStream(List<Double> serie) {
        return IntStream.range(1, serie.size())
                .mapToObj(i -> serie.get(i) - serie.get(i - 1))
                .toList();
    }

    // ❌ CASO 4: mutar dos acumuladores a la vez. El bucle es más honesto.
    record Resumen(int pagados, int impagados, double facturado) { }
    static Resumen resumenBucle(List<Pedido> pedidos) {
        int pagados = 0, impagados = 0; double facturado = 0;
        for (Pedido p : pedidos) {
            if (p.pagado()) { pagados++; facturado += p.total(); } else impagados++;
        }
        return new Resumen(pagados, impagados, facturado);
    }
    // Con streams haría falta teeing anidado o tres pasadas. El bucle se lee mejor.

    public static void main(String[] args) {
        var datos = List.of(new Pedido("1","ana",100,true), new Pedido("2","ana",50,false),
                            new Pedido("3","luis",200,true));
        System.out.println(totalPorClienteStream(datos));
        System.out.println(totalPorClienteBucle(datos));
        System.out.println(primerImpagadoGrandeBucle(datos, 10).map(Pedido::id));
        System.out.println(primerImpagadoGrandeStream(datos, 10).map(Pedido::id));
        System.out.println(diferenciasBucle(List.of(1.0, 3.0, 6.0)));
        System.out.println(diferenciasStream(List.of(1.0, 3.0, 6.0)));
        System.out.println(resumenBucle(datos));
    }
}
El criterio en una frase: el stream gana cuando describe qué quieres y el bucle gana cuando tienes que decir cómo. Si al leer tu pipeline tienes que ejecutarlo mentalmente paso a paso para entenderlo, has elegido mal. Y sobre rendimiento: para colecciones pequeñas el bucle es algo más rápido (el stream tiene coste de enlazado y de boxing), para grandes se igualan, y con paralelismo bien aplicado el stream gana. En el 99 % de los casos la diferencia es irrelevante frente a la consulta a base de datos que hay al lado: optimiza legibilidad.

Comprobación rápida de la sección 10

11 · Excepciones y errores

Las excepciones son parte del contrato de un método: dicen qué puede ir mal y qué se espera que haga el llamante. Tratarlas como ruido que hay que silenciar es la causa de la mayoría de los incidentes imposibles de diagnosticar.

11.1 La jerarquía y el debate checked / unchecked

                          Throwable
                    ┌─────────┴─────────┐
                  Error              Exception
            (no capturar)      ┌────────┴────────────┐
                               │             RuntimeException
      OutOfMemoryError    IOException      (unchecked)
      StackOverflowError  SQLException          │
      NoClassDefFoundError  InterruptedException NullPointerException
      LinkageError        ClassNotFoundException IllegalArgumentException
      AssertionError      (CHECKED: el compilador  IllegalStateException
                           obliga a declarar o     IndexOutOfBoundsException
                           capturar)               ClassCastException
                                                   ArithmeticException
                                                   ConcurrentModificationException
                                                   UnsupportedOperationException
ErrorChecked (Exception)Unchecked (RuntimeException)
SignificaLa JVM está en un estado del que no se puede recuperarCondición externa previsible y recuperableError de programación o violación de una precondición
El compilador obligaNo: throws o catchNo
¿Capturarlas?No. Solo en el nivel más externo, para registrar y morirSí, si puedes hacer algo útilSolo en la frontera, para traducir a una respuesta
EjemplosOutOfMemoryError, StackOverflowErrorIOException, SQLException, InterruptedExceptionNullPointerException, IllegalArgumentException

El debate: Java es el único lenguaje mayoritario con excepciones comprobadas, y hay consenso amplio de que el experimento no salió bien. Los argumentos:

A favor de las checked

  • La firma documenta los fallos posibles: el compilador te obliga a pensar en ellos.
  • Imposible olvidar un caso de error importante.
  • Útil en APIs de bajo nivel donde el fallo es esperable y local (leer un fichero que puede no existir).

En contra

  • Rompen la abstracción: añadir una excepción a un método obliga a cambiar toda la cadena de llamadas.
  • Provocan el antipatrón más dañino de Java: catch (Exception e) { } vacío para que compile.
  • No funcionan con lambdas: las interfaces funcionales del JDK no las declaran.
  • Provocan throws Exception en las firmas, que no informa de nada.
  • Ningún lenguaje posterior las ha copiado. Kotlin, C#, Scala y Go optaron por otras vías.
La postura práctica de 2026, que es la que verás en Spring y en casi todo el ecosistema moderno: usa excepciones no comprobadas para el dominio y envuelve las checked de las bibliotecas en la frontera, conservando siempre la causa. Spring hace exactamente eso: convierte SQLException (comprobada, con códigos de error específicos de cada motor) en su jerarquía DataAccessException (no comprobada y portable). Y el JDK mismo introdujo UncheckedIOException por el mismo motivo.

11.2 try-with-resources y supresión

import java.io.*;
import java.nio.file.*;
import java.util.zip.GZIPInputStream;

public class Recursos {

    // ✅ try-with-resources: cierra en ORDEN INVERSO, incluso si hay excepción,
    //    y suprime correctamente los errores de cierre.
    static String leerComprimido(Path ruta) throws IOException {
        try (var in = Files.newInputStream(ruta);
             var gzip = new GZIPInputStream(in);
             var lector = new BufferedReader(new InputStreamReader(gzip, java.nio.charset.StandardCharsets.UTF_8))) {
            return lector.lines().reduce("", (a, b) -> a + b + "\n");
        }   // se cierran lector → gzip → in, en ese orden
    }

    // ❌ La versión con finally, que es lo que hacía todo el mundo antes de Java 7.
    //    Tiene un bug grave y muy famoso: si close() lanza, la excepción ORIGINAL se pierde.
    static String leerMal(Path ruta) throws IOException {
        InputStream in = null;
        try {
            in = Files.newInputStream(ruta);
            return new String(in.readAllBytes());
        } finally {
            if (in != null) in.close();     // si esto lanza, sustituye a la excepción real
        }
    }

    // ---------- Excepciones suprimidas: cómo verlas ----------
    static class RecursoRoto implements AutoCloseable {
        private final String nombre;
        RecursoRoto(String nombre) { this.nombre = nombre; }
        void usar() { throw new IllegalStateException("fallo al usar " + nombre); }
        @Override public void close() { throw new IllegalStateException("fallo al cerrar " + nombre); }
    }

    public static void main(String[] args) {
        try (var r = new RecursoRoto("A")) {
            r.usar();
        } catch (Exception e) {
            System.out.println("Principal: " + e.getMessage());          // fallo al usar A
            for (Throwable suprimida : e.getSuppressed()) {
                System.out.println("Suprimida: " + suprimida.getMessage()); // fallo al cerrar A
            }
        }
        // La excepción del cuerpo es la principal; la del close se adjunta como suprimida.
        // Con try/finally habrías visto solo "fallo al cerrar A" y habrías depurado el
        // sitio equivocado durante una hora.

        // ---------- Variable de recurso ya existente (Java 9+) ----------
        var existente = new RecursoRoto("B");
        try (existente) {          // no hace falta reasignar a una variable nueva
            System.out.println("usando B");
        } catch (Exception e) { System.out.println("cierre de B: " + e.getMessage()); }
    }
}

11.3 Multi-catch, finally y sus trampas

import java.io.IOException;
import java.nio.file.*;
import java.sql.SQLException;

public class CatchYFinally {

    // ✅ Multi-catch: mismo tratamiento para varios tipos. El parámetro es final implícito.
    static void multiCatch(Path p) {
        try {
            procesar(p);
        } catch (NoSuchFileException | AccessDeniedException e) {
            // Los dos son problemas del fichero: mismo tratamiento
            throw new IllegalArgumentException("Fichero inaccesible: " + p, e);
        } catch (IOException e) {
            throw new UncheckedIOException2(e);
        }
    }

    // ⚠️ Orden de los catch: de lo MÁS específico a lo más general.
    //    Al revés no compila ("exception has already been caught"), lo cual es una suerte.

    // ❌ TRAMPA 1: return dentro de finally DESCARTA la excepción en curso
    @SuppressWarnings("finally")
    static int trampaReturn() {
        try {
            throw new RuntimeException("esta excepción desaparece");
        } finally {
            return 42;          // el método devuelve 42 y la excepción se pierde. Sin avisos.
        }
    }

    // ❌ TRAMPA 2: finally también descarta la excepción si lanza otra
    static int trampaLanzar() {
        try {
            throw new RuntimeException("original");
        } finally {
            throw new IllegalStateException("la que se ve");   // tapa a la original
        }
    }

    // ❌ TRAMPA 3: finally sobrescribe el valor de retorno... o no
    static int trampaValor() {
        int x = 1;
        try { return x; }         // se EVALÚA aquí: se devuelve 1
        finally { x = 99; }       // modificar la variable después no cambia el retorno
    }

    // ❌ TRAMPA 4: tragarse InterruptedException es un bug de concurrencia
    static void trampaInterrupt() {
        try { Thread.sleep(1000); }
        catch (InterruptedException e) {
            // ❌ ignorarla borra la señal de interrupción y el hilo nunca se enterará
        }
    }
    // ✅ Lo correcto: restaurar la bandera o propagar
    static void interruptBien() throws InterruptedException {
        try { Thread.sleep(1000); }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();      // restaura la bandera
            throw e;                                  // o propaga
        }
    }

    public static void main(String[] args) {
        System.out.println(trampaReturn());     // 42, sin excepción: el bug perfecto
        System.out.println(trampaValor());      // 1
        try { trampaLanzar(); } catch (Exception e) { System.out.println(e.getMessage()); }
        trampaInterrupt();
        multiCatch(Path.of("/no/existe"));
    }

    static void procesar(Path p) throws IOException { Files.readString(p); }
    static class UncheckedIOException2 extends RuntimeException {
        UncheckedIOException2(Throwable c) { super(c); }
    }
}
Nunca pongas return, break, continue ni un throw dentro de un finally. Descarta silenciosamente la excepción que estaba propagándose y te deja depurando un síntoma sin causa. El finally es solo para liberar recursos, y desde Java 7 casi siempre es innecesario porque try-with-resources lo hace mejor. Los analizadores estáticos (SpotBugs, SonarQube, ErrorProne) detectan este patrón: actívalos.

11.4 Excepciones de dominio bien diseñadas

import java.math.BigDecimal;

// ---------- 1. Una raíz por dominio: permite capturar por familias ----------
public abstract class ErrorDeNegocio extends RuntimeException {
    private final String codigo;

    protected ErrorDeNegocio(String codigo, String mensaje) { super(mensaje); this.codigo = codigo; }
    protected ErrorDeNegocio(String codigo, String mensaje, Throwable causa) {
        super(mensaje, causa); this.codigo = codigo;
    }
    /** Código estable para el cliente de la API y para las métricas. */
    public String codigo() { return codigo; }
}

// ---------- 2. Excepciones concretas con DATOS, no solo con un mensaje ----------
public final class SaldoInsuficiente extends ErrorDeNegocio {
    private final String iban;
    private final BigDecimal solicitado;
    private final BigDecimal disponible;

    public SaldoInsuficiente(String iban, BigDecimal solicitado, BigDecimal disponible) {
        super("SALDO_INSUFICIENTE",
              "Cuenta %s: se solicitan %s y hay %s".formatted(iban, solicitado, disponible));
        this.iban = iban; this.solicitado = solicitado; this.disponible = disponible;
    }
    // Los accesores permiten que el llamante decida sin parsear el mensaje.
    // Un mensaje es para humanos; los datos, para código.
    public String iban() { return iban; }
    public BigDecimal solicitado() { return solicitado; }
    public BigDecimal disponible() { return disponible; }
    public BigDecimal falta() { return solicitado.subtract(disponible); }
}

// ---------- 3. Excepción sin stack trace: cuando el fallo es esperado y frecuente ----------
public final class RecursoNoEncontrado extends ErrorDeNegocio {
    public RecursoNoEncontrado(String tipo, String id) {
        // super(mensaje, causa, enableSuppression, writableStackTrace)
        // Con writableStackTrace = false, no se rellena la traza: hasta 100 veces más rápido.
        // Úsalo SOLO si el sitio del fallo es irrelevante (un 404 de negocio).
        super("NO_ENCONTRADO", "%s no encontrado: %s".formatted(tipo, id));
    }
}

// ---------- 4. Traducir en la frontera, conservando la causa ----------
final class RepositorioClientes {
    java.util.Optional<String> buscar(String id) {
        try {
            return java.util.Optional.of(consultar(id));
        } catch (java.sql.SQLException e) {
            // ✅ Envolver conservando la causa: no se pierde el diagnóstico
            throw new ErrorDeInfraestructura("Fallo consultando el cliente " + id, e);
        }
    }
    private String consultar(String id) throws java.sql.SQLException { return id; }
}

final class ErrorDeInfraestructura extends RuntimeException {
    ErrorDeInfraestructura(String mensaje, Throwable causa) { super(mensaje, causa); }
}
AntipatrónPor qué dueleQué hacer
catch (Exception e) { }El fallo desaparece. El sistema sigue con datos corruptos y nadie lo sabe hasta semanas después.Registra con contexto y propaga, o trata el caso de verdad.
e.printStackTrace()Va a System.err: sin nivel, sin marca de tiempo, sin correlación, y no lo recoge el agregador de logs.log.error("Fallo al X con {}", id, e).
throw new RuntimeException(e.getMessage())Pierde la causa y la traza. Es el error más caro de esta tabla.throw new MiError("contexto", e): la causa siempre.
catch (Throwable t)Captura OutOfMemoryError y StackOverflowError, y sigue ejecutando sobre una JVM inservible.catch (Exception e) como máximo, y solo en el nivel más externo.
Excepciones para flujo normalCoste de la traza, código ilegible y peor rendimiento.Devuelve Optional, un boolean o un tipo sealed de resultado.
throws Exception en la firmaNo informa de nada y obliga a los llamantes a capturar todo.Declara las excepciones concretas o traduce a no comprobadas.
Mensajes sin datos«Error al procesar» no permite reproducir nada.Incluye el identificador, el valor y el estado. Nunca datos personales ni secretos.
Registrar y propagarEl mismo error aparece cinco veces en el log, con cinco trazas distintas.O registras (y tratas) o propagas. Registra una sola vez, en el borde.
@Transactional + checkedPor defecto, Spring no hace rollback con excepciones comprobadas: la transacción se confirma con datos a medias.Excepciones no comprobadas, o @Transactional(rollbackFor = …).

11.5 Cómo leer un stack trace

Exception in thread "http-nio-8080-exec-3" org.springframework.dao.DataIntegrityViolationException:
    could not execute statement [ERROR: duplicate key value violates unique constraint "cliente_email_key"]
  at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(...)
  at com.ejemplo.pedidos.aplicacion.RegistrarCliente.ejecutar(RegistrarCliente.java:47)   ← ①
  at com.ejemplo.pedidos.api.ClienteController.crear(ClienteController.java:31)           ← ②
  at java.base/java.lang.reflect.Method.invoke(Method.java:580)
  ... 68 frames omitidos (Spring, Tomcat)                                                 ← ③
Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement  ← ④
  at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(...)
  ... 92 more
Caused by: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique
    constraint "cliente_email_key"  Detail: Key (email)=(ana@ejemplo.com) already exists.   ← ⑤
  at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(...)

Cómo leerlo:
  ①  El primer frame de TU código (tu paquete). Aquí empieza tu investigación, no arriba.
  ②  Quién llamó. La cadena hacia abajo es la pila de llamadas, de dentro hacia fuera.
  ③  Los frames de framework casi nunca importan. Filtra por tu paquete.
  ④  "Caused by" en cadena: cada capa envolvió la anterior.
  ⑤  LA ÚLTIMA "Caused by" ES LA CAUSA REAL. Aquí está el diagnóstico completo:
      email duplicado, y hasta el valor concreto. Empieza a leer por el final.
// Herramientas para trabajar con trazas
public class Trazas {
    public static void main(String[] args) {
        try {
            capa1();
        } catch (Exception e) {
            // 1. La causa raíz, sin recorrer la cadena a mano
            Throwable raiz = e;
            while (raiz.getCause() != null) raiz = raiz.getCause();
            System.out.println("Causa raíz: " + raiz);

            // 2. Filtrar la traza a tu propio paquete: de 90 frames a 3
            java.util.Arrays.stream(e.getStackTrace())
                    .filter(f -> f.getClassName().startsWith("Trazas"))
                    .forEach(f -> System.out.println("  " + f));
        }

        // 3. StackWalker (Java 9+): recorrer la pila sin materializar toda la traza.
        //    Es la forma eficiente de saber "quién me ha llamado".
        StackWalker.getInstance().walk(frames -> frames
                .limit(3)
                .map(f -> f.getClassName() + "." + f.getMethodName() + ":" + f.getLineNumber())
                .toList())
            .forEach(System.out::println);

        // 4. NPE con mensaje útil (Java 14+, activado por defecto desde 15).
        //    En lugar de "NullPointerException" a secas, dice la expresión exacta:
        record Direccion(String ciudad) { }
        record Cliente(Direccion direccion) { }
        Cliente c = new Cliente(null);
        try { System.out.println(c.direccion().ciudad().length()); }
        catch (NullPointerException e) { System.out.println("NPE útil: " + e.getMessage()); }
        // "Cannot invoke \"String.length()\" because the return value of
        //  \"Direccion.ciudad()\" is null"  ← el nombre exacto de lo que era null
    }

    static void capa1() { try { capa2(); } catch (Exception e) { throw new RuntimeException("capa1", e); } }
    static void capa2() { try { capa3(); } catch (Exception e) { throw new IllegalStateException("capa2", e); } }
    static void capa3() { throw new java.util.NoSuchElementException("el problema real está aquí"); }
}

11.6 Validación en el límite y el patrón Result

import java.util.List;
import java.util.Objects;

public class ValidacionYResultado {

    // ---------- Validación en el límite: fail fast con mensajes útiles ----------
    record Transferencia(String origen, String destino, java.math.BigDecimal importe, String concepto) {
        Transferencia {
            Objects.requireNonNull(origen, "origen es obligatorio");
            Objects.requireNonNull(destino, "destino es obligatorio");
            Objects.requireNonNull(importe, "importe es obligatorio");
            if (origen.equals(destino))
                throw new IllegalArgumentException("origen y destino no pueden coincidir: " + origen);
            if (importe.signum() <= 0)
                throw new IllegalArgumentException("el importe debe ser positivo: " + importe);
            if (concepto != null && concepto.length() > 140)
                throw new IllegalArgumentException("concepto de más de 140 caracteres");
        }
    }
    // IllegalArgumentException → el llamante pasó algo mal (400 Bad Request)
    // IllegalStateException    → el objeto no está en un estado válido para esa operación (409)
    // Elegir bien entre las dos es una señal de madurez que se nota en las revisiones.

    // ---------- Patrón Result: cuando hay VARIAS formas de fallar y todas son "normales" ----------
    sealed interface Resultado<T> {
        record Ok<T>(T valor) implements Resultado<T> { }
        record Error<T>(String codigo, String mensaje) implements Resultado<T> { }

        default <R> Resultado<R> map(java.util.function.Function<T, R> f) {
            return switch (this) {
                case Ok<T>(T v) -> new Ok<>(f.apply(v));
                case Error<T>(String c, String m) -> new Error<>(c, m);
            };
        }
        default T orElseThrow() {
            return switch (this) {
                case Ok<T>(T v) -> v;
                case Error<T>(String c, String m) -> throw new IllegalStateException(c + ": " + m);
            };
        }
    }

    // Validación acumulativa: en un formulario quieres TODOS los errores, no el primero
    record ErrorValidacion(String campo, String mensaje) { }

    static List<ErrorValidacion> validar(String email, int edad) {
        var errores = new java.util.ArrayList<ErrorValidacion>();
        if (email == null || !email.contains("@")) errores.add(new ErrorValidacion("email", "formato inválido"));
        if (edad < 18) errores.add(new ErrorValidacion("edad", "debe ser mayor de edad"));
        if (edad > 120) errores.add(new ErrorValidacion("edad", "valor no plausible"));
        return List.copyOf(errores);
    }

    public static void main(String[] args) {
        System.out.println(validar("malo", 15));
        Resultado<Integer> r = new Resultado.Ok<>(21);
        System.out.println(r.map(n -> n * 2).orElseThrow());          // 42
        Resultado<Integer> e = new Resultado.Error<>("E1", "algo falló");
        System.out.println(e.map(n -> n * 2));
        try { new Transferencia("A", "A", java.math.BigDecimal.ONE, null); }
        catch (IllegalArgumentException ex) { System.out.println(ex.getMessage()); }
    }
}
ExcepciónOptionalResult (sealed)
ExpresaAlgo excepcional ha ocurridoPuede no haber valorÉxito o uno de N fallos, cada uno con sus datos
El compilador obliga a tratarloSolo si es checkedSí (hay que desenvolver): el switch debe ser exhaustivo
CosteRellenar la traza (evitable)Un objetoUn objeto
LegibilidadSepara el camino feliz del errorBuena para un casoExcelente para varios casos; verboso para uno
Úsalo cuandoEl fallo es realmente anómalo o debe abortar la operación“No hay resultado” es normal y no necesitas explicar por quéValidación acumulativa, integraciones con varios códigos de error, máquinas de estado

Comprobación rápida de la sección 11

12 · Entrada/salida, formatos y fechas

Tres áreas que aparecen en cualquier aplicación real y en las que se cometen errores caros: leer ficheros sin reventar la memoria, serializar sin abrir agujeros de seguridad y manejar fechas sin perder una hora al año.

12.1 java.nio.file: Path, Files y nada de File

java.io.File está obsoleto en la práctica. Sus métodos devuelven boolean en lugar de lanzar excepciones: si file.delete() devuelve false, no sabes si no existía, si no tenías permisos o si estaba en uso. Files.delete(path) lanza NoSuchFileException, AccessDeniedException o DirectoryNotEmptyException, que es información con la que puedes hacer algo. Usa Path y Files siempre.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.stream.Stream;

public class NioBasico {

    public static void main(String[] args) throws IOException {
        // ---------- Path: manipulación de rutas, SIN tocar el disco ----------
        Path base = Path.of("/var", "datos", "informes");      // separador correcto en cada SO
        Path fichero = base.resolve("2026-07.csv");            // /var/datos/informes/2026-07.csv
        System.out.println(fichero.getFileName());             // 2026-07.csv
        System.out.println(fichero.getParent());               // /var/datos/informes
        System.out.println(base.relativize(fichero));          // 2026-07.csv
        System.out.println(Path.of("a/b/../c").normalize());   // a/c  (resuelve . y ..)
        System.out.println(fichero.toAbsolutePath());

        // ⚠️ SEGURIDAD: normalize() ANTES de comprobar que la ruta está dentro de la base.
        //    Sin esto tienes un path traversal: "../../etc/passwd".
        Path solicitada = base.resolve("../../etc/passwd").normalize();
        if (!solicitada.startsWith(base)) throw new SecurityException("Ruta fuera de la base: " + solicitada);

        // ---------- Lectura y escritura: siempre con charset explícito ----------
        Path tmp = Files.createTempFile("demo", ".txt");
        Files.writeString(tmp, "hola\nmundo\n", StandardCharsets.UTF_8);
        Files.writeString(tmp, "otra línea\n", StandardCharsets.UTF_8, StandardOpenOption.APPEND);

        String todo = Files.readString(tmp, StandardCharsets.UTF_8);      // ficheros pequeños
        List<String> lineas = Files.readAllLines(tmp, StandardCharsets.UTF_8);
        byte[] bytes = Files.readAllBytes(tmp);
        System.out.println(lineas + " / " + bytes.length + " bytes / " + todo.length() + " chars");

        // ---------- Metadatos en UNA llamada al sistema, no en cinco ----------
        BasicFileAttributes at = Files.readAttributes(tmp, BasicFileAttributes.class);
        System.out.println(at.size() + " bytes, creado " + at.creationTime()
                + ", modificado " + at.lastModifiedTime() + ", ¿directorio? " + at.isDirectory());
        // ❌ Files.exists() + Files.size() + Files.getLastModifiedTime() = 3 syscalls y una
        //    condición de carrera entre ellas.

        // ---------- Copiar, mover, borrar ----------
        Path copia = tmp.resolveSibling("copia.txt");
        Files.copy(tmp, copia, StandardCopyOption.REPLACE_EXISTING);
        // ATOMIC_MOVE: el fichero destino aparece completo o no aparece. Imprescindible para
        // el patrón "escribe en .tmp y renombra": ningún lector ve un fichero a medias.
        Path destino = tmp.resolveSibling("final.txt");
        Files.move(copia, destino, StandardCopyOption.ATOMIC_MOVE);
        Files.deleteIfExists(destino);      // no lanza si no existe
        Files.createDirectories(Path.of(System.getProperty("java.io.tmpdir"), "a", "b", "c")); // mkdir -p

        // ---------- Recorrer un árbol: SIEMPRE con try-with-resources ----------
        // Files.walk devuelve un Stream que mantiene descriptores de fichero abiertos.
        // Si no lo cierras, agotas los file handles del proceso.
        try (Stream<Path> s = Files.walk(Path.of("."), 3)) {          // profundidad máxima 3
            s.filter(Files::isRegularFile)
             .filter(p -> p.toString().endsWith(".java"))
             .limit(5)
             .forEach(System.out::println);
        }

        // Files.find: filtro con atributos, sin un stat extra por fichero
        try (Stream<Path> s = Files.find(Path.of("."), 2,
                (p, a) -> a.isRegularFile() && a.size() > 10_000)) {
            System.out.println("Ficheros grandes: " + s.count());
        }

        // Files.list: solo un nivel (equivale a ls, no a find)
        try (Stream<Path> s = Files.list(Path.of("."))) { System.out.println(s.count() + " entradas"); }

        Files.deleteIfExists(tmp);
    }
}

12.2 Leer ficheros grandes sin agotar la memoria

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.stream.Stream;

public class FicherosGrandes {

    // ❌ Con un fichero de 4 GB esto es un OutOfMemoryError garantizado:
    //    readAllLines carga TODAS las líneas en una List en memoria.
    static long contarMal(Path p) throws IOException {
        return Files.readAllLines(p).stream().filter(l -> l.contains("ERROR")).count();
    }

    // ✅ Streaming: memoria constante, independientemente del tamaño del fichero.
    static long contarBien(Path p) throws IOException {
        try (Stream<String> lineas = Files.lines(p, StandardCharsets.UTF_8)) {
            return lineas.filter(l -> l.contains("ERROR")).count();
        }
    }
    // ⚠️ Files.lines lanza UncheckedIOException a mitad del stream si el fichero tiene bytes
    //    inválidos para el charset. No lo captura tu catch (IOException) de fuera del try.

    // ✅ Bucle clásico con BufferedReader: más control, y puedes usar excepciones comprobadas
    static long contarConBucle(Path p) throws IOException {
        long n = 0;
        try (BufferedReader r = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
            String linea;
            while ((linea = r.readLine()) != null) {
                if (linea.contains("ERROR")) n++;
            }
        }
        return n;
    }

    // ✅ Binario: copia por bloques con un búfer reutilizado (nunca byte a byte)
    static void copiar(Path origen, Path destino) throws IOException {
        try (InputStream in = Files.newInputStream(origen);
             OutputStream out = new BufferedOutputStream(Files.newOutputStream(destino))) {
            in.transferTo(out);        // Java 9+: el bucle de copia, ya escrito y optimizado
        }
    }

    // ✅ Cuando quieres una lectura de verdad rápida: canal + búfer directo
    static long sumarBytes(Path p) throws IOException {
        long suma = 0;
        try (var canal = java.nio.channels.FileChannel.open(p, StandardOpenOption.READ)) {
            var buf = java.nio.ByteBuffer.allocateDirect(64 * 1024);   // fuera del heap
            while (canal.read(buf) != -1) {
                buf.flip();
                while (buf.hasRemaining()) suma += buf.get() & 0xFF;
                buf.clear();
            }
        }
        return suma;
    }

    public static void main(String[] args) throws IOException {
        Path p = Files.createTempFile("log", ".txt");
        Files.writeString(p, "INFO ok\nERROR fallo\nERROR otro\n");
        System.out.println(contarBien(p) + " " + contarConBucle(p) + " " + contarMal(p));
        System.out.println(sumarBytes(p) + " bytes sumados");
        Files.delete(p);
    }
}
NecesidadHerramientaMemoria
Fichero de configuración pequeño (< 1 MB)Files.readStringTodo el fichero
Procesar líneas de un log de GBFiles.lines o BufferedReaderUna línea
Copiar binariosin.transferTo(out) o Files.copyUn búfer
Escribir un informe línea a líneaFiles.newBufferedWriterUn búfer
Acceso aleatorio o máximo rendimientoFileChannel + ByteBufferControlada
Recorrer directoriosFiles.walk / find (¡cerrar!)Perezosa
Nunca uses el charset por omisión. Antes de Java 18, new FileReader(f) o new String(bytes) usaban el charset de la plataforma: UTF-8 en tu portátil Linux, windows-1252 en la máquina de un compañero, y a veces ASCII en un contenedor con locale mínimo. El resultado es el clásico «los acentos salen mal solo en producción». JEP 400 (Java 18) fijó UTF-8 como omisión, pero escribe siempre StandardCharsets.UTF_8 de forma explícita: documenta la intención y funciona en cualquier versión.

12.3 Serialización de Java: por qué no debes usarla

import java.io.*;

public class SerializacionPeligrosa {

    // La serialización nativa parece cómoda...
    static class Sesion implements Serializable {
        private static final long serialVersionUID = 1L;
        private String usuario;
        private transient String tokenSecreto;      // transient = no se serializa
        Sesion(String u, String t) { usuario = u; tokenSecreto = t; }
    }

    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var out = new ObjectOutputStream(bytes)) { out.writeObject(new Sesion("ana", "s3cr3t")); }
        try (var in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            Sesion s = (Sesion) in.readObject();
            System.out.println(s.usuario + " / " + s.tokenSecreto);   // ana / null
        }
    }
}
ProblemaConsecuencia real
Ejecución de código en readObjectEs la vulnerabilidad más explotada de la historia de Java: gadget chains (ysoserial) consiguen ejecución remota de código con solo enviar bytes. Log4Shell, las cadenas de Apache Commons Collections, WebLogic… todo eso.
Constructor no invocadoLa deserialización crea el objeto sin pasar por el constructor: todas tus validaciones e invariantes se saltan.
Formato opaco y frágilBinario ilegible, atado a los nombres de campo. Renombra un campo y rompes la compatibilidad; añade uno y necesitas rituales con serialVersionUID.
Solo JavaNingún otro lenguaje lo lee. Inservible para integraciones.
Denial of serviceUn payload de 400 bytes puede provocar un consumo de CPU exponencial (billion laughs con HashSets anidados).
Qué hacer en su lugar: JSON con Jackson, o Protobuf/Avro si necesitas formato binario y esquema. Si te encuentras serialización nativa en un proyecto heredado y no puedes eliminarla ya, aplica un filtro de deserialización: -Djdk.serialFilter=com.miapp.*;!* o ObjectInputFilter.Config.setSerialFilter(...) (JEP 290). El JDK está eliminando la serialización a largo plazo (JEP 154 la marcó como propensa a errores y hay trabajo en curso para permitir desactivarla por completo).

12.4 JSON con Jackson: lo que hay que saber

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;

public class JacksonEsencial {

    // ---------- Un DTO moderno: record + anotaciones ----------
    @JsonInclude(JsonInclude.Include.NON_NULL)          // no serializa los null
    record PedidoDto(
            @JsonProperty("id_pedido") String idPedido,     // nombre distinto en el JSON
            String cliente,
            BigDecimal total,                               // NUNCA double para dinero
            @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
            LocalDate fecha,
            Instant creadoEn,
            List<LineaDto> lineas,
            @JsonIgnore String notaInterna                  // nunca sale ni entra
    ) { }

    record LineaDto(String sku, int cantidad, BigDecimal precio) { }

    static ObjectMapper crearMapper() {
        return new ObjectMapper()
                // 1. Sin esto, java.time se serializa como un objeto ilegible o falla
                .registerModule(new JavaTimeModule())
                // 2. Fechas como ISO-8601 ("2026-07-31T10:15:30Z"), no como número de milisegundos
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                // 3. Tolerar campos nuevos del productor: CLAVE para no romperse en cada despliegue
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                // 4. Rechazar null en primitivos en lugar de convertirlo en 0 en silencio
                .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
                // 5. BigDecimal exacto: sin esto, 0.1 puede llegar como double y perder precisión
                .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
    }

    public static void main(String[] args) throws Exception {
        var mapper = crearMapper();
        var dto = new PedidoDto("P-1", "ana", new BigDecimal("120.50"), LocalDate.of(2026, 7, 31),
                Instant.parse("2026-07-31T08:00:00Z"),
                List.of(new LineaDto("SKU-1", 2, new BigDecimal("60.25"))), "no exponer");

        String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(dto);
        System.out.println(json);

        PedidoDto vuelta = mapper.readValue(json, PedidoDto.class);
        System.out.println(vuelta.equals(dto));    // false: notaInterna se ignoró (es null al volver)

        // ---------- Genéricos: TypeReference es obligatorio por el borrado de tipos ----------
        String lista = """
            [{"sku":"A","cantidad":1,"precio":10.00},{"sku":"B","cantidad":3,"precio":5.50}]""";
        List<LineaDto> lineas = mapper.readValue(lista, new TypeReference<List<LineaDto>>() { });
        System.out.println(lineas);
        // ❌ mapper.readValue(lista, List.class) devuelve List<LinkedHashMap> y falla al usarla

        // ---------- JsonNode: cuando no conoces la estructura ----------
        JsonNode raiz = mapper.readTree(json);
        System.out.println(raiz.path("lineas").path(0).path("sku").asText("desconocido"));
        // path() devuelve un nodo "missing" en lugar de null: se puede encadenar sin NPE
        // get() devuelve null y provoca NPE. Usa path().
    }
}
AnotaciónPara qué
@JsonProperty("nombre")Renombrar un campo; también hace obligatorio con required=true.
@JsonIgnore / @JsonIgnorePropertiesExcluir campos. Imprescindible para no filtrar contraseñas o datos internos.
@JsonInclude(NON_NULL)Omitir nulos: respuestas más pequeñas y menos ambigüedad.
@JsonFormatPatrón y zona de fechas; shape=STRING para números grandes (los long pierden precisión en JavaScript).
@JsonCreatorConstructor o factoría concreta para deserializar. Con records y el módulo de parámetros suele ser innecesario.
@JsonAliasAceptar varios nombres de entrada: útil durante una migración de contrato.
@JsonSubTypes + @JsonTypeInfoPolimorfismo. Nunca con Id.CLASS sobre entrada no confiable: es deserialización insegura otra vez.
@JsonAnySetterRecoger campos desconocidos en un Map en lugar de descartarlos.

12.5 java.time: el modelo completo

java.util.Date y Calendar están reemplazados desde Java 8, y con razón: Date es mutable, no representa una fecha (es un instante), su getYear() devuelve el año menos 1900, los meses van de 0 a 11 y SimpleDateFormat no es seguro entre hilos y ha causado incidentes reales. java.time es inmutable, seguro entre hilos y tiene un tipo distinto para cada concepto, que es justo lo que necesitas.

TipoQué representaÚsalo para
InstantUn punto en la línea temporal (UTC, precisión de nanosegundos)Marcas de tiempo: creado_en, log, auditoría. Lo que va en la base de datos.
LocalDateFecha sin hora ni zonaFecha de nacimiento, fecha de factura, vencimiento.
LocalTimeHora sin fecha ni zonaHora de apertura de una tienda.
LocalDateTimeFecha y hora sin zonaCasi nunca por sí solo: no identifica un instante. Solo para «lo que marca el reloj de la pared».
ZonedDateTimeFecha, hora y zona con reglas de horario de veranoCitas y eventos futuros con significado local («la reunión es a las 9 en Madrid»).
OffsetDateTimeFecha, hora y desplazamiento fijo (+02:00)APIs y bases de datos (TIMESTAMP WITH TIME ZONE). Es lo que se serializa.
DurationCantidad de tiempo en segundos/nanosTimeouts, medir tiempos, «90 minutos».
PeriodCantidad en años/meses/días«3 meses de suscripción», edad.
YearMonth, MonthDay, YearFechas parcialesCaducidad de tarjeta, aniversarios.
DayOfWeek, MonthEnumsLógica de calendario legible.
import java.time.*;
import java.time.format.*;
import java.time.temporal.*;
import java.util.Locale;

public class TiempoCompleto {

    public static void main(String[] args) {
        // ---------- Crear ----------
        Instant ahora = Instant.now();                        // UTC, siempre
        LocalDate hoy = LocalDate.now(ZoneId.of("Europe/Madrid"));
        LocalDate nacimiento = LocalDate.of(1990, Month.MARCH, 15);
        ZonedDateTime cita = ZonedDateTime.of(2026, 10, 25, 2, 30, 0, 0, ZoneId.of("Europe/Madrid"));
        System.out.println(ahora + " | " + hoy + " | " + cita);

        // ---------- Aritmética: SIEMPRE devuelve un objeto nuevo (inmutable) ----------
        LocalDate finDeMes = hoy.with(TemporalAdjusters.lastDayOfMonth());
        LocalDate proximoLunes = hoy.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
        LocalDate tercerViernes = hoy.with(TemporalAdjusters.dayOfWeekInMonth(3, DayOfWeek.FRIDAY));
        System.out.println(finDeMes + " | " + proximoLunes + " | " + tercerViernes);
        System.out.println(hoy.plusMonths(1).minusDays(3).withDayOfMonth(1));
        // ⚠️ hoy.plusDays(1) NO modifica hoy. Si escribes hoy.plusDays(1); sin asignar,
        //    no pasa nada. Es el error número uno con java.time.

        // ⚠️ Suma de meses y días que no existen: se ajusta al último día válido
        System.out.println(LocalDate.of(2026, 1, 31).plusMonths(1));    // 2026-02-28, no 03-03
        System.out.println(LocalDate.of(2024, 2, 29).plusYears(1));     // 2025-02-28

        // ---------- Duraciones y periodos: no son lo mismo ----------
        Duration d = Duration.between(Instant.parse("2026-07-31T08:00:00Z"), ahora);
        System.out.println(d.toDays() + "d " + d.toHoursPart() + "h " + d.toMinutesPart() + "m");
        Period edad = Period.between(nacimiento, hoy);
        System.out.println(edad.getYears() + " años");
        System.out.println(ChronoUnit.DAYS.between(nacimiento, hoy) + " días vividos");
        System.out.println(Duration.ofMinutes(90));           // PT1H30M (formato ISO-8601)
        System.out.println(Duration.parse("PT2H15M").toMinutes());   // 135

        // ---------- Zonas y horario de verano: el terreno de las sorpresas ----------
        ZoneId madrid = ZoneId.of("Europe/Madrid");
        // 1. Hora que NO EXISTE: el 29-03-2026 a las 02:30 el reloj salta de 02:00 a 03:00
        ZonedDateTime inexistente = LocalDateTime.of(2026, 3, 29, 2, 30).atZone(madrid);
        System.out.println("No existe → " + inexistente);      // 03:30+02:00: se adelanta
        // 2. Hora que existe DOS VECES: el 25-10-2026 a las 02:30 ocurre dos veces
        ZonedDateTime ambigua = LocalDateTime.of(2026, 10, 25, 2, 30).atZone(madrid);
        System.out.println("Ambigua → " + ambigua);            // elige el PRIMER desplazamiento (+02:00)
        System.out.println("El otro → " + ambigua.withLaterOffsetAtOverlap());   // +01:00
        // ⚠️ Por esto un cálculo de "24 horas después" con Duration puede no dar la misma hora local:
        System.out.println(ambigua.plus(Duration.ofDays(1)));   // exactamente 24 h de reloj
        System.out.println(ambigua.plusDays(1));                // el mismo día siguiente, hora local

        // 3. Convertir entre zonas
        ZonedDateTime enTokio = cita.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));  // mismo instante
        ZonedDateTime otraCita = cita.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));   // misma hora local
        System.out.println(enTokio + " ≠ " + otraCita);

        // ---------- Conversiones entre tipos: el mapa que hay que tener claro ----------
        Instant i2 = hoy.atStartOfDay(madrid).toInstant();     // LocalDate → Instant (necesita zona)
        LocalDate d2 = ahora.atZone(madrid).toLocalDate();     // Instant → LocalDate (necesita zona)
        OffsetDateTime odt = ahora.atOffset(ZoneOffset.UTC);
        long epochMillis = ahora.toEpochMilli();
        Instant i3 = Instant.ofEpochMilli(epochMillis);
        System.out.println(i2 + " " + d2 + " " + odt + " " + i3);
        // Y con la API antigua, si te toca convivir con ella:
        java.util.Date legacy = java.util.Date.from(ahora);
        Instant vuelta = legacy.toInstant();
        System.out.println(vuelta.equals(ahora.truncatedTo(ChronoUnit.MILLIS)));

        // ---------- Formateo y parseo ----------
        var iso = DateTimeFormatter.ISO_INSTANT;
        var humano = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", new Locale("es", "ES"));
        var largo = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
                .withLocale(new Locale("es", "ES")).withZone(madrid);
        System.out.println(iso.format(ahora));
        System.out.println(humano.format(cita));
        System.out.println(largo.format(ahora));
        LocalDate parseada = LocalDate.parse("31/07/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy"));
        System.out.println(parseada);
        // ✅ DateTimeFormatter ES inmutable y seguro entre hilos: puedes hacerlo static final.
        //    SimpleDateFormat NO lo era, y compartirlo entre hilos daba fechas absurdas
        //    de forma intermitente. Fue una fuente clásica de bugs imposibles.

        // ---------- Reloj inyectable: cómo se testea el tiempo ----------
        Clock fijo = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
        System.out.println(LocalDate.now(fijo));       // 2026-01-01, siempre
        // En producción inyectas Clock.systemUTC(); en los test, Clock.fixed(...).
        // Así puedes verificar lógica de vencimientos sin esperar ni tocar el reloj del sistema.
    }
}
Error clásicoPor qué es un errorSolución
fecha.plusDays(1); sin asignarLos tipos son inmutables: el resultado se descarta.fecha = fecha.plusDays(1);
LocalDateTime en base de datosNo identifica un instante: al cambiar la zona del servidor, los datos cambian de significado.Instant o OffsetDateTime con timestamptz.
ZoneId.systemDefault() implícitoEl resultado depende de la máquina y del contenedor. Irreproducible.Zona explícita, y fija TZ=UTC en los servidores.
Duration.ofDays(1) para «mañana»Son 24 horas exactas; en el cambio de hora no es el día siguiente a la misma hora.plusDays(1) sobre ZonedDateTime.
SimpleDateFormat como campo compartidoNo es seguro entre hilos: produce fechas erróneas de forma aleatoria.DateTimeFormatter, que sí lo es.
yyyy frente a YYYYYYYY es el año de la semana ISO: el 31/12/2026 se formatea como 2027.Usa yyyy salvo que sepas exactamente lo que haces.
Comparar con equalsInstants con distinta precisión no son iguales; ZonedDateTime compara también la zona.isBefore, isAfter, isEqual, o truncatedTo.
Guardar la edadCambia cada año y queda obsoleta.Guarda la fecha de nacimiento y calcula.

Comprobación rápida de la sección 12

13 · La JVM por dentro

Esta sección separa a los candidatos que «saben Java» de los que pueden diagnosticar un incidente a las tres de la mañana. No necesitas conocer el código de HotSpot; necesitas saber dónde vive tu memoria, qué hace el recolector, cómo se compila tu código en caliente y qué comando ejecutar cuando el servicio empieza a responder en cinco segundos en lugar de en cincuenta milisegundos.

13.1 Cargadores de clases y el orden de carga

          ┌──────────────────────────────────────────────────────────┐
          │  Bootstrap ClassLoader  (código nativo, padre de todos)  │
          │  Carga java.base: String, Object, List…                  │
          │  getClassLoader() devuelve null para estas clases        │
          └───────────────────────────┬──────────────────────────────┘
                                      │ padre
          ┌───────────────────────────┴──────────────────────────────┐
          │  Platform ClassLoader  (antes "extension")               │
          │  Módulos del JDK que no están en java.base: java.sql…    │
          └───────────────────────────┬──────────────────────────────┘
                                      │ padre
          ┌───────────────────────────┴──────────────────────────────┐
          │  Application (System) ClassLoader                        │
          │  Tu código y tus dependencias: el classpath / modulepath │
          └───────────────────────────┬──────────────────────────────┘
                                      │ padre
          ┌───────────────────────────┴──────────────────────────────┐
          │  Cargadores propios: Tomcat (uno por webapp), Spring     │
          │  Boot (LaunchedURLClassLoader), OSGi, plugins…           │
          └──────────────────────────────────────────────────────────┘

DELEGACIÓN AL PADRE PRIMERO: al pedir una clase, cada cargador pregunta primero
a su padre. Solo la busca él mismo si el padre no la encuentra. Esto garantiza
que nadie pueda sustituir java.lang.String por una versión propia.

Excepción: los contenedores web invierten el orden para las clases de la webapp
(primero WEB-INF/classes, luego el padre), para que cada aplicación use SU versión
de una biblioteca. De ahí vienen los NoSuchMethodError raros al desplegar.
public class CargaDeClases {

    static class Config {
        static { System.out.println("→ inicializador estático de Config"); }
        static final String CONSTANTE = "compilada en línea";   // NO provoca inicialización
        static final String CALCULADA = calcular();             // SÍ la provoca
        static String calcular() { return "en tiempo de ejecución"; }
    }

    public static void main(String[] args) throws Exception {
        // Las CINCO fases: cargar → verificar → preparar → resolver → inicializar

        // 1. Referenciar una constante de compilación NO carga la clase:
        System.out.println(Config.CONSTANTE);       // no imprime el bloque static
        // El compilador ha copiado el literal en el bytecode de main.

        // 2. Acceder a un campo estático calculado SÍ la inicializa:
        System.out.println(Config.CALCULADA);       // ahora sí sale "→ inicializador estático"

        // 3. Class.forName inicializa; ClassLoader.loadClass NO
        Class<?> c1 = Class.forName("java.util.ArrayList");                 // inicializa
        Class<?> c2 = CargaDeClases.class.getClassLoader()
                          .loadClass("java.util.LinkedList");                // solo carga
        System.out.println(c1.getSimpleName() + " " + c2.getSimpleName());

        // 4. Quién ha cargado qué
        System.out.println("String → " + String.class.getClassLoader());     // null = bootstrap
        System.out.println("Driver → " + java.sql.Driver.class.getClassLoader()); // platform
        System.out.println("mía    → " + CargaDeClases.class.getClassLoader()); // app

        // 5. Leer un recurso del classpath: el camino correcto
        try (var in = CargaDeClases.class.getResourceAsStream("/mensajes.properties")) {
            System.out.println("recurso presente: " + (in != null));
        }
    }
}
SíntomaCausa habitual
ClassNotFoundExceptionSe buscó por nombre (Class.forName, reflexión, Spring) y no está en el classpath. Falta una dependencia.
NoClassDefFoundErrorLa clase sí estaba al compilar pero no en ejecución, o su inicializador estático falló antes. Mira más arriba en el log: casi siempre hay un ExceptionInInitializerError previo que es la causa real.
NoSuchMethodErrorConflicto de versiones: compilaste contra la 2.0 y en ejecución hay la 1.0. Diagnóstico: mvn dependency:tree.
LinkageError / ClassCastException con la misma claseLa misma clase cargada por dos cargadores distintos: son tipos diferentes para la JVM aunque el nombre coincida. Típico en contenedores web.
UnsupportedClassVersionErrorBytecode compilado con un JDK más nuevo que el que ejecuta. «class file version 65.0» = Java 21.

13.2 El mapa de memoria de un proceso Java

┌─ PROCESO JAVA (lo que ve el sistema operativo: RSS) ────────────────────────┐
│                                                                             │
│  ┌─ HEAP (-Xms / -Xmx) ─ compartido, recolectado ────────────────────────┐  │
│  │                                                                       │  │
│  │  ┌─ Generación joven ────────────────────┐ ┌─ Generación vieja ────┐  │  │
│  │  │  Edén        │ S0 │ S1                │ │  Objetos que han      │  │  │
│  │  │  (aquí nace  │ superviviente          │ │  sobrevivido a varios │  │  │
│  │  │  todo)       │                        │ │  ciclos jóvenes       │  │  │
│  │  └──────────────┴────────────────────────┘ └───────────────────────┘  │  │
│  │   minor GC: rápido, copia lo vivo          major/full GC: más caro    │  │
│  └───────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  ┌─ FUERA DEL HEAP ───────────────────────────────────────────────────────┐ │
│  │  Metaspace       metadatos de clases. Crece sin límite por omisión.    │ │
│  │                  -XX:MaxMetaspaceSize. Aquí vive el bytecode.          │ │
│  │  Code cache      código nativo generado por el JIT (~240 MB máx).      │ │
│  │  Pilas de hilo   una por hilo (-Xss, ~1 MB). 500 hilos ≈ 500 MB.       │ │
│  │  Memoria directa ByteBuffer.allocateDirect, Netty, mmap.               │ │
│  │                  -XX:MaxDirectMemorySize (por omisión = -Xmx).         │ │
│  │  GC + JIT        estructuras internas del recolector y del compilador. │ │
│  │  Nativa          JNI, malloc de bibliotecas nativas.                   │ │
│  └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

⚠️  RSS ≈ heap + TODO lo de abajo. Por eso un contenedor con límite de 512 MB y
    -Xmx512m muere por OOMKill del kernel: el heap cabe, el proceso no.
    Regla práctica: -XX:MaxRAMPercentage=70 y deja el resto para lo demás.
ZonaQué guardaSe recolectaError al agotarse
HeapTodos los objetos y arraysOutOfMemoryError: Java heap space
Pila (por hilo)Marcos de método, variables locales, primitivos, referenciasNo: LIFO automáticoStackOverflowError
MetaspaceClass, métodos, pool de constantesSí, al descargar cargadoresOutOfMemoryError: Metaspace
Code cacheCódigo nativo del JITSí (sweeper)Aviso «CodeCache is full», el JIT se desactiva y todo se vuelve lento
Memoria directaBuffers de NIO, NettySolo indirectamenteOutOfMemoryError: Direct buffer memory

13.3 El ciclo de vida de un objeto

1. new Pedido()      → asignación en el EDÉN. Es un puntero que avanza (bump-the-pointer):
                        cuesta unos pocos nanosegundos, no un malloc.
                        Con TLAB (Thread Local Allocation Buffer) cada hilo tiene su trozo
                        del edén, así que no hay contención ni bloqueos.

2. Uso normal        → si el JIT demuestra que el objeto no escapa del método, puede
                        eliminarlo por completo (escape analysis) y poner sus campos en
                        registros. El objeto nunca existe.

3. El edén se llena  → MINOR GC (stop-the-world, milisegundos):
                        · se recorren las raíces (pilas, estáticos, JNI)
                        · lo VIVO se copia a un espacio superviviente; el resto se descarta
                        · coste proporcional a lo que SOBREVIVE, no a la basura
                        → la hipótesis generacional: el 90-98 % de los objetos muere joven

4. Sobrevive varios  → promoción a la GENERACIÓN VIEJA
   ciclos               (-XX:MaxTenuringThreshold, o directamente si es enorme)

5. La vieja se llena → ciclo concurrente / MAJOR GC. Mucho más caro.

6. Sin referencias   → el objeto es inalcanzable. Si tiene finalize() o un Cleaner,
   alcanzables         se encola para tratamiento (y eso RETRASA su liberación).

7. Memoria liberada  → el espacio vuelve al pool. Puede devolverse al SO
                        (-XX:+ShrinkHeapInSteps, G1 con -XX:G1PeriodicGCInterval).
import java.lang.ref.*;
import java.util.*;

public class Referencias {
    public static void main(String[] args) throws Exception {
        // Las cuatro fuerzas de referencia, de más fuerte a más débil:

        // 1. FUERTE (lo normal): mientras exista, el objeto no se recolecta
        Object fuerte = new Object();

        // 2. SOFT: se recolecta solo si hace falta memoria. Base de una caché sensible a memoria.
        SoftReference<byte[]> blanda = new SoftReference<>(new byte[1024 * 1024]);

        // 3. WEAK: se recolecta en el siguiente ciclo si no hay referencias fuertes.
        //    Es lo que usa WeakHashMap para no retener sus propias claves.
        WeakReference<Object> debil = new WeakReference<>(new Object());

        // 4. PHANTOM: para saber que algo ya se ha recolectado y limpiar recursos nativos.
        var cola = new ReferenceQueue<Object>();
        var fantasma = new PhantomReference<>(new Object(), cola);

        System.gc();     // una SUGERENCIA, no una orden. Nunca lo llames en producción.
        Thread.sleep(100);
        System.out.println("fuerte vive: " + (fuerte != null));
        System.out.println("blanda vive: " + (blanda.get() != null));    // normalmente sí
        System.out.println("débil vive:  " + (debil.get() != null));     // normalmente no
        System.out.println("fantasma encolado: " + (cola.poll() != null));

        // WeakHashMap: la entrada desaparece cuando la CLAVE deja de estar referenciada
        Map<Object, String> wm = new WeakHashMap<>();
        Object clave = new Object();
        wm.put(clave, "valor");
        wm.put(new Object(), "esta se va");
        System.out.println("antes: " + wm.size());     // 2
        System.gc(); Thread.sleep(100);
        System.out.println("después: " + wm.size());   // 1
        // ⚠️ Trampa: si el VALOR referencia fuertemente a la CLAVE, la entrada nunca se libera.

        // ❌ finalize() está eliminado (JEP 421). Usa Cleaner:
        var limpiador = Cleaner.create();
        var recurso = new Object();
        limpiador.register(recurso, () -> System.out.println("liberando recurso nativo"));
        // La acción NO debe referenciar al objeto registrado: lo mantendría vivo para siempre.
        System.out.println(fantasma.refersTo(null));
    }
}

13.4 Los recolectores: qué elegir y por qué

RecolectorPausas típicasThroughputHeap adecuadoÚsalo cuando
SerialGCAltas, proporcionales al heapBueno en heaps diminutos< 100 MBCLI, funciones serverless, contenedores con 1 CPU. Sin coste de coordinación entre hilos.
ParallelGCAltas (cientos de ms a segundos)El mejorCualquieraProcesos por lotes donde solo importa el tiempo total y una pausa de 2 s no molesta a nadie.
G1GC omisiónObjetivo configurable, típico 50–200 ms10–15 % peor que Parallel2 GB – 100 GBLa opción por defecto correcta para servicios web. Equilibrio entre pausas y rendimiento. Divide el heap en regiones y recoge primero las que más basura tienen.
ZGC< 1 ms, independientes del heapAlgo peor que G18 GB – 16 TBLatencia estricta (p99 con SLA), heaps muy grandes. Generacional y por omisión desde Java 23. Usa más CPU y memoria.
Shenandoah< 10 msSimilar a ZGCCualquieraAlternativa a ZGC (OpenJDK/Red Hat), buena también en heaps pequeños.
EpsilonGCNinguna: no recolectaSolo para pruebas de rendimiento y programas de vida muy corta. Cuando se llena el heap, muere.
# ---------- Elegir recolector ----------
java -XX:+UseG1GC          -XX:MaxGCPauseMillis=100  -jar app.jar   # objetivo de pausa
java -XX:+UseZGC           -XX:+ZGenerational        -jar app.jar   # latencia mínima
java -XX:+UseParallelGC                              -jar batch.jar # máximo rendimiento
java -XX:+UseSerialGC                                -jar cli.jar   # contenedor de 1 CPU

# ---------- Dimensionar la memoria (lo primero que hay que hacer bien) ----------
# ❌ -Xmx fijo en un contenedor: si cambias el límite del pod, hay que recordar cambiar esto
java -Xms512m -Xmx512m -jar app.jar
# ✅ Porcentaje de la memoria del contenedor: se adapta solo
java -XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70 -jar app.jar
#   Xms = Xmx evita el coste de ir creciendo y las pausas de redimensionado.

# ---------- Diagnóstico imprescindible en TODA app de producción ----------
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/dumps/app.hprof \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
     -jar app.jar
# El volcado al morir es la diferencia entre "diagnosticado en 20 minutos" y
# "no hemos podido reproducirlo". Cuesta cero mientras no ocurre nada.
# ExitOnOutOfMemoryError: tras un OOM la JVM está en estado indefinido; mejor que el
# orquestador reinicie el pod que seguir sirviendo respuestas corruptas.

# ---------- Inspeccionar una JVM viva ----------
jcmd -l                                  # listar procesos Java y su PID
jcmd <pid> VM.flags                      # flags efectivos (incluidos los ergonómicos)
jcmd <pid> VM.system_properties
jcmd <pid> GC.heap_info                  # ocupación por generación
jcmd <pid> GC.class_histogram            # ¡el más útil! objetos por clase, sin volcado
jcmd <pid> GC.heap_dump /tmp/vivo.hprof  # volcado en caliente (PAUSA la app: cuidado)
jcmd <pid> Thread.print                  # todas las pilas (equivale a jstack)
jcmd <pid> VM.native_memory summary      # requiere -XX:NativeMemoryTracking=summary
jstat -gcutil <pid> 1000                 # % de uso y nº de GC cada segundo
jmap -histo:live <pid> | head -30        # histograma forzando un GC previo

# ---------- Java Flight Recorder: el perfilador que puedes dejar en producción ----------
# Sobrecarga < 2 %. Es LA herramienta para problemas de rendimiento reales.
java -XX:StartFlightRecording=duration=60s,filename=perfil.jfr,settings=profile -jar app.jar
jcmd <pid> JFR.start name=diag settings=profile     # en una JVM ya arrancada
jcmd <pid> JFR.dump name=diag filename=/tmp/diag.jfr
jcmd <pid> JFR.stop name=diag
jfr summary perfil.jfr                   # resumen por tipo de evento
jfr print --events ExecutionSample perfil.jfr | head -50
# Y se abre con JDK Mission Control (JMC): asignaciones por pila de llamadas,
# pausas de GC, bloqueos de monitor, E/S lenta, excepciones… todo con su traza.

13.5 OutOfMemoryError: cada variante tiene una causa distinta

MensajeQué significaDiagnóstico y solución
Java heap spaceNo cabe un objeto nuevo en el heap tras un GC completo.Volcado + Eclipse MAT (Leak Suspects). Si el histograma crece sin parar, es una fuga; si el heap está lleno de datos legítimos, sube -Xmx o pagina la consulta.
GC overhead limit exceededMás del 98 % del tiempo en GC recuperando menos del 2 %. El heap está lleno de objetos vivos.Igual que el anterior: es la misma enfermedad detectada antes. La app está agonizando, no todavía muerta.
MetaspaceDemasiadas clases cargadas, o cargadores que no se descargan.Típico en redespliegues en caliente y en generación dinámica de clases (proxies, CGLIB, expresiones compiladas en un bucle). -XX:+TraceClassLoading, y busca cargadores retenidos en el volcado.
Direct buffer memorySe agotó la memoria fuera del heap de los ByteBuffer directos.Netty o un cliente HTTP sin liberar buffers. -XX:MaxDirectMemorySize y revisar el pooling.
unable to create native threadEl SO no da más hilos: límite de ulimit -u o memoria agotada por las pilas.Casi siempre un pool mal configurado o hilos creados en un bucle. Cuenta hilos con Thread.print. Reduce -Xss o usa hilos virtuales.
Requested array size exceeds VM limitUn array de más de ~231−2 elementos.Un bug de cálculo, no un problema de memoria. Suele ser un readAllBytes sobre algo enorme.
StackOverflowErrorLa pila del hilo se agotó.Recursión infinita (mira la traza: verás el ciclo repetido), o recursión legítima muy profunda; entonces -Xss2m o pásalo a iterativo. Cuidado con equals/toString mutuamente recursivos entre entidades.
Muerte sin OutOfMemoryError (código 137)El kernel mató el proceso: RSS por encima del límite del contenedor.No es un problema de heap. Suma metaspace, pilas y memoria nativa: baja MaxRAMPercentage y activa NativeMemoryTracking.

13.6 Las cinco fugas de memoria clásicas en Java

import java.util.*;
import java.util.concurrent.*;

public class Fugas {

    // ---------- FUGA 1: colección estática que solo crece ----------
    // La más común de todas. "Caché" escrita a mano, sin límite ni caducidad.
    static final Map<String, byte[]> CACHE_MALA = new HashMap<>();
    static void fuga1(String id) { CACHE_MALA.put(id, new byte[1024 * 1024]); }
    // ✅ Solución: Caffeine con maximumSize y expireAfterWrite, o al menos
    //    un LinkedHashMap con removeEldestEntry (§7.4). Y una métrica del tamaño.

    // ---------- FUGA 2: quitar del ámbito pero no de la estructura ----------
    static class PilaConFuga<E> {
        private Object[] elementos = new Object[16];
        private int tamano;
        void push(E e) { elementos[tamano++] = e; }
        @SuppressWarnings("unchecked")
        E popMal() { return (E) elementos[--tamano]; }
        // ❌ El array sigue referenciando el elemento sacado: no se puede recolectar.
        //    El ejemplo canónico de Effective Java. Una pila que ha tenido 1 M de
        //    elementos retiene 1 M de referencias aunque esté "vacía".
        @SuppressWarnings("unchecked")
        E popBien() {
            E e = (E) elementos[--tamano];
            elementos[tamano] = null;      // ✅ obsoleta la referencia explícitamente
            return e;
        }
    }

    // ---------- FUGA 3: listener registrado y nunca dado de baja ----------
    static class Publicador {
        private final List<Runnable> oyentes = new ArrayList<>();
        void suscribir(Runnable r) { oyentes.add(r); }
        // Sin un desuscribir(), cada oyente (y todo lo que capture: la vista entera,
        // la sesión, el contexto) vive tanto como el publicador.
        // ✅ Solución: devolver un objeto de cancelación, o guardar WeakReference.
    }

    // ---------- FUGA 4: ThreadLocal en un pool de hilos ----------
    static final ThreadLocal<byte[]> CONTEXTO = new ThreadLocal<>();
    static void manejarPeticion() {
        CONTEXTO.set(new byte[10 * 1024 * 1024]);
        try { /* ... trabajo ... */ }
        finally { CONTEXTO.remove(); }     // ✅ IMPRESCINDIBLE
        // ❌ Sin el remove(), el valor vive mientras viva el HILO. En un pool los hilos
        //    no mueren nunca: 200 hilos × 10 MB = 2 GB retenidos para siempre.
        //    Es la causa del famoso aviso de Tomcat al redesplegar:
        //    "The web application created a ThreadLocal but failed to remove it".
    }

    // ---------- FUGA 5: clave mutable o sin equals/hashCode en un HashMap ----------
    static class ClaveMutable {
        int id;
        @Override public int hashCode() { return id; }
        @Override public boolean equals(Object o) { return o instanceof ClaveMutable c && c.id == id; }
    }
    static void fuga5() {
        var mapa = new HashMap<ClaveMutable, String>();
        var k = new ClaveMutable(); k.id = 1;
        mapa.put(k, "valor");
        k.id = 2;                          // el hash cambia: la entrada es INALCANZABLE
        System.out.println(mapa.get(k));    // null, pero la entrada sigue ocupando memoria
        System.out.println(mapa.size());    // 1: no se puede recuperar ni borrar
    }
    // Bonus: un ExecutorService sin shutdown() impide que la JVM termine y retiene
    // todas las tareas encoladas. Ciérralo siempre en un finally o con un @PreDestroy.

    public static void main(String[] args) { fuga5(); manejarPeticion(); }
}
Cómo se encuentra una fuga en la práctica, en orden: 1) Confirma la tendencia con métricas (jvm_memory_used_bytes en Micrometer/Prometheus): si el heap tras cada GC completo sube monótonamente durante horas, es una fuga; si sube y baja, es carga. 2) jcmd <pid> GC.class_histogram dos veces separadas por 30 minutos y compara: la clase que crece es tu pista. 3) Volcado (GC.heap_dump) y ábrelo con Eclipse MAT: el informe Leak Suspects suele señalar el culpable directamente, y con Path to GC Roots ves exactamente quién retiene los objetos. 4) Si la memoria crece pero el heap no, es nativa: NativeMemoryTracking y JFR.

13.7 El JIT: por qué tu código es 30 veces más rápido al minuto

Ejecuciones     Nivel        Qué hace
─────────────────────────────────────────────────────────────────────────────
0 – ~200        Nivel 0      INTÉRPRETE: lee bytecode y lo ejecuta. Lento, pero
                             arranca al instante y recoge contadores y perfil.
~200 – ~10.000  Nivel 1-3    C1 (client compiler): compila rápido, optimiza poco.
                             Sigue instrumentando para el perfil.
> ~10.000       Nivel 4      C2 (server compiler): compilación agresiva usando el
                             perfil recogido. Es donde está el rendimiento real.

Optimizaciones que hace C2 y que explican los resultados "imposibles":
  · INLINING          copia el cuerpo del método en el llamante. Es la optimización
                      madre: habilita a todas las demás. Los getters desaparecen.
  · ESCAPE ANALYSIS   si el objeto no sale del método, no se asigna: sus campos van
                      a registros (scalar replacement). El new desaparece.
  · DEVIRTUALIZACIÓN  si en la práctica solo se ha visto UNA implementación de la
                      interfaz, la llamada se convierte en directa y se puede inline.
  · LOOP UNROLLING, vectorización (SIMD), eliminación de comprobaciones de rango,
    propagación de constantes, eliminación de código muerto y de bloqueos.

  · DEOPTIMIZACIÓN    Si una suposición se rompe (aparece una segunda implementación,
                      salta una rama nunca vista), C2 descarta el código compilado y
                      vuelve al intérprete. Por eso el rendimiento puede EMPEORAR
                      de golpe en producción cuando llega un tipo de tráfico nuevo.

CONSECUENCIA PRÁCTICA: los primeros segundos de una JVM son lentos (warm-up).
Un test que mide 1000 iteraciones mide el intérprete, no tu algoritmo.
# Ver qué compila el JIT y qué inline hace
java -XX:+PrintCompilation MiApp                       # una línea por compilación
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MiApp
java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=jit.log MiApp

# Arranque más rápido sin esperar al warm-up:
java -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -jar app.jar   # solo C1: arranca antes
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar                 # AppCDS: precarga clases
java -XX:SharedArchiveFile=app.jsa -jar app.jar                    # 20-40 % menos de arranque

13.8 Cómo medir de verdad: JMH y por qué no vale nanoTime

// ❌ EL MICROBENCHMARK QUE MIENTE. Este código no mide lo que crees.
public class MedirMal {
    public static void main(String[] args) {
        long t0 = System.nanoTime();
        long suma = 0;
        for (int i = 0; i < 1_000_000; i++) suma += i * 2;
        long t1 = System.nanoTime();
        System.out.println("Tardó " + (t1 - t0) / 1_000_000 + " ms, suma=" + suma);
    }
}
// Los cinco motivos por los que el número es inútil:
//  1. WARM-UP: en el primer millón de iteraciones el JIT aún no ha terminado su trabajo.
//  2. DEAD CODE ELIMINATION: si no usas el resultado, C2 borra el bucle entero.
//     Un bucle "de 3 nanosegundos" suele significar que no hay bucle.
//  3. CONSTANT FOLDING: si las entradas son constantes, el compilador precalcula todo.
//  4. GC: una pausa a mitad de la medición te añade 50 ms de ruido.
//  5. UNA SOLA MUESTRA: sin repeticiones no hay varianza, y sin varianza no hay conclusión.
// ✅ JMH: la única forma seria de medir en la JVM. Está hecho por el equipo de HotSpot.
// Dependencia: org.openjdk.jmh:jmh-core + jmh-generator-annprocess (scope provided)
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)        // 5 iteraciones para que el JIT haga su trabajo
@Measurement(iterations = 10, time = 1)  // 10 mediciones reales
@Fork(value = 2, jvmArgs = {"-Xms1g", "-Xmx1g"})   // JVM nueva: aísla el perfil del JIT
public class ComparativaConcatenacion {

    @Param({"10", "1000"})
    private int n;

    private String[] datos;

    @Setup(Level.Trial)
    public void preparar() {
        datos = new String[n];
        for (int i = 0; i < n; i++) datos[i] = "elemento-" + i;
    }

    @Benchmark
    public String concatenarConMas() {
        String r = "";
        for (String s : datos) r += s;       // O(n²): copia todo en cada vuelta
        return r;                            // devolver el valor evita que se elimine el código
    }

    @Benchmark
    public String conStringBuilder() {
        var sb = new StringBuilder();
        for (String s : datos) sb.append(s);
        return sb.toString();
    }

    @Benchmark
    public String conStringJoin() { return String.join("", datos); }

    // Cuando produces varios valores y no puedes devolverlos todos, usa el Blackhole:
    // así JMH garantiza que el compilador no elimine el cálculo.
    @Benchmark
    public void variosResultados(Blackhole bh) {
        for (String s : datos) bh.consume(s.length());
    }
}
// mvn clean package && java -jar target/benchmarks.jar -prof gc
// El perfilador -prof gc te da bytes asignados por operación: muchas veces es la
// métrica que de verdad explica la diferencia.
Y aun con JMH: un microbenchmark mide un método aislado, no tu sistema. Antes de optimizar, mide dónde se va el tiempo con un perfilador de verdad (JFR, async-profiler) sobre carga realista. La mayoría de los problemas de rendimiento de aplicaciones Java no están en el código Java: están en consultas N+1, en llamadas de red en serie, en pools mal dimensionados y en serialización. Optimizar un bucle que consume el 0,3 % del tiempo es trabajo perdido, y en una entrevista mencionar este orden de prioridades vale más que recitar flags.

Comprobación rápida de la sección 13

14 · Patrones de diseño en Java moderno

Los patrones del GoF se escribieron en 1994, para un lenguaje sin lambdas, sin records ni sealed. Muchos siguen siendo válidos, pero la implementación ha cambiado: lo que antes eran tres clases y una interfaz hoy es a menudo una lambda. En una entrevista no basta con recitar el diagrama: se valora que sepas cuál usar, cómo se escribe hoy y cuándo no aplicarlo.

14.1 Singleton: enum, y por qué

// ---------- ✅ LA FORMA CORRECTA: un enum de una sola constante ----------
public enum ConfiguracionApp {
    INSTANCIA;

    private final java.util.Properties props = cargar();

    private java.util.Properties cargar() {
        var p = new java.util.Properties();
        try (var in = ConfiguracionApp.class.getResourceAsStream("/app.properties")) {
            if (in != null) p.load(in);
        } catch (java.io.IOException e) {
            throw new IllegalStateException("No se pudo cargar la configuración", e);
        }
        return p;
    }
    public String get(String clave) { return props.getProperty(clave); }
}
// Por qué el enum gana a todas las alternativas:
//   · La JVM garantiza UNA instancia, con inicialización perezosa y segura entre hilos, gratis.
//   · Es inmune a la reflexión: newInstance() sobre un enum lanza excepción.
//   · Es inmune a la serialización: deserializar devuelve la MISMA constante,
//     mientras que un singleton con readObject se puede duplicar.
//   · Cero líneas de sincronización. Cero errores posibles.

// ---------- La versión clásica, para saber leerla ----------
final class SingletonHolder {
    private SingletonHolder() { }
    // Idiom "initialization-on-demand holder": perezoso y seguro sin synchronized,
    // porque la JVM ya garantiza la seguridad de la inicialización de clases.
    private static class Holder { static final SingletonHolder INSTANCIA = new SingletonHolder(); }
    static SingletonHolder getInstance() { return Holder.INSTANCIA; }
}

// ---------- ❌ El doble bloqueo comprobado, que se sigue preguntando ----------
final class DobleBloqueo {
    private static volatile DobleBloqueo instancia;   // volatile es OBLIGATORIO
    private DobleBloqueo() { }
    static DobleBloqueo getInstance() {
        DobleBloqueo local = instancia;               // una sola lectura volátil
        if (local == null) {
            synchronized (DobleBloqueo.class) {
                local = instancia;
                if (local == null) instancia = local = new DobleBloqueo();
            }
        }
        return local;
    }
    // Sin volatile, otro hilo puede ver la referencia NO NULA pero el objeto a medio
    // construir, porque el compilador puede reordenar la asignación y la inicialización.
    // Fue un bug real y sutil durante años. Hoy: usa el enum y olvídate.
}
Y la pregunta importante: ¿lo necesitas? Un singleton es estado global: complica los test (no puedes sustituirlo), oculta dependencias (nadie ve en la firma que un método usa la configuración) y crea acoplamiento. En una aplicación con Spring, el contenedor ya te da instancias únicas con @Component, pero inyectadas, sustituibles en los test y sin estado global. Usa el singleton a mano solo en utilidades sin dependencias o en código sin contenedor.

14.2 Factory y Builder

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;

// ══════════════════ FACTORÍA ESTÁTICA: nombres que explican la intención ══════════════════
public final class Importe {
    private final BigDecimal valor;
    private final Currency moneda;

    private Importe(BigDecimal valor, Currency moneda) { this.valor = valor; this.moneda = moneda; }

    // Los constructores no tienen nombre; las factorías sí. Compara:
    //    new Importe(v, m)  frente a  Importe.deEuros("12.50")
    public static Importe deEuros(String cantidad) {
        return new Importe(new BigDecimal(cantidad), Currency.getInstance("EUR"));
    }
    public static Importe cero(Currency moneda) { return new Importe(BigDecimal.ZERO, moneda); }
    public static Importe deCentimos(long centimos, Currency m) {
        return new Importe(BigDecimal.valueOf(centimos, m.getDefaultFractionDigits()), m);
    }
    // Ventajas sobre el constructor:
    //   1. Nombre significativo (y puedes tener dos con la misma firma).
    //   2. Puede devolver una instancia CACHEADA (Integer.valueOf, List.of).
    //   3. Puede devolver un SUBTIPO sin que el llamante lo sepa.
    //   4. Puede devolver Optional o lanzar con un mensaje de dominio.
    @Override public String toString() { return valor + " " + moneda.getCurrencyCode(); }
}

// ══════════════════ FACTORÍA POLIMÓRFICA con enum + sealed ══════════════════
sealed interface Notificacion permits Email, Sms, Push { void enviar(String destino, String texto); }
record Email() implements Notificacion { public void enviar(String d, String t) { System.out.println("email→" + d); } }
record Sms() implements Notificacion { public void enviar(String d, String t) { System.out.println("sms→" + d); } }
record Push() implements Notificacion { public void enviar(String d, String t) { System.out.println("push→" + d); } }

enum Canal {
    EMAIL(new Email()), SMS(new Sms()), PUSH(new Push());
    private final Notificacion impl;
    Canal(Notificacion impl) { this.impl = impl; }
    Notificacion crear() { return impl; }
    // Sin if/switch encadenados y sin poder olvidarse de un caso: el enum es la tabla.
}

// ══════════════════ BUILDER con record: valida una sola vez, al final ══════════════════
record Pedido(String id, String cliente, LocalDate fecha, List<String> lineas,
              BigDecimal total, String cupon) {

    Pedido {   // constructor compacto: el punto único donde se valida el invariante
        Objects.requireNonNull(id); Objects.requireNonNull(cliente);
        Objects.requireNonNull(fecha); Objects.requireNonNull(total);
        lineas = List.copyOf(lineas);                    // copia defensiva
        if (lineas.isEmpty()) throw new IllegalArgumentException("un pedido sin líneas no existe");
        if (total.signum() < 0) throw new IllegalArgumentException("total negativo: " + total);
    }

    static Builder builder() { return new Builder(); }

    static final class Builder {
        private String id; private String cliente; private LocalDate fecha = LocalDate.now();
        private final List<String> lineas = new ArrayList<>();
        private BigDecimal total = BigDecimal.ZERO; private String cupon;

        Builder id(String v) { this.id = v; return this; }
        Builder cliente(String v) { this.cliente = v; return this; }
        Builder fecha(LocalDate v) { this.fecha = v; return this; }
        Builder linea(String sku, BigDecimal precio) {
            lineas.add(sku); total = total.add(precio); return this;
        }
        Builder cupon(String v) { this.cupon = v; return this; }
        Pedido build() { return new Pedido(id, cliente, fecha, lineas, total, cupon); }
        // El builder acumula sin validar; el record valida al construir. Así el objeto
        // final es SIEMPRE válido e inmutable, y nunca existe un Pedido a medio montar.
    }

    /** Modificación de un inmutable: devuelve una copia. El "wither". */
    Pedido conCupon(String nuevo) {
        return new Pedido(id, cliente, fecha, lineas, total, nuevo);
    }
}

class DemoCreacion {
    public static void main(String[] args) {
        System.out.println(Importe.deEuros("12.50"));
        System.out.println(Importe.deCentimos(1250, Currency.getInstance("EUR")));
        Canal.SMS.crear().enviar("+34600000000", "hola");

        var p = Pedido.builder()
                .id("P-1").cliente("ana")
                .linea("SKU-1", new BigDecimal("60.25"))
                .linea("SKU-2", new BigDecimal("60.25"))
                .build();
        System.out.println(p);
        System.out.println(p.conCupon("VERANO26").cupon());
    }
}
Cuándo un builder y cuándo no. Con dos o tres parámetros, un constructor o una factoría es más claro. El builder gana a partir de cuatro o cinco parámetros, o cuando hay muchos opcionales y quieres evitar el «constructor telescópico» (seis constructores solapados) y el error de intercambiar dos String consecutivos, que compila perfectamente y falla en producción. En proyectos con Lombok, @Builder hace lo mismo; el patrón sigue siendo el que ves arriba.

14.3 Strategy con lambdas y Template Method

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.function.*;

public class EstrategiaYPlantilla {

    // ══════════════ STRATEGY: antes eran 4 clases; hoy es una interfaz funcional ══════════════
    @FunctionalInterface
    interface Descuento extends UnaryOperator<BigDecimal> {
        static Descuento ninguno() { return b -> b; }
        static Descuento porcentaje(int pct) {
            return b -> b.multiply(BigDecimal.valueOf(100 - pct))
                         .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
        }
        static Descuento fijo(BigDecimal cantidad) {
            return b -> b.subtract(cantidad).max(BigDecimal.ZERO);
        }
        /** Composición: las estrategias se combinan, algo que con clases costaba un decorador. */
        default Descuento y(Descuento siguiente) { return b -> siguiente.apply(this.apply(b)); }
    }

    static final Map<String, Descuento> CUPONES = Map.of(
            "VERANO26", Descuento.porcentaje(20),
            "BIENVENIDA", Descuento.fijo(new BigDecimal("5.00")),
            "COMBO", Descuento.porcentaje(10).y(Descuento.fijo(new BigDecimal("2.00")))
    );
    // El "switch gigante" que crece con cada cupón desaparece: es una entrada en un mapa.
    // Y en Spring, este mapa lo construye el contenedor con Map<String, Descuento> inyectado.

    // ══════════════ TEMPLATE METHOD: el esqueleto fijo, los huecos variables ══════════════
    abstract static class ProcesoDeImportacion {
        /** final: el orden de los pasos es la razón de ser de la clase. */
        public final Resumen ejecutar(java.nio.file.Path fichero) {
            var registros = leer(fichero);
            var validos = new ArrayList<String>();
            var errores = new ArrayList<String>();
            for (String r : registros) {
                try { validar(r); validos.add(transformar(r)); }
                catch (RuntimeException e) { errores.add(r + " → " + e.getMessage()); }
            }
            guardar(validos);
            alTerminar(validos.size(), errores.size());     // gancho opcional
            return new Resumen(validos.size(), errores);
        }
        protected abstract List<String> leer(java.nio.file.Path f);
        protected abstract void validar(String registro);
        protected String transformar(String registro) { return registro; }   // implementación por omisión
        protected abstract void guardar(List<String> datos);
        protected void alTerminar(int ok, int fallos) { }                    // gancho vacío
    }
    record Resumen(int procesados, List<String> errores) { }

    static class ImportarClientesCsv extends ProcesoDeImportacion {
        protected List<String> leer(java.nio.file.Path f) { return List.of("ana;30", "bob;abc", "eva;41"); }
        protected void validar(String r) {
            var partes = r.split(";");
            if (partes.length != 2) throw new IllegalArgumentException("se esperaban 2 campos");
            Integer.parseInt(partes[1]);        // lanza NumberFormatException si no es un número
        }
        protected String transformar(String r) { return r.toUpperCase(); }
        protected void guardar(List<String> datos) { System.out.println("guardando " + datos); }
        protected void alTerminar(int ok, int fallos) { System.out.println(ok + " ok, " + fallos + " fallos"); }
    }

    // ⚠️ Alternativa moderna sin herencia: el mismo esqueleto recibiendo funciones.
    //    Preferible cuando no necesitas estado compartido ni una jerarquía.
    static <T, R> List<R> procesar(List<T> entrada, Predicate<T> valido, Function<T, R> mapa,
                                   Consumer<List<R>> persistir) {
        var salida = entrada.stream().filter(valido).map(mapa).toList();
        persistir.accept(salida);
        return salida;
    }

    public static void main(String[] args) {
        var base = new BigDecimal("100.00");
        CUPONES.forEach((k, d) -> System.out.println(k + " → " + d.apply(base)));
        System.out.println(new ImportarClientesCsv().ejecutar(java.nio.file.Path.of("x.csv")));
        System.out.println(procesar(List.of(1, 2, 3, 4), n -> n % 2 == 0, n -> "n" + n, System.out::println));
    }
}

14.4 Decorator, Adapter y Observer

import java.time.Duration;
import java.util.*;
import java.util.function.*;

public class OtrosPatrones {

    // ══════════════ DECORATOR: añadir comportamiento sin tocar la clase ni heredar ══════════════
    interface RepositorioPrecios { Optional<Double> precio(String sku); }

    record RepositorioReal() implements RepositorioPrecios {
        public Optional<Double> precio(String sku) {
            System.out.println("  [consulta real a la BD: " + sku + "]");
            return Optional.of(sku.length() * 10.0);
        }
    }

    /** Cada decorador hace UNA cosa y envuelve al siguiente. Se combinan en cualquier orden. */
    record ConCache(RepositorioPrecios origen, Map<String, Double> cache) implements RepositorioPrecios {
        ConCache(RepositorioPrecios origen) { this(origen, new HashMap<>()); }
        public Optional<Double> precio(String sku) {
            return Optional.ofNullable(cache.computeIfAbsent(sku, k -> origen.precio(k).orElse(null)));
        }
    }
    record ConMetricas(RepositorioPrecios origen, String nombre) implements RepositorioPrecios {
        public Optional<Double> precio(String sku) {
            long t0 = System.nanoTime();
            try { return origen.precio(sku); }
            finally { System.out.println("  [" + nombre + " tardó " + Duration.ofNanos(System.nanoTime() - t0).toMillis() + " ms]"); }
        }
    }
    record ConReintento(RepositorioPrecios origen, int intentos) implements RepositorioPrecios {
        public Optional<Double> precio(String sku) {
            RuntimeException ultimo = null;
            for (int i = 0; i < intentos; i++) {
                try { return origen.precio(sku); }
                catch (RuntimeException e) { ultimo = e; }
            }
            throw ultimo;
        }
    }
    // Composición: métricas(caché(reintento(real))). Esto es exactamente lo que hacen
    // los proxies de Spring con @Cacheable, @Transactional, @Retryable y @Timed:
    // decoradores generados en tiempo de ejecución. Por eso una llamada interna
    // (this.metodo()) NO pasa por el proxy y la anotación no surte efecto.

    // ══════════════ ADAPTER: encajar una API ajena en tu interfaz ══════════════
    /** Biblioteca externa que no puedes modificar y cuya firma no te gusta. */
    static class ClienteSmsExterno {
        void send(String to, String body, boolean urgent) { System.out.println("SMS a " + to); }
    }
    interface Notificador { void notificar(String destino, String mensaje); }

    /** El adaptador aísla tu dominio de la biblioteca: si mañana la cambias, tocas una clase. */
    record AdaptadorSms(ClienteSmsExterno externo) implements Notificador {
        public void notificar(String destino, String mensaje) {
            externo.send(destino, mensaje.length() > 160 ? mensaje.substring(0, 160) : mensaje, false);
        }
    }

    // ══════════════ OBSERVER: publicar eventos sin acoplar al que los consume ══════════════
    sealed interface Evento permits PedidoCreado, PedidoPagado { String pedidoId(); }
    record PedidoCreado(String pedidoId, double total) implements Evento { }
    record PedidoPagado(String pedidoId, String metodo) implements Evento { }

    static final class Bus {
        private final Map<Class<?>, List<Consumer<Evento>>> oyentes = new HashMap<>();

        <E extends Evento> AutoCloseable suscribir(Class<E> tipo, Consumer<E> oyente) {
            @SuppressWarnings("unchecked") Consumer<Evento> c = (Consumer<Evento>) oyente;
            oyentes.computeIfAbsent(tipo, k -> new ArrayList<>()).add(c);
            // ✅ Devolver un objeto de cancelación evita la fuga clásica del observador
            //    registrado para siempre (§13.6, fuga 3).
            return () -> oyentes.get(tipo).remove(c);
        }

        void publicar(Evento e) {
            for (var o : oyentes.getOrDefault(e.getClass(), List.of())) {
                try { o.accept(e); }
                catch (RuntimeException ex) {
                    // Un oyente que falla no debe impedir que se avise a los demás.
                    System.out.println("  oyente falló: " + ex.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) throws Exception {
        RepositorioPrecios repo = new ConMetricas(new ConCache(new RepositorioReal()), "precios");
        System.out.println(repo.precio("SKU-1"));
        System.out.println(repo.precio("SKU-1"));      // segunda vez: sin consulta real

        new AdaptadorSms(new ClienteSmsExterno()).notificar("+34600", "hola");

        var bus = new Bus();
        try (var s = bus.suscribir(PedidoCreado.class, e -> System.out.println("email por " + e.pedidoId()))) {
            bus.suscribir(PedidoCreado.class, e -> { throw new IllegalStateException("almacén caído"); });
            bus.suscribir(PedidoPagado.class, e -> System.out.println("factura de " + e.pedidoId()));
            bus.publicar(new PedidoCreado("P-1", 120.5));
            bus.publicar(new PedidoPagado("P-1", "tarjeta"));
        }
        bus.publicar(new PedidoCreado("P-2", 10));      // ya no hay oyente de email
    }
}

14.5 Inyección de dependencias a mano (y por qué entenderla)

import java.util.List;
import java.util.Optional;

public class InyeccionManual {

    // ---------- Puertos: interfaces que define el DOMINIO, no la infraestructura ----------
    interface RepositorioPedidos { Optional<String> buscar(String id); void guardar(String p); }
    interface PasarelaPago { boolean cobrar(String pedido, double importe); }
    interface Reloj { java.time.Instant ahora(); }

    // ---------- El caso de uso: NO sabe de bases de datos, HTTP ni Spring ----------
    static final class ConfirmarPedido {
        private final RepositorioPedidos repo;
        private final PasarelaPago pago;
        private final Reloj reloj;

        // ✅ Inyección por CONSTRUCTOR, campos final:
        //    · imposible construir el objeto sin sus dependencias
        //    · el objeto es inmutable y seguro entre hilos
        //    · las dependencias son VISIBLES en la firma: si son ocho, el diseño chilla
        //    · se puede instanciar en un test sin ningún framework, con dobles
        ConfirmarPedido(RepositorioPedidos repo, PasarelaPago pago, Reloj reloj) {
            this.repo = java.util.Objects.requireNonNull(repo);
            this.pago = java.util.Objects.requireNonNull(pago);
            this.reloj = java.util.Objects.requireNonNull(reloj);
        }

        String ejecutar(String pedidoId, double importe) {
            String pedido = repo.buscar(pedidoId)
                    .orElseThrow(() -> new IllegalArgumentException("pedido inexistente: " + pedidoId));
            if (!pago.cobrar(pedido, importe)) throw new IllegalStateException("pago rechazado");
            repo.guardar(pedido + "|confirmado|" + reloj.ahora());
            return pedido;
        }
    }

    // ---------- El "contenedor": la raíz de composición, en un solo sitio ----------
    public static void main(String[] args) {
        RepositorioPedidos repo = new RepositorioPedidos() {
            private final List<String> datos = new java.util.ArrayList<>(List.of("P-1"));
            public Optional<String> buscar(String id) { return datos.stream().filter(id::equals).findFirst(); }
            public void guardar(String p) { System.out.println("guardado: " + p); }
        };
        PasarelaPago pago = (p, i) -> { System.out.println("cobrando " + i); return true; };
        Reloj reloj = java.time.Instant::now;      // en los test: () -> Instant.parse("...")

        var caso = new ConfirmarPedido(repo, pago, reloj);
        System.out.println(caso.ejecutar("P-1", 120.50));
    }
    // Esto es TODO lo que hace Spring, automatizado: construir el grafo de objetos
    // y pasar las dependencias. Entender esto explica por qué se prefiere la inyección
    // por constructor a @Autowired sobre el campo (que además impide los campos final
    // y permite crear objetos en estado inválido).
}
Cuándo NO usar un patrón. El sobrediseño cuesta más que el infradiseño porque es más difícil de deshacer. Señales de alarma concretas: una interfaz con una sola implementación que no es un puerto ni se usa en los test; una AbstractFactoryProviderStrategy; un builder para un objeto de dos campos; un bus de eventos para dos llamadas síncronas; cinco niveles de abstracción para leer una fila de una tabla. Un patrón resuelve un problema de cambio previsible. Si el cambio no está a la vista, escribe lo simple y refactoriza cuando el segundo caso aparezca: entonces sabrás cuál es el eje de variación real, y no lo estarás adivinando.
PatrónProblema que resuelveCómo se escribe hoyEn el JDK / Spring
SingletonUna única instanciaenum de una constante@Component (ámbito por omisión)
Factory MethodCrear sin acoplarse a la clase concretaFactoría estática con nombreList.of, Optional.of, Integer.valueOf
BuilderMuchos parámetros opcionalesBuilder + record que validaStream.Builder, HttpRequest.newBuilder()
StrategyIntercambiar un algoritmoInterfaz funcional + lambdaComparator, RejectedExecutionHandler
Template MethodEsqueleto fijo con huecosClase abstracta, o funciones como parámetroAbstractList, JdbcTemplate
DecoratorAñadir comportamiento apilableRecord que envuelve la interfazBufferedInputStream, proxies de @Transactional
AdapterEncajar una API ajenaClase o record finoArrays.asList, Collections.enumeration
ObserverNotificar sin acoplarConsumer + registro cancelableApplicationEventPublisher, Flow.Subscriber
VisitorOperar sobre una jerarquía cerradasealed + switch con patronesYa no hace falta el patrón: el lenguaje lo cubre
CommandEncapsular una acciónRunnable / CallableExecutorService.submit
IteratorRecorrer sin exponer la estructuraIterable (for mejorado)Toda la API de colecciones
ProxyInterponer control de acceso o cachéjava.lang.reflect.Proxy, decoradoresAOP de Spring, repositorios de Data JPA

15 · Código limpio y prácticas profesionales

Esto no es estética. El código se lee diez veces más de lo que se escribe, y la mayor parte del coste de un sistema está en modificarlo después. En una entrevista técnica, la conversación sobre estas prácticas suele pesar tanto como el ejercicio de código.

15.1 Nombres, funciones y argumentos

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public class NombresYFunciones {

    // ══════════════ ❌ ANTES: un método que hace de todo, con nombres opacos ══════════════
    public static BigDecimal proc(List<Object[]> d, int t, boolean f, boolean g, String c) {
        BigDecimal r = BigDecimal.ZERO;
        for (Object[] x : d) {
            if (t == 1 && !f) continue;                       // ¿qué es t == 1?
            if ((boolean) x[3] && g) continue;                 // ¿x[3]?
            BigDecimal p = (BigDecimal) x[1];
            int q = (int) x[2];
            BigDecimal s = p.multiply(BigDecimal.valueOf(q));
            if (c != null && c.equals("V")) s = s.multiply(new BigDecimal("0.8"));  // 0.8 = ?
            r = r.add(s);
        }
        return r;
    }
    // Problemas: nombre sin significado, Object[] como estructura, dos booleanos seguidos
    // (¿proc(d, 1, true, false, "V")? imposible de leer en la llamada), números mágicos,
    // y cinco responsabilidades en veinte líneas.

    // ══════════════ ✅ DESPUÉS ══════════════
    record Linea(String sku, BigDecimal precioUnitario, int cantidad, boolean cancelada) {
        BigDecimal subtotal() { return precioUnitario.multiply(BigDecimal.valueOf(cantidad)); }
    }
    enum Cupon {
        NINGUNO(BigDecimal.ONE), VERANO(new BigDecimal("0.80")), BLACK_FRIDAY(new BigDecimal("0.70"));
        private final BigDecimal factor;
        Cupon(BigDecimal factor) { this.factor = factor; }
        BigDecimal aplicarA(BigDecimal importe) { return importe.multiply(factor); }
    }
    /** Un objeto de parámetros en lugar de una lista de banderas. */
    record OpcionesDeCalculo(boolean incluirCanceladas, Cupon cupon) {
        static OpcionesDeCalculo porOmision() { return new OpcionesDeCalculo(false, Cupon.NINGUNO); }
    }

    /** Hace UNA cosa, y el nombre lo dice. Cuatro líneas, cero comentarios necesarios. */
    static BigDecimal calcularTotal(List<Linea> lineas, OpcionesDeCalculo opciones) {
        return lineas.stream()
                .filter(l -> opciones.incluirCanceladas() || !l.cancelada())
                .map(Linea::subtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add)
                .let(opciones.cupon()::aplicarA);      // (ilustrativo; en Java: cupon.aplicarA(...))
    }
}
ReglaPor quéSeñal de que la incumples
Nombres que revelan la intenciónEl nombre es la documentación que nunca se desactualiza.data, info, manager, tmp, proc, flag.
Un nivel de abstracción por métodoMezclar «calcular el IVA» con «abrir una conexión» obliga a cambiar de contexto al leer.El método tiene un try de SQL y reglas de negocio a la vez.
Métodos cortosLo que no cabe en una pantalla no se puede razonar de un vistazo.Hay que hacer scroll, o hay comentarios que dividen el método en «secciones».
Máximo 3 parámetrosMás de tres son imposibles de recordar en el orden correcto.Dos parámetros del mismo tipo seguidos: copiar(origen, destino) invertido compila.
Nada de parámetros booleanosguardar(pedido, true, false) no significa nada en el punto de llamada.Tienes que abrir la firma para entender una llamada.
Sin números ni cadenas mágicosUn 0.8 suelto es indescifrable y se duplica.if (estado == 3). Usa un enum.
Devuelve prontoLas guardas al principio evitan cinco niveles de if anidados.Indentación de más de tres niveles.
Sin efectos secundarios ocultosUn método llamado validar que además guarda es una trampa.El nombre es un verbo de consulta pero modifica estado.

15.2 Comentarios que valen, inmutabilidad y nulos

import java.math.BigDecimal;
import java.util.*;

public class ComentariosYDefensa {

    // ❌ Comentarios que son ruido: repiten el código y envejecen mal
    // Incrementa el contador
    // private int contador = 0;
    // /** @param id el id */  ← no aporta nada
    // // 2019-04-12 Juan: arreglado el bug de las fechas  ← eso lo dice git

    /**
     * ✅ Comentarios que sí valen: explican el POR QUÉ, no el QUÉ.
     *
     * Redondeamos con HALF_EVEN y no con HALF_UP porque es lo que exige la norma
     * contable del cliente (documento FIN-2024-07, sección 4.2) y porque evita el
     * sesgo acumulado al alza en lotes de millones de líneas.
     */
    static BigDecimal redondear(BigDecimal v) { return v.setScale(2, java.math.RoundingMode.HALF_EVEN); }

    /**
     * ✅ Advertencia sobre una decisión no obvia:
     * NO uses un HashMap aquí: el orden de inserción forma parte del contrato del
     * informe que consume Hacienda. Cambiarlo rompe la validación del fichero.
     */
    private final Map<String, BigDecimal> totalesPorConcepto = new LinkedHashMap<>();

    // ══════════════ Inmutabilidad por omisión ══════════════
    // final en todos los campos y variables locales que no cambian. No es ceremonia:
    // es una afirmación verificada por el compilador de que ese valor no cambia,
    // y elimina de golpe una clase entera de bugs de concurrencia.
    private final String id;
    private final List<String> etiquetas;

    ComentariosYDefensa(String id, List<String> etiquetas) {
        this.id = Objects.requireNonNull(id, "id");
        this.etiquetas = List.copyOf(etiquetas);       // copia defensiva a la ENTRADA
    }
    public List<String> etiquetas() { return etiquetas; }   // inmutable: no hace falta copiar

    // ══════════════ Defensa en los límites, confianza dentro ══════════════
    // Valida en la FRONTERA (controlador, constructor de la entidad, adaptador) y
    // asume que dentro del núcleo los datos ya son válidos. Validar en cada capa
    // triplica el código y da falsa seguridad.

    // ══════════════ El manejo de nulos que funciona ══════════════
    // 1. Nunca devuelvas null en una colección: devuelve List.of().
    static List<String> buscar(String q) { return q == null ? List.of() : List.of(q); }
    // 2. Optional como valor de retorno cuando la ausencia es normal (§8).
    // 3. Objects.requireNonNull en los constructores: falla en el sitio del error,
    //    no tres capas más adentro con un NPE incomprensible.
    // 4. Anota la intención: @Nullable / @NonNull (jspecify, o las de Spring) para que
    //    el IDE y el análisis estático avisen antes de compilar.
    // 5. Nunca uses null como "valor especial" en un mapa o una lista.

    public static void main(String[] args) {
        System.out.println(redondear(new BigDecimal("2.345")) + " " + buscar(null));
    }
}

15.3 Logging correcto con SLF4J

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

public class LoggingCorrecto {

    // ✅ private static final, y la clase actual. Ni public, ni de instancia.
    private static final Logger log = LoggerFactory.getLogger(LoggingCorrecto.class);

    void procesar(String pedidoId, int lineas) {
        // ❌ CONCATENAR: construye la cadena SIEMPRE, aunque el nivel DEBUG esté desactivado.
        //    Con 50.000 peticiones por segundo esto es basura para el GC a cambio de nada.
        log.debug("Procesando pedido " + pedidoId + " con " + lineas + " líneas");

        // ✅ PARÁMETROS {}: la cadena se construye solo si el nivel está activo.
        log.debug("Procesando pedido {} con {} líneas", pedidoId, lineas);

        // ✅ Si el argumento es CARO de calcular, usa un supplier o comprueba el nivel.
        if (log.isTraceEnabled()) log.trace("Detalle completo: {}", calcularDetalleCaro());

        try {
            arriesgado();
        } catch (Exception e) {
            // ✅ La excepción va como ÚLTIMO argumento, SIN {} y SIN e.getMessage():
            //    así se registra la traza completa con todas las causas.
            log.error("Fallo procesando el pedido {}", pedidoId, e);
            // ❌ log.error("Fallo: " + e.getMessage());        pierde la traza
            // ❌ log.error("Fallo {}", pedidoId, e.getMessage()); pierde la traza
            // ❌ e.printStackTrace();                          no llega al agregador
            throw new IllegalStateException("pedido " + pedidoId, e);
            // ⚠️ Y ahora NO registres otra vez más arriba: un fallo, una entrada de log.
        }
    }

    /** MDC: contexto que se añade a TODAS las líneas del hilo. Es lo que hace útiles los logs. */
    void conContexto(String peticionId, String usuario) {
        MDC.put("peticionId", peticionId);
        MDC.put("usuario", usuario);
        try {
            log.info("Inicio");        // saldrá con peticionId y usuario en el JSON
            procesar("P-1", 3);
        } finally {
            MDC.clear();               // IMPRESCINDIBLE en un pool de hilos (§13.6, fuga 4)
        }
    }

    private String calcularDetalleCaro() { return "…"; }
    private void arriesgado() { throw new RuntimeException("boom"); }
}
NivelCuándo¿Despierta a alguien?
ERRORAlgo ha fallado y requiere intervención humana. Datos perdidos, integración caída.Sí. Si no, no es ERROR.
WARNAlgo anómalo pero recuperado: un reintento que funcionó, configuración obsoleta.No, pero se revisa.
INFOHitos del negocio: arranque, pedido confirmado, tarea programada. Poco volumen.No
DEBUGDetalle para diagnosticar. Desactivado en producción, activable sin reiniciar.No
TRACEEntradas y salidas de método, cuerpos de petición. Solo en desarrollo.No
Nunca registres datos personales ni secretos. Contraseñas, tokens, números de tarjeta, DNI, cuerpos completos de peticiones con datos de salud… Los logs se replican, se envían a servicios de terceros y se conservan meses: es una brecha del RGPD por escrito y con marca de tiempo. Registra identificadores (un pedidoId) en lugar de contenidos, y enmascara lo imprescindible (****1234). Y cuidado con toString() de records con campos sensibles: los imprime todos.

15.4 Formateo, análisis estático y revisión

Herramientas que sí cambian las cosas

  • Spotless + google-java-format o palantir: formateo automático en el build. Elimina el 100 % de las discusiones de estilo en las revisiones.
  • ErrorProne (Google): detecta errores reales en tiempo de compilación (equals entre tipos incompatibles, resultado ignorado, Optional.get sin comprobar).
  • SpotBugs + find-sec-bugs: análisis del bytecode; encuentra NPEs potenciales y vulnerabilidades.
  • SonarQube: métricas, duplicación y cobertura con histórico. Útil por la tendencia, no por el número absoluto.
  • NullAway: comprobación de nulidad prácticamente sin coste.
  • OWASP Dependency-Check o Dependabot: CVEs en tus dependencias, que es de donde vienen casi todas las vulnerabilidades reales.
  • ArchUnit: test que verifican la arquitectura («el dominio no importa nada de infraestructura»).
  • JaCoCo: cobertura. Un umbral bajo (60-70 %) que se cumple siempre es mejor que un 90 % que se falsea con test vacíos.

Qué mirar en una revisión de código

  • ¿Resuelve el problema del ticket, y solo ese?
  • ¿Hay un test que falla sin el cambio? Si no, el test no prueba nada.
  • ¿Los nombres explican la intención sin leer el cuerpo?
  • ¿Se validan las entradas en la frontera y se lanzan excepciones con contexto?
  • ¿Se tragan excepciones o se registran varias veces?
  • ¿Hay estado mutable compartido? ¿Está protegido?
  • ¿Consultas dentro de un bucle (N+1)? ¿Recursos sin cerrar?
  • ¿Se filtran datos sensibles por el log o por la respuesta?
  • ¿Es reversible el despliegue? ¿La migración de esquema es compatible hacia atrás?
  • Comenta sobre el código, no sobre la persona; distingue lo bloqueante de la sugerencia («nit:»).

Comprobación rápida de las secciones 14 y 15

16 · Preguntas frecuentes

Batería rápida para comprobar que el módulo quedó asimilado. Si no puedes responder en voz alta, vuelve a la sección citada.

¿Qué idea de este módulo explicaría primero en una entrevista?

La que conecta el problema de negocio con la solución técnica y sus contrapartidas. No recites APIs: cuenta un caso, una decisión y qué descartaste.

¿Cómo sé si lo he entendido de verdad?

Si puedes escribir un ejemplo mínimo de memoria, explicar el fallo típico y decir cuándo no usar la técnica. La checklist del final de cada sección es el listón.

¿Qué debo practicar con teclado y no solo leer?

Todo lo que tenga bloque de código en el módulo: cópialo, rómpelo, mídelo. La lectura sin ejecución no fija el contrato de equals, un plan de ejecución o un probe de Kubernetes.

¿Cómo relaciono este módulo con el proyecto final?

Cada concepto debe aparecer en el repositorio del módulo 13 (Cafetería Tech / MiniShop): un commit, un test o una decisión documentada. Si no aparece, no cuenta como aprendido.

¿Qué preguntas trampa debo anticipar?

Las que piden el por qué y el cuándo no. Prepárate a decir “depende” seguido de dos criterios medibles, no de una preferencia estética.

¿Cuánto tiempo debo dedicarle a este módulo en el plan?

El que indica el badge de la cabecera. Si vas corto de días, prioriza las secciones marcadas como críticas en el índice y los ejercicios numerados; deja el resto para el repaso del fin de semana.

¿Qué hago si un ejemplo no compila con mi versión?

Comprueba Java 21+ y Spring Boot 3.x. Las APIs nuevas (virtual threads, RestClient, ProblemDetail) no están en Java 8 ni en Spring Boot 2. Ajusta o sube versión; no “arregles” degradando el ejemplo.

¿Debo memorizar flags, anotaciones y comandos?

Memoriza el mapa mental y tres ejemplos. Los flags exactos se consultan; lo que se evalúa es saber cuál buscar y por qué lo necesitas.

¿Cómo evito estudiar en modo pasivo?

Cierra el HTML y escribe de memoria: un test, una entidad, un Dockerfile o una respuesta de entrevista de 90 segundos. Luego contrasta. Ese ciclo es el 70 % teclado del plan.

¿Qué enlazo con otros módulos?

Usa el aside y los enlaces internos. Persistencia remite a SQL (06) y a Spring Boot (04); despliegue a microservicios (08) y seguridad (10). No dupliques: profundiza donde el plan te manda.

¿Cómo demuestro esto en el CV o en GitHub?

Con un commit claro, un test que falle sin el arreglo, y una línea en el README del proyecto (“detectamos N+1 / OOMKilled / … y lo medimos”). Evidencia > adjetivos.

Si solo me queda una hora, ¿qué hago?

Lee la sección de errores comunes, responde tres FAQ en voz alta y marca dos ejercicios como hechos solo si los has ejecutado. Mejor poco sólido que mucho subrayado.

17 · Ejercicios y retos

Autoevaluación

18 · Resumen y recursos

Qué debes recordar

  • El por qué manda sobre la lista de APIs.
  • Mide antes de optimizar; los síntomas engañan.
  • Documenta decisiones y contrapartidas en el proyecto.
  • Los tests y la observabilidad cierran el aprendizaje.

Siguiente paso

  • Completa las checklists marcadas arriba.
  • Pasa al módulo siguiente solo con los ejercicios 1–3 hechos.
  • Anota dudas para el simulacro del módulo 12.
Cierre: no intentes dominar todo el módulo de una sentada. Domina el núcleo, demuéstralo con código y vuelve a las secciones avanzadas cuando el proyecto te las exija.