不学习底层知识可能不会阻碍你成为一个称职的程序员,但也许会阻碍你成为一个优秀的程序员。我所理解的底层知识,是指编程或开发所依赖的平台(或者框架、工具)的知识。对于 Java 开发者来说,虚拟机、字节码就是其底层知识。
这篇文章我们以输出 "Hello, World" 来开始字节码之旅,如果之前没有怎么接触过字节码的话,这篇文章应该能够让你对字节码有一个最基本的认识
新建一个 Hello.java 文件,源码如下:
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, World");
}
}
Java 从源文件到执行的过程如下图所示
JDK 工具 javac(java 编译器)帮我们完成了源文件编译成 JVM 可识别的 class 文件的工作。在命令行中执行javac Hello.java
,可以看到生成了 Hello.class 文件。用xxd
命令以 16 进制的方式查看这个 class 文件。
xxd Hello.class
00000000: cafe babe 0000 0034 0022 0a00 0600 1409 .......4."......
00000010: 0015 0016 0800 170a 0018 0019 0700 1a07 ................
00000020: 001b 0100 063c 696e 6974 3e01 0003 2829 .....<init>...()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V...Code...LineN
00000040: 756d 6265 7254 6162 6c65 0100 124c 6f63 umberTable...Loc
00000050: 616c 5661 7269 6162 6c65 5461 626c 6501 alVariableTable.
00000060: 0004 7468 6973 0100 074c 4865 6c6c 6f3b ..this...LHello;
class 文件的头四个字节称为魔数(Magic Number),可以看到 class 的魔数为 0xCAFEBABE。很多文件都以魔数来进行文件类型的区分,比如 PDF 文件的魔数是%PDF
-(16进制0x255044462D
),png 文件的魔数是\x89PNG
(0x89504E47)。文件格式的制定者可以自由的选择魔数值,只要魔数值还没有被广泛的采用过且不会引起混淆即可。
Java 早期开发者选用了这样一个浪漫气息的魔数,高司令有解释这一段 轶事。这个魔数值在 Java 还称为 Oak 语言的时候就已经确定下来了。
这个魔数是 JVM 识别 .class 文件的标志,虚拟机在加载类文件之前会先检查这四个字节,如果不是 0xCAFEBABE 则拒绝加载该文件,更多关于字节码格式的说明,我们会在后面的文章中慢慢介绍。
类文件是二进制块,想直接与它打交道比较艰难,但是很多情况下我们必须理解类文件。比如服务器上的接口出了 bug,重新打包部署以后问题并没有解决,为了找出原因你可能需要看一下部署以后的 class 文件究竟是不是我们想要的。还有一种情况跟你合作的开发商跑路了,只给你留下一堆编译过的代码,没有源代码,当出 bug 时我们需要研究这些类文件,看问题出在哪里。好在 JDK 提供了专门用来分析类文件的工具:javap
,用来方便的窥探 class 文件内部的细节。javap 有比较多的参数选项,其中-c -v -l -p -s
是最常用的。
Usage: javap <options> <classes>
where possible options include:
-help --help -? Print this usage message
-version Version information
-v -verbose Print additional information
-l Print line number and local variable tables
-public Show only public classes and members
-protected Show protected/public classes and members
-package Show package/protected/public classes
and members (default)
-p -private Show all classes and members
-c Disassemble the code
-s Print internal type signatures
-sysinfo Show system info (path, size, date, MD5 hash)
of class being processed
-constants Show final constants
-classpath <path> Specify where to find user class files
-cp <path> Specify where to find user class files
-bootclasspath <path> Override location of bootstrap class files
最常用的选项是-c
,可以对类进行反编译。执行javap -c Hello
的输出结果如下
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello, World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
上面代码前面的数字表示从方法开始算起的字节码偏移量
java/lang/Object."<init>":()V
,也即构造器函数到此为止,默认构造器函数就讲完了,接下来,我们来看 9 ~ 14 行的 main 函数
默认情况下,javap 会显示访问权限为 public、protected 和默认(包级 protected)级别的方法,加上 -p 选项以后可以显示 private 方法和字段
javap 加上 -v 参数的输出更多详细的信息,比如栈大小、方法参数的个数。
public Hello();
stack=1, locals=1, args_size=1
public static void main(java.lang.String[]);
stack=2, locals=1, args_size=1
为什么Hello()
和main()
的args_size
都等于1
呢?明明 Hello 的构造器函数没有参数的呀?对于非静态函数,this对象会作为函数的隐式第一个参数,所以Hello()
的args_size=1
对于静态main
函数,不需要this
对象,它的参数就是String[] args
这个数组,也等于1
javap 还有一个好用的选项 -s,可以输出签名的类型描述符。我们可以看下 Hello.java 所有的方法签名
javap -s Hello
Compiled from "Hello.java"
public class Hello {
public Hello();
descriptor: ()V
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
}
可以看到 main 函数的方法签名是 ([Ljava/lang/String;)V
。JVM 内部使用方法签名与我们日常阅读的方法签名不太一样,但是后面会频繁遇到,主要分为两部分字段描述符和方法描述符。
字段描述符(Field Descriptor),是一个表示类、实例或局部变量的语法符号,它的表示形式是紧凑的,比如 int 是用 I 表示的。完整的类型描述符如下表
方法描述符(Method Descriptor)表示一个方法所需参数和返回值信息,表示形式为( ParameterDescriptor* ) ReturnDescriptor
。ParameterDescriptor 表示参数类型,ReturnDescriptor表示返回值信息,当没有返回值时用V表示。比如方法Object foo(int i, double d, Thread t)
的描述符为(IDLjava/lang/Thread;)Ljava/lang/Object
;
这篇文章讲解了一个输出 "Hello, World" 的字节码的细节,一起来回顾一下要点: