Skip to content

BLOCKING_EXECUTOR in ExecutorsRegistrar uses unbounded CachedThreadPool — causes 68+ threads on low-memory devices #7899

@tyr4n7

Description

@tyr4n7

Description

ExecutorsRegistrar.BLOCKING_EXECUTOR uses Executors.newCachedThreadPool() with no upper bound on thread creation:

// firebase-common/.../concurrent/ExecutorsRegistrar.java
static final Lazy<ScheduledExecutorService> BLOCKING_EXECUTOR =
    new Lazy<>(
        () ->
            scheduled(
                Executors.newCachedThreadPool(
                    factory("Firebase Blocking", ...))));

On low-end Android devices (2GB RAM, 2 CPU cores), this creates 68+ "Firebase Blocking Thread" instances during normal app usage. Each thread consumes ~1MB of stack space, contributing to memory pressure that triggers the low memory killer (lmkd).

Environment

  • Firebase product: firebase-messaging 25.0.1 (only Firebase product in use; BLOCKING_EXECUTOR comes from the transitive firebase-common dependency)
  • Device: 2GB RAM / 2-core CPU (emulator configured to simulate low-end Android Go devices)
  • Android version: API 34

Evidence

Thread dump captured via Thread.getAllStackTraces() shows 68 threads named "Firebase Blocking Thread #0" through "Firebase Blocking Thread #67", all in TIMED_WAITING state:

"Firebase Blocking Thread #0"  TIMED_WAITING
"Firebase Blocking Thread #1"  TIMED_WAITING
...
"Firebase Blocking Thread #67" TIMED_WAITING

Shortly after, the process was killed by lmkd:

lowmemorykiller: reason: low watermark is breached and swap is low (276kB < 151960kB)
ActivityManager: Process has died: fg  TOP

The app was the foreground TOP process and was still killed due to system-wide memory exhaustion.

Comparison

On a higher-spec emulator (4+ cores, more RAM), the same executor creates ~37 "Firebase Blocking Thread" instances. The slower CPU on the 2GB device means tasks take longer to complete, so more threads pile up before they can be reused — the exact scenario where unbounded pools are most dangerous.

Suggested fix

Replace Executors.newCachedThreadPool() with a bounded ThreadPoolExecutor in ExecutorsRegistrar.BLOCKING_EXECUTOR:

static final Lazy<ScheduledExecutorService> BLOCKING_EXECUTOR =
    new Lazy<>(
        () ->
            scheduled(
                new ThreadPoolExecutor(
                    0,
                    Math.max(4, Runtime.getRuntime().availableProcessors()),
                    60L, TimeUnit.SECONDS,
                    new SynchronousQueue<>(),
                    factory("Firebase Blocking", ...))));

This caps thread creation while preserving the same idle-thread reaping behavior. A LinkedBlockingQueue alternative would allow tasks to queue safely when all threads are busy.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions