Перейти к основному содержимому

Использование JNA для доступа к родным динамическим библиотекам

· 9 мин. чтения

Задача: Медиана двух отсортированных массивов

Даны два отсортированных массива размерами n и m. Найдите медиану слияния этих двух массивов.
Временная сложность решения должна быть O(log(m + n)) ...

ANDROMEDA

1. Обзор

В этом руководстве мы увидим, как использовать библиотеку Java Native Access (сокращенно JNA) для доступа к собственным библиотекам без написания кода JNI (Java Native Interface) .

2. Почему ЮНА?

В течение многих лет Java и другие языки на основе JVM в значительной степени соответствовали своему девизу «написал один раз, работает везде». Однако иногда нам нужно использовать нативный код для реализации некоторых функций :

  • Повторное использование устаревшего кода, написанного на C/C++ или любом другом языке, позволяющем создавать собственный код.
  • Доступ к специфичным для системы функциям, недоступным в стандартной среде выполнения Java
  • Оптимизация скорости и/или использования памяти для определенных разделов данного приложения.

Первоначально такое требование означало, что нам придется прибегнуть к JNI — Java Native Interface. Хотя этот подход эффективен, он имеет свои недостатки, и его обычно избегают из-за нескольких проблем:

  • Требует от разработчиков написания «связующего кода» на C/C++ для соединения Java и нативного кода.
  • Требуется полная цепочка инструментов для компиляции и компоновки, доступная для каждой целевой системы.
  • Маршалинг и демаршаллинг значений в JVM и обратно — утомительная и подверженная ошибкам задача.
  • Проблемы с юридическими вопросами и поддержкой при смешивании Java и нативных библиотек

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

Конечно, есть некоторые компромиссы:

  • Мы не можем напрямую использовать статические библиотеки
  • Медленнее по сравнению с написанным вручную кодом JNI

Однако для большинства приложений преимущества простоты JNA намного перевешивают эти недостатки. Таким образом, будет справедливо сказать, что, если у нас нет очень специфических требований, JNA сегодня, вероятно, является лучшим доступным выбором для доступа к собственному коду из Java — или, кстати, любого другого языка на основе JVM.

3. Настройка проекта ЮНА

Первое, что нам нужно сделать, чтобы использовать JNA, — это добавить его зависимости в pom.xml нашего проекта :

<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.6.0</version>
</dependency>

Последнюю версию jna-platform можно загрузить с Maven Central.

4. Использование ЮНА

Использование JNA представляет собой двухэтапный процесс:

  • Во-первых, мы создаем интерфейс Java, который расширяет интерфейс библиотеки JNA для описания методов и типов, используемых при вызове целевого собственного кода.
  • Затем мы передаем этот интерфейс в JNA, который возвращает конкретную реализацию этого интерфейса, которую мы используем для вызова собственных методов.

4.1. Вызов методов из стандартной библиотеки C

В нашем первом примере давайте воспользуемся JNA для вызова функции cosh из стандартной библиотеки C, доступной в большинстве систем. Этот метод принимает двойной аргумент и вычисляет его гиперболический косинус . Программа AC может использовать эту функцию, просто включив заголовочный файл <math.h> :

#include <math.h>
#include <stdio.h>
int main(int argc, char** argv) {
double v = cosh(0.0);
printf("Result: %f\n", v);
}

Давайте создадим интерфейс Java, необходимый для вызова этого метода:

public interface CMath extends Library { 
double cosh(double value);
}

Затем мы используем класс JNA Native для создания конкретной реализации этого интерфейса, чтобы мы могли вызывать наш API:

CMath lib = Native.load(Platform.isWindows()?"msvcrt":"c", CMath.class);
double result = lib.cosh(0);

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

Теперь имена динамических библиотек обычно зависят от системы, и стандартная библиотека C не является исключением: libc.so в большинстве систем на базе Linux и msvcrt.dll в Windows. Вот почему мы использовали вспомогательный класс Platform , включенный в JNA, чтобы проверить, на какой платформе мы работаем, и выбрать правильное имя библиотеки.

Обратите внимание, что нам не нужно добавлять расширения .so или .dll , поскольку они подразумеваются. Кроме того, для систем на базе Linux нам не нужно указывать префикс «lib», который является стандартным для разделяемых библиотек.

Поскольку динамические библиотеки ведут себя как синглтоны с точки зрения Java, обычной практикой является объявление поля INSTANCE как части объявления интерфейса:

public interface CMath extends Library {
CMath INSTANCE = Native.load(Platform.isWindows() ? "msvcrt" : "c", CMath.class);
double cosh(double value);
}

4.2. Сопоставление основных типов

В нашем первоначальном примере вызываемый метод использовал только примитивные типы как в качестве аргумента, так и в качестве возвращаемого значения. JNA обрабатывает эти случаи автоматически, обычно используя их естественные аналоги Java при отображении из типов C:

  • символ => байт
  • короткий => короткий
  • wchar_t => символ
  • интервал => интервал
  • длинный => com.sun.jna.NativeLong
  • долго долго => долго
  • плавать => плавать
  • двойной => двойной
  • символ * => строка

Отображение, которое может показаться странным, — это то, которое используется для нативного длинного типа. Это связано с тем, что в C/C++ тип long может представлять 32- или 64-разрядное значение, в зависимости от того, используем ли мы 32- или 64-разрядную систему.

Чтобы решить эту проблему, JNA предоставляет тип NativeLong , который использует правильный тип в зависимости от архитектуры системы.

4.3. Структуры и союзы

Другой распространенный сценарий связан с API-интерфейсами собственного кода, которые ожидают указатель на некоторую структуру или тип объединения . При создании интерфейса Java для доступа к нему соответствующий аргумент или возвращаемое значение должны быть типом Java, расширяющим Structure или Union соответственно.

Например, учитывая эту структуру C:

struct foo_t {
int field1;
int field2;
char *field3;
};

Его одноранговый класс Java будет:

@FieldOrder({"field1","field2","field3"})
public class FooType extends Structure {
int field1;
int field2;
String field3;
};

JNA требует аннотацию @FieldOrder , чтобы правильно сериализовать данные в буфер памяти, прежде чем использовать их в качестве аргумента для целевого метода.

В качестве альтернативы мы можем переопределить метод getFieldOrder() для того же эффекта. При нацеливании на одну архитектуру/платформу первый метод, как правило, достаточно хорош. Мы можем использовать последний для решения проблем выравнивания на разных платформах, которые иногда требуют добавления некоторых дополнительных полей заполнения.

Союзы работают аналогично, за исключением нескольких моментов:

  • Нет необходимости использовать аннотацию @FieldOrder или реализовывать getFieldOrder().
  • Мы должны вызвать setType() перед вызовом нативного метода.

Давайте посмотрим, как это сделать на простом примере:

public class MyUnion extends Union {
public String foo;
public double bar;
};

Теперь давайте используем MyUnion с гипотетической библиотекой:

MyUnion u = new MyUnion();
u.foo = "test";
u.setType(String.class);
lib.some_method(u);

Если бы и foo , и bar были одного типа, вместо этого нам пришлось бы использовать имя поля:

u.foo = "test";
u.setType("foo");
lib.some_method(u);

4.4. Использование указателей

JNA предлагает абстракцию Pointer , которая помогает работать с API, объявленными с нетипизированным указателем — обычно это void * . Этот класс предлагает методы, которые позволяют читать и записывать доступ к базовому собственному буферу памяти, что имеет очевидные риски.

Прежде чем начать использовать этот класс, мы должны быть уверены, что четко понимаем, кто «владеет» памятью, на которую ссылаются, в каждый момент времени. В противном случае, скорее всего, возникнут трудно отлаживаемые ошибки, связанные с утечками памяти и/или недопустимым доступом.

Предполагая, что мы знаем, что делаем (как всегда), давайте посмотрим, как мы можем использовать хорошо известные функции malloc() и free() с JNA, используемые для выделения и освобождения буфера памяти. Во-первых, давайте снова создадим наш интерфейс-оболочку:

