Edit Page

Kotlin/Native 内存管理

最终更新: 2024/03/21

Kotlin/Native 使用一个现代化的内存管理器, 类似于 JVM, Go, 以及其它主流技术, 包括以下功能:

  • 对象存储在共享的堆(heap)中, 可以在任何线程中访问.
  • 定期执行追踪垃圾收集器(Garbage Collector, GC), 回收那些从 "根(roots)" 无法到达的对象, 比如局部变量, 全局变量.

垃圾收集器

Kotlin/Native 的 GC 算法一直在持续演进. 目前使用的算法是 Stop-the-World Mark 和 Concurrent Sweep 收集器, 它不会把堆(heap)分为不同的代(generation).

GC 使用完全并行标记(Full Parallel Mark), 它结合了暂停的转换器(Paused Mutator), GC 线程, 以及可选的标记线程(Marker Thread), 来处理标记队列(Mark Queue). 默认情况下, 暂停的转换器(Paused Mutator)和至少一个 GC 线程共通参与标记过程. 您可以使用 -Xbinary=gcMarkSingleThreaded=true 编译选项, 禁用完全并行标记(Full Parallel Mark). 但是, 这样做可能会增加垃圾收集器的暂停时间.

当标记阶段完成时, GC 会处理弱引用(Weak Reference), 并将指向未标记对象的引用(Unmarked Object)设置为 null. 为了减少 GC 的暂停时间, 你可以使用 -Xbinary=concurrentWeakSweep=true 编译选项, 禁用对弱引用(Weak Reference)的并发处理.

GC 在单独的线程中执行, 根据定时器和内存压力来启动. 此外, 也可以 手动调用.

手动启动垃圾收集

要强制启动垃圾收集器, 可以调用 kotlin.native.internal.GC.collect(). 这个方法会触发一次新的垃圾收集, 并等待它结束.

监测 GC 性能

目前没有专门的指标来监测 GC 性能. 但是, 可以查看 GC log 来进行问题诊断. 要启用 log, 请在 Gradle 构建脚本中设置以下编译选项:

-Xruntime-logs=gc=info

目前, log 只会被输出到 stderr.

禁用垃圾收集

我们推荐启用 GC. 但是, 某些情况下你也可以禁用它, 例如, 为了测试目的, 或者你遇到问题, 而且程序的生存周期很短. 要禁用 GC, 请在 Gradle 构建脚本中设置以下编译选项:

-Xgc=noop

使用这个选项后, GC 不会收集 Kotlin 对象, 因此只要程序继续运行, 内存消耗就会持续上升. 请注意, 不要耗尽系统内存.

内存消耗

Kotlin/Native 使用它自己的 内存分配器. 它将系统内存分为多个页面(Page), 允许按连续的顺序进行独立的清理. 每次分配的内存都会成为一个页面(Page)内的内存块(Memory Block), 并且页面会追踪各个块的大小. 各种不同的页面类型进行了不同的优化, 以适应于不同的内存分配大小. 内存块的连续排列保证了可以对所有的分配块进行高效的迭代.

当一个线程分配内存时, 它会根据分配的大小搜索适当的页面. 线程会根据不同的大小类别维护一组页面. 对于一个确定的大小, 当前页通常可以容纳这个内存分配. 如果不能, 那么线程会从共享的分配空间请求一个不同的页面. 这个页面的状态可能是可用, 需要清理, 或需要创建.

Kotlin/Native 内存分配器有一种保护功能, 可以防止突然激增的内存分配请求. 它可以防止转换器(Mutator)迅速的分配大量垃圾, 以至于 GC 线程无法处理, 导致内存使用量无限的增长. 在这种情况下, GC 会强制进入 Stop-the-World 阶段, 直到完成迭代.

你可以自己监控内存消耗, 检查内存泄漏, 并调整内存消耗.

检查内存泄露

要访问内存管理器的统计信息, 可以调用 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())
    // 如果删除下面这行, 测试将会失败
    global.clear()
}

@Test
fun test() {
    val before = getUsage()
    // 这里使用一个单独的函数, 确保所有的临时对象都被清除
    run()
    val after = getUsage()
    assertEquals(before, after)
}

调整内存消耗

如果程序中不存在内存泄露, 但你仍然观察到异常高的内存消耗, 请尝试将 Kotlin 更新到最新版本. 我们一直在持续改进内存管理器, 因此即使只是一次简单的编译器更新, 也可能改善你的程序的内存消耗情况.

更新 Kotlin 版本后, 如果您还是遇到内存消耗过高的情况, 可以选择以下几种解决方法:

  • 在你的 Gradle 构建脚本中使用以下编译选项之一, 切换到不同的内存分配器:

    • -Xallocator=mimalloc, 使用 mimalloc 内存分配器.
    • -Xallocator=std, 使用系统的内存分配器.
  • 如果你使用 mimalloc 内存分配器, 你可以命令它及时将内存释放回系统. 具体做法是, 在你的 gradle.properties 文件中启用以下二进制文件选项:

    kotlin.native.binary.mimallocUseCompaction=true
    

    这样的性能损失比较小, 但与系统的内存分配器相比, 它的结果比较不确定.

如果以上方法都不能改善内存消耗问题, 请到 YouTrack 报告问题.

在后台进行单元测试

在单元测试中, 不会处理主线程队列, 因此, 除非 mock 过 Dispatchers.Main, 否则不要使用它. mock 它的方法是, 调用 kotlinx-coroutines-test 中的 Dispatchers.setMain.

如果你没有依赖于 kotlinx.coroutines, 或者因为某些原因 Dispatchers.setMain 不适合你的需求, 请使用以下变通方法, 实现测试启动器(test launcher):

package testlauncher

import platform.CoreFoundation.*
import kotlin.native.concurrent.*
import kotlin.native.internal.test.*
import kotlin.system.*

fun mainBackground(args: Array<String>) {
    val worker = Worker.start(name = "main-background")
    worker.execute(TransferMode.SAFE, { args.freeze() }) {
        val result = testLauncherEntryPoint(it)
        exitProcess(result)
    }
    CFRunLoopRun()
    error("CFRunLoopRun should never return")
}

然后, 使用 -e testlauncher.mainBackground 编译器选项来编译测试程序的二进制文件.

下一步