JavaSvet - otvorena java zajednica

 
glavna stranica arr2javasvet  english version arr2java.net

Slabe reference

Igor Spasić
Sadržaj:
1 Nov 2007

Paket java.lang.ref postoji još od Jave 1.2, ali se čini da senior programeri i dalje nisu dovoljno upoznati sa pojmom slabih referenci i njihovom upotrebom. Činjenica je da je rad sa slabim referencama stvar koja se ne sreće u svakodnevnom radu, ali gotovo je sigurno da će zatrebati onima koji se bave arhitekturom sistema. Naročito ako se uzme u obzir da njihovom upotrebom možemo sprečiti curenje memorije (memory-leak): problem koji se teško može uočiti prilikom testiranja i koji se obično ispoljava tek nakon nekog vremena rada aplikacije (na pr.: u produkciji).

Terminološka napomena: konkretni tipovi slabih referenci su dati na engleskom, a sam pojam slabih referenci na srpskom, kako bi se izbegle jezičke nedoumice u vezi ovih pojmova.

Garbage Collector ukratko

Kada se instancira objekat, on zauzima mesto u memoriji (na heap-u), a njemu se iz programa pristupa preko njegove reference, koja ukazuje na njega. Ovo je jedan od najosnovnijih mehanizama Jave. Poznato je, na primer, da se argumentima metoda prenose reference na objekte, tako da ako se na jednom mestu promeni sadržaj objekta, sve ostale reference nadalje vide to izmenjeno stanje, bez obzira u kome scope-u se nalaze. Ovakve reference se nazivaju jake reference (strong references) i one se koriste skoro sve vreme. U sledećem primeru:

Object obj = new Object();

je deklarisana i definisana referenca obj, koja ukazuje na instanciran objekat na heap-u.

Reference imaju veze sa načinom rada GCa (garbage collector). Poznato je da su moderni jezici oslobođeni eksplicitnog uništavanja nepotrebnih instanci objekata, čime se značajno olakšava pisanje softvera i gotovo uklanjaju specifična vrsta nezgodnih problema, vezanih za curenje memorije. Umesto ručnog oslobađanja memorije, GC s vremena na vreme čisti memoriju na taj način što uklanja sve instance na koje ne postoje strong reference. Drugim rečima, dok god postoji referenca obj, GC neće osloboditi memoriju koju zauzima instancirani objekat na koji ukazuje.

Onog trenutka kada referenca obj izađe iz svog scope-a, ili kada joj se pridruži null vrednost, objekat na koji ukazuje postaje kandidat za GC - naravno, pod uslovom da ne postoji još neka strong referenca koja ukazuje na njega. Treba obratiti pažnju da objekti prvo postaju kandidati za GC, što znači da neće istog trenutka biti uklonjeni iz memorije; šta više, ne postoji garancija da će ih GC uopšte ikada ukloniti ako odluči da za tim nema potrebe. Trenutak brisanja objekta iz memorije zavisi od algoritma GCa, trenutnog stanja memorije i sličnih parametara, na koje u run-time nema uticaja.

Definicije

Zadatak GCa je da identifikuje objekte koji više nisu u uoptrebi i da ih oslobodi iz memorije. Objekat je u upotrebi ako mu se može pristupiti ili je dostupan od strane programa u posmatranom trenutku i trenutnom stanju programa.

Tkzv. 'Root Set' ili osnovni skup referenci predstavljaju sve varijable, argumenti, statičke varijable itd. Za sve objekte na koje ukazuju reference iz osnovnog skupa (root set) se kaže da su dostupni u posmatranom trenutku i trenutnom stanju programa. Ti objekti takođe mogu da imaju reference na druge objekte, za koje se isto kažu da su dostupni. Svi ostali objekti na heap-u su nedostupni i kao takvi podesni za GC.

Sledeći dijagram ovo prikazuje:

