Kodujże!

blog krakoskiego programisty

Author: Maciej Marczak

Mikroserwisy i Spring Cloud. Projekt bazowy.

Artykuł ten jest częścią serii zatytułowanej „Mikroserwisy i Spring Cloud”.

1. Mikroserwisy i Spring Cloud. Wprowadzenie.
2. Mikroserwisy i Spring Cloud. Projekt bazowy.

Projekt bazowy

We wpisie tym opiszę projekt bazowy, który z każdą kolejną odsłoną cyklu będzie coraz bardziej rozbudowywany, i który posłuży nam jako podstawa do testowania rozwiązań dostarczanych przez biblioteki z rodziny Spring Cloud.

Sklep Internetowy

Projektem tym jest sklep internetowy składający się z trzech współpracujących ze sobą mikroserwisów. Są nimi:

  • WarehouseService – serwis udostępniający metody do przeszukiwania magazynu i pobierania z niego produktów,
  • CartService – serwis zarządzający koszykami klientów zalogowanych do systemu,
  • OrderService – serwis odpowiedzialny za proces realizacji zamówienia. W swej pracy komunikuje się z CartService, od którego pobiera informacje o stanie koszyka, i któremu zleca jego czyszczenie (po przetworzeniu zamówienia).

Aby lepiej zrozumieć role poszczególnych mikroserwisów, spójrzmy jak mogłaby wyglądać ich wzajemna komunikacja w procesie przygotowywania i realizacji zamówienia:

  • Po wejściu na stronę sklepu klient otrzymuje od WarehouseService listę dostępnych w sklepie produktów.
  • Klient wybiera z listy interesujący go produkt i wysyła do CartService żądanie dodania tego produktu do swojego koszyka.
  • Klient realizuje zamówienie poprzez wysłanie żądania do OrderService, który kolejno pyta CartService o zawartość koszyka klienta, przetwarza zamówienie, a na koniec ponownie komunikuje się z CartService, tym razem w celu wyczyszczenia koszyka.

Czy taki model ma sens z biznesowego punktu widzenia? Prawdopodobnie nie. Nie jest jednak moim celem implementowanie skomplikowanych procesów, a jedynie stworzenie „piaskownicy” do zabawy springowymi rozwiązaniami. W związku z powyższym tworzony kod będzie w wielu miejscach uproszczony i „dziurawy” (nie pokryje wszystkich przypadków użycia, brak będzie dokładnej obsługi błędów). Początkowo też zrezygnujemy z tworzenia dedykowanego interfejsu graficznego oraz z implementowania security i logiki do zarządzania użytkownikami.

WarehouseService

Zadaniem WarehouseService jest udostępnianie metod służących do zarządzania stanem magazynu sklepowego. Stan magazynu definiowany jest przez zbiór znajdujących się w nim produktów, w związku z czym głównym obiektem domenowym w tym serwisie jest Product.

Tworzymy dla niego repozytorium.

Oraz serwis implementujący logikę biznesową dotycząca pobierania i przeszukiwania listy produktów.

Powyższy serwis nie zawiera żadnych metod służących do dodawania/usuwania przedmiotów z magazynu. Nie implementujemy ich, ponieważ póki co żaden z klientów nie potrzebuje takiej funkcjonalności.

Ostatnim kluczowym elementem jest kontroler.

CartService

Tak jak dla WarehouseService głównym obiektem domenowym jest Product, tak dla CartService jest nim Cart.

Każdy koszyk przypisany jest do konkretnego użytkownika (userId) oraz zawiera listę znajdujących się w nim produktów (products). Produkty te oczywiście odnoszą się do obiektów z WarehouseService, lecz do ich reprezentacji używamy lekko „odchudzonej” wersji klasy Product z tamtego serwisu.

W porównaniu do Product z WarehouseService nie mamy tutaj pola name. Z punktu widzenia koszyka jest ono nieistotne, więc nie zaprzątamy sobie nim głowy.

Dlaczego nie współdzielimy kodu klasy Product pomiędzy tymi dwoma serwisami? Bo tak jest najprościej. 🙂 Gdybyśmy mieli bibliotekę enkapsulującą wszystkie encje używane w systemie i byłaby ona zależnością dla każdego z tworzących system mikroserwisów, to mogłoby to sprawić wiele problemów. Co gdyby jeden z serwisów chciał dołożyć jedno pole więcej do encji i operować tak zaktualizowaną jej postacią? Wtedy każdy inny serwis musiałby się dostosować, a być może tylko jeden z nich byłby faktycznie zainteresowany tą zmianą.

Logika biznesowa zarządzania koszykiem znajduje się w klasie CartService.

Jednym z pól tej klasy jest cart. W celu uproszczenia implementacji przyjęliśmy założenie, że z systemu będzie korzystał tylko jeden użytkownik. W związku z tym CartService póki co będzie miał na stałe „zaszyty” koszyk tego jednego użytkownika.

Pozostał jeszcze kontroler.

OrderService

