Lambda with anonymous or local class inside unnecessarily captures 'this'

Archie Cobbs archie.cobbs at gmail.com
Thu Nov 13 21:42:46 UTC 2025


Hi Tagir,

This is speculation here...

Possibly the new behavior is a side effect of JDK-8164714
<https://bugs.openjdk.org/browse/JDK-8164714> (pr #23875
<https://github.com/openjdk/jdk/pull/23875/files>) which ensures outer
instances get provided as parameters, even if they are not otherwise used,
so they can be checked non-null.

I believe that parameter would later get detected by this logic
<https://github.com/openjdk/jdk/blob/6322aaba63b235cb6c73d23a932210af318404ec/src/java.base/share/classes/java/lang/invoke/InnerClassLambdaMetafactory.java#L226-L232>
 in InnerClassLambdaMetafactory, which would cause it to not deduplicate
the lambda instance.

If the above is true, then to fix this, one could imagine that the
metafactory logic might be made smarter, so it could filter out such "null
check only" captured outer instances. I'm not sure exactly how that would
be detectable... maybe by adding a new boolean BSM flag?

-Archie

On Thu, Nov 13, 2025 at 7:00 AM Tagir Valeev <amaembo at gmail.com> wrote:

> Hello!
>
> I was playing with lambda instance deduplication and come up with the
> following set of test cases:
>
> import java.util.function.Supplier;
>
> public final class CapturingLambda {
>     Runnable empty() {
>         return () -> {};
>     }
>
>     Runnable simple() {
>         return () -> {
>             System.out.println("simple");
>         };
>     }
>
>     Runnable withRunnableInside() {
>         return () -> {
>             Runnable r = () -> {};
>             r.run();
>         };
>     }
>
>     Runnable withAnonymousRunnableInside() {
>         return () -> {
>             Runnable r = new Runnable() {
>                 @Override
>                 public void run() {
>                 }
>             };
>             r.run();
>         };
>     }
>
>     Runnable withLocalClassInside() {
>         return () -> {
>             class Local {}
>             new Local();
>         };
>     }
>
>     Runnable withLocalClassInsideNoInstance() {
>         return () -> {
>             class Local {}
>         };
>     }
>
>     void checkDeduplicated(String name, Supplier<Runnable> lambdaSupplier)
> {
>         Runnable lambda1 = lambdaSupplier.get();
>         Runnable lambda2 = lambdaSupplier.get();
>         if (lambda1 == lambda2) {
>             System.out.println("Deduplicated: "+name);
>         } else {
>             System.out.println("Not deduplicated: "+name);
>         }
>     }
>
>     private void test() {
>         checkDeduplicated("empty", this::empty);
>         checkDeduplicated("simple", this::simple);
>         checkDeduplicated("withRunnableInside", this::withRunnableInside);
>         checkDeduplicated("withAnonymousRunnableInside",
> this::withAnonymousRunnableInside);
>         checkDeduplicated("withLocalClassInside",
> this::withLocalClassInside);
>         checkDeduplicated("withLocalClassInsideNoInstance",
> this::withLocalClassInsideNoInstance);
>     }
>
>     public static void main(String[] args) {
>         new CapturingLambda().test();
>     }
> }
>
> Here, checkDeduplicated checks whether the particular lambda is
> deduplicated when returned several times. We know that lambdas are
> deduplicated if they don't capture anything. The output of this code in
> Java 24 and Java 25 is the following:
>
> Deduplicated: empty
> Deduplicated: simple
> Deduplicated: withRunnableInside
> Not deduplicated: withAnonymousRunnableInside
> Not deduplicated: withLocalClassInside
> Deduplicated: withLocalClassInsideNoInstance
>
> (side note: in Java 23 and below, withLocalClassInsideNoInstance is Not
> deduplicated)
>
> The problem is that lambdas returned from withAnonymousRunnableInside and
> withLocalClassInside capture 'this', thus not deduplicated. For example,
> here's the generated bytecode for withLocalClassInside:
>
>   private void lambda$withLocalClassInside$0();
>     descriptor: ()V
>     flags: (0x1002) ACC_PRIVATE, ACC_SYNTHETIC
>     Code:
>       stack=3, locals=1, args_size=1
>          0: new           #73                 // class
> com/example/CapturingLambda$1Local
>          3: dup
>          4: aload_0
>          5: invokespecial #75                 // Method
> com/example/CapturingLambda$1Local."<init>":(Lcom/example/CapturingLambda;)V
>          8: pop
>          9: return
>
> So 'this' is passed to the generated constructor of the local class
> CapturingLambda$1Local. However, inside the constructor, the only thing we
> do with this parameter is check it against null:
>
>   com.example.CapturingLambda$1Local(com.example.CapturingLambda);
>     descriptor: (Lcom/example/CapturingLambda;)V
>     flags: (0x0000)
>     Code:
>       stack=2, locals=2, args_size=2
>          0: aload_1
>          1: dup
>          2: invokestatic  #1                  // Method
> java/util/Objects.requireNonNull:(Ljava/lang/Object;)Ljava/lang/Object;
>          5: pop
>          6: pop
>          7: aload_0
>          8: invokespecial #7                  // Method
> java/lang/Object."<init>":()V
>         11: return
>
> Is it really necessary? I understand that the mandated parameter can be
> probably helpful to maintain the (unspecified) binary compatibility with
> pre-Java-18 class files not to break the reflection code. However, can we
> probably omit the null-check and pass null here? This would help to
> deduplicate lambda instances more and remove the unnecessary strong
> reference to the outer object. Currently, the lambda cannot outlive the
> enclosing object, and this looks sad. What do you think?
>
> With best regards,
> Tagir Valeev
>


-- 
Archie L. Cobbs
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://mail.openjdk.org/pipermail/compiler-dev/attachments/20251113/f21123d9/attachment-0001.htm>


More information about the compiler-dev mailing list