在初学 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 ) 文件。
| 类型 | 名称 | 说明 | 长度 |
|---|---|---|---|
| u4 | magic | 魔数,识别Class文件格式 | 4个字节 |
| u2 | minor_version | 副版本号 | 2个字节 |
| u2 | major_version | 主版本号 | 2个字节 |
| u2 | constant_pool_count | 常量池计算器 | 2个字节 |
| cp_info | constant_pool | 常量池 | n个字节 |
| u2 | access_flags | 访问标志 | 2个字节 |
| u2 | this_class | 类索引 | 2个字节 |
| u2 | super_class | 父类索引 | 2个字节 |
| u2 | interfaces_count | 接口计数器 | 2个字节 |
| u2 | interfaces | 接口索引集合 | 2个字节 |
| u2 | fields_count | 字段个数 | 2个字节 |
| field_info | fields | 字段集合 | n个字节 |
| u2 | methods_count | 方法计数器 | 2个字节 |
| method_info | methods | 方法集合 | n个字节 |
| u2 | attributes_count | 附加属性计数器 | 2个字节 |
| attribute_info | attributes | 附加属性集合 | 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” 名字的由来 ....