Tweakers.net en Java
Tweakers.net heeft in de afgelopen maanden hard gewerkt aan het Tweakers 7.0-project. Een belangrijke doelstelling van dit project is het verbeteren van de specificatiefilters van de Pricewatch, die we ook op lijsten met nieuwsberichten, reviews, video's, enzovoort willen toepassen. Ook die content kan straks dus door de gebruiker gefilterd worden.
Onder meer om deze doelstelling te realiseren kozen we voor een uitbreiding van onze bestaande Pricewatch Engine. Een belangrijke taak van die engine is het snel aanleveren van gefilterde en gesorteerde informatie, inclusief statistieken, nodig om te bepalen welke keuzeopties bij de Pricewatch-lijsten worden aangeboden.

Toen we in 2009 de eerste versie van de engine ontwikkelden, ging het al om meer dan 150.000 producten, elk met een lijst specificaties. Het was al meteen duidelijk dat een php-applicatie niet snel genoeg was om de gegevens praktisch te verwerken. Daarom kozen we destijds voor een Java-applicatie. De door de gebruiker gekozen filters worden in php-code vertaald en doorgestuurd naar de Java-applicatie, die vervolgens een brok hapklare informatie aan de php-code teruggeeft.
Voor optimale prestaties laadt de engine alle gegevens in het ram en ook de bijbehorende zoekdatabase bevindt zich in het werkgeheugen. Alle gegevens samen nemen ruim 600MB in beslag.
Voor het Tweakers 7.0-project willen we echter naast de producten ook de nieuwsartikelen, reviews en andere content toevoegen. In onze MySQL-database nemen die gegevens al meer dan twee gigabyte in beslag en daarbij komt nog allerlei meta-informatie, zoals aanvullende relaties en zoektabellen. De hoeveelheid data neemt dus fors toe.

In dit artikel beschrijven we hoe we erin zijn geslaagd om het geheugengebruik binnen de perken te houden. De hier genoemde tips en trucs zijn primair gericht op Java, maar een groot deel van de ideeën is ook in andere talen toe te passen. Objecten worden immers in elke objectgeoriënteerde taal volgens vergelijkbare principes opgezet en ook datastructuren hebben doorgaans veel overeenkomsten.
Waarom geheugen besparen?
Tegenwoordig kun je al voor relatief weinig geld een 'simpele' dual-socketserver van 144GB werkgeheugen voorzien. Het loont dus niet direct om het geheugengebruik te minimaliseren. Een voor de hand liggende reden om het toch te doen, is dat het praktisch is om applicaties in een lokale ide met reële data te kunnen testen. Onze workstations beschikken over 8GB ram, waardoor een heap size van rond de 5GB wel ongeveer het maximum is. Met de ide, een geheugenprofiler, een mailclient, een browser en de applicatie zelf erbij is er af en toe 7,98GB geheugen in gebruik en dan wordt zo'n systeem behoorlijk traag.
Ook de prestaties zijn een reden voor efficiënt geheugengebruik. Kleinere objecten en compactere datastructuren nemen overal minder ruimte in, dus ook in de cpu-caches en -registers. Daarnaast is er minder bandbreedte nodig om de data van en naar de cpu's te verplaatsen. Minder geheugen gebruiken betekent verder dat de garbage collector minder hard hoeft te werken, zodat er minder en/of kortere collection pauses in de applicatie optreden en er meer cpu-tijd voor de applicatie overblijft.
Eenmaal geoptimaliseerde gegevens kunnen gedurende de hele levensduur van je applicatie een efficiënter gebruik opleveren. Wij konden bijvoorbeeld een aantal strings vervangen door byte-arrays, die niet alleen kleiner waren, maar ook efficiënter konden worden verwerkt. Uiteraard hangt de prestatiewinst sterk af van de gekozen optimalisatie en de omstandigheden.
Objecten in Java
Gegevens worden in Java met objecten beschreven. In deze objecten kunnen primitieven - zoals getallen, booleans en karakters - worden opgeslagen, ze kunnen naar andere objecten verwijzen en ze kunnen een combinatie van primitieven en verwijzingen bevatten.
Al die zaken kosten uiteraard geheugen. De precieze hoeveelheid hangt af van de gebruikte Java Virtual Machine. Er is bijvoorbeeld geen garantie dat de IBM JDK of de JRockit VM evenveel geheugen voor een object gebruikt als de Oracle JDK, hoewel er in de praktijk waarschijnlijk veel overeenkomsten zijn.
Met de hotspot-compiler van Oracle wordt voor elk object een header van twee referenties gebruikt, waaraan vervolgens de gewenste data wordt toegevoegd. Een referentie beslaat 4 bytes bij 32bits-systemen en 8 bytes bij een 64bits-VM. Met CompressedOops kunnen referenties bij een 64bits-VM in slechts 4 bytes worden gecodeerd. Aangezien CompressedOops sinds Oracles Java 7 standaard is ingeschakeld, gaan we bij de onderstaande berekeningen uit van referenties van 4 bytes.
De exacte geheugenlay-out van objecten in Oracles JVM is helaas niet eenvoudig te achterhalen en wordt ook niet gespecificeerd in de Java Language Specification. Daarin is alleen vastgelegd welke waarden moeten kunnen worden opgeslagen in een byte, short, int, float, long, double of char. Een boolean kost in de Oracle JVM 1 byte, maar in een andere JVM zouden bijvoorbeeld 8 booleans in 1 byte gegroepeerd kunnen worden.
Primitieve
| Bits | Bytes
|
byte |
8 |
1 |
short |
16 |
2 |
int en float |
32 |
4 |
long en double |
64 |
8 |
char |
16 |
2 |
boolean |
1 - 8 |
1 |
referentie |
32 of 64 |
4 of 8 |
De Oracle JVM slaat objecten in blokken van 8 bytes op, omdat de toegang en het beheer van de data zo efficiënter zijn dan wanneer met losse bytes wordt gewerkt. Omdat 8 bytes net genoeg is om de twee verplichte referenties in op te slaan, beslaat een object met een willekeurig veld dus altijd minimaal 16 bytes. Als objecten kleiner zijn dan een veelvoud van 8 bytes, blijft er altijd wat loze ruimte over, die 'padding' wordt genoemd.
Objectgroottes
Een bekend object in Java is de string, waarin een tekenreeks kan worden opgeslagen. Een string kan zijn interne array met chars delen met een andere string en bevat daarom een eigen beginpositie en lengte. Het char-array is een apart object, dat behalve de karakters ook een lengteparameter bevat.
Velden in een string
| Type | Lengte
|
objectheader |
2x referentie |
2x4 bytes |
hash |
int |
4 bytes |
offset |
int |
4 bytes |
count |
int |
4 bytes |
value (char-array) |
referentie |
4 bytes |
Subtotaal |
|
24 bytes |
Velden in de char-array |
|
|
objectheader |
2x referentie |
2x4 bytes |
length |
int |
4 bytes |
char[0], char[1], ..., char[n] |
char |
n*2 bytes |
padding van 2, 4 of 6 bytes |
|
|
Subtotaal bij lege tekst |
|
12 bytes + 4 bytes padding |
Totaal voor lege tekst |
|
40 bytes |
Een lege string kost dus al 24+16, oftewel 40 bytes, overigens net als strings van 1 en 2 tekens. Uiteraard is de impact van de overhead kleiner bij langere strings.
Ook van andere veelgebruikte objecten kan het geen kwaad om te weten hoeveel data ze gebruiken. Een arraylist heeft bijvoorbeeld een object-array en een size-integer aan boord. Een lege arraylist kost dus in theorie 2*4+4+4=16 bytes plus 16 bytes voor de lege array, wat het totaal op 32 bytes brengt. Er zit echter een addertje onder het gras; een arraylist wordt standaard aangemaakt met een object-array van 10 elementen, die nog eens 2*4+4+10*4 bytes (+4 bytes padding) in beslag neemt; dat brengt het totaal op 72 bytes.
Voor hashmaps geldt een vergelijkbaar verhaal. Een hashmap bevat een array van entries, een size, een threshold, een modcount en een loadfactor. Daarnaast worden nog referenties naar de entryset, de keyset en de values bewaard. Dat kost dus 2*4+4*4+4+3*4=40 bytes. Daar komt nog een entry-array bij, die standaard 16 elementen groot is en dus 2*4+4+16*4=76 bytes omvat. Met padding betekent dat een grootte van 80 bytes. Een blanco hashmap beslaat dus 120 bytes en dan zit er nog geen data in.
Als je vooraf een grootte voor een hashmap opgeeft, wordt de capaciteit afgerond naar het eerstvolgende veelvoud van 2. De entry-objecten kosten weer 2*4 bytes voor de header en hebben daarnaast referenties naar de key, value en een eventuele next-entry. Daarnaast bevatten ze nog een kopie van de hashcode, wat een totaal van 24 bytes oplevert. Een met 5 elementen gevulde hashmap kost daardoor 40 + 12 + (4*8) + (24*5) = (+4 bytes padding) 208 bytes.
Elementen
| Object[]
| ArrayList | LinkedList | HashSet |
10 |
56 bytes |
72 bytes |
264 bytes
|
356 bytes |
100 |
416 bytes |
432 bytes |
2424 bytes |
2980 bytes
|
1000 |
4016 bytes |
4032 bytes |
24024 bytes
|
28164 bytes
|
Met de voorgaande voorbeelden is waarschijnlijk wel duidelijk dat objecten in Java al gauw een stuk meer geheugen gebruiken dan je aan de hand van de opgeslagen data zou verwachten. Daarmee is de geheugenbehoefte van Java echter niet uitzonderlijk. Het objectmodel van PHP is bijvoorbeeld nog een stuk erger. Door de dynamische types van velden nemen 5 objecten met ieder 2 integers in een array maar liefst 4640 bytes in beslag.
In Java vergt dezelfde data de hierboven genoemde 208 bytes voor een hashmap, met daarnaast 5*16=80 bytes voor de integer-keys en 5*16=80 bytes voor de genoemde objecten. Dat brengt het totaal op 368 bytes en dat is al een stuk schappelijker.
Geheugen besparen: de basis
Om het geheugengebruik in Java te beperken zijn er twee mogelijkheden: de objecten moeten kleiner worden of het aantal objecten moet worden verlaagd. Alle trucs om geheugen te besparen zijn uiteindelijk terug te voeren op deze twee uitgangspunten. Soms kan het handiger zijn om het aantal objecten te vergroten, zolang dat maar betekent dat de objecten per stuk kleiner worden. Omgekeerd kan het ook nuttig zijn om grotere objecten te gebruiken, als het aantal objecten dan maar flink omlaag kan.
Verklein het aantal objecten
Elk object heeft minimaal 8 bytes nodig en meestal meer. Bovendien wordt minstens één referentie bijgehouden naar elk object dat in het geheugen actief blijft en ook dat kost geheugen. Door het aantal objecten te verkleinen wordt dus doorgaans geheugen bespaard.
Als een verzameling van 100.000 strings van gemiddeld 10 karakters, die dus 56 bytes per stuk kosten, kan worden teruggebracht tot 50.000 stuks, levert dat een besparing op van ruim 2,5MB. Soms kun je ook besparen door een groepje vaste objecten bij te houden, zodat je niet steeds nieuwe hoeft aan te maken, maar bestaande objecten kunt gebruiken.
Verklein de objecten zelf
Om geheugen te besparen ligt het voor de hand je objecten kleiner te maken. Door de afronding naar veelvouden van 8 bytes is dat echter niet altijd zinvol. Als een object 2 int-velden bevat en je kunt er 1 wegbezuinigen, dan levert dat je geen bit winst op. In beide gevallen is het object 16 bytes groot.
Als je echter zeker weet dat die 100.000 strings uit het eerdere voorbeeld uitsluitend tekens uit de ISO-8859-1-karakterset bevatten, dan weet je ook dat elk karakter maar 1 byte nodig heeft. De benodigde objecten vereisen dan nog maar een object van 2*4+4 (+4 bytes padding) = 16 bytes en een byte-array van 2*4+4+10 (+2 bytes padding) = 24 bytes, dus 40 bytes per stuk. Als je alleen de byte-arrays opslaat, zijn er zelfs maar 24 in plaats van de oorspronkelijke 56 bytes nodig. Dat levert in dit voorbeeld dus 1,5 of zelfs 3MB winst op.
Dat zijn nog geen enorme aantallen, maar het spreekt voor zich dat de winst bij grotere objecten en grotere aantallen objecten behoorlijk kan toenemen. Als de strings gemiddeld 200 karakters lang zijn, zouden we met onze byte-array-trucjes dus 20 tot 21MB kunnen besparen.
Tools om geheugen te analyseren
Er zijn diverse tools om het geheugengebruik van een draaiende applicatie te bekijken. Dit doen ze doorgaans door een heap dump te maken en die vervolgens te analyseren. Naast commerciële profilers als Yourkit, JProbe en JProfiler zijn er ook gratis tools; Oracle levert bij zijn JDK de tooltjes jmap en jhat, en Eclipse Memory Analyzer is in Eclipse te integreren. Het laatste programma kan vrij uitgebreide analyses van de heap dump maken, maar heeft momenteel nog geen ondersteuning voor CompressedOops, zodat de cijfers bij de objecten niet helemaal kloppen. Dat maakt echter niet veel uit bij het vinden van dubbele objecten, het volgen van referenties, het tellen van aantallen objecten, enzovoort.
Minder objecten gebruiken
De beste manier om geheugen te besparen is minder objecten te gebruiken. Vooral immutable objecten, zoals strings, zijn ideaal om te delen tussen verschillende objecten, waarbij maar één object nodig is voor een unieke waarde.
java.lang.String
Zoals we al eerder meldden, is de string in Java immutable. Dat houdt in dat de inhoud van een eenmaal aangemaakt object nooit meer wordt aangepast. Als er een bewerking op plaatsvindt, wordt er een nieuw object met een nieuwe inhoud gegenereerd; elke verwijzing naar de oude versie blijft echter ook geldig en geeft de oude, onveranderde waarde terug. Voor dezelfde tekst kan dus ook daadwerkelijk dezelfde string gebruikt worden, ongeacht of dat met diverse objecten, verschillende typen objecten of diverse threads gebeurt.
Aangezien de string een van de meest gebruikte data-objecten in Java is, hebben profilers vaak speciale routines om string-objecten naar inhoud te sorteren en om te zoeken naar dubbele of lege strings. Overigens kan Eclipse Memory Analyzer dat ook met andere objecten en zelfs met arrays.

Als je net als Tweakers.net met databases werkt, is de kans groot dat je voor elke tekst in elke record een nieuw string-object krijgt, ook als de tekst leeg is. In ons geval hebben we een slordige miljoen lege strings in de database: niet ingevulde url-velden, lege titels of beschrijvingen, enzovoort. Met 40 bytes per stuk is een miljoen lege strings goed voor zowat 40MB verspild geheugen.
Hiervoor zijn allerlei eenvoudige oplossingen te verzinnen. Je kunt bijvoorbeeld een utility-klasse voorzien van een methode die nagaat of een string leeg is en dan een referentie naar een vaste, lege standaardstring teruggeeft. Dat kan met zoiets:
class StringUtil {
private static final String EMPTY_STRING = "";
/**
* Prevent copies of the empty string "".
* @param input A potential copy of the empty string.
* @return The reference to the singleton empty String
* or the original string if it was non-empty.
*/
public static String getUniqueString(String input) {
return (input != null && input.isEmpty() ? EMPTY_STRING : input);
}
}
Als er veel dubbele strings zijn, zijn er twee manieren om het aantal objecten te reduceren. Bij een kleine, vaste verzameling korte teksten is het al gauw interessant om met een enum te werken. Strings worden dan in feite weggegooid zodra de bijbehorende enum in de bijbehorende vaste lijst is gevonden.
Aangezien een enum ook een klasse is, kun je er eventueel extra functionaliteit in stoppen. Je kunt bijvoorbeeld een enum voor http-statuscodes definiëren die naast de tekst ook de statuscode zelf bevat. Bovendien bestaan voor enums ook de speciale EnumSet- en EnumMap-klassen, waarmee nog extra werk en geheugen bespaard kunnen worden.
De tweede manier is om te werken met een StringInterner of canonicalizer. Hiermee worden twee gelijkwaardige strings door hetzelfde, unieke object gerepresenteerd.
Java biedt zelf de String.intern()-methode aan, maar deze kan beter niet voor dit doel gebruikt worden. De string wordt dan in de permgen opgeslagen en die heeft doorgaans een zeer beperkte grootte. In plaats daarvan kan simpelweg een HashMap <String, String> gebruikt worden. Ook Lucenes StringInterner (als je toch al Lucene gebruikt) en de Interner uit Googles Guava-bibliotheek zijn interessant.
class TrivialStringInterner {
private final HashMap<String, String> stringCache = new HashMap<>();
/**
* Return the single unique instance for the given String-content.
* I.e. guarantee that this is true:
* (output1.equals(output2)) == (output1 == output2)
* @param input A potentially duplicate String.
* @return The guaranteed singleton String (within this interner).
*/
public String intern(String input) {
String output = stringCache.get(input);
if(output == null) {
stringCache.put(input);
output = input;
}
return output;
}
}
Bij Tweakers.net wordt dit onder andere gebruikt voor de extensies van afbeeldingen. Dat zijn er maar heel weinig, in ons geval maar drie, maar we weten natuurlijk niet op voorhand of er niet toch nog eentje bijkomt. Met bijna 500.000 afbeeldingen in de database is het in elk geval al snel lonend om die te internen. Andere voorbeelden zijn plaatsnamen en landcodes.
Overigens is het internen of canonical maken van objecten ook mogelijk bij andere objecttypen. Bij elk type dat hergebruikt kan worden, kun je besparingen halen. Zo ontdekten we dat er voor onze 450.000 V&A-advertenties maar 60.000 unieke adressen werden gebruikt. Door adressen van de advertenties los te koppelen en ze uniek op te slaan, bespaarden we tientallen megabytes.
Let er wel op dat de datastructuur die je nodig hebt om de unieke objecten te vinden zelf ook geheugen kost. De hashmap voor de V&A-adressen neemt ruim 1,5MB in beslag; daar zouden nog aardig wat adresobjecten in gepast hebben.
Verder gebruikt de Oracle-JVM al unieke strings voor dezelfde tekst. Als in een stuk code tien keer dezelfde tekst staat, dan wordt binnen de applicatie ook steeds dezelfde string gebruikt. De Oracle-JVM roept namelijk voor elke hardcoded string in het programma zelf al String.intern() aan.
Primitieven gebruiken
Andere objecten die in de praktijk veel data opslokken, zijn de wrappers rond de primitieven. Ieder van deze objecten kost 16 bytes en bovendien 4 bytes aan referentie vanuit de plek waar ze gebruikt worden. Een object met acht primitieven van het byte-type neemt zo 168 echte bytes in beslag.
Mocht je toch primitieven in objecten opslaan, gebruik dan in geen geval de constructor, zoals new Boolean(boolVal),
maar de factory-methode van Java zelf, zoals Boolean.valueOf(boolVal)
. Deze maakt handig gebruik van een aantal vaste caches om vaak gebruikte objecten te delen, zoals de booleans false en true, en de integerwaarden van -127 tot 128. Uiteraard kun je zelf ook objecten cachen als je ziet dat je ergens maar een beperkt aantal verschillende waarden hoeft te gebruiken.
Overigens kun je in uitzonderlijke situaties met een Long- of Double-wrapperobject juist geheugen besparen. De referenties ernaar kosten tenslotte maar 4 bytes, terwijl de primitieven zelf 8 bytes ruimte innemen.
Trove-collecties voor primitieven
Collecties in Java zijn erg handig en ze werken goed en snel. Ze hebben echter een nadeel; je kunt er geen primitieven in opslaan. Het enige alternatief is om met arrays van primitieven te werken en dat is niet erg praktisch. Als je een map van int naar int wil bijhouden, moet je Map<Integer, Integer>
gebruiken. Met 100.000 key-valueparen komt een hashmap met de bijbehorende entry-objecten dan al op minstens 2,8MB en is er nog 3MB voor de integer-objecten nodig.
Gelukkig zijn er diverse bibliotheken te vinden die het mogelijk maken om direct met primitieven te werken. De compleetste die we zijn tegengekomen is Trove. Naast collecties voor primitieven biedt deze library ook hashtables die met 'open addressing' werken, waardoor er geen entry-objecten nodig zijn.

