Temat traktowania łańcuchów znaków w Javie jest standardowym tematem egzaminacyjnym, jak również ciekawym przypadkiem w codziennej pracy i w czasie nauki. Zacząłem pisać ten wpis pewien czas temu, do jego ukończenia zmobilizował mnie wpis na blogu dayt3k'a traktujący o tym temacie.

Łańcuchy znaków, a dokładnie - literały - traktowane są w Javie wyjątkowo, inaczej niż "typowe" obiekty. Zanim przejdziemy do ich traktowania, odpowiedzmy sobie najpierw na pytanie, czym w Javie jest literał znakowy?

Zgodnie z JLS§3.10.5:

Literał znakowy składa się z zera lub więcej znaków ujętych w znaki cudzysłowu. Literał znakowy jest zawsze typu String. Literał znakowy zawsze odnosi się do tej samej instancji klasy String.

Kluczem do zrozumienia sensu takiej definicji jest niezmienność obiektów typu String, które w Javie są immutable. Oznacza to dokładnie tyle, że raz utworzony obiekt typu String na zawsze zachowuje swoją tożsamość i nie ulega modyfikacjom.

Przykład, który ilustruje tą cechę:

String test = "test";
System.out.println(test);
test.concat("test");
System.out.println(test);

Wynikiem tego działania będzie dwukrotne wyświetlenie napisu test. Użycie metody concat na rzecz obiektu test nie modyfikuje tego obiektu. W zamian tworzy w pamięci nowy obiekt typu String zawierający testtest i następnie zwraca go. Ponieważ wartości zwracanej nigdzie nie zachowujemy, już w chwili jej zwrócenia nie istnieje do niej żadna referencja i może zająć się nią Garbage Collector. Z naszego punktu widzenia natomiast nic się nie zmieniło, obiekt wskazywany przez test pozostaje dokładnie tym samym obiektem.

O co zatem chodzi z fragmentem

Literał znakowy zawsze odnosi się do tej samej instancji klasy String.

z JLS?

W javie istnieje pojęcie String Literal Pool. Bardzo często ten byt określany jest jako zbiór obiektów typu String zbudowanych z literałów. Nie jest to jednak prawda. String Literal Pool jest zbiorem referencji do obiektów typu String utworzonych z literałów.

W Javie wszystkie obiekty tworzone są na stercie, łańcuchy znaków nie lądują magicznie w innym miejscu - podlegają dokładnie tym samym regułom. Jedyną różnicą jest fakt, że dla obiektów stworzonych z literałow tworzona jest referencja w String Literal Pool. Każdy literał w programie ma zatem zagwarantowaną przynajmniej jedną referencję do siebie - tą tworzoną w zbiorze literałów. Co więcej, w przypadku obiektów tworzonych z literałów w czasie działania programu istnieje zawsze dokładnie jeden obiekt typu String o danej zawartości - założeniem koncepcyjnym była oszczędność pamięci. Po co bowiem trzymać dwa obiekty zawierające dokładnie ten sam łańcuch znaków, skoro już na etapie kompilacji wiemy, że będą to duplikaty? Takie podejście do problemu umożliwia nam właśnie niezmienność obiektów typu String. Ponieważ obiekt na pewno nie ulegnie zmianie, można bezpiecznie korzystać z jednego wspólnego obiektu dla wielu referencji.

String a = "test";
String b = "test";
System.out.println(a==b);

Operator == zawsze porównuje referencje, także w tym przypadku. Mimo to wynikiem działania programu będzie true. Powodem tego jest fakt, że "test" jest literałem.

W chwili uruchomienia programu tworzony jest dokładnie jeden obiekt String zawierający łańcuch test. Do tak stworzonego obiektu umieszczana jest referencja w String Literal Pool, gdzie pozostaje ona do końca działania programu. Referencje a oraz b wskazują na dokładnie ten sam, jeden obiekt. Dlatego też porównanie z użyciem operatora == zwraca prawdę. W powyższym przykładzie do pojedynczego obiektu typu String przetrzymywane są trzy referencje - dwie zmienne lokalne oraz referencja w stałym zbiorze łańcuchów.

Żeby uściślić - podczas kompilacji literały znakowe, podobnie jak inne stałe, umieszczane są w pliku wynikowym. Podczas ładowania maszyna wirtualna dla każdego literału sprawdza, czy obiekt o takiej zawartości już nie istnieje. Jeżeli nie, tworzy obiekt typu String na stercie, oraz pojedynczą referencję do niego w String Literal Pool. Napotykając kolejne wystąpienie identycznego literału, JVM podstawia referencję do już istniejącego obiektu pobraną z String Literal Pool.

Bardzo ważne jest, że tak opisane zachowanie prawidłowe jest jedynie dla literałów. Obiekty tworzone dynamicznie w czasie uruchomienia programu - bądź to utworzone za pomocą słowa kluczowego new, bądź pobrane ze strumieni lub uzyskane w inny sposób, nie podlegają przedstawionym regułom.

