JNI 도움말

JNI는 Java Native Interface의 약어입니다. Android가 Java 또는 Kotlin 프로그래밍 언어로 작성된 관리 코드에서 컴파일하는 바이트코드가 C/C++로 작성된 네이티브 코드와 상호 작용할 수 있는 방법을 정의합니다. JNI는 공급업체 중립적이고, 동적 공유 라이브러리에서 코드를 로드할 수 있도록 지원하며, 번거롭긴 하지만 효율적인 경우도 있습니다.

참고: Android는 Java 프로그래밍 언어와 비슷한 방식으로 Kotlin을 ART 친화적인 바이트코드로 컴파일하기 때문에 JNI 아키텍처 및 관련 비용 측면에서 이 페이지의 지침을 Kotlin과 Java 프로그래밍 언어 둘 다에 적용할 수 있습니다. 자세한 내용은 Kotlin 및 Android를 참조하세요.

잘 모르겠으면 Java Native Interface 사양을 참조하여 JNI의 작동 방식과 사용할 수 있는 기능을 파악하세요. 인터페이스의 일부 측면은 처음 읽어서 바로 이해되는 내용이 아니므로 다음 몇 개의 섹션이 유용할 수 있습니다.

글로벌 JNI 참조를 살펴보고, 글로벌 JNI 참조가 생성 및 삭제된 위치를 확인하려면 Android 스튜디오 3.2 이상에서 메모리 프로파일러JNI 힙 보기를 사용합니다.

일반적인 도움말

JNI 레이어의 공간을 최소화합니다. 여기서 고려해야 할 몇 가지 측정기준이 있습니다. JNI 솔루션이 다음 지침을 따르도록 해야 합니다(아래에는 가장 중요한 것부터 중요도 순으로 나열되어 있음).

  • JNI 레이어에서 리소스의 마샬링을 최소화합니다. JNI 레이어에서 마샬링하는 작업에는 적지 않은 비용이 듭니다. 마샬링해야 하는 데이터 양과 데이터를 마샬링해야 하는 빈도를 최소화하는 인터페이스를 설계합니다.
  • 가능한 경우 관리형 프로그래밍 언어로 작성된 코드와 C++로 작성된 코드 간에 비동기 통신을 사용하지 않습니다. 이렇게 하면 JNI 인터페이스를 더 쉽게 유지 관리할 수 있습니다. 일반적으로 UI와 동일한 언어로 비동기 업데이트를 유지하여 비동기 UI 업데이트를 간소화할 수 있습니다. 예를 들어 JNI를 통해 Java 코드의 UI 스레드에서 C++ 함수를 호출하는 대신, Java 프로그래밍 언어의 두 스레드 간에 콜백을 수행하는 것이 좋습니다. 스레드 중 하나에서 C++ 차단 호출을 수행한 다음, 차단 호출이 완료되면 UI 스레드에 알립니다.
  • JNI를 처리해야 하거나, JNI에서 처리해야 하는 스레드 수를 최소화합니다. Java 및 C++ 언어 둘 다에서 스레드 풀을 사용해야 하는 경우 개별 작업자 스레드가 아닌 풀 소유자 간에 JNI 통신을 유지합니다.
  • 향후 리팩터링을 지원하기 위해 쉽게 파악할 수 있는 소수의 C++ 및 Java 소스 위치에 인터페이스 코드를 유지합니다. 적절한 경우 JNI 자동 생성 라이브러리를 사용하는 것이 좋습니다.

JavaVM 및 JNIEnv

JNI는 두 개의 주요 데이터 구조인 'JavaVM'과 'JNIEnv'를 정의합니다. 둘 다 본질적으로 함수 테이블에 대한 포인터의 포인터입니다. C++ 버전에서는 함수 테이블에 대한 포인터와 테이블을 통해 간접적으로 처리되는 각 JNI 함수의 멤버 함수가 있는 클래스입니다. JavaVM은 JavaVM을 만들고 삭제할 수 있는 '호출 인터페이스' 함수를 제공합니다. 이론적으로 프로세스당 여러 개의 JavaVM을 사용할 수 있지만, Android에서는 하나만 허용합니다.

JNIEnv는 대부분의 JNI 함수를 제공합니다. 네이티브 함수는 모두 JNIEnv를 첫 번째 인수로 받습니다.

