Przy okazji jednego z ostatnich code review natrafiłem na kawałek kodu w stylu:

Listing 1. Przykładowy kod z cr

     Collections2.transform(bussinessObjects, new Function<BussinessObject, SomeProperty>(){
        
          public SomeProperty apply(BussinessObject bo){
              return bo.getSomeProperty();
          }
        }
     );

Bardzo częsty obrazek w kodzie. Ogólna logika jest tu taka, że z listy obiektów pewnej klasy chcemy uzyskać listę obiektów z jednego z ich pól i dalej bawić się z tylko z tymi polami. Ma to sens. Kod tego typu jest czytelny i intuicyjny. W dodatku bardzo ładnie mapuje się na specyfikację „…ze zbioru BO wyciągamy wartości pola X dla każdego BO i następnie…”.

Pytanie brzmi czy powyższe rozwiązanie jest ok?

Klasa anonimowa to zło

Szczególnie w Javie, w której przez lata klasa anonimowa była protezą dla brakujących domknięć/miksinów. Jest to też pokłosie szybkiego kodowania prostych rzeczy tak jak przedstawiona powyżej. Wiemy, że można by to zrobić w pętli. Wiemy, że będzie też ok. Dlatego wybieramy prostszy i szybszy zapis z wykorzystaniem transformat z Guavy. W dodatku kod funkcji jest mocno jednorazowy zatem nie wyciągamy go nigdzie na zewnątrz do np. zmiennej.
Oczywiście pozostaje sprawa powtórzeń w kodzie, ale przy ogarniętym procesie budowania w razie czego otrzymamy informację z narzędzi śledzących takie rzeczy. Dochodzą tu jeszcze rzeczy związane z „drabinkowaniem” kodu i jego czytelnością. To jednak insza inszość.

Jako, że jest to proteza to i nie ma w Javie dobrych mechanizmów pozwalających na optymalizację takiego kodu przez JIT. Zauważyć należy, że funkcja taka jest bezstanowa lub jej stan jest silnie powiązany ze stanem obiektu, w którym została utworzona. Zatem można by przenieść ją do pola na poziomie obiektu. Odpowiednio statycznego dla funkcji bezstanowych i niestatycznego dla funkcji związanych z obiektem. Pytanie brzmi o ile szybciej będzie wykonywał się kod po takim przeniesieniu. Rzeczą oczywistą jest to, że usunięcie z kodu inicjalizacji obiektu przyspiesza go. Oczywiste jest też to, że im więcej wywołań metody w której oryginalnie inicjujemy obiekt funkcji tym różnica jest większa.

Sprawdźmy to!

Zadanie

By stworzyć w teście warunki w miarę zbliżone do takich jakie mogą panować na serwerze test będzie miał następującą konstrukcję:

  • Będziemy pracować na stosunkowo dużym zbiorze danych tak by zapchać pamięć.
  • Operacja wykonywana w funkcji powinna być „JITowalna” i łatwa w optymalizacji na poziomie kompilatora.
  • Operacja powinna być „na zdrowy chłopski rozum” dość długa.

Pierwsze dwa punkty wydają się oczywiste. Serwery są zazwyczaj obciążone zarówno jeśli chodzi o pamięć jak i procesor. Duża ilość danych pozwoli nam na zapchanie pamięci. W dodatku sama operacja „biznesowa” powinna być tak skonstruowana by JIT był ją wstanie zoptymalizować. Ostatni punkt pozwoli nam na sprawdzenie czy takie przeniesienie ma sens jeżeli operacja wewnątrz funkcji jest przynajmniej w teorii dość powolna. Innymi słowy czy czas inicjalizacji obiektu będzie mały w stosunku do czasu operacji.

Nasze zadanie zatem brzmi. Policzy N razy n! dla wszystkich liczb n ze zbioru [0, N-1]. Czyli jeżeli N=5 to odpalamy pięć razy pętle w której liczymy silnie dla liczb od 0 do 4. Całość ma sens jeżeli używamy BigInteger.

Listing 2. Kod „biznesowy”

public class Silnia {

	public BigInteger oblicz(BigInteger n) {
		return silnia(BigInteger.ONE, n);
	}


	private BigInteger tailRec(BigInteger acc, BigInteger n) {
		if (n.equals(BigInteger.ZERO))
			return acc;
		return tailRec(acc.multiply(n), n.subtract(BigInteger.ONE));
	}

}

Tu uwaga Java nie wspiera rekurencji ogonkowej. Po prostu. Wynika to z modelu bezpieczeństwa przyjętego przy projektowaniu języka i tego jak będzie on współpracował ze stosem. Do poczytania tu. Zatem powyższy kod to tylko sztuka dla sztuki i podanie wystarczająco dużej liczby na wejściu spowoduje StackOverflowException wynikający ze zbyt dużej liczby wywołań.

Nasze testowe funkcje będą wołać metodę oblicz. Teraz czas na naszych bohaterów:

Listing 3. Klasy inicjujące funkcje na różne sposoby

class FunctionAlwaysNew {

	public Collection<BigInteger> obliczSilnie(List<BigInteger> input) {

		return Collections2.transform(input, new Function<BigInteger, BigInteger>() {
			@Override
			public BigInteger apply(BigInteger n) {
				return App.silnia.oblicz(n);
			}
		});
	}
}

//...
class FunctionAsStatic {

	private static final Function<BigInteger, BigInteger> FUNCTION = new Function<BigInteger, BigInteger>() {
		@Override
		public BigInteger apply(BigInteger n) {
			return App.silnia.oblicz(n);
		}
	};

	public Collection<BigInteger> obliczSilnie(List<BigInteger> input) {
		return Collections2.transform(input, FUNCTION);
	}
}

Ilość danych na wejściu 17,5mln liczb. Parametr -Xmx2g:

Wprowadź dużą liczbę całkowitą.
17500000
Tworzę listę z danymi
Dane gotowe. Naciśnij enter by zacząć test
Rozpoczynam test dla przypadku z użyciem pola statycznego.
Test zakończony. Zajął 21ms

Rozpoczynam test dla przypadku z użyciem new.
Test zakończony. Zajął 1512ms

i to samo dla parametrów -Xmx2g -server:

Wprowadź dużą liczbę całkowitą.
17500000
Tworzę listę z danymi
Dane gotowe. Naciśnij enter by zacząć test
Rozpoczynam test dla przypadku z użyciem pola statycznego.
Test zakończony. Zajął 622ms

Rozpoczynam test dla przypadku z użyciem new.
Test zakończony. Zajął 6878ms

Zatem widać, że inicjalizacja statyczna jest jakieś dwa rzędy wielkości szybsza, a w przypadku użycia trybu serwera różnica jest rzędu wielkości.

No cześć maleńka… chcesz zobaczyć moją lambdę

Ok teraz kod z pomocą lambd i pytanie na ile będzie szybszy. Przy czym nie używam tu Stream API ponieważ wersja wielowątkowa to insza inszość i temat na inny tekst. Chcąc użyć lambd mamy do wyboru trzy opcje. Pierwsza to „inline”, druga to lambda przypisana do pola, a trzecia to method ref. Kod interesujących nas klas poniżej:

Listing 4. Wykorzystanie lambd

class FunctionLambdaInline {

	public Collection<BigInteger> obliczSilnie(List<BigInteger> input) {

		return Collections2.transform(input, n -> App.silnia.oblicz(n));
	}
}

//..

class FunctionLambdaStatic {

	public static final Function<BigInteger, BigInteger> FUNCTION = n -> App.silnia.oblicz(n);

	public Collection<BigInteger> obliczSilnie(List<BigInteger> input) {

		return Collections2.transform(input, FUNCTION);
	}
}

//...

class FunctionLambdaRef {

	public Collection<BigInteger> obliczSilnie(List<BigInteger> input) {

		return Collections2.transform(input, App.silnia::oblicz);
	}
}

I wyniki dla wersji z -Xmx2g:

Wprowadź dużą liczbę całkowitą.
17500000
Tworzę listę z danymi
Dane gotowe. Naciśnij enter by zacząć test
Rozpoczynam test dla przypadku z użyciem pola statycznego.
Test zakończony. Zajął 30ms

Rozpoczynam test dla przypadku z użyciem new.
Test zakończony. Zajął 2162ms

Rozpoczynam test dla przypadku z użyciem lambdy inline.
Test zakończony. Zajął 576ms

Rozpoczynam test dla przypadku z użyciem lambdy method ref.
Test zakończony. Zajął 6358ms

Rozpoczynam test dla przypadku z użyciem lambdy statycznej.
Test zakończony. Zajął 19ms

i z -Xmx2g -server:

Wprowadź dużą liczbę całkowitą.
17500000
Tworzę listę z danymi
Dane gotowe. Naciśnij enter by zacząć test
Rozpoczynam test dla przypadku z użyciem pola statycznego.
Test zakończony. Zajął 29ms

Rozpoczynam test dla przypadku z użyciem new.
Test zakończony. Zajął 3137ms

Rozpoczynam test dla przypadku z użyciem lambdy inline.
Test zakończony. Zajął 37ms

Rozpoczynam test dla przypadku z użyciem lambdy method ref.
Test zakończony. Zajął 6540ms

Rozpoczynam test dla przypadku z użyciem lambdy statycznej.
Test zakończony. Zajął 24ms

Podsumowanie

Oczywiście metodyka testów jest mocno chałupnicza i wyniki są tylko poglądowe. Dotyczą też javy „od suna” czyli maszyny hotspot.
Wyniki pozwalają jednak wnioskować, że inicjalizacja statyczna będzie dużo szybsza niż każdorazowe wołanie new. Ponad to jeżeli mamy okazję użyć javy 8 to należy wystrzegać się method ref i w zamian używać lambd. Przy czym w wersji serwerowej nie ma większego znaczenia jak będzie ona tworzona.