Rò rỉ bộ nhớ (memory leak) — tình huống ứng dụng không giải phóng bộ nhớ bị chiếm dụng bởi các đối tượng không còn cần thiết. Trong phát triển di động, điều này đặc biệt quan trọng: heap hạn chế và không có swap dẫn đến OutOfMemoryError và ứng dụng bị treo. Theo Purdue University (2022), 35% ứng dụng Android trên Google Play chứa ít nhất một rò rỉ bộ nhớ. Hãy xem xét các kịch bản điển hình, công cụ chẩn đoán và phương pháp khắc phục.
Những điểm chính
Rò rỉ bộ nhớ — tình huống khi bộ nhớ được cấp phát không được trả lại cho hệ thống sau khi đối tượng không còn cần thiết cho chương trình. Trình dọn rác coi đối tượng đó là còn sống vì có một chuỗi tham chiếu hoạt động từ GC Root trỏ đến nó.
Trong Java/Kotlin, trình dọn rác hoạt động tự động, nhưng nó không thể xác định rằng một đối tượng không cần thiết về mặt logic nếu có tham chiếu kỹ thuật đến nó. Nhà phát triển phải chủ động ngắt các kết nối không cần thiết. Trong Swift/Objective-C, ARC tự động đếm tham chiếu, nhưng retain cycles ngăn bộ đếm về không.
Nguy hiểm chính của rò rỉ là hiệu ứng tích lũy. Mỗi rò rỉ tiêu thụ một lượng nhỏ bộ nhớ, nhưng với các chuyển đổi màn hình lặp lại (xoay màn hình, mở/đóng Activity), rò rỉ tích tụ cho đến khi cạn kiệt giới hạn heap.
Rò rỉ — đối tượng không thể truy cập được từ mã nhưng không bị GC xóa. Phình to — đối tượng cần thiết về mặt logic nhưng được lưu trữ với số lượng quá mức. Ví dụ về phình to: bộ đệm ảnh 100 MB với tập làm việc 30 MB. Cả hai vấn đề đều dẫn đến OOM, nhưng nguyên nhân và phương pháp xử lý khác nhau.
ART (Android Runtime) sử dụng dọn rác theo thế hệ với nén đồng thời. Bộ nhớ được chia thành thế hệ trẻ (Young), thế hệ già (Old) và đối tượng lớn (Large). Các đối tượng sống qua nhiều chu kỳ GC được chuyển đến Old generation, nơi việc dọn ít xảy ra hơn — điều này tăng tốc các chu kỳ thông thường.
GC bắt đầu khi heap đạt đến một ngưỡng chiếm dụng nhất định (thường là 75-85%). Trong thời gian GC, tất cả các luồng của ứng dụng đều bị tạm dừng (STW — Stop The World). Càng nhiều đối tượng sống, thời gian tạm dừng càng lâu. Rò rỉ làm tăng số lượng đối tượng sống, kéo dài thời gian tạm dừng GC.
Trình dọn xác định các đối tượng sống bằng cách duyệt đồ thị từ GC Roots: trường tĩnh, biến stack của các luồng đang hoạt động, tham chiếu JNI. Bất kỳ đối tượng nào có thể truy cập qua tham chiếu từ các gốc này đều được coi là sống — ngay cả khi nhà phát triển biết rằng nó không còn cần thiết.
// Ví dụ: bộ sưu tập tĩnh như GC Root — rò rỉ vĩnh viễn
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference không ngăn GC — hành vi đúng
}
}
WeakReference giải quyết vấn đề: GC bỏ qua tham chiếu yếu khi xác định đối tượng sống. Nếu chỉ còn tham chiếu yếu đến một đối tượng, nó sẽ được dọn trong chu kỳ GC gần nhất.
Activity Context — kịch bản rò rỉ phổ biến nhất trong Android. Nếu singleton, trường tĩnh hoặc dịch vụ tồn tại lâu lưu trữ tham chiếu đến Activity Context, toàn bộ Activity với tất cả Views không thể được GC dọn. Giải pháp: sử dụng Application Context cho các đối tượng tồn tại lâu.
Handler và tin nhắn đã gửi — Handler.postDelayed(runnable, delay) đặt một tin nhắn vào hàng đợi Main Looper. Nếu Activity bị hủy trước khi hết thời gian trễ, tin nhắn vẫn ở trong hàng đợi và giữ tham chiếu qua Runnable → lớp ẩn danh → lớp bên ngoài (Activity).
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // bắt buộc: xóa hàng đợi
super.onPause()
}
}
Lớp bên trong — lớp bên trong không tĩnh có tham chiếu ngầm đến thể hiện của lớp bên ngoài. Nếu lớp bên ngoài là Activity và lớp bên trong được truyền ra ngoài (ví dụ, vào RecyclerView.Adapter), Activity không thể được dọn.
Android Studio Memory Profiler — công cụ tích hợp để giám sát heap thời gian thực. Hiển thị biểu đồ bộ nhớ đã dùng, số lượng cấp phát và đối tượng theo loại. Cho phép ghi heap dump và xuất sang định dạng HPROF để phân tích trong MAT.
Eclipse MAT (Memory Analyzer Tool) — trình phân tích heap dump trên máy tính để bàn. Tự động xây dựng báo cáo Leak Suspects, làm nổi bật các đối tượng có retained size lớn nhất và đề xuất chuỗi GC Root nghi ngờ cho mỗi đối tượng khả nghi.
Xcode Memory Graph Debugger — dành cho iOS. Tạm dừng ứng dụng và trực quan hóa đồ thị đối tượng. Retain cycles được tô màu đỏ; bạn có thể nhấp vào bất kỳ đối tượng nào để xem retain count và tham chiếu của nó.
| Công cụ | Khả năng | Độ phức tạp |
|---|---|---|
| Memory Profiler | Biểu đồ thời gian thực, heap dump, Object Allocation Tracking | Thấp |
| Eclipse MAT | Dominator tree, Leak Suspects, truy vấn OQL | Trung bình |
| LeakCanary | Phát hiện tự động, dấu vết rò rỉ trong thông báo | Tối thiểu |
| Xcode Memory Graph | Đồ thị trực quan retain cycles, danh sách đối tượng sống | Thấp |
Theo Uber Engineering Blog, việc tích hợp lược sử bộ nhớ tự động (LeakCanary + phân tích heap dump) vào đường ống CI/CD giảm 60% sự cố liên quan đến bộ nhớ trong sản xuất trong vòng 3 tháng.
Thay thế Context — nếu đối tượng sống lâu hơn Activity, hãy sử dụng applicationContext. Tất cả các đối tượng tồn tại lâu (singleton, repository, helper cơ sở dữ liệu) nên nhận Application Context, không phải Activity Context. Ngoại lệ: thành phần UI cần truy cập theme hoặc tài nguyên cụ thể của Activity.
Thành phần nhận biết vòng đời — sử dụng LifecycleObserver, DefaultLifecycleObserver hoặc phần mở rộng phản ứng tự động hủy đăng ký tại onDestroy. Android Jetpack cung cấp lifecycleScope và viewModelScope, được dọn dẹp bởi sự kiện vòng đời tương ứng.
Lớp bên trong tĩnh — nếu lớp bên trong không cần truy cập vào các trường của lớp bên ngoài, hãy làm cho nó static. Lớp bên trong tĩnh không có tham chiếu ngầm đến lớp bên ngoài. Nếu cần truy cập, hãy sử dụng WeakReference cho tham chiếu tường minh.
class MyActivity : AppCompatActivity() {
// ❌ Lớp bên trong không tĩnh — tham chiếu ngầm đến MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Lớp bên trong tĩnh — không có tham chiếu ngầm
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
Trong iOS, sử dụng danh sách chụp: [weak self] trong các closure có thể sống lâu hơn người tạo ra chúng. Cho delegate, sử dụng tham chiếu yếu (weak var delegate). Cho các closure được đảm bảo chỉ được gọi trong vòng đời của self, có thể sử dụng [unowned self], nhưng cẩn thận — truy cập đối tượng đã giải phóng sẽ gây treo.
Câu hỏi thường gặp
Trong Android, thực hiện một số chuyển đổi giữa các màn hình (Activity A → B → A → B) và kiểm tra adb shell dumpsys meminfo package_name. Nếu Total PSS tăng ổn định và không trở về giá trị ban đầu — có rò rỉ. Trong iOS, tương tự: sử dụng Debug Memory Graph trong Xcode để kiểm tra trực quan.
Có, nếu CoroutineScope không bị hủy khi thành phần bị phá hủy. Coroutine được khởi chạy trong GlobalScope tiếp tục thực thi ngay cả sau finish() của Activity. Giải pháp: sử dụng viewModelScope (bị hủy trong onCleared) hoặc lifecycleScope (bị hủy trong onDestroy). Cho phạm vi tùy chỉnh, hãy tạo phạm vi nhận biết vòng đời qua LifecycleOwner.
Bitmap lưu trữ dữ liệu pixel trong heap gốc (native heap), không phải heap Java. Điều này có nghĩa là Java GC không thấy kích thước thực của Bitmap. Nếu Bitmap không được tái chế bằng recycle() hoặc tham chiếu không được đặt thành null, bộ nhớ gốc sẽ không được giải phóng. Sử dụng BitmapFactory với inSampleSize để tải các bản sao thu nhỏ và Glide/Coil để quản lý bộ đệm tự động.
Trường tĩnh — là một GC Root. Nó tồn tại miễn là lớp được tải (trong Android — miễn là Process còn sống). Nếu trường tĩnh tham chiếu đến Activity, Bitmap, View hoặc bất kỳ đối tượng nặng nào khác, đối tượng đó sẽ không bao giờ được GC dọn. Trường tĩnh là một tham chiếu vĩnh viễn. Giải pháp: chỉ lưu trữ WeakReference hoặc đặt trường tĩnh thành null trong onDestroy.
ARC tự động giải phóng đối tượng khi bộ đếm tham chiếu mạnh giảm về không. Retain cycle là cách duy nhất để rò rỉ trong ARC. Luôn sử dụng weak cho tham chiếu cha→con nơi con có thể sống lâu hơn cha (delegate, data source). Cho closure, sử dụng danh sách chụp [weak self] và kiểm tra self có nil không bên trong closure.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm