wtorek, marca 28, 2017

Referencje w Java'ie

Wychodzi na to, że wiele osób daje się zwieść temu, że w Java'ie część rzeczy jest przekazywana przez referencję, a część przez wartość.

Zacznijmy od tego co jest przez co przekazywane, później powiemy co to znaczy, że jest przekazywane i w końcu jak to sobie można wyobrazić.

W Java'ie wszystkie obiekty są przekazywane przez referencję, a wszystkie wartości proste aka prymitywy przez wartość*.

Co to znaczy?

Gdy mamy kod:
1. void doSth(MyType obj) {
2.   obj.field=7;
3.   obj = new MyType();
4. }
/.../
5. MyType obj = new MyType();
6. doSth(obj);
7. MyType obj2 = obj;
8. print(obj2.field);

w linii 5 zostaje stworzony nowy obiekt, a referencja do niego zostaje zapisana do zmiennej obj.
W linii 6 referencja ta zostaje przekazana do metody doSth().

Obiekt w linii 5 jest tworzony gdzieś** w pamięci.
Operator new zwraca referencję do obiektu.
Referencja jest liczbą, która mówi o który obiekt chodzi.
W najprostszym przypadku jest to adres obiektu w pamięci, chociaż w Java'ie lepiej myśleć o tym jako o indeksie w tablicy w której trzymamy fizyczne adresy obiektów (tak to kiedyś było zaimplementowane i robiło GC prostszym).

Najprościej wyobrazić to sobie można następująco (to nie działa w taki sposób bo JVM jest maszyną stosową, a nie rejestrową, ale model mentalny można taki mieć ;-)).

Gdy tworzony jest nowy obiekt to JVM decyduje gdzie się on znajdzie i adres tego nowo utworzonego obiektu zwraca i zapisuje w zmiennej obj.
Zapisanie w zmiennej oznacza w JVM albo zapisane tego "adresu" gdzieś w "rejestrze" albo na stosie.
Gdy w linii 6 przekazujemy obj do metody doSth() to przekazujemy "adres" do obiektu, nie sam obiekt. Nie robimy kopi obiektu, obiekt jest nadal w tym samym miejscu pamięci gdzie był po wykonaniu linii 5.
Metoda doSth() coś sobie robi na tym obiekcie w linii 2. Zapisuje w obiekcie wskazanym przez adres z "obj" bity które są identyfikowane jako liczba 7***.
W linii 3 robi się coś dziwnego, tworzony jest nowy obiekt, który zostaje stworzony w jakimś nowym miejscu niż nasz pierwszy obiekt. Do zmiennej lokalnej obj zostaje zapisany ten nowy adres.
Tu warto się zatrzymać i wrócić ;-) Do tej linii w obj był "adres" obiektu stworzonego w linii 5. Jednak w linii 3 przypisujemy do tej zmiennej adres nowego obiektu. Java robi tu trick, w linii 2 obj.field mówi "pole o nazwie field obiektu, który znajduje się pod adresem zapisanym w obj", w linii 3 samo obj to po prostu zmienna lokalna (rejestr, miejsce w pamięci) w której zapisujemy adres nowego obiektu.
Kończymy metodę doSth(). Zmienna obj w metodzie doSth() przestaje istnieć. Nie ma więc nikogo kto trzyma "adres" obiektu z linii 3. Obiekt ten może zostać poddany działaniu GC.
Jesteśmy w linii 7. Do nowej zmiennej lokalnej zapisujemy "adres" obiektu z linii 5.
W linii 8 chcemy wypisać wartość pola o nazwie field spod adresu przechowywanego w obj2.
Jest to pole o nazwie field obiektu stworzonego w linii 5, a jego wartość to 7 co jest wynikiem linii 2.

Tu dygresja do assemblera i fizycznego komputera ;-)

Pamięć komputera to w uproszczeniu 1 linia komórek pamięci.
Pierwsza komórka ma numer 0, druga 1 i tak dalej.

Adres to numer komórki. Adres pod którym znajdują się jakieś dane to numer komórki w pamięci od której te dane się zaczynają.

Referencja to właśnie taki adres pierwszej komórki pamięci gdzie znajduje się obiekt......

OK, tak to sobie można wyobrazić i tak to wygląda w wielu JVM, ale w naszym modelu mogłoby powstać pytanie, że jeśli to tak działa to jak działa GC, który może przenosić obiekty. Przecież wtedy adresy też się powinny zmieniać.
Stąd u nas referencja to tak naprawdę indeks w tablicy z adresami.

