Alternatieven voor Java's ThreadLocal om objecten te hergebruiken

Door ACM op dinsdag 17 november 2015 19:25 - Reacties (10)
Categorie: Development, Views: 2.837

Soms zijn objecten relatief 'duur' om te maken, in vergelijking tot hun runtime-performance. Bekende voorbeelden in Java zijn NumberFormat en DateFormat. Ze zijn zo duur om te maken omdat ze onder andere eerst de 'huidige' Locale opzoeken en zich vervolgens zo configurere dat ze correcte formats opleveren voor die taal. Als dat eenmaal gebeurd is, is de aanroep van format() vrij snel gebeurd.
In Tweakers gebruiken we de NumberFormat erg veel in onze "Java-engine".
Objecten hergebruiken
Bij dat soort objcten zou je liever niet voor elke aanroep van format() eerst een nieuw object maken. Idealiter zou je één keer een NumberFormat-object maken en die steeds hergebruiken. Dat is vooral effectief als je veel cijfers in mooie leesbare formatting wilt genereren.
In Java is het dan ook een heel gebruikelijke performancetip om NumberFormat en dergelijke te hergebruiken. Als je maar één thread gebruikt is dat ook een heel makkelijk advies om op te volgen; je kan dan domweg je formatter in een class-property bewaren of eventueel zelfs een static object ervoor aanmaken.

En dat bewaren van een object helpt ook echt, in een eenvoudige JMH-benchmark bleek dat steeds een nieuw NumberFormat-object aanmaken en daarna één cijfer formatten op zo'n 1,8 miljoen formats per seconde uitkwam. Dat klinkt heel veel, maar als je dat vergelijkt met een hergebruikt object dan valt het toch tegen. Met één object aanmaken in totaal en steeds daarmee formatten kwam de test namelijk uit op ruim 39 miljoen formats per seconde uit, ruim twintig keer zoveel.

Overigens is het hergebruiken van objecten lang niet altijd de moeite waard. De JVM kent aardig wat optimalisaties om kleine, kortlevende objecten zeer snel aan te maken en weer te verwijderen. Doe het dus alleen als je denkt dat het echt wat toevoegt.
Hergebruik bij meerdere threads
Gebruik je echter meerdere threads, dan wordt het een stuk lastiger. Als de interne state van een object verandert, dan is het object in principe niet thread safe. Als je ze dan toch in meerdere threads tegelijk gebruikt, dan levert dat onvoorspelbaar gedrag op. Dit geldt onder andere voor NumberFormat en DateFormat, maar er zijn natuurlijk veel meer objecten waar dat relevant voor is. Het idee van een object-property of een static object werkt hier daarom niet zomaar, dan kan je niet garanderen dat dat object slechts door één thread (tegelijk) gebruikt wordt.

Om objecten toch thread safe te kunnen hergebruiken is er in Java een standaardtechniek ontwikkeld. Dat is de ThreadLocal. Die zorgt ervoor dat er voor iedere thread een uniek object wordt gemaakt en dat dat object vervolgens binnen die thread steeds hergebruikt wordt. Dit heeft wat overhead ten opzichte van direct hergebruiken, maar in de praktijk is het bijna even snel. Het kwam in diezelfde benchmark uit op bijna 33 miljoen formats per seconde met één thread en met vier en acht threads (op een vier-core machine zonder hyperthreading) op ruim 130 miljoen formats per seconde.
Nadeel van ThreadLocal in webapplicaties
Helaas heeft het gebruik van ThreadLocal voor webapplicaties een groot nadeel: het is een gebruikelijke bron van geheugenlekken. Dat is iets dat vrij specifiek voor webapplicaties geldt en niet voor reguliere applicaties, je hoeft je dus niet direct zorgen te gaan maken.
De reden dat het die geheugenlekken veroorzaakt hangt samen met de manier waarop de meeste application servers, waaronder Tomcat, applicaties en classes inladen. Dat doen ze met een losse ClassLoader.
Het gaat wat te ver om dat hier helemaal uit te leggen, maar het komt er op neer dat Threads gedeeld worden door meerdere webapplicaties en dat de webapplicaties een eigen ClassLoader (en dus geheugen) voor de eigen klassedefinities hebben. Als je vervolgens een ThreadLocal gebruikt, wordt de klassedefinitie van de objecten daarin vastgehouden binnen die algemene Thread. En doordat die klasse-definitie wordt vastgehouden wordt effectief dat hele stuk geheugen (inclusief alle klassedefinities voor de webapplicatie) vastgehouden. En dat blijft dan dus ook in de heap aanwezig nadat je de applicatie hebt undeployed.

