Fork me on GitHub

Swift VS Kotlin之内存管理(译)

原文链接:blog.indoorway.com

Illustration by [Wojciech Tymicki](https://dribbble.com/Panweb)

Wojciech Tymicki 设计

在我的上一篇文章中,我提到了 Swift 和 Kotlin 的内存管理,这是你可以在这里找到它。我曾把这个问题留给你一个人,现在我想要补偿你为你描述他们之间的不同。

垃圾收集

     
垃圾收集过程,也称为自动内存管理,是动态分配的内存的自动回收,这里有解释。也许你还不太明白?好的,那我用一些图片和一个简化的例子来解释一下。

这是你的内存堆(不要把它与分配给你应用的stack)混淆。它现在是空的。

1) 空内存堆

1) 空内存堆

当App启动的时候,它在运行时将内存分配给对象(引用类型)。

2) 具有分配内存块的内存堆(蓝色矩形)

2) 具有分配内存块的内存堆(蓝色矩形)

过了一段时间,一些对象可以进行垃圾收集,然后从内存堆中删除。

3) 深蓝色矩形表示有资格进行垃圾收集的对象,然后从内存堆中删除这些对象

3) 深蓝色矩形表示有资格进行垃圾收集的对象,然后从内存堆中删除这些对象

当一个对象没有被引用时,就会成为垃圾收集的最佳候选者。例如,你有一个 TODO 应用程序,有一个任务列表屏幕和一个任务细节屏幕。首先,应用程序分配内存,以便在启动时显示任务列表。当用户点击列表上的一个条目时,将显示详情页面。对于这个操作,你的应用程序动态地分配额外的内存到堆。当用户关闭任务细节页面时,应该删除与之相关的所有对象。原因很简单。你没有无限的记忆,你必须重新获得它。这就是为什么系统删除不可访问的对象。好处是显而易见的,毫无疑问。这意味着程序员不负责删除未使用的对象。与 C、C++ 不同,Kotlin 和 Swift 都实现了这一过程的自动化。所以,如果我们有这个免费的功能,一切都神奇地发生了,你应该关心它吗?是的,你应该这样做,因为这并不意味着你不能犯错。此外,这些知识还可以让你编写更好的应用程序,使其运行更加顺畅。如果你想成为一名优秀的 Android 或 iOS 开发人员,你应该精通内存管理。

尽管 Swift 和 Kotlin 让程序员从为应用程序释放内存的任务中解脱出来,但它们的方式不同。我将尽可能简单地描述这个高级主题,我不会过于关注细节,因为我想让大家都能理解这篇文章。对于那些想了解更多的人,我留下了一些参考资料。让我们先从 Kotlin 开始。

Kotlin 的内存管理 (Android)

   
Android 使用最常见的垃圾收集类型,即使用 CMS算法 跟踪垃圾收集。CMS 表示并发的标记清除。对于我们的需求,你可以忽略“C”字母。更重要的是理解基本的标记和清除算法是如何工作的。

第一步是定义垃圾收集根。它们可以是静态变量、活动线程(例如 Android 中的 UI 线程),或者其他在这里列出的。完成此操作后,GC将开始 标记阶段 。为此,GC 遍历整个对象树。每个创建的对象都有一个标记位,默认设置为0。当一个对象在标记阶段被访问时,它的标记位被设置为1 —— 这意味着它是可用的。

在上面的图片中,这个阶段后保持灰色的对象是不可访问的,因此我们的App不再需要它们了。但是,在你进一步讨论之前,请再看一看这幅画。你注意到那些互相指向的对象了吗?iOS 开发者称它们为引用周期。这个问题在 Android 世界中并不存在。当没有到 GC根 的路径时,将删除“循环”。

另一个值得一提的标志阶段是它的隐性成本。你应该熟悉 全局停顿 这个词。在每个收集周期之前,GC 暂停我们的应用程序,以防止在遍历对象树时分配新对象。暂停持续时间取决于可访问对象的数量。对象的总数或堆大小并不重要。这就是为什么创建许多“活着的”不必要的对象是痛苦的 —— 例如,在循环中自动装箱。GC 在内存堆几乎满时启动进程。因此,当你创建许多不必要的对象时,你会更快地填充内存堆,这反过来又会生成更多的 GC循环 和更多的帧丢失,因为每次暂停都会占用应用程序的时间。

现在,我们来谈谈移除。为了不造成任何浪费,GC 将运行下一阶段 —— 清理 。在这种情况下,GC 将搜索内存堆,以查找所有带有标记位的对象,设置为0。在最后一步中,删除它们,并将所有可访问对象的标记位重置为0,就这么简单。然而,这种解决方案有一个缺点,它可能导致内存堆碎片化。这意味着你的堆可能有相当多的空间(空闲内存),但是这个空间被划分为小块。因此,当尝试将一个 2MB 的对象分配到4MB的空闲内存时,可能会遇到麻烦。为什么?因为,最大的一个块可能不足以容纳 2MB。这就是为什么 Android 使用一个名为 Compact 的改进版本。Compact 变体还有一个附加步骤。在扫描阶段幸存的对象将被移动到内存堆的开始 —— 检查图片。

天下没有免费的午餐。这加剧了 GC暂停。

这就是 Android 的内存管理。当然,我没有涵盖所有方面。例如,我跳过了堆生成主题。如果你想了解这个问题,请点击这里,并查看 The Generational Garbage Collection Process 章节。

好了,现在是 Swift 的出场时间了。

Swift 的内存管理 (iOS)

   
Swift 使用一种简单的垃圾收集机制。它叫做 ARC(自动引用计数)。这种方法基于跟踪其他对象持有的对象的强引用计数。类的每个新创建实例都存储额外的信息 —— 引用计数器。无论何时将对象分配给属性、变量或常量(对其进行强引用),都要增加引用计数器的值。在该值不等于0之前,你的对象是安全的,不能被释放。但是当引用计数器变为0时,对象将立即被回收*,没有任何暂停,也不会启动GC收集周期。与跟踪垃圾收集类型相比,这是一个很大的优势。

好吧,我应该把星号(上一段话中出现的*)放在“立即”旁边。我这样做是因为你可以在互联网上找到一些信息,这是内存管理的神话之一。我鼓励你去看看这个有趣的讨论 :)

当然,还有另一方面 —— 之前提到的引用周期。首先,让我们看看如何创建一个 引用循环 ,以及 iOS开发人员 需要做些什么来避免内存泄漏。让我们考虑两个类似的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
}
}

这两个类都有一个名字和一个可选的财产 —— 狗、狗主人,因为一个人可能并不总是有一只狗,狗也不一定属于某个人 —— 因为狗可能并不总是有主人的,这实在令人伤感 :(

下一个代码片段创建每个类的实例,同时将引用计数设置为1:

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

JoeLassie 分别强引用了 Person 和 Dog 实例。到目前为止还好。如果将 nil 赋给 joe 变量,则回收内存,因为对 Person 实例不再有强引用。

要创建一个强引用循环,只需将两个实例链接在一起。

1
2
joe!.dog = lassie
lassie!.owner = joe

请注意引用计数。它们的值都是2。

现在,如果你破坏了 joelassie 持有的强引用,则引用计数器不会重置为0。

1
2
joe = nil
lassie = nil

由于引用循环,ARC 无法释放实例。

当然,这是有解决办法的。要解决强引用循环,应该使用 weakunowned 进行修饰。你只需在变量之前添加一个特殊的关键字,然后当你将一个对象赋给该变量时,对象的引用计数器就不会被提升。

内存管理如何影响我们的编码方式

   
坦白地说,当我开始学习 iOS 的时候,我认为 iOS 和 Android 中的 weakreference 功能也是一样的。当然,这不是真的。

在iOS中使用 weak 关键字是正常的,当你广泛使用 代理模式 时,甚至可以认为是良好的实践。谈到 Android,这并不是一种常见的实践,除非你仍然使用异步任务(我希望不是)。

由于循环引用,iOS开发人员 有时需要编写比 Android开发人员 更复杂的代码。最好的例子是使用 closure (Swift)和 lambda (Android)。

Android:

1
2
3
4
5
6
7
8
class UpdateHandler {
var actionAfterUpdate: (() -> Unit) = {} // Lambda

fun update() {
// do work
actionAfterUpdate()
}
}

iOS:

1
2
3
4
5
6
7
8
class UpdateHandler {
var actionAfterUpdate: () -> Void = {} // Closure

func update() {
// do work
actionAfterUpdate()
}
}

update() 方法完成时,actionAfterUpdate 将执行。现在,让我们检查如何使用 UpdateHandler.

Android:

1
2
3
4
5
6
7
8
9
10
11
12
class MyObject {
val updateHandler = UpdateHandler()

fun doSomething() {
// do important thing
}

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

iOS:

1
2
3
4
5
6
7
8
9
10
11
12
class MyObject {
let updateHandler = UpdateHandler()

func doSomething() {
// do important thing
}

func timeToUpdate() {
updateHandler.actionAfterUpdate = { self.doSomething() }
updateHandler.update()
}
}

如你所见,使用 UpdateHandler 很简单。在调用 update() 方法之前,你要声明更新之后应该发生什么。一切似乎都很好,但是… iOS 版本的代码有一个严重的错误… 它很糟糕,因为它会导致内存泄漏。是什么问题?它是 actionAfterUpdate 闭包,它保存对 self 的强引用。 Self 是一个 MyObject 实例,它还包含对 UpdateHandler 的引用 —— 一个retain的循环!为了防止内存泄漏,我们必须在闭包中使用 weak (或 unowned 在本例中这就足够了)关键字:

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

另一个问题是 已失效的侦听器问题 。简而言之,当你注册一个侦听器并忘记取消注册时,会导致应用程序中的内存泄漏。

我修改了以前使用的示例,以更详细地讨论这个尚未解决的复杂问题。

现在,UpdateHandler 是一个单例,它的寿命和我们的应用程序的寿命一样长。

Android:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()
}

以及 MyObject 类中的一些修改。

Android:

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

iOS:

1
2
3
class MyObject: OnUpdateListener {
func onUpdateComplete() {...}
}

如你所见,MyObject 是一个更新侦听器,当更新完成时它会执行一定的操作。

为了强调这个问题,我将代码放在一个地方,每当你退出并重新启动应用程序时,它都会被调用。但是,请注意,这个示例在产品代码中没有意义。这只是一个简单的例子 :)

