Jeśli potrzebujemy różnych maszyn JVM dla różnych architektur, nie mogę zrozumieć, jaka jest logika wprowadzenia tej koncepcji. W innych językach potrzebujemy różnych kompilatorów dla różnych maszyn, ale w Javie potrzebujemy różnych JVM, więc jaka jest logika wprowadzenia koncepcji JVM lub tego dodatkowego kroku?
37
Odpowiedzi:
Logika jest taka, że kod bajtowy JVM jest znacznie prostszy niż kod źródłowy Java.
Na wysoce abstrakcyjnym poziomie można uznać, że kompilatory składają się z trzech podstawowych części: parsowania, analizy semantycznej i generowania kodu.
Parsowanie polega na odczytaniu kodu i przekształceniu go w reprezentację drzewa w pamięci kompilatora. Analiza semantyczna to część, w której analizuje to drzewo, odkrywa, co to znaczy, i upraszcza wszystkie konstrukcje wysokiego poziomu do niższych. Generowanie kodu pobiera uproszczone drzewo i zapisuje je w postaci płaskiego wyniku.
W przypadku pliku kodu bajtowego faza analizy jest znacznie uproszczona, ponieważ jest zapisany w tym samym formacie strumienia bajtów, którego używa JIT, a nie w rekursywnym (ustrukturyzowanym na drzewie) języku źródłowym. Ponadto wiele ciężkich analiz semantycznych zostało już przeprowadzonych przez kompilator Java (lub inny język). Wystarczy więc odczytać kod, wykonać minimalną analizę i analizę semantyczną, a następnie wygenerować kod.
To sprawia, że zadanie JIT musi być dużo prostsze, a zatem znacznie szybsze do wykonania, przy jednoczesnym zachowaniu wysokopoziomowych metadanych i informacji semantycznych, które umożliwiają teoretyczne pisanie kodu z jednego źródła na wiele platform.
źródło
Różne reprezentacje pośrednie są coraz bardziej powszechne w projektowaniu kompilatora / środowiska wykonawczego, z kilku powodów.
W przypadku Javy pierwszym powodem prawdopodobnie była przenośność : Java była początkowo szeroko rozpowszechniona jako „Napisz raz, uruchom gdziekolwiek”. Możesz to osiągnąć, dystrybuując kod źródłowy i używając różnych kompilatorów do kierowania na różne platformy, ma to jednak kilka wad:
Inne zalety reprezentacji pośredniej obejmują:
źródło
Wygląda na to, że zastanawiasz się, dlaczego nie rozpowszechniamy tylko kodu źródłowego. Pozwól, że odwrócę to pytanie: dlaczego po prostu nie rozpowszechniamy kodu maszynowego?
Najwyraźniej odpowiedź brzmi: Java z założenia nie zakłada, że wie, na której maszynie będzie działał twój kod; może to być komputer stacjonarny, superkomputer, telefon lub cokolwiek pomiędzy i poza nim. Java pozostawia miejsce dla lokalnego kompilatora JVM. Oprócz zwiększenia przenośności kodu ma to tę zaletę, że pozwala kompilatorowi wykonywać takie czynności, jak optymalizacje specyficzne dla komputera, jeśli takie istnieją, lub nadal produkować przynajmniej działający kod, jeśli nie istnieje. Rzeczy takie jak instrukcje SSE lub przyspieszenie sprzętowe mogą być używane tylko na komputerach, które je obsługują.
W tym świetle uzasadnienie użycia kodu bajtowego zamiast surowego kodu źródłowego jest jaśniejsze. Zbliżenie się do surowego języka maszynowego, jak to możliwe, pozwala nam uświadomić sobie lub częściowo zrealizować niektóre zalety kodu maszynowego, takie jak:
Pamiętaj, że nie wspominam o szybszym wykonaniu. Zarówno kod źródłowy, jak i bajtowy są lub mogą (teoretycznie) zostać w pełni skompilowane do tego samego kodu maszynowego w celu faktycznego wykonania.
Ponadto kod bajtowy pozwala na pewne ulepszenia w stosunku do kodu maszynowego. Oczywiście istnieją niezależności od platformy i optymalizacje specyficzne dla sprzętu, o których wspomniałem wcześniej, ale są też takie rzeczy, jak obsługa kompilatora JVM w celu tworzenia nowych ścieżek wykonywania ze starego kodu. Może to być załatanie problemów związanych z bezpieczeństwem lub wykrycie nowych optymalizacji lub skorzystanie z nowych instrukcji sprzętowych. W praktyce rzadko spotyka się w ten sposób duże zmiany, ponieważ może to ujawniać błędy, ale jest to możliwe i dzieje się tak przez cały czas w niewielkim stopniu.
źródło
Wydaje się, że istnieją co najmniej dwa różne możliwe pytania. Jeden tak naprawdę dotyczy generalnie kompilatorów, a Java jest po prostu tylko przykładem tego gatunku. Drugi jest bardziej specyficzny dla Javy, którego używa określone kody bajtów.
Kompilatory w ogóle
Rozważmy najpierw ogólne pytanie: dlaczego kompilator miałby używać pośredniej reprezentacji w procesie kompilowania kodu źródłowego, aby działał na określonym procesorze?
Redukcja złożoności
Jedna odpowiedź na to pytanie jest dość prosta: przekształca problem O (N * M) w problem O (N + M).
Jeśli otrzymamy N języków źródłowych i M celów, a każdy kompilator jest całkowicie niezależny, potrzebujemy kompilatorów N * M do przetłumaczenia wszystkich tych języków źródłowych na wszystkie te cele (gdzie „cel” jest czymś w rodzaju kombinacji procesor i system operacyjny).
Jeśli jednak wszystkie te kompilatory zgadzają się na wspólną reprezentację pośrednią, wówczas możemy mieć N frontonów kompilatora, które tłumaczą języki źródłowe na reprezentację pośrednią, i M back endy kompilatora, które tłumaczą reprezentację pośrednią na coś odpowiedniego dla konkretnego celu.
Segmentacja problemu
Co więcej, dzieli problem na dwie mniej lub bardziej ekskluzywne domeny. Ludzie, którzy znają / troszczą się o projektowanie języka, parsowanie i tego typu rzeczy, mogą skoncentrować się na interfejsach kompilatora, podczas gdy ludzie, którzy wiedzą o zestawach instrukcji, projektowaniu procesorów i podobnych rzeczach mogą skoncentrować się na zapleczu.
Na przykład, biorąc pod uwagę coś takiego jak LLVM, mamy wiele interfejsów dla różnych języków. Posiadamy również zaplecze dla wielu różnych procesorów. Język facet może napisać nowy interfejs dla swojego języka i szybko wspierać wiele celów. Facet zajmujący się procesorem może napisać nowy back-end dla swojego obiektu docelowego bez zajmowania się projektowaniem języka, parsowaniem itp.
Rozdzielanie kompilatorów na front i back end z pośrednią reprezentacją do komunikacji między nimi nie jest oryginalne w Javie. Od dawna jest to dość powszechna praktyka (zresztą na długo przed pojawieniem się Java).
Modele dystrybucji
W zakresie, w jakim Java dodała coś nowego w tym względzie, było to w modelu dystrybucji. W szczególności, mimo że kompilatory były przez długi czas wewnętrznie dzielone na części frontonu i back-endu, zwykle były one dystrybuowane jako pojedynczy produkt. Na przykład, jeśli kupiłeś kompilator Microsoft C, wewnętrznie miał on „C1” i „C2”, które były odpowiednio frontonem i back-endem - ale kupiłeś tylko „Microsoft C”, który obejmował oba sztuk (z „sterownikiem kompilatora”, który koordynował operacje między nimi). Mimo że kompilator został zbudowany w dwóch częściach, dla zwykłego programisty korzystającego z kompilatora była to tylko jedna rzecz, która tłumaczyła się z kodu źródłowego na kod obiektowy, bez niczego pomiędzy nimi.
Zamiast tego Java dystrybuowała front-end w Java Development Kit, a back-end w Java Virtual Machine. Każdy użytkownik Java miał zaplecze kompilatora, aby celować w dowolny system, z którego korzystał. Programiści Java dystrybuowali kod w formacie pośrednim, więc kiedy użytkownik go załadował, JVM zrobił wszystko, co było konieczne, aby wykonać go na konkretnej maszynie.
Precedensy
Zauważ, że ten model dystrybucji również nie był zupełnie nowy. Na przykład system P UCSD działał podobnie: interfejsy kompilatora produkowały kod P, a każda kopia systemu P zawierała maszynę wirtualną, która zrobiła wszystko, co było konieczne do wykonania kodu P na tym konkretnym celu 1 .
Kod bajtowy Java
Kod bajtu Java jest dość podobny do kodu P. To w zasadzie instrukcje dla dość prostej maszyny. Ta maszyna ma być abstrakcją istniejących maszyn, więc dość szybko można ją szybko przełożyć na niemal każdy konkretny cel. Łatwość tłumaczenia była ważna na początku, ponieważ pierwotnie zamierzano interpretować kody bajtowe, podobnie jak zrobił to P-System (i tak, dokładnie tak działały wczesne wdrożenia).
Silne strony
Kod bajtowy Java jest łatwy do wytworzenia dla kompilatora. Jeśli (na przykład) masz dość typowe drzewo reprezentujące wyrażenie, zazwyczaj dość łatwo jest przejść przez drzewo i wygenerować kod dość bezpośrednio z tego, co znajdziesz w każdym węźle.
Kody bajtów Java są dość kompaktowe - w większości przypadków znacznie bardziej kompaktowe niż kod źródłowy lub kod maszynowy dla większości typowych procesorów (a zwłaszcza dla większości procesorów RISC, takich jak SPARC, które Sun sprzedał podczas projektowania Java). Było to wtedy szczególnie ważne, ponieważ jednym z głównych celów Java była obsługa apletów - kodu osadzonego na stronach internetowych, które zostaną pobrane przed wykonaniem - w czasie, gdy większość ludzi uzyskiwała dostęp do nas za pośrednictwem modemów przez linie telefoniczne około 28,8 kilobitów na sekundę (choć oczywiście sporo osób używa starszych, wolniejszych modemów).
Słabości
Główną słabością kodów bajtów Java jest to, że nie są one szczególnie ekspresyjne. Chociaż potrafią dość dobrze wyrażać koncepcje obecne w Javie, nie działają prawie tak dobrze w wyrażaniu koncepcji, które nie są częścią Java. Podobnie, chociaż na większości komputerów łatwo jest wykonać kody bajtowe, jest to o wiele trudniejsze w sposób, który w pełni wykorzystuje jakąkolwiek konkretną maszynę.
Na przykład, dość rutynową rzeczą jest, że jeśli naprawdę chcesz zoptymalizować kody bajtów Java, w zasadzie wykonujesz kilka inżynierii wstecznej, aby przetłumaczyć je wstecz z reprezentacji podobnej do kodu maszynowego i przekształcić je z powrotem w instrukcje SSA (lub coś podobnego) 2 . Następnie manipulujesz instrukcjami SSA w celu przeprowadzenia optymalizacji, a następnie przekładasz stamtąd na coś, co jest ukierunkowane na architekturę, na której naprawdę Ci zależy. Jednak nawet w przypadku tego dość złożonego procesu niektóre pojęcia obce dla Javy są wystarczająco trudne do wyrażenia, dlatego trudno jest przetłumaczyć z niektórych języków źródłowych na kod maszynowy, który działa (nawet blisko) optymalnie na większości typowych maszyn.
Podsumowanie
Jeśli zastanawiasz się, dlaczego ogólnie używać reprezentacji pośrednich, dwa główne czynniki to:
Jeśli pytasz o specyfikę kodów bajtów Java i dlaczego wybrali tę konkretną reprezentację zamiast jakiejś innej, to powiedziałbym, że odpowiedź w dużej mierze wraca do ich pierwotnych zamiarów i ograniczeń sieci w tamtym czasie , co prowadzi do następujących priorytetów:
Zdolność do reprezentowania wielu języków lub wykonywania optymalnego dla wielu różnych celów była znacznie niższym priorytetem (jeśli w ogóle były one uważane za priorytety).
źródło
Oprócz zalet wskazanych przez inne osoby, kod bajtowy jest znacznie mniejszy, więc łatwiej jest dystrybuować i aktualizować oraz zajmuje mniej miejsca w środowisku docelowym. Jest to szczególnie ważne w środowiskach o ograniczonej przestrzeni.
Ułatwia także ochronę kodu źródłowego chronionego prawem autorskim.
źródło
Chodzi o to, że kompilacja kodu bajtowego do kodu maszynowego jest szybsza niż interpretacja oryginalnego kodu na kod maszynowy w samą porę. Potrzebujemy jednak interpretacji, aby nasza aplikacja była wieloplatformowa, ponieważ chcemy używać naszego oryginalnego kodu na każdej platformie bez zmian i bez przygotowań (kompilacji). Najpierw więc javac kompiluje nasz kod źródłowy do bajtowego, a następnie możemy uruchomić ten kod bajtowy w dowolnym miejscu i zostanie on zinterpretowany przez maszynę wirtualną Java do szybszego kodowania maszynowego. Odpowiedź: oszczędza czas.
źródło
Pierwotnie JVM był czystym tłumaczem . I dostajesz najlepszego tłumacza, jeśli język, który tłumaczysz, jest tak prosty, jak to możliwe. Taki był cel kodu bajtowego: Zapewnienie wydajnie interpretowalnego wejścia do środowiska wykonawczego. Ta pojedyncza decyzja zbliżyła Javę do języka skompilowanego niż do języka interpretowanego, co ocenia się na podstawie jego działania.
Dopiero później, kiedy okazało się, że wydajność JVM z interpretacji wciąż jest do dupy, ludzie zainwestowali wysiłek w stworzenie dobrze działających kompilatorów just-in-time. To nieco zamknęło lukę w stosunku do szybszych języków, takich jak C i C ++. (Pozostają jednak pewne nieodłączne problemy z prędkością Java, więc prawdopodobnie nigdy nie otrzymasz środowiska Java, które działa tak dobrze, jak dobrze napisany kod C.)
Oczywiście z technikami just-in-time zbierające pod ręką, my mogliśmy wrócić do rzeczywistości dystrybucją kodu źródłowego, i just-in-time kompilowanie go do kodu maszynowego. Spowodowałoby to jednak znaczne obniżenie wydajności uruchamiania do momentu skompilowania wszystkich odpowiednich części kodu. Kod bajtowy nadal jest tutaj znaczącą pomocą, ponieważ jest o wiele prostszy w analizie niż równoważny kod Java.
źródło
Tekstowy kod źródłowy to struktura, która ma być łatwa do odczytania i modyfikacji przez człowieka.
Kod bajtowy to struktura, która ma być łatwa do odczytania i wykonania przez maszynę.
Ponieważ wszystko, co JVM robi z kodem, jest odczytywane i wykonywane, kod bajtów lepiej nadaje się do wykorzystania przez JVM.
Zauważam, że nie było jeszcze żadnych przykładów. Głupie pseudo przykłady:
Oczywiście bajtowy kod to nie tylko optymalizacje. Duża część polega na możliwości wykonywania kodu bez konieczności dbania o skomplikowane reguły, takie jak sprawdzanie, czy klasa zawiera element zwany „foo” gdzieś w dalszej części pliku, gdy metoda odnosi się do „foo”.
źródło