Изучая Java и архитектуру JVM, я нашел беседу на встрече [1] и другие ресурсы. Затем я понял, что другим разработчикам может быть интересно узнать, что такое Java, за кулисами. Итак, я решил написать статью о том, как работает Java-приложение? Как работает виртуальная машина Java, объясняя шаг за шагом? Чтобы обсудить это, я собрал некоторую справочную информацию. Затем, наконец, проиллюстрируйте этапы работы виртуальной машины. Пример приложения основан на выступлении Теренса Парра [1].
Справочная информация
«Java - это язык программирования общего назначения, основанный на классах, объектно-ориентированный и разработанный так, чтобы иметь как можно меньше зависимостей реализации. Он предназначен для того, чтобы разработчики приложений могли писать один раз, запускать где угодно (WORA), что означает, что скомпилированный код Java может работать на всех платформах, поддерживающих Java, без необходимости перекомпиляции. Приложения Java обычно компилируются в байт-код, который может работать на любой виртуальной машине Java (JVM) независимо от базовой компьютерной архитектуры. Синтаксис Java похож на C и C ++, но имеет меньше низкоуровневых средств, чем любой из них. По данным GitHub, по состоянию на 2019 год Java была одним из самых популярных языков программирования, особенно для клиент-серверных веб-приложений. Сообщалось о 9 миллионах разработчиков ». [2]
Как указано в определении Java, как только вы напишете свою программу на Java, она будет работать где угодно. Для достижения этой цели Java использует байт-код и JVM. Прежде чем приступить к обсуждению архитектуры Java, давайте посмотрим на другие языки, такие как C.
После фазы компиляции все объекты объединяются и превращаются в исполняемый файл. Это называется связыванием. Выходной exe готов к запуску.
С другой стороны, компилятор Java преобразует файлы .class без связывания этих файлов. Эти файлы классов содержат другой IL, называемый байт-кодом. Но компьютер не мог читать и запускать байт-код напрямую. Таким образом, виртуальной машине необходимо преобразовать их в исполняемый на компьютере код. Преимущество этого подхода заключается в том, что при создании файлов байт-кода каждая архитектура использует свою собственную JVM и может запускать Java. Так Java работает на каждой платформе.
С другой стороны, JIT-компилятор (Just-In-Time) интерпретирует этот байт-код во время выполнения. Таким образом, Java сопоставимо медленнее, чем некоторые языки. Кроме того, эта архитектура делает Java как компилируемым, так и интерпретируемым языком. Поскольку компилятор Java компилирует файлы Java для создания файлов классов. Затем JIT интерпретирует файлы классов в собственный компьютерный код.
Используя эту справочную информацию, мы напишем нашу собственную виртуальную машину. Эта виртуальная машина будет очень примитивной. Но понимание основной концепции более четкое. Понимание того, как выполняется байт-код, важнее написания простого кода.
Преобразование кода Java в байт-код является обязанностью компилятора Java. Но давайте посмотрим без подробностей. Приведенный ниже код содержит наиболее примитивный факториальный код.
/** * @author Yunus */ public class Main { static int factorial(int num) { if (num < 2) { return 1; } return num * factorial(num - 1); } public static void main(String[] args) { factorial(2); } }
С помощью приведенной ниже команды будет сгенерирован байт-код.
javac Main.java
Давайте посмотрим на файл Main.class с помощью следующей команды.
$javap -p -c -v Main.class Last modified Jul 3, 2020; size 378 bytes MD5 checksum 1928bdcf95625d8714f1aa34b13ec2f2 Compiled from "Main.java" public class Main minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#16 // java/lang/Object."<init>":()V #2 = Methodref #3.#17 // Main.factorial:(I)I #3 = Class #18 // Main #4 = Class #19 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 factorial #10 = Utf8 (I)I #11 = Utf8 StackMapTable #12 = Utf8 main #13 = Utf8 ([Ljava/lang/String;)V #14 = Utf8 SourceFile #15 = Utf8 Main.java #16 = NameAndType #5:#6 // "<init>":()V #17 = NameAndType #9:#10 // factorial:(I)I #18 = Utf8 Main #19 = Utf8 java/lang/Object { public Main(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 4: 0 static int factorial(int); descriptor: (I)I flags: ACC_STATIC Code: stack=3, locals=1, args_size=1 0: iload_0 1: iconst_2 2: if_icmpge 7 5: iconst_1 6: ireturn 7: iload_0 8: iload_0 9: iconst_1 10: isub 11: invokestatic #2 // Method factorial:(I)I 14: imul 15: ireturn LineNumberTable: line 7: 0 line 8: 5 line 10: 7 StackMapTable: number_of_entries = 1 frame_type = 7 /* same */ public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iconst_2 1: invokestatic #2 // Method factorial:(I)I 4: pop 5: return LineNumberTable: line 14: 0 line 15: 5 } SourceFile: "Main.java"
Наша основная функция имеет такую инструкцию, как,
1: invokestatic #2 // Method factorial:(I)I
В основном говорит, что вызывают факторный метод. Значение # 2 можно найти в пуле констант, в котором указаны наши факториальные функции и их информация. Правильную функцию можно найти в таблице методов.
#2 = Methodref #3.#17 #3 = Class #18 #17 = NameAndType #9:#10 // factorial:(I)I #18 = Utf8 Main #9 = Utf8 factorial #10 = Utf8 (I)I
Давайте сфокусируемся на байтовом коде факториальных функций ниже:
0: iload_0 1: iconst_2 2: if_icmpge 7 5: iconst_1 6: ireturn 7: iload_0 8: iload_0 9: iconst_1 10: isub 11: invokestatic #2 // Method factorial:(I)I 14: imul 15: ireturn
Итак, чтобы запустить факториальный код, нам нужно преобразовать его в компьютерные операции. Есть огромный список инструкций [5]. Но я буду реализовывать только используемые инструкции внутри факториального кода. При таком минималистичном подходе это будет легко понять.
Вначале у нас есть единственный стек в памяти, потому что у нас нет объекта и т. Д.
Наша минималистичная виртуальная машина в основном принимает инструкции и выполняет их. Итак, чтобы сохранить важную информацию, нам нужен указатель стека (sp), указатель инструкции (ip) и указатель кадра (fp).
Указатель стека будет использоваться как текущая позиция в стеке.
Указатель инструкции указывает, какая инструкция была выполнена.
Указатель кадра…
Например: iload_0, загрузить int значение из локальной переменной 0
Также есть инструктор iload, загружающий int значение из локальной переменной #index
iload_0 и iload 0 оба верны. Для большей универсальности я буду использовать iload 0 для загрузки любого индекса. То же и для других инструкций.
Для простоты мы будем просто call и номер инструкции вместо invokestatic # 2.
if_icmpge = ›если value1 больше или равно value2, перейти к инструкции в branchoffset. Поэтому я разделил эти инструкции, как показано ниже (ilt и brf).
Мы движемся вперед, как если бы генерировался следующий байт-код. Если у инструкции есть операнд, изменяется номер следующей инструкции.
Factorial method: 0: iload 0 2: iconst 2 4: ilt 5: brf 10 7: iconst 1 9: return 10: iload 0 12: iload 0 14: iconst 1 16: isub 17: call 0,1 20: imul 21: return Main method: 22: iconst 2 24: call 0,1 27: print 28: halt
Прежде всего, давайте определимся с инструкциями. Фрагменты кода взяты из выступления на конференции, как я уже сказал в начале, я сделал некоторый рефакторинг [1]. Я хочу продемонстрировать каждый шаг, чтобы продемонстрировать, как он работает? На самом деле коды очень простые. Понимание концепции важнее.
Прежде всего, я создаю класс Instruction, который содержит только инструкции, необходимые для нашей факториальной выборки.
import java.util.Arrays; import java.util.Optional; public enum Instruction { ILOAD(1, 1), ICONST(2, 1), ILT(3, 0), RET(4, 0), ISUB(5, 0), CALL(6, 2), IMUL(7, 1), PRINT(8, 0), HALT(9, 0), BRF(10, 1); private final int code; private final int numberOfOperand; Instruction(int code, int numberOfOperands) { this.code = code; this.numberOfOperand = numberOfOperands; } public static Optional<Instruction> findByCode(int code) { return Arrays.asList(Instruction.values()).stream().filter(x -> x.getCode() == code).findFirst(); } public int getCode() { return code; } public int getNumberOfOperand() { return numberOfOperand; } }
Определите тестовый класс, байт-коды как входные. И состояние начинается с первой инструкции основного метода.
public class Test { static int[] factorial = { Instruction.ILOAD.getCode(), 0, Instruction.ICONST.getCode(), 2, Instruction.ILT.getCode(), Instruction.BRF.getCode(), 10, Instruction.ICONST.getCode(), 1, Instruction.RET.getCode(), Instruction.ILOAD.getCode(), 0, Instruction.ILOAD.getCode(), 0, Instruction.ICONST.getCode(), 1, Instruction.ISUB.getCode(), Instruction.CALL.getCode(), 0, 1, Instruction.IMUL.getCode(), Instruction.RET.getCode(), Instruction.ICONST.getCode(), 2, Instruction.CALL.getCode(), 0, 1, Instruction.PRINT.getCode(), Instruction.HALT.getCode() }; public static void main(String[] args) { Vm vm = new Vm(factorial, 22); vm.execute(); } }
Vm класс:
public class Vm { public static final int DEFAULT_STACK_SIZE = 1000; public static final int NUMBER_OF_GLOBAL = 0; public static final int FALSE = 0; public static final int TRUE = 1; int ip = -1, sp = -1, fp = -1, defaultOffset = -3; int[] code, globals, stack; public Vm(int[] code, int startip) { this.code = code; ip = startip; globals = new int[NUMBER_OF_GLOBAL]; stack = new int[DEFAULT_STACK_SIZE]; } public void execute() { int opcode = code[ip]; Instruction instruction = getInstruction(opcode); Optional<Instruction> optionalInstruction; if (instruction == null) return; int a, b, addr, offset; while (opcode != Instruction.HALT.getCode() && ip < code.length) { instruction = getInstruction(opcode); ip++; switch (instruction) { case ILOAD: offset = defaultOffset + code[ip++]; stack[++sp] = stack[fp + offset]; break; case ICONST: stack[++sp] = code[ip++]; break; case ILT: b = stack[sp--]; a = stack[sp--]; stack[++sp] = (a < b) ? TRUE : FALSE; break; case ISUB: b = stack[sp--]; a = stack[sp--]; stack[++sp] = a - b; break; case CALL: addr = code[ip++]; int nargs = code[ip++]; stack[++sp] = nargs; stack[++sp] = fp; stack[++sp] = ip; fp = sp; ip = addr; break; case RET: int rvalue = stack[sp--]; sp = fp; ip = stack[sp--]; fp = stack[sp--]; nargs = stack[sp--]; sp -= nargs; stack[++sp] = rvalue; break; case IMUL: b = stack[sp--]; a = stack[sp--]; stack[++sp] = a * b; break; case PRINT: System.out.println(stack[sp--]); break; case BRF: addr = code[ip++]; if (stack[sp--] == FALSE) ip = addr; break; } opcode = code[ip]; } }
Теперь самое главное. Я хочу обсудить состояние стека после выполнения каждой инструкции, чтобы погрузиться в логику.
Давайте проиллюстрируем, как вычислить факториал 2.
Перед выполнением любого байт-кода наш стек выглядит так:
22: iconst 2 // bytecode Instruction.ICONST.getCode(), 2, // input equilavent
Этот байт-код в основном определяет целочисленную константу внутри стека.
Как видите, 2 расположены в стеке. И ip стал 24, что означает следующую инструкцию.
24: call 0,1 Instruction.CALL.getCode(), 0, 1
Текущая инструкция говорит, что вызовите инструкцию 0 с 1 аргументом. Вызов и возврат - самые сложные операции. Поэтому я постараюсь дать вам более подробную информацию. При вызове другой функции нам необходимо сохранить важную информацию. Эта информация будет использована после ответа на звонок. В нашем примере приложения мы храним 3 части информации.
1-) Количество аргументов (1)
2-) Указатель текущего кадра (-1)
3-) Следующая инструкция (27)
Наш ip становится 0, потому что следующая инструкция - 0. Sp равно 3. Fp становится 3, потому что непосредственно перед переходом к другой функции наше sp равно 3. Итак, Fp факториальной функции равно 3. Наконец, мы вызвали нашу факториальную функцию из основного метода. Теперь давайте перейдем к первой инструкции факториальной функции.
0: iload 0 Instruction.ILOAD.getCode(), 0,
Как вы заметили в коде при загрузке данных, есть смещение -3. Почему это существует? Потому что при вызове функции мы помещаем 3 лишних информации о предыдущей ситуации. При загрузке данных из нашего стека нам нужно получить значения с этим смещением, чтобы найти правильное значение. 3 дополнительной информации отмечены желтым цветом. Аргументы - синие, а наши местные ценности - зеленые. iload 0 загружает 2, которые были переданы как аргументы внутри основного метода. Затем снова толкает стек, чтобы использовать его как локальное значение. Теперь ip становится 2. Поехали:
2: iconst 2 Instruction.ICONST.getCode(), 2,
Теперь мы подошли к n ‹2 части наших функций. Для этого нам нужно поместить значение 2 в наш стек.
4: ilt Instruction.ILT.getCode(),
ILT, просто вставьте последние 2 значения и выполните меньше операций. Если истина, то помещает 1 в стек, иначе кладет 0. 2 ‹2 ложно, поэтому кладет 0.
5: brf 10 Instruction.BRF.getCode(), 10,
Мы делим операцию if_icmpge на lt и brf. Brf означает, что если false, то переходить по указанному индексу. Brf выскакивает и просматривает стек, последний элемент которого 0. 0 означает ложь, поэтому нам нужно выполнить команду ветвления номер 10.
10: iload 0 Instruction.ILOAD.getCode(), 0,
12: iload 0 Instruction.ILOAD.getCode(), 0,
14: iconst 1 Instruction.ICONST.getCode(), 1,
16: isub Instruction.ISUB.getCode(),
Теперь мы пытаемся вернуть 2 * факториал (2–1), чтобы выполнить операции sub. Наша программа извлекает два последних значения и получает их разницу для помещения в стек. Итак, мы вытащили 2 и 1. Затем поместите 1 в стек.
17: call 0,1 Instruction.CALL.getCode(), 0, 1
Теперь мы выполняем рекурсивный вызов (2 * factorial (1).
Мы ставим 1, потому что количество аргументов равно 1. Наш последний fp был 3, поэтому мы нажимаем 3. Наконец, после 17-й инструкции нам нужно продолжить с 20-й, поэтому нажмите 20. Теперь мы снова начинаем факториальные функции. Мы будем повторять те же операции некоторое время. Я буду выкладывать только изображения текущих состояний.
0: iload 0 Instruction.ILOAD.getCode(), 0,
2: iconst 2 Instruction.ICONST.getCode(), 2,
4: ilt Instruction.ILT.getCode(),
На этот раз 1 ‹2 истинно, поэтому вставьте 1 в стек.
5: brf 10 Instruction.BRF.getCode(), 10,
На этот раз последний элемент stack не является ложным, поэтому мы не выполняем ветвление. Продолжаем следовать инструкции 7.
7: iconst 1 Instruction.ICONST.getCode(), 1,
9: return Instruction.RET.getCode(),
Еще одна очень важная инструкция возвращается. Выполнены следующие шаги:
- Получить возвращаемое значение из стека. В нашем случае это 1.
- Получить значение указателя инструкции из стека. Нам нужно это значение, чтобы найти, где продолжить. В нашем случае это 20. После того, как факториал вернет рекурсивный вызов, нам нужно применить умножение (это inst # 20).
- Получите указатель кадра. Это 3.
- После извлечения этого значения из стека. Мы уменьшили sp как количество аргументов.
- Нам нужно отправить возвращаемое значение в стек.
20: imul Instruction.IMUL.getCode(),
Умножает последние два элемента внутри стека.
21: return Instruction.RET.getCode(),
Будет выполнена другая инструкция возврата.
Как видите, мы вернулись к инструкции 27. Мы снова внутри функции main. С local 2, который является возвращаемым значением факториальной функции.
27: print Instruction.PRINT.getCode(),
Как и у вас, нет никаких печатных инструкций. Но для демонстрации нашей работы была добавлена эта инструкция.
28: halt Instruction.HALT.getCode()
После команды остановки наша программа будет закрыта.
На этот раз я исследовал ресурсы Bytecode, JVM и JIT и записал выходные данные этих ресурсов. В следующий раз мы расскажем о предварительной компиляции (компиляции AOT) на Java, GraalVM и преимуществах / недостатках. Будьте на связи.
Цитирует:
1-) https://www.youtube.com/watch?v=OjaAToVkoTw
2-) https://en.wikipedia.org/wiki/Java_(programming_language)
3-) https://www.guru99.com/java-virtual-machine-jvm.html
5-) https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings