杂谈:Java 为何可以跨平台?

808 阅读9分钟

在初学 Java 时,老师总不忘告诉我们 Java 所具备的一个优点:跨平台特性。那反过来,为什么 C/CPP 语言编译出的程序就不能跨平台运行呢?

1. 从源代码到可执行文件

无论你是用何种高级语言编写程序,它们 "最终的归宿" 一定是纯二进制码编制成的机器语言。以 C 语言为例,现在市面上已有足够多优秀的 IDE(比如 Visual Studio,Code Blocks)让我们轻松地编写 .c 文件,我们要做的就是按照 C 语言的语法编写逻辑,然后点一下 run/build 按钮,这个源文件就被自动编译且被执行。比如说一个这样的 hello.c 程序:

#include <stdio.h>
int main(){printf("hello,c!");return 0;}

IDE 替我们完成了这样的流程: 这个流程图表述了 Hello.c 经过预处理,编译,汇编,链接,最后生成了一个可执行的直接二进制文件的过程。

2. 汇编语言

在计算机诞生之初(到现在),计算机只认识 0 或 1。那个时代的科学家只能通过纸带打孔的方式来告诉计算机:有孔代表 1,反之则为 0。这样一来,终于实现了人机交互,但显然这种编程的效率实在是太低了。好在计算机科学家们逐渐发现,无论程序多么复杂,构成它们的计算机指令数量是有限的。而只要包装这些重要指令需要被芯片固定识别就足够了。

汇编语言 ( assembly language ) 应运而生。它是一种用于电子计算机、微处理器、微控制器,或其他可编程器件的低级语言。在不同的设备中,汇编语言对应着不同的机器语言指令集。

针对于这些固定的指令,使用符号来代替冗长的二进制代码,让代码的可读性发生了巨大的提升。在汇编程序完成之后,需要再有一个专门的程序(汇编器)来把编写的汇编程序编译成 0 和 1 。这样计算机也可以识别了,而汇编语言本身也方便了程序的编写和阅读。

但是新的问题又出现了:不同公司所生产的 CPU 芯片,它们的指令集并不相同(比如 Intel 代表的复杂指令集阵营,ARM 代表的精简指令集阵营,不同公司设计指令集的思路是截然不同的)。简而言之,一种汇编语言专用于某种计算机系统结构,它还不像许多高级语言,源代码无法在不同系统平台之间正确编译。

3. 高级语言

随着程序的越来越复杂,当时的程序员们越来越迫切地希望能有一个崭新的编程语言:它不依赖于计算机硬件,人们无论在任何型号的计算机都可以使用统一的写法实现编程工作。终于在 1954 年,第一个完全意义上的高级编程语言 FORTRAN 诞生。从此之后,编程工作就脱离了特定机器的局限性。在第一个编程语言诞生至今,已经有共计几百种高级语言诞生,比如说 C,BASIC,CPP。

当我们使用 C 语言进行编程时,首先要使用编译器将源文件编译为汇编程序,再使用汇编器编译成 0,1。一个可执行文件就如此诞生了。

3.1 高级语言的局限性

尽管高级语言帮助程序员实现了跨硬件的飞跃,但是随着操作系统的流行,新的问题又出现了。这是同一份源代码 Hello.c 在不同平台的编译结果:

  • Windows 平台下的编译结果是 Hello.exe
  • Linux 平台下的编译结果是 Hello
  • Mac 平台下的编译结果是 Hello.out

这是不同平台所采用的编译器并不完全一致造成的。譬如说同一个 int 类型的数据,有些编译器会为其分配 2 字节空间,而另一些编译器会为其分配 4 字节的空间。显然,在此平台内正常运行的程序,在另一个平台就可能出现内存溢出的错误。

因此,对于需要在不同平台运行的软件工具来说,官网往往会提供这个软件源码文件下载包。开发人员需要下载这份源代码,并在各自的部署环境中编译成支持在本地运行的本地代码。

比如说,hadoop 官方也建议下载源码并使用编译器编译,然后再使用。

hadoop 是使用 Java 语言开发的,但是考虑到性能问题,有些操作并不适合使用 Java 来解决,所以就引入了 本地库 ( Native Libraries ) 的概念。说白了,就是hadoop 的某些功能,必须通过 JNT 来协调 Java 类文件和 Native 代码生成的库文件一起才能工作。Linux 系统要运行 Native 代码,首先要将源代码编译成目标CPU 架构的 .so 文件。而不同的处理器架构,需要编译出相应平台的动态库 .so 文件,才能被正确的执行。

