요 근래 이직 준비를 하면서 바빠 블로그를 신경 쓰지 못했는데,
운 좋게 서비스 회사에 이직을 하게 돼서
기쁜 마음으로 다시 블로그를 켜게 되었다.
무슨 주제로 포스팅을 할까 하다가
첫 직장에서 경험했던 업무들 중에 가장 인상 깊었던 일을
리마인드 겸 정리하고 공유하고자 한다.
때는 바야흐로 입사한 지 6개월도 되지 않은 따끈따끈한 신입시절

나는 통합관제 라이선스 모듈 기능을 개발하게 되었고,
라이선스 키를 암호화/복호화하는 모듈을 연동하게 되었다.
연동해야 하는 프로젝트는 Java + Spring Boot로 구성된 서버였고
암/복호화 모듈은 C언어로 구성되어 있었다.
나는 그래서 연동을 위해서 JNI를 처음 써보게 되었다.
일은 여기서 터진다.
나는 C언어도 처음이고 JNI도 처음 써보는데 Java memory leak 이슈를 만나게 되었다.
사실 처음에는 Java memory leak 현상 인지도 몰랐다.
그저 암/복호화 모듈을 Java 프로젝트에 연동 후에 기동하여
해당 기능을 실행하니까.. Java 프로젝트가 그냥 뻗어버렸다!
IDE 로그에 별다른 것도 남기지 않고 갑자기 죽어버리니
당최 디버깅도 어려웠고 이것저것 삽질을 하고 있었는데
Java 프로젝트 경로 내에 처음 보는 파일들이 생겨있던걸 확인하게 되었다.
알고 보니 JNI로 연동된 암/복호화 모듈에서 에러가 난 부분에 대해서
에러 로그 파일을 떨군(?) 것이다.
로그 파일을 분석해보니 memory 관련 이슈인걸 알게 되었고,
확인해보니 JVM에는 JNI용 메모리를 관리하는 영역이 존재한다는 걸 알게 되었다!
이때 내가 JNI memory leak 이슈를 어떻게 확인했고
어떻게 대응했는지 정리해 보고자 한다.
(에러 파일은 대충 이러한 내용이었다.)
# A fatal error has been detected by the Java Runtime Environment:
#
# java.lang.OutOfMemoryError : unable to create new native Thread
# A fatal error has been detected by the Java Runtime Environment:
#
# java.lang.OutOfMemoryError: requested 32756 bytes for ChunkPool::allocate. Out of swap space?
#
# Internal Error (allocation.cpp:166), pid=2290, tid=27 # Error: ChunkPool::allocate
일단,
JNI라는 것은 무엇일까?
JNI는 Java Native Interface라는 단어의 약자로 JVM 위에서 실행되고 있는
자바 코드가 네이티브 응용 프로그램 (하드웨어와 운영 체제 플랫폼에 종속된 프로그램들)
그리고 C, C++과 어셈블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나
반대로 호출되는 것을 가능하게 하는 프로그래밍 프레임워크이다.
기본적인 사항들을 확인해보면
- 컴파일
텍스트인 소스코드를 개발자가 작성하여, 컴퓨터에게 일을 시키기 위해서는
이를 컴퓨터가 이해할 수 있는 명령체계로 변환을 시켜야 하는데 이를 컴파일링이라고 한다.
각 프로그래밍 언어별로 문법이 서로 다르고, 이 다른 문법을 해석하여 컴퓨터가 이해하고
실행할 수 있는 형식으로 변화시켜 주는 것이 바로 각 언어 컴파일러의 역할이다.
- 개요
C와 Java는 개념부터 실행환경까지 모두 다르다!
C는 직접 바이너리 코드로 변환되어 CPU에게 직접 일을 시키기 때문에 속도가 빠르지만
하드웨어와 직접 연결되어 있기 때문에 하드웨어 별로 호환성이 보장되지 않고,
컴퓨터 자원 관련하여 신경 써야 할 것들이 많다.
Java는 직접 바이너리 코드로 변환되는 것이 아니고 Java 프로그램이 실행되면,
먼저 자바 코드가 메모리에 올라가는 것이 아닌 JVMD이라는 자바 프로그램 실행 환경이
돌아가게 되고, 그 위에 자바 코드가 돌아가게 된다.
이런 두 언어를 통합시키는 것을 가능하게 하기 위해 JNI가 생겨났다.
- JNI
C언어에서 만들어낸 dll, so 파일을 이용하여 해당 라이브러리를 자바 객체로서 가져오고
자바 프로그램에서 이 객체를 사용하여 자바의 인자 값을 전해주거나
C 모듈의 반환 값을 가져오거나, C 로직으로 작업을 수행하는 등의 일을 할 수 있다.
여기서 사용되는 C 함수는, 자바 바이트 코드로서 JVM이 실행시키는 것이 아니라
단지 JVM이 해당 바이너리 코드를 메모리에 올려서
독립된 C 실행 프로그램으로서 실행하게 하고
그 사이에 인자 값이나 반환 값에 대한 데이터 교환의 역할만을 보장하는 것이다.
실행되는 C 로직은 JVM에 의해 실행되는 것이 아니기에
GC와 같은 혜택을 받지 못하고, 하드웨어 별로 영향을 받게 된다.
즉, 메모리 관리가 굉장히 중요한 것이다!
JNI를 사용하기 위한 사용법은 어떻게 될까?
1. 자바 파일 안에 호출하려고 하는 C 함수에 대한 선언문과 호출문,
그리도 dll 로드문을 작성
2. native call을 하려는 C 함수에 대한 정의문 및 헤더 파일을 작성
3. 만들어진 C 파일을 dll (window용)로 빌드
4. 빌드한 dll 파일을 자바 코드에서 호출해서 만든 C 함수를 자바에서 사용
예제 코드로 살펴보자
public class HelloWorld {
private native void print();
static {
System.loadLibrary("Native");
}
public static void main(String[] args) {
new HelloWorld().print();
}
}
- 자바 파일에 JNI 호출을 위한 코드를 작성한다.
여기서 print 메서드 선언은 C 코드로 구현해야 할 함수 부분에 대한 native 선언문이다.
SystemloadLibrary 메서드를 사용해 C 언어로 컴파일된 dll 파일을 읽는다.
javac HelloWorld.java
- 보통 IDE 경우에는 자바 파일을 저장만 해도 자동으로 컴파일이 되어
프로젝트 경로에 class 파일이 만들어져 있는데, 없다면 javac로 만들어준다.
javah HelloWorld
- 이때 javah의 대상은 class 파일 이므로 해당 경로 내에서 수행한다.
#include<jni.h>
#include "jni_HelloWorld.h"
#include<stdio.h>
JNIEXPORT void JNICALL Java_jni_HelloWorld_print(JNIEnv *env, jobject obj)
{
printf("Hello world!\n");
return;
}
- 헤더 파일에 대한 C 함수 몸체를 작성하고 생성한 헤더 파일을 include 해준다
함수명 및 리턴 형식, 인자 형시식은 생성한 헤더 파일 내의 함수 선언문을 참고로 작성하면 된다.
해당 파일을 C 파일로 저장하고 헤더 파일과 동일한 경로에 놓아준다.
cl -I"C:\jdk1.7\include" -I"C:\jdk1.7\include\win32" -LD HelloWorld.c -FeNative.dll
- 해당 명령어를 이용해 dll 파일을 만들고 생성된 dll 파일을 자바 프로젝트 경로에 추가
(dll : 윈도용 / so : 리눅스용)
관련 문법을 몇 개만 보자
- 데이터 타입

