Java Language
Java-valkuilen - Prestatieproblemen
Zoeken…
Invoering
Dit onderwerp beschrijft een aantal "valkuilen" (dwz fouten die beginnende Java-programmeurs maken) die betrekking hebben op de prestaties van Java-toepassingen.
Opmerkingen
In dit onderwerp worden enkele "micro" Java-coderingsmethoden beschreven die inefficiënt zijn. In de meeste gevallen zijn de inefficiënties relatief klein, maar het is nog steeds de moeite waard om ze te vermijden.
Valkuil - De overheadkosten van het maken van logberichten
TRACE
en DEBUG
zijn er om zeer gedetailleerd informatie over de werking van de gegeven code tijdens runtime te kunnen overbrengen. Het wordt meestal aanbevolen om het logniveau boven deze waarden in te stellen, maar u moet er goed op letten dat deze uitspraken geen invloed hebben op de prestaties, zelfs als ze 'uitgeschakeld' zijn.
Overweeg deze logboekverklaring:
// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString()
+ " parameters: " + Arrays.toString(veryLongParamArray));
Zelfs als het logboekniveau is ingesteld op INFO
, worden argumenten die zijn doorgegeven aan debug()
geëvalueerd bij elke uitvoering van de regel. Dit maakt het onnodig consumeren op verschillende punten:
-
String
aaneenschakeling: er worden meerdereString
instanties gemaakt -
InetAddress
kan zelfs een DNS-zoekopdracht uitvoeren. - the
veryLongParamArray
kan erg lang zijn - het creëren van een string daaruit kost geheugen, kost tijd
Oplossing
De meeste logboekregelingen bieden middelen om logberichten te maken met behulp van fixstrings en objectverwijzingen. Het logboekbericht wordt alleen geëvalueerd als het bericht daadwerkelijk is vastgelegd. Voorbeeld:
// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));
Dit werkt heel goed zolang alle parameters kunnen worden geconverteerd naar tekenreeksen met String.valueOf (Object) . Als de berekening van logboekberichten complexer is, kan het logboekniveau worden gecontroleerd voordat wordt geregistreerd:
if (LOG.isDebugEnabled()) {
// Argument expression evaluated only when DEBUG is enabled
LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
Arrays.toString(veryLongParamArray);
}
Hier wordt LOG.debug()
met de kostbare Arrays.toString(Obect[])
alleen verwerkt wanneer DEBUG
daadwerkelijk is ingeschakeld.
Valkuil - String-aaneenschakeling in een lus wordt niet geschaald
Beschouw de volgende code als illustratie:
public String joinWords(List<String> words) {
String message = "";
for (String word : words) {
message = message + " " + word;
}
return message;
}
Helaas is deze code niet efficiënt als de words
lang is. De kern van het probleem is deze verklaring:
message = message + " " + word;
Voor elke lusherhaling maakt deze instructie een nieuwe message
met een kopie van alle tekens in de oorspronkelijke message
met daaraan toegevoegd extra tekens. Dit genereert veel tijdelijke tekenreeksen en kopieert veel.
Wanneer we joinWords
analyseren, ervan uitgaande dat er N woorden zijn met een gemiddelde lengte van M, zien we dat O (N) tijdelijke tekenreeksen worden gemaakt en O (MN 2 ) tekens in het proces worden gekopieerd. De N2-component is bijzonder verontrustend.
De aanbevolen aanpak voor dit soort probleem 1 is om als volgt een StringBuilder
te gebruiken in plaats van een reeks aaneenschakeling:
public String joinWords2(List<String> words) {
StringBuilder message = new StringBuilder();
for (String word : words) {
message.append(" ").append(word);
}
return message.toString();
}
De analyse van joinWords2
moet rekening houden met de overheadkosten van het "laten groeien" van de StringBuilder
back-array met de karakters van de bouwer. Het blijkt echter dat het aantal nieuwe objecten dat is gemaakt O (logN) is en dat het aantal gekopieerde tekens O (MN) -tekens is. De laatste bevat tekens die zijn gekopieerd in de laatste aanroep naar toString()
.
(Het is mogelijk om dit verder af te stemmen door de StringBuilder
met de juiste capaciteit om mee te beginnen. De algemene complexiteit blijft echter hetzelfde.)
Terugkerend naar de oorspronkelijke methode van joinWords
, blijkt dat de kritische uitspraak door een typische Java-compiler zal worden geoptimaliseerd voor zoiets als dit:
StringBuilder tmp = new StringBuilder();
tmp.append(message).append(" ").append(word);
message = tmp.toString();
De Java-compiler zal de StringBuilder
niet "uit de lus" hijsen, zoals we met de hand hebben gedaan in de code voor joinWords2
.
Referentie:
1 - In Java 8 en hoger kan de Joiner
klasse worden gebruikt om dit specifieke probleem op te lossen. Dat is echter niet waar dit voorbeeld eigenlijk over zou moeten gaan .
Valkuil - Het gebruik van 'nieuw' om primitieve wrapper-instanties te maken is inefficiënt
Met de Java-taal kunt u new
gebruiken om instanties Integer
, Boolean
enzovoort te maken, maar dit is over het algemeen een slecht idee. Het is beter om autoboxing (Java 5 en hoger) of de methode valueOf
gebruiken.
Integer i1 = new Integer(1); // BAD
Integer i2 = 2; // BEST (autoboxing)
Integer i3 = Integer.valueOf(3); // OK
De reden dat expliciet het gebruik van een new Integer(int)
een slecht idee is, is dat het een nieuw object maakt (tenzij geoptimaliseerd door de JIT-compiler). Wanneer daarentegen autoboxing of een expliciete valueOf
oproep worden gebruikt, zal de Java runtime proberen een hergebruiken Integer
object uit een cache van reeds bestaande objecten. Elke keer dat de runtime een "hit" in de cache heeft, wordt voorkomen dat er een object wordt gemaakt. Dit bespaart ook heap-geheugen en vermindert GC-overheadkosten veroorzaakt door objectverloop.
Opmerkingen:
- In de afgelopen Java implementaties wordt autoboxing geïmplementeerd door te bellen
valueOf
, en er zijn caches voorBoolean
,Byte
,Short
,Integer
,Long
enCharacter
. - Het cachinggedrag voor de integrale typen wordt opgelegd door de Java Language Specification.
Valkuil - Het aanroepen van 'new String (String)' is inefficiënt
Het gebruik van new String(String)
om een string te dupliceren is inefficiënt en bijna altijd onnodig.
- Stringobjecten zijn onveranderlijk, dus u hoeft ze niet te kopiëren om ze te beschermen tegen wijzigingen.
- In sommige oudere versies van Java kunnen
String
objecten achtergrondmatrices delen met andereString
objecten. In die versies is het mogelijk om geheugen te lekken door een (kleine) substring van een (grote) string te maken en te behouden. Vanaf Java 7 wordenString
back-upmatrices echter niet gedeeld.
Omdat er geen tastbaar voordeel is, is het eenvoudigweg verspillen van een new String(String)
:
- Het maken van de kopie kost CPU-tijd.
- De kopie gebruikt meer geheugen waardoor de memorand footprint van de toepassing wordt vergroot en / of de overheadkosten van de GC worden verhoogd.
- Bewerkingen zoals
equals(Object)
enhashCode()
kunnen langzamer zijn als String-objecten worden gekopieerd.
Valkuil - Calling System.gc () is inefficiënt
Het is (bijna altijd) een slecht idee om System.gc()
aan te roepen.
De javadoc voor de methode gc()
geeft het volgende aan:
"Het aanroepen van de
gc
methode suggereert dat de Java Virtual Machine moeite doet om ongebruikte objecten te recyclen om het geheugen dat ze momenteel innemen beschikbaar te maken voor snel hergebruik. Wanneer de controle terugkeert van de methodeaanroep, heeft de Java Virtual Machine een uiterste inspanning gedaan om ruimte van alle weggegooide objecten. "
Hieruit kunnen enkele belangrijke punten worden getrokken:
Het gebruik van het woord "suggereert" in plaats van (zeg) "vertelt" betekent dat de JVM vrij is om de suggestie te negeren. Het standaard JVM-gedrag (recente releases) is om de suggestie te volgen, maar dit kan worden opgeheven door
-XX:+DisableExplicitGC
in te stellen bij het starten van de JVM.De uitdrukking "een beste poging om ruimte terug te winnen van alle weggegooide objecten" impliceert dat het aanroepen van
gc
een "volledige" afvalinzameling zal veroorzaken.
Dus waarom is System.gc()
een slecht idee?
Ten eerste is het runnen van een volledige afvalinzameling duur. Een volledige GC omvat het bezoeken en "markeren" van elk object dat nog bereikbaar is; dat wil zeggen elk object dat geen afval is. Als u dit activeert wanneer er niet veel afval moet worden ingezameld, doet de GC veel werk voor relatief weinig voordeel.
Ten tweede kan een volledige afvalinzameling de "plaats" -eigenschappen van de niet-verzamelde objecten verstoren. Objecten die ongeveer gelijktijdig door dezelfde thread worden toegewezen, hebben de neiging om dicht bij elkaar in het geheugen te worden toegewezen. Dit is goed. Objecten die tegelijkertijd worden toegewezen, zijn waarschijnlijk gerelateerd; dwz verwijzing naar elkaar. Als uw toepassing die referenties gebruikt, is de kans groot dat geheugentoegang sneller zal zijn vanwege verschillende geheugen- en paginacaching-effecten. Helaas heeft een volledige afvalverzameling de neiging om objecten rond te verplaatsen, zodat objecten die ooit dichtbij waren nu verder uit elkaar liggen.
Ten derde kan het uitvoeren van een volledige afvalinzameling ervoor zorgen dat uw aanvraag wordt onderbroken totdat de verzameling is voltooid. Terwijl dit gebeurt, reageert uw toepassing niet.
De beste strategie is zelfs om de JVM te laten beslissen wanneer de GC wordt uitgevoerd en wat voor soort verzameling ze moet uitvoeren. Als u niet stoort, kiest de JVM een tijd- en verzameltype dat de doorvoer optimaliseert of de GC-pauzetijden minimaliseert.
In het begin zeiden we "... (bijna altijd) een slecht idee ...". In feite zijn er een paar scenario's waar het een goed idee zou kunnen zijn:
Als u een eenheidstest uitvoert voor een code die gevoelig is voor het verzamelen van afval (bijv. Iets met finalizers of zwakke / zachte /
System.gc()
kan het nodig zijnSystem.gc()
roepen.In sommige interactieve toepassingen kunnen er bepaalde momenten zijn waarop de gebruiker niet kan schelen of er een pauze voor het ophalen van afval is. Een voorbeeld is een spel waarbij er natuurlijke pauzes zijn in het "spel"; bijv. bij het laden van een nieuw niveau.
Valkuil - Overmatig gebruik van primitieve wikkeltypen is inefficiënt
Overweeg deze twee stukjes code:
int a = 1000;
int b = a + 1;
en
Integer a = 1000;
Integer b = a + 1;
Vraag: Welke versie is efficiënter?
Antwoord: De twee versies zien er bijna identiek uit, maar de eerste versie is veel efficiënter dan de tweede.
De tweede versie gebruikt een weergave voor de nummers die meer ruimte gebruiken en is afhankelijk van automatisch boksen en automatisch ontkoppelen achter de schermen. In feite is de tweede versie direct equivalent aan de volgende code:
Integer a = Integer.valueOf(1000); // box 1000
Integer b = Integer.valueOf(a.intValue() + 1); // unbox 1000, add 1, box 1001
In vergelijking met de andere versie die int
gebruikt, zijn er duidelijk drie extra methodeaanroepen wanneer Integer
wordt gebruikt. In het geval van valueOf
de aanroepen elk een nieuw Integer
object maken en initialiseren. Al dit extra boks- en unboxing-werk zal de tweede versie waarschijnlijk een orde van grootte langzamer maken dan de eerste.
Daarnaast valueOf
de tweede versie objecten toe op de heap in elke waarde van aanroep. Hoewel het ruimtegebruik platformspecifiek is, ligt het waarschijnlijk in het bereik van 16 bytes voor elk Integer
object. De int
versie heeft daarentegen geen extra heapruimte nodig, ervan uitgaande dat a
en b
lokale variabelen zijn.
Een andere grote reden waarom primitieven sneller zijn dan hun box-equivalent, is hoe hun respectieve arraytypen in het geheugen worden vastgelegd.
Als u int[]
en Integer[]
als voorbeeld neemt, worden in het geval van een int[]
de int
waarden aaneengesloten in het geheugen vastgelegd. Maar in het geval van een geheel Integer[]
worden niet de waarden vastgelegd, maar verwijzingen (verwijzingen) naar objecten Integer
, die op hun beurt de werkelijke int
waarden bevatten.
Naast dat het een extra niveau van richting is, kan dit een grote tank zijn als het gaat om cacheplaats wanneer de waarden worden herhaald. In het geval van een int[]
de CPU alle waarden in de array in één keer in de cache kunnen ophalen, omdat ze aaneengesloten zijn in het geheugen. Maar in het geval van een geheel Integer[]
de CPU mogelijk een extra geheugen ophalen voor elk element, omdat de array alleen verwijzingen naar de werkelijke waarden bevat.
Kortom, het gebruik van primitieve wrapper-typen is relatief duur in zowel CPU- als geheugenbronnen. Het onnodig gebruiken ervan is efficiënt.
Valkuil - Het herhalen van de sleutels van een kaart kan inefficiënt zijn
De volgende voorbeeldcode is langzamer dan het moet zijn:
Map<String, String> map = new HashMap<>();
for (String key : map.keySet()) {
String value = map.get(key);
// Do something with key and value
}
Dat komt omdat het een kaartopzoekactie (de methode get()
) vereist voor elke sleutel in de kaart. Deze opzoeking is mogelijk niet efficiënt (in een HashMap betekent dit dat de hashCode
op de sleutel wordt opgeroepen en vervolgens de juiste bucket in interne gegevensstructuren wordt hashCode
en soms zelfs equals
opgeroepen). Op een grote kaart is dit misschien geen triviale overhead.
De juiste manier om dit te voorkomen, is door de vermeldingen op de kaart te doorlopen, wat gedetailleerd wordt beschreven in het onderwerp Collecties
Valkuil - Het gebruik van size () om te testen of een verzameling leeg is, is inefficiënt.
Het Java Collections Framework biedt twee gerelateerde methoden voor alle Collection
objecten:
-
size()
retourneert het aantal items in eenCollection
, en -
isEmpty()
methode retourneert true als (en alleen als) deCollection
leeg is.
Beide methoden kunnen worden gebruikt om de leegte van de collectie te testen. Bijvoorbeeld:
Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty(); // Best
Hoewel deze benaderingen er hetzelfde uitzien, slaan sommige implementaties van collecties niet de grootte op. Voor een dergelijke verzameling moet de implementatie van size()
de grootte berekenen telkens wanneer deze wordt aangeroepen. Bijvoorbeeld:
- Een eenvoudige gekoppelde
java.util.LinkedList
(maar niet dejava.util.LinkedList
) moet mogelijk de lijst doorlopen om de elementen te tellen. - De klasse
ConcurrentHashMap
moet de vermeldingen in alle "segmenten" van de kaart optellen. - Een luie implementatie van een verzameling moet mogelijk de hele verzameling in het geheugen realiseren om de elementen te tellen.
Een methode isEmpty()
hoeft daarentegen alleen te testen of er ten minste één element in de verzameling is. Dit betekent niet dat de elementen moeten worden geteld.
Hoewel size() == 0
niet altijd minder efficiënt is dan isEmpty()
, is het ondenkbaar dat een correct geïmplementeerde is isEmpty()
minder efficiënt is dan size() == 0
. Daarom heeft isEmpty()
voorkeur.
Valkuil - Efficiëntieproblemen met reguliere expressies
Reguliere expressieovereenkomst is een krachtig hulpmiddel (in Java en in andere contexten) maar heeft enkele nadelen. Een daarvan die reguliere expressies vaak vrij duur zijn.
Patroon- en Matcher-instanties moeten opnieuw worden gebruikt
Overweeg het volgende voorbeeld:
/**
* Test if all strings in a list consist of English letters and numbers.
* @param strings the list to be checked
* @return 'true' if an only if all strings satisfy the criteria
* @throws NullPointerException if 'strings' is 'null' or a 'null' element.
*/
public boolean allAlphanumeric(List<String> strings) {
for (String s : strings) {
if (!s.matches("[A-Za-z0-9]*")) {
return false;
}
}
return true;
}
Deze code is correct, maar het is inefficiënt. Het probleem zit in de matches(...)
oproep. Onder de motorkap zijn s.matches("[A-Za-z0-9]*")
gelijk aan dit:
Pattern.matches(s, "[A-Za-z0-9]*")
wat op zijn beurt gelijk is aan
Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()
De Pattern.compile("[A-Za-z0-9]*")
call ontleedt de reguliere uitdrukking, analyseren en bouwen een Pattern
object dat de gegevensstructuur die wordt gebruikt door de regex motor gaat. Dit is een niet-triviale berekening. Vervolgens wordt een Matcher
object gemaakt om het s
argument te omzeilen. Ten slotte roepen we match()
aan om de daadwerkelijke patroonafstemming te doen.
Het probleem is dat dit werk allemaal wordt herhaald voor elke lusherhaling. De oplossing is om de code als volgt te herstructureren:
private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");
public boolean allAlphanumeric(List<String> strings) {
Matcher matcher = ALPHA_NUMERIC.matcher("");
for (String s : strings) {
matcher.reset(s);
if (!matcher.matches()) {
return false;
}
}
return true;
}
Merk op dat de javadoc voor Pattern
staat:
Instanties van deze klasse zijn onveranderlijk en veilig voor gebruik door meerdere gelijktijdige threads. Instanties van de
Matcher
klasse zijn niet veilig voor dergelijk gebruik.
Gebruik geen match () wanneer u find () moet gebruiken
Stel dat u wilt testen of een string s
drie of meer cijfers op een rij bevat. Je kunt dit op verschillende manieren uitdrukken, waaronder:
if (s.matches(".*[0-9]{3}.*")) {
System.out.println("matches");
}
of
if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
System.out.println("matches");
}
De eerste is beknopter, maar is waarschijnlijk ook minder efficiënt. Op het eerste gezicht gaat de eerste versie proberen de hele string tegen het patroon te matchen. Aangezien ". *" Bovendien een "hebzuchtig" patroon is, zal de patroonmatcher waarschijnlijk "gretig" naar het einde van de string gaan en teruggaan tot hij een overeenkomst vindt.
De tweede versie zoekt daarentegen van links naar rechts en stopt met zoeken zodra de 3 cijfers achter elkaar worden gevonden.
Gebruik efficiëntere alternatieven voor reguliere expressies
Reguliere expressies zijn een krachtig hulpmiddel, maar ze moeten niet uw enige hulpmiddel zijn. Veel taken kunnen op andere manieren efficiënter worden uitgevoerd. Bijvoorbeeld:
Pattern.compile("ABC").matcher(s).find()
doet hetzelfde als:
s.contains("ABC")
behalve dat dit laatste een stuk efficiënter is. (Zelfs als u de kosten voor het compileren van de reguliere expressie kunt afschrijven.)
Vaak is de niet-regex-vorm ingewikkelder. De test die is uitgevoerd door de matches()
die de eerdere methode allAlplanumeric
aanroepen, kan bijvoorbeeld worden herschreven als:
public boolean matches(String s) {
for (char c : s) {
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9')) {
return false;
}
}
return true;
}
Nu is dat meer code dan het gebruik van een Matcher
, maar het gaat ook aanzienlijk sneller.
Catastrofale backtracking
(Dit is mogelijk een probleem met alle implementaties van reguliere expressies, maar we zullen het hier vermelden omdat het een valkuil is voor het gebruik van Pattern
.)
Beschouw dit (gekunstelde) voorbeeld:
Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());
De eerste println
oproep wordt snel true
afgedrukt. De tweede wordt false
afgedrukt. Uiteindelijk. Als je inderdaad met de bovenstaande code experimenteert, zul je zien dat elke keer dat je een A
toevoegt vóór de C
, de tijd verdubbelt.
Dit is gedrag is een voorbeeld van catastrofale backtracking . De engine voor het matchen van patronen die de regex-matching implementeert, probeert vruchteloos alle mogelijke manieren waarop het patroon zou kunnen matchen.
Laten we eens kijken wat (A+)+B
eigenlijk betekent. Oppervlakkig gezien lijkt het te zeggen "een of meer A
tekens gevolgd door een B
waarde", maar in werkelijkheid zegt het een of meer groepen, die elk uit een of meer A
tekens bestaan. Dus bijvoorbeeld:
- 'AB' komt maar op één manier overeen: '(A) B'
- 'AAB' komt op twee manieren overeen: '(AA) B' of '(A) (A) B'
- 'AAAB' komt op vier manieren overeen: '(AAA) B' of '(AA) (A) B
or '(A)(AA)B
of '(A) (A) (A) B' - enzovoorts
Met andere woorden, het aantal mogelijke overeenkomsten is 2 N, waarbij N het aantal A
tekens is.
Het bovenstaande voorbeeld is duidelijk gekunsteld, maar patronen die dit soort prestatiekenmerken vertonen (bijv. O(2^N)
of O(N^K)
voor een grote K
) komen vaak voor wanneer ondoordachte reguliere expressies worden gebruikt. Er zijn enkele standaardoplossingen:
- Vermijd herhalende patronen in andere herhalende patronen.
- Gebruik niet te veel herhalende patronen.
- Gebruik zo nodig niet-terugkerende herhalingen.
- Gebruik geen regexen voor gecompliceerde parseringstaken. (Schrijf in plaats daarvan een goede parser.)
Pas ten slotte op voor situaties waarin een gebruiker of een API-client een regex-tekenreeks met pathologische kenmerken kan leveren. Dat kan leiden tot accidentele of opzettelijke "denial of service".
Referenties:
- De tag Reguliere expressies , met name http://www.riptutorial.com/regex/topic/259/getting-started-with-regular-expressions/977/backtracking#t=201610010339131361163 en http://www.riptutorial.com/ regex / topic / 259 / getting-slag-met-reguliere expressies / 4527 / when-you-mag-niet-use-reguliere-expressies # t = 201610010339593564913
- "Regex Performance" door Jeff Atwood.
- "Hoe Java te doden met een reguliere expressie" door Andreas Haufler.
Valkuil - Interne tekenreeksen zodat u == kunt gebruiken, is een slecht idee
Wanneer sommige programmeurs dit advies zien:
"Het testen van strings met
==
is onjuist (tenzij de strings intern zijn)"
hun eerste reactie is om intern strings te gebruiken zodat ze ==
kunnen gebruiken. (Immers ==
is sneller dan String.equals(...)
aanroepen, String.equals(...)
.)
Dit is de verkeerde aanpak, vanuit een aantal perspectieven:
Breekbaarheid
Allereerst kunt u ==
alleen veilig gebruiken als u weet dat alle String
objecten die u test, zijn geïnterneerd. De JLS garandeert dat String-literals in uw broncode zijn geïnterneerd. Geen van de standaard Java SE API's garandeert echter dat geïnterneerde tekenreeksen worden geretourneerd, behalve String.intern(String)
zelf. Als u slechts één bron van String
objecten mist die niet zijn geïnterneerd, is uw toepassing onbetrouwbaar. Die onbetrouwbaarheid zal zich manifesteren als valse negatieven in plaats van uitzonderingen die het moeilijker maken om te detecteren.
Kosten van het gebruik van 'intern ()'
Onder de motorkap werkt interning door een hashtabel bij te houden die eerder geïnterneerde String
objecten bevat. Een soort zwak referentiemechanisme wordt gebruikt zodat de intern hashtabel geen opslaglek wordt. Hoewel de hashtabel is geïmplementeerd in native code (in tegenstelling tot HashMap
, HashTable
enzovoort), zijn de intern
gesprekken nog steeds relatief duur qua CPU en gebruikt geheugen.
Deze kosten moeten worden vergeleken met de besparing die we gaan krijgen door ==
plaats van equals
. We gaan zelfs niet breken, tenzij elke geïnterneerde string "een paar" keer met andere strings wordt vergeleken.
(Trouwens: de weinige situaties waarin interning de moeite waard is, hebben meestal te maken met het verminderen van de geheugenafdruk van een toepassing waarbij dezelfde strings vaak terugkeren en die strings een lange levensduur hebben.)
De impact op de inzameling van afval
Naast de directe CPU- en geheugenkosten die hierboven zijn beschreven, hebben geïnterneerde Strings invloed op de prestaties van de vuilnisman.
Voor versies van Java voorafgaand aan Java 7 worden geïnterneerde tekenreeksen vastgehouden in de "PermGen" -ruimte die zelden wordt verzameld. Als PermGen moet worden verzameld, activeert dit (meestal) een volledige afvalinzameling. Als de PermGen-ruimte helemaal vol is, crasht de JVM, zelfs als er vrije ruimte was in de reguliere heap-ruimtes.
In Java 7 werd de stringpool uit "PermGen" naar de normale heap verplaatst. De hashtabel wordt echter nog steeds een gegevensstructuur met een lange levensduur, waardoor geïnterneerde tekenreeksen lang meegaan. (Zelfs als de geïnterneerde tekenreeksobjecten in Eden-ruimte waren toegewezen, zouden ze waarschijnlijk worden gepromoveerd voordat ze werden verzameld.)
Dus in alle gevallen zal het interneren van een string zijn levensduur verlengen ten opzichte van een gewone string. Dat zal de overhead van de afvalinzameling verhogen gedurende de levensduur van de JVM.
Het tweede probleem is dat de hashtabel een zwak referentiemechanisme moet gebruiken om te voorkomen dat stringgeheugen lekt. Maar een dergelijk mechanisme is meer werk voor de vuilnisman.
Deze overheadkosten voor afvalinzameling zijn moeilijk te kwantificeren, maar er bestaat geen twijfel over dat ze wel bestaan. Als u intern
veel gebruikt, kunnen deze aanzienlijk zijn.
De string pool hashtable grootte
Volgens deze bron is de stringpool vanaf Java 6 geïmplementeerd als hashtabel met vaste grootte met kettingen om met strings naar dezelfde bucket te handelen. In vroege releases van Java 6 had de hashtabel een (hard-wired) constante grootte. Een afstemmingsparameter ( -XX:StringTableSize
) werd toegevoegd als een update voor de mid-life aan Java 6. Vervolgens werd in een update voor de mid-life naar Java 7 de standaardgrootte van de pool gewijzigd van 1009
in 60013
.
Het komt erop neer dat als u van plan bent om intensief intern
in uw code te gebruiken, het raadzaam is om een versie van Java te kiezen waarvan de hashtable-grootte instelbaar is en zorg ervoor dat u de juiste grootte instelt. Anders kunnen de prestaties van een intern
achteruitgaan naarmate het zwembad groter wordt.
Interning als een potentiële denial of service vector
Het hashcode-algoritme voor tekenreeksen is bekend. Als u strings interneert die zijn geleverd door kwaadwillende gebruikers of toepassingen, kan dit worden gebruikt als onderdeel van een DoS-aanval (Denial of Service). Als de kwaadwillende agent regelt dat alle reeksen die het biedt dezelfde hash-code hebben, kan dit leiden tot een ongebalanceerde hashtabel en O(N)
-prestaties voor intern
... waarbij N
het aantal botsende reeksen is.
(Er zijn eenvoudiger / effectievere manieren om een DoS-aanval tegen een service te starten. Deze vector kan echter worden gebruikt als het doel van de DoS-aanval is om de beveiliging te verbreken of om eerstelijns DoS-verdedigingen te ontwijken.)
Valkuil - Klein lezen / schrijven op ongebufferde streams zijn inefficiënt
Overweeg de volgende code om het ene bestand naar het andere te kopiëren:
import java.io.*;
public class FileCopy {
public static void main(String[] args) throws Exception {
try (InputStream is = new FileInputStream(args[0]);
OutputStream os = new FileOutputStream(args[1])) {
int octet;
while ((octet = is.read()) != -1) {
os.write(octet);
}
}
}
}
(We hebben beraadslaagd weggelaten normale argument controleren, foutrapportage en ga zo maar door, omdat ze niet relevant zijn voor punt van dit voorbeeld.)
Als je de bovenstaande code compileert en gebruikt om een enorm bestand te kopiëren, zul je merken dat het erg langzaam is. In feite zal het minstens een paar ordes van grootte langzamer zijn dan de standaard hulpprogramma's voor het kopiëren van OS-bestanden.
( Voeg hier werkelijke prestatiemetingen toe! )
De primaire reden dat het bovenstaande voorbeeld langzaam is (in het geval van een groot bestand) is dat het één-byte lezen uitvoert en één-byte schrijft op niet-gebufferde bytestreams. De eenvoudige manier om de prestaties te verbeteren, is om de streams te verpakken met gebufferde streams. Bijvoorbeeld:
import java.io.*;
public class FileCopy {
public static void main(String[] args) throws Exception {
try (InputStream is = new BufferedInputStream(
new FileInputStream(args[0]));
OutputStream os = new BufferedOutputStream(
new FileOutputStream(args[1]))) {
int octet;
while ((octet = is.read()) != -1) {
os.write(octet);
}
}
}
}
Deze kleine veranderingen zullen de kopieersnelheid van de gegevens verbeteren met ten minste een paar orden van grootte, afhankelijk van verschillende platformgerelateerde factoren. De gebufferde stream-wrappers zorgen ervoor dat de gegevens in grotere brokken worden gelezen en geschreven. De instanties hebben beide buffers geïmplementeerd als byte-arrays.
Met
is
, worden gegevens van het bestand enkele kilobytes tegelijk in de buffer gelezen. Wanneerread()
wordt aangeroepen, retourneert de implementatie doorgaans een byte uit de buffer. Het zal alleen uit de onderliggende invoerstroom lezen als de buffer is geleegd.Het gedrag voor
os
is analoog.os.write(int)
vanos.write(int)
schrijven enkele bytes in de buffer. Gegevens worden alleen naar de uitvoerstroom geschreven wanneer de buffer vol is of wanneeros
wordt leeggemaakt of gesloten.
Hoe zit het met op karakter gebaseerde streams?
Zoals u moet weten, biedt Java I / O verschillende API's voor het lezen en schrijven van binaire en tekstgegevens.
-
InputStream
enOutputStream
zijn de basis-API's voor stream-gebaseerde binaire I / O -
Reader
enWriter
zijn de basis-API's voor stream-gebaseerde tekst-I / O.
Voor tekst-I / O zijn BufferedReader
en BufferedWriter
de equivalenten voor BufferedInputStream
en BufferedOutputStream
.
Waarom maken gebufferde streams zoveel verschil?
De echte reden dat gebufferde streams de prestaties helpen, heeft te maken met de manier waarop een toepassing met het besturingssysteem praat:
De Java-methode in een Java-toepassing of native procedure-aanroepen in de native runtime-bibliotheken van de JVM zijn snel. Ze nemen meestal een paar machine-instructies en hebben minimale impact op de prestaties.
JVM-runtime-oproepen naar het besturingssysteem zijn daarentegen niet snel. Ze omvatten iets dat bekend staat als een "syscall". Het typische patroon voor een syscall is als volgt:
- Zet de syscall-argumenten in registers.
- Voer een SYSENTER trap-instructie uit.
- De trap-handler is overgeschakeld naar de bevoorrechte status en wijzigt de toewijzingen van het virtuele geheugen. Vervolgens wordt het naar de code verzonden om de specifieke syscall af te handelen.
- De syscall-handler controleert de argumenten en zorgt ervoor dat er geen toegang tot het geheugen wordt verteld dat het gebruikersproces niet zou moeten zien.
- Het syscall-specifieke werk wordt uitgevoerd. In het geval van een
read
syscall kan dit het volgende inhouden:- controleren of er gegevens moeten worden gelezen op de huidige positie van de bestandsdescriptor
- de bestandssysteemhandler aanroepen om de vereiste gegevens van schijf (of waar deze worden opgeslagen) op te halen in de buffercache,
- gegevens kopiëren van de buffercache naar het door JVM geleverde adres
- thstream pointerse file descriptor positie aanpassen
- Keer terug van de syscall. Dit houdt in dat VM-toewijzingen opnieuw moeten worden gewijzigd en de bevoorrechte status moet worden uitgeschakeld.
Zoals u zich kunt voorstellen, kan een enkele syscall duizenden machine-instructies uitvoeren. Conservatief, minstens twee ordes van grootte langer dan een normale methode-aanroep. (Waarschijnlijk drie of meer.)
Daarom is de reden dat gebufferde streams een groot verschil maken, dat ze het aantal syscalls drastisch verminderen. In plaats van een syscall uit te voeren voor elke read()
-aanroep, leest de gebufferde invoerstroom desgewenst een grote hoeveelheid gegevens in een buffer. De meeste read()
-aanroepen op de gebufferde stream controleren een aantal eenvoudige grenzen en retourneren een eerder gelezen byte
. Soortgelijke redenering is van toepassing in het geval van de uitvoerstroom, en ook in de gevallen van de tekenstroom.
(Sommige mensen denken dat gebufferde I / O-prestaties voortkomen uit de mismatch tussen de leesverzoekgrootte en de grootte van een schijfblok, schijfrotatielatentie en dergelijke. In feite gebruikt een modern besturingssysteem een aantal strategieën om ervoor te zorgen dat de applicatie hoeft meestal niet op de schijf te wachten. Dit is niet de echte uitleg.)
Zijn gebufferde streams altijd een overwinning?
Niet altijd. Gebufferde streams zijn zeker een overwinning als uw toepassing veel "kleine" lees- of schrijfbewerkingen gaat uitvoeren. Als uw toepassing echter alleen grote lezen of schrijven naar / van een grote byte[]
of char[]
hoeft uit te voeren, dan bieden gebufferde streams u geen echte voordelen. Inderdaad kan er zelfs een (kleine) prestatieboete zijn.
Is dit de snelste manier om een bestand naar Java te kopiëren?
Nee dat is het niet. Wanneer u Java's stream-gebaseerde API's gebruikt om een bestand te kopiëren, maakt u de kosten van ten minste één extra geheugen-naar-geheugen-kopie van de gegevens. Het is mogelijk om dit te voorkomen als u de NIO ByteBuffer
en Channel
API's gebruikt. ( Voeg hier een link naar een afzonderlijk voorbeeld toe. )