Ostatnim serwisem w naszym skromnym systemie jest OrderService. Jako że pomijamy szczegóły techniczne procesu realizacji zamówienia, to jest to jeden z prostszych implementacyjnie komponentów. Punktem wejścia do niego jest OrderController.

Tak jak i pozostałe dwa kontrolery, tak i ten jest bardzo „chudy” i jedyne co robi to oddelegowuje pracę do warstwy serwisów.

Implementacja OrderService raczej nie wymaga komentarza. CartService natomiast to warstwa abstrakcji, pod którą kryją się rest-owe wywołania do faktycznego serwisu.

Przykład działania

Kody źródłowe powyższych projektów można znaleźć na moim GitHubie. Aby uruchomić cały system wystarczy w folderze  00 - base project wywołać komendę  ./gradlew bootRun --parallelWarehouseServiceCartServiceOrderService zaczną nasłuchiwać na portach odpowiednio 8030, 8040 i 8050.

Przykładowa sekwencja zapytań może wyglądać następująco.

Podsumowanie

Przedstawiony projekt pozbawiony jest jakichkolwiek zależności względem rozwiązań z rodziny Spring Cloud. Postaramy się dodawać je cegiełka po cegiełce, stopniowo budując pełnoprawny system. Zaczniemy, już w następnym wpisie, od biblioteki Open Feign.

Mikroserwisy i Spring Cloud. Wprowadzenie.

Chcąc rozszerzyć nieco zakres tematów poruszanych na blogu i wyjść poza obszar zagadnień niskopoziomowych postanowiłem rozpocząć serię wpisów o mikroserwisach. W tym i w następnych artykułach postaram się przybliżyć założenia, na których opiera się ten model architektury, a także pokazać jak w realizacji tych założeń może pomóc nam projekt Spring Cloud. Mam nadzieję, że informacje zawarte tutaj okażą się dla osób zainteresowanych tematem przydatne, a forma ich przekazu przystępna.

Zapraszam do lektury!

Poniższy spis treści będzie aktualizowany wraz z pojawianiem się kolejnych wpisów.

1. Mikroserwisy z użyciem Spring Cloud. Wprowadzenie.

Słowem wstępu…

Historia branży informatycznej to historia odkrywania na nowo istniejących rozwiązań. Wybierzcie sobie trzy dowolne buzzwordy, jakie królowały na konferencjach na przestrzeni ostatnich kilku lat, a po bardziej szczegółowym zgłębieniu tematu najprawdopodobniej okaże się, że reprezentowane przez nie koncepty nie są niczym nowym i istniały w świadomości programistów na długo przed tym, jak branża spopularyzowała i zgloryfikowała je na wszelkie możliwe sposoby. Architektura mikroserwisowa nie jest tutaj wyjątkiem. Można bowiem o niej myśleć jako następcy SOA (Service Oriented Architecture) powstałym na drodze ewolucji sterowanej chęcią wykorzenienia z SOA tych elementów, które ograniczały możliwości tego modelu.

O różnicach między SOA a MSA (Microservices Architecture) będzie w jednym z kolejnych wpisów. Przed podjęciem takiego tematu niezbędne jest jednak zdefiniowanie czym w ogóle są mikroserwisy.

Czym jest MSA?

Architektura mikroserwisowa to podejście, w którym aplikacja tworzona jest jako zbiór niewielkich, autonomicznych, skupionych na wykonywaniu konkretnych zadań serwisów, współpracujących ze sobą w celu dostarczenia niezbędnej z punktu widzenia klienta funkcjonalności.

Rozbijmy tę definicję na części.

Niewielkie

Nie ma uniwersalnej miary, która pozwoliłaby z pewnością określić czy dany serwis jest mały, duży, a może w sam raz? Rozmiar w tym kontekście jest pojęciem względnym. Bardzo podoba mi się natomiast to, co na ten temat napisał Sam Newman w książce Building Microservices. Twierdzi on, że jeżeli nad kodem danego serwisu jest w stanie zapanować jeden mały zespół, to najprawdopodobniej wszystko jest w porządku.

Autonomiczne

Mikroserwisy powinny być od siebie niezależne, przy czym wyznacznikiem niezależności jest tutaj możliwość dokonania zmian i zredeployowania mikroserwisu bez konieczności modyfikacji pozostałych mikroserwisów w systemie. W praktyce oznacza to, że mikroserwisy nie powinny wiedzieć nawzajem o swoich szczegółach implementacyjnych, a w swej współpracy opierać się jedynie na dobrze zdefiniowanych interfejsach.

Skupione na wykonywaniu konkretnych zadań

Jest to nic innego jak przeniesienie Zasady Jednej Odpowiedzialności (Single Responsibility Principle) na grunt mikroserwisów. Dla przypomnienia – SRP głosi, że dany moduł (w naszym przypadku mikroserwis) powinien być odpowiedzialny tylko za jedną funkcjonalność dostarczaną przez system, i że ta funkcjonalność powinna być całkowicie w tym module zamknięta.

Współpracujące ze sobą

