Kotlin/Native 메모리 관리

Kotlin/Native 메모리 관리

Kotlin/Native는 JVM, Go 등 주류 기술과 유사한 현대적인 메모리 관리자를 사용하며, 다음 기능을 포함합니다:

  • 객체는 공유 힙(heap)에 저장되며 어떤 스레드에서든 접근할 수 있어요.
  • 추적 가비지 컬렉션은 로컬 및 전역 변수 같은 "루트"에서 도달할 수 없는 객체를 수집하기 위해 주기적으로 수행됩니다.

출처: Kotlin/Native memory management

본문

가비지 컬렉터

Kotlin/Native의 가비지 컬렉터(GC) 알고리즘은 계속 진화하고 있어요. 현재는 힙을 세대(generation)로 나누지 않는 동시 마크 앤 스윕(CMS, concurrent mark and sweep) 컬렉터로 동작합니다.

GC는 별도의 스레드에서 실행되며 메모리 압력 휴리스틱이나 타이머를 기준으로 시작됩니다. 또는 수동으로 호출할 수도 있습니다.(가비지 컬렉션 수동 활성화)

GC는 애플리케이션 스레드, GC 스레드, 선택적 마커 스레드를 포함해 여러 스레드에서 마크 큐를 병렬로 처리합니다. 애플리케이션 스레드와 최소 하나의 GC 스레드가 마킹 과정에 참여해요. 기본적으로 마킹 단계는 애플리케이션 스레드와 동시에 실행되어 GC 일시 중지 시간을 줄입니다. GC 성능은 GC 로그로 모니터링할 수 있습니다.(GC 성능 모니터링)

마크 단계의 병렬화는 kotlin.native.binary.gcMarkSingleThreaded=true 컴파일러 옵션으로 비활성화할 수 있습니다. 다만 큰 힙에서는 가비지 컬렉터의 일시 중지 시간이 늘어날 수 있어요.

마킹 단계가 끝나면 GC는 약한 참조(weak reference)를 처리하고 표시되지 않은 객체를 가리키는 참조 지점을 null로 만듭니다. 기본적으로 약한 참조는 GC 일시 중지 시간을 줄이기 위해 동시에 처리됩니다.

CMS에 문제가 있다면 병렬 마크 동시 스윕(PMCS) 설정으로 되돌릴 수 있어요. 그러려면 gradle.properties 파일에 다음 이진 옵션(native-binary-options)을 설정합니다:

kotlin.native.binary.gc=pmcs

가비지 컬렉션 수동 활성화하기

가비지 컬렉터를 강제로 시작하려면 kotlin.native.internal.GC.collect()를 호출하세요. 이 메서드는 새 컬렉션을 시작하고 완료를 기다립니다.

GC 성능 모니터링하기

GC 성능을 모니터링하려면 로그를 살펴보고 문제를 진단할 수 있어요. 로깅을 활성화하려면 Gradle 빌드 스크립트에 다음 컴파일러 옵션을 설정하세요:

-Xruntime-logs=gc=info

현재 로그는 stderr로만 출력됩니다.

Apple 플랫폼에서는 Xcode Instruments 툴킷으로 iOS 앱 성능을 디버깅할 수 있어요. 가비지 컬렉터는 Instruments에서 사용 가능한 signpost로 일시 중지를 보고합니다. signpost는 앱 내 커스텀 로깅을 가능하게 해서 GC 일시 중지가 애플리케이션 프리즈에 해당하는지 확인할 수 있게 해줍니다.

앱에서 GC 관련 일시 중지를 추적하려면:

  1. 기능을 활성화하려면 gradle.properties 파일에 다음 컴파일러 옵션을 설정합니다:
kotlin.native.binary.enableSafepointSignposts=true
  1. Xcode를 열고 Product | Profile로 가거나 Cmd + I를 누릅니다. 이 작업은 앱을 컴파일하고 Instruments를 실행합니다.
  2. 템플릿 선택에서 os_signpost를 선택합니다.
  3. subsystem으로 org.kotlinlang.native.runtime을, category로 safepoint를 지정해 구성합니다.
  4. 빨간 녹음 버튼을 클릭해 앱을 실행하고 signpost 이벤트 녹음을 시작합니다. 여기서 가장 아래 그래프의 각 파란색 블롭은 개별 signpost 이벤트, 즉 GC 일시 중지를 나타냅니다.

