Java 9 – Nowa reprezentacja Stringów

Dotychczas w Javie Stringi, czyli łańcuchy znaków były reprezentowane właśnie w ten konkretny sposób: jako dosłownie łańcuch znaków, a raczej tablica, char[]. Każdy char ma alokowane 16 bitów pamięci, dla reprezentowania palety znaków UTF16. W najpopularniej wykorzystywanym języku łacińskim o kodowaniu UTF8 chary nie były wykorzystywane w pełni, ponieważ każdy znak alokowane miał 16 bitów, a wypełniał 8. A co, gdyby udało się pozbyć tej pustej przestrzeni i wykorzystać wszystkie bajty przeznaczone na dany napis?

W większości aplikacji javowych tablice charów zajmują około 25% sterty. Przy informacjach z początku tekstu, śmiało można założyć, że około 10% sterty to niewykorzystana, pusta przestrzeń. Od javy 9 jednak, Stringi są kompresowane do 8 bitów, czyszcząc pustą przestrzeń, de facto, spomiędzy znaków. Dzieje się tak, ponieważ zmieniono reprezentację łańcucha znaków wewnątrz klasy String z char[] na byte[].

Klasa String w JDK8 i JDK9

final class String implements... {
  private final char value[];
  
  private int hash;
}
public final class String implements... {
  @Stable
  private final byte[] value;
  private final byte coder;
  private int hash;
}

Widać tu już wspomnianą zmianę, czyli występujące w jdk9 tablice bitów zamiast znaków, jednak jest jeszcze coś – pole coder.

Podczas tworzenia stringa w jdk9, obiekt jest automatycznie kompresowany. Jeśli kompresja przebiega bezproblemowo, tj. nic nie przeszkadza w zamianie UFT16 na UTF8 (a przeważnie tak jest), pole coder jest ustawiane na wartość Latin1. Jeśli kompresja się nie uda – coder zostanie ustawiony na wartość UFT16. Dzięki temu obiekty String są utrzymywane jako UTF8 lub UTF16, a sterowane jest to automatycznie w zależności od zawartości. W podobny sposób zachowywały się jakiś czas temu jeszcze telefony komórkowe, w których długość wiadomości SMS ustawiona na 160 znaków dotyczyła kodowania 7-bitowego. Przypadkowe dodanie polskiego znaku diakrytycznego zmieniało automatycznie kodowanie na 16 bitowe oraz skracało maksymalną długość wiadomości do 70. Pokolenie obecnych trzydziestolatków niejednokrotnie się na to nacięło.

Zmiany w String API

Zmiana implementacji Stringa w jdk9 pociągnęła za sobą także kilka zmian w String API. Weźmy pod uwagę metodę charAt, ponieważ tam elegancko widać wykorzystanie codera.

public char charAt(int index){
  if ((index < 0 || index >= value.length)){
    throw new StringIndexOutOfBoundsException(index);
  }
  return value[index];
}
public char charAt(int index){
  if (isLatin1()){
    return StringLatin1.charAt(value, index);
  } else {
    return StringUTF16.charAt(value, index);
  }
}

boolean isLatin1(){
  return COMPACT_STRINGS && coder == LATIN1;
}

COMPACT_STRINGS to stała, która defaultowo ma wartość true.

Zmiana prócz Stringa pociągnęła także za sobą klasy AbstractStringBuilder oraz AbstractStringBuffer – klasy nadrzędne do builderów i bufferów.

Korzyści z COMPACT STRINGS

    • Zauważalna redukcja zużycia pamięci na Stringi, Buildery i Buffery – ok. 40% mniejsze zużycie pamięci per string
    • Ok. 15% mniejsze zużycie pamięci na całej stercie
    • Mniejsze użycie garbage collectora  (poczytaj: Garbage Collector w JDK9)
    • Bezpośredni wpływ na wydajność i opóźnienia
    • A najlepsze – dzieje się to automatycznie po upgradzie do jdk9, niezauważalnie dla developera, gdyż implementacja jest ukryta, a kompatybilność zachowana

Konkatenacja stringów

Wyobraźmy sobie standardową metodę, konkatenującą stringi w standardowy sposób, niech to będzie logger:

private void logger(String message, int count){
  LOGGER.info("[" + System.currentTimeInMillis() + "] " + message + " ("+ count + ")");
}

Tak prezentuje się bytecode takiej metody:

private static void logger(java.lang.String, int);
  Code:
     0: getstatic		#4		// Field java/lang/System.out:Ljava/io/PrintStream;
     3: new			#5		// class java/lang/StringBuilder
     6: dup
     7: invokespecial		#6		// Method java/lang/StringBuilder."<init>":()V
    10: ldc			#7		// String [
    12: invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    15: invokestatic		#9		// Method java/lang/System.currentTimeMillis:()J
    18: invokevirtual		#10		// Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
    21: ldc			#11		// String ]
    23: invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    28: invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    31: aload_0
    32: invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    35: ldc			#13		// String (
    37 invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    40: iload_1
    41: invokevirtual		#14		// Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
    44: ldc			#15		// String )
    46: invokevirtual		#8		// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    49: invokevirtual		#16		// Methoid java/lang/StringBuilder.toString:()Ljava/lang/String;
    52: invokevirtual		#17		// Method java/io/PrintStream.println:(Ljava/lang/String;)V
    55: return

