Mark duplicate bitmaps in Heap dump Task
- Add filter to Heap dump task in profilers to mark duplicate bitmaps in
memory
- Uses a hashmap of BitmapInfos (height, weight, buffer) to find the
duplicate bitmap instances
Bug: 424384806
Test: Added BitmapDuplicationAnalyzerTest.kt and
BitmapDuplicationInstanceFilterTest.kt
Change-Id: I181f83a6f784cee2126d9ca755a0cc415d35880b
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt b/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
index 94f12b0..6d04d97 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
+++ b/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
@@ -53,7 +53,7 @@
selectionRange: Range,
ideComponents: IdeProfilerComponents,
timeline: StreamingTimeline,
- isFullScreenHeapDumpUi: Boolean): AspectObserver() {
+ isFullScreenHeapDumpUi: Boolean) : AspectObserver() {
val heapView = MemoryHeapView(selection)
val captureView = MemoryCaptureView(selection, ideComponents) // TODO: remove after full migration. Only needed for legacy tests
val classGrouping = MemoryClassGrouping(selection)
@@ -195,8 +195,10 @@
private fun buildSummaryPanel() = JPanel(FlowLayout(FlowLayout.LEFT)).apply {
fun mkLabel(desc: String, action: Runnable? = null) =
StatLabel(0L, desc, numFont = ProfilerFonts.H2_FONT, descFont = AdtUiUtils.DEFAULT_FONT.biggerOn(1f), action = action)
+
val totalClassLabel = mkLabel("Classes")
val totalLeakLabel = mkLabel("Leaks", action = Runnable(::showLeaks))
+ val totalBitmapDuplicatesLabel = mkLabel("Duplicates", action = Runnable(::showBitmapDuplicates))
val totalCountLabel = mkLabel("Count")
val totalNativeSizeLabel = mkLabel("Native Size")
val totalShallowSizeLabel = mkLabel("Shallow Size")
@@ -232,6 +234,15 @@
icon = if (leakCount > 0) StudioIcons.Common.WARNING else null
}
}
+ when (val filter = capture.bitmapDuplicationFilter) {
+ null -> totalBitmapDuplicatesLabel.isVisible = false
+ else -> totalBitmapDuplicatesLabel.apply {
+ val duplicateCount = heap.getInstanceFilterMatchCount(filter).toLong()
+ isVisible = true
+ numValue = duplicateCount
+ icon = if (duplicateCount > 0) StudioIcons.Common.WARNING else null
+ }
+ }
}
}
}
@@ -242,6 +253,7 @@
add(totalClassLabel)
add(totalLeakLabel)
+ add(totalBitmapDuplicatesLabel)
add(FlatSeparator(6, 36))
add(totalCountLabel)
add(totalNativeSizeLabel)
@@ -255,4 +267,10 @@
instanceFilterMenu.component.selectedItem = it
}
}
+
+ private fun showBitmapDuplicates() {
+ (selection.selectedCapture as? HeapDumpCaptureObject)?.getBitmapDuplicationFilter()?.let {
+ instanceFilterMenu.component.selectedItem = it
+ }
+ }
}
\ No newline at end of file
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
index 4675d0b..11f73e4 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
+++ b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
@@ -494,11 +494,11 @@
private ColoredTreeCellRenderer makeNameColumnRenderer() {
return new ValueColumnRenderer() {
- private boolean myIsLeaked = false;
+ private boolean myIsWarning = false;
@Override
protected void paintComponent(Graphics g) {
- if (myIsLeaked) {
+ if (myIsWarning) {
int width = getWidth();
int height = getHeight();
Icon i = mySelected && isFocused() && !NewUI.isEnabled()
@@ -522,15 +522,47 @@
int row,
boolean hasFocus) {
super.customizeCellRenderer(tree, value, selected, expanded, leaf, row, hasFocus);
- if (value instanceof MemoryObjectTreeNode &&
- ((MemoryObjectTreeNode)value).getAdapter() instanceof InstanceObject) {
- CaptureObjectInstanceFilter leakFilter = myCaptureObject.getActivityFragmentLeakFilter();
- myIsLeaked = leakFilter != null &&
- leakFilter.getInstanceTest().invoke((InstanceObject)((MemoryObjectTreeNode)value).getAdapter());
- String msg = "To investigate leak, select instance and see \"References\"";
- setToolTipText(myIsLeaked ? msg : null);
+ if (value instanceof MemoryObjectTreeNode<?> node && node.getAdapter() instanceof InstanceObject instance) {
+ boolean isLeaked = isInstanceLeaked(instance);
+ boolean isDuplicateBitmap = isInstanceDuplicateBitmap(instance);
+
+ myIsWarning = isLeaked || isDuplicateBitmap;
+
+ String tooltipMessage = getProblemTooltipMessage(isLeaked, isDuplicateBitmap);
+ setToolTipText(tooltipMessage);
}
}
+
+ /**
+ * Checks if the given instance object is considered a leak.
+ */
+ private boolean isInstanceLeaked(@NotNull InstanceObject instance) {
+ CaptureObjectInstanceFilter leakFilter = myCaptureObject != null ? myCaptureObject.getActivityFragmentLeakFilter() : null;
+ return leakFilter != null && leakFilter.getInstanceTest().invoke(instance);
+ }
+
+ /**
+ * Checks if the given instance object is considered a duplicate bitmap.
+ */
+ private boolean isInstanceDuplicateBitmap(@NotNull InstanceObject instance) {
+ CaptureObjectInstanceFilter duplicateBitmapFilter = myCaptureObject != null ? myCaptureObject.getBitmapDuplicationFilter() : null;
+ return duplicateBitmapFilter != null && duplicateBitmapFilter.getInstanceTest().invoke(instance);
+ }
+
+ /**
+ * Returns the appropriate tooltip message based on the problem states,
+ * prioritizing duplicate bitmap message over leak message.
+ */
+ @Nullable
+ private String getProblemTooltipMessage(boolean isLeaked, boolean isDuplicateBitmap) {
+ if (isDuplicateBitmap) {
+ return "To investigate duplicate bitmap, select instance and see \"References\"";
+ }
+ else if (isLeaked) {
+ return "To investigate leak, select instance and see \"References\"";
+ }
+ return null;
+ }
};
}
}
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
index 7bdd375..d936834 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
+++ b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
@@ -67,10 +67,13 @@
import java.awt.RenderingHints;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
@@ -666,8 +669,8 @@
// When `targetSet` is empty, if `rootNode` isn't empty, many of its leaves (if any) trivially count as smallest super-set nodes.
// Because the result isn't interesting, we arbitrarily return `rootNode` itself for this special case to save some work.
return targetSet.isEmpty() ? rootNode
- : rootNode.getAdapter().isSupersetOf(target) ? findSmallestSuperSetNode(rootNode, target)
- : null;
+ : rootNode.getAdapter().isSupersetOf(target) ? findSmallestSuperSetNode(rootNode, target)
+ : null;
}
@NotNull
@@ -754,15 +757,30 @@
@VisibleForTesting
ColoredTreeCellRenderer getNameColumnRenderer() {
return new ColoredTreeCellRenderer() {
- private long myLeakCount = 0;
+ // A helper class to define a "problem" to check for.
+ private static class ProblemDescriptor {
+ final CaptureObjectInstanceFilter filter;
+ final String singularName;
+ final String pluralName;
+
+ ProblemDescriptor(CaptureObjectInstanceFilter filter, String singularName, String pluralName) {
+ this.filter = filter;
+ this.singularName = singularName;
+ this.pluralName = pluralName;
+ }
+ }
+
+ // Use a LinkedHashMap to store counts for different problems, preserving insertion order.
+ private final Map<ProblemDescriptor, Long> myProblemCounts = new LinkedHashMap<>();
+ private long myTotalProblemCount = 0;
@Override
protected void paintComponent(Graphics g) {
- if (myLeakCount > 0) {
+ if (myTotalProblemCount > 0) {
int width = getWidth();
int height = getHeight();
- String text = String.valueOf(myLeakCount);
+ String text = String.valueOf(myTotalProblemCount);
((Graphics2D)g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
int textWidth = g.getFontMetrics().stringWidth(text);
@@ -815,7 +833,9 @@
}
else if (node.getAdapter() instanceof PackageSet) {
ClassifierSet set = (ClassifierSet)node.getAdapter();
- setIconColorized(set.hasStackInfo() ? StudioIcons.Profiler.Overlays.PACKAGE_STACK : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
+ setIconColorized(set.hasStackInfo()
+ ? StudioIcons.Profiler.Overlays.PACKAGE_STACK
+ : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
String name = set.getName();
append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
}
@@ -841,7 +861,9 @@
}
else if (node.getAdapter() instanceof HeapSet) {
ClassifierSet set = (ClassifierSet)node.getAdapter();
- setIconColorized(set.hasStackInfo() ? StudioIcons.Profiler.Overlays.PACKAGE_STACK : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
+ setIconColorized(set.hasStackInfo()
+ ? StudioIcons.Profiler.Overlays.PACKAGE_STACK
+ : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
String name = set.getName() + " heap";
append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
}
@@ -858,17 +880,63 @@
append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
}
- if (node.getAdapter() instanceof ClassifierSet) {
- CaptureObjectInstanceFilter leakFilter = myCaptureObject != null ? myCaptureObject.getActivityFragmentLeakFilter() : null;
- myLeakCount = leakFilter != null ?
- ((ClassifierSet)node.getAdapter()).getInstanceFilterMatchCount(leakFilter) :
- 0;
- setToolTipText(myLeakCount > 1 ? "There are " + myLeakCount + " leaks" :
- myLeakCount > 0 ? "There is 1 leak" :
- null);
+ if (node.getAdapter() instanceof ClassifierSet classifierSet && myCaptureObject != null) {
+ myProblemCounts.clear();
+ // Build a list of problems to check for
+ List<ProblemDescriptor> problemsToCheck = new ArrayList<>();
+ CaptureObjectInstanceFilter leakFilter = myCaptureObject.getActivityFragmentLeakFilter();
+ if (leakFilter != null) {
+ problemsToCheck.add(new ProblemDescriptor(leakFilter, "leak", "leaks"));
+ }
+
+ CaptureObjectInstanceFilter bitmapFilter = myCaptureObject.getBitmapDuplicationFilter();
+ if (bitmapFilter != null) {
+ problemsToCheck.add(new ProblemDescriptor(bitmapFilter, "duplicate bitmap", "duplicate bitmaps"));
+ }
+
+ // Check for each problem and update counts
+ for (ProblemDescriptor problem : problemsToCheck) {
+ long count = classifierSet.getInstanceFilterMatchCount(problem.filter);
+ if (count > 0) {
+ myProblemCounts.put(problem, count);
+ }
+ }
+
+ myTotalProblemCount = myProblemCounts.values().stream().mapToLong(Long::longValue).sum();
+ setToolTipText(generateProblemTooltip());
}
setTextAlign(SwingConstants.LEFT);
}
+
+ private String generateProblemTooltip() {
+ if (myProblemCounts.isEmpty()) {
+ return null;
+ }
+
+ List<String> problemDescriptions = myProblemCounts.entrySet().stream()
+ .map(entry -> {
+ long count = entry.getValue();
+ ProblemDescriptor problem = entry.getKey();
+ String name = (count == 1) ? problem.singularName : problem.pluralName;
+ return String.format(Locale.US, "%,d %s", count, name);
+ })
+ .collect(Collectors.toList());
+
+ String summary;
+ int size = problemDescriptions.size();
+ if (size == 1) {
+ summary = problemDescriptions.get(0);
+ }
+ else if (size == 2) {
+ summary = problemDescriptions.get(0) + " and " + problemDescriptions.get(1);
+ }
+ else {
+ String lastProblem = "and " + problemDescriptions.get(size - 1);
+ summary = String.join(", ", problemDescriptions.subList(0, size - 1)) + ", " + lastProblem;
+ }
+
+ return (myTotalProblemCount > 1 ? "There are " : "There is ") + summary;
+ }
};
}
diff --git a/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt b/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt
new file mode 100644
index 0000000..3838ba0
--- /dev/null
+++ b/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory
+
+import com.android.tools.profilers.memory.adapters.InstanceObject
+import com.android.tools.profilers.memory.adapters.ValueObject
+import com.intellij.openapi.diagnostic.Logger
+import java.util.Locale
+
+/**
+ * Analyzes a heap dump to find duplicate bitmap instances based on their pixel data.
+ *
+ * This analyzer works by finding the shared android.graphics.Bitmap$DumpData`. It
+ * builds a map of native pointers to pixel buffers and then hashes the dimensions
+ * and buffer content of each bitmap to identify duplicates.
+ */
+class BitmapDuplicationAnalyzer {
+
+ private val duplicateBitmapInstances = mutableSetOf<InstanceObject>()
+
+ companion object {
+ private val LOG = Logger.getInstance(BitmapDuplicationAnalyzer::class.java)
+ const val BITMAP_CLASS_NAME = "android.graphics.Bitmap"
+ const val BITMAP_DUMP_DATA_CLASS_NAME = "android.graphics.Bitmap\$DumpData"
+ }
+
+ /**
+ * A data class to hold identifying information for a bitmap to check for equality.
+ */
+ private data class BitmapInfo(
+ val height: Int,
+ val width: Int,
+ val bufferHash: Int
+ ) : Comparable<BitmapInfo> {
+ /**
+ * Orders bitmaps by their size (dimension HxW) and then buffer hash,
+ * in descending order so larger bitmaps are considered more significant.
+ */
+ override fun compareTo(other: BitmapInfo): Int {
+ val areaCompare = (other.width * other.height).compareTo(this.width * this.height)
+ if (areaCompare != 0) return areaCompare
+
+ return other.bufferHash.compareTo(this.bufferHash)
+ }
+ }
+
+ /**
+ * Analyzes a collection of instances to find duplicate Bitmaps based on content.
+ * This should be called once after all instances are loaded.
+ */
+ fun analyze(instances: Iterable<InstanceObject>) {
+ duplicateBitmapInstances.clear()
+
+ // 1. Find the shared native pointer to buffer map from the heap dump.
+ val ptrToBufferMap = buildPtrToBufferMap(instances)
+ if (ptrToBufferMap.isEmpty()) {
+ LOG.debug("Could not build pointer-to-buffer map. Skipping duplicate bitmap analysis.")
+ return
+ }
+
+ // 2. Group all bitmap instances by their BitmapInfo (dimensions + content hash).
+ val bitmapsByInfo = mutableMapOf<BitmapInfo, MutableList<InstanceObject>>()
+ for (instance in instances) {
+ if (instance.classEntry.className == BITMAP_CLASS_NAME && instance.depth != Integer.MAX_VALUE) {
+ getBitmapInfo(instance, ptrToBufferMap)?.let { info ->
+ bitmapsByInfo.computeIfAbsent(info) { mutableListOf() }.add(instance)
+ }
+ }
+ }
+
+ // 3. Any group with more than one instance contains duplicates.
+ bitmapsByInfo.values.forEach { instanceList ->
+ if (instanceList.size > 1) {
+ duplicateBitmapInstances.addAll(instanceList)
+ }
+ }
+ }
+
+ /**
+ * Returns the set of InstanceObjects identified as duplicates.
+ */
+ fun getDuplicateInstances(): Set<InstanceObject> = duplicateBitmapInstances
+
+ /**
+ * Creates a [BitmapInfo] object for a given bitmap instance if its dimensions and pixel buffer can be found.
+ */
+ private fun getBitmapInfo(instance: InstanceObject, ptrToBufferMap: Map<Long, ByteArray>): BitmapInfo? {
+ var width: Int? = null
+ var height: Int? = null
+ var nativePtr: Long? = null
+
+ for (field in instance.fields) {
+ when (field.fieldName) {
+ "mWidth" -> width = field.value as? Int
+ "mHeight" -> height = field.value as? Int
+ "mNativePtr" -> nativePtr = field.value as? Long
+ }
+ }
+
+ if (width == null || height == null || nativePtr == null) return null
+ val buffer = ptrToBufferMap[nativePtr] ?: return null
+
+ return BitmapInfo(height, width, buffer.contentHashCode())
+ }
+
+ /**
+ * Builds a map of native pointers to their corresponding byte buffers.
+ */
+ private fun buildPtrToBufferMap(instances: Iterable<InstanceObject>): Map<Long, ByteArray> {
+ val ptrToBufferMap = mutableMapOf<Long, ByteArray>()
+
+ val dumpDataInstance = instances.firstOrNull { it.classEntry.className == BITMAP_DUMP_DATA_CLASS_NAME && it.depth != Integer.MAX_VALUE }
+ ?: return emptyMap()
+ val buffersInstance = getNestedInstanceObject(dumpDataInstance, "buffers") ?: return emptyMap()
+ val nativesInstance = getNestedInstanceObject(dumpDataInstance, "natives") ?: return emptyMap()
+
+ val buffersFields = buffersInstance.fields
+ val nativesFields = nativesInstance.fields
+ if (buffersFields.size != nativesFields.size) {
+ LOG.warn(
+ String.format(
+ Locale.US, "Mismatch in size between 'buffers' (%d) and 'natives' (%d) fields. Cannot process.",
+ buffersFields.size, nativesFields.size
+ )
+ )
+ return emptyMap()
+ }
+
+ for (i in nativesFields.indices) {
+ val nativePtr = nativesFields.getOrNull(i)?.value as? Long ?: continue
+ val bufferInstance = buffersFields.getOrNull(i)?.getAsInstance() ?: continue
+ val byteArray = getByteArrayFromInstanceObject(bufferInstance) ?: continue
+ ptrToBufferMap[nativePtr] = byteArray
+ }
+ return ptrToBufferMap
+ }
+
+ private fun getByteArrayFromInstanceObject(instance: InstanceObject): ByteArray? {
+ val arrayObject = instance.arrayObject ?: run {
+ LOG.warn("Buffer instance does not contain an ArrayObject.")
+ return null
+ }
+
+ if (arrayObject.arrayElementType != ValueObject.ValueType.BYTE) {
+ LOG.warn("ArrayObject element type is not BYTE. Found: ${arrayObject.arrayElementType}")
+ return null
+ }
+
+ return arrayObject.asByteArray
+ }
+
+ private fun getNestedInstanceObject(parentInstance: InstanceObject, fieldName: String): InstanceObject? {
+ return parentInstance.fields.firstOrNull { field ->
+ fieldName == field.fieldName && field.value is InstanceObject
+ }?.getAsInstance()
+ }
+}
\ No newline at end of file
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java b/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
index 45bfb32..b5e9645 100644
--- a/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
@@ -28,6 +28,7 @@
import com.android.tools.profilers.memory.adapters.classifiers.ClassifierSet;
import com.android.tools.profilers.memory.adapters.classifiers.HeapSet;
import com.android.tools.profilers.memory.adapters.instancefilters.ActivityFragmentLeakInstanceFilter;
+import com.android.tools.profilers.memory.adapters.instancefilters.BitmapDuplicationInstanceFilter;
import com.android.tools.profilers.memory.adapters.instancefilters.CaptureObjectInstanceFilter;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
@@ -208,6 +209,15 @@
return null;
}
+ /**
+ * @return the filter of duplicated bitmaps if that is supported, or null otherwise
+ * An implementation of this method should only return a filter in `getSupportedInstanceFilters`
+ */
+ @Nullable
+ default BitmapDuplicationInstanceFilter getBitmapDuplicationFilter() {
+ return null;
+ }
+
@NotNull
default Set<CaptureObjectInstanceFilter> getSelectedInstanceFilters() {
return Collections.EMPTY_SET;
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt b/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
index dd2fdcc..6c4a305 100644
--- a/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
@@ -39,8 +39,10 @@
import com.android.tools.profilers.memory.adapters.CaptureObject.ClassifierAttribute.SHALLOW_SIZE
import com.android.tools.profilers.memory.adapters.CaptureObject.InstanceAttribute
import com.android.tools.profilers.memory.adapters.classifiers.AllHeapSet
+import com.android.tools.profilers.memory.BitmapDuplicationAnalyzer
import com.android.tools.profilers.memory.adapters.classifiers.HeapSet
import com.android.tools.profilers.memory.adapters.instancefilters.ActivityFragmentLeakInstanceFilter
+import com.android.tools.profilers.memory.adapters.instancefilters.BitmapDuplicationInstanceFilter
import com.android.tools.profilers.memory.adapters.instancefilters.CaptureObjectInstanceFilter
import com.android.tools.profilers.memory.adapters.instancefilters.ProjectClassesInstanceFilter
import com.android.tools.proguard.ProguardMap
@@ -65,6 +67,7 @@
private val featureTracker: FeatureTracker,
private val ideProfilerServices: IdeProfilerServices) : CaptureObject {
private val _heapSets: MutableMap<Int, HeapSet> = HashMap()
+
// A load factor of 0.5 is used for performance reasons due to the interaction of two hash tables. See b/372321482 for details.
private val instanceIndex = Long2ObjectOpenHashMap<InstanceObject>(16, Hash.FAST_LOAD_FACTOR)
@@ -79,8 +82,10 @@
var hasNativeAllocations = false
private set
private val activityFragmentLeakFilter = ActivityFragmentLeakInstanceFilter(classDb)
- private val supportedInstanceFilters: Set<CaptureObjectInstanceFilter> = setOf(activityFragmentLeakFilter,
- ProjectClassesInstanceFilter(ideProfilerServices))
+ private val bitmapDuplicationAnalyzer = BitmapDuplicationAnalyzer()
+ private lateinit var bitmapDuplicationFilter: BitmapDuplicationInstanceFilter
+ private lateinit var supportedInstanceFilters: Set<CaptureObjectInstanceFilter> // To be initialized after bitmap filter
+
private val currentInstanceFilters = mutableSetOf<CaptureObjectInstanceFilter>()
private val executorService = MoreExecutors.listeningDecorator(
Executors.newSingleThreadExecutor(ThreadFactoryBuilder().setNameFormat("memory-heapdump-instancefilters").build())
@@ -100,6 +105,7 @@
override fun getHeapSet(heapId: Int) = _heapSets.getOrDefault(heapId, null)
override fun getInstances(): Stream<InstanceObject> =
if (hasLoaded) heapSets.find { it is AllHeapSet }!!.instancesStream else Stream.empty()
+
override fun getStartTimeNs() = heapDumpInfo.startTime
override fun getEndTimeNs() = heapDumpInfo.endTime
override fun getClassDatabase() = classDb
@@ -142,15 +148,20 @@
heapSetMappings.forEach { (heap, heapSet) ->
heap.classes.forEach { addInstanceToRightHeap(heapSet, it.id, createClassObjectInstance(javaLangClassObject, it)) }
heap.forEachInstance { instance ->
- assert(ClassDb.JAVA_LANG_CLASS != instance.classObj!!.className)
- val classEntry = instance.classObj!!.makeEntry()
- addInstanceToRightHeap(heapSet, instance.id, HeapDumpInstanceObject(this@HeapDumpCaptureObject, instance, classEntry, null))
- true
+ assert(ClassDb.JAVA_LANG_CLASS != instance.classObj!!.className)
+ val classEntry = instance.classObj!!.makeEntry()
+ addInstanceToRightHeap(heapSet, instance.id, HeapDumpInstanceObject(this@HeapDumpCaptureObject, instance, classEntry, null))
+ true
}
if ("default" != heap.name || snapshot.heaps.size == 1 || heap.instancesCount > 0) {
_heapSets.put(heap.id, heapSet)
}
}
+ // Run analysis after all instances are loaded into instanceIndex
+ bitmapDuplicationAnalyzer.analyze(allInstances)
+ bitmapDuplicationFilter = BitmapDuplicationInstanceFilter(bitmapDuplicationAnalyzer.getDuplicateInstances())
+ // Initialize supportedInstanceFilters now that bitmapDuplicationFilter is ready
+ supportedInstanceFilters = setOf(activityFragmentLeakFilter, ProjectClassesInstanceFilter(ideProfilerServices), bitmapDuplicationFilter)
}
private fun addInstance(heapSet: HeapSet, id: Long, instObj: InstanceObject) {
@@ -171,9 +182,10 @@
override fun getInstanceAttributes() =
if (hasNativeAllocations) listOf(
- InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.NATIVE_SIZE, InstanceAttribute.SHALLOW_SIZE,
- InstanceAttribute.RETAINED_SIZE)
+ InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.NATIVE_SIZE, InstanceAttribute.SHALLOW_SIZE,
+ InstanceAttribute.RETAINED_SIZE)
else listOf(InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.SHALLOW_SIZE, InstanceAttribute.RETAINED_SIZE)
+
open fun findInstanceObject(instance: Instance) = if (hasLoaded) instanceIndex.get(instance.id) else null
fun createClassObjectInstance(javaLangClass: InstanceObject?, classObj: ClassObj): InstanceObject {
@@ -184,6 +196,7 @@
}
override fun getActivityFragmentLeakFilter() = activityFragmentLeakFilter
+ override fun getBitmapDuplicationFilter() = bitmapDuplicationFilter
override fun getSupportedInstanceFilters() = supportedInstanceFilters
override fun getSelectedInstanceFilters() = currentInstanceFilters
@@ -224,7 +237,7 @@
private fun refreshInstances(instances: Set<InstanceObject>, executor: Executor): Void? {
executor.execute {
_heapSets.values.forEach { it.clearClassifierSets() }
- when (val h = _heapSets.values.find {it is AllHeapSet}) {
+ when (val h = _heapSets.values.find { it is AllHeapSet }) {
null -> instances.forEach { _heapSets[it.heapId]!!.addDeltaInstanceObject(it) }
else -> instances.forEach { h.addDeltaInstanceObject(it) }
}
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt b/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt
new file mode 100644
index 0000000..15bb0b4
--- /dev/null
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory.adapters.instancefilters
+
+import com.android.tools.profilers.memory.adapters.InstanceObject
+
+/**
+ * A filter that shows only Bitmap instances that have been identified as duplicates
+ * based on their pixel data content.
+ *
+ * @param duplicateBitmapSet The set of all InstanceObjects that have been pre-calculated
+ * to be part of a duplication group by [BitmapDuplicationAnalyzer].
+ */
+class BitmapDuplicationInstanceFilter(
+ private val duplicateBitmapSet: Set<InstanceObject>
+) : CaptureObjectInstanceFilter(
+ "duplicate bitmaps",
+ "Show duplicate Bitmap instances",
+ "Shows all Bitmap instances that have the same dimensions and pixel content as at least one other Bitmap instance.",
+ null,
+ // The core filter logic: an instance passes the filter if it's in the pre-computed set of duplicates.
+ { instanceObject -> duplicateBitmapSet.contains(instanceObject) }
+)
\ No newline at end of file
diff --git a/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt b/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt
new file mode 100644
index 0000000..46d0ebe
--- /dev/null
+++ b/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory
+
+import com.android.tools.profilers.memory.adapters.FakeCaptureObject
+import com.android.tools.profilers.memory.adapters.FakeInstanceObject
+import com.android.tools.profilers.memory.adapters.ValueObject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class BitmapDuplicationAnalyzerTest {
+
+ private val analyzer = BitmapDuplicationAnalyzer()
+
+ companion object {
+ private const val BITMAP_CLASS_NAME = "android.graphics.Bitmap"
+ private const val DUMP_DATA_CLASS_NAME = "android.graphics.Bitmap\$DumpData"
+ private const val BYTE_ARRAY_CLASS_NAME = "byte[]"
+ private const val BYTE_ARRAY_ARRAY_CLASS_NAME = "byte[][]"
+ private const val LONG_ARRAY_CLASS_NAME = "long[]"
+ }
+
+ @Test
+ fun testAnalyze_identifiesDuplicateBitmaps() {
+ val fakeCapture = FakeCaptureObject.Builder().build()
+ val bufferContent = byteArrayOf(1, 2, 3, 4)
+ val otherBufferContent = byteArrayOf(9, 9, 9, 9)
+
+ val nativePtrs = listOf(100L, 200L, 300L)
+ val buffers = listOf(
+ bufferContent.copyOf(),
+ otherBufferContent.copyOf(),
+ bufferContent.copyOf()
+ )
+ val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+ val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+ val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+ val bmp3 = createBitmapInstance(fakeCapture, 3L, 10, 10, 300L)
+
+ // Add the top-level objects that should be on the heap. The test framework
+ // should make any referenced objects (like the internal arrays) reachable.
+ fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2, bmp3))
+ analyzer.analyze(fakeCapture.instances.toList())
+
+ val duplicates = analyzer.getDuplicateInstances()
+ assertThat(duplicates).containsExactly(bmp1, bmp3)
+ }
+
+ @Test
+ fun testAnalyze_withNoDuplicates() {
+ val fakeCapture = FakeCaptureObject.Builder().build()
+ val bufferContent1 = byteArrayOf(1, 2, 3, 4)
+ val bufferContent2 = byteArrayOf(5, 6, 7, 8)
+
+ // Three bitmaps, none of which are duplicates.
+ // bmp1 and bmp3 have the same content but different dimensions.
+ // bmp1 and bmp2 have different content and different dimensions.
+ val nativePtrs = listOf(100L, 200L, 300L)
+ val buffers = listOf(
+ bufferContent1.copyOf(),
+ bufferContent2.copyOf(),
+ bufferContent1.copyOf()
+ )
+ val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+ val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+ val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+ val bmp3 = createBitmapInstance(fakeCapture, 3L, 15, 15, 300L) // Same buffer as bmp1, different dimensions
+
+ fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2, bmp3))
+ analyzer.analyze(fakeCapture.instances.toList())
+
+ val duplicates = analyzer.getDuplicateInstances()
+ assertThat(duplicates).isEmpty()
+ }
+
+ @Test
+ fun testAnalyze_withMissingDumpData() {
+ val fakeCapture = FakeCaptureObject.Builder().build()
+
+ val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+ val bmp2 = createBitmapInstance(fakeCapture, 2L, 10, 10, 300L)
+
+ // No DumpData instance is added to the capture.
+ fakeCapture.addInstanceObjects(setOf(bmp1, bmp2))
+ analyzer.analyze(fakeCapture.instances.toList())
+
+ val duplicates = analyzer.getDuplicateInstances()
+ assertThat(duplicates).isEmpty()
+ }
+
+ @Test
+ fun testAnalyze_withMismatchedArrays() {
+ val fakeCapture = FakeCaptureObject.Builder().build()
+ val bufferContent = byteArrayOf(1, 2, 3, 4)
+
+ // Mismatch: 2 native pointers but only 1 buffer.
+ val nativePtrs = listOf(100L, 200L)
+ val buffers = listOf(bufferContent.copyOf())
+ val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+ val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+ val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+
+ fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2))
+ analyzer.analyze(fakeCapture.instances.toList())
+
+ val duplicates = analyzer.getDuplicateInstances()
+ assertThat(duplicates).isEmpty()
+ }
+
+ /** Creates a fake `android.graphics.Bitmap` instance for testing. */
+ private fun createBitmapInstance(
+ capture: FakeCaptureObject,
+ id: Long,
+ width: Int,
+ height: Int,
+ nativePtr: Long
+ ): FakeInstanceObject {
+ val instance = FakeInstanceObject.Builder(capture, id, BITMAP_CLASS_NAME)
+ .setFields(listOf("mWidth", "mHeight", "mNativePtr"))
+ .setDepth(1)
+ .build()
+ instance.setFieldValue("mWidth", ValueObject.ValueType.INT, width)
+ instance.setFieldValue("mHeight", ValueObject.ValueType.INT, height)
+ instance.setFieldValue("mNativePtr", ValueObject.ValueType.LONG, nativePtr)
+ return instance
+ }
+
+ /**
+ * Creates a fake `android.graphics.Bitmap$DumpData` instance which holds the
+ * mapping between native pointers and their corresponding pixel data buffers.
+ * This structure is what the analyzer inspects to find bitmap data.
+ */
+ private fun createDumpDataInstance(
+ capture: FakeCaptureObject,
+ nativePtrs: List<Long>,
+ buffers: List<ByteArray>
+ ): FakeInstanceObject {
+ val bufferInstances = buffers.mapIndexed { i, bytes ->
+ FakeInstanceObject.Builder(capture, 1000L + i, BYTE_ARRAY_CLASS_NAME)
+ .setValueType(ValueObject.ValueType.ARRAY)
+ .setArray(ValueObject.ValueType.BYTE, bytes, bytes.size)
+ .build()
+ }
+
+ val buffersArray = FakeInstanceObject.Builder(capture, 2000L, BYTE_ARRAY_ARRAY_CLASS_NAME)
+ .setFields(buffers.indices.map { it.toString() })
+ .build()
+ bufferInstances.forEachIndexed { i, instance ->
+ buffersArray.setFieldValue(i.toString(), ValueObject.ValueType.OBJECT, instance)
+ }
+
+ val nativesArray = FakeInstanceObject.Builder(capture, 3000L, LONG_ARRAY_CLASS_NAME)
+ .setFields(nativePtrs.indices.map { it.toString() })
+ .build()
+ nativePtrs.forEachIndexed { i, ptr ->
+ nativesArray.setFieldValue(i.toString(), ValueObject.ValueType.LONG, ptr)
+ }
+
+ val dumpDataInstance = FakeInstanceObject.Builder(capture, 4000L, DUMP_DATA_CLASS_NAME)
+ .setFields(listOf("buffers", "natives"))
+ .setDepth(1)
+ .build()
+ dumpDataInstance.setFieldValue("buffers", ValueObject.ValueType.OBJECT, buffersArray)
+ dumpDataInstance.setFieldValue("natives", ValueObject.ValueType.OBJECT, nativesArray)
+ return dumpDataInstance
+ }
+}
\ No newline at end of file
diff --git a/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java b/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java
new file mode 100644
index 0000000..4ad6191
--- /dev/null
+++ b/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory.adapters.instancefilters;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.android.tools.profilers.memory.adapters.FakeCaptureObject;
+import com.android.tools.profilers.memory.adapters.FakeInstanceObject;
+import com.android.tools.profilers.memory.adapters.InstanceObject;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import org.junit.Test;
+
+public class BitmapDuplicationInstanceFilterTest {
+
+ @Test
+ public void filterSelectsOnlyDuplicateBitmaps() {
+ FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+ // Instances that are considered duplicates
+ FakeInstanceObject duplicateBitmap1 =
+ new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+ FakeInstanceObject duplicateBitmap2 =
+ new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+ // An instance that is not a duplicate
+ FakeInstanceObject uniqueBitmap =
+ new FakeInstanceObject.Builder(capture, 3, "android.graphics.Bitmap").build();
+
+ // An instance that is not a bitmap at all
+ FakeInstanceObject notABitmap =
+ new FakeInstanceObject.Builder(capture, 4, "java.lang.String").build();
+
+ // The set of all instances to be filtered
+ Set<InstanceObject> allInstances =
+ ImmutableSet.of(duplicateBitmap1, duplicateBitmap2, uniqueBitmap, notABitmap);
+
+ // The pre-computed set of duplicate bitmaps
+ Set<InstanceObject> duplicateSet = ImmutableSet.of(duplicateBitmap1, duplicateBitmap2);
+
+ // Create the filter with the set of duplicates
+ BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+
+ // Apply the filter
+ Set<InstanceObject> result = filter.filter(allInstances);
+
+ // Verify that only the duplicate bitmaps are in the result
+ assertThat(result).containsExactly(duplicateBitmap1, duplicateBitmap2);
+ }
+
+ @Test
+ public void filterWithEmptyDuplicateSetReturnsEmpty() {
+ FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+ FakeInstanceObject bitmap1 =
+ new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+ FakeInstanceObject bitmap2 =
+ new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+ Set<InstanceObject> allInstances = ImmutableSet.of(bitmap1, bitmap2);
+ Set<InstanceObject> duplicateSet = ImmutableSet.of();
+
+ BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+ Set<InstanceObject> result = filter.filter(allInstances);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void filterWithEmptyInstancesReturnsEmpty() {
+ FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+ FakeInstanceObject duplicateBitmap1 =
+ new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+ FakeInstanceObject duplicateBitmap2 =
+ new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+ Set<InstanceObject> allInstances = ImmutableSet.of();
+ Set<InstanceObject> duplicateSet = ImmutableSet.of(duplicateBitmap1, duplicateBitmap2);
+
+ BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+ Set<InstanceObject> result = filter.filter(allInstances);
+
+ assertThat(result).isEmpty();
+ }
+}