Ta część definicji nie wymaga głębszego komentarza. Chodzi oczywiście o wzajemną komunikację mikroserwisów i wzajemne wykorzystywanie dostarczanych przez nie funkcjonalności. Warto jedynie nadmienić, że komunikacja winna być oparta o lekkie protokoły (jak np. HTTP), nieograniczające spektrum możliwych do zintegrowania z nimi technologii.

Zalety

Podejście mikroserwisowe, jeżeli dobrze zaaplikowane, niesie ze sobą szereg korzyści. Należą do nich m.in.:

  • skalowalność – dzięki „rozbiciu” aplikacji na mikroserwisy mamy możliwość skalowania tylko tych komponentów, które stanowią wąskie gardło systemu. Jest to całkowite przeciwieństwo skalowania znanego z aplikacji monolitycznych, w których to operacji tej musi ulegać całość tworzonego oprogramowania.www.kodujze.pl - Skalowanie Mikroserwisów
  • odporność na awarie – awarie pojedynczych mikroserwisów mogą zostać odizolowane w taki sposób, by nie pociągały za sobą awarii całego systemu. Przykładowo, jeżeli w sklepie internetowym z powodu błędu w kodzie awarii ulegnie mikroserwis odpowiedzialny za generowanie spersonalizowanych ofert, to możemy na chwilę zrezygnować z tej funkcjonalności. W przypadku monolitu ten sam problem mógłby spowodować całkowite wstrzymanie pracy aplikacji.
  • łatwość wprowadzania zmian – jako że mikroserwisy komunikują się ze sobą za pomocą dobrze zdefiniowanych interfejsów, to wszelkie zmiany dotyczące ich szczegółów implementacyjnych mogą być wprowadzane w dowolnych momentach życia systemu, niezależnie od pozostałych jego komponentów. Dzięki temu nie trzeba robić deploymentu całej aplikacji w celu wprowadzenia niewielkich poprawek.
  • czytelność – podział na mikroserwisy wymusza na nas konieczność myślenia o systemie jako zbiorze komunikujących się ze sobą jednostek biznesowych. To z kolei przekłada się na lepszą jakość kodu, gdyż musimy z tego powodu myśleć o interfejsach między mikroserwisami, ukrywaniu ich szczegółów implementacyjnych, wymaganych zależnościach – elementach, które w świecie obiektowym pomagają pisać czysty kod. 🙂
  • możliwość mieszania technologi – użycie do komunikacji protokołu niezwiązanego z żadnym konkretnym językiem czy biblioteką (jak np. HTTP) powoduje, że każdy z mikroserwisów może być napisany z użyciem innej technologii. Dzięki temu nie musimy ograniczać się do jednego języka, a każdy komponent może być stworzony za pomocą narzędzi, które najlepiej adresują jego techniczne potrzeby.
  • możliwość eksperymentowania – z powodu ich niewielkich rozmiarów, łatwiejszym staje się eksperymentowanie w kwestii budowy i zasad działania poszczególnych mikroserwisów. Możemy na przykład stworzyć alternatywną wersję jakiegoś mikroserwisu (napisaną w innej technologii, czy z wykorzystaniem innych algorytmów) i „bezboleśnie” podmienić ją w produkcie.

Wady

Biorąc pod uwagę powszechną ostatnio „modę na mikroserwisy” można odnieść wrażenie, że jest to rozwiązanie pozbawione wad. Nic bardziej mylnego! Tworzenie oprogramowania w oparciu o MSA jest zdecydowanie trudniejsze niż tworzenie aplikacji monolitycznych. Problematyczne jest chociażby testowanie takich systemów, ich deployment (o czym świadczy gwałtowny rozwój gałęzi DevOps-owej w branży), obsługa błędów i transakcyjności. Co więcej, utrzymanie architektury mikroserwisowej wymaga bardzo dobrej komunikacji między zespołami pracującymi nad różnymi jej elementami.

Techniczne wyzwania

Jest kilka kwestii, o które musimy zadbać w systemach zbudowanych w oparciu o MSA. Są to:

  • zarządzanie konfiguracją systemu – jak sprytnie zarządzać konfiguracją kilkudziesięciu mikroserwisów, z których każdy może być uruchomiony w tym samym czasie w wielu instancjach?
  • dynamiczne skalowanie – jak wykorzystać naturalną zdolność mikroserwisów do skalowania i przeprowadzać tę operację automatycznie, w oparciu o aktualny ruch w systemie?
  • logowanie i śledzenie ruchu użytkowników – skoro każde żądanie może przejść przez dziesiątki różnych mikroserwisów przed powrotem do klienta, to jak możemy prześledzić jego ruch?
  • obsługa błędów – jak wykrywać i izolować uszkodzone mikroserwisy?

Gdzie w tym wszystkim jest Spring Cloud?

Spring Cloud to projekt, który zrzesza pod wspólną nazwą dziesiątki różnych bibliotek i frameworków stworzonych w celu zaadresowania wyżej wymienionych problemów.

