Post

JNI_OnUnload는 언제 호출되는가

JNI_OnUnload 호출 시점 찾아내기

JNI_OnUnload는 언제 호출되는가

JNI_OnUnload는 언제 호출되는가

안드로이드 네이티브 모듈에서 정리 로직을 JNI_OnUnload에 넣어뒀는데, 아무리 봐도 안 불리는 것 같았다. 로그를 심어도 안 찍히고, 앱을 강제로 죽여봐도 조용했다. JNI_OnLoad는 라이브러리 로드하자마자 잘만 불리는데 짝꿍인 언로드는 감감무소식이라, 궁금하면 못 참는 성격이라 결국 최소 재현 환경을 만들어서 이 함수가 정확히 언제 불리는지 끝까지 파봤다. 결론부터 말하면, JNI_OnUnload는 “라이브러리를 정리할 때” 불리는 함수가 아니다.

환경은 macOS(Apple Silicon) + OpenJDK 21로 재현했다. 데스크톱 JVM과 안드로이드 ART가 이 부분에선 똑같이 동작해서, 검증은 빠른 데스크톱 쪽에서 먼저 하고 안드로이드로 옮겼다.

일단, 진짜 호출되지 않는다

첫 가정은 단순했다. JNI_OnLoad의 대칭이니까 라이브러리가 내려갈 때, 늦어도 프로세스가 끝날 때쯤엔 불리겠지. 그런데 아니었다.

데스크톱에서 제일 단순하게 재현해봤다. 네이티브는 딱 두 함수만 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
jint JNI_OnLoad(JavaVM *vm, void *reserved) {

    { . . . }

    printf("  >> JNI_OnLoad   called\n"); 
    
    { . . . }
    
    return JNI_VERSION_1_6;
}

void JNI_OnUnload(JavaVM *vm, void *reserved) {

    { . . . }

    printf("  >> JNI_OnUnload called\n");
    
    { . . . }
}

자바 쪽은 그냥 main에서 바로 로드하고, GC를 스무 번쯤 때려봤다.

1
2
3
4
5
6
7
public class NegativeMain {
    public static void main(String[] args) throws Exception {
        System.load(args[0]);           // main이 직접 호출
        for (int index = 0; index < 20; index++) { System.gc(); Thread.sleep(50); }
        System.out.println("done");
    }
}

결과.

1
2
  >> JNI_OnLoad   called
done

JNI_OnLoad만 찍히고 JNI_OnUnload는 끝까지 안 나온다. GC를 아무리 돌려도 소용없었다. 여기서 다시 생각해보면서 접근했다.

스펙을 다시 읽어봤다

JNI 스펙을 다시 뒤져보니 답이 있었다. 요지는 이렇다. VM은 네이티브 라이브러리를 담고 있는 클래스로더가 가비지 컬렉션될 때 JNI_OnUnload를 부른다. 라이브러리를 “닫을 때”가 아니라, 그 라이브러리를 로드한 클래스로더가 수거될 때다.

이 한 문장이 전부를 설명한다. main에서 로드하면 그 라이브러리는 애플리케이션 클래스로더(안드로이드라면 PathClassLoader)에 매달린다. 그런데 이 로더는 프로세스가 살아있는 동안 절대 수거되지 않는다. 앱의 클래스 대부분을 얘가 들고 있으니 당연한 얘기다. 로더가 안 죽으니 라이브러리도 안 내려가고, 그래서 JNI_OnUnload도 영원히 안 불린다. GC를 아무리 돌려도 애초에 수거 대상이 아니었던 거다.

System.load는 누구한테 매달리나

그럼 “라이브러리를 로드한 클래스로더”는 어떻게 정해지나. System.loadSystem.loadLibrary는 내부적으로 자기를 호출한 클래스를 찾아서(Reflection.getCallerClass) 그 클래스의 정의 클래스로더에 네이티브 라이브러리를 등록한다. 즉 어느 클래스가 load를 호출했느냐가 라이브러리의 수명을 결정한다.

