Java 9 får bedre garbage collection til at rydde op i hukommelsessvineri

15. september 2016 kl. 06:3417
Små enheder som Raspberry Pi eller store XML-filer kan gøre hukommelsesforbruget til et problem, udviklerne er nødt til at tænke over.
Artiklen er ældre end 30 dage
Manglende links i teksten kan sandsynligvis findes i bunden af artiklen.

I computerens første mange år var det en helt nødvendig færdighed for programmører at holde styr på præcist, hvor meget af hukommelsen der blev brugt og til hvad. Men moderne styresystemer og programmeringssprog gør det næsten altid muligt aldrig at skænke hukommelsen en tanke.

Forbløffende få gennemgår øvelsen med at finde ud af, hvor meget hukommelse deres program vil bruge

Der er imidlertid grund til at tænke over hukommelsesforbruget, hvis man enten arbejder med software til meget små enheder som eksempelvis de mindste udgaver af Raspberry Pi eller endnu mindre computere til Internet of Things.

Men også i store enterprise-systemer kan hukommelsesforbruget påvirke ydelsen.

»Det er forbløffende få, der gennemgår øvelsen med at finde ud af, hvor meget hukommelse deres program vil bruge, fordi hukommelse er billigt. Men det er ikke nødvendigvis tilfældet på små enheder som Raspberry Pi,« forklarede Kees Jan Koster, stifter af Java Monitor, på Java-konferencen JDK.IO.

Artiklen fortsætter efter annoncen

Hukommelse bliver for eksempel brugt, hver eneste gang man i objektorienteret programmering opretter et nyt objekt. Det bruger en lille bid af hukommelsen, som bliver reserveret til objektet.

Ældre sprog kræver manuel ophævelse

De fleste ældre programmeringssprog forudsætter, at programmøren manuelt sørger for at frigive det reserverede område, når objektet ikke længere skal bruges, men i dag bruger flere sprog garbage collection.

Garbage collection blev ganske vist allerede introduceret i Lisp i 1959, men i forhold til at skrive oprydningen ind i selve programmet, så blev garbage collection anset for at være for dyrt i processorcyklusser. Da Java blev introduceret i 1995, valgte man at bygge garbage collection ind i den virtuelle maskine, Java-applikationer blev udviklet på.

Den type garbage collection, Java benytter, kaldes generational. Det dækker over, at hukommelsen kategoriseres efter, om et område for eksempel er helt uberørt af allokerede objekter, eller om det indeholder nogle objekter, der stadig er i brug, men området har været gennem en oprydning.

Artiklen fortsætter efter annoncen

Udfordringen for garbage collection er, at det ikke er gratis. Den skal finde ud af, om der stadig er noget, der bruger en del af hukommelsen, og når der er blevet ryddet op mange gange, så kan det blive nødvendigt at flytte rundt på allokationer, fordi den ledige plads mellem dem så at sige er for trang.

Faktisk indeholder Java-platformen en kombination af forskellige garbage collection-metoder, og hvis man som udvikler arbejder med systemer, hvor garbage collection kan blive et problem, så er det muligt at justere på en række parametre.

Garbage collection kræver programstop

Et af problemer er, at garbage collection er nødt til at stoppe programmet kortvarigt, mens der ryddes op. Hvis det ikke håndteres på den rigtige måde, så kan man få et program, hvor disse pauser er mærkbare. Det kan eksempelvis være spil eller andre hukommelseskrævende programmer, hvor brugeren kan bemærke de små pauser.

Som udvikler kan man også have garbage collection i tankerne, når man skriver sin kode, og ikke blot efterfølgende når ydelsen skal finjusteres.

»Du skal ikke være bange for at generere en masse objekter, hvis du lader dem dø hurtigt. Det er billig garbage collection. Alternativt skal du lade dem leve virkelig længe,« forklarer Kees Jan Koster.

Garbage collection kan også få problemer med meget store allokeringer. Det kan eksempelvis være store XML-dokumenter på flere hundrede megabyte.

»Typisk indlæser man det, og så kopierer man det for eksempelvis at sikre, at der ikke er tegnsætproblemer, eller man skal trække noget tekst ud. Det er ikke ualmindeligt, at man indlæser sin tekst 6-8 gange i hukommelsen, og det er et problem, hvis det er et dokument på 200 megabyte,« siger Kees Jan Koster.

Her vil man som programmør kunne undgå det ved i stedet af oprette filen som en stream med en læsebuffer, så man kun indlæser en del af filen, når man skal bruge den, så man kan arbejde på et meget mindre område af hukommelsen.

Artiklen fortsætter efter annoncen

Garbage collection er nemlig ikke særlig egnet til at håndtere det, der kaldes 'humongous objects', hvilket dækker over objekter i hukommelsen, der er så store, at garbage collectoren ikke bare kan flytte rundt på dem.

»Ideelt set, så bør du have meget få humongous object, og de bør være kortlivede,« siger Monica Beckwith, der er selvstændig konsulent og arbejder med blandt andet optimering af garbage collection i Java.

Objects kommer ikke i vejen

På JDK.IO-konferencen fortalte hun om garbage collectoren G1, der bliver den nye standard-garbage collector i Java 9. Den har blandt andet fået en metode til at håndtere humongous objects, som dog reelt blot indebærer, at garbage collectoren er klar over dem, og kan afsætte den fornødne plads til, at de ikke kommer i vejen.

Med G1 vil et humongous object være defineret ud fra den samlede mængde hukommelse, der er til rådighed. G1 opdeler hukommelsen som en heap i 2.048 regioner. Størrelsen af hver region vil variere fra system til system, men et object kategoriseres som humongous, hvis det er større end eller lig med halvdelen af størrelsen på en region.

Hvis objektet er større end en hel region, så bliver det håndteret som en humongous region. Det er reelt områder, som ikke bliver garbage collected.

Derudover vil G1 garbage collectoren prøve at optimere sig selv undervejs. Det betyder blandt andet, at den ud fra, hvor lang tid det tog at lave de tidligere kørsler, vil på at finde frem til, hvor hyppigt den skal køre de næste.

»Hvis du har brugt de tidligere garbage collectorer, så er du nok vant til at skulle finjustere dem. Med G1 er det bedre at lade den gøre det for dig. Du bør ikke pille ved noget, medmindre du er helt sikker på, at det vil hjælpe,« siger Monica Beckwith.

Java 9 kommer også til at indeholde Jigsaw, som gør det muligt at reducere, hvor meget af hele Java-platformen der skal pakkes med for at køre en applikation. Det kan også være nyttigt på meget små enheder med begrænset hukommelse, fordi selve den virtuelle maskine med applikationen ellers kan fylde så meget, at styresystemet bliver nødt til at bruge swap og lagre en del af dét, der skulle have ligget i hukommelsen, på disken.

Styresystemerne er i dag ganske gode til at placere de mindst brugte dele af en applikation i swap-lagret, men hvis den virtuelle maskine fylder så meget, at man overskrider en svært identificerbar tærskel, så kan det føre til uforudsigelig ydelse.

»Vi prøvede at måle det på en applikation ved at have to get-funktioner, vi målte på. Det gik enten vildt hurtigt, eller også tog det to sekunder, fordi det lå i swap,« forklarede Kees Jan Koster.

17 kommentarer.  Hop til debatten
Denne artikel er gratis...

...men det er dyrt at lave god journalistik. Derfor beder vi dig overveje at tegne abonnement på Version2.

Digitaliseringen buldrer derudaf, og it-folkene tegner fremtidens Danmark. Derfor er det vigtigere end nogensinde med et kvalificeret bud på, hvordan it bedst kan være med til at udvikle det danske samfund og erhvervsliv.

