我听说 Java 必须使用 JIT 才能快。与解释相比,这非常有意义,但为什么有人不能制作一个能够生成快速 Java 代码的提前编译器呢?我知道gcj
,但我认为它的输出通常不会比 Hotspot 快。
语言有没有让这变得困难的事情?我认为归结为以下几点:
- 反射
- 类加载
我错过了什么?如果我避免使用这些功能,是否可以将 Java 代码一次编译为本机机器代码并完成?
JIT 编译器可以更快,因为机器代码是在它也将在其上执行的确切机器上生成的。这意味着 JIT 拥有可用的最佳信息来发出优化的代码。
如果您将字节码预编译为机器代码,编译器无法针对目标机器进行优化,只能针对构建机器进行优化。
我将在《编程大师》一书中粘贴James Gosling给出的有趣答案。
好吧,我听说Java 世界中实际上有两个编译器。你有 Java 字节码的编译器,然后你有你的 JIT,它基本上再次专门重新编译所有内容。您所有可怕的优化都在 JIT 中。
詹姆斯:没错。这些天来,我们几乎总是在击败真正优秀的 C 和 C++ 编译器。当您使用动态编译器时,当编译器在最后一刻正确运行时,您将获得两个优势。一是你确切地知道你正在运行什么芯片组。很多时候,当人们编译一段 C 代码时,他们必须编译它才能在某种通用 x86 架构上运行。您获得的几乎所有二进制文件都没有特别适合其中任何一个。您下载 Mozilla 的最新版本,它几乎可以在任何英特尔架构 CPU 上运行。几乎有一个 Linux 二进制文件。它非常通用,并且是用 GCC 编译的,这不是一个很好的 C 编译器。
当 HotSpot 运行时,它确切地知道您正在运行什么芯片组。它确切地知道缓存是如何工作的。它确切地知道内存层次结构是如何工作的。它确切地知道所有管道互锁在 CPU 中是如何工作的。它知道该芯片具有哪些指令集扩展。它针对您使用的机器进行了优化。然后另一半是它实际上看到应用程序正在运行。它能够拥有知道哪些事情是重要的统计数据。它能够内联 C 编译器永远做不到的事情。在 Java 世界中被内联的东西是相当了不起的。然后,您将采用存储管理与现代垃圾收集器一起工作的方式。使用现代垃圾收集器,存储分配非常快。
任何 AOT 编译器的真正杀手是:
Class.forName(...)
这意味着您不能编写涵盖所有Java 程序的 AOT 编译器,因为只有在运行时才能获得有关程序特征的信息。但是,您可以在 Java 的一个子集上执行此操作,这就是我认为 gcj 所做的。
另一个典型的例子是 JIT 能够在调用方法中直接内联 getX() 等方法,如果发现这样做是安全的,并在适当的情况下撤消它,即使程序员没有明确地告诉它方法是最终的。JIT 可以看到在运行的程序中给定的方法没有被覆盖,因此在这种情况下可以被视为最终方法。这在下一次调用中可能会有所不同。
2019 年编辑:Oracle 引入了 GraalVM,它允许在 Java 的一个子集(一个相当大的,但仍然是一个子集)上进行 AOT 编译,主要要求是所有代码在编译时都可用。这允许 Web 容器的毫秒启动时间。
Java 的 JIT 编译器也是惰性和自适应的。
懒惰它只编译方法而不是编译整个程序(如果你不使用程序的一部分,这非常有用)。类加载实际上有助于使 JIT 更快,因为它允许它忽略尚未遇到的类。
作为自适应,它首先发出一个快速而肮脏的机器代码版本,然后只有在频繁使用该方法时才返回并完成工作。
最后归结为这样一个事实,即拥有更多信息可以实现更好的优化。在这种情况下,JIT 有更多关于代码正在运行的实际机器的信息(正如 Andrew 所提到的),它还有很多在编译期间不可用的运行时信息。
Java 跨虚拟方法边界内联和执行高效接口调度的能力需要在编译之前进行运行时分析——换句话说,它需要 JIT。由于所有方法都是虚拟的,并且“无处不在”使用接口,因此有很大的不同。
理论上,如果 JIT 编译器有足够的时间和可用的计算资源,则它比 AOT 更有优势。例如,如果您的企业应用程序在具有大量 RAM 的多处理器服务器上运行数天或数月,则 JIT 编译器可以生成比任何 AOT 编译器更好的代码。
现在,如果你有一个桌面应用程序,快速启动和初始响应时间(AOT 的亮点)之类的东西变得更加重要,而且计算机可能没有足够的资源来进行最高级的优化。
如果你有一个资源稀缺的嵌入式系统,JIT 就没有机会对抗 AOT。
然而,以上都是理论。实际上,创建这样一个高级 JIT 编译器比一个像样的 AOT 编译器要复杂得多。一些实际的证据怎么样?
JIT 可以识别和消除一些只能在运行时知道的条件。一个典型的例子是现代 VM 使用的虚拟调用的消除 - 例如,当 JVM 找到一条invokevirtual
orinvokeinterface
指令时,如果只加载了一个覆盖所调用方法的类,则 VM 实际上可以使该虚拟调用静态,因此能够内联它。另一方面,对于 C 程序,函数指针始终是函数指针,并且不能内联对它的调用(无论如何,在一般情况下)。
这是 JVM 能够内联虚拟调用的情况:
interface I {
I INSTANCE = Boolean.getBoolean("someCondition")? new A() : new B();
void doIt();
}
class A implements I {
void doIt(){ ... }
}
class B implements I {
void doIt(){ ... }
}
// later...
I.INSTANCE.doIt();
假设我们不在其他地方创建A
或B
实例,someCondition
并且设置为true
,JVM 知道调用doIt()
always 意味着A.doIt
,因此可以避免方法表查找,然后内联调用。非 JITted 环境中的类似构造不会是可内联的。
我认为官方 Java 编译器是 JIT 编译器这一事实是其中很大一部分。与 Java 的机器代码编译器相比,优化 JVM 和机器代码编译器花了多少时间?
Dimitry Leskov 绝对就在这里。
以上只是关于什么可以使 JIT 更快的理论,实现每个场景几乎是不可能的。此外,由于我们在 x86_64 CPU 上只有少数不同的指令集,因此针对当前 CPU 上的每个指令集几乎没有什么好处。在使用本机代码构建性能关键型应用程序时,我总是遵循以 x86_64 和 SSE4.2 为目标的规则。Java 的基本结构造成了大量的限制,JNI 可以帮助您展示它的低效率,JIT 只是通过使其整体更快来对此进行修饰。除了默认情况下每个函数都是虚拟的这一事实之外,它还在运行时使用类类型,而不是例如 C++。C++ 在性能方面有很大的优势,因为不需要在运行时加载类对象,它' s 在内存中分配的所有数据块,并且仅在请求时初始化。换句话说,C++ 在运行时没有类类型。Java 类是实际的对象,而不仅仅是模板。我不会进入 GC,因为这无关紧要。Java 字符串也较慢,因为它们使用动态字符串池,这需要运行时每次在池表中进行字符串搜索。其中许多原因是因为 Java 最初并不是为了快速而构建的,所以它的基础总是很慢。大多数本地语言(主要是 C/C++)都是专门为精简而构建的,不会浪费内存或资源。事实上,Java 的前几个版本非常缓慢且浪费内存,其中包含大量不必要的变量元数据等等。就像今天一样,
想想 JIT 需要跟踪执行惰性 JIT 的所有工作,每次调用函数时增加一个计数器,检查它被调用了多少次......等等。运行 JIT 需要很多时间。在我看来,这种交易是不值得的。这只是在PC上
曾经尝试在 Raspberry 和其他嵌入式设备上运行 Java?绝对糟糕的表现。树莓派上的 JavaFX?甚至没有功能...... Java 和它的 JIT 远不能满足它所宣传的所有内容以及人们盲目地吐槽它的理论。