Spring Cloud Config to serwer, który pomaga zarządzać konfiguracją mikroserwisów. Ribbon jest load-balancerem działającym w oparciu o dane otrzymywane od serwera Eureka (dane o mikroserwisach i ich instancjach). Hystrix dostarcza narzędzi służących do obsługi awarii, a Zipkin śledzi drogi przebyte przez nadchodzące do systemu żądania. W przypadku komunikacji opartej o protokół HTTP do grona zależności dołącza Feign, który znacząco upraszcza proces odpytywania zdalnych zasobów. Bramą do systemu bardzo często jest Zuul – serwer pracujący w charakterze API Gateway, czyli fasady, pod którą kryją się wywołania konkretnych mikroserwisów.

Projektów jest oczywiście więcej. Wymienione powyżej to te, o których postaram się napisać w tej serii, i które stanowią swoistego rodzaju fundament projektu Spring Cloud.

Algorytmy GC. Serial, Parallel, CMS

Wpis ten jest kontynuacją wątku o podstawach działania mechanizmów GC w HotSpot JVM. Informacje zawarte tutaj odnoszą się do wiedzy przedstawionej w poprzednim artykule, w związku z czym tych wszystkich, którzy nie mieli okazji go przeczytać, zapraszam tutaj: JVM Garbage Collector. Wprowadzenie. 🙂

1. JVM Garbage Collector. Wprowadzenie.
2. Algorytmy GC. Serial, Parallel, CMS

Algorytmy GC

HotSpot JVM oferuje cztery algorytmy działania Garbage Collectora: SerialParallelMostly-Concurrent (CMS)G1. Dzisiaj zajmiemy się pierwszymi trzema z tej listy. Zasady ich działania są zbliżone do siebie, więc postanowiłem zgrupować je w ramach jednego wpisu.

Usuwanie nieosiągalnych obiektów

Zanim jednak zagłębimy się w szczegóły działania poszczególnych algorytmów, pragnę jeszcze na moment powrócić do tematu usuwania nieosiągalnych obiektów. W pierwszym wpisie z tej serii wspomniałem o trzech strategiach przeprowadzania tej operacji. O ile nie uważam, że jest to zagadnienie wymagające nie wiadomo jakich opisów, tak wizualizacja w postaci przedstawienia struktury stery przed i po zaaplikowaniu konkretnej strategii jest czymś, czego w poprzednim wpisie zabrakło, a co znacznie ułatwia zrozumienie tematu. Pozwólcie zatem, że uzupełnię tutaj tę lukę.

  • Mark-Sweep – oznaczanie obszarów zajmowanych przez nieosiągalne obiekty jako wolnych do alokacji.Algorytmy GC - Struktura sterty. Mark-Sweep.
  • Mark-Sweep-Compact – mark-sweep z dodatkowym kompaktowaniem sterty.Algorytmy GC - Struktura sterty. Mark-Sweep-Compact.
  • Mark-Copy – kopiowanie żywych obiektów do nowego miejsca na stercie.Algorytmy GC - Struktura sterty. Mark-Copy.

Serial GC

Serial GC stosuje strategię mark-copy podczas czyszczenia młodszej generacji i mark-sweep-compact podczas czyszczenia starszej generacji*. Obydwie te operacje odśmiecania wykonywane są na jednym wątku i powodują całkowite wstrzymanie pracy pozostałych wątków aplikacji (jest to tzw. stop-the-world event).

W związku z powyższym algorytm ten znajduje zastosowanie głównie w przypadku aplikacji posiadających niewielkich rozmiarów sterty (do kilkudziesięciu MB) i maszyn z jednym rdzeniem – w tych sytuacjach tworzenie nowych wątków może nie tylko nie przyspieszyć procesu odśmiecania, ale i nawet go spowolnić. Jest to spowodowane dodatkowym narzutem związanym z przełączaniem kontekstu i synchronizacją. Inną sytuacją, w której Serial GC może okazać się przydatny, jest uruchamianie wielu instancji JVM na jednym urządzeniu. Użycie tego algorytmu minimalizuje wtedy wpływ, jaki operacje odśmiecania mają na pracujące w tym samym czasie pozostałe maszyny wirtualne.

Parallel GC

Parallel GC działa tak jak Serial GC, z tą różnicą, że do sprzątania obydwu generacji wykorzystuje wiele wątków. Co więcej, pozwala on na spojrzenie na proces odśmiecania z dalszej perspektywy i zdefiniowanie celów, jakie GC powinien osiągać w trakcie swej pracy. Zamiast empirycznego sprawdzania jakie rozmiary generacji najlepiej wpływają na wydajność aplikacji, możemy zdefiniować:

  • jaki maksymalny czas trwania pauz typu stop-the-world jest dla nas akceptowalny,
  • jaki maksymalny stosunek czasu spędzonego przeprowadzając GC do czasu normalnego działania aplikacji dopuszczamy,
  • jaki powinien być maksymalny rozmiar sterty.

Cele te mają priorytety zgodne z powyższą kolejnością i GC próbuje realizować każdy z nich tylko wtedy, gdy wszystkie poprzednie są osiągnięte.