main에서 부르면 호출 클래스는 NegativeMain, 정의 로더는 앱 로더 → 안 죽음. 반대로 내가 마음대로 버릴 수 있는 로더 안의 클래스에서 부르면, 라이브러리는 그 로더에 묶인다. 그 로더만 수거되게 만들면 JNI_OnUnload를 볼 수 있다는 뜻이다.

그래서 일부러 수거시켜봤다

작전은 이렇다. 버려도 되는 클래스로더를 하나 만들고, 그 안에 System.load만 호출하는 작은 홀더 클래스를 둔다. 로드가 끝나면 로더든 클래스든 인스턴스든 붙잡고 있는 참조를 전부 끊고 GC를 유도한다.

홀더는 이게 전부다.

1
2
3
4
5
public class LibHolder {
    public static void load(String path) {
        System.load(path);   // 호출 클래스가 LibHolder → 라이브러리는 이 로더에 묶인다
    }
}

여기서 첫 번째 함정. 이 LibHolder가 앱 클래스패스에 그냥 놓여 있으면, 자식 로더가 로딩을 부모(앱 로더)에게 위임해버려서 결국 앱 로더가 정의 로더가 된다. 그럼 도로 원점이다. 그래서 두 가지를 지켜야 한다. 부모를 앱 로더가 아니라 플랫폼(부트) 로더로 주고, LibHolder는 오직 이 버릴 로더의 경로에서만 보이게 한다. 이렇게 해야 버릴 로더가 LibHolder의 정의 로더가 되고, 나중에 같이 수거된다.

드라이버 쪽. 강한 참조가 하나라도 살아남으면 수거가 안 되니까, 로더와 클래스 참조를 전부 메서드 안에 가두고 밖으로는 WeakReference만 내보냈다.

1
2
3
4
5
6
7
8
9
10
11
static WeakReference<ClassLoader> useInIsolation(String pluginDir, String libPath) throws Exception {
    URL url = new File(pluginDir).toURI().toURL();
    // 부모 = 플랫폼 로더. LibHolder를 이 로더가 "정의"하게 만드는 게 핵심.
    URLClassLoader loader = new URLClassLoader("poc-loader",
            new URL[]{ url }, ClassLoader.getPlatformClassLoader());

    Class<?> holder = Class.forName("LibHolder", true, loader);
    holder.getMethod("load", String.class).invoke(null, libPath);

    return new WeakReference<>(loader);   // 약한 참조만 탈출시킨다
}

호출부는 이 약한 참조가 비워질 때까지 GC를 돌린다.

1
2
3
4
5
6
WeakReference<ClassLoader> ref = useInIsolation(pluginDir, libPath);
for (int index = 0; index < 50 && ref.get() != null; index++) {
    System.gc();
    Thread.sleep(50);
}
System.out.println("collected = " + (ref.get() == null));

실행하면 이렇게 나온다.

1
2
3
  >> JNI_OnLoad   called
  >> JNI_OnUnload called      <-- 여기
collected = true

드디어 불렸다. 로더가 수거되는 그 순간에 라이브러리가 내려가면서 JNI_OnUnload가 찍힌다. collected = true가 같이 나오는 걸로 로더가 실제로 죽었다는 것도 확인된다.

두 번째 함정은 참조 관리인데, 이게 은근히 까다롭다. static 필드에 인스턴스를 하나 걸어두거나, 그 로더로 로드한 클래스에서 스레드를 하나 돌리거나, 콜백을 어디 등록해두면 그걸로 끝이다. 로더가 안 죽는다. 그래서 위에서 굳이 모든 강한 참조를 useInIsolation 프레임 안에 가두고, 메서드가 끝나면 자연스럽게 unreachable이 되도록 만든 거다. 실무 코드에서 “왜 안 불리지” 싶으면 열에 아홉은 어딘가 살아있는 참조 때문이다.

finalizer도 하나 짚고 넘어가면, 예전 JDK에선 NativeLibrary의 finalizer가 언로드를 처리해서 System.runFinalization()을 같이 불러줘야 확실했다. 요즘 JDK는 Cleaner 기반이라 그럴 필요가 없다. 실제로 21에서 runFinalization()을 빼고 돌려봐도 JNI_OnUnload는 똑같이 불렸다. 어차피 deprecated라 지우는 게 낫다.

컴파일하다 만난 잔가시 (macOS)

본질과는 상관없지만 시간을 좀 잡아먹어서 적어둔다. macOS에서 네이티브를 컴파일하는데 jni.hjni_md.h를 못 찾는다고 에러가 났다.

1
jni.h:45:10: fatal error: 'jni_md.h' file not found

jni.h는 플랫폼별 헤더인 jni_md.h를 include하는데, 이 파일이 OS마다 다른 하위 폴더에 들어있다. 리눅스는 include/linux/, macOS는 include/darwin/, 윈도우는 include/win32/. -I에 OS별 디렉토리를 안 넣어주면 못 찾는다. 하드코딩하기 싫어서 그냥 찾아서 넣게 했다.

1
2
JNI_MD_DIR="$(dirname "$(find "$JH/include" -name jni_md.h | head -1)")"
gcc -shared -fPIC -I"$JH/include" -I"$JNI_MD_DIR" native.c -o libnative.so

다시 안드로이드로

원래 목적이 안드로이드였으니 돌아왔다. 원리는 데스크톱과 완전히 똑같고, 클래스로더 종류만 바뀐다. 데스크톱에선 URLClassLoader를 버릴 로더로 썼지만, 안드로이드는 dex를 다루니까 DexClassLoader를 쓴다.

핵심은 여전히 하나다. 앱의 PathClassLoader는 안 죽으니, 홀더 클래스를 별도의 일회용 로더에 올려야 한다. 그래서 LibHolder를 따로 dex로 말아서(assets에 넣고) DexClassLoader로 로드한다. 부모는 부트 로더로 줘서 프레임워크 클래스는 그대로 쓰되 LibHolder는 이 일회용 로더가 정의하게 한다.

1
2
3
4
5
6
val parent = ClassLoader.getSystemClassLoader().parent   // 부트 로더
val loader = DexClassLoader(dexPath, codeCacheDir.absolutePath, null, parent)
val holder = Class.forName("LibHolder", true, loader)
holder.getMethod("load", String::class.java)
      .invoke(null, "${applicationInfo.nativeLibraryDir}/libnative.so")
// 이후 loader/holder 참조 끊고 Runtime.getRuntime().gc()

.so는 그냥 jniLibs에 넣으면 nativeLibraryDir로 추출되니 그 절대경로를 System.load에 넘기면 된다. 확인은 adb logcat으로 하면 JNI_OnLoad 다음에 JNI_OnUnload가 찍히는 게 보인다.

Conclusion

여기까지 파보고 내린 결론.

JNI_OnUnload의 호출 시점은 비결정적이다. GC가 그 로더를 실제로 수거해야 불리는데, GC가 언제 돌지, 그때 로더가 정말 unreachable일지는 보장할 수 없다. “곧 불리겠지”를 기대하면 안 된다.

그리고 프로세스가 그냥 죽는 상황, 그러니까 사용자가 앱을 스와이프로 종료하거나 시스템이 메모리 회수하려고 프로세스를 죽이거나 크래시가 나거나 하는 경우에 JNI_OnUnload는 안 불린다고 보는 게 맞다. 그래서 “앱 종료할 때 네이티브 리소스 정리”를 이 함수에 의존하면 안 된다. 그건 이 훅의 용도가 아니다.

그럼 언제 쓰나. JNI_OnUnload가 제값을 하는 건 플러그인처럼 라이브러리를 동적으로 붙였다 떼는 구조에서다. 특정 클래스로더 단위로 라이브러리를 올렸다가, 그 로더를 통째로 버리면서 네이티브 쪽 정리를 같이 하고 싶을 때. 이럴 때는 로더 수명을 내가 통제하니까 언로드도 통제된다.

한 줄로 남기면, JNI_OnUnload는 “라이브러리 정리 훅”이 아니라 “클래스로더 수거 훅”이다. 처음에 이걸 착각해서 한참 헤맸는데, 관점을 클래스로더 생명주기로 바꾸고 나니 전부 말이 됐다.

This post is licensed under CC BY 4.0 by the author.
If you find any errors, please let me know by comment or email. Thank you.

© Ruffalo. Some rights reserved.

I'm

Using the Chirpy theme for Jekyll.