Zaawansowany samouczek dotyczący klas Java: przewodnik po ponownym ładowaniu klas

Opublikowany: 2022-03-11

W projektach programistycznych Java typowy przepływ pracy polega na ponownym uruchomieniu serwera przy każdej zmianie klasy i nikt na to nie narzeka. To fakt dotyczący programowania w Javie. Pracowaliśmy w ten sposób od pierwszego dnia z Javą. Ale czy przeładowanie klas Java jest tak trudne do osiągnięcia? Czy ten problem może być zarówno trudny, jak i ekscytujący do rozwiązania dla wykwalifikowanych programistów Java? W tym samouczku zajęć Java postaram się rozwiązać ten problem, pomóc Ci uzyskać wszystkie korzyści z przeładowywania klas w locie i ogromnie zwiększyć Twoją produktywność.

Ponowne ładowanie klas Java nie jest często omawiane i jest bardzo mało dokumentacji opisującej ten proces. Jestem tutaj, aby to zmienić. Ten samouczek dotyczący zajęć w języku Java wyjaśni krok po kroku ten proces i pomoże ci opanować tę niesamowitą technikę. Należy pamiętać, że wdrażanie przeładowywania klas Java wymaga dużej uwagi, ale nauczenie się, jak to zrobić, pozwoli Ci znaleźć się w pierwszej lidze, zarówno jako programista Java, jak i architekt oprogramowania. Nie zaszkodzi również zrozumieć, jak uniknąć 10 najczęstszych błędów Java.

Konfiguracja przestrzeni roboczej

Cały kod źródłowy tego samouczka jest przesyłany na GitHub tutaj.

Aby uruchomić kod podczas korzystania z tego samouczka, będziesz potrzebować Maven, Git i Eclipse lub IntelliJ IDEA.

Jeśli korzystasz z Eclipse:

  • Uruchom polecenie mvn eclipse:eclipse , aby wygenerować pliki projektu Eclipse.
  • Załaduj wygenerowany projekt.
  • Ustaw ścieżkę wyjściową na target/classes .

Jeśli używasz IntelliJ:

  • Zaimportuj plik pom projektu.
  • IntelliJ nie skompiluje się automatycznie, gdy uruchomisz dowolny przykład, więc musisz:
  • Uruchom przykłady w IntelliJ, a następnie za każdym razem, gdy chcesz skompilować, musisz nacisnąć Alt+BE
  • Uruchom przykłady poza IntelliJ za pomocą run_example*.bat . Ustaw autokompilację kompilatora IntelliJ na true. Następnie, za każdym razem, gdy zmienisz dowolny plik java, IntelliJ automatycznie go skompiluje.

Przykład 1: Ponowne ładowanie klasy za pomocą modułu ładującego klas Java

Pierwszy przykład daje ogólne zrozumienie programu ładującego klasy Java. Oto kod źródłowy.

Biorąc pod uwagę następującą definicję klasy User :

 public static class User { public static int age = 10; }

Możemy wykonać następujące czynności:

 public static void main(String[] args) { Class<?> userClass1 = User.class; Class<?> userClass2 = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example1.StaticInt$User"); ...

W tym przykładzie z samouczka do pamięci zostaną załadowane dwie klasy User . userClass1 zostanie załadowany przez domyślny ładowacz klas JVM, a userClass2 za pomocą DynamicClassLoader , niestandardowego ładowacza klas, którego kod źródłowy jest również dostarczany w projekcie GitHub i który opiszę szczegółowo poniżej.

Oto reszta main metody:

 out.println("Seems to be the same class:"); out.println(userClass1.getName()); out.println(userClass2.getName()); out.println(); out.println("But why there are 2 different class loaders:"); out.println(userClass1.getClassLoader()); out.println(userClass2.getClassLoader()); out.println(); User.age = 11; out.println("And different age values:"); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass1)); out.println((int) ReflectUtil.getStaticFieldValue("age", userClass2)); }

