1) Платформа Java
Java — это язык программирования и вычислительная платформа для разработки приложений. Впервые он был выпущен Sun Microsystem в 1995 году, а затем приобретен корпорацией Oracle. Это один из самых популярных языков программирования.
Что такое платформа Java?
Платформа Java представляет собой набор программ, которые помогают разрабатывать и запускать программы, написанные на языке программирования Java. Платформа Java включает в себя механизм исполнения, компилятор и набор библиотек. JAVA — это независимый от платформы язык. Это не характерно для какого-либо процессора или операционной системы.
Это видео знакомит с платформой Java и объясняет, почему Java является платформой и языком программирования.
Нажмите здесь, если видео не доступно
Чтобы понять язык программирования JAVA, нам нужно понять некоторую базовую концепцию того, как компьютерная программа может выполнить команду и выполнить действие.
Что такое ПК?
Компьютер — это электронное устройство, способное выполнять вычисления, и все мы знаем, что оно состоит из монитора, клавиатуры, мыши и памяти для хранения информации. Но самый важный компонент компьютера — это ПРОЦЕССОР. Что все думает о компьютере, но вопрос в том, как компьютер делает это мышление? Как он понимает текст, изображения, видео и т. Д.?
Что такое язык ассемблера?
Компьютер представляет собой электронное устройство, и он может понимать только электронные сигналы или двоичные сигналы. Например, 5-вольтный электронный сигнал может представлять двоичное число 1, а 0 вольт может представлять двоичное число 0. Таким образом, ваш компьютер постоянно подвергается бомбардировке этими сигналами.
Восемь битов таких сигналов сгруппированы для интерпретации текста, чисел и символов.
Например, символ # идентифицируется компьютером как 10101010. Аналогично, шаблон для добавления функции представлен 10000011.
Это известно как 8-битные вычисления. Процессор текущего дня способен декодировать 64-битное время. Но какова связь этой концепции с языком программирования JAVA? Позвольте понять это как пример.
Предположим, если вы хотите сказать компьютеру добавить два числа (1 + 2), которые представлены некоторыми двоичными числами (10000011), как вы собираетесь сказать компьютеру? Да, мы собираемся использовать ассемблер для исполнения нашего кода.
«Язык ассемблера — самая элементарная форма языков разработки программного обеспечения».
Мы собираемся дать команду компьютеру в этом формате, как показано ниже. Ваш код для добавления двух чисел на этом языке будет в следующем порядке.
- Магазин № 1 в памяти, скажем, A
- Магазин № 2 в памяти, скажем, B
- Добавить содержимое местоположения A & B
- Хранить результаты
Но как мы собираемся это сделать? В 1950-х годах, когда компьютеры были огромными и потребляли много энергии, вы конвертировали ваш ассемблерный код в соответствующий машинный код в 1 и 0, используя листы отображения. Позже этот код будет перфорирован в карточках машины и передан на компьютер. Компьютер прочитает этот код и выполнит программу. Это было бы долгим процессом, пока ассемблер не пришел на помощь.
Что такое Ассемблер и Компилятор?
Благодаря прогрессу в технологии устройств ввода-вывода, вы можете напрямую набирать свою программу на ПК, используя программу ASSEMBLER. Он преобразует его в соответствующий машинный код (110001 ..) и передает на ваш процессор. Итак, возвращаясь к нашему примеру добавления (1 + 2), ассемблер преобразует этот код в машинный код и выдаст результат.
Кроме того, вам также придется совершать вызовы для создания функций, предоставляемых операционной системой, для отображения вывода кода.
Но один ассемблер не вовлечен во весь этот процесс; это также требует, чтобы компилятор скомпилировал длинный код в небольшой кусок кода. С развитием языков разработки программного обеспечения весь код ассемблера может сжаться до одной строчной печати f 1 + 2 A с помощью программного обеспечения под названием COMPILER. Он используется для преобразования вашего кода на языке C в ассемблерный код, а ассемблер преобразует его в соответствующий машинный код, и этот машинный код будет передан в процессор. Наиболее распространенный процессор, используемый в ПК или Компьютерах, является процессором Intel.
Хотя современные компиляторы, поставляемые в комплекте с ассемблером, могут напрямую преобразовывать ваш код на более высоком языке в машинный код.
Теперь предположим, что операционная система Windows работает на этом процессоре Intel, комбинация Операционная система плюс процессор называется ПЛАТФОРМА. Наиболее распространенной платформой в мире является Windows, а Intel называет ее платформой Wintel. Другими популярными платформами являются AMD и Linux, Power PC и Mac OS X.
Теперь, с изменением процессора, инструкции по сборке также будут меняться. Например,
- Добавить инструкцию в Intel можно назвать ДОПОЛНЕНИЕ для AMD
- ИЛИ Math ADD для Power PC
И очевидно, что с изменением операционной системы уровень и характер вызовов уровня ОС также изменятся.
Как разработчик, я хочу, чтобы моя программа работала на всех доступных платформах, чтобы максимизировать мои доходы. Поэтому мне придется покупать отдельные компиляторы, которые преобразуют мою команду print f в машинный код.
Но компиляторы стоят дорого, и есть вероятность проблем с совместимостью. Поэтому покупка и установка отдельного компилятора для разных ОС и процессора нецелесообразна. Итак, что может быть альтернативным решением? Введите язык Java.
Как работает виртуальная машина Java?
С помощью виртуальной машины Java эта проблема может быть решена. Но как это работает на разных процессорах и ОС Давайте разберем этот процесс шаг за шагом.
Шаг 1) Код для отображения сложения двух чисел — System.out.println (1 + 2), который сохраняется как файл .java.
Шаг 2) Используя Java-компилятор, код преобразуется в промежуточный код, называемый байт-кодом. На выходе получается файл .class.
Шаг 3) Этот код не понят ни одной платформой, а только виртуальной платформой, называемой виртуальной машиной Java.
Шаг 4) Эта виртуальная машина находится в оперативной памяти вашей операционной системы. Когда виртуальная машина получает этот байт-код, она идентифицирует платформу, на которой она работает, и преобразует байт-код в собственный машинный код.
Фактически, работая на компьютере или просматривая веб-страницы, каждый раз, когда вы видите один из этих значков, убедитесь, что виртуальная машина Java загружена в вашу оперативную память. Но то, что делает Java прибыльным, заключается в том, что один раз скомпилированный код может работать не только на всех платформах ПК, но и на мобильных устройствах или других электронных гаджетах, поддерживающих Java.
«Java — это язык программирования, а также платформа»
Насколько независима Java-платформа?
Как и компилятор C, компилятор Java не создает собственный исполняемый код для конкретной машины. Вместо этого Java создает уникальный формат, называемый байт-кодом. Он выполняется в соответствии с правилами, изложенными в спецификации виртуальной машины.
Байт-код понятен любой JVM, установленной в любой ОС. Короче говоря, исходный код Java может работать на всех операционных системах.
Машинный код и байт код: на каком языке говорит ваша программа?

У тех, кто только начинает знакомиться с Java, довольно часто возникает путаница в понятиях машинный и байт код. Что они собой представляют? В чём различия? В короткой заметке мы постараемся максимально просто и понятно расписать их особенности, чтоб раз и навсегда закрыть этот вопрос.

Машинный код
Процессор — это, по сути, очень сложный и продвинутый калькулятор. У него есть множество ячеек памяти (называемых регистрами) с которыми и между которыми проводятся различные математические и байтовые операции. Машинный код как раз и представляет собой описание последовательности выполнения операций и набора участвующих данных. По сути, это единственный язык, который понимает процессор вашего компьютера.
Врожденная несовместимость
При этом далеко не все процессоры «говорят» на одном языке. Различия есть не только между архитектурами CISC и RISC, но и внутри этих «лагерей».
CISC (англ. Complex Instruction Set Computing) — концепция проектирования процессоров, которая характеризуется следующим набором свойств:
- много команд, разных по длине;
- много режимов адресации;
- сложная кодировка инструкции.
В новых поколениях процессоров внедряют дополнительные наборы инструкций, которые моделям старшего поколения попросту неизвестны. Из-за этого программы, скомпилированные для одной архитектуры (или одного поколения процессоров) не могут работать на другом аппаратном обеспечении. Все это вынуждает заниматься перекомпиляцией программ для обеспечения их работы на других компьютерах. Впрочем, заново компилировать приходится не только из-за процессоров, но и еще из-за различий во взаимодействии программ и операционной системы. Именно из-за них невозможно запустить «виндовую» программу под Linux, а «линуксовую» под Windows.
Байт-код
Байт-код во многом похож на машинный код, только он использует набор инструкций не реального процессора, а виртуального. При этом он может включать в себя участки, ориентированные на использование JIT-компилятора, оптимизирующего выполнение команд под реальный процессор, на котором запущена программа.
| JIT-компиляция (англ. Just-in-time compilation, компиляция «на лету») или динамическая компиляция (англ. dynamic translation) — это технология увеличения производительности программных систем, использующих байт-код, путём компиляции байт-кода в машинный код или в другой формат непосредственно во время работы программы. «Официально» в Java до 9-й версии был только JIT-компилятор. В Java 9 появился ещё один компилятор, причём компилирует он с опережением (AoT). Эта возможность позволяет компилировать классы Java в нативный код перед запуском на виртуальной машине. Данная функция предназначена для улучшения времени запуска и малых, и больших приложений, с ограниченным влиянием на максимальную производительность. |
Для CISC процессоров некоторые инструкции могут объединяться в более сложные конструкции, поддерживаемые процессором, а для RISC – наоборот разбиваться на более простые последовательности команд.
Еще и виртуальная ОС
Впрочем, байт код содержит не только процессорные инструкции. В нем также содержится логика взаимодействия с виртуальной операционной системой, которая делает поведение приложения независящим от используемой на компьютере операционной системы. Это отлично видно в JVM, где работа с системными вызовами и GUI зачастую не зависят от ОС, на которой запущена программа. По большому счету JVM эмулирует запуск процесса программы, в отличие от решений вроде Virtual Box, которые создают только виртуальную систему/железо.
JVM одна такая?
Определенно нет. Тот же DotNet CLI это тоже виртуальная машина, которую чаще всего используют на компьютерах, работающих под Windows с x86 совместимыми процессорами. Впрочем существует ее реализация и под другие системы: приложения под него должны работать в Windows RT запущенной на ARM (RISC) совместимых процессорах, или можно запустить их на Linux/OSX в среде Mono, являющей сторонней (и потому не полностью совместимой) реализацией DotNet для этих платформ. Так что эта платформа, как и JVM, работает на разных процессорах и разных ОС. Существует еще множество похожих решений (как старых, так и новых): LLVM, Flash SWF, и другие. У некоторых языков программирования есть собственные виртуальные машины. К примеру, CPython компилирует исходники из PY в файлы PYC – скомпилированный (compiled) байт код который подготовлен к запуску в PVM. Или есть намного более древний пример — Lisp можно компилировать в файлы FASL (Fast Load). Фактически они содержат AST дерево, построенное генератором из исходного кода. Эти файлы могут быть прочитаны и запущены интерпретатором Lisp на разных платформах, или использованы для создания машинного кода для используемой на данный момент аппаратной архитектуры.
JVM: что это в мире Java