Og der har aldrig været mere akut brug for en kritisk vagthund, der råber op, når der tages forkerte it-beslutninger.

Den rolle har Version2 indtaget siden 2006 - og det bliver vi ved med.

Debatten
Log ind eller opret en bruger for at deltage i debatten.
settingsDebatindstillinger
17
29. september 2016 kl. 12:56

Så er heller ikke værre at bruge malloc og free

Når du så har fyldt hele hukommelsen op med 8KB store "objekter" ved en masse mallocs, og du så "free"'er hver anden af dem...

Hvor vil du så få plads til et 16KB objekt?

Ja, så må du jo rydde op == Garbage Collection == Opfinde den dybe tallerken igen.

16
24. september 2016 kl. 14:24

Dvs. for at kunne de samme ting, vil en programmør, som ikke vælger Java, (men derimod maskinkode, eller ANSI C) selv skulle stå for at lave hele under systemet. Han være nødt til at lave sin egen garbage collector... og muligvis også mellemtrinnet med en virtuel maskine som afvikler bytekode. Og derved vil han have opfundet "den dybe tallerken" igen.

Så er heller ikke værre at bruge malloc og free - det lyder næsten som om at alt der er sværere end 2+2 er helt igennem umuligt for en almindelig programmør :)

C er velsignet med tre mekanismer der gør håndtering af komplekse datastrukturer ret overkommelig:

  • en pointer, er en pointer er en int
  • cast
  • function pointers

hvis man så vil dekorerer koden er der jo altid #define

Man kommer rigtig langt med ovenstående og f.eks. et bibliotek, der implementerer en LISP lignende cons mekanisme. ;-)

15
16. september 2016 kl. 10:00

Du har ret i din analyse, men det er ikke et praktisk eksempel, da du forudsætter egenskaber få praktisk anvendelige sprog har. F.eks. har objekter i Lisp ikke ensartet størrelse - hvor har du det fra?

Det er ganske rigtigt baseret på tidlige LISP varianter. Men selv større objekter kan bygges af objekter med fast størrelse. To felter er måske i underkanten, men med tre felter + et RC felt er der kun overhead på 33% for CONS-celler og andre par, og med en forgrening på tre, kan de fleste structs laves med dybde <= 2. Arrays kan også bygges som træer, selv om det giver dyrere adgang. Hvis tidsforbrug er vigtigere end pladsforbrug, kan man gøre den faste størrelse til 8 ord -- et RC felt og 7 datafelter. Så kan man nå næsten 300 millioner elementer med 10 niveauer.

Og, jo, random access bliver ret dyrt, men operationer som map, filter og fold har meget lidt overhead sammenlignet med flade arrays.

14
15. september 2016 kl. 23:41

Ja. Derfor skal GC af yngste generation være billig. Det kan gøres f.eks. ved at gøre yngste generation lille nok til at være i primær- eller sekundærcachen. På en i7 er primærcachen kun 32kB, så det er nok lidt i underkanten, men sekundærcache er 256kB. Så hvis den yngste generation sættes til 128kB, kan både from-space og to-space for GC af yngste generation være i cachen.

Det er en meget forsimplet betragtning som kan lede til store problemer. Fx har jeg arbejdet med systemer hvor vi efter meget test valgte at køre med en young generation size på 240GB (ja gigabytes). Jeg kan ikke lige huske eden size, men det var en pæn del af young generation size.

Meget simplistisk beskrevet så afgøres GC time for en generation af antallet af overlevende objekter. Så hvis man ønsker korte young generation pauser (selv G1 laver pauser), så skal man vælge den størrelse der sikrer at færrest muligt objekter overlever. I princippet vil det betyder at young generation skal være så lille som muligt. I praksis vil det dog blot skubbe problemet til senere generationer samt resultere i flere young generarion collections. Så størrelsen af young generation er ikke simpel.

Men det normale problem er old generation pauser (som selv med G1 kan blive signifikante hvis man har for høj allocation rate), og her bør man vælge en størrelse der sikrer lavest mulige chance for at young generation objekter overlever. Det vil i praksis sige at young generation skal være så stor som muligt.

Hvad der er optimalt afhænger af memory forbrug, optimerings kriterie (latency vs. throughput), allocation rate, lifetime histogram, etc. etc.

13
15. september 2016 kl. 19:39

Du har ret i din analyse, men det er ikke et praktisk eksempel, da du forudsætter egenskaber få praktisk anvendelige sprog har. F.eks. har objekter i Lisp ikke ensartet størrelse - hvor har du det fra? Siden MACLISP fra 70erne har man som minimum haft arrays, og siden er alle de gængse strukturtyper kommet til. Jeg tror kun det var de første LISP 1.5-prototyper der brugte 36-bit ord til det hele (og her kunne symboler også være større, omend jeg ikke tror symboler var hob-allokerede).

12
15. september 2016 kl. 17:56

For alle de der kort levende objekter vil hæve young generation GC frekvensen. Konsekvensen heraf er at flere lidt længere levende objekter overlever til næste generation, som så får højere GC frekvens

Ja. Derfor skal GC af yngste generation være billig. Det kan gøres f.eks. ved at gøre yngste generation lille nok til at være i primær- eller sekundærcachen. På en i7 er primærcachen kun 32kB, så det er nok lidt i underkanten, men sekundærcache er 256kB. Så hvis den yngste generation sættes til 128kB, kan både from-space og to-space for GC af yngste generation være i cachen. Den næstyngste generation kan passende sættes til halvdelen af tertiærcachen (som på i7 er 8MB). Den tredjeyngste generation kan sættes til 256MB, og dernæst 8GB. Dermed er hver generation 32 gange større end den foregående. Man kunne også overveje at lave den yngste generation helt ned på 16kB, så primærcachen kan bruges til denne. Det giver godt nok meget hyppige GC, men også ret billige, da primærcache typisk er 4-8 gange hurtigere end sekundærcache.

Hvis vi antager, at yngste generation har 25% levende objekter ved GC, tager det 32×4 = 128 GC af yngste generation mellem hver GC af den næste, og så fremdeles. Store objekter allokeres direkte i en af de større / ældre generationer, så de ikke hele tiden skal gennem GC. Store objekter lever også typisk længere end små. Som tommelfingerregel må intet objekt fylde mere end 5% af den generation, hvori den allokeres, undtagen den ældste generation, hvor grænsen kun er sat af denne generations størrelse.

11
15. september 2016 kl. 17:26

Når et objekts referencetæller når 0, og objektet deallokeres, så skal man også dekrementere referencetællerne for alle de andre objekter som objektet peger på, hvilke så igen kan nå 0, osv - altså kan du få en kaskade af de-allokeringer hvis størrelse er afhængig af programmets dynamiske tilstand

Ja, det er korrekt. Men denne kaskade behøver ikke ske med det samme. Hvis alle objekter har ens størrelse (som i f.eks. LISP), kan man sætte hele det frigjorte træ forrest på frilisten, og når der er behov for et nyt objekt, tager man toppen af første træ i frilisten og tæller ned i børnenes referencetællere. Hvis nogle af dem når 0, sættes de på frilisten.

Dermed koster deallokering kun en operation: Sæt knuden på frilisten, uden at modificere børn. Allokering bliver lidt dyrere: Tag første knude på frilisten, men tæl ned i referencer til børnene, og dealloker evt. disse. Nulstil/overskriv pegere til børn i den allokerede knude. Det samlede arbejde for allokering og frigørelse er uændret i forhold til at tage kaskaden med det samme.

Hvi vi antager, at en knude har to felter (LISP igen), vil allokering af en knude maksimalt kræve justering af to referencetællere, to test for 0, frigørelse af to knuder (ved at sætte dem på frilisten, altså en skrivning per knude), og nulstilling/overskrivning af to felter.

