Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
785b68d
Add LinkEvalDominatorTree: exact Lengauer-Tarjan dominator tree
pyricau Mar 4, 2026
710e156
Replace dominator-tree retained size with two-phase BFS algorithm
pyricau Mar 4, 2026
2158bab
Fix unused import in PrioritizingShortestPathFinder
pyricau Mar 4, 2026
73c003d
Fix deprecation message, class KDoc, and misleading comment
pyricau Mar 5, 2026
4c6e7d7
Refactor retained size computation: move to PrioritizingShortestPathF…
pyricau Mar 5, 2026
ae75bf3
Implement review feedback: exact retained size computation in Priorit…
pyricau Mar 5, 2026
e18f29e
Move ObjectSizeCalculator to its own file
pyricau Mar 5, 2026
aa8d8d9
Fold Phase 2 into findShortestPathsFromGcRoots; move objectSizeCalcul…
pyricau Mar 5, 2026
019270b
Remove unused LongLongMap import
pyricau Mar 5, 2026
2b212c1
Fix retained size API: nullable retainedSizes, ObjectSizeCalculator.F…
pyricau Mar 5, 2026
e952547
Fix detekt: add braces to multiline if-else in PrioritizingShortestPa…
pyricau Mar 5, 2026
dc1bcbb
Restore comments removed by previous refactor
pyricau Mar 5, 2026
dad448b
Remove unnecessary analyzeImpl indirection in HeapAnalyzer
pyricau Mar 5, 2026
9781388
Remove FINDING_DOMINATORS step from OnAnalysisProgressListener
pyricau Mar 5, 2026
eb701cd
Replace computeRetainedHeapSize+objectSizeCalculatorFactory with sing…
pyricau Mar 5, 2026
eb15956
Delete DominatorTree and ObjectDominators, replacing with LinkEvalDom…
pyricau Mar 5, 2026
78d34c4
Invert subLeakedObjectPaths to parent → list-of-subs
pyricau Mar 5, 2026
9e630e3
Merge Phase 1 and Phase 2 into a single BFS loop
pyricau Mar 5, 2026
b83c52f
Fix detekt MultiLineIfElse violation
pyricau Mar 5, 2026
5aa6871
Revert "Fix detekt MultiLineIfElse violation"
pyricau Mar 5, 2026
ba626e7
Revert "Merge Phase 1 and Phase 2 into a single BFS loop"
pyricau Mar 5, 2026
f60d5da
Fix installGitHooks for git worktrees
pyricau Mar 5, 2026
dcb830a
Fix broken import and missing objectSizeCalculatorFactory in tests
pyricau Mar 5, 2026
ba17cf6
Fix detekt violations in OpenJdkInstanceRefReadersTest
pyricau Mar 5, 2026
d4cd0ee
Fix two pre-existing bugs in HprofInMemoryIndex exposed by LinkEvalDo…
pyricau Mar 5, 2026
09c224a
Simplify retainedSizes to MutableMap<Long, Long> (byte size only)
pyricau Mar 5, 2026
7e9e0ff
Revert "Simplify retainedSizes to MutableMap<Long, Long> (byte size o…
pyricau Mar 5, 2026
7e74317
Remove redundant visitedSet.remove for leaked object ids after Phase 1
pyricau Mar 5, 2026
04e8c9e
Remove phase1SeedIds, use foundLeakingObjectIds directly
pyricau Mar 6, 2026
3800b78
Replace for-over-shortestPaths with while-over-unprocessedSeedIds in …
pyricau Mar 6, 2026
34b4995
Remove redundant visitedSet.add(seedId) in Phase 2
pyricau Mar 6, 2026
588e55c
Collapse outer seed loop and inner BFS into a single while loop
pyricau Mar 6, 2026
1be69b4
Rethrow on findObjectById failure in Phase 2, matching Phase 1 pattern
pyricau Mar 6, 2026
35b9b03
Resolve all Phase 2 TODOs in PrioritizingShortestPathFinder
pyricau Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions shark/shark-android/src/test/java/shark/HprofIOPerfTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class HprofIOPerfTest {
)
.isEqualTo(
listOf(
19713, 40.0, 1021868, 20981, 40.0, 1079140
19711, 40.0, 1021265, 20979, 40.0, 1078529
)
)
}
Expand All @@ -197,7 +197,7 @@ class HprofIOPerfTest {
)
.isEqualTo(
listOf(
17408, 40.0, 1954105, 17413, 40.0, 1954285
17407, 40.0, 1953885, 17412, 40.0, 1954065
)
)
}
Expand All @@ -215,7 +215,7 @@ class HprofIOPerfTest {
)
.isEqualTo(
listOf(
11787, 32.0, 554413, 11789, 32.0, 554477
11786, 32.0, 554362, 11788, 32.0, 554426
)
)
}
Expand Down Expand Up @@ -260,6 +260,11 @@ class HprofIOPerfTest {
referenceReaderFactory = AndroidReferenceReaderFactory(referenceMatchers),
gcRootProvider = MatchingGcRootProvider(referenceMatchers),
computeRetainedHeapSize = computeRetainedHeapSize,
objectSizeCalculatorFactory = if (computeRetainedHeapSize) {
{ heapGraph -> AndroidObjectSizeCalculator(heapGraph) }
} else {
null
},
),
objectInspectors = AndroidObjectInspectors.appDefaults,
listener = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ class HprofRetainedHeapPerfTest {
retainedPair.first - retainedBeforeAnalysis to retainedPair.second
}