此时,高级语言已经具备了一定的跨平台特性,至少程序员不用再针对不同的平台重新编写对应的代码了。不过一个问题仍然存在:程序员仍避免不了反复的编译步骤保证 "跨" 平台运行,这远没有达到 "一次编写,到处运行" 的标准 ( 这是 Java 提出的一句著名的口号 )。

4. 跨平台语言 Java 的诞生

那有没有一种方法,能够让高级语言实现完全意义上的跨平台运行呢?

Java 伴随着它的虚拟机 JVM 粉墨登场。和其它平台相关的语言相比,它最大的改进是:编译后生成的不再是纯二进制的机器指令,而是一个紧凑的,遵守《Java 虚拟机规范》写法的字节码文件 .class,从此彻底隔绝了平台和机器硬件的差异。

这极大的提高了 Java 程序员的开发和部署效率,因为他们只需要在任意平台的 javac 工具将 .java 代码编译成 .class 文件,就可以在任意平台下的 JVM 运行它。

随着虚拟机技术的发展,无关性还从平台引申到了编程语言的层面上 ( 指任何语言编写的源代码最终都可以被 JVM 执行 )。包括各种虚拟容器技术的发展,其核心思想也与 JVM 无异,那就是:适配器设计模式

4.1 JVM 机如何理解 Class 文件?

笔者在第一篇博客中曾简单提及了有关 .class 文件的内容:杂谈:食用 .class 文件的正确方式。想要 “窥探” .class 文件内部,我们需要借助一些工具来完成:笔者在这里安装了 NotePad++HexEditor_0.9.6_x64 插件。

下面给出一个简单的 JavaClass.java 文件:

class JavaClass {
	public static void main(String[] args){
		System.out.println("Hello Java");
	}	
}

使用 javac 生成 JavaClass.class 文件,通过附带16进制插件的 NotePad++ 打开它:

它包含了有关于 JavaClass 类的全部信息。其中,xx代表着1个字节。JVM 能够根据这些符号最后在控制台中输出 Hello Java,说明这些符号一定遵守着某些特定法则:即 《Java虚拟机规范》

4.2 简单了解 Class 文件的字节码结构

我们可以参照这个表结构来解读 HelloJava.class ( 或者是任何一个 .class ) 文件。

类型名称说明长度
u4magic魔数,识别Class文件格式4个字节
u2minor_version副版本号2个字节
u2major_version主版本号2个字节
u2constant_pool_count常量池计算器2个字节
cp_infoconstant_pool常量池n个字节
u2access_flags访问标志2个字节
u2this_class类索引2个字节
u2super_class父类索引2个字节
u2interfaces_count接口计数器2个字节
u2interfaces接口索引集合2个字节
u2fields_count字段个数2个字节
field_infofields字段集合n个字节
u2methods_count方法计数器2个字节
method_infomethods方法集合n个字节
u2attributes_count附加属性计数器2个字节
attribute_infoattributes附加属性集合n个字节

根据 Java 字节码结构表,就可以解析 NotePad++ 显示的16进制文件所涵盖的信息。.class文件只有两种数据类型:无符号数,还有表

数据类型定义说明
无符号数无符号数可以用来描述数字、索引引用、数量值或按照UTF-8编码构成的字符串值。其中无符号数属于基本的数据类型。以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节
表是由多个无符号数或其他表构成的复合数据结构。所有的表都以“_info”结尾。由于表没有固定长度,所以通常会在其前面加上个数说明。

我们还可以借助 javap 命令来分析。javap 是 JDK 自带的反编译工具,能够根据二进制 Class 文件解析出对应的 code 区、本地变量表、异常表和代码行偏移量映射表、常量池等等信息:

$ javap -verbose JavaClass.class

程序最终会返回:

Classfile .../JavaClass.class
  Last modified 2020-5-28; size 422 bytes
  MD5 checksum 75803102330020b05d871810e7dbe63c
  Compiled from "JavaClass.java"
class JavaClass
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // Hello Java
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // JavaClass
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               JavaClass.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               Hello Java
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               JavaClass
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  JavaClass();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello Java
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "JavaClass.java"

4.3 CAFE,BABE!

所有 .class 文件的前 4 个字节的值为0xCAFE BABE。这不是巧合,而是用于内部区分文件类型的标记,又称魔数。使用它而非文件拓展名区分文件类型的原因是出于安全目的考虑,因为用户可以任意地更改文件拓展名。

此外,这个魔数似乎了暗示了 “Java” 名字的由来 ....