最近我遇到了一种情况,在 Java 中使用可变参数是合乎逻辑的。后来我发现可变参数只是数组的语法糖。

我读了一些相关内容,发现它比使用普通参数要慢。我遇到了类似EnumSet.of(...)这样的方法,它们有 5 种不同的重载,以避免由于性能原因而不得不使用可变参数。

使用 java varargs 无法执行的另一件事是将参数转发给另一个函数,或者换句话说 – 解包它们。在 C++ 中,您有参数包,它们不是数组,可以转发。

所以,我很好奇:将 varargs 实现为数组给语言带来了什么具体优势?

19

  • 2
    您的哪些代码受到此性能下降的影响?


    – 

  • 2
    这是权衡。在 Java 中,你会遇到性能问题,在 C++ 中,你会遇到读取越界攻击。而且它们添加得比较晚,没有人愿意改变调用标准。


    – 

  • 3
    为什么它们首先被实现为数组。 ” – 你必须问开发人员,或者参与开发的某个人 – 我们大多数人只能告诉你我们的意见(猜测)但是你能想到哪些其他(简单的)替代方案,而不必改变整个语言? – 顺便说一句,你应该完成这次:“避免主要基于意见的问题


    – 


  • 2
    你对此进行过基准测试吗?你确定有可测量的性能下降吗?我对此有些怀疑。我认为使用可变参数造成的任何减速都会以纳秒为单位来衡量。我也不知道你会提出什么替代方案。


    – 

  • 3


    – 


最佳答案
2

正如我将展示的,其核心内容如下:

  1. 它使语言规范保持简单。
  2. 它使语言实现保持简单。
  3. 它不需要花费太多,因为 Java 中的内存分配(即分配一个数组来保存参数)非常快。

但最重要的是:

  1. 使用可变参数数组所带来的微小性能损失仅会由可变参数函数承受,而所有其他函数调用都会变得略小略快。

首先,在 Java 诞生之时,C++ 中还不存在参数包,所以我们不要将苹果与橘子进行比较:我们必须将 java 可变参数与旧的 C 风格的可变参数进行比较。

现在,C 语言中可变参数仅在默认调用约定(即cdecl )下才可用。(如果您尝试使用其他调用约定,例如stdcall,则不能使用可变参数。)

根据 cdecl 调用约定,任何函数实际上都可以使用任意数量的参数来调用,并且这不会导致到处都出现严重的崩溃,因为调用者知道它们将多少个参数推入堆栈,并负责在函数返回后将它们从堆栈中弹出,以保持堆栈平衡。

x86 下的 cdecl 函数调用如下所示:

push arg1              ;4 bytes
push arg2              ;4 bytes
call function
add sp, 8              ;balance the stack.

因此,在 C 语言的默认调用约定下,每个传递了一个或多个参数的函数调用都必须跟在堆栈平衡指令之后。这意味着所有函数调用的性能都会受到影响,无论它们是否是可变参数。

我一直认为这有点弱智。

在 Java 中,他们决定不再使用不同的调用约定,并且每个函数的参数数量将是固定的。这样,他们就可以让函数(而不是调用者)负责平衡堆栈。

在 x86 中,操作如下:

ret 8                  ;return, also popping 8 bytes.

这是一个更明智的语言设计选择。不幸的是,这似乎意味着该语言不能有可变参数。

嗯,当然可以,通过使用数组来实现它们。

这个轮子已经发明了,为什么不重新利用它呢?

因此,Java 中的 varargs 调用的成本可能比可以想象的绝对最低限度略高,但每个非 varargs 调用的成本都略优,因此这显然是赢家。

4

  • 1
    我想这确实有道理。虽然有点不相关,但 C 确实有一个调用约定,即被调用者清理堆栈。而且(我刚刚发现)这确实意味着你不能使用可变参数,所以编译器只是改变了调用约定。


    – 

  • @iexav 我修改了我的答案以解释 C 的不同调用约定。


    – 

  • C 确实允许转发可变参数;因为 C89 stdarg.hva_list支持这一点(比较printfvprintfstdio.h 中),甚至在此之前实现就有方法,主要是指向连续存储单元的指针(在 C 中是一个数组),这就是标准解决它的原因——以及为什么它要求标准可变参数用包含…的原型声明,而具有 K&R1 兼容类型的固定参数函数可以在没有原型的情况下使用,并且(直到 C99)如果它们返回而int根本没有任何声明。


    – 

  • @dave_thompson_085 哦。好的,我会改正的。


    – 

为了回答这个问题,我将提出一些选择分析,然后解释为什么 Java 设计人员选择基于数组的解决方案。

分析

“可变参数”表示调用可以提供可变数量的参数,被调用的方法会将其作为一个参数实体接收,其属性如下

  • 具有强类型元素,
  • 可以迭代所有单独的参数,
  • 可以通过索引检索各个参数,
  • 或类似。

无论底层发生了什么,接收参数看起来都与数组或列表非常相似。当然,编译器和底层 JVM 必须支持所选的机制,要么重用已经支持的东西,要么扩展它。那么,有哪些选择呢?

  1. 在核心 Java 语言和底层虚拟机中,数组数据类型是受支持的,并且符合这些要求。只是当存在与调用参数列表匹配的带有可变参数的方法时,编译器需要进行特殊处理。
  2. 使用 Collections 框架中的某些类型(例如 ArrayList)不会带来任何相关的优势(例如,更改参数列表的长度很少有用),但会强制将原始类型int装箱Integer
  3. 让调用者将各个参数像所有“普通”参数一样放入 JVM 堆栈中,而不将它们打包到堆分配的数组或列表中,需要对 JVM 进行一些重大扩展或修改。被调用的方法不再知道堆栈有多深,这使得在方法返回时引用参数和展开堆栈变得更加复杂。而且调用者仍然需要接收一个组合参数并支持访问其元素的对象。这需要一个 JVM 支持的特殊类来应对这种情况。

解释

鉴于可变参数的使用频率不高,Java 设计者选择了一种无需特殊修改即可重用现有 JVM 的机制。这与许多其他 Java 扩展一致,旨在尽可能保持 JVM 的稳定性。

在剩下的两个选择(数组与列表)中,数组的开销较小,并且在元素类型处理方面更胜一筹。例如,有一个原生的int[],但没有ArrayList<int>,只有ArrayList<Integer>

varargs 特性是,我猜 Java 团队已经忙于其他 Java 5 特性(比如泛型),因此仅仅为了支持 varargs 而投资进行重大的 JVM 修改可能被认为“不值得”。

Hotspot 编译器仍然会将 JVM 字节码翻译成机器语言。在此过程中,它会应用许多巧妙的转换。如果存在可以用传统调用替代的 varags 模式的特殊检测,我不会感到惊讶。