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

86

假设我现在有以下这样的代码:

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实现,这种缓存是否有任何用处?


我认为你使用该函数的频率非常高,需要/值得进行这种程度的调整,也许你最好放弃lambda并直接实现该函数,这样可以为其他优化留出更多空间。 - SJuan76
@SJuan76:我不确定这个!如果一个方法引用被编译成匿名类,它的速度就像普通接口调用一样快。因此,我认为在热代码中不应避免使用函数式风格。 - gexicide
4
方法引用是通过invokedynamic实现的。我怀疑通过缓存函数对象会提高性能,相反可能会抑制编译器的优化。你比较过这两种方式的性能吗? - nosid
@nosid:没有进行比较。但是我正在使用OpenJDK的早期版本,所以我的数字可能无关紧要,因为我猜测第一个版本只是快速而粗略地实现了新功能,这不能与功能成熟后的性能进行比较。规范真的要求必须使用“invokedynamic”吗?我在这里看不出理由! - gexicide
4
它应该会自动缓存(这并不相当于每次创建一个新的匿名类),所以您不必担心这种优化。 - assylias
3个回答

101

对于无状态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"

在这个第二个例子中,相同的调用点被执行了两次,生成一个包含对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 指令,该指令引用位于类 LambdaMetafactory 中的 JRE 提供的引导方法,并提供静态参数以生成所需的 lambda 实现类。实际的 JRE 会决定元工厂生产什么,但是记住并重复使用在第一次调用时创建的 CallSite 实例是 invokedynamic 指令的指定行为。

当前 JRE 会生成一个包含常量对象的 ConstantCallSite 和一个到该常量对象的 MethodHandle,用于无状态 lambda(没有想象中的原因来进行不同处理)。而对于 static 方法的方法引用始终是无状态的。因此,对于无状态 lambda 和单个调用站点的回答必须是:不要缓存,JVM 将执行此操作,如果它没有执行,则必须有很强的理由不进行干预。
对于具有参数的 lambda(例如 this::func 是具有对 this 实例的引用的 lambda),情况有所不同。JRE 可以缓存它们,但这意味着需要维护某种实际参数值与生成的 lambda 之间的 Map,这可能比仅再次创建那个简单结构化 lambda 实例更昂贵。当前的 JRE 不会缓存带有状态的 lambda 实例。
但这并不意味着 lambda 类每次都会被创建。这只是意味着解析的调用站点会像普通对象构造一样行事,即在第一次调用时生成的 lambda 类的实例化。
相同目标方法的方法引用由不同的调用站点创建时,类似的事情也适用。JRE 允许它们共享单个 lambda 实例,但在当前版本中它并没有这样做,最可能是因为不清楚缓存维护是否会得到回报。在这里,甚至生成的类也可能不同。

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

我认为,只有一些特殊情况下缓存才可能有用:

  • 我们谈论许多不同的调用站点引用相同的方法
  • lambda 在构造函数/类初始化中创建,因为后续使用站点将
    • 由多个线程同时调用
    • 受到第一个调用的较低性能影响

5
澄清:术语“调用点(call-site)”是指执行 invokedynamic 指令以创建lambda的过程,而不是函数式接口方法将被执行的地方。 - Holger
2
@Marko Topolnik:这将是一种合规的编译策略,但截至Oracle的jdk版本1.8.0_40,情况并非如此。这些lambda表达式不会被记忆,因此可以进行垃圾回收。但请记住,一旦invokedynamic调用站点已链接,它可能会像普通代码一样进行优化,即逃逸分析适用于这些lambda实例。 - Holger
2
似乎没有名为“MethodReference”的标准库类。这里是不是指的是MethodHandle - Lii
2
@Lii:你说得对,那是个打字错误。有趣的是之前似乎没有人注意到过。 - Holger
2
@Artem Novikov:lambda表达式实例与创建它的字节码指令(invokedynamic)相关联。对于无状态lambda表达式,该指令应始终生成相同的实例,因此可以简单地具有指向该实例的指针。对于捕获lambda,同一指令必须潜在地创建包含不同状态的不同实例,因此普通指针将不足以满足需求。并且该指令不是实例特定的,可以为任意this值执行,因此在这方面this并不特殊。 - Holger
显示剩余11条评论

12

有一种情况下,使用缓存的lambda表达式是一个好主意,那就是当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,那就更好了。


啊,我现在明白了。听起来很合理,虽然可能不是原帖作者所说的情况。不过还是点了赞。 - Tagir Valeev

9
据我了解,语言规范允许这种优化,即使它会改变可观察到的行为。请参见第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);
    }
}

对于非静态函数,我无法复现相同的效果。然而,在语言规范中,我没有找到任何阻碍此优化的内容。

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接