Bemærk, at for at dette virker skal referencetællerfeltet duplere som hægte i frilisten.

Hvis knuder kan have forskellig størrelse, er sagen en helt anden. Dels kan man komme til at skulle lede efter en stor nok knude, og man risikerer også fragmentering.

10
15. september 2016 kl. 15:06

At kunne sådanne ting vil altid kræve garbage collection af en eller anden art, da opdelingen af hukommelsen afhænger af hvad input er.

Måske skulle du tage et kig på C++. Der er alle de ting du snakker om - bortset fra GC.

Den store fordel ved Java er mængden af open source, at man sjældent behøver at bekymre sig om memory management (til gengæld er det ikke nemt hvis man når dertil) og at en fejl i et hjørne af programmet sjældent behøver at vælte hele programmet. Nogen vil så sige at man skal lade være med at lave fejl og at sproget stinker.

9
15. september 2016 kl. 14:14

»Du skal ikke være bange for at generere en masse objekter, hvis du lader dem dø hurtigt. Det er billig garbage collection. Alternativt skal du lade dem leve virkelig længe,« forklarer Kees Jan Koster.

Her har Kees kun lidt ret. For alle de der kort levende objekter vil hæve young generation GC frekvensen. Konsekvensen heraf er at flere lidt længere levende objekter overlever til næste generation, som så får højere GC frekvens. I yderste konsekvens kan det betyde at der bliver flere full collections (selv med G1).

8
15. september 2016 kl. 12:53

Spørgsmålet kan vel være "Hvorfor ikke?"

Java er smart til komplekse datastrukturer der ændrer sig over tid. Linked lists, prioritetskøer, træstrukturer og den slags.

At kunne sådanne ting vil altid kræve garbage collection af en eller anden art, da opdelingen af hukommelsen afhænger af hvad input er. Det kan ikke forudsiges af programmøren.

Javas VM til bytekode sikrer at samme kode kan køre på flere forskellige vidt forskellige arkitekturer.

Dvs. for at kunne de samme ting, vil en programmør, som ikke vælger Java, (men derimod maskinkode, eller ANSI C) selv skulle stå for at lave hele under systemet. Han være nødt til at lave sin egen garbage collector... og muligvis også mellemtrinnet med en virtuel maskine som afvikler bytekode. Og derved vil han have opfundet "den dybe tallerken" igen.

Sandsynligheden for at hans udgave er bedre, end Javas 21års erfaring, ideudvikling og gennemtestede system, er nok ret lille.

Du lyder til at synes at bekymre dig om performance. Men i praksis er der jo nærmest ingen forskel på hvor hurtigt komplekse opgaver kører, om de kører "native" eller i Java. I hvert fald ikke noget man ofte gerne vil bytte væk, for den noget nemmere, og stabile, udvikling man kan opnå ved at bruge Java.

... Og så er en Raspberry pi (selv Etteren) jo trods alt mere end dobbelt så hurtig som en PC var, da Java kom frem.

7
15. september 2016 kl. 11:27

Til ægte real-time bør man nok bruge reference counting. RC giver væsentligt større overhead end tracing collection, men man kan garantere mod pauser, hvor concurrent GC blot gør disse meget usandsynlige -- meget store allokeringer kan tvinge concurrent GC til at standse programmet, indtil tilstrækkeligt data er frigivet. RC frigiver data med det samme, så hvis der ikke er plads til en allokering med det samme, så er der ikke plads, punktum.

