【问题标题】:Optimization of Java Stream API functional interfaces for highly loaded system针对高负载系统的Java Stream API功能接口优化
【发布时间】:2020-06-03 21:22:24
【问题描述】:

我们有非常频繁调用的 Java Stream API 方法,例如每秒 10'000 - 20'000 次(数据流系统)。让我们回顾一下以下简单的test 方法(故意简化,并没有真正的价值):

public void test() {
        Stream.of(1, 2, 3, 4, 5)
                .map(i -> i * i)
                .filter(new SuperPredicate())
                .sorted(Comparator.comparing(i -> -i + 1,  Comparator.nullsFirst(Comparator.naturalOrder())))
                .forEach(System.out::println);
 }

class SuperPredicate implements Predicate<Integer> {
    public SuperPredicate() {
        System.out.println("SuperPredicate constructor");
    }
    @Override
    public boolean test(Integer i) {
        return i % 3 != 0;
    }
}

在每次调用test 方法时,都会创建功能接口的新实例(在我们的示例中为SuperPredicateComparator.nullsFirst())。所以对于频繁的方法调用,会创建数以千计的多余对象。我知道在 Java 中创建对象需要几纳秒,但如果我们谈论高负载,它也可能会增加 GC 的负载,从而影响性能。

正如我所见,我们可以将此类函数式接口的创建移至同一类中的private static final 变量中,因为它们是无状态的,因此会稍微减少系统负载。这是一种微优化。我们需要这样做吗? Java 编译器/JIT 编译器是否以某种方式优化了这种情况?或者编译器可能有一些选项/优化标志来改善这种情况?

【问题讨论】:

  • 您过滤和排序,但担心这个?是时候学习使用分析器了!
  • 如果您有一个良好的性能测试环境,启用 JIT 编译日志可能会很有启发性。 (-XX:+UnlockExperimentalVMOptions, -XX:+LogCompilation)
  • 保存你的 SuperPredicate 对象可能比每次都调用构造函数更好,但这是一个很小的优化,它可能无关紧要。正如 Thorbjørn Ravn Andersen 所说,排序和过滤将使这些微小的成本黯然失色
  • 如果性能真的那么重要,我会考虑完全原始,使用数组和 int:s
  • @VasiliySarzhynskyi 不要相信我说的话 - 自己衡量。

标签: java optimization java-stream


【解决方案1】:

您只能将对象存储在 static final 字段中以供重用,前提是它们不依赖于周围上下文的变量,更不用说可能改变状态了。

在这种情况下,根本没有理由创建像SuperPredicate 这样的类。您可以简单地使用    i -&gt; i % 3 != 0 并免费获得记住第一个创建实例的行为。如Does a lambda expression create an object on the heap every time it's executed? 中所述,在参考实现中,为非捕获 lambda 表达式创建的实例将被记住并重用。

也不需要新的比较器。撇开潜在的溢出不谈,使用函数i -&gt; -i + 1 只是由于否定而颠倒了顺序,而+1 对顺序没有影响。由于表达式-i + 1 的结果永远不会是null,因此不需要Comparator.nullsFirst(Comparator.naturalOrder())。所以你可以用Comparator.reverseOrder() 替换整个比较器,得到相同的结果但不包含任何对象实例化,因为reverseOrder() 将返回一个共享单例。

What is the equivalent lambda expression for System.out::println 中所述,方法引用System.out::println捕获 System.out 的当前值。因此参考实现不会重用引用PrintStream 实例的实例。如果我们将其更改为 i -&gt; System.out.println(i),它将是一个非捕获 lambda 表达式,它将在每次函数评估时重新读取 System.out

所以当我们使用

Stream.of(1, 2, 3, 4, 5)
    .map(i -> i * i)
    .filter(i -> i % 3 != 0)
    .sorted(Comparator.reverseOrder())
    .forEach(i -> System.out.println(i));

我们得到了相同的结果,而不是您的示例代码,但保存了四个对象实例,分别用于谓词、消费者、nullsFirst(…) 比较器和comparing(…) 比较器。


为了估计这种保存的影响,Stream.of(…) 是一个 varargs 方法,因此将为参数创建一个临时数组,然后,它将返回一个表示流管道的对象。每个中间操作创建另一个临时对象,表示流管道的更改状态。在内部,将使用Spliterator 实现实例。这样一共有六个临时对象,仅用于描述操作。

当终端操作开始时,将创建一个表示该操作的新对象。每个中间操作将由具有对下一个消费者的引用的Consumer 实现表示,因此组合的消费者可以传递给SpliteratorforEachRemaining 方法。由于sorted 是一个有状态的操作,它会首先将所有元素存储到一个中间ArrayList(它生成两个对象)中,在将它们传递给下一个消费者之前对其进行排序。

这使得总共有十二个对象,作为流管道的固定开销。操作System.out.println(i) 会将每个Integer 对象转换为由两个对象组成的String 对象,因为每个String 对象都是数组对象的包装器。这为这个特定示例提供了十个额外的对象,但更重要的是,每个元素两个对象,因此对更大的数据集使用相同的流管道将增加操作期间创建的对象数量。

我认为,在幕前和幕后创建的临时对象的实际数量,使得保存四个对象无关紧要。如果分配和垃圾回收性能与您的操作相关,您通常必须关注每个元素的成本,而不是流管道的固定成本。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-09-23
    • 2023-03-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-07-23
    相关资源
    最近更新 更多