Dat geheugenlek met ThreadLocals is op zich goed te voorkomen, maar in het kort komt het advies daarbij er op neer op het direct weer verwijderen van het object nadat de request is afgelopen. Op die manier bespaar je dan uiteindelijk wel wát keren dat je dure objecten opnieuw worden aangemaakt, maar niet zoveel als idealiter mogelijk is. Belangrijker nog is dat het vereist dat de code die het einde van je request afhandelt ook nog even rekening houdt met allerlei zaken die elders (misschien wel in je template-engine) spelen.
Alternatieven voor ThreadLocal
In onze webapplicaties vond ik het niet erg mooi om allerlei 'kennis' van de interne processen nog in de afhandeling van het einde van requests te verwerken. Bovendien voelt het ook vreemd om de code zo aan te passen dat zo'n langlevend object uiteindelijk toch maar voor enkele tientallen of een paar honderd format's gebruikt wordt.
Object pool
Een veelgebruikte techniek om 'dure' objecten te bewaren en te kunnen hergebruiken is een Object pool. Daarbij maak je enkele instanties van je klasse aan en bewaar je die op een centrale plaats. Vervolgens kan er elke keer als er zo'n object nodig is eentje gereserveerd worden en kan het aan het einde van de werkzaamheden weer teruggegeven worden.
Deze aanpak is onder andere erg gebruikelijk voor verbindingen met databases en andere databronnen. Daar heet het dan vaak een Connection Pool. Doordat het geen relatie met de Threads heeft is er verder geen sprake van dat type geheugenlek.

Er zijn vele Object pools te vinden. Ik heb een aantal ervan in JMH-benchmarks omgezet. Dat ging om eenvoudige zelfgeschreven varianten met queues of om generieke Object-poolimplementaties. De queues waren allemaal gebaseerd op de standaardimplementaties van Java, zoals de ConcurrentLinkedQueue, de ArrayDeque in combinatie met synchronization of locks en de ArrayBlockingQueue. De pools bestonden uit een voorbeeld op de Semaphore, de Apache commons Pool, de Fast object pool en een idee voor een Lock free pool.

<iframe src="//charts.tweakzones.net/kpFFR/1/index.html" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" oallowfullscreen="oallowfullscreen" msallowfullscreen="msallowfullscreen" width="600" height="400"></iframe>

Op de Fast object pool na hadden alle implementaties als nadeel dat ze significant trager werden naarmate er twee of meer threads actief werden. En in vergelijking met de ThreadLocal was het sowieso dramatisch, de snelste implementatie bij één thread kwam nog op een redelijke 21 miljoen formats per seconde; maar bij vier threads was de snelste slechts 17 miljoen. Dat is vooral slecht vergeleken met de 130 miljoen die bij ThreadLocal mogelijk was. Het was zelfs vrij slecht in vergelijking met domweg steeds een nieuw NumberFormat-object aanmaken, die zat met vier threads op 6,4 miljoen formats per seconde.
Blijkbaar is het principe van Object pool toch niet zo geschikt voor dit soort objecten, waarbij het 'echte werk' (de format-aanroep) slechts heel kort duurt.
Objecten per Thread bewaren
Aangezien de Object pools nogal tegen vielen, ben ik een ander idee gaan uitwerken. Er lijkt niet echt een naam voor te bestaan, maar uiteindelijk heb ik een soortgelijke constructie als de ThreadLocal gemaakt. Het belangrijkste verschil is dat de objecten nu niet meer 'in' de Thread worden opgeslagen. Dat heeft als groot voordeel dat als de webapplicatie stopt, dat dan ook alle objecten vrij gemaakt kunnen worden en de geheugenlek niet meer voorkomt.

De objecten worden domweg in een Map<Long, T> opgeslagen, waarbij T het te bewaren object is en de Long de identifier van een Thread representeert. Uiteindelijk heb ik daar diverse implementaties voor gemaakt. Dat ging om ConcurrentHashMap, ConcurrentSkipListMap, Trove4J's TSynchronizedLongObjectMap, Google's Guava Cache, ConcurrentLinkedHashMap, Caffeine's Cache.

