DEV Community

Cover image for Memory Management and Garbage Collection in Kotlin Multiplatform XCFramework
Arseni Kavalchuk
Arseni Kavalchuk

Posted on

Memory Management and Garbage Collection in Kotlin Multiplatform XCFramework

Introduction

In the previous article, we explored manual memory management and garbage collection in Kotlin Multiplatform (KMP) with a focus on shared libraries. KMP's ability to generate shared libraries for C++ projects and frameworks for Apple platforms makes it highly versatile, but each output format brings unique challenges and advantages in memory management. This article dives into using an Apple XCFramework from Kotlin Multiplatform, focusing on memory management and garbage collection (GC) in a Swift-based environment. We’ll highlight key differences from the shared library implementation and observe how automatic memory management in Swift’s ARC simplifies the process.

Building and Using an XCFramework in Swift

Building an XCFramework

Creating an XCFramework with Kotlin Multiplatform is straightforward, especially with Gradle's built-in support for Apple frameworks. The process is defined in the build script, as seen in the Gradle configuration example.

Key steps for building the XCFramework include:

  1. Setting Up Target Platforms: In the kotlin block of build.gradle.kts, specify the Apple targets (iosArm64, iosX64, and iosSimulatorArm64).
  2. Defining XCFramework Output: Use the framework directive to configure the XCFramework output, specifying necessary build parameters like baseName and the output directory.
  3. Building with Gradle: Use the command ./gradlew assembleKmpSampleXCFramework to generate the framework, which can then be imported into an Xcode project. The task name will depend on the XCFramework(name) passed in the build.gradle.kts. In our case it is KmpSample and the task name is assembleKmpSampleXCFramework respectively.

Using the XCFramework in a Swift Project

The XCFramework can be added directly to an Xcode project, allowing seamless use of Kotlin code in Swift. The Swift sample project demonstrates this.

I won't spend much in this article on how do you exactly include the framework into the Swift project. But essentially it just requires creating a Swift project (like a CLI app, programming language: Swift), and drag-n-dropping the KmpSample.xcframework folder from build/XCFrameworks/[debug,release]/ directly into Xcode project.

Now let's focus on the example of invoking Kotlin methods from Swift:

import Foundation
import KmpSample

let clazzInstance = KmpClazz()

// Call interfaceMethod
let resultString = clazzInstance.interfaceMethod()
print("Result from interfaceMethod: \(resultString)")

// Call returnInt
if let intResult = clazzInstance.returnInt() {
    print("Result from returnInt: \(intResult)")
}

// Call returnLong
if let longResult = clazzInstance.returnLong() {
    print("Result from returnLong: \(longResult)")
}

// Passing a byte array to a Kotlin function
var byteArray: [UInt8] = [0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE]
let size = byteArray.count
byteArray.withUnsafeMutableBytes { rawBufferPointer in
    KmpSampleKt.readNativeByteArray(byteArray: rawBufferPointer.baseAddress!, size: Int32(size))
}
Enter fullscreen mode Exit fullscreen mode

In contrast to the shared library setup, using the XCFramework in Swift reduces the need for manual disposal of resources. Swift's ARC automatically manages object references, freeing developers from the memory management steps required in C++.

Memory Management in Kotlin Multiplatform with XCFramework

Kotlin Native provides a garbage collector (GC) that manages memory for objects created within the Kotlin framework. Although Swift’s ARC handles the lifespan of objects, the GC in Kotlin remains active, especially for objects created internally within Kotlin’s runtime. To monitor GC behavior, pass the -Xruntime-logs=gc=info flag during compilation, as configured in the Gradle build here.

With GC logging enabled, you’ll see output like this when running the framework in a Swift environment:

[INFO][gc][tid#1494899][0.000s] Adaptive GC scheduler initialized
[INFO][gc][tid#1494899][0.001s] Set up parallel mark with maxParallelism = 8 and cooperative mutators
[INFO][gc][tid#1494899][0.001s] Parallel Mark & Concurrent Sweep GC initialized
Enter fullscreen mode Exit fullscreen mode

Observing GC Behaviour

To test how Kotlin GC performs under heavy load, we can simulate high-frequency object creation and disposal in Swift. The sample code in IntegrationGcTest creates millions of KmpClazz instances, calling methods and disposing of objects in a loop. The following code demonstrates this scenario:

import Foundation
import KmpSample

for i in 1...10_000_000 {
    let clazzInstance = KmpClazz()

    // Call interfaceMethod
    let resultString = clazzInstance.interfaceMethod()

    // Call returnInt
    if let intResult = clazzInstance.returnInt() {}

    // Call returnLong
    if let longResult = clazzInstance.returnLong() {}

    if i % 1_000_000 == 0 {
        print("Created \(i) objects")
    }
}
Enter fullscreen mode Exit fullscreen mode

GC Log Analysis for XCFramework in Swift

The full GC log for the example above

The resulting GC log from this loop is extensive. Here’s a segment of the log:

[INFO][gc][tid#1796721][16.128s] Epoch #157: Started. Time since last GC 95913 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Root set: 0 thread local references, 0 stack references, 0 global references, 1 stable references. In total 1 roots.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mark: 2728 objects.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Sweep extra objects: swept 58915 objects, kept 68256 objects
[INFO][gc][tid#1796721][16.138s] Epoch #157: Sweep: swept 65528 objects, kept 2728 objects
[INFO][gc][tid#1796721][16.138s] Epoch #157: Heap memory usage: before 6160384 bytes, after 6422528 bytes
[INFO][gc][tid#1796721][16.138s] Epoch #157: Time to pause #1: 32 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mutators pause time #1: 2884 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Time to pause #2: 2 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mutators pause time #2: 10 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Finished. Total GC epoch time is 10008 microseconds.
[INFO][gc][tid#1796725][16.145s] Epoch #157: Finalization is done in 6885 microseconds after epoch end.
Enter fullscreen mode Exit fullscreen mode

Key Observations from the GC Logs

  1. GC Epoch Frequency: The XCFramework GC runs frequent epochs due to high object turnover. Compared to shared libraries, there are significantly more GC epochs (157 vs 31), suggesting a more responsive GC handling objects in short-lived scopes. See GC log for the shared library for comparison.
  2. Stable References and Roots: Since objects in Swift go out of scope naturally, stable references are kept to a minimum, which prevents the Kotlin GC from holding on to unreferenced objects, enhancing efficiency.
  3. Performance Impact: Frequent GC epochs show that although ARC handles disposals, Kotlin’s GC is still active and essential in memory management for stable references within the framework.

Summary

Using Kotlin Multiplatform’s XCFramework simplifies memory management in Apple projects by offloading much of the work to Swift’s ARC. This setup reduces the need for explicit disposal, enhancing code readability and reducing potential memory leaks. However, Kotlin Native’s garbage collector still plays an active role, particularly for stable references within the Kotlin framework.

In the next article, we’ll further investigate performance optimization for KMP GC handling in large-scale Swift applications.

References

  1. Configure and build KMP native binaries
  2. Integration with Swift/Objective-C ARC
  3. Playground for Kotlin Multiplatform (Native) code
  4. Using Kotlin Multiplatform XCFramework/Framework in Swift

Top comments (0)