java实例从入门到精通(Java核心扎马系列第1话)
java实例从入门到精通(Java核心扎马系列第1话)(下文手撕字节码预告图)javap -v -l -c HelloWorld >> HelloWorld.txt 将HelloWorld.class文件进行反汇编。生成Java字节码文件。 -v 可以输出附加的信息。如:常量池 -c 对代码进行反汇编 >> 将内容重定向到指定文件由于字节码文件内容太多,可以重定向输出到指定.txt文件 PART3 分析Java字节码指令文件 本文暂时先对javap反编译的字节码文件进行分析,下文中,会直接根据.class文件手撕字节码。方便加深对JVM的理解。 package com.uec.practice; /** * HelloWorld 源代码 * @author: LeoLee * @date: 2019/10/28 17:21 */ public class HelloWorld { /** 静态变量。会
HelloWorld,你好世界
Hello World 中文意思是『你好 世界』。因为《The C Programming Language》中使用它做为第一个演示程序,非常著名,所以后来的程序员在学习编程时延续了这一习惯。
相信这点大家都是经历过的。那么现在我们抛出一个问题:对于Java程序而言,一个简单的HelloWorld程序到底是如何运行起来的呢?从.java文件的编写到输出屏幕上的Hello World,这中间经历了什么过程?文中会一一解开。
另外,考虑到读者应该都是有一定开发经验的技术人,所以本文中的HelloWorld程序 我稍微添加了一部分实例变量、静态变量、代码块、自定义方法等部分。旨在更有效地输出相关知识点。
PART1 HelloWorld程序源代码package com.uec.practice; /** * HelloWorld 源代码 * @author: LeoLee * @date: 2019/10/28 17:21 */ public class HelloWorld { /** 静态变量。会放在常量池中 */ static String ss; // 静态代码块 static { System.out.println("静态代码块1"); // 编译器会做优化 b = 10; ss = "静态代码块赋值字符串"; } /** 静态变量 */ static int b = 1; // 普通代码块 { System.out.println("普通代码块1"); // a 的顺序 与 变量的顺序可以倒置 顺序在编译期会进行自动优化 a = 12; b = 20; ss = "代码块赋值字符串"; } int a = 1; public HelloWorld(){ System.out.println("无参构造方法执行"); } public static void main(String[] args) { System.out.println("Hello World!"); System.out.println("静态变量 b = " HelloWorld.b); // 实例化 HelloWorld helloWorld = new HelloWorld(); System.out.println("实例变量 a = " helloWorld.a); // 求和 int sum = helloWorld.sum(helloWorld.a HelloWorld.b HelloWorld.b HelloWorld.b); System.out.println("求和结果: " sum); } /** * 求和 * 选四个参数的原因:区分字节码指令 iload 和 iload_0、iload_1、iload_2、iload_3 指令的不同。 * @param a * @param b * @param c * @param d * @return */ public int sum(int a int b int c int d){ return a b c d; } }
上面源代码的内容给出了:静态字符串变量ss、静态int变量b、静态代码块、实例变量b、实例代码块、无参构造方法、main()入口函数、自定义sum()函数。
PART2 如何手动编译为.class文件手动编译为.class文件
javac -g -verbose -encoding UTF-8 HelloWorld.java -g 生成所有调试信息 只有设置此配置项后续才可以生成局部变量表 -encoding <编码> 指定源文件使用的字符编码。部分操作系统环境下编码类型与UTF-8编码冲突
反汇编到字节码文件
javap -v -l -c HelloWorld >> HelloWorld.txt 将HelloWorld.class文件进行反汇编。生成Java字节码文件。 -v 可以输出附加的信息。如:常量池 -c 对代码进行反汇编 >> 将内容重定向到指定文件由于字节码文件内容太多,可以重定向输出到指定.txt文件
本文暂时先对javap反编译的字节码文件进行分析,下文中,会直接根据.class文件手撕字节码。方便加深对JVM的理解。
(下文手撕字节码预告图)
针对上图中内容的一些备注:
Java字节码指令后的 #数字,是指向静态常量池的位置 #数字。通过这个数字,我们可以递归地得到最终的常量。
LineNumberTable为调试器提供了用来指示Java源代码与字节码指令之间的对应信息。例如,Java源代码中的第9行对应于main方法中的字节码0,并且第10行对应于字节码8。
LocalVariableTable 是局部变量表。会显示 开始位置、长度、槽位、变量名、参数类型签名等信息。
想要充分理解上图中的内容,需要对照Java字节码指令理解。
下面介绍代码相关的核心字节码指令:
一个类如何在JVM中执行完成
JVM到底是如何加载HelloWorld 这个类的呢?
总的来说,虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
加载
它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,比如 jar 文件,class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。这块涉及到双亲委派模型。下文会谈到。
链接(验证、准备、解析)
验证(Verification)
这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
准备(Pereparation)
创建类或者接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显示初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。例如:不会执行putstatic、p
解析(Resolution)
将常量池中的符号引用(symbolic reference)替换为直接引用。涉及到类,接口,方法和字段等各方面的解析。符号引用,可以是任何形式的字面量,只要保证使用时可以无歧义的定位到目标即可。符号引用与内存布局无关,引用的目标不一定已经加载到内存。而直接引用,可以是直接指向目标的指针、相对偏移量或一个可以间接定位到目标的句柄。存在直接引用,引用的目标必然在内存中存在。
初始化阶段(initialization)
真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。初始化阶段是执行类构造器<clinit>方法的过程。父类的<clinit>优先于子类执行。
然后就是 使用、卸载。
关于双亲委派模型,简单说就是当加载器(Class-Loader)试图加载某个类型的时候,除非父类加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型,防止恶意第三方代码攻击。
加载工作是由Java类加载器来完成的。当JVM启动时,会使用下面三个类加载器:
Bootstrap类加载器:加载位于/jre/lib目录下的核心Java类库。它是JVM核心的一部分,并且使用本地代码编写。
扩展类加载器:加载扩展目录中的代码(比如/jar/lib/ext)。
系统类加载器:加载在CLASSPATH上的代码。
所以,HelloWorld类是由系统加载器加载的。当main方法执行时,它会触发加载其它依赖的类,进行链接和初始化。
最后一步,主类会方法main()帧被压入JVM堆栈,并且程序计数器(PC)也进行了相应的设置。然后,PC指示将println()帧压入JVM堆栈栈顶。当main()方法执行完毕会被弹出堆栈,至此执行过程就结束了。
PART4 典型案例操练下面抛出几道典型的面试题。请先自己思考。再看下面的解读。
1. 考核Java类加载顺序的问题
/** * 父类 */ public class LoadSequenceFather { public LoadSequenceFather(){ System.out.println("父类构造方法"); } { System.out.println("父类普通方法"); } static { System.out.println("父类静态方法"); } } /** * 子类 */ public class LoadSequence extends LoadSequenceFather{ public LoadSequence(){ System.out.println("自身构造方法"); } { System.out.println("自身普通方法"); } static { System.out.println("自身静态方法"); } public static void main(String[] args) { new LoadSequence(); } }
知识点解读:
按上述理解,类加载,首先加载静态属性与静态代码块,优先父类 (父类静态方法 ->自身静态方法 )。然后优先初始化父类,加载父类普通代码块,父类构造。(父类普通方法->父类构造方法)。最后实例化自身。(自身普通方法 -> 自身构造方法)
答案:
2. 考核Java数据类型的常量池问题
public class IntegerTest { public static void main(String[] args) { Integer i1 = 40; Integer i2 = 40; Integer i3 = 0; Integer i4 = new Integer(40); Integer i5 = new Integer(40); Integer i6 = new Integer(0); System.out.println("i1=i2\t" (i1 == i2)); System.out.println("i1=i2 i3\t" (i1 == i2 i3)); System.out.println("i4=i5\t" (i4 == i5)); System.out.println("i4=i5 i6\t" (i4 == i5 i6)); } } 该题的输出结果是什么? 思考,如果将40 换成 400 结果又是多少?
知识点解读:
1. javap -c -l IntegerTest。查看代码的字节码文件,我们不难看出Integer的赋值操作,底层其实调用的是Integer.valueOf方法。
我们进一步看 Integer.valueOf方法的源码:
/** * This method will always cache values in the range -128 to 127 * inclusive and may cache other values outside of this range. * * @param i an {@code int} value. * @return an {@code Integer} instance representing {@code i}. * @since 1.5 */ public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i (-IntegerCache.low)]; return new Integer(i); }
可以看出 IntegerCache 的范围 -128 到127。如果我们赋值40的话,那么直接会从IntegerCache 中取,不会创建新的对象。如果是400的话,超出范围,会重新创建对象。答案可想而知。
答案:
3. 考核Java字符串类型的常量池问题
public class StringTest { public static void main(String[] args) { String s1 = "DouDou"; String s2 = "DouDou"; System.out.println("s1==s2 : " (s1 == s2)); String s3 = new String("DouDou"); String s4 = new String("DouDou"); System.out.println("s3==s4 : " (s3 == s4)); System.out.println("字符串加法比较:" ((s1 s2) == (s1 s2))); StringBuilder stringBuilder = new StringBuilder(s1); System.out.println("StringBuilder的比较:" (stringBuilder.append(s3) == stringBuilder.append(s2))); // 会创建多少个对象? 5 个 String str1 = new String("A" "B") ; // 又会创建多少个对象? String str2 = new String("ABC") "ABC" ; } }
知识点解读:
1. 字符串的直接赋值,会直接从字符串常量池中获取相应的字符串。
2. 创建字符串变量,会创建对象引用。
3. 字符串的 " " 运算,底层会将每次 ,转为创建一个新的StringBuilder 对象。在大量字符串拼接的情况下,效率很低。
4. 字符串常量池设计的思想。
字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能。
JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化,为字符串开辟一个字符串常量池,类似于缓存区。
创建字符串常量时,首先判断字符串常量池是否存在该字符串,若存在,返回引用实例,不存在,实例化该字符串并放入池中。之所以可以进行该优化的基础是:因为字符串是不可变的,可以不用担心数据冲突进行共享。
运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用 这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收。
答案:
String str1 = new String("A" "B") ;
语句创建的对象有:
1. 字符串常量池 "A"
2. 字符串常量池 "B"
3. 字符串常量池 "AB"
4. 引用str1
5. 对象new String("A" "B")
String str2 = new String("ABC") "ABC" ;
语句创建的对象有:
1. 字符串常量池"ABC"
2. 对象new String("ABC")
3. 引用str2
PART5 工具推荐https://github.com/zxh0/classpy
界面简洁。将二进制数据和字节码对比显示,对于学习字节码方便很多。
PART6 结语这篇文章通过最简单的HelloWorld程序,从源代码,到.class文件,到Java字节码文件,再到被JVM加载,执行,打印出预期的结果,介绍了整个流程。对简单的Java的字节码文件进行了简要解析,同事聊了下Java类加载过程。下一篇我们直接对.class文件内容进行字节码的翻译。加深认知。