Sửa lỗi java.lang.OutOfMemoryError Khi Tải Bitmap Lớn trong Android

intermediate📱 Android2026-05-21| Android (API 21+), Java/Kotlin, Android Studio, thiết bị có bộ nhớ heap hạn chế

Error Message

java.lang.OutOfMemoryError: Failed to allocate a 52428800 byte allocation with 8388608 free bytes and 7MB until OOM
#bitmap#memory#glide#picasso#oom

Lỗi Gặp Phải

Bạn đang tải một ảnh — có thể từ bộ chọn thư viện, camera, hoặc URL từ xa — và ứng dụng bị crash với:

java.lang.OutOfMemoryError: Failed to allocate a 52428800 byte allocation with 8388608 free bytes and 7MB until OOM

Con số 52428800 bytes đó là 50 MB. Android đã cố cấp phát 50 MB cho một bitmap duy nhất, chỉ còn ~8 MB trống trong heap, và đành bỏ cuộc. Ứng dụng đã chết.

Nguyên Nhân

Android giải mã ảnh thành dữ liệu pixel thô trong bộ nhớ. Một bức ảnh 12 MP (4032×3024) được giải mã dưới dạng ARGB_8888 sẽ chiếm:

4032 × 3024 × 4 bytes = ~46.5 MB

Đó chỉ là một bức ảnh. Heap mặc định trên nhiều thiết bị tầm trung là 48–128 MB tổng cộng, dùng chung cho mọi thứ khác mà ứng dụng đang xử lý. Tải hai hoặc ba bitmap chưa nén là bạn đã vượt ngân sách.

Các nguyên nhân phổ biến:

  • Tải ảnh camera full resolution trực tiếp vào ImageView
  • Giải mã ảnh trong RecyclerView mà không tái sử dụng bitmap
  • Dùng BitmapFactory.decodeFile() mà không có tùy chọn sampling
  • Giữ tham chiếu đến bitmap trong static field hoặc singleton
  • Tải ảnh bằng Glide/Picasso nhưng bỏ qua cache của chúng (ví dụ: gọi .skipMemoryCache(true) ở khắp nơi)

Bước 1 — Xác Định Nguồn Gốc Trong Logcat

Tìm đúng lời gọi đang gây crash trước khi chỉnh sửa bất kỳ dòng code nào. Chạy ứng dụng, tái hiện crash, sau đó lọc Logcat:

adb logcat | grep -E "OutOfMemoryError|BitmapFactory|alloc"

Hoặc trong Android Studio, lọc theo OutOfMemoryError. Stack trace sẽ chỉ ra chính xác dòng code — thường là một lời gọi BitmapFactory.decode* hoặc bộ tải của thư viện ảnh.

Bước 2 — Nếu Bạn Đang Dùng BitmapFactory Trực Tiếp

Đừng bao giờ giải mã ảnh đầy đủ mà không co lại cho phù hợp với view đích. Hãy dùng BitmapFactory.Options:

fun decodeSampledBitmap(filePath: String, reqWidth: Int, reqHeight: Int): Bitmap {
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true  // chỉ đọc kích thước, không giải mã pixel
    }
    BitmapFactory.decodeFile(filePath, options)

    // Tính toán inSampleSize
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false

    return BitmapFactory.decodeFile(filePath, options)
}

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height, width) = options.run { outHeight to outWidth }
    var inSampleSize = 1
    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

Truyền kích thước hiển thị thực tế của ImageView — không phải giá trị hardcode:

imageView.post {
    val bmp = decodeSampledBitmap(filePath, imageView.width, imageView.height)
    imageView.setImageBitmap(bmp)
}

Cách này có thể giảm một allocation 46 MB xuống còn 3–4 MB mà chất lượng hiển thị thumbnail không khác biệt.

Bước 3 — Chuyển Sang RGB_565 Cho Ảnh Không Có Trong Suốt

Ảnh chụp và ảnh nền hiếm khi cần kênh alpha. Dùng RGB_565 thay vì ARGB_8888 mặc định — nó lưu 2 byte mỗi pixel thay vì 4, giảm bộ nhớ xuống một nửa:

val options = BitmapFactory.Options().apply {
    inPreferredConfig = Bitmap.Config.RGB_565
    inSampleSize = 4  // hoặc giá trị bạn đã tính
}
val bitmap = BitmapFactory.decodeFile(filePath, options)

Bước 4 — Nếu Bạn Đang Dùng Glide

Glide xử lý hầu hết những vấn đề này tự động. OOM vẫn len lỏi vào khi bạn override kích thước đích lên full resolution, hoặc tải vào view có wrap_content mà không có kích thước tường minh.

Hãy giới hạn rõ ràng kích thước và dùng định dạng pixel nhẹ hơn:

Glide.with(context)
    .load(imageUrl)
    .override(800, 600)          // giới hạn kích thước
    .format(DecodeFormat.PREFER_RGB_565)  // giảm một nửa bộ nhớ
    .diskCacheStrategy(DiskCacheStrategy.ALL)
    .into(imageView)

Đối với banner toàn màn hình khi bạn muốn độ phân giải theo chiều rộng màn hình mà không cố định số pixel:

Glide.with(context)
    .load(imageUrl)
    // .override(Target.SIZE_ORIGINAL)  // tránh dùng trừ khi thực sự cần full res
    .override(Resources.getSystem().displayMetrics.widthPixels, 0)  // chiều rộng màn hình, chiều cao tự động
    .into(imageView)

Cũng kiểm tra xem bạn có đang giữ tham chiếu Target trong static field không — đó là cách chắc chắn khiến Glide không bao giờ giải phóng được bitmap.

Bước 5 — Nếu Bạn Đang Dùng Picasso

API của Picasso rõ ràng hơn về việc đặt kích thước. Kết hợp resize()onlyScaleDown() để giới hạn ảnh lớn mà không vô tình phóng to ảnh nhỏ:

Picasso.get()
    .load(imageUrl)
    .resize(800, 600)
    .onlyScaleDown()
    .centerCrop()
    .into(imageView)

Với file cục bộ, truyền URI thay vì đường dẫn file thô. Picasso xử lý URI file:// gọn gàng và không double-buffer dữ liệu như đường dẫn thô đôi khi gây ra.

Bước 6 — Giải Phóng Bitmap Không Còn Cần Dùng

Quản lý bitmap thủ công — không dùng Glide hay Picasso — nghĩa là bạn chịu trách nhiệm dọn dẹp. Gọi recycle() khi view bị detach:

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    val drawable = imageView.drawable
    if (drawable is BitmapDrawable) {
        drawable.bitmap?.recycle()
    }
    imageView.setImageDrawable(null)
}

Một quy tắc bất di bất dịch: đừng bao giờ gọi recycle() trên bitmap đang được hiển thị. Nó gây ra lỗi Canvas: trying to use a recycled bitmap còn khó debug hơn cả lỗi OOM ban đầu.

Bước 7 — Kiểm Tra Memory Leak Bằng Profiler

OOM do rò rỉ bộ nhớ trông khác với một allocation lớn đơn lẻ. Mức dùng heap tăng dần sau mỗi lần điều hướng và không bao giờ giảm xuống. Dùng Memory Profiler của Android Studio để phát hiện:

  • Chạy ứng dụng ở chế độ debug → View → Tool Windows → Profiler
  • Nhấp vào track Memory
  • Điều hướng đến màn hình tải ảnh, nhấn back, lặp lại 5 lần
  • Nhấp Force GC (biểu tượng thùng rác)
  • Nếu mức heap không giảm về mức ban đầu, bạn đang bị rò rỉ bộ nhớ

Nhấp Capture heap dump và tìm các instance Bitmap đáng lẽ đã được giải phóng. Chỉ một bitmap bị giữ lại cũng có thể chiếm 46 MB bộ nhớ vĩnh viễn.

Bước 8 — Tăng largeHeap Chỉ Khi Không Còn Cách Nào Khác

Thêm android:largeHeap="true" vào AndroidManifest.xml sẽ tăng gần gấp đôi heap khả dụng trên hầu hết thiết bị:

<application
    android:largeHeap="true"
    ...>

Đây chỉ là giải pháp câu giờ, không hơn. Crash sẽ quay lại ngay khi ứng dụng tải thêm một chút dữ liệu, và một số thiết bị OEM bỏ qua flag này hoàn toàn. Chỉ dùng trong khi bạn đang xử lý bản sửa thực sự — hoặc cho các ứng dụng có nhu cầu bộ nhớ thực sự lớn như trình chỉnh sửa ảnh.

Kiểm Tra Sau Khi Sửa

  • Tái hiện lại đúng luồng thao tác đã gây crash trước đây — không còn crash tức là bản sửa đang hoạt động
  • Theo dõi Memory Profiler trong quá trình tải ảnh và xác nhận các allocation giữ ổn định
  • Kiểm tra Logcat xem còn cảnh báo Bitmap allocation nào không
  • Kiểm tra trên thiết bị cấu hình thấp (RAM 1–2 GB), hoặc đặt emulator về 512 MB RAM — các vấn đề ẩn trên thiết bị cao cấp sẽ lộ ra ngay lập tức

Bài Học Rút Ra

  • Đừng bao giờ gọi BitmapFactory.decode* mà không dùng inJustDecodeBounds + inSampleSize
  • Glide và Picasso xử lý những phần khó — hãy để chúng quản lý vòng đời thay vì tự cài đặt lại thủ công
  • Ảnh 12 MP luôn chiếm ~46 MB trong bộ nhớ, dù file JPEG trên ổ đĩa nhỏ đến đâu
  • RGB_565 là cách tiết kiệm bộ nhớ miễn phí cho mọi ảnh không có trong suốt
  • Rò rỉ bộ nhớ khó phát hiện hơn allocation lớn đơn lẻ — hãy dùng profiler trước khi kết luận đây là vấn đề tải một lần

Related Error Notes