为什么编译后的Java类文件比编译后的C文件更小?

12

我想知道为什么编译一个打印“Hello, World!”的.c文件生成的.o文件比打印同样内容的Java .class文件要大?


1
我不知道(我有猜测,但我会保持沉默,让专家回答),但那是一个相当糟糕的程序来比较编译器输出大小。 - user395760
7
将C程序静态链接后,将其大小与Java程序+Java虚拟机进行比较,可以使比较更加公平。 - Fred Foo
我们要谈论多大的规模? - user395760
9个回答

16

Java使用字节码实现跨平台和“预编译”,但是字节码由解释器使用,并且被设计为足够紧凑,因此与编译的C程序中可以看到的机器码不同。只需查看Java编译的完整过程:

Java program  
-> Bytecode   
  -> High-level Intermediate Representation (HIR)   
    -> Middle-level Intermediate Representation (MIR)   
      -> Low-level Intermediate Representation (LIR)  
        -> Register allocation
          -> EMIT (Machine Code)

这是Java程序转换为机器代码的过程。如您所见,字节码与机器代码相差甚远。我在互联网上找不到好的资料来演示这一过程,即真实程序的示例;我找到的所有内容都在这个演示中,您可以看到每个步骤如何改变代码的呈现方式。希望它能回答您编译的C程序和Java字节码之间的差异是如何产生的。

更新: 在“字节码”之后的所有步骤都是由JVM在运行时执行的,这取决于其决定是否编译该代码(这是另一个故事……JVM在字节码解释和编译成本地平台相关代码之间进行平衡)

最终找到了很好的例子,取自Java HotSpot™客户端编译器的线性扫描寄存器分配(顺便说一下,这是理解JVM内部发生了什么的好读物)。想象一下我们有一个Java程序:

public static void fibonacci() {
  int lo = 0;
  int hi = 1;
  while (hi < 10000) {
    hi = hi + lo;
    lo = hi - lo;
    print(lo);
  }
}

那么它的字节码是:

0:  iconst_0
1:  istore_0 // lo = 0
2:  iconst_1
3:  istore_1 // hi = 1
4:  iload_1
5:  sipush 10000
8:  if_icmpge 26 // while (hi < 10000)
11: iload_1
12: iload_0
13: iadd
14: istore_1 // hi = hi + lo
15: iload_1
16: iload_0
17: isub
18: istore_0 // lo = hi - lo
19: iload_0
20: invokestatic #12 // print(lo)
23: goto 4 // end of while-loop
26: return

每个命令占用1个字节(JVM支持256个命令,但实际上没有那么多)+参数。总共需要27个字节。我省略了所有阶段,下面是可执行的机器代码:

00000000: mov dword ptr [esp-3000h], eax
00000007: push ebp
00000008: mov ebp, esp
0000000a: sub esp, 18h
0000000d: mov esi, 1h
00000012: mov edi, 0h
00000017: nop
00000018: cmp esi, 2710h
0000001e: jge 00000049
00000024: add esi, edi
00000026: mov ebx, esi
00000028: sub ebx, edi
0000002a: mov dword ptr [esp], ebx
0000002d: mov dword ptr [ebp-8h], ebx
00000030: mov dword ptr [ebp-4h], esi
00000033: call 00a50d40
00000038: mov esi, dword ptr [ebp-4h]
0000003b: mov edi, dword ptr [ebp-8h]
0000003e: test dword ptr [370000h], eax
00000044: jmp 00000018
00000049: mov esp, ebp
0000004b: pop ebp
0000004c: test dword ptr [370000h], eax
00000052: ret

结果占用了83个字节(52个十六进制+1个字节)。

PS. 我没有考虑链接(其他人已经提到了),以及编译和字节码文件头(它们可能也不同;我不知道C语言是怎么样的,但在字节码文件中,所有字符串都被移动到特殊的头池中,在程序中使用其在头部的“位置”等)。

UPDATE2:值得一提的是,Java使用堆栈(istore/iload命令),而基于x86和大多数其他平台的机器代码则使用寄存器。如您所见,机器代码“充满”寄存器,这使得编译程序与更简单的基于栈的字节码相比增加了额外的大小。


感谢您提供这个详尽的答案。 - palAlaa
你的回答看起来像一篇研究论文!! - R K
除此之外,这远远不能解释整个差异。代码可能会短2-3倍,但差异通常要大得多。@axtavt的答案更准确地解释了这种差异。 - jalf

7
在这种情况下导致大小差异的主要原因是文件格式不同。对于这样一个小程序来说,ELF(.o)文件格式会引入严重的空间开销。
例如,“Hello, world”程序的示例.o文件需要864字节。它由以下内容组成(使用 readelf 命令探索):
- 文件头52字节 - 节头440字节(40个字节x11个节) - 节名称81字节 - 符号表160字节 - 代码43字节 - 数据14字节Hello, world \ n \ 0) - 等等
类似程序的.class文件只需415字节,尽管它包含更多的符号名称且这些名称很长。它由以下内容组成(使用Java Class Viewer探索):
- 289字节的常量池(包括常量、符号名称等) - 94字节的方法表(代码) - 8字节的属性表(源文件名引用) - 固定大小的头24字节
另请参阅:
- 可执行和可链接格式 - Java类文件 - Java Class Viewer