Objekti označeni plavim su dostupni. Objekti označeni crvenim nisu dostupni i predstavljaju đubre garbage. Jedan objekat može da ukazuje na neki koji je dostupan, a da sam ne bude dostupan. Ako je objekat referenciran samo preko nedostupnih objekata i sam nije dostupan.

Strong reference ukazuju na dostupne objekte.

Kada su jake reference prejake

Strong reference mogu da prave problem kada ukazuju na objekat čije vreme života nije tačno definisano i koje je duže od poziva metode, a kraće od vremena izvršavanja aplikacije. Dobar primer za to može biti neki thread, ili, još bolje, socket, za koje se vezuju neki dodatni meta-podaci. Primera radi, za thread se može dodatno vezati informacija o njegovom stanju, a za socket se može vezati informacija o korisniku. Takve meta-podatke obično uvezujemo preko mape, kao u sledećem primeru:

public class MapLeaker {

   
public ExecutorService exec = Executors.newFixedThreadPool(5);
   
public Map taskStatus = Collections.synchronizedMap(new HashMap());
   
private Random rnd = new Random();

   
private enum TaskStatus {
       
NOT_STARTED, STARTED, FINISHED
   
}

   
private class Task implements Runnable {
       
int[] temp = new int[rnd.nextInt(1000)];
       
public void run() {
           
taskStatus.put(this, TaskStatus.STARTED);
            sleep
(rnd.nextInt(100));       // do something
           
taskStatus.put(this, TaskStatus.FINISHED);
       
}
    }

   
public Task newTask() {
       
Task t = new Task();
        taskStatus.put
(t, TaskStatus.NOT_STARTED);
        exec.execute
(t);
       
return t;
   
}

   
public static void sleep(int ms) {
       
try {
           
Thread.sleep(ms);
       
} catch (InterruptedException e) {
           
// ignore
       
}
    }

   
// ------------------------------------------------------- main

   
public static void main(String[] args) {
       
sleep(10000);
        System.out.println
("---start---");
        MapLeaker ml =
new MapLeaker();
       
for (int i = 0; i < 100000; i++) {
           
sleep(100);
            ml.newTask
();
       
}
    }
}

Ako se uključi java konzola (VM argument: -Dcom.sun.management.jmxremote), dobija se sledeći dijagram za potrošnju memorije u vremenu:

Jasno se vidi kako potrošnja memorije raste, sve dok ne nastane OutOfMemoryError. Ovakav dijagram je tipičan za problem curenja memorije.

Ovaj problem bi se mogao rešiti na standardni način: povremenim ručnim prolaskom kroz celu mapu i eksplicitnim uklanjanjem nepotrebnih objekata iz mape. Ovo se može uraditi svaki put prilikom dodavanja novog objekta ili u nekom pozadinskom thread-u.

Međutim, postoji elegantnije i ispravnije rešenje. Laički posmatrano, bilo bi lepo kada bi mogli da 'oslabimo' reference, tako da GC nekako sam zna da sve reference koje drži gornja mapa nisu jake, te da sam briše iz mape nepotrebne objekte.

 

Reference API

Java je od verzije 1.2 uvela pojam referenci čija je jačina slabija od standardnih, strong referenci. Ove slabe reference definišu nekoliko novih tipova na koji neki objekat može biti dostupan. Pored dostupnih i nedostupnih objekata, postoje i objekti koji su dostupni preko sledećih tipova referenci:

Za razliku od jakih referenci, koje su deo samog jezika, ove slabe reference su izvedene kroz specijalne klase i deo su standardnog APIja.

Definicije

Kada je neki objekat dostupan samo preko uobičajenih, strong referenci, za njega kažemo da je 'jako dostupan'. Ukoliko postoji samo jedan niz referenci do objekta i ako on sadrži bar jednu slabu referencu, za takav objekat kažemo da je 'slabo dostupan'. GC smatra da je objekat u upotrebi ako i samo ako je jako dostupan. Slabo dostupni objekti, kao i nereferencirani objekti se smatraju đubretom i pogodni su za uklanjanje.

