Cod Java Buggy: Top 10 cele mai frecvente greșeli pe care le fac dezvoltatorii Java
Publicat: 2022-03-11Java este un limbaj de programare care a fost dezvoltat inițial pentru televiziunea interactivă, dar de-a lungul timpului s-a răspândit peste tot oriunde poate fi folosit software. Proiectat cu noțiunea de programare orientată pe obiecte, eliminând complexitățile altor limbaje precum C sau C++, colectarea gunoiului și o mașină virtuală agnostică din punct de vedere arhitectural, Java a creat un nou mod de programare. În plus, are o curbă de învățare blândă și pare să adere cu succes la propria sa moto - „Scrie o dată, alergă peste tot”, ceea ce este aproape întotdeauna adevărat; dar problemele Java sunt încă prezente. Voi aborda zece probleme Java care cred că sunt cele mai frecvente greșeli.
Greșeala comună #1: neglijarea bibliotecilor existente
Este cu siguranță o greșeală pentru dezvoltatorii Java să ignore nenumăratele biblioteci scrise în Java. Înainte de a reinventa roata, încercați să căutați biblioteci disponibile - multe dintre ele au fost lustruite de-a lungul anilor de existență și sunt libere de utilizat. Acestea ar putea fi biblioteci de înregistrare, cum ar fi logback și Log4j, sau biblioteci legate de rețea, cum ar fi Netty sau Akka. Unele dintre biblioteci, cum ar fi Joda-Time, au devenit un standard de facto.
Următoarea este o experiență personală dintr-unul dintre proiectele mele anterioare. Partea de cod responsabilă pentru evadarea HTML a fost scrisă de la zero. A funcționat bine ani de zile, dar în cele din urmă a întâlnit o intrare de utilizator care a făcut-o să se rotească într-o buclă infinită. Utilizatorul, constatând că serviciul nu răspunde, a încercat să reîncerce cu aceeași intrare. În cele din urmă, toate CPU-urile de pe serverul alocate pentru această aplicație au fost ocupate de această buclă infinită. Dacă autorul acestui instrument naiv de evadare HTML ar fi decis să folosească una dintre bibliotecile bine cunoscute disponibile pentru evadarea HTML, cum ar fi HtmlEscapers de la Google Guava, probabil că acest lucru nu s-ar fi întâmplat. Cel puțin, adevărat pentru majoritatea bibliotecilor populare cu o comunitate în spate, eroarea ar fi fost găsită și remediată mai devreme de către comunitate pentru această bibliotecă.
Greșeala obișnuită #2: lipsește cuvântul cheie „break” într-un bloc Switch-Case
Aceste probleme Java pot fi foarte jenante și, uneori, rămân nedescoperite până când sunt rulate în producție. Comportamentul fallthrough în instrucțiunile switch este adesea util; cu toate acestea, lipsa unui cuvânt cheie „break” atunci când un astfel de comportament nu este dorit poate duce la rezultate dezastruoase. Dacă ați uitat să puneți o „pauză” în „cazul 0” în exemplul de cod de mai jos, programul va scrie „Zero” urmat de „Unul”, deoarece fluxul de control din interiorul aici va trece prin întreaga instrucțiune „switch” până când ajunge la o „pausă”. De exemplu:
public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }
În cele mai multe cazuri, soluția mai curată ar fi utilizarea polimorfismului și mutarea codului cu comportamente specifice în clase separate. Greșelile Java precum aceasta pot fi detectate folosind analizoare de cod statice, de exemplu FindBugs și PMD.
Greșeala comună nr. 3: Uitarea de a elibera resurse
De fiecare dată când un program deschide un fișier sau o conexiune la rețea, este important ca începătorii Java să elibereze resursa după ce ați terminat de utilizat. O atenție similară ar trebui luată în cazul în care s-ar face vreo excepție în timpul operațiunilor cu astfel de resurse. S-ar putea argumenta că FileInputStream are un finalizator care invocă metoda close() la un eveniment de colectare a gunoiului; cu toate acestea, deoarece nu putem fi siguri când va începe un ciclu de colectare a gunoiului, fluxul de intrare poate consuma resurse de computer pentru o perioadă nedeterminată de timp. De fapt, există o declarație cu adevărat utilă și îngrijită introdusă în Java 7 în special pentru acest caz, numită try-with-resources:
private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }
Această instrucțiune poate fi utilizată cu orice obiect care implementează interfața AutoClosable. Se asigură că fiecare resursă este închisă până la sfârșitul declarației.
Greșeala comună #4: Scurgeri de memorie
Java folosește gestionarea automată a memoriei și, deși este o ușurare să uiți de alocarea și eliberarea manuală a memoriei, aceasta nu înseamnă că un dezvoltator Java începător nu ar trebui să fie conștient de modul în care este utilizată memoria în aplicație. Probleme cu alocările de memorie sunt încă posibile. Atâta timp cât un program creează referințe la obiecte care nu mai sunt necesare, nu va fi eliberat. Într-un fel, încă putem numi această scurgere de memorie. Scurgerile de memorie în Java se pot întâmpla în diferite moduri, dar cel mai frecvent motiv este referințele la obiecte eterne, deoarece colectorul de gunoi nu poate elimina obiectele din heap cât timp există încă referințe la ele. Se poate crea o astfel de referință definind o clasă cu un câmp static care conține o colecție de obiecte și uitând să setați acel câmp static la null după ce colecția nu mai este necesară. Câmpurile statice sunt considerate rădăcini GC și nu sunt niciodată colectate.
Un alt motiv potențial din spatele unor astfel de scurgeri de memorie este un grup de obiecte care se referă unele la altele, provocând dependențe circulare, astfel încât colectorul de gunoi nu poate decide dacă aceste obiecte cu referințe de dependență încrucișată sunt sau nu necesare. O altă problemă este scurgerile în memoria non-heap atunci când este utilizat JNI.
Exemplul de scurgere primitivă ar putea arăta astfel:
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }
Acest exemplu creează două sarcini programate. Prima sarcină ia ultimul număr dintr-o decă numită „numere” și tipărește numărul și dimensiunea dequei în cazul în care numărul este divizibil cu 51. A doua sarcină pune numere în deque. Ambele sarcini sunt programate la o rată fixă și rulează la fiecare 10 ms. Dacă codul este executat, veți vedea că dimensiunea deque-ului crește permanent. Acest lucru va face în cele din urmă să umple deque-ul cu obiecte care consumă toată memoria heap disponibilă. Pentru a preveni acest lucru, păstrând în același timp semantica acestui program, putem folosi o metodă diferită pentru preluarea numerelor din deque: „pollLast”. Spre deosebire de metoda „peekLast”, „pollLast” returnează elementul și îl elimină din deque, în timp ce „peekLast” returnează doar ultimul element.
Pentru a afla mai multe despre scurgerile de memorie în Java, vă rugăm să consultați articolul nostru care a demistificat această problemă.
Greșeala comună #5: Alocarea excesivă a gunoiului
Alocarea excesivă a gunoiului se poate întâmpla atunci când programul creează o mulțime de obiecte de scurtă durată. Colectorul de gunoi funcționează continuu, eliminând obiectele care nu sunt necesare din memorie, ceea ce afectează negativ performanța aplicațiilor. Un exemplu simplu:
String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));
În dezvoltarea Java, șirurile sunt imuabile. Deci, la fiecare iterație este creat un șir nou. Pentru a rezolva acest lucru, ar trebui să folosim un StringBuilder mutabil:
StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));
În timp ce prima versiune necesită destul de mult timp pentru a fi executată, versiunea care utilizează StringBuilder produce un rezultat într-o perioadă semnificativ mai mică de timp.
Greșeala comună #6: Folosirea referințelor nule fără nevoie
Evitarea folosirii excesive a null este o practică bună. De exemplu, este de preferat să returnați matrice sau colecții goale din metode în loc de valori nule, deoarece poate ajuta la prevenirea NullPointerException.
Luați în considerare următoarea metodă care traversează o colecție obținută dintr-o altă metodă, așa cum se arată mai jos:
List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }
Dacă getAccountIds() returnează null atunci când o persoană nu are cont, atunci NullPointerException va fi declanșată. Pentru a remedia acest lucru, va fi necesară o verificare nulă. Cu toate acestea, dacă în loc de un null returnează o listă goală, atunci NullPointerException nu mai este o problemă. Mai mult, codul este mai curat, deoarece nu trebuie să verificăm null variabila accountIds.
Pentru a face față altor cazuri în care se dorește evitarea nulurilor, pot fi folosite strategii diferite. Una dintre aceste strategii este să utilizați tipul Opțional, care poate fi fie un obiect gol, fie un înveliș cu o anumită valoare:
Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }
De fapt, Java 8 oferă o soluție mai concisă:
Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);
Tipul opțional a făcut parte din Java încă din versiunea 8, dar este bine cunoscut de multă vreme în lumea programării funcționale. Înainte de aceasta, era disponibil în Google Guava pentru versiunile anterioare de Java.
Greșeala comună #7: Ignorarea excepțiilor
Este adesea tentant să lăsați excepțiile netratate. Cu toate acestea, cea mai bună practică atât pentru dezvoltatorii Java începători, cât și pentru cei experimentați este de a le gestiona. Excepțiile sunt aruncate intenționat, așa că în majoritatea cazurilor trebuie să abordăm problemele care cauzează aceste excepții. Nu trece cu vederea aceste evenimente. Dacă este necesar, puteți fie să-l aruncați din nou, să afișați un dialog de eroare utilizatorului sau să adăugați un mesaj în jurnal. Cel puțin, ar trebui explicat de ce excepția a fost lăsată netratată pentru a permite altor dezvoltatori să cunoască motivul.
selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }
O modalitate mai clară de a evidenția nesemnificația unei excepții este codificarea acestui mesaj în numele variabilei excepțiilor, astfel:
try { selfie.delete(); } catch (NullPointerException unimportant) { }
Greșeala comună #8: Excepție de modificare concomitentă
Această excepție apare atunci când o colecție este modificată în timp ce se repetă peste ea folosind alte metode decât cele furnizate de obiectul iterator. De exemplu, avem o listă de pălării și vrem să le eliminăm pe toate cele care au clapete pentru urechi:
List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }
Dacă rulăm acest cod, „ConcurrentModificationException” va fi ridicată, deoarece codul modifică colecția în timp ce o iterează. Aceeași excepție poate apărea dacă unul dintre firele de execuție multiple care lucrează cu aceeași listă încearcă să modifice colecția în timp ce alții repetă peste ea. Modificarea concomitentă a colecțiilor în mai multe fire este un lucru firesc, dar ar trebui tratată cu instrumente obișnuite din setul de instrumente de programare concomitentă, cum ar fi blocări de sincronizare, colecții speciale adoptate pentru modificarea concomitentă etc. Există diferențe subtile în ceea ce privește modul în care această problemă Java poate fi rezolvată în carcase cu un singur filet și carcase cu mai multe filete. Mai jos este o scurtă discuție despre câteva moduri în care acest lucru poate fi gestionat într-un singur scenariu cu fir:

