- Компилирование и исполнение Java-кода в Runtime
- Зачем?
- Последовательность действий
- Шаг 1. Генерация кода
- Шаг 2. Компиляция кода
- Шаг 3. Загрузка и выполнение кода
- Итог
- Компиляция и исполнение Java приложений под капотом
- 2. Компиляция в байт-код
- 3. Пример компиляции и выполнения программы
- 4. Выполнение программы виртуальной машиной
- 5. Just-in-time (JIT) компиляция
- 6. Заключение
Компилирование и исполнение Java-кода в Runtime
Привет Хабр! Сегодня я хотел бы поговорить про динамическое компилирование и исполнение Java-кода, подобно скриптовым языкам программирования. В этой статье вы найдете пошаговое руководство как скомпилировать Java в Bytecode и загрузить новые классы в ClassLoader на лету.
Зачем?
В разработке все чаще возникают типовые задачи, которые можно было бы закрыть простой генерацией кода. Например, сгенерировать DTO классы по имеющейся спецификации по стандартам OpenAPI или AsyncAPI. В целом, для генерации кода нет необходимости компилировать и выполнять код в runtime, ведь можно сгенерировать исходники классов, а собрать уже вместе с проектом. Однако при написании инструментов для генерации кода, было бы не плохо покрыть это тестами. А при проверке самый очевидный сценарий: сгенерировал-скомпилировал-загрузил-проверил-удалил. И вот тут-то и возникает задача генерации и проверки кода «на лету».
Также иногда возникают потребности выполнять какой-то код удаленно. Как правило это какие-то распределенные облачные вычисления. В этом случае можно отправлять исходный код на вычислительный узел, а там уже происходит динамическая сборка и выполнение.
Последовательность действий
Для выполнения Java-кода в Runtime нам потребуется:
- Динамически создать и сохранить наш код в .java файл.
- Скомпилировать исходники в Bytecode (файлы .class).
- Загрузить скомпилированные классы в ClassLoader.
- Использовать reflection api для получения методов и выполнения их.
Шаг 1. Генерация кода
Вообще для генерации исходников можно конечно просто написать текст через StringBuider в файл и быть довольным. Но мне хотелось бы показать более прикладные решения, поэтому рассмотрим вариант генерации кода с использованием пакета com.sun.codemodel, а вот тут есть неплохой туториал по этому пакету. Так же на его основе есть библиотека jsonschema2pojo для генерации кода на основе jsonschema. Итак к коду:
public void generateTestClass() throws JClassAlreadyExistsException, IOException < //создаем модель, это своего рода корень вашего дерева кода JCodeModel codeModel = new JCodeModel(); //определяем наш класс Habr в пакете hello JDefinedClass testClass = codeModel._class("hello.Habr"); // определяем метод helloHabr JMethod method = testClass.method(JMod.PUBLIC + JMod.STATIC, codeModel.VOID, "helloHabr"); // в теле метода выводим строку "Hello Habr!" method.body().directStatement("System.out.println(\"Hello Habr!\");"); //собираем модель и пишем пакеты в currentDirectory codeModel.build(Paths.get(".").toAbsolutePath().toFile()); >
Пример выше сгенерирует класс Habr.java с одним методом:
package hello; public class Habr < public static void helloHabr() < System.out.println("Hello Habr!"); >>
Шаг 2. Компиляция кода
Для компиляции в Bytecode обычно используется javac и выполняется он простой командой:
javac -sourcepath src -d build\classes hello\Habr.java
Однако, нам надо скомпилировать наш класс прямо из кода. И для этого есть библиотека компилятора, до которой можно достучаться через javax/tools/JavaCompiler. Это реализация javax/tools/Tool (которая лежит в /lib/tools.jar). Выглядеть это будет как-то так:
Path srcPath = Paths.get("hello"); List files = Files.list(srcPath) .map(Path::toFile) .collect(Collectors.toList()); //получаем компилятор JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); //получаем новый инстанс fileManager для нашего компилятора try(StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) < //получаем список всех файлов описывающих исходники Iterable extends JavaFileObject>javaFiles = fileManager.getJavaFileObjectsFromFiles(files); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); //заводим задачу на компиляцию JavaCompiler.CompilationTask task = compiler.getTask( null, fileManager, diagnostics, null, null, javaFiles ); //выполняем задачу task.call(); //выводим ошибки, возникшие в процессе компиляции for (Diagnostic diagnostic : diagnostics.getDiagnostics()) < System.out.format("Error on line %d in %s%n", diagnostic.getLineNumber(), diagnostic.getSource()); >>
Шаг 3. Загрузка и выполнение кода
Для выполнения кода нам надо загрузить его через ClassLoader и через reflection api вызвать наш метод.
//получаем ClassLoader, лучше получать лоадер от текущего класса, //я сделал от System только чтоб пример был рабочий ClassLoader classLoader = System.class.getClassLoader(); //получаем путь до нашей папки со сгенерированным кодом URLClassLoader urlClassLoader = new URLClassLoader( new URL[], classLoader); //загружаем наш класс Class helloHabrClass = urlClassLoader.loadClass("hello.Habr"); //находим и вызываем метод helloHabr Method methodHelloHabr = helloHabrClass.getMethod("helloHabr"); //в параметре передается ссылка на экземпляр класса для вызова метода //либо null при вызове статического метода methodHelloHabr.invoke(null);
Итог
В этой статье я постарался показать полноценный сценарий генерации и выполнения кода в Runtime. Самому мне это пригодилось при написании unit-тестов для библиотеки по генерации DTO классов на базе документации сгенерированной библиотекой springwolf. Реализацию тестов в моем проекте можно посмотреть тут.
Компиляция и исполнение Java приложений под капотом
Всем привет! Сегодня я хотел бы поделиться знаниями о том, что происходит под капотом JVM (Java Virtual Machine) после того, как мы запускаем написанное Java приложение. В наше время существуют моднейшие среды разработки, которые помогают не думать о внутреннем устройстве JVM, компиляции и выполнении Java-кода, из за чего новые разработчики могут упустить эти важные аспекты. В то же время, на собеседованиях часто задают вопросы касательно этой темы, из-за чего я и решил написать статью.
2. Компиляция в байт-код
Начнем с теории. Когда мы пишем какое-либо приложение, мы создаем файл с расширением .java и помещаем в него код на языке программирования Java. Такой файл, содержащий код, понятный человеку, называется файлом с исходным кодом. После того, как файл с исходным кодом готов, нужно его выполнить! Но на стадии в нем содержится информация, понятная только человеку. Java — мультиплатформенный язык программирования. Это значит, что программы, написанные на языке Java, можно выполнять на любой платформе, где установлена специальная исполняющая система Java. Такая система называется Java Virtual Machine (JVM). Для того, чтобы перевести программу из исходного кода в код, понятный JVM, нужно её скомпилировать. Код, понятный JVM называется байт-кодом и содержит набор инструкций, которые в дальнейшем будет исполнять виртуальная машина. Для компиляции исходного кода в байт-код существует компилятор javac , входящий в поставку JDK (Java Development Kit). На вход компилятор принимает файл с расширением .java , содежащий исходный код программы, а на выходе выдает файл с расширением .class , содержащий байт-код, необходимый для исполнения программы виртуальной машиной. После того, как программа была скомпилирована в байт-код, она может быть выполнена с помощью виртуальной машины.
3. Пример компиляции и выполнения программы
Предположим, что у нас есть простая программа, содержащаяся в файле Calculator.java , которая принимает 2 численных аргумента командной строки и печатает результат их сложения:
Для того, чтобы скомпилировать эту программу в байт-код, воспользуемся компилятором javac в командной строке:
После компиляции на выходе мы получаем файл с байт-кодом Calculator.class , который мы можем исполнить при помощи установленной на нашем компьютере java-машины командой java в командной строке:
Заметим, что после названия файла были указаны 2 аргумента командной строки — числа 1 и 2. После выполнения программы, в командной строке выведется число 3. В примере выше у нас был простой класс, который живет сам по себе. Но что, если класс находится в каком либо пакете? Смоделируем такую ситуацию: создадим директории src/ru/javarush и поместим туда наш класс. Теперь он выглядит следующим образом (добавили имя пакета в начале файла):
package ru.javarush; class Calculator < public static void main(String[] args)< int a = Integer.valueOf(args[0]); int b = Integer.valueOf(args[1]); System.out.println(a + b); >>
javac -d bin src/ru/javarush/Calculator.java
В этом примере мы использовали дополнительную опцию компилятора -d bin , которая складывает скомпилированные файлы в директорию bin со структурой, аналогичной директории src , при этом директория bin должна быть создана заранее. Такой прием используется, чтобы не путать файлы с исходным кодом с файлами с байт-кодом. Перед запуском скомпилированной программы стоит пояснить понятие classpath . Classpath — это путь, относительно которого виртуальная машина будет искать пакеты и скомпилированные классы. Тоесть, таким образом мы говорим виртуальной машине какие директории в файловой системе являются корневыми для иерархии пакета Java. Classpath можно укзать при запуска программы с помощью флага -classpath . Запуск программы осуществляем с помощью команды:
java -classpath ./bin ru.javarush.Calculator 1 2
В этом примере нам потребовалось указать полное имя класса, включая имя пакета, в котором он находится. Финальное дерево файлов выглядит следующим образом:
├── src │ └── ru │ └── javarush │ └── Calculator.java └── bin └── ru └── javarush └── Calculator.class
4. Выполнение программы виртуальной машиной
Итак, мы запустили написанную программу. Но что же происходит в момент запуска скомпилированной программы виртуальной машиной? Для начала разберемся, что означают понятия компиляции и интерпретации кода. Компиляция — трансляция программы, составленной на исходном языке высокого уровня, в эквивалентную программу на низкоуровневом языке, близком машинному коду. Интерпретация — пооператорный (покомандный, построчный) анализ, обработка и тут же выполнение исходной программы или запроса (в отличие от компиляции, при которой программа транслируется без её выполнения). Язык Java обладает как компилятором ( javac ), так и интерпретатором, в роли которого выступает виртуальная машина, которая построчно преобразует байт-код в машинный код и тут же его исполняет. Таким образом, когда мы запускаем скомпилированную программу, виртуальная машина начинает её интерпретацию, то есть построчное преобразование байт-кода в машинный код, а так же его исполнение. К сожалению, чистая интерпретация байт-кода является довольно долгим процессом и делает язык java медленным в сравнении с его конкурентами. Дабы избежать этого, был введен механизм, позволяющий ускорить интерпретацию байт-кода виртуальной машиной. Этот механизм называется Just-in-time компиляцией (JITC).
5. Just-in-time (JIT) компиляция
Простыми словами, механизм Just-In-Time компиляции заключается в следующем: если в программе присутствуют части кода, которые выполняются много раз, то их можно скомпилировать один раз в машинный код, чтобы в будущем ускорить их выполнение. После компиляции такой части программы в машинный код, при каждом следующем вызове этой части программы виртуальная машина будет сразу выполнять скомпилированный машинный код, а не интерпретировать его, что естественно ускорит выполнение программы. Ускорение работы программы достигается за счет увеличения потребления памяти (где-то же нам нужно хранить скомпилированный машинный код!) и за счет увеличения временных затрат на компиляцию во время исполнения программы. JIT компиляция — довольно сложный механизм, поэтому пройдемся по верхам. Всего существует 4 уровня JIT компиляции байт-кода в машинный код. Чем выше уровень компиляции, тем он сложнее, но и одновременно выполнение такого участка будет быстрее, чем участка с меньшим уровнем. JIT — компилятор самостоятельно решает, какой уровень компиляции задать для каждого фрагмента программы на основе того, как часто выполняется этот фрагмент. Под капотом JVM использует 2 JIT-компилятора — C1 и C2. C1 компилятор так же называется клиентским компилятором и способен скомпилировать код только до 3-его уровня. За 4-ый, самый сложны и быстрый уровень компиляции отвечает компилятор C2.
Из вышесказанного можно сделать вывод о том, что для простых, клиентских приложений, выгоднее использовать компилятор C1, так как в этом случае нам важно как быстро стартует приложение. Серверные, долгоживущие приложения могут стартовать большее количество времени, однако в дальнейшем должны работать и выполнять свою функцию быстро — тут нам подойдет компилятор C2.
При запуске Java — программы на x32 версии JVM мы в ручную можем указать, какой режим мы хотим использовать, при помощи флагов -client и -server . При указании флага -client JVM не будет производить сложные оптимизации с байт-кодом, что ускорит время старта приложения и уменьшит количество потребляемой памяти. При указании флага -server приложение будет стартовать большее количество времени из-за сложных оптимизаций байт-кода и будет использовать больше памяти для хранения машинного кода, однако в дальнейшем работать такая программа будет быстрее. В x64 версии JVM флаг -client игнорируется и по умолчанию используется серверная конфигурация приложения.
6. Заключение
Компилятор javac преобразует исходный код программы в байт-код, который может быть выполнен на любой платформе, на которой установлена виртуальная машина Java;
Для ускорения работы Java-приложений, JVM использует механизм Just-In-Time компиляции, который преобразует наиболее часто выполняемые участки программы в машинный код и хранит их в памяти.