Как мы запустили программу на Java без JavaVM

Всем привет! В этой статье мы расскажем о том, как технология GraalVM Native Image помогла нам решить ряд задач в одном из наших новых продуктов, написанном на Java, расскажем о проблемах, с которыми столкнулись в ходе применения этой технологии, и о том, как эти проблемы решали.
image

Про продукт


1С: Исполнитель — наш новый продукт, предназначенный в первую очередь для администрирования информационных систем на платформе 1С: Предприятие. Содержит кроссплатформенный язык сценариев (работает в Linux и Windows, в планах — поддержка macOS), библиотеку времени исполнения и среду разработки и отладки (можно работать в IDE на базе Eclipse и в Visual Studio Code).
Продукт написан на Java.
Под катом — рассказ о специфике самого продукта 1С: Исполнитель. Кому интересно именно про Graal — могут смело переходить к следующей секции.
Про 1С: Исполнитель
Чуть подробнее о проекте, образ которого мы хотим в конечном счете получить. Исполнитель — это интерпретатор для кроссплатформенного языка сценариев, и сам язык сценариев к нему. Подобно тому, как cmd.exe исполняет bat-скрипты или bash исполняет bash-скрипты, Исполнитель работает со своими скриптами. Если вы, например, решили внедрить практику CI в разработку приложения на базе платформы 1С: Предприятие, то использование Исполнителя будет подходящим выбором, потому что Исполнитель содержит необходимый функционал для такой работы, а также позволяет избавить администратора от ограничений платформо-зависимых решений в пользу фокусировки на самой задаче. Кроме того, язык содержит объекты для работы с кластером серверов 1С и базами данных 1С: Предприятия, использование этих объектов может сильно облегчить администрирование платформы, еще в Исполнителе есть встроенные объекты для полноценной работы со системой взаимодействия. Посмотрим на примере, как Исполнитель может управлять кластером:
const Address = "127.0.0.1"
const Port = 1545
const AgentAdminName = "AdminOfThisExample"
const AgentAdminPassword = "123456"
const ClusterAdminName = "ClusterAdmin"
const ClusterAdminPassword = "654321"

const Dbms = "PostgreSQL"
const DatabaseServer = "127.0.0.1"
const DatabaseAdminName = "postgres"
const DatabaseAdminPassword = ""

/*
 1. Поднимаем кластер и ждем пока запустится процесс, выполняя проверку
 2. Поднимаем дополнительный рабочий сервер в новом кластере
 3. Поднимаем информационную базу  в новом кластере (с созданием БД)
*/
method Script()
    try
        use Agent = new AdministrationServer(Address, Port)
        Agent.Authenticate(AgentAdminName, AgentAdminPassword)

        // Поднимаем кластер с указанными параметрами: // Name: ClusterName01
        // ИмяКомпьютера: 127.0.0.1
        // Порт: 10500
        var ClusterId = AddCluster(Agent, "ClusterName01", "127.0.0.1", 10501)
        var Cluster = Agent.GetCluster(ClusterId)
        Cluster.Authenticate(ClusterAdminName, ClusterAdminPassword)
        // Cluster поднимается долго, для поднятия инфобазы нужны рабочие процессы
        while (Cluster.GetWorkProcesses().Empty())
            // ждем
        ;

        // Поднимаем рабочий сервер в кластере с UUID = ClusterId с указанными параметрами: 
        // Компьютер: 10.70.4.50
        // Порт: 20541
        var WorkingServerId = AddWorkingServer(Cluster, "10.70.4.50", 20541)
        
        // Поднимаем инфобазу на кластере
        var InfoBaseId = AddInfoBase(Cluster, "TestInfobaseName", "TestInfobaseDBMSName")
        var InfoBase = Cluster.GetInfoBase(InfoBaseId)
        var InfoBaseConnections = InfoBase.GetConnections()
        
        var Connections = Cluster.GetConnections()
        for  Connection in Connections
            if (Connection.ApplicationName == "RAS")
                Connection.Disconnect()
            ;
        ;
    catch E: AdministrationClusterException
        fail("Error: " + E.Description)
    ;