Parallel GC jest dobrym wyborem, jeżeli nasz program bardzo szybko zapełnia stertę i nie przeszkadzają nam sporadyczne przerwy w jego działaniu. Przykładem może być tutaj dowolna aplikacja raportingowa, cyklicznie generująca sprawozdania na podstawie danych zawartych w bazie.

CMS

Mostly Concurrent Mark and Sweep GC, bo tak brzmi pełna nazwa tego algorytmu, stosuje wielowątkowy mark-copy podczas czyszczenia młodszej generacji. Jeżeli zaś chodzi o starszą generację, to sprawa jest nieco bardziej skomplikowana, bo algorytm ten stara się skrócić czas trwania pauz typu stop-the-world, i w tym celu stosuje „głównie współbieżną” wersję strategii mark-sweep.

Każdy cykl pracy tej zmodyfikowanej strategii składa się z sześciu etapów. Są nimi kolejno:

  1. Initial Mark
    Jeden z dwóch etapów, które wstrzymują działanie aplikacji. W fazie tej identyfikowane są GC roots, od których rozpocznie się proces oznaczania żywych obiektów.
  2. Concurrent Mark
    Przeszukiwanie drzew referencji zakorzenionych w GC roots i oznaczanie odwiedzonych obiektów jako żywych.
  3. Concurrent Preclean
    Przeszukiwanie drzew referencji zakorzenionych w obiektach, które uległy zmianie od czasu rozpoczęcia fazy Concurrent Mark.
  4. Concurrent Abortable Preclean
    Jest to przedłużenie poprzedniej fazy. CMS chce zminimalizować szansę na to, że Final Remark rozpocznie pracę w tym samym momencie, w którym rozpocznie się odśmiecanie młodszej generacji, więc bazując na danych dotyczących poprzednich cykli sztucznie wydłuża czas trwania Concurrent Preclean, aby rozpocząć Final Remark mniej-więcej w połowie czasu między kolejnymi operacjami czyszczenia edenu.
  5. Final Remark
    Jest to drugi i zarazem ostatni etap, który wstrzymuje pracę wątków aplikacji. Jego celem jest ostateczne zlokalizowanie i oznaczenie wszystkich żywych obiektów w starszej generacji.
  6. Concurrent Sweep
    Oznaczanie obszarów zajmowanych przez nieużywane obiekty jako wolnych do alokacji.

CMS minimalizuje czas trwania pauz typu stop-the-world kosztem zwiększonego obciążenia procesora. W związku z tym jest to algorytm stosowany w przypadku programów takich jak serwery aplikacyjne czy aplikacje desktopowe, gdzie responsywność jest najważniejszym priorytetem.

 

* Zarówno w przypadku Serial, jak i Parallel GC czyszczenie starszej generacji zawsze poprzedzone jest odśmiecaniem młodszej generacji, więc efektywnie mówimy tutaj o full gc.

java.nio – Jak zbudować prosty nieblokujący serwer?

Ostatnio zacząłem uważniej przyglądać się reaktywnemu programowaniu i frameworkowi WebFlux, który ukazał się wraz z releasem Spring 5.0. Przy tej okazji spędziłem sporo czasu na czytaniu o nieblokujących serwerach HTTP, będących swoistego rodzaju fundamentem, na którym zbudowany jest cały ten reaktywny stos. Fundamentem, który dla wielu programistów jest… czarną skrzynką. Być może to tylko moje odczucie, ale odnoszę wrażenie, że coraz częściej uczymy się API frameworków i bibliotek, a nie zasad ich działania. Z tego powodu postanowiłem napisać krótki post o pakiecie java.nio, w opraciu o który zbudowane są takie projekty jak Netty czy Undertow (używane we wspomnianym WebFluxie).

Wpisu tego nie można traktować jako kompendium wiedzy o pakiecie java.nio. Moim celem jest przedstawienie zagadnienia w sposób zwięzły i zrozumiały, a nie ślepe przepisywanie dokumentacji. 🙂

Miłej lektury!

Czym jest nieblokujący serwer?

W „tradycyjnym” podejściu serwer nasłuchuje w pętli ruch na zadanym porcie, a gdy tylko pojawia się nowe żądanie, oddelegowuje pracę związaną z jego obsługą do wcześniej utworzonej puli wątków. Model ten ma pewne wady. Po pierwsze, w każdym momencie liczba jednocześnie obsługiwanych klientów może być co najwyżej równa rozmiarowi puli. Po drugie, jeżeli chociaż jeden z tych klientów korzysta ze słabego łącza, to wątek, który został mu przypisany, przez większość czasu jest bezczynny w swoim oczekiwaniu na kolejne bity zapytania. W tym czasie mógłby zająć się innym klientem, więc jest to poważne marnowanie zasobów.

Nieblokujący serwer to serwer, który stara się zaadresować te problemy. W nieblokującym serwerze jeden wątek może obsługiwać wiele zapytań naraz. Jest to możliwe dzięki zastosowaniu nieblokującego IO, implementowanego w Javie przez klasy z pakietu java.nio.

java.nio

