Это вторая часть стенограммы доклада.
Иван Углянский ( dbg_nsk ) из JPoint 2020, посвященного соединению Java с собственным кодом.
В Последняя часть мы поговорили о традиционном способе связи — через Java Native Interface (JNI), рассмотрели его конкретные проблемы и оценили производительность.
Картина получилась удручающая, так что давайте разберемся, чем можно заменить JNI?
Если не JNI, то кто?
Если писать на JNI так больно, то возникает очевидная мысль: может, не стоит заставлять Java-программистов писать код на C или C++ — необычных и не самых дружелюбных языках? Что именно предлагается: как можно больше писать на стороне Java, и каким-то образом генерировать связь с нативным кодом автоматически, переложив эту задачу на сторонние библиотеки.За время существования Java было написано огромное количество таких библиотек, и простое их перечисление заняло бы немало времени.
Я выбрал три, которые считаю наиболее важными для экосистемы Java на сегодняшний день: самые популярные, либо быстро развивающиеся прямо сейчас, либо предлагающие уникальный функционал.
Дж.
Н.
А.
Начнем, конечно, с Дж.
Н.
А.
, что означает Java Native Access. Это довольно старая (с 2007 года) и одна из самых популярных на сегодняшний день библиотек для общения с нативным кодом.
Если вы хотите использовать его в своем проекте, вам нужно добавить эту зависимость в pom.xml:
После этого вы пишете абсолютно обычный код на C, без всяких заклинаний типа JNIEXPORT или JNICALL:<dependency> <groupId>net.java.dev.jna</groupId> <artifactId>jna</artifactId> <version>5.5.9</version> <dependency>
А затем на стороне Java вы создаете этот интерфейс MyNativeLibrary.
Он содержит не нативный, а обычный Java-метод SayHello, который принимает обычную Java-строку.
Это аналог нативного метода SayHello, который вы описали в коде C. После этого вы указываете через создание INSTANCE и вызов Native.load, в какой библиотеке искать этот метод, вызываетеsayHello и… все работает! Вы не написали ни одного нативного ключевого слова, ничего конкретного со стороны самого нативного кода (кроме разве что экспорта метода), а просто сделали максимально комфортный переход на нативный код с Java! Конечно, вызовы таких тривиальных нативных функций обычно не очень интересны.
Чаще всего нативные функции работают с нативными данными — они принимают или возвращают структуры C (иногда по значению), указатели, строки с нулевым завершением и т. д. JNA и здесь не подводит: позволяет работать с аналогами соответствующих сущностей на сторона Явы.
Вместо указателей вы можете работать с классом Pointer из JNA; есть поддержка C-подобных массивов, строк и даже vararg. Но это еще не все! Если вы хотите использовать какую-нибудь популярную библиотеку C, то вам даже не нужно ничего писать на Java, никаких новых интерфейсов или загрузок библиотек.
Для JNA их довольно много заранее подготовленные интерфейсы для работы с популярными библиотеками для разных платформ: LibC, X11, udev, Kernel32, Pdh, Psapi и многие-многие другие.
JNA на GitHub Допустим, вы хотите получить информацию из операционной системы, вызвав, например, функцию из Kernel32. ты подключаешься зависимость от заготовок , выделите структуру SYSTEMTIME в куче, передайте указатель на нее системному методу GetSystemTime и получите результат.
Чуть более сложный пример: вы хотели узнать через Psapi, сколько памяти вы выделили и сколько у вас физической памяти.
Опять же, вы выделяете в куче структуру PERFORMANCE_INFORMATION, передаете ее функции GetPerformanceInfo из библиотеки Psapi — и вы имеете низкоуровневую информацию о состоянии памяти, которую не так-то просто получить через Java.
Все это выглядит очень круто по сравнению с JNI и работает «из коробки», буквально как по волшебству.
Производительность ЮНА
Но, конечно, за любое волшебство приходится платить.В случае с ЮНА, в первую очередь, мы будем платить по результатам.
Вызовы машинного кода через JNA работают крайне медленно.
В прошлой части мы провели эксперимент: сравнили производительность вызова пустого нативного метода через JNI с вызовом того же пустого, но Java-метода.
Потом оказалось, что родной вызывается раз в 6 медленнее.
Теперь сравним обращения к пустому нативу через JNI и через JNA.
Добьемся еще падения производительности в 8,5 раз! Оказывается, вызов нативного кода через JNA уже работает в 50 раз медленнее, чем обычный вызов Java. Не будем издеваться и повторим эксперимент по возврату обратно из нативного кода в Java, где разница окажется ещё более трагичной.
Почему это? Начнем с того, что JNA основана на JNI, поэтому работать быстрее она точно не сможет. Это естественное ограничение свыше.
Далее мы еще раз проверяем нашу карту, где первый пункт назначения теперь играет особую роль:
Откуда нам взять реализацию нативных методов, если мы даже не объявляем нативные методы в Java и не пишем различные JNIEXPORT в нативе? Как виртуальная машина найдет правильные реализации в собственных библиотеках? В этом помогает сама JNA: при вызове Java-метода SayHello исполнение фактически доходит до com.sun.jna.Function.invoke(.
), где информация собирается посредством размышлений о том, какой именно метод хотели вызвать.
Кроме того, подготавливаются параметры, обрабатываются всякие сложные случаи, типа передачи по значению и так далее.
Все это занимает много времени.
Потом наконец происходит переход на нативный, но не на ваш, а на специальный нативный из библиотеки JNA, который называется ignoreVoid. На C написана огромная диспетчеризация, в зависимости от того, какие аргументы вы передаете и какой метод хотите вызвать.
И только после этого выполнение наконец доходит до метода C SayHello, чего вы и хотели изначально.
В результате мы сразу получаем просадку производительности из-за двух дополнительных уровней косвенности.
Корректность ЮНА
Другая проблема JNA заключается в том, что она ужасно захламлена Java-обертками вокруг собственных сущностей.Например, для работы с указателями в памяти создаются объекты Pointer, и многое другое, чем хотелось бы (JNA часто создает копии оберток даже тогда, когда без этого можно было бы обойтись).
Работа с обертками — это, во-первых, неприятная нагрузка на GC, да и вообще — дополнительные накладные расходы на поддержание связи между оберткой и самой нативной сущностью.
Но что еще более важно, неосторожное использование этих оберток может повредить правильность работу вашего приложения.
Позвольте мне привести вам простой пример.
В JNA есть такой замечательный класс com.sun.jna.Memory. Оно появилось исключительно потому, что создатели JNA заботились о Java-программистах.
Дело в том, что в C часто встречается следующая закономерность: вы malloc выделяете себе память с помощью определенного буфера, передаете ее функции, где с ней работают, и по возвращении из функции вызываете free для очистки памяти.
Память — это класс в JNA, предназначенный именно для решения этой ситуации, но со стороны Java. Единственное: зачем писать бесплатно на Java стороне вручную, это неудобно! У нас есть отличный механизм для этого, называемый финализацией, который позволяет вам вызывать метод финализации до того, как объект будет собран мусором.
Именно в этом методе Finalize класса Memory создатели JNA решили вызвать метод free.
Хотя появление финализации в этой истории уже может настораживать (ведь оно часто вызывает проблемы с GC ), в целом пока все выглядит вполне нормально.
Но вот реальный пример из нашей практики, демонстрирующий, к чему все это может привести.
У нас был клиент, который активно использовал JNA. Во время работы его приложения в памяти создавался тот же объект Memory. Кроме того, у клиента был собственный класс StringByReference, предназначенный для передачи строки по ссылке.
Он был написан по всем канонам JNA, также использовал финализацию для очистки ресурсов, а также содержал ссылку на тот же объект класса.
Память .
Эти два объекта выглядели так:
В классе Память есть поле адрес — необработанный адрес соответствующей встроенной памяти.
В финализаторе вызывается родная free для очистки этой памяти, а затем поле адрес аннулируется.
И в Finalize() пользовательского класса проверялось: если поле адрес еще не нуль, тогда позвоните GlobalFree из указателя, который находится по этому адресу .
И вот здесь начинается веселье! Чей финализатор будет вызван первым? В спецификации об этом ничего не сказано — порядок вызова финализаторов не определен.
Если финализатор памяти срабатывает первым, то мы очищаем память, адрес которой записан.
адрес .
Затем переходим к финализатору Строка по ссылке , где мы ничего не делаем, потому что V адрес уже равен нулю.
Результат — утечка родной памяти, адрес которой был записан в ячейку, уже освобожденную в первом финализаторе.
Но это не так уж и плохо! Теперь представьте, что финализатор срабатывает первым для Строка по ссылке , и в то же время в адрес есть мусор (и это вполне может быть так, поскольку JNA любит засорять неправильно спроектированными обертками вокруг нативных сущностей, включая Memory).
Мы вызовем GlobalFree из какой-то битой памяти и вызовем спорадический сбой, причем проявиться он может не сразу, а через 10 минут работы нашего приложения.
Можно, конечно, поспорить, кто здесь виноват — JNA или пользователь, написавший такой класс.
Но когда к нам приходили поддержать люди, у которых родные не работали, я всегда в первую очередь спрашивал: «У вас случайно нет ЮНАЭ» И чаще всего они удивлялись, говорили «Да, именно ЮНА» и спрашивали, откуда я это знаю.
К сожалению, подобных проблем в JNA немало.
И вы часто можете столкнуться со спорадической и трудной для отладки ошибкой, когда просто пытаетесь вызвать собственный метод с минимальными усилиями.
Какой подход выбрать?
Итак, теперь у нас уже есть два подхода: мы можем вызывать аборигены через JNI или можем использовать JNA. Чтобы понять, какой подход лучше, предлагаю ввести сравнительную таблицу по нескольким критериям.Во-первых, насколько удобно использовать тот или иной подход. Здесь ЮНА, конечно, выигрывает. Это невероятно удобно, особенно если вам нужно взаимодействовать с популярной библиотекой, для которой уже есть шаблоны.
Все работает из коробки как по волшебству.
Второе — производительность.
Здесь JNA существенно проигрывает, JNI на порядок быстрее.
Если для вас важна производительность, вам даже не придется смотреть на JNA. В-третьих, надежность.
Здесь обе технологии терпят неудачу.
В JNI легко ошибиться руками (см.
первая часть отчета ), а JNA ненадежен, поскольку из-за внутреннего устройства самого JNA может возникнуть высокоуровневая и трудно прогнозируемая ошибка.
Следующий критерий — заготовки для библиотек, насколько легко взять готовую библиотеку и пользоваться ею.
По умолчанию в JNI такого нет; вам нужно написать немного кода на C, а уже потом вызывать методы из библиотеки.
В JNA имеется множество шаблонов, что значительно облегчает взаимодействие с нативным кодом.
Дальше документация.
JNI имеет превосходную и подробную спецификацию, в которой указано, что можно и что нельзя делать.
Но у JNA и здесь все хорошо: потому что.
Поскольку это уже довольно старый фреймворк, для него написано много качественной документации.
И последний момент — работает ли он с C++.
Это тоже может быть важно, ведь огромное количество библиотек написано не на чистом C, а на плюсах.
JNI plus не работает «из коробки».
Конечно, всегда можно написать extern «C», определить бинарный интерфейс и взаимодействовать с ним через JNI, но это не сразу, нужно проделать дополнительную работу.
То же самое относится и к ЮНА.
Другой вариант — Java Native Runtime.
Давайте двигаться дальше! Наша следующая остановка JNR или собственная среда выполнения Java. Чтобы взаимодействовать через него с нативным кодом, вам необходимо добавить эту зависимость в ваш pom.xml: <dependency>
<groupId>com.github.jnr</groupId>
<artifactId>jnr-ffi</artifactId>
<version>2.1.14</version>
<dependency>
После этого вы снова пишете обычный код C без каких-либо заклинаний, а затем на стороне Java пишете немного другие интерфейсы.
Вы пишете методsayHello без нативного модификатора, указываете немного по-другому, где взять нативную библиотеку и.
вы снова быстро и безболезненно оказываетесь в нативном коде!
Вы, наверное, сейчас спрашиваете себя: «Почему он пишет об еще одном клоне ЮНА? Мы только что прочитали об этом несколько страниц, так что теперь собираемся разбирать все подобные фреймворкиЭ» Действительно, внешне JNR очень похожа на JNA, но имеет ряд кардинальных преимуществ.
И, конечно же, главный из них – производительность.
Напомню, что такой позорный результат бенчмарка мы получили от JNA.
А если провести абсолютно аналогичный эксперимент с вызовом пустого нативного метода через JNR, то моментально получим ускорение примерно в 6 раз.
Это по-прежнему не соответствует вызову машинного кода через старый добрый JNI, но намного быстрее, чем JNA. Более того, у JNR есть интересная особенность: к функции, соответствующей нативному методу, можно добавить аннотацию @IngoreError:
Это значит, что нет смысла после вызова натива выяснять, произошла ли какая-то ошибка, анализировать и обрабатывать коды ошибок.
Вместо этого мы просто игнорируем их, даже если что-то идет не так.
Часто это вполне приемлемое поведение.
Размещаем аннотацию и получаем.
112 попугаев.
Почти то же самое, что и вызов нативного кода через JNI!
И это очень здорово, ведь перед нами до сих пор остро стоит вопрос, где взять аборигенов?
Как всегда, JNR не может быть быстрее JNI, потому что внутренне он на нем основан — тут полная аналогия с JNA. А вот как именно искать нативные методы — в JNR этот вопрос решен по-другому.
На стороне Java генерируется сверхлегкая оболочка байт-кода, которая чаще всего встраивается и которая, в свою очередь, вызывает определенный собственный метод. Но на этот раз код для этого родной Метод также генерируется на лету!
То есть буквально во время выполнения будут генерироваться ассемблерные инструкции, реализующие поиск вашего родного метода и переход к нему.
Сгенерированный код максимально близок к оптимальному.
Например, если вы вызываете пустой метод без параметров и возвращаемого значения, тело собственной оболочки будет состоять из одной инструкции перехода, которая мгновенно приведет вас к правильному собственному методу.
Таким образом, у нас по-прежнему есть два дополнительных уровня косвенности, но они реализованы настолько эффективно, что снижения производительности почти нет. А вообще JNR классная штука, в проекте используется ДжРубин , о нем часто говорят и он активно развивается.
Если ты посмотришь Репозиторий JNR , то получается, что буквально последний коммит был вчера или сегодня.
И это здорово — если в JNR обнаружится проблема, ее очень быстро устранят. Недостатком является то, что ассемблерные оболочки генерируются не на всех платформах; например, в Windows вы не получите такого мощного ускорения собственных вызовов по сравнению с JNA. Да и в целом библиотека очень posix-ориентирована, одни и те же обертки присутствуют только у популярных posix-библиотек, остальные придётся писать вручную.
Наконец, документации почти нет. Чтобы понять, как это использовать в нетривиальных сценариях, нужно зайти в репозиторий JRuby и посмотреть, как именно люди используют JNR.
Мы заполняем нашу таблицу соответствующим образом: JNR получает твердую пятерку за удобство; Производительность практически на уровне родной, что очень впечатляет. По поводу надежности: Не могу принять однозначного решения.
JNR не был уличен в чем-то плохом, и ничего криминального в его кодексе я тоже не увидел.
Но, возможно, это связано с тем, что он менее популярен среди пользователей, чем JNA. По мере того, как все больше людей начнут его использовать, могут появиться новые проблемы.
Шаблоны для библиотек есть, но они слабые, документация плохая, а с C++ JNR из коробки тоже не работает.
Что работает с профессионалами?
А библиотека JavaCPP прекрасно работает с собственным кодом, написанным на C++.
Если вы хотите использовать его в своем проекте, добавьте в pom.xml следующую зависимость: <dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>1.5.3</version>
<dependency>
И смело погружайтесь в чистый позитивный код. Для примера возьмем cpp-файл, в котором написан абсолютно обычный C++-код: встроенные функции, std::string, стандартные потоки вывода.
Опять же, здесь нет никаких специальных макросов или команд для взаимодействия с Java, а также никакого вмешательства в собственный код.
На стороне Java создайте еще один класс и укажите, что он будет сопоставлен с файлом MyCPPLib.cpp. На этот раз вы по-прежнему пишете нативный методsayHelloFromCPP, но в качестве аргумента, используя аннотацию, указываете, что речь идет о плюсе std::string. Вызовите этот метод — и вы совершенно свободно попадете в позитивный код из Java! Как обычно, нас не очень интересует вызов тривиальных методов: для реальной работы мы хотим вызывать сложный плюс код, наполненный различными особенностями, специфичными для этого языка.
И на помощь приходит JavaCPP: эта библиотека позволяет работать с плюсовыми классами, перегрузкой операторов, деструкторами и конструкторами, шаблонами и даже умными указателями на стороне Java. Конечно, невозможно перетащить все огромное разнообразие возможностей из C++ в Java, но JavaCPP очень старается сделать это по максимуму.
Более того, JavaCPP фактически предлагает впечатляющее число подготовленные оболочки для различных библиотек C++.
Если чего-то не хватает, то новую обертку легко сгенерировать самостоятельно, здесь Есть подробная инструкция, как это сделать.
Наконец, создавать проекты с помощью JavaCPP — одно удовольствие.
Вы просто добавляете соответствующие таргеты в свой maven или gradle конфиг, после чего все, включая нативную часть, автоматически собирается под нужную вам платформу.
Больше не нужно гуглить, как собрать нативную библиотеку, чтобы она была совместима с JNI — все работает «из коробки».
Где подвох?
Конечно, вас мучает вопрос, какова производительность, особенно по сравнению с другими фреймворками? Итак, JavaCPP снова основан на JNI, поэтому прыгнуть быстрее нашей верхней границы точно не получится.Но если посмотреть, как здесь реализовано соединение с нативным кодом, то окажется, что все не так уж и страшно.
Действительно, мы все еще объявили собственный метод на стороне Java, поэтому все, что остается сделать JavaCPP, — это сгенерировать небольшой сценарий C с JNIEXPORT, JNICALL и всем остальным, который будет напрямую вызывать ваш плюс-код.
В результате мы получаем всего лишь один дополнительный уровень косвенности, и
Накладные расходы на вызов пустого метода C++ через JavaCPP обычно сравнимы с вызовом обычного собственного метода через JNI.
Но не теряйте голову! Стоит нам хоть немного усложнить вызываемый метод или даже вызвать еще пустой, но уже экземплярный метод класса C++, как мы сразу получаем серьезный удар по производительности.
И, к сожалению, это тенденция — чем больше сущностей и функций C++ мы перетащим в Java, тем медленнее все это будет работать.
Кроме того, не все положительные особенности можно однозначно представить в Java. Что, например, нам делать с деструкторами и умными указателями? В Java нет подобной функциональности, поэтому вам придется как-то ее смоделировать.
К счастью, создатели JavaCPP делают это не с помощью финализаторов, а с помощью более надежных фантомных ссылок и ReferenceQueue. Однако это также вносит недетерминированность в выполнение вашего кода и фактически сильно меняет его семантику по сравнению с C++.
Чем больше похожих функций вы используете на стороне Java, тем сложнее будет предсказать поведение вашей программы и тем больше вероятность, что что-то пойдет не так (привет JNA с ее спорадическими сбоями).
Краткое описание JavaCPP
Подводя итог всему, что касается JavaCPP, обновляем нашу таблицу: за простоту использования JavaCPP получает твёрдую пятёрку; производительность и надежность в порядке, но оба ухудшаются при использовании дополнительных функций; подготовка библиотеки выше всяких похвал.
Документация хорошая: есть вики, гайды, пользоваться одно удовольствие.
И, конечно же, JavaCPP прекрасно работает с C++.
Именно после анализа этой библиотеки становится понятно, почему я поставил за это минусы всем остальным претендентам.
Что будет дальше
Завершая свой рассказ, предлагаю вам заглянуть в ближайшее будущее и разобраться, как предлагается решить проблемы соединения Java с нативным кодом.
Проект Панама
Прежде всего, поговорим о знаменитом проекте «Панама».Это мегапроект на OpenJDK, который находится в разработке уже 6 лет. Его основная цель — добиться простоты использования кода C/C++ и библиотек Java, чтобы для Java-программиста это было так же удобно, как и написание обычного Java-кода.
В основе Panama лежит идея, которую мы уже хорошо знаем: давайте не будем заставлять людей писать C/C++, а вместо этого обогатим язык Java, чтобы всю работу, связанную с собственным кодом, можно было выразить на Java (и за счет сложного взаимодействия с собственным кодом).
остается самим вызовом натива).
При этом Панамский проект состоит из нескольких крупных частей: Первый — это API доступа к памяти.
Он уже модуль инкубатора включен в Java 15, поэтому вы можете попробовать его прямо сейчас и оставить свой отзыв.
Это новый API для работы с собственной памятью и потенциальная замена ByteBuffer. Но нас больше интересует вторая часть - новый ИФУ (Foreign Function Interface), предлагающий совершенно альтернативную технологию вызова собственных методов.
Для этого предлагается использовать новую технологию Native Method Handles. Если вам нужно работать с чем-то низкоуровневым, например с указателем, вы используете MemorySegment из того же API доступа к памяти, который уже существует в Java. И, наконец, если у вас есть какая-то готовая библиотека или просто C-код, то вы можете использовать специальный инструмент jextract для генерации Java-привязки, через которую потом можно вызывать нативные методы.
Это выглядит примерно так.
В этом простом примере есть два собственных метода и файл .
h с их заголовками.
Запускаем jextract и получаем целый каталог файлов .
class, которые добавляем в наш проект. Самый интересный для нас сейчас файл — файл panamatest_h.class. Вот некоторый код .
java, который может соответствовать этому файлу: panamatest_h.java
package org.sample;
public final class panamatest_h {
public static MethodHandle test$MH() {
return panamatest_h$constants.test$MH();
}
public static void test () {
try {
panamatest_h$constants.test$MH().
invokeExact();
} catch (Throwable ex) {
throw new AssertionError(ex);
}
}
…
}
Есть метод, который вернет вам MethodHandle, соответствующий нативному тесту, и даже Java-метод-тест, названный точно так же, который, по сути, вызывает нативный через этот MethodHandle. Давай попробуем! Нас в первую очередь интересует производительность, потому что Панама наконец-то не ограничена JNI, т.к.
это совершенно новая технология, созданная с нуля.
Это означает, что цифры могут нас приятно удивить.
Проводим наш классический эксперимент: вызываем пустой натив из Java через JNI и получаем 119 попугаев.
Повторяем эксперимент, но уже на сборке JDK с Panama, где можно использовать Native Method Handle и.
получаем уже 124 попугая!
Это может показаться не таким уж большим достижением, но подумайте об этом: нам никогда раньше не удавалось победить JNI, ни в какой конфигурации.
И вот уже принципиально новая технология дает прирост в 4%.
Более того, буквально в этот момент продолжаются работы по разгону нативных звонков в Панаме, поэтому 4% сегодня могут легко превратиться в 20% завтра.
Следите за обновлениями! И как будто этого недостаточно, в Панаме обсуждают принципиально новую функцию: так называемые небезопасные местные звонки.
В отличие от обычных, вызовы таких нативных методов не работают параллельно со сборкой мусора, а, наоборот, блокируют ее.
На нашей картинке из последний пост В потоках выполнения во время сборки мусора появляются новые актеры: потоки, которые выполняют собственный код, но не пытаются синхронизироваться со сборщиком мусора.
Они ему не говорят, что перешли на нативный код, не ставят точку безопасности на выходе или что-то в этом роде.
Теги: #программирование #C++ #java #gc #gc #jpoint #Иван Углянский #Иван Углянский #родной код