Советы по 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 в Memory Profiler в Android Studio 3.2 и выше.

Общие советы

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

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

JavaVM и JNIEnv

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

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

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

Объявления JNIEnv и JavaVM в C отличаются от объявлений в C++. Включаемый файл "jni.h" предоставляет разные определения типов в зависимости от того, включён ли он в 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 в thread-local-storage; таким образом, он будет передан деструктору в качестве аргумента.)

jclass, jmethodID и jfieldID

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

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

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

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

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

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

Котлин

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()
    }
}

Ява

    /*
     * 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();
    }

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

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

Каждый аргумент, переданный нативному методу, и почти каждый объект, возвращаемый функцией 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 и ожидать от него корректной работы.

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

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

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

До Android 8 работа со строками UTF-16 обычно была быстрее, поскольку Android не требовал копирования в GetStringChars , тогда как GetStringUTFChars требовал выделения памяти и преобразования в UTF-8. Android 8 изменил представление String , чтобы использовать 8 бит на символ для строк ASCII (для экономии памяти), и начал использовать перемещаемый сборщик мусора . Эти функции значительно сокращают количество случаев, когда 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 с указателем NULL.

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

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

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

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

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

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

Региональные звонки

Существует альтернатива вызовам вроде 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 с ненулевым аргументом.
  • Имена классов: передача в вызов JNI любого имени класса, кроме стиля «java/lang/String».
  • Критические вызовы: выполнение вызова JNI между «критическим» получением и его соответствующим выпуском.
  • Direct ByteBuffers: передача неверных аргументов в NewDirectByteBuffer .
  • Исключения: выполнение вызова JNI при наличии ожидающего исключения.
  • JNIEnv*s: использование JNIEnv* из неправильного потока.
  • jfieldID: использование jfieldID со значением NULL или использование jfieldID для установки поля в значение неправильного типа (например, попытка назначить StringBuilder полю String), или использование jfieldID для статического поля для установки поля экземпляра и наоборот, или использование jfieldID из одного класса с экземплярами другого класса.
  • jmethodIDs: используется неправильный тип jmethodID при выполнении вызова Call*Method JNI: неправильный тип возврата, несоответствие статического/нестатического, неправильный тип для «this» (для нестатических вызовов) или неправильный класс (для статических вызовов).
  • Ссылки: использование DeleteGlobalRef / DeleteLocalRef для неправильного типа ссылки.
  • Режимы выпуска: передача неверного режима выпуска в вызов выпуска (что-то отличное от 0 , JNI_ABORT или JNI_COMMIT ).
  • Безопасность типов: возврат несовместимого типа из вашего собственного метода (например, возврат StringBuilder из метода, объявленного для возврата String).
  • UTF-8: передача недопустимой модифицированной последовательности байтов UTF-8 в вызов JNI.

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

Есть несколько способов включить CheckJNI.

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

Если у вас есть рутированное устройство, вы можете использовать следующую последовательность команд для перезапуска среды выполнения с включенным 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 . Преимущество того, что среда выполнения обнаруживает ваши функции, заключается в том, что требуется написать немного меньше кода.

Чтобы использовать RegisterNatives :

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

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

Котлин

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

Ява

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+, а явная регистрация в JNI RegisterNatives строго требуется для работы на Android версий 8–11. Эти аннотации игнорируются в Android 7–11, а несоответствие ABI для @CriticalNative может привести к неправильному маршалингу аргументов и вероятным сбоям.

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

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

Котлин

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)

Ява

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-битные указатели, используйте long поле вместо int при сохранении указателя на собственную структуру в поле Java.

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

Поддерживаются все функции 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 . Это поведение соответствует API рефлексии Java Class.forName(name) .

FAQ: Почему я получаю 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 будет использовать правильный загрузчик классов.
  • Передайте экземпляр класса функциям, которым он нужен, объявив собственный метод, принимающий аргумент класса, а затем передав ему Foo.class .
  • Кэшируйте ссылку на объект ClassLoader где-нибудь под рукой и вызывайте вызовы loadClass напрямую. Это требует некоторых усилий.

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

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

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

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

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

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

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