;

method AddCluster(Agent: AdministrationServer, Name: String, ComputerName: String, ClusterPort: Number): UUID
    var Cluster = Agent.CreateCluster()
    Cluster.Name = Name
    Cluster.ComputerName = ComputerName
    Cluster.ProcessRestartPeriod = 3600
    Cluster.Port = ClusterPort
    Cluster.LoadBalancingMode = AdministrationProcessChoicePriority.ByMemory
    Cluster.ConnectionSecurityLevel = AdministrationConnectionSecurityLevel.Unsecure
    return Cluster.Write()
;

method AddInfoBase(Cluster: AdministrationCluster, InfobaseName: String, DbName: String): UUID
    var NewIB = Cluster.CreateInfoBase()
    NewIB.LockScheduledJobs = false
    NewIB.SessionsLockEnabled = false
    NewIB.Name = InfobaseName
    NewIB.DataBaseName = DbName
    NewIB.SessionStartPermissionCode = "Session start permission code"
    NewIB.Dbms = Dbms
    NewIB.Locale = "ru"
    NewIB.ExternalManagementRequired = false
    NewIB.Description = "Infobase with name <" + InfobaseName + ">"
    NewIB.LockParameter = "Lock params"
    NewIB.DatabaseServer = DatabaseServer
    NewIB.DatabaseUser = DatabaseAdminName
    NewIB.DatabaseUserPassword = DatabaseAdminPassword
    NewIB.DateOffset = 0
    NewIB.CreateDatabase = true
    NewIB.LockMessage = "Lock message"
    NewIB.ExternalSessionManagementConnectionString = "This is a string of parameters"
    return NewIB.Write()
;

method AddWorkingServer(Cluster: AdministrationCluster, WorkingServerAddress: String, WorkingServerPort: Number): UUID
    var NewServer = Cluster.CreateWorkServer()
    NewServer.ComputerName = WorkingServerAddress
    NewServer.SingleProcessConnectionsNumber = 10
    NewServer.CreateManagerForEachService = true
    NewServer.Port = WorkingServerPort
    NewServer.MainServer = false
    // Дальнейшая настройка параметров
    // ...
    return NewServer.Write()
;

Еще один пример простого скрипта на языке Исполнителя. Допустим, задача такая: получить ipconfig, сохранить его в файл, добавить в архив и отправить архив по почте (логин-пароль для smtp считать из xml). Для этого нужно использовать соответствующие объекты языка. Код будет выглядеть примерно вот так:

method GetIpConfig(): String
    Console.Write("Getting ip config")
    // Вернем результат работы ipconfig
    var Process = new OsProcess("cmd.exe", ["/c", "ipconfig"])
    Process.Start()
    var Output =  Process.GetOutputStream().ReadAsText("cp866")

    Console.Write("Process with ip config finished")
    return Output
;

method Zip()
    var Zip = new ZipFile("example.zip", "my_secret_password")
    var FolderToZip = new File("C:\\executor-examples/folder_to_zip")

    // Запишем результат ipconfig в файл в папку на архивацию
    var IpConfigOutputFile = new File("ipconfig_output.txt", FolderToZip)
    
    if (not IpConfigOutputFile.Exists())
        Files.Create(IpConfigOutputFile)
    ;
    use OutputStream = IpConfigOutputFile.OpenWritableStream()
        OutputStream.Write(GetIpConfig())
    Console.Write("Ip config has written to file")

    // Добавим в архив папку
    Console.Write("Zipping folder")
    Zip.Add(FolderToZip.Path)

    // Запишем коллекцию из элементов архива
    for e in Zip.Entries()
        Console.Write("- Archive element: " + e.PathInArchive)
    ;
    
    var EmailMessage = new OutgoingEmailMessage("example.@1c.ru", "example2@1c.ru", "This is archive")
    EmailMessage.Text = "Just an example, dont care"
    EmailMessage.AttachFile("example.zip", "very_important_zf")
    
    var Auth = ReadAuthXml(new File("C:/prog/examples/scripts/auth.xml"))
    var Params = new SmtpConnectionParameters("smtp.gmail.com", 465, new EmailAuthentication(Auth.Get(0), Auth.Get(1)))

    Console.Write("Sending email")
    SmtpClient.Send(Params, EmailMessage)
    Console.Write("Email has been sent")

    Files.Delete("example.zip", True)
;

method ReadAuthXml(XmFile: File): Array
    Console.Write("Reading xml file")
    var Xml = new XmlReader(XmFile.OpenReadableStream())
    var User = ""
    var Password = ""
    while (Xml.Next())
        if (Xml.Name == "user" and User == "")
            Xml.Next()
            User = Xml.Value
        ;
        if (Xml.Name == "password" and Password == "")
            Xml.Next()
            Password = Xml.Value
        ;
    ;
    Console.Write("Xml file has been read")
    return [User, Password]
;

Запускать из консоли можно вот так:
image

Все эти сущности, которые отправляют email, читают xml и т.п. называются объектами языка (или просто объектами).
Стандартная поставка Исполнителя включает в себя скрипт с командами запуска (executor.cmd) и набор jar-ников, в которых содержатся используемые библиотеки, а также код описания и работы объектов:
image

Мы сами, в частности, активно используем 1С: Исполнитель для задач администрирования и автоматизации в наших высоконагруженных облачных сервисах 1cFresh и 1С: Готовое Рабочее Место (ГРМ).

Постановка задачи


В ходе использования 1С: Исполнителя и мы, и наши пользователи столкнулись с двумя проблемами:
  • Недостаточно быстрый запуск продукта из-за инициализации Java на старте.
  • В ряде компаний ИТ-политики не разрешают установку Java, что делает применение 1С: Исполнителя невозможным.

Для улучшения ситуации мы решили сделать специальную поставку Исполнителя, не требующую установленной Java и представляющую собой нативное (исполняемое непосредственно в среде ОС) приложение. Чтобы пользователю было проще различать версии и реализации, решено было назвать исходный Исполнитель — Исполнитель-U (от слова Universal — универсальный), а его нативный образ — Исполнитель-X (eXecutable). О том, как мы создавали Исполнитель-X, и пойдёт речь ниже. А для его создания мы решили использовать технологию GraalVM Native Image.

Про технологию


GraalVM Native Image — это технология, которая позволяет скомпилировать Java (и не только Java) приложение в нативный образ, то есть AOT компиляция из Java идет сразу в машинный код. Также можно компилировать в shared library или в статически связанный образ. Это может помочь сократить время запуска и уменьшить объем используемой памяти, так как не нужно будет держать мета информацию о классах. С технологией можно ознакомиться на сайте Грааля, подробный мануал. В этом же блоке затронем значимые вещи для использования в проектах.

Ограничения технологии


У Native Image есть ряд ограничений, перечислим основные.

Ограничения, которые можно обойти конфигурированием:

  • Динамическая загрузка классов
  • Рефлексия
  • Динамические прокси

Однако есть и жесткие ограничения (jar-файлы, например, вообще нельзя подгружать в рантайме), почитать об этом можно тут.

Что работает по-другому


Основная вещь, которая работает по-другому в нативном образе в сравнении с привычным Java-миром — это, пожалуй, инициализация классов. В большинстве случаев инициализация классов происходит во время компиляции. Для нас данный факт, в частности, означает, что в статических переменных лучше не хранить значения, зависящие от конкретной машины или окружения (потому что с момента компиляции такие значение не поменяются). Кроме того, нельзя динамически подгружать библиотеки (из jar).

