Zygote
Threads & concurrencynerd

Threads in a real app process

Open any Android app in a profiler and you will find dozens of threads you never created. Knowing where they come from is the difference between a scary trace and a readable one.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

A minimal Android app with one empty activity typically shows a dozen or more threads.1 A real app with network, images, a database and analytics can show sixty. Every one of them is there for a reason, and most of them are not yours.

TL;DR#

  • Your app process contains framework threads (main, , , JIT, GC, profile saver) plus library threads (OkHttp, Room, Coil, Play services callbacks) plus your own.
  • Binder threads are created on demand to serve incoming calls; their count is bounded by the process's thread pool limit.
  • RenderThread is the separate thread that replays display lists to the GPU, so drawing can continue while the main thread is busy.
  • Thread count is not a bug in itself, but unbounded growth is: a leak in a thread pool or an un-cancelled coroutine scope shows up here first.2
  • Thread names matter. Name yours. HandlerThread("io-sync") costs nothing and makes every trace readable.

Mental model#

Think of a workshop with permanent staff and contractors.

The main thread is the foreman: one person, everything routed through them. RenderThread is the painter, working alongside from their own bench. Binder threads are the receptionists — more appear when the phone rings a lot, up to the number of lines. GC and JIT are the maintenance crew, working in bursts and mostly unnoticed.

Where the analogy breaks#

The contractors in an Android process are created by code you did not write and often cannot see: a library initialising itself in a provider, a Play services callback, a WorkManager executor. The workshop does not have a single hiring manager. That is why "where did this thread come from?" is a real investigative question, and why naming threads is a professional habit rather than a nicety.

The big picture#

Loading diagram…
Text description of this diagram

A code walkthrough showing where work runs in a real app. Two lanes are shown: the main thread and the IO dispatcher. A ViewModel launches on the main dispatcher; the repository starts two child coroutines on IO. While the children run, the main thread is free. When they complete, the continuation resumes on main and publishes state. The same picture applies to every library on this page: work starts on one lane, suspends, and resumes on another — the threads in a process trace are the lanes.

The lanes in that diagram are abstractions over threads. In a real trace, they map onto the main thread and onto worker threads from Dispatchers.IO's pool — threads you did not create, named DefaultDispatcher-worker-N.

Deep dive#

The threads the framework creates#

A typical app process has these before your code runs:

ThreadCreated byWhat it does
mainActivityThread.main()lifecycle, input, frames, your code
RenderThreadthe view root, on first drawreplays display lists to the GPU
Binder:NNNN_NBinder driver, on demandserves incoming Binder calls
Jit thread poolARTcompiles hot methods
HeapTaskDaemonARTconcurrent GC work
Profile SaverARTwrites the profile used for AOT compilation
Signal CatcherARThandles SIGQUIT and dumps ANR traces
FinalizerDaemon / ReferenceQueueDaemonARTfinalisation and reference processing
adb shell ps -T -p $(adb shell pidof com.example.notes) -o TID,NAME

The output is the ground truth. main is TID equal to PID; everything else is a thread.

Binder threads#

A process that exposes a Binder interface gets a pool of threads to serve incoming calls. They are created on demand, up to a maximum (typically 15 or 16 for app processes), and named Binder:<pid>_<n>.

Two behaviours follow, and both cause real bugs:

  1. Calls arrive on a Binder thread, not the main thread. If your Service method touches UI without posting, it crashes or corrupts state.
  2. A slow method holds a Binder thread. Exhaust the pool with slow calls and new callers block until one frees up.
// AIDL stub implementation: this runs on a Binder thread.
private val binder = object : IRemoteSync.Stub() {
    override fun requestSync(account: String) {
        // Do not touch UI here.
        scope.launch { doSync(account) }   // hand off and return fast
    }
}

Binder also propagates thread priority for synchronous calls, so a server thread doing work for a high-priority client inherits that priority. That is deliberate: it stops background work from starving a UI-critical transaction. The cost is that priority inheritance can be surprising when you are profiling.3

Library threads#

Every I/O library brings threads:

LibraryThreads you will see
OkHttpOkHttp ConnectionPool, OkHttp <host> dispatcher threads
Rooma query executor, plus the InvalidationTracker observer
Coil / Glidea decode pool, a bitmap pool
WorkManagerits own executor and scheduler threads
Play servicescallback threads per API, plus the GMS process itself
Firebasean event queue thread and a network thread

None of these is a problem by itself. What matters is whether they are bounded and whether they shut down. A pool that grows with every screen visit is a leak, and it will show up as a rising thread count in ps -T over a session.

Your threads, and how to name them#

// A named HandlerThread: appears in traces as "io-sync".
private val ioThread = HandlerThread("io-sync").apply { start() }
private val ioHandler = Handler(ioThread.looper)
 
// A named coroutine dispatcher: thread names become "sync-1", "sync-2".
val syncDispatcher = Executors
    .newFixedThreadPool(2) { runnable ->
        Thread(runnable, "sync-${threadCounter.incrementAndGet()}")
    }
    .asCoroutineDispatcher()

The naming convention is not cosmetic. An ANR trace with 40 threads named Thread-23 through Thread-62 is unreadable; the same trace with io-sync, image-decode and analytics-flush tells you immediately which subsystem was involved.

Reading a thread dump#

adb shell kill -3 $(adb shell pidof com.example.notes)
adb logcat -d | sed -n '/DALVIK THREADS/,/^$/p'

The dump shows every thread with its state, priority, and stack. The reading strategy:

  1. Find main. What is it waiting on?
  2. If it is WAITING on a lock, find the thread holding that lock.
  3. If it is in nativePollOnce, the main thread is idle — the ANR is not a blocked main thread but a missing response on an input channel, or a broadcast that never finished.
  4. Look for threads in RUNNABLE doing unexpected work: a network read, a JSON parse, a database query.

Thread count and memory#

Each thread has a stack with a sizeable virtual reservation, though the resident cost is far smaller. Sixty threads therefore cost less memory than their stack sizes suggest, but they still cost virtual address space and sixty scheduling entities.

Android 17 enforces stricter per-app memory limits and adds generational GC to ART's concurrent collector.4 Both make allocation-heavy, thread-heavy designs more expensive than they used to be. A pool of eight threads where two would do is now a measurable cost, not just an aesthetic one.

Where threads go wrong#

Four failure modes, in order of frequency:

  1. Unbounded thread creation. Thread { }.start() in a loop, or a pool created per screen.
  2. Un-cancelled scopes. A CoroutineScope() created outside a lifecycle, never cancelled.
  3. Blocking a shared pool. A Dispatchers.IO task that waits on another Dispatchers.IO task can starve under load.
  4. Thread confinement violations. Touching views or Compose state from a non-main thread, which produces intermittent crashes rather than deterministic ones.
// Wrong: a new scope per call, never cancelled.
fun load() {
    CoroutineScope(Dispatchers.IO).launch { repository.load() }
}
 
// Right: tied to a lifetime.
fun load() {
    viewModelScope.launch { repository.load() }
}

The symptom of the first pattern is a slow rise in thread count and memory across a session, with no single crash. It is one of the most common production leaks and one of the easiest to spot with ps -T.

Measuring the right thing#

QuestionTool
How many threads, and named what?ps -T
What is each thread doing now?kill -3 + logcat
Where does time go per thread?Perfetto trace
Are frames missing deadlines?dumpsys gfxinfo
Is the app allocating too much?Android Studio Profiler, heap dump

None of these requires a debug build. All of them work on a production device, which is why "I cannot reproduce it" is a weaker excuse than it used to be.

How it evolved#

Runtime changes9 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
247.0 Nougat
  • Hybrid JIT + AOT compilation in ART
3313 Tiramisu
  • The notification permission becomes a runtime permission
3717 Cinnamon Bun
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector

The framework threads have been remarkably stable: main, Binder pool, JIT and GC have existed in roughly this shape since ART replaced Dalvik. The growth is on the library side, which is exactly why naming your own threads has become more valuable over time.

Under the hood#

Curiosity corner#

  • The Signal Catcher thread is what produces ANR traces. It receives SIGQUIT and walks every thread's stack, which is why a trace shows a consistent snapshot.
  • ps -T shows threads with the same name repeated. That is a pool; the TIDs differ and that is all that matters.
  • A process can have more threads than the CPU can schedule at once without being slow. Context switching is cheap; the cost is memory and cache pressure, not the count itself.

Try it#

List every thread in an app process

adb shell ps -T -p $(adb shell pidof com.example.notes) -o TID,NAME,STAT

Count threads over time

for i in 1 2 3; do adb shell ps -T -p $(adb shell pidof com.example.notes) | wc -l; sleep 5; done

Check yourself#

Check yourself

1 / 12

A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
  2. docsManage your app's memorydeveloper.android.comaccessed 2026-10-27↩
  3. docsBinder threadingsource.android.comaccessed 2026-10-27↩
  4. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
  5. docsAnrdeveloper.android.comaccessed 2026-10-27↩
  6. docsOverview of system tracingdeveloper.android.comaccessed 2026-10-27↩
  7. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