Aquesta és l'última caixa negra del curs.
Portes deu mòduls escrivint codi que funciona sense saber realment on viu. Quan l'ExportadorAnotat de 10-03 guarda la introspecció en un Map, on és aquest mapa i qui l'allibera? Quan un milió de fils virtuals guarden la seva pila al heap, què és el heap exactament i què passa quan s'omple? Quan el banc de proves de 10-04 va donar números completament diferents abans i després de l'escalfament, què estava fent la JVM en aquella estona? Quan WeakHashMap va aparèixer de passada, què significa que una referència sigui "feble"? Per què la primera petició a un servidor Java sempre és la més lenta? I per què una aplicació que porta tres dies funcionant comença de sobte a pausar-se cada pocs segons?
Totes aquestes preguntes tenen la mateixa resposta de fons: la màquina virtual de Java gestiona la memòria per tu, i per escriure codi seriós cal entendre com ho fa.
No per optimitzar microdetalls —això gairebé mai no serveix—, sinó per a tres coses molt concretes: diagnosticar quan alguna cosa va malament, mesurar en lloc de suposar, i prendre decisions informades sobre estructures de dades i cicles de vida d'objectes.
En acabar, la JVM haurà deixat de ser una caixa negra: sabràs quines regions de memòria existeixen i què viu a cadascuna, com decideix el recol·lector què esborrar, per què les fuites de memòria existeixen a Java malgrat el recol·lector, quines eines fer servir per veure-ho amb els teus propis ulls, i per què un microbenchmark casolà menteix. I el mòdul 10 quedarà tancat.
Contingut
- Les regions de memòria de la JVM
- La pila: marcs, variables locals i
StackOverflowError - El heap: on viuen els objectes
- Metaspace, memòria cau de codi i memòria nativa
OutOfMemoryErrori els seus diferents missatges- El model generacional
- Com decideix el recol·lector què esborrar
- Pauses stop-the-world
- Els recol·lectors actuals
- Els tres paràmetres que de debò es toquen
- Fuites de memòria a Java: sí que existeixen
- Els quatre patrons clàssics de fuita
- Referències febles, toves i fantasma
WeakHashMapi la memòria cau de fitxes de BiblioTechfinalizeobsolet iCleaner- Mesurar abans d'optimitzar
- Les eines del JDK
- Java Flight Recorder
- Per què un microbenchmark casolà menteix
- JMH: mesurar de debò
- El compilador JIT
- Bones pràctiques de rendiment, per impacte
String, interning iStringBuilder, mesurats- Errors Comuns i Consells
- Exercicis
- Les regions de memòria de la JVM
La JVM divideix la memòria del procés en diverses regions amb propòsits diferents. Algunes són per fil i d'altres compartides, i aquesta distinció explica molts comportaments.
graph TD
subgraph "Proces de la JVM"
subgraph "Per FIL"
P1["Pila (Stack)<br/>marcs de metode<br/>variables locals<br/>~512 KB - 1 MB"]
P2["Comptador de programa<br/>Pila de metodes natius"]
end
subgraph "COMPARTIT"
H["HEAP<br/>tots els objectes<br/>i els seus camps<br/>-Xms / -Xmx"]
M["Metaspace<br/>metadades de classes<br/>memoria nativa"]
C["Memoria cau de codi<br/>metodes compilats<br/>pel JIT"]
end
N["Memoria nativa<br/>ByteBuffer directes<br/>biblioteques JNI<br/>piles dels fils"]
end
| Regió | Àmbit | Què conté | S'allibera |
|---|---|---|---|
| Pila | Per fil | Marcs de mètode: variables locals, paràmetres, referències | Automàticament en tornar del mètode |
| Heap | Compartit | Tots els objectes i els seus camps d'instància | Pel recol·lector de brossa |
| Metaspace | Compartit | Metadades de classes, camps static, pool de constants |
En descarregar-se el carregador de classes |
| Memòria cau de codi | Compartit | Codi màquina generat pel JIT | En desoptimitzar o descarregar |
| Memòria nativa | Procés | Buffers directes, JNI, piles de fils | Manualment o en morir l'objecte |
La distinció fonamental, i la que cal fixar:
public void exemple() {
int comptador = 42; // el VALOR es a la pila
Llibre llibre = new Llibre("978-0000000001", "Java Eficac");
// ^ la REFERENCIA es a la pila ^ l OBJECTE es al heap
}Els primitius locals viuen a la pila. Els objectes viuen sempre al heap; el que és a la pila és la referència que hi apunta (típicament 4 o 8 bytes). I els camps d'un objecte viuen on viu l'objecte: al heap, encara que siguin primitius.
Veure quanta memòria hi ha:
public class MemoriaDisponible {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
System.out.printf("Maxima (-Xmx): %,d MB%n", rt.maxMemory() / 1024 / 1024);
System.out.printf("Total reservada: %,d MB%n", rt.totalMemory() / 1024 / 1024);
System.out.printf("Lliure en total: %,d MB%n", rt.freeMemory() / 1024 / 1024);
System.out.printf("En us: %,d MB%n",
(rt.totalMemory() - rt.freeMemory()) / 1024 / 1024);
System.out.printf("Processadors: %d%n", rt.availableProcessors());
}
}Maxima (-Xmx): 4.096 MB
Total reservada: 258 MB
Lliure en total: 196 MB
En us: 62 MB
Processadors: 8Compte amb la interpretació: maxMemory() i totalMemory() es refereixen només al heap, no a la memòria total del procés. Un procés Java amb -Xmx4g pot consumir 5 o 6 GB de memòria del sistema, perquè el metaspace, la memòria cau de codi, les piles dels fils i els buffers directes són fora del heap. Aquesta és la causa número u que un contenidor amb límit de memòria mati un procés Java que "hi cabia".
- La pila: marcs, variables locals i
StackOverflowError
StackOverflowErrorCada fil té la seva pròpia pila. Cada crida a mètode hi empeny un marc (stack frame) que conté:
- Els paràmetres del mètode.
- Les variables locals.
- La pila d'operands (on la JVM fa els càlculs).
- L'adreça de retorn.
En tornar del mètode, el marc es descarta complet, de cop i de franc. No hi ha recol·lector implicat: és un simple decrement del punter de pila. Per això les variables locals primitives són la memòria més barata de Java.
public class Marcs {
public static void main(String[] args) { // marc 1
int a = 10;
primer(a);
}
static void primer(int x) { // marc 2
String text = "BiblioTech";
segon(x * 2, text);
}
static void segon(int y, String t) { // marc 3
System.out.println(y + " " + t);
} // el marc 3 es descarta
}Quan la pila s'omple:
public class DesbordamentDePila {
private static int profunditat = 0;
static void recursioInfinita() {
profunditat++;
recursioInfinita(); // sense cas base
}
public static void main(String[] args) {
try {
recursioInfinita();
} catch (StackOverflowError e) {
System.out.println("StackOverflowError als " + profunditat + " marcs");
}
}
}Punts que convé fixar:
StackOverflowError és un Error, no una Exception. Reprenent 06-01: els Error indiquen problemes dels quals normalment no es pot recuperar i no s'han de capturar en codi normal. Aquí ho fem només per demostrar.
La profunditat depèn de la mida de la pila i de la mida de cada marc. Un mètode amb vint variables locals ocupa marcs més grans i desborda abans. Per això el número varia entre execucions i entre màquines.
S'ajusta amb -Xss:
java -Xss1m ElMeuPrograma # 1 MB per fil (tipic per defecte a 64 bits)
java -Xss16m ElMeuPrograma # per a recursio profunda legitima
java -Xss256k ElMeuPrograma # per tenir MOLTS filsI aquí hi ha la connexió amb 10-06: la pila és exactament el motiu pel qual els fils de plataforma són cars. Amb -Xss1m, deu mil fils reserven deu gigabytes d'espai d'adreces. Els fils virtuals guarden la seva pila al heap, creix sota demanda i per això n'hi caben milions.
I amb Thread.ofVirtual(), -Xss no s'aplica: la pila d'un fil virtual comença amb uns pocs centenars de bytes i creix segons calgui.
- El heap: on viuen els objectes
El heap és la regió compartida on viu tot objecte creat amb new, tot array, tota cadena.
Quant ocupa aquest objecte? Amb la JVM HotSpot de 64 bits i punters comprimits (per defecte amb heaps menors de 32 GB):
| Component | Bytes |
|---|---|
| Capçalera de l'objecte (mark word) | 8 |
| Punter a la classe (comprimit) | 4 |
String isbn (referència) |
4 |
String titol (referència) |
4 |
String autor (referència) |
4 |
int pagines |
4 |
double valoracio |
8 |
| Farciment fins a múltiple de 8 | 4 |
Total de l'objecte Llibre |
40 |
I això sense comptar les cadenes, que són objectes a part al heap: un String d'11 caràcters ocupa uns 56 bytes (24 de capçalera del String més un byte[] de 32).
Conseqüències pràctiques:
- Tot objecte té una sobrecàrrega de 12-16 bytes. Un
Integerper guardar un nombre de 4 bytes n'ocupa 16. Per aixòIntStreamenfront deStream<Integer>importa (10-04), i per aixòint[]enfront deList<Integer>importa. - Els objectes petits i nombrosos són cars. Un milió d'
Integersón 16 MB més l'array de referències; unint[]d'un milió són 4 MB. - Els punters comprimits estalvien molt. Amb heaps més grans de 32 GB, les referències passen de 4 a 8 bytes i el consum total puja al voltant d'un 20 %. És un argument real per mantenir el heap per sota de 32 GB.
Mesurar-ho:
- Metaspace, memòria cau de codi i memòria nativa
Metaspace
Guarda les metadades de les classes: la seva estructura, els seus mètodes, el pool de constants i els camps static.
Abans de Java 8 això vivia a la PermGen, una regió dins del heap i de mida fixa, el desbordament de la qual (OutOfMemoryError: PermGen space) era el terror dels servidors d'aplicacions que redesplegaven en calent. Java 8 la va substituir pel metaspace, que és a memòria nativa i creix dinàmicament.
Quan s'omple: aplicacions que generen classes dinàmicament i no les alliberen. Els proxies dinàmics de 10-03, els frameworks que generen bytecode (Spring amb CGLIB, Hibernate), i els redesplegaments en calent que deixen carregadors de classes vius.
Memòria cau de codi
Guarda el codi màquina que el compilador JIT genera a partir del bytecode (apartat 21). Si s'omple, la JVM deixa de compilar i torna a interpretar, amb una caiguda de rendiment dràstica:
És rar, però passa en aplicacions enormes amb molt codi calent.
Memòria nativa
Fora del control del recol·lector:
- Piles dels fils (
-Xss× nombre de fils). ByteBufferdirectes (ByteBuffer.allocateDirect), que vas veure a 07-03 amb NIO.- Biblioteques natives via JNI o l'API de funcions foranes de Java 22.
- Estructures internes de la JVM i del GC.
// Buffer DIRECTE: la memoria NO es al heap
ByteBuffer directe = ByteBuffer.allocateDirect(64 * 1024 * 1024); // 64 MB natius
// Buffer normal: SI que es al heap
ByteBuffer alHeap = ByteBuffer.allocate(64 * 1024 * 1024);Els directes eviten una còpia en fer E/S, però el seu alliberament depèn del recol·lector: la memòria nativa només s'allibera quan l'objecte ByteBuffer que la referencia es recol·lecta. S'acoten amb:
La fórmula del consum real d'un procés Java:
Memoria total ≈ Heap (-Xmx)
+ Metaspace
+ Memoria cau de codi
+ (Nombre de fils × -Xss)
+ Buffers directes
+ Estructures internes de la JVM (~100-300 MB)Per això -Xmx2g en un contenidor amb límit de 2 GB provoca que el sistema mati el procés (OOMKilled), encara que el heap no s'ompli mai. Regla pràctica: el -Xmx no hauria de passar del 60-75 % del límit del contenidor. Des de Java 10, la JVM detecta els límits dels cgroups i ajusta el heap per defecte, cosa que ajuda molt:
OutOfMemoryError i els seus diferents missatges
OutOfMemoryError i els seus diferents missatgesUn OutOfMemoryError no és un sol problema: el missatge concret diu quina regió s'ha exhaurit, i cadascuna té una causa i un remei diferents.
| Missatge | Regió | Causa típica | Remei |
|---|---|---|---|
Java heap space |
Heap | Fuita de memòria, o heap massa petit per a la càrrega | Analitzar el bolcat; pujar -Xmx |
GC overhead limit exceeded |
Heap | Es passa més del 98 % del temps recol·lectant i es recupera menys del 2 % | Gairebé sempre una fuita |
Metaspace |
Metaspace | Classes generades dinàmicament que no s'alliberen | Revisar carregadors; -XX:MaxMetaspaceSize |
Requested array size exceeds VM limit |
Heap | Array de més de ~2.147.483.645 elements | Repensar l'estructura |
unable to create native thread |
Nativa | Massa fils, o piles massa grans | Reduir fils, baixar -Xss, fer servir fils virtuals |
Direct buffer memory |
Nativa | Buffers directes no alliberats | -XX:MaxDirectMemorySize; revisar cicles de vida |
Compressed class space |
Metaspace | Massa classes amb punters comprimits | -XX:CompressedClassSpaceSize |
package com.nexussoftware.bibliotech.diagnostic;
import java.util.ArrayList;
import java.util.List;
public class ProvocarOutOfMemory {
/** Executar amb: java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError */
public static void main(String[] args) {
List<byte[]> retinguts = new ArrayList<>();
int blocs = 0;
try {
while (true) {
retinguts.add(new byte[1024 * 1024]); // 1 MB cada vegada
blocs++;
}
} catch (OutOfMemoryError e) {
// Capturar OOM nomes per DIAGNOSTICAR i sortir. Mai per continuar.
System.err.println("OutOfMemoryError despres de " + blocs + " MB");
System.err.println("Missatge: " + e.getMessage());
retinguts.clear(); // alliberar per poder imprimir
System.exit(1);
}
}
}Opcions imprescindibles en producció:
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bibliotech/heapdumps/ \
-XX:+ExitOnOutOfMemoryError \
-jar bibliotech.jarHeapDumpOnOutOfMemoryError: genera un bolcat del heap just en fallar. Sense això, l'incident es perd i només queda esperar que es repeteixi.ExitOnOutOfMemoryError: mata el procés. Sona dràstic i és el correcte: una JVM que ha patit unOutOfMemoryErrorestà en estat indeterminat —algun fil va morir a mig fer alguna cosa— i és millor que l'orquestrador la reiniciï que deixar-la funcionant malament.
No capturis mai OutOfMemoryError per continuar. Capturar-lo per registrar un missatge i sortir és defensable; capturar-lo i continuar és garantir corrupció de dades.
- El model generacional
Aquí comença el recol·lector de brossa pròpiament dit.
El disseny dels recol·lectors de Java es recolza en una observació empírica anomenada hipòtesi generacional feble:
La immensa majoria dels objectes moren molt joves.
I és certa de manera aclaparadora: en una aplicació típica, més del 90 % dels objectes es tornen inabastables gairebé immediatament després de crear-se. Pensa en l'EstadistiquesBiblioTech de 10-04: cada Fitxa intermèdia d'un stream, cada StringBuilder temporal, cada Optional, cada objecte d'un map — tots moren en microsegons.
Si la majoria mor jove, per què examinar tota la memòria a cada recol·lecció? D'aquí el model generacional:
graph LR
subgraph "GENERACIO JOVE"
E["Eden<br/>aqui neixen<br/>TOTS els objectes"]
S0["Supervivent 0"]
S1["Supervivent 1"]
end
subgraph "GENERACIO VELLA"
O["Old / Tenured<br/>objectes que han<br/>sobreviscut a diversos GC"]
end
E -->|"GC menor:<br/>els vius es copien"| S0
S0 -->|"seguent GC menor"| S1
S1 -->|"seguent GC menor"| S0
S1 -->|"despres de N supervivencies:<br/>PROMOCIO"| O
E -->|"objectes enormes"| O
El cicle, pas a pas:
- Tot objecte nou neix a l'edèn. Reservar-hi memòria és quasi de franc: un increment de punter (bump the pointer), de l'ordre de nanosegons.
- Quan l'edèn s'omple, hi ha un GC menor (minor GC): s'identifiquen els objectes vius de l'edèn i del supervivent actiu, i es copien a l'altre supervivent. L'edèn i el supervivent d'origen queden completament buits de cop.
- Cada supervivència incrementa l'"edat" de l'objecte. En superar un llindar (
-XX:MaxTenuringThreshold, típicament 15), es promociona a la generació vella. - Quan la generació vella s'omple, hi ha un GC major (major o full GC), molt més costós.
La clau és al pas 2, i és contraintuïtiva: el cost d'un GC menor és proporcional al nombre d'objectes vius, no al d'objectes morts. Si d'un edèn de 500 MB en sobreviuen 2 MB, el GC copia 2 MB i descarta els 498 restants sense tocar-los. Els objectes que moren joves són, literalment, gratis de recol·lectar.
| GC menor | GC major / complet | |
|---|---|---|
| Regió | Generació jove | Vella (o tot el heap) |
| Freqüència | Alta (segons) | Baixa (minuts o hores) |
| Durada | Mil·lisegons | Dècimes de segon o segons |
| Cost proporcional a | Objectes vius | Mida de la regió |
| Preocupa? | Normalment no | Sí: són les pauses visibles |
Veure-ho en directe:
[0.412s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 51M->8M(256M) 6.412ms
[1.238s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 59M->12M(256M) 5.891ms
[2.104s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 63M->14M(256M) 7.203ms
[8.771s][info][gc] GC(9) Pause Full (System.gc()) 198M->31M(256M) 187.442msCom llegir-ho: 51M->8M(256M) significa que abans del GC es feien servir 51 MB, després 8 MB, amb un heap total de 256 MB. Es van alliberar 43 MB en 6,4 ms. L'última línia és un GC complet: 187 ms de pausa, trenta vegades més.
I el que això ensenya sobre com escriure codi: crear objectes temporals de vida curta no és car. L'optimització de "reutilitzar objectes per no generar brossa" és, gairebé sempre, contraproduent: converteix objectes barats de la generació jove en objectes de la generació vella que sí que costen de recol·lectar.
- Com decideix el recol·lector què esborrar
Una idea molt estesa i falsa: que Java compta referències i esborra un objecte quan arriba a zero. No és així, i entendre per què importa.
El recol·lector fa servir abastabilitat des de les arrels (reachability from GC roots).
Les arrels del GC són els punts de partida, i són:
- Les variables locals de tots els marcs de totes les piles de tots els fils.
- Els camps
staticde totes les classes carregades. - Les referències des de codi natiu (JNI).
- Els fils vius en si mateixos.
- Els monitors que s'estiguin fent servir per a sincronització.
- Certes referències internes de la JVM.
L'algorisme, conceptualment:
- Partir de les arrels.
- Marcar cada objecte abastable seguint totes les seves referències, recursivament.
- Tot el que no està marcat és brossa, sigui quin sigui el nombre de referències que tingui.
graph TD
R1["ARREL: variable local<br/>a main()"] --> A["Repositori"]
R2["ARREL: camp static<br/>Configuracio.INSTANCIA"] --> B["Configuracio"]
A --> C["HashMap intern"]
C --> D["Llibre 1"]
C --> E["Llibre 2"]
F["Prestec antic"] --> G["Llibre 3"]
G --> F
H["Fitxa orfe"]
F -.->|"NO abastable<br/>des de cap arrel"| I["BROSSA"]
G -.-> I
H -.-> I
Per què els cicles no importen. Al diagrama, Prestec antic apunta a Llibre 3 i Llibre 3 apunta de tornada. Amb recompte de referències, cadascun tindria una referència i no s'alliberarien mai: fuita garantida. Amb abastabilitat, cap dels dos no s'abasta des de cap arrel, així que tots dos són brossa i es recol·lecten.
Això és un avantatge real de Java davant de llenguatges amb recompte de referències (Python parcialment, Swift, Objective-C), on els cicles exigeixen referències febles explícites per evitar fuites.
La conseqüència pràctica més important: un objecte s'allibera quan deixa de ser abastable, no quan "ja no el necessites". I d'aquí surten totes les fuites de l'apartat 11: n'hi ha prou que una sola arrel mantingui viva la cadena perquè l'objecte —i tot el que ell referencia— continuï ocupant memòria.
System.gc(), de passada: és un suggeriment, no una ordre. La JVM el pot ignorar, i cridar-lo sol empitjorar les coses perquè força un GC complet amb la seva pausa llarga. En producció es desactiva:
Només té sentit en diagnòstic, per comprovar si la memòria retinguda s'allibera de debò (ho veuràs a l'exercici 1).
- Pauses stop-the-world
Per poder recórrer el graf d'objectes de manera coherent, el recol·lector necessita en algun moment que el graf no canviï. Això implica aturar tots els fils de l'aplicació: una pausa stop-the-world.
graph LR
A["Fils de l aplicacio<br/>executant se"] --> B["Punt segur<br/>safepoint"]
B --> C["TOTS els fils<br/>ATURATS"]
C --> D["El GC treballa"]
D --> E["Fils represos"]
E --> A
Els fils no s'aturen en qualsevol punt: la JVM espera que cadascun arribi a un punt segur (safepoint), on el seu estat és consistent i descriptible. Hi ha punts segurs en tornar d'un mètode, en els salts cap enrere dels bucles i a les crides bloquejants.
I d'aquí surt un problema real i difícil de diagnosticar: un bucle molt atapeït sense punts segurs pot endarrerir la pausa sencera. Tots els altres fils ja estan aturats esperant-lo. Se'n diu time to safepoint i es diagnostica així:
[3.201s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 812 ms,
Reaching safepoint: 47 ms, At safepoint: 8 ms, Total: 55 msReaching safepoint: 47 ms és temps perdut abans que comenci el GC, perquè algun fil ha trigat a arribar-hi. Si aquest número és alt, el problema no és el recol·lector.
Tota l'evolució dels recol·lectors dels últims quinze anys es resumeix en una frase: reduir el temps de pausa. Els moderns fan la major part de la feina concurrentment amb l'aplicació, deixant pauses curtes només per als passos que ho exigeixen.
Les dues mètriques en tensió:
| Mètrica | Què mesura | Què la afavoreix |
|---|---|---|
| Rendiment (throughput) | Percentatge de temps dedicat a l'aplicació | GC menys freqüent però amb pauses llargues |
| Latència | Durada de la pausa més llarga | GC concurrent amb pauses curtes, a canvi de més CPU |
Un procés per lots nocturn prefereix rendiment; una API que promet respondre en menys de 100 ms prefereix latència. No es poden maximitzar totes dues.
- Els recol·lectors actuals
| Recol·lector | Activació | Pauses | Rendiment | Heap típic | Cas d'ús |
|---|---|---|---|---|---|
| Serial | -XX:+UseSerialGC |
Llargues | Bo en heaps petits | < 100 MB | Contenidors diminuts, un sol nucli, CLI |
| Parallel | -XX:+UseParallelGC |
Llargues (paral·leles) | El millor | < 4 GB | Processos per lots on la pausa no importa |
| G1 | -XX:+UseG1GC (per defecte) |
Mitjanes, predictibles | Molt bo | 4 GB - 32 GB | Aplicacions de servidor: l'elecció per defecte |
| ZGC | -XX:+UseZGC |
< 1 ms | Bo | 8 GB - 16 TB | Latència crítica: trading, temps real |
| Shenandoah | -XX:+UseShenandoahGC |
< 10 ms | Bo | Qualsevol | Latència crítica, alternativa a ZGC |
| Epsilon | -XX:+UseEpsilonGC |
Cap: no recol·lecta | — | — | Només proves: mesura l'assignació real |
G1 (Garbage-First), el de per defecte des de Java 9, divideix el heap en regions de mida fixa (1-32 MB) que poden ser edèn, supervivent o vella de manera dinàmica. A cada cicle recol·lecta primer les regions amb més brossa —d'aquí el nom— i així maximitza la memòria alliberada per unitat de pausa. Se li demana un objectiu de pausa:
És un objectiu, no una garantia. G1 ajusta la mida de la generació jove per intentar complir-lo; demanar 10 ms amb un heap de 32 GB simplement farà que recol·lecti constantment.
ZGC i Shenandoah són recol·lectors concurrents que fan gairebé tota la feina mentre l'aplicació s'executa, amb pauses per sota del mil·lisegon independents de la mida del heap. El preu és més consum de CPU i una mica més de memòria. Des de Java 21, ZGC té mode generacional, que millora molt el seu rendiment:
Epsilon és un recol·lector que no recol·lecta res: quan el heap s'omple, la JVM mor. Sona inútil i té dos usos legítims: mesurar exactament quanta memòria assigna un test (si n'assigna més de la prevista, falla), i executar processos de vida ultracurta on recol·lectar no compensa.
La recomanació pràctica:
graph TD
A["Quin recollector?"] --> B{"Heap < 100 MB<br/>o 1 nucli?"}
B -->|"si"| C["Serial"]
B -->|"no"| D{"Importen les<br/>pauses?"}
D -->|"no: proces per lots"| E["Parallel"]
D -->|"si"| F{"Pauses < 10 ms<br/>imprescindibles?"}
F -->|"no"| G["G1 (per defecte)"]
F -->|"si"| H["ZGC o Shenandoah"]
I el consell més important: no canviïs de recol·lector sense mesurar. El 95 % de les aplicacions funcionen perfectament amb G1 per defecte. Canviar a ZGC "perquè és més modern" sense un problema de latència mesurat sol empitjorar el rendiment global.
- Els tres paràmetres que de debò es toquen
La JVM té més de mil opcions d'ajust:
D'aquestes mil, a la pràctica se'n toquen tres.
-Xmx: mida màxima del heap
-Xmx: mida màxima del heapEl més important amb diferència. Massa petit i pateixes GC constants o OutOfMemoryError; massa gran i les pauses s'allarguen i el sistema operatiu comença a paginar.
Regla per a contenidors: entre el 60 % i el 75 % del límit de memòria, deixant la resta per a metaspace, piles, buffers i estructures de la JVM. O millor, deixar que la JVM ho calculi:
I el límit dels 32 GB: per sobre d'aquesta mida es desactiven els punters comprimits i el consum puja al voltant d'un 20 %. Un heap de 31 GB pot emmagatzemar més objectes que un de 33 GB.
-Xms: mida inicial del heap
-Xms: mida inicial del heapEn servidors, posa'l igual que -Xmx. El heap arrenca ja amb la seva mida final i s'evita que la JVM l'ampliï a poc a poc durant els primers minuts, amb GC i reajustaments innecessaris. En eines de línia d'ordres de vida curta, un -Xms petit fa que arrenquin més ràpid.
- L'elecció del recol·lector
Amb el criteri de l'apartat anterior, i només després de mesurar.
I les opcions de diagnòstic, que sí que convé posar sempre
java -Xms2g -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bibliotech/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/var/log/bibliotech/gc.log:time,uptime:filecount=5,filesize=10M \
-jar bibliotech.jarAquestes no canvien el rendiment: fan que, quan alguna cosa falli, tinguis la informació per diagnosticar-la. És la diferència entre resoldre un incident en una hora i no resoldre'l mai.
El que NO cal fer: copiar d'internet una llista de vint opcions d'ajust. Cada opció interactua amb les altres, moltes són obsoletes o eliminades, i el conjunt sol ser pitjor que els valors per defecte —que porten vint anys ajustant-se amb dades reals de milers d'aplicacions.
- Fuites de memòria a Java: sí que existeixen
Hi ha la creença que Java, en tenir recol·lector de brossa, no pot tenir fuites de memòria. És falsa.
Una fuita a Java té una definició precisa:
Objectes que continuen essent abastables des d'una arrel del GC, però que el programa ja no farà servir mai.
El recol·lector fa la seva feina perfectament: no els pot esborrar perquè són abastables. L'error és al codi, que manté una referència que ja no hauria de mantenir.
El símptoma característic, que convé reconèixer:
[3600.1s] GC(1201) Pause Young 892M->741M(1024M) 41.2ms
[3612.4s] GC(1202) Pause Young 901M->768M(1024M) 44.8ms
[3625.9s] GC(1203) Pause Young 918M->794M(1024M) 48.1ms
[3641.2s] GC(1204) Pause Full 1010M->961M(1024M) 892.3ms
[3644.7s] GC(1205) Pause Full 1014M->978M(1024M) 941.7ms
[3648.1s] GC(1206) Pause Full 1020M->1009M(1024M) 1102.4ms
java.lang.OutOfMemoryError: GC overhead limit exceededLlegeix l'evolució del segon número: després de cada GC queden 741, 768, 794 MB... La memòria retinguda creix monòtonament. Al final, el recol·lector passa més temps treballant que l'aplicació i arriba el GC overhead limit exceeded.
Aquest patró —memòria després del GC que puja en escala— és la firma d'una fuita, i és el primer que cal mirar en un log de GC.
- Els quatre patrons clàssics de fuita
Fuita 1: col·lecció que creix i ningú no buida
El més freqüent amb diferència.
package com.nexussoftware.bibliotech.servei;
import java.util.*;
/**
* FUITA: la memoria cau creix indefinidament.
*/
public class CacheAmbFuita {
// static: es una ARREL DEL GC. Tot el que pengi d aqui viu
// mentre visqui la classe, es a dir, per sempre.
private static final Map<String, Fitxa> CACHE = new HashMap<>();
public Fitxa obtenir(String isbn) {
return CACHE.computeIfAbsent(isbn, this::calcularFitxa);
}
// Mai no s elimina res. Amb un milio d ISBN diferents,
// un milio de fitxes retingudes per sempre.
}/** CORRECTE: memoria cau ACOTADA amb expulsio LRU (10-01). */
public class CacheAcotada {
private static final int CAPACITAT = 10_000;
private final Map<String, Fitxa> cache =
Collections.synchronizedMap(new LinkedHashMap<>(CAPACITAT, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Fitxa> mesAntiga) {
return size() > CAPACITAT;
}
});
public Fitxa obtenir(String isbn) {
return cache.computeIfAbsent(isbn, this::calcularFitxa);
}
}Tota memòria cau necessita una política d'expulsió. Per mida, per temps o per referències febles (apartat 13). Una memòria cau sense política d'expulsió no és una memòria cau: és una fuita amb bones intencions.
Fuita 2: listeners no donats de baixa
package com.nexussoftware.bibliotech.presentacio;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class GestorEsdeveniments {
private static final List<Consumer<String>> SUBSCRIPTORS = new ArrayList<>();
public static void subscriure(Consumer<String> subscriptor) {
SUBSCRIPTORS.add(subscriptor);
}
public static void publicar(String esdeveniment) {
SUBSCRIPTORS.forEach(s -> s.accept(esdeveniment));
}
}/** FUITA: se subscriu i mai no es dona de baixa. */
public class FinestraPrestecs {
private final List<Prestec> prestecs = new ArrayList<>(10_000); // objecte pesat
public FinestraPrestecs() {
// La LAMBDA captura 'this' implicitament en fer servir un camp.
// GestorEsdeveniments.SUBSCRIPTORS (static) rete la lambda,
// la lambda rete aquesta FinestraPrestecs,
// i aquesta finestra rete els seus 10.000 prestecs. PER SEMPRE.
GestorEsdeveniments.subscriure(esdeveniment -> actualitzar(esdeveniment));
}
private void actualitzar(String esdeveniment) { /* fa servir prestecs */ }
public void tancar() {
// No hi ha manera de donar se de baixa: no guardem la referencia
}
}/** CORRECTE: guardar la referencia i donar se de baixa. */
public class FinestraPrestecsCorrecta implements AutoCloseable {
private final List<Prestec> prestecs = new ArrayList<>(10_000);
private final Consumer<String> subscriptor;
public FinestraPrestecsCorrecta() {
this.subscriptor = this::actualitzar; // guardem la referencia
GestorEsdeveniments.subscriure(subscriptor);
}
private void actualitzar(String esdeveniment) { }
@Override
public void close() {
GestorEsdeveniments.dessubscriure(subscriptor); // simetria: subscriure/dessubscriure
}
}La regla de simetria: tot subscriure, registrar, afegir o open necessita el seu dessubscriure, desregistrar, eliminar o close, preferiblement en un try-with-resources (06-06).
Fuita 3: ThreadLocal en un pool de fils
package com.nexussoftware.bibliotech.servei;
/**
* FUITA: el fil del pool NO mor, aixi que el seu ThreadLocal tampoc.
*/
public class ContextAmbFuita {
private static final ThreadLocal<ContextPeticio> CONTEXT = new ThreadLocal<>();
public static void processar(String empleat, Runnable tasca) {
CONTEXT.set(new ContextPeticio(empleat)); // es guarda...
tasca.run();
// ...i MAI no es neteja.
}
}Per què és especialment insidiós. Amb un fil normal, en morir el fil mor el seu ThreadLocalMap. Però en un pool (08-05) els fils no moren: es reutilitzen milers de vegades. Cada petició hi deixa el seu context, i el pool de 200 fils acumula 200 contextos que no s'alliberen mai. Pitjor encara: la petició següent que caigui en aquell fil veurà el context de l'anterior, cosa que a més de fuita és un problema de seguretat.
/** CORRECTE: neteja garantida amb finally. */
public class ContextCorrecte {
private static final ThreadLocal<ContextPeticio> CONTEXT = new ThreadLocal<>();
public static void processar(String empleat, Runnable tasca) {
CONTEXT.set(new ContextPeticio(empleat));
try {
tasca.run();
} finally {
CONTEXT.remove(); // OBLIGATORI, i en finally
}
}
}I aquí connecta amb 10-06: ScopedValue existeix precisament per eliminar aquesta classe d'error, perquè el seu àmbit és un bloc i la neteja és automàtica.
Fuita 4: classe interna no estàtica que reté l'externa
Reprenem 04-03. Una classe interna no estàtica guarda una referència implícita a la seva instància externa (Externa.this):
package com.nexussoftware.bibliotech.servei;
import java.util.ArrayList;
import java.util.List;
public class CatalegPesat {
private final List<Material> materials = new ArrayList<>(100_000); // ~50 MB
/**
* NO estatica: rete implicitament CatalegPesat.
*/
public class ComptadorSimple {
private int compte;
public void incrementar() { compte++; }
public int getCompte() { return compte; }
// No fa servir 'materials' per a res, i tanmateix el rete
}
public ComptadorSimple crearComptador() {
return new ComptadorSimple();
}
}// Es crea el cataleg de 50 MB, se n treu un comptador de 16 bytes
// i es descarta el cataleg... pero el comptador el rete.
ComptadorSimple comptador;
{
CatalegPesat cataleg = new CatalegPesat();
comptador = cataleg.crearComptador();
}
// 'cataleg' ja no es abastable... EXCEPTE a traves de comptador.this$0
// Els 50 MB continuen alla./** CORRECTE: static, sense referencia a l externa. */
public static class ComptadorSimple {
private int compte;
public void incrementar() { compte++; }
public int getCompte() { return compte; }
}La regla de 04-03, ara amb la seva justificació completa: fes static tota classe interna que no necessiti accedir a l'estat de l'externa. L'IDE ho suggereix; fes-li cas.
El mateix s'aplica a les lambdes i a les classes anònimes: capturen this si fan servir qualsevol camp o mètode d'instància. Una lambda que sobreviu al seu creador el reté.
Taula resum
| Fuita | Com es detecta | Com s'arregla |
|---|---|---|
| Col·lecció que creix | Bolcat de heap: un HashMap gegant |
Política d'expulsió (LRU, TTL, febles) |
| Listeners | Moltes instàncies d'una classe d'UI o de servei | Simetria subscriure/dessubscriure |
ThreadLocal en pool |
ThreadLocalMap grans al bolcat |
remove() en finally |
| Classe interna | Objectes petits amb this$0 a objectes grans |
Fer-la static |
- Referències febles, toves i fantasma
Java ofereix quatre forces de referència, i són l'eina per dir-li al recol·lector "pots esborrar això si ho necessites".
| Tipus | Classe | El GC la esborra... | Ús |
|---|---|---|---|
| Forta | (normal) | Mai mentre sigui abastable | Tot el codi normal |
| Tova | SoftReference |
Només si ha de faltar memòria | Memòries cau que es poden sacrificar |
| Feble | WeakReference |
Al següent GC | Metadades associades a objectes |
| Fantasma | PhantomReference |
Ja està esborrada; només notifica | Neteja de recursos natius |
package com.nexussoftware.bibliotech.diagnostic;
import java.lang.ref.*;
public class ForcesDeReferencia {
public static void main(String[] args) throws InterruptedException {
// --- FORTA: el GC no la toca ---
Llibre forta = new Llibre("978-0000000001", "Java Eficac");
System.gc();
Thread.sleep(100);
System.out.println("Forta despres del GC: " + (forta != null)); // true
// --- FEBLE: desapareix al seguent GC ---
WeakReference<Llibre> feble = new WeakReference<>(
new Llibre("978-0000000002", "Patrons de Disseny"));
System.out.println("Feble abans: " + (feble.get() != null)); // true
System.gc();
Thread.sleep(100);
System.out.println("Feble despres del GC: " + (feble.get() != null)); // false
// --- TOVA: sobreviu mentre hi hagi memoria ---
SoftReference<Llibre> tova = new SoftReference<>(
new Llibre("978-0000000003", "Refactoritzacio"));
System.gc();
Thread.sleep(100);
System.out.println("Tova despres del GC: " + (tova.get() != null)); // true
// --- FANTASMA: get() SEMPRE retorna null ---
ReferenceQueue<Llibre> cua = new ReferenceQueue<>();
PhantomReference<Llibre> fantasma = new PhantomReference<>(
new Llibre("978-0000000004", "Codi Net"), cua);
System.out.println("Fantasma.get(): " + fantasma.get()); // sempre null
System.gc();
Thread.sleep(100);
Reference<?> encuada = cua.poll();
System.out.println("Notificada? " + (encuada != null)); // true
}
}Forta despres del GC: true
Feble abans: true
Feble despres del GC: false
Tova despres del GC: true
Fantasma.get(): null
Notificada? trueLes tres regles d'ús:
WeakReference — "vull accedir a això mentre algú altre ho faci servir, però no vull mantenir-ho viu jo". És el cas d'associar metadades a un objecte sense impedir que es recol·lecti.
SoftReference — "això és una memòria cau: esborra-ho si cal memòria". Es fa servir menys del que es creu, perquè el comportament exacte depèn de la JVM i una memòria cau amb política explícita de mida sol ser més predictible.
PhantomReference — "avisa'm quan això ja s'hagi recol·lectat per alliberar un recurs natiu". És el mecanisme de Cleaner (apartat 15) i no es fa servir directament gairebé mai.
La ReferenceQueue és el mecanisme de notificació: se li passa en crear la referència i la JVM encua la referència quan el seu objecte es recol·lecta. Permet reaccionar sense sondejar.
WeakHashMap i la memòria cau de fitxes de BiblioTech
WeakHashMap i la memòria cau de fitxes de BiblioTechWeakHashMap és un Map les claus del qual són referències febles: quan una clau deixa de ser abastable des de la resta del programa, l'entrada desapareix sola.
package com.nexussoftware.bibliotech.diagnostic;
import java.util.*;
public class ComparacioDeMapes {
public static void main(String[] args) throws InterruptedException {
Map<Llibre, String> normal = new HashMap<>();
Map<Llibre, String> feble = new WeakHashMap<>();
Llibre retingut = new Llibre("978-0000000001", "Java Eficac");
Llibre temporal = new Llibre("978-0000000002", "Patrons de Disseny");
normal.put(retingut, "prestatge A-1");
normal.put(temporal, "prestatge A-2");
feble.put(retingut, "prestatge A-1");
feble.put(temporal, "prestatge A-2");
System.out.println("Abans -> HashMap: " + normal.size()
+ ", WeakHashMap: " + feble.size());
temporal = null; // es perd l ultima referencia FORTA
System.gc();
Thread.sleep(200);
System.out.println("Despres -> HashMap: " + normal.size()
+ ", WeakHashMap: " + feble.size());
}
}El HashMap reté el llibre per sempre encara que ningú més no el faci servir: és una fuita. El WeakHashMap deixa que es recol·lecti i elimina l'entrada.
El cas d'ús real: metadades associades a objectes que no controles.
package com.nexussoftware.bibliotech.servei;
import com.nexussoftware.bibliotech.domini.*;
import java.time.Instant;
import java.util.*;
/**
* Memoria cau de fitxes de BiblioTech amb dos nivells.
*
* Nivell 1 (WeakHashMap): fitxes associades a materials VIUS.
* Si el material desapareix del cataleg, la seva fitxa s allibera sola.
*
* Nivell 2 (LinkedHashMap LRU): fitxes per ISBN, acotada.
* Sobreviu a la desaparicio del material, pero amb sostre.
*/
public class CacheFitxes {
private static final int CAPACITAT_LRU = 5_000;
/**
* Clau: l objecte Material. Referencia FEBLE.
* Si ningu mes no referencia el material, l entrada se n va sola.
*/
private final Map<Material, Fitxa> perMaterial =
Collections.synchronizedMap(new WeakHashMap<>());
/**
* Clau: l ISBN (un String, referencia forta).
* Aqui SI que cal politica d expulsio explicita.
*/
private final Map<String, Fitxa> perIsbn =
Collections.synchronizedMap(new LinkedHashMap<>(CAPACITAT_LRU, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Fitxa> mesAntiga) {
return size() > CAPACITAT_LRU;
}
});
private long encertsFebles = 0;
private long encertsLru = 0;
private long fallades = 0;
public Fitxa obtenir(Material material) {
Fitxa perObjecte = perMaterial.get(material);
if (perObjecte != null) {
encertsFebles++;
return perObjecte;
}
Fitxa perClau = perIsbn.get(material.getIsbn());
if (perClau != null) {
encertsLru++;
perMaterial.put(material, perClau); // repoblar el nivell 1
return perClau;
}
fallades++;
Fitxa nova = calcular(material);
perMaterial.put(material, nova);
perIsbn.put(material.getIsbn(), nova);
return nova;
}
private Fitxa calcular(Material material) {
// Calcul costos: consultes, agregats, format
return new Fitxa(material.getIsbn(), material.getTitol(), true);
}
public String estadistiques() {
long total = encertsFebles + encertsLru + fallades;
return """
CacheFitxes
Nivell 1 (WeakHashMap): %d entrades vives, %d encerts
Nivell 2 (LRU %d): %d entrades, %d encerts
Fallades: %d
Taxa d encert: %.1f %%"""
.formatted(perMaterial.size(), encertsFebles,
CAPACITAT_LRU, perIsbn.size(), encertsLru,
fallades, total == 0 ? 0.0
: (encertsFebles + encertsLru) * 100.0 / total);
}
}Tres advertiments sobre WeakHashMap que cal conèixer:
1. Els valors són referències FORTES. Si un valor referencia la seva pròpia clau, l'entrada no s'allibera mai:
// FUITA: el valor apunta a la clau, que aixi mai no queda inabastable
WeakHashMap<Llibre, Prestec> mapa = new WeakHashMap<>();
mapa.put(llibre, new Prestec(..., llibre)); // Prestec guarda el Llibre2. La neteja no és immediata. Depèn que hi hagi un GC. size() pot retornar un número més gran del que "hauria" fins que el recol·lector hi passi.
3. Amb claus String literals no funciona com esperes. Els literals estan internats (apartat 23) i són fortament abastables des del pool de constants: no es recol·lecten mai.
finalize obsolet i Cleaner
finalize obsolet i CleanerReprenem 03-09. Object.finalize() es va pensar com un destructor: un mètode que la JVM cridaria abans de recol·lectar l'objecte, per alliberar recursos.
Va ser un error de disseny i està obsolet des de Java 9, marcat per a eliminació des de Java 18. Les raons:
| Problema | Conseqüència |
|---|---|
| No hi ha garantia que s'executi | El programa pot acabar sense cridar-lo mai |
| No hi ha garantia de quan | Poden passar hores |
| Endarrereix la recol·lecció | Un objecte amb finalize necessita dos cicles de GC |
| S'executa en un fil sense prioritat | S'hi pot acumular una cua immensa |
| Una excepció s'ignora en silenci | L'objecte queda mig finalitzat |
| Pot "ressuscitar" l'objecte | Guardant this en un camp estàtic |
| Risc de seguretat | Atacs per finalització sobre constructors fallits |
// NO FACIS AIXO MAI
@Override
protected void finalize() throws Throwable {
if (fitxer != null) {
fitxer.close();
}
}La solució correcta és AutoCloseable + try-with-resources (06-06). És determinista, passa exactament quan ha de passar i el compilador ajuda.
I per a la xarxa de seguretat, Cleaner (Java 9):
package com.nexussoftware.bibliotech.persistencia;
import java.lang.ref.Cleaner;
import java.nio.channels.FileChannel;
import java.nio.file.*;
/**
* Recurs amb tancament determinista (AutoCloseable) MES una xarxa
* de seguretat amb Cleaner per si algu s oblida de tancar lo.
*/
public class MagatzemPrestecs implements AutoCloseable {
/** UN per aplicacio: crea un fil. */
private static final Cleaner NETEJADOR = Cleaner.create();
/**
* ESTATICA i sense referencia a l objecte extern.
* Si tingues una referencia a MagatzemPrestecs, aquest mai no
* seria inabastable i el Cleaner NO s activaria MAI.
* Es la fuita 4 de l apartat 12, aplicada aqui.
*/
private static class EstatANetejar implements Runnable {
private final FileChannel canal;
private final Path cami;
EstatANetejar(FileChannel canal, Path cami) {
this.canal = canal;
this.cami = cami;
}
@Override
public void run() {
try {
if (canal.isOpen()) {
System.err.println("AVIS: MagatzemPrestecs(" + cami
+ ") no s ha tancat; el tanca el Cleaner");
canal.close();
}
} catch (Exception e) {
// El Cleaner s empassa les excepcions: no hi ha a qui propagar les
}
}
}
private final EstatANetejar estat;
private final Cleaner.Cleanable neteja;
public MagatzemPrestecs(Path cami) throws java.io.IOException {
FileChannel canal = FileChannel.open(cami,
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
this.estat = new EstatANetejar(canal, cami);
this.neteja = NETEJADOR.register(this, estat);
}
public void guardar(String linia) { /* ... */ }
@Override
public void close() {
neteja.clean(); // tancament DETERMINISTA i idempotent
}
}// US NORMAL: try-with-resources, tancament garantit i determinista
try (var magatzem = new MagatzemPrestecs(Path.of("prestecs.dat"))) {
magatzem.guardar("PR-2026-0041;978-0000000001;Marta Ruiz");
}finalize |
Cleaner |
|
|---|---|---|
| Estat | Obsolet, marcat per a eliminació | Actual |
| Endarrereix la recol·lecció | Sí (dos cicles) | No |
| Pot ressuscitar l'objecte | Sí | No: l'estat és independent |
| Excepcions | S'ignoren en silenci | Es contenen al netejador |
| Ús recomanat | Cap | Només com a xarxa de seguretat |
La regla: AutoCloseable per al tancament real, Cleaner com a xarxa de seguretat, finalize mai.
- Mesurar abans d'optimitzar
Abans de les eines, la regla que governa tota la resta. La va formular Donald Knuth el 1974 i continua vigent:
"Els programadors malbaraten quantitats enormes de temps pensant en la velocitat de parts no crítiques dels seus programes... L'optimització prematura és l'arrel de tot mal. Tot i així, no hem de deixar passar les nostres oportunitats en aquell 3 % crític."
La segona frase se cita menys i és igual d'important: hi ha un 3 % que sí que importa, i la feina consisteix a trobar-lo.
L'error d'optimitzar a cegues té una forma característica. Un desenvolupador mira el codi, decideix que un bucle "sembla lent", el reescriu de manera enginyosa i il·legible, i el resultat va igual de ràpid — perquè el temps real se n'anava en una consulta a base de dades sense índex, en una crida de xarxa repetida o en una expressió regular recompilada a cada iteració.
El mètode correcte:
graph TD
A["1. Definir l objectiu<br/>'l informe ha de trigar menys de 2 s'"] --> B["2. MESURAR la situacio actual"]
B --> C{"Compleix l objectiu?"}
C -->|"si"| D["No optimitzar. Acabat."]
C -->|"no"| E["3. Perfilar: ON se n va el temps?"]
E --> F["4. Optimitzar NOMES el punt calent"]
F --> G["5. MESURAR de nou"]
G --> H{"Ha millorat?"}
H -->|"no"| I["REVERTIR el canvi"]
H -->|"si"| C
I --> E
Els quatre principis:
- Sense objectiu no hi ha optimització, hi ha entreteniment. "Més ràpid" no és un objectiu; "l'informe mensual en menys de 2 segons amb 100.000 préstecs" sí.
- Perfila, no endevinis. La intuïció sobre on se'n va el temps és notòriament dolenta, fins i tot entre desenvolupadors experts.
- Optimitza el coll d'ampolla, i només aquest. Millorar un 90 % una cosa que ocupa el 2 % del temps millora el total un 1,8 %.
- Si no ha millorat, reverteix. Un canvi que complica el codi sense millorar el rendiment és una pèrdua neta.
I la llei d'Amdahl, que hi posa números: si una part ocupa la fracció p del temps i l'acceleres s vegades, la millora total és:
Amb p = 0,05 (el 5 % del temps) i s = ∞ (infinitament ràpid), la millora total és 1,05×. Un 5 %. Per molt brillant que sigui l'optimització.
- Les eines del JDK
El JDK porta un conjunt complet d'eines de diagnòstic. Totes són a $JAVA_HOME/bin.
jps: llistar processos Java
14237 com.nexussoftware.bibliotech.BiblioTechApp -Xms2g -Xmx2g -XX:+UseG1GC
14891 jdk.jcmd/sun.tools.jps.Jps -Dapplication.home=/usr/lib/jvm/jdk-21El primer número és el PID, que necessiten totes les altres.
jstat: estadístiques en viu
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 62.31 41.20 18.44 96.12 92.03 142 1.204 2 0.381 1.585
0.00 62.31 78.90 18.44 96.12 92.03 142 1.204 2 0.381 1.585
58.44 0.00 12.03 18.51 96.12 92.03 143 1.213 2 0.381 1.594| Columna | Significat |
|---|---|
S0, S1 |
% d'ús dels espais supervivents |
E |
% d'ús de l'edèn: veure'l pujar i tornar a zero és el ritme del GC menor |
O |
% d'ús de la generació vella. Si puja monòtonament, hi ha fuita |
M, CCS |
% de metaspace i d'espai de classes comprimides |
YGC, YGCT |
Nombre i temps total de GC menors |
FGC, FGCT |
Nombre i temps total de GC complets |
GCT |
Temps total en GC |
Com llegir-ho en un minut: si E puja i baixa amb normalitat i O es manté estable, tot va bé. Si O puja i no baixa mai, i FGC creix, hi ha una fuita.
jmap: informació i bolcats del heap
num #instances #bytes class name
----------------------------------------------
1: 847291 40669968 [B (byte[])
2: 847012 20328288 java.lang.String
3: 412008 19776384 com.nexussoftware.bibliotech.domini.Prestec
4: 198432 9524736 java.util.HashMap$Node
5: 412008 6592128 java.time.LocalDate
6: 98211 4713-28 java.util.ArrayListAquest histograma és la primera eina davant d'una sospita de fuita. Executa'l dues vegades separades per uns minuts i compara: la classe les instàncies de la qual creixen sense parar és la culpable.
# Bolcat COMPLET del heap per analitzar amb eines grafiques
jmap -dump:live,format=b,file=/tmp/bibliotech.hprof 14237Advertiment: un bolcat provoca una pausa proporcional a la mida del heap (un heap de 8 GB pot aturar el procés uns quants segons) i genera un fitxer de la mida del heap. En producció, amb compte i preferiblement sobre una instància treta del balancejador.
jcmd: la navalla suïssa
És l'eina moderna que engloba les altres:
GC.class_histogram
GC.heap_dump
GC.heap_info
GC.run
JFR.start
JFR.dump
Thread.print
VM.flags
VM.native_memory
VM.system_properties
VM.uptimejcmd 14237 GC.heap_info
jcmd 14237 Thread.print # bolcat de fils: interbloqueigs (08-04)
jcmd 14237 VM.flags # opcions efectives de la JVM
jcmd 14237 GC.class_histogram
jcmd 14237 VM.native_memory summary # requereix -XX:NativeMemoryTracking=summaryThread.print és el primer davant d'una penjada. Mostra la pila de cada fil i detecta interbloqueigs automàticament:
Found one Java-level deadlock:
=============================
"client-42":
waiting to lock monitor 0x00007f... (object 0x000000071ab2, a java.lang.Object),
which is held by "client-17"
"client-17":
waiting to lock monitor 0x00007f... (object 0x000000071ab3, a java.lang.Object),
which is held by "client-42"És exactament l'interbloqueig de 08-04, vist des de fora.
jconsole i VisualVM
Eines gràfiques de monitoratge en viu: memòria, fils, classes, CPU i MBeans JMX. jconsole ve al JDK; VisualVM es descarrega a part i és més completa, amb perfilat i anàlisi de bolcats de heap.
Per analitzar bolcats de heap de debò, Eclipse MAT (Memory Analyzer Tool) és la referència: calcula la mida retinguda de cada objecte (quanta memòria s'alliberaria si desaparegués) i té un informe automàtic de sospitosos de fuita que encerta la majoria de vegades.
- Java Flight Recorder
JFR és l'eina més potent del conjunt, i la menys coneguda.
És un motor de recol·lecció d'esdeveniments integrat a la JVM amb una sobrecàrrega al voltant de l'1 %, prou baixa per deixar-lo sempre actiu en producció. Registra milers de tipus d'esdeveniment: assignacions, GC, bloquejos, E/S, excepcions, compilació JIT, ús de CPU.
# En arrencar
java -XX:StartFlightRecording=duration=120s,filename=/tmp/bibliotech.jfr \
-jar bibliotech.jar
# Sobre un proces en marxa
jcmd 14237 JFR.start name=diagnostic settings=profile duration=120s \
filename=/tmp/bibliotech.jfr
jcmd 14237 JFR.check
jcmd 14237 JFR.dump name=diagnostic filename=/tmp/instantania.jfr
jcmd 14237 JFR.stop name=diagnosticEls perfils predefinits són default (sobrecàrrega ~1 %, apte per a producció contínua) i profile (~2 %, més detall, per a diagnòstic puntual).
Analitzar la gravació des de línia d'ordres:
Event Type Count Size (bytes)
=========================================================
jdk.ObjectAllocationSample 18421 589472
jdk.ExecutionSample 9204 294528
jdk.GCPhasePause 412 13184
jdk.JavaMonitorEnter 287 14924
jdk.SocketRead 194 9312
jdk.ThreadPark 142 5680# Esdeveniments concrets
jfr print --events GCPhasePause /tmp/bibliotech.jfr | head -30
jfr print --events ObjectAllocationSample /tmp/bibliotech.jfr | head -40
jfr print --events JavaMonitorEnter /tmp/bibliotech.jfrI JDK Mission Control (JMC) és l'aplicació gràfica que analitza els .jfr amb informes automàtics: punts calents de CPU, llocs d'assignació, contenció de panys, pauses de GC, latències d'E/S.
Per què JFR és superior a un perfilador tradicional:
| Perfilador amb instrumentació | JFR | |
|---|---|---|
| Sobrecàrrega | 10 % - 100 % o més | ~1 % |
| Distorsió dels resultats | Alta: altera el que mesura | Mínima |
| Ús en producció | Desaconsellat | Dissenyat per a això |
| Esdeveniments de la JVM | No els veu | GC, JIT, safepoints, panys |
| Gravació contínua | Difícil | Sí, amb memòria intermèdia circular |
La configuració recomanada per a producció:
java -XX:StartFlightRecording=disk=true,maxsize=512m,maxage=12h,\
settings=default,filename=/var/log/bibliotech/recording.jfr \
-XX:FlightRecorderOptions=repository=/var/log/bibliotech/jfr \
-jar bibliotech.jarAmb això, quan hi hagi un incident, tindràs les últimes 12 hores d'esdeveniments gravades en lloc d'haver d'esperar que es repeteixi.
- Per què un microbenchmark casolà menteix
A 10-04 i 10-06 vas escriure bancs de proves casolans i vam dir, dues vegades, que eren orientatius i que només JMH dona resultats fiables. Toca explicar per què.
package com.nexussoftware.bibliotech.diagnostic;
public class BenchmarkEnganyos {
public static void main(String[] args) {
long inici = System.nanoTime();
for (int i = 0; i < 100_000_000; i++) {
Math.sqrt(i); // "mesurem" sqrt
}
long ns = System.nanoTime() - inici;
System.out.printf("100 milions de sqrt en %.2f ms%n", ns / 1_000_000.0);
}
}Cent milions d'arrels quadrades en 3,4 mil·lisegons. Això són 34 picosegons per operació, molt per sota del que triga un sol cicle de rellotge. El resultat és impossible, i la raó és que el compilador JIT va eliminar el bucle sencer: el resultat de Math.sqrt(i) no es fa servir, així que és codi mort.
Els cinc problemes d'un microbenchmark casolà:
- Escalfament (warmup)
La JVM comença interpretant el bytecode. Només després de milers d'execucions el JIT compila el mètode a codi màquina optimitzat. Mesurar les primeres execucions mesura l'intèrpret, no el codi real, i pot ser 10 o 100 vegades més lent.
- Eliminació de codi mort
Si el JIT demostra que un càlcul no afecta el resultat observable, l'elimina. És exactament el que va passar més amunt.
- Plegament de constants
Si les entrades són constants conegudes en compilació, el JIT calcula el resultat una vegada i el substitueix:
// El JIT pot substituir aixo per int r = 5050;
int suma = 0;
for (int i = 1; i <= 100; i++) suma += i;
- Efectes del recol·lector
Un GC que passi durant el mesurament n'afegeix la pausa al resultat. Un mesurament que "va sortir lent" pot ser simplement un que va coincidir amb un GC.
- Soroll del sistema
Altres processos, l'escalat de freqüència de la CPU, el planificador del sistema operatiu, la memòria cau de la CPU encara freda. La variància entre execucions idèntiques pot superar el 20 %.
Un banc casolà decent mitiga alguns, i és el que vam fer a 10-04:
public class BenchmarkMenysDolent {
/** volatile: impedeix que el JIT elimini el calcul */
private static volatile double embornal;
public static void main(String[] args) {
// 1. ESCALFAMENT
for (int r = 0; r < 10; r++) {
mesurar();
}
// 2. Diverses repeticions, i MEDIANA (menys sensible a pics que la mitjana)
long[] temps = new long[11];
for (int r = 0; r < temps.length; r++) {
temps[r] = mesurar();
}
java.util.Arrays.sort(temps);
System.out.printf("Mediana: %.2f ms%n", temps[temps.length / 2] / 1_000_000.0);
}
private static long mesurar() {
long inici = System.nanoTime();
double acumulat = 0;
for (int i = 0; i < 100_000_000; i++) {
acumulat += Math.sqrt(i);
}
embornal = acumulat; // consumir el resultat
return System.nanoTime() - inici;
}
}Cent vegades més lent que el mesurament "impossible". Aquest número sí que és plausible: uns 3 nanosegons per arrel quadrada. Però continua sense ser fiable: no controla el GC, no aïlla el soroll, no calcula intervals de confiança i el volatile introdueix el seu propi cost.
- JMH: mesurar de debò
JMH (Java Microbenchmark Harness) és l'eina oficial, desenvolupada pel mateix equip que la JVM, i l'única forma seriosa de mesurar codi Java.
Què fa per tu:
- Executa iteracions d'escalfament fins que el JIT ha estabilitzat el codi.
- Fa servir
Blackholeper consumir resultats de manera que el JIT no els pugui eliminar. - Bifurca processos (
@Fork) per evitar que la compilació d'un test contamini el següent. - Executa múltiples iteracions i calcula mitjana, desviació i intervals de confiança.
- Evita el plegament de constants amb
@State. - Informa de l'assignació de memòria per operació.
package com.nexussoftware.bibliotech.benchmark;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class BenchmarkBiblioTech {
@Param({"100", "10000", "1000000"})
private int mida;
private List<Llibre> cataleg;
@Setup
public void preparar() {
cataleg = IntStream.range(0, mida)
.mapToObj(i -> new Llibre(String.format("978-%010d", i),
"Titol " + i, "Autor " + (i % 100),
200 + (i % 400), 3.0 + (i % 20) / 10.0))
.collect(Collectors.toCollection(ArrayList::new));
}
@Benchmark
public long ambBucle() {
long total = 0;
for (Llibre l : cataleg) {
if (l.getPagines() > 400) {
total += l.getPagines();
}
}
return total;
}
@Benchmark
public long ambStream() {
return cataleg.stream()
.filter(l -> l.getPagines() > 400)
.mapToLong(Llibre::getPagines)
.sum();
}
@Benchmark
public long ambStreamParallel() {
return cataleg.parallelStream()
.filter(l -> l.getPagines() > 400)
.mapToLong(Llibre::getPagines)
.sum();
}
/** Blackhole: consumeix el valor de manera que el JIT no el pugui eliminar. */
@Benchmark
public void agrupacio(Blackhole bh) {
bh.consume(cataleg.stream()
.collect(Collectors.groupingBy(Llibre::getAutor, Collectors.counting())));
}
}Benchmark (mida) Mode Cnt Score Error Units
BenchmarkBiblioTech.ambBucle 100 avgt 20 0,082 ± 0,003 us/op
BenchmarkBiblioTech.ambBucle 10000 avgt 20 8,914 ± 0,142 us/op
BenchmarkBiblioTech.ambBucle 1000000 avgt 20 1102,412 ± 28,331 us/op
BenchmarkBiblioTech.ambStream 100 avgt 20 0,341 ± 0,012 us/op
BenchmarkBiblioTech.ambStream 10000 avgt 20 11,208 ± 0,201 us/op
BenchmarkBiblioTech.ambStream 1000000 avgt 20 1284,904 ± 31,204 us/op
BenchmarkBiblioTech.ambStreamParallel 100 avgt 20 12,041 ± 1,412 us/op
BenchmarkBiblioTech.ambStreamParallel 10000 avgt 20 28,114 ± 2,031 us/op
BenchmarkBiblioTech.ambStreamParallel 1000000 avgt 20 241,882 ± 18,442 us/op
Benchmark (mida) Mode Cnt Score Units
ambStream:gc.alloc.rate.norm 10000 avgt 20 128,004 B/op
ambBucle:gc.alloc.rate.norm 10000 avgt 20 0,001 B/opCinc conclusions, i totes són útils:
1. Amb 100 elements, el bucle és 4× més ràpid que el stream. Muntar la canonada del stream té un cost fix d'uns 250 nanosegons que domina quan hi ha poca feina.
2. Amb un milió, la diferència baixa al 16 %. El cost fix s'amortitza i queda la sobrecàrrega per element de les lambdes.
3. El paral·lel és 50× més LENT amb 100 elements i 4,5× més ràpid amb un milió. Exactament el que vas anticipar a 10-04, ara amb números fiables.
4. El stream assigna 128 bytes per operació; el bucle, zero. Aquest és el cost real de la canonada, i és una dada que cap banc casolà no dona.
5. El marge d'error (±) és el que fa el mesurament creïble. 1102,412 ± 28,331 significa que la diferència amb 1284,904 ± 31,204 és real i no soroll. Sense aquest interval, comparar dos números solts no significa res.
I la lliçó de disseny que se'n treu: la diferència entre bucle i stream és del 16 % en un milió d'elements. Si el teu codi no és en un bucle calent, tria el que es llegeixi millor. La claredat de stream().filter().sum() val molt més que 180 microsegons en un informe que s'executa una vegada al dia.
- El compilador JIT
Queda per explicar per què el codi Java s'accelera amb el temps.
Java segueix un model de compilació mixta:
graph LR
A["Codi font<br/>.java"] -->|"javac"| B["Bytecode<br/>.class"]
B --> C["Interpret<br/>lent, arrencada immediata"]
C -->|"compta invocacions<br/>i salts"| D{"Punt calent?"}
D -->|"no"| C
D -->|"si, llindar C1"| E["JIT C1<br/>compilacio rapida<br/>optimitzacio lleugera"]
E -->|"continua calent<br/>llindar C2"| F["JIT C2<br/>compilacio lenta<br/>optimitzacio AGRESSIVA"]
F -->|"suposicio invalidada"| C
Les etapes:
- Interpretació. En arrencar, la JVM executa el bytecode instrucció a instrucció. És lent però comença de seguida.
- C1 (client). Després d'uns quants milers d'invocacions, el mètode es compila ràpid amb optimitzacions lleugeres. S'instrumenta per recollir estadístiques.
- C2 (servidor). Si continua essent calent (desenes de milers d'execucions), C2 el recompila amb optimitzacions agressives basades en el perfil recollit.
Aquesta transició s'anomena compilació per nivells (tiered compilation) i és activa per defecte. Explica el comportament característic d'una aplicació Java: arrenca lenta i s'accelera durant els primers minuts.
Veure-ho:
112 1 3 java.lang.String::hashCode (49 bytes)
118 2 3 java.util.HashMap::hash (20 bytes)
134 5 4 java.lang.String::equals (65 bytes)
891 142 4 com...GestorPrestecs::prestar (218 bytes)
1204 198 3 com...EstadistiquesBiblioTech::recomptePerTipus (94 bytes)
2841 142 4 com...GestorPrestecs::prestar (218 bytes) made not entrantLes columnes són: mil·lisegons des de l'arrencada, identificador de compilació, nivell (1-3 és C1, 4 és C2) i el mètode. Aquest made not entrant del final és una desoptimització.
Les optimitzacions principals
Inlining (integració). Substitueix la crida a un mètode pel seu cos, eliminant el cost de la crida i —més important— obrint la porta a altres optimitzacions en veure el codi complet:
// Tu escrius
public int paginesTotals(List<Llibre> llibres) {
int total = 0;
for (Llibre l : llibres) {
total += l.getPagines(); // crida a un getter
}
return total;
}
// El JIT integra getPagines() i genera alguna cosa equivalent a
for (Llibre l : llibres) {
total += l.pagines; // acces directe al camp
}Per això els getters no costen res a Java, al contrari del que molts suposen: el JIT els integra sempre.
Escape analysis (anàlisi d'escapament). Si el JIT demostra que un objecte no escapa del mètode, el pot eliminar per complet, col·locant-ne els camps en registres:
public double distancia(int x1, int y1, int x2, int y2) {
Punt a = new Punt(x1, y1); // no escapa
Punt b = new Punt(x2, y2); // no escapa
return Math.hypot(a.x() - b.x(), a.y() - b.y());
}
// El JIT pot NO CREAR els dos objectes: zero assignacio al heapAixò explica per què els record de vida curta (10-06) i els Optional intermedis dels streams (10-04) solen ser gratis: el JIT els fa desaparèixer.
Especulació i desoptimització. El JIT aposta basant-se en el que ha observat. Si un Material sempre ha estat un Llibre, compila una crida monomòrfica i directa. Si demà apareix una Revista, la suposició s'invalida: el mètode es marca made not entrant i es torna a interpretar i recompilar.
Això té una conseqüència pràctica sorprenent: el codi que ha vist pocs tipus diferents va més ràpid. Un mètode polimòrfic cridat sempre amb la mateixa implementació s'optimitza a fons; el mateix mètode amb cinc implementacions alternant-se no es pot optimitzar igual.
Altres optimitzacions: eliminació de codi mort, desenrotllament de bucles, plegament de constants, propagació de còpies, vectorització amb instruccions SIMD, eliminació de comprovacions de rang quan el compilador demostra que l'índex és a dins.
Conseqüències pràctiques
| Efecte | Conseqüència |
|---|---|
| Arrencada lenta | La primera petició pot ser 10-100× més lenta. Importa en funcions sense servidor i en CLI |
| Escalfament necessari | Qualsevol mesurament ha d'escalfar primer |
| El codi simple s'optimitza millor | Els mètodes petits s'integren; els enormes, no |
| Menys tipus = més ràpid | El polimorfisme excessiu impedeix l'especulació |
| La JVM et guanya escrivint trucs | Optimitzacions manuals que fan nosa al JIT empitjoren el resultat |
L'arrencada lenta es mitiga amb AppCDS (compartir classes precarregades), amb AOT (compilació anticipada), o amb GraalVM Native Image, que compila a binari natiu amb arrencada en mil·lisegons i menys memòria, a canvi de perdre les optimitzacions dinàmiques i d'exigir declarar tota la reflexió (10-03).
- Bones pràctiques de rendiment, per impacte
Ordenades de major a menor impacte real. Els primers punts valen ordres de magnitud; els últims, percentatges.
Impacte ALTÍSSIM: l'algorisme i l'estructura de dades
Reprenem el mòdul 5. Això val més que tota la resta junta.
// O(n*m): per a cada material, recorre TOTS els prestecs
for (Material m : materials) { // n = 10.000
for (Prestec p : prestecs) { // m = 50.000
if (p.getIsbn().equals(m.getIsbn())) { ... }
}
}
// 500.000.000 comparacions
// O(n+m): indexar primer
Map<String, Prestec> perIsbn = prestecs.stream()
.collect(Collectors.toMap(Prestec::getIsbn, p -> p, (a, b) -> a));
for (Material m : materials) {
Prestec p = perIsbn.get(m.getIsbn()); // O(1)
}
// 60.000 operacions: 8.000 vegades menysI l'elecció de la col·lecció:
| Operació | ArrayList |
LinkedList |
HashMap |
TreeMap |
|---|---|---|---|---|
| Accés per índex | O(1) | O(n) | — | — |
| Cerca per valor | O(n) | O(n) | O(1) | O(log n) |
| Inserir al final | O(1) amortitzat | O(1) | O(1) | O(log n) |
| Inserir al principi | O(n) | O(1) | — | — |
| Recorregut complet | Molt ràpid (memòria contigua) | Lent (salts) | Mitjà | Mitjà |
I ArrayList guanya LinkedList gairebé sempre, fins i tot on la teoria diu el contrari, per la localitat de memòria cau: els elements contigus es llegeixen en bloc, mentre que LinkedList salta per tot el heap. Ho vas comprovar a 10-04 amb el paral·lelisme.
Impacte ALT: evitar feina innecessària
// MALAMENT: compila l expressio regular a CADA crida
public boolean esIsbnValid(String isbn) {
return isbn.matches("978-\\d{10}"); // Pattern.compile intern cada vegada
}
// BE: compilada una sola vegada
private static final Pattern ISBN = Pattern.compile("978-\\d{10}");
public boolean esIsbnValid(String isbn) {
return ISBN.matcher(isbn).matches();
}// MALAMENT: consulta dins del bucle
for (Prestec p : prestecs) {
Material m = cataleg.consultarABaseDeDades(p.getIsbn()); // N consultes
}
// BE: una consulta per lots
Map<String, Material> materials = cataleg.consultarPerLot(
prestecs.stream().map(Prestec::getIsbn).distinct().toList());El problema N+1 és, amb diferència, el problema de rendiment més comú en aplicacions empresarials. El veuràs amb nom i cognoms a 11-03 amb Hibernate.
Impacte ALT: reutilitzar objectes cars
public class ServeisBiblioTech {
// Immutables i segurs entre fils: UN per aplicacio (10-05, 09-06)
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
private static final Pattern ISBN = Pattern.compile("978-\\d{10}");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
}Crear un HttpClient per petició (09-06) multiplica per quatre la latència i fa fugir fils. Crear un DateTimeFormatter per línia de CSV multiplica el temps d'exportació.
I l'excepció a la regla: SimpleDateFormat no es pot reutilitzar entre fils (10-05). Però com que ja no el fas servir, el problema no existeix.
Impacte MITJÀ: StringBuilder en bucles
// MALAMENT: quadratic. Cada + crea un String nou copiant ho tot
String informe = "";
for (Prestec p : prestecs) {
informe += p.getId() + ";" + p.getEmpleat() + "\n";
}
// BE: lineal
StringBuilder sb = new StringBuilder(prestecs.size() * 48);
for (Prestec p : prestecs) {
sb.append(p.getId()).append(';').append(p.getEmpleat()).append('\n');
}
String informe = sb.toString();
// MILLOR encara si encaixa: streams (10-04)
String informe = prestecs.stream()
.map(p -> p.getId() + ";" + p.getEmpleat())
.collect(Collectors.joining("\n"));Matís important: una concatenació fora d'un bucle no és un problema. El compilador converteix "a" + b + "c" en una crida eficient (amb invokedynamic i StringConcatFactory des de Java 9). El problema és específicament el bucle, on la còpia és quadràtica.
Impacte MITJÀ: evitar l'autoboxing
// MALAMENT: 10 milions d objectes Long
Long suma = 0L;
for (int i = 0; i < 10_000_000; i++) {
suma += i; // desempaqueta, suma, EMPAQUETA
}
// BE: zero objectes
long suma = 0L;
for (int i = 0; i < 10_000_000; i++) {
suma += i;
}
// I en streams
int total = cataleg.stream().mapToInt(Llibre::getPagines).sum(); // IntStreamImpacte MITJÀ: mida inicial de les col·leccions
// Redimensiona ~14 vegades en arribar a 10.000, copiant cada vegada
List<Prestec> llista = new ArrayList<>();
// Una sola reserva
List<Prestec> llista = new ArrayList<>(10_000);
// HashMap: capacitat / factor de carrega, per no rehaixejar
Map<String, Prestec> mapa = new HashMap<>((int) (10_000 / 0.75f) + 1);Els microtrucs que NO serveixen
| "Optimització" | Realitat |
|---|---|
i++ enfront de ++i en un bucle |
Idèntics després de compilar |
Bucles descendents (for (i = n; i-- > 0;)) |
Sense diferència mesurable |
| Evitar getters accedint al camp | El JIT els integra: idèntic |
Marcar mètodes final "perquè s'optimitzin" |
El JIT ja ho dedueix |
System.gc() per "alliberar memòria" |
Empitjora: força un GC complet |
| Reutilitzar objectes per no crear brossa | Contraproduent: promociona objectes a la vella |
x >> 1 en lloc de x / 2 |
El compilador ja ho fa, i es llegeix pitjor |
Concatenar amb StringBuilder fora d'un bucle |
Innecessari des de Java 9 |
String, interning i StringBuilder, mesurats
String, interning i StringBuilder, mesuratsUn apartat concret perquè les cadenes són el tipus més utilitzat i el que més memòria consumeix en una aplicació típica —recorda l'histograma de l'apartat 17: byte[] i String als dos primers llocs.
El pool de cadenes i l'interning
public class PoolDeCadenes {
public static void main(String[] args) {
String a = "Java Eficac"; // literal: al POOL
String b = "Java Eficac"; // MATEIX objecte del pool
String c = new String("Java Eficac"); // objecte NOU al heap
String d = c.intern(); // cerca al pool
System.out.println("a == b: " + (a == b)); // true
System.out.println("a == c: " + (a == c)); // false
System.out.println("a == d: " + (a == d)); // true
System.out.println("a.equals(c): " + a.equals(c)); // true
// Concatenacio en TEMPS DE COMPILACIO: es un literal
String e = "Java " + "Eficac";
System.out.println("a == e: " + (a == e)); // true
// Concatenacio en EXECUCIO: objecte nou
String part = "Java ";
String f = part + "Eficac";
System.out.println("a == f: " + (a == f)); // false
}
}Aquesta és la raó per la qual es comparen cadenes amb equals i mai amb == (01-04), i ara saps exactament per què: == compara referències, i dues cadenes amb el mateix contingut poden ser objectes diferents.
intern() té un ús legítim i estret: quan llegeixes milions de cadenes repetides d'un fitxer o d'una base de dades, internar-les estalvia memòria perquè totes les repeticions comparteixen un objecte. Però el pool viu al heap i té un cost de cerca; no el facis servir sense mesurar.
Compactació de cadenes (Java 9). Internament, String va passar de char[] (2 bytes per caràcter) a byte[] més un indicador de codificació: les cadenes Latin-1 —la majoria— ocupen la meitat. És transparent i va ser una de les millores de memòria més grans de la història del JDK.
Concatenació mesurada
package com.nexussoftware.bibliotech.benchmark;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5) @Measurement(iterations = 10) @Fork(2)
public class BenchmarkCadenes {
@Param({"10", "100", "1000", "10000"})
private int n;
@Benchmark
public String concatenacioAmbMes() {
String resultat = "";
for (int i = 0; i < n; i++) {
resultat += "PR-2026-" + i + ";"; // QUADRATIC
}
return resultat;
}
@Benchmark
public String ambStringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("PR-2026-").append(i).append(';');
}
return sb.toString();
}
@Benchmark
public String ambStringBuilderDimensionat() {
StringBuilder sb = new StringBuilder(n * 16); // capacitat estimada
for (int i = 0; i < n; i++) {
sb.append("PR-2026-").append(i).append(';');
}
return sb.toString();
}
}Benchmark (n) Mode Cnt Score Error Units
BenchmarkCadenes.concatenacioAmbMes 10 avgt 20 0,412 ± 0,011 us/op
BenchmarkCadenes.concatenacioAmbMes 100 avgt 20 8,204 ± 0,204 us/op
BenchmarkCadenes.concatenacioAmbMes 1000 avgt 20 512,891 ± 14,201 us/op
BenchmarkCadenes.concatenacioAmbMes 10000 avgt 20 48204,112 ± 1204,441 us/op
BenchmarkCadenes.ambStringBuilder 10 avgt 20 0,198 ± 0,008 us/op
BenchmarkCadenes.ambStringBuilder 100 avgt 20 1,412 ± 0,041 us/op
BenchmarkCadenes.ambStringBuilder 1000 avgt 20 14,208 ± 0,312 us/op
BenchmarkCadenes.ambStringBuilder 10000 avgt 20 148,904 ± 3,204 us/op
BenchmarkCadenes.ambStringBuilderDimensionat 10000 avgt 20 104,412 ± 2,118 us/op
BenchmarkCadenes.concatenacioAmbMes:gc.alloc.rate.norm 10000 avgt 20 512048912,0 B/op
BenchmarkCadenes.ambStringBuilder:gc.alloc.rate.norm 10000 avgt 20 524312,0 B/opLlegeix la taula amb atenció, perquè és la millor demostració de tot l'apartat:
| n | Amb + |
Amb StringBuilder |
Factor |
|---|---|---|---|
| 10 | 0,41 µs | 0,20 µs | 2× |
| 100 | 8,2 µs | 1,4 µs | 6× |
| 1.000 | 513 µs | 14,2 µs | 36× |
| 10.000 | 48.204 µs | 149 µs | 324× |
El factor creix amb n, i això és la firma d'una diferència de complexitat: la concatenació amb + és O(n²) perquè cada + copia tota la cadena acumulada; StringBuilder és O(n) amortitzat.
I l'assignació de memòria és la dada demolidora: 512 megabytes contra 524 kilobytes per a la mateixa cadena de resultat. Mil vegades més brossa generada, amb tota la feina de GC que això implica.
Dimensionar el StringBuilder estalvia un 30 % addicional en evitar redimensionaments.
I la conclusió que cal endur-se: amb n = 10 la diferència és irrellevant i no val la pena preocupar-s'hi. Amb n = 10.000 és la diferència entre 149 microsegons i 48 mil·lisegons. El mateix canvi de codi és irrellevant o crític segons el context — i només el mesurament ho diu.
Errors Comuns i Consells
1. Optimitzar sense mesurar. L'error rei. La intuïció sobre on se'n va el temps és dolenta fins i tot entre experts. Perfila primer.
2. Creure que un microbenchmark casolà diu alguna cosa. Sense escalfament, sense consumir el resultat i sense repeticions, el JIT elimina el codi i mesures el buit. Fes servir JMH.
3. Confondre el heap amb la memòria del procés. -Xmx2g no significa "2 GB en total": hi falten metaspace, memòria cau de codi, piles de fils i buffers directes. És la causa número u d'OOMKilled en contenidors.
4. Cridar System.gc(). És un suggeriment, força un GC complet amb pausa llarga i gairebé mai no ajuda. Desactiva'l en producció.
5. Capturar OutOfMemoryError per continuar. La JVM queda en estat indeterminat. Registra'l i surt.
6. Creure que el recol·lector compta referències. Fa servir abastabilitat des d'arrels, per això els cicles no són un problema i per això una sola referència des d'un camp static manté viu un graf sencer.
7. Memòries cau sense política d'expulsió. Una memòria cau que només creix no és una memòria cau: és la fuita número u.
8. ThreadLocal sense remove() en un pool. Els fils del pool no moren, així que el valor tampoc. Fuita i filtració de dades entre peticions.
9. Classes internes no estàtiques i lambdes que capturen this. Retenen la instància externa completa. Fes-les static quan no necessitin l'estat extern.
10. Fer servir finalize. Obsolet, no garantit i endarrereix la recol·lecció. AutoCloseable per al tancament, Cleaner com a xarxa de seguretat.
11. Copiar opcions d'ajust de la JVM d'internet. Moltes són obsoletes o eliminades, interactuen entre si i el conjunt sol ser pitjor que els valors per defecte. Toca -Xmx, -Xms i el recol·lector, i només amb dades.
12. Reutilitzar objectes "per no crear brossa". Els objectes de vida curta són quasi de franc a la generació jove. Reutilitzar-los els promociona a la vella, on sí que costen.
13. Preocupar-se pel + en cadenes fora d'un bucle. Des de Java 9 el compilador ho resol eficientment. El problema és el bucle.
Consell 1: posa les opcions de diagnòstic des del primer dia. -XX:+HeapDumpOnOutOfMemoryError, log de GC rotat i JFR continu no costen rendiment i són la diferència entre resoldre un incident en una hora o mai.
Consell 2: aprèn a llegir un log de GC. Cinc minuts amb jstat -gcutil o un log de GC responen a "hi ha fuita?" millor que un dia de conjectures. Busca el patró de la memòria retinguda que puja en escala.
Consell 3: l'estructura de dades correcta val més que totes les microoptimitzacions juntes. Canviar O(n²) per O(n) millora mil vegades; canviar i++ per ++i millora zero.
Consell 4: escriu codi clar primer. El JIT optimitza millor el codi senzill, i el codi clar és el que es pot optimitzar després quan el mesurament ho demani. El codi "optimitzat a mà" i il·legible sol ser més lent i sempre és pitjor de mantenir.
Consell 5: si no pots explicar per què un canvi millora, no el facis. I si millora però no saps per què, mesura'l en un altre context abans de generalitzar-lo.
Exercicis
Exercici 1: detector i diagnòstic de fuites
Construeix un laboratori de fuites per a BiblioTech:
- Una classe
LaboratoriDeFuitesque reprodueixi els quatre patrons de l'apartat 12, cadascun en un mètode aïllat. - Un
MonitorDeMemoriaque, en un fil a part, registri cada segon la memòria usada després d'un GC (fent servirRuntimeiSystem.gc(), justificant que aquí sí que està permès) i detecti automàticament el patró de creixement monòton. - Per a cada fuita, imprimeix la memòria retinguda al principi i al final, i el veredicte del detector.
- Implementa la versió corregida de cada fuita i demostra que el detector ja no la senyala.
- Afegeix instruccions per generar un bolcat de heap amb
jcmdi què cal buscar-hi.
Exercici 2: memòria cau de fitxes amb tres estratègies, mesurada
Implementa i compara tres estratègies de memòria cau per a les fitxes de BiblioTech:
CacheSenseLimit(HashMappur, la fuita).CacheLru(LinkedHashMapacotada).CacheFeble(WeakHashMap).
Per a cadascuna mesura, amb 100.000 accessos seguint una distribució realista (el 80 % dels accessos sobre el 20 % dels ISBN):
- Taxa d'encerts.
- Memòria retinguda després d'un GC.
- Temps total.
- Entrades supervivents després de pressió de memòria.
Genera una taula comparativa i una recomanació raonada. Documenta per què el mesurament és casolà i què caldria per fer-lo amb JMH.
Exercici 3: informe de rendiment de BiblioTech
Escriu InformeDeRendiment, una eina d'autodiagnòstic que BiblioTech pugui executar en producció:
- Estat de la memòria per regió, fent servir
ManagementFactory(MemoryMXBean,MemoryPoolMXBean). - Estadístiques de GC per recol·lector (
GarbageCollectorMXBean): nombre de recol·leccions, temps total i percentatge del temps de vida. - Estat dels fils (
ThreadMXBean): totals, dimonis, pic, i detecció d'interbloqueigs. - Classes carregades i descarregades (
ClassLoadingMXBean). - Temps de compilació JIT (
CompilationMXBean). - Un semàfor de salut (verd/ambre/vermell) amb regles justificades.
- Un mètode que arrenqui una gravació JFR sota demanda amb
jcmd.
Tot en un record segellat per secció, amb switch exhaustius i blocs de text per a l'informe (10-06).
Solucions
Solució 1
package com.nexussoftware.bibliotech.diagnostic;
import java.util.*;
import java.util.function.Consumer;
/**
* Reprodueix els quatre patrons classics de fuita i les seves correccions.
* NO es codi de produccio: es un laboratori didactic.
*/
public class LaboratoriDeFuites {
// ==================================================================
// FUITA 1: colleccio que creix sense limit
// ==================================================================
static class CacheAmbFuita {
// static + mai no es neteja = arrel del GC que ho rete tot
private static final Map<String, byte[]> CACHE = new HashMap<>();
static void usar(String clau) {
CACHE.computeIfAbsent(clau, k -> new byte[10 * 1024]); // 10 KB
}
static int mida() { return CACHE.size(); }
static void netejar() { CACHE.clear(); }
}
static class CacheAcotada {
private static final int CAPACITAT = 500;
private static final Map<String, byte[]> CACHE =
new LinkedHashMap<>(CAPACITAT, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> e) {
return size() > CAPACITAT;
}
};
static void usar(String clau) {
CACHE.computeIfAbsent(clau, k -> new byte[10 * 1024]);
}
static int mida() { return CACHE.size(); }
static void netejar() { CACHE.clear(); }
}
// ==================================================================
// FUITA 2: listeners no donats de baixa
// ==================================================================
static class GestorEsdeveniments {
private static final List<Consumer<String>> SUBSCRIPTORS = new ArrayList<>();
static void subscriure(Consumer<String> s) { SUBSCRIPTORS.add(s); }
static void dessubscriure(Consumer<String> s) { SUBSCRIPTORS.remove(s); }
static int quants() { return SUBSCRIPTORS.size(); }
static void netejar() { SUBSCRIPTORS.clear(); }
}
/** FUITA: se subscriu amb una lambda que captura this i mai no es dona de baixa. */
static class FinestraAmbFuita {
private final byte[] dadesPesades = new byte[100 * 1024]; // 100 KB
FinestraAmbFuita() {
GestorEsdeveniments.subscriure(esdev -> processar(esdev)); // captura this
}
private void processar(String e) { /* fa servir dadesPesades */ }
}
/** CORRECTE: guarda la referencia i ofereix close(). */
static class FinestraCorrecta implements AutoCloseable {
private final byte[] dadesPesades = new byte[100 * 1024];
private final Consumer<String> subscriptor;
FinestraCorrecta() {
this.subscriptor = this::processar;
GestorEsdeveniments.subscriure(subscriptor);
}
private void processar(String e) { }
@Override public void close() { GestorEsdeveniments.dessubscriure(subscriptor); }
}
// ==================================================================
// FUITA 3: ThreadLocal en un pool
// ==================================================================
static class ContextAmbFuita {
private static final ThreadLocal<byte[]> CONTEXT = new ThreadLocal<>();
static void processar() {
CONTEXT.set(new byte[500 * 1024]); // 500 KB
// sense remove(): el fil del pool el rete per sempre
}
}
static class ContextCorrecte {
private static final ThreadLocal<byte[]> CONTEXT = new ThreadLocal<>();
static void processar() {
CONTEXT.set(new byte[500 * 1024]);
try {
// feina real
} finally {
CONTEXT.remove(); // OBLIGATORI
}
}
}
// ==================================================================
// FUITA 4: classe interna no estatica
// ==================================================================
static class CatalegPesat {
private final byte[] materials = new byte[2 * 1024 * 1024]; // 2 MB
/** NO estatica: rete CatalegPesat.this */
class ComptadorAmbFuita {
private int compte;
void incrementar() { compte++; }
}
/** static: no rete res de l externa */
static class ComptadorCorrecte {
private int compte;
void incrementar() { compte++; }
}
ComptadorAmbFuita crearAmbFuita() { return new ComptadorAmbFuita(); }
static ComptadorCorrecte crearCorrecte() { return new ComptadorCorrecte(); }
}
// ==================================================================
// MONITOR
// ==================================================================
/**
* Mesura la memoria RETINGUDA (despres del GC), no la usada.
*
* Aqui System.gc() SI que esta justificat: es una eina de
* diagnostic, no codi de produccio, i necessitem saber
* quanta memoria sobreviu a una recolleccio.
*/
static class MonitorDeMemoria {
private final List<Long> mostres = new ArrayList<>();
private final String nom;
MonitorDeMemoria(String nom) { this.nom = nom; }
long mostrejar() {
System.gc();
try { Thread.sleep(120); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Runtime rt = Runtime.getRuntime();
long usada = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
mostres.add(usada);
return usada;
}
/** Creixement monoton en almenys el 80 % dels trams = fuita. */
boolean detectaFuita() {
if (mostres.size() < 4) {
return false;
}
int creixents = 0;
for (int i = 1; i < mostres.size(); i++) {
if (mostres.get(i) > mostres.get(i - 1)) {
creixents++;
}
}
double proporcio = (double) creixents / (mostres.size() - 1);
long delta = mostres.get(mostres.size() - 1) - mostres.get(0);
return proporcio >= 0.8 && delta > 5; // 5 MB de llindar
}
String veredicte() {
long inicial = mostres.get(0);
long finalM = mostres.get(mostres.size() - 1);
return String.format("%-26s %4d MB -> %4d MB (Δ %+4d MB) %s",
nom, inicial, finalM, finalM - inicial,
detectaFuita() ? "*** FUITA DETECTADA ***" : "estable");
}
}
// ==================================================================
public static void main(String[] args) throws Exception {
System.out.println("Heap maxim: "
+ Runtime.getRuntime().maxMemory() / 1024 / 1024 + " MB");
System.out.println("Executar amb: java -Xmx512m LaboratoriDeFuites");
System.out.println("=".repeat(74));
provarFuita1();
provarFuita2();
provarFuita3();
provarFuita4();
System.out.println("=".repeat(74));
System.out.println("""
DIAGNOSTIC AMB EINES REALS
──────────────────────────
1. Localitzar el proces:
jps -lvm
2. Veure si la generacio vella creix sense baixar (columna O):
jstat -gcutil <PID> 1000 30
3. Histograma d objectes vius, dues vegades separades per minuts:
jcmd <PID> GC.class_histogram | head -20
La classe el recompte de la qual creix sense parar es la sospitosa.
4. Bolcat complet per a l analisi:
jcmd <PID> GC.heap_dump /tmp/bibliotech.hprof
5. Obrir el .hprof amb Eclipse MAT:
- "Leak Suspects Report" dona el culpable al 80 % dels casos
- Ordenar per "Retained Heap": quanta memoria allibera cada objecte
- "Path to GC Roots" sobre el sospitos: QUI el rete
(excloent referencies febles i toves)
""");
}
private static void provarFuita1() {
System.out.println("\n--- FUITA 1: colleccio sense limit ---");
var monitor = new MonitorDeMemoria("HashMap sense limit");
monitor.mostrejar();
for (int ronda = 0; ronda < 5; ronda++) {
for (int i = 0; i < 2_000; i++) {
CacheAmbFuita.usar("isbn-" + ronda + "-" + i);
}
monitor.mostrejar();
}
System.out.println(monitor.veredicte());
System.out.println(" Entrades retingudes: " + CacheAmbFuita.mida());
CacheAmbFuita.netejar();
var monitor2 = new MonitorDeMemoria("LinkedHashMap acotat");
monitor2.mostrejar();
for (int ronda = 0; ronda < 5; ronda++) {
for (int i = 0; i < 2_000; i++) {
CacheAcotada.usar("isbn-" + ronda + "-" + i);
}
monitor2.mostrejar();
}
System.out.println(monitor2.veredicte());
System.out.println(" Entrades retingudes: " + CacheAcotada.mida() + " (sostre 500)");
CacheAcotada.netejar();
}
private static void provarFuita2() {
System.out.println("\n--- FUITA 2: listeners ---");
var monitor = new MonitorDeMemoria("Sense dessubscriure");
monitor.mostrejar();
for (int ronda = 0; ronda < 5; ronda++) {
for (int i = 0; i < 500; i++) {
new FinestraAmbFuita(); // es crea, es descarta... i es rete
}
monitor.mostrejar();
}
System.out.println(monitor.veredicte());
System.out.println(" Subscriptors vius: " + GestorEsdeveniments.quants());
GestorEsdeveniments.netejar();
var monitor2 = new MonitorDeMemoria("Amb try-with-resources");
monitor2.mostrejar();
for (int ronda = 0; ronda < 5; ronda++) {
for (int i = 0; i < 500; i++) {
try (var v = new FinestraCorrecta()) {
// us
}
}
monitor2.mostrejar();
}
System.out.println(monitor2.veredicte());
System.out.println(" Subscriptors vius: " + GestorEsdeveniments.quants());
}
private static void provarFuita3() throws Exception {
System.out.println("\n--- FUITA 3: ThreadLocal en pool ---");
var pool = java.util.concurrent.Executors.newFixedThreadPool(50);
var monitor = new MonitorDeMemoria("ThreadLocal sense remove");
monitor.mostrejar();
for (int ronda = 0; ronda < 4; ronda++) {
var latch = new java.util.concurrent.CountDownLatch(200);
for (int i = 0; i < 200; i++) {
pool.submit(() -> { ContextAmbFuita.processar(); latch.countDown(); });
}
latch.await();
monitor.mostrejar();
}
System.out.println(monitor.veredicte());
System.out.println(" 50 fils × 500 KB retinguts ≈ 25 MB que no s alliberen");
var monitor2 = new MonitorDeMemoria("ThreadLocal amb remove");
monitor2.mostrejar();
for (int ronda = 0; ronda < 4; ronda++) {
var latch = new java.util.concurrent.CountDownLatch(200);
for (int i = 0; i < 200; i++) {
pool.submit(() -> { ContextCorrecte.processar(); latch.countDown(); });
}
latch.await();
monitor2.mostrejar();
}
System.out.println(monitor2.veredicte());
pool.shutdown();
}
private static void provarFuita4() {
System.out.println("\n--- FUITA 4: classe interna no estatica ---");
List<Object> comptadors = new ArrayList<>();
var monitor = new MonitorDeMemoria("Classe interna NO estatica");
monitor.mostrejar();
for (int ronda = 0; ronda < 4; ronda++) {
for (int i = 0; i < 20; i++) {
CatalegPesat cataleg = new CatalegPesat(); // 2 MB
comptadors.add(cataleg.crearAmbFuita()); // rete els 2 MB
}
monitor.mostrejar();
}
System.out.println(monitor.veredicte());
System.out.println(" " + comptadors.size()
+ " comptadors de 16 bytes retenint " + (comptadors.size() * 2) + " MB");
comptadors.clear();
var monitor2 = new MonitorDeMemoria("Classe interna estatica");
monitor2.mostrejar();
for (int ronda = 0; ronda < 4; ronda++) {
for (int i = 0; i < 20; i++) {
CatalegPesat cataleg = new CatalegPesat();
comptadors.add(CatalegPesat.crearCorrecte()); // no rete res
}
monitor2.mostrejar();
}
System.out.println(monitor2.veredicte());
System.out.println(" " + comptadors.size()
+ " comptadors sense retenir cap cataleg");
}
}Heap maxim: 512 MB
Executar amb: java -Xmx512m LaboratoriDeFuites
==========================================================================
--- FUITA 1: colleccio sense limit ---
HashMap sense limit 12 MB -> 116 MB (Δ +104 MB) *** FUITA DETECTADA ***
Entrades retingudes: 10000
LinkedHashMap acotat 14 MB -> 19 MB (Δ +5 MB) estable
Entrades retingudes: 500 (sostre 500)
--- FUITA 2: listeners ---
Sense dessubscriure 14 MB -> 272 MB (Δ +258 MB) *** FUITA DETECTADA ***
Subscriptors vius: 2500
Amb try-with-resources 14 MB -> 15 MB (Δ +1 MB) estable
Subscriptors vius: 0
--- FUITA 3: ThreadLocal en pool ---
ThreadLocal sense remove 15 MB -> 40 MB (Δ +25 MB) *** FUITA DETECTADA ***
50 fils × 500 KB retinguts ≈ 25 MB que no s alliberen
ThreadLocal amb remove 15 MB -> 16 MB (Δ +1 MB) estable
--- FUITA 4: classe interna no estatica ---
Classe interna NO estatica 16 MB -> 176 MB (Δ +160 MB) *** FUITA DETECTADA ***
80 comptadors de 16 bytes retenint 160 MB
Classe interna estatica 17 MB -> 18 MB (Δ +1 MB) estable
80 comptadors sense retenir cap catalegComentaris. Quatre observacions.
El detector busca la firma correcta: creixement monòton de la memòria RETINGUDA. Mesurar la memòria usada no serviria, perquè puja i baixa amb el ritme del GC. Mesurar després de forçar un GC deixa només el que sobreviu, i això és l'únic que importa per diagnosticar una fuita.
La fuita 4 és la més desproporcionada i la més difícil de veure. Vuitanta objectes de setze bytes retenen 160 MB. En un bolcat de heap, el sospitós apareix com un ComptadorAmbFuita amb una mida retinguda enorme, i el camí a l'arrel mostra el camp sintètic this$0. Aquesta és exactament la informació que dona Eclipse MAT i que no dona cap altra eina.
La fuita 3 no creix indefinidament: s'estabilitza en 25 MB. És 50 fils × 500 KB, i allà es queda. Això la fa més difícil de detectar (no dispara OutOfMemoryError) i més perillosa per una altra raó: la petició següent que caigui en aquell fil veuria el context de l'anterior, amb la implicació de seguretat corresponent.
Les instruccions amb jcmd i MAT són la part transferible. El laboratori és didàctic; en producció es diagnostica amb l'histograma comparat i el "Leak Suspects Report" de MAT.
Solució 2
package com.nexussoftware.bibliotech.diagnostic;
import com.nexussoftware.bibliotech.domini.Fitxa;
import java.util.*;
import java.util.function.Function;
/**
* Comparativa de tres estrategies de memoria cau.
*
* ADVERTIMENT: mesurament CASOLA. Els temps son orientatius.
* Veure el comentari final sobre JMH.
*/
public class ComparativaDeCaches {
/** Contracte comu. */
interface Cache {
Fitxa obtenir(String isbn, Function<String, Fitxa> calcul);
int mida();
long encerts();
long fallades();
String nom();
default double taxaEncerts() {
long total = encerts() + fallades();
return total == 0 ? 0 : encerts() * 100.0 / total;
}
}
/** ESTRATEGIA 1: sense limit. Es la fuita de l apartat 12. */
static class CacheSenseLimit implements Cache {
private final Map<String, Fitxa> mapa = new HashMap<>();
private long encerts, fallades;
public Fitxa obtenir(String isbn, Function<String, Fitxa> calcul) {
Fitxa f = mapa.get(isbn);
if (f != null) { encerts++; return f; }
fallades++;
f = calcul.apply(isbn);
mapa.put(isbn, f);
return f;
}
public int mida() { return mapa.size(); }
public long encerts() { return encerts; }
public long fallades() { return fallades; }
public String nom() { return "HashMap sense limit"; }
}
/** ESTRATEGIA 2: LRU acotada. */
static class CacheLru implements Cache {
private final int capacitat;
private final LinkedHashMap<String, Fitxa> mapa;
private long encerts, fallades;
CacheLru(int capacitat) {
this.capacitat = capacitat;
this.mapa = new LinkedHashMap<>(capacitat, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, Fitxa> e) {
return size() > CacheLru.this.capacitat;
}
};
}
public Fitxa obtenir(String isbn, Function<String, Fitxa> calcul) {
Fitxa f = mapa.get(isbn);
if (f != null) { encerts++; return f; }
fallades++;
f = calcul.apply(isbn);
mapa.put(isbn, f);
return f;
}
public int mida() { return mapa.size(); }
public long encerts() { return encerts; }
public long fallades() { return fallades; }
public String nom() { return "LRU (" + capacitat + ")"; }
}
/**
* ESTRATEGIA 3: claus febles.
*
* COMPTE: les claus son String. Si vinguessin d un pool internat,
* NO es recollectarien mai. Aqui es construeixen dinamicament,
* aixi que si que son elegibles.
*/
static class CacheFeble implements Cache {
private final Map<String, Fitxa> mapa = new WeakHashMap<>();
private long encerts, fallades;
public Fitxa obtenir(String isbn, Function<String, Fitxa> calcul) {
Fitxa f = mapa.get(isbn);
if (f != null) { encerts++; return f; }
fallades++;
f = calcul.apply(isbn);
mapa.put(isbn, f);
return f;
}
public int mida() { return mapa.size(); }
public long encerts() { return encerts; }
public long fallades() { return fallades; }
public String nom() { return "WeakHashMap"; }
}
// ------------------------------------------------------------------
private static final int ACCESSOS = 100_000;
private static final int ISBNS_DIFERENTS = 20_000;
private static final int CALENTS = ISBNS_DIFERENTS / 5; // el 20 %
/** Calcul costos simulat. */
private static Fitxa calcular(String isbn) {
double acumulat = 0;
for (int i = 1; i <= 2_000; i++) {
acumulat += Math.sqrt(i);
}
return new Fitxa(isbn, "Fitxa " + isbn + " (" + (long) acumulat + ")", true);
}
/** Distribucio 80/20: el 80 % dels accessos sobre el 20 % de les claus. */
private static List<String> generarAccessos(Random atzar) {
List<String> accessos = new ArrayList<>(ACCESSOS);
for (int i = 0; i < ACCESSOS; i++) {
int index = atzar.nextInt(100) < 80
? atzar.nextInt(CALENTS)
: CALENTS + atzar.nextInt(ISBNS_DIFERENTS - CALENTS);
accessos.add(String.format("978-%010d", index));
}
return accessos;
}
record Resultat(String nom, double taxaEncerts, long memoriaMb,
long ms, int entrades, int supervivents) { }
private static Resultat mesurar(Cache cache, List<String> accessos) throws Exception {
long memoriaAbans = memoriaRetinguda();
long inici = System.nanoTime();
for (String isbn : accessos) {
cache.obtenir(isbn, ComparativaDeCaches::calcular);
}
long ms = (System.nanoTime() - inici) / 1_000_000;
long memoriaDespres = memoriaRetinguda();
int entrades = cache.mida();
// Pressio de memoria: reservar i deixar anar per forcar recollections agressives
try {
List<byte[]> pressio = new ArrayList<>();
for (int i = 0; i < 200; i++) {
pressio.add(new byte[1024 * 1024]);
}
pressio.clear();
} catch (OutOfMemoryError e) {
// esperat: es el que busquem
}
memoriaRetinguda();
int supervivents = cache.mida();
return new Resultat(cache.nom(), cache.taxaEncerts(),
Math.max(0, memoriaDespres - memoriaAbans), ms, entrades, supervivents);
}
private static long memoriaRetinguda() throws InterruptedException {
System.gc();
Thread.sleep(150);
Runtime rt = Runtime.getRuntime();
return (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
}
public static void main(String[] args) throws Exception {
Random atzar = new Random(42); // llavor fixa: reproduible
List<String> accessos = generarAccessos(atzar);
System.out.printf("""
Accessos: %,d sobre %,d ISBN diferents (distribucio 80/20)
Heap maxim: %d MB
""", ACCESSOS, ISBNS_DIFERENTS,
Runtime.getRuntime().maxMemory() / 1024 / 1024);
List<Resultat> resultats = new ArrayList<>();
resultats.add(mesurar(new CacheSenseLimit(), accessos));
resultats.add(mesurar(new CacheLru(CALENTS), accessos));
resultats.add(mesurar(new CacheLru(1_000), accessos));
resultats.add(mesurar(new CacheFeble(), accessos));
System.out.printf("%n%-22s %9s %9s %8s %10s %14s%n",
"ESTRATEGIA", "ENCERTS", "MEMORIA", "TEMPS", "ENTRADES", "SOTA PRESSIO");
System.out.println("-".repeat(78));
resultats.forEach(r -> System.out.printf("%-22s %8.1f%% %7d MB %6d ms %10d %14d%n",
r.nom(), r.taxaEncerts(), r.memoriaMb(), r.ms(),
r.entrades(), r.supervivents()));
System.out.println("""
RECOMANACIO
───────────
• HashMap sense limit: la taxa d encerts mes alta i la pitjor decisio.
Rete TOTES les entrades indefinidament: es la fuita 1 de
l apartat 12. Nomes acceptable si el nombre de claus esta acotat
per disseny (un enum, una llista fixa de configuracio).
• LRU dimensionada al conjunt calent: la millor relacio.
Amb una distribucio 80/20, una LRU de la mida del 20 % calent
captura gairebe tots els encerts amb una fraccio de la memoria.
ES L ELECCIO PER DEFECTE.
• LRU infradimensionada: la taxa d encerts s enfonsa perque les
entrades calentes s expulsen entre elles (thrashing). Dimensionar
una memoria cau per sota del conjunt de treball es pitjor que no tenir ne.
• WeakHashMap: memoria autoregulada, pero comportament
IMPREDICTIBLE: les entrades desapareixen quan el GC ho decideix.
El seu cas real no es "memoria cau acotada" sino "metadades associades a
objectes vius que no controlo" (apartat 14).
SOBRE AQUEST MESURAMENT
───────────────────────
Es CASOLA i els seus numeros son orientatius:
- No hi ha escalfament suficient: les primeres estrategies
mesurades paguen la compilacio JIT de calcular().
- System.gc() es un suggeriment: la memoria "retinguda" es
aproximada.
- No hi ha repeticions ni intervals de confianca.
- L ordre d execucio hi influeix (la primera memoria cau deixa el
heap en un estat diferent per a la seguent).
Per mesurar de debo: JMH amb @State(Scope.Benchmark),
@Setup(Level.Iteration) recreant la memoria cau, @Fork(3) per aillar
processos i -prof gc per a l assignacio normalitzada per operacio.
""");
}
}Accessos: 100.000 sobre 20.000 ISBN diferents (distribucio 80/20)
Heap maxim: 512 MB
ESTRATEGIA ENCERTS MEMORIA TEMPS ENTRADES SOTA PRESSIO
------------------------------------------------------------------------------
HashMap sense limit 80,0% 12 MB 412 ms 20000 20000
LRU (4000) 79,4% 3 MB 438 ms 4000 4000
LRU (1000) 61,2% 1 MB 891 ms 1000 1000
WeakHashMap 79,8% 9 MB 467 ms 19884 0
RECOMANACIO
───────────
• HashMap sense limit: la taxa d encerts mes alta i la pitjor decisio.
...Comentaris. Quatre punts.
La LRU dimensionada al conjunt calent aconsegueix el 79,4 % d'encerts amb una quarta part de la memòria. Amb una distribució 80/20, aquest és el punt òptim: capturar el conjunt de treball real i deixar caure la resta.
La LRU de 1.000 entrades s'enfonsa al 61 % i triga el doble. El conjunt calent són 4.000 claus; amb capacitat per a 1.000, les entrades calentes s'expulsen les unes a les altres contínuament (thrashing), i cada fallada costa un càlcul complet. Una memòria cau infradimensionada és pitjor que no tenir-ne, perquè paga el cost de gestió sense donar el benefici.
El WeakHashMap perd TOTES les entrades sota pressió de memòria. Aquesta columna és la clau: passa de 19.884 a 0. És exactament el comportament promès i exactament el que el fa inadequat com a memòria cau de rendiment: no pots garantir cap taxa d'encerts.
I la secció "sobre aquest mesurament" és la part més honesta de l'exercici. Reconèixer les limitacions d'un mesurament casolà és el que separa una dada d'una opinió amb números. Els percentatges d'encert sí que són fiables (són deterministes); els temps són orientatius.
Solució 3
package com.nexussoftware.bibliotech.diagnostic;
import java.lang.management.*;
import java.time.Duration;
import java.util.*;
import java.util.stream.Collectors;
/**
* Autodiagnostic de rendiment de BiblioTech.
* Fa servir unicament l API de gestio del JDK: sense dependencies externes.
*/
public class InformeDeRendiment {
// --- Seccions de l informe com a tipus segellat (10-06) ---
public sealed interface Seccio {
record Memoria(long heapUsatMb, long heapMaxMb, long heapCompromesMb,
long noHeapUsatMb, List<Pool> pools) implements Seccio { }
record Pool(String nom, String tipus, long usatMb, long maxMb) { }
record Recolleccio(List<EstadisticaGc> recollectors, long tempsTotalMs,
long tempsDeVidaMs) implements Seccio { }
record EstadisticaGc(String nom, long recolleccions, long tempsMs) { }
record Fils(int vius, int dimonis, int pic, long totalCreats,
List<String> interbloqueigs) implements Seccio { }
record Classes(long carregades, long totalCarregades, long descarregades) implements Seccio { }
record Compilacio(String compilador, long tempsMs) implements Seccio { }
}
public enum Salut { VERD, AMBRE, VERMELL }
// ------------------------------------------------------------------
public Seccio.Memoria recollirMemoria() {
MemoryMXBean memoria = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = memoria.getHeapMemoryUsage();
MemoryUsage noHeap = memoria.getNonHeapMemoryUsage();
List<Seccio.Pool> pools = ManagementFactory.getMemoryPoolMXBeans().stream()
.map(p -> new Seccio.Pool(
p.getName(),
p.getType().toString(),
p.getUsage().getUsed() / 1024 / 1024,
p.getUsage().getMax() < 0 ? -1 : p.getUsage().getMax() / 1024 / 1024))
.toList();
return new Seccio.Memoria(
heap.getUsed() / 1024 / 1024,
heap.getMax() / 1024 / 1024,
heap.getCommitted() / 1024 / 1024,
noHeap.getUsed() / 1024 / 1024,
pools);
}
public Seccio.Recolleccio recollirGc() {
List<Seccio.EstadisticaGc> recollectors =
ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(gc -> new Seccio.EstadisticaGc(
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime()))
.toList();
long total = recollectors.stream()
.mapToLong(Seccio.EstadisticaGc::tempsMs).sum();
return new Seccio.Recolleccio(recollectors, total,
ManagementFactory.getRuntimeMXBean().getUptime());
}
public Seccio.Fils recollirFils() {
ThreadMXBean fils = ManagementFactory.getThreadMXBean();
List<String> interbloqueigs = new ArrayList<>();
long[] bloquejats = fils.findDeadlockedThreads();
if (bloquejats != null) {
for (ThreadInfo info : fils.getThreadInfo(bloquejats, true, true)) {
interbloqueigs.add(String.format("%s (id %d) bloquejat a %s, retingut per %s",
info.getThreadName(), info.getThreadId(),
info.getLockName(), info.getLockOwnerName()));
}
}
return new Seccio.Fils(
fils.getThreadCount(), fils.getDaemonThreadCount(),
fils.getPeakThreadCount(), fils.getTotalStartedThreadCount(),
interbloqueigs);
}
public Seccio.Classes recollirClasses() {
ClassLoadingMXBean classes = ManagementFactory.getClassLoadingMXBean();
return new Seccio.Classes(classes.getLoadedClassCount(),
classes.getTotalLoadedClassCount(), classes.getUnloadedClassCount());
}
public Seccio.Compilacio recollirCompilacio() {
CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
if (jit == null) {
return new Seccio.Compilacio("(no disponible)", 0);
}
return new Seccio.Compilacio(jit.getName(),
jit.isCompilationTimeMonitoringSupported() ? jit.getTotalCompilationTime() : -1);
}
// ------------------------------------------------------------------
/** Semafor de salut amb regles justificades, mitjancant switch exhaustiu. */
public Salut avaluar(Seccio seccio) {
return switch (seccio) {
// Regla: per sobre del 90 % del heap despres del GC, risc real d OOM
case Seccio.Memoria(var usat, var max, var compromes, var noHeap, var pools) -> {
double us = max <= 0 ? 0 : usat * 100.0 / max;
yield us > 90 ? Salut.VERMELL : us > 75 ? Salut.AMBRE : Salut.VERD;
}
// Regla: mes del 10 % del temps de vida en GC indica un problema seriso
case Seccio.Recolleccio(var recollectors, var totalMs, var vidaMs) -> {
double percentatge = vidaMs == 0 ? 0 : totalMs * 100.0 / vidaMs;
yield percentatge > 10 ? Salut.VERMELL : percentatge > 5 ? Salut.AMBRE : Salut.VERD;
}
// Regla: un interbloqueig es SEMPRE vermell
case Seccio.Fils(var vius, var dimonis, var pic, var creats, var bloquejos) -> {
if (!bloquejos.isEmpty()) yield Salut.VERMELL;
yield vius > 1_000 ? Salut.AMBRE : Salut.VERD;
}
// Regla: moltes classes carregades i cap descarregada suggereix fuita de metaspace
case Seccio.Classes(var carregades, var total, var descarregades) ->
carregades > 50_000 ? Salut.AMBRE : Salut.VERD;
// El temps de JIT alt nomes es informatiu
case Seccio.Compilacio c -> Salut.VERD;
};
}
private String icona(Salut s) {
return switch (s) {
case VERD -> "[ OK ]";
case AMBRE -> "[AVIS]";
case VERMELL -> "[ MAL ]";
};
}
// ------------------------------------------------------------------
public String generar() {
var memoria = recollirMemoria();
var gc = recollirGc();
var fils = recollirFils();
var classes = recollirClasses();
var jit = recollirCompilacio();
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
OperatingSystemMXBean so = ManagementFactory.getOperatingSystemMXBean();
StringBuilder sb = new StringBuilder();
sb.append("""
══════════════════════════════════════════════════════════
INFORME DE RENDIMENT -- BiblioTech
══════════════════════════════════════════════════════════
JVM: %s %s
Proveidor: %s
Sistema: %s %s (%d processadors)
En marxa: %s
PID: %d
""".formatted(
runtime.getVmName(), runtime.getVmVersion(), runtime.getVmVendor(),
so.getName(), so.getArch(), so.getAvailableProcessors(),
formatarDurada(runtime.getUptime()),
ProcessHandle.current().pid()));
// --- MEMORIA ---
sb.append("\n%s MEMORIA%n".formatted(icona(avaluar(memoria))));
sb.append(" Heap: %,d MB usats / %,d MB compromesos / %,d MB maxim (%.1f %%)%n"
.formatted(memoria.heapUsatMb(), memoria.heapCompromesMb(),
memoria.heapMaxMb(),
memoria.heapMaxMb() <= 0 ? 0
: memoria.heapUsatMb() * 100.0 / memoria.heapMaxMb()));
sb.append(" No-heap: %,d MB (metaspace, memoria cau de codi)%n"
.formatted(memoria.noHeapUsatMb()));
sb.append(" Regions:%n");
memoria.pools().forEach(p -> sb.append(" %-28s %6d MB%s%n".formatted(
p.nom(), p.usatMb(), p.maxMb() < 0 ? "" : " / " + p.maxMb() + " MB")));
// --- GC ---
double percentatgeGc = gc.tempsDeVidaMs() == 0 ? 0
: gc.tempsTotalMs() * 100.0 / gc.tempsDeVidaMs();
sb.append("\n%s RECOLLECCIO DE BROSSA%n".formatted(icona(avaluar(gc))));
gc.recollectors().forEach(r -> sb.append(
" %-28s %,8d recolleccions %,8d ms (mitjana %.1f ms)%n".formatted(
r.nom(), r.recolleccions(), r.tempsMs(),
r.recolleccions() == 0 ? 0.0 : (double) r.tempsMs() / r.recolleccions())));
sb.append(" Total en GC: %,d ms de %,d ms de vida (%.2f %%)%n"
.formatted(gc.tempsTotalMs(), gc.tempsDeVidaMs(), percentatgeGc));
// --- FILS ---
sb.append("\n%s FILS%n".formatted(icona(avaluar(fils))));
sb.append(" Vius: %d (%d dimonis) Pic: %d Creats en total: %,d%n"
.formatted(fils.vius(), fils.dimonis(), fils.pic(), fils.totalCreats()));
if (fils.interbloqueigs().isEmpty()) {
sb.append(" Sense interbloqueigs detectats%n".formatted());
} else {
sb.append(" *** INTERBLOQUEIG DETECTAT ***%n".formatted());
fils.interbloqueigs().forEach(i -> sb.append(" ").append(i).append('\n'));
}
// --- CLASSES I JIT ---
sb.append("\n%s CLASSES%n".formatted(icona(avaluar(classes))));
sb.append(" Carregades ara: %,d Total historic: %,d Descarregades: %,d%n"
.formatted(classes.carregades(), classes.totalCarregades(), classes.descarregades()));
sb.append("\n%s COMPILACIO JIT%n".formatted(icona(avaluar(jit))));
sb.append(" Compilador: %s Temps total: %,d ms%n"
.formatted(jit.compilador(), jit.tempsMs()));
// --- OPCIONS ---
sb.append("\n OPCIONS DE LA JVM%n".formatted());
runtime.getInputArguments().forEach(a -> sb.append(" ").append(a).append('\n'));
sb.append("""
──────────────────────────────────────────────────────────
SEGUENT PAS SI ALGUNA COSA ESTA EN AVIS O EN MAL
jstat -gcutil %d 1000 30 (creix la columna O?)
jcmd %d GC.class_histogram (quina classe creix?)
jcmd %d Thread.print (interbloqueigs i piles)
jcmd %d JFR.start settings=profile duration=120s \\
filename=/tmp/bibliotech.jfr
══════════════════════════════════════════════════════════
""".formatted(ProcessHandle.current().pid(), ProcessHandle.current().pid(),
ProcessHandle.current().pid(), ProcessHandle.current().pid()));
return sb.toString();
}
private String formatarDurada(long ms) {
Duration d = Duration.ofMillis(ms);
return "%dd %02dh %02dm %02ds".formatted(
d.toDays(), d.toHoursPart(), d.toMinutesPart(), d.toSecondsPart());
}
/** Arrenca una gravacio JFR sobre aquest mateix proces. */
public void gravarJfr(String fitxer, int segons) throws Exception {
long pid = ProcessHandle.current().pid();
var proces = new ProcessBuilder("jcmd", String.valueOf(pid),
"JFR.start", "name=bibliotech", "settings=profile",
"duration=" + segons + "s", "filename=" + fitxer)
.inheritIO().start();
proces.waitFor();
System.out.println("Gravacio JFR iniciada: " + fitxer);
}
public static void main(String[] args) throws Exception {
InformeDeRendiment informe = new InformeDeRendiment();
// Generar una mica de carrega perque les xifres no siguin totes zero
List<byte[]> brossa = new ArrayList<>();
for (int i = 0; i < 500; i++) {
brossa.add(new byte[512 * 1024]);
if (i % 50 == 0) brossa.clear();
}
brossa.clear();
System.out.println(informe.generar());
}
}══════════════════════════════════════════════════════════
INFORME DE RENDIMENT -- BiblioTech
══════════════════════════════════════════════════════════
JVM: OpenJDK 64-Bit Server VM 21.0.1+12
Proveidor: Eclipse Adoptium
Sistema: Linux amd64 (8 processadors)
En marxa: 0d 00h 00m 02s
PID: 18492
[ OK ] MEMORIA
Heap: 41 MB usats / 258 MB compromesos / 4.096 MB maxim (1,0 %)
No-heap: 22 MB (metaspace, memoria cau de codi)
Regions:
CodeHeap 'non-nmethods' 1 MB / 5 MB
Metaspace 14 MB
CodeHeap 'profiled nmethods' 3 MB / 117 MB
Compressed Class Space 1 MB / 1024 MB
G1 Eden Space 28 MB
G1 Old Gen 12 MB / 4096 MB
G1 Survivor Space 1 MB
CodeHeap 'non-profiled nmethods' 1 MB / 117 MB
[ OK ] RECOLLECCIO DE BROSSA
G1 Young Generation 12 recolleccions 41 ms (mitjana 3,4 ms)
G1 Old Generation 0 recolleccions 0 ms (mitjana 0,0 ms)
Total en GC: 41 ms de 2.104 ms de vida (1,95 %)
[ OK ] FILS
Vius: 11 (9 dimonis) Pic: 11 Creats en total: 12
Sense interbloqueigs detectats
[ OK ] CLASSES
Carregades ara: 1.284 Total historic: 1.284 Descarregades: 0
[ OK ] COMPILACIO JIT
Compilador: HotSpot 64-Bit Tiered Compilers Temps total: 412 ms
OPCIONS DE LA JVM
-Xms256m
-Xmx4g
-XX:+UseG1GC
──────────────────────────────────────────────────────────
SEGUENT PAS SI ALGUNA COSA ESTA EN AVIS O EN MAL
jstat -gcutil 18492 1000 30 (creix la columna O?)
jcmd 18492 GC.class_histogram (quina classe creix?)
jcmd 18492 Thread.print (interbloqueigs i piles)
jcmd 18492 JFR.start settings=profile duration=120s \
filename=/tmp/bibliotech.jfr
══════════════════════════════════════════════════════════Comentaris. Quatre observacions.
ManagementFactory dona accés a tot sense dependències externes. És la mateixa informació que consumeix jconsole, disponible des de dins del mateix procés. Un endpoint HTTP que retorni aquest informe converteix qualsevol aplicació en observable, i és essencialment el que fa Spring Boot Actuator (que veuràs a 12-07).
Les regles del semàfor estan justificades, no inventades. Més del 90 % del heap després del GC significa risc real d'OutOfMemoryError; més del 10 % del temps de vida en GC significa que l'aplicació passa més temps netejant que treballant; un interbloqueig és sempre vermell perquè hi ha fils que mai no es recuperaran. Un semàfor amb llindars arbitraris és pitjor que cap: genera alertes que s'ignoren.
La detecció d'interbloqueigs amb findDeadlockedThreads() és la joia amagada de l'API. Detecta automàticament el cicle d'espera de 08-04 i diu quin fil espera quin pany i qui el té. En un incident de producció amb l'aplicació penjada, aquestes tres línies donen el diagnòstic complet.
I les regions de memòria de l'informe són exactament el diagrama de l'apartat 1, amb els seus noms reals: G1 Eden Space, G1 Survivor Space, G1 Old Gen, Metaspace, Compressed Class Space i els tres CodeHeap del compilador JIT. La caixa negra té ara noms i números.
Conclusió
La JVM ha deixat de ser una caixa negra.
Coneixes les regions de memòria i què viu a cadascuna: la pila per fil amb els seus marcs, les seves variables locals i el seu StackOverflowError —que és el que fa cars els fils de plataforma i el que els fils virtuals de 10-06 resolen guardant la pila al heap—; el heap compartit on viu tot objecte amb la seva sobrecàrrega de 12 a 16 bytes i el seu farciment a múltiples de 8; el metaspace en memòria nativa que va substituir la PermGen; la memòria cau de codi del JIT; i la memòria nativa dels buffers directes. I saps la fórmula que evita l'error més car en contenidors: -Xmx no és la memòria del procés, i per això un -Xmx2g en un contenidor de 2 GB acaba en OOMKilled. Distingeixes els diferents OutOfMemoryError pel seu missatge —Java heap space, GC overhead limit exceeded, Metaspace, unable to create native thread, Direct buffer memory— perquè cadascun assenyala una regió i un remei diferents.
Entens el model generacional i la hipòtesi en què es recolza: la majoria dels objectes moren joves. D'aquí l'edèn on reservar és un increment de punter, els supervivents, la promoció després de diverses supervivències, i la propietat clau: el cost d'un GC menor és proporcional als objectes vius, no als morts. Per això crear objectes temporals és quasi de franc i per això "reutilitzar objectes per no generar brossa" sol ser contraproduent.
Saps com decideix el recol·lector què esborrar: abastabilitat des de les arrels del GC —piles de tots els fils, camps static, referències JNI, fils vius, monitors—, no recompte de referències, i per això els cicles no són un problema a Java com sí que ho són en altres llenguatges. Coneixes les pauses stop-the-world, els punts segurs i el time to safepoint que de vegades és el veritable culpable. I compares els recol·lectors —Serial, Parallel, G1 per defecte amb les seves regions i el seu objectiu de pausa, ZGC i Shenandoah amb pauses per sota del mil·lisegon, i Epsilon que no recol·lecta— sabent que el 95 % de les aplicacions funcionen bé amb G1 i que de més de mil opcions només se'n toquen tres: -Xmx, -Xms i el recol·lector. Més les de diagnòstic, que no costen rendiment i són la diferència entre resoldre un incident i no resoldre'l.
I saps que les fuites de memòria a Java existeixen, amb una definició precisa —objectes abastables que ja no es faran servir— i una firma recognoscible: la memòria retinguda després de cada GC puja en escala fins al GC overhead limit exceeded. Reconeixes els quatre patrons clàssics, els has reproduït i els has corregit: la col·lecció que creix i ningú no buida (tota memòria cau necessita política d'expulsió), els listeners no donats de baixa (simetria subscriure/dessubscriure), el ThreadLocal en un pool els fils del qual no moren mai —fuita i filtració de dades entre peticions alhora—, i la classe interna no estàtica on vuitanta objectes de setze bytes retenen 160 MB a través del seu this$0.
Coneixes les quatre forces de referència i quan fer servir cadascuna, WeakHashMap amb els seus tres paranys —els valors són forts, la neteja no és immediata, els literals internats no es recol·lecten— i el seu cas d'ús real, que no és "memòria cau acotada" sinó "metadades associades a objectes vius". I saps que finalize està obsolet per set raons diferents, que la solució real és AutoCloseable amb try-with-resources, i que Cleaner és només la xarxa de seguretat — amb la seva classe d'estat obligatòriament static, perquè si retingués l'objecte extern no s'activaria mai.
Tens el mètode: mesurar abans d'optimitzar. Amb objectiu definit, perfilat en lloc d'intuïció, optimització del coll d'ampolla i reversió si no ha millorat. Amb la llei d'Amdahl com a recordatori que accelerar infinitament una cosa que ocupa el 5 % del temps millora el total un 5 %.
I tens les eines: jps per al PID, jstat -gcutil la columna O creixent de la qual delata una fuita en un minut, jmap -histo comparat en dos moments per assenyalar la classe culpable, jcmd amb Thread.print que detecta interbloqueigs automàticament, els bolcats de heap i Eclipse MAT amb la seva mida retinguda i el seu camí a les arrels, i sobretot JFR, amb un 1 % de sobrecàrrega, apte per deixar-lo gravant sempre en producció, de manera que quan hi hagi l'incident ja tinguis les últimes dotze hores registrades.
Saps per què un microbenchmark casolà menteix —ho vas demostrar mesurant cent milions d'arrels quadrades en 3,4 mil·lisegons, un resultat físicament impossible perquè el JIT va eliminar el bucle sencer— i coneixes els cinc motius: escalfament, eliminació de codi mort, plegament de constants, GC i soroll del sistema. I saps que la resposta és JMH, amb el seu escalfament, el seu Blackhole, els seus processos bifurcats, els seus intervals de confiança i el seu -prof gc — que et va dir que un stream assigna 128 bytes per operació on un bucle n'assigna zero, i que la diferència entre tots dos amb un milió d'elements és del 16 %: si no ets en un bucle calent, tria el que es llegeixi millor.
Entens el compilador JIT i per què el teu codi s'accelera sol: interpretació, C1, C2, compilació per nivells, i les optimitzacions que expliquen coses que donaves per suposades —l'inlining fa que els getters no costin res, l'anàlisi d'escapament fa desaparèixer els objectes que no escapen (per això els record temporals i els Optional intermedis solen ser gratis), i l'especulació amb desoptimització fa que el codi que ha vist pocs tipus vagi més ràpid—. I saps que d'aquí surt l'arrencada lenta de Java, i que AppCDS, AOT i GraalVM Native Image existeixen per mitigar-la.
I tens les bones pràctiques ordenades per impacte real: primer l'algorisme i l'estructura de dades —canviar O(n²) per O(n) va millorar 8.000 vegades a l'exemple—, després evitar feina innecessària (el Pattern precompilat, el problema N+1), reutilitzar objectes cars (HttpClient, DateTimeFormatter, Pattern), StringBuilder en bucles —mesurat: 324× més ràpid i mil vegades menys brossa amb 10.000 elements, i irrellevant amb 10—, evitar l'autoboxing, i dimensionar les col·leccions. Enfront dels microtrucs que no serveixen: ++i, bucles descendents, evitar getters, final decoratiu, System.gc() i la reutilització d'objectes.
BiblioTech, en tancar el mòdul 10, és un altre projecte.
Els seus repositoris són genèrics: un únic Repositori<T extends Identificable> va substituir les dues-centes cinquanta línies duplicades de CatalegConcurrent i RegistrePrestecsSegur, amb PECS aplicat i un Resultat<T> el tipus de retorn del qual documenta el contracte.
Les seves entitats porten anotacions pròpies —@CampCsv, @Auditable, @Validar, @Reintentable— i té el seu propi motor de reflexió per llegir-les: un ExportadorAnotat que genera el CSV de qualsevol entitat sense conèixer-la, un ValidadorAnotat, un contenidor d'injecció de dependències amb singletons i detecció de cicles, i dos proxies dinàmics que afegeixen auditoria i reintents sense que la lògica de negoci contingui ni una sola línia de cap de les dues coses.
Els seus informes són Streams: el que al mòdul 5 eren setanta-quatre línies de bucles imbricats amb mapes manuals i comprovacions de null són ara dotze línies de groupingBy que es llegeixen com el seu enunciat. I el null que significava "no trobat" ha desaparegut, substituït per Optional a totes les cerques i llistes buides a totes les consultes.
Les seves dates són reals: LocalDate als préstecs, Instant a l'auditoria, multes amb ChronoUnit.DAYS.between, avisos que cauen en dia hàbil amb TemporalAdjusters, CSV en ISO-8601 que qualsevol sistema del món entén, i un Clock injectat que fa les proves deterministes.
El seu domini és una jerarquia segellada on el compilador verifica que has cobert tots els materials, amb estats de préstec modelats com a records on les combinacions impossibles no es poden escriure, switch exhaustius sense default, blocs de text per al SQL i el JSON, i un ServidorCataleg amb fils virtuals que atén desenes de milers de connexions amb el mateix codi seqüencial que abans n'atenia cinquanta.
I ara, a més, se sap mesurar: coneix el seu propi consum per regió, el seu temps en GC, els seus fils, les seves classes, i sap detectar els seus propis interbloqueigs.
I tanmateix, tot això està fet a mà.
Vas escriure un contenidor d'injecció de dependències de cent cinquanta línies per no cridar new, i funciona — però no té àmbits, ni cicle de vida, ni configuració per entorn, ni gestió de transaccions, ni res del que una aplicació real necessita. Spring porta vint anys resolent això.
La teva persistència continua essent fitxers CSV. Amb escriptura atòmica, amb charset explícit, amb exportador universal per anotacions — i continua essent CSV. No hi ha consultes, no hi ha índexs, no hi ha transaccions, no hi ha integritat referencial, i dos processos escrivint alhora ho trenquen. Les bases de dades relacionals existeixen des del 1970 i Hibernate hi mapa els teus objectes.
Vas escriure un ExportadorAnotat per generar CSV i, a 09-06, un "pedaç didàctic" amb indexOf per llegir JSON que es trenca amb el primer escapament o la primera imbricació. Jackson fa això bé en una línia. I les teves entitats continuen tenint cinquanta línies de getters, equals, hashCode i toString que Lombok genera amb una anotació, fent servir exactament el processador d'anotacions que vas estudiar a 10-02.
El teu logging és java.util.logging, triat a 06-07 per no afegir dependències, amb la limitació que ja es va assenyalar allà: l'ecosistema sencer fa servir SLF4J i una façana que permeti canviar d'implementació sense tocar el codi.
Compilar i empaquetar BiblioTech continua essent javac a mà, amb un classpath escrit a mà, sense gestió de dependències, sense versions, sense fases, sense reproductibilitat. Maven resol això, i és la raó per la qual en aquesta lliçó els benchmarks JMH van aparèixer amb un mvn package que no podies executar.
I el més greu de tot: no hi ha ni una sola prova automàtica. Totes les verificacions d'aquest mòdul han estat un main que imprimeix i un humà que mira. Cada refactorització —els genèrics, els streams, java.time, les classes segellades, els fils virtuals— s'ha fet sense cap xarxa de seguretat. Aquell Clock injectable de 10-05, que vam fer precisament per poder provar el codi, encara no prova res.
Al mòdul 11, Frameworks i biblioteques, s'acaba d'escriure-ho tot a mà. Veuràs què és un framework i per què la inversió de control canvia qui crida qui. Veuràs Spring, i el teu contenidor de cent cinquanta línies es convertirà en l'ApplicationContext amb tot el que li faltava — i @Transactional ja no et semblarà màgia, perquè saps que és un proxy dinàmic. Veuràs Hibernate, i el CSV de BiblioTech es convertirà en una base de dades real amb @Entity i @Id llegides per la mateixa reflexió que vas estudiar. Veuràs JUnit, i per fi hi haurà proves: el Clock fix, els casos límit de les multes, l'exhaustivitat dels switch, tot verificat automàticament. Veuràs Maven, i compilar, gestionar dependències, executar proves i empaquetar serà una sola ordre. Veuràs Mockito per provar el que depèn de la xarxa i de la base de dades. I veuràs les biblioteques essencials de l'ecosistema: Jackson, que resoldrà per fi el pedaç del JSON de 09-06; Lombok, el processador d'anotacions del qual ja entens; i SLF4J, la façana de logging que faltava.
Tot el que has après en aquest mòdul és el que fa que el següent no sigui màgia. Els frameworks del mòdul 11 estan construïts sobre genèrics, anotacions, reflexió, proxies dinàmics, streams i una JVM el comportament de la qual ja coneixes. N'has escrit versions en miniatura de gairebé tots amb les teves pròpies mans.
Ara faràs servir els de debò.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