A wynik:

 Seems to be the same class: qj.blog.classreloading.example1.StaticInt$User qj.blog.classreloading.example1.StaticInt$User But why there are 2 different class loaders: qj.util.lang.DynamicClassLoader@3941a79c sun.misc.Launcher$AppClassLoader@1f32e575 And different age values: 11 10

Jak widać tutaj, chociaż klasy User mają tę samą nazwę, w rzeczywistości są to dwie różne klasy i można nimi zarządzać i manipulować nimi niezależnie. Wartość wieku, choć zadeklarowana jako statyczna, istnieje w dwóch wersjach, dołączających się osobno do każdej klasy i może być zmieniana niezależnie.

W normalnym programie Java ClassLoader jest portalem wprowadzającym klasy do JVM. Gdy jedna klasa wymaga załadowania innej klasy, ładowanie należy do klasy ClassLoader .

Jednak w tym przykładzie klasy Java niestandardowy ClassLoader o nazwie DynamicClassLoader jest używany do ładowania drugiej wersji klasy User . Jeśli zamiast DynamicClassLoader ponownie użyjemy domyślnego modułu ładującego klasy (za pomocą polecenia StaticInt.class.getClassLoader() ), to zostanie użyta ta sama klasa User , ponieważ wszystkie załadowane klasy są buforowane.

Zbadanie sposobu, w jaki działa domyślny moduł Java ClassLoader w porównaniu z DynamicClassLoader, jest kluczem do czerpania korzyści z tego samouczka dotyczącego klas Java.

DynamicClassLoader

W normalnym programie Java może być wiele programów ładujących klasy. Ta, która ładuje twoją główną klasę, ClassLoader , jest klasą domyślną, a z twojego kodu możesz tworzyć i używać tyle programów ładujących klasy, ile chcesz. To jest zatem klucz do przeładowania klas w Javie. DynamicClassLoader jest prawdopodobnie najważniejszą częścią tego całego samouczka, więc musimy zrozumieć, jak działa dynamiczne ładowanie klas, zanim będziemy mogli osiągnąć nasz cel.

W przeciwieństwie do domyślnego zachowania ClassLoader , nasz DynamicClassLoader dziedziczy bardziej agresywną strategię. Normalny ładowacz klas nadałby priorytet swojemu rodzicowi ClassLoader i ładowałby tylko te klasy, których nie może załadować jego rodzic. To jest odpowiednie w normalnych okolicznościach, ale nie w naszym przypadku. Zamiast tego DynamicClassLoader spróbuje przejrzeć wszystkie ścieżki swoich klas i rozwiązać klasę docelową, zanim zrezygnuje z prawa do swojego rodzica.

W powyższym przykładzie DynamicClassLoader jest tworzony tylko z jedną ścieżką klasy: "target/classes" (w naszym bieżącym katalogu), więc jest w stanie załadować wszystkie klasy, które znajdują się w tej lokalizacji. W przypadku wszystkich klas, których tam nie ma, będzie musiał odnosić się do nadrzędnego modułu ładującego klasy. Na przykład, musimy załadować klasę String w naszej klasie StaticInt , a nasz ładowacz klas nie ma dostępu do rt.jar w naszym folderze JRE, więc zostanie użyta klasa String ładowacza klas nadrzędnych.

Poniższy kod pochodzi z AggressiveClassLoader , klasy nadrzędnej DynamicClassLoader , i pokazuje, gdzie jest zdefiniowane to zachowanie.

 byte[] newClassData = loadNewClass(name); if (newClassData != null) { loadedClasses.add(name); return loadClass(newClassData, name); } else { unavaiClasses.add(name); return parent.loadClass(name); }

Zwróć uwagę na następujące właściwości DynamicClassLoader :

  • Załadowane klasy mają taką samą wydajność i inne atrybuty, jak inne klasy ładowane przez domyślny program ładujący klas.
  • DynamicClassLoader może być zbierany razem ze wszystkimi załadowanymi klasami i obiektami.

