方法引用缓存在Java 8中是个好主意吗?


81

考虑我有如下代码:

class Foo {

   Y func(X x) {...} 

   void doSomethingWithAFunc(Function<X,Y> f){...}

   void hotFunction(){
        doSomethingWithAFunc(this::func);
   }

}

假设hotFunction经常调用。那么建议缓存this::func如下:

class Foo {
     Function<X,Y> f = this::func;
     ...
     void hotFunction(){
        doSomethingWithAFunc(f);
     }
}

据我对java方法引用的理解,使用方法引用时,虚拟机会创建一个匿名类的对象。因此,缓存引用只会创建一次该对象,而第一种方法是在每个函数调用上创建该对象。它是否正确?

应该缓存出现在代码中热门位置的方法引用,还是VM能够优化它,并使多余的缓存?是否有一般的最佳实践,还是这种高度VM实施特定于这种缓存是否有用?


我要说的是,您使用该功能的程度很高,以至于需要/希望进行此级别的调整,也许您最好删除lambda并直接实现该功能,从而为其他优化留出更多空间。
SJuan76

@ SJuan76:我对此不太确定!如果将方法引用编译为匿名类,则它与常规接口调用一样快。因此,我不认为热代码应避免使用函数样式。
gexicide 2014年

4
方法引用是通过实现的invokedynamic。我怀疑通过缓存功能对象会看到性能的提高。相反:它可能会禁止编译器优化。您是否比较了两种变体的性能?
nosid 2014年

@nosid:不,没有做比较。但是我使用的是OpenJDK的早期版本,所以我的数字可能并不重要,因为我猜第一个版本只能快速实现新功能,而无法与功能成熟时的性能相比。随着时间的推移。规范是否真正规定invokedynamic必须使用?我在这里看不到任何理由!
gexicide

4
它应该被自动缓存(这并不等同于每次创建一个新的匿名类),因此您不必担心这种优化。
assylias 2014年

Answers:


82

对于无状态Lambda或有状态Lambda ,必须区分频繁执行同一调用站点的情况,以及频繁使用对同一方法的方法引用(通过不同的调用站点)的情况。

看下面的例子:

    Runnable r1=null;
    for(int i=0; i<2; i++) {
        Runnable r2=System::gc;
        if(r1==null) r1=r2;
        else System.out.println(r1==r2? "shared": "unshared");
    }

在这里,相同的调用站点将执行两次,生成无状态的lambda,并且当前的实现将打印出来"shared"

Runnable r1=null;
for(int i=0; i<2; i++) {
  Runnable r2=Runtime.getRuntime()::gc;
  if(r1==null) r1=r2;
  else {
    System.out.println(r1==r2? "shared": "unshared");
    System.out.println(
        r1.getClass()==r2.getClass()? "shared class": "unshared class");
  }
}

在第二个示例中,同一调用站点被执行两次,生成一个包含对Runtime实例的引用的lambda,并且当前实现将打印出来,"unshared"但是"shared class"

Runnable r1=System::gc, r2=System::gc;
System.out.println(r1==r2? "shared": "unshared");
System.out.println(
    r1.getClass()==r2.getClass()? "shared class": "unshared class");

相反,在最后一个示例中,是两个不同的调用站点,它们产生等效的方法引用,但从那时起1.8.0_05,将打印"unshared""unshared class"


对于每个lambda表达式或方法引用,编译器将发出一条invokedynamic指令,该指令引用该类中JRE提供的引导方法LambdaMetafactory以及生成所需lambda实现类所需的静态参数。元工厂生成的内容将留给实际的JRE,但这是invokedynamic指令的指定行为,可以记住并重新使用CallSite在第一次调用时创建的实例。

当前的JRE为无状态的lambda生成一个ConstantCallSite包含aMethodHandle的常量对象(并且没有可想象的理由进行不同的处理)。并且对方法的方法引用static始终是无状态的。因此,对于无状态的lambda和单个调用站点,答案必须是:不缓存,JVM会这样做,如果不缓存,则必须有强烈的理由,您不应该进行抵消。

对于具有参数this::func的lambda ,并且是具有this实例引用的lambda,情况有所不同。允许JRE缓存它们,但这意味着Map在实际参数值和所得的lambda之间保持某种形式,这可能比再次创建该简单的结构化lambda实例要昂贵得多。当前的JRE不缓存具有状态的Lambda实例。

但这并不意味着每次都会创建lambda类。这仅意味着已解析的调用站点将像普通对象构造一样,实例化在第一次调用时生成的lambda类。