De TIntIntHashMap bevat een array met keys, een array met values, een byte-array met de status van velden, 8 floats en ints, en 2 booleans. De 3 arrays zijn wel standaard twee keer zo groot als het aantal opgeslagen objecten, vanwege de implicaties voor de prestaties die aan een kleinere array vastzitten. Voor onze 100.000 key-value paren is uiteindelijk 56 + (12+2*4*100.000) + (12+2*4*100.000) + (12+2*100.000) = 1,7MB nodig en omdat er geen 200.000 integer-objecten meer nodig zijn, ben je met die 1,7MB ook meteen klaar.
Naast de collecties voor primitieven kent Trove overigens ook een compacte THashMap en THashSet, waarbij vooral de laatste aanzienlijk kleiner is dan de Java-tegenhanger. De HashSet van Java is onder de motorkap namelijk gewoon een HashMap, die dus nog meer overhead heeft dan een 'echte' HashMap.
In onze code maken we tegenwoordig veelvuldig gebruik van de diverse primitieve-naar-object-arrays, zoals TByteObjectHashMap en TIntObjectHashMap, en we zijn ook fans geworden van de TIntArrayList en THashSet. Aangezien we deze vrij geleidelijk zijn gaan gebruiken, is het lastig te zeggen hoeveel geheugen we hiermee uiteindelijk hebben bespaard, maar met meer dan een miljoen van deze collecties in het geheugen bedraagt de totale besparing zeker enkele honderden megabytes.
Objecten kleiner maken
Naast het beperken van het aantal objecten kun je de objecten uiteraard ook kleiner maken. In bepaalde situaties kan het ook lonen om ze door speciale, veel kleinere objecten te vervangen.
Speciale Java-collecties gebruiken
Naast de Trove-collecties biedt Java collecties voor twee veel voorkomende situaties: collecties die leeg zijn en collecties die slechts 1 element bevatten. De Collections-klasse kent 6 methodes om zulke collecties compacter te maken.
De emptySet-, emptyList- en emptyMap-methodes hergebruiken hetzelfde object, waardoor ze het totale aantal bestaande objecten reduceren en effectief 0 bytes kosten. De singleton, de singletonList en de singletonMap zijn ook veel compacter dan hun normale tegenhangers en gebruiken per stuk 16 bytes.
In onze code hebben we meer dan 350.000 referenties naar emptyList en emptyMap, zodat we ten opzichte van de standaard-arraylists en -hashmaps respectievelijk 24 en 40MB geheugen besparen. Uiteraard kunnen die lege lijsten niet gevuld worden met nieuwe objecten, maar dat was in die gevallen voor ons toch niet nodig.
De singleton-versies gebruiken we zelfs nog vaker, respectievelijk 900.000, 600.000 en 100.000 maal. In vergelijking met de standaard-hashsets, -arraylists en -hashmaps besparen we zo respectievelijk 124, 32 en 12MB. Omdat we ook al veelvuldig de op maat gesneden THashSets en arraylists gebruiken, zijn de besparingen in de praktijk iets kleiner.

