Merge simple non-abstract methods into abstract methods
This introduces a new optimization that attempts to merge non-abstract virtual methods into the abstract methods they override, to reduce the overall method count.
Example:
abstract class A {
abstract boolean m();
}
class B extends A {
@Override
boolean m() {
return true;
}
}
Gets optimized into:
abstract class A {
boolean m() {
return true;
}
}
class B extends A {}
Bug: b/241190995
Change-Id: I4925bb25f69d58962199a4f82212139eaa47f3ecdiff --git a/src/main/java/com/android/tools/r8/R8.java b/src/main/java/com/android/tools/r8/R8.java
index 376be97..3aaecc5 100644
--- a/src/main/java/com/android/tools/r8/R8.java
+++ b/src/main/java/com/android/tools/r8/R8.java
@@ -80,6 +80,7 @@
import com.android.tools.r8.optimize.redundantbridgeremoval.RedundantBridgeRemover;
import com.android.tools.r8.optimize.singlecaller.SingleCallerInliner;
import com.android.tools.r8.optimize.smallmethodinliner.SmallMethodInliner;
+import com.android.tools.r8.optimize.virtualmethodhoisting.VirtualMethodHoister;
import com.android.tools.r8.origin.CommandLineOrigin;
import com.android.tools.r8.profile.art.ArtProfileCompletenessChecker;
import com.android.tools.r8.profile.rewriting.ProfileCollectionAdditions;
@@ -834,6 +835,7 @@
.runIfNecessary(executorService, timing);
assert appView.getTypeElementFactory().verifyNoCachedTypeElements();
+ new VirtualMethodHoister(appView.withLiveness()).run(executorService, timing);
new SingleCallerInliner(appViewWithLiveness).runIfNecessary(executorService, timing);
new ProtoNormalizer(appViewWithLiveness).run(executorService, timing);
}
diff --git a/src/main/java/com/android/tools/r8/graph/AppView.java b/src/main/java/com/android/tools/r8/graph/AppView.java
index 4fa7270..5277d5d 100644
--- a/src/main/java/com/android/tools/r8/graph/AppView.java
+++ b/src/main/java/com/android/tools/r8/graph/AppView.java
@@ -9,7 +9,6 @@
import com.android.tools.r8.FeatureSplit;
import com.android.tools.r8.androidapi.AndroidApiLevelCompute;
import com.android.tools.r8.androidapi.ComputedApiLevel;
-import com.android.tools.r8.classmerging.ClassMergerMode;
import com.android.tools.r8.contexts.CompilationContext;
import com.android.tools.r8.contexts.CompilationContext.ProcessorContext;
import com.android.tools.r8.errors.dontwarn.DontWarnConfiguration;
@@ -75,6 +74,7 @@
import com.android.tools.r8.utils.threads.ThreadTask;
import com.android.tools.r8.utils.threads.ThreadTaskUtils;
import com.android.tools.r8.utils.timing.Timing;
+import com.android.tools.r8.verticalclassmerging.ClassMergerMode;
import com.android.tools.r8.verticalclassmerging.VerticallyMergedClasses;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableSet;
diff --git a/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java
new file mode 100644
index 0000000..aa8a5a5
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java
@@ -0,0 +1,326 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.graph.DexProgramClass.asProgramClassOrNull;
+import static com.android.tools.r8.ir.code.Opcodes.ARGUMENT;
+import static com.android.tools.r8.ir.code.Opcodes.ASSUME;
+import static com.android.tools.r8.ir.code.Opcodes.CONST_NUMBER;
+import static com.android.tools.r8.ir.code.Opcodes.CONST_STRING;
+import static com.android.tools.r8.ir.code.Opcodes.RETURN;
+import static com.android.tools.r8.utils.internal.MapUtils.ignoreKey;
+
+import com.android.tools.r8.graph.AccessFlags;
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexClass;
+import com.android.tools.r8.graph.DexEncodedMethod;
+import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.DexProgramClass;
+import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.graph.ImmediateProgramSubtypingInfo;
+import com.android.tools.r8.graph.MethodAccessFlags;
+import com.android.tools.r8.graph.ProgramMethod;
+import com.android.tools.r8.graph.PrunedItems;
+import com.android.tools.r8.ir.code.IRCode;
+import com.android.tools.r8.ir.code.Instruction;
+import com.android.tools.r8.ir.code.Return;
+import com.android.tools.r8.ir.code.Value;
+import com.android.tools.r8.ir.conversion.LirConverter;
+import com.android.tools.r8.optimize.argumentpropagation.utils.DepthFirstTopDownClassHierarchyTraversal;
+import com.android.tools.r8.optimize.argumentpropagation.utils.ProgramClassesBidirectedGraph;
+import com.android.tools.r8.shaking.AppInfoWithLiveness;
+import com.android.tools.r8.utils.ThreadUtils;
+import com.android.tools.r8.utils.collections.DexMethodSignatureMap;
+import com.android.tools.r8.utils.internal.ListUtils;
+import com.android.tools.r8.utils.internal.ThrowingAction;
+import com.android.tools.r8.utils.timing.Timing;
+import com.google.common.collect.Sets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Searches for non-abstract methods that override an abstract method, where the body of the
+ * non-abstract method can safely be hoisted up to the declaration of the abstract method. This
+ * helps reduce the method count.
+ */
+public class VirtualMethodHoister {
+
+ private final AppView<AppInfoWithLiveness> appView;
+ private final DexItemFactory factory;
+
+ private final VirtualMethodHoisterLens.Builder lensBuilder =
+ new VirtualMethodHoisterLens.Builder();
+
+ public VirtualMethodHoister(AppView<AppInfoWithLiveness> appView) {
+ this.appView = appView;
+ this.factory = appView.dexItemFactory();
+ }
+
+ public void run(ExecutorService executorService, Timing timing) throws ExecutionException {
+ if (shouldRun()) {
+ try (Timing t0 = timing.begin("VirtualMethodHoister")) {
+ internalRun(executorService, timing);
+ appView.getTypeElementFactory().clearTypeElementsCache();
+ appView.notifyOptimizationFinished();
+ }
+ }
+ }
+
+ private boolean shouldRun() {
+ return appView.options().isOptimizing() && appView.options().isShrinking();
+ }
+
+ private void internalRun(ExecutorService executorService, Timing timing)
+ throws ExecutionException {
+ Set<DexMethod> hoistedMethods = hoistMethodsConcurrently(executorService);
+ if (hoistedMethods.isEmpty()) {
+ return;
+ }
+
+ // Prune the methods that have been removed due to hoisting.
+ pruneApp(hoistedMethods, executorService, timing);
+
+ // Rewrite AppView and LirCode.
+ VirtualMethodHoisterLens lens = lensBuilder.build(appView);
+ appView.rewriteWithLens(lens, executorService, timing);
+ LirConverter.rewriteLirWithLens(appView, timing, executorService, ThrowingAction.empty());
+ appView.clearCodeRewritings(executorService, timing);
+ }
+
+ private Set<DexMethod> hoistMethodsConcurrently(ExecutorService executorService)
+ throws ExecutionException {
+ // Process the strongly connected program components concurrently.
+ ImmediateProgramSubtypingInfo immediateSubtypingInfo =
+ ImmediateProgramSubtypingInfo.createWithDeterministicOrder(appView);
+ Set<DexMethod> hoistedMethods = ConcurrentHashMap.newKeySet();
+ List<Set<DexProgramClass>> stronglyConnectedComponents =
+ new ProgramClassesBidirectedGraph(appView, immediateSubtypingInfo)
+ .computeStronglyConnectedComponents();
+ ThreadUtils.processItems(
+ stronglyConnectedComponents,
+ stronglyConnectedComponent -> {
+ VirtualMethodHoisterTraversal traversal =
+ new VirtualMethodHoisterTraversal(appView, immediateSubtypingInfo);
+ traversal.run(
+ ListUtils.sort(stronglyConnectedComponent, Comparator.comparing(DexClass::getType)));
+ hoistedMethods.addAll(traversal.removeAndGetHoistedMethods());
+ },
+ appView.options().getThreadingModule(),
+ executorService);
+ return hoistedMethods;
+ }
+
+ private void pruneApp(
+ Set<DexMethod> hoistedMethods, ExecutorService executorService, Timing timing)
+ throws ExecutionException {
+ PrunedItems prunedItems =
+ PrunedItems.builder().setPrunedApp(appView.app()).addRemovedMethods(hoistedMethods).build();
+ appView.pruneItems(prunedItems, executorService, timing);
+ }
+
+ private class VirtualMethodHoisterTraversal extends DepthFirstTopDownClassHierarchyTraversal {
+
+ private final Map<DexProgramClass, Set<DexEncodedMethod>> hoistedMethods =
+ new IdentityHashMap<>();
+
+ private final Map<DexProgramClass, BottomUpTraversalState> states = new IdentityHashMap<>();
+
+ VirtualMethodHoisterTraversal(
+ AppView<AppInfoWithLiveness> appView,
+ ImmediateProgramSubtypingInfo immediateSubtypingInfo) {
+ super(appView, immediateSubtypingInfo);
+ }
+
+ private BottomUpTraversalState getOrCreateState(DexProgramClass clazz) {
+ return states.computeIfAbsent(clazz, ignoreKey(BottomUpTraversalState::new));
+ }
+
+ public Set<DexMethod> removeAndGetHoistedMethods() {
+ Set<DexMethod> hoistedMethodReferences = Sets.newIdentityHashSet();
+ hoistedMethods.forEach(
+ (sourceClass, sourceMethods) -> {
+ sourceClass.getMethodCollection().removeMethods(sourceMethods);
+ for (DexEncodedMethod sourceMethod : sourceMethods) {
+ hoistedMethodReferences.add(sourceMethod.getReference());
+ }
+ });
+ return hoistedMethodReferences;
+ }
+
+ @Override
+ public void visit(DexProgramClass clazz) {
+ // Top-down pass, not used.
+ // TODO(b/241190995): Accumulate abstract methods in the top-down traversal so that we only
+ // carry up virtual methods that override abstract methods in the bottom-up traversal.
+ }
+
+ @Override
+ public void prune(DexProgramClass clazz) {
+ if (clazz.isInterface()) {
+ assert !states.containsKey(clazz);
+ return;
+ }
+
+ BottomUpTraversalState state = getOrCreateState(clazz);
+ clazz.forEachProgramVirtualMethod(
+ method -> {
+ ProgramMethod sourceMethod = findHoistCandidate(method, state);
+
+ // Shadowing: Clear subclass candidates for this signature.
+ state.clearCandidates(method);
+
+ if (sourceMethod != null) {
+ // Replace the abstract target method by the source method. In principle the
+ // replacement may be subject to further hoisting if we add it to the state, but
+ // we don't expect abstract overrides of abstract methods.
+ applyHoist(sourceMethod, method);
+ } else if (!method.getAccessFlags().isAbstract()) {
+ // Add method as a candidate for hoisting.
+ state.addCandidate(method);
+ }
+ });
+
+ // Propagate state to superclasses (only superclass, not interfaces).
+ if (clazz.hasSuperType() && !state.isEmpty()) {
+ DexProgramClass superClass =
+ asProgramClassOrNull(appView.definitionFor(clazz.getSuperType()));
+ if (superClass != null) {
+ getOrCreateState(superClass).addAll(state);
+ }
+ }
+
+ states.remove(clazz);
+ }
+
+ private ProgramMethod findHoistCandidate(
+ ProgramMethod targetMethod, BottomUpTraversalState state) {
+ if (targetMethod.getAccessFlags().isAbstract()
+ && !appView.getKeepInfo(targetMethod).isPinned(appView.options())) {
+ for (ProgramMethod candidate : state.getCandidates(targetMethod)) {
+ if (isSafeToHoist(candidate, targetMethod)) {
+ return candidate;
+ }
+ }
+ }
+ return null;
+ }
+
+ private boolean isSafeToHoist(ProgramMethod candidate, ProgramMethod targetMethod) {
+ if (appView.getKeepInfo(candidate).isPinned(appView.options())) {
+ return false;
+ }
+ IRCode code = candidate.buildIR(appView);
+ for (Instruction instruction : code.instructions()) {
+ int opcode = instruction.opcode();
+ if (opcode == ARGUMENT
+ || opcode == ASSUME
+ || opcode == CONST_NUMBER
+ || opcode == CONST_STRING) {
+ // Safe
+ } else if (opcode == RETURN) {
+ Return returnInstruction = instruction.asReturn();
+ if (!returnInstruction.isReturnVoid()) {
+ Value returnValue = returnInstruction.returnValue();
+ if (returnValue.getAliasedValue().isThis()) {
+ DexType returnType = targetMethod.getReturnType();
+ DexClass targetClass = targetMethod.getHolder();
+ if (!appView.appInfo().isSubtype(targetClass.getType(), returnType)) {
+ return false;
+ }
+ }
+ }
+ } else {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void applyHoist(ProgramMethod sourceMethod, ProgramMethod targetMethod) {
+ // Replace the target method with the source method.
+ DexProgramClass targetClass = targetMethod.getHolder();
+ targetClass
+ .getMethodCollection()
+ .replaceVirtualMethod(
+ targetMethod.getReference(),
+ t ->
+ sourceMethod
+ .getDefinition()
+ .toTypeSubstitutedMethodAsInlining(
+ t.getReference(),
+ factory,
+ builder -> builder.modifyAccessFlags(AccessFlags::demoteFromFinal)));
+
+ // Record for lens and pruning.
+ if (canRetargetInvokesToTargetMethod(sourceMethod, targetMethod)) {
+ lensBuilder.map(sourceMethod, targetMethod);
+ }
+
+ hoistedMethods
+ .computeIfAbsent(sourceMethod.getHolder(), ignoreKey(Sets::newIdentityHashSet))
+ .add(sourceMethod.getDefinition());
+ }
+
+ private boolean canRetargetInvokesToTargetMethod(
+ ProgramMethod sourceMethod, ProgramMethod targetMethod) {
+ DexProgramClass sourceHolder = sourceMethod.getHolder();
+ DexClass targetHolder = targetMethod.getHolder();
+ if (!targetHolder.getAccessFlags().isPublic()) {
+ if (sourceHolder.getAccessFlags().isPublic() || !sourceMethod.isSamePackage(targetMethod)) {
+ return false;
+ }
+ }
+ if (targetMethod.getAccessFlags().isPublic()) {
+ return true;
+ }
+ MethodAccessFlags sourceAccessFlags = sourceMethod.getAccessFlags();
+ MethodAccessFlags targetAccessFlags = targetMethod.getAccessFlags();
+ if (sourceAccessFlags.isPackagePrivate()
+ && !targetAccessFlags.isPrivate()
+ && sourceMethod.isSamePackage(targetMethod)) {
+ return true;
+ }
+ return sourceAccessFlags.isProtected()
+ && targetAccessFlags.isProtected()
+ && sourceMethod.isSamePackage(targetMethod);
+ }
+ }
+
+ private static class BottomUpTraversalState {
+
+ private final DexMethodSignatureMap<List<ProgramMethod>> candidates =
+ DexMethodSignatureMap.create();
+
+ void addCandidate(ProgramMethod method) {
+ candidates.computeIfAbsent(method, ignoreKey(ArrayList::new)).add(method);
+ }
+
+ void addAll(BottomUpTraversalState other) {
+ other.candidates.forEach(
+ (signature, methods) ->
+ candidates.computeIfAbsent(signature, ignoreKey(ArrayList::new)).addAll(methods));
+ }
+
+ List<ProgramMethod> getCandidates(ProgramMethod method) {
+ return candidates.getOrDefault(method, Collections.emptyList());
+ }
+
+ void clearCandidates(ProgramMethod method) {
+ candidates.remove(method);
+ }
+
+ boolean isEmpty() {
+ return candidates.isEmpty();
+ }
+ }
+}
diff --git a/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java
new file mode 100644
index 0000000..47af9d1
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java
@@ -0,0 +1,64 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.ProgramMethod;
+import com.android.tools.r8.graph.lens.DefaultNonIdentityGraphLens;
+import com.android.tools.r8.graph.lens.GraphLens;
+import com.android.tools.r8.graph.lens.MethodLookupResult;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class VirtualMethodHoisterLens extends DefaultNonIdentityGraphLens {
+
+ private final Map<DexMethod, DexMethod> methodMap;
+
+ public VirtualMethodHoisterLens(AppView<?> appView, Map<DexMethod, DexMethod> methodMap) {
+ super(appView);
+ this.methodMap = methodMap;
+ }
+
+ // Methods.
+
+ @Override
+ public DexMethod getNextMethodSignature(DexMethod method) {
+ return methodMap.getOrDefault(method, method);
+ }
+
+ @Override
+ protected MethodLookupResult internalDescribeLookupMethod(
+ MethodLookupResult previous, DexMethod context, GraphLens codeLens) {
+ DexMethod newReference = methodMap.get(previous.getReference());
+ if (newReference != null) {
+ return MethodLookupResult.builder(this, codeLens)
+ .setReboundReference(newReference)
+ .setReference(newReference)
+ .setPrototypeChanges(previous.getPrototypeChanges())
+ .setType(previous.getType())
+ .build();
+ }
+ return previous.verify(this, codeLens);
+ }
+
+ public static class Builder {
+
+ private final Map<DexMethod, DexMethod> methodMap = new ConcurrentHashMap<>();
+
+ public void map(ProgramMethod from, ProgramMethod to) {
+ methodMap.put(from.getReference(), to.getReference());
+ }
+
+ public boolean isEmpty() {
+ return methodMap.isEmpty();
+ }
+
+ public VirtualMethodHoisterLens build(AppView<?> appView) {
+ return new VirtualMethodHoisterLens(appView, new IdentityHashMap<>(methodMap));
+ }
+ }
+}
diff --git a/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java b/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
index ee067de..6ec6d19 100644
--- a/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
+++ b/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
@@ -94,6 +94,10 @@
return getOrDefault(method.getSignature(), defaultValue);
}
+ public T getOrDefault(DexClassAndMethod method, T defaultValue) {
+ return getOrDefault(method.getMethodSignature(), defaultValue);
+ }
+
@Override
public void forEach(BiConsumer<? super DexMethodSignature, ? super T> action) {
backing.forEach(action);
@@ -263,6 +267,10 @@
return remove(method.getSignature());
}
+ public T remove(DexClassAndMethod method) {
+ return remove(method.getMethodSignature());
+ }
+
public boolean removeIf(BiPredicate<DexMethodSignature, T> predicate) {
return backing.entrySet().removeIf(entry -> predicate.test(entry.getKey(), entry.getValue()));
}
diff --git a/src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java b/src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
similarity index 76%
rename from src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java
rename to src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
index bb9a37b..bae4e34 100644
--- a/src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
@@ -1,17 +1,14 @@
// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
-package com.android.tools.r8.classmerging;
+package com.android.tools.r8.verticalclassmerging;
public enum ClassMergerMode {
INITIAL,
+ INTERMEDIATE,
FINAL;
public boolean isInitial() {
return this == INITIAL;
}
-
- public boolean isFinal() {
- return this == FINAL;
- }
}
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java b/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
index b64c347..e129b0c 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
@@ -6,7 +6,6 @@
import static com.android.tools.r8.ir.analysis.type.Nullability.definitelyNotNull;
import static com.android.tools.r8.ir.analysis.type.Nullability.maybeNull;
-import com.android.tools.r8.classmerging.ClassMergerMode;
import com.android.tools.r8.graph.AppView;
import com.android.tools.r8.graph.ClasspathMethod;
import com.android.tools.r8.graph.Code;
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
index 5af1803..a36a91c 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
@@ -5,7 +5,6 @@
import static com.android.tools.r8.graph.DexClassAndMethod.asProgramMethodOrNull;
-import com.android.tools.r8.classmerging.ClassMergerMode;
import com.android.tools.r8.classmerging.ClassMergerSharedData;
import com.android.tools.r8.classmerging.Policy;
import com.android.tools.r8.graph.AppView;
@@ -72,7 +71,7 @@
public static VerticalClassMerger createForIntermediateClassMerging(
AppView<AppInfoWithLiveness> appView, Timing timing) {
timing.begin("VerticalClassMerger (2/3)");
- return new VerticalClassMerger(appView, ClassMergerMode.FINAL);
+ return new VerticalClassMerger(appView, ClassMergerMode.INTERMEDIATE);
}
public static VerticalClassMerger createForFinalClassMerging(
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
index aaeae0e..32ce821 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
@@ -3,17 +3,18 @@
// BSD-style license that can be found in the LICENSE file.
package com.android.tools.r8.verticalclassmerging;
-import com.android.tools.r8.classmerging.ClassMergerMode;
import com.android.tools.r8.utils.InternalOptions;
+import java.util.function.Predicate;
public class VerticalClassMergerOptions {
private final InternalOptions options;
private boolean enabled = true;
- private boolean enableInitial = true;
private boolean enableBridgeAnalysis = true;
+ private Predicate<ClassMergerMode> modePredicate = null;
+
public VerticalClassMergerOptions(InternalOptions options) {
this.options = options;
}
@@ -23,7 +24,11 @@
}
public void disableInitial() {
- enableInitial = false;
+ if (modePredicate == null) {
+ modePredicate = mode -> mode != ClassMergerMode.INITIAL;
+ } else {
+ modePredicate = modePredicate.and(mode -> mode != ClassMergerMode.INITIAL);
+ }
}
public boolean isEnabled(ClassMergerMode mode) {
@@ -34,7 +39,7 @@
|| !options.isShrinking()) {
return false;
}
- if (mode.isInitial() && !enableInitial) {
+ if (modePredicate != null && !modePredicate.test(mode)) {
return false;
}
return true;
@@ -51,4 +56,8 @@
public void setEnableBridgeAnalysis(boolean enableBridgeAnalysis) {
this.enableBridgeAnalysis = enableBridgeAnalysis;
}
+
+ public void setModePredicate(Predicate<ClassMergerMode> modePredicate) {
+ this.modePredicate = modePredicate;
+ }
}
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java
new file mode 100644
index 0000000..2cd16d0
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java
@@ -0,0 +1,102 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingTest extends TestBase {
+
+ @Parameter(0)
+ public TestParameters parameters;
+
+ @Parameters(name = "{0}")
+ public static TestParametersCollection data() {
+ return getTestParameters().withAllRuntimesAndApiLevels().build();
+ }
+
+ @Test
+ public void test() throws Exception {
+ testForR8(parameters)
+ .addInnerClasses(getClass())
+ .addKeepMainRule(Main.class)
+ .enableNoHorizontalClassMergingAnnotations()
+ .compile()
+ .inspect(this::inspect)
+ .run(parameters.getRuntime(), Main.class)
+ .assertSuccessWithOutputLines("1", "2");
+ }
+
+ private void inspect(CodeInspector inspector) {
+ ClassSubject baseClass = inspector.clazz(Base.class);
+ assertThat(baseClass, isPresent());
+
+ // Base#method should no longer be abstract
+ MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+ assertThat(baseMethod, isPresent());
+ assertThat(baseMethod, not(isAbstract()));
+ assertTrue(
+ baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+ // A#method should be hoisted to Base, so A should no longer have it.
+ ClassSubject aClass = inspector.clazz(A.class);
+ assertThat(aClass, isPresent());
+ assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+ // B#method should still be present.
+ ClassSubject bClass = inspector.clazz(B.class);
+ assertThat(bClass, isPresent());
+ assertThat(bClass.uniqueMethodWithOriginalName("method"), isPresent());
+ }
+
+ static class Main {
+
+ public static void main(String[] args) {
+ Base a = System.currentTimeMillis() > 0 ? new A() : new B();
+ Base b = System.currentTimeMillis() > 0 ? new B() : new A();
+ System.out.println(a.method());
+ System.out.println(b.method());
+ }
+ }
+
+ public abstract static class Base {
+ public abstract int method();
+ }
+
+ @NoHorizontalClassMerging
+ public static class A extends Base {
+
+ @Override
+ public int method() {
+ return 1;
+ }
+ }
+
+ @NoHorizontalClassMerging
+ public static class B extends Base {
+
+ @Override
+ public int method() {
+ return 2;
+ }
+ }
+}
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java
new file mode 100644
index 0000000..0fae771
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java
@@ -0,0 +1,115 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.NoVerticalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingTwoLevelsTest extends TestBase {
+
+ @Parameter(0)
+ public TestParameters parameters;
+
+ @Parameters(name = "{0}")
+ public static TestParametersCollection data() {
+ return getTestParameters().withAllRuntimesAndApiLevels().build();
+ }
+
+ @Test
+ public void test() throws Exception {
+ testForR8(parameters)
+ .addInnerClasses(getClass())
+ .addKeepMainRule(Main.class)
+ .enableNoHorizontalClassMergingAnnotations()
+ .enableNoVerticalClassMergingAnnotations()
+ .compile()
+ .inspect(this::inspect)
+ .run(parameters.getRuntime(), Main.class)
+ .assertSuccessWithOutputLines("1", "2");
+ }
+
+ private void inspect(CodeInspector inspector) {
+ ClassSubject baseClass = inspector.clazz(Base.class);
+ assertThat(baseClass, isPresent());
+
+ // Base#method should no longer be abstract (it should have hoisted A#method)
+ MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+ assertThat(baseMethod, isPresent());
+ assertThat(baseMethod, not(isAbstract()));
+ assertTrue(
+ baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+ // Sub should not have the method (it inherits from Base)
+ ClassSubject subClass = inspector.clazz(Sub.class);
+ assertThat(subClass, isPresent());
+ assertThat(subClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+ // A#method should be hoisted to Base, so A should no longer have it.
+ ClassSubject aClass = inspector.clazz(A.class);
+ assertThat(aClass, isPresent());
+ assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+ // Z#method should still be present.
+ ClassSubject zClass = inspector.clazz(Z.class);
+ assertThat(zClass, isPresent());
+ assertThat(zClass.uniqueMethodWithOriginalName("method"), isPresent());
+ }
+
+ static class Main {
+
+ public static void main(String[] args) {
+ Base a = System.currentTimeMillis() > 0 ? new A() : new Z();
+ Base b = System.currentTimeMillis() > 0 ? new Z() : new A();
+ System.out.println(a.method());
+ System.out.println(b.method());
+ }
+ }
+
+ public abstract static class Base {
+ public abstract int method();
+ }
+
+ @NoVerticalClassMerging
+ @NoHorizontalClassMerging
+ public abstract static class Sub extends Base {}
+
+ @NoVerticalClassMerging
+ @NoHorizontalClassMerging
+ public static class A extends Sub {
+
+ @Override
+ public int method() {
+ return 1;
+ }
+ }
+
+ @NoVerticalClassMerging
+ @NoHorizontalClassMerging
+ public static class Z extends Base {
+
+ @Override
+ public int method() {
+ return 2;
+ }
+ }
+}
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java
new file mode 100644
index 0000000..e93f769
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java
@@ -0,0 +1,197 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.CodeMatchers.invokesMethod;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.KeepUnusedArguments;
+import com.android.tools.r8.NeverClassInline;
+import com.android.tools.r8.NeverInline;
+import com.android.tools.r8.NeverPropagateValue;
+import com.android.tools.r8.NoAccessModification;
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.NoParameterTypeStrengthening;
+import com.android.tools.r8.NoVerticalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import com.android.tools.r8.verticalclassmerging.ClassMergerMode;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/**
+ * Tests that VirtualMethodHoister does not introduce IllegalAccessErrors from rewriting invokes to
+ * methods higher up in the class hierarchy that are inaccessible to callers. Instead, the
+ * VirtualMethodHoister should leave such invokes unchanged, meaning the invokes no longer point
+ * directly to a method definition.
+ */
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingWithNonReboundReferenceTest extends TestBase {
+
+ @Parameter(0)
+ public TestParameters parameters;
+
+ @Parameters(name = "{0}")
+ public static TestParametersCollection data() {
+ return getTestParameters().withAllRuntimesAndApiLevels().build();
+ }
+
+ @Test
+ public void testJvm() throws Exception {
+ parameters.assumeJvmTestParameters();
+ testForJvm(parameters)
+ .addProgramClasses(Base.class, ASuper.class, B.class, S.class, T.class)
+ .addProgramClassFileData(getProgramClassFileData())
+ .run(parameters.getRuntime(), Main.class)
+ .assertSuccessWithOutputLines("1", "2", "3");
+ }
+
+ @Test
+ public void test() throws Exception {
+ testForR8(parameters)
+ .addProgramClasses(Base.class, ASuper.class, B.class, S.class, T.class)
+ .addProgramClassFileData(getProgramClassFileData())
+ .addKeepMainRule(Main.class)
+ .addOptionsModification(
+ o ->
+ o.getVerticalClassMergerOptions()
+ .setModePredicate(mode -> mode == ClassMergerMode.FINAL))
+ .enableInliningAnnotations()
+ .enableNeverClassInliningAnnotations()
+ .enableMemberValuePropagationAnnotations()
+ .enableNoAccessModificationAnnotationsForClasses()
+ .enableNoHorizontalClassMergingAnnotations()
+ .enableNoParameterTypeStrengtheningAnnotations()
+ .enableNoVerticalClassMergingAnnotations()
+ .enableUnusedArgumentAnnotations()
+ .compile()
+ .inspect(this::inspect)
+ .run(parameters.getRuntime(), Main.class)
+ .assertSuccessWithOutputLines("1", "2", "3");
+ }
+
+ private List<byte[]> getProgramClassFileData() {
+ return ImmutableList.of(
+ transformer(Main.class)
+ .replaceClassDescriptorInMethodInstructions(descriptor(A.class), "Lpkg/A;")
+ .transform(),
+ transformer(A.class).setClassDescriptor("Lpkg/A;").transform());
+ }
+
+ private void inspect(CodeInspector inspector) {
+ ClassSubject baseClass = inspector.clazz(Base.class);
+ assertThat(baseClass, isPresent());
+
+ // S should be merged into T.
+ ClassSubject sClass = inspector.clazz(S.class);
+ assertThat(sClass, isAbsent());
+
+ ClassSubject tClass = inspector.clazz(T.class);
+ assertThat(tClass, isPresent());
+
+ // Base#method should no longer be abstract
+ MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+ assertThat(baseMethod, isPresent());
+ assertThat(baseMethod, not(isAbstract()));
+ assertTrue(
+ baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+ // A#method should be hoisted to Base, so A should no longer have it.
+ ClassSubject aClass = inspector.clazz("pkg.A");
+ assertThat(aClass, isPresent());
+ assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+ // A#callMethod should NOT be updated to call Base#method, since it doesn't have access to Base.
+ // Moreover, the non-rebound method reference that A#method(S) should be correctly rewritten
+ // to A#method(T) when the final round of the vertical class merger merges S into T.
+ MethodSubject callMethodSubject = aClass.uniqueMethodWithOriginalName("callMethod");
+ assertThat(callMethodSubject, isPresent());
+ assertThat(
+ callMethodSubject,
+ invokesMethod(
+ null,
+ aClass.getFinalName(),
+ baseMethod.getFinalName(),
+ ImmutableList.of(tClass.getFinalName())));
+
+ // B#method should still be present.
+ ClassSubject bClass = inspector.clazz(B.class);
+ assertThat(bClass, isPresent());
+ assertThat(bClass.uniqueMethodWithOriginalName("method"), isPresent());
+ }
+
+ public static class Main {
+
+ public static void main(String[] args) {
+ Base a = System.currentTimeMillis() > 0 ? new A() : new B();
+ Base b = System.currentTimeMillis() > 0 ? new B() : new A();
+ System.out.println(a.method(new T()));
+ System.out.println(b.method(new T()));
+ System.out.println(new A().callMethod(new T()));
+ }
+ }
+
+ @NoAccessModification
+ @NoVerticalClassMerging
+ abstract static class Base {
+
+ @NoParameterTypeStrengthening
+ public abstract int method(S s);
+ }
+
+ @NoVerticalClassMerging
+ public abstract static class ASuper extends Base {}
+
+ @NeverClassInline
+ @NoHorizontalClassMerging
+ public static class /*pkg.*/ A extends ASuper {
+
+ @KeepUnusedArguments
+ @NeverInline
+ @NeverPropagateValue
+ @NoParameterTypeStrengthening
+ @Override
+ public int method(S s) {
+ return 1;
+ }
+
+ @KeepUnusedArguments
+ @NeverInline
+ @NeverPropagateValue
+ @NoParameterTypeStrengthening
+ public int callMethod(S s) {
+ return method(s) + 2;
+ }
+ }
+
+ @NoHorizontalClassMerging
+ public static class B extends Base {
+
+ @KeepUnusedArguments
+ @NoParameterTypeStrengthening
+ @Override
+ public int method(S s) {
+ return 2;
+ }
+ }
+
+ public static class S {}
+
+ @NeverClassInline
+ public static class T extends S {}
+}