← Back to home

Blog · Sleepless Coder

Java №3. Что такое JVM

Мы привыкли к мы привыкли думать, что когда вы запускаете программу — компьютер исполняет машинный код (если упрощать уровень системы, гипервизора и т.д.). Но с Java работают немного другие правила, тут в игре байткод.


А кто этот ваш байткод вообще? Тут надо отступить и разъяснить, какие виды языков программирования и собранных приложений существуют. Если сильно упростить, языки часто делят на компилируемые и интерпретируемые. В реальности всё сложнее: один и тот же язык может иметь разные реализации, JIT-компиляцию, байткод и прочие радости жизни. Но для понимания JVM нам этого деления хватит.


Компилируемые

Основная идея компилируемых языков в том, что код программы переводится в понятный для компьютера машинный код. Исходники невозможно восстановить 1 в 1: имена локальных переменных, комментарии, форматирование и часть структуры уже потеряны. Декомпилятор может восстановить похожий код, но не оригинальный файл. При этом такой код заточен под определённую систему, а порой и архитектуру. Отсюда и знакомые вам windows_x64, linux_arm, macos_apple

Интерпретируемые

Идея интерпретируемых же языков в том, что код программы остаётся таким, каким он был написан. Для запуска используется программа-интерпретатор или runtime-среда (python, node). Из плюсов такого подхода — когда вы пишете код программы, вам зачастую не требуется задумываться о поддержке разных систем или копаться с компиляцией для них, вы отдаёте исходный код, который уже интерпретируется целевым компьютером.


А где тут Java?

А она встала ровно посередине. Сочетая плюсы и минусы обоих подходов.

Когда вы написали свою программу на Java, вам необходимо её скомпилировать, но тут и появляется важный нюанс, вместо компиляции в машинный код, Java компилируется в платформенно-независимый байткод. Но как его запустить? Тут и вступает в дело JVM (Java Virtual Machine), программа, которая читает и интерпретирует байт-код, но при этом, если какой-то кусочек используется очень активно, он может скомпилировать его в машинный код, используя JIT, что позволяет выигрывать и в скорости исполнения, и в удобстве разработки.


Но это теория, давайте посмотрим, как оно выглядит на самом деле! Давайте возьмём example программу от IDEA:

public static void main(String[] args) {
    //TIP Press <shortcut actionId="ShowIntentionActions"/> with your caret at the highlighted text
    // to see how IntelliJ IDEA suggests fixing it.
    System.out.printf("Hello and welcome!");

    for (int i = 1; i <= 5; i++) {
        //TIP Press <shortcut actionId="Debug"/> to start debugging your code. We have set one <icon src="AllIcons.Debugger.Db_set_breakpoint"/> breakpoint
        // for you, but you can always add more by pressing <shortcut actionId="ToggleLineBreakpoint"/>.
        System.out.println("i = " + i);
    }
}

И теперь скомпилируем её в байткод:

Адрес Байт-код Декомпилированное действие Пояснение
00000000 B2 00 07 getstatic java.io.PrintStream java.lang.System.out Из класса System получаем статическое поле out, кладём в стек объект PrintStream
00000003 12 0D ldc “Hello and welcome!” Загружаем строку “Hello and welcome!” из пула констант в стек.
00000005 03 iconst_0 Кладём в стек число 0
00000006 BD 00 02 anewarray java.lang.Object Создаём новый массив нулевого размера и кладём его в стек
Так компилятор готовит пустой массив аргументов для varargs-метода printf.
00000009 B6 00 0F invokevirtual java.io.PrintStream java.io.PrintStream.printf(java.lang.String, java.lang.Object[]) Вызываем метод printf, он возвращает PrintStream
0000000C 57 pop Удаляем из стека полученный выше PrintStream
0000000D 04 iconst_1 Кладём в стек число 1
0000000E 3C istore_1 Сохраняем число 1 в локальную переменную с индексом 1 (int i = 1)
0000000F 1B iload_1 Загружаем значение локальной переменной i
00000010 08 iconst_5 Кладём в стек число 5
00000011 A3 00 15 if_icmpgt pos.00000026 Забираем из стека i и 5. Если i > 5, то выполняем переход на адрес 00000026
00000014 B2 00 07 getstatic java.io.PrintStream java.lang.System.out Из класса System получаем статическое поле out, кладём в стек объект PrintStream
00000017 1B iload_1 Кладём в стек текущее значение i
00000018 BA 00 15 00 00 invokedynamic java.lang.String BootstrapMethod[0]:makeConcatWithConstants(int) Собираем строку "i = " + i.
0000001D B6 00 19 invokevirtual void java.io.PrintStream.println(java.lang.String) Вызываем println
00000020 84 01 01 iinc local.01, 1 Увеличиваем локальную переменную с индексом 1 на единицу
00000023 A7 FF EC goto pos.0000000F Переходим на адрес 0000000F
00000026 B1 return Завершаем выполнение кода

Давайте посмотрим как декомпилирует этот код IDEA

public static void main(String[] args) {
    System.out.printf("Hello and welcome!");

    for(int i = 1; i <= 5; ++i) {
        System.out.println("i = " + i);
    }
}

Довольно похоже, но это такая IDEA, вот вывод декомпилятора jar.tools:

public static void main(String[] args) {
    System.out.printf("Hello and welcome!", new Object[0]);

    for (int i = 1; i <= 5; i++) {
        System.out.println("i = " + i);
    }
}

И это маленький сниппет, на более крупных проектах разницы между разными декомпиляторами будут больше.

Но главная мысль вот в чём: Java-код не летит напрямую в процессор. Сначала он превращается в байткод, потом JVM берёт этот байткод и уже на месте решает, как его эффективнее выполнить. Отсюда и это требование наличие JVM.


Кстати, если вы хотите почитать статью про то, как я декомпилировал и патчил одно Java приложение, расширяя некоторые лимиты, вы можете почитать её тут - https://boosty.to/redguy/posts/51d4ba44-6938-40ae-8c2f-bff0436b5ac1?isFromShowcase=true