一、JVM与java体系结构
1 虚拟机的结构
2 java的编译
有句话说java是半编译半解释型语言,因为从*.java到*.class文件的过程是编译。而*.class文件的运行是解释。
但其实*.class文件的运行过程是半编译半解释的。有些需要反复用到的字节码是直接编译成机器指令来执行的。就好比域名的解析,常用访问的域名解析是直接通过本地域名服务器,不常用的才会自顶向下层层解析。例如for循环中的代码是要重复利用的,就把这段字节码编译成机器指令缓存起来,每次循环都调用缓存区中的机器指令,而不需要进行解释。如图:
3 jvm的指令集
指令集架构有基于栈的指令集架构和基于寄存器的指令集架构。
jvm中使用的就是基于栈的指令集架构。它的好处是不需要硬件支持,可移植性高,零地址指令多。但性能不及寄存器式架构(如x86汇编)
栈式架构必然是大量使用到栈。例如我们将如下代码进行反汇编:
package com.spd.jvm;
public class Test {
public static void main(String[] args) {
int i = 2;
int j = 3;
int k = 2 + 3;
}
}
得到的结果是:
iconst_2 // 将int型数2压栈
istore_1 // 将栈顶int型存入本地变量1
iconst_3 // 将int型数3压栈
istore_2 // 将栈顶int型存入本地变量2
iconst_5 // 将int型数5压栈
istore_3 // 将栈顶int型存入本地变量3
return // 从当前方法返回void
而对x86汇编而言是这么写的:
mov ds : [0], 2 ; 设ds : [0]为i
mov ds : [4], 3 ; 设ds : [4]为j
mov eax, ds : [0]
add eax, ds : [4]
mov ds : [12], ax ; 设ds : [12]为k,k = ax = i + j
ret
由此可见,确实比起x86汇编更多使用到了栈。另外也能看出来同一功能栈式架构比寄存器式架构需要用更多指令,但是他的指令集更小。
二、类加载子系统
1 类加载子系统的结构
2 类加载子系统的作用
类加载子系统只负责类的加载,而类能否运行由执行引擎决定。
3 类加载的加载过程
① 通过一个类的全限定名获取定义此类的二进制字节流
② 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
③ 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
4 类加载的链接过程
① 验证:验证魔数及版本号。
② 准备:为类变量分配内存并设置为他们的初始值。初始化阶段才赋值。(不会分配final修饰的static,因为他们在编译过程就已经分配了。准备阶段只会进行显式初始化。也不会为实例变量分配初始化。)
package com.spd.jvm;
public class Test {
private static int age = 18; // prepare: age = 0 -> initial: age = 18
private static char sex = '男'; // prepare: sex = '\0' -> initial: sex = '男'
public static String name = "spd"; // prepare: name = null -> initial: name = "spd"
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
③ 解析:将常量池中的符号引用转换成直接引用。
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能简介定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
5 类加载的初始化过程
① <clinit>()方法:
初始化过程实际上就是执行类构造器方法<clinit>()的过程。
该方法不等同于类的构造器(类的构造器是<init>()函数),他不需要定义,是javac编译器自动收集类中的所有类变量类中的所有变量的赋值动作和静态代码块中的语句合并而来。因而如果类中没有静态代码块也没有静态变量,那就没有<clinit>()方法。
有父类,则先执行父类<clinit>()
虚拟机必须保证一个类的<clinit>()方法在多线程中被同步加锁。
package com.spd.jvm;
public class Test {
private static int a = 1;
static {
a = 2;
b = 20;
}
public static int b = 10;
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
对这段代码反汇编查看字节码中的<clinit>()方法:
iconst_1 // 将int型1压入栈顶
putstatic #5 // 为指定类的静态域赋值,此处#5即为a(不知道需不需要弹栈)
iconst_2 // 将int型2压入栈顶
putstatic #5 // 为指定类的静态域赋值
bipush 20 // 将单字节常量值(-128 ~ 127)压入栈顶,此处为20
putstatic #6 // 为指定类的静态域赋值,此处#6即为b
bipush 10 // 将单字节常量值(-128 ~ 127)压入栈顶,此处为10
putstatic #6 // 为指定类的静态域赋值
return // 从当前方法返回空
如上便是<clinit>()方法的作用。那为什么静态代码块中b的赋值语句明明在b的声明语句之前,静态代码块还能给b赋值呢?因为准备阶段就给静态变量分配好了内存空间,赋值的过程是在其后的初始化过程中完成的。
prepare :
a = 0, b = 0;
initial:
a = 1;
static {
a = 2;
b = 3;
}
b = 4;
但是可以在静态代码块中前向赋值,却不可以前向调用。例如如下代码就会报错:
package com.spd.jvm;
public class Test {
private static int a = 1;
static {
a = 2;
b = 20;
System.out.println(a);
System.out.println(b); // <- 此处报错
}
public static int b = 10;
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
6 类加载器的分类
jvm支持两种类型的类加载器,引导类加载器和自定义类加载器。凡是派生于抽象类ClassLoader的类加载器都被划分为自定义类加载器。(扩展类加载器和系统类加载器等)
引导类加载器、扩展类加载器、系统类加载器。三者并不存在上层下层关系,更不存在继承关系。而是呈现包含关系的。就像文件的层级目录一样。
另外引导类加载器是用c/c++语言编写的,其他类加载器都是用java写的。
我们可以用代码演示他们之间的异同。
package com.spd.jvm;
public class Test {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader sys = ClassLoader.getSystemClassLoader();
System.out.println(sys);
// 获取其上层:扩展类加载器
ClassLoader ext = sys.getParent();
System.out.println(ext);
// 试图获取其上层:引导类加载器
ClassLoader bootstrap = ext.getParent();
System.out.println(bootstrap); // 获取不到,打印空
// 自定义类默认使用系统类加载器
ClassLoader loader1 = Test.class.getClassLoader();
System.out.println(loader1);
System.out.println(loader1 == sys);
// java核心api中的类是用引导类加载器加载的。
ClassLoader loader2 = String.class.getClassLoader();
System.out.println(loader2);
}
}
① 引导类加载器:由C/C++实现,嵌套在JVM内部。他用来加载jdk的核心类库。并不继承自ClassLoader,没有父加载器。只加载包名为java javax sun开头的类。
运行以下程序,获得引导类加载起所能加载地api的路径。
package com.spd.jvm;
import sun.misc.Launcher;
import java.net.URL;
public class Test {
public static void main(String[] args) {
System.out.println("-------引导类加载器-------");
/* 获得引导类加载起所能加载地api的路径。 */
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url.toExternalForm());
}
}
}
运行结果:
-------引导类加载器-------
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/resources.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/rt.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/sunrsasign.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jsse.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jce.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/charsets.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/lib/jfr.jar
file:/C:/Program%20Files/Java/jdk1.8.0_191/jre/classes
从这些路径中随便找一个jar包,发现其中有一个类,名为Provider,进行测试:
package com.spd.jvm;
import java.security.Provider;
public class Test {
public static void main(String[] args) {
ClassLoader classLoader = Provider.class.getClassLoader();
System.out.println(classLoader);
}
}
输出null,证明该类使用引导类加载器。
② 扩展类加载器:虚拟机自带、java语言编写。父加载器为启动类加载器。用来从jdk的安装目录jre/lib/ext子目录下加载类库。自定义类放在该目录中也是同等待遇。
一个套路。
package com.spd.jvm;
import sun.security.ec.CurveDB;
public class Test {
public static void main(String[] args) {
System.out.println("-------扩展类加载器-------");
String dirs = System.getProperty("java.ext.dirs");
/* 获取路径 */
for (String path : dirs.split(";")) {
System.out.println(path);
}
/* 从上面获取到的路径里面随便取一个查看他的类加载器 */
ClassLoader classLoader = CurveDB.class.getClassLoader();
System.out.println(classLoader);
}
}
③ 系统类加载器:派生于ClassLoader,负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。
证明过程略。
日常开发中,上文提到的三种类加载器就够用了。必要时也可以使用自定义类加载器。
自定义类加载器应用于:隔离加载类、修改类的加载方式(如动态加载)、扩展加载源(可以从数据库等其他来源加载类)、防止源码泄露(反反编译,字节码文件经过加密,加载类时需进行解密)等。
7 双亲委派机制
若某个憨批在项目中建了一个包,名为java.lang,其中还有有个类还刚好叫String,那此时运行程序时会加载jdk中的String还是憨批自己写的String呢?不妨试试。(憨批竟是我自己)
package java.lang;
public class String {
static {
System.out.println("I am not String in jdk.");
}
}
package com.spd.jvm;
public class Test {
public static void main(String[] args) {
String str = new String();
System.out.println("Hello world!");
}
}
Hello world!
Process finished with exit code 0
运行结果表明加载器加载的是jdk中的String。这就是通过双亲委派机制实现的。
也就是一个类加载器收到了类加载请求他并不会立即去加载,而是委托给父类加载器去加载。若父类能加载成功那就完事了,否则交给子类加载器去加载。
所以上面那个例子的过程应该一看是String类是自定义的,先交给系统类加载器去执行,系统类加载器委托给扩展类加载器,扩展类加载器又委托给引导类加载器,最终由引导类加载器一看包名是java打头,于是进行加载。
如果是一般的自定义类,那就是从系统到引导层层委托,完了引导扩展都不太行,最终还是交给系统类加载器管。
那双亲委派机制的好处是啥?一个是避免类的重复加载,再一个是避免核心api被随意篡改(比如憨批自定义的String)
另外在提一嘴沙箱安全机制,自定义一个java.lang.String,并写上main方法,运行后说没有main方法,因为加载的是jdk中的String类,这是为了保护Java核心源代码,这就是基于沙箱安全机制。
8 类的主动使用与被动等待
两个类是否相等首先需要判断完整类名是否相等,再判断对应的ClassLoader是否相同。
换句话说,若两个类对象来自于同一个class文件,而具有不同的类加载器,那也算是不相等的类对象。
类的主动使用和被动使用区别在于会不会导致类的初始化。也就是说被动使用是不会执行<clinit>()方法的。