assertThat(retained after PARSING_HEAP_DUMP).isEqualTo(5.01 MB +-5 % margin)
assertThat(retained after EXTRACTING_METADATA).isEqualTo(5.06 MB +-5 % margin)
assertThat(retained after FINDING_RETAINED_OBJECTS).isEqualTo(5.16 MB +-5 % margin)
assertThat(retained after FINDING_PATHS_TO_RETAINED_OBJECTS).isEqualTo(6.56 MB +-5 % margin)
assertThat(retained after FINDING_DOMINATORS).isEqualTo(6.56 MB +-5 % margin)
assertThat(retained after INSPECTING_OBJECTS).isEqualTo(6.57 MB +-5 % margin)
assertThat(retained after COMPUTING_NATIVE_RETAINED_SIZE).isEqualTo(6.57 MB +-5 % margin)
assertThat(retained after COMPUTING_RETAINED_SIZE).isEqualTo(5.49 MB +-5 % margin)
assertThat(retained after PARSING_HEAP_DUMP).isEqualTo(4.98 MB +-10 % margin)
assertThat(retained after EXTRACTING_METADATA).isEqualTo(5.20 MB +-10 % margin)
assertThat(retained after FINDING_RETAINED_OBJECTS).isEqualTo(5.25 MB +-10 % margin)
assertThat(retained after FINDING_PATHS_TO_RETAINED_OBJECTS).isEqualTo(6.00 MB +-10 % margin)
assertThat(retained after FINDING_DOMINATORS).isEqualTo(6.00 MB +-10 % margin)
assertThat(retained after INSPECTING_OBJECTS).isEqualTo(6.00 MB +-10 % margin)
assertThat(retained after COMPUTING_NATIVE_RETAINED_SIZE).isEqualTo(6.00 MB +-10 % margin)
assertThat(retained after COMPUTING_RETAINED_SIZE).isEqualTo(6.00 MB +-10 % margin)
}

private fun indexRecordsOf(hprofFile: File): HprofIndex {
Expand Down
2 changes: 1 addition & 1 deletion shark/shark-android/src/test/java/shark/LegacyHprofTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class LegacyHprofTest {
val analysis = analyzeHprof("gcroot_unknown_object.hprof")

assertThat(analysis.applicationLeaks).hasSize(2)
assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(5306218)
assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(5018520)
}

@Test fun androidMStripped() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ internal class HprofInMemoryIndex private constructor(
}

fun objectAtIndex(index: Int): LongObjectPair<IndexedObject> {
require(index > 0)
require(index >= 0)
if (index < classIndex.size) {
val objectId = classIndex.keyAt(index)
val array = classIndex.getAtIndex(index)
Expand Down
5 changes: 5 additions & 0 deletions shark/shark/api/shark.api
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,11 @@ public final class shark/LibraryLeakReferenceMatcher : shark/ReferenceMatcher {
public fun toString ()Ljava/lang/String;
}

public final class shark/LinkEvalDominatorTree {
public fun <init> (Lshark/HeapGraph;Lshark/ReferenceReader$Factory;Lshark/GcRootProvider;)V
public final fun compute (Lshark/DominatorTree$ObjectSizeCalculator;)Ljava/util/Map;
}

public final class shark/MatchingGcRootProvider : shark/GcRootProvider {
public fun <init> (Ljava/util/List;)V
public fun provideGcRoots (Lshark/HeapGraph;)Lkotlin/sequences/Sequence;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package shark

import shark.DominatorTree.ObjectSizeCalculator
import shark.internal.ShallowSizeCalculator

class AndroidObjectSizeCalculator(graph: HeapGraph) : ObjectSizeCalculator {
Expand Down
45 changes: 40 additions & 5 deletions shark/shark/src/main/java/shark/DominatorTree.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,28 @@ import shark.internal.packedWith
import shark.internal.unpackAsFirstInt
import shark.internal.unpackAsSecondInt

class DominatorTree(expectedElements: Int = 4) {

fun interface ObjectSizeCalculator {
fun computeSize(objectId: Long): Int
}
/**
* Builds a dominator tree incrementally during a BFS traversal of the heap graph, using a
* Lowest Common Ancestor (LCA) approach to update immediate dominators as new parents are
* discovered.
*
* **Known limitation**: this is an approximation, not an exact dominator algorithm. It can
* produce incorrect immediate dominators when a cross-edge (to an already-visited node) is
* processed using a stale parent dominator, and that parent's dominator is later raised by
* another cross-edge after the first cross-edge's children have already been settled. The child's
* dominator is then left too specific (too far from root), so its retained size is
* under-attributed — some objects that it truly retains exclusively are instead attributed to a
* higher ancestor. See [updateDominated] for a concrete example and [DominatorTreeTest] for a
* test documenting this behavior.
*
* For an exact dominator tree, use [LinkEvalDominatorTree] instead.
*
* @deprecated For an exact dominator tree, use [LinkEvalDominatorTree] instead.
*/
@Deprecated("Use LinkEvalDominatorTree for an exact dominator tree.")
class DominatorTree(
expectedElements: Int = 4
) {

/**
* Map of objects to their dominator.
Expand Down Expand Up @@ -56,6 +73,24 @@ class DominatorTree(expectedElements: Int = 4) {
* first search, so when objectId already has a known dominator then its dominator path is
* shorter than the dominator path of [parentObjectId].
*
* **Known limitation**: the BFS assumption breaks down when a cross-edge (to an already-visited
* node) is processed with a stale parent dominator. Consider:
* ```
* root → A → B → C (tree edges; B discovered first via A)
* root → A → C (C also directly reachable from A)
* root → D → E → B (E→B is a cross-edge at same BFS level as B)
* ```
* BFS dequeues B before E (both at level 2). When B is dequeued, B→C is processed as a
* cross-edge with dom(B) = A (stale — E→B hasn't been processed yet). LCA(dom(C)=A, B) with
* dom(B)=A returns A, so dom(C) stays A. Later, when E is dequeued, E→B is processed and
* correctly raises dom(B) to root (root→D→E→B bypasses A). But dom(C) is never revisited:
* it remains A, even though the path root→D→E→B→C bypasses A, making root the true idom(C).
*
* The consequence is that dom(C) is left too specific (too far from root), so C's retained
* size is incorrectly attributed to A instead of root. A's retained size is over-reported.
*
* For an exact dominator tree that avoids this issue entirely, use [LinkEvalDominatorTree].
*
* @return true if [objectId] already had a known dominator, false otherwise.
*/
fun updateDominated(
Expand Down
45 changes: 37 additions & 8 deletions shark/shark/src/main/java/shark/HeapAnalyzer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ import shark.OnAnalysisProgressListener.Step.BUILDING_LEAK_TRACES
import shark.OnAnalysisProgressListener.Step.COMPUTING_NATIVE_RETAINED_SIZE
import shark.OnAnalysisProgressListener.Step.COMPUTING_RETAINED_SIZE
import shark.OnAnalysisProgressListener.Step.EXTRACTING_METADATA
import shark.OnAnalysisProgressListener.Step.FINDING_DOMINATORS
import shark.OnAnalysisProgressListener.Step.FINDING_PATHS_TO_RETAINED_OBJECTS
import shark.OnAnalysisProgressListener.Step.FINDING_RETAINED_OBJECTS
import shark.OnAnalysisProgressListener.Step.INSPECTING_OBJECTS
import shark.OnAnalysisProgressListener.Step.PARSING_HEAP_DUMP
import shark.PrioritizingShortestPathFinder.Event.StartedFindingDominators
import shark.PrioritizingShortestPathFinder.Event.StartedFindingPathsToRetainedObjects
import shark.RealLeakTracerFactory.Event.StartedBuildingLeakTraces
import shark.RealLeakTracerFactory.Event.StartedComputingJavaHeapRetainedSize
Expand Down Expand Up @@ -69,7 +67,6 @@ class HeapAnalyzer constructor(
graph,
leakingObjectFinder,
referenceMatchers,
computeRetainedHeapSize,
objectInspectors,
metadataExtractor
).let { result ->
Expand Down Expand Up @@ -107,15 +104,31 @@ class HeapAnalyzer constructor(
// Ideally the result contains only what this can return. No file, etc.
// File: used to create the graph + in result
// leakingObjectFinder: Helper object, shared
// computeRetainedHeapSize: boolean param for single analysis
// referenceMatchers: param?. though honestly not great.
// objectInspectors: Helper object.
// metadataExtractor: helper object, not needed for leak finding
// referenceReader: can't be helper object, needs graph => param something that can produce it from
// graph (and in the impl we give that thing the referenceMatchers)
/**
* Searches the heap dump for leaking instances and then computes the shortest strong reference
* path from those instances to the GC roots.
* path from those instances to the GC roots. Retained size is always computed.
*/
fun analyze(
heapDumpFile: File,
graph: HeapGraph,
leakingObjectFinder: LeakingObjectFinder,
referenceMatchers: List<ReferenceMatcher> = emptyList(),
objectInspectors: List<ObjectInspector> = emptyList(),
metadataExtractor: MetadataExtractor = MetadataExtractor.NO_OP,
): HeapAnalysis = analyzeImpl(
heapDumpFile, graph, leakingObjectFinder, referenceMatchers, objectInspectors,
metadataExtractor, computeRetainedHeapSize = true
)

/**
* Overload of [analyze] that accepts a [computeRetainedHeapSize] parameter.
* When [computeRetainedHeapSize] is false, retained heap sizes will not be computed
* and the analysis will be faster.
*/
fun analyze(
heapDumpFile: File,
Expand All @@ -125,15 +138,27 @@ class HeapAnalyzer constructor(
computeRetainedHeapSize: Boolean = false,
objectInspectors: List<ObjectInspector> = emptyList(),
metadataExtractor: MetadataExtractor = MetadataExtractor.NO_OP,
): HeapAnalysis = analyzeImpl(
heapDumpFile, graph, leakingObjectFinder, referenceMatchers, objectInspectors,
metadataExtractor, computeRetainedHeapSize
)

private fun analyzeImpl(
heapDumpFile: File,
graph: HeapGraph,
leakingObjectFinder: LeakingObjectFinder,
referenceMatchers: List<ReferenceMatcher>,
objectInspectors: List<ObjectInspector>,
metadataExtractor: MetadataExtractor,
computeRetainedHeapSize: Boolean,
): HeapAnalysis {
val analysisStartNanoTime = System.nanoTime()

return try {
val leakTracer = RealLeakTracerFactory(
shortestPathFinderFactory = PrioritizingShortestPathFinder.Factory(
listener = { event ->
listener = { event ->
when (event) {
StartedFindingDominators -> listener.onAnalysisProgress(FINDING_DOMINATORS)
StartedFindingPathsToRetainedObjects -> listener.onAnalysisProgress(
FINDING_PATHS_TO_RETAINED_OBJECTS
)
Expand All @@ -142,6 +167,11 @@ class HeapAnalyzer constructor(
referenceReaderFactory = AndroidReferenceReaderFactory(referenceMatchers),
gcRootProvider = MatchingGcRootProvider(referenceMatchers),
computeRetainedHeapSize = computeRetainedHeapSize,
objectSizeCalculatorFactory = if (computeRetainedHeapSize) {
{ heapGraph -> AndroidObjectSizeCalculator(heapGraph) }
} else {
null
},
),
objectInspectors
) { event ->
Expand Down Expand Up @@ -199,7 +229,6 @@ class HeapAnalyzer constructor(
}
}


private fun since(analysisStartNanoTime: Long): Long {
return NANOSECONDS.toMillis(System.nanoTime() - analysisStartNanoTime)
}
Expand Down
4 changes: 2 additions & 2 deletions shark/shark/src/main/java/shark/LeakTraceObject.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ data class LeakTraceObject(
val classSimpleName: String get() = className.lastSegment('.')

val typeName
get() = type.name.toLowerCase(Locale.US)
get() = type.name.lowercase(Locale.US)

override fun toString(): String {
val firstLinePrefix = ""
Expand Down Expand Up @@ -113,4 +113,4 @@ data class LeakTraceObject(
return String.format("%.1f %sB", bytes / unit.toDouble().pow(exp.toDouble()), pre)
}
}
}
}
Loading
Loading