JNIEnv는 스레드 로컬 저장소에 사용됩니다. 따라서 스레드 간에 JNIEnv를 공유할 수 없습니다. 코드 조각에서 JNIEnv를 가져올 다른 방법이 없다면 JavaVM을 공유하고 GetEnv를 사용하여 스레드의 JNIEnv를 검색해야 합니다. JNIEnv가 있다고 가정합니다. 아래 AttachCurrentThread를 참조하세요.

JNIEnv 및 JavaVM의 C 선언은 C++ 선언과 다릅니다. "jni.h" include 파일은 C 또는 C++에 포함되는지에 따라 다른 typedef를 제공합니다. 따라서 두 언어에 모두 포함된 헤더 파일에 JNIEnv 인수를 포함하는 것은 좋지 않습니다. 즉, 헤더 파일에 #ifdef __cplusplus가 필요한 경우 이 헤더에 JNIEnv를 가리키는 내용이 있으면 추가 작업을 수행해야 할 수 있습니다.

스레드

모든 스레드는 커널을 통해 예약된 Linux 스레드입니다. 일반적으로 관리 코드에서 Thread.start를 사용하여 시작되지만, 다른 곳에서 만든 다음 JavaVM에 연결할 수도 있습니다. 예를 들어 pthread_create를 사용하여 시작된 스레드를 JNI AttachCurrentThread 또는 AttachCurrentThreadAsDaemon 함수로 연결할 수 있습니다. 스레드가 연결되기 전에는 JNIEnv가 없으며, JNI 호출을 수행할 수 없습니다.

기본적으로 생성된 스레드를 연결하면 java.lang.Thread 객체가 생성되고 'main' ThreadGroup에 추가되어 디버거에서 볼 수 있게 됩니다. 이미 연결된 스레드에서는 AttachCurrentThread를 호출해도 작동하지 않습니다.

Android는 네이티브 코드를 실행하는 스레드를 정지하지 않습니다. 가비지 컬렉션이 진행 중이거나 디버거에서 정지 요청을 실행한 경우 Android는 다음에 JNI 호출을 수행할 때 스레드를 일시중지합니다.

JNI를 통해 연결된 스레드는 종료되기 전에 DetachCurrentThread를 호출해야 합니다. 이 동작을 직접 코딩하는 것이 어려운 경우, Android 2.0(Eclair) 이상에서 pthread_key_create를 사용하여 스레드가 종료되기 전에 호출할 소멸자 함수를 정의하고 이 함수에서 DetachCurrentThread를 호출할 수 있습니다. pthread_setspecific에 이 키를 사용하여 JNIEnv를 스레드 로컬 저장소에 저장하면 소멸자에 인수로 전달됩니다.

jclass, jmethodID 및 jfieldID

네이티브 코드에서 객체의 필드에 액세스하려면 다음 절차를 따릅니다.

  • FindClass를 사용하여 클래스의 클래스 객체 참조 가져오기
  • GetFieldID를 사용하여 필드의 필드 ID 가져오기
  • GetIntField 등을 적절하게 사용하여 필드 내용 가져오기

마찬가지로, 메서드를 호출하려면 먼저 클래스 객체 참조를 가져온 다음 메서드 ID를 가져옵니다. ID는 단순히 내부 런타임 데이터 구조에 대한 포인터인 경우가 많습니다. ID 검색을 위해 여러 문자열 비교가 필요할 수 있지만, ID가 검색되고 나면 필드를 가져오거나 메서드를 호출하는 실제 호출은 매우 빠르게 실행됩니다.

성능이 중요한 경우, 값을 한 번 검색한 다음 그 결과를 네이티브 코드에 캐시하면 유용합니다. 프로세스당 JavaVM 한 개로 제한되기 때문에 이 데이터를 정적 로컬 구조에 저장하는 것이 합당합니다.

클래스 참조, 필드 ID 및 메서드 ID는 클래스가 언로드될 때까지 유효합니다. ClassLoader와 관련된 모든 클래스를 가비지 컬렉션할 수 있는 경우에만 클래스가 언로드됩니다. 이러한 경우는 드물지만 Android에서 불가능하지는 않습니다. 그러나 jclass는 클래스 참조이며, NewGlobalRef 호출(다음 섹션 참조)을 사용해서 보호해야 합니다.

클래스가 로드될 때 ID를 캐시하고 클래스가 언로드되었다가 다시 로드되면 ID를 자동으로 다시 캐시하려는 경우, ID를 초기화하는 올바른 방법은 다음과 같은 코드 조각을 적절한 클래스에 추가하는 것입니다.

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

ID 검색을 수행하는 nativeClassInit 메서드를 C/C++ 코드에서 만듭니다. 이 코드는 클래스를 초기화할 때 한 번 실행됩니다. 클래스를 언로드했다가 다시 로드하면 코드가 다시 실행됩니다.

로컬 참조 및 글로벌 참조

네이티브 메서드에 전달되는 모든 인수와 JNI 함수에서 반환되는 거의 모든 객체는 '로컬 참조'입니다. 즉, 현재 스레드에서 현재 네이티브 메서드의 지속 기간 동안 유효합니다. 네이티브 메서드가 반환된 후 객체 자체가 계속 실행되더라도 참조는 유효하지 않습니다.

이 내용은 jclass, jstring, jarrayjobject의 모든 하위 클래스에 적용됩니다. 확장된 JNI 검사를 사용하면 런타임에서 대부분의 참조 오용에 대해 경고합니다.

비로컬 참조를 얻는 유일한 방법은 NewGlobalRefNewWeakGlobalRef 함수를 사용하는 것입니다.

더 오랜 기간 동안 참조를 유지하려면 '글로벌' 참조를 사용해야 합니다. NewGlobalRef 함수는 로컬 참조를 인수로 사용하고 글로벌 참조를 반환합니다. 글로벌 참조는 DeleteGlobalRef를 호출할 때까지 유효합니다.

이 패턴은 일반적으로 FindClass에서 반환된 jclass를 캐싱할 때 사용됩니다. 예를 들면 다음과 같습니다.

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

모든 JNI 메서드는 로컬 참조와 글로벌 참조를 모두 인수로 허용합니다. 동일한 객체에 대한 참조가 서로 다른 값을 가질 수 있습니다. 예를 들어 동일한 객체에 대한 연속 NewGlobalRef 호출의 반환 값이 각기 다를 수 있습니다. 두 참조가 동일한 객체를 참조하는지 확인하려면 IsSameObject 함수를 사용해야 합니다. 네이티브 코드에서 ==를 사용하여 참조를 비교하지 마세요.

결과적으로, 네이티브 코드에서 객체 참조를 상수 또는 고유한 값으로 가정해서는 안 됩니다. 객체를 나타내는 32비트 값이 메서드 호출마다 달라질 수 있으며, 두 개의 다른 객체가 연속 호출에서 동일한 32비트 값을 가질 수 있습니다. jobject 값을 키로 사용하지 마세요.

프로그래머는 로컬 참조를 '과도하게 할당하지 않도록' 해야 합니다. 구체적으로 설명하면, 객체 배열을 실행하는 동안 다수의 로컬 참조를 만드는 경우 JNI를 통해 자동으로 처리하지 않고 DeleteLocalRef를 사용해서 직접 로컬 참조를 해제해야 합니다. 구현에서는 16개의 로컬 참조에 대한 슬롯만 예약하면 되기 때문에 16개보다 많은 로컬 참조가 필요한 경우 작업하면서 로컬 참조를 삭제하거나, EnsureLocalCapacity/PushLocalFrame을 사용해서 로컬 참조를 더 예약해야 합니다.

jfieldIDjmethodID는 객체 참조가 아닌 불투명한 유형이므로 NewGlobalRef에 전달해서는 안 됩니다. GetStringUTFChars, GetByteArrayElements 등의 함수에서 반환된 원시 데이터 포인터도 객체가 아닙니다. 이러한 포인터는 스레드 간에 전달될 수 있으며, 일치하는 Release 호출 시까지 유효합니다.

단, 한 가지 특별한 경우가 있습니다. AttachCurrentThread를 사용하여 네이티브 스레드를 연결하는 경우 스레드가 분리되기 전에는 실행 중인 코드에서 자동으로 로컬 참조를 해제하지 않습니다. 직접 만든 로컬 참조를 모두 수동으로 삭제해야 합니다. 일반적으로, 루프를 통해 로컬 참조를 만드는 네이티브 코드도 수동으로 삭제해야 합니다.

글로벌 참조를 사용할 때는 주의해야 합니다. 글로벌 참조는 불가피할 수 있지만 디버그하기가 어려우며, 글로벌 참조로 인해 진단하기 어려운 메모리(mis) 동작이 발생할 수 있습니다. 그 밖의 모든 조건이 같다면, 글로벌 참조가 적은 솔루션이 더 좋습니다.