가비지 컬렉션 비활성화하기

GC를 켜 두는 것이 좋아요. 하지만 테스트 목적이나 짧은 실행 프로그램에서 문제가 있을 때처럼 특정 경우에는 비활성화할 수 있습니다. 그러려면 gradle.properties 파일에 다음 이진 옵션을 설정하세요:

kotlin.native.binary.gc=noop

이 옵션을 활성화하면 GC가 Kotlin 객체를 수집하지 않으므로 프로그램이 실행되는 동안 메모리 소비가 계속 늘어납니다. 시스템 메모리를 모두 소모하지 않도록 주의하세요.

메모리 소비

Kotlin/Native는 자체 메모리 할당자를 사용해요. 시스템 메모리를 페이지로 나누고 연속적인 순서로 독립적인 스윕을 허용합니다. 각 할당은 페이지 내의 메모리 블록이 되고, 페이지는 블록 크기를 추적합니다. 다양한 페이지 타입은 다양한 할당 크기에 최적화됩니다. 메모리 블록의 연속 배치는 모든 할당 블록의 효율적인 순회를 보장합니다.

스레드가 메모리를 할당할 때는 할당 크기에 따라 적합한 페이지를 찾습니다. 스레드는 서로 다른 크기 범주에 대한 페이지 집합을 유지해요. 보통 주어진 크기의 현재 페이지가 할당을 수용할 수 있습니다. 그렇지 않으면 스레드가 공유 할당 공간에서 다른 페이지를 요청합니다. 이 페이지는 이미 사용 가능하거나, 스윕이 필요하거나, 먼저 만들어야 할 수 있습니다.

Kotlin/Native 메모리 할당자는 메모리 할당의 급격한 급증에 대한 보호 기능이 있어요. 뮤테이터(mutator)가 가비지를 빠르게 많이 할당하기 시작하고 GC 스레드가 따라잡지 못해 메모리 사용량이 끝없이 늘어나는 상황을 막습니다. 이 경우 GC는 순회가 완료될 때까지 stop-the-world 단계를 강제합니다.

메모리 소비를 직접 모니터링하고, 메모리 누수를 확인하며, 메모리 소비를 조정할 수 있어요.

메모리 소비 모니터링하기

메모리 문제를 디버깅하려면 메모리 관리자 지표를 확인할 수 있어요. 게다가 Apple 플랫폼에서 Kotlin의 메모리 소비를 추적하는 것도 가능합니다.

메모리 누수 확인하기

메모리 관리자 지표에 접근하려면 kotlin.native.internal.GC.lastGCInfo()를 호출하세요. 이 메서드는 가비지 컬렉터의 마지막 실행에 대한 통계를 반환합니다. 통계는 다음에 유용할 수 있어요:

  • 전역 변수 사용 시 메모리 누수 디버깅
  • 테스트 실행 시 누수 확인
import kotlin.native.internal.*
import kotlin.test.*

class Resource

val global = mutableListOf<Resource>()

@OptIn(ExperimentalStdlibApi::class)
fun getUsage(): Long {
    GC.collect()
    return GC.lastGCInfo!!.memoryUsageAfter["heap"]!!.totalObjectsSizeBytes
}

fun run() {
    global.add(Resource())
    // The test will fail if you remove the next line
    global.clear()
}

@Test
fun test() {
    val before = getUsage()
    // A separate function is used to ensure that all temporary objects are cleared
    run()
    val after = getUsage()
    assertEquals(before, after)
}
Apple 플랫폼에서 메모리 소비 추적하기

Apple 플랫폼에서 메모리 문제를 디버깅할 때 Kotlin 코드가 예약하는 메모리 양을 볼 수 있어요. Kotlin의 몫은 식별자로 태그되며 Xcode Instruments의 VM Tracker 같은 도구로 추적할 수 있습니다.

이 기능은 다음 모든 조건을 충족할 때 기본 Kotlin/Native 메모리 할당자에서만 사용할 수 있어요:

  • 태깅 활성화: 메모리가 유효한 식별자로 태그되어야 합니다. Apple은 240~255 사이의 숫자를 권장하며 기본값은 246입니다. kotlin.native.binary.mmapTag=0 Gradle 프로퍼티를 설정하면 태깅이 비활성화됩니다.
  • mmap으로 할당: 할당자가 mmap 시스템 호출을 사용해 파일을 메모리에 매핑해야 합니다. kotlin.native.binary.disableMmap=true Gradle 프로퍼티를 설정하면 기본 할당자는 mmap 대신 malloc을 사용합니다.
  • 페이징 활성화: 할당 페이징(버퍼링)이 활성화되어야 합니다. kotlin.native.binary.pagedAllocator=false(할당자 페이징 비활성화) Gradle 프로퍼티를 설정하면 대신 메모리가 객체 단위로 예약됩니다.

메모리 소비 조정하기

예상보다 메모리 소비가 높다면 다음 해결책을 시도해 보세요.

Kotlin 업데이트하기

Kotlin을 최신 버전으로 업데이트하세요. 우리는 메모리 관리자를 계속 개선하고 있으므로, 간단한 컴파일러 업데이트만으로도 메모리 소비가 개선될 수 있어요.

할당자 페이징 비활성화하기

할당 페이징(버퍼링)을 비활성화하면 메모리 할당자가 객체 단위로 메모리를 예약할 수 있어요. 일부 경우에는 엄격한 메모리 제한을 충족하거나 애플리케이션 시작 시 메모리 소비를 줄이는 데 도움이 될 수 있습니다.

그러려면 gradle.properties 파일에 다음 옵션을 설정하세요:

kotlin.native.binary.pagedAllocator=false

할당자 페이징을 비활성화하면 Apple 플랫폼에서 메모리 소비를 추적하는 것(Apple 플랫폼에서 메모리 소비 추적)은 불가능해집니다.

Latin-1 문자열 지원 활성화하기

기본적으로 Kotlin의 문자열은 UTF-16 인코딩으로 저장되어 각 문자가 2바이트로 표현됩니다. 일부 경우 문자열이 소스 코드에 비해 바이너리에서 두 배의 공간을 차지하고 데이터를 읽는 데 두 배의 메모리를 사용하게 됩니다.

애플리케이션의 바이너리 크기를 줄이고 메모리 소비를 조정하려면 Latin-1 인코딩 문자열 지원을 활성화할 수 있어요. Latin-1 (ISO 8859-1) 인코딩은 처음 256개의 유니코드 문자 각각을 단 1바이트로 표현합니다.

활성화하려면 gradle.properties 파일에 다음 옵션을 설정하세요:

kotlin.native.binary.latin1Strings=true

Latin-1 지원을 사용하면 모든 문자가 그 범위 내에 있는 한 문자열이 Latin-1 인코딩으로 저장됩니다. 그렇지 않으면 기본 UTF-16 인코딩이 사용됩니다.

이 기능이 실험적인 동안에는 cinterop 확장 함수 String.pin, String.usePinned, String.refTo의 효율성이 떨어집니다. 각 호출 시 자동으로 문자열이 UTF-16으로 변환될 수 있어요.

이 옵션들이 모두 도움이 되지 않았다면 YouTrack에 이슈를 만들어 주세요.

백그라운드의 단위 테스트

단위 테스트에서는 메인 스레드 큐를 처리하는 것이 없으므로, mock 되지 않는 한 Dispatchers.Main을 사용하지 마세요. mock은 kotlinx-coroutines-testDispatchers.setMain을 호출해서 할 수 있습니다.

kotlinx.coroutines에 의존하지 않거나 어떤 이유로 Dispatchers.setMain이 동작하지 않는다면, 테스트 실행기를 구현하는 다음 우회책을 시도해 보세요.

그다음 테스트 바이너리를 -e testlauncher.mainBackground 컴파일러 옵션으로 컴파일합니다.

다음 단계

더 알아보기