Native-Image Исполнителя


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

Вот как мы пошагово внедряли технологию в продукт.
В проекте нами используется система сборки Maven. Для того чтобы собрать приложение в нативный образ есть специальный плагин. Поэтому первым делом нужно подключить native-image-maven-plugin. Прошу обратить внимание, что в нем есть аргументы сборки, мы будем ими активно пользоваться. Ведь с их помощью можно конфигурировать процесс компиляции вашего образа и дальнейшие действия с ним.

Аргумент --no-fallback сообщает компилятору, что образ надо собирать такой, чтобы он работал без JVM (как раз то, что нам нужно). --allow-incomplete-classpath в свою очередь разрешает сборку даже если компилятор не может найти некоторые классы (включить их в образ). В нашем случае, если мы отключали эту опцию, то получали ошибку компиляции из-за попыток сослаться на классы, которые в 1С: Исполнителе даже не используются. Нужно помнить, что если во время сборки эти классы были недоступны, то и во время исполнения они доступными не будут, поэтому при попытках обратиться по их classpath будет выброшено исключение.


    org.graalvm.nativeimage
    native-image-maven-plugin
    ${graal.version}
    
        
            
                native-image
            
            package
        
    
    
        executor-native-image 
        
           

           
           --no-fallback
           --allow-incomplete-classpath 
        
        com.e1c.g5rt.executor.boot.ExecutorBootstrap
    


Так мы принялись впервые собирать и тестировать нативный образ Исполнителя. Однако же после того как мы получили образ, были обнаружены следующие проблемы:
  1. Нет объектов языка Исполнителя. Это те самые объекты, которые мы видели в разделе «Подробнее про 1С: Исполнитель» — объекты отправки почты, File и так далее. Все они лежат отдельно в jar-никах, мы их подгружаем в коде при старте Исполнителя в рантайме.
  2. Не работает часть функциональности, которая должна обеспечивать саму работу Исполнителя (даже без этих объектов). Например, интерфейс командной строки. Так, задание пути до скрипта с портом для дебага ($executor -d -s ), или получение версии ($executor -v) не работает. Сами аргументы не разбираются по заданному правилу в одной из библиотек.
  3. Не отображаются тексты ошибок компиляции скрипта. Да и в целом тексты ошибок по всему проекту не отображаются.

Так, например, работал только простой скрипт с выводом информации в консоль, потому что данный объект описывается не отдельно, а непосредственно в коде Исполнителя.

Проблема в том, что в библиотеках, которые задействованы в нашем проекте, используется reflection, динамические прокси и динамическая загрузка классов. Значит, нам нужно создать конфигурационные файлы, которые будут участвовать при сборке и сообщать компилятору как и где используется, например, reflection. Для обработки нужно выписать classpath и флаги в такой файл в нужном формате. Но для этого нужно знать, где у нас этот reflection используется. Учесть все случаи использования в нашем случае вручную нереально. И вообще довольно трудно по всему проекту искать reflection, не говоря уже про библиотеки, код которых мы не контролируем. Тут на помощь приходит native-image-agent. Это специальная утилита к GraalVM, которая поможет нам найти reflection, динамический прокси и т.д. во всём проекте. Как это работает? Вы запускаете ваше Java-приложение вместе с аргументом agentlib: native-image-agent. Во время исполнения утилита выписывает в нужном формате reflection, proxy в конфигурационные файлы, которые уже потом будут использоваться при сборке нативного образа. То есть на этом шаге ваша задача определить сценарии работы приложения и прогнать их с агентом, потому что просто глядя на код GraalVM не сможет разобраться с ограничениями.

$ java -agentlib:native-image-agent=config-merge-dir=<папка с конфиг файлами> -jar .jar <аргументы запуска>

$ ls <папка с конфиг файлами>
jni-config.json  proxy-config.json  reflect-config.json  resource-config.json

