Ai folosit vreodată System.out.println în loc de un breakpoint? Ai dat vina pe baza de date pentru o problemă de performanță doar ca să descoperi mai târziu că nu era ea de vină? Realitatea este că debuggingul nu este magie, ci este o abilitate. Una pe care puțini o învață structurat. În acest articol încerc să aduc puțină lumină într-un proces adesea haotic: de la înțelegerea unui simplu NullPointerException, până la investigarea problemelor de performanță care apar doar în producție.
Dacă te-ai săturat să "încerci lucruri" la întâmplare și vrei, în sfârșit, să înțelegi ceea ce faci și de ce funcționează, ai ajuns unde trebuie.
Bine ai venit în jocul numit debugging!
Care este cel mai simplu și mai banal sfat de debugging pe care îl dau cel mai des?:
"Read the f-ing error."
Nu o să descriu aici structura unui stack trace, presupun că dacă citești asta, știi deja cum arată. Exception, Caused by, linia X, clasa Y... nimic spectaculos. Și poate tocmai pentru că pare banal, tindem să citim logurile pe diagonală, să ne uităm la primele două rânduri și să sărim direct în cod.
Asta e rețeta perfectă să ratezi exact informația care ți-ar fi scutit ore de debugging. Voi veni în schimb cu o scurtă poveste.
Foarte recent, anul acesta, m-am trezit într-o dimineață cu un NullPointerException
în producție. Niciun deployment peste noapte și, aparent, nicio modificare. Deschid logurile și, evident:
Caused by:
java.lang.NullPointerException
… linia 130.
Ajung în cod: linia 130 - un frumos lanț de apeluri. Trei obiecte posibile care ar putea fi null. Ce faci? Te apuci să bagi if(obj != null)
peste tot? Poate. Sau...
Dacă ai lucrat cu Java 16 sau Java mai nou, știi că lucrurile s-au schimbat: NullPointerException nu mai este generic: acum îți spune exact ce este null.
(Dacă n-ai știut asta până acum, fă rapid un query la GPT. Merită!)
Citesc mai departe logul și găsesc perla:
java.lang.NullPointerException: Cannot invoke
"com.my.project.Client
.load(java.util.Collection)"
because "this.layer" is null
because "this.layer" is null
Perfect. Gata. Am găsit obiectul vinovat. Pot merge în cod să văd de ce layer este null.
Dar... nu mă opresc aici. Mai derulez puțin logul. Și acolo apare adevărata cauză, mascată, o singură dată printre sute de instanțe de NPE:
ConfigurationException: Failed configuring Catalog: The following properties are not present or have errors: ...
Boom. Nu era bug în cod, nu era obiectul nedefinit degeaba.Era o eroare de configurare. Un parametru lipsă, o proprietate greșit scrisă, și totul a căzut în lanț. NPE-ul era doar efectul, nu cauza.
Soluția? Corectez configurarea, restartez serviciul și totul revine la normal. Și, desigur, îl caut și pe colegul care a scris configurația... ca să-l mustru un pic.
Concluzia este simplă, debuggingul începe cu ochii, nu cu tastatura. Un log bine citit îți poate economisi ore de testat scenarii false. Uneori, cauza se află fix acolo, dar trebuie să citești cu atenție până jos. Nu să sari la linia 130. Citește cu atenție. Citește tot.
Și asta ne duce la următorul subiect ...
Pentru că foarte mulți au acum reflexul "am eroare -> bag în GPT", chiar și pentru cazuri în care informația necesară este deja în stack trace sau în cod, m-am gândit să scriu câteva cuvinte și despre asta.
Avem la dispoziție o unealtă incredibilă, ChatGPT sau alte LLM-uri, și tentația este mare: apare o eroare -> copiem mesajul și îl aruncăm în chat. Dar nu toate erorile merită sau necesită acest pas. Pentru unele, este chiar contraproductiv. De ce? Pentru că răspunsul real este deja în fața ta, în loguri, în stack trace, în cod.
Iată câteva exemple de erori pentru care nu ar trebui să te bazezi pe GPT:
Cea mai clasică și frecventă eroare Java. Dacă ai un NullPointerException
, stack trace-ul îți spune exact unde a apărut și (dacă folosești Java 16+) și ce obiect era null. Ce să faci în loc de a întreba GPT:
Citește stack trace-ul complet.
Caută lanțul de apeluri.
Verifică inițializarea și flow-ul de date.
Verifică eventualele ramuri de cod care pot returna null.
De ce GPT nu ajută aici? GPT nu are acces la codul tău concret, deci îți va da soluții generice de genul "folosește Optional" sau "fă un null check", fără să rezolve cauza reală.
Pentru că acest capitol este destul de banal, și pentru că acesta nu este un curs, ci un articol, nu voi trece prin toate tipurile de erori. Hai să le enumerăm rapid - și te îndemn, dacă nu le știi deja, să folosești GPT pentru a afla mai multe detalii. Da, GPT - era în care Google era prima opțiune a cam apus.
Erori uzuale:
NullPointerException,
ClassCastException,
NumberFormatException,
IndexOutOfBoundsException
ArrayIndexOutOfBoundsException,
În plus, vin și cu o eroare bonus - ceea ce eu numesc: The Spring trap. Promit că după asta trecem la lucruri mai serioase.
Spring lucrează cu beans, iar fiecare bean are un scope - adică un "ciclu de viață". Principalele scope-uri sunt:
singleton (default) - o singură instanță pentru tot contextul Spring;
prototype - o nouă instanță la fiecare injectare;
request - o instanță per request HTTP (în context web);
session - o instanță per sesiune (în context web);
application - o instanță per context de aplicație;
Acum, scopul default este - ai ghicit - singleton. Asta înseamnă că, dacă nu specifici altfel (și de multe ori lucrăm doar cu adnotări simple), vei avea o singură instanță a acelui bean pe tot runtime-ul aplicației.
Și totul e bine... până când beanul respectiv are membri cu stare. Și dacă aducem în această supă și faptul că Spring rulează într-un mediu multi-threaded — adică același bean singleton poate fi accesat simultan de mai multe threaduri, creăm rețeta perfectă pentru un mic dezastru.
Rezultatul? Foarte ușor ajungi să modifici starea unui bean și să creezi probleme de concurență greu de depistat.
De ce menționez asta? Pentru că am întâlnit această problemă în absolut toate proiectele Spring la care am lucrat până acum. Toate.
O scurtă poveste: prima mea lună într-un nou proiect, întâmplare recentă. Intru în daily și aud fraza: "Clientii se plâng că, din când în când, foarte rar, primesc brusc un răspuns diferit la același query. Dar dacă îl rulează din nou, vine răspunsul corect."
Urmat de: "Probabil că nu își dau seama și greșesc query-ul."
Eu, din ultimul rând, cu zâmbetul până la urechi: Asta sună a bean scope issue. The Spring Trap.
Câteva ore mai târziu… fix asta era: un bean singleton cu stare, modificat în timpul execuției, accesat concurent de mai multe threaduri.
The Spring Trap este una dintre acele "erori" greu de diagnosticat și mult prea frecvente. Fii atent la scope-uri și scapă de capcane înainte să le simți în producție.
Și acum, așa cum am promis, trecem la lucruri mai serioase. Haide să povestim despre probleme de performanță și câteva moduri prin care le poți aborda.
Ajungem acum la un subiect un pic mai interesant: problemele de performanță. Nu mai vorbim de erori vizibile — vorbim de acele situații când "merge... dar merge greu". Și din experiență, sunt adesea cele mai frustrante și cel mai greu de diagnosticat. Pentru asta avem o categorie de unelte foarte utile: profiling tools.
Ce este un profiling tool? Un profiler este o unealtă care "urmărește" aplicația ta în timp real și îți arată:
unde petrece timpul CPU;
ce metode sunt cele mai lente;
cât timp aștepți pe IO sau pe rețea;
cât timp petreci în Garbage Collection;
Este o unealtă complementară debuggingului clasic - nu cauți erori, ci optimizarea execuției.
Există multe profiling tools (YourKit, VisualVM, JProfiler etc). Dar pentru majoritatea cazurilor uzuale, profilingul integrat din IntelliJ IDEA este foarte puternic și extrem de convenabil. Este foarte bine integrat cu codul tău -> vezi direct în editor codul adnotat în dreapta cu timpul de execuție, ușor de pornit și altele. Mă voi axa pe acest tool, dar, principial vorbind poți folosi oricare tool ți se potrivește.
Ca să folosești profilerul din IntelliJ, rulezi aplicația cu "Run with Profiler", alegi tipul de profiler dorit și la final analizezi flame graphul sau call tree-ul generat — direct în editorul tău, cu codul adnotat și timpii de execuție vizibili în dreapta. Așa cum poți vedea în imaginea precedentă.
Iată cum arată un exemplu de call tree în profiler-ul din IntelliJ — fiecare ramură arată ce metode au fost apelate și cât timp au consumat (procentual) din totalul execuției.
Poți observa ușor în call tree care sunt zonele de code unde se pierde performanța. Mai mult, adnotările din dreapta codului te ajută să înțelegi cu ușurință bucata de cod care poate cauza probleme de performanță. Pare simplu și în general este, dar sunt niște aspecte care trebuie discutate.
Un lucru foarte important de înțeles este ce reprezintă acești timpi. Nu tot timpul măsurat de profiler este timp de execuție activ al codului tău. Putem împărți timpii pe două categorii:
CPU time -> efectiv cod care rulează pe procesor;
Este foarte ușor să te păcălești. Vezi că metoda ta petrece "20s" și tragi concluzia că este o problema. Dar de fapt 19s din acel timp poate fi așteptare în rețea (Socket.read()
) sau în Thread.sleep()
sau pe un Future.get()
. De aceea este important să înțelegi ce vezi. Folosește call tree pentru a investiga efectiv unde este timpul activ pe CPU. Uită-te separat la metoda de rețea, baza de date, file IO și nu optimiza cod care, de fapt, doar așteaptă.
Haide să ne uităm și la tabul de Timeline. Aici observăm threadurile de execuție și două culori magice: verde (timp de execuție pe CPU) și roșu (așteptare: IO, rețea, lockuri etc.). Și, te întrebi, de ce e aproape totul roșu? Exact, ai pus punctul pe "i". Există foarte multe situații - cum este și cea ilustrată în imagine - în care timpul petrecut efectiv în codul tău este doar o mică parte din timpul total de execuție. Restul este pur și simplu așteptare. Asta înseamnă că nu întotdeauna o problemă de performanță înseamnă "cod lent" - poate fi upstream lent, un serviciu extern care răspunde greu sau blocaje în rețea. Apropo, toate exemplele de până acum sunt cazuri reale, din producție, bazate pe probleme recente unde a trebuit să rulez sesiuni de profiling ca să înțeleg exact unde se pierdea timpul.
Implicit, IntelliJ activează un singur profiler simplu (sampler). Dar în realitate ai trei moduri de profiling posibile:
IntelliJ profiler - default, cel mai performant și precis, recomandat pentru flame graphs foarte detaliate.
Async profiler - low overhead, bun pentru overview.
Rămâi cu noi pentru mai multe detalii.
Cum le activezi pe toate? Setting-> Build, Execution, Deployment -> Java Profiler.
Am putea intra adânc în detalii, dar articolul acesta este mai degrabă un teaser decât un curs complet — așa că să trecem cu încredere la următorul subiect.
Și ajungem acum la o unealtă extrem de utilă, mai ales când ai probleme de performanță în producție și nu poți să le reproduci local: Java Flight Recorder (sau pe scurt, JFR). JFR este un profiler integrat direct în JVM, cu overhead minim, care îți permite să capturezi un "filmuleț" al comportamentului aplicației tale.
Ce înregistrează JFR?
consumul CPU;
timpii de execuție pe threaduri;
garbage collection;
lock contention (unde se blochează threadurile);
IO blocking;
thread states;
Practic, îți oferă o radiografie completă a stării runtime-ului JVM într-o anumită perioadă de timp.
Cum îl folosim? Se poate activa direct din command line (argumente JVM) sau prin diverse instrumente de monitorizare. Iată cum îl activezi direct din comanda bash:
-XX:StartFlightRecording=filename=/loc/recording.jfr,
duration=10m,settings=profile
După ce ai capturat fișierul .jfr, îl poți deschide foarte ușor în IntelliJ (care suportă acum vizualizare JFR) sau în Java Mission Control (tool oficial de la Oracle). Integrarea cu IntelliJ îți arată cod adnotat ca un profiler clasic (sampling/tracing).
Când te ajută cu adevărat JFR? Când ai o problemă de performanță care apare rar, nu apare pe local sau staging, depinde de traffic sau de anumite condiții reale. În plus, JFR este eficient și destul de sigur pentru uzul în producție.
JFR este arma ta secretă pentru debugging de performanță în producție. Nu o folosi doar când e "criză", uneori este util să rulezi un JFR periodic, ca să înțelegi trendurile reale ale aplicației.
Ar mai fi multe de spus și am putea discuta despre memory leaks, heap dumps, thread dumps și tot felul de alte "capcane" și unelte utile pentru debugging. Dar o să las asta pentru o sesiune viitoare.
Sper că v-am stârnit interesul și curiozitatea de a explora mai mult acest univers fascinant. Debuggingul nu înseamnă doar să repari buguri, înseamnă să înțelegi cu adevărat cum funcționează aplicația ta, ceea ce face bine și unde poate fi îmbunătățită.
Așa că, data viitoare când vă treziți în fața unui stack trace sau a unei aplicații care "merge greu", nu vă panicați. Luați-o metodic, citiți cu atenție logurile, folosiți un profiler când e cazul, și amintiți-vă: debuggingul este un joc în care cu cât joci mai mult, cu atât devii mai bun.
Dacă v-a plăcut articolul și vreți să aflați mai multe, stați pe aproape, poate revenim cu o continuare despre memory leaks & co.