Java разработчик: как стать за месяц?

Java — очень сложно, так говорят… Правда ли это?

7 фактов про Java

Когда умрет Java? Стоит ли учить этот язык?
Java Virtual Machine, или JVM это виртуальная машина, которая интерпретирует байт-код Java. Это ключевой компонент платформы Java, который позволяет программам Java работать на любой платформе, поддерживающей JVM.

Для понимания JVM полезно взглянуть на нее как на устройство, спроектированное для того, чтобы сделать жизнь программистов Java удобнее. В самом простом определении это java виртуальная машина, которая имитирует аппаратное обеспечение компьютера и обеспечивает окружение, в котором Java-приложения могут работать независимо от характеристик реального компьютера.
Представьте, что ваш Java-код — это набор инструкций, написанных на языке, который компьютеры не могут понимать напрямую. JVM берет этот код и переводит его в набор инструкций, который может выполнить компьютер. Она выполняет это в реальном времени, в процессе выполнения программы. Таким образом, когда вы запускаете свое Java-приложение, JVM берет ваш исходный код и превращает его в что-то, что компьютер понимает и может выполнить.
JVM является неотъемлемой частью Java Runtime Environment (JRE), который, в свою очередь, является частью Java Development Kit (JDK). Она является интерпретатором Java-кода и обеспечивает не только исполнение программ, но и управление памятью, сборку мусора, обработку исключений и многие другие важные функции.
Она также обеспечивает переносимость кода, облегчает разработку и снижает сложность создания высокопроизводительных приложений. Без JVM мир Java был бы гораздо менее удобным для программистов.
Основные характеристики
Для начала давайте разберемся, что такое JVM и как она функционирует.
JVM — это своего рода виртуальная машина, специально созданная для джавы. Она является ключевым компонентом всей платформы и исполняет функцию перевода Java-кода в машинный код.
Возможно, вы слышали о принципе «скомпилируй единожды, запускай везде» (compile once, run anywhere). Этот принцип — это именно то, что делает джаву таким мощным и привлекательным языком программирования. Когда вы пишете свою программу на Java и компилируете ее в байт-код, вы создаете файл, который может быть выполнен на любом устройстве, где есть совместимая JVM. Это означает, что вы можете создавать приложения для компьютеров, мобильных устройств, встраиваемых систем и многого другого, без необходимости переписывать свой код для каждой платформы.
Это обеспечивает переносимость приложений и экономит время разработчиков, так как им не нужно адаптировать свой код под разные платформы, кроме того, дает разработчикам свободу и гибкость, позволяя им создавать универсальные приложения, которые могут быть запущены практически на любом устройстве.
Техническое и повседневное определение
Технически говоря, JVM представляет собой строго определенный набор инструкций и правил, описанных в спецификации программного обеспечения. Эта спецификация является руководством для разработчиков, описывающим, как машина должна интерпретировать байт-код, который генерируется компилятором Java. Она содержит подробные технические указания, описывающие структуру данных, формат инструкций и ожидаемое поведение JVM при выполнении программы.
Приглашаем вас на авторский тренинг-интенсив Сергея Немчинского Java инструментарий!
Наш онлайн-курс нацелен на решение частой проблемы — показать как работает Java инструментарий, научить вас писать простые программы, запускать их на выполнение, деплоить на простой сервер приложений и дебажить код для нахождения ошибок.
Когда разработчики джава создают новые версии JVM, они следуют этой спецификации, чтобы убедиться, что их реализация будет совместима с Java-приложениями, написанными в соответствии с этой спецификацией. Это обеспечивает единообразие и совместимость между различными версиями JVM и позволяет Java-приложениям быть переносимыми и исполняться на разных виртуальных машинах.
С повседневной точки зрения это выглядит так: когда вы создаете Java-приложение и хотите его запустить, вы фактически запускаете JVM, который берет ваш скомпилированный Java-код и интерпретирует его в исполняемые инструкции. Это происходит автоматически, за кулисами, когда вы кликаете по значку приложения или вводите команду для запуска программы в командной строке.
Спецификация
История спецификации начинается с того, что в 1995 году Джеймс Гослинг и его команда в Sun Microsystems впервые представили Java. JVM здесь была ключевым элементом этой новой языковой платформы.