Поскольку необходимо именно исполнять приложение для отработки сценариев, в нашем случае мы написали код, который запускает Исполнитель вместе с выбранными скриптами и с разными аргументами. Эти скрипты заранее описаны, код для них взят из тестов на объекты, где мы стараемся отрабатывать крайние случаи и уж точно вызываем все методы (что, в конечном счете, для этих прогонов и нужно). Эти танцы с бубном проводятся для того, чтобы получившийся в итоге образ работал правильно.

На самом деле, мы еще никак не решили проблему, связанную с сообщениями об ошибках, потому что сообщения у нас расположены по всему проекту и этот код может даже и не вызываться при прогонах скриптов. Чтобы пользователи могли получать сообщения на разных языках, нами используется собственная библиотека локализации. Сообщения должны быть описаны на двух языках: русском и английском. Внутри компании существует регламент по использованию этой библиотеки: текст на русском языке с помощью аннотаций описывается в интерфейсах с именем IMessageList, есть привычные бандлы ресурсов, в которых сообщения уже на английском описываются в формате <имя метода из интерфейса>=<сообщение>. Чтобы лучше понять вышенаписанное, можно ознакомится со структурой файлов и их содержимым ниже.

Пример
Структура файлов:
  • java
    • IMessageList.java
  • resources
    • IMessageList_en.propeties

Java файл выглядит так:
@Localizable
public interface IMessageList
{
   IMessageList Messages = LocalizableFactory.create(IMessageList.class);

   @RuString("Ошибка, и это ее сообщение.")
   String some_error();
}

property файл тогда должен выглядеть вот так:

some_error=Error, and this is a message.


При запусках приложения с native-image-agent часть файлов для сообщений, конечно, попадет в конфигурационные файлы, но далеко не все. Потому что покрыть абсолютно все вызовы сообщений невозможно (ведь тестовые прогоны могут не задействовать классы специфических ошибок). То есть нам для решения проблемы с сообщениями уже не подходят прогоны с агентом.
// Так выглядит описание одного интерфейса сообщений в reflect-config
{
 "name":"com.e1c.g5rt.executor.client.IMessageList",
 "allPublicMethods":true
}
// Так выглядит описание одного интерфейса сообщений в proxy-config
["com.e1c.g5rt.executor.client.IMessageList"]

Поэтому в данном случае мы использовали отдельное приложение, которое на вход принимает fat-jar Исполнителя, открывая его как обычный zip-файл, и находит классы для локализованных сообщений (содержат в имени IMessageList.class). После этого остается просто выписать classpath в нужном формате в файлы конфигурации для reflection и proxy. Далее эти файлы дополняются выводом из агента и на этой основе собирается нативный образ.

Самые внимательные могут спросить, почему поиск идет по имени, ведь это не столь надежно, а лучше бы искать по аннотации. Да, можно, однако потребовалось бы больше времени на разбор всех файлов во всех jar. В общем, пока нам хватит первого приближения для решения этой задачи.

image

После этих действий мы получили относительно нормально работающий нативный образ Исполнителя.

Однако перед нами возникла следующая проблема: логи из Исполнителя пишутся прямо в консоль (даже уровня debug), такого быть не должно. Более того файлы для логов не создаются. То есть у нас проблемы в целом с логированием во всём проекте.

Почему может не работать логирование? Мы помним, что классы инициализируются, как правило, при построении нативного образа. А тем более при построении инициализируются статические поля классов. Для нативного образа статическое поле значит, что оно меняться во время использования не будет. Поэтому одна из возможных причин поломки логирования — это использование логгеров в статических полях классов. То есть мы открываем файлы в статическом коде и с этими файлами работаем.

Вообще иметь в статических полях классов машинозависимые значения не рекомендуется (потому что пользователь при использовании вашего приложения обнаружит, что с момента компиляции образа значения не изменились).

После всех проб мы решили вообще на время отключить логи в Исполнителе, а еще мы позволили себе инициализировать все классы для логирования в buildtime, что в теории даст нам еще больший прирост скорости запуска.



   org.graalvm.nativeimage
   native-image-maven-plugin
   ${graal-version}
   
       
           
               native-image
           
           package
       
   
   
       executor-native-image
       
           --no-server
           --no-fallback
           --allow-incomplete-classpath
           --report-unsupported-elements-at-runtime

           
           -J-Dlogback.configurationFile=${project.basedir}/config/logback-ni.xml


                                   -H:ConfigurationFileDirectories=${project.basedir}/src/main/resources/META-INF/native-image/

...
...
...

           
           --initialize-at-build-time=org.slf4j.LoggerFactory
           --initialize-at-build-time=org.slf4j.impl.StaticLoggerBinder
           --initialize-at-build-time=org.apache.log4j.Logger
           --initialize-at-build-time=org.apache.log4j.Category
           --initialize-at-build-time=org.slf4j.MDC

                      --initialize-at-build-time=ch.qos.logback.classic.joran.action.ConsolePluginAction
           --initialize-at-build-time=ch.qos.logback.core.util.Loader
           --initialize-at-build-time=ch.qos.logback.classic.Level
           --initialize-at-build-time=ch.qos.logback.core.status.InfoStatus
           --initialize-at-build-time=ch.qos.logback.classic.spi.ThrowableProxy
           --initialize-at-build-time=ch.qos.logback.core.util.StatusPrinter
           --initialize-at-build-time=ch.qos.logback.core.util.Duration
           --initialize-at-build-time=ch.qos.logback.core.status.WarnStatus
           --initialize-at-build-time=ch.qos.logback.core.status.StatusBase
           --initialize-at-build-time=ch.qos.logback.classic.Logger
              com.e1c.g5rt.executor.niboot.NativeImageExecutorBootstrap
   

Тем временем, мы приближаемся к корректно работающему Исполнителю.

Но обнаруживается, что у нас есть нестыковки с кодировкой. В Linux кодировка вывода в консоль — UTF-8, здесь всё понятно и вопросов не вызывает. В Windows же за это отвечает код страницы (посмотреть его можно выполнив команду chcp). Код страницы для разных языков свой, например, 866 для кириллицы, 437 для латиницы. А в чём у нас проблема? При выводе на консоль кириллицы отображается либо какие-то кракозябры, либо знаки вопроса.
Простейший пример для воспроизведения: github.com/oracle/graal/issues/2492

Путем проб и ошибок было установлено, что в аргументы при сборке надо добавить следующее:


-H:-AddAllCharsets
-J-Dfile.encoding=cp866

Добавили все кодировки и передали образу Java-аргумент на установку выбранной кодировки. Также прописать кодировку надо и при запуске самого образа. Если кодировки при сборке образа и при его запуске будет отличаться, то мы опять получим кракозябры.

Однако, что мы получаем для нативного образа Исполнителя в Windows, что у нас кодировка вывода всегда будет одна и та же, 866, и эта кодировка жестко прибита в образе? К сожалению, да, здесь уже как-то побороть или придумать другое решение мы не смогли. Если Вы его знаете, пожалуйста, напишите в комментариях. Если что, про chcp 65001 (UTF-8 в windows консоли) мы в курсе, попробовав собрать образ, получили, что ввод из stdin, содержащий кириллицу, трансформируется в кракозябры.

Опять-таки после этого у нас получился нативный бинарник Исполнителя ещё ближе к тому, что задумывалось. Однако мы столкнулись с ещё одной проблемой, вернее, с особенностью технологии. GraalVM Native Image не поддерживает вообще получение каких-либо переменных из окружения. Значит, получить локаль просто так не получится.
image

Замечание: вообще, получить переменные можно, только если передать проперти аргументом в бинарник специальным образом.

