Merge pull request #2676 from square/py/improve_heap_diff

Iterating on heap growth based on friction / feedback from trying to integrate it inside Square.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8e58e6d..43265b8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -41,6 +41,7 @@
 # and they'll automatically resolve to higher version without having to necessarily resort to a
 # resolution strategy.
 androidX-fragment = { module = "androidx.fragment:fragment", version = "1.0.0" }
+androidX-multidex = { module = "androidx.multidex:multidex", version = "2.0.1" }
 # Exposed transitively, avoid increasing
 androidX-startup = { module = "androidx.startup:startup-runtime", version = "1.0.0" }
 androidX-test-core = { module = "androidx.test:core", version = "1.4.0" }
@@ -55,6 +56,7 @@
 androidX-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version = "2.2.0" }
 androidX-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workManager" }
 androidX-work-multiprocess = { module = "androidx.work:work-multiprocess", version.ref = "workManager" }
+androidX-collections = { module = "androidx.collection:collection-ktx", version = "1.4.0" }
 
 androidSupport = { module = "com.android.support:support-v4", version = "28.0.0" }
 assertjCore = { module = "org.assertj:assertj-core", version = "3.9.1" }
diff --git a/leakcanary/leakcanary-android-instrumentation/build.gradle b/leakcanary/leakcanary-android-instrumentation/build.gradle
index 16c7a58..5bbf189 100644
--- a/leakcanary/leakcanary-android-instrumentation/build.gradle
+++ b/leakcanary/leakcanary-android-instrumentation/build.gradle
@@ -15,6 +15,7 @@
   androidTestImplementation projects.objectWatcher.objectWatcherAndroid
   // Plumber auto installer for running tests
   androidTestImplementation projects.plumber.plumberAndroid
+  androidTestImplementation libs.androidX.multidex
   androidTestImplementation libs.androidX.test.core
   androidTestImplementation libs.androidX.test.espresso
   androidTestImplementation libs.androidX.test.rules
@@ -28,6 +29,7 @@
     targetSdk versions.compileSdk
     minSdk versions.minSdk
     testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    multiDexEnabled true
   }
   buildFeatures.buildConfig = false
   namespace 'com.squareup.leakcanary.instrumentation'
diff --git a/leakcanary/leakcanary-android-instrumentation/src/androidTest/AndroidManifest.xml b/leakcanary/leakcanary-android-instrumentation/src/androidTest/AndroidManifest.xml
index 2743ca7..700c2fc 100644
--- a/leakcanary/leakcanary-android-instrumentation/src/androidTest/AndroidManifest.xml
+++ b/leakcanary/leakcanary-android-instrumentation/src/androidTest/AndroidManifest.xml
@@ -16,7 +16,9 @@
   -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
-  <application>
+  <application
+    android:name="androidx.multidex.MultiDexApplication"
+    >
     <activity android:name="leakcanary.TestActivity"/>
   </application>
 </manifest>
diff --git a/leakcanary/leakcanary-android-test/src/main/java/leakcanary/RepeatingAndroidInProcessScenario.kt b/leakcanary/leakcanary-android-test/src/main/java/leakcanary/RepeatingAndroidInProcessScenario.kt
index 1b3f5b8..fc65e2d 100644
--- a/leakcanary/leakcanary-android-test/src/main/java/leakcanary/RepeatingAndroidInProcessScenario.kt
+++ b/leakcanary/leakcanary-android-test/src/main/java/leakcanary/RepeatingAndroidInProcessScenario.kt
@@ -1,8 +1,8 @@
 package leakcanary
 
-import shark.RepeatingScenarioObjectGrowthDetector
 import shark.HeapGraphProvider
 import shark.ObjectGrowthDetector
+import shark.RepeatingScenarioObjectGrowthDetector
 import shark.repeatingScenario
 
 /**
@@ -23,7 +23,8 @@
   return repeatingScenario(
     heapGraphProvider = HeapGraphProvider.dumpingAndDeleting(
       heapDumper = HeapDumper.forAndroidInProcess()
-        .withGc(gcTrigger = GcTrigger.inProcess()),
+        .withGc(gcTrigger = GcTrigger.inProcess())
+        .withDetectorWarmup(this),
       heapDumpFileProvider = HeapDumpFileProvider.tempFile()
     ),
     maxHeapDumps = maxHeapDumps,
diff --git a/leakcanary/leakcanary-core/api/leakcanary-core.api b/leakcanary/leakcanary-core/api/leakcanary-core.api
index 78f756d..af0bddc 100644
--- a/leakcanary/leakcanary-core/api/leakcanary-core.api
+++ b/leakcanary/leakcanary-core/api/leakcanary-core.api
@@ -49,6 +49,19 @@
 	public static synthetic fun withGc$default (Lleakcanary/HeapDumper;Lleakcanary/GcTrigger;ILjava/lang/Object;)Lleakcanary/HeapDumper;
 }
 
+public final class leakcanary/ObjectGrowthWarmupHeapDumper : leakcanary/HeapDumper {
+	public static final field Companion Lleakcanary/ObjectGrowthWarmupHeapDumper$Companion;
+	public fun <init> (Lshark/ObjectGrowthDetector;Lleakcanary/HeapDumper;)V
+	public fun dumpHeap (Ljava/io/File;)V
+}
+
+public final class leakcanary/ObjectGrowthWarmupHeapDumper$Companion {
+}
+
+public final class leakcanary/ObjectGrowthWarmupHeapDumperKt {
+	public static final fun withDetectorWarmup (Lleakcanary/HeapDumper;Lshark/ObjectGrowthDetector;)Lleakcanary/HeapDumper;
+}
+
 public final class leakcanary/TempHeapDumpFileProvider : leakcanary/HeapDumpFileProvider {
 	public static final field INSTANCE Lleakcanary/TempHeapDumpFileProvider;
 	public fun newHeapDumpFile ()Ljava/io/File;
diff --git a/leakcanary/leakcanary-core/build.gradle b/leakcanary/leakcanary-core/build.gradle
index 41ae014..35ba8a2 100644
--- a/leakcanary/leakcanary-core/build.gradle
+++ b/leakcanary/leakcanary-core/build.gradle
@@ -9,4 +9,9 @@
 dependencies {
   api projects.leakcanary.leakcanaryGc
   api projects.shark.shark
+  implementation libs.okio2
+
+  testImplementation libs.assertjCore
+  testImplementation libs.junit
+  testImplementation projects.shark.sharkHprofTest
 }
diff --git a/leakcanary/leakcanary-core/src/main/java/leakcanary/ObjectGrowthWarmupHeapDumper.kt b/leakcanary/leakcanary-core/src/main/java/leakcanary/ObjectGrowthWarmupHeapDumper.kt
new file mode 100644
index 0000000..ee184cb
--- /dev/null
+++ b/leakcanary/leakcanary-core/src/main/java/leakcanary/ObjectGrowthWarmupHeapDumper.kt
@@ -0,0 +1,59 @@
+package leakcanary
+
+import java.io.File
+import shark.ByteArraySourceProvider
+import shark.ObjectGrowthDetector
+import shark.repeatingScenario
+import okio.ByteString.Companion.decodeHex
+import shark.HprofHeapGraph.Companion.openHeapGraph
+
+class ObjectGrowthWarmupHeapDumper(
+  private val objectGrowthDetector: ObjectGrowthDetector,
+  private val delegate: HeapDumper
+) : HeapDumper {
+
+  private var warm = false
+
+  override fun dumpHeap(heapDumpFile: File) {
+    if (!warm) {
+      warmup()
+      warm = true
+    }
+    delegate.dumpHeap(heapDumpFile)
+  }
+
+  private fun warmup() {
+    val heapDumpsAsHex = listOf({ heapDump1Hex() }, { heapDump2Hex() }, { heapDump3Hex() })
+    val heapDumpsAsHexIterator = heapDumpsAsHex.iterator()
+    val warmupDetector = objectGrowthDetector.repeatingScenario(
+      heapGraphProvider = {
+        ByteArraySourceProvider(
+          heapDumpsAsHexIterator.next()().decodeHex().toByteArray()
+        ).openHeapGraph()
+      },
+      maxHeapDumps = heapDumpsAsHex.size,
+      scenarioLoopsPerDump = 1
+    )
+    warmupDetector.findRepeatedlyGrowingObjects {}
+  }
+
+  @SuppressWarnings("MaxLineLength")
+  companion object {
+    // Header:
+    // 4a4156412050524f46494c4520312e302e33 is the header version string
+    // then 00 is the string separator
+    // 00000004 is the identifier byte size, 4 bytes
+    // 0b501e7e ca55e77e (obsolete cassette) is a cool heap dump timestamp.
+    internal fun heapDump1Hex() =
+      "4a4156412050524f46494c4520312e302e3300000000040b501e7eca55e77e01000000000000001b000000016a6176612e6c616e672e7265662e5265666572656e63650200000000000000100000000100000002000000010000000101000000000000000c000000037265666572656e74010000000000000014000000046a6176612e6c616e672e4f626a656374020000000000000010000000010000000500000001000000040c000000000000006520000000050000000100000000000000000000000000000000000000000000000000000000000000000000050000000520000000020000000100000005000000000000000000000000000000000000000000000004000000000001000000030205000000022c000000000000000001000000000000001f000000066a6176612e6c616e672e7265662e5765616b5265666572656e6365020000000000000010000000010000000700000001000000060c00000000000000302000000007000000010000000200000000000000000000000000000000000000000000000400000000000005000000072c0000000000000000010000000000000021000000086c65616b63616e6172792e4b657965645765616b5265666572656e6365020000000000000010000000010000000900000001000000080100000000000000180000000a6865617044756d70557074696d654d696c6c69730100000000000000070000000b6b65790100000000000000080000000c6e616d650100000000000000150000000d7761746368557074696d654d696c6c69730100000000000000180000000e72657461696e6564557074696d654d696c6c69730c00000000000000622000000009000000010000000700000000000000000000000000000000000000000000001c000000010000000a0b000000000000753000040000000b020000000c020000000d0b0000000e0b0500000009210000000f0000000100000005000000002c0000000000000000010000000000000016000000106a6176612e6c616e672e4f626a6563745b5d020000000000000010000000010000001100000001000000100c000000000000004520000000110000000100000005000000000000000000000000000000000000000000000000000000000000050000001122000000120000000100000001000000110000000f2c000000000000000001000000000000000a00000013486f6c64657202000000000000001000000001000000140000000100000013010000000000000008000000156c6973740c00000000000000392000000014000000010000000500000000000000000000000000000000000000000000000000000001000000150200000012000005000000142c0000000000000000"
+
+    internal fun heapDump2Hex() =
+      "4a4156412050524f46494c4520312e302e3300000000040b501e7eca55e77e01000000000000001b000000016a6176612e6c616e672e7265662e5265666572656e63650200000000000000100000000100000002000000010000000101000000000000000c000000037265666572656e74010000000000000014000000046a6176612e6c616e672e4f626a656374020000000000000010000000010000000500000001000000040c000000000000006520000000050000000100000000000000000000000000000000000000000000000000000000000000000000050000000520000000020000000100000005000000000000000000000000000000000000000000000004000000000001000000030205000000022c000000000000000001000000000000001f000000066a6176612e6c616e672e7265662e5765616b5265666572656e6365020000000000000010000000010000000700000001000000060c00000000000000302000000007000000010000000200000000000000000000000000000000000000000000000400000000000005000000072c0000000000000000010000000000000021000000086c65616b63616e6172792e4b657965645765616b5265666572656e6365020000000000000010000000010000000900000001000000080100000000000000180000000a6865617044756d70557074696d654d696c6c69730100000000000000070000000b6b65790100000000000000080000000c6e616d650100000000000000150000000d7761746368557074696d654d696c6c69730100000000000000180000000e72657461696e6564557074696d654d696c6c69730c00000000000000732000000009000000010000000700000000000000000000000000000000000000000000001c000000010000000a0b000000000000753000040000000b020000000c020000000d0b0000000e0b0500000009210000000f00000001000000050000000021000000100000000100000005000000002c0000000000000000010000000000000016000000116a6176612e6c616e672e4f626a6563745b5d020000000000000010000000010000001200000001000000110c000000000000004920000000120000000100000005000000000000000000000000000000000000000000000000000000000000050000001222000000130000000100000002000000120000000f000000102c000000000000000001000000000000000a00000014486f6c64657202000000000000001000000001000000150000000100000014010000000000000008000000166c6973740c00000000000000392000000015000000010000000500000000000000000000000000000000000000000000000000000001000000160200000013000005000000152c0000000000000000"
+
+    internal fun heapDump3Hex() =
+      "4a4156412050524f46494c4520312e302e3300000000040b501e7eca55e77e01000000000000001b000000016a6176612e6c616e672e7265662e5265666572656e63650200000000000000100000000100000002000000010000000101000000000000000c000000037265666572656e74010000000000000014000000046a6176612e6c616e672e4f626a656374020000000000000010000000010000000500000001000000040c000000000000006520000000050000000100000000000000000000000000000000000000000000000000000000000000000000050000000520000000020000000100000005000000000000000000000000000000000000000000000004000000000001000000030205000000022c000000000000000001000000000000001f000000066a6176612e6c616e672e7265662e5765616b5265666572656e6365020000000000000010000000010000000700000001000000060c00000000000000302000000007000000010000000200000000000000000000000000000000000000000000000400000000000005000000072c0000000000000000010000000000000021000000086c65616b63616e6172792e4b657965645765616b5265666572656e6365020000000000000010000000010000000900000001000000080100000000000000180000000a6865617044756d70557074696d654d696c6c69730100000000000000070000000b6b65790100000000000000080000000c6e616d650100000000000000150000000d7761746368557074696d654d696c6c69730100000000000000180000000e72657461696e6564557074696d654d696c6c69730c00000000000000842000000009000000010000000700000000000000000000000000000000000000000000001c000000010000000a0b000000000000753000040000000b020000000c020000000d0b0000000e0b0500000009210000000f000000010000000500000000210000001000000001000000050000000021000000110000000100000005000000002c0000000000000000010000000000000016000000126a6176612e6c616e672e4f626a6563745b5d020000000000000010000000010000001300000001000000120c000000000000004d20000000130000000100000005000000000000000000000000000000000000000000000000000000000000050000001322000000140000000100000003000000130000000f00000010000000112c000000000000000001000000000000000a00000015486f6c64657202000000000000001000000001000000160000000100000015010000000000000008000000176c6973740c00000000000000392000000016000000010000000500000000000000000000000000000000000000000000000000000001000000170200000014000005000000162c0000000000000000"
+  }
+}
+
+fun HeapDumper.withDetectorWarmup(objectGrowthDetector: ObjectGrowthDetector): HeapDumper =
+  ObjectGrowthWarmupHeapDumper(objectGrowthDetector, this)
diff --git a/leakcanary/leakcanary-core/src/test/java/leakcanary/ObjectGrowthWarmupHeapDumperTest.kt b/leakcanary/leakcanary-core/src/test/java/leakcanary/ObjectGrowthWarmupHeapDumperTest.kt
new file mode 100644
index 0000000..002d0da
--- /dev/null
+++ b/leakcanary/leakcanary-core/src/test/java/leakcanary/ObjectGrowthWarmupHeapDumperTest.kt
@@ -0,0 +1,48 @@
+package leakcanary
+
+import okio.ByteString.Companion.decodeHex
+import okio.ByteString.Companion.toByteString
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import shark.HprofHeader
+import shark.dumpToBytes
+
+class ObjectGrowthWarmupHeapDumperTest {
+
+  @Test fun `heap dump 1 as hex constant matches generated heap dump hex`() {
+    assertThat(ObjectGrowthWarmupHeapDumper.heapDump1Hex()).isEqualTo(dumpGrowingListHeapAsHex(1))
+  }
+
+  @Test fun `heap dump 2 as hex constant matches generated heap dump hex`() {
+    assertThat(ObjectGrowthWarmupHeapDumper.heapDump2Hex()).isEqualTo(dumpGrowingListHeapAsHex(2))
+  }
+
+  @Test fun `heap dump 3 as hex constant matches generated heap dump hex`() {
+    assertThat(ObjectGrowthWarmupHeapDumper.heapDump3Hex()).isEqualTo(dumpGrowingListHeapAsHex(3))
+  }
+
+  private fun dumpGrowingListHeapAsHex(listItemCount: Int): String {
+    val heapDumpTimestamp = ("0b501e7e" + "ca55e77e").decodeHex().toByteArray().toLong()
+    return dumpToBytes(hprofHeader = HprofHeader(heapDumpTimestamp = heapDumpTimestamp)) {
+      "Holder" clazz {
+        val refs = (1..listItemCount).map {
+          instance(objectClassId)
+        }.toTypedArray()
+        staticField["list"] = objectArray(*refs)
+      }
+    }.toByteString().hex()
+  }
+
+  private fun ByteArray.toLong(): Long {
+    check(size == 8)
+    var pos = 0
+    return (this[pos++].toLong() and 0xffL shl 56
+      or (this[pos++].toLong() and 0xffL shl 48)
+      or (this[pos++].toLong() and 0xffL shl 40)
+      or (this[pos++].toLong() and 0xffL shl 32)
+      or (this[pos++].toLong() and 0xffL shl 24)
+      or (this[pos++].toLong() and 0xffL shl 16)
+      or (this[pos++].toLong() and 0xffL shl 8)
+      or (this[pos].toLong() and 0xffL))
+  }
+}
diff --git a/leakcanary/leakcanary-jvm-test/build.gradle b/leakcanary/leakcanary-jvm-test/build.gradle
index 7fcc007..5226407 100644
--- a/leakcanary/leakcanary-jvm-test/build.gradle
+++ b/leakcanary/leakcanary-jvm-test/build.gradle
@@ -12,4 +12,5 @@
 
   testImplementation libs.assertjCore
   testImplementation libs.junit
+  testImplementation projects.shark.sharkHprofTest
 }
diff --git a/leakcanary/leakcanary-jvm-test/src/main/java/leakcanary/RepeatingJvmInProcessScenario.kt b/leakcanary/leakcanary-jvm-test/src/main/java/leakcanary/RepeatingJvmInProcessScenario.kt
index 45fb828..9dede19 100644
--- a/leakcanary/leakcanary-jvm-test/src/main/java/leakcanary/RepeatingJvmInProcessScenario.kt
+++ b/leakcanary/leakcanary-jvm-test/src/main/java/leakcanary/RepeatingJvmInProcessScenario.kt
@@ -23,7 +23,8 @@
   return repeatingScenario(
     heapGraphProvider = HeapGraphProvider.dumpingAndDeleting(
       heapDumper = HeapDumper.forJvmInProcess()
-        .withGc(gcTrigger = GcTrigger.inProcess()),
+        .withGc(gcTrigger = GcTrigger.inProcess())
+        .withDetectorWarmup(this),
       heapDumpFileProvider = HeapDumpFileProvider.tempFile()
     ),
     maxHeapDumps = maxHeapDumps,
diff --git a/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmHeapGrowthDetectorConfigTest.kt b/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmHeapGrowthDetectorConfigTest.kt
index e6b614c..142c5d7 100644
--- a/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmHeapGrowthDetectorConfigTest.kt
+++ b/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmHeapGrowthDetectorConfigTest.kt
@@ -2,9 +2,7 @@
 
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Test
-import shark.HeapGraphProvider
 import shark.ObjectGrowthDetector
-import shark.repeatingScenario
 import shark.forJvmHeap
 
 class JvmHeapGrowthDetectorConfigTest {
@@ -15,7 +13,10 @@
 
   @Test
   fun `leaky increase leads to heap growth`() {
-    val detector = ObjectGrowthDetector.forJvmHeap().repeatingJvmInProcessScenario()
+    val detector = ObjectGrowthDetector.forJvmHeap().repeatingJvmInProcessScenario(
+      maxHeapDumps = 2,
+      scenarioLoopsPerDump = 10
+    )
 
     val growingNodes = detector.findRepeatedlyGrowingObjects {
       leakies += Leaky()
diff --git a/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmLiveObjectGrowthDetectorTest.kt b/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmLiveObjectGrowthDetectorTest.kt
new file mode 100644
index 0000000..9ac88ac
--- /dev/null
+++ b/leakcanary/leakcanary-jvm-test/src/test/java/leakcanary/JvmLiveObjectGrowthDetectorTest.kt
@@ -0,0 +1,197 @@
+package leakcanary
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import shark.ActualMatchingReferenceReaderFactory
+import shark.JvmObjectGrowthReferenceMatchers
+import shark.MatchingGcRootProvider
+import shark.ObjectGrowthDetector
+import shark.forJvmHeap
+
+class JvmLiveObjectGrowthDetectorTest {
+
+  class MultiLeaky {
+    val leaky = Any() to Any()
+  }
+
+  class CustomLinkedList(var next: CustomLinkedList? = null)
+
+  @get:Rule
+  val testFolder = TemporaryFolder()
+
+  val leakies = mutableListOf<Any>()
+
+  val stringLeaks = mutableListOf<String>()
+
+  var customLeakyLinkedList = CustomLinkedList()
+
+  val leakyHashMap = HashMap<String, Any>()
+
+  val multiLeakies = mutableListOf<MultiLeaky>()
+
+  @Test
+  fun `empty scenario leads to no heap growth`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val emptyScenario = {}
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects(roundTripScenario = emptyScenario)
+
+    assertThat(heapTraversal.growingObjects).isEmpty()
+  }
+
+  @Test
+  fun `leaky increase leads to heap growth`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      leakies += Any()
+    }
+
+    assertThat(heapTraversal.growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `string leak increase leads to heap growth`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    var index = 0
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      stringLeaks += "Yo ${++index}"
+    }
+
+    assertThat(heapTraversal.growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `leak increase that ends leads to no heap growth`() {
+    val maxHeapDumps = 10
+    val stopLeakingIndex = maxHeapDumps / 2
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(
+        maxHeapDumps = maxHeapDumps,
+        scenarioLoopsPerDump = 1
+      )
+
+    var index = 0
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      if (++index < stopLeakingIndex) {
+        leakies += Any()
+      }
+    }
+
+    assertThat(heapTraversal.growingObjects).isEmpty()
+  }
+
+  @Test
+  fun `multiple leaky scenarios per dump leads to heap growth`() {
+    val scenarioLoopsPerDump = 5
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = scenarioLoopsPerDump)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      leakies += Any()
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    val growingChild = growingObject.growingChildren.single()
+    assertThat(growingChild.objectCountIncrease).isEqualTo(scenarioLoopsPerDump)
+  }
+
+  @Test
+  fun `detect growth of custom linked list`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
+    }
+
+    assertThat(heapTraversal.growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `custom leaky linked list reports descendant to root as flattened collection`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
+      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
+      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
+      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    val growingChild = growingObject.growingChildren.single()
+    assertThat(growingChild.objectCountIncrease).isEqualTo(4)
+  }
+
+  @Test
+  fun `growth along shared sub paths reported as single growth of shortest path`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      multiLeakies += MultiLeaky()
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.name).contains("ArrayList")
+  }
+
+  @Test
+  fun `OpenJdk HashMap without synthetic refs shows internal table array growing`() {
+    val detector = ObjectGrowthDetector.forJvmHeapNoSyntheticRefs()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    var index = 0
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      leakyHashMap["key${++index}"] = Any()
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.name).startsWith("INSTANCE_FIELD HashMap.table")
+  }
+
+  @Test
+  fun `OpenJdk HashMap with synthetic refs shows itself growing`() {
+    val detector = ObjectGrowthDetector.forJvmHeap()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    var index = 0
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      leakyHashMap["key${++index}"] = Any()
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.name)
+      .startsWith("INSTANCE_FIELD JvmLiveObjectGrowthDetectorTest.leakyHashMap")
+  }
+
+  @Test
+  fun `OpenJdk ArrayList virtualized as array`() {
+    val detector = ObjectGrowthDetector.forJvmHeap()
+      .repeatingJvmInProcessScenario(scenarioLoopsPerDump = 1)
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects {
+      leakies += Any()
+    }
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.name).contains("leakies")
+  }
+
+  private fun ObjectGrowthDetector.Companion.forJvmHeapNoSyntheticRefs(): ObjectGrowthDetector {
+    val referenceMatchers = JvmObjectGrowthReferenceMatchers.defaults
+    return ObjectGrowthDetector(
+      gcRootProvider = MatchingGcRootProvider(JvmObjectGrowthReferenceMatchers.defaults),
+      referenceReaderFactory = ActualMatchingReferenceReaderFactory(referenceMatchers)
+    )
+  }
+}
diff --git a/shark/shark-cli/src/main/java/shark/HeapGrowthCommand.kt b/shark/shark-cli/src/main/java/shark/HeapGrowthCommand.kt
index daa512c..0d4ff1e 100644
--- a/shark/shark-cli/src/main/java/shark/HeapGrowthCommand.kt
+++ b/shark/shark-cli/src/main/java/shark/HeapGrowthCommand.kt
@@ -106,7 +106,7 @@
           .repeatingHeapGraph()
         val results = detector.findRepeatedlyGrowingObjects(
           heapGraphSequence = heapGraphs,
-          initialState = InitialState(scenarioLoopsPerDump, hprofFiles.size),
+          initialState = InitialState(scenarioLoopsPerDump),
         ).also {
           previous?.let {
             val finishTime = System.nanoTime().nanoseconds
@@ -173,8 +173,7 @@
               echo("As the last heap dump found 0 growing objects, there's no point continuing with the same heap dump baseline.")
               echo("To keep going, go through scenario $nTimes then press ENTER to use the last heap dump as the NEW baseline.")
               echo("To quit, enter 'q'.")
-              val command = consoleReader.readCommand()
-              when (command) {
+              when (val command = consoleReader.readCommand()) {
                 "q" -> throw PrintMessage("Quitting.")
                 "" -> {
                   promptForCommand = false
@@ -190,7 +189,7 @@
           val nextInputTraversal = if (reset) {
             FirstHeapTraversal(
               shortestPathTree = latestTraversal.shortestPathTree.copyResettingAsInitialTree(),
-              previousTraversal = InitialState(latestTraversal.scenarioLoopsPerGraph, null)
+              previousTraversal = InitialState(latestTraversal.scenarioLoopsPerGraph)
             )
           } else {
             latestTraversal
diff --git a/shark/shark-graph/build.gradle b/shark/shark-graph/build.gradle
index 7aa4e6c..ab83749 100644
--- a/shark/shark-graph/build.gradle
+++ b/shark/shark-graph/build.gradle
@@ -8,6 +8,7 @@
 
 dependencies {
   api projects.shark.sharkHprof
+  api libs.androidX.collections
 
   implementation libs.kotlin.stdlib
   implementation libs.okio2
diff --git a/shark/shark-hprof-test/src/main/kotlin/shark/HprofWriterHelper.kt b/shark/shark-hprof-test/src/main/kotlin/shark/HprofWriterHelper.kt
index 8198732..e475b66 100644
--- a/shark/shark-hprof-test/src/main/kotlin/shark/HprofWriterHelper.kt
+++ b/shark/shark-hprof-test/src/main/kotlin/shark/HprofWriterHelper.kt
@@ -55,26 +55,41 @@
 
   private val classDumps = mutableMapOf<Long, ClassDumpRecord>()
 
-  private val objectClassId = clazz(superclassId = 0, className = "java.lang.Object")
-  private val objectArrayClassId = arrayClass("java.lang.Object")
-  private val stringClassId = clazz(
-    className = "java.lang.String", fields = listOf(
-    "value" to ReferenceHolder::class,
-    "count" to IntHolder::class
-  )
-  )
-
-  private val referenceClassId = clazz(
-    className = "java.lang.ref.Reference",
-    fields = listOf(
-      "referent" to ReferenceHolder::class
+  val objectClassId by lazy {
+    clazz(
+      superclassId = 0,
+      className = "java.lang.Object"
     )
-  )
+  }
+  private val objectArrayClassId by lazy {
+    arrayClass("java.lang.Object")
+  }
 
-  private val weakReferenceClassId = clazz(
-    className = "java.lang.ref.WeakReference",
-    superclassId = referenceClassId
-  )
+  private val stringClassId by lazy {
+    clazz(
+      className = "java.lang.String",
+      fields = listOf(
+        "value" to ReferenceHolder::class,
+        "count" to IntHolder::class
+      )
+    )
+  }
+
+  private val referenceClassId by lazy {
+    clazz(
+      className = "java.lang.ref.Reference",
+      fields = listOf(
+        "referent" to ReferenceHolder::class
+      )
+    )
+  }
+
+  private val weakReferenceClassId by lazy {
+    clazz(
+      className = "java.lang.ref.WeakReference",
+      superclassId = referenceClassId
+    )
+  }
   private val keyedWeakReferenceClassId = clazz(
     superclassId = weakReferenceClassId,
     className = "leakcanary.KeyedWeakReference",
diff --git a/shark/shark/api/shark.api b/shark/shark/api/shark.api
index c75f40e..c6577c1 100644
--- a/shark/shark/api/shark.api
+++ b/shark/shark/api/shark.api
@@ -73,14 +73,11 @@
 }
 
 public final class shark/ByteSize : java/lang/Comparable {
-	public static final field BYTES_PER_GB J
-	public static final field BYTES_PER_KB J
-	public static final field BYTES_PER_MB J
-	public static final field Companion Lshark/ByteSize$Companion;
 	public static final synthetic fun box-impl (J)Lshark/ByteSize;
 	public synthetic fun compareTo (Ljava/lang/Object;)I
 	public fun compareTo-rK2stxE (J)I
 	public static fun compareTo-rK2stxE (JJ)I
+	public static fun constructor-impl (J)J
 	public static final fun div-BWD4q2E (JJ)J
 	public fun equals (Ljava/lang/Object;)Z
 	public static fun equals-impl (JLjava/lang/Object;)Z
@@ -99,16 +96,19 @@
 	public final synthetic fun unbox-impl ()J
 }
 
-public final class shark/ByteSize$Companion {
-	public final fun getBytes-1A9dbZA (I)J
-	public final fun getBytes-1A9dbZA (J)J
-	public final fun getGigabytes-1A9dbZA (I)J
-	public final fun getGigabytes-1A9dbZA (J)J
-	public final fun getKilobytes-1A9dbZA (I)J
-	public final fun getKilobytes-1A9dbZA (J)J
-	public final fun getMegabytes-1A9dbZA (I)J
-	public final fun getMegabytes-1A9dbZA (J)J
-	public final fun getZERO-UyN4wxk ()J
+public final class shark/ByteSizeKt {
+	public static final field BYTES_PER_GB J
+	public static final field BYTES_PER_KB J
+	public static final field BYTES_PER_MB J
+	public static final fun getBytes (I)J
+	public static final fun getBytes (J)J
+	public static final fun getGigabytes (I)J
+	public static final fun getGigabytes (J)J
+	public static final fun getKilobytes (I)J
+	public static final fun getKilobytes (J)J
+	public static final fun getMegabytes (I)J
+	public static final fun getMegabytes (J)J
+	public static final fun getZERO_BYTES ()J
 }
 
 public final class shark/ChainingInstanceReferenceReader : shark/ReferenceReader {
@@ -141,8 +141,9 @@
 	public fun <init> (I)V
 	public synthetic fun <init> (IILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public final fun buildFullDominatorTree (Lshark/DominatorTree$ObjectSizeCalculator;)Ljava/util/Map;
-	public final fun computeRetainedSizes (Ljava/util/Set;Lshark/DominatorTree$ObjectSizeCalculator;)Ljava/util/Map;
+	public final fun computeRetainedSizes (Landroidx/collection/LongSet;Lshark/DominatorTree$ObjectSizeCalculator;)Landroidx/collection/LongLongMap;
 	public final fun contains (J)Z
+	public final fun get (J)J
 	public final fun updateDominated (JJ)Z
 	public final fun updateDominatedAsRoot (J)Z
 }
@@ -173,7 +174,6 @@
 
 public final class shark/FirstHeapTraversal : shark/HeapTraversalOutput {
 	public fun <init> (Lshark/ShortestPathObjectNode;Lshark/InitialState;)V
-	public fun getHeapGraphCount ()Ljava/lang/Integer;
 	public fun getScenarioLoopsPerGraph ()I
 	public fun getShortestPathTree ()Lshark/ShortestPathObjectNode;
 	public fun getTraversalCount ()I
@@ -282,7 +282,6 @@
 public final class shark/HeapGrowthTraversal : shark/HeapTraversalOutput {
 	public fun <init> (ILshark/ShortestPathObjectNode;Ljava/util/List;Lshark/HeapTraversalInput;)V
 	public final fun getGrowingObjects ()Ljava/util/List;
-	public fun getHeapGraphCount ()Ljava/lang/Integer;
 	public fun getScenarioLoopsPerGraph ()I
 	public fun getShortestPathTree ()Lshark/ShortestPathObjectNode;
 	public fun getTraversalCount ()I
@@ -291,7 +290,6 @@
 }
 
 public abstract interface class shark/HeapTraversalInput {
-	public abstract fun getHeapGraphCount ()Ljava/lang/Integer;
 	public abstract fun getScenarioLoopsPerGraph ()I
 	public abstract fun getTraversalCount ()I
 }
@@ -314,14 +312,18 @@
 }
 
 public final class shark/InitialState : shark/HeapTraversalInput {
+	public static final field Companion Lshark/InitialState$Companion;
+	public static final field DEFAULT_SCENARIO_LOOPS_PER_GRAPH I
 	public fun <init> ()V
-	public fun <init> (ILjava/lang/Integer;)V
-	public synthetic fun <init> (ILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
-	public fun getHeapGraphCount ()Ljava/lang/Integer;
+	public fun <init> (I)V
+	public synthetic fun <init> (IILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public fun getScenarioLoopsPerGraph ()I
 	public fun getTraversalCount ()I
 }
 
+public final class shark/InitialState$Companion {
+}
+
 public final class shark/JavaLocalReferenceReader : shark/ChainingInstanceReferenceReader$VirtualInstanceReferenceReader {
 	public fun <init> (Lshark/HeapGraph;Ljava/util/List;)V
 	public final fun getGraph ()Lshark/HeapGraph;
@@ -357,6 +359,20 @@
 	public static synthetic fun forJvmHeap$default (Lshark/ObjectGrowthDetector$Companion;Ljava/util/List;ILjava/lang/Object;)Lshark/ObjectGrowthDetector;
 }
 
+public abstract class shark/JvmObjectGrowthReferenceMatchers : java/lang/Enum, shark/ReferenceMatcher$ListBuilder {
+	public static final field Companion Lshark/JvmObjectGrowthReferenceMatchers$Companion;
+	public static final field HEAP_TRAVERSAL Lshark/JvmObjectGrowthReferenceMatchers;
+	public static final field JVM_LEAK_DETECTION_IGNORED_MATCHERS Lshark/JvmObjectGrowthReferenceMatchers;
+	public static final field PARALLEL_LOCK_MAP Lshark/JvmObjectGrowthReferenceMatchers;
+	public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public static fun valueOf (Ljava/lang/String;)Lshark/JvmObjectGrowthReferenceMatchers;
+	public static fun values ()[Lshark/JvmObjectGrowthReferenceMatchers;
+}
+
+public final class shark/JvmObjectGrowthReferenceMatchers$Companion {
+	public final fun getDefaults ()Ljava/util/List;
+}
+
 public final class shark/KeyedWeakReferenceFinder : shark/LeakingObjectFinder {
 	public static final field INSTANCE Lshark/KeyedWeakReferenceFinder;
 	public fun findLeakingObjectIds (Lshark/HeapGraph;)Ljava/util/Set;
@@ -937,6 +953,30 @@
 	public static synthetic fun repeatingScenario$default (Lshark/ObjectGrowthDetector;Lshark/HeapGraphProvider;IIILjava/lang/Object;)Lshark/RepeatingScenarioObjectGrowthDetector;
 }
 
+public final class shark/Retained {
+	public final field packedValue J
+	public static final synthetic fun box-impl (J)Lshark/Retained;
+	public static fun constructor-impl (J)J
+	public fun equals (Ljava/lang/Object;)Z
+	public static fun equals-impl (JLjava/lang/Object;)Z
+	public static final fun equals-impl0 (JJ)Z
+	public static final fun getHeapSize-UyN4wxk (J)J
+	public static final fun getObjectCount-impl (J)I
+	public fun hashCode ()I
+	public static fun hashCode-impl (J)I
+	public static final fun isUnknown-impl (J)Z
+	public static final fun isZero-impl (J)Z
+	public fun toString ()Ljava/lang/String;
+	public static fun toString-impl (J)Ljava/lang/String;
+	public final synthetic fun unbox-impl ()J
+}
+
+public final class shark/RetainedKt {
+	public static final fun Retained-5mcd9r4 (JI)J
+	public static final fun getUNKNOWN_RETAINED ()J
+	public static final fun getZERO_RETAINED ()J
+}
+
 public abstract interface class shark/ShortestPathFinder {
 	public abstract fun findShortestPathsFromGcRoots (Ljava/util/Set;)Lshark/PathFindingResults;
 }
@@ -946,27 +986,30 @@
 }
 
 public final class shark/ShortestPathObjectNode {
-	public fun <init> (Ljava/lang/String;Lshark/ShortestPathObjectNode;Z)V
+	public fun <init> (Ljava/lang/String;Lshark/ShortestPathObjectNode;)V
 	public final fun copyResettingAsInitialTree ()Lshark/ShortestPathObjectNode;
 	public final fun getChildren ()Ljava/util/List;
-	public final fun getChildrenObjectCount ()I
-	public final fun getChildrenObjectCountIncrease ()I
+	public final fun getGrowingChildren ()Ljava/util/List;
 	public final fun getName ()Ljava/lang/String;
 	public final fun getParent ()Lshark/ShortestPathObjectNode;
-	public final fun getRetained ()Lshark/ShortestPathObjectNode$Retained;
-	public final fun getRetainedIncrease ()Lshark/ShortestPathObjectNode$Retained;
-	public final fun getRetainedIncreaseOrNull ()Lshark/ShortestPathObjectNode$Retained;
-	public final fun getRetainedOrNull ()Lshark/ShortestPathObjectNode$Retained;
+	public final fun getRetained-bh0qJVg ()J
+	public final fun getRetainedIncrease-bh0qJVg ()J
 	public final fun getSelfObjectCount ()I
-	public final fun getSelfObjectCountIncrease ()I
 	public final fun pathFromRootAsString ()Ljava/lang/String;
 	public fun toString ()Ljava/lang/String;
 }
 
-public final class shark/ShortestPathObjectNode$Retained {
-	public synthetic fun <init> (JILkotlin/jvm/internal/DefaultConstructorMarker;)V
-	public final fun getHeapSize-UyN4wxk ()J
-	public final fun getObjectCount ()I
+public final class shark/ShortestPathObjectNode$GrowingChildNode {
+	public fun <init> (Lshark/ShortestPathObjectNode;I)V
+	public final fun component1 ()Lshark/ShortestPathObjectNode;
+	public final fun component2 ()I
+	public final fun copy (Lshark/ShortestPathObjectNode;I)Lshark/ShortestPathObjectNode$GrowingChildNode;
+	public static synthetic fun copy$default (Lshark/ShortestPathObjectNode$GrowingChildNode;Lshark/ShortestPathObjectNode;IILjava/lang/Object;)Lshark/ShortestPathObjectNode$GrowingChildNode;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getChild ()Lshark/ShortestPathObjectNode;
+	public final fun getObjectCountIncrease ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
 }
 
 public final class shark/VirtualizingMatchingReferenceReaderFactory : shark/ReferenceReader$Factory {
@@ -974,6 +1017,12 @@
 	public fun createFor (Lshark/HeapGraph;)Lshark/ReferenceReader;
 }
 
+public final class shark/internal/IntIntPairUtilsKt {
+	public static final fun getUnpackAsFirstInt (J)I
+	public static final fun getUnpackAsSecondInt (J)I
+	public static final fun packedWith (II)J
+}
+
 public final class shark/internal/InternalSharkCollectionsHelper {
 	public static final field INSTANCE Lshark/internal/InternalSharkCollectionsHelper;
 	public final fun arrayListValues (Lshark/HeapObject$HeapInstance;)Lkotlin/sequences/Sequence;
diff --git a/shark/shark/build.gradle b/shark/shark/build.gradle
index 3e9461c..84310f2 100644
--- a/shark/shark/build.gradle
+++ b/shark/shark/build.gradle
@@ -15,7 +15,6 @@
 
   testImplementation libs.assertjCore
   testImplementation libs.junit
-  testImplementation libs.okio2
   testImplementation projects.shark.sharkTest
   testImplementation projects.shark.sharkHprofTest
 }
diff --git a/shark/shark/src/main/java/shark/ByteSize.kt b/shark/shark/src/main/java/shark/ByteSize.kt
index 0732107..57cd689 100644
--- a/shark/shark/src/main/java/shark/ByteSize.kt
+++ b/shark/shark/src/main/java/shark/ByteSize.kt
@@ -3,18 +3,20 @@
 /**
  * Inspired by https://github.com/saket/file-size as well as Kotlin's Duration API.
  */
