Стек стеком погоняет, или преобразование байткода виртуальной машины Java в байткод машины Фантом ОС

    ОС Фантом — экспериментальная операционная система, содержащая на прикладном уровне виртуальную байткод-машину в персистентной оперативной памяти.

    Один из двух ключевых запланированных для ОС Фантом путей миграции существующего кода — преобразование байткода Java в байткод Фантом.

    Надо сказать, что эти виртуальные машины изрядно, хотя и совершенно случайно, похожи. Виртуальная машина Фантома была спроектирована тогда, когда про Явскую я ещё ничего не знал, но, наверное, сходство целей привело к сходству принятых решений.

    Обе машины — стековые. Обе оперируют двумя отдельными стеками — стеком для работы с объектами (на стеке лежат только ссылки), и бинарным стеком — для вычислений. Машина Фантома имеет также отдельные стеки для фреймов функций и ловушек исключений. Как эта часть устроена в JVM, я не знаю до сих пор, но полагаю, что вряд ли кардинально отличным образом.

    Естественно, что и набор операций стековых машин местами схож как две капли.

    Но, безусловно, есть и весьма существенные отличия.

    Во-первых, виртуальная машина Фантома предназначена для работы прикладного кода в менее дружественной среде. Ява исходит из того, что каждая программа живёт в отдельном адресном пространстве, и всё, что вокруг — “наш” код. Фантом допускает прямые вызовы между приложениями разных пользователей и разных программ, что требует более жёсткого отношения к некоторым аспектам виртуальной машины, включая тот же вызов, да и интерфейс объекта вообще. Например, мы не можем полагаться на то, что вызванный метод ведёт себя “прилично” — нельзя давать ему доступ в свой стек, нельзя полагаться на наличие или отсутствие возвращаемого значения. Нельзя гарантировать различие между методом, функцией и статической функцией. То есть, мы можем предполагать, что именно мы вызываем, но что нам «подсунули» с той стороны — неизвестно.

    В силу всего сказанного, вызов в Фантоме унифицирован абсолютно — это всегда вызов метода (есть this и есть класс), и всегда возвращается значение, которое для void метода равно null и явно уничтожается вызывающим кодом. Это гарантирует, что какая бы ошибка вызова не случилась, что бы не подвернулось в качестве предмета вызова, протокол вызова и возврата будет соблюдён.

    Есть отличие и в работе с целыми. Ява выделяет их в отдельную категорию типов, отличную от объектных, “классовых” типов — java.lang.Integer и int — разные вещи в Яве. Компайлер иногда удачно скрывает этот факт, но внутри они различаются. Фантом и здесь идёт в сторону максимализма. Целое — честный объект. Его можно вытащить на целочисленный стек и там посчитать в “необъектной”, бинарной форме, но он вернётся в форму объекта будучи присвоен переменной или передан в параметре. Это, кстати, тоже вытекает из требования униформности протокола вызова метода — методы, возвращающие целое и объект по протоколу тождественны. (То же самое, очевидно, относится и к другим «интегральным» типам — long, float, double.)

    Есть и другие отличия, например, протокол подключения того, что в Яве называется native методы. В Фантоме это «системные вызовы», и, опять же, на уровне вызова метода они ничем не отличимы от обычного “честного” метода. (Код такого метода содержит специальную инструкцию для “ухода” в ядро ОС, но “снаружи” метода это не видно. Это, в частности, позволяет наследовать и оверрайдить такие методы традиционным путём, через замену VMT.)

    Представляется (по крайней мере, мне представлялось), что преобразование байткода одной стековой машины в байткод другой стековой машины — элементарная задача. В конце концов, там и там стеки, и 90% операций — просто идентичны. Ну нет никакой разницы между Фантомовским и Явским байткодом целочисленного сложения: поднять два целых со стека, сложить, положить на стек результат.

    Первый подход к трансляции опирался именно на модель последовательного преобразования байткода Ява в фантомовский. Быстро выяснилось, что сделать это линейно нельзя. Совсем. Приходится “отрабатывать” при разборе Явского кода “работу” стека, и синтезировать промежуточное представление. Часть такого транслятора была написана и признана негодной — трудоёмкость превзошла все мыслимые границы. К примеру, локально, в точке вызова, совершенно невозможно выяснить, объектный это вызов (первый параметр — this), или нет. Яве всё равно, а нам важно. Выяснить это можно, но нужно приложить немало усилий. Это даже при условии, что писать приходилось только анализатор — бекенд компилятора, генерирующий вполне надёжный байткод Фантома, к тому времени стабильно работал (в силу того что был готов и стабильно использовался компилятор “собственного” языка).

    В этом месте работа бы застопорилась, не попадись мне под руки фреймворк по имени Soot. Изначально предназначенный для статического анализа и инструментовки Ява байткода, он идеально подошёл для описанной задачи. Soot парсит класс-файл JVM и генерирует чрезвычайно вменяемое внутреннее представление — дерево операций с компактным (полтора десятка типов узлов) базисом, плюс информация о типах и другой метаинформации.

    С этой точки конверсия производится катастрофически проще — фактически, нужно преобразовать дерево в дерево. На сдачу, кстати, получаем и поддержку Dalvik (Andrid VM bytecode).

    Нельзя сказать, что теперь всё безоблачно. Хотя первые примитивные Ява-классы уже прошли компиляцию и начата работа по юнит-тестам компилятора. Есть ещё масса проблем.

    Например: в фантоме наследование от классов с “внутренней” реализацией предполагалось запретить. В то же время, Ява “привыкла” видеть у строки тип java.lang.String, а не internal.String. Но это ещё ладно! Сложнее со сравнением объектов. В Яве == для целых и строк работает различно, сравнивает значения и ссылки, соответственно. Более консистентный Фантом чётко различает сравнение значений и ссылок, а значит простое на вид преобразование операторов == и != вызывает проблему — надо или разбираться с типом, или вводить в базис “явский” байткод, который ведёт себя как описано выше. Что “неаккуратненько”, зато чертовски просто.

    Вообще изначально предполагалось, что систему типов Явы надо инкапсулировать, представив их в дереве типов виртуальной машины Фантом внутри ветки java. Фактически, сейчас я от этого отказался. Представляется, что это вызовет больше проблем, чем решит.

    Смешная проблема была с доступом к публичным полям: в Фантоме их… нет. Вообще. Только методы. Обход проблемы потребовал автоматической генерации и использования геттеров и сеттеров. Что, наверное, тоже проблематично — сейчас им даются типовые “Явские” имена getVariable/setVariable, что может вызвать конфликт. Нужно, видимо, сделать имена “генерируемых” методов специальными и недоступными из обычного пространства имён методов, но делать так тоже несколько жалко — автогенерация публичных геттеров-сеттеров имеет прикладную ценность.

    Следующей проблемой будут примитивы синхронизации. В Яве точкой синхронизации может быть любой объект. Держать для этого в каждом объекте Фантома специальные поля не хочется, но уметь как-то “достраивать” объекты надо. Причём не только синхронизация но и, например, механизм слабых ссылок требует “навешивать” на объект дополнительные сущности. В данный момент это предполагается делать через поле заголовка объекта, на которое можно, при необходимости, вешать объект или множество объектов для обслуживания специальных случаев. У большинства “линейных” объектов это поле будет пустовать, и заполняться только если с ним делают что-то особенное.

    Уф. Наверное, для начала на этом поставим точку с запятой.

    Ну и да, это всё — open source. Если интересно принять участие в работе над ОС, или в вашем проекте нужна готовая виртуальная машина, проект легко находится на гитхабе по ключу phantomuserland.
    Метки:
    Поделиться публикацией
    Комментарии 17
    • +2
      Какую версию Java-байткода вы поддерживаете? Java-6?

      Как я понимаю, производительность вас сильно не волнует, если вы совсем от примитивных типов отказались?

      Не очень понял, как транслируется вызов статического метода (invokestatic). Какой объект используется в качестве this?

      Интерфейсы у вас есть в языке? Такие же, как в Java? Как у них устроена таблица виртуальных методов?
      • +1
        4*спасибо за вопрос. :)

        Чёткой отсылки к номеру версии байткода в документации soot я не нашёл, но последний билд проекта — от 2012 года, что наводит на мысль, что явно не 8. Скорее 6, да.

        Производительности я считаю правильным достигать через JIT, сохраняя регулярную структуру формальной виртуальной машины. Внутри кода JIT ничто не мешает пускаться во все тяжкие, уходя от навязанных виртуальной машиной представлений. Собственно, даже в интерпретаторе любая формула из группы интегральных переменных будет отработана в бинарном виде, преобразование инта в объектную форму происходит только при вызовах и сохранениях переменных.

        Вызов статики пока не реализован, но проблем тут нет вообще. Есть два простых варианта. первый — генерировать для статики специальный класс, второй — тупо делать статические методы так же как обычные, держать для них синглтон инстанс this, и при кодогенерации запрещать доступ к полям this. Спасибо ещё раз за вопрос, заодно я, кажется, определился с реализацией. :)

        Интерфейсы запроектированы. Реализация реально не дошла. Есть два варианта реализации. Первый. Предпочтительный. Реально все пойнтера виртуальной машины таскают с собой пойнтер на интерфейс, по которому и происходит обращение к vmt. Это позволит реализовать cast с заменой в пойнтере интерфейса на любой нужный. Дальше вызов по vmt[ordinal интерфейса]. Второй вариант совсем тупой — invokedynamic. Но он пока без полиморфизма. Что, впрочем, тоже решается легко — передавать сигнатуру вместо имени метода.
      • +2
        >> Надо сказать, что эти виртуальные машины изрядно, хотя и совершенно случайно, похожи.

        >> Как эта часть устроена в JVM, я не знаю до сих пор

        Гм…

        >> К примеру, локально, в точке вызова, совершенно невозможно выяснить, объектный это вызов (первый параметр — this), или нет.

        А в чём проблема?
        В байткоде это всегда invokestatic, а метод и так знает, что он статический.

        >> Чёткой отсылки к номеру версии байткода в документации soot я не нашёл, но последний билд проекта — от 2012 года

        Currently, Soot can process code from the following sources:

        — Java (bytecode and source code up to Java 7), including other languages that compile to Java bytecode, e.g. Scala


        © http://sable.github.io/soot/#what-input-formats-does-soot-provide

        Последний коммит — две недели назад: https://github.com/Sable/soot/graphs/commit-activity
        • 0
          О. Спасибо. А я уж было слегка грустил и задумывался, не переехать ли на ASM.

          Про invoke — откровенно говоря, не помню деталей. Давно делал ту версию. Вполне возможно, что мне тупо не пришло в голову опереться на тип invoke.
        • +1
          Рад, что вы решили написать статью на Хабре. Приятно иметь возможность пообщаться с таким интересным человеком, так сказать, воочию.

          Теперь по делу. Не считаете ли вы, что персистентную ОС с единым адресным пространством сегодня стоило бы разрабатывать без ВМ, а на основе ЯП с гарантиями защиты памяти типа Rust?
          • 0
            Спасибо на добром слове.

            Ограничивать ОС одним ЯП, да ещё и не самым популярным — затея спорная. Да и гарантии защиты памяти даёт не сам язык, а среда исполнения+генератор кода. В Фантоме виртуальная машина самодостаточна с точки зрения обеспечения надёжности, но это же всё равно промежуточный этап — дальше будет генерация бинарного кода из байткода (JIT), и ВМ как таковая исчезнет, останется только в виде промежуточного представления программы.
            • +1
              Да, ограничиваться одним языком не стоит, тут вы правы. Просто смущает мысль о необходимости раздутой среды исполнения, похожей на JVM. Дело в том, что разработчики Rust продемонстрировали, что можно почти полностью отказаться от среды исполнения (как в C), но обеспечить безопасность памяти и параллелизма на основе статического анализа (при наличии соотв. системы типов, разумеется). Потому тут напрашивается какое-то подмножество LLVM-IR, над которым тоже можно было бы проводить подобные проверки. Т.е., по-сути, это та же ВМ, но с минимальной средой исполнения (только проверки границ массива, стека, ещё что-то, без чего нельзя обойтись).
              • 0
                Оно и в Яве давно так, интерпретатор почти уже не используется. Можно её вообще скомпилировать в бинарник (gcj, part of gcc compilers collection). Как таковая среда — это библиотека. Если она не нужна, можно получить сишный размер программы.
                • 0
                  К сожалению, GCJ мертв. Из AOT компиляции для java байткода знаю только Excelsior JET
                  • 0
                    Жаль. Хороший был проект. А почему, известно? Люди ушли, или проблемы?
                    • 0
                      По моему мнению это связано с появлением проекта OpenJDK, куда устремились люди и средства корпораций. Посмотрите на даты, очень похоже на это.
          • 0
            В дотнете, вроде как, на уровне вм есть особые фичи для поддержки овеществленных дженериков, а в яве, вроде как, нет такого… хотя чуваки из редхата при создании цейлона обошлись и запихали их в рантайм через аннотации…
            А в фантоме с этим как?
            • 0
              А какой именно поддержки?
              • 0
                Ну я конечно не спец в этом деле и могу сморозить глупость. Как я понял, в дотнете дженерик класс остаётся неким шаблоном до самого рантайма и в рантайме тоже, и по мере надобности из него делаются нормальные классы со своими статическими полями, например, этот конкретный класс можно по имени поискать, и т.п.
                Ну вот такая фича, наверное, и должна на уровне вм поддерживаться, типа создавать конкретные классы из шаблончиков по мере надобности. Ну в случае если интересует не только запуск явы на фантоме.
                • 0
                  На уровне виртуальной машины это, вроде бы, большого смысла не имеет, там только проверка типов специфична. Вот раскрыть шаблон при генерации JIT — это интересно. Но нет, сейчас такого нет, потому что, для начала, пока нет JIT.
            • 0
              Не все так тривиально. Например, за invokedynamic там целая вселенная в runtime чтобы было производительно. Плюс есть всякие intrisics:http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/classfile/vmSymbols.hpp#l581

              Вообще, не совсем понятно зачем такое делать в практическом смысле? JVM имеет много уровней оптимизации в compile/runtime. И последние добавляемые в язык/байткод фичи тесно завязаны на runtime. Если ваш runtime отличается существенно, то и производительности не видать.

              Далее, что насчет jmm? Java это прежде всего спецификация. Если вы хотите поддерживать java программы, нужно будет гарантировать и совместимость. А там есть очень нетривиальные вещи. Та же java memory model.

              Не говорю, что это вам не по силам, но действительно ли это цель?
              • 0
                Задачей, очевидно, является не написание VM, а создание ОС с персистентной ортогональностью и виртуальной машиной в глобальной памяти. (Блекджеком и шахматами и поэтессами повеяло, да?)

                С обычной явской VM я этот вес не подниму точно. С таким подходом — поднял до уровня proof of concept. То есть — пока оно себя оправдывает. Мне, кроме всего, важно понять ограничения и возможности подхода, а это хорошо делать с развязанными руками.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.