DEV Community

Cover image for Manual Memory Management and Garbage Collection in Kotlin Multiplatform Native Shared Libraries
Arseni Kavalchuk
Arseni Kavalchuk

Posted on

Manual Memory Management and Garbage Collection in Kotlin Multiplatform Native Shared Libraries

Introduction

Kotlin Multiplatform (KMP) has opened up exciting opportunities for developers to create cross-platform applications using a shared codebase that can run natively on different platforms, including Android, iOS, macOS, Windows, and Linux. One of the core benefits of KMP is its ability to target native environments through shared libraries and frameworks, particularly useful in projects where both C++ and Apple (iOS/macOS) components need to interact with shared logic without redundant implementations.

In this article, we’ll focus on building a shared library and an Apple framework from a Kotlin Multiplatform project. Specifically, we’ll explore the differences between a shared library and a framework, with sample GitHub projects illustrating how APIs are structured and generated in each case. This will be followed by a practical guide on using the shared library in a C++ project and an in-depth look at memory management in Kotlin Native, focusing on how garbage collection (GC) functions within KMP. By the end, you’ll have a solid grasp of implementing and managing a shared library and framework in Kotlin Multiplatform.

Shared Library vs. Apple Framework: What’s the Difference?

We will use the sample KMP project as an example of the native shared library.

The sample class that we are using in this article is:

interface KmpInterface {
    fun interfaceMethod(): String
}

