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.
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#
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:
| Thread | Created by | What it does |
|---|---|---|
main | ActivityThread.main() | lifecycle, input, frames, your code |
RenderThread | the view root, on first draw | replays display lists to the GPU |
Binder:NNNN_N | Binder driver, on demand | serves incoming Binder calls |
Jit thread pool | ART | compiles hot methods |
HeapTaskDaemon | ART | concurrent GC work |
Profile Saver | ART | writes the profile used for AOT compilation |
Signal Catcher | ART | handles SIGQUIT and dumps ANR traces |
FinalizerDaemon / ReferenceQueueDaemon | ART | finalisation and reference processing |
adb shell ps -T -p $(adb shell pidof com.example.notes) -o TID,NAMEThe 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:
- Calls arrive on a Binder thread, not the main thread. If your
Servicemethod touches UI without posting, it crashes or corrupts state. - 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:
| Library | Threads you will see |
|---|---|
| OkHttp | OkHttp ConnectionPool, OkHttp <host> dispatcher threads |
| Room | a query executor, plus the InvalidationTracker observer |
| Coil / Glide | a decode pool, a bitmap pool |
| WorkManager | its own executor and scheduler threads |
| Play services | callback threads per API, plus the GMS process itself |
| Firebase | an 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:
- Find
main. What is it waiting on? - If it is
WAITINGon a lock, find the thread holding that lock. - 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. - Look for threads in
RUNNABLEdoing 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:
- Unbounded thread creation.
Thread { }.start()in a loop, or a pool created per screen. - Un-cancelled scopes. A
CoroutineScope()created outside a lifecycle, never cancelled. - Blocking a shared pool. A
Dispatchers.IOtask that waits on anotherDispatchers.IOtask can starve under load. - 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#
| Question | Tool |
|---|---|
| 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#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
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#
- ActivityThread.javathe process's real main() and the Looper setup
- RenderThread.cppthe render thread on the native side
- ProcessState.cppBinder thread pool creation and its limits
- thread_list.ccthe runtime's registry of every Java thread
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 -Tshows 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#
adb shell ps -T -p $(adb shell pidof com.example.notes) -o TID,NAME,STAT
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
A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?
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.
- docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsManage your app's memorydeveloper.android.comaccessed 2026-10-27↩
- docsBinder threadingsource.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docsAnrdeveloper.android.comaccessed 2026-10-27↩
- docsOverview of system tracingdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