Als je toch meer dan 1 element in een collection wil stoppen, is een arraylist met weinig elementen - wij hanteren een grens van 10 stuks – bijna altijd sneller dan een HashSet of een THashSet. Daarnaast is een arraylist compacter. Als de verschillen in semantiek acceptabel zijn en er een collectie met een snelle contains-methode nodig is, is dat een prima manier om nog wat MB's te besparen.
Als voor een arraylist wordt gekozen, is de trimToSize()-methode ook erg nuttig. Deze verkleint de interne object-array tot precies de goede grootte. Voor alle op arrays gebaseerde collecties - zoals hashmap, hashset en arraylist - is het toch al verstandig om ze direct in de constructor de goede grootte mee te geven. Dat bespaart naast geheugen ook rekentijd, omdat de interne array niet vergroot hoeft te worden.
Strings comprimeren
Zoals gezegd zijn strings verantwoordelijk voor een groot deel van het geheugengebruik van veel applicaties. Het feit dat Java voor elk karakter 2 bytes gebruikt, maakt dat nog wat zichtbaarder. Het is echter niet altijd mogelijk of zinvol om te testen of een bepaalde tekst meermalen wordt gebruikt.
Bij lange teksten, zoals onze nieuwsberichten en reviews, weten we bijvoorbeeld al op voorhand dat de strings uniek zijn. Er valt dan niets te winnen met het internen van de objecten. Toch zijn de teksten die wij in het geheugen willen laden, samen goed voor 1,2GB aan objecten. Hier was dan ook een flinke besparing te realiseren.
Onze teksten zijn vrijwel uitsluitend in de ISO-8859-15-karakterset opgesteld, en ze worden ook zo naar jouw browser verstuurd. Dat betekent dat we de strings kunnen vervangen door een datatype dat 1 in plaats van 2 bytes per karakter gebruikt. Dat levert uiteraard een halvering van het benodigde geheugen op.
Dat vonden we echter nog niet genoeg, dus hebben we uitgezocht of datacompressie zinvol zou zijn. Omdat het vooral om korte teksten gaat en we niet te veel tijd aan het comprimeren en decomprimeren kwijt wilden zijn, was het rendement van compressie niet al te groot, maar we hebben toch de LZF-encoder in gebruik genomen.
LZF is een van de snelste compressie-algoritmes die momenteel beschikbaar zijn. Bovendien is er een snelle, puur in Java geschreven implementatie van beschikbaar, die ook nog eens onder een bruikbare licentie wordt aangeboden. Deze LZF-implementatie heeft voor de compressie van onze 600MB tekst ongeveer 4 seconden nodig. Het decomprimeren is zelfs al in 0,7 seconde gepiept.
LZF maakt overigens niet alle teksten korter. In dat geval bewaren we simpelweg de ongecomprimeerde byte-variant van de originele tekst, maar desondanks kunnen we nog ongeveer de helft op de geheugenbehoefte besparen. Uiteindelijk verzamelde onze StringCompressor de volgende statistieken.
Type string
| Aantal
| Grootte |
Aangeboden strings |
821.450 |
1.263.011.216 |
Waarvan leeg |
190.636 |
0
|
Waarvan niet comprimeerbaar |
105.356 |
10.586.802
|
Waarvan wel comprimeerbaar |
525.458 |
394.556.723 |
Totaal gebruikt geheugen na verkleining |
|
425.013.432 |
Door de aangeboden strings te vervangen door objecten met een byte-array en die waar mogelijk te comprimeren, hebben we het geheugengebruik dus met ongeveer 800MB gereduceerd. Dat is een winst van ruim 66 procent.
Voor wie nog meer wil besparen, zijn er nog alternatieven. Bij lange teksten zullen de meeste general-purpose-algoritmen behoorlijke reducties opleveren. Met de korte teksten die wij in de aanbieding hebben, moet echter goed opgelet worden; Huffman tables en dictionaries worden normaliter in de gecomprimeerde tekst opgeslagen en dat kost weer extra ruimte. Een van de weinige algoritmes die hiermee efficiënt overweg kunnen, is FemToZip. We hebben ook geëxperimenteerd met een eigen compressiesysteem dat een externe dictionary en Huffman-table bouwde.
Beide methodes leverden opnieuw een halvering van het geheugengebruik op. Helaas bleken de methodes veel complexer; ze vereisen goed gekozen trainingsdata en veel vooronderzoek. Bovendien waren beide algoritmes aanzienlijk trager dan LZF; FemToZip had ruim 200 seconden nodig voor het comprimeren en het decomprimeren duurde ook nog altijd 15 seconden. In vergelijking met de prestaties van LZF vonden we dat niet acceptabel.
Objecten vervangen of opdelen
Het juiste type gebruiken
In een aantal gevallen kan data als tekst, maar ook als een specifiek datatype worden opgeslagen. Voorbeelden zijn ip-adressen, datums en vaste keuzelijsten. Het vervangen van vaste keuzes door enums of met behulp van interning is al genoemd. Daar wordt vooral winst geboekt omdat het aantal objecten kan worden gereduceerd.
Bij de keus tussen een string en een ander datatype is het vooral van belang om te onderzoeken welk type compacter is. Ook speelt een rol dat specifieke datatypes vaak handige bewerkingen op de data mogelijk maken.
Een Inet4Address neemt in Java slechts 24 bytes in, terwijl een stringversie van een ip-adres (bij 12 tekens) in totaal 24+40=64bytes kost. In ons geval ging het om 450.000 adressen en dus om een besparing van ruim 17MB. Voor het converteren van strings naar InetAddress-objecten is de InetAddresses-klasse van Guava erg praktisch. Deze voert namelijk geen dns-look-up op een gegeven ip-adres uit, terwijl de vergelijkbare methode van InetAddress dat soms wel doet.
Vergelijkbare winsten zijn te halen door netjes een Integer, Date, Long, Double en zo verder te gebruiken. Let er wel op dat bij ieder van die objecten een hoeveelheid cpu-tijd nodig is om ze van en naar strings te converteren. Als de desbetreffende objecten al als primitieve worden aangeboden, is de keus uiteraard eenvoudiger.
Objecten opdelen
In sommige gevallen bevatten veelgebruikte objecten velden die maar af en toe gevuld zijn. In ons geval werd voor Vraag & Aanbod-advertenties eerst één klasse gebruikt. Voor dienstenadvertenties zijn er echter vijf aanvullende velden, zoals de begin- en eindtijd, die daardoor ook bij productadvertenties werden opgeslagen.
Aangezien we veel meer productadvertenties dan dienstenadvertenties hebben, kostten die extra velden 16 bytes per object. Met onze 450.000 advertenties is dat bijna 7MB. Door de advertentieobjecten te verdelen in ServiceAdvertisements en ProductAdvertisements, konden we die velden in de meeste gevallen weglaten en zo dus weer een paar megabyte besparen.
Een andere manier van opdelen is het verhuizen van velden naar een los object. Bij een advertentie kan opgegeven worden of het product voor een vaste prijs, door middel van een veiling, via ruil of tegen elk aannemelijk bod wordt aangeboden. We schreven daarom een VaPrice-interface met aparte klassen per type betaling; zo hoeven we specifieke velden alleen te alloceren en te vullen als ze ook echt gebruikt worden.
Sterker nog, er zijn bijvoorbeeld ruim 200.000 advertenties met een concrete vraagprijs, maar daarbij worden minder dan 1700 unieke bedragen gebruikt. De meeste bezoekers kiezen uit een vrij klein aantal prijzen; bedragen als 20, 25 of 50 euro komen vaak voor. Bovendien hoefden we maar één object aan de waarde 't.e.a.b.' te wijden, in plaats van 30.000.
Conclusie
Onze totale geheugenbesparing ten opzichte van een 'naïeve' implementatie loopt door de besproken technieken waarschijnlijk richting de 2 gigabyte. Dat is genoeg om ruimschoots binnen de eerder genoemde maximale heap size van 4GB te blijven.
Daarmee is echter niet gezegd dat iedereen maar meteen moet beginnen met dit soort optimalisaties. De meeste genoemde ideeën kosten extra rekentijd bij het aanmaken van objecten, verbloemen complexiteit of doen aannames die strijdig kunnen zijn met defensive coding-principes. Erger nog, ze kunnen bij verkeerd gebruik juist extra geheugen kosten.
Tijdens de ontwikkeling van onze Java-code bleek echter dat de besproken methoden en technieken niet in de standaard-'toolkit' van onze programmeurs zaten. Dat is niet zo vreemd; dergelijke optimalisaties maken doorgaans geen deel uit van een standaard-Java-cursus. Ook in de praktijk is het niet gebruikelijk dat er meer dan een paar duizend objecten in het geheugen worden gehouden.
Het is precies daarom dat we dit artikel hebben gepubliceerd; andere Java-programmeurs hoeven zo het wiel niet opnieuw uit te vinden. Nogmaals, het blind in elke applicatie toepassen van deze technieken is niet de bedoeling. Gebruik altijd een goede geheugenprofiler om uit te zoeken welke optimalisaties werkelijk de moeite waard zijn en probeer ook de nadelen van je optimalisatie te vinden. Dat scheelt je niet alleen veel werk, maar voorkomt ook dat je jezelf met nodeloos complexe code allerlei nieuwe problemen op de hals haalt. Zoals vaak wordt gezegd: "Premature optimization is the root of all evil."