Quản lý bộ nhớ trong Swift
Tại sao cần phải biết quản lý bộ nhớ?
Nhà giàu mà không biết tiêu tiền cũng sạt nghiệp. Mặc dù phần cứng máy tính/điện thoại ngày càng phát triển, nhưng cứ tiêu xài hoan phí bộ nhớ thì dẫn đến app rất chậm, lag. Users chửi, khách hàng chửi
Biết để đi phỏng vấn. Mình chưa đi phỏng vấn lần nào nhưng dám chắc mấy câu này rất dễ bị hỏi
Học để biết. Kiến thức bao la, biết thêm một kiến thức không bổ bề ngang cũng tràn bề dọc
Rốt cuộc Stack và Heap là gì?
Abstraction, em là ai?
Trước khi bắt đầu vào phần Stack và Heap, mình muốn nói về sự trừu tượng trong học thuật.
Trong cuộc sống, sẽ có rất nhiều thứ bạn xài hằng ngày nhưng không thể giải thích được cách hoạt động của nó. Khi ai đó hỏi bạn "xe ô tô chạy sao mày? ", dù không chạy ô tô bạn vẫn trả lời được "ừ thì cắm chìa khóa rồi khởi động xe rồi chạy thôi". Bạn hiểu là ô tô có động cơ có bánh xe, có phanh, có đèn. Bạn phân biệt được ô tô với xe bò dù không chạy 2 loại này.
Đó chính là sự trừu tượng khóa - Abstraction. Abstraction rất tốt, nó giúp chúng ta hiểu, phân biệt sự vật hiện tượng như xe bò với ô tô ở trên. Trong IT, Abstraction lại càng được sử dụng nhiều hơn.
Tại sao chúng ta cần Abstraction ?
Để hiểu được thực sự 100% Stack, Heap là gì, tại sao thanh Ram lại có 2 cái này, vòng đời, phạm vi của chúng, rồi thằng nào con nào "chi phối" 2 thứ này: Hệ điều hành, complier hay language runtimes, vv là cả một quá trình. Chúng ta phải tự tay viết hệ điều hành, complier, học sâu kiến trúc máy tính mới hiểu được rõ.
Ơi giời, nhưng đã có Abstraction đây rồi, chúng ta không cần hiểu rõ đến mức như vậy. Mục tiêu của chúng ta là lái ô tô chở gấu đi chơi, không phải thiết kế ô tô. Nhưng hiểu một xíu về cách hoạt động sẽ giúp ta lái mượt hơn, ví dụ bẻ cua nên tăng tốc hay giảm tốc.
Stack và Heap?
Stack
Heap
Vùng nhớ stack được sử dụng cho việc thực thi thread. Khi gọi hàm, các biến cục bộ của hàm được lưu trữ vào block của stack (theo kiểu LIFO). Cho đến khi hàm trả về giá trị, block này sẽ được xóa tự động. Hay nói cách khác, các biến cục bộ được lưu trữ ở vùng nhớ stack và tự động được giải phóng khi kết thúc hàm.
Vùng nhớ heap được dùng cho cấp phát bộ nhớ động
Vùng nhớ được cấp phát tồn tại đến khi lập trình viên giải phóng vùng nhớ đó
Kích thước vùng nhớ stack được fix cố định. Chúng ta không thể tăng hoặc giảm kích thước vùng nhớ stack. Nếu không đủ vùng nhớ stack, gây ra stack overflow. Hiện tượng này xảy ra khi nhiều hàm lồng nhau hoặc đệ quy nhiều lần dẫn đến không đủ vùng nhớ.
Hệ điều hành sẽ có cơ chế tăng kích thước vùng nhớ heap.
Thực sự các anh hùng cũng cái nhau dữ dội, bắt bẻ từng câu chữ về Stack vs Heap trên stackoverflow, mọi người có thể xem thêm tại đây nha:
http://stackoverflow.com/questions/79923/what-and-where-are-the-stack-and-heap
Value Types và Reference types
Biến là một trong những thứ sử dụng bộ nhớ nhiều nhất trong lập trình. Không nói qua khi 50% thời gian lập trình là dùng biến.
Ở các ngôn ngữ lập trình, kiểu dữ liệu của được chia làm 2 loại và value type (tham trị) và reference type ( tham chiếu ).
Ví dụ:
Value type Int, Double, Struct, vv.
Reference type là Closure, Class
Điểm khác biệt quan trọng nhất chúng ta cần nhớ giữa 2 loại này:
Value type lưu ngay giá trị của biến trên Stack
Reference type chỉ lưu địa chỉ đến vùng nhớ trong Heap.
Vẽ data model là gì?
Trước khi đi sâu vào vấn đề cũng như hiểu rõ behind-the-scene tại sao có sự khác nhau như vậy. Chúng ta nên ôn lại cách vẽ data model .Data model mô phỏng lại cách thức lưu trữ của value type là reference type
Vẽ data model cho Value Type
Cho đoạn code sau:
var number1: Int number1 = 69 var number2: Int number2 = number1 number1 = 96
Đầu tiên với:
var number1: Int
Khai báo biến number kiểu Int. Tưởng tượng, bạn đang yêu cầu: "Swift, cho tao một chỗ trống để lưu biến kiểu Int, tao đặt tên là number1 nha".
Hình chữ nhật tượng trưng cho một ô nhớ, number1 kế bên là tên biến.
Tiếp theo:
number1 = 69
"Ê Swift, tao muốn bỏ giá trị 69 vô biến number1". Thấy dấu \= , Swift sẽ lấy giá trị bên phải dấu = bỏ vô cái hộp lúc trước đã tạo để lưu trữ dữ liệu.
var number2: Int
Tiếp tục tạo một biến number2:
Tiếp theo:
number2 = number1
Theo như chúng ta suy nghĩ, khi thực hiện phép gán =, Swift liệu có lấy giá trị bên phải bỏ vào hộp (ô nhớ) như trước?
Nhưng không, Swift sẽ tạo lấy một bản copy của 69 từ number1 rồi bỏ vào ô nhớ ở number2 như hình bên dưới
Như ta thấy,không có sự liên kết nào giữa number1 và number2.
Và dòng code cuối cùng:
number1 = 96
Do không có sự liên kết nào giữa number1 và number2 nên khi thay đổi giá trị của number1, number2 không bị ảnh hưởng gì hết.
Vẽ data model cho Reference Type
Ta có đoạn code sau:
class User{ var age = 1 init(age: Int){ self.age = age } } var user1: User user1 = User(age: 21) user1.age = 22 var user2 = User(age: 25) user2 = user1
user1.age = 30
var user1: User
Đầu tiên, khởi tạo biến:
Tiếp theo là khởi tạo đối tượng từ class User:
user1 = User(age: 21)
Lúc này, khác với Value Type, Swift sẽ tạo một instance User ở trong Heap.
Sau khi tạo instance xong, Swift sẽ lấy địa chỉ của instance này bỏ vào ô nhớ của user1. Trong hình trên, instance có địa chỉ là @0x69
Đương nhiên địa chỉ @0x69 là mình bịa ra, để không phải bịa lung tung như vậy nữa. Ta để dấu mũi tên cho dễ nhìn, giống vầy:
Tiếp theo:
user1.age = 22
Swift sẽ
Lấy địa chỉ trong ô nhớ user1
Tìm instance có địa chỉ đó trong Heap
Thay đổi ô nhớ age trong instance tìm được thành 22.
Hãy nhìn sơ đồ mình họa bên dưới
Tiếp theo tại một biến mới là user2 và khởi tạo luôn:
var user2 = User(age: 25)
user2 = user1
Trước khi toán tử gán = thực thi, ô nhớ user2 vẫn đang lưu địa chỉ 0x60. Nhưng sau khi phép gán = thực thi, nó sẽ lưu địa chỉ lấy từ ô nhớ user1 là 0x69. Ta sẽ phải vẽ lại mũi tên như sau:
Đọc nãy giờ vẫn không thấy quản lý bộ nhớ đâu?
Quản lý bộ nhớ chỗ nào???
Từ từ cháo mới nhừ được. Câu hỏi đặt ra là cái instance còn lại có địa chỉ 0x60 trong Heap sẽ được xử lý thế nào, nó tự động được xóa đi, hay ta phải code để xóa nó?
Strong Reference, Reference counting là gì
Mặc định, một mũi tên trỏ đến một instance trong Heap ở những ví dụ trên được xem là một Strong Reference. Còn reference counting thì đếm mũi tên trỏ đến instance.
Automatic Reference Counting (ARC)
ARC là cơ chế quản lý bộ nhớ của Swift. Cơ chế hoạt động của nó rất giống việc chúng ta vẽ data model nãy giờ. Đó là lý do nãy giờ mình muốn bạn ôn lại Stack, Heap, và vẽ vời như vậy.
Nội dung thì dài dòng nhưng đại ý là:
Nếu một instance không có còn strong reference nào hay được hiểu là reference counting = 0 thì cơ chế ARC sẽ xóa và giải phóng bộ nhớ cho instance đó trong Heap
Như ví dụ trên, instance có địa chỉ 0x60 sẽ bị xóa vì có reference counting = 0
Ví dụ ARC
Quick note về vòng đời của object trong Swift:
Allocation: Giai đoạn cấp phát bộ nhớ. Stack và Heap sẽ đảm nhận việc này.
Initialization: Khởi tạo đối tượng. Hàm init được chạy
Usage: Dùng đối tượng đó
Deinitialization: Hàm deinit chạy
Deallocation: Giải phóng bộ nhớ. Stack hoặc Heap lấy lại vùng nhớ không xài nữa
Stack và Value Type thì tự động giải phóng rồi, nên ta chỉ quan tâm Heap và Reference Type.
Để mô phỏng lại quá trình hủy object, có 2 cách:
Khai báo biến kiểu optional để ta có thể gán nó = nil
Cho đoạn code cần test vào một hàm, chạy hàm đó. Hết scope của hàm đó, những biến trong hàm sẽ bị hủy
Lấy luôn ví dụ User từ đầu đến giờ, mình thêm hàm deinit để track xem instance nào trong Heap bị delete nhé:
class User{ var age = 1 init(age: Int){ self.age = age } deinit { print("user has age: \(age) was deallocated") } } var user1: User? user1 = User(age: 21) user1?.age = 22 var user2: User? = User(age: 25) user2 = user1
và kết quả:
user has age: 25 was initialized
Đúng như những gì chúng ta vẽ nãy giờ phải không nào?
Thêm một ví dụ từ Official Guide:
class Person { let name: String init(name: String) { self.name = name print("\(name) is being initialized") } deinit { print("\(name) is being deinitialized") } }
var reference1: Person? var reference2: Person? var reference3: Person?
reference1 = Person(name: "John Appleseed") reference2 = reference1 reference3 = reference1
// instance đang có 3 strong reference, xóa hết mới giải phóng bộ nhớ cho instance đó được, bỏ comment để xóa //reference1 = nil //reference2 = nil //reference3 = nil
Ví dụ trên mình không vẽ data model nữa vì nó tương tự như ví dụ User rồi.
Strong Reference Cycles
Cho đoạn code:
class Person { let name: String init(name: String) { self.name = name } var apartment: Apartment? deinit { print("\(name) is being deinitialized") } }
class Apartment { let name: String init(name: String) { self.name = name } var owner: Person? deinit { print("Apartment \(name) is being deinitialized") } }
var person: Person? = Person(name: "Khoa dep trai") var apartment: Apartment? = Apartment(name: "Novaland")
person?.apartment = apartment apartment?.owner = person
Dựa vào đoạn code ta có thể vẽ data model sau:
Bây giờ ta muốn hủy 2 biến person và apartment:
person = nil apartment = nil
Một lần nữa vẽ data model để xem chuyện gì xảy ra, và tại sao không xóa được 2 biến trên?
"Hiện tượng" này được gọi là Strong Reference Cycle.
Memory Leak là gì?
Nguyên nhân bị Strong Reference Cycle do cả 2 instance vẫn còn lại strong reference = 1. Mà theo cơ chế ARC thì trong reference = 0 thì instance mới bị hủy được. Trường hợp ta muốn hủy instance nhưng thực tế instance vẫn còn trong Heap như vầy, người ta gọi mà Memory Leak.
Để giải quyết vấn đề này, Swift cung cấp 2 cách đó là Weak Reference và Unowned Reference
Weak Reference
Về chức năng thì Weak Reference giống Strong Reference. Nhưng với cơ chế ARC, thì instance có nhiều weak reference cũng sẽ bị xóa. Đề xài weak reference, ta chỉ cần thêm keyword weak là được:
class Person { let name: String init(name: String) { self.name = name } var apartment: Apartment? deinit { print("\(name) is being deinitialized") } } class Apartment { let name: String init(name: String) { self.name = name } weak var owner: Person? deinit { print("Apartment \(name) is being deinitialized") } } var person: Person? = Person(name: "Khoa dep trai") var apartment: Apartment? = Apartment(name: "Novaland")
person?.apartment = apartment apartment?.owner = person!
person = nil apartment = nil
Ta sẽ được kết quả:
Khoa dep trai is being deinitialized
Apartment Novaland is being deinitialized
Để hiểu rõ, ta cứ vẽ data model ra thôi, dùng mũi tên nét đứt ( -------> ) để biểu diễn weak reference:
Như vậy, instance Person sẽ bị xóa trước do strong reference = 0. Tiếp theo do nó bị xóa nên strong reference đến instance Apartment cũng bị xóa luôn. Dẫn đến instance Apartment có strong reference = 0. Đó là lý do ta thấy
Khoa dep trai is being deinitialized
xuất hiện trước:
Apartment Novaland is being deinitialized
Bạn thử thêm weak reference vào class Person, và xóa weak reference ở class Apartment thì output sẽ ngược lại:
weak var apartment: Apartment?
Output lúc này:
Apartment Novaland is being deinitialized
Khoa dep trai is being deinitialized
Tương tự, bạn tự vẽ data model nhé.
Unowned Reference
unowned reference cũng giống như weak reference. ARC chỉ giữ lại instance có strong reference >= 1. Còn instance có unownd reference hay weak reference đều bị xóa
Điểm khác là:
Một instance A unowned reference ( trỏ ) đến một instance B khi mà instance B đó có vòng đời bằng hoặc dài hơn instance A
Là sao? Hãy cùng xem ví dụ sau: Có 2 class và Customer và CreditCard mô phỏng lại ứng dụng ngân hàng:
class Customer { let name: String weak var card: CreditCard? init(name: String) { self.name = name } deinit { print("\(name) is being deinitialized") } }
class CreditCard { let number: Int unowned let customer: Customer init(number: Int, customer: Customer) { self.number = number self.customer = customer } deinit { print("Card #\(number) is being deinitialized") } }
var john: Customer? john = Customer(name: "John Appleseed") john!.card = CreditCard(number: 123456789, customer: john!) john = nil
Vẽ data model cho dễ nhìn:
Một Customer có thể có CreditCard, tuy nhiên CreditCard chỉ tồn tại khi nó gắn với một Customer.
Instance của CarditCard có vòng đời ngắn hơn instance của Customer là cái chắc. ( A trỏ đến B mà vòng đời B dài hơn hoặc bằng A. A là CreditCard B là Customer )
Thực ra ví dụ này từ Official Guide của Apple. Nó không rõ ràng lắm lại vì trong trường hợp này xài weak cũng được mà. Cùng tìm hiểu thêm về memory managment trong Closure để phân biệt rõ weak với unowned nhé
Memory managment trong Closure
Closure cũng như class là reference types.
Trong một class, nếu một property là closure và trong closure đó lại dùng property/method của class nữa ( xài self.property ) thì sẽ xảy ra "hiện tượng" strong reference cycle như những ví dụ ở trên.
Thôi trăm nghe không bằng một thấy, hãy nhìn qua ví dụ tính Fibonacci dưới đây:
class Fibonacci{ var value: Int
init(value: Int) {
self.value = value
}
lazy var fibonacci: () -> Int = {
var a = 0
var b = 1
for \_ in 0..<self.value{
let temp = a
a = b
b = temp + a
}
return a
}
deinit {
print("\\(value) was deinitialized")
}
} var fi: Fibonacci? = Fibonacci(value: 7) fi?.fibonacci()
fi = nil
Vẽ data model ta được:
Dĩ nhiên trường hợp closure này không thể dùng weak/unowned trước property như những ví dụ trước. Để giải quyết vấn đề này, Swift cung cấp một giải pháp: closure capture list
Closure capture list
Capture list sẽ quy định luật để lấy giá trị của property trong closure. Tức là lấy self.property/self.method như thế nào. Mặc định là strong reference như hình ở trên rồi
Ta dùng syntax sau ở phần body của closure:
[weak self ] in
hoặc:
[ unowned self ] in
hoặc lấy nhiều property/method cũng được:
[weak self, unowned self.property, .....] in
Cùng xem qua sự khác nhau giữa chúng nhé, xem dòng comment bên dưới:
lazy var fibonacci: () -> Int = {
\[ weak self \] in
var a = 0
var b = 1
// lúc này self có thể nil, nên phải check optional
guard let max = self?.value else {
fatalError() // return luôn không cần return type
}
for \_ in 0..<max{
let temp = a
a = b
b = temp + a
}
return a
}
Và output lúc này:
7 was deinitialized
Bạn tự vẽ data model để minh họa nhé.
Ở ví dụ này, ta xài unowned là hợp lý nhất vì cả class và closure có vòng đời bằng nhau.
lazy var fibonacci: () -> Int = {
\[ unowned self \] in
var a = 0
var b = 1
// xài unowned lúc này self.value không thể nil được vì vòng đời closure bằng với class
for \_ in 0..<self.value{
let temp = a
a = b
b = temp + a
}
return a
}
Từ những ví dụ trên, ta có thể rút ra kết luận sau: + Unowned thì không thể nil được vì vòng đời cái instance trỏ đi bằng với cái instance nó trỏ đến
+ Weak ngược lại có thể nil, suy ra không thể là hằng được.
Nguồn: Raywenderlich
Strong reference cycle trong IOS
Để tránh strong reference cycle, IOS dùng cơ chế ARC này ở nhiều chỗ.
Dễ thấy nhất là @IBOutlet:
@IBOutlet weak var tableView : UITableView!
và delegate:
Ví dụ homeVC có một tableView:
Đa số delegate nên xài weak vì delegate có thể có hoặc không có.
Thêm một trường hợp hay gặp nữa là: Ví dụ a có thuộc tính delegate tới b, b có thuộc tính delegate tới c.
Nếu để strong reference thì nếu muốn hủy b, c sẽ không hủy được b. Vì b còn strong reference tới a.
What's next:
Còn một số cái như unsafe unowned reference mình chưa đề cập. Tuy nhiên với kĩ năng vẽ data model, bạn có thể đọc document trên Apple, vẽ lại data model và hy vọng nó giúp bạn dễ hiểu những gì đang diễn ra hơn.
Một điểm nữa, document của Apple dùng ví dụ khá trực quan và thực tế, không phải kiểu Foo, Bar như những document khác. Bạn nên đọc lại phần này vài lần để củng cố lại kiến thức nhé.
Resources:
https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/AutomaticReferenceCounting.html
https://krakendev.io/blog/weak-and-unowned-references-in-swift
teamtreehouse.com
https://www.raywenderlich.com/134411/arc-memory-management-swift