Это ещё не всё, из комментариев к issues на гитхабе и вообще в целом из документации мы сделали вывод, что одновременно хранить в нативном образе один бандл ресурсов с разными локалями нельзя (а при компиляции выбирается только один). Что это значит? А то, что мы не можем выбрать язык, на котором выводить сообщение пользователю в рантайме. А хотелось бы хотя бы для справки (-h) иметь два варианта: на русском и английском. Нам пришлось держать 2 бандла ресурсов и уже определять в коде, на каком языке выводить сообщения.

static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages");
static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru");

ResourceManager()
{
    ...

Для выбора языка, кстати, в итоге ввели специальный параметр в CLI. Короче говоря, у GraalVM Native Image c локализацией какие-то временные трудности.

Промежуточный итог и общий процесс сборки


Итак, мы получили относительно корректно работающий нативный образ Исполнителя. Посмотрим на общий процесс сборки:
  1. Собираем в fat-jar Исполнитель
  2. Собираем стандартную поставку Исполнителя (см пункт «Подробнее про Исполнитель»)
  3. Специальной утилитой собираем все локализованные сообщения и заполняем конфигурационные файлы
  4. После этого на стандартной поставке запускается исполнение джавы с native-image-agent
  5. Сборка нативного образа Исполнителя (как раз-таки мы получили из всех шагов выше конфигурационные файлы которые нам позволят построить правильный образ)

Получившийся образ работает быстро, а чтобы не быть голословными — перейдём к цифрам. Нативный образ можно собирать и на CI, потому что написан скрипт для прогонов и запуска сборки. Такой подход позволит держать образ актуальным и получать его сразу для Windows и для Linux (если завести два варианта машинок на CI).

Результаты


Тестирование быстродействия велось на таком оборудовании:
  • Оперативная память — 16 Гб
  • Процессор — Intel Core i5–3550 CPU 3.30 GHz x 4
  • Операционная система — Windows 10
  • Диск — SSD Samsung evo 850 EMT03B6Q, 250GB

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

Исполнение простейшего скрипта («Hello world») для нативного образа Исполнителя занимает в разы меньше времени: 0,3с для нативного и 1,9с для стандартного. Надо заметить, что нативный образ вызывался ранее, но и обычная поставка также была вызвана несколько раз до этого (т.е. JVM уже «прогрета»).

image
Рассмотрим скрипт посложнее; в этом разбираются большое количество JSON-ов и из них получаются объекты и наоборот (примерно 1000 строк), кроме того есть много сравнений строк. Первый запуск образа занимал 1,9 с, для стандартного же — 3с, последующие запуски нативного образа занимали 0.5 секунд, а в стандартном Исполнителе 2,8 с. Разница по ощущениям для пользователя довольно большая (особенно если работать в паттерне «поменял что-то — сразу запустил»).

image
Еще на языке Исполнителя был реализован алгоритм решета Эратосфена (без оптимизаций и т.п., так как нам нужно сравнить Исполнители).

method Script()
    var n = 300000000
    var prime = new Array()
    for i=0 to n + 1
        prime.Add(True)
    ;
    prime[0] = False
    prime[1] = False

    for i=2 to n + 1
        if (prime[i])
            if (i * i <= n)
                var j = i * i
                while(j <= n)
                    prime[j] = False
                    j += i
                ;
            ;
        ;
    ;
;

Ниже представлены результаты в зависимости от разных границ, до которой считаем простые:

Для N = 10^7 видно, что нативный образ выигрывает (50с против 110с) у стандартной поставки. Однако для N = 10^8 время уже сравнимое (900c и 1100c) — значит, мы где-то близко к условной границе оптимальной применимости образа. Действительно, для N = 3×10^8 нативный образ исполняет скрипт с решетом за 4200с, когда обычный — за 3300 с.
Тут мы видим JIT-компиляцию во всей её красе. А еще то, что, SubstrateVM не рассчитан на работу с большим объемом памяти.

image
Суммарный вес образа Исполнителя стал 100 мб, что на самом деле мало, потому что мы должны получить классы из стандартной поставки Java, кроме того мы должны включить в этот образ SubstrateVM и код Исполнителя и библиотек объектов (в обычном Исполнителе 40 мб). Это отличный результат для вещи, которая работает изолированно.

Таким образом, мы выполнили те задачи, которые перед собой ставили:

  1. Мы избавили пользователя от скачивания Java (Исполнитель запускается как обычное приложение для Windows/Linux)
  2. Мы уменьшили время запуска Исполнителя.

Планы


Планы развития:
  1. Добавить логирование, вроде бы у новой версии грааля должно быть с этим получше (на момент подготовки статьи мы пользовались версией 20.1, а сейчас уже доступна версия 21.1)
  2. Полная локализация. Мы будем пробовать два варианта: отдельная сборка под разные языки, либо же общий образ под разные языки сообщений.
  3. Посмотреть, как можно еще ускорить нативный образ Исполнителя.

Библиотеки, которые работают в нативном образе Исполнителя


Далее перечислены библиотеки, которые в итоге заработали в нативном образе Исполнителя, с их версиями:
  • Guice (v 5.0.1)
  • Guava (v 28.1)
  • Netty (v 4.1.43)
  • Jackson (v 2.10.4)
  • Gson (v 2.8.2)
  • Apache http client (v 4.4.1)
  • zip4j (v 2.6.4)
  • Threeten (v 1.4.0)
  • Antlr (v 3.2)
  • EMF (v 2.15.0)
  • jsch (v 0.1.55)
  • com.sun.mail.android-mail (v 1.5.6)
  • woodstox (v 5.0.3)
  • Streamsupport (v 1.7.2)
  • Java-WebSocket (v 1.3.9)
  • Библиотеки 1С для работы с кластером V8

Интересные факты и примеры


Даже зная про функционал, который работает по-другому в нативном образе, можно наткнуться на неожиданное поведение. Например, в нашем случае это произошло в коде для вывода сообщений из кода в консоль.

В первом мы подключили два бандла ресурсов с одинаковыми сообщениями, но на разном языке (почему мы так сделали — описано выше). Каждый раз, когда нам нужен определенный бандл, дергается метод getResourceBundle (), который уже выдает нам нужный файл с сообщениями.

class ResourceManager
{
	static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages");
	static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru");

	ResourceManager()
	{
	}

	method1()
	{
		//Из Locale.getDefault(), которую ранее установили на нужную, получаем нужный нам bundle (по getResourceBundle())
	}

	private ResourceBundle getResourceBundle()
	{
		if (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault()))
			return defaultResourceBundle;
		return ruResourceBundle;
	}
}

Учитывая, что локаль в рантайме у нас не поменяется, не слишком рациональный код, не правда ли (ну хотя бы работает)? Что ж, перепишем!

Получим примерно такой код. Тут мы храним нужный нам бандл в не статическом и не константном поле класса. В конструкторе же определяем нужный бандл.

class ResourceManager
{
	static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages");
	static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru");

	ResourceBundle currentBundle; //НЕ СТАТИЧЕСКОЕ И НЕ КОНСТАНТНОЕ ПОЛЕ

	ResourceManager()
	{
		//Из Locale.getDefault(), которую ранее установили на нужную, получаем нужный нам bundle (логика такая же, как и у getResourceBundle() выше)
		currentBundle = ruResourceBundle;
		if (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault()))
			currentBundle = defaultResourceBundle;
	}

	method1()
	{
		//используем currentBundle 
	}

	private ResourceBundle getResourceBundle()
	{
		f (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault()))
			return defaultResourceBundle;
		return ruResourceBundle;
	}

Однако во втором коде значение currentBundle никогда не меняется с момента компиляции, оставаясь одним из выбранных вариантов бандлов, который использовался во время сборки образа.

Ссылки


© Habrahabr.ru