public interface StdC extends Library {
StdC INSTANCE = // ... instance creation omitted
Pointer malloc(long n);
void free(Pointer p);
}

Теперь давайте воспользуемся им для выделения буфера и поиграем с ним:

StdC lib = StdC.INSTANCE;
Pointer p = lib.malloc(1024);
p.setMemory(0l, 1024l, (byte) 0);
lib.free(p);

Метод setMemory() просто заполняет базовый буфер постоянным значением байта (в данном случае нулем). Обратите внимание, что экземпляр Pointer понятия не имеет, на что он указывает, не говоря уже о его размере. Это означает, что мы можем довольно легко испортить нашу кучу, используя ее методы.

Позже мы увидим, как мы можем смягчить такие ошибки, используя функцию защиты от сбоев JNA.

4.5. Обработка ошибок

Старые версии стандартной библиотеки C использовали глобальную переменную errno для хранения причины сбоя конкретного вызова. Например, вот как типичный вызов open() будет использовать эту глобальную переменную в C:

int fd = open("some path", O_RDONLY);
if (fd < 0) {
printf("Open failed: errno=%d\n", errno);
exit(1);
}

Конечно, в современных многопоточных программах этот код работать не будет, верно? Что ж, благодаря препроцессору C разработчики все еще могут писать такой код, и он будет отлично работать. Оказывается, в настоящее время errno — это макрос, который расширяется до вызова функции:

// ... excerpt from bits/errno.h on Linux
#define errno (*__errno_location ())

// ... excerpt from <errno.h> from Visual Studio
#define errno (*_errno())

Теперь этот подход отлично работает при компиляции исходного кода, но при использовании JNA такого не происходит. Мы могли бы объявить расширенную функцию в нашем интерфейсе оболочки и вызвать ее явно, но JNA предлагает лучшую альтернативу: LastErrorException .

Любой метод, объявленный в интерфейсах-оболочках с бросками LastErrorException , будет автоматически включать проверку на наличие ошибки после нативного вызова. Если он сообщит об ошибке, JNA выдаст исключение LastErrorException , которое включает исходный код ошибки.

Давайте добавим пару методов в интерфейс оболочки StdC , который мы использовали ранее, чтобы показать эту функцию в действии:

public interface StdC extends Library {
// ... other methods omitted
int open(String path, int flags) throws LastErrorException;
int close(int fd) throws LastErrorException;
}

Теперь мы можем использовать open() в предложении try/catch:

StdC lib = StdC.INSTANCE;
int fd = 0;
try {
fd = lib.open("/some/path",0);
// ... use fd
}
catch (LastErrorException err) {
// ... error handling
}
finally {
if (fd > 0) {
lib.close(fd);
}
}

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

4.6. Обработка нарушений прав доступа

Как упоминалось ранее, JNA не защищает нас от неправильного использования данного API, особенно при работе с буферами памяти, передаваемыми взад и вперед нативному коду . В обычных ситуациях такие ошибки приводят к нарушению прав доступа и завершают работу JVM.

JNA в некоторой степени поддерживает метод, который позволяет коду Java обрабатывать ошибки нарушения прав доступа. Есть два способа активировать его:

  • Установка для системного свойства jna.protected значения true
  • Вызов Native.setProtected(true)

Как только мы активируем этот защищенный режим, JNA будет перехватывать ошибки нарушения доступа, которые обычно приводят к сбою, и выдает исключение java.lang.Error . Мы можем убедиться, что это работает, используя Pointer , инициализированный с недопустимым адресом, и пытаясь записать в него некоторые данные:

Native.setProtected(true);
Pointer p = new Pointer(0l);
try {
p.setMemory(0, 100*1024, (byte) 0);
}
catch (Error err) {
// ... error handling omitted
}

Однако, как указано в документации, эту функцию следует использовать только в целях отладки/разработки.

5. Вывод

В этой статье мы показали, как использовать JNA для более легкого доступа к собственному коду по сравнению с JNI.

Как обычно, весь код доступен на GitHub .