UTF-8 및 UTF-16 문자열

Java 프로그래밍 언어는 UTF-16을 사용합니다. 편의상, JNI는 Modified UTF-8에서도 작동하는 메서드를 제공합니다. 수정된 인코딩은 \u0000을 0x00이 아닌 0xc0 0x80으로 인코딩하기 때문에 C 코드에 유용합니다. 이 경우 표준 libc 문자열 함수에 사용하기 적합한 C 스타일 0 종료 문자열을 사용할 수 있다는 장점이 있습니다. 단점은 임의의 UTF-8 데이터를 JNI로 전달하여 올바르게 작동할 것으로 기대할 수 없다는 것입니다.

가능한 경우, 일반적으로 UTF-16 문자열로 작업하는 것이 더 빠릅니다. Android에서는 현재 GetStringChars에 사본이 필요하지 않지만, GetStringUTFChars에는 할당 및 UTF-8로 변환 작업이 필요합니다. UTF-16 문자열은 0으로 종료되지 않고 \u0000이 허용되므로 문자열 길이와 jchar 포인터를 계속 유지해야 합니다.

Get하는 문자열을 Release해야 합니다. 문자열 함수는 로컬 참조가 아닌 프리미티브 데이터에 대한 C 스타일 포인터인 jchar* 또는 jbyte*를 반환합니다. 이러한 포인터는 Release를 호출할 때까지 유효하므로, 네이티브 메서드가 반환될 때 해제되지 않습니다.

NewStringUTF에 전달되는 데이터는 Modified UTF-8 형식이어야 합니다. 파일 또는 네트워크 스트림에서 문자 데이터를 읽은 다음, 필터링하지 않고 NewStringUTF에 전달하는 실수를 하는 경우가 많습니다. 데이터가 유효한 MUTF-8(또는 호환되는 하위 집합인 7비트 ASCII)임을 알고 있는 경우를 제외하고, 잘못된 문자를 제거하거나 적절한 Modified UTF-8 형식으로 변환해야 합니다. 그러지 않으면 UTF-16 변환에서 예기치 않은 결과가 나올 수 있습니다. 기본적으로 에뮬레이터에 대해 켜져 있는 CheckJNI는 문자열을 검사하고, 잘못된 입력을 받은 경우 VM을 중단합니다.

프리미티브 배열

JNI는 배열 객체의 내용에 액세스하는 함수를 제공합니다. 객체 배열은 한 번에 한 항목씩 액세스해야 하지만, 프리미티브 배열은 C로 선언된 것처럼 직접 읽고 쓸 수 있습니다.

VM 구현을 제한하지 않고 최대한 효율적인 인터페이스를 만들기 위해 Get<PrimitiveType>ArrayElements 호출 패밀리를 통해 런타임에서 실제 요소에 대한 포인터를 반환하거나 일부 메모리를 할당하고 사본을 만들 수 있습니다. 어떤 방법을 사용하든, 반환된 원시 포인터는 대응하는 Release 호출을 실행할 때까지 유효하므로 데이터를 복사하지 않은 경우 배열 객체가 고정되고 힙을 압축하는 과정에서 재배치할 수 없습니다. Get하는 모든 배열을 직접 Release해야 합니다. 또한 Get 호출에 실패할 경우 코드에서 나중에 NULL 포인터를 Release하지 않도록 해야 합니다.

isCopy 인수에 대해 NULL이 아닌 포인터를 전달하면 데이터가 복사되었는지 여부를 확인할 수 있습니다. 이 인수는 거의 유용하지 않습니다.

Release 호출은 세 개의 값 중 하나를 가질 수 있는 mode 인수를 사용합니다. 런타임에 수행하는 작업은 실제 데이터에 대한 포인터가 반환되었는지 또는 데이터 사본이 반환되었는지에 따라 달라집니다.

  • 0
    • 실제 데이터: 배열 객체가 고정 취소됩니다.
    • 사본: 데이터가 다시 복사됩니다. 사본이 있던 버퍼가 해제됩니다.
  • JNI_COMMIT
    • 실제 데이터: 아무 작업도 수행되지 않습니다.
    • 사본: 데이터가 다시 복사됩니다. 사본이 있던 버퍼가 해제되지 않습니다.
  • JNI_ABORT
    • 실제 데이터: 배열 객체가 고정 취소됩니다. 이전 쓰기가 중단되지 않습니다.
    • 사본: 사본이 있던 버퍼가 해제되고, 사본의 변경 사항이 손실됩니다.