Wbrew  powszechnemu przekonaniu NIO nie jest akronimem od Non-blocking IO, lecz New IO (java.nio jest nowszą wersją paczki java.io, a nie tylko jej rozszerzeniem). Pakiet ten jest częścią biblioteki standardowej Javy od wersji 1.4 i dostarcza narzędzi służących do przeprowadzania operacji wejścia/wyjścia zarówno w sposób blokujący, jak i nieblokujący. Został zbudowany w oparciu o trzy główne abstrakcje:

  • bufory (buffers),
  • kanały (channels),
  • selektory (selectors).

Bufor

W kontekście niskopoziomowych mechanizmów

W kontekście niskopoziomowych mechanizmów bufor to blok pamięci, w którym tymczasowo umieszcza się dane odbierane lub wysyłane do zewnętrznego urządzenia. Jest swego rodzaju pośrednikiem, który umożliwia komunikację urządzeń przetwarzających informacje z różnymi prędkościami.

Bardzo dobrą analogią, dzięki której można wyjaśnić znaczenie buforów, jest analogia cieknącego wiadra (leaky bucket analogy).

Wyobraźmy sobie, że mamy wiaderko z dziurą na dnie. Możemy do niego wlewać wodę z dowolną prędkością, lecz prędkość jej uciekania zdeterminowana jest przez rozmiar dziury. W analogi tej wiaderko to bufor, prędkość wlewania wody do niego to prędkość, z jaką proces chce wysyłać dane, a rozmiar dziury to ograniczenia interfejsu sieciowego.

W kontekście Javy

W kontekście Javy bufor to nic innego jak klasa, która opakowuje swój niskopoziomowy odpowiednik w stan i metody służące do jego łatwiejszej manipulacji. Prócz zawartości, Java śledzi również takie własności bufora jak jego:

  • pozycję (position) – indeks następnego elementu, który ma zostać odczytany/zapisany,
  • limit (limit) – indeks pierwszego elementu, który ma nie być odczytany/zapisany,
  • ładowność (capacity) – rozmiar bufora.

Aby lepiej zrozumieć te charakterystyki, a także zobaczyć w akcji działanie pewnych metod służących do manipulacji buforem, spójrzmy na przykład.

Kanał

Kanał reprezentuje połączenie z obiektem zdolnym do przeprowadzania operacji wejścia/wyjścia. Korzysta z buforów, z których czyta dane przeznaczone do wysłania, i do których zapisuje otrzymane informacje. Tak jak Bufor jest opakowaniem natywnego bufora, tak kanał jest warstwą abstrakcji, bezpośrednio pod którą kryje się deskryptor pliku.

Selektor

Niektóre kanały (m.in. te związane z komunikacją sieciową) rozszerzają klasę abstrakcyjną  SelectableChannel.  Kanały reprezentowane przez instancje tej klasy mają taką własność, że można je ustawić w nieblokujący tryb pracy i multipleksować. Narzędziem, które służy do multipleksacji, jest selektor.

Zasada działania selektora jest bardzo prosta. Po utworzeniu rejestrujemy w nim wszystkie kanały, które chcemy nasłuchiwać. W wyniku tej operacji każdemu kanałowi przypisywany jest selectionKey – obiekt jednoznacznie identyfikujący go i zawierający informacje o jego stanie (np. gotowość do przyjęcia danych). Następnie cyklicznie odpytujemy selektor o listę kluczy, których kanały są gotowe do przeprowadzenia operacji wejścia/wyjścia.

Przykład

W ramach podsumowania złożymy w całość wszystkie opisane powyżej elementy i stworzymy prosty, nieblokujący, jednowątkowy serwer. Serwer ten przetransformuje każdy otrzymany tekst na wersję UPPERCASE i odeśle go z powrotem do klienta.

JVM Garbage Collector. Wprowadzenie.

W ramach pierwszego technicznego wpisu postanowiłem zagłębić się w temat Garbage Collectorów. Praktycznie każdy programista pracujący z technologiami stworzonymi w oparciu o JVM słyszał o GC – że jest to mechanizm, który pracuje w tle, sprząta po nas, i… to w zasadzie tyle. Oczywiście mocno to uogólniam, ale prawdą jest, że jest to rodzaj specjalistycznej wiedzy, niewymaganej w przypadku większości stanowisk developerskich. Nie znaczy to oczywiście, że znajomość pracy GC jest nieprzydatna. Wręcz przeciwnie! Jest kluczowa, jeżeli chcemy „podkręcić” działanie aplikacji.

Post ten jest tylko wprowadzeniem do tematu. Różne algorytmy działania „odśmiecacza pamięci” (czy tylko mnie gryzie ta polska nazwa? :)) postaram się opisać w osobnych wpisach.

1. JVM Garbage Collector. Wprowadzenie.
2. Algorytmy GC. Serial, Parallel, CMS

Czym jest Garbage Collector?

Garbage Collector to program, którego głównym zadaniem jest usuwanie z pamięci nieużywanych obiektów. Gdyby nie jego działanie, sterta, na którą trafiają nowo tworzone obiekty, szybko by się zapełniała i tym samym uniemożliwiała dalsze funkcjonowanie aplikacji. Często nie zdajemy sobie sprawy ze skali tego problemu, a wystaczy zerknąć na pierwszy lepszy przykład.

