由浅入深的JVM学习(一)
首先我们看一张图,来大概知道一下JVM的结构:
有上图可以看到,JVM(java虚拟机)由3部分组成,类加载器子系统,JVM运行时数据区,执行引擎。
那么各个子系统有什么作用呢?我们来看下面的这个简单的代码:
public class App { public int add(int a,int b){ int c = (a+b)*100; return c; } public static void main(String[] args) { App app = new App(); int add = app.add(1, 2); System.out.println(add); } }
以上这段代码,调用add()方法,把1,2传入,获得结构是300。那么它是怎么被jvm加载的呢?
我们在idea写完代码之后,进行编译,运行。在这过程中,我们其实是调用了javac,和java的指令去编译和运行。
当我们调用javac时,我们会得到一个App.class的文件。我们称之为,字节码文件。
那我们看看App.class这个字节码文件长什么样子:
编译之后,我们可以得到一个如上图一样的十六进制字节码文件。
那么类加载器就去加载这个字节码文件,类加载器的加载机制是什么样的呢?
1.加载:
主要是将.class文件中的二进制字节流加载到jvm中。
2.验证:
验证第一步加载阶段获得的二进制字节流.class文件是否符合jvm规范。
3.准备:
准备阶段是给static变量分配内存(方法区中),并设置初始值
4.解析:
虚拟机将常量池的符号引用替换成直接引用。(什么是符号引用,什么是直接引用,这里做一下说明)
解析阶段就更抽象了,稍微说一下,因为不太重要,有两个概念,符号引用,直接引用。说的通俗一点但是不太准确,比如在类A中调用了new B();大家想一想,我们编译完成.class文件后其实这种对应关系还是存在的,只是以字节码指令的形式存在,比如 "invokespecial #2" 大家可以猜到#2其实就是我们的类B了,那么在执行这一行代码的时候,JVM咋知道#2对应的指令在哪,这就是一个静态的家伙,假如类B已经加载到方法区了,地址为(#f00123),所以这个时候就要把这个#2转成这个地址(#f00123),这样JVM在执行到这时不就知道B类在哪了,就去调用了。(说的这么通俗,我都怀疑人生了).其他的,像方法的符号引用,常量的符号引用,其实都是一个意思,大家要明白,所谓的方法,常量,类,都是高级语言(Java)层面的概念,在.class文件中,它才不管你是啥,都是以指令的形式存在,所以要把那种引用关系(谁调用谁,谁引用谁)都转换为地址指令的形式。好了。说的够通俗了。大家凑合理解吧。这块其实不太重要,对于大部分coder来说,所以我就通俗的讲了讲。
5.初始化:
根据程序中的赋值语句,主动为类变量赋值。
这一块其实就是调用类的构造方法,注意是类的构造方法,不是实例构造函数,实例构造函数就是我们通常写的构造方法,类的构造方法是自动生成的,生成规则:
static变量的赋值操作+static代码块
按照出现的先后顺序来组装。
注意:1 static变量的内存分配和初始化是在准备阶段.2 一个类可以是很多个线程同时并发执行,JVM会加锁保证单一性,所以不要在static代码块中搞一些耗时操作。避免线程阻塞。
类加载器的种类:
1.启动类加载器(Bootstrap ClassLoader):
最顶层的类加载器,负责加载JAVA_HOME\lib目录中的,或者通过-Xbootclasspath 参数指定加载路径,且被虚拟机认可(按文件名识别,如rt.jar)的类
2.扩展类加载器(Extension ClassLoader):
负责加载JAVA_HOME\lib\ext 目录中的,或者通过java.ext.dirs系统变量指定路径中的类库
3.应用类加载器(Application ClassLoader):
也叫做系统类加载器,可以通过getSystemClassLoader()获取,负责加载用户路径(classpath)上的类库。
如果没有自定义类加载器的话,这个类加载器就是默认的类加载器。
类加载器的加载步骤:
(1)AppClassLoader查找资源时,不是首先查看自己的地盘是否有这个字节码文件,而是委托给父类加载器ExtClassLoader。
当然这里有一个假定,就是在AppClassLoader的缓存中,没有找到目标class。比方说第一次加载这个目标类,缓存中肯定没有这个类。
(2)ExtClassLoader查找资源时,也不是首先查看自己的地盘是否有这个字节码文件,而是直接委托给父类加载器BootstrapClassLoader。
(3)如果父类加载器BootstrapClassLoader在它的地盘上找到,并加载成功,则直接返回。反过来如果在JVM的核心地盘——%sun.boot.class.path% 中没有找到。
则回到ExtClassLoader。
(4)如果ExtClassLoader在它的地盘找到,并加载成功,则直接返回。反过来如果在ExtClassLoader的地盘——%java.ext.dirs% 中没有找到。则回到AppClassLoader自己的地盘。
(5)于是乎,逗了一大圈,终于回到了自己的地盘。还附带了两条件,就是前面的老大们没有搞定,否则也没有AppClassLoader啥事情了。
(6)AppClassLoader在自己的地盘找到,这个地盘就是%java.class.path%路径下查找。找到就返回。
(7)最终,如果没有找到,就抛出异常了。
这个过程,就是一个典型的双亲委托机制的一次执行流程。
双亲委派模型:
双亲委派模型原理:
如果一个类加载器收到了类加载的请求,他首先不会自己尝试去加载这个类,而是委托父类加载器去加载,每一层次的类加载器都是如此,
因此所有的类的加载请求都会被传送到最顶层的类加载器(BootstrapClassLoader)。只有当父类加载器无法加载成功,并反馈失败的时候,
子类加载器才会试着去加载。
问:那么为什么要使用双亲委派这个机制呢?有什么作用呢?
因为这样可以避免重复加载,父类加载器已经加载了该类的时候,子类加载器就没有必要再去加载了,双亲委派机制也就构成了类的沙箱机制。
当我们自己创建一个java.lang包,创建一个String类的时候,这个String类就不起作用,因为父类加载器已经加载了java.lang.String。
这样也避免了某些坏人想要改JVM里面的类的想法。
接下来就解析一下类的字节码文件:
这么一团东西到底代表着什么意思呢?别急,它是有模板的,具体那几个字节代表什么意思,都是有模板依据的:
1.根据上图的我们知道第一个值是magic(魔数)占用了4个字节(u4),我们通过字节码文件可以看出前4个字节是ca fe ba be。这个东西就类似于用来标记我们
这个字节码文件的有效性。为什么用ca fe ba be来标识,据说是高斯林(java之父)喜欢喝咖啡
2.第二个是小版本号,占u2,那就是00 00
3.第三个是大版本号,占u2,那就是00 31
可以看出jdk为5。
4.常量池的数量,占u2,00 2a。转换成10进制就是42。那说明接下来的cp_info有42个字节。而cp_info也有自己的格式,此处就不深入了。有兴趣的同学可以自行查找学习。
那App.class字节码文件通过类加载器加载进内存之后,就会进入到JVM运行时数据区。
运行时数据区分了很多块。有虚拟机栈,本地方法栈,程序计数器。堆,方法区。
从上图可以看出,虚拟机栈,本地方法栈,程序计数器 属于线程私有数据。所谓线程私有数据,就是只一个线程有一份数据。
堆,方法区 属于线程共享数据,即这些区域的数据是线程公用的。
本地方法栈(线程私有):登记native方法,在Execution Engine执行时加载本地方法库
程序计数器(线程私有):就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),
由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
Java(虚拟)栈(线程私有): Java线程执行方法的内存模型,一个线程对应一个栈,每个方法在执行的同时都会创建一个栈帧
(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致
方法区(线程共享):类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。
简单说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中,
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
JDK版本差异
元数据区:元数据区取代了永久代(jdk1.8以前),本质和永久代类似,都是对JVM规范中方法区的实现,区别在于元数据区并不在虚拟机中,而是使用本地物理内存,永久代在虚拟机中,永久代逻辑结构上属于堆,但是物理上不属于堆,堆大小=新生代+老年代。元数据区也有可能发生OutOfMemory异常。
Jdk1.6及之前: 有永久代, 常量池在方法区
Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池在堆
Jdk1.8及之后: 无永久代,常量池在元空间
元数据区的动态扩展,默认–XX:MetaspaceSize值为21MB的高水位线。一旦触及则Full GC将被触发并卸载没有用的类(类对应的类加载器不再存活),然后高水位线将会重置。新的高水位线的值取决于GC后释放的元空间。如果释放的空间少,这个高水位线则上升。如果释放空间过多,则高水位线下降。
为什么jdk1.8用元数据区取代了永久代?
官方解释:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
JVM执行原理:
JVM指令集详解: 变量到操作数栈: iload,iload_,lload,lload_,fload,fload_,dload,dload_,aload,aload_ 操作数栈到变量: istore,istore_,lstore,lstore_,fstore,fstore_,dstore,dstor_,astore,astore 常数到操作数栈 bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_,lconst_,fconst_,dconst_ 把数据装载到操作数栈 baload,caload,saload,iaload,laload,faload,daload,aaload 从操作数栈存存储到数组: bastore, castore,sastore,iastore,lastore,fastore,dastore,aastore 操作数栈管理 pop,pop2,dup,dup2,dup_xl,dup2_xl,dup_x2,dup2_x2,swap 运算与转换: 加:iadd,ladd,fadd,dadd • 减:is ,ls ,fs ,ds • 乘:imul,lmul,fmul,dmul • 除:idiv,ldiv,fdiv,ddiv • 余数:irem,lrem,frem,drem • 取负:ineg,lneg,fneg,dneg • 移位:ishl,lshr,iushr,lshl,lshr,lushr • 按位或:ior,lor 按位与:iand,land • 按位异或:ixor,lxor 类型转换: i2l,i2f,i2d,l2f,l2d,f2d(放宽数值转换) i2b,i2c,i2s,l2i,f2i,f2l,d2i,d2l,d2f(缩窄数值转换) 有条件转移 ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnonnull,if_icmpeq,if_icmpene, if_icmplt,if_icmpgt,if_icmple,if_icmpge,if_acmpeq,if_acmpne,lcmp,fc mpl,fcmpg,dcmpl,dcmpg 复合条件转移: tableswitch,lookupswitch 无条件转移: goto,goto_w,jsr,jsr_w,ret
我们可以通过javap的命令去获取执行的程序:
我们可以通过javap -v App.class > d:/app.log 把执行过程输出到d盘,内容如下:
Classfile /F:/spring/JVM01/target/classes/com/takey/App.class Last modified 2020-12-1; size 690 bytes MD5 checksum c7150fcd871c2543e218d3783c62cb52 Compiled from "App.java" public class com.takey.App minor version: 0 major version: 49 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #7.#28 // java/lang/Object."<init>":()V #2 = Class #29 // com/takey/App #3 = Methodref #2.#28 // com/takey/App."<init>":()V #4 = Methodref #2.#30 // com/takey/App.add:(II)I #5 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream; #6 = Methodref #33.#34 // java/io/PrintStream.println:(I)V #7 = Class #35 // java/lang/Object #8 = Utf8 <init> #9 = Utf8 ()V #10 = Utf8 Code #11 = Utf8 LineNumberTable #12 = Utf8 LocalVariableTable #13 = Utf8 this #14 = Utf8 Lcom/takey/App; #15 = Utf8 add #16 = Utf8 (II)I #17 = Utf8 a #18 = Utf8 I #19 = Utf8 b #20 = Utf8 c #21 = Utf8 main #22 = Utf8 ([Ljava/lang/String;)V #23 = Utf8 args #24 = Utf8 [Ljava/lang/String; #25 = Utf8 app #26 = Utf8 SourceFile #27 = Utf8 App.java #28 = NameAndType #8:#9 // "<init>":()V #29 = Utf8 com/takey/App #30 = NameAndType #15:#16 // add:(II)I #31 = Class #36 // java/lang/System #32 = NameAndType #37:#38 // out:Ljava/io/PrintStream; #33 = Class #39 // java/io/PrintStream #34 = NameAndType #40:#41 // println:(I)V #35 = Utf8 java/lang/Object #36 = Utf8 java/lang/System #37 = Utf8 out #38 = Utf8 Ljava/io/PrintStream; #39 = Utf8 java/io/PrintStream #40 = Utf8 println #41 = Utf8 (I)V { public com.takey.App(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 5: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/takey/App; public int add(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=3 0: iload_1 1: iload_2 2: iadd 3: bipush 100 5: imul 6: istore_3 7: iload_3 8: ireturn LineNumberTable: line 8: 0 line 9: 7 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lcom/takey/App; 0 9 1 a I 0 9 2 b I 7 2 3 c I public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=3, args_size=1 0: new #2 // class com/takey/App 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: aload_1 9: iconst_1 10: iconst_2 11: invokevirtual #4 // Method add:(II)I 14: istore_2 15: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream; 18: iload_2 19: invokevirtual #6 // Method java/io/PrintStream.println:(I)V 22: return LineNumberTable: line 13: 0 line 14: 8 line 15: 15 line 16: 22 LocalVariableTable: Start Length Slot Name Signature 0 23 0 args [Ljava/lang/String; 8 15 1 app Lcom/takey/App; 15 8 2 add I } SourceFile: "App.java"
字节码文件加载进内存之后,当去执行main方法的时候,会产生一个main方法的主线程,也叫main线程。
该线程里面包含着,程序计数器(用于记录程序执行的执行到那个位置了),本地方法栈(跟虚拟机栈类似,用于执行native方法),
虚拟机栈(用于存放栈帧,一个方法一个栈帧,该App类里面有2个方法,一个是main方法,一个是add方法,按照栈的FILO特性,main先执行,再执行add,所以main在栈底)
栈帧里面包含着:局部变量表(存放该方法的局部变量,例如add方法中的,a,b,c),操作数栈(进行该方法里面的一些逻辑计算,例如add方法里面的a+b),方法出口(执行完add方法之后return回到main方法,继续执行main方法),动态链接 (指向常量池中该方法的引用,例如main方法中new App(),开始是符号引用(new #2),它要去常量池中去查找对应的直接引用(#2 = Class))