Dakle, slabe reference omogućavaju da se napravi referenca na neki objekat, a da se ne spreči da bude pokupljen od strane GCa. Ako GC pokupi slabo dostupni objekat, sve slabe reference koje ukazuju na njega se setuju na null, tako da se objektu više ne može pristupiti preko slabih referenci.

Na pokaznom dijagramu, crvenim su označeni nedostupni objekti. Plavim su označeni jako dostupni objekti. Osenčeni objekti predstavljaju slabe reference. Sivim su označeni objekti koji su slabo dostupni, jer nema putanje kroz koju bi bili jako dostupni.

Objekat označen sa 'x' je jako dostupan jer postoji bar jedna putanja sačinjena samo od jakih referenci. Pored te jedne reference, na ovaj objekat ukazuje i jedna weak referenca, kao i jedan odbačeni objekat. Ako bi nestala jaka referenca, ovaj objekat bi postao slabo dostupan.

Lanci povezanosti

Da bi ustanovio jačinu dostupnosti nekog objekta, GC počinje od osnovnog seta i nalazi sve putanje do objekta na heap-u. Ako postoji bar jedna putanja sačinjena od jakih referenci, objekat je jako dostupan. Kada sve putanje imaju neku slabu referencu, jačina povezanosti jednaka je najslabijoj referenci najjače putanje.

Na primer, objekat u sledećem lanacu: Root Set -> phantom -> weak -> soft -> Object je phantom dostupan. Kada bi postojala postojala i strong referenca na weak referencu u ovom lancu, objekat bi postao weak dostupan - postoje 2 putanje, ali je jača putanja sa novom jakom referencom. Slično, ako bi postojala još jedna strong referenca na soft referencu, postojale bi 3 putanje, a objekat bi bio soft dostupan.

Kada neka putanja sadrži reference različite jačine, GC ih procesira od najjače do najslabije. Ovaj proces se uvek događa u sledećem redosledu, ali se ne garantuje kada će se desiti:

Soft & Weak reference

Soft reference

Objekat je soft dostupan ako nije jako dostupan i ako ne postoji putanja do njega sa weak ili phantom referencama, već samo sa jednom ili više soft referencom. GC može i ne mora da pokupi objekat koji je soft dostupan, što zavisi od toga koliko je skoro instanciran objekat, ali je dužan da oslobodi sve soft dostupne objekte pre nego što baci OutOfMemoryError.

Weak refrence

Neki objekat je weak dostupan ako nije jako ili soft dostupan i ako ne postoji putanja do njega sa phantom referencama, već samo sa jednom ili više weak referenci. Weak dostupni objekti se finaliziraju tek nakon nekog vremena pošto su njihove weak reference obrisane. Jedina prava razlika između weak i soft referenci je u tome što za soft dostupne objekte GC koristi algoritam po kojem utvrđuje da li da ih pokupi, dok bi weak dostupne objekte GC trebalo da pokupi u prvom sledećem prolazu. Ova razlika čak i zavisi od načina rada JVM: kada je u 'client' modu, JVM se trudi da ima malu potrošnju memorije, pa ranije briše soft reference. U serverskom modu je potrebno imati bolje performanse, pa se soft reference duže zadržavaju.

ReferenceQueue

Laički rečeno, soft i weak reference nisu dovoljno jake da zadrže objekat u memoriji. Sve slabe reference u Javi nasleđuju Reference klasu, koja, najprostije rečeno, predstavlja jednostavni wrapper oko objekta kojeg referencira. Njen interfejs sadrži metodu get() kojom se dobavlja objekat kojeg referencira. Vraćenu vrednost uvek treba proveravati, jer ako je objekat pokupljen od strane GCa, vraćena vrednost će biti null.