- 문법
1. 함수 이름은 반드시 <반환값>Java<패키지명><클래스명><메서트명> 이어야 한다.
2. 함수 인수의 첫 번째는 꼭 JNIEnv여야 하며, 두 번째는 jobject 이어야 한다.
3. cpp로 작성하였다면 external "C"를 선언한다.
4. JNIEnv* env와 jobject thiz는 JNI로부터 받은 변수 이므로 함수 인수에 꼭 사용한다.
5. 포인터 env는 JNI에서 가장 중요한 포인터로, 자바 VM에 대한 인터페이스를 포함하는 구조체의 포인터이다.
6. 포인터 env에는 JNI의 환경 정보가 포함되어 있고, 자바 VM과의 상호작용 및 자바 객체와의 연계에 필요한 모든 기능을 포함한다.
7. 변수 thiz에는 포함된 클래스의 정보가 들어있다.
8. 만약 인수가 추가된다면 env와 변수 thiz 다음에 선언하여 사용한다.
9. 라이브러리 Symbol은 항상 C 형식, C++로 작성했다면 extern "C"로 함수를 감싸서 C 형식으로 변경한다.
10. c++ 형식일 경우 env 포인터를 이용하여 멤버 함수를 호출할 때 멤버 함수에 env포인터를 전달하지 않아야 한다.
그렇다면 메모리는 어떻게 관리되는 걸까?
Java memory 관련한 이슈를 처음 접해봐서 어떤 식으로 접근해야 할지 굉장히 막막했다.
그래서 차근차근 알아보기 위해서 JNI가 Java 프로젝트에서 어떤 방식으로
메모리 관리가 되는지 알아보게 되었다.

