JDK 28부터 Arena.ofConfined()에서 64바이트 이하의 작은 할당은 스레드마다 들고 있는 메모리 풀에서 잘라 쓰게 된다. 소스 코드는 하나도 바꿀 필요가 없다. FFM(Foreign Function & Memory) API로 네이티브 함수를 부를 때마다 confined arena를 열고 닫는 코드라면, 그 오버헤드가 OpenJDK 개발 벤치마크 기준으로 7배에서 18배까지 줄었다.
confined arena를 호출마다 새로 여는 패턴은 왜 느렸고, JDK 28에서는 무엇이 바뀌었나?
이 글은 10월 5일 Inside.java에 올라온 Per-Ake Minborg의 글과 OpenJDK PR #31365 설명을 읽고 정리했다. JDK 28은 아직 정식 출시 전이고, PR 페이지에는 리뷰가 진행 중인 상태로 보여서 세부 동작은 바뀔 수 있다.
1. 원래 무엇이 비쌌나
confined arena는 보통 네이티브 호출 하나를 감싸는 임시 공간으로 쓴다. 결과를 받을 포인터나 int 하나, errno 값, 짧은 문자열 같은 것들이다. 글에 실린 대표 패턴은 이렇다.
try (Arena arena = Arena.ofConfined()) {
MemorySegment result = arena.allocate(ValueLayout.JAVA_INT);
nativeFunction.invokeExact(result); // 결과를 segment에 써 준다
return result.get(ValueLayout.JAVA_INT, 0);
}문제는 4바이트짜리 int 하나를 받는 데도 JDK 27까지는 일반 네이티브 할당기를 거치고 정리 작업(cleanup action)까지 등록했다는 점이다. 메모리를 실제로 읽고 쓰는 시간보다 이 부가 작업이 더 오래 걸리는 경우가 생긴다.
설계의 근거로 든 데이터가 흥미롭다. OpenJDK 테스트 스위트를 계측해 보니 confined arena 중 일부는 네이티브 메모리를 아예 할당하지 않았고, 할당한 arena의 99.99% 이상이 64바이트 미만이었다고 한다. 대부분이 아주 작은 값을 잠깐 쓰고 버리는 용도였던 셈이다.
2. 풀은 어떻게 동작하나
플랫폼 스레드
각 플랫폼 스레드가 작은 풀 캐시를 지연 생성해서 들고 있다. 기본값은 64바이트 풀 최대 4개다.
- arena가 처음으로 풀에 들어갈 만한 할당을 할 때만 캐시에서 풀 하나를 가져온다. 할당을 안 하는 arena는 풀을 건드리지 않는다.
- 그 풀은 해당 arena 전용이고, 작은 할당은 풀 앞쪽부터 순서대로 잘라 준다.
- 풀에 안 들어가는 크기이거나 풀이 맞춰 줄 수 없는 정렬을 요구하면 예전처럼 일반 경로로 간다.
- arena를 닫으면 쓴 부분을 0으로 지우고 풀을 스레드 캐시에 돌려준다. 캐시가 이미 차 있으면 네이티브 할당기에 반납한다.
중첩된 confined arena는 각자 자기 풀을 가져가기 때문에 서로 섞이지 않는다.
가상 스레드
가상 스레드마다 캐시를 두면 수백만 개 스레드에 캐시가 붙는다. 그래서 가상 스레드는 지금 올라탄 carrier 스레드의 캐시에서 풀을 빌린다. pinning은 캐시에서 풀을 넘겨받는 짧은 순간에만 걸리고, 그 뒤로는 arena가 풀을 소유하니까 arena가 열린 동안 다른 carrier로 옮겨 가도 된다. 닫을 때는 그 시점에 올라탄 carrier의 캐시로 돌아간다.
3. 벤치마크 수치
글에 실린 개발 벤치마크 결과다. 기존 지연 시간을 풀링 후 지연 시간으로 나눈 값이라 클수록 좋다.
| 플랫폼 | 5바이트 할당 | 20바이트 할당 |
|---|---|---|
| Linux AArch64 | 12.3배 | 8.6배 |
| Linux x64 | 6.8배 | 7.8배 |
| macOS AArch64 | 18.6배 | 16.8배 |
| Windows x64 | 16.8배 | 18.9배 |
Apple M4에서 잰 ns/op 값을 보면 경계가 어디인지 더 잘 보인다.
| 할당 크기 | 풀링 (ns/op) | 풀링 없음 (ns/op) |
|---|---|---|
| 5바이트 | 1.052 | 15.862 |
| 20바이트 | 1.138 | 18.139 |
| 100바이트 | 19.044 | 18.856 |
| 500바이트 | 26.383 | 26.168 |
64바이트를 넘는 100바이트부터는 사실상 차이가 없다. 풀이 64바이트라서 그 위로는 기존 경로를 타기 때문이다. 가상 스레드에서는 5바이트 기준 3.222 대 15.672, 20바이트 기준 2.737 대 18.109로 플랫폼 스레드보다 이득이 조금 작았다. carrier를 다루는 비용이 더해지는 탓이다.
물론 마이크로벤치마크다. 글에서도 애플리케이션 전체가 이만큼 빨라진다는 뜻은 아니라고 선을 긋는다. 네이티브 호출 자체는 짧고 arena 생성, 할당, 정리가 시간의 대부분을 차지하는 코드에서 효과가 크다.
4. 바뀌지 않는 것과 조심할 것
segment 생명주기와 접근 규칙은 그대로다. arena를 닫은 뒤 segment에 접근하면 예전처럼 예외가 난다. 재사용 전에 풀을 0으로 지우고, cleanup action도 풀을 지우기 전에 실행된다. 스레드가 끝나면 캐시에 있던 풀도 해제된다.
개인적으로 더 신경 쓰인 건 진단 쪽이다. arena를 닫아도 free가 바로 불리지 않고 JDK 캐시로 돌아갈 수 있다. 그래서 arena 할당과 네이티브 malloc/free 호출이 1:1로 대응한다고 가정하면 안 된다. 네이티브 메모리 추적 도구로 누수를 보거나 malloc 횟수를 세는 테스트가 있다면 JDK 28에서 숫자가 달라질 수 있다.
적용 범위도 정리해 두면 이렇다.
| 구분 | JDK 28 풀링 적용 여부 |
|---|---|
Arena.ofConfined() |
64바이트 이하, 일반 정렬이면 적용 |
| 64바이트 초과 할당 | 기존 경로 |
| 그 외 arena 종류 | 영향 없음 |
PR 설명에는 풀 크기를 내부용 비공식 시스템 프로퍼티로 조절할 수 있고(8바이트에서 1MiB), 음수를 주면 풀링이 꺼진다고 적혀 있다. 다만 프로퍼티 이름은 확인하지 못했고 지원되는 옵션도 아니라서 운영 설정에 넣을 만한 건 아니다.
5. 우리 코드에서 볼 부분
혜택을 보는 쪽은 꽤 분명하다. 네이티브 호출마다 confined arena를 여는 FFM 바인딩, jextract로 생성한 래퍼, 포인터 out 파라미터나 작은 C 구조체를 주고받는 코드, 짧은 문자열을 네이티브로 넘기는 코드다. 백엔드에서는 JNI 대신 FFM으로 압축, 암호화, 시스템 콜 래퍼를 붙인 라이브러리가 여기에 해당한다.
JDK 28 EA 빌드로 넘어가 볼 때 확인할 것만 추리면 이렇다.
- confined arena를 하나 열어 큰 버퍼와 작은 값을 같이 할당하던 코드는 작은 값만 짧게 쓰는 arena로 나눌지
- 호출마다 arena를 재사용하려고 직접 만든 풀이 있다면 JDK 풀과 겹치지 않는지
- 네이티브 메모리 누수 테스트가
malloc/free횟수에 기대고 있지 않은지 - 가상 스레드에서 FFM을 부르는 경로는 플랫폼 스레드와 따로 측정할지
오히려 arena를 오래 들고 다니며 재사용하려고 애쓰던 코드는 이제 단순하게 try로 짧게 여닫는 쪽이 나을 수도 있다. 이건 각자 벤치마크로 확인할 부분이다.