Colectați obiecte și îndepărtați-le într-o altă buclă
Colectarea pălăriilor cu clapete pentru urechi într-o listă pentru a le elimina ulterior dintr-o altă buclă este o soluție evidentă, dar necesită o colecție suplimentară pentru depozitarea pălăriilor pentru a fi îndepărtate:
List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }
Utilizați metoda Iterator.remove
Această abordare este mai concisă și nu are nevoie de o colecție suplimentară pentru a fi creată:
Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }
Utilizați metodele ListIterator
Utilizarea iteratorului de listă este adecvată atunci când colecția modificată implementează interfața Listă. Iteratoarele care implementează interfața ListIterator acceptă nu numai operațiuni de eliminare, ci și operațiuni de adăugare și setare. ListIterator implementează interfața Iterator, astfel încât exemplul ar arăta aproape la fel ca metoda Iterator remove. Singura diferență este tipul de iterator hat și modul în care obținem acel iterator cu metoda „listIterator()”. Fragmentul de mai jos arată cum să înlocuiți fiecare pălărie cu clapete pentru urechi cu sombrero folosind metodele „ListIterator.remove” și „ListIterator.add”:
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }
Cu ListIterator, apelurile de metodă de eliminare și adăugare pot fi înlocuite cu un singur apel de setat:
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }
Utilizați metode de flux introduse în Java 8 Cu Java 8, programatorii au capacitatea de a transforma o colecție într-un flux și de a filtra acel flux în funcție de anumite criterii. Iată un exemplu despre modul în care stream-ul api ne-ar putea ajuta să filtram pălăriile și să evităm „ConcurrentModificationException”.
hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));
Metoda „Collectors.toCollection” va crea o nouă ArrayList cu pălării filtrate. Aceasta poate fi o problemă dacă condiția de filtrare ar fi îndeplinită de un număr mare de articole, rezultând o ArrayList mare; prin urmare, ar trebui să fie folosit cu grijă. Utilizați metoda List.removeIf prezentată în Java 8 O altă soluție disponibilă în Java 8, și în mod clar cea mai concisă, este utilizarea metodei „removeIf”:
hats.removeIf(IHat::hasEarFlaps);
Asta e. Sub capotă, folosește „Iterator.remove” pentru a realiza comportamentul.
Utilizați colecții specializate
Dacă la început am decis să folosim „CopyOnWriteArrayList” în loc de „ArrayList”, atunci nu ar fi fost nicio problemă, deoarece „CopyOnWriteArrayList” oferă metode de modificare (cum ar fi set, add, and remove) care nu se schimbă. matricea de suport a colecției, ci mai degrabă creați o nouă versiune modificată a acesteia. Acest lucru permite repetarea versiunii originale a colecției și modificări ale acesteia în același timp, fără riscul de „ConcurrentModificationException”. Dezavantajul acelei colecții este evident - generarea unei noi colecții cu fiecare modificare.
Există și alte colecții adaptate pentru cazuri diferite, de exemplu „CopyOnWriteSet” și „ConcurrentHashMap”.
O altă posibilă greșeală cu modificările concurente ale colecției este crearea unui flux dintr-o colecție și, în timpul iterației fluxului, modificarea colecției de suport. Regula generală pentru fluxuri este de a evita modificarea colecției de bază în timpul interogării fluxului. Următorul exemplu va arăta o modalitate incorectă de a gestiona un flux:
List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));
Metoda peek adună toate elementele și efectuează acțiunea prevăzută asupra fiecăruia dintre ele. Aici, acțiunea încearcă să elimine elemente din lista de bază, ceea ce este eronat. Pentru a evita acest lucru, încercați unele dintre metodele descrise mai sus.
Greșeala comună #9: Încălcarea contractelor
Uneori, codul furnizat de biblioteca standard sau de un furnizor terță parte se bazează pe reguli care ar trebui respectate pentru ca lucrurile să funcționeze. De exemplu, ar putea fi hashCode și equals contract care, atunci când este urmat, face ca funcționarea să fie garantată pentru un set de colecții din cadrul de colecție Java și pentru alte clase care folosesc metodele hashCode și equals. Nerespectarea contractelor nu este genul de eroare care duce întotdeauna la excepții sau la rupere de compilare a codului; este mai complicat, pentru că uneori schimbă comportamentul aplicației fără niciun semn de pericol. Codul eronat ar putea intra în lansarea de producție și poate provoca o mulțime de efecte nedorite. Acestea pot include comportamentul prost al interfeței de utilizare, rapoartele de date greșite, performanța slabă a aplicației, pierderea datelor și multe altele. Din fericire, aceste bug-uri dezastruoase nu se întâmplă foarte des. Am menționat deja hashCode și contractul egal. Este folosit în colecții care se bazează pe hashing și compararea obiectelor, cum ar fi HashMap și HashSet. Mai simplu spus, contractul conține două reguli:
- Dacă două obiecte sunt egale, atunci codurile lor hash ar trebui să fie egale.
- Dacă două obiecte au același cod hash, atunci ele pot fi sau nu egale.
Încălcarea primei reguli a contractului duce la probleme în timpul încercării de a prelua obiecte dintr-o hartă hash. A doua regulă înseamnă că obiectele cu același cod hash nu sunt neapărat egale. Să examinăm efectele încălcării primei reguli:
public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }
După cum puteți vedea, clasa Boat a suprascris metodele equals și hashCode. Cu toate acestea, a rupt contractul, deoarece hashCode returnează valori aleatorii pentru același obiect de fiecare dată când este apelat. Următorul cod cel mai probabil nu va găsi o barcă numită „Enterprise” în hashset, în ciuda faptului că am adăugat acest tip de barcă mai devreme:
public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }
Un alt exemplu de contract implică metoda finalizării. Iată un citat din documentația oficială java care descrie funcția sa:
Contractul general de finalizare este că acesta este invocat dacă și atunci când mașina virtuală JavaTM a determinat că nu mai există niciun mijloc prin care acest obiect să poată fi accesat de către orice fir (care nu a murit încă), decât ca urmare a unui acțiune întreprinsă prin finalizarea unui alt obiect sau clasă care este gata să fie finalizată. Metoda finalize poate lua orice acțiune, inclusiv punerea la dispoziție a acestui obiect din nou pentru alte fire; scopul obișnuit al finalizării, totuși, este de a efectua acțiuni de curățare înainte ca obiectul să fie aruncat irevocabil. De exemplu, metoda finalize pentru un obiect care reprezintă o conexiune de intrare/ieșire poate efectua tranzacții I/O explicite pentru a întrerupe conexiunea înainte ca obiectul să fie eliminat definitiv.
S-ar putea decide să folosească metoda finalize pentru eliberarea resurselor, cum ar fi gestionanții de fișiere, dar asta ar fi o idee proastă. Acest lucru se datorează faptului că nu există garanții de timp privind momentul în care finalizarea va fi invocată, deoarece este invocată în timpul colectării gunoiului, iar timpul GC este indeterminabil.
Greșeala comună #10: Utilizarea tipului brut în loc de unul parametrizat
Tipurile brute, conform specificațiilor Java, sunt tipuri care fie nu sunt parametrizate, fie membri non-statici ai clasei R care nu sunt moștenite de la superclasa sau superinterfața lui R. Nu existau alternative la tipurile brute până când tipurile generice au fost introduse în Java . Acceptă programarea generică începând cu versiunea 1.5, iar genericele au fost, fără îndoială, o îmbunătățire semnificativă. Cu toate acestea, din motive de compatibilitate inversă, a fost lăsat o capcană care ar putea rupe sistemul de tip. Să ne uităm la următorul exemplu:
List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Aici avem o listă de numere definite ca o ArrayList brută. Deoarece tipul său nu este specificat cu parametrul tip, putem adăuga orice obiect în el. Dar în ultima linie aruncăm elemente în int, îl dublem și tipărim numărul dublat la ieșirea standard. Acest cod se va compila fără erori, dar odată rulat va ridica o excepție de rulare, deoarece am încercat să turnăm un șir într-un număr întreg. În mod evident, sistemul de tip nu ne poate ajuta să scriem cod sigur dacă ascundem informațiile necesare din acesta. Pentru a remedia problema, trebuie să specificăm tipul de obiecte pe care le vom stoca în colecție:
List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Singura diferență față de original este linia care definește colecția:
List<Integer> listOfNumbers = new ArrayList<>();
Codul fix nu s-ar compila deoarece încercăm să adăugăm un șir într-o colecție care se așteaptă să stocheze numai numere întregi. Compilatorul va afișa o eroare și va indica linia în care încercăm să adăugăm șirul „Douăzeci” la listă. Este întotdeauna o idee bună să parametrizați tipurile generice. În acest fel, compilatorul poate face toate verificările de tip posibile, iar șansele de excepții de rulare cauzate de inconsecvențele sistemului de tip sunt minimizate.
Concluzie
Java ca platformă simplifică multe lucruri în dezvoltarea de software, bazându-se atât pe JVM sofisticat, cât și pe limbajul în sine. Cu toate acestea, caracteristicile sale, cum ar fi eliminarea gestionării manuale a memoriei sau instrumentele OOP decente, nu elimină toate problemele și problemele cu care se confruntă un dezvoltator Java obișnuit. Ca întotdeauna, cunoștințele, practica și tutorialele Java ca acesta sunt cele mai bune mijloace pentru a evita și a aborda erorile aplicației - așa că cunoașteți-vă bibliotecile, citiți java, citiți documentația JVM și scrieți programe. Nu uitați nici de analizoarele de cod statice, deoarece acestea ar putea indica erorile reale și pot evidenția erori potențiale.