isCopy 플래그를 검사하는 한 가지 이유는 배열을 변경한 후 JNI_COMMIT을 사용해서 Release를 호출해야 하는지 확인하기 위한 것입니다. 변경 작업과 배열의 내용을 사용하는 코드 실행을 번갈아 하는 경우, 작동하지 않는 커밋을 건너뛰어도 됩니다. 플래그를 검사하는 또 다른 이유는 JNI_ABORT를 효율적으로 처리하기 위한 것입니다. 예를 들어 배열을 가져와 현재 위치에서 수정하고 조각을 다른 함수에 전달한 다음, 변경 사항을 취소할 수 있습니다. JNI에서 새로운 사본을 만들고 있다는 것을 알고 있다면 다른 '편집 가능한' 사본을 만들 필요가 없습니다. JNI에서 원본을 전달하는 경우 고유한 사본을 만들어야 합니다.

*isCopy가 false이면 Release 호출을 건너뛸 수 있다고 가정하는 실수를 하는 경우가 많습니다(예제 코드에서 반복적으로 나타남). 하지만 그렇지 않습니다. 복사 버퍼를 할당하지 않은 경우 원래 메모리가 고정되어야 하며, 가비지 컬렉터를 통해 이동할 수 없습니다.

또한 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 호출은 예외를 throw할 수 있지만, 오류를 확인하는 더 간단한 방법을 제공하는 경우도 많습니다. 예를 들어 NewString이 NULL이 아닌 값을 반환하는 경우 예외를 확인할 필요가 없습니다. 그러나 CallObjectMethod와 같은 함수를 사용하여 메서드를 호출하는 경우 예외가 throw되면 반환 값이 유효하지 않으므로 항상 예외를 확인해야 합니다.

해석된 코드에서 예외가 throw되는 경우 네이티브 스택 프레임이 해제되지 않으며, Android에서는 C++ 예외가 아직 지원되지 않습니다. JNI ThrowThrowNew 명령은 단순히 현재 스레드에 예외 포인터를 설정합니다. 네이티브 코드에서 관리 대상으로 돌아가면 예외가 확인되고 적절하게 처리됩니다.

네이티브 코드에서 ExceptionCheck 또는 ExceptionOccurred를 호출하여 예외를 'catch'하고, ExceptionClear를 사용하여 지울 수 있습니다. 평소와 같이 예외를 처리하지 않고 삭제하면 문제가 발생할 수 있습니다.

Throwable 객체 자체를 조작하기 위한 기본 제공 함수는 없으므로, 예외 문자열을 가져오려는 경우 Throwable 클래스를 찾고, getMessage "()Ljava/lang/String;"의 메서드 ID를 검색하여 호출하고, 결과가 NULL이 아니면 GetStringUTFChars를 사용하여 printf(3) 또는 그와 동등한 항목에 전달할 수 있는 값을 가져와야 합니다.

확장 검사

JNI는 오류 검사를 거의 하지 않습니다. 오류가 있으면 일반적으로 비정상 종료가 발생합니다. Android에서는 CheckJNI라는 모드도 제공됩니다. 이 모드에서는 JavaVM 및 JNIEnv 함수 테이블 포인터가 표준 구현을 호출하기 전에 일련의 확장 검사를 수행하는 함수 테이블로 전환됩니다.

추가 검사에는 다음이 포함됩니다.

  • 배열: 음수 크기의 배열을 할당하려는 시도
  • 잘못된 포인터: 잘못된 jarray/jclass/jobject/jstring을 JNI 호출에 전달 또는 nullable이 아닌 인수를 사용하여 NULL 포인터를 JNI 호출에 전달
  • 클래스 이름: 'java/lang/String' 스타일의 클래스 이름이 아닌 값을 JNI 호출에 전달
  • 중요한 호출: '중요한' Get과 그 해제 간의 JNI 호출 수행
  • Direct ByteBuffer: 잘못된 인수를 NewDirectByteBuffer에 전달
  • 예외: 대기중인 예외가 있는 동안 JNI 호출 수행
  • JNIEnv*s: 잘못된 스레드의 JNIEnv* 사용
  • jfieldID: NULL jfieldID 사용, jfieldID를 사용하여 필드를 잘못된 형식의 값으로 설정(예: String 필드에 StringBuilder 할당 시도), 정적 필드에 jfieldID를 사용하여 인스턴스 필드를 설정하거나 그 반대의 경우, 한 클래스의 jfieldID를 다른 클래스의 인스턴스에서 사용
  • jmethodID: Call*Method JNI 호출을 수행할 때 잘못된 종류의 jmethodID 사용: 잘못된 반환 형식, 정적/비정적 불일치, 잘못된 'this' 형식(비정적 호출의 경우) 또는 잘못된 클래스(정적 호출의 경우)
  • 참조: 잘못된 종류의 참조에서 DeleteGlobalRef/DeleteLocalRef 사용
  • 해제 모드: 잘못된 해제 모드를 해제 호출에 전달(0, JNI_ABORT 또는 JNI_COMMIT 이외의 값)
  • 형식 안전성: 네이티브 메서드에서 호환되지 않는 형식 반환(예: String을 반환하는 것으로 선언된 메서드에서 StringBuilder 반환)
  • UTF-8: 잘못된 Modified 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)를 호출합니다. 인수는 'undecorated' 라이브러리 이름이므로 libfubar.so를 로드하려면 "fubar"를 전달합니다.