RC kan give endnu større problemer end andre spildopsamlingsalgoritmer, og er ikke så deterministisk som man kan gå og tro. Når et objekts referencetæller når 0, og objektet deallokeres, så skal man også dekrementere referencetællerne for alle de andre objekter som objektet peger på, hvilke så igen kan nå 0, osv - altså kan du få en kaskade af de-allokeringer hvis størrelse er afhængig af programmets dynamiske tilstand (altså, hvem peger på hvem). Det kan ske hver eneste gang et objekt deallokeres, hvilket typisk sker ofte i et RC-system. Ydermere er RC-systemer ofte bygget på en almindelig malloc()-style hob, hvilket kan give problemer med fragmentering. Her kan en pakkende spildopsamler være hjælpsom, omend man selvfølgelig aldrig ved hvornår den finder på at rydde op.

Hvis man skal lave realtidssystemer, så er dynamisk allokering i almindelighed noget man skal være varsom med.

5
15. september 2016 kl. 11:07

Java er en meget populær teknologi og der findes mange eksisterende Java programmer og software libraries som man kan tænke, at nogle har lyst til at bruge på en RPi. Ellers kan det tænkes, at nogle foretrækker Java over andre teknologier.

4
15. september 2016 kl. 11:06

Hvorfor bruge Java på en Raspberry Pi ?

Et bedre spørgsmål ville være "Hvorfor bruge .NET på en Raspberry Pi". Java har fra starten af været henvendt til platforms-diversitet og "andre enheder end Windows" og den er godt modnet. (Og burde dermed også være det på Raspberry Pi.)

Svaret er det samme, uanset om du spørger til .NET eller Java:

  1. Du har i forvejen en stor kodebase i .NET/Java, som du ønsker at genbruge. At omskrive dit .NET/Java til eks. ANSI C, er lidt ligesom at køre negle over en tavle ... i de 7 mdr det tager at konvertere det. (Og jeg er ANSI C programmør.)

  2. Nyudvikling i .NET/Java er trods alt, en hel del hurtigere end i C/C++. (Jo, der er delte meninger og diskussioner mht. udviklings-performance af eks. C++ vs. Java.)

  3. Det er nemmere at tiltrække arbejdskræft til .NET/Java projekter end ANSI C. (Dog er C/C++ også meget udbredte, så det er nok ikke det helt store problem.)

3
15. september 2016 kl. 11:01

Garbage collection kræver programstop

Det er en sandhed med modifikationer. En klassisk tracing collector såsom mark-sweep eller copying collection standser ganske rigtigt programmet, mens spildopsamlingen foregår. Men der findes modifikationer af disse, så de kan køre samtidigt med programmet -- det kræver nogle ekstra mekanismer til bevaring af invarianter, som bl.a. gør overskrivning af felter i allerede eksisterende objekter lidt dyrere end ellers, men man undgår de lange pauser (op til flere millisekunder), som traditionel GC giver.

Til ægte real-time bør man nok bruge reference counting. RC giver væsentligt større overhead end tracing collection, men man kan garantere mod pauser, hvor concurrent GC blot gør disse meget usandsynlige -- meget store allokeringer kan tvinge concurrent GC til at standse programmet, indtil tilstrækkeligt data er frigivet. RC frigiver data med det samme, så hvis der ikke er plads til en allokering med det samme, så er der ikke plads, punktum.

2
15. september 2016 kl. 09:32

Hvorfor bruge Java på en Raspberry Pi ?

Jeg spørger bare dumt, synes det meste jeg ser er Linux version, med standardprogrammer som er tilrettet. Her taler jeg om de første versioner. Ikke de sidste version som snart har CPU kraft, RAM og GPU til også at køre windows.

Bruger ikke selv de sidste på grund af strømforbrug, og hvis jeg skal have et mediecenter/afspiller, så bruger jeg en chromecast..

Men nogen grund til Java på de helt små enheder, kan godt se ideen sammen med et Windows/Linux desktop brug.

1
15. september 2016 kl. 09:28

Når nu Garbage Collection nævnes i forbindelse med Real-time systemer er der ingen vej uden om "pilleri" og intensive tests over lange perioder. Her er et link til en god intro om G1 ifbm. real-time systemer