class KmpClazz: KmpInterface {
    fun returnLong(): Long? = 42L
    fun returnInt(): Int? = 42
    override fun interfaceMethod(): String {
        return "KmpClazz"
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

When working with Kotlin Multiplatform, two primary methods exist for sharing code with native platforms: shared libraries and Apple frameworks. Each serves distinct purposes:

  • Shared Libraries: Typically generated as static or dynamic libraries (.so, .dll, or .dylib files), these are more common in environments where the target is a native codebase in C++ or similar languages. The generated shared library API reveals how the Kotlin code translates into C-compatible functions and structures.

  • Apple Frameworks: Produced as .framework bundles, these are exclusive to Apple platforms and cater to Swift and Objective-C interoperability. The framework API example shows the Objective-C bridge generated for the Kotlin code, allowing seamless integration with Apple projects.

The configuration difference for MacOS target can be specified with the following code in build.gradle.kts:

kotlin {
    macosX64 {
        binaries {
            sharedLib(libraryName)
            framework(libraryName)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In terms of structure, the shared library is more barebones, focusing on function exports and C-compatible structures. On the other hand, the Apple framework offers a more robust integration, allowing Swift or Objective-C to call Kotlin code more naturally with less manual intervention.


Building and Using a Shared Library in C++ Projects

To use the KMP shared library in a C++ project, some foundational steps include initialization, object creation, and handling pointers and disposal to ensure memory efficiency. Here’s a practical guide to using a KMP shared library in C++ based on the KMP C++ integration sample code.

Steps for Using the Shared Library

  1. Initialization: Begin by initializing the library symbols to access the APIs exported from Kotlin:
   libKmpSample_ExportedSymbols *lib = libKmpSample_symbols();
Enter fullscreen mode Exit fullscreen mode
  1. Object Creation: Instantiate objects and classes from the Kotlin library by calling the relevant constructors.
   libKmpSample_kref_design_KmpClazz clazzInstance = lib->kotlin.root.design.KmpClazz.KmpClazz();
Enter fullscreen mode Exit fullscreen mode
  1. Invoking Methods: Use the lib object to call Kotlin methods and retrieve results.
   const char *interfaceMethodResult = lib->kotlin.root.design.KmpClazz.interfaceMethod(clazzInstance);
   std::cout << "interfaceMethod result: " << interfaceMethodResult << std::endl;
   lib->DisposeString(interfaceMethodResult);  // Dispose of the returned string when no longer needed
Enter fullscreen mode Exit fullscreen mode
  1. Memory Management: Carefully dispose of any objects or pointers obtained from the Kotlin side to avoid memory leaks. This includes invoking DisposeStablePointer for instances not automatically collected by Kotlin Native's garbage collector.

These essential steps provide a structure for incorporating KMP shared libraries in C++ applications, especially when navigating Kotlin’s garbage collection and memory management system in a native environment.


Memory Management in Kotlin Multiplatform Native

Memory management in Kotlin Multiplatform involves balancing explicit disposals in C++ with Kotlin Native’s garbage collection (GC) features. Although Kotlin provides automatic memory management, developers need to handle memory references explicitly when using KMP in C++ or native code.

Kotlin Native Garbage Collection

Kotlin Native incorporates a garbage collector, which is especially crucial in cross-language contexts where unmanaged code (like C++) interacts with managed code (Kotlin). Enabling detailed GC logging helps understand GC behavior, allowing developers to optimize memory handling. To enable GC logs, pass the -Xruntime-logs=gc=info flag in the build configuration, as shown in the sample Gradle build configuration.

Here’s an example GC log output when using this flag:

[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

Managing Objects and References

Using GC doesn’t mean we can ignore memory management entirely in C++. Explicit disposal remains necessary for stable references and objects returned from Kotlin to the native environment. Here’s an example showing how to manage references:

libKmpSample_ExportedSymbols *lib = libKmpSample_symbols();
libKmpSample_kref_design_KmpClazz clazzInstance = lib->kotlin.root.design.KmpClazz.KmpClazz();

// Call and convert the result from returnInt
libKmpSample_kref_kotlin_Int intResult = lib->kotlin.root.design.KmpClazz.returnInt(clazzInstance);
int nativeInt = lib->getNonNullValueOfInt(intResult);
std::cout << "returnInt result: " << nativeInt << std::endl;

// Dispose of stable pointers
lib->DisposeStablePointer(intResult.pinned);
lib->DisposeStablePointer(clazzInstance.pinned);
Enter fullscreen mode Exit fullscreen mode

In this example, the DisposeStablePointer function cleans up memory allocated by the Kotlin library to prevent memory leaks. The following sample script IntegrationGcTest creates 10 millions of KmpClazz instances in a loop to observe GC activity.

Observing GC Behavior with and Without Proper Disposal

With proper disposal:

Created 9900000 objects
[INFO][gc][tid#1544690][9.475s] Epoch #31: Started. Time since last GC 230405 microseconds.
[WARNING][gc,gcScheduler][tid#1544103][9.526s] Pausing the mutators until epoch 31 is done
[INFO][gc][tid#1544690][9.534s] Epoch #31: Root set: 0 thread local references, 0 stack references, 0 global references, 2 stable references. In total 2 roots.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Mark: 32730 objects.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Sweep extra objects: swept 0 objects, kept 0 objects
[INFO][gc][tid#1544690][9.534s] Epoch #31: Sweep: swept 311293 objects, kept 32730 objects
[INFO][gc][tid#1544690][9.534s] Epoch #31: Heap memory usage: before 5767168 bytes, after 5767168 bytes
[INFO][gc][tid#1544690][9.534s] Epoch #31: Time to pause #1: 36 microseconds.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Mutators pause time #1: 18493 microseconds.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Time to pause #2: 0 microseconds.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Mutators pause time #2: 8 microseconds.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Finished. Total GC epoch time is 58878 microseconds.
[INFO][gc][tid#1544690][9.534s] Epoch #31: Finalization is done in 77 microseconds after epoch end.
Enter fullscreen mode Exit fullscreen mode

Root set: 0 thread local references, 0 stack references, 0 global references, 2 stable references. Total GC epoch time is 58878 microseconds.

The full GC log

Without disposal:

Created 9900000 objects
[INFO][gc][tid#1495514][25.624s] Epoch #31: Started. Time since last GC 275504 microseconds.
[WARNING][gc,gcScheduler][tid#1494899][26.363s] Pausing the mutators until epoch 31 is done
[INFO][gc][tid#1495514][26.761s] Epoch #31: Root set: 0 thread local references, 1 stack references, 0 global references, 19894275 stable references. In total 19894276 roots.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Mark: 32666 objects.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Sweep extra objects: swept 0 objects, kept 0 objects
[INFO][gc][tid#1495514][26.761s] Epoch #31: Sweep: swept 311357 objects, kept 32666 objects
[INFO][gc][tid#1495514][26.761s] Epoch #31: Heap memory usage: before 5767168 bytes, after 5767168 bytes
[INFO][gc][tid#1495514][26.761s] Epoch #31: Time to pause #1: 1 microseconds.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Mutators pause time #1: 707412 microseconds.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Time to pause #2: 1 microseconds.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Mutators pause time #2: 8 microseconds.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Finished. Total GC epoch time is 1136902 microseconds.
[INFO][gc][tid#1495514][26.761s] Epoch #31: Finalization is done in 55 microseconds after epoch end.
Enter fullscreen mode Exit fullscreen mode

Root set: 0 thread local references, 1 stack references, 0 global references, 19894275 stable references. Total GC epoch time is 1136902 microseconds.

When objects and references are correctly disposed, GC pauses are minimal, and root references remain low, optimizing application performance.


Summary

Kotlin Multiplatform provides developers with a versatile toolkit for creating shared libraries and frameworks that can be utilized across multiple platforms, with both C++ and Apple frameworks supported seamlessly. By understanding the nuances of shared libraries versus frameworks, developers can choose the best integration approach for their needs, leveraging a common Kotlin codebase.

While Kotlin’s GC simplifies memory management, cross-platform contexts require careful attention to object disposal to avoid performance issues. Proper management of references, enabled by GC insights, helps streamline memory use and maintain application responsiveness. In the following article, we’ll continue exploring GC behavior, particularly within a Swift-based CLI app that interacts with the KMP-built Apple Framework.

References

  1. Get started with Kotlin/Native using Gradle
  2. Kotlin/Native as a dynamic library – tutorial
  3. Kotlin Mutliplatform (Native) Sample Project
  4. Xcode C++ Sample Project uses KMP Shared Library

Top comments (0)