Odpowiedzi:
Tak, kompilacja do kodu bajtowego Java jest łatwiejsza niż kompilacja do kodu maszynowego. Wynika to częściowo z tego, że istnieje tylko jeden format docelowy (jak wspomina Mandrill, choć zmniejsza to tylko złożoność kompilatora, a nie czas kompilacji), częściowo dlatego, że JVM jest znacznie prostszą maszyną i wygodniejszym w programowaniu niż prawdziwe procesory - ponieważ została zaprojektowana w tandem z językiem Java, większość operacji Java odwzorowuje dokładnie jedną operację kodu bajtowego w bardzo prosty sposób. Innym bardzo ważnym powodem jest to, że praktycznie niema miejsce optymalizacja. Prawie wszystkie problemy dotyczące wydajności pozostawiono kompilatorowi JIT (lub JVM jako całości), więc cały środkowy koniec normalnych kompilatorów znika. Może w zasadzie przejść raz przez AST i wygenerować gotowe sekwencje kodu bajtowego dla każdego węzła. Istnieje pewne „obciążenie administracyjne” generowania tabel metod, stałych pul itp., Ale to nic w porównaniu ze złożonością, powiedzmy, LLVM.
Kompilator to po prostu program, który pobiera czytelne dla człowieka 1 pliki tekstowe i tłumaczy je na instrukcje binarne dla maszyny. Jeśli cofniesz się o krok i pomyślisz o swoim pytaniu z teoretycznej perspektywy, złożoność jest mniej więcej taka sama. Jednak na bardziej praktycznym poziomie kompilatory kodu bajtowego są prostsze.
Jakie ogólne kroki muszą się stać, aby skompilować program?
Istnieją tylko dwie prawdziwe różnice między nimi.
Zasadniczo program z wieloma jednostkami kompilacyjnymi wymaga łączenia podczas kompilacji z kodem maszynowym i generalnie nie wymaga kodu bajtowego. Można rozdzielić zdanie na temat tego, czy łączenie jest częścią kompilacji w kontekście tego pytania. Jeśli tak, kompilacja kodu bajtowego byłaby nieco prostsza. Jednak złożoność łączenia jest kompensowana w czasie wykonywania, gdy maszyna wirtualna obsługuje wiele problemów związanych z łączeniem (patrz moja uwaga poniżej).
Kompilatory kodu bajtowego nie optymalizują tak bardzo, ponieważ maszyna wirtualna może to zrobić lepiej w locie (kompilatory JIT są obecnie dość standardowym dodatkiem do maszyn wirtualnych).
Na tej podstawie dochodzę do wniosku, że kompilatory kodu bajtowego mogą pominąć złożoność większości optymalizacji i wszystkich połączeń, odraczając oba z nich do środowiska wykonawczego VM. Kompilatory kodu bajtowego są w praktyce prostsze, ponieważ nakładają na maszynę wirtualną wiele zawiłości, które kompilatory kodu maszynowego biorą na siebie.
1 Nie licząc języków ezoterycznych
Powiedziałbym, że upraszcza projektowanie kompilatora, ponieważ kompilacja jest zawsze Java do ogólnego kodu maszyny wirtualnej. Oznacza to również, że musisz skompilować kod tylko raz i będzie on działał na dowolnej platformie (zamiast kompilacji na każdym komputerze). Nie jestem pewien, czy czas kompilacji będzie krótszy, ponieważ maszynę wirtualną można traktować jak maszynę standardową.
Z drugiej strony każda maszyna będzie musiała mieć załadowaną maszynę wirtualną Java, aby mogła zinterpretować „kod bajtowy” (czyli kod maszyny wirtualnej wynikający z kompilacji kodu java), przetłumaczyć go na rzeczywisty kod maszyny i uruchomić .
Imo jest to dobre dla bardzo dużych programów, ale bardzo złe dla małych (ponieważ maszyna wirtualna to marnowanie pamięci).
Złożoność kompilacji zależy w dużej mierze od semantycznej luki między językiem źródłowym i docelowym oraz poziomu optymalizacji, który chcesz zastosować, wypełniając tę lukę.
Na przykład kompilacja kodu źródłowego Java do kodu bajtowego JVM jest stosunkowo prosta, ponieważ istnieje podstawowy podzbiór Java, który mapuje prawie bezpośrednio na podzbiór kodu bajtowego JVM. Istnieją pewne różnice: Java ma pętle, ale nie GOTO
, JVM nie ma, GOTO
ale nie ma pętli, Java ma ogólne, JVM nie, ale można z nimi sobie łatwo poradzić (transformacja z pętli do skoków warunkowych jest banalna, skasowanie typu nieco mniej ale nadal możliwe do zarządzania). Istnieją inne różnice, ale mniej poważne.
Kompilowanie kodu źródłowego Ruby do kodu bajtowego JVM jest znacznie bardziej zaangażowane (szczególnie przed invokedynamic
i MethodHandles
zostały wprowadzone w Javie 7, a dokładniej w 3. edycji specyfikacji JVM). W Ruby metody można zastąpić w czasie wykonywania. W JVM najmniejszą jednostką kodu, którą można zastąpić w środowisku wykonawczym, jest klasa, więc metody Ruby muszą zostać skompilowane nie do metod JVM, ale do klas JVM. Wysłanie metody Ruby nie jest zgodne z wysłaniem metody JVM, a wcześniej invokedynamic
nie było możliwości wstrzyknięcia własnego mechanizmu wywoływania metody do JVM. Ruby ma kontynuacje i coroutines, ale JVM nie ma możliwości ich wdrożenia. (JVMGOTO
jest ograniczony do przeskakiwania celów w ramach metody.) Jedyną prymitywną kontrolą przepływu, którą posiada JVM, która byłaby wystarczająco potężna, aby zaimplementować kontynuacje, są wyjątki i zaimplementować wątki coroutines, z których oba są niezwykle ciężkie, podczas gdy całym celem coroutines jest być bardzo lekkim.
OTOH, kompilowanie kodu źródłowego Ruby do kodu bajtowego Rubiniusa lub kodu bajtowego YARV jest znowu trywialne, ponieważ oba z nich są jawnie zaprojektowane jako cel kompilacji dla Ruby (chociaż Rubinius był również używany w innych językach, takich jak CoffeeScript, i najbardziej znany Fancy) .
Podobnie, kompilowanie natywnego kodu x86 do kodu bajtowego JVM nie jest proste, znowu istnieje dość duża przerwa semantyczna.
Haskell to kolejny dobry przykład: z Haskell istnieje kilka wydajnych kompilatorów gotowych do produkcji przemysłowej, które produkują natywny kod maszynowy x86, ale do tej pory nie ma działającego kompilatora ani dla JVM, ani CLI, ponieważ semantyczny luka jest tak duża, że bardzo skomplikowane jest jej wypełnienie. Jest to więc przykład, w którym kompilacja do natywnego kodu maszynowego jest w rzeczywistości mniej złożona niż kompilacja do kodu bajtowego JVM lub CIL. Wynika to z faktu, że natywny kod maszynowy zawiera operacje podstawowe na niższych poziomach ( GOTO
wskaźniki,…), które można łatwiej „wymusić”, aby zrobić to, co chcesz, niż stosowanie operacji podstawowych na wyższym poziomie, takich jak wywołania metod lub wyjątki.
Można więc powiedzieć, że im wyższy poziom języka docelowego, tym bardziej musi on dopasowywać semantykę języka źródłowego, aby zmniejszyć złożoność kompilatora.
W praktyce większość JVM to obecnie bardzo złożone oprogramowanie, które wykonuje kompilację JIT (więc kod bajtowy jest dynamicznie tłumaczony na kod maszynowy przez JVM).
Więc chociaż kompilacja z kodu źródłowego Java (lub kodu źródłowego Clojure) do kodu bajtowego JVM jest rzeczywiście prostsza, sama JVM dokonuje złożonego tłumaczenia na kod maszynowy.
Fakt, że tłumaczenie JIT wewnątrz JVM jest dynamiczne, pozwala JVM skupić się na najbardziej odpowiednich częściach kodu bajtowego. Praktycznie rzecz biorąc, większość JVM bardziej optymalizuje najgorętsze części (np. Najczęściej nazywane metody lub najczęściej wykonywane podstawowe bloki) kodu bajtowego JVM.
Nie jestem pewien, The połączeniu złożoność JVM + kompilator Javy do kodu bajtowego jest znacznie mniej niż złożoności naprzód-of-time kompilatorów.
Zauważ również, że większość tradycyjnych kompilatorów (takich jak GCC lub Clang / LLVM ) przekształca wejściowy kod źródłowy C (lub C ++, lub Ada, ...) w wewnętrzną reprezentację ( Gimple dla GCC, LLVM dla Clang), która jest dość podobna do jakiś kod bajtowy. Następnie przekształcają te wewnętrzne reprezentacje (najpierw optymalizując je w siebie, tj. Większość przebiegów optymalizacji GCC przyjmuje Gimple jako dane wejściowe i produkuje Gimple jako dane wyjściowe; później emituje z niego asembler lub kod maszynowy) do kodu obiektowego.
BTW, z najnowszą infrastrukturą GCC (zwłaszcza libgccjit ) i LLVM, możesz użyć ich do kompilacji innego (lub własnego) języka do ich wewnętrznych reprezentacji Gimple lub LLVM, a następnie skorzystać z wielu możliwości optymalizacji środkowego i tylnego interfejsu końcowe części tych kompilatorów.