일단 기본적으로 JVM에는 각종 데이터가 저장되는 공간이 존재하고
Java heap 영역과는 별개로 JNI가 사용하는 메모리 영역 (Native memory)가 존재한다.
Native memory는 Java Runtime 프로세스에서 사용하는 C-heap 이라고도 칭하며
크기를 제어하는 해당 매개변수가 없고 크기는 운영 체제 프로세스의 최댓값에 따라 다르다고 한다.
Native memory의 주요 역할은 다음과 같다.
1. 자바 heap의 상태 데이터를 관리
2. native stack인 JNI를 호출
3. JIT, JIT 입력 및 출력의 저장
4. NIO 다이렉트 버퍼
5. Thread
6. Class Loader 및 정보의 저장
그렇다면 JNI 메모리는 어떨까?
Java 코드에서 Java 개체는 JVM의 Java heap에 저장되고 GC에 의해 자동으로 회수된다.
하지만 네이티브 코드에서 메모리는 네이티브 메모리에 할당되며
네이티브 프로그래밍 사양에 따라 조작해야 한다.
예를 들어 내가 C 메모리를 할당하려면 malloc() 또는 new를 사용하고
메모리는 회수하려면 free() 또는 delete를 수동으로 사용해야 한다.
그러나 JNI와 위의 두 가지는 약간 다른데, JNI는 네이티브 코드가 JNI 함수를 통해
Java 객체에 액세스 할 수 있도록 Java에 해당하는 참조 유형을 제공한다.
레퍼런스가 가리키는 자바 객체는 일반적으로 자바 heap에 저장되고
네이티브 코드가 가지고 있는 레퍼런스는 네이티브 메모리에 저장된다.
jstring jstr = env->NewStringUTF("Hello World!");
- jstring 유형은 Java의 String 유형에 해당하는 JNI에서 제공한다.
- JNI 함수 NewStringUTF() Java heap에 저장되고 jstring 유형의 참조를 반환하는 String 객체를 구성하는 데 사용된다.
- String 객체에 대한 참조는 Native의 로컬 변수인 jstr에 저장되며 Native memory에 저장된다.
그렇다면 메모리 관리를 어떻게 해야 할까?
위에서 계속 언급된 것처럼 JNI의 데이터의 대게는 GC의 영역에서 벗어나기 때문에
이를 위해서 JNI는 C에서 해당 객체에 대한 참조를 명시적으로 제거할 수 있는 함수를 제공하여
GC가 잘 동작할 수 있도록 메커니즘이 마련되어 있다.
1. String

JNIEXPORT jstring JNICALL
Java_Hello_getText(JNIEnv *env, jobject obj, jstring message)
{
char buff[255];
const char *msg;
/* get string from java String object */
msg = (*env)->GetStringUTFChars(env, message, NULL);
if(msg == NULL)
return NULL; /* OutOfMemoryError already thrown */
printf("received from java : %s\n", msg);
/* free the memory allocated for msg */
(*env)->ReleaseStringUTFChars(env, message, msg);
scanf("%s", buff);
/* create java String object */
return (*env)->NewStringUTF(env, buff);
}
- Java String 은 GetStringUTFChars() 함수를 통해서 C 문자열 (캐릭터의 배열)로 가져올 수 있다.
- 가져온 문자열은 사용이 모두 끝난 후에 ReleaseStringUTFChars() 함수를 통해 할당된 메모리 영역을 반환해야 한다.
- C 문자열로부터 NativeStringUTF() 함수를 통해 Java String 객체를 생성할 수 있고,
이를 통해 생성된 객체는 전적으로 Java에서만 사용되는 것으로 간주되며
C에서 참조를 가지고 있더라도 GC가 이를 확인하지 않으므로 Java에서의 참조만 없다면 해당 객체는 제거될 수 있다.
2. Object Construction
객체를 생성하는 순서는 다음과 같다
- 생성할 객체의 class를 얻는다.
- 생성자를 얻는다.
- 생성자 매개변수와 함께 객체를 생성한다.
JNIEXPORT jobject JNICALL
Java_Hello_getObject(JNIEnv *env, jobject obj)
{
jClass class;
jmethodID constructor;
int parameter = 1;
jobject result;
/* get class */
class = (*env)->FindClass(env, "java/lang/Integer");
if(class == NULL)
return NULL;
/* get constructor */
constructor = (*env)->GetMethodID(env, class, "<init>", "(I)V");
if(constructor == NULL)
return NULL;
/* construct object */
result = (*env)->NewObject(env, class, constructor, parameter);
return result;
}
- FindClass() 함수를 통해 특정 클래스를 얻어올 수 있다.
두 번째 매개변수로 얻어올 클래스의 패키지 경로를 포함한 전체 이름을 적는다.
- GetMethodID() 함수는 특정 클래스의 메서드를 얻어오는 함수이다.
원래 세 번째 매개변수에는 메서드의 이름, 네번째 매개변수에는 메소드의 시그니처를 넣어야 하지만
생성자를 얻어올 경우 메소드의 이름을 "init"으로,
시그니처의 반환 타입은 void를 의미하는 "V"로 고정해야 한다.
- 메소드 시그니처는 메소드의 반환 타입과 매개변수들의 타입을 문자열로 정의한 것으로
"({매개변수}){반환타입}" 형식이다.
각각 Java 타입에 해당하는 시그니처는 다음과 같다.