Jeśli mamy 5 obiektów to ta tablica wygląda jakoś tak:
{0x0000,0x00FA,0x1100,0x11FE,0xEE07}
Gdzie te liczby to adresy pamięci gdzie jest obiekt.
Wtedy referencja do obiektu pod adresem 0x00FA będzie miała wartość 1 i będzie wskazywała na element z indeksem 1****...

To referencje z grubsza mamy.
Widzimy, że "przekazywane obiektu przez referencję" to po prostu przekazane "adresu" do obiektu.

W przypadku typów prostych mamy przekazywanie przez wartość.

Znaczy to tylko tyle, że JVM trzyma w pamięci bajty, które dla danego typu oznaczają jakąś tam wartość. Gdy przekazujemy taki typ to JVM po prostu kopiuje bajty.

Gdy kod wygląda tak:
1. void doSth(int i) {
2.   print(i);
3.   i++;
4.  print(i);
5. }
/.../
6. int number = 7;
7. doSth(number);
8. print(number);

To w linii 7 JVM wkłada do zmiennej bajty, które są równe liczbie 7. To wkładanie do zmiennej to zapisanie tej liczby 7 do "rejestru" albo na stosie.
W linii 7 JVM kopiuje te bajty i przekazuje je do metody doSth. Tutaj zwykle przez zapisanie na stosie, a metoda doSth sobie je ze stosu pobierze.
W linii 2 JVM kopiuje te bajty i przekazuje do metody print, która je sobie wypisuje. (znów przez stos).
W linii 3 JVM zmienia "i" na zmienianą wartość... Wartość w rejestrze jest zwiększana o 1, albo ta ze stosu zdejmowana, zwiększana o jeden i znów zapisywana.
W linii 4 JVM znów zapisuje wartość ze zmiennej (rejestru) na stos i przekazuje tak do metody print...
Jesteśmy znów w linii 8.
Nasza wartość 7 z linii 6 jest przechowywana w "rejestrze" i nie została zmieniona w doSth (tam pracowaliśmy na kopi). Ta wartość jest zapisywana na stosie i przekazywana tak do print, które wypisze 7....

Jeśli to nie jest jeszcze jasne to popatrzmy na to jak to działa w normalnym komputerze.
Mamy procesor, który to procesor ma rejestry, w rejestrach może trzymać liczby.
Liczba ta może być wartością i wtedy np. chcąc dodać 1 do 2, ładujemy do jednego rejestru 1, do drugiego 2 i później w pierwszym umieszczamy wynik dodawania wartości rejestrów.
Możemy jednak też trzymać w rejestrze adres miejsca w pamięci gdzie jest wartość 1, a w drugim rejestrze adres miejsca pamięci gdzie jest liczba 2.
Teraz dodając dwie liczby zrobimy inaczej, najpierw do jeszcze jednego rejestru procesor wciągnie to co jest w pamięci pod adresem zapisanym w pierwszym rejestrze, do kolejnego rejestru wciągnie to na co wskazuje 2 rejestr, zrobi dodawanie i w końcu zapisze wynik pod adres zapisany w pierwszym rejestrze.... tak z grubsza ;-)

W przypadku referencji od strony bebechów JVM kod wygląda tak:

       0: new           #2                  // class MyType
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: aload_1
      10: invokevirtual #4                  // Method doSth:(LMyType;)V
      13: return

W linii 0 JVM tworzy nowy obiekt, czyli rezerwuje gdzieś pamięć, tworzy tam struktury takie jak pola i im podobne. Po stworzeniu obiektu umieszcza jego "adres" na stosie.
W linii 3 wartość "adresu" ze stosu jest kopiowana i zapisywana ponownie na stosie (mamy tam więc 2 razy tą samą wartość "jedna nad drugą").
Teraz w linii 4 wywoływana jest metoda <init> z naszego nowego obiektu. Invokespecial odczytuje wartość "adresu" obiektu ze stosu, sprawdza jaki to jest obiekt, wpisuje tą wartość do "this", zapisuje na stosie adres następnego rozkazu i skacze pod adres gdzie znajduje się metoda <init>.
Metoda <init> korzystając z adresu w referencji (czyli w this) wypełnia przygotowane w linii 0 pola wartościami. Gdy skończy pobiera wartość ze stosu z adresem następnego rozkazu (został tam odłożony w momencie wołania invokespecial), i wraca do linii 7.
W linii 7 składuje wartość referencji (która jest na stosie nadal, dzięki linii 3) w zmiennej lokalnej 1. To jest odpowiednik przypisania do zmiennej obj.
W linii 8 wartość zmiennej 1 (takiego "rejestru") jest zapisywana na stosie (który to już raz...).
W linii 8 robimy to samo.
W 10 wykonujemy invokevirtual, które to invokevirtual zdejmuje wartość ze stosu, zagląda do obiektu, ustala jego typ, teraz w tabeli metod wirtualnych szuka najbardziej szczegółowej metody doSth, zapisuje na stosie miejsce do którego ma wrócić, skacze do kodu metody wirtualnej, jeszcze przed tym w this zapisuje adres obiektu stworzonego w 0......
Po zakończeniu metody doSth ze stosu odczytywany jest adres miejsca gdzie trzeba wrócić, i skaczemy do linii 13.....