Ovakvo ponašanje slabih referenci samo za sebe ne bi bila mnogo korisno da ne postoji ReferenceQueue. Kada GC ukloni neki objekat, njegova slaba referenca se pridodaje u ovaj red (queue). Upravo ovim se omogućuje da se, s vremena na vreme, prati koje je objekte GC pokupio i da se uradi odgovarajuća deinicijalizacija i čišćenje u programu. Soft i weak reference ne moraju da imaju pridruženi queue.

Detalji mehanizma referenci

Uobičajeni način korišćenja weak reference izgleda ovako:

FooObject obj = new FooObject();                //1
ReferenceQueue rq = new ReferenceQueue();       //2
WeakReference wr = new WeakReference(obj, rq);  //3

Nakon izvršenja ovih linija, stanje objekata je sledeće:

Postoje tri jake reference: obj, rq i wr. Postoji jedna weak referenca koja ukazuje na objekat i koja je vezana za svoj queue. Važi sledeće:

wr.get();   // vraca referencu na FooObject
rq.poll();  // vraca null

 

Da bi weak referenca došla do značaja potrebno je ukloniti jaku referencu koja pokazuje na objekat, setovanjem njene vrednosti na null. Posle toga se forsira GC.

obj = null;
System.gc();

Ako se pretpostavi da je GC pronašao da je instanca FooObject-a weak dostupna (više ne ukazuje ni jedna jaka referenca, obj == null), stanje objekata će biti sledeće:

Instanca objekta ne postoji, jer ju je pokupio GC. Međutim, queue sadrži informaciju da je objekat finaliziran. Zato važi sledeće:

wr.get();   // vraca null
rq.poll();  // vraca referencu na WeakReference objekat

Phatom reference

Objekat je phantom dostupan, kada nije strong, soft ili weak dostupan i kada postoje samo putanje do objekta samo sa jednom ili više phantom referenci. Objekti koji su phantom dostupni su finalizirani, ali još uvek nisu uklonjeni iz memorije.

Phantom reference su prilično različite od ostalih, jer su toliko slabe da njihovo get() uvek vraća null. Za razliku od soft i weak referenci, phantom reference se jedino mogu koristiti (tj. instancirati) uz pridruženi ReferenceQueue. Druga, važnija, razlika je u tajmingu kada se phantom referenca smešta u ReferenceQueue. Soft i weak reference se stavljaju na pridruženi queue nakon nekog vremena pošto su reference obrisane (reference atribut ima vrednost null). Suprotno, phantom reference se smeštaju na queue tek kada postanu phantom dostupne, ali pre nego što reference budu obrisane (reference != null). Upravo zato get() vraća uvek null: da bi se sprečilo 'oživljavanje' objekta (kao što je to moguće u finalize metodi).

Kada GC nađe samo phantom reference na objekat, on odmah stavlja phantom reference na queue. Kada program pročita queue i nakon informacije da je phantomska referenca enqueued, program obično radi neku dodatno čišćenje, posle kojeg mora da signalizira referenci sa clear() da obriše svoju referencu i da objekat konačno može da bude uklonjen iz memorije.

Slabe reference u rešavanju problema

Problem sa mapom iz primera je što su instance Task-ova jako dostupne sve vreme, iz same mape, jer čine ključ mape. Trebalo bi, dakle, oslabiti ključeve mape, pa bi se Task praktično wrappovao u WeakRefence. Ovim je urađeno tek pola posla, jer je potrebno i napraviti mehanizam kojim se očitava ReferenceQueue i iz mape brišu ključevi koji su se našli u redu.

Kako je ovo čest slučaj, java je uključila u API klasu WeakHashMap. Ona radi jako slično opisanom mehanizmu, ima weak ključeve i strong reference na vrednosti. Kada se ovo zna, rešenje gornjeg problema postaje trivijalno:

 public Map taskStatus = Collections.synchronizedMap(new WeakHashMap());

Korišćenjem druge implementacije mape se sprečava curenje memorije, što se i vidi na java konzoli:

 

Hej, ne tako brzo!