런타임에서 네이티브 메서드를 찾을 수 있는 두 가지 방법이 있습니다. RegisterNatives를 사용하여 명시적으로 등록하거나, dlsym을 사용하여 런타임을 통해 동적으로 검색할 수 있습니다. RegisterNatives의 장점은 기호가 있는지 미리 검사할 뿐 아니라, JNI_OnLoad 이외의 항목을 내보내지 않으므로 더 작고 빠른 공유 라이브러리를 가질 수 있다는 것입니다. 런타임을 통해 함수를 검색할 경우의 장점은 작성할 코드가 줄어든다는 것입니다.

RegisterNatives를 사용하려면 다음을 수행합니다.

  • JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 함수를 제공합니다.
  • JNI_OnLoad에서 RegisterNatives를 사용하여 모든 네이티브 메서드를 등록합니다.
  • 사용자 JNI_OnLoad만 라이브러리에서 내보내도록 -fvisibility=hidden을 사용하여 빌드합니다. 이렇게 하면 더 빠르고 작은 코드가 생성되며, 앱에 로드되는 다른 라이브러리와 잠재적인 충돌을 피할 수 있습니다. 그러나 네이티브 코드에서 앱이 비정상 종료되는 경우에는 덜 유용한 스택 추적이 생성됩니다.

정적 초기화 프로그램은 다음과 같아야 합니다.

Kotlin

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

Java

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

C++로 작성된 경우 JNI_OnLoad 함수는 다음과 같아야 합니다.

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

        // Get jclass with env->FindClass.
        // Register methods with env->RegisterNatives.

        return JNI_VERSION_1_6;
    }

네이티브 메서드 '검색'을 대신 사용하려면 메서드 이름을 특정 방식으로 지정해야 합니다(자세한 내용은 JNI 사양 참조). 즉, 메서드 서명이 잘못된 경우 메서드가 실제로 호출될 때까지 알 수 없습니다.

네이티브 메서드가 있는 클래스가 하나뿐이면 System.loadLibrary 호출을 이 클래스에 포함하는 것이 좋습니다. 또는 Application에서 호출하여 항상 로드되고, 항상 조기에 로드되도록 해야 합니다.

JNI_OnLoad에서 FindClass 호출을 수행하면 공유 라이브러리를 로드하는 데 사용된 클래스 로더의 컨텍스트에서 클래스가 확인됩니다. 일반적으로 FindClass는 Java 스택의 맨 위에 있는 메서드와 연결된 로더를 사용하거나, 스레드가 방금 연결되었기 때문에 로더가 없는 경우 '시스템' 클래스 로더를 사용합니다. 이 때문에 JNI_OnLoad가 클래스 객체 참조를 검색 및 캐시하기에 편리한 장소가 됩니다.

64비트 고려 사항

64비트 포인터를 사용하는 아키텍처를 지원하려면 Java 필드에 네이티브 구조에 대한 포인터를 저장할 때 int가 아닌 long 필드를 사용합니다.

지원되지 않는 기능/이전 버전과의 호환성