Reasumując (jak ktoś aż tutaj dotarł ;-)).
W Java'ie można sobie wyobrazić, że zmienna trzyma bajty. Powiedzmy dla uproszczenia, że jest to zawsze 8 bajtów.
Jeżeli w kodzie używamy typu prostego to bajty te to wartość tego typu prostego.
Jeżeli w kodzie używamy obiektu, to bajty "trzymane w zmiennej" to wartość referencji, czyli "adres" obiektu w pamięci.
Jeżeli przekazujemy taką zmienną do innej metody to te bajty są kopiowane i metoda dostaje swoją własną kopię tych bajtów. A kod metody wie czy opisują one wartość typu prostego, czy "adres" obiektu.
Gdy przypisujemy coś do zmiennej to kopiujemy bajty z prawej strony na lewą, jeśli to jest typ prosty to kopiujemy w tych bajtach wartość, jeśli to obiekt to kopiujemy wartość "adresu".

Przez to przekazywanie obiektów przez referencję (czyli kopiowanie dla metody bajtów z "adresem") może w JVM dochodzić do wycieków pamięci. Bo JVM liczy ile jest referencji do danego obiektu i póki ta liczba jest większa niż 0 to obiekt będzie trzymany w pamięci. Przez to wystarczy gdzieś zakitrać sobie referencję do obiektu i może ona nam siedzieć w pamięci po wsze czasy, chociaż jest nam nie potrzebna.
Możemy np. wrzucić taką referencję do HashMap'y z jakimś cache'm, wszystkie inne referencja poza tą z mapy mogą już dawno nie żyć, obiekt może nie być potrzebny, ale nadal będzie w pamięci siedział bo HashMap'a trzyma doń referencję (czyli bajty z "adresem").

I to by było na tyle ;-)

[Disclaimer: to wszystko wyżej są pewne uproszczenia by można sobie to było wyobrazić, wiele z tych rzeczy zależy od implementacji JVMa]

* - tak naprawdę naprawdę ;-) ZAWSZE kopiowane są bajty, z tym że w przypadku typów prostych, jak int, long, byte, short, etc JVM interpretuje te bajty jako wartość. Np. bajty 0x0007 (czyli 2 bajty, pierwszy o wartości 0, a drugi 7) dla typu char zostaną zinterpretowane przez JVM jako znak 7.
Jeżeli jest to referencja to JVM kopiuje bajty z "adresem" obiektu. Jeśli te bajty to 0x0007 (naprawdę mamy tutaj zwykle 4 albo 8 bajtów, nie dwa) to tym razem zostanie to zinterpretowane przez JVM jako adres 0x0007, co może oznaczać komórki pamięci od adresy 7, albo 7 indeks w tablicy, albo jeszcze 7*8 bajtów, to już zależy od JVMa, ale to coś pozwala znaleźć miejsce w pamięci gdzie jest nasz obiekt.
** - najpewniej w Edenie, ale to nie jest tak istotne, ważne że prawie zawsze gdzieś na stercie
*** - i niepostrzeżenie zrobiliśmy tutaj kopiowanie wartości, bo to typ prosty jest.
**** - może to być też wartość 5, jako numer bajtu od którego zaczyna się adres. To są już szczegóły implementacji danej JVM.


Podobne postybeta
Refleksje i serializacja w Java'ie - podstawy i obalanie mitów ;-)
Java 8 nadchodzi....
clone() i Cloneable się mszczą ;-)
finalize() - do czego służy, a do czego nie i z czym to się je.
Celeron M353 900MHz vs. Intel Atom 1.6GHz, czyli o tym czemu jest remis? ;-)

1 komentarz:

  1. Cytując stackoverflow:
    "Java is always pass-by-value. Unfortunately, when we deal with objects we are really dealing with object-handles called references which are passed-by-value as well. This terminology and semantics easily confuse many beginners."

    https://stackoverflow.com/questions/40480/is-java-pass-by-reference-or-pass-by-value

    OdpowiedzUsuń