Android ( MainActivity.kt ):

1
2
3
4
5
6
override fun onStart() {
super.onStart()
val myObject = MyObject()
UpdateHandler.registerUpdateListener(myObject)
UpdateHandler.update()
}

iOS ( AppDelegate.swift ):

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

因此,我创建了一个 myObject ,并将其作为更新侦听器传递给 UpdateHandler ,并调用 update() 方法。update() 方法通知侦听器完成的工作,调用 onUpdateComplete() 方法( onUpdateComplete() 方法在 MyObject 中执行)。

MyObject 类的实例应该在 onStart()applicationWillEnterForegorund(…) 完成时删除,因为在方法中创建的对象只要方法时间执行,并且在该时间之后成为垃圾收集的条件,就可以存活。但是在本例中,UpdateHandler 将永远保存对 MyObject 实例的引用。如何管理潜在的内存泄漏?可能所有iOS开发人员都会说:“使用弱引用!””,他们是对的。在 UpdateHandler 类中使用带有 listener 变量的 weak 关键字可以达到这个目的:

1
2
3
4
5
class UpdateHandler {
...
private weak var listener: OnUpdateListener? = nil
...
}

正因为如此,ARC 才能为我们移除监听器。

但是Android呢?一些 Android 开发人员也会说同样的话 —— “把监听器当作弱引用!” 好吧,这可能会有帮助…… 有时…… 但我确信这是你问题的开始 :) 你应该知道,每当你拿回叫作为弱引用时,小猫就会死去。

WeakReference 使对象适合进行垃圾收集。所以它可能比你想象的要早。唯一的解决方案是添加 unregisterUpdateListener 方法并手动清除 listener

相似但不相同

   
祝贺每一个坚持到底的人!

如果这篇文章能帮助人们理解这个复杂的话题,我会很高兴。我试着像你五岁一样,一步一步地解释。貌似两种类似的编程语言隐藏了引擎盖下的许多不同之处,我希望你们能意识到这一点。有时Android的常见做法在iOS上不起作用,反之亦然。从一个平台移动到另一个平台并不像看上去的那么简单。

再次编辑 (19.06.2018)

   
请注意,我说的是:

“Swift 使用一个简单的 GC 收集机制”

是我不好,这可能是误导。我不是说 ARC 是一个垃圾收集器,我的意思是垃圾收集是一个处理垃圾(未使用的对象)的过程。ARC 是处理废物的另一种机制或技术。我也没有提到 Android 上的垃圾收集过程在你的应用运行时是有效的,而 ARC 是在编译时提供的。

谢谢你们的反馈,也感谢你们关注这里的内容。

------------- 本文结束感谢您的阅读 -------------