In de praktijk bleek met name de standaard ConcurrentHashMap van Java erg goed te presteren. In onderstaande grafiek is ter referentie ook weer het aanmaken van losse objecten per format meegenomen.

<iframe src="//charts.tweakzones.net/JyuPo/1/index.html" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen" webkitallowfullscreen="webkitallowfullscreen" mozallowfullscreen="mozallowfullscreen" oallowfullscreen="oallowfullscreen" msallowfullscreen="msallowfullscreen" width="600" height="400"></iframe>
Zelf uitproberen?
Als je zelf wilt kijken hoe een en ander presteert, dan kan je op onze github-repository de code bekijken en clonen. Je kan uiteraard ook in je eigen clone benchmarks toevoegen als je een goed idee hebt, dat volgens jou (nog) beter presteert dan de ConcurrentHashMap of de ThreadPool.
Als je denkt dat (een deel van) de benchmarks verkeerd zijn geïmplementeerd, hoor ik dat natuurlijk ook graag.
Over de benchmarks
De benchmarks zijn gedraaid met Java's Microbenchmark Harness. De cijfers zijn verzameld op een Intel Core i5-4690 met vier cpu-cores en een kloksnelheid van 3,5GHz (met pieken door Turbo Boost naar ruim 3,8GHz). Dit systeem draait op Windows 10.

Volgende: "Er zijn genoeg alternatieven voor banners!" Nou, welke dan? 03-'12 "Er zijn genoeg alternatieven voor banners!" Nou, welke dan?

Reacties


Door Tweakers user onok, woensdag 18 november 2015 09:21

Ik ben niet echt een java guru, maar het artikel is desondanks goed te begrijpen. Leuk stukje :)

Door Tweakers user RoadRunner84, woensdag 18 november 2015 13:34

Wat is het verschil tussen ConcurrentHashMap en ConcurrentHashMapFixed?

Door Tweakers user ACM, woensdag 18 november 2015 15:08

RoadRunner84 schreef op woensdag 18 november 2015 @ 13:34:
Wat is het verschil tussen ConcurrentHashMap en ConcurrentHashMapFixed?
Bijna niets, de 'Fixed' beperkt zich in grootte door af en toe alles weg te gooien (ik had geen zin er LRU van te maken)

Zie ook de files op github, die non-fixed heeft die if van regel 37 niet.

Door Tweakers user supreme tweaker, woensdag 18 november 2015 15:38

In welke scenario's veranderen de NumberFormat en DateFormat intern eigenlijk van state? Kan je niet veel beter voor een ander patroon kiezen in plaats van dit symptoom te bestrijden?

Door Tweakers user ACM, woensdag 18 november 2015 15:53

supreme tweaker schreef op woensdag 18 november 2015 @ 15:38:
In welke scenario's veranderen de NumberFormat en DateFormat intern eigenlijk van state? Kan je niet veel beter voor een ander patroon kiezen in plaats van dit symptoom te bestrijden?
Helaas. Het zijn vrij veel dingen om rekening mee te houden. De NumberFormat kan bijvoorbeeld intern switchen naar een speciale geoptimaliseerde versie die zelf weer een array cached en die array wordt bij elke call naar format(double) gebruikt. DateFormat ken ik minder goed, dus daar weet ik de scenario's niet van.

En zo zijn er meer dingen, de meeste configuratiedingen leveren ook rare effecten op. Zeker als je die zou wijzigen (bijv aantal cijfers achter de komma). Als je dan die wijziging in thread A zou doen en in thread B nog uitgaat van de originele configuratie, dan zou je ook ineens andere output krijgen dan verwacht.

Het is wat dat betreft heel simpel; de opbouw van de klasse gaat er gewoon van uit dat het niet thread safe is. Dus je 'mag' het gewoon niet in verschillende threads tegelijk gebruiken. Het is dan ook geen symptoom bestrijden om dat te voorkomen.

Wat hooguit een vorm van symptoombestrijding is, is het proberen te omzeilen van de dure constructor ;)
Overigens zijn er ook wel alternatieve implementaties (bijv in Apache commons) voor dit soort klassen die dan wel thread safe zijn. Maar daar moet je dan natuurlijk wel naar op zoek, extra libraries toevoegen, mogelijk inleveren op functionaliteit en kijken of de performance wel vergelijkbaar is (die gecachte array is er waarschijnlijk niet voor niets).

