200 != 200

W projekcie, nad którym pracowałem, wprowadziłem testy API. Testy działały bez zarzutu — do czasu. W pewnym momencie przestały przechodzić, a powód był co najmniej tajemniczy. Okazało się bowiem, że o ile 100 == 100, to już 200 != 200.

Zacznę może od kontekstu. Testowana końcówka REST to była prosta modyfikacja zasobu, standardowy PUT zmieniający jedno pole w małej encji. Encja wymagała obecności pola o nazwie id, jednak aby zapewnić zgodność z regułami REST, wartość ta występowała również w ścieżce URL. Na potrzeby tego wpisu uznajmy, że żądanie szło na adres /fruit/5, treść żądania zaś wyglądała następująco:

{
    "id": 5,
    "name": "banana"
}

Kod kontrolera zaś, po przetłumaczeniu na nasz owocowy przykład, wyglądał następująco:

@PutMapping(path = "/{fruitId}")
@ResponseStatus(OK)
public Fruit updateFruit(@Valid @RequestBody Fruit fruit,
                         @PathVariable Long fruitId) {

    if (fruit.getId() != fruitId) {
        throw new PathEntityIdMismatchException();
    }

    fruitService.updateFruit(fruit);

    return fruitService.findFruitById(fruitId);
}

Zwróćmy uwagę na dość ewidentny problem, czyli warunek if zawierający porównanie !=. Teoretycznie nie powinno to działać, bo przecież pracujemy tu na obiektach (typu Long), a więc poprawne byłoby użycie metody equals() albo jeszcze lepiej, na wypadek gdyby którakolwiek z porównywanych wartości była nullem, metody java.util.Objects.equals():

if (Objects.equals(fruid.getId(), fruitId)) {
    throw new PathEntityIdMismatchException();
}

Nastąpiła jednak rzecz niespodziewana: kod działał pomimo teoretycznie nieprawidłowego porównania. Aby działało to poprawnie, oba porównywane obiekty musiałyby być dokładnie tą samą instancją obketu Long. A jednak działało. Ktoś w zespole taki kod napisał, podczas code review z jakiegoś powodu nikt nie przyczepił się, QA nic nie znalazło, testy automatyczne przeszły i aplikacja poprawnie sobie działała na serwerze produkcyjnym.

I tu dochodzimy do moich testów API. W trakcie ich pisania testowane końcówki uruchamiałem wielokrotnie, co chyba jest zrozumiałe. W pewnym momencie dotychczas prawidłowo działająca końcówka aktualizująca nasz owoc zaczęła rzucać wyjątek PathEntityIdMismatchException. Szybkie odpalenie debuggera wykazało, że porównywane wartości, coś w okolicach 200, są takie same, ale instancje obiektów Long są różne. Dla pewności sprawdziłem końcówkę dla innego obiektu, gdzie ID było jednocyfrowe. Tu instancje były te same!

Podkreślmy zatem: Java przypisywała niższe wartości zawsze tej samej instancji obiektu, ale dla wyższych tworzyła oddzielne instancje.

Szybki przegląd kodu klasy Long przyniósł zaskakujące rozwiązanie. Otóż Java trzyma w pamięci podręcznej 256 obiektów Long dla wartości od -128 do +127 i używa ich zamiast powoływania nowych instancji za każdym razem, gdy do obiektu typu Long przypisujemy literał lub wynik jednej z metod z rodziny valueOf().

Oto relewantny kod w klasie Long:

private static class LongCache {
    private LongCache(){}

    static final Long cache[] = new Long[-(-128) + 127 + 1];

    static {
        for(int i = 0; i < cache.length; i++)
            cache[i] = new Long(i - 128);
    }
}

Podobną pamięć podręczną znajdziemy również w klasach Integer, Short, Byte (również zakres -128 do +127) i Character (od 0 do +127). Zauważmy, że ponieważ typ Byte ma zakres wartości od -128 do +127, wszystkie możliwe wartości tego typu są trzymane w pamięci podręcznej.

Cache nie jest stosowany, z przyczyn oczywistych, dla typów zmiennoprzecinkowych Float i Double. Nie są także cachowane wartości Boolean. Sposobem na powołanie oddzielnych instancji o ten samej wartości jest używanie konstruktora danego typu, np. new Long(200L).

Dla niedowiarków napisałem kawałeczek kodu udowadniający, że rzeczywiście dzieje się tak, jak napisałem powyżej.

Znacie to mityczne uczucie „nie wiem, dlaczego to działa”? To był właśnie taki moment. Życie pokazało jednak, że jeśli coś działa, choć nie powinno, to w pewnym momencie działać przestanie.


Komentarze

Kacper

2018-04-02, 20:16

Hej.

To o czym piszesz jest standardowo w programie certyfikatu OCA. To działa podobnie jak pool dla stringów inicjowanych literalnie. Tyle, że w przypadku Integerow to jest zahardkodowane w byte codzie.


Napisz komentarz


Szukaj wpisów


Chmura tagów