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
编译器选项来编译测试程序的二进制文件.