[Reactie gewijzigd op woensdag 18 november 2015 15:57]


Door Tweakers user pumpidumpi, woensdag 18 november 2015 19:32

Nadeel van je methode is dat als de applicatieserver een Thread afsluit het object in je cache blijft staan.

Door Tweakers user ACM, woensdag 18 november 2015 19:45

pumpidumpi schreef op woensdag 18 november 2015 @ 19:32:
Nadeel van je methode is dat als de applicatieserver een Thread afsluit het object in je cache blijft staan.
Klopt, maar het zijn in dit voorbeeld ook weer geen hele dure objecten om te behouden. Op de totale heap van 4GB+ is dit niet hetgene waar ik me op dat aspect zorgen om zou maken :)

Maar het is zeker een afweging die je zal moeten maken. Hoe meer het een probleem is dat je 'teveel' objecten hebt, hoe meer zin een 'echte' objectpool zal hebben. Die kan bijvoorbeeld een beperkt maximum aantal uitdelen en threads zelfs laten wachten tot er een object vrij is. In die gevallen is ThreadLocal ook geen hele goede oplossing, omdat die ook voor elke Thread een nieuw object zal alloceren.

Door Tweakers user hellfire_ultd, donderdag 19 november 2015 09:41

En wat nu als je je eigen ThreadLocal implementeert door middel van een WeakHashMap? Het thread-object wordt dan de key en je formatter de value. Als de thread niet meer bestaat, verdwijnt de entry uit de map. Er is dan dus geen geheugenlek meer.
supreme tweaker schreef op woensdag 18 november 2015 @ 15:38:
In welke scenario's veranderen de NumberFormat en DateFormat intern eigenlijk van state? Kan je niet veel beter voor een ander patroon kiezen in plaats van dit symptoom te bestrijden?
Ik heb in het verleden gezien dat de SimpleDateFormat hele rare dingen doet als twee threads hem tegelijk gebruiken. Die classes hebben interne state om de formatting uit te voeren en zijn daardoor per definitie niet geschikt voor multi-threaded gebruik.

[Reactie gewijzigd op donderdag 19 november 2015 09:42]


Door Tweakers user pumpidumpi, donderdag 19 november 2015 11:56

hellfire_ultd schreef op donderdag 19 november 2015 @ 09:41:
En wat nu als je je eigen ThreadLocal implementeert door middel van een WeakHashMap? Het thread-object wordt dan de key en je formatter de value. Als de thread niet meer bestaat, verdwijnt de entry uit de map. Er is dan dus geen geheugenlek meer.
Een ThreadLocal gebruikt een WeakReference, dat is het probleem niet. Bij het redeployen van je app blijft de thread bestaan. In het geval van een NumberFormat is daar wel overheen te komen, die zit standaard al in dezelfde classloader als waar ThreadLocal zit. Maar als je er eigen objecten in gaat zetten wordt het probleem groter.
Threads een maximale levensduur geven lost het probleem ook een soort van op.

Door Tweakers user ACM, donderdag 19 november 2015 13:08

pumpidumpi schreef op donderdag 19 november 2015 @ 11:56:
Een ThreadLocal gebruikt een WeakReference, dat is het probleem niet. Bij het redeployen van je app blijft de thread bestaan. In het geval van een NumberFormat is daar wel overheen te komen, die zit standaard al in dezelfde classloader als waar ThreadLocal zit.
Dat is op zich waar, maar je kan niet de NumberFormat-class aanpassen en de key in die ThreadLocalMap van de Thread is de (subclass van) ThreadLocal die de NumberFormat aanmaakt. Dus in theorie heb je gelijk, in de praktijkt komt dat scenario niet zoveel voor als je zelf besluit een ThreadLocal (met standaard Java-objecten) te gebruiken :)
Threads een maximale levensduur geven lost het probleem ook een soort van op.
Dat is op zich ook wat Tomcat doet als ie de ThreadLocal detecteert na een undeploy. Dan gaat ie proberen alle bestaande threads te verversen. En in theorie wordt dan na een tijdje die ThreadLocal er wel uit gewipt doordat al die WeakReferences dan worden opgeheven.

Reageren is niet meer mogelijk