Mając możliwość ładowania i używania dwóch wersji tej samej klasy, myślimy teraz o zrzuceniu starej wersji i załadowaniu nowej, aby ją zastąpić. W następnym przykładzie zrobimy to… w sposób ciągły.

Przykład 2: Ciągłe ładowanie klasy

Ten kolejny przykład Javy pokaże, że JRE może ładować i ponownie ładować klasy na zawsze, ze starymi klasami zrzucanymi i zbieranymi śmieciami oraz zupełnie nową klasą ładowaną z dysku twardego i oddaną do użytku. Oto kod źródłowy.

Oto główna pętla:

 public static void main(String[] args) { for (;;) { Class<?> userClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example2.ReloadingContinuously$User"); ReflectUtil.invokeStatic("hobby", userClass); ThreadUtil.sleep(2000); } }

Co dwie sekundy stara klasa User będzie zrzucana, ładowana nowa i wywoływana jej metoda hobby .

Oto definicja klasy User :

 @SuppressWarnings("UnusedDeclaration") public static class User { public static void hobby() { playFootball(); // will comment during runtime // playBasketball(); // will uncomment during runtime } // will comment during runtime public static void playFootball() { System.out.println("Play Football"); } // will uncomment during runtime // public static void playBasketball() { // System.out.println("Play Basketball"); // } }

Podczas uruchamiania tej aplikacji należy spróbować skomentować i odkomentować kod wskazany w klasie User . Zobaczysz, że zawsze będzie używana najnowsza definicja.

Oto kilka przykładowych danych wyjściowych:

 ... Play Football Play Football Play Football Play Basketball Play Basketball Play Basketball

Za każdym razem, gdy tworzona jest nowa instancja DynamicClassLoader , ładuje ona klasę User z folderu target/classes , w którym ustawiliśmy Eclipse lub IntelliJ, aby wyprowadzał najnowszy plik klasy. Wszystkie stare klasy DynamicClassLoader i stare klasy User zostaną odłączone i poddane garbage collectorowi.

Bardzo ważne jest, aby zaawansowani programiści Java rozumieli dynamiczne przeładowywanie klas, zarówno aktywne, jak i niepołączone.

Jeśli znasz JVM HotSpot, tutaj warto zauważyć, że strukturę klas można również zmienić i przeładować: należy usunąć metodę playFootball i dodać metodę playBasketball . Różni się to od HotSpot, który pozwala tylko na zmianę zawartości metody lub nie można ponownie załadować klasy.

Teraz, gdy jesteśmy w stanie przeładować klasę, nadszedł czas, aby spróbować przeładować wiele klas jednocześnie. Wypróbujmy to w następnym przykładzie.

Przykład 3: Ponowne ładowanie wielu klas

Dane wyjściowe tego przykładu będą takie same jak w przykładzie 2, ale pokażą, jak zaimplementować to zachowanie w strukturze bardziej przypominającej aplikację z obiektami kontekstu, usługi i modelu. Kod źródłowy tego przykładu jest dość obszerny, więc pokazałem tutaj tylko jego fragmenty. Pełny kod źródłowy znajduje się tutaj.

Oto main metoda:

 public static void main(String[] args) { for (;;) { Object context = createContext(); invokeHobbyService(context); ThreadUtil.sleep(2000); } }

Oraz metoda createContext :

 private static Object createContext() { Class<?> contextClass = new DynamicClassLoader("target/classes") .load("qj.blog.classreloading.example3.ContextReloading$Context"); Object context = newInstance(contextClass); invoke("init", context); return context; }

Metoda invokeHobbyService :

 private static void invokeHobbyService(Object context) { Object hobbyService = getFieldValue("hobbyService", context); invoke("hobby", hobbyService); }

A oto klasa Context :

 public static class Context { public HobbyService hobbyService = new HobbyService(); public void init() { // Init your services here hobbyService.user = new User(); } }

Oraz klasa HobbyService :

 public static class HobbyService { public User user; public void hobby() { user.hobby(); } }

Klasa Context w tym przykładzie jest znacznie bardziej skomplikowana niż klasa User w poprzednich przykładach: ma łącza do innych klas i ma metodę init , która ma być wywoływana przy każdym wystąpieniu. Zasadniczo jest bardzo podobny do klas kontekstowych aplikacji ze świata rzeczywistego (które śledzą moduły aplikacji i wykonują wstrzykiwanie zależności). Tak więc możliwość ponownego załadowania tej klasy Context wraz ze wszystkimi powiązanymi z nią klasami jest świetnym krokiem w kierunku zastosowania tej techniki w prawdziwym życiu.

Ponowne ładowanie klas Java jest trudne nawet dla zaawansowanych inżynierów Java.

Wraz ze wzrostem liczby klas i obiektów nasz krok „odrzucania starych wersji” również stanie się bardziej skomplikowany. Jest to również największy powód, dla którego przeładowanie klas jest tak trudne. Aby ewentualnie usunąć stare wersje, musimy upewnić się, że po utworzeniu nowego kontekstu wszystkie odniesienia do starych klas i obiektów zostaną usunięte. Jak sobie z tym radzimy elegancko?

main metoda w tym miejscu będzie trzymać obiekt context i jest to jedyne łącze do wszystkich rzeczy, które należy usunąć. Jeśli zerwiemy to łącze, obiekt context i klasa context oraz obiekt usługi … zostaną poddane garbage collectorowi.

Małe wyjaśnienie, dlaczego normalnie zajęcia są tak trwałe i nie zbierają śmieci:

  • Zwykle ładujemy wszystkie nasze klasy do domyślnego programu ładującego klasy Java.
  • Relacja klasa-ładowacz klas jest relacją dwukierunkową, przy czym program ładujący klasy buforuje również wszystkie załadowane klasy.
  • Tak długo, jak ładowacz klas jest nadal podłączony do dowolnego aktywnego wątku, wszystko (wszystkie załadowane klasy) będzie odporne na odśmiecacz.
  • To powiedziawszy, o ile nie możemy oddzielić kodu, który chcemy ponownie załadować, od kodu już załadowanego przez domyślny program ładujący klas, nasze nowe zmiany w kodzie nigdy nie zostaną zastosowane w czasie wykonywania.

W tym przykładzie widzimy, że przeładowanie wszystkich klas aplikacji jest w rzeczywistości dość łatwe. Celem jest jedynie utrzymanie cienkiego, upuszczalnego połączenia między aktywnym wątkiem a używanym dynamicznym modułem ładującym klas. Ale co, jeśli chcemy, aby niektóre obiekty (i ich klasy) nie były ponownie ładowane i były ponownie używane między cyklami ponownego ładowania? Spójrzmy na następny przykład.

Przykład 4: Oddzielenie utrwalonych i przeładowanych przestrzeni klas

Oto kod źródłowy...

main metoda:

 public static void main(String[] args) { ConnectionPool pool = new ConnectionPool(); for (;;) { Object context = createContext(pool); invokeService(context); ThreadUtil.sleep(2000); } }

Widać więc, że sztuczka polega na załadowaniu klasy ConnectionPool i utworzeniu jej instancji poza cyklem ponownego ładowania, utrzymywaniu jej w utrwalonej przestrzeni i przekazaniu referencji do obiektów Context

Metoda createContext też jest nieco inna:

 private static Object createContext(ConnectionPool pool) { ExceptingClassLoader classLoader = new ExceptingClassLoader( (className) -> className.contains(".crossing."), "target/classes"); Class<?> contextClass = classLoader.load("qj.blog.classreloading.example4.reloadable.Context"); Object context = newInstance(contextClass); setFieldValue(pool, "pool", context); invoke("init", context); return context; }

Odtąd obiekty i klasy, które są ponownie ładowane z każdym cyklem, będziemy nazywać „przestrzeń do ponownego wczytania”, a inne obiekty i klasy, które nie podlegają recyklingowi i nie są odnawiane podczas cykli przeładowywania, „przestrzeń utrwaloną”. Musimy bardzo jasno określić, które obiekty lub klasy pozostają w której przestrzeni, rysując w ten sposób linię oddzielającą te dwie przestrzenie.

Jeśli nie zostanie prawidłowo obsłużony, to oddzielenie ładowania klas Java może prowadzić do niepowodzenia.

Jak widać na rysunku, nie tylko obiekt Context i obiekt UserService odnoszą się do obiektu ConnectionPool , ale klasy Context i UserService odnoszą się również do klasy ConnectionPool . To bardzo niebezpieczna sytuacja, która często prowadzi do zamieszania i porażki. Klasa ConnectionPool nie może być ładowana przez nasz DynamicClassLoader , w pamięci musi znajdować się tylko jedna klasa ConnectionPool , która jest ładowana przez domyślną klasę ClassLoader . To jeden z przykładów, dlaczego tak ważne jest zachowanie ostrożności podczas projektowania architektury przeładowującej klasy w Javie.

Co się stanie, jeśli nasz DynamicClassLoader przypadkowo załaduje klasę ConnectionPool ? Wtedy obiekt ConnectionPool z utrwalonej przestrzeni nie może zostać przekazany do obiektu Context , ponieważ obiekt Context oczekuje obiektu innej klasy, która również nosi nazwę ConnectionPool , ale w rzeczywistości jest inną klasą!

Jak więc uniemożliwić naszemu DynamicClassLoader ładowanie klasy ConnectionPool ? Zamiast używać DynamicClassLoader , w tym przykładzie użyto jej podklasy o nazwie: ExceptingClassLoader , która przekaże ładowanie do superclassloadera na podstawie funkcji warunku:

 (className) -> className.contains("$Connection")

Jeśli nie użyjemy tutaj ExceptingClassLoader , DynamicClassLoader załaduje klasę ConnectionPool , ponieważ ta klasa znajduje się w folderze „ target/classes ”. Innym sposobem zapobiegania pobraniu klasy ConnectionPool przez nasz DynamicClassLoader jest skompilowanie klasy ConnectionPool do innego folderu, być może w innym module, i zostanie ona skompilowana osobno.

Zasady wyboru przestrzeni

Teraz zadanie ładowania klasy Java staje się naprawdę zagmatwane. Jak określić, które klasy powinny znajdować się w przestrzeni utrwalonej, a które w przestrzeni reloadable? Oto zasady:

  1. Klasa w przestrzeni ładowanej może odwoływać się do klasy w przestrzeni utrwalonej, ale klasa w przestrzeni utrwalonej może nigdy nie odwoływać się do klasy w przestrzeni ładowanej. W poprzednim przykładzie ładowalna klasa Context odwołuje się do utrwalonej klasy ConnectionPool , ale ConnectionPool nie ma odniesienia do Context
  2. Klasa może istnieć w dowolnej przestrzeni, jeśli nie odwołuje się do żadnej klasy w innej przestrzeni. Na przykład klasa narzędziowa ze wszystkimi metodami statycznymi, takimi jak StringUtils może być ładowana raz w utrwalonej przestrzeni i ładowana osobno w przestrzeni ładowalnej.

Widać więc, że zasady nie są zbyt restrykcyjne. Z wyjątkiem klas przecinających, do których odwołują się obiekty w dwóch przestrzeniach, wszystkie inne klasy mogą być swobodnie używane w przestrzeni utrwalonej lub przestrzeni do przeładowania lub w obu tych przestrzeniach. Oczywiście tylko klasy w przestrzeni do przeładowania będą cieszyć się z przeładowywania z cyklami przeładowania.

Tak więc rozwiązano najtrudniejszy problem z przeładowywaniem klas. W następnym przykładzie spróbujemy zastosować tę technikę do prostej aplikacji internetowej i cieszyć się ponownym ładowaniem klas Java, tak jak każdy język skryptowy.

Przykład 5: Mała Książka Telefoniczna

Oto kod źródłowy...

Ten przykład będzie bardzo podobny do tego, jak powinna wyglądać normalna aplikacja internetowa. Jest to aplikacja jednostronicowa z wbudowanym serwerem internetowym AngularJS, SQLite, Maven i Jetty.

Oto przestrzeń do przeładowania w strukturze serwera WWW:

Dokładne zrozumienie przestrzeni do przeładowania w strukturze serwera WWW pomoże Ci opanować ładowanie klas Java.

Serwer WWW nie będzie zawierał odniesień do prawdziwych serwletów, które muszą pozostać w przestrzeni do ponownego załadowania, aby mogły zostać ponownie załadowane. To, co przechowuje, to serwlety pośredniczące, które przy każdym wywołaniu swojej metody obsługi rozwiążą rzeczywisty serwlet w rzeczywistym kontekście do uruchomienia.

W tym przykładzie wprowadzono również nowy obiekt ReloadingWebContext , który udostępnia serwerowi sieci Web wszystkie wartości, takie jak normalny Context, ale wewnętrznie przechowuje odniesienia do rzeczywistego obiektu kontekstu, który może zostać ponownie załadowany przez DynamicClassLoader . To właśnie ten ReloadingWebContext dostarcza serwlety pośredniczące do serwera WWW.

ReloadingWebContext obsługuje serwlety pośredniczące na serwerze WWW w procesie ponownego ładowania klas Java.

ReloadingWebContext będzie opakowaniem rzeczywistego kontekstu i:

  • Przeładuje rzeczywisty kontekst po wywołaniu HTTP GET do „/”.
  • Dostarczy serwlety pośredniczące do serwera WWW.
  • Ustawia wartości i wywołuje metody za każdym razem, gdy rzeczywisty kontekst jest inicjowany lub niszczony.
  • Można skonfigurować tak, aby przeładowywał kontekst lub nie, oraz który program ładujący klasy jest używany do przeładowywania. Pomoże to podczas uruchamiania aplikacji w środowisku produkcyjnym.

Ponieważ bardzo ważne jest, aby zrozumieć, w jaki sposób izolujemy przestrzeń utrwaloną i przestrzeń ładowalną, oto dwie klasy, które przecinają się między tymi dwiema przestrzeniami:

Klasa qj.util.funct.F0 dla obiektu public F0<Connection> connF w Context

  • Obiekt funkcji zwróci Connection za każdym razem, gdy funkcja zostanie wywołana. Ta klasa znajduje się w pakiecie qj.util, który jest wykluczony z DynamicClassLoader .

Klasa java.sql.Connection dla obiektu public F0<Connection> connF w Context

  • Normalny obiekt połączenia SQL. Ta klasa nie znajduje się w ścieżce klasy naszego DynamicClassLoader , więc nie zostanie odebrana.

Streszczenie

W tym samouczku dotyczącym klas Java widzieliśmy, jak ponownie ładować pojedynczą klasę, ponownie ładować pojedynczą klasę w sposób ciągły, ponownie ładować całą przestrzeń wielu klas i ponownie ładować wiele klas niezależnie od klas, które muszą być utrwalane. Dzięki tym narzędziom kluczowym czynnikiem zapewniającym niezawodne przeładowanie klasy jest super czysta konstrukcja. Następnie możesz swobodnie manipulować swoimi klasami i całą JVM.

Implementacja przeładowywania klas Java nie jest najłatwiejszą rzeczą na świecie. Ale jeśli spróbujesz i w pewnym momencie zauważysz, że twoje zajęcia są ładowane w locie, to już prawie jesteś. Niewiele pozostanie do zrobienia, zanim osiągniesz całkowicie doskonały, czysty projekt swojego systemu.

Powodzenia, moi przyjaciele i ciesz się nowo odkrytą supermocą!