Похожие материалы
Java разработчик: как стать за месяц?
Java — очень сложно, так говорят… Правда ли это?
7 фактов про Java
Когда умрет Java? Стоит ли учить этот язык?
Что такое JVM?
JVM (Java Virtual Machine) — это виртуальная машина, которая позволяет выполнять байт-код Java, обеспечивая кросс-платформенность языка Java.
Какова основная функция JVM?
JVM интерпретирует или компилирует в реальное время байт-код Java в машинный код для конкретной платформы, на которой она работает.
В чем разница между JRE и JVM?
JRE (Java Runtime Environment) включает в себя JVM и дополнительные библиотеки и ресурсы, необходимые для выполнения приложений Java.
Является ли JVM специфичной для конкретной платформы?
Да, для каждой ОС и архитектуры процессора существует своя версия JVM.
Что такое JIT-компиляция в контексте JVM?
JIT (Just-In-Time) компиляция — это метод, при котором байт-код Java компилируется в машинный код во время выполнения, а не заранее.
Могу ли я запустить код, написанный на других языках, на JVM?
Да, существует множество языков, таких как Kotlin, Scala и Groovy, которые компилируются в байт-код Java и могут быть выполнены на JVM.
Интерпретаторы байт-кодов своими руками

Виртуальные машины языков программирования в последние десятилетия получили весьма широкое распространение. С презентации Java Virtual Machine во второй половине 90-х прошло уже достаточно много времени, и можно с уверенностью сказать, что интерпретаторы байт-кодов — не будущее, а настоящее.
Но данная техника, на мой взгляд, практически универсальна, и понимание основных принципов разработки интерпретаторов пригодится не только создателю очередного претендента на звание «Язык года» по версии TIOBE, но вообще любому программисту.
Словом, если вам интересно узнать, как складывают числа наши любимые языки программирования, о чём до сих пор спорят разработчики виртуальных машин и как безболезненно сопоставлять строки и регулярные выражения, прошу под кат.
Предыстория
Одна из самописных систем отдела Business Intelligence нашей компании имеет интерфейс в виде несложного языка запросов. В первой версии системы язык этот интерпретировался на лету, без компиляции, непосредственно из входной строки с запросом. Вторая версия парсера будет работать уже с промежуточным байт-кодом, что позволит отделить язык запросов от их выполнения и сильно упростит код.
В процессе работы над второй версией системы у меня случился отпуск, в течение которого я на часик-другой каждый день отвлекался от дел семейных на изучение материалов, посвящённых архитектуре и производительности интерпретаторов байт-кода. Полученными в результате заметками и примерами интерпретаторов я решил поделиться с читателями Хабра в виде серии статей.
В первой из них представлено пять небольших (до сотни строк простенького кода на C) виртуальных машин(ок), каждая из которых раскрывает определённый аспект разработки таких интерпретаторов.
Откуда есть пошли байт-коды в языках программирования
Виртуальных машин, самых разнообразных виртуальных наборов инструкций за последние несколько десятков лет было придумано великое множество. Википедия утверждает, что первые языки программирования стали компилироваться в различные упрощённые промежуточные представления еще в 60-е годы прошлого века. Какие-то из этих первых байт-кодов преобразовывались в машинные коды и выполнялись реальными процессорами, другие — интерпретировались на лету процессорами виртуальными.
Популярность виртуальных наборов инструкций в качестве промежуточного представления кода объясняется тремя причинами:
- Программы в виде байт-кодов легко переносятся на новые платформы.
- Интерпретаторы байт-кодов работают быстрее интерпретаторов синтаксического дерева кода.
- Разработать простейшую виртуальную машину можно буквально за пару часов.
Давайте сделаем несколько простейших виртуальных машин на C и на этих примерах выделим основные технические аспекты реализации виртуальных машин.
Полные коды примеров выложены на GitHub. Примеры можно собрать любым относительно свежим GCC:
gcc interpreter-basic-switch.c -o interpreter ./interpreter
Все примеры имеют одинаковую структуру: сначала идёт код самой виртуальной машины, после — главная функция с assert-ами, проверяющими работу кода. Я старался внятно комментировать опкоды и ключевые места интерпретаторов. Надеюсь, статья будет понятна даже людям, ежедневно не пишущим на C.
Самый простой в мире интерпретатор байт-кода
Как я уже говорил, простейший интерпретатор сделать очень легко. Комментарии — сразу за листингом, а начнём непосредственно с кода:
struct < uint8_t *ip; uint64_t accumulator; >vm; typedef enum < /* increment the register */ OP_INC, /* decrement the register */ OP_DEC, /* stop execution */ OP_DONE >opcode; typedef enum interpret_result < SUCCESS, ERROR_UNKNOWN_OPCODE, >interpret_result; void vm_reset(void) < puts("Reset vm state"); vm = (typeof(vm)) < NULL >; > interpret_result vm_interpret(uint8_t *bytecode) < vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) < uint8_t instruction = *vm.ip++; switch (instruction) < case OP_INC: < vm.accumulator++; break; >case OP_DEC: < vm.accumulator--; break; >case OP_DONE: < return SUCCESS; >default: return ERROR_UNKNOWN_OPCODE; > > return SUCCESS; >
Здесь меньше ста строк, но все характерные атрибуты виртуальной машины представлены. У машины единственный регистр ( vm.accumulator ), три операции (инкремент регистра, декремент регистра и завершение исполнения программы) и указатель на текущую инструкцию ( vm.ip ).
Каждая операция (англ. operation code, или opcode) кодируется одним байтом, а диспетчеризация осуществляется при помощи обычного switch в функции vm_interpret . Ветки в switch содержат логику операций, то есть меняют состояние регистра либо завершают исполнение программы.
Операции передаются в функцию vm_interpret в виде массива байтов — байт-кода (англ. bytecode) — и последовательно выполняются до тех пор, пока в байт-коде не встретится операция завершения работы виртуальной машины ( OP_DONE ).
Ключевой аспект виртуальной машины — семантика, то есть набор операций, которые над ней возможны. В данном случае операций только две, и они меняют значение единственного регистра.
Некоторые исследователи (Virtual-machine Abstraction and Optimization Techniques, 2009) предлагают разделять виртуальные машины на высокоуровневые и низкоуровневые по близости семантики виртуальной машины к семантике физической машины, на которой будет выполняться байт-код.
В предельном случае байт-код низкоуровневых виртуальных машин может полностью повторять машинный код физической машины с имитацией оперативной памяти, полным набором регистров, инструкциями работы со стеком и так далее. Виртуальная машина Bochs, например, повторяет набор инструкций архитектуры x86.
И наоборот: операции высокоуровневых виртуальных машин близко отражают семантику компилируемого в байт-код специализированного языка программирования. Так работают, например, SQLite, Gawk и многочисленные версии Prolog.
Промежуточное положение занимают интерпретаторы языков программирования общего назначения, имеющие элементы как высокого, так и низкого уровней. В популярнейшей Java Virtual Machine есть как низкоуровневые инструкции для работы со стеком, так и встроенная поддержка объектно-ориентированного программирования с автоматическим выделением памяти.
Приведённый же код относится скорее к самым примитивным из низкоуровневых виртуальных машин: каждая виртуальная инструкция — обёртка над одной-двумя физическими инструкциями, виртуальный регистр же полностью соответствует одному регистру «железного» процессора.
Аргументы инструкций в байт-коде
Можно сказать, что единственный регистр в нашем примере виртуальной машины — одновременно и аргумент, и возвращаемое значение всех выполняемых инструкций. Однако нам может пригодиться возможность передавать аргументы в инструкции. Один из способов — прямо помещать их в байт-код.
Расширим пример, внеся инструкции (OP_ADDI, OP_SUBI), принимающие аргумент в виде байта, следующего непосредственно за опкодом:
struct < uint8_t *ip; uint64_t accumulator; >vm; typedef enum < /* increment the register */ OP_INC, /* decrement the register */ OP_DEC, /* add the immediate argument to the register */ OP_ADDI, /* subtract the immediate argument from the register */ OP_SUBI, /* stop execution */ OP_DONE >opcode; typedef enum interpret_result < SUCCESS, ERROR_UNKNOWN_OPCODE, >interpret_result; void vm_reset(void) < puts("Reset vm state"); vm = (typeof(vm)) < NULL >; > interpret_result vm_interpret(uint8_t *bytecode) < vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) < uint8_t instruction = *vm.ip++; switch (instruction) < case OP_INC: < vm.accumulator++; break; >case OP_DEC: < vm.accumulator--; break; >case OP_ADDI: < /* get the argument */ uint8_t arg = *vm.ip++; vm.accumulator += arg; break; >case OP_SUBI: < /* get the argument */ uint8_t arg = *vm.ip++; vm.accumulator -= arg; break; >case OP_DONE: < return SUCCESS; >default: return ERROR_UNKNOWN_OPCODE; > > return SUCCESS; >
Новые инструкции (см. функцию vm_interpret ) читают из байт-кода свой аргумент и прибавляют его к регистру / вычитают его из регистра.
Такой аргумент называется непосредственным аргументом (англ. immediate argument), поскольку он располагается прямо в массиве опкодов. Главное ограничение в нашей реализации заключается в том, что аргумент представляет собой один-единственный байт и может принимать только 256 значений.
В нашей виртуальной машине диапазон возможных значений аргументов инструкций не играет большой роли. Но если виртуальная машина будет использоваться в качестве интерпретатора настоящего языка, то имеет смысл усложнить байт-код, добавив в него отдельную от массива опкодов таблицу констант и инструкции с непосредственным аргументом, соответствующим адресу настоящего аргумента в таблице констант.
Стековая машина
Инструкции в нашей несложной виртуальной машине всегда работают с одним регистром и никак не могут передавать друг другу данные. Кроме того, аргумент у инструкции может быть только непосредственный, а, скажем, операции сложения или умножения принимают два аргумента.
Проще говоря, у нас нет никакой возможности вычислять сложные выражения. Для решения этой задачи необходима стековая машина, то есть виртуальная машина со встроенным стеком:
#define STACK_MAX 256 struct < uint8_t *ip; /* Fixed-size stack */ uint64_t stack[STACK_MAX]; uint64_t *stack_top; /* A single register containing the result */ uint64_t result; >vm; typedef enum < /* push the immediate argument onto the stack */ OP_PUSHI, /* pop 2 values from the stack, add and push the result onto the stack */ OP_ADD, /* pop 2 values from the stack, subtract and push the result onto the stack */ OP_SUB, /* pop 2 values from the stack, divide and push the result onto the stack */ OP_DIV, /* pop 2 values from the stack, multiply and push the result onto the stack */ OP_MUL, /* pop the top of the stack and set it as execution result */ OP_POP_RES, /* stop execution */ OP_DONE, >opcode; typedef enum interpret_result < SUCCESS, ERROR_DIVISION_BY_ZERO, ERROR_UNKNOWN_OPCODE, >interpret_result; void vm_reset(void) < puts("Reset vm state"); vm = (typeof(vm)) < NULL >; vm.stack_top = vm.stack; > void vm_stack_push(uint64_t value) < *vm.stack_top = value; vm.stack_top++; >uint64_t vm_stack_pop(void) < vm.stack_top--; return *vm.stack_top; >interpret_result vm_interpret(uint8_t *bytecode) < vm_reset(); puts("Start interpreting"); vm.ip = bytecode; for (;;) < uint8_t instruction = *vm.ip++; switch (instruction) < case OP_PUSHI: < /* get the argument, push it onto stack */ uint8_t arg = *vm.ip++; vm_stack_push(arg); break; >case OP_ADD: < /* Pop 2 values, add 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left + arg_right; vm_stack_push(res); break; >case OP_SUB: < /* Pop 2 values, subtract 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left - arg_right; vm_stack_push(res); break; >case OP_DIV: < /* Pop 2 values, divide 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); /* Don't forget to handle the div by zero error */ if (arg_right == 0) return ERROR_DIVISION_BY_ZERO; uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left / arg_right; vm_stack_push(res); break; >case OP_MUL: < /* Pop 2 values, multiply 'em, push the result back to the stack */ uint64_t arg_right = vm_stack_pop(); uint64_t arg_left = vm_stack_pop(); uint64_t res = arg_left * arg_right; vm_stack_push(res); break; >case OP_POP_RES: < /* Pop the top of the stack, set it as a result value */ uint64_t res = vm_stack_pop(); vm.result = res; break; >case OP_DONE: < return SUCCESS; >default: return ERROR_UNKNOWN_OPCODE; > > return SUCCESS; >
В этом примере операций уже больше, и почти все они работают только со стеком. OP_PUSHI помещает на стек свой непосредственный аргумент. Инструкции OP_ADD, OP_SUB, OP_DIV, OP_MUL извлекают по паре значений из стека, вычисляют результат и помещают его обратно на стек. OP_POP_RES снимает значение со стека и помещает его в регистр result, предназначенный для результатов работы виртуальной машины.
Для операции деления (OP_DIV) отлавливается ошибка деления на ноль, что останавливает работу виртуальной машины.
Возможности такой машины намного шире предыдущей с единственным регистром и позволяют, например, вычислять сложные арифметические выражения. Другим (и немаловажным!) преимуществом является простота компиляции языков программирования в байт-код стековой машины.
Регистровая машина
Благодаря своей простоте стековые виртуальные машины получили самое широкое распространение среди разработчиков языков программирования; те же JVM и Python VM используют именно их.
Однако у таких машин есть и недостатки: в них приходится добавлять специальные инструкции для работы со стеком, при вычислении выражений все аргументы многократно проходят через единственную структуру данных, в стековом коде неизбежно появляется множество лишних инструкций.
Между тем выполнение каждой лишней инструкции влечёт за собой затраты на диспетчеризацию, то есть декодирование опкода и переход к телу инструкций.
Альтернатива стековым машинам — регистровые виртуальные машины. У них более сложный байт-код: в каждой инструкции явно закодированы номер регистров-аргументов и номер регистра-результата. Соответственно, вместо стека в качестве хранилища промежуточных значений используется расширенный набор регистров.
#define REGISTER_NUM 16 struct < uint16_t *ip; /* Register array */ uint64_t reg[REGISTER_NUM]; /* A single register containing the result */ uint64_t result; >vm; typedef enum < /* Load an immediate value into r0 */ OP_LOADI, /* Add values in r0,r1 registers and put them into r2 */ OP_ADD, /* Subtract values in r0,r1 registers and put them into r2 */ OP_SUB, /* Divide values in r0,r1 registers and put them into r2 */ OP_DIV, /* Multiply values in r0,r1 registers and put them into r2 */ OP_MUL, /* Move a value from r0 register into the result register */ OP_MOV_RES, /* stop execution */ OP_DONE, >opcode; typedef enum interpret_result < SUCCESS, ERROR_DIVISION_BY_ZERO, ERROR_UNKNOWN_OPCODE, >interpret_result; void vm_reset(void) < puts("Reset vm state"); vm = (typeof(vm)) < NULL >; > void decode(uint16_t instruction, uint8_t *op, uint8_t *reg0, uint8_t *reg1, uint8_t *reg2, uint8_t *imm) < *op = (instruction & 0xF000) >> 12; *reg0 = (instruction & 0x0F00) >> 8; *reg1 = (instruction & 0x00F0) >> 4; *reg2 = (instruction & 0x000F); *imm = (instruction & 0x00FF); > interpret_result vm_interpret(uint16_t *bytecode) < vm_reset(); puts("Start interpreting"); vm.ip = bytecode; uint8_t op, r0, r1, r2, immediate; for (;;) < /* fetch the instruction */ uint16_t instruction = *vm.ip++; /* decode it */ decode(instruction, &op, &r0, &r1, &r2, &immediate); /* dispatch */ switch (op) < case OP_LOADI: < vm.reg[r0] = immediate; break; >case OP_ADD: < vm.reg[r2] = vm.reg[r0] + vm.reg[r1]; break; >case OP_SUB: < vm.reg[r2] = vm.reg[r0] - vm.reg[r1]; break; >case OP_DIV: < /* Don't forget to handle the div by zero error */ if (vm.reg[r1] == 0) return ERROR_DIVISION_BY_ZERO; vm.reg[r2] = vm.reg[r0] / vm.reg[r1]; break; >case OP_MUL: < vm.reg[r2] = vm.reg[r0] * vm.reg[r1]; break; >case OP_MOV_RES: < vm.result = vm.reg[r0]; break; >case OP_DONE: < return SUCCESS; >default: return ERROR_UNKNOWN_OPCODE; > > return SUCCESS; >
В примере показана регистровая машина на 16 регистров. Инструкции занимают по 16 бит каждая и кодируются тремя способами:
- 4 бита на код операции + 4 бита на имя регистра + 8 бит на аргумент.
- 4 бита на код операции + трижды по 4 бита на имена регистров.
- 4 бита на код операции + 4 бита на единственное имя регистра + 8 неиспользованных бит.
У нашей небольшой виртуальной машины совсем мало операций, поэтому четырёх бит (или 16 возможных операций) на опкод вполне достаточно. Операция определяет, что именно представляют оставшиеся биты инструкции.
Первый вид кодирования (4 + 4 + 8) нужен для загрузки данных в регистры операцией OP_LOADI. Второй вид (4 + 4 + 4 + 4) используется для арифметических операций, которые должны знать, где брать пару аргументов и куда складывать результат вычисления. И, наконец, последний вид (4 + 4 + 8 ненужных бит) используется для инструкций с единственным регистром в качестве аргумента, в нашем случае это OP_MOV_RES.
Для кодирования и декодирования инструкций теперь нужна специальная логика (функция decode ). С другой стороны, логика инструкций благодаря явному указанию на расположение аргументов становится проще — исчезают операции со стеком.
Ключевые особенности: в байт-коде регистровых машин меньше инструкций, отдельные инструкции шире, компиляция в такой байт-код сложнее — компилятору приходится решать, как использовать доступные регистры.
Надо заметить, что на практике в регистровых виртуальных машинах обычно есть и стек, куда помещаются, например, аргументы функций; регистры же используются для вычисления отдельных выражений. Даже если явного стека нет, то для построения стека используется массив, играющий ту же роль, что оперативная память в физических машинах.
Стековые и регистровые машины, сравнение
Есть интересное исследование (Virtual machine showdown: Stack versus registers, 2008), оказавшее большое влияние на все последующие разработки в области виртуальных машин для языков программирования. Его авторы предложили способ прямой трансляции из стекового кода стандартной JVM в регистровый код и сравнили производительность.
Способ нетривиальный: код сначала транслируется, а потом достаточно сложным образом оптимизируется. Но последующее сравнение производительности одной и той же программы показало, что дополнительные циклы процессора, затраченные на декодирование инструкций, полностью компенсируются уменьшением общего числа инструкций. В общем, если коротко, регистровая машина оказалась эффективнее стековой.
Как уже упоминалось выше, у этой эффективности есть вполне осязаемая цена: компилятор должен сам аллоцировать регистры и дополнительно желательно наличие развитого оптимизатора.
Спор о том, какая же архитектура лучше, всё ещё не закончен. Если говорить о компиляторах Java, то байт-код Dalvik VM, до недавних пор работавший в каждом Android-устройстве, был регистровым; но титульная JVM сохранила стековый набор инструкций. Виртуальная машина Lua использует регистровую машину, но Python VM — по-прежнему стековая. И так далее.
Байт-код в интерпретаторах регулярных выражений
Наконец, чтобы отвлечься от низкоуровневых виртуальных машин, давайте рассмотрим специализированный интерпретатор, проверяющий строки на соответствие регулярному выражению:
typedef enum < /* match a single char to an immediate argument from the string and advance ip and cp, or * abort*/ OP_CHAR, /* jump to and match either left expression or the right one, abort if nothing matches*/ OP_OR, /* do an absolute jump to an offset in the immediate argument */ OP_JUMP, /* stop execution and report a successful match */ OP_MATCH, >opcode; typedef enum match_result < MATCH_OK, MATCH_FAIL, MATCH_ERROR, >match_result; match_result vm_match_recur(uint8_t *bytecode, uint8_t *ip, char *sp) < for (;;) < uint8_t instruction = *ip++; switch (instruction) < case OP_CHAR:< char cur_c = *sp; char arg_c = (char)*ip ; /* no match? FAILed to match */ if (arg_c != cur_c) return MATCH_FAIL; /* advance both current instruction and character pointers */ ip++; sp++; continue; >case OP_JUMP: < /* read the offset and jump to the instruction */ uint8_t offset = *ip; ip = bytecode + offset; continue; >case OP_OR: < /* get both branch offsets */ uint8_t left_offset = *ip++; uint8_t right_offset = *ip; /* check if following the first offset get a match */ uint8_t *left_ip = bytecode + left_offset; if (vm_match_recur(bytecode, left_ip, sp) == MATCH_OK) return MATCH_OK; /* no match? Check the second branch */ ip = bytecode + right_offset; continue; >case OP_MATCH: < /* success */ return MATCH_OK; >> return MATCH_ERROR; > > match_result vm_match(uint8_t *bytecode, char *str)
Главная инструкция — OP_CHAR. Она берёт свой непосредственный аргумент и сравнивает его с текущим символом в строке ( char *sp ). В случае совпадения ожидаемого и текущего символов в строке происходит переход к следующей инструкции и следующему символу.
Машина также понимает операцию перехода (OP_JUMP), принимающую единственный непосредственный аргумент. Аргумент означает абсолютное смещение в байт-коде, откуда следует продолжать вычисление.
Последняя важная операция — OP_OR. Она принимает два смещения, пробуя применить сначала код по первому из них, потом, в случае ошибки, второму. Делает она это при помощи рекурсивного вызова, то есть инструкция делает обход в глубину дерева всех возможных вариантов регулярного выражения.
Удивительно, но четырёх опкодов и семидесяти строк кода достаточно, чтобы выразить регулярные выражения вида «abc», «a?bc», «(ab|bc)d», «a*bc». В этой виртуальной машине даже нет явного состояния, так как всё необходимое — указатели на начало потока инструкций, текущую инструкцию и текущий символ — передаётся аргументами рекурсивной функции.
Если вам интересны детали работы движков регулярных выражений, для начала можете ознакомиться с серией статей Расса Кокса (англ. Russ Cox), автора движка для работы с регулярными выражениями от Google RE2.
Итоги
Давайте подведём итоги.
Для языков программирования общего назначения используются, как правило, две архитектуры: стековая и регистровая.
В стековой модели основной структурой данных и способом передачи аргументов между инструкциями является стек. В регистровой модели для вычисления выражений используется набор регистров, но для хранения аргументов функций всё равно используется явный или неявный стек.
Наличие явного стека и набора регистров приближает такие машины к низкоуровневым и даже физическим. Обилие низкоуровневых инструкций в таком байт-коде означает, что существенные затраты ресурсов физического процессора приходятся на декодирование и диспетчеризацию виртуальных инструкций.
С другой стороны, в популярных виртуальных машинах большую роль играют и высокоуровневые инструкции. В Java, например, это инструкции полиморфных вызовов функций, аллокация объектов и сборка мусора.
Чисто высокоуровневые виртуальные машины — к примеру, интерпретаторы байт-кодов языков с развитой и далёкой от железа семантикой — большую часть времени проводят не в диспетчере или декодере, а в телах инструкций и, соответственно, относительно эффективны.
- Если вам понадобилось исполнить какой-либо байт-код и сделать это в разумные сроки, то постарайтесь оперировать инструкциями, наиболее близкими к вашей задаче; чем выше семантический уровень, тем лучше. Это снизит затраты на диспетчеризацию и упростит генерацию кода.
- Если потребовались большая гибкость и разнородная семантика, то следует хотя бы попробовать выделить общий знаменатель в байт-коде так, чтобы результирующие инструкции были на условно среднем уровне.
- Если в перспективе может понадобиться вычислять какие-либо выражения, делайте стековую машину, это уменьшит головную боль при компиляции байт-кода.
- Если выражений не предвидится, то делайте тривиальную регистровую машину, что позволит избежать затрат на стек и упростит сами инструкции.
В следующих статьях я разберу практические реализации виртуальных машин в популярных языках программирования и расскажу, зачем отделу Business Intelligence Badoo понадобился байт-код.
- Блог компании Badoo
- Программирование
- C++
- Виртуализация
- Компиляторы