+// DO NOT ADD A COMPANION OBJECT: a value class is supposed to be lightweight and its usage inlined
+// into few instructions. After adding a companion object, call sites get a lot more instructions.
 @JvmInline
-value class ByteSize private constructor(
+value class ByteSize constructor(
   val inWholeBytes: Long
 ) : Comparable<ByteSize> {
 
-  val inWholeKilobytes: Long
+  inline val inWholeKilobytes: Long
     get() = inWholeBytes / BYTES_PER_KB
 
-  val inWholeMegabytes: Long
+  inline val inWholeMegabytes: Long
     get() = inWholeBytes / BYTES_PER_MB
 
-  val inWholeGigabytes: Long
+  inline val inWholeGigabytes: Long
     get() = inWholeBytes / BYTES_PER_GB
 
   override fun toString(): String {
@@ -28,33 +30,31 @@
 
   override operator fun compareTo(other: ByteSize) = inWholeBytes.compareTo(other.inWholeBytes)
 
-  operator fun plus(other: ByteSize): ByteSize =
+  inline operator fun plus(other: ByteSize): ByteSize =
     ByteSize(inWholeBytes = inWholeBytes + other.inWholeBytes)
 
-  operator fun minus(other: ByteSize): ByteSize =
+  inline operator fun minus(other: ByteSize): ByteSize =
     ByteSize(inWholeBytes = inWholeBytes - other.inWholeBytes)
 
-  operator fun times(other: ByteSize): ByteSize =
+  inline operator fun times(other: ByteSize): ByteSize =
     ByteSize(inWholeBytes * other.inWholeBytes)
 
-  operator fun div(other: ByteSize): ByteSize =
+  inline operator fun div(other: ByteSize): ByteSize =
     ByteSize(inWholeBytes / other.inWholeBytes)
-
-  companion object {
-    const val BYTES_PER_KB: Long = 1_000L
-    const val BYTES_PER_MB: Long = 1_000L * BYTES_PER_KB
-    const val BYTES_PER_GB: Long = 1_000L * BYTES_PER_MB
-
-    val ZERO: ByteSize = ByteSize(0L)
-
-    val Long.bytes get() = ByteSize(this)
-    val Long.kilobytes get() = ByteSize(this * BYTES_PER_KB)
-    val Long.megabytes get() = ByteSize(this * BYTES_PER_MB)
-    val Long.gigabytes get() = ByteSize(this * BYTES_PER_GB)
-
-    val Int.bytes get() = ByteSize(toLong())
-    val Int.kilobytes get() = ByteSize(this * BYTES_PER_KB)
-    val Int.megabytes get() = ByteSize(this * BYTES_PER_MB)
-    val Int.gigabytes get() = ByteSize(this * BYTES_PER_GB)
-  }
 }
+
+const val BYTES_PER_KB: Long = 1_000L
+const val BYTES_PER_MB: Long = 1_000L * BYTES_PER_KB
+const val BYTES_PER_GB: Long = 1_000L * BYTES_PER_MB
+
+val ZERO_BYTES: ByteSize = ByteSize(0L)
+
+inline val Long.bytes get() = ByteSize(this)
+inline val Long.kilobytes get() = ByteSize(this * BYTES_PER_KB)
+inline val Long.megabytes get() = ByteSize(this * BYTES_PER_MB)
+inline val Long.gigabytes get() = ByteSize(this * BYTES_PER_GB)
+
+inline val Int.bytes get() = ByteSize(toLong())
+inline val Int.kilobytes get() = ByteSize(this * BYTES_PER_KB)
+inline val Int.megabytes get() = ByteSize(this * BYTES_PER_MB)
+inline val Int.gigabytes get() = ByteSize(this * BYTES_PER_GB)
diff --git a/shark/shark/src/main/java/shark/ClassReferenceReader.kt b/shark/shark/src/main/java/shark/ClassReferenceReader.kt
index 1a0a88c..a415687 100644
--- a/shark/shark/src/main/java/shark/ClassReferenceReader.kt
+++ b/shark/shark/src/main/java/shark/ClassReferenceReader.kt
@@ -32,13 +32,20 @@
   override fun read(source: HeapClass): Sequence<Reference> {
     val ignoredStaticFields = staticFieldNameByClassName[source.name] ?: emptyMap()
 
-    return source.readStaticFields().mapNotNull {  staticField ->
+    return source.readStaticFields().mapNotNull { staticField ->
       // not non null: no null + no primitives.
       if (!staticField.value.isNonNullReference) {
         return@mapNotNull null
       }
       val fieldName = staticField.name
-      if (fieldName == "\$staticOverhead" || fieldName == "\$classOverhead") {
+      if (
+      // Android noise
+        fieldName == "\$staticOverhead" ||
+        // Android noise
+        fieldName == "\$classOverhead" ||
+        // JVM noise
+        fieldName == "<resolved_references>"
+      ) {
         return@mapNotNull null
       }
 
diff --git a/shark/shark/src/main/java/shark/DominatorTree.kt b/shark/shark/src/main/java/shark/DominatorTree.kt
index c918297..e8f7c28 100644
--- a/shark/shark/src/main/java/shark/DominatorTree.kt
+++ b/shark/shark/src/main/java/shark/DominatorTree.kt
@@ -1,10 +1,18 @@
 @file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER", "CANNOT_OVERRIDE_INVISIBLE_MEMBER")
+
 package shark
 
+import androidx.collection.LongLongMap
+import androidx.collection.LongSet
+import androidx.collection.MutableLongLongMap
+import androidx.collection.MutableLongSet
 import shark.ObjectDominators.DominatorNode
 import shark.internal.hppc.LongLongScatterMap
 import shark.internal.hppc.LongLongScatterMap.ForEachCallback
 import shark.internal.hppc.LongScatterSet
+import shark.internal.packedWith
+import shark.internal.unpackAsFirstInt
+import shark.internal.unpackAsSecondInt
 
 class DominatorTree(expectedElements: Int = 4) {
 
@@ -23,6 +31,12 @@
   operator fun contains(objectId: Long): Boolean = dominated.containsKey(objectId)
 
   /**
+   * Returns the dominator object id or [ValueHolder.NULL_REFERENCE] if [dominatedObjectId] is the
+   * root dominator.
+   */
+  operator fun get(dominatedObjectId: Long) = dominated[dominatedObjectId]
+
+  /**
    * Records that [objectId] is a root.
    */
   fun updateDominatedAsRoot(objectId: Long): Boolean {
@@ -106,7 +120,8 @@
 
   fun buildFullDominatorTree(objectSizeCalculator: ObjectSizeCalculator): Map<Long, DominatorNode> {
     val dominators = mutableMapOf<Long, MutableDominatorNode>()
-    dominated.forEach(ForEachCallback {key, value ->
+    // Reverse the dominated map to have dominators ids as keys and list of dominated as values
+    dominated.forEach(ForEachCallback { key, value ->
       // create entry for dominated
       dominators.getOrPut(key) {
         MutableDominatorNode()
@@ -118,7 +133,12 @@
       }.dominated += key
     })
 
-    val allReachableObjectIds = dominators.keys.toSet() - ValueHolder.NULL_REFERENCE
+    val allReachableObjectIds = MutableLongSet(dominators.size)
+    dominators.forEach { (key, _) ->
+      if (key != ValueHolder.NULL_REFERENCE) {
+        allReachableObjectIds += key
+      }
+    }
 
     val retainedSizes = computeRetainedSizes(allReachableObjectIds) { objectId ->
       val shallowSize = objectSizeCalculator.computeSize(objectId)
@@ -128,7 +148,9 @@
 
     dominators.forEach { (objectId, node) ->
       if (objectId != ValueHolder.NULL_REFERENCE) {
-        val (retainedSize, retainedCount) = retainedSizes.getValue(objectId)
+        val retainedPacked = retainedSizes[objectId]
+        val retainedSize = retainedPacked.unpackAsFirstInt
+        val retainedCount = retainedPacked.unpackAsSecondInt
         node.retainedSize = retainedSize
         node.retainedCount = retainedCount
       }
@@ -157,12 +179,12 @@
    * @return a map of object id to retained size.
    */
   fun computeRetainedSizes(
-    retainedObjectIds: Set<Long>,
+    retainedObjectIds: LongSet,
     objectSizeCalculator: ObjectSizeCalculator
-  ): Map<Long, Pair<Int, Int>> {
-    val nodeRetainedSizes = mutableMapOf<Long, Pair<Int, Int>>()
+  ): LongLongMap {
+    val nodeRetainedSizes = MutableLongLongMap(retainedObjectIds.size)
     retainedObjectIds.forEach { objectId ->
-      nodeRetainedSizes[objectId] = 0 to 0
+      nodeRetainedSizes[objectId] = 0 packedWith 0
     }
 
     dominated.forEach(object : ForEachCallback {
@@ -174,9 +196,15 @@
         var instanceSize = -1
 
         // If the entry is a node, add its size to nodeRetainedSizes
-        nodeRetainedSizes[key]?.let { (currentRetainedSize, currentRetainedCount) ->
+
+        val missing = -1 packedWith -1
+        val packedRetained = nodeRetainedSizes.getOrDefault(key, missing)
+        if (packedRetained != missing) {
+          val currentRetainedSize = packedRetained.unpackAsFirstInt
+          val currentRetainedCount = packedRetained.unpackAsSecondInt
           instanceSize = objectSizeCalculator.computeSize(key)
-          nodeRetainedSizes[key] = currentRetainedSize + instanceSize to currentRetainedCount + 1
+          nodeRetainedSizes[key] =
+            (currentRetainedSize + instanceSize) packedWith currentRetainedCount + 1
         }
 
         if (value != ValueHolder.NULL_REFERENCE) {
@@ -195,11 +223,11 @@
                 instanceSize = objectSizeCalculator.computeSize(key)
               }
               // Update retained size for that node
-              val (currentRetainedSize, currentRetainedCount) = nodeRetainedSizes.getValue(
-                dominator
-              )
+              val dominatorRetained = nodeRetainedSizes[dominator]
+              val currentRetainedSize = dominatorRetained.unpackAsFirstInt
+              val currentRetainedCount = dominatorRetained.unpackAsSecondInt
               nodeRetainedSizes[dominator] =
-                (currentRetainedSize + instanceSize) to currentRetainedCount + 1
+                (currentRetainedSize + instanceSize) packedWith (currentRetainedCount + 1)
               dominatedByNextNode.clear()
             } else {
               dominatedByNextNode += dominator
@@ -218,3 +246,4 @@
     return nodeRetainedSizes
   }
 }
+
diff --git a/shark/shark/src/main/java/shark/HeapTraversal.kt b/shark/shark/src/main/java/shark/HeapTraversal.kt
index 5bca9e1..8050f6a 100644
--- a/shark/shark/src/main/java/shark/HeapTraversal.kt
+++ b/shark/shark/src/main/java/shark/HeapTraversal.kt
@@ -11,16 +11,10 @@
    * growing at least [scenarioLoopsPerGraph] times since the previous traversal.
    */
   val scenarioLoopsPerGraph: Int
-
-  /**
-   * The expected max number of traversals, or null if unknown.
-   */
-  val heapGraphCount: Int?
 }
 
 class InitialState(
-  override val scenarioLoopsPerGraph: Int = 1,
-  override val heapGraphCount: Int? = null
+  override val scenarioLoopsPerGraph: Int = DEFAULT_SCENARIO_LOOPS_PER_GRAPH,
 ) : HeapTraversalInput {
   override val traversalCount = 0
 
@@ -28,9 +22,10 @@
     check(scenarioLoopsPerGraph >= 1) {
       "There should be at least 1 scenario loop per heap dump"
     }
-    check(heapGraphCount == null || heapGraphCount >= 2) {
-      "There should be at least 2 heap dumps to detect growing objects"
-    }
+  }
+
+  companion object {
+    const val DEFAULT_SCENARIO_LOOPS_PER_GRAPH = 1
   }
 }
 
@@ -71,7 +66,6 @@
 ) : HeapTraversalOutput {
   override val traversalCount = 1
   override val scenarioLoopsPerGraph = previousTraversal.scenarioLoopsPerGraph
-  override val heapGraphCount: Int? = previousTraversal.heapGraphCount
 }
 
 class HeapGrowthTraversal(
@@ -88,15 +82,8 @@
   val isGrowing: Boolean get() = growingObjects.isNotEmpty()
 
   override val scenarioLoopsPerGraph = previousTraversal.scenarioLoopsPerGraph
-  override val heapGraphCount: Int? = previousTraversal.heapGraphCount
   override fun toString(): String {
-    val traversal = if (heapGraphCount != null) {
-      "traversal=$traversalCount/$heapGraphCount"
-    } else {
-      "traversal=$traversalCount"
-    }
-
-    return "HeapGrowthTraversal($traversal, " +
+    return "HeapGrowthTraversal(traversal=$traversalCount, " +
       "isGrowing=$isGrowing, " +
       "scenarioLoopsPerGraph=$scenarioLoopsPerGraph, " +
       "growingNodes=\n${growingObjects.joinToString("\n")}\n" +
diff --git a/shark/shark/src/main/java/shark/JvmObjectGrowthDetector.kt b/shark/shark/src/main/java/shark/JvmObjectGrowthDetector.kt
index 5365d3f..837b28c 100644
--- a/shark/shark/src/main/java/shark/JvmObjectGrowthDetector.kt
+++ b/shark/shark/src/main/java/shark/JvmObjectGrowthDetector.kt
@@ -1,8 +1,7 @@
 package shark
 
 fun ObjectGrowthDetector.Companion.forJvmHeap(
-  referenceMatchers: List<ReferenceMatcher> = JdkReferenceMatchers.defaults +
-    HeapTraversalOutput.ignoredReferences
+  referenceMatchers: List<ReferenceMatcher> = JvmObjectGrowthReferenceMatchers.defaults
 ): ObjectGrowthDetector {
   return ObjectGrowthDetector(
     gcRootProvider = MatchingGcRootProvider(referenceMatchers),
diff --git a/shark/shark/src/main/java/shark/JvmObjectGrowthReferenceMatchers.kt b/shark/shark/src/main/java/shark/JvmObjectGrowthReferenceMatchers.kt
new file mode 100644
index 0000000..35e6922
--- /dev/null
+++ b/shark/shark/src/main/java/shark/JvmObjectGrowthReferenceMatchers.kt
@@ -0,0 +1,37 @@
+package shark
+
+import java.util.EnumSet
+import shark.ReferenceMatcher.Companion.ALWAYS
+import shark.ReferencePattern.Companion.instanceField
+
+enum class JvmObjectGrowthReferenceMatchers : ReferenceMatcher.ListBuilder {
+
+  JVM_LEAK_DETECTION_IGNORED_MATCHERS {
+    override fun add(references: MutableList<ReferenceMatcher>) {
+      references += JdkReferenceMatchers.defaults.filterIsInstance<IgnoredReferenceMatcher>()
+    }
+  },
+
+  HEAP_TRAVERSAL {
+    override fun add(references: MutableList<ReferenceMatcher>) {
+      references += HeapTraversalOutput.ignoredReferences
+    }
+  },
+
+  PARALLEL_LOCK_MAP {
+    override fun add(references: MutableList<ReferenceMatcher>) {
+      // Seems to be sometimes growing at a fast pace. JVM only ("Android-removed: Remove unused ParallelLoaders")
+      references += instanceField("java.lang.ClassLoader", "parallelLockMap")
+        .ignored(patternApplies = ALWAYS)
+    }
+  },
+
+  ;
+
+  companion object {
+    val defaults: List<ReferenceMatcher>
+      get() = ReferenceMatcher.fromListBuilders(
+        EnumSet.allOf(JvmObjectGrowthReferenceMatchers::class.java)
+      )
+  }
+}
diff --git a/shark/shark/src/main/java/shark/ObjectGrowthDetector.kt b/shark/shark/src/main/java/shark/ObjectGrowthDetector.kt
index db2ad84..4a2ec3e 100644
--- a/shark/shark/src/main/java/shark/ObjectGrowthDetector.kt
+++ b/shark/shark/src/main/java/shark/ObjectGrowthDetector.kt
@@ -2,16 +2,19 @@
 
 package shark
 
+import androidx.collection.MutableLongList
+import androidx.collection.MutableLongLongMap
+import androidx.collection.MutableLongSet
+import androidx.collection.mutableLongListOf
 import java.util.ArrayDeque
 import java.util.Deque
-import shark.ByteSize.Companion.bytes
 import shark.HeapObject.HeapClass
 import shark.HeapObject.HeapInstance
 import shark.HeapObject.HeapObjectArray
 import shark.HeapObject.HeapPrimitiveArray
 import shark.ReferenceLocationType.ARRAY_ENTRY
-import shark.ShortestPathObjectNode.Retained
-import shark.internal.hppc.LongScatterSet
+import shark.internal.unpackAsFirstInt
+import shark.internal.unpackAsSecondInt
 
 /**
  * Looks for objects that have grown in outgoing references in a new heap dump compared to a
@@ -31,18 +34,10 @@
         "previousTraversal:$previousTraversal"
     }
 
-    val computeRetainedHeapSize = previousTraversal.heapGraphCount?.let { heapGraphCount ->
-      // Compute retained size for the prior to last and the last graphs.
-      previousTraversal.traversalCount >= heapGraphCount - 1
-    } ?: (previousTraversal.traversalCount > 1)
-
     // Estimate of how many objects we'll visit. This is a conservative estimate, we should always
     // visit more than that but this limits the number of early array growths.
     val estimatedVisitedObjects = (heapGraph.instanceCount / 2).coerceAtLeast(4)
-    val state = TraversalState(
-      estimatedVisitedObjects = estimatedVisitedObjects,
-      computeRetainedHeapSize = computeRetainedHeapSize
-    )
+    val state = TraversalState(estimatedVisitedObjects = estimatedVisitedObjects)
     return heapGraph.use {
       state.traverseHeapDiffingShortestPaths(
         heapGraph,
@@ -59,9 +54,19 @@
     }
   }
 
+  // data class to be a properly implemented key.
+  private data class EdgeKey(
+    val nodeAndEdgeName: String,
+    val isLowPriority: Boolean
+  )
+
+  private class Edge(
+    val nonVisitedDistinctObjectIds: MutableLongList,
+    var isLeafObject: Boolean
+  )
+
   private class TraversalState(
-    estimatedVisitedObjects: Int,
-    computeRetainedHeapSize: Boolean
+    estimatedVisitedObjects: Int
   ) {
     var visitingLast = false
 
@@ -73,11 +78,14 @@
      */
     val toVisitLastQueue: Deque<Node> = ArrayDeque()
 
-    val visitedSet = LongScatterSet(estimatedVisitedObjects)
-    val dominatorTree =
-      if (computeRetainedHeapSize) DominatorTree(estimatedVisitedObjects) else null
+    val visitedSet = MutableLongSet(estimatedVisitedObjects)
 
-    val tree = ShortestPathObjectNode("root", null, newNode = false).apply {
+    // Not using estimatedVisitedObjects because there could be a lot less nodes than objects.
+    // This is a list because order matters.
+    val dequeuedNodes = mutableListOf<DequeuedNode>()
+    val dominatorTree = DominatorTree(estimatedVisitedObjects)
+
+    val tree = ShortestPathObjectNode("root", null).apply {
       selfObjectCount = 1
     }
     val queuesNotEmpty: Boolean
@@ -89,249 +97,300 @@
     graph: CloseableHeapGraph,
     previousTraversal: HeapTraversalInput
   ): HeapTraversalOutput {
-
-    // First iteration, all nodes are growing.
-    if (previousTraversal is InitialState) {
-      tree.growing = true
-    }
-
     val previousTree = when (previousTraversal) {
       is InitialState -> null
       is HeapTraversalOutput -> previousTraversal.shortestPathTree
     }
 
+    val firstTraversal = previousTree == null
+
+    val secondTraversal = previousTraversal is FirstHeapTraversal
     val objectReferenceReader = referenceReaderFactory.createFor(graph)
 
-    val roots = graph.groupRoots()
-    enqueueRoots(previousTree, roots)
-
-    val nodesMaybeGrowing = mutableListOf<Node>()
+    enqueueRoots(previousTree, graph)
 
     while (queuesNotEmpty) {
       val node = poll()
 
-      if (previousTree != null) {
-        if (node.previousPathNode == null) {
-          // This is a new node, not seen in the previous iteration. If its parent is growing
-          // then we'll consider this one as growing as well.
-          nodesMaybeGrowing += node
-        } else {
-          if (node.previousPathNode.growing) {
-            nodesMaybeGrowing += node
-          }
-        }
-      }
-
-      class ExpandedObject(
-        val valueObjectId: Long,
-        val nodeAndEdgeName: String,
-        val isLowPriority: Boolean,
-        val isLeafObject: Boolean
-      )
+      val dequeuedNode = DequeuedNode(node)
+      dequeuedNodes.add(dequeuedNode)
+      val current = dequeuedNode.shortestPathNode
 
       // Note: this is different from visitedSet.size(), which includes gc roots.
-      var visitedObjectCount = 0
+      var countOfVisitedObjectForCurrentNode = 0
 
-      val edges = node.objectIds.flatMap { objectId ->
+      val edgesByNodeName = mutableMapOf<EdgeKey, Edge>()
+      // Each object we've found for that node is returning a set of edges.
+      node.objectIds.forEach exploreObjectEdges@{ objectId ->
         // This is when we actually visit.
         val added = visitedSet.add(objectId)
+
         if (!added) {
-          emptySequence()
-        } else {
-          visitedObjectCount++
-          if (node.isLeafObject) {
-            emptySequence()
+          return@exploreObjectEdges
+        }
+
+        countOfVisitedObjectForCurrentNode++
+
+        if (node.isLeafObject) {
+          return@exploreObjectEdges
+        }
+
+        val heapObject = graph.findObjectById(objectId)
+        val refs = objectReferenceReader.read(heapObject)
+        refs.forEach recordEdge@{ reference ->
+          // dominatorTree is updated prior to enqueueing, because that's where we have the
+          // parent object id information. visitedSet is updated on dequeuing, because bumping
+          // node priority would be complex when as we'd need to move object ids between nodes
+          // rather than just move nodes.
+          dominatorTree.updateDominated(
+            objectId = reference.valueObjectId,
+            parentObjectId = objectId
+          )
+          // note: we only update visitedSet once dequeued. This could lead
+          // to duplicates in queue, but avoids having to bump priority of already
+          // enqueued low priority nodes.
+          if (reference.valueObjectId in visitedSet) {
+            return@recordEdge
+          }
+          val details = reference.lazyDetailsResolver.resolve()
+          val refType = details.locationType.name
+          val owningClassSimpleName =
+            graph.findObjectById(details.locationClassObjectId).asClass!!.simpleName
+          val refName = if (details.locationType == ARRAY_ENTRY) "[x]" else details.name
+          val referencedObjectName =
+            when (val referencedObject = graph.findObjectById(reference.valueObjectId)) {
+              is HeapClass -> "class ${referencedObject.name}"
+              is HeapInstance -> "instance of ${referencedObject.instanceClassName}"
+              is HeapObjectArray -> "array of ${referencedObject.arrayClassName}"
+              is HeapPrimitiveArray -> "array of ${referencedObject.primitiveType.name.lowercase()}"
+            }
+
+          val nodeAndEdgeName =
+            "$refType ${owningClassSimpleName}.${refName} -> $referencedObjectName"
+
+          val edgeKey = EdgeKey(nodeAndEdgeName, reference.isLowPriority)
+
+          val edge = edgesByNodeName[edgeKey]
+          if (edge == null) {
+            edgesByNodeName[edgeKey] = Edge(
+              nonVisitedDistinctObjectIds = mutableLongListOf(reference.valueObjectId),
+              isLeafObject = reference.isLeafObject,
+            )
           } else {
-            val heapObject = graph.findObjectById(objectId)
-            val refs = objectReferenceReader.read(heapObject)
-            refs.mapNotNull { reference ->
-              // dominatorTree is updated prior to enqueueing, because that's where we have the
-              // parent object id information. visitedSet is updated on dequeuing, because bumping
-              // node priority would be complex when as we'd need to move object ids between nodes
-              // rather than just move nodes.
-              dominatorTree?.updateDominated(
-                objectId = reference.valueObjectId,
-                parentObjectId = objectId
-              )
-              if (reference.valueObjectId in visitedSet) {
-                null
-              } else {
-                val details = reference.lazyDetailsResolver.resolve()
-                val refType = details.locationType.name
-                val owningClassSimpleName =
-                  graph.findObjectById(details.locationClassObjectId).asClass!!.simpleName
-                val refName = if (details.locationType == ARRAY_ENTRY) "[x]" else details.name
-                val referencedObjectName =
-                  when (val referencedObject = graph.findObjectById(reference.valueObjectId)) {
-                    is HeapClass -> "class ${referencedObject.name}"
-                    is HeapInstance -> "instance of ${referencedObject.instanceClassName}"
-                    is HeapObjectArray -> "array of ${referencedObject.arrayClassName}"
-                    is HeapPrimitiveArray -> "array of ${referencedObject.primitiveType.name.lowercase()}"
-                  }
-                val nodeAndEdgeName =
-                  "$refType ${owningClassSimpleName}.${refName} -> $referencedObjectName"
-                ExpandedObject(
-                  reference.valueObjectId, nodeAndEdgeName, reference.isLowPriority,
-                  reference.isLeafObject
-                )
-              }
+            // node is leaf object if all objects in node are leaf objects.
+            edge.isLeafObject = edge.isLeafObject && reference.isLeafObject
+            // Make it distinct
+            if (reference.valueObjectId !in edge.nonVisitedDistinctObjectIds) {
+              edge.nonVisitedDistinctObjectIds += reference.valueObjectId
             }
           }
         }
-      }.groupBy {
-        it.nodeAndEdgeName + if (it.isLowPriority) "low-priority" else ""
       }
 
-      if (visitedObjectCount > 0) {
-        val parent = node.shortestPathNode.parent!!
-        val current = node.shortestPathNode
-        parent._children += current
+      if (countOfVisitedObjectForCurrentNode > 0) {
+        val parent = node.parentPathNode
+        parent.addChild(current)
+        // First traversal, all nodes with children are growing.
+        if (firstTraversal) {
+          parent.growing = true
+        }
         if (current.name == parent.name) {
           var linkedListStartNode = current
           while (linkedListStartNode.name == linkedListStartNode.parent!!.name) {
             // Never null, we don't expect to ever see "root" -> "root"
             linkedListStartNode = linkedListStartNode.parent!!
           }
-          linkedListStartNode.selfObjectCount += visitedObjectCount
+          linkedListStartNode.selfObjectCount += countOfVisitedObjectForCurrentNode
         } else {
-          current.selfObjectCount = visitedObjectCount
-        }
-        // First iteration, all nodes are growing.
-        if (previousTree == null) {
-          current.growing = true
+          current.selfObjectCount = countOfVisitedObjectForCurrentNode
         }
       }
 
-      val previousNodeMap = node.previousPathNode?.let { shortestPathNode ->
-        shortestPathNode._children.associateBy { it.name }
+      val previousNodeChildrenMapOrNull = node.previousPathNode?.let { previousPathNode ->
+        previousPathNode.children.associateBy { it.name }
       }
 
-      edges.forEach { (_, expandedObjects) ->
-        val firstOfGroup = expandedObjects.first()
-        val leafObject = expandedObjects.all { it.isLeafObject }
-        val nodeAndEdgeName = firstOfGroup.nodeAndEdgeName
-        val previousPathNode = if (previousNodeMap != null) {
-          previousNodeMap[nodeAndEdgeName]
-        } else {
-          null
+      val edgesEnqueued = edgesByNodeName.count { (edgeKey, edge) ->
+        val previousPathNodeChildOrNull =
+          previousNodeChildrenMapOrNull?.get(edgeKey.nodeAndEdgeName)
+        val nonVisitedDistinctObjectIdsArray = LongArray(edge.nonVisitedDistinctObjectIds.size)
+        edge.nonVisitedDistinctObjectIds.forEachIndexed { index, objectId ->
+          nonVisitedDistinctObjectIdsArray[index] = objectId
         }
+
         enqueue(
-          parentPathNode = node.shortestPathNode,
-          previousPathNode = previousPathNode,
-          objectIds = expandedObjects.map { it.valueObjectId },
-          nodeAndEdgeName = nodeAndEdgeName,
-          isLowPriority = firstOfGroup.isLowPriority,
-          isLeafObject = leafObject
+          parentPathNode = current,
+          previousPathNode = previousPathNodeChildOrNull,
+          objectIds = nonVisitedDistinctObjectIdsArray,
+          nodeAndEdgeName = edgeKey.nodeAndEdgeName,
+          isLowPriority = edgeKey.isLowPriority,
+          isLeafObject = edge.isLeafObject
         )
+        return@count true
+      }
+
+      if (edgesEnqueued > 0) {
+        current.createChildrenBackingList(edgesEnqueued)
       }
     }
 
-    val growingNodes = if (previousTree != null) {
-      val growingNodePairs = mutableListOf<Pair<Node, ShortestPathObjectNode>>()
-      val growingNodes = nodesMaybeGrowing.mapNotNull { node ->
-        val shortestPathNode = node.shortestPathNode
-        val growing = if (node.previousPathNode != null) {
-          // Existing node. Growing if was growing (already true) and edges increased at least detectedGrowth.
-          // Why detectedGrowth? We perform N scenarios and only take N/detectedGrowth heap dumps, which avoids including
-          // any side effects of heap dumps in our leak detection.
-          shortestPathNode.childrenObjectCount >= node.previousPathNode.childrenObjectCount + previousTraversal.scenarioLoopsPerGraph
-        } else {
-          val parent = shortestPathNode.parent!!
-          // New node. Growing if parent is growing.
-          // New node always have a parent.
-          // check for more than 0 because linked list structures will bubble their count up
-          // and don't need to be marked as growing.
-          parent.growing && shortestPathNode.selfObjectCount > 0
-        }
-        if (growing) {
-          if (node.previousPathNode != null) {
-            val previousChildrenByName =
-              node.previousPathNode._children.associateBy { it.name }
-            shortestPathNode._children.forEach { child ->
-              val previousChild = previousChildrenByName[child.name]
-              if (previousChild != null) {
-                child.selfObjectCountIncrease =
-                  child.selfObjectCount - previousChild.selfObjectCount
-              } else {
-                child.selfObjectCountIncrease = child.selfObjectCount
-              }
-            }
-          } else {
-            shortestPathNode._children.forEach { child ->
-              child.selfObjectCountIncrease = child.selfObjectCount
-            }
-          }
-          // Mark as growing in the tree (useful for next iteration)
-          shortestPathNode.growing = true
+    return if (previousTraversal is InitialState) {
+      // Iterating on last dequeued first means we'll get dominated first and progressively go
+      // up the dominator tree.
+      val objectSizeCalculator = AndroidObjectSizeCalculator(graph)
+      // A map that stores two ints, size and count, in a single long value with bit packing.
+      val retainedSizeAndCountMap = MutableLongLongMap(dequeuedNodes.size)
+      for (node in dequeuedNodes.asReversed()) {
+        var nodeRetainedSize = ZERO_BYTES
+        var nodeRetainedCount = 0
 
-          val previouslyGrowing = !shortestPathNode.newNode
-          val parentAlreadyReported = (shortestPathNode.parent?.growing) ?: false
+        for (objectId in node.objectIds) {
+          val objectShallowSize = objectSizeCalculator.computeSize(objectId)
 
-          val repeatedlyGrowingNode = previouslyGrowing && !parentAlreadyReported
-          // Return in list of growing nodes.
-          if (repeatedlyGrowingNode) {
-            if (dominatorTree != null) {
-              growingNodePairs += node to shortestPathNode
-            }
-            shortestPathNode
-          } else {
-            null
-          }
-        } else {
-          null
-        }
-      }
-      dominatorTree?.let { dominatorTree ->
-        val growingNodeObjectIds = growingNodePairs.flatMapTo(LinkedHashSet()) { (node, _) ->
-          node.objectIds
-        }
-        val objectSizeCalculator = AndroidObjectSizeCalculator(graph)
-        val retainedMap =
-          dominatorTree.computeRetainedSizes(growingNodeObjectIds, objectSizeCalculator)
-        growingNodePairs.forEach { (node, shortestPathNode) ->
-          var heapSize = ByteSize.ZERO
-          var objectCount = 0
-          for (objectId in node.objectIds) {
-            val (additionalByteSize, additionalObjectCount) = retainedMap.getValue(objectId)
-            heapSize += additionalByteSize.bytes
-            objectCount += additionalObjectCount
-          }
-          shortestPathNode.retainedOrNull = Retained(
-            heapSize = heapSize,
-            objectCount = objectCount
+          val packedSizeAndCount = retainedSizeAndCountMap.increase(
+            objectId, objectShallowSize, 1
           )
-          val previousRetained = node.previousPathNode?.retainedOrNull
-          shortestPathNode.retainedIncreaseOrNull = if (previousRetained == null) {
-            Retained(ByteSize.ZERO, 0)
-          } else {
-            Retained(
-              heapSize - previousRetained.heapSize, objectCount - previousRetained.objectCount
-            )
+
+          val retainedSize = packedSizeAndCount.unpackAsFirstInt
+          val retainedCount = packedSizeAndCount.unpackAsSecondInt
+
+          val dominatorObjectId = dominatorTree[objectId]
+          if (dominatorObjectId != ValueHolder.NULL_REFERENCE) {
+            retainedSizeAndCountMap.increase(dominatorObjectId, retainedSize, retainedCount)
           }
+          nodeRetainedSize += retainedSize.bytes
+          nodeRetainedCount += retainedCount
+        }
+
+        if (node.shortestPathNode.growing) {
+          node.shortestPathNode.retained = Retained(
+            heapSize = nodeRetainedSize,
+            objectCount = nodeRetainedCount
+          )
+          // First traversal, can't compute an increase, nothing to diff on.
+          node.shortestPathNode.retainedIncrease = ZERO_RETAINED
         }
       }
-      growingNodes
-    } else {
-      null
-    }
-
-    return if (growingNodes == null) {
-      check(previousTraversal is InitialState)
       FirstHeapTraversal(tree, previousTraversal)
     } else {
-      check(previousTraversal !is InitialState)
-      HeapGrowthTraversal(previousTraversal.traversalCount + 1, tree, growingNodes, previousTraversal)
+      val reportedGrowingNodeObjectIdsForRetainedSize = MutableLongSet()
+      // Marks node as "growing" if we can find a corresponding previous node that was growing and
+      // we see at least one child node that increased its number of objects over our threshold.
+      val reportedGrowingNodes = dequeuedNodes.mapNotNull reportedGrowingNodeOrNull@{ node ->
+        val previousPathNode = node.previousPathNode
+        // if node wasn't previously growing, skip it.
+        if (previousPathNode == null || !previousPathNode.growing) {
+          return@reportedGrowingNodeOrNull null
+        }
+
+        val shortestPathNode = node.shortestPathNode
+
+        // Existing node. Growing if was growing (already true) and edges increased at least
+        // detectedGrowth for at least one children which was already growing.
+        // Why detectedGrowth? We perform N scenarios and only take N/detectedGrowth heap dumps
+        // which avoids including any side effects of heap dumps in our leak detection.
+        val previouslyGrowingChildren = if (secondTraversal) {
+          previousPathNode.children.asSequence()
+        } else {
+          previousPathNode.growingChildrenArray?.asSequence()
+        }
+
+        // Node had no previously growing children, skip.
+        if (previouslyGrowingChildren == null) {
+          return@reportedGrowingNodeOrNull null
+        }
+
+        val previousGrowingChildrenByName =
+          previouslyGrowingChildren.associateBy { it.name }
+
+        // Set size to max possible
+        val growingChildren = ArrayList<ShortestPathObjectNode>(shortestPathNode.children.size)
+        val growingChildrenIncreases = IntArray(shortestPathNode.children.size)
+        shortestPathNode.children.forEach growingChildren@{ child ->
+          val previousChild = previousGrowingChildrenByName[child.name]
+            ?: return@growingChildren
+          val childrenIncrease = child.selfObjectCount - previousChild.selfObjectCount
+
+          if (childrenIncrease < previousTraversal.scenarioLoopsPerGraph) {
+            // Child stopped growing
+            return@growingChildren
+          }
+
+          growingChildrenIncreases[growingChildren.size] = childrenIncrease
+          growingChildren += child
+        }
+
+        // No child grew beyond threshold, skip.
+        if (growingChildren.isEmpty()) {
+          return@reportedGrowingNodeOrNull null
+        }
+
+        shortestPathNode.growingChildrenArray = growingChildren.toTypedArray()
+        shortestPathNode.growingChildrenIncreasesArray =
+          growingChildrenIncreases.copyOf(growingChildren.size)
+
+        // Mark as growing in the tree (useful for next iteration): if we conditioned setting this
+        // to "parentGrowing", then adding an identical subgraph to an array would otherwise lead
+        // to each distinct paths from roots to a node of a subgraph to be surfaced as a distinct
+        // path.
+        // We're traversing from parents to child. If we didn't mark here, then a path of
+        // 3 growing nodes A->B->C would see B not marked as growing (because it's parent is)
+        // and then C would end up being reported as really growing.
+        shortestPathNode.growing = true
+
+        val parentGrowing = (shortestPathNode.parent?.growing) ?: false
+
+        // Parent already growing, there's no need to report its child node as a growing node.
+        if (parentGrowing) {
+          return@reportedGrowingNodeOrNull null
+        }
+
+        node.objectIds.forEach { objectId ->
+          reportedGrowingNodeObjectIdsForRetainedSize.add(objectId)
+        }
+        return@reportedGrowingNodeOrNull shortestPathNode
+      }
+      val objectSizeCalculator = AndroidObjectSizeCalculator(graph)
+      val retainedMap = dominatorTree.computeRetainedSizes(
+        reportedGrowingNodeObjectIdsForRetainedSize, objectSizeCalculator
+      )
+      dequeuedNodes.forEach reportedGrowingNodeRetainedSize@{ node ->
+        val shortestPathNode = node.shortestPathNode
+        // If not growing, or growing but with a parent that's growing, skip.
+        if (!shortestPathNode.growing ||
+          (shortestPathNode.parent != null && shortestPathNode.parent.growing)
+        ) {
+          return@reportedGrowingNodeRetainedSize
+        }
+
+        var heapSize = ZERO_BYTES
+        var objectCount = 0
+        for (objectId in node.objectIds) {
+          val packed = retainedMap[objectId]
+          val additionalByteSize = packed.unpackAsFirstInt
+          val additionalObjectCount = packed.unpackAsSecondInt
+          heapSize += additionalByteSize.bytes
+          objectCount += additionalObjectCount
+        }
+        shortestPathNode.retained = Retained(
+          heapSize = heapSize,
+          objectCount = objectCount
+        )
+        val previousRetained = node.previousPathNode?.retained ?: UNKNOWN_RETAINED
+        shortestPathNode.retainedIncrease = if (previousRetained.isUnknown) {
+          ZERO_RETAINED
+        } else {
+          Retained(
+            heapSize - previousRetained.heapSize, objectCount - previousRetained.objectCount
+          )
+        }
+      }
+      HeapGrowthTraversal(
+        previousTraversal.traversalCount + 1, tree, reportedGrowingNodes, previousTraversal
+      )
     }
   }
 
-  private fun HeapGraph.groupRoots() =
-    gcRootProvider.provideGcRoots(this).map { gcRootReference ->
-      val name = "GcRoot(${gcRootReference.gcRoot::class.java.simpleName})"
-      name to gcRootReference
-    }
-      // sort preserved
-      .groupBy { it.first + if (it.second.isLowPriority) "low-priority" else "" }
-
   private fun TraversalState.poll(): Node {
     return if (!visitingLast && !toVisitQueue.isEmpty()) {
       toVisitQueue.poll()
@@ -343,68 +402,68 @@
 
   private fun TraversalState.enqueueRoots(
     previousTree: ShortestPathObjectNode?,
-    roots: Map<String, List<Pair<String, GcRootReference>>>
+    heapGraph: CloseableHeapGraph
   ) {
     val previousTreeRootMap = previousTree?.let { tree ->
-      tree._children.associateBy { it.name }
+      tree.children.associateBy { it.name }
     }
 
-    roots.forEach { (_, gcRootReferences) ->
-      val firstOfGroup = gcRootReferences.first()
-      val nodeAndEdgeName = firstOfGroup.first
-      val previousPathNode = if (previousTreeRootMap != null) {
-        previousTreeRootMap[nodeAndEdgeName]
-      } else {
-        null
-      }
-      val objectIds = gcRootReferences.map { it.second.gcRoot.id }
-      dominatorTree?.let {
-        objectIds.forEach { objectId ->
-          it.updateDominatedAsRoot(objectId)
-        }
+    val edgesByNodeName = mutableMapOf<EdgeKey, MutableLongList>()
+    gcRootProvider.provideGcRoots(heapGraph).forEach { gcRootReference ->
+      val objectId = gcRootReference.gcRoot.id
+      if (objectId == ValueHolder.NULL_REFERENCE) {
+        return@forEach
       }
 
+      val name = "GcRoot(${gcRootReference.gcRoot::class.java.simpleName})"
+      val edgeKey = EdgeKey(name, gcRootReference.isLowPriority)
+
+      val edgeObjectIds = edgesByNodeName[edgeKey]
+      if (edgeObjectIds == null) {
+        edgesByNodeName[edgeKey] = mutableLongListOf(objectId)
+      } else {
+        if (objectId !in edgeObjectIds) {
+          edgeObjectIds += objectId
+        }
+      }
+    }
+    val enqueuedCount = edgesByNodeName.count { (edgeKey, edgeObjectIds) ->
+      val previousPathNode = previousTreeRootMap?.get(edgeKey.nodeAndEdgeName)
+
+      edgeObjectIds.forEach { objectId ->
+        dominatorTree.updateDominatedAsRoot(objectId)
+      }
+
+      val edgeObjectIdsArray = LongArray(edgeObjectIds.size)
+
+      edgeObjectIds.forEachIndexed { index, objectId ->
+        edgeObjectIdsArray[index] = objectId
+      }
       enqueue(
         parentPathNode = tree,
         previousPathNode = previousPathNode,
-        objectIds = objectIds,
-        nodeAndEdgeName = nodeAndEdgeName,
-        isLowPriority = firstOfGroup.second.isLowPriority,
+        objectIds = edgeObjectIdsArray,
+        nodeAndEdgeName = edgeKey.nodeAndEdgeName,
+        isLowPriority = edgeKey.isLowPriority,
         isLeafObject = false
       )
+      return@count true
     }
+    tree.createChildrenBackingList(enqueuedCount)
   }
 
   private fun TraversalState.enqueue(
     parentPathNode: ShortestPathObjectNode,
     previousPathNode: ShortestPathObjectNode?,
-    objectIds: List<Long>,
+    objectIds: LongArray,
     nodeAndEdgeName: String,
     isLowPriority: Boolean,
     isLeafObject: Boolean
   ) {
-    // TODO Maybe the filtering should happen at the callsite.
-    // TODO we already filter visited on the traversal side. maybe crash?
-    val filteredObjectIds = objectIds.filter { objectId ->
-      objectId != ValueHolder.NULL_REFERENCE &&
-        // note: we only update visitedSet once dequeued. This could lead
-        // to duplicates in queue, but avoids having to bump priority of already
-        // enqueued low priority nodes.
-        objectId !in visitedSet
-    }
-      // Deduplicate object ids
-      .toSet()
-
-    if (filteredObjectIds.isEmpty()) {
-      return
-    }
-
-    val shortestPathNode =
-      ShortestPathObjectNode(nodeAndEdgeName, parentPathNode, newNode = previousPathNode == null)
-
     val node = Node(
-      objectIds = filteredObjectIds,
-      shortestPathNode = shortestPathNode,
+      objectIds = objectIds,
+      parentPathNode = parentPathNode,
+      nodeAndEdgeName = nodeAndEdgeName,
       previousPathNode = previousPathNode,
       isLeafObject = isLeafObject
     )
@@ -416,14 +475,46 @@
     }
   }
 
-  private data class Node(
+  private fun MutableLongLongMap.increase(
+    objectId: Long,
+    addedValue1: Int,
+    addedValue2: Int,
+  ): Long {
+    val missing = ValueHolder.NULL_REFERENCE
+    val packedValue = getOrDefault(objectId, ValueHolder.NULL_REFERENCE)
+    return if (packedValue == missing) {
+      val newPackedValue = ((addedValue1.toLong()) shl 32) or (addedValue2.toLong() and 0xffffffffL)
+      put(objectId, newPackedValue)
+      newPackedValue
+    } else {
+      val existingValue1 = (packedValue shr 32).toInt()
+      val existingValue2 = (packedValue and 0xFFFFFFFF).toInt()
+      val newValue1 = existingValue1 + addedValue1
+      val newValue2 = existingValue2 + addedValue2
+      val newPackedValue = ((newValue1.toLong()) shl 32) or (newValue2.toLong() and 0xffffffffL)
+      put(objectId, newPackedValue)
+      newPackedValue
+    }
+  }
+
+  private class Node(
     // All objects that you can reach through paths that all resolves to the same structure.
-    val objectIds: Set<Long>,
-    val shortestPathNode: ShortestPathObjectNode,
+    val objectIds: LongArray,
+    val parentPathNode: ShortestPathObjectNode,
+    val nodeAndEdgeName: String,
     val previousPathNode: ShortestPathObjectNode?,
     val isLeafObject: Boolean,
   )
 
+  private class DequeuedNode(
+    node: Node
+  ) {
+    // All objects that you can reach through paths that all resolves to the same structure.
+    val objectIds = node.objectIds
+    val shortestPathNode = ShortestPathObjectNode(node.nodeAndEdgeName, node.parentPathNode)
+    val previousPathNode = node.previousPathNode
+  }
+
   /**
    * This allows external modules to add factory methods for configured instances of this class as
    * extension functions of this companion object.
diff --git a/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt b/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt
index 42e47db..f0eb49d 100644
--- a/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt
+++ b/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt
@@ -15,6 +15,8 @@
  */
 package shark
 
+import androidx.collection.LongLongMap
+import androidx.collection.MutableLongSet
 import shark.HeapObject.HeapClass
 import shark.HeapObject.HeapInstance
 import shark.HeapObject.HeapObjectArray
@@ -27,20 +29,20 @@
 import shark.LeakTraceObject.ObjectType.ARRAY
 import shark.LeakTraceObject.ObjectType.CLASS
 import shark.LeakTraceObject.ObjectType.INSTANCE
-import shark.internal.ReferencePathNode
-import shark.internal.ReferencePathNode.ChildNode
-import shark.internal.ReferencePathNode.RootNode
-import shark.internal.ShallowSizeCalculator
-import shark.internal.createSHA1Hash
-import shark.internal.lastSegment
-import java.util.ArrayList
 import shark.RealLeakTracerFactory.Event.StartedBuildingLeakTraces
 import shark.RealLeakTracerFactory.Event.StartedComputingJavaHeapRetainedSize
-import shark.RealLeakTracerFactory.Event.StartedComputingNativeRetainedSize
 import shark.RealLeakTracerFactory.Event.StartedInspectingObjects
 import shark.RealLeakTracerFactory.TrieNode.LeafNode
 import shark.RealLeakTracerFactory.TrieNode.ParentNode
+import shark.internal.ReferencePathNode
+import shark.internal.ReferencePathNode.ChildNode
+import shark.internal.ReferencePathNode.RootNode
 import shark.internal.ReferencePathNode.RootNode.LibraryLeakRootNode
+import shark.internal.createSHA1Hash
+import shark.internal.lastSegment
+import shark.internal.packedWith
+import shark.internal.unpackAsFirstInt
+import shark.internal.unpackAsSecondInt
 
 // TODO kdoc
 // TODO better name than "real"
@@ -50,16 +52,17 @@
   private val shortestPathFinderFactory: ShortestPathFinder.Factory,
   private val objectInspectors: List<ObjectInspector>,
   private val listener: Event.Listener
-): LeakTracer.Factory {
+) : LeakTracer.Factory {
 
   // TODO Enum or sealed? class makes it possible to report progress. Enum
   // provides ordering of events.
   sealed interface Event {
     object StartedBuildingLeakTraces : Event
     object StartedInspectingObjects : Event
+
     @Deprecated("Event not sent anymore")
-    object StartedComputingNativeRetainedSize: Event
-    object StartedComputingJavaHeapRetainedSize: Event
+    object StartedComputingNativeRetainedSize : Event
+    object StartedComputingJavaHeapRetainedSize : Event
 
     fun interface Listener {
       fun onEvent(event: Event)
@@ -73,7 +76,7 @@
     //  traversed the whole graph.
     //  referenceMatchers are only needed for the NativeGlobalVariablePattern, which is related
     //  to GC roots
-    return LeakTracer { objectIds->
+    return LeakTracer { objectIds ->
       val helpers = FindLeakInput(
         heapGraph,
         shortestPathFinderFactory.createFor(heapGraph),
@@ -83,7 +86,6 @@
     }
   }
 
-
   private class FindLeakInput(
     val graph: HeapGraph,
     val shortestPathFinder: ShortestPathFinder,
@@ -236,6 +238,7 @@
         is ParentNode -> {
           findResultsInTrie(childNode, outputPathResults)
         }
+
         is LeafNode -> {
           outputPathResults += childNode.pathNode
         }
@@ -270,7 +273,7 @@
   private fun FindLeakInput.buildLeakTraces(
     shortestPaths: List<ShortestPath>,
     inspectedObjectsByPath: List<List<InspectedObject>>,
-    retainedSizes: Map<Long, Pair<Int, Int>>?
+    retainedSizes: LongLongMap?
   ): Pair<List<ApplicationLeak>, List<LibraryLeak>> {
     listener.onEvent(StartedBuildingLeakTraces)
 
@@ -345,20 +348,26 @@
   private fun FindLeakInput.computeRetainedSizes(
     inspectedObjectsByPath: List<List<InspectedObject>>,
     dominatorTree: DominatorTree
-  ): Map<Long, Pair<Int, Int>> {
+  ): LongLongMap {
     val nodeObjectIds = inspectedObjectsByPath.flatMap { inspectedObjects ->
       // TODO Stop at the first leaking object
       inspectedObjects.filter { it.leakingStatus == UNKNOWN || it.leakingStatus == LEAKING }
         .map { it.heapObject.objectId }
-    }.toSet()
+    }
+
+    val nodeObjectIdsSet = MutableLongSet(nodeObjectIds.size)
+    nodeObjectIds.forEach {
+      nodeObjectIdsSet += it
+    }
+
     listener.onEvent(StartedComputingJavaHeapRetainedSize)
     val objectSizeCalculator = AndroidObjectSizeCalculator(graph)
-    return dominatorTree.computeRetainedSizes(nodeObjectIds, objectSizeCalculator)
+    return dominatorTree.computeRetainedSizes(nodeObjectIdsSet, objectSizeCalculator)
   }
 
   private fun buildLeakTraceObjects(
     inspectedObjects: List<InspectedObject>,
-    retainedSizes: Map<Long, Pair<Int, Int>>?
+    retainedSizes: LongLongMap?
   ): List<LeakTraceObject> {
     return inspectedObjects.map { inspectedObject ->
       val heapObject = inspectedObject.heapObject
@@ -370,7 +379,18 @@
         else -> INSTANCE
       }
 
-      val retainedSizeAndObjectCount = retainedSizes?.get(inspectedObject.heapObject.objectId)
+      var retainedHeapByteSize: Int? = null
+      var retainedObjectCount: Int? = null
+
+      if (retainedSizes != null) {
+        val missing = -1 packedWith -1
+        val retainedSizeAndObjectCount =
+          retainedSizes.getOrDefault(inspectedObject.heapObject.objectId, missing)
+        if (retainedSizeAndObjectCount != missing) {
+          retainedHeapByteSize = retainedSizeAndObjectCount.unpackAsFirstInt
+          retainedObjectCount = retainedSizeAndObjectCount.unpackAsSecondInt
+        }
+      }
 
       LeakTraceObject(
         type = objectType,
@@ -378,8 +398,8 @@
         labels = inspectedObject.labels,
         leakingStatus = inspectedObject.leakingStatus,
         leakingStatusReason = inspectedObject.leakingStatusReason,
-        retainedHeapByteSize = retainedSizeAndObjectCount?.first,
-        retainedObjectCount = retainedSizeAndObjectCount?.second
+        retainedHeapByteSize = retainedHeapByteSize,
+        retainedObjectCount = retainedObjectCount
       )
     }
   }
diff --git a/shark/shark/src/main/java/shark/RepeatingScenarioObjectGrowthDetector.kt b/shark/shark/src/main/java/shark/RepeatingScenarioObjectGrowthDetector.kt
index 7ebcdbc..d98e735 100644
--- a/shark/shark/src/main/java/shark/RepeatingScenarioObjectGrowthDetector.kt
+++ b/shark/shark/src/main/java/shark/RepeatingScenarioObjectGrowthDetector.kt
@@ -9,7 +9,7 @@
    */
   private val heapGraphProvider: HeapGraphProvider,
   objectGrowthDetector: ObjectGrowthDetector,
-  maxHeapDumps: Int = DEFAULT_MAX_HEAP_DUMPS,
+  private val maxHeapDumps: Int = DEFAULT_MAX_HEAP_DUMPS,
   scenarioLoopsPerDump: Int = DEFAULT_SCENARIO_LOOPS_PER_DUMP,
 ) {
 
@@ -17,7 +17,6 @@
 
   private val initialState = InitialState(
     scenarioLoopsPerGraph = scenarioLoopsPerDump,
-    heapGraphCount = maxHeapDumps
   )
 
   /**
@@ -37,7 +36,7 @@
   private fun dumpHeapOnNext(
     repeatedScenario: () -> Unit,
   ): Sequence<CloseableHeapGraph> {
-    val heapDumps = (1..initialState.heapGraphCount!!).asSequence().map {
+    val heapDumps = (1..maxHeapDumps).asSequence().map {
       repeat(initialState.scenarioLoopsPerGraph) {
         repeatedScenario()
       }
diff --git a/shark/shark/src/main/java/shark/Retained.kt b/shark/shark/src/main/java/shark/Retained.kt
new file mode 100644
index 0000000..3dc12e4
--- /dev/null
+++ b/shark/shark/src/main/java/shark/Retained.kt
@@ -0,0 +1,45 @@
+package shark
+
+import shark.internal.packedWith
+import shark.internal.unpackAsFirstInt
+import shark.internal.unpackAsSecondInt
+
+/**
+ * Constructors can't be inlined so we used a function instead.
+ */
+inline fun Retained(
+  /**
+   * The minimum number of bytes which would be freed if all references to this object were
+   * released. Should not exceed [Int.MAX_VALUE] bytes.
+   */
+  heapSize: ByteSize,
+
+  /**
+   * The minimum number of objects which would be unreachable if all references to this object were
+   * released.
+   */
+  objectCount: Int,
+) = Retained(heapSize.inWholeBytes.toInt() packedWith objectCount)
+
+// DO NOT ADD A COMPANION OBJECT: a value class is supposed to be lightweight and its usage inlined
+// into few instructions. After adding a companion object, call sites get a lot more instructions.
+@JvmInline
+value class Retained @PublishedApi internal constructor(
+  @PublishedApi @JvmField
+  internal val packedValue: Long
+) {
+  inline val heapSize: ByteSize
+    get() = packedValue.unpackAsFirstInt.bytes
+
+  inline val objectCount: Int
+    get() = packedValue.unpackAsSecondInt
+
+  inline val isUnknown: Boolean
+    get() = this == UNKNOWN_RETAINED
+
+  inline val isZero: Boolean
+    get() = this == ZERO_RETAINED
+}
+
+val ZERO_RETAINED = Retained(ZERO_BYTES, 0)
+val UNKNOWN_RETAINED = Retained((-1).bytes, -1)
diff --git a/shark/shark/src/main/java/shark/ShortestPathObjectNode.kt b/shark/shark/src/main/java/shark/ShortestPathObjectNode.kt
index 3424354..0b38af2 100644
--- a/shark/shark/src/main/java/shark/ShortestPathObjectNode.kt
+++ b/shark/shark/src/main/java/shark/ShortestPathObjectNode.kt
@@ -1,67 +1,89 @@
 package shark
 
-import shark.ByteSize.Companion.bytes
-
 typealias GrowingObjectNodes = List<ShortestPathObjectNode>
 
 class ShortestPathObjectNode(
   val name: String,
   val parent: ShortestPathObjectNode?,
-  internal val newNode: Boolean
 ) {
-  @Suppress("VariableNaming")
-  internal val _children = mutableListOf<ShortestPathObjectNode>()
+  // Null at first, then created with capacity set to the number of edges enqueued from that node.
+  // This means we'll sometimes use a little more space than what we actually need, but the
+  // trade-off is that we only get to create the array once, and there's no array size doubling.
+  private var _children: MutableList<ShortestPathObjectNode>? = null
+
+  // Null on initial run (all children are growing). After the first run, set with only
+  // children that are constantly growing over the per children threshold.
+  internal var growingChildrenArray: Array<ShortestPathObjectNode>? = null
+  internal var growingChildrenIncreasesArray: IntArray? = null
+
   val children: List<ShortestPathObjectNode>
-    get() = _children
+    get() = _children ?: emptyList()
+
+  data class GrowingChildNode(
+    val child: ShortestPathObjectNode,
+    val objectCountIncrease: Int
+  )
+
+  /**
+   * Returns a list of pair of child [ShortestPathObjectNode] and associated object count
+   * increase, filtered to only the children nodes that were marked as growing, i.e. children
+   * that had an object count increase greater or equal to the scenario loop count.
+   */
+  val growingChildren: List<GrowingChildNode>
+    get() = growingChildrenArray!!.withIndex()
+      .map { indexedValue ->
+        GrowingChildNode(indexedValue.value, growingChildrenIncreasesArray!![indexedValue.index])
+      }
 
   var selfObjectCount = 0
     internal set
-  var selfObjectCountIncrease = 0
-    internal set
 
   /**
    * Set for growing nodes if the traversal requested the computation of retained sizes, otherwise
    * null.
-   * This is on the last 2 traversals.
    */
-  var retainedOrNull: Retained? = null
+  var retained: Retained = UNKNOWN_RETAINED
     internal set
 
   /**
    * Set for growing nodes if [retainedOrNull] is not null. Non 0 if the previous traversal also
    * computed retained size.
-   * This is on the last 2 traversals.
    */
-  var retainedIncreaseOrNull: Retained? = null
+  var retainedIncrease: Retained = UNKNOWN_RETAINED
     internal set
 
-  val retained: Retained get() = retainedOrNull!!
-
-  val retainedIncrease: Retained get() = retainedIncreaseOrNull!!
-
   internal var growing = false
 
-  val childrenObjectCount: Int
-    get() = _children.sumOf { it.selfObjectCount }
+  internal fun createChildrenBackingList(maxChildren: Int) {
+    check(_children == null) {
+      "Expected createChildList() to be called at most once per node."
+    }
+    _children = ArrayList(maxChildren)
+  }
 
-  val childrenObjectCountIncrease: Int
-    get() = _children.sumOf { it.selfObjectCountIncrease }
+  internal fun addChild(child: ShortestPathObjectNode) {
+    val children = checkNotNull(_children) {
+      "Excepted createChildList() to have been called"
+    }
+    children.add(child)
+  }
 
   fun copyResettingAsInitialTree(): ShortestPathObjectNode {
     return copyResetRecursive(null)
   }
 
   private fun copyResetRecursive(newParent: ShortestPathObjectNode?): ShortestPathObjectNode {
-    val newNode = ShortestPathObjectNode(name, newParent, true)
+    val newNode = ShortestPathObjectNode(name, newParent)
     newNode.selfObjectCount = selfObjectCount
-    newNode.selfObjectCountIncrease = 0
-    newNode.retainedOrNull = retainedOrNull
-    if (retainedOrNull != null) {
-      newNode.retainedIncreaseOrNull = Retained(0L.bytes, 0)
+    newNode.retained = retained
+    if (!retained.isUnknown) {
+      newNode.retainedIncrease = ZERO_RETAINED
     }
     newNode.growing = true
-    _children.forEach { child ->
-      newNode._children += child.copyResetRecursive(newNode)
+    newNode.createChildrenBackingList(children.size)
+    val newChildren = newNode._children!!
+    children.forEach { child ->
+      newChildren += child.copyResetRecursive(newNode)
     }
     return newNode
   }
@@ -91,7 +113,7 @@
       result.append(pathNode.selfObjectCount)
       result.append(" objects)")
       if (index == pathAfterRoot.lastIndex) {
-        if (retainedOrNull != null) {
+        if (!retained.isUnknown) {
           result.appendLine()
           result.append("    Retained size: ${retained.heapSize} (+ ${retainedIncrease.heapSize})")
           result.appendLine()
@@ -102,17 +124,16 @@
         result.appendLine()
         result.append("    Children:")
         result.appendLine()
-        val childrenByMostIncreasedFirst =
-          pathNode.children
-            // TODO Ideally here we'd filter on the increase threshold (e.g. 5 instead of 0)
-            .filter { it.selfObjectCountIncrease > 0 }
-            .sortedBy { -it.selfObjectCountIncrease }
+
+        val childrenByMostIncreasedFirst = growingChildren
+          .sortedBy { -it.objectCountIncrease }
+
         result.append(
           childrenByMostIncreasedFirst.joinToString(
             separator = "\n",
             postfix = "\n"
-          ) { child ->
-            "    ${child.selfObjectCount} objects (${child.selfObjectCountIncrease} new): ${child.name}"
+          ) { (child, increase) ->
+            "    ${child.selfObjectCount} objects (${increase} new): ${child.name}"
           })
       } else {
         result.appendLine()
@@ -121,18 +142,4 @@
     }
     return result.toString()
   }
-
-  class Retained(
-    /**
-     * The minimum number of bytes which would be freed if all references to this object were
-     * released.
-     */
-    val heapSize: ByteSize,
-
-    /**
-     * The minimum number of objects which would be unreachable if all references to this object were
-     * released.
-     */
-    val objectCount: Int,
-  )
 }
diff --git a/shark/shark/src/main/java/shark/internal/IntIntPairUtils.kt b/shark/shark/src/main/java/shark/internal/IntIntPairUtils.kt
new file mode 100644
index 0000000..d802d41
--- /dev/null
+++ b/shark/shark/src/main/java/shark/internal/IntIntPairUtils.kt
@@ -0,0 +1,13 @@
+package shark.internal
+
+@PublishedApi
+internal inline infix fun Int.packedWith(that: Int) =
+  ((toLong()) shl 32) or (that.toLong() and 0xffffffffL)
+
+@PublishedApi
+internal inline val Long.unpackAsFirstInt: Int
+  get() = (this shr 32).toInt()
+
+@PublishedApi
+internal inline val Long.unpackAsSecondInt: Int
+  get() = (this and 0xFFFFFFFF).toInt()
diff --git a/shark/shark/src/test/java/shark/LiveObjectGrowthDetectorTest.kt b/shark/shark/src/test/java/shark/LiveObjectGrowthDetectorTest.kt
deleted file mode 100644
index cfae958..0000000
--- a/shark/shark/src/test/java/shark/LiveObjectGrowthDetectorTest.kt
+++ /dev/null
@@ -1,209 +0,0 @@
-package shark
-
-import java.io.File
-import org.assertj.core.api.Assertions.assertThat
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TemporaryFolder
-import shark.HprofHeapGraph.Companion.openHeapGraph
-
-class LiveObjectGrowthDetectorTest {
-
-  class Leaky
-
-  class MultiLeaky {
-    val leaky = Leaky() to Leaky()
-  }
-
-  class CustomLinkedList(var next: CustomLinkedList? = null)
-
-  @get:Rule
-  val testFolder = TemporaryFolder()
-
-  val leakies = mutableListOf<Leaky>()
-
-  val stringLeaks = mutableListOf<String>()
-
-  var customLeakyLinkedList = CustomLinkedList()
-
-  val leakyHashMap = HashMap<String, Leaky>()
-
-  val multiLeakies = mutableListOf<MultiLeaky>()
-
-  @Test
-  fun `empty scenario leads to no heap growth`() {
-    val detector = simpleDetector().fromScenario()
-
-    val emptyScenario = {}
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects(roundTripScenario = emptyScenario)
-      .growingObjects
-
-    assertThat(growingNodes).isEmpty()
-  }
-
-  @Test
-  fun `leaky increase leads to heap growth`() {
-    val detector = simpleDetector().fromScenario()
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      leakies += Leaky()
-    }.growingObjects
-
-    assertThat(growingNodes).isNotEmpty
-  }
-
-  @Test
-  fun `string leak increase leads to heap growth`() {
-    val detector = simpleDetector().fromScenario()
-
-    var index = 0
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      stringLeaks += "Yo ${++index}"
-    }.growingObjects
-
-    assertThat(growingNodes).isNotEmpty
-  }
-
-  @Test
-  fun `leak increase that ends leads to no heap growth`() {
-    val maxHeapDumps = 10
-    val stopLeakingIndex = maxHeapDumps / 2
-    val detector = simpleDetector().fromScenario(maxHeapDumps = maxHeapDumps)
-
-    var index = 0
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      if (++index < stopLeakingIndex) {
-        leakies += Leaky()
-      }
-    }.growingObjects
-
-    assertThat(growingNodes).isEmpty()
-  }
-
-  @Test
-  fun `multiple leaky scenarios per dump leads to heap growth`() {
-    val scenarioLoopsPerDump = 5
-    val detector =
-      simpleDetector().fromScenario(scenarioLoopsPerDump = scenarioLoopsPerDump)
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      leakies += Leaky()
-    }.growingObjects
-
-    assertThat(growingNodes).hasSize(1)
-
-    val growingNode = growingNodes.first()
-    assertThat(growingNode.childrenObjectCountIncrease).isEqualTo(scenarioLoopsPerDump)
-  }
-
-  @Test
-  fun `custom leaky linked list leads to heap growth`() {
-    val detector = simpleDetector().fromScenario()
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
-    }.growingObjects
-
-    assertThat(growingNodes).isNotEmpty
-  }
-
-  @Test
-  fun `custom leaky linked list reports descendant to root as flattened collection`() {
-    val detector = simpleDetector().fromScenario()
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
-      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
-      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
-      customLeakyLinkedList = CustomLinkedList(customLeakyLinkedList)
-    }.growingObjects
-
-    assertThat(growingNodes).hasSize(1)
-
-    val growingNode = growingNodes.first()
-    assertThat(growingNode.children.size).isEqualTo(1)
-    assertThat(growingNode.childrenObjectCountIncrease).isEqualTo(4)
-  }
-
-  @Test
-  fun `growth along shared sub paths reported as single growth of shortest path`() {
-    val detector = simpleDetector().fromScenario()
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      multiLeakies += MultiLeaky()
-    }.growingObjects
-
-    assertThat(growingNodes).hasSize(1)
-
-    val growingNode = growingNodes.first()
-    assertThat(growingNode.name).contains("ArrayList")
-  }
-
-  @Test
-  fun `OpenJdk HashMap virtualized as array`() {
-    val detector = openJdkDetector().fromScenario(maxHeapDumps = 5)
-
-    var index = 0
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      leakyHashMap["key${++index}"] = Leaky()
-    }.growingObjects
-
-    val growingNode = growingNodes.first()
-    assertThat(growingNode.name).contains("leakyHashMap")
-  }
-
-  @Test
-  fun `OpenJdk ArrayList virtualized as array`() {
-    val detector = openJdkDetector().fromScenario()
-
-    val growingNodes = detector.findRepeatedlyGrowingObjects {
-      leakies += Leaky()
-    }.growingObjects
-
-    val growingNode = growingNodes.first()
-    assertThat(growingNode.name).contains("leakies")
-  }
-
-  private fun ObjectGrowthDetector.fromScenario(
-    scenarioLoopsPerDump: Int = 1,
-    maxHeapDumps: Int = 5
-  ): RepeatingScenarioObjectGrowthDetector {
-    return repeatingScenario(
-      heapGraphProvider = ::dumpHeapGraph,
-      maxHeapDumps = maxHeapDumps,
-      scenarioLoopsPerDump = scenarioLoopsPerDump
-    )
-  }
-
-  private fun simpleDetector(): ObjectGrowthDetector {
-    val referenceMatchers = JdkReferenceMatchers.defaults + HeapTraversalOutput.ignoredReferences
-    val referenceReaderFactory = ActualMatchingReferenceReaderFactory(referenceMatchers)
-    val gcRootProvider = MatchingGcRootProvider(referenceMatchers)
-    return ObjectGrowthDetector(gcRootProvider, referenceReaderFactory)
-  }
-
-  private fun openJdkDetector(): ObjectGrowthDetector {
-    val referenceMatchers = JdkReferenceMatchers.defaults + HeapTraversalOutput.ignoredReferences
-
-    val referenceReaderFactory = VirtualizingMatchingReferenceReaderFactory(
-      referenceMatchers = referenceMatchers,
-      virtualRefReadersFactory = { graph ->
-        listOf(
-          JavaLocalReferenceReader(graph, referenceMatchers),
-        ) +
-          OpenJdkInstanceRefReaders.values().mapNotNull { it.create(graph) }
-      }
-    )
-
-    val gcRootProvider = MatchingGcRootProvider(referenceMatchers)
-    return ObjectGrowthDetector(gcRootProvider, referenceReaderFactory)
-  }
-
-  private fun dumpHeapGraph(): CloseableHeapGraph {
-    val hprofFolder = testFolder.newFolder()
-    val hprofFile = File(hprofFolder, "${System.nanoTime()}.hprof")
-    JvmTestHeapDumper.dumpHeap(hprofFile.absolutePath)
-    return hprofFile.openHeapGraph()
-  }
-}
diff --git a/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt b/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt
index 6623b87..8976ab9 100644
--- a/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt
+++ b/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt
@@ -3,25 +3,26 @@
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Test
 import shark.HprofHeapGraph.Companion.openHeapGraph
+import shark.ValueHolder.Companion.NULL_REFERENCE
 
 class ObjectGrowthDetectorTest {
 
   @Test
-  fun `first traversal returns InitialHeapTraversal`() {
-    val detector = newSimpleDetector()
+  fun `first traversal returns FirstHeapTraversal`() {
+    val detector = ObjectGrowthDetector.forJvmHeap()
 
-    val heapTraversal = detector.findGrowingObjects(
+    val firstTraversal = detector.findGrowingObjects(
       heapGraph = dump {
       },
       previousTraversal = InitialState(scenarioLoopsPerGraph = 1),
     )
 
-    assertThat(heapTraversal).isInstanceOf(FirstHeapTraversal::class.java)
+    assertThat(firstTraversal).isInstanceOf(FirstHeapTraversal::class.java)
   }
 
   @Test
   fun `second traversal returns HeapTraversalWithDiff`() {
-    val detector = newSimpleDetector()
+    val detector = ObjectGrowthDetector.forJvmHeap()
     val first = detector.findGrowingObjects(
       heapGraph = emptyHeapDump(),
       previousTraversal = InitialState(scenarioLoopsPerGraph = 1),
@@ -37,7 +38,7 @@
 
   @Test
   fun `detect no growth on identical heaps`() {
-    val detector = newSimpleDetector()
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
     val dumps = listOf(
       dump {
         classWithStringsInStaticField("Hi")
@@ -47,15 +48,14 @@
       }
     )
 
-    val growingNodes = detector.detectHeapGrowth(dumps)
+    val growingObjects = detector.findRepeatedlyGrowingObjects(dumps).growingObjects
 
-    assertThat(growingNodes).isEmpty()
+    assertThat(growingObjects).isEmpty()
   }
 
   @Test
   fun `detect no growth on structurally identical heap`() {
-    val detector = newSimpleDetector()
-
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
     val dumps = listOf(
       dump {
         classWithStringsInStaticField("Hi")
@@ -65,14 +65,14 @@
       }
     )
 
-    val growingNodes = detector.detectHeapGrowth(dumps)
+    val growingObjects = detector.findRepeatedlyGrowingObjects(dumps).growingObjects
 
-    assertThat(growingNodes).isEmpty()
+    assertThat(growingObjects).isEmpty()
   }
 
   @Test
   fun `detect static field growth`() {
-    val detector = newSimpleDetector()
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
     val dumps = listOf(
       dump {
         classWithStringsInStaticField("Hello")
@@ -82,14 +82,135 @@
       }
     )
 
-    val growingNodes = detector.detectHeapGrowth(dumps)
+    val growingObjects = detector.findRepeatedlyGrowingObjects(dumps).growingObjects
 
-    assertThat(growingNodes).hasSize(1)
+    assertThat(growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `object growth computes retained size increase with 2 iterations`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        classWithStringsInStaticField("Hello")
+      },
+      dump {
+        classWithStringsInStaticField("Hello", "World!")
+      }
+    )
+
+    val heapTraversal =  detector.findRepeatedlyGrowingObjects(dumps)
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.retainedIncrease.objectCount).isEqualTo(1)
+    val expectedRetainedSizeIncrease = (12 + "World!".length * 2).bytes
+    assertThat(growingObject.retainedIncrease.heapSize).isEqualTo(expectedRetainedSizeIncrease)
+  }
+
+  @Test
+  fun `object growth computes retained size increase with 3 iterations`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        classWithStringsInStaticField("Hello")
+      },
+      dump {
+        classWithStringsInStaticField("Hello", "World!")
+      },
+      dump {
+        classWithStringsInStaticField("Hello", "World!", "Turtles")
+      }
+    )
+
+    val heapTraversal =  detector.findRepeatedlyGrowingObjects(dumps)
+
+    val growingObject = heapTraversal.growingObjects.single()
+    assertThat(growingObject.retainedIncrease.objectCount).isEqualTo(1)
+    val expectedRetainedSizeIncrease = (12 + "Turtles".length * 2).bytes
+    assertThat(growingObject.retainedIncrease.heapSize).isEqualTo(expectedRetainedSizeIncrease)
+  }
+
+
+  @Test
+  fun `detect growth of custom linked list`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        val customLinkedListClass = clazz(
+          className = "CustomLinkedList",
+          fields = listOf("next" to ValueHolder.ReferenceHolder::class),
+        )
+        val linkedListTail = instance(customLinkedListClass, listOf(nullReference()))
+        val linkedListHead = instance(customLinkedListClass, listOf(linkedListTail))
+        clazz(
+          className = "ListHolder",
+          staticFields = listOf("staticList" to linkedListHead)
+        )
+      },
+      dump {
+        val customLinkedListClass = clazz(
+          className = "CustomLinkedList",
+          fields = listOf("next" to ValueHolder.ReferenceHolder::class),
+        )
+        val linkedListTail = instance(customLinkedListClass, listOf(nullReference()))
+        val linkedListMiddle = instance(customLinkedListClass, listOf(linkedListTail))
+        val linkedListHead = instance(customLinkedListClass, listOf(linkedListMiddle))
+        clazz(
+          className = "ListHolder",
+          staticFields = listOf("staticList" to linkedListHead)
+        )
+      }
+    )
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects(dumps)
+
+    assertThat(heapTraversal.growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `custom leaky linked list reports descendant to root as flattened collection`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        val customLinkedListClass = clazz(
+          className = "CustomLinkedList",
+          fields = listOf("next" to ValueHolder.ReferenceHolder::class),
+        )
+        val linkedListTail = instance(customLinkedListClass, listOf(nullReference()))
+        val linkedListHead = instance(customLinkedListClass, listOf(linkedListTail))
+        clazz(
+          className = "ListHolder",
+          staticFields = listOf("staticList" to linkedListHead)
+        )
+      },
+      dump {
+        val customLinkedListClass = clazz(
+          className = "CustomLinkedList",
+          fields = listOf("next" to ValueHolder.ReferenceHolder::class),
+        )
+        val linkedListTail = instance(customLinkedListClass, listOf(nullReference()))
+        val linkedListMiddle1 = instance(customLinkedListClass, listOf(linkedListTail))
+        val linkedListMiddle2 = instance(customLinkedListClass, listOf(linkedListMiddle1))
+        val linkedListMiddle3 = instance(customLinkedListClass, listOf(linkedListMiddle2))
+        val linkedListMiddle4 = instance(customLinkedListClass, listOf(linkedListMiddle3))
+        val linkedListHead = instance(customLinkedListClass, listOf(linkedListMiddle4))
+        clazz(
+          className = "ListHolder",
+          staticFields = listOf("staticList" to linkedListHead)
+        )
+      }
+    )
+
+    val heapTraversal = detector.findRepeatedlyGrowingObjects(dumps)
+
+    val growingObject = heapTraversal.growingObjects.single()
+    val growingChild = growingObject.growingChildren.single()
+    assertThat(growingChild.objectCountIncrease).isEqualTo(4)
   }
 
   @Test
   fun `detect no growth if more loops than node increase`() {
-    val detector = newSimpleDetector()
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
     val dumps = listOf(
       dump {
         classWithStringsInStaticField("Hello")
@@ -99,14 +220,17 @@
       }
     )
 
-    val growingNodes = detector.detectHeapGrowth(dumps, 2)
+    val growingObjects = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = 2
+    ).growingObjects
 
-    assertThat(growingNodes).isEmpty()
+    assertThat(growingObjects).isEmpty()
   }
 
   @Test
   fun `detect static field growth counts`() {
-    val detector = newSimpleDetector()
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
 
     val heapDumpCount = 3
     val scenarioLoopCount = 7
@@ -119,29 +243,262 @@
       }
     }
 
-    val growingNodes = detector.detectHeapGrowth(dumps)
+    val growingObjects = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = scenarioLoopCount
+    ).growingObjects
 
-    val growingNode = growingNodes.first()
+    val growingNode = growingObjects.first()
 
     assertThat(growingNode.selfObjectCount).isEqualTo(1)
-    assertThat(growingNode.childrenObjectCount).isEqualTo(heapDumpCount * scenarioLoopCount)
-    assertThat(growingNode.childrenObjectCountIncrease).isEqualTo(scenarioLoopCount)
+    assertThat(growingNode.children.sumOf { it.selfObjectCount }).isEqualTo(
+      heapDumpCount * scenarioLoopCount
+    )
+    val growingChildren = growingNode.growingChildren
+    assertThat(growingChildren).hasSize(1)
+    assertThat(growingChildren.first().objectCountIncrease).isEqualTo(scenarioLoopCount)
     assertThat(growingNode.children).hasSize(1)
   }
 
-  private fun ObjectGrowthDetector.detectHeapGrowth(
-    heapDumps: List<CloseableHeapGraph>,
-    scenarioLoopsPerGraph: Int = 1
-  ): GrowingObjectNodes {
-    return repeatingHeapGraph().findRepeatedlyGrowingObjects(
-      initialState = InitialState(
-        scenarioLoopsPerGraph = scenarioLoopsPerGraph,
-        heapGraphCount = heapDumps.size
-      ),
-      heapGraphSequence = heapDumps.asSequence()
+  @Test
+  fun `no heap growth when node with no children grows`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+
+    val dumps = listOf(
+      dump {
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "strings" to objectArray(),
+          )
+        )
+      },
+      dump {
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "strings" to objectArray(
+              string("Hello 1"),
+              string("Hello 2")
+            ),
+          )
+        )
+      },
+    )
+    val growingObjects = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = 2
     ).growingObjects
+    assertThat(growingObjects).isEmpty()
   }
 
+  @Test
+  fun `detect heap growth when node with existing children grows`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+
+    val dumps = listOf(
+      dump {
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "strings" to objectArray(
+              string("Hello 1"),
+              string("Hello 2")
+            ),
+          )
+        )
+      },
+      dump {
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "strings" to objectArray(
+              string("Hello 1"),
+              string("Hello 2"),
+              string("Hello 3"),
+              string("Hello 4"),
+            ),
+          )
+        )
+      },
+    )
+    val growingObjects = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = 2
+    ).growingObjects
+    assertThat(growingObjects).hasSize(1)
+  }
+
+  @Test
+  fun `detect no growth if sum of children over threshold but individual children under threshold`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+
+    val dumps = listOf(
+      dump {
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf("strings1" to objectArray(string("Hello")))
+        )
+      },
+      dump {
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "strings1" to objectArray(string("Hello")),
+            "strings2" to objectArray(string("World")),
+            "strings3" to objectArray(string("!")),
+          )
+        )
+      }
+    )
+    val growingObjects = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = 2
+    ).growingObjects
+    assertThat(growingObjects).isEmpty()
+  }
+
+  @Test
+  fun `detect no growth if different individual children over threshold`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        val otherType = clazz("SomeClass")
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "list" to objectArray(
+              string("Hello 1"),
+              string("Hello 2"),
+              instance(otherType),
+              instance(otherType),
+            ),
+          )
+        )
+      },
+      dump {
+        val otherType = clazz("SomeClass")
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "list" to objectArray(
+              string("Hello 1"),
+              string("Hello 2"),
+              string("Hello 3"),
+              string("Hello 4"),
+              instance(otherType),
+              instance(otherType),
+            ),
+          )
+        )
+      },
+      dump {
+        val otherType = clazz("SomeClass")
+        clazz("SomeClass")
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "list" to objectArray(
+              string("Hello 1"),
+              string("Hello 2"),
+              string("Hello 3"),
+              string("Hello 4"),
+              instance(otherType),
+              instance(otherType),
+              instance(otherType),
+              instance(otherType),
+            ),
+          )
+        )
+      },
+    )
+
+    val heapGrowthTraversal = detector.findRepeatedlyGrowingObjects(
+      heapGraphs = dumps,
+      scenarioLoopsPerGraph = 2
+    )
+    assertThat(heapGrowthTraversal.traversalCount).isEqualTo(dumps.size)
+    val growingObjects = heapGrowthTraversal.growingObjects
+    assertThat(growingObjects).isEmpty()
+  }
+
+  @Test
+  fun `growth along shared sub paths reported as single growth of shortest path`() {
+    val detector = ObjectGrowthDetector.forJvmHeap().listRepeatingHeapGraph()
+    val dumps = listOf(
+      dump {
+        val pairClass = clazz("Pair", fields = listOf(
+          "first" to ValueHolder.ReferenceHolder::class,
+          "second" to ValueHolder.ReferenceHolder::class,
+        ))
+        val growingClass = clazz("GrowingClass", fields = listOf("growingField" to ValueHolder.ReferenceHolder::class))
+        val pair = instance(pairClass, listOf(instance(objectClassId), instance(objectClassId)))
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "list" to objectArray(
+              instance(growingClass, listOf(pair)),
+            ),
+          )
+        )
+      },
+      dump {
+        val pairClass = clazz("Pair", fields = listOf(
+          "first" to ValueHolder.ReferenceHolder::class,
+          "second" to ValueHolder.ReferenceHolder::class,
+        ))
+        val growingClass = clazz("GrowingClass", fields = listOf("growingField" to ValueHolder.ReferenceHolder::class))
+        val pair1 = instance(pairClass, listOf(instance(objectClassId), instance(objectClassId)))
+        val pair2 = instance(pairClass, listOf(instance(objectClassId), instance(objectClassId)))
+        clazz(
+          "ClassWithStatics",
+          staticFields = listOf(
+            "list" to objectArray(
+              instance(growingClass, listOf(pair1)),
+              instance(growingClass, listOf(pair2)),
+            ),
+          )
+        )
+      },
+    )
+
+    val heapGrowthTraversal = detector.findRepeatedlyGrowingObjects(dumps)
+
+    val growingObject = heapGrowthTraversal.growingObjects.single()
+    assertThat(growingObject.name).startsWith("STATIC_FIELD ClassWithStatics.list")
+  }
+
+  class ListRepeatingHeapGraphObjectGrowthDetector(
+    objectGrowthDetector: ObjectGrowthDetector
+  ) {
+    private val delegate = objectGrowthDetector.repeatingHeapGraph()
+
+    fun findRepeatedlyGrowingObjects(
+      heapGraphs: List<CloseableHeapGraph>,
+      scenarioLoopsPerGraph: Int = InitialState.DEFAULT_SCENARIO_LOOPS_PER_GRAPH,
+    ): HeapGrowthTraversal {
+      return delegate.findRepeatedlyGrowingObjects(
+        initialState = InitialState(
+          scenarioLoopsPerGraph = scenarioLoopsPerGraph,
+        ),
+        heapGraphSequence = heapGraphs.asSequence()
+      ).apply {
+        check(traversalCount == heapGraphs.size) {
+          "Expected traversalCount $traversalCount to be equal to heapGraphs size ${heapGraphs.size} for $this"
+        }
+      }
+    }
+  }
+
+  private fun ObjectGrowthDetector.listRepeatingHeapGraph(): ListRepeatingHeapGraphObjectGrowthDetector =
+    ListRepeatingHeapGraphObjectGrowthDetector(this)
+
   private fun HprofWriterHelper.classWithStringsInStaticField(vararg strings: String) {
     clazz(
       "ClassWithStatics",
@@ -157,19 +514,5 @@
     return dump(HprofHeader(), block).openHeapGraph()
   }
 
-  private fun newSimpleDetector(): ObjectGrowthDetector {
-    val referenceReaderFactory = ActualMatchingReferenceReaderFactory(
-      referenceMatchers = emptyList()
-    )
-    val gcRootProvider = GcRootProvider { graph ->
-      graph.gcRoots.asSequence().map {
-        GcRootReference(
-          gcRoot = it,
-          isLowPriority = false,
-          matchedLibraryLeak = null
-        )
-      }
-    }
-    return ObjectGrowthDetector(gcRootProvider, referenceReaderFactory)
-  }
+  private fun nullReference() = ValueHolder.ReferenceHolder(NULL_REFERENCE)
 }
diff --git a/shark/shark/src/test/java/shark/internal/DominatorTreeTest.kt b/shark/shark/src/test/java/shark/internal/DominatorTreeTest.kt
index 7b2d42e..b7771a5 100644
--- a/shark/shark/src/test/java/shark/internal/DominatorTreeTest.kt
+++ b/shark/shark/src/test/java/shark/internal/DominatorTreeTest.kt
@@ -1,5 +1,6 @@
 package shark.internal
 
+import androidx.collection.mutableLongSetOf
 import org.assertj.core.api.Assertions.assertThat
 import org.junit.Test
 import shark.DominatorTree
@@ -39,18 +40,21 @@
     val root = newObjectId().apply { tree.updateDominatedAsRoot(this) }
     val child = newObjectId().apply { tree.updateDominated(this, root) }
 
-    val sizes = tree.computeRetainedSizes(setOf(child), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(child), `10 bytes per object`)
 
-    assertThat(sizes).containsOnlyKeys(child)
+    val keys = mutableSetOf<Long>()
+    sizes.forEachKey { keys += it }
+
+    assertThat(keys).containsOnly(child)
   }
 
   @Test fun `single root has self size as retained size`() {
     val tree = DominatorTree()
     val root = newObjectId().apply { tree.updateDominatedAsRoot(this) }
 
-    val sizes = tree.computeRetainedSizes(setOf(root), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(root), `10 bytes per object`)
 
-    assertThat(sizes[root]).isEqualTo(10 to 1)
+    assertThat(sizes[root]).isEqualTo(10 packedWith 1)
   }
 
   @Test fun `size of dominator includes dominated`() {
@@ -58,9 +62,9 @@
     val root = newObjectId().apply { tree.updateDominatedAsRoot(this) }
     tree.updateDominated(newObjectId(), root)
 
-    val sizes = tree.computeRetainedSizes(setOf(root), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(root), `10 bytes per object`)
 
-    assertThat(sizes[root]).isEqualTo(20 to 2)
+    assertThat(sizes[root]).isEqualTo(20 packedWith 2)
   }
 
   @Test fun `size of chain of dominators is additive`() {
@@ -69,10 +73,10 @@
     val child = newObjectId().apply { tree.updateDominated(this, root) }
     tree.updateDominated(newObjectId(), child)
 
-    val sizes = tree.computeRetainedSizes(setOf(root, child), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(root, child), `10 bytes per object`)
 
-    assertThat(sizes[root]).isEqualTo(30 to 3)
-    assertThat(sizes[child]).isEqualTo(20 to 2)
+    assertThat(sizes[root]).isEqualTo(30 packedWith 3)
+    assertThat(sizes[child]).isEqualTo(20 packedWith 2)
   }
 
   @Test fun `diamond dominators don't dominate`() {
@@ -84,11 +88,11 @@
     tree.updateDominated(grandChild, child1)
     tree.updateDominated(grandChild, child2)
 
-    val sizes = tree.computeRetainedSizes(setOf(root, child1, child2), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(root, child1, child2), `10 bytes per object`)
 
-    assertThat(sizes[child1]).isEqualTo(10 to 1)
-    assertThat(sizes[child2]).isEqualTo(10 to 1)
-    assertThat(sizes[root]).isEqualTo(40 to 4)
+    assertThat(sizes[child1]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[child2]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[root]).isEqualTo(40 packedWith 4)
   }
 
   @Test fun `two dominators dominated by common ancestor`() {
@@ -100,11 +104,11 @@
     tree.updateDominated(grandChild, child1)
     tree.updateDominated(grandChild, child2)
 
-    val sizes = tree.computeRetainedSizes(setOf(root, child1, child2), `10 bytes per object`)
+    val sizes = tree.computeRetainedSizes(mutableLongSetOf(root, child1, child2), `10 bytes per object`)
 
-    assertThat(sizes[child1]).isEqualTo(10 to 1)
-    assertThat(sizes[child2]).isEqualTo(10 to 1)
-    assertThat(sizes[root]).isEqualTo(40 to 4)
+    assertThat(sizes[child1]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[child2]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[root]).isEqualTo(40 packedWith 4)
   }
 
   @Test fun `two dominators dominated by lowest common ancestor`() {
@@ -118,12 +122,12 @@
     tree.updateDominated(grandGrandChild, grandChild2)
 
     val sizes =
-      tree.computeRetainedSizes(setOf(root, child, grandChild1, grandChild2), `10 bytes per object`)
+      tree.computeRetainedSizes(mutableLongSetOf(root, child, grandChild1, grandChild2), `10 bytes per object`)
 
-    assertThat(sizes[grandChild1]).isEqualTo(10 to 1)
-    assertThat(sizes[grandChild1]).isEqualTo(10 to 1)
-    assertThat(sizes[child]).isEqualTo(40 to 4)
-    assertThat(sizes[root]).isEqualTo(50 to 5)
+    assertThat(sizes[grandChild1]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[grandChild2]).isEqualTo(10 packedWith 1)
+    assertThat(sizes[child]).isEqualTo(40 packedWith 4)
+    assertThat(sizes[root]).isEqualTo(50 packedWith 5)
   }
 
   @Test fun `two separate trees do not share size`() {
@@ -138,10 +142,10 @@
     }
 
     val sizes =
-      tree.computeRetainedSizes(setOf(root1, root2), `10 bytes per object`)
+      tree.computeRetainedSizes(mutableLongSetOf(root1, root2), `10 bytes per object`)
 
-    assertThat(sizes[root1]).isEqualTo(110 to 11)
-    assertThat(sizes[root2]).isEqualTo(110 to 11)
+    assertThat(sizes[root1]).isEqualTo(110 packedWith 11)
+    assertThat(sizes[root2]).isEqualTo(110 packedWith 11)
   }
 
   @Test fun `no common descendant does not include size`() {
@@ -155,10 +159,10 @@
     tree.updateDominated(descendant, root2)
 
     val sizes =
-      tree.computeRetainedSizes(setOf(root1, root2), `10 bytes per object`)
+      tree.computeRetainedSizes(mutableLongSetOf(root1, root2), `10 bytes per object`)
 
-    assertThat(sizes[root1]).isEqualTo(100 to 10)
-    assertThat(sizes[root2]).isEqualTo(10 to 1)
+    assertThat(sizes[root1]).isEqualTo(100 packedWith 10)
+    assertThat(sizes[root2]).isEqualTo(10 packedWith 1)
   }
 
   @Test fun `only compute retained size for retained objects`() {
@@ -171,7 +175,7 @@
     tree.updateDominated(grandGrandChild, root2)
 
     val objectsWithComputedSize = mutableSetOf<Long>()
-    tree.computeRetainedSizes(setOf(child)) { objectId ->
+    tree.computeRetainedSizes(mutableLongSetOf(child)) { objectId ->
       objectsWithComputedSize += objectId
       1
     }