다음 예외를 제외하고, 모든 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, NewGlobalRefDeleteWeakGlobalRef에만 약한 글로벌 참조를 전달할 수 있었습니다. 사양에 따라 프로그래머는 약한 글로벌 참조를 사용하기 전에 약한 글로벌 변수에 대한 하드 참조를 만들어 제한이 발생하지 않도록 해야 합니다.

    Android 4.0(Ice Cream Sandwich)부터 약한 글로벌 참조를 다른 JNI 참조처럼 사용할 수 있습니다.

  • 로컬 참조

    Android 4.0(Ice Cream Sandwich)까지는 로컬 참조가 실제로 직접 포인터였습니다. Ice Cream Sandwich에서 더 나은 가비지 컬렉터를 지원하는 데 필요한 간접 참조가 추가되었지만, 이전 릴리스에는 검색할 수 없는 많은 JNI 버그가 있습니다. 자세한 내용은 ICS의 JNI 로컬 참조 변경 사항을 참조하세요.

    Android 8.0 이전의 Android 버전에서는 로컬 참조 수가 버전별 한도로 제한됩니다. Android 8.0부터 Android에서 무제한 로컬 참조를 지원합니다.

  • GetObjectRefType을 사용하여 참조 유형 결정

    Android 4.0(Ice Cream Sandwich)까지는 직접 포인터를 사용했기 때문에(위 내용 참조) GetObjectRefType을 올바르게 구현할 수 없었습니다. 대신, 약한 글로벌 변수 테이블, 인수, 로컬 변수 테이블, 글로벌 변수 테이블을 순서대로 살펴보는 휴리스틱을 사용했습니다. 직접 포인터를 처음 찾았을 때 참조가 조사 중인 형식이라고 보고합니다. 예를 들어 정적 네이티브 메서드에 암시적 인수로 전달된 jclass와 동일한 글로벌 jclass에서 GetObjectRefType을 호출하면, JNIGlobalRefType 대신 JNILocalRefType이 표시됩니다.

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 출력에서 라이브러리 로드 관련 메시지를 확인합니다.
  • 이름 또는 서명 불일치로 인해 메서드를 찾을 수 없습니다. 이 오류의 일반적인 원인은 다음과 같습니다.
    • 지연 메서드 검색의 경우 extern "C" 및 적절한 공개 상태(JNIEXPORT)로 C++ 함수를 선언하지 않았습니다. Ice Cream Sandwich 이전에는 JNIEXPORT 매크로가 부정확했기 때문에 이전 jni.h에서 새 GCC를 사용할 수 없습니다. arm-eabi-nm을 사용하여 라이브러리에 표시되는 기호를 확인할 수 있습니다. 손상된 것처럼 보이거나(Java_Foo_myfunc이 아닌 _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass와 같이 표시됨), 기호 유형이 대문자 'T'가 아닌 소문자 't'인 경우 선언을 조정해야 합니다.
    • 명시적 등록의 경우 메서드 서명을 입력할 때 사소한 오류가 발생합니다. 등록 호출에 전달하는 내용이 로그 파일의 서명과 일치하는지 확인합니다. 'B'는 byte이고 'Z'는 boolean입니다. 서명의 클래스 이름 구성 요소는 'L'로 시작하고 ';'로 끝납니다. '/'를 사용하여 패키지/클래스 이름을 구분하고, '$'를 사용하여 내부 클래스 이름을 구분합니다(예: Ljava/util/Map$Entry;).

javah를 사용하여 JNI 헤더를 자동으로 생성하면 몇 가지 문제를 방지하는 데 도움이 될 수도 있습니다.

FAQ: FindClass에서 내 클래스를 찾지 못한 이유는 무엇인가요?

이 권장 사항은 대부분 GetMethodID 또는 GetStaticMethodID를 사용하여 메서드를 찾지 못한 경우나 GetFieldID 또는 GetStaticFieldID를 사용하여 필드를 찾지 못한 경우에도 적용됩니다.

