Советы по JNI

JNI – это Java Native Interface. Он определяет способ взаимодействия байт-кода, который Android компилирует из управляемого кода (написанного на языках программирования Java или Kotlin), с нативным кодом (написанным на C/C++). JNI не зависит от поставщика, поддерживает загрузку кода из динамических общих библиотек и, хотя иногда он громоздок, достаточно эффективен.

Примечание. Поскольку Android компилирует Kotlin в байт-код, совместимый с ART, аналогично языку программирования Java, вы можете применять рекомендации на этой странице как к Kotlin, так и к Java с точки зрения архитектуры JNI и связанных с ней затрат. Подробнее о Kotlin и Android…

Если вы не знакомы с JNI, прочитайте спецификацию Java Native Interface, чтобы понять, как работает JNI и какие функции доступны. Некоторые аспекты интерфейса не сразу очевидны при первом прочтении, поэтому следующие несколько разделов могут оказаться полезными.

Чтобы посмотреть глобальные ссылки JNI и узнать, где они создаются и удаляются, используйте представление Куча JNI в Профилировщике памяти в Android Studio 3.2 и более поздних версиях.

Общие рекомендации

Постарайтесь минимизировать объем слоя JNI. Здесь нужно учитывать несколько аспектов. При разработке решения JNI старайтесь следовать приведенным ниже рекомендациям (перечислены в порядке важности, начиная с наиболее важной):

  • Минимизируйте маршалинг ресурсов на уровне JNI. Маршалинг на уровне JNI требует значительных ресурсов. Постарайтесь разработать интерфейс, который минимизирует объем данных, которые вам нужно маршалировать, и частоту, с которой вы должны маршалировать данные.
  • По возможности избегайте асинхронного взаимодействия между кодом, написанным на управляемом языке программирования, и кодом на C++. Это упростит обслуживание интерфейса JNI. Обычно асинхронные обновления интерфейса можно упростить, если выполнять их на том же языке, на котором написан интерфейс. Например, вместо того чтобы вызывать функцию C++ из потока UI в коде Java через JNI, лучше выполнить обратный вызов между двумя потоками на языке программирования Java. При этом один из потоков будет выполнять блокирующий вызов C++, а затем уведомлять поток UI о его завершении.
  • Сведите к минимуму количество потоков, которые должны взаимодействовать с JNI. Если вам нужно использовать пулы потоков и в Java, и в C++, постарайтесь организовать связь JNI между владельцами пулов, а не между отдельными рабочими потоками.
  • Чтобы упростить рефакторинг, храните код интерфейса в небольшом количестве легко идентифицируемых исходных файлов C++ и Java. При необходимости используйте библиотеку автоматической генерации JNI.

JavaVM и JNIEnv

JNI определяет две основные структуры данных: JavaVM и JNIEnv. Оба они по сути являются указателями на указатели на таблицы функций. (В версии C++ это классы с указателем на таблицу функций и функцией-членом для каждой функции JNI, которая косвенно проходит через таблицу.) JavaVM предоставляет функции интерфейса вызова, которые позволяют создавать и удалять JavaVM. Теоретически в одном процессе может быть несколько виртуальных машин Java, но Android позволяет использовать только одну.

JNIEnv предоставляет большинство функций JNI. Все нативные функции получают JNIEnv в качестве первого аргумента, за исключением методов @CriticalNative. Подробнее об ускоренных нативных вызовах…

JNIEnv используется для локального хранилища потока. Поэтому нельзя использовать один JNIEnv в разных потоках. Если у фрагмента кода нет другого способа получить JNIEnv, используйте JavaVM и GetEnv, чтобы найти JNIEnv потока. (если он есть; см. AttachCurrentThread ниже).

Объявления C для JNIEnv и JavaVM отличаются от объявлений C++. Файл "jni.h" содержит разные определения typedef в зависимости от того, включен ли он в C или C++. По этой причине не рекомендуется включать аргументы JNIEnv в заголовочные файлы, используемые обоими языками. (Другими словами, если вашему заголовочному файлу требуется #ifdef __cplusplus, вам может потребоваться выполнить дополнительную работу, если что-либо в этом заголовке ссылается на JNIEnv.)

Потоки

Все потоки являются потоками Linux, запланированными ядром. Обычно они запускаются из управляемого кода (с помощью Thread.start()), но их также можно создать в другом месте, а затем прикрепить к JavaVM. Например, поток, запущенный с помощью pthread_create() или std::thread, можно подключить с помощью функций AttachCurrentThread() или AttachCurrentThreadAsDaemon(). Пока поток не подключен, у него нет JNIEnv и он не может выполнять вызовы JNI.

Обычно лучше использовать Thread.start() для создания любого потока, который должен вызывать код Java. Это позволит обеспечить достаточный объем памяти в стеке, а также использовать правильный ThreadGroup и тот же ClassLoader, что и в коде Java. Кроме того, задать имя потока для отладки в Java проще, чем в нативном коде (см. pthread_setname_np(), если у вас есть pthread_t или thread_t, и std::thread::native_handle(), если у вас есть std::thread и вы хотите получить pthread_t).

При подключении потока, созданного нативно, создается объект java.lang.Thread, который добавляется в основной поток ThreadGroup. Это делает его видимым для отладчика. Вызов AttachCurrentThread() для уже прикрепленного потока не выполняет никаких действий.

Android не приостанавливает потоки, выполняющие нативный код. Если выполняется сборка мусора или отладчик отправил запрос на приостановку, Android приостановит поток при следующем вызове JNI.

Потоки, подключенные через JNI, должны вызывать DetachCurrentThread() перед выходом. Если это неудобно, в Android 2.0 (Eclair) и более поздних версиях можно использовать pthread_key_create(), чтобы определить функцию деструктора, которая будет вызываться перед выходом из потока, и вызвать из нее DetachCurrentThread(). (Используйте этот ключ с pthread_setspecific(), чтобы сохранить JNIEnv в локальном хранилище потока. Таким образом он будет передан в деструктор в качестве аргумента.)

jclass, jmethodID и jfieldID

Чтобы получить доступ к полю объекта из нативного кода, выполните следующие действия:

  • Получите ссылку на объект класса с помощью FindClass.
  • Получить идентификатор поля с GetFieldID
  • Замените содержимое поля на подходящее значение, например: GetIntField

Аналогично, чтобы вызвать метод, сначала нужно получить ссылку на объект класса, а затем идентификатор метода. Идентификаторы часто являются просто указателями на внутренние структуры данных среды выполнения. Для их поиска может потребоваться несколько сравнений строк, но после того, как вы их получите, фактический вызов для получения поля или вызова метода будет очень быстрым.

Если производительность важна, рекомендуется один раз найти значения и кэшировать результаты в нативном коде. Поскольку на один процесс может приходиться только одна виртуальная машина Java, эти данные целесообразно хранить в статической локальной структуре.

Ссылки на классы, идентификаторы полей и методов гарантированно действительны до выгрузки класса. Классы выгружаются, только если все классы, связанные с ClassLoader, могут быть удалены сборщиком мусора. Это редкая ситуация, но в Android она возможна. Обратите внимание, что jclass – это ссылка на класс, которую необходимо защитить с помощью вызова NewGlobalRef (см. следующий раздел).

Если вы хотите кешировать идентификаторы при загрузке класса и автоматически повторно кешировать их, если класс когда-либо выгружается и перезагружается, правильный способ инициализации идентификаторов – добавить в соответствующий класс фрагмент кода, который выглядит следующим образом:

Kotlin

companion object {
    /*
     * We use a static class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private external fun nativeInit()

    init {
        nativeInit()
    }
}

Java

    /*
     * We use a class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private static native void nativeInit();

    static {
        nativeInit();
    }

Создайте в коде C/C++ метод nativeClassInit, который будет выполнять поиск идентификаторов. Код будет выполнен один раз при инициализации класса. Если класс будет выгружен, а затем загружен снова, то и блок будет выполнен повторно.

Локальные и глобальные ссылки

Каждый аргумент, переданный нативному методу, и почти каждый объект, возвращенный функцией JNI, является локальной ссылкой. Это означает, что она действительна в течение времени выполнения текущего нативного метода в текущем потоке. Даже если сам объект продолжает существовать после возврата нативного метода, ссылка недействительна.

Это относится ко всем подклассам jobject, включая jclass, jstring и jarray. (Среда выполнения предупредит вас о большинстве неправильных использований ссылок, если включены расширенные проверки JNI.)

Получить нелокальные ссылки можно только с помощью функций NewGlobalRef и NewWeakGlobalRef.

Если вы хотите сохранить ссылку на более длительный период, используйте глобальную ссылку. Функция NewGlobalRef принимает локальную ссылку в качестве аргумента и возвращает глобальную. Глобальная ссылка гарантированно будет действительна, пока вы не вызовете DeleteGlobalRef.

Этот шаблон обычно используется при кешировании jclass, возвращенного из FindClass, например:

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

Все методы JNI принимают в качестве аргументов как локальные, так и глобальные ссылки. Ссылки на один и тот же объект могут иметь разные значения. Например, значения, возвращаемые последовательными вызовами NewGlobalRef для одного и того же объекта, могут различаться. Чтобы проверить, ссылаются ли две ссылки на один и тот же объект, используйте функцию IsSameObject. Никогда не сравнивайте ссылки с == в нативном коде.

Одно из следствий этого заключается в том, что вы не должны предполагать, что ссылки на объекты являются постоянными или уникальными в нативном коде. Значение, представляющее объект, может отличаться от одного вызова метода к другому. Также возможно, что два разных объекта будут иметь одинаковое значение при последовательных вызовах. Не используйте значения jobject в качестве ключей.

Программисты не должны выделять слишком много локальных ссылок. На практике это означает, что если вы создаете большое количество локальных ссылок, например при прохождении массива объектов, вам следует освобождать их вручную с помощью DeleteLocalRef, а не позволять JNI делать это за вас. Реализация должна резервировать слоты только для 16 локальных ссылок, поэтому если вам нужно больше, то удаляйте их по мере использования или зарезервируйте больше с помощью EnsureLocalCapacity/PushLocalFrame.

Обратите внимание, что jfieldID и jmethodID – это непрозрачные типы, а не ссылки на объекты, и их не следует передавать в NewGlobalRef. Указатели на первичные данные, возвращаемые такими функциями, как GetStringUTFChars и GetByteArrayElements, также не являются объектами. (Они могут передаваться между потоками и действительны до соответствующего вызова Release.)

Один необычный случай заслуживает отдельного упоминания. Если вы прикрепите нативный поток с помощью AttachCurrentThread, код, который вы выполняете, никогда не будет автоматически освобождать локальные ссылки, пока поток не отсоединится. Все созданные вами локальные ссылки придется удалять вручную. Как правило, любой нативный код, который создает локальные ссылки в цикле, должен выполнять удаление вручную.

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

Строки UTF-8 и UTF-16

В языке программирования Java используется UTF-16. Для удобства JNI предоставляет методы, которые работают и с модифицированной кодировкой UTF-8. Модифицированная кодировка полезна для кода C, поскольку она кодирует \u0000 как 0xc0 0x80 вместо 0x00. Преимущество такого подхода в том, что вы можете рассчитывать на строки в стиле C, заканчивающиеся нулем, которые подходят для использования со стандартными строковыми функциями libc. Недостаток заключается в том, что вы не можете передавать произвольные данные UTF-8 в JNI и ожидать, что они будут работать правильно.

Чтобы получить представление UTF-16 для String, используйте GetStringChars. Обратите внимание, что строки UTF-16 не заканчиваются нулем и \u0000 разрешено, поэтому вам нужно сохранить длину строки, а также указатель jchar.

Не забудьте Release строки, которые вы Get. Строковые функции возвращают jchar* или jbyte*, которые являются указателями в стиле C на примитивные данные, а не локальными ссылками. Они гарантированно действительны до вызова Release, то есть не освобождаются при возврате нативного метода.

Данные, передаваемые в NewStringUTF, должны быть в формате Modified UTF-8. Распространенная ошибка – чтение символьных данных из файла или сетевого потока и их передача в NewStringUTF без фильтрации. Если вы не уверены, что данные представлены в формате MUTF-8 (или 7-битной кодировке ASCII, которая является совместимым подмножеством), вам нужно удалить недопустимые символы или преобразовать их в правильный формат MUTF-8. В противном случае преобразование в UTF-16 может привести к неожиданным результатам. CheckJNI (по умолчанию включен для эмуляторов) сканирует строки и прерывает работу виртуальной машины, если получает недопустимые входные данные.

До Android 8 обычно было быстрее работать со строками UTF-16, поскольку Android не требовал копирования в GetStringChars, тогда как GetStringUTFChars требовал выделения памяти и преобразования в UTF-8. В Android 8 для представления String в строках ASCII стало использоваться 8 бит на символ (для экономии памяти), а также был добавлен перемещающий сборщик мусора. Эти функции значительно сокращают количество случаев, когда ART может предоставить указатель на данные String без создания копии, даже для GetStringCritical. Однако если большинство строк, обрабатываемых кодом, короткие, то в большинстве случаев можно избежать выделения и освобождения памяти, используя буфер, выделенный в стеке, и GetStringRegion или GetStringUTFRegion. Пример:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> heap_buffer;
    jchar* buffer = stack_buffer;
    jsize length = env->GetStringLength(str);
    if (length > kStackBufferSize) {
      heap_buffer.reset(new jchar[length]);
      buffer = heap_buffer.get();
    }
    env->GetStringRegion(str, 0, length, buffer);
    process_data(buffer, length);

Примитивные массивы

JNI предоставляет функции для доступа к содержимому объектов массива. К массивам объектов нужно обращаться по одному элементу за раз, а массивы примитивов можно читать и записывать напрямую, как если бы они были объявлены в C.

Чтобы сделать интерфейс максимально эффективным, не ограничивая реализацию виртуальной машины, семейство вызовов Get<PrimitiveType>ArrayElements позволяет среде выполнения либо возвращать указатель на фактические элементы, либо выделять память и создавать копию. В любом случае возвращаемый необработанный указатель гарантированно будет действительным до тех пор, пока не будет выполнен соответствующий вызов Release (это означает, что если данные не были скопированы, объект массива будет закреплен и не сможет быть перемещен в рамках уплотнения кучи). Вы должны Release каждый массив, который вы Get. Кроме того, если вызов Get завершится ошибкой, убедитесь, что ваш код не пытается выполнить операцию Release с нулевым указателем.

Вы можете определить, были ли скопированы данные, передав аргументу isCopy указатель, отличный от NULL. Это редко бывает полезно.

Вызов Release принимает аргумент mode, который может иметь одно из трех значений. Действия, выполняемые средой выполнения, зависят от того, возвращает ли она указатель на фактические данные или их копию:

  • 0
    • Фактический результат: объект массива не закреплен.
    • Копирование. Данные копируются обратно. Буфер с копией освобождается.
  • JNI_COMMIT
    • Фактическое поведение: ничего не происходит.
    • Копирование. Данные копируются обратно. Буфер с копией не освобождается.
  • JNI_ABORT
    • Фактический результат: объект массива не закреплен. Предыдущие записи не прерываются.
    • Копирование: буфер с копией освобождается, все изменения в нем теряются.

Проверять флаг isCopy нужно, чтобы знать, требуется ли вызывать Release с JNI_COMMIT после изменения массива. Если вы поочередно вносите изменения и выполняете код, использующий содержимое массива, то можете пропустить фиксацию, не выполняющую никаких действий. Ещё одна возможная причина проверки флага – эффективная обработка JNI_ABORT. Например, вы можете получить массив, изменить его на месте, передать части другим функциям, а затем отменить изменения. Если вы знаете, что JNI создает для вас новую копию, нет необходимости создавать ещё одну "редактируемую" копию. Если JNI передает вам оригинал, вам нужно сделать собственную копию.

Распространенная ошибка (повторяющаяся в примере кода) – предположение, что вызов Release можно пропустить, если *isCopy имеет значение false. На самом деле это не так: Если буфер копирования не был выделен, то исходная память должна быть закреплена и не может быть перемещена сборщиком мусора.

Обратите внимание, что флаг JNI_COMMIT не освобождает массив, поэтому в дальнейшем вам потребуется снова вызвать Release с другим флагом.

Вызовы Region

Существует альтернатива вызовам, таким как Get<Type>ArrayElements и GetStringChars, которая может быть очень полезна, когда все, что вы хотите сделать, это скопировать данные внутрь или наружу. Вот несколько советов:

    jbyte* data = env->GetByteArrayElements(array, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(array, data, JNI_ABORT);
    }

Она захватывает массив, копирует из него первые len байт и освобождает массив. В зависимости от реализации вызов Get закрепит или скопирует содержимое массива. Код копирует данные (возможно, во второй раз), а затем вызывает Release. В этом случае JNI_ABORT гарантирует, что третья копия не будет создана.

Того же результата можно достичь проще:

    env->GetByteArrayRegion(array, 0, len, buffer);

У этого подхода есть несколько преимуществ:

  • Требуется один вызов JNI вместо двух, что снижает нагрузку.
  • Не требует закрепления или дополнительных копий данных.
  • Снижается риск ошибки программиста – нет риска забыть вызвать Release после сбоя.

Аналогично, вы можете использовать вызов Set<Type>ArrayRegion, чтобы скопировать данные в массив, и GetStringRegion или GetStringUTFRegion, чтобы скопировать символы из String.

Исключения

Не вызывайте большинство функций JNI, пока ожидается исключение. Ваш код должен обнаружить исключение (с помощью возвращаемого значения функции, ExceptionCheck или ExceptionOccurred) и вернуть его или очистить исключение и обработать его.

При наличии ожидающего исключения можно вызывать только следующие функции JNI:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

Многие вызовы JNI могут вызывать исключение, но часто предоставляют более простой способ проверки на наличие сбоя. Например, если NewString возвращает значение, отличное от NULL, проверять наличие исключения не нужно. Однако если вы вызываете метод (используя функцию, например CallObjectMethod), вы должны всегда проверять наличие исключения, поскольку возвращаемое значение не будет действительным, если было создано исключение.

Обратите внимание, что исключения, создаваемые управляемым кодом, не разворачивают нативные стековые фреймы. Исключения C++, которые обычно не рекомендуются для Android, не должны генерироваться за пределами перехода JNI от кода C++ к управляемому коду. Инструкции JNI Throw и ThrowNew просто устанавливают указатель исключения в текущем потоке. При возврате к управляемому коду из нативного кода исключение будет отмечено и обработано соответствующим образом.

Нативный код может "перехватить" исключение, вызвав ExceptionCheck или ExceptionOccurred, и очистить его с помощью ExceptionClear. Как обычно, игнорирование исключений без их обработки может привести к проблемам.

Встроенных функций для работы с объектом Throwable нет. Если вы хотите, например, получить строку исключения, вам нужно найти класс Throwable, найти идентификатор метода для getMessage "()Ljava/lang/String;", вызвать его и, если результат не равен NULL, использовать GetStringUTFChars, чтобы получить то, что можно передать в printf(3) или эквивалент.

Расширенная проверка

JNI выполняет очень мало проверок на ошибки. Ошибки обычно приводят к сбою. В Android также есть режим CheckJNI, в котором указатели таблицы функций JavaVM и JNIEnv переключаются на таблицы функций, выполняющих расширенную серию проверок перед вызовом стандартной реализации.

Дополнительные проверки включают:

  • Массивы: попытка выделить массив отрицательного размера.
  • Неверные указатели: передача неверного jarray/jclass/jobject/jstring в вызов JNI или передача указателя NULL в вызов JNI с аргументом, не допускающим значение NULL.
  • Названия классов: передача в вызов JNI чего-либо, кроме названия класса в стиле java/lang/String.
  • Критические вызовы: вызов JNI между "критическим" получением и соответствующим выпуском.
  • Direct ByteBuffers: передача недопустимых аргументов в NewDirectByteBuffer.
  • Исключения: вызов JNI при наличии ожидающего исключения.
  • JNIEnv*s: использование JNIEnv* из неправильного потока.
  • jfieldID: использование NULL jfieldID или jfieldID для присвоения полю значения неправильного типа (например, попытка присвоить StringBuilder полю String), использование jfieldID для статического поля, чтобы задать поле экземпляра, или наоборот, а также использование jfieldID из одного класса с экземплярами другого класса.
  • jmethodID: использование неправильного типа jmethodID при вызове Call*Method JNI (неверный тип возвращаемого значения, несоответствие статического/нестатического типа, неправильный тип для this (для нестатических вызовов) или неправильный класс (для статических вызовов)).
  • Ссылки: использование DeleteGlobalRef/DeleteLocalRef для неправильного типа ссылки.
  • Режимы выпуска: передача недопустимого режима выпуска в вызов выпуска (что-то, отличное от 0, JNI_ABORT или JNI_COMMIT).
  • Типобезопасность. Возврат несовместимого типа из нативного метода (например, возврат StringBuilder из метода, объявленного для возврата String).
  • UTF-8: передача недопустимой последовательности байтов модифицированной кодировки UTF-8 в вызов JNI.

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

Включить CheckJNI можно несколькими способами.

Если вы используете эмулятор, CheckJNI включен по умолчанию.

Если у вас устройство с настроенным root-доступом, вы можете использовать следующую последовательность команд, чтобы перезапустить среду выполнения с включенной функцией CheckJNI:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

В любом из этих случаев при запуске среды выполнения в выходных данных logcat вы увидите примерно следующее:

D AndroidRuntime: CheckJNI is ON

Если у вас обычное устройство, вы можете использовать следующую команду:

adb shell setprop debug.checkjni 1

Это не повлияет на уже запущенные приложения, но для всех приложений, запущенных после этого, будет включен CheckJNI. Если вы измените значение свойства или перезагрузите устройство, CheckJNI снова будет отключен. В этом случае при следующем запуске приложения в выходных данных logcat появится примерно следующее:

D Late-enabling CheckJNI

Вы также можете задать атрибут android:debuggable в манифесте приложения, чтобы включить CheckJNI только для него. Обратите внимание, что инструменты сборки Android будут делать это автоматически для определенных типов сборки.

Нативные библиотеки

Вы можете загружать нативный код из общих библиотек с помощью стандартного метода System.loadLibrary.

На практике в старых версиях Android были ошибки в PackageManager, из-за которых установка и обновление нативных библиотек были ненадежными. В проекте ReLinker предлагаются обходные решения для этой и других проблем с загрузкой нативных библиотек.

Вызовите System.loadLibrary (или ReLinker.loadLibrary) из инициализатора статического класса. Аргументом является "недекорированное" название библиотеки, поэтому для загрузки libfubar.so нужно передать "fubar".

Если у вас только один класс с нативными методами, вызов System.loadLibrary должен быть в статическом инициализаторе этого класса. В противном случае вы можете вызвать ее из Application, чтобы библиотека всегда загружалась и загружалась рано.

Среда выполнения может найти нативные методы двумя способами. Вы можете либо явно зарегистрировать их с помощью RegisterNatives(), либо разрешить среде выполнения динамически искать их с помощью dlsym(). В большинстве приложений метод RegisterNatives() вызывается из JNI_OnLoad(). Это позволяет выполнить предварительную регистрацию нативных методов с явным сопоставлением. Написание кода требует немного больше времени, но у этого подхода есть следующие преимущества:

  1. Ошибки выявляются на ранних этапах. Неправильное название функции приведет к ошибке при загрузке библиотеки.
  2. Обычно экспортировать нужно только символ JNI_OnLoad(). В некоторых случаях может потребоваться ANativeActivity_OnCreate() или аналогичный метод.
  3. Android Studio может предупреждать вас и автоматически исправлять распространенные ошибки в названиях классов и сигнатурах функций.

Преимущество использования dlsym() заключается в том, что вам нужно написать меньше кода. Кроме того, этот способ эффективнее, если функции JNI обычно не вызываются.

Чтобы использовать RegisterNatives, сделайте следующее:

  • Предоставьте функцию JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • В файле JNI_OnLoad зарегистрируйте все нативные методы с помощью RegisterNatives.
  • Выполните сборку с помощью скрипта версии (рекомендуется) или используйте -fvisibility=hidden, чтобы из библиотеки экспортировался только JNI_OnLoad. Это позволяет уменьшить размер кода и ускорить его работу, а также избежать возможных конфликтов с другими библиотеками, загруженными в приложение. Однако в случае сбоя приложения в нативном коде трассировка стека будет менее информативной.

Статический инициализатор должен выглядеть следующим образом:

Kotlin

companion object {
    init {
        System.loadLibrary("fubar")
    }
}

Java

static {
    System.loadLibrary("fubar");
}

Функция JNI_OnLoad на языке C++ должна выглядеть примерно так:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    // Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
    jclass c = env->FindClass("com/example/app/package/MyClass");
    if (c == nullptr) return JNI_ERR;

    // Register your class' native methods.
    static const JNINativeMethod methods[] = {
        {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
        {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
    };
    int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}

Чтобы использовать обнаружение нативных методов, их нужно называть определенным образом (подробности см. в спецификации JNI). Это означает, что если сигнатура метода неверна, вы не узнаете об этом до первого вызова метода.

Любые вызовы FindClass, сделанные из JNI_OnLoad, будут разрешать классы в контексте загрузчика классов, который использовался для загрузки общей библиотеки. При вызове из других контекстов FindClass использует загрузчик классов, связанный с методом в верхней части стека Java. Если такого метода нет (поскольку вызов выполняется из нативного потока, который только что был прикреплен), используется системный загрузчик классов. Системный загрузчик классов не знает о классах вашего приложения, поэтому вы не сможете найти собственные классы с помощью FindClass в этом контексте. Это делает JNI_OnLoad удобным местом для поиска и кэширования классов: как только у вас появится действительная глобальная ссылка jclass, вы сможете использовать ее из любого прикрепленного потока.

Более быстрые звонки с помощью @FastNative и @CriticalNative

Нативные методы можно аннотировать с помощью @FastNative или @CriticalNative (но не обоих сразу), чтобы ускорить переходы между управляемым и нативным кодом. Однако эти аннотации вносят определенные изменения в поведение, которые необходимо тщательно учитывать перед использованием. Ниже мы кратко опишем эти изменения, но рекомендуем ознакомиться с подробной информацией в документации.

Аннотацию @CriticalNative можно применять только к нативным методам, которые не используют управляемые объекты (в параметрах, возвращаемых значениях или в качестве неявного this). Эта аннотация изменяет ABI перехода JNI. В нативной реализации не должно быть параметров JNIEnv и jclass.

При выполнении метода @FastNative или @CriticalNative сборщик мусора не может приостановить поток для выполнения важных задач и может быть заблокирован. Не используйте эти аннотации для методов, которые выполняются долго, в том числе для методов, которые обычно выполняются быстро, но в целом не ограничены. В частности, код не должен выполнять значительные операции ввода-вывода или получать нативные блокировки, которые могут удерживаться в течение длительного времени.

Эти аннотации были реализованы для системного использования в Android 8 и стали общедоступным API, протестированным CTS, в Android 14. Эти оптимизации, скорее всего, будут работать и на устройствах с Android 8–13 (хотя и без строгих гарантий CTS), но динамический поиск нативных методов поддерживается только на Android 12 и более поздних версиях. Для работы на Android 8–11 строго требуется явная регистрация с помощью JNI RegisterNatives. Эти аннотации игнорируются в Android 7 и более ранних версиях. Несоответствие ABI для @CriticalNative приведет к неправильной маршализации аргументов и, скорее всего, к сбоям.

Для критически важных методов, которым нужны эти аннотации, настоятельно рекомендуется явно зарегистрировать методы с помощью JNI RegisterNatives, а не полагаться на обнаружение нативных методов по имени. Чтобы приложение запускалось быстрее, рекомендуется включить вызывающие объекты методов @FastNative или @CriticalNative в базовый профиль. Начиная с Android 12 вызов нативного метода @CriticalNative из скомпилированного управляемого метода почти так же эффективен, как и невстроенный вызов в C/C++, если все аргументы помещаются в регистры (например, до 8 целочисленных аргументов и до 8 аргументов с плавающей запятой на arm64).

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

Kotlin

fun writeInt(nativeHandle: Long, value: Int) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value)
    }
}

@CriticalNative
external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean

external fun nativeWriteInt(nativeHandle: Long, value: Int)

Java

void writeInt(long nativeHandle, int value) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value);
    }
}

@CriticalNative
static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value);

static native void nativeWriteInt(long nativeHandle, int value);

Особенности 64-разрядных систем

Чтобы обеспечить поддержку архитектур, использующих 64-битные указатели, при хранении указателя на нативную структуру в поле Java используйте поле long, а не int.

Неподдерживаемые функции и обратная совместимость

Поддерживаются все функции JNI 1.6, за исключением следующих:

  • DefineClass не реализован. Android не использует байт-коды Java или файлы классов, поэтому передача двоичных данных классов не работает.

Для обратной совместимости с более ранними версиями Android вам может потребоваться знать следующее:

  • Динамический поиск нативных функций

    До версии Android 2.0 (Eclair) символ "$" не преобразовывался должным образом в "_00024" при поиске названий методов. Чтобы обойти это ограничение, используйте явную регистрацию или переместите нативные методы из внутренних классов.

  • Как отсоединить цепочку

    До Android 2.0 (Eclair) было невозможно использовать функцию деструктора pthread_key_create, чтобы избежать проверки "поток должен быть отсоединен перед выходом". (Среда выполнения также использует функцию деструктора ключа pthread, поэтому вызов будет зависеть от того, какая функция будет вызвана первой.)

  • Слабые глобальные ссылки

    До Android 2.2 (Froyo) слабые глобальные ссылки не были реализованы. Более старые версии будут отклонять попытки их использования. Для проверки поддержки можно использовать константы версии платформы Android.

    До версии Android 4.0 (Ice Cream Sandwich) слабые глобальные ссылки можно было передавать только в NewLocalRef, NewGlobalRef и DeleteWeakGlobalRef. (Спецификация настоятельно рекомендует программистам создавать жесткие ссылки на слабые глобальные переменные, прежде чем что-либо с ними делать, поэтому это не должно быть ограничением.)

    Начиная с Android 4.0 (Ice Cream Sandwich), слабые глобальные ссылки можно использовать так же, как и любые другие ссылки JNI.

  • Отсылки к местной культуре

    До Android 4.0 (Ice Cream Sandwich) локальные ссылки были прямыми указателями. В Ice Cream Sandwich добавлена косвенная адресация, необходимая для поддержки более эффективных сборщиков мусора, но это означает, что многие ошибки JNI невозможно обнаружить в более ранних выпусках. Подробнее об изменениях в локальных ссылках JNI в ICS…

    В версиях Android до Android 8.0 количество локальных ссылок ограничено в зависимости от версии. Начиная с Android 8.0, Android поддерживает неограниченное количество локальных ссылок.

  • Определение типа цифрового отпечатка с помощью тега GetObjectRefType

    До выхода Android 4.0 (Ice Cream Sandwich) из-за использования прямых указателей (см. выше) было невозможно правильно реализовать GetObjectRefType. Вместо этого мы использовали эвристику, которая просматривала таблицу слабых глобальных переменных, аргументы, таблицу локальных переменных и таблицу глобальных переменных в указанном порядке. При первом обнаружении прямого указателя система сообщит, что ваш цифровой отпечаток относится к типу, который она проверяет. Это означало, что если вы вызывали GetObjectRefType для глобального jclass, который совпадал с jclass, переданным в качестве неявного аргумента вашему статическому нативному методу, то получали JNILocalRefType, а не JNIGlobalRefType.

  • @FastNative и @CriticalNative

    До Android 7 эти аннотации игнорировались. Несоответствие ABI для @CriticalNative приведет к неправильной маршализации аргументов и, скорее всего, к сбоям.

    Динамический поиск нативных функций для методов @FastNative и @CriticalNative не был реализован в Android 8–10 и содержит известные ошибки в Android 11. Использование этих оптимизаций без явной регистрации в JNI RegisterNatives может привести к сбоям в Android 8–11.

  • FindClass бросает ClassNotFoundException

    Для обратной совместимости Android выдает ClassNotFoundException вместо NoClassDefFoundError, когда класс не найден FindClass. Это соответствует поведению Java Reflection API Class.forName(name).

Часто задаваемые вопросы: почему я получаю UnsatisfiedLinkError?

При работе с нативным кодом нередко можно увидеть такую ошибку:

java.lang.UnsatisfiedLinkError: Library foo not found

В некоторых случаях это означает именно то, что написано: библиотека не найдена. В других случаях библиотека существует, но не может быть открыта dlopen(3). Подробные сведения о сбое можно найти в сообщении об исключении.

Распространенные причины, по которым вы можете столкнуться с исключениями "библиотека не найдена":

  • Библиотека не существует или недоступна для приложения. Проверьте ее наличие и разрешения с помощью adb shell ls -l <path>.
  • Библиотека не была создана с помощью NDK. Это может привести к зависимостям от функций или библиотек, которых нет на устройстве.

Другой класс ошибок UnsatisfiedLinkError выглядит следующим образом:

java.lang.UnsatisfiedLinkError: myfunc
        at Foo.myfunc(Native Method)
        at Foo.main(Foo.java:10)

В logcat вы увидите следующее:

W/dalvikvm(  880): No implementation found for native LFoo;.myfunc ()V

Это означает, что среда выполнения пыталась найти соответствующий метод, но безуспешно. Чаще всего это происходит по следующим причинам:

  • Библиотека не загружается. Проверьте выходные данные logcat на наличие сообщений о загрузке библиотеки.
  • Метод не найден из-за несоответствия имени или подписи. Обычно это происходит по следующим причинам:
    • Для отложенного поиска методов: отсутствие объявления функций C++ с помощью extern "C" и соответствующей видимости (JNIEXPORT). Обратите внимание, что до версии Ice Cream Sandwich макрос JNIEXPORT был некорректным, поэтому использование нового GCC со старым jni.h не будет работать. Вы можете использовать arm-eabi-nm, чтобы посмотреть, как символы выглядят в библиотеке. Если они искажены (например, _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass вместо Java_Foo_myfunc) или тип символа – строчная буква t вместо заглавной T, то вам нужно скорректировать объявление.
    • При явной регистрации – незначительные ошибки при вводе сигнатуры метода. Убедитесь, что данные, которые вы передаете в вызове регистрации, соответствуют подписи в файле журнала. Помните, что B – это byte, а Z – это boolean. Названия классов в сигнатурах начинаются с L и заканчиваются точкой с запятой (;). Названия пакетов и классов разделяются косой чертой (/), а названия внутренних классов – знаком доллара ($), например Ljava/util/Map$Entry;.

Чтобы избежать некоторых проблем, можно использовать javah для автоматического создания заголовков JNI.

Часто задаваемые вопросы: почему FindClass не находит мой курс?

(Большинство этих советов в равной степени применимы к случаям, когда не удается найти методы с помощью GetMethodID или GetStaticMethodID или поля с помощью GetFieldID или GetStaticFieldID.)

Убедитесь, что строка с названием класса имеет правильный формат. Названия классов JNI начинаются с названия пакета, а их части разделяются косой чертой, например java/lang/String. Если вы ищете класс массива, вам нужно начать с соответствующего количества квадратных скобок, а также заключить класс в "L" и ";", поэтому одномерный массив String будет [Ljava/lang/String;. Если вы ищете внутренний класс, используйте символ $, а не точку. В целом, чтобы узнать внутреннее название класса, лучше всего использовать javap в файле .class.

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

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

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

Самый верхний метод – Foo.myfunc. FindClass находит объект ClassLoader, связанный с классом Foo, и использует его.

Обычно этого достаточно. Проблемы могут возникнуть, если вы создадите поток самостоятельно (например, вызвав pthread_create, а затем подключив его с помощью AttachCurrentThread). В этом случае стековых фреймов из вашего приложения не будет. Если вы вызовете FindClass из этого потока, JavaVM запустится в системном загрузчике классов, а не в том, который связан с вашим приложением. В результате попытки найти классы, относящиеся к приложению, будут неудачными.

Есть несколько способов обойти это ограничение:

  • Выполняйте поиск FindClass один раз в JNI_OnLoad и кешируйте ссылки на классы для дальнейшего использования. Любые вызовы FindClass, выполняемые в рамках JNI_OnLoad, будут использовать загрузчик классов, связанный с функцией, которая вызвала System.loadLibrary (это специальное правило, предназначенное для упрощения инициализации библиотеки). Если код приложения загружает библиотеку, FindClass будет использовать правильный загрузчик классов.
  • Передайте экземпляр класса в функции, которым он нужен. Для этого объявите нативный метод, принимающий аргумент Class, и передайте в него Foo.class.
  • Кешируйте ссылку на объект ClassLoader в удобном месте и отправляйте вызовы loadClass напрямую. Это требует некоторых усилий.

Часто задаваемые вопросы: как передать необработанные данные в нативный код?

Вы можете оказаться в ситуации, когда вам потребуется доступ к большому буферу необработанных данных как из управляемого, так и из нативного кода. Распространенными примерами являются манипуляции с растровыми изображениями или звуковыми сэмплами. Есть два основных подхода.

Вы можете хранить данные в byte[]. Это позволяет очень быстро получать доступ из управляемого кода. Однако на стороне нативного кода вы не можете быть уверены, что получите доступ к данным без их копирования. В некоторых реализациях GetByteArrayElements и GetPrimitiveArrayCritical возвращают фактические указатели на необработанные данные в управляемой куче, а в других – выделяют буфер в нативной куче и копируют данные.

В качестве альтернативы можно хранить данные в прямом байтовом буфере. Их можно создать с помощью java.nio.ByteBuffer.allocateDirect или функции JNI NewDirectByteBuffer. В отличие от обычных байтовых буферов, хранилище не выделяется в управляемой куче и всегда доступно непосредственно из нативного кода (получите адрес с помощью GetDirectBufferAddress). В зависимости от того, как реализован прямой доступ к байтовому буферу, доступ к данным из управляемого кода может быть очень медленным.

Выбор зависит от двух факторов:

  1. Будет ли большая часть обращений к данным происходить из кода, написанного на Java или C/C++?
  2. Если данные в конечном итоге передаются в системный API, в какой форме они должны быть? Например, если данные в конечном итоге передаются функции, которая принимает byte[], то обработка в прямом ByteBuffer может быть нецелесообразной.

Если явного победителя нет, используйте прямой байтовый буфер. Поддержка этих типов встроена непосредственно в JNI, и в будущих выпусках производительность должна улучшиться.