类似的情况适用于对由不同调用站点创建的相同目标方法的方法引用。允许JRE在它们之间共享一个Lambda实例,但是在当前版本中,它不允许共享,这很可能是因为不清楚缓存维护是否会奏效。在这里,即使生成的类也可能不同。


因此,像您的示例一样进行缓存可能会使您的程序执行不同的操作。但不一定更有效。缓存的对象并不总是比临时对象更有效。除非您真的测量了由lambda创建引起的性能影响,否则不应该添加任何缓存。

我认为,仅在某些特殊情况下缓存可能有用:

  • 我们谈论的是涉及相同方法的许多不同呼叫站点
  • lambda是在构造函数/类初始化中创建的,因为稍后在使用站点上将
    • 同时被多个线程调用
    • 遭受第一次调用性能降低的困扰

5
澄清:术语“调用位置”是指执行invokedynamic将创建lambda的指令。它不是执行功能接口方法的地方。
Holger 2014年

1
我认为this捕获lambda是实例作用域的单例(对象上的合成实例变量)。不是吗
Marko Topolnik 2014年

2
@Marko Topolnik:那将是一个合规的编译策略,但不是,就Oracle的jdk而言1.8.0_40并非如此。这些lambda不记得了,因此可以进行垃圾收集。但是请记住,一旦invokedynamic链接了呼叫站点,它可能会像普通代码一样得到优化,即Escape Analysis可用于此类lambda实例。
Holger 2014年

2
似乎没有一个名为的标准库类MethodReference。你的意思是MethodHandle这里吗?
2015年

2
@Lii:你是对的,那是错字。有趣的是,似乎没有人注意到。
Holger 2015年

11

不幸的是,这是一个非常理想的情况,如果您想在将来某个时候将lambda作为侦听器传递,则希望将其删除。当传递另一个this :: method引用时,将需要缓存的引用,而不会将其视为删除中的同一对象,并且不会删除原始对象。例如:

public class Example
{
    public void main( String[] args )
    {
        new SingleChangeListenerFail().listenForASingleChange();
        SingleChangeListenerFail.observableValue.set( "Here be a change." );
        SingleChangeListenerFail.observableValue.set( "Here be another change that you probably don't want." );

        new SingleChangeListenerCorrect().listenForASingleChange();
        SingleChangeListenerCorrect.observableValue.set( "Here be a change." );
        SingleChangeListenerCorrect.observableValue.set( "Here be another change but you'll never know." );
    }

    static class SingleChangeListenerFail
    {
        static SimpleStringProperty observableValue = new SimpleStringProperty();

        public void listenForASingleChange()
        {
            observableValue.addListener(this::changed);
        }

        private<T> void changed( ObservableValue<? extends T> observable, T oldValue, T newValue )
        {
            System.out.println( "New Value: " + newValue );
            observableValue.removeListener(this::changed);
        }
    }

    static class SingleChangeListenerCorrect
    {
        static SimpleStringProperty observableValue = new SimpleStringProperty();
        ChangeListener<String> lambdaRef = this::changed;

        public void listenForASingleChange()
        {
            observableValue.addListener(lambdaRef);
        }

        private<T> void changed( ObservableValue<? extends T> observable, T oldValue, T newValue )
        {
            System.out.println( "New Value: " + newValue );
            observableValue.removeListener(lambdaRef);
        }
    }
}

在这种情况下不需要lambdaRef会很好。


啊,我明白了。听起来很合理,尽管可能不是OP所说的情况。然而,赞成。
Tagir Valeev 2015年

8

据我了解的语言规范,即使它改变了可观察的行为,它也允许这种优化。请参见JSL8§15.13.3节中的以下引号:

§15.13.3方法引用的运行时评估

在运行时,只要正常完成产生,对方法引用表达式的评估与对类实例创建表达式的评估相似。对对象的引用即可。[..]

[..]或者分配和初始化具有以下属性的类的新实例,或者引用具有以下属性的类的现有实例

一个简单的测试表明,静态方法的方法引用(可以)为每个评估得出相同的引用。下面的程序打印三行,其中前两行相同:

public class Demo {
    public static void main(String... args) {
        foobar();
        foobar();
        System.out.println((Runnable) Demo::foobar);
    }
    public static void foobar() {
        System.out.println((Runnable) Demo::foobar);
    }
}

对于静态函数,我无法重现同样的效果。但是,我没有在语言规范中找到任何东西来抑制这种优化。

因此,只要没有性能分析来确定此手动优化的价值,我强烈建议您不要这样做。缓存会影响代码的可读性,目前尚不清楚它是否具有任何价值。过早的优化是万恶之源。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.