BigDecimal jest immutable, w związku z czym wszelkie operacje arytmetyczne związane z tą klasą zwracają jako rezultat działania nowe obiekty. Powyższa metoda wywołana dla listy tysiąca elementów dołoży do sterty (za sprawą metody add) co najmniej tyle samo nowych instancji BigDecimal. Gdyby nie Garbage Collector, powtarzające się wykonania sum zapchałyby w końcu przestrzeń adresową programu i zakończyły jego działanie z wypisanym na konsoli  OutOfMemoryError.

Zasada działania

Różne implementacje maszyny wirtualnej Javy stosują różne podejście w kwestii automatycznego zarządzania pamięcią. Opis działania zamieszczony poniżej dotyczy HotSpot JVM, czyli maszyny wirtualnej dostarczanej przez firmę Oracle. Wybór tej implementacji podyktowany jest tym, że jest ona po prostu najpopularniejsza.

HotSpot oferuje cztery algorytmy GC: SerialParallelCMS (Concurrent Mark-Sweep)Garbage First (G1). Szczegóły implementacyjne każdego z nich opiszę w późniejszych wpisach. Póki co jednak spojrzymy na nie z dalszej perspektywy, ponieważ o ile pomiędzy poszczególnymi algorytmami istnieją poważne różnice, tak wszystkie z nich działają według podobnego schematu.

W pierwszym kroku Garbage Collector znajduje wszystkie żywe obiekty. W drugim usuwa pozostałe i ewentualnie reorganizuje pamięć programu, aby uniknąć problemu fragmentacji.

Problem fragmentacji

Zanim przyjrzymy się z bliska procesom znajdowania i usuwania obiektów, zatrzymajmy się na chwilę przy problemie fragmentacji.

W miarę tworzenia nowych obiektów zabieramy coraz więcej miejsca ze sterty. Pamięć alokowana jest w sposób ciągły i, tuż przed wkroczeniem do akcji Garbage Collectora, wygląda jak na obrazku poniżej.

JVM Garbage Collector - Sterta przed czyszczeniem przez GC

Gdyby GC nie przejmował się fragmentacją i zakończył swoją pracę po zwolnieniu pamięci, to mogłoby się okazać, że struktura sterty jest mocno „podziurawiona”.

JVM Garbage Collector - Sterta po czyszczeniu przez GC

Jest to bardzo zła sytuacja z co najmniej dwóch powodów. Po pierwsze, kiedy tworzymy nowy obiekt, to JVM spędza więcej czasu na szukaniu odpowiedniego obszaru do alokacji. Po drugie, może być tak, że żaden z wolnych bloków nie jest na tyle duży, by ten obiekt zmieścić.

Szukanie żywych obiektów

Garbage Collector Roots to obiekty osiągalne spoza sterty, czyli takie, do których możemy odwołać się w sposób bezpośredni, a nie tylko poprzez łańcuch referencji.

W powyższym przykładzie  user  traktowany jest jako GC Root, ale  user.creationDate  już nie, ponieważ aby użyć tego obiektu potrzebujemy referencji do  usera .

Kiedy JVM uzna, że należy wykonać cykl GC, to w ramach szukania żywych obiektów najpierw zdefinuje listę takich GC Roots, a następnie przeszuka zakorzenione w nich drzewa referencji i zaznaczy wszystkie napotkane obiekty jako osiągalne. Te, których w tym czasie nie oznaczy, zostaną w późniejszym etapie usunięte ze sterty.

JVM Garbage Collector - GC Roots

GC Roots to na przykład zmienne lokalne i parametry metody wykonywanej w momencie uruchomienia Garbage Collectora, aktywne wątki, zmienne statyczne załadowanych klas (oczywiście pod warunkiem, że ich classloadery same w sobie są osiągalne), zasoby JNI.

Generacje

Gdyby GC miał za każdym razem przeglądać w ten sposób wszystkie zaalokowane obiekty, to czas spędzony przez niego na wykonywaniu odśmiecania miałby poważny wpływ na wydajność aplikacji. Aby zaradzić temu problemowi, twórcy JVM w trakcie projektowania struktury sterty wzięli pod uwagę prostą obserwację, że większość obiektów zaraz po utworzeniu jest niepotrzebna (przykład z początku tego wpisu dotyczący instancji klasy BigDecimal dobrze to obrazuje) i zdecydowali się podzielić ją na obszary zwane generacjami.

JVM Garbage Collector - Generacje

Young Generation to obszar, w którym żyją nowe obiekty. Dzieli się na trzy strefy: edensurvivor one, oraz survivor two. Kiedy tworzymy nowy obiekt, pamięć przeznaczona dla niego alokowana jest w strefie eden. Wraz z działaniem aplikacji strefa ta zapełnia się coraz bardziej, aż w końcu jest w niej na tyle mało miejsca, że do akcji wkracza Garbage Collector. Garbage Collector przeprowadza marking edenu i przenosi do survivor one żywe obiekty, jednocześnie odrzucając te nieosiągalne. Cykl zaczyna się od nowa, z tą różnicą, że GC analizuje teraz również strefę survivor one, a na koniec przenosi wszystkie żywe obiekty do survivor two. W każdej następnej iteracji role stref survivor się odwracają. Takie podejście rozwiązuje problem fragmentacji, ponieważ eden nieustannie ulega całkowitemu czyszczeniu.