클래스 이름 문자열의 형식이 올바른지 확인합니다. JNI 클래스 이름은 패키지 이름으로 시작하고 java/lang/String과 같이 슬래시로 구분됩니다. 배열 클래스를 검색하는 경우 적절한 개수의 대괄호로 시작해야 하며, 클래스를 'L'과 ';'로 래핑해야 하므로 String의 1차원 배열은 [Ljava/lang/String;이 됩니다. 내부 클래스를 검색하는 경우 '.' 대신 '$'를 사용합니다. 일반적으로 .class 파일에서 javap를 사용하여 클래스의 내부 이름을 확인하는 것이 좋습니다.

ProGuard를 사용하는 경우 ProGuard에서 클래스를 제거하지 않았는지 확인합니다. 이 오류는 클래스/메서드/필드가 JNI에서만 사용되는 경우에 발생할 수 있습니다.

클래스 이름이 제대로 표시되는 경우, 클래스 로더 문제일 수 있습니다. FindClass는 코드와 관련된 클래스 로더에서 클래스 검색을 시작하려고 하며, 다음과 같은 호출 스택을 검사합니다.

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

맨 위에 있는 메서드는 Foo.myfunc입니다. FindClassFoo 클래스와 관련된 ClassLoader 객체를 찾아서 사용합니다.

일반적으로 이 기능은 예상한 대로 실행됩니다. pthread_create를 호출한 다음 AttachCurrentThread를 통해 연결하여 스레드를 직접 만드는 경우 문제가 발생할 수 있습니다. 지금은 애플리케이션의 스택 프레임이 없습니다. 이 스레드에서 FindClass를 호출하는 경우 JavaVM이 애플리케이션과 관련된 클래스 로더가 아닌 '시스템' 클래스 로더에서 시작되므로 앱 특정 클래스를 찾으려고 하면 실패합니다.

이 문제를 해결할 수 있는 몇 가지 방법이 있습니다.

  • JNI_OnLoad에서 FindClass 검색을 한 번 수행하고 나중에 사용할 수 있도록 클래스 참조를 캐시합니다. JNI_OnLoad를 실행하는 과정에서 수행한 FindClass 호출은 System.loadLibrary를 호출한 함수와 관련된 클래스 로더를 사용합니다. 이 특수 규칙은 보다 편리한 라이브러리 초기화를 위해 제공됩니다. 앱 코드에서 라이브러리를 로드하는 경우 FindClass에 올바른 클래스 로더가 사용됩니다.
  • Class 인수를 사용할 네이티브 메서드를 선언한 다음 Foo.class를 전달하여 클래스 인스턴스를 필요한 함수에 전달합니다.
  • ClassLoader 객체에 대한 참조를 편리한 위치에 캐시하고 loadClass 호출을 직접 실행합니다. 이 경우 추가 작업이 필요합니다.

FAQ: 원시 데이터를 네이티브 코드와 공유하려면 어떻게 해야 하나요?

관리 코드와 네이티브 코드의 원시 데이터가 모두 포함된 대규모 버퍼에 액세스해야 하는 경우도 있습니다. 일반적인 예로, 비트맵 또는 사운드 샘플을 조작하는 경우입니다. 기본 방법에는 두 가지가 있습니다.

byte[]에 데이터를 저장할 수 있습니다. 관리 코드에서 빠르게 액세스할 수 있는 방법입니다. 그러나 네이티브 코드의 경우 데이터를 복사하지 않으면 데이터에 액세스하지 못할 수도 있습니다. 일부 구현에서는 GetByteArrayElementsGetPrimitiveArrayCritical이 관리형 힙의 원시 데이터에 대한 실제 포인터를 반환하지만, 네이티브 힙에 버퍼를 할당하고 데이터를 복사하는 구현도 있습니다.

또는, 직접 바이트 버퍼에 데이터를 저장할 수 있습니다. 직접 바이트 버퍼는 java.nio.ByteBuffer.allocateDirect 또는 JNI NewDirectByteBuffer 함수를 사용하여 만들 수 있습니다. 일반 바이트 버퍼와 달리, 관리형 힙에 저장소가 할당되지 않으며 항상 네이티브 코드에서 직접 액세스할 수 있습니다(GetDirectBufferAddress를 사용하여 주소를 가져옴). 직접 바이트 버퍼 액세스가 구현된 방식에 따라 관리 코드에서 데이터에 액세스하는 속도가 매우 느릴 수 있습니다.

다음 두 가지 요인에 따라 사용할 방법을 선택해야 합니다.

  1. 대부분의 데이터 액세스가 Java 또는 C/C++로 작성된 코드에서 발생합니까?
  2. 데이터가 궁극적으로 시스템 API에 전달되는 경우 어떤 형식이어야 합니까? 예를 들어 데이터가 궁극적으로 byte[]를 사용하는 함수에 전달되는 경우 직접 ByteBuffer에서 처리하는 것은 부적절할 수 있습니다.

어떤 방법이 더 적합한지 확실하지 않은 경우 직접 바이트 버퍼를 사용합니다. 직접 바이트 버퍼 지원이 JNI에 직접 빌드되었으며, 향후 릴리스에서는 성능이 향상될 것입니다.