Posmatrajući gornji program sa unetom ispravkom, neko bi mogao da se zapita: pošto se taskovi sada odmah stavljaju sa weak referencom kao ključ u mapu, da li to znači da će GC pri prolasku pokupiti kako završene taskove, tako i one taskove koji još uvek nisu ni započeti? To se, naravno, neće dogoditi, iz prostog razloga: na taskove koji čekaju izvršenje postoji jedna strong referenca - ThreadPoolExecutor ima u sebi blokirajući red taskova koje treba izvršiti. Zato su svi taskovi koji nisu izvršeni jako dostupni.

A šta se dešava kada naiđe GC dok se radi sa objektom koji je dobijen preko slabe reference? Ni tu nema problema, jer da bi se radilo sa slabo dostupnim objektom, mora se zatražiti strong referenca od slabe reference. Tada objekat postaje jako dostupan sve dok strong referenca ne izađe iz scope-a ili dobije null za vrednost. Dok god se radi sa objektom, on je jako dostupan.

Šta ispisuje sledeći primer posle rada GC kada glavni program čeka 500ms, a šta kada čeka 15s?

    static class Task implements Runnable {
       
public void run() {
           
System.out.println("in");
            sleep
(10000);
            System.out.println
("out");
       
}
    }

   
public static void main(String[] args) {
       
Thread t = new Thread(new Task());
        WeakReference tr =
new WeakReference(t);
        WeakReference wr =
new WeakReference(new Object());

        System.out.println
("start");
        t.start
(); t = null;

        sleep
(500);
//        sleep(15000);

       
System.out.println(wr.get());
        System.out.println
(tr.get());
        System.out.println
("gc");
        System.gc
();
        System.gc
();
        System.gc
();
        System.out.println
(wr.get());
        System.out.println
(tr.get());
        System.out.println
("end");
   
}

Kreiraju se 2 weak reference, gde jedna prima za objekat thread čija se strong referenca odmah briše. Pošto glavni program u prvom slučaju traje manje od dužine trajanja programa, očekuje se da će posle GCa obe weak reference biti null. Na prvi pogled se čini da je istanca Task slabo dostupna, i da bi GC možda mogao da je pokupi dok radi. Naravno, to se ne dešava, jer dok kog se radi sa objektom, on je jako dostupan. Zato je posle GC samo jedna referenca null.

Kada je vreme trajanja glavnog programa duže od trajanja thread-a, posle GCa obe weak reference postaju null.

Proširenja

Tužno je što je Sun stao samo na WeakHashMap. Ipak su slabe reference i ova klasa uvedeni jako davno, tako da nema opravdanja zašto ne postoje mnogo moćnije i generičkije kolekcije za rad sa slabim referencama. Kako je autoru ovog članka trebala jedna takva kolekcija, odlučio je da napravi generičku verziju: ReferenceMap, kojoj se prilikom instanciranja definiše koje su jačine i ključevi i vrednosti. Prvobitna implementacija je bila jednostavna: nasleđivala je HashMap-u i interno radila wrapovanje ključeva i/ili vrednosti u slabe reference. Pri pozivu svake metode radilo se poliranje internog ReferenceQueue, radi čišćenja mape od nepotrebnih objekata.

Ovaj pristup ima dva problema, a to su da takva implementacija ne može da bude generička (generics) i što nije baš performantna. Zato je urađen refaktoring, pa sada ReferenceMap nasleđuje AbstractMap, a koristi interno HashMap, zatim je napravljena posebna konverzija ključeva i vrednosti u neke od slabih referenci. Thread nadgleda ReferenceQueue i uklanja nepotrebne reference iz mape. Pokazalo se da autori Guice projekta razmišljaju slično, pošto je njihova implementacija bila vrlo slična - i jedina koju je autor našao na internetu, a da radi na opisan način. Ovako izrađena mapa nema problema sa performansama. Detalji izlaze iz okvira dokumenta, te se mogu diskutovati na nekom od sastanaka.