Obiekty ze stref survivor, które przetrwały w nich wystarczająco długo, przenoszone są do obszaru nazwanego Old Generation. Jest on znacznie większy w porównaniu do Young Generation*, dlatego też GC odbywa się w nim rzadziej. Old Generation nie podlega takiemu samemu podziałowi na strefy jak Young Generation, a sposób jego czyszczenia zależy od implementacji Garbage Collectora.

Procesy czyszczenia generacji YoungOld nazywają się odpowiednio Minor GCMajor GC.

Usuwanie nieosiągalnych obiektów

Istnieją trzy główe strategie dotyczące usuwania nieosiągalnych obiektów. Możemy:

  • oznaczyć zajmowane przez nie obszary pamięci jako wolne do alokacji,
  • zrobić to, co wyżej i dodatkowo przesunąć żywe obiekty w powstałe wolne miejsca, aby uniknąć problemu fragmentacji,
  • skopiować wszystkie żywe obiekty do nowego obszaru pamięci.

Przykład implementacyjny ostatniej strategii został przedstawiony przy okazji definiowania czym jest Young Generation. Prawie wszystkie algorytmy przeprowadzają w ten sposób Minor GC. Major GC natomiast zależy od konkretnej wersji algorytmu.

Podsumowanie

Jest to koniec części wprowadzającej do tematu Garbage Collectorów. O ile pomiędzy poszczególnymi algorytmami GC istnieją poważne różnice, tak opisane tutaj zagadnienia stanowią swoistego rodzaju fundament ich działania. W późniejszych wpisach postaram się szczegółowiej opisać te algorytmy, a tymczasem wszystkich zainteresowanych tematem zachęcam do lektury poniższych źródeł. 🙂

 

* Jak zauważył w komentarzu Bartek Kowalczyk, dzięki tzw. Adaptive Size Policy rozmiary generacji mogą się zmieniać w zależności od potrzeb aplikacji.

Wpis powitalny

Cześć!

Na wstępie mała ciekawostka – wpis ten powstawał dłużej niż jakiekolwiek inne dzieło w historii ludzkiego piśmiennictwa. Nie jestem nawet w stanie przywołać z pamięci wszystkich sytuacji, w których uzbrojony w kawę i dobre chęci siadałem przed Wordem tylko po to, żeby zminimalizować go i podjąć kolejną próbę znalezienia końca internetu. Czas więc mijał, koniec internetu zdawał się oddalać, a ja dalej nie byłem w stanie zebrać się w sobie i napisać tych kilku zdań w ramach powitania. Staram się nigdy nie odkładać rzeczy na później, ale w tej jednej kwestii osiągnąłem chyba najwyższy możliwy poziom prokrastynacji. Poirytowany całą tą sytuacją powiedziałem sobie dzisiaj, że albo w końcu zacznę, albo całkowicie zrezygnuję z tego pomysłu…

Pozwólcie zatem, że się przedstawię.

Kim jestem?

Mam na imię Maciej i jestem programistą. Programowanie jest dla mnie nie tyle pracą, co przede wszystkim pasją, którą aktywnie staram się rozwijać. Pomysł na pisanie bloga zrodził się w mojej głowie kilka lat temu jako naturalna konsekwencja tego zamiłowania, lecz, jak już ustaliliśmy, ciągle coś mnie od realizacji tego pomysłu odciągało. Studia, praca, inne zainteresowania dosyć dobrze zaszczepiły we mnie przekonanie, że na pisanie przyjdzie jeszcze pora. Bardzo błędne przekonanie, bo pora była na to zawsze.

Pochodzę z Krakowa, lecz aktualnie przebywam na emigracji i pracuję w CERN-ie, gdzie zajmuję się tworzeniem serwisów i aplikacji webowych dla pracujących tutaj naukowców i techników. Specjalizuję się w technologiach JVM-owych, ale staram się nie kategoryzować siebie jako Java Developer – język to tylko narzędzie, a narzędzia, jak doskonale wiemy, dobiera się w zależności od rodzaju wykonywanej pracy.

O czym będzie blog?

Na blogu będę pisał o rzeczach zarówno mocno technicznych, jak i bardziej ogólnych, dotyczących programistycznej codzienności. Chcę wykorzystać to miejsce w sieci do dzielenia się z Wami zdobytą wiedzą, doświadczeniem, ale i również spojrzeniem na pewne sprawy z branży. Myślę, że każda osoba związana czy też po prostu zainteresowana IT znajdzie wśród moich wpisów coś dla siebie. 🙂

Pozdrawiam!

© 2019 Kodujże!

Theme by Anders NorenUp ↑