- 마지막으로 NewObject() 함수를 통해 해당 객체를 생성한다.
네 번째 파라미터부터는 지정된 생성자의 파라미터로 사용될 변수를 순서대로 넣으면 된다.
만약 생성자의 파라미터가 두 개라면 네번째 파라미터에 생성자의 첫 번째 파라미터
다섯 번째 파라미터에 생성자의 두 번째 파라미터를 넣으면 된다.
3. Array

<Type>에 원하는 배열 타입을 입력하면 된다.
예를 들어 int 배열을 생성하고 싶을 경우 NewIntArray() 함수를 사용하면 된다.
Get<Type>ArrayRegion() 함수와 Get<Type>ArrayElements() 함수는
모두 배열의 값을 얻어올 수 있다는 공통점이 있지만 사용방법이 다르다.
- Get<Type>ArrayRegion() : 얻어올 메모리 영역이 미리 확보되어 있을 때 사용
- Get<Type>ArrayElements() : 메모리 영역이 확보된 배열 포인터를 반환한다.
따라서 해당 포인터를 모두 사용하고 난 다음에는
Release<Type>ArrayElements() 함수를 통해 해당 메모리 영역을 반환해야 한다.
4. DeleteLocalRef()
만약 C에서 객체를 생성하였는데, Java로 반환되지도 않고 더 이상 사용되지 않는다면
DeleteLocalRef() 함수를 통해 반드시 해당 객체에 대한 참조를 지워야 한다.
멀리도 돌아왔다.
사전 지식을 알아봤으니 실제로 내가 개발한 프로젝트에서는 무슨 이슈가 났었을까?
결론은 "C 함수 내 메모리 미회수로 인한 Java JNI 영역 memory leak"이었다.
소스코드를 업로드할 순 없지만, 어떤 부분이 부족했냐면
1. C에서 사용되는 메모리 관리
JNI에서 메모리 할당을 많이 하고 leak이 발생하게 되면 DATA 영역이 지속적으로 증가한다.
따라서 프로세스의 메모리 사용률이 높다고 판단되면 어느 부분의 메모리 영역이
많이 점유하는지 판단하는 것이 중요하다.
DATA 영역은 C의 Heap이라고 부르는 영역으로 C에서 malloc() 함수를 통해 할당된 메모리는 DATA 영역에 할당된다.
즉, malloc() 함수로 메모리를 할당한 후 free() 해주지 않는다면 지속적으로 메모리가 증가하게 되는 것이다.
나는 malloc()으로 생성한 메모리를 회수해주지 않았던 것이다!
(C언어 메커니즘을 하나도 몰랐던 내 잘못..)
2. JNI용 native 지역 변수 메모리 관리
원래의 object가 차지하는 메모리에 덧붙여, JNI reference 역시 메모리를 소모한다.
물론 JVM이 local reference 들에 대해서는 함수 return 시에 메모리 해제를 자동으로 수행하지만
array를 loop로 돈다던데 하는 행위는 OutOfMemory로 이어질 수 있다.
사용이 끝났거나 어디서 호출할지 모르는 local reference는 DeleteLocalRef를 통해서 바로 해제해야 한다.
나는 이것도 해제하지 않고 그대로 두었다!
(제대로 한 게 하나도 없던 6개월 차의 나)
저 두 부분의 소스코드를 수정하고
아주 정상적으로 작동하는 로직이 되었다!
메모리 관리에 좀 더 신경 써서 개발해야 함을 느꼈던 시절의 회고였다.
(근데 지금도 딱히?)

'Etc > Trouble Shooting' 카테고리의 다른 글
| Cortex Alertmanager 연동하며 만난 이슈 대응 기 (0) | 2022.05.27 |
|---|