Jak widać w liniach 8, 10, 12, 13, 15 itd, konkatenacja stringów przy użyciu operatora dodawania, w głębi jest po prostu wywołwaniem kolejnych StringBuilder.append, a na końcu – metody toString wywołanej na tymże builderze. Łańcuch następnie jest drukowany z wykorzystaniem println. Zejdźmy więc głębiej – do metody append.

Translacja konkatenacji na append StringBuildera może być błędna – właśnie z powodu alokacji pamięci. StringBuilder (tak samo jak String) w JDK8 ma standardową pojemność 16 bitów. Przy kolejnych wywołaniach metody append, obiekt ma każdorazowo modyfikowany rozmiar i jest realokowany w pamięci. Jak zatem można to było zoptymalizować?

W Javie9 konkatenacja stringów skutkuje utworzeniem w bytecodzie instrukcji invocedynamic – tej samej, która wykorzystywana jest między innymi do wyrażeń lambda, a która umożliwia dynamiczne ładowanie i wywoływanie metod w trakcie działania programu:

private static void logger(java.lang.String, int);
   Code:
      0: getstatic			#15		// Field java/lang/System.out:Ljava/io/PrintStream
      3: invokestatic		#21		// Method java/lang/System.currentTimeMillis:()J
      6: aload_0
      7: aload_1
      8: invokedynamic		#25, 0	// InvokeDynamic #0:makeConcatWithConstants:(JLjava/lang/String;I)Ljava/lang/String;
     13: invokevirtual		#29		// Method java/io/PrintStream.println:(Ljava/lang/String;)V
     16: return

Dzięki temu implementacja konkatenacji może zostać opóźniona aż do momentu jej wywołania, na zasadzie lazy loadingu. Dzięki temu optymalizacja może zachodzić także w późniejszej fazie ewolucji języka – już bez zmian bytecodu.

Kiedy ta instrukcja wykonywana jest po raz pierwszy, rzeczywisty cel jej wykorzystania jest nieznany. Skąd więc wirtualna maszyna wie jaki kod wykonać? Instrukcja zapewnia środki do ładowania wywołanych obiektów docelowych podczas początkowego wywołania. Jak widzimy w bytecodzie – invokedynamic ma referencję do metody makeConcatWithConstants, do której przesyła stringi jako parametry. Zejdźmy głębiej.

SourceFile: "IndifyStringConcatenation.java"
BootstrapMethods:
  0: #42 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/Call5ite;
    Method arguments:
      #48 [\u0001] \u0001 (\u0001)
InnerClasses:
  public static final #55= #51 of #53;	// Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles

Grzebiąc głębiej widzimy, że wczytywana metoda (bootstrap method) to makeContactWithConstants w klasie StringConcatFactory. Klasa ta oferuje zróżnicowane strategie konkatenacji stringów. Jedna z nich używa fixed length StringBuildera , który zwiększa wydajność konkatenacji przez brak konieczności realokacji. Inna z kolei używa tablicy bytów o konkretnym rozmiarze, która finalnie jest transformowana w wynikowy string. Ta ostatnia jest strategią domyślną i okazuje się być trzykrotnie szybsza niż standardowa konkatenacja z JDK8.

Zwróćcie uwagę na to, jak wyglądają argumenty metody (linia 5) – mamy tam symbol \u0001, i to wystepujący trzykrotnie. Otoczony jest znakami, które były początkowo podanie w metodzie logger jako literały – nawiasy kwadratowe i okrągłe. Można się domyśleć, że każdy z tych \u0001 to element dodany do łańcucha, kolejno reprezentujący czas, message i count.

Podsumowując: w Javie 9 jeśli konkatenujemy stringi wykorzystuując operator dodawania, w bytecodzie pojawia się instrukcja invokedynamic odpowiedzialna za dynamiczne wczytywanie i wywoływanie metod. Dzięki temu w runtimie może być wykonana odpowiednia strategia konkatenacji. Ten mechanizm zostawia otwarte drzwi developerom rozwijającym język do kolejnych optymalizacji tego mechanizmu, bez potrzeby rekompilowania kodu.

Z połączenia IndifiedStringConcatenation oraz Compact Strings wyłania się obraz potężnego upgradu wydajnościowego, który przyszedł do nas w Javie 9 przy rzeczy tak prozaicznej jak po prostu zwykłe napisy.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Strona używa danych zapisanych na komputerze odwiedzającego. Kliknij przycisk AKCEPTUJ COOKIES, aby nie widzieć więcej tego komunikatu Polityka prywatności

Gniado IT wykorzystuje cookies do przechowywania informacji na TWOIM komputerze. Cookies wykorzystywane przez tę witrynę to standardowe cookies wykorzystywane przez silnik Wordpress. Możesz ustawić swoją przeglądarkę w ten sposób, aby blokowała wszelkie próby użycia cookies, jednakże może to skutkować nieprawidłowym działaniem witryny. Dane nie są wykorzystywane do profilowania reklamowego, czy pozyskania wrażliwych / poufnych informacji.

Zamknij