4
即使C程序被编译为在处理器上运行的本地机器代码(当然是通过操作系统分派),但它们往往需要为操作系统进行大量设置和撤销,加载动态链接库,如C库等。
另一方面,Java编译为虚拟平台的字节码(基本上是计算机内的模拟计算机),该平台与Java本身一起设计,因此许多这些开销(如果它们甚至是必要的,因为代码和VM接口都是定义良好的)可以移入VM本身,使程序代码变得精简。
尽管各个编译器不同,但有几个选项可以减少它或以不同的方式构建代码,这将产生不同的效果。
话虽如此,这并不是非常重要。

2
大部分的设置和拆卸工作都在标准库函数中(例如 _start_exit),因此在链接之前不会包含在对象文件中。 - user395760
没错,我之前曾经逐条追踪汇编指令,记得那个在里面。谢谢。不过,相比使用nasm编译,仍然有相当多的额外(必要)垃圾代码。 - Kamalnayan

1

一个类文件是Java字节码。

它很可能更小,因为C/C++库和操作系统库链接到C++编译器生成的目标代码中,最终生成可执行二进制文件。

简单来说,这就像将Java字节码与C编译器在链接创建二进制文件之前生成的目标代码进行比较。区别在于JVM解释Java字节码以正确执行程序所需的操作,而C需要来自操作系统的信息,因为操作系统充当解释器。

而且,在C中,您从外部库引用的每个符号(函数等)至少在一个对象文件中引用一次。如果您在多个对象文件中使用它,则仍然只会被导入一次。这种“导入”可以通过两种方式实现。使用静态链接,函数的实际代码将复制到可执行文件中。这会增加文件大小,但优点是不需要外部库(.dll/.so文件)。而动态链接则不会发生这种情况,但结果是您的程序需要其他库才能运行。

在Java中,所有内容都是“动态”链接的。


3
.o 文件不是一个完整的可执行文件,也没有与任何库链接。链接操作将在之后进行。 - user395760

1
简而言之:Java程序被编译为Java字节码,需要一个单独的解释器(Java虚拟机)来执行。
并不能百分之百地保证c编译器生成的.o文件比Java编译器生成的.class文件更小。这完全取决于编译器的实现。

1

.o.class文件大小差异的关键原因之一是Java字节码比机器指令高级一些。当然,它仍然是相当低级的东西,但这会产生影响,因为它有效地压缩了整个程序。(C和Java代码都可以在其中有启动代码。)

另一个区别是Java类文件通常表示相对较小的功能块。虽然可能存在映射到更小块的C对象文件,但通常更常见的是将更多(相关)功能放入单个文件中。作用域规则的差异也可以强调这一点(C实际上没有任何对应于模块级别作用域的东西,但它确实有文件级别作用域;Java的包级作用域适用于多个类文件)。如果比较整个程序的大小,则可以得到更好的度量。

就“链接”大小而言,Java 可执行 JAR 文件通常比较小(在给定的功能级别下),因为它们是经过压缩传输的。相对来说,C 程序很少以压缩形式传输。(标准库大小也有所不同,但因为 C 程序可以指望除 libc 之外的其它库存在,而且 Java 程序可以访问巨大的标准库,所以这两者可以算作持平。挑出优劣显得有些棘手。)
然后,还有调试信息的问题。特别是,如果您编译了一个带有 IO 的 C 程序,并开启了调试,您将会得到关于标准库中包含的类型的大量信息,仅仅是因为过滤掉这些信息太麻烦了。Java 代码只会在对象文件中提供关于实际编译代码的调试信息,因为它可以指望相关信息在目标文件中可用。这是否会改变代码的实际大小?答案是否定的。但它可能会对文件大小产生重大影响。
总的来说,我认为很难比较 C 和 Java 程序的大小。或者说,你可以比较它们,但很难学到有用的信息。

1

大多数(高达90%的简单函数)ELF格式.o文件是垃圾代码。对于一个包含单个空函数体的.o文件,您可以期望如下大小分解:

  • 1%代码
  • 9%符号和重定位表(用于链接的必需品)
  • 90%头部开销,编译器和/或汇编器存储的无用版本/供应商注释等。

如果您想要查看已编译的C代码的真实大小,请使用size命令。


0

Java被编译成一种与机器无关的语言。这意味着在编译后,Java虚拟机(JVM)会在运行时将其翻译成机器可读的指令。C被编译为机器指令,因此所有二进制代码都是为了在目标机器上运行程序。

由于Java被编译为一种与机器无关的语言,特定机器的具体细节由JVM处理。(即C具有机器特定的开销)

这就是我对它的理解方式 :-)


-1

可能有几个原因:

  • Java类文件根本没有包含初始化代码。它只有一个类和一个函数 - 非常小。相比之下,C程序具有某种程度的静态链接初始化代码,可能还有DLL thunk。
  • C程序还可以将节对齐到页面边界 - 这将立即增加4kb的程序大小,以确保代码段从页面边界开始。

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