{"id":722,"date":"2026-10-02T14:05:00","date_gmt":"2026-10-02T12:05:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=722"},"modified":"2026-09-30T23:13:08","modified_gmt":"2026-09-30T21:13:08","slug":"listener-removes-itself-concurrentmodificationexception","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/listener-removes-itself-concurrentmodificationexception\/","title":{"rendered":"Why a Listener That Removes Itself Throws ConcurrentModificationException"},"content":{"rendered":"<p>A listener that unregisters itself inside its callback changes the list while a <code>for<\/code> loop still walks it. Kotlin&#8217;s <code>for<\/code> loop runs on the list&#8217;s iterator. The iterator of <code>ArrayList<\/code> is fail-fast. It notices the structural change and throws <code>ConcurrentModificationException<\/code> on the next call to <code>next()<\/code>. No second thread is involved. A second cause makes the bug hard to pin down. Whether the loop crashes depends on where the listener sits in the list. It also depends on the runtime, because the JVM and Android decide <code>hasNext()<\/code> differently. In one position, each runtime ends the loop early with no crash. The remaining listener never hears the event. So a JVM unit test can pass while the phone crashes. Copying the list stops the crash. But the copy still calls a listener that an earlier listener just removed. The fix is to iterate a snapshot and check each listener is still registered before calling it.<\/p>\n\n<h2>A sync tracker that crashes on one thread<\/h2>\n\n<p>The running example is a <code>SyncTracker<\/code>. It tells every registered screen when a background sync ends. One caller wants the event only once, so its listener unregisters itself on the first call. Everything runs on the main thread.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun interface SyncListener { fun onSyncFinished(ok: Boolean) }\n\nclass SyncTracker {\n    private val listeners = mutableListOf&lt;SyncListener&gt;()\n\n    fun register(l: SyncListener) { listeners += l }\n    fun unregister(l: SyncListener) { listeners -= l }\n\n    fun notifyFinished(ok: Boolean) {\n        for (l in listeners) l.onSyncFinished(ok)\n    }\n}\n\n\/\/ A one-shot caller: show a snackbar once, then stop listening\ntracker.register(object : SyncListener {\n    override fun onSyncFinished(ok: Boolean) {\n        showSnackbar(ok)\n        tracker.unregister(this)\n    }\n})<\/pre>\n\n\n<p>Here are four registration orders run through this class on the JVM, with Kotlin 2.4.20. The backing list is a <code>java.util.ArrayList<\/code>. Each line shows which listeners were called before the loop stopped.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">toast, oneShot, badge   -&gt; no crash, called=[toast, oneShot]\na, b, oneShot, badge    -&gt; no crash, called=[a, b, oneShot]\noneShot, toast, badge   -&gt; ConcurrentModificationException, called=[oneShot]\ntoast, badge, oneShot   -&gt; ConcurrentModificationException, called=[toast, badge, oneShot]<\/pre>\n\n\n<p>Two of the four orders crash. In the other two, nothing is thrown. But <code>badge<\/code> never gets the event. That silent case is worse than the crash, because nothing in the logs points at it.<\/p>\n\n<h2>Why ConcurrentModificationException fires on a single thread<\/h2>\n\n<p>The <a href=\"https:\/\/kotlinlang.org\/docs\/control-flow.html\" target=\"_blank\" rel=\"noopener\">Kotlin control-flow docs<\/a> say the <code>for<\/code> loop &#8220;iterates through anything that provides an iterator.&#8221; A collection provides one by default. So <code>for (l in listeners)<\/code> calls <code>listeners.iterator()<\/code>, then <code>hasNext()<\/code> and <code>next()<\/code> on each pass. <code>listeners.forEach { }<\/code> is an inline function over the same iterator, so it fails the same way.<\/p>\n\n<p>The <a href=\"https:\/\/docs.oracle.com\/en\/java\/javase\/21\/docs\/api\/java.base\/java\/util\/ArrayList.html\" target=\"_blank\" rel=\"noopener\">Java 21 <code>ArrayList<\/code> reference<\/a> calls that iterator fail-fast. A structural change made outside the iterator&#8217;s own <code>remove<\/code> or <code>add<\/code> makes it throw <code>ConcurrentModificationException<\/code>. Removing an element counts as structural. The list tracks this with a counter called <code>modCount<\/code>. The iterator copies the counter when it&#8217;s created. In the <a href=\"https:\/\/github.com\/openjdk\/jdk\/blob\/master\/src\/java.base\/share\/classes\/java\/util\/ArrayList.java\" target=\"_blank\" rel=\"noopener\">OpenJDK source<\/a>, <code>next()<\/code> compares the two before returning anything.<\/p>\n\n<p>Here &#8220;concurrent&#8221; means during iteration. No other thread is involved. The one-shot listener calls <code>unregister(this)<\/code> from inside the loop body. That bumps <code>modCount<\/code>. The next call to <code>next()<\/code> sees the mismatch and throws.<\/p>\n\n<h2>hasNext() decides whether you crash or silently skip<\/h2>\n\n<p>The check lives in <code>next()<\/code>, not in <code>hasNext()<\/code>. If <code>hasNext()<\/code> returns false first, the loop ends quietly. That&#8217;s the second cause. It differs by runtime.<\/p>\n\n<p>On the JVM, OpenJDK&#8217;s <code>hasNext()<\/code> returns <code>cursor != size<\/code>. It reads the live size. Say the one-shot listener is third of four. After it runs, the cursor is 3 and the size has dropped to 3. <code>hasNext()<\/code> says false. The loop exits. The fourth listener is skipped without an error.<\/p>\n\n<p>Android doesn&#8217;t run OpenJDK&#8217;s class unchanged. AOSP&#8217;s <a href=\"https:\/\/android.googlesource.com\/platform\/libcore\/+\/refs\/heads\/main\/ojluni\/src\/main\/java\/java\/util\/ArrayList.java\" target=\"_blank\" rel=\"noopener\"><code>libcore<\/code> copy of <code>ArrayList<\/code><\/a> marks an &#8220;Android-changed&#8221; <code>limit<\/code> field. It stores the size at the moment the iterator is created. <code>hasNext()<\/code> returns <code>cursor &lt; limit<\/code>. The AOSP comment says this keeps <code>hasNext()<\/code> stable while elements are added or removed. The side effect flips which position is silent.<\/p>\n\n<p>The model below copies both <code>hasNext()<\/code> rules. It keeps the same <code>modCount<\/code> check in <code>next()<\/code>. It runs as plain Kotlin on the JVM. So the Android column comes from the AOSP source above, not from ART.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class ModelList&lt;T&gt;(private val androidRule: Boolean) : Iterable&lt;T&gt; {\n    private val items = ArrayList&lt;T&gt;()\n    private var modCount = 0\n    fun add(x: T) { items.add(x); modCount++ }\n    fun remove(x: T) { if (items.remove(x)) modCount++ }\n\n    override fun iterator() = object : Iterator&lt;T&gt; {\n        val limit = items.size \/\/ Android: size captured at creation\n        var cursor = 0\n        val expected = modCount\n        override fun hasNext() =\n            if (androidRule) cursor &lt; limit else cursor != items.size\n        override fun next(): T {\n            if (modCount != expected) throw ConcurrentModificationException()\n            return items[cursor++]\n        }\n    }\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">oneShot at index 0 of 4 | JVM: CME                          | Android: CME\noneShot at index 1 of 4 | JVM: CME                          | Android: CME\noneShot at index 2 of 4 | JVM: no crash, L3 never called    | Android: CME\noneShot at index 3 of 4 | JVM: CME                          | Android: no crash<\/pre>\n\n\n<p>The JVM rows match the real <code>ArrayList<\/code> run from the first section. That matters for tests. <a href=\"https:\/\/developer.android.com\/training\/testing\/local-tests\" target=\"_blank\" rel=\"noopener\">Local unit tests<\/a> run on your workstation&#8217;s JVM, not on a device. A test that registers the one-shot listener third of four doesn&#8217;t crash there. The same order crashes on the phone. Put it last and the reverse happens.<\/p>\n\n<p>The Java docs warn against leaning on any of this. They say the exception is thrown &#8220;on a best-effort basis.&#8221; Code that depends on it for correctness is wrong.<\/p>\n\n<h2>Copying the list stops the crash but calls a removed listener<\/h2>\n\n<p>The usual first fix is to iterate a copy: <code>listeners.toList().forEach { }<\/code>. Another is to swap in <code>CopyOnWriteArrayList<\/code>. Both stop the crash. Both also create a new bug once one listener removes a different one.<\/p>\n\n<p>Extend the example. A navigation listener reacts to the sync by closing a detail screen. Closing the screen unregisters that screen&#8217;s listener. The screen&#8217;s listener sits after the navigation listener in the list.<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class DetailScreen(private val log: MutableList&lt;String&gt;) {\n    var destroyed = false\n    val listener = SyncListener {\n        log += if (destroyed) \"screen(DESTROYED)\" else \"screen\"\n    }\n}\n\ntracker.register(oneShot)                  \/\/ unregisters itself\ntracker.register {                         \/\/ navigation\n    screen.destroyed = true\n    tracker.unregister(screen.listener)\n}\ntracker.register(screen.listener)\ntracker.register(badge)<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">toList snapshot      -&gt; [oneShot, nav, screen(DESTROYED), badge]\nCopyOnWriteArrayList -&gt; [oneShot, nav, screen(DESTROYED), badge]<\/pre>\n\n\n<p>The destroyed screen still gets the event. On a device, that could be a callback touching a view after <code>onDestroyView()<\/code>. The <a href=\"https:\/\/docs.oracle.com\/en\/java\/javase\/21\/docs\/api\/java.base\/java\/util\/concurrent\/CopyOnWriteArrayList.html\" target=\"_blank\" rel=\"noopener\"><code>CopyOnWriteArrayList<\/code> reference<\/a> documents why. Its iterator reads a snapshot of the array taken when the iterator was created. It &#8220;will not reflect additions, removals, or changes&#8221; made after that. <code>toList()<\/code> behaves the same way, because it&#8217;s a copy too.<\/p>\n\n<h2>The fix: iterate a snapshot, then recheck membership<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">@MainThread\nfun notifyFinished(ok: Boolean) {\n    for (l in listeners.toList()) {\n        \/\/ Skip anyone unregistered earlier in this same dispatch\n        if (l in listeners) l.onSyncFinished(ok)\n    }\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">snapshot + recheck   -&gt; [oneShot, nav, badge]<\/pre>\n\n\n<p>The snapshot makes the loop immune to changes in <code>listeners<\/code>. The membership check keeps removals in effect for the rest of the dispatch. A listener added during the dispatch waits for the next event, because it isn&#8217;t in the snapshot. That&#8217;s usually what you want. Say it out loud in an interview anyway.<\/p>\n\n<p>The <code>in<\/code> check is a linear scan. For a handful of listeners, that cost doesn&#8217;t matter. If the list is large, back it with a <code>LinkedHashSet<\/code> to keep order and get fast lookups.<\/p>\n\n<p>This version assumes one thread, so the <code>@MainThread<\/code> annotation documents the contract. If other threads can register, use <code>CopyOnWriteArrayList<\/code> for the storage. Keep the same membership check in the loop.<\/p>\n\n<p>This is also what LiveData does. <a href=\"https:\/\/github.com\/androidx\/androidx\/blob\/androidx-main\/lifecycle\/lifecycle-livedata-core\/src\/main\/java\/androidx\/lifecycle\/LiveData.java\" target=\"_blank\" rel=\"noopener\"><code>LiveData.java<\/code><\/a> stores observers in a <a href=\"https:\/\/github.com\/androidx\/androidx\/blob\/androidx-main\/arch\/core\/core-common\/src\/main\/java\/androidx\/arch\/core\/internal\/SafeIterableMap.java\" target=\"_blank\" rel=\"noopener\"><code>SafeIterableMap<\/code><\/a>. Its class comment describes a linked list that &#8220;supports modifications during iterations.&#8221; Removing an entry updates any live iterator. <code>removeObserver()<\/code> also marks the wrapper inactive. <code>considerNotify()<\/code> checks that flag first. So an observer removed mid-dispatch never gets the value.<\/p>\n\n<h2>What to say when the interviewer hands you this snippet<\/h2>\n\n<ol>\n<li>Name the mechanism first. The <code>for<\/code> loop uses the list&#8217;s iterator. <code>unregister(this)<\/code> changes the list inside the loop body, so the fail-fast check in <code>next()<\/code> throws. It&#8217;s a single-thread bug.<\/li>\n<li>Explain why it&#8217;s intermittent. The check runs only in <code>next()<\/code>. If <code>hasNext()<\/code> says false first, the loop ends silently and later listeners are skipped. Mention that JVM and Android pick different positions for that silent exit.<\/li>\n<li>Propose the copy, then break it yourself. A snapshot or <code>CopyOnWriteArrayList<\/code> stops the crash. It still calls a listener that an earlier listener removed.<\/li>\n<li>Give the final version. Iterate a snapshot and recheck membership before each call. Then state the threading contract. Point to LiveData as the same design in androidx.<\/li>\n<\/ol>\n\n<p>This fits the spot-the-bug format covered in the <a href=\"https:\/\/grindloop.ai\/blog\/android-machine-coding-round-three-formats\/\">Android live-coding round formats<\/a>. Step 3 goes past finding the crash, because you break your own first fix. The debugging round scores that follow-up bug too. See <a href=\"https:\/\/grindloop.ai\/blog\/android-debugging-interview-second-bug\/\">what the debugging round scores after the first fix<\/a>.<\/p>\n\n<h2>Answers that sound right and still fail<\/h2>\n\n<ul>\n<li>&#8220;It&#8217;s a threading bug, so add <code>synchronized<\/code>.&#8221; There&#8217;s one thread. A JVM lock is reentrant, so the same thread walks straight back in. A nested <code>synchronized<\/code> version still throws <code>ConcurrentModificationException<\/code>.<\/li>\n<li>&#8220;Use <code>iterator.remove()<\/code>.&#8221; That works when the loop does the removing. Here the listener removes itself through <code>unregister()<\/code>. It never sees the iterator.<\/li>\n<li>&#8220;Loop by index instead.&#8221; <code>for (i in 0 until listeners.size)<\/code> computes the range once. After the one-shot listener leaves, every later index shifts down by one. In the four-listener example, the navigation listener was skipped. The loop then threw <code>IndexOutOfBoundsException<\/code> at the end.<\/li>\n<li>&#8220;Switch to <code>CopyOnWriteArrayList<\/code> and you&#8217;re done.&#8221; It stops the crash. It still delivers to listeners removed during the same dispatch.<\/li>\n<li>&#8220;Catch <code>ConcurrentModificationException<\/code>.&#8221; The Java docs say the check is best-effort and only for detecting bugs. The catch also hides the listeners that were never called.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>A related bug explains <a href=\"https:\/\/grindloop.ai\/blog\/android-pagination-race-condition\/\">why fast scrolling duplicates items in a paginated list<\/a>. That bug hands out the live list. A caller iterating it hits the same exception.<\/em><\/p>","protected":false},"excerpt":{"rendered":"<p>Unregistering a listener inside its own callback throws ConcurrentModificationException or skips listeners. Why, JVM vs Android, and the snapshot fix.<\/p>\n","protected":false},"author":2,"featured_media":724,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Why a Listener That Removes Itself Throws ConcurrentModificationException","rank_math_description":"Unregistering a listener inside its own callback throws ConcurrentModificationException or skips listeners. Why, JVM vs Android, and the snapshot fix.","rank_math_focus_keyword":"ConcurrentModificationException","footnotes":""},"categories":[9,7],"tags":[15,16,91,12,73,45],"class_list":["post-722","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-java","tag-android","tag-interview-prep","tag-java-collections","tag-kotlin","tag-live-coding","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/722","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=722"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/722\/revisions"}],"predecessor-version":[{"id":723,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/722\/revisions\/723"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/724"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=722"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=722"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=722"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}