Historia pytania: Java wprowadziła natywną binarną serializację w JDK 1.1 za pomocą API ObjectOutputStream i ObjectInputStream, ustanawiając protokół, w którym grafy obiektów są spłaszczane do strumieni bajtów w celu zachowania lub transferu sieciowego. Specyfikacja wymaga, aby podczas rekonstrukcji ObjectInputStream przydzielał pamięć dla obiektu docelowego przy użyciu sun.misc.Unsafe lub bezpośredniej refleksji, całkowicie omijając konstruktory. Ten wybór projektowy fundamentalnie koliduje z poleganiem wzorca singletona na prywatnych konstruktorach w celu ograniczenia instancjonowania.
Problem: Gdy klasa implementuje Serializable, framework deserializacji tworzy nową instancję, wywołując allocateInstance bez wykonywania jakiejkolwiek logiki konstruktora. Dla singletona zaprojektowanego w celu wymuszania pojedynczej obecności poprzez prywatny konstruktor i statyczną fabrykę, to naruszenie tworzy drugi odrębny obiekt w stosie, łamiąc gwarancję równości tożsamości. W konsekwencji, stan statyczny przeznaczony do bycia globalnym staje się fragmentowany w wielu instancjach, co prowadzi do niespójnego zachowania w aplikacjach polegających na pojedynczych punktach kontrolnych.
Rozwiązanie:
Metoda readResolve służy jako hak po deserializacji zdefiniowany w kontrakcie Serializable, pozwalający klasie zastąpić zdeserializowany obiekt kanoniczną instancją przed jego zwróceniem do wywołującego. Deklarując metodę z dokładnie tą samą sygnaturą protected Object readResolve() throws ObjectStreamException, deweloperzy mogą przechwycić nowo stworzoną duplikat i zamiast tego zwrócić statyczne pole INSTANCE. Ta substytucja następuje bezproblemowo w procesie rozwiązywania strumienia, skutecznie odrzucając fałszywy obiekt do zbierania śmieci, jednocześnie zachowując integralność singletona.
public class Configuration implements Serializable { private static final Configuration INSTANCE = new Configuration(); private String dbUrl; private Configuration() { this.dbUrl = System.getenv("DB_URL"); } public static Configuration getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }
Rozważ architekturę rozproszonych mikroserwisów, w której singleton DatabaseConfig zarządza parametrami puli połączeń i poświadczeniami. Usługa serializuje tę konfigurację do rozproszonej pamięci podręcznej, takiej jak Redis, aby przyspieszyć zimne starty po wdrożeniach. W przypadku zdarzeń skalowania poziomego, nowe instancje usługi pobierają i deserializują ten binarny blob, nieumyślnie uruchamiając domyślny protokół deserializacji.
Bez środków ochronnych, ObjectInputStream instancjonuje osobny obiekt DatabaseConfig różny od statycznego INSTANCE przechowywanego w JVM. Ta duplikacja tworzy scenariusz podziału, w którym nowa instancja nie ma haków inicjalizacyjnych wykonywanych podczas statycznej konstrukcji, potencjalnie wskazując na przestarzałe punkty końcowe bazy danych lub nieinicjalizowane dostawcy poświadczeń. W wyniku tego aplikacja cierpi na wycieki zasobów, ponieważ duplikowane puli połączeń powstają, wyczerpując limity połączeń bazy danych i powodując kaskadowe awarie w klastrze.
Jednym z podejść jest przekształcenie singletona w typ Enum, wykorzystując zapewnienie JVM, że enumy są singletonami z definicji i odpornymi na serializację z konstrukcji. Zaletami są: mechanizm serializacji automatycznie obsługuje stałe enumów na podstawie wyszukiwania po nazwie, zapobiegając całkowicie tworzeniu instancji. Wady: Enumy nie mogą rozszerzać klas abstrakcyjnych, co ogranicza elastyczność architektoniczną, a także brakuje im semantyki leniwej inicjalizacji, co może prowadzić do wcześniejszego załadowania ciężkiej konfiguracji podczas inicjalizacji klasy.
Alternatywnie, implementacja metody readResolve w istniejącej klasie pozwala jej zwrócić kanoniczny INSTANCE po zakończeniu deserializacji. Zaletami: To zachowuje hierarchie dziedziczenia i wspiera złożoną logikę inicjalizacji, jednocześnie wyraźnie chroniąc przed podwójnym tworzeniem. Wady: Deweloperzy często pomijają tę metodę, a jej implementacja wymaga starannej synchronizacji, jeśli instancjacja singletona sama w sobie jest leniwa i bezpieczeństwo wątków nie jest jeszcze gwarantowane podczas statycznej inicjalizacji.
Trzecią opcją jest przełączenie się na Externalizable, ręczne kontrolowanie strumienia serializacji za pomocą writeExternal i readExternal, aby zapisywać tylko identyfikatory konfiguracji, a nie pełny stan. Zaletami: To zapobiega atakom będącym skutkiem tworzenia instancji poprzez odmowę serializacji wewnętrznych obiektów, zamiast tego pobierając konfiguracje z bezpiecznego magazynu podczas readExternal. Wady: To wprowadza znaczną ilość kodu pomocniczego i wymaga zachowania zgodności wstecznej z formatami strumieni w wersjach aplikacji, zwiększając obciążenie konserwacyjne.
Zespół inżynieryjny wybrał rozwiązanie 2, implementując readResolve, aby zwrócić statyczne INSTANCE, ponieważ DatabaseConfig musiał rozszerzać abstrakcyjną klasę BaseConfiguration w celu wspólnej funkcjonalności audytowej, co czyniło enumy niewłaściwymi. Połączyli to z chętną inicjalizacją, aby uniknąć problemów z synchronizacją podczas deserializacji, zapewniając, że singleton istniał przed jakąkolwiek deserializacją. To podejście wyważyło minimalne wprowadzenie zmian w kodzie z solidną ochroną przed podatnością na podwójne instancje.
Po wdrożeniu testy obciążeniowe potwierdziły, że deserializacja pamięci podręcznych konfiguracji zwracała identyczne referencje obiektów, eliminując duplikowane puli połączeń. Usługa skalowała się poziomo bez wyczerpania połączeń z bazą danych, a profilowanie pamięci zweryfikowało, że żadne dodatkowe instancje DatabaseConfig nie pozostały w stercie po cyklach zbierania śmieci. To rozwiązanie utrzymało rozszerzalność architektoniczną, jednocześnie wzmacniając kontrakt singletona przeciwko atakom serializacyjnym.
Jak interakcja między readObject a readResolve wpływa na stan pól transientnych w deserializowanych singletonach?
readObject rekonstruuje cały stan obiektu z strumienia, w tym wykonując logikę inicjalizacji dla pól transientnych, zanim JVM uzna obiekt za kompletny. Następnie następuje wykonanie readResolve, a jeśli zwróci inną kanoniczną instancję, JVM odrzuca w pełni zrekonstruowany tymczasowy obiekt, w tym wszelkie wartości transientne obliczone podczas readObject. Deweloperzy muszą ręcznie skopiować stan transientny do kanonicznej instancji w ramach readResolve, jeśli takie efemeryczne dane są wymagane, chociaż dla prawdziwych singletonów pola transientne powinny generalnie być ponownie wyprowadzone z kanonicznego stanu, a nie z serializowanych strumieni.
Dlaczego implementacja Externalizable omija ochrony oferowane przez readResolve?
Interfejs Externalizable przenosi kontrolę serializacji całkowicie do klasy za pomocą writeExternal i readExternal, omijając standardowy mechanizm ObjectInputStream defaultReadObject, który sprawdza readResolve. Kiedy readExternal zapełnia nowo skonstruowaną instancję, strumień traktuje to jako ostateczny obiekt i zwraca go bezpośrednio, nie wywołując readResolve, chyba że deweloper wyraźnie go wywoła w ramach readExternal. Ta różnica architektoniczna oznacza, że deweloperzy korzystający z Externalizable muszą ręcznie implementować logikę kontroli instancji w ramach readExternal, zazwyczaj rzucając InvalidObjectException lub łącząc stan z singletonem jawnie, a nie polegając na automatycznym hakowaniu substytucji.
Co uniemożliwia prawidłowe działanie readResolve w typach rekordów Javy?
Rekordy serializują i deserializują się przez swoje kanoniczne konstruktory i metody dostępu do komponentów zamiast używać opartej na refleksji populacji pól stosowanej dla tradycyjnych klas, co oznacza, że proces deserializacji nigdy nie tworzy pustego obiektu, który readResolve mogłoby zastąpić. JVM rekonstruuje rekordy, wywołując kanoniczny konstruktor z zdeserializowanymi wartościami komponentów, co czyni readResolve nieodpowiednim, ponieważ instancja jest w pełni skonstruowana i niemodyfikowalna natychmiast po utworzeniu. Aby uzyskać zachowanie podobne do singletona z rekordów, deweloperzy muszą zamiast tego używać metod fabrycznych statycznych oznaczonych jako @Serial dla niestandardowych prowizji serializacyjnych lub porzucić rekordy na rzecz standardowych klas, gdy niezbędna jest ścisła kontrola instancji przez readResolve.