public class Main {
    public static void main(String[] args) {
        String a = "test";
        String b = new String("test");
        String c = args[0];
        System.out.println(a == b);
        System.out.println(a == c);
        System.out.println(b == c);
    }
}

Powyższy program uruchomiony przy użyciu polecenia java Main test wypisze trzykrotnie false. Ani obiekt utworzony słowem kluczowym new, ani utworzony z wartości przekazanej do maszyny wirtualnej w trakcie uruchomienia nie jest tożsamy z obiektem wskazywanym przez a. Wszystkie trzy referencje wskazują na trzy różne obiekty.

Użycie słowa new wymusza utworzenie nowego obiektu. Zgodnie z mechanizmem działania String Literal Pool wiemy też, że w chwili ładowania klasy - gdy tworzona jest pula łańcuchów - JVM nic nie wie o wartości args[0], nie jest ona odnotowana w pliku klasy w wyniku kompilacji. Również i tu obiekt wskazywany przez c nie będzie związany z obiektem tworzonym przez mechanizm puli łańcuchów.

Przyjrzyjmy się bliżej przypadkowi z new

public class Main {
    public static void main(String[] args) {
        String b = new String("test");
    }
}

Ile obiektów typu String tworzonych jest w tym programie? Dwa. Kompilator odnotowuje wystąpienie literału "test". Podczas ładowania klasy tworzony jest obiekt zawierający łańcuch "test" oraz referencja do niego w String Literal Pool. Referencja ta jednak nie jest przypisywana do zmiennej b - w zamian tworzony jest drugi obiekt zawierający łańcuch "test" i referencja do tegoż obiektu przypisana zostaje zmiennej b.

Klasa String oferuje natomiast metodę intern(). Działanie jej jest bardzo proste - JVM przeszukuje String Literal Pool w poszukiwaniu referencji do obiektu o zawartości identycznej z obiektem, na rzecz którego wywołaliśmy intern(). Jeżeli referencja taka zostanie znaleziona, zostaje przez intern() zwrócona.

public class Main {
    public static void main(String[] args) {
        String a = "test";
        String b = new String("test");
        String c = args[0];
        System.out.println(a == b.intern());
        System.out.println(a == c.intern());
        System.out.println(b.intern() == c.intern());
    }
}

W wyniku działania tego programu, dzięki zastosowaniu intern(), program wypisze trzykrotnie true. Funkcja intern() w każdym przypadku zwraca referencję do obiektu utworzonego z literału "test" w trakcie ładowania klasy.

Pozostaje jeszcze jedna kwestia - jak ta cecha łańcuchów w Javie wpływa na Garbage Collector? Otóż literały znakowe nigdy nie są usuwane przez GC. Powód jest bardzo prosty - do literałów znakowych zawsze istnieje przynajmniej jedna referencja - ta w String Literal Pool.

String a = "test";
a = null;

Choć może wydawać się, że obiekt wskazywany w pierwszej linii przez a po wykonaniu drugiej będzie mógł zostać usunięty przez Garbage Collector, nie jest to prawda - do obiektu typu String zawierającego "test" nadal wskazuje jedna referencja w puli łańcuchów stałych.

JLS zwraca uwagę na 6 szczegółów:

  • Literały w tej samej klasie i w tym samym pakiecie reprezentują referencję do tego samego obiektu.
  • Literały w różnych klasach w tym samym pakiecie reprezentują referencję do tego samego obiektu.
  • Literały w różnych klasach i w różnych pakietach również reprezentują referencję do tego samego obiektu.
  • Łańcuchy obliczone z wyrażeń stałych są wyznaczane w czasie kompilacji, a następnie traktowane tak jakby były literałami.
  • Łańcuchy wyznaczone w czasie wykonania są tworzone w czasie wykonania, a co za tym idzie, zawsze różne.
  • Wynikiem jawnego internowania (intern()) łańcucha jest dokładnie ten sam łańcuch, jak już istniejący literał o tej samej treści.

Chciałbym zwrócić tutaj uwagę na punkt 4.

public class Main {
    public static void main(String[] args) {
        String a = "a";
        String b = "b";
        int c = 0;

        final String x = "a";
        final String y = "b";
        final int z = 0;


        System.out.println((a + b + c) == "ab0");
        System.out.println((x + y + z) == "ab0");
    }
}

Wynikiem działania będzie wypisanie false a następnie true. Wyrażenia zbudowanie z użyciem compile time constant expressions, czyli stałych wyrażeń znanych w trakcie kompilacji, w momenie kompilacji sprowadzane są do literałów i w taki też sposób traktowane.

Komentarze do wpisu "Literały łańcuchowe - String Literal Pool":

1. daytek napisał(a):
21 lipca 2009, 17:31:48

Bardzo rozbudowana wersja mojego artykulu. Traktuje doslowanie o wszystkim i dobrze :) Bede obserwowal dalsze wpisy :)

Dodaj komentarz: