Swift VS Kotlin之内存管理

741 阅读8分钟
原文链接: blog.indoorway.com

In my previous article, which you can find here, I mentioned memory management in Swift and Kotlin. I left you alone with this problem, but now I want to make it up to you by describing this difference.

Garbage Collection

The Garbage Collection process, also known as automatic memory management, is the automatic recycling of dynamically allocated memory, as explained here. Still don’t get it? Ok, so let me explain this using some pictures and a simplified example.

This is your memory heap (don’t confuse it with stack) assigned to your app. It’s empty right now.

1) Empty memory heap

When your app starts, it allocates memory to objects (reference types) in runtime.

2) Memory heap with allocated memory blocks (blue rectangles)

After a while, some objects become eligible for garbage collection, and are then removed from the memory heap.

3) The dark blue rectangles indicate objects eligible for garbage collection, which are then removed from the memory heap

An object becomes a good candidate for garbage collection when there are no references to it. For instance, you have a TODO app. There is a task list screen and a task details screen. First, your app allocates memory for displaying the task list on launch. When the user clicks on an item on the list, the details screen is presented. For this action, your app dynamically allocates additional memory to the heap. When the user closes the task details screen, all objects related to it should be removed. The reason is simple. You don’t have unlimited memory and you have to reclaim it. That’s why the system removes unreachable objects. The benefits are clear without a shadow of doubt. It means that the programmer is not responsible for the deletion of unused objects. Both Kotlin and Swift have this process automated, unlike C, C++. So, if we have this feature for free and everything happens magically, should you care about it? Yes, you should, because it doesn’t mean you can’t make mistakes. Moreover, this knowledge allows you to write better apps which function much more smoothly. If you want to be a great Android or iOS developer, you should be well versed in memory management.

Despite the fact that Swift and Kotlin relieve programmers from the task of freeing up memory for an app, they do this in different ways. I will try to describe this advanced topic as simply as possible. I won’t focus on the details, because I want to keep this article understandable for everyone. For those who want to learn more, I have left some references. Let’s start with Kotlin.

Memory management in Kotlin (Android)

Android uses the most common type of garbage collection, known as tracing garbage collection with the CMS algorithm. CMS stands for Concurrent Mark-Sweep. For our needs, you can ignore the “C” letter. It’s more important here to understand how a basic mark and sweep algorithm work.

The first step is to define the Garbage Collection Roots. They could be static variables, active threads (e.g a UI Thread in Android), or other as listed here. When this has been done, GC starts a mark phase. For this purpose, the GC traverses a whole objects tree. Every created object has a mark bit which is set to 0 by default. When an object is visited in the mark phase, its mark bit is set to 1 — it means it’s reachable.

In the picture above, the objects which stayed grey after this phase are inaccessible, thus our app doesn’t need them anymore. But, before you go any further, please have a look at the picture again. Do you notice the objects which are pointing to each other? iOS developers call them retain cycles. This issue doesn’t exist in the Android world. The “cycles” are removed when there is no path to the GC Root.

Another thing which is worth mentioning about the mark phase is its hidden cost. You should be familiar with the term Stop The World. Before each collection cycle, the GC pauses our app in order to prevent allocating new objects when going through the objects tree. The pause duration depends on the number of reachable objects. The total number of objects or the heap size doesn’t matter. That’s why creating many “alive” unnecessary objects is painful — for instance, autoboxing inside a loop. The GC starts the process, when the memory heap is almost full. So, when you create many unnecessary objects, you fill up the memory heap quicker, which in turn generates more GC cycles and more frame drops, because every pause uses up your app’s time.

Now, let’s talk about removing. To get rid of any waste, the GC runs the next phase — sweeping. In this case, the GC searches the memory heap in order to find all objects with a mark bit set to 0. In the last step, they are removed and the mark bits of all reachable objects are reset to 0. It’s as simple as that. However, this solution has a drawback. It may lead to memory heap fragmentation. It means that your heap may have quite a lot of space in total (free memory), but this space is divided into small blocks. As a result, you may get into trouble when trying to allocate a 2 MB object into 4 MB of free memory. Why? Because, the largest single block may not be enough to accommodate 2 MB. That’s the reason why Android uses an improved version called Compact. The Compact variant has one additional step. The objects which survived the sweep phase are moved to the beginning of the memory heap — check the picture.

There’s no such thing as a free lunch. This enhancement increases the GC pause.

So that’s memory management in Android in a nutshell. Of course, I didn’t cover all the aspects. For example, I skipped the heap generation topic. If you are eager and want to learn about that, click here and check out The Generational Garbage Collection Process section.

Ok, now it’s time for Swift.

Memory management in Swift (iOS)

Swift uses a simple garbage collection mechanism. It’s called ARC (Automatic Reference Counting). This approach is based on tracking the strong references count to an object held by other objects. Every new created instance of a class stores extra information — a references counter. Whenever you assign an object to a property, variable or constant (making a strong reference to it), you increment the references counter value. Until that value is not equal to 0, your object is safe and cannot be deallocated. But as soon as the references counter goes to 0, the object will be reclaimed immediately*, without any pause and without initiating the GC collection cycle. It’s a big advantage over the tracing garbage collection type.

Ok, I put an asterisk next to “immediately”. I did this because you can find some information on the Internet that it’s one of the memory management myths. I encourage you to check out this interesting discussion :)

Of course, there’s the other side of the coin — the retain cycles mentioned before. First of all, let’s have a look how to create a retain cycle and what iOS developers have to do to avoid memory leaks. Let’s consider two similar classes:

class Person {
    let name: String
    var dog: Dog?
    init(name: String) {
        self.name = name
    }
}
class Dog {
    
    let name: String
    var owner: Person?
    
    init(name: String) {
        self.name = name
    }
}

Both classes have a name and an optional property — a dog for a person, because a person may not always have a dog, and a person for a dog, because a dog may not always have an owner — so sad :(

The next snippet of code creates instances of each class and at the same time sets the references counter to 1 for both:

var joe: Person? = Person(name: "Joe")
var lassie: Dog? = Dog(name: "Lassie")

Joe and Lassie are strong references to the Person and Dog instance respectively. So far, so good. If you assign a nil to the joe variable, you reclaim memory, because there’s no strong reference to the Person instance anymore.

To create a strong reference cycle a.k.a. a retain cycle, just link the two instances together.

joe!.dog = lassie
lassie!.owner = joe

Please notice the references counters. Both of them have the same value of 2.

Now, if you break the strong references held by the joe and lassie, the references counters don’t reset to 0.

joe = nil
lassie = nil

ARC is not able to deallocate the instances, due to the retain cycle.

Of course, there’s a solution to that. To resolve the strong reference cycle, you should use a weak or unowned reference. You just add a special keyword before a variable, and then when you assign an object to that variable, the object’s references counter is not bumped up.

How memory management affects the way we code

Frankly speaking, when I started to learn iOS, I thought that a weak reference in iOS and Android function in the same way. Of course, that isn’t true.

Using the weak keyword in iOS is something normal and can even be considered good practice when you widely use the Delegation pattern. When it comes to Android, it’s not a common practice, unless you still use AsyncTasks (I hope not).

Due to the retain cycles, iOS developers sometimes need to write more complex code for simple things than Android developers. The best example of that is using a closure (Swift) and lambda (Android).

Android:

class UpdateHandler {
    var actionAfterUpdate: (() -> Unit) = {} // Lambda
fun update() {
        // do work
        actionAfterUpdate()
    }
}

iOS:

class UpdateHandler {
    var actionAfterUpdate: () -> Void = {} // Closure
    
    func update() {
        // do work
        actionAfterUpdate()
    }
}

The actionAfterUpdate will execute when the update() method is complete. Now, let’s check how to use the UpdateHandler.

Android:

class MyObject {
    val updateHandler = UpdateHandler()

    fun doSomething() {
        // do important thing
    }

    fun timeToUpdate() {
        updateHandler.actionAfterUpdate = { doSomething() }
        updateHandler.update()
    }
}

iOS:

class MyObject {
    let updateHandler = UpdateHandler()
    
    func doSomething() {
        // do important thing
    }
    
    func timeToUpdate() {
        updateHandler.actionAfterUpdate = { self.doSomething() }
        updateHandler.update()
    }
}

As you can see, using the UpdateHandler is simple. Before you call the update() method, you declare what should happen after that update. Everything seems to be fine, but… the iOS version has a terrible mistake… It’s terrible because it causes a memory leak. What’s the problem? It’s the actionAfterUpdate closure, which holds a strong reference to self. Self is a MyObject instance which also holds a reference to the UpdateHandler — a retain cycle! To prevent a memory leak we have to use a weak (or unowned, which is sufficient in this case) keyword inside the closure:

updateHandler.actionAfterUpdate = { [weak self] in self?.doSomething() 
}

Another problem is the Lapsed Listener problem. In short, when you register a listener and forget to unregister that, as a consequence you end up with a memory leak in your app.

I modified examples which I used before to discuss this can of worms in more detail.

Right now, the UpdateHandler is a singleton, whose lifespan is as long as the lifespan of our app. To get an update, you must register a listener first.

Android:

object UpdateHandler {
    private var listener: OnUpdateListener? = null

    fun registerUpdateListener(listener: OnUpdateListener) {
        this.listener = listener
    }

    fun update() {
        // do work
        listener?.onUpdateComplete()
    }
}

interface OnUpdateListener {
    fun onUpdateComplete()
}

iOS:

class UpdateHandler {
    
    static let sharedInstance = UpdateHandler()
    
    private var listener: OnUpdateListener? = nil
    
    func registerUpdateListener(listener: OnUpdateListener) {
        self.listener = listener
    }
    
    func update() {
        // do work
        listener?.onUpdateComplete()
    }
}

protocol OnUpdateListener: class {
    func onUpdateComplete()
}

And some modifications in the MyObject class.

Android:

class MyObject: OnUpdateListener {
    override fun onUpdateComplete() {...}
}

iOS:

class MyObject: OnUpdateListener {
    func onUpdateComplete() {...}
}

As you can see, MyObject is an update listener and it does a certain action when the update is complete.

To emphasise the problem, I put that code in a place where it is invoked each time when you quit and restart the app. However, please notice that this example doesn’t make sense in the production code. It’s just a simple example :)

Android (MainActivity.kt):

override fun onStart() {
    super.onStart()
    val myObject = MyObject()
    UpdateHandler.registerUpdateListener(myObject)
    UpdateHandler.update()
}

iOS (AppDelegate.swift):

func applicationWillEnterForeground(_ application: UIApplication) {
    let myObject = MyObject()
    UpdateHandler.sharedInstance.registerUpdateListener(listener: myObject)
    UpdateHandler.sharedInstance.update()
}

So, I have created a myObject, passed it to the UpdateHandler as an update listener and called the update() method. The update() method notifies the listener about finished work, calling the onUpdateComplete() method (the onUpdateComplete() method is executed inside MyObject).

The instance of the MyObject class should be removed when onStart() / applicationWillEnterForegorund(…) is complete, because objects created inside methods stay alive as long as the method time execution and become eligible for garbage collection after that time. But in this case, the UpdateHandler holds a reference to the MyObject instance forever. How can you manage a potential memory leak? Probably all iOS developers would say — “Use a weak reference!”, and they’re right. Using a weak keyword with a listener variable inside the UpdateHandler class does the trick:

class UpdateHandler {
    ...
    private weak var listener: OnUpdateListener? = nil
    ...
}

Thanks to this, ARC is able to remove the listener for us.

But what with Android? A few Android developers would say the same — “Hold a listener as a WeakReference!”. Ok, it may help…sometimes…but I’m sure that it’s the beginning of your problems :) You should know, every time you hold your callback as a WeakReference a kitten dies.

The WeakReference makes your object eligible for garbage collection. So it may be removed earlier than you think. The one and only solution to this case is adding a unregisterUpdateListener method and clearing the listener manually.

Similar yet different

Congratulations to everyone who has persevered to the very end!

I will be glad if this article helps someone to understand this complicated topic. I tried to explain like you’re five, step by step. Seemingly two similar programming languages hide many differences under the hood, and I want you to be aware of that. Sometimes a common practice from Android doesn’t work on iOS and vice versa. Moving from one platform to another is not as simple as it might seem.

Edit (19.06.2018)

Please notice, I wrote:

“Swift uses a simple GC collection mechanism”

which may be misleading and it’s my bad. I didn’t mean that ARC has a garbage collector. I meant garbage collection as a process of getting rid of waste (unused objects). ARC is another mechanism/technique of getting rid of waste. I also didn’t mention that the garbage collection process on Android works in the runtime of your app, whereas ARC is provided at compile time.

Thank you guys for your feedback and for keeping a watchful eye on content of this page.