# my-jvm **Repository Path**: panleiming/my-jvm ## Basic Information - **Project Name**: my-jvm - **Description**: JVM的学习 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2020-06-10 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README JVM学习 === # 目录 1. Java文件编译到JVM的过程 1. 源码到类文件 2. 类文件到虚拟机(类加载机制) 2. 类装载器ClassLoader 1. 分类 2. 加载原则 3. 运行时数据区(Run-Time Data Areas) 1. 方法区(Method Area) 2. 堆(Heap) 3. 虚拟机栈(Java Virtual Machine Stacks) 4. 程序计数器(The pc Register) 5. 本地方法栈(Native Method Stacks) 4. 结合字节码指令理解Java虚拟机栈和栈帧 5. Java对象内存布局 6. 垃圾回收(Garbage Collect) 1. 如何确定一个对象是垃圾 2. 传统垃圾收集算法 1. 标记-清除(Mark-Sweep) 2. 标记-整理(Mark-Compact) 3. 复制(Copying) 3. 分代收集算法 1. 内存模型 2. Survivor区详解 3. Old区详解 7. 实战 1. 内存溢出案例及观测 1. 8. 问题 1. 栈,方法区和堆的指针指向 2. 如何理解Minor/Major/Full GC 3. 为什么需要Survivor区?只有Eden不行吗? 4. 为什么需要两个Survivor区? 5. 新生代中Eden:S0:S1为什么是8:1:1? # Java文件编译到JVM的过程 ## 源码到类文件 ### 源码 源码文件通过javac命令编译成class文件 ```java public class Student { private String name; private Integer age; private String sex; public Student(){ } public Student(String name, Integer age, String sex) { this.name = name; this.age = age; this.sex = sex; } public String toString() { return "Student's name is " + name + ", age is " + age + ", sex is " + sex; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } public String getSex() { return sex; } public void setSex(String sex) { this.sex = sex; } } ``` > 编译:javac Student.java -----> Java.class ### 编译过程 Student.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> Student.class ### 类文件(Class文件) .class字节码文件包括以下信息: 1. 魔数与class文件版本 2. 常量池 3. 访问标志 4. 类索引,父类索引,接口索引 5. 字段表集合 6. 方法表集合 7. 属性表集合 编译过后的Class文件 ```java cafe babe 0000 0034 0036 0a00 0e00 2609 000d 0027 0900 0d00 2809 000d 0029 0700 2a0a 0005 0026 0800 2b0a 0005 002c 0800 2d0a 0005 002e 0800 2f0a 0005 0030 0700 ...... ``` > magic(魔数) cafe babe > minor_version, major_version 0000 0034 对应10进制的52,代表JDK8中的一个版本 > constant_pool_count 0036 对应十进制的54,代表常量池中54个常量 ## 类文件到虚拟机(类加载机制) ### 装载(Load) 查找和导入class文件 1. 通过一个类的全限定名获取定义此类的二进制字节流 2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构 3. 在Java堆中生成代表这个类的java.lang.Class对象,作为对方法区中这些数据结构的访问入口 ### 链接(Link) 1. 验证(Verify):保证加载类的正确性 * 文件格式验证 * 元数据验证 * 字节码验证 * 符号引用验证 2. 准备(Prepare):为类的静态变量分配内存,并将其初始化为默认值 3. 解析(Resolve):把类中的符号引用转换为直接引用 ## 初始化(initialize) 对类的静态变量,静态代码块执行初始化操作 # 类装载器ClassLoader 在装载阶段,其中通过类的全限定名获取其定义的二进制字节流,需要借助类装载器完成 ## 分类 1. Bootstrap ClassLoader:负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class或xbootclasspath选项指定的jar包。由C++实现,不是ClassLoader子类 2. Extension ClassLoader:负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包 3. App ClassLoader:负责加载classpath中指定的jar包及Djava.class.path所指定目录下的类和jar包 4. Custom ClassLoader:通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat,jboss都会根据j2ee规范自行实现ClassLoader ![各种ClassLoader的作用](http://assets.processon.com/chart_image/5f0fb97de0b34d44f04b72b9.png) ## 加载原则 检查某个类是否已经加载:顺序是自地向上,从Custom ClassLoader到Bootstrap ClassLoader逐层检查,只要某个ClassLoader已加载,就视为已加载此类,保证此类只被加载一次 加载的顺序:加载的顺序是自顶向下,也就是由上层类逐层尝试加载此类 ### 双亲委派机制 **定义**:如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。 **优势**:java类随着加载它的类加载器一起具备了一种带有优先级的层次关系。比如,java中的Object类,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object在各种类加载器环境在都是一个类。如果不采用双亲委派模型,那么由各个类加载器自己去加载的话,那么系统中会存在多种不同的Object类。 **破坏**:可以继承ClassLoader类,然后重写其中的loadClass方法 # 运行时数据区(Run-Time Data Areas) 图示: ![运行时数据区](http://assets.processon.com/chart_image/5f1003d3f346fb2bfb28e4ee.png) ## 方法区(Method Area) 方法区在JDK8中就是Metaspace。 方法区是各个线程共享的内存区域,在虚拟机启动时创建。用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。 虽然java虚拟机规范把方法区描述为堆的一部分逻辑,但是它却又有一个别名叫做Non-Heap(非堆),目的是与java堆区分开来。 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。 Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。 ## 堆(Heap) Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。Java对象实例以及数组都在堆上分配。 ## 虚拟机栈(Java Virtual Machine Stacks) 虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机肯定是线程私有的,独有的,随着线程的创建而创建。 每一个被线程执行的方法,为该栈中的栈帧,及每个方法对应一个栈帧。调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。 例如: ```java public class Test { public void a() { b(); c(); } public void b(){ System.out.println("b"); } public void c(){ System.out.println("c"); } } ``` ![虚拟机栈的创建及销毁](http://assets.processon.com/chart_image/5f10116ee401fd06f3dd0ebd.png) ## 程序计数器(The pc Register) 程序计数器占用的内存空间很小,由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令,因此,为了线程切换后能够恢复到正确的执行位置,每条线程需要一个独立的程序计数器(线程私有)。 如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,则这个计数器为空。 ## 本地方法栈(Native Method Stacks) 如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。 # 结合字节码指令理解Java虚拟机栈和栈帧 > 栈帧:每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间 每个栈帧中包括局部变量表(Local Variables),操作数栈(Operand Stack),指向运行时常量池的引用(A Reference to the run-time constant pool),方法返回地址(Return Address)和附加信息。 * 局部变量表:方法中定义的局部变量以及方法的参数存在这张表中 * 操作数栈:以压栈和出栈的方式存储操作数的 * 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking) * 方法返回地址:当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理 ![栈帧](http://assets.processon.com/chart_image/5f10509c07912906d9a7fb17.png) java文件: ```java public class Calculator { public int add(int v1, int v2) { int sum = v1 + v2; return sum; } } ``` 使用javac编译,然后用javap反编译,反编译后如下: ```java Classfile /home/pankarl/gitResposity/my-netty/client/src/main/java/Calculator.class Last modified 2020-7-16; size 256 bytes MD5 checksum d83d6ecabfb8f6e8d2903f0fc286e33c Compiled from "Calculator.java" public class Calculator minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #3.#12 // java/lang/Object."":()V #2 = Class #13 // Calculator #3 = Class #14 // java/lang/Object #4 = Utf8 #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 add #9 = Utf8 (II)I #10 = Utf8 SourceFile #11 = Utf8 Calculator.java #12 = NameAndType #4:#5 // "":()V #13 = Utf8 Calculator #14 = Utf8 java/lang/Object { public Calculator(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 2: 0 public int add(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=3 0: iload_1 // 从[局部变量1]中装载int类型值入栈 1: iload_2 // 从[局部变量2]中装载int类型值入栈 2: iadd // 将栈顶元素弹出栈,执行int类型的加法,结果入栈 3: istore_3 // 将栈顶int类型值保存到[局部变量3]中 4: iload_3 // 从[局部变量3]中装载int类型值入栈 5: ireturn // 从方法中返回int类型的数据 LineNumberTable: line 4: 0 line 5: 4 } SourceFile: "Calculator.java" ``` # Java对象内存布局 一个Java对象在内存中包括3个部分:对象头,实例数据和对齐填充 ![Java对象内存布局](http://assets.processon.com/chart_image/5f11468707912906d9a97bc4.png) # 垃圾回收(Garbage Collect) ## 如何确定一个对象是垃圾 ### 引用计数法 对于某个对象而言,只要应用程序中持有对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。 弊端:如果AB相互持有引用,导致永远不能被回收 ### 可达性分析 通过GC Root的对象,开始向下寻找,看某个对象是否可达 **能作为GC Root:类加载器,Thread,虚拟机栈的本地变量表,static成员,常量引用,本地方法栈的变量等** ## 传统垃圾收集算法 ### 标记-清除(Mark-Sweep) 算法分两个阶段 * 标记:找出内存中需要回收的对象,并且把它们标记出来。此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时 * 清除:清除掉被标记需要回收的对象,释放出对应的内存空间 ![标记-清除](http://assets.processon.com/chart_image/5f11512f07912906d9a9b388.png) **缺点** ```java 标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。 (1)标记和清除两个过程都比较耗时,效率不高 (2)会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作 ``` ### 标记-整理(Mark-Compact) 标记过程仍然与“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。 ![标记整理](http://assets.processon.com/chart_image/5f115a5107912906d9a9e084.png) ### 复制(Copying) 将内存划分为两块相等的区域,每次只使用其中一块,当其中一块内存使用完了,就将还存活的对象复制到另一块上面,然后把已经使用过的内存空间一次清除掉。 ![复制](http://assets.processon.com/chart_image/5f11636c6376895d7fb59d81.png) **缺点**:空间利用率低 ## 分代收集算法 Young区:复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高) Old区:标记清除或标记整理(Old对象存活时间比较长,复制来复制去没必要且比较耗内存没有必要,不如做个标记再清除或整理) ### 内存模型 一块是非堆区,一块是堆区 堆区分为两大块,一个是Old区,一个是Young区 Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区,Eden:S0:S1=8:1:1 S0和S1一样大,也可以叫From和To 一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大对象会直接分配到Old区 ![JVM内存模型](http://assets.processon.com/chart_image/5f116a3a7d9c081beabd15ca.png) ### Survivor区详解 Survivor区分为两块S0和S1,也可以叫做From和To。在同一时间点上,S0和S1只能有一个区有数据,另外一个是空的。 比如一开始只有Eden区和From区有对象,To中是空的。此时进行一次GC操作,From区中对象的年龄就会+1,Eden区中所有存活的对象会被复制到To区,From区中还能存活的对象会有两个去处。 1. 若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区 2. 如果Eden区和From区没有达到阈值的对象会被复制到To区 此时Eden区和From区已经被清空。这时候From和To交换角色,之前的From变成了To,之前的To变成了From。也就是说无论如何都要保证名为To的Survivor区域是空的。 Minor GC会一直重复这样的过程,直到To区被填满,然后会将所有对象复制到老年代中。 ### Old区详解 一般Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。在Old区也会有GC的操作,Old区的GC称作Major GC。 # 实战 ## 内存溢出案例及观测 [项目参考]() ### 堆内存溢出 ```java public class HeapOOM { static List list = new LinkedList<>(); public static void main(String[] args) throws InterruptedException { while(true) { Object obj = new Object(); list.add(obj); Thread.sleep(1); } } } ``` ![堆溢出](images/HeapOOM.png) ### 非堆内存溢出(Metaspace) ```java public class MethodAreaOOM { public static void main(String[] args) { MyMetaspace.createClasses(); } static class MyMetaspace extends ClassLoader { public static List> createClasses() { List> classes = new ArrayList>(); for (int i = 0; i < 1000000; i++) { ClassWriter cw = new ClassWriter(0); cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); mw.visitVarInsn(Opcodes.AALOAD, 0);; mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V"); mw.visitInsn(Opcodes.RETURN); mw.visitMaxs(1, 1); mw.visitEnd(); MyMetaspace test = new MyMetaspace(); byte[] code = cw.toByteArray(); Class exampleClass = test.defineClass("Class" + i, code, 0, code.length); classes.add(exampleClass); } return classes; } } } ``` ### 虚拟机栈溢出 ```java public class StackOOM { public void method() { method(); } public static void main(String[] args) { new StackOOM().method(); } } ``` # 问题 ## 栈,方法区和堆的指针指向 ### 栈指向堆的情况 如果在栈帧中有一个变量,类型为引用类型,比如`Object object = new Object()`,这时候就是典型的栈中元素指向堆中的对象 ### 方法区指向堆 方法区中会存放静态变量,常量等数据,比如`private static Object obj = new Object()`,这时候就是方法区中元素指向堆中的对象 ### 堆指向方法区 一个Java对象会有对象头,其中就存放了该类的信息,类信息是存储在方法区的 ## 如何理解Minor/Major/Full GC * Minor GC:新生代发生GC * Major GC:老年代发生GC * Full GC:新生代和老年代都发生GC ## 为什么需要Survivor区?只有Eden不行吗? 如果没有Survivor区,并且没有年龄限制的话,Eden区没进行一次Minor GC,存活的对象就会被送到老年代。这样一来,老年代很快被填满,触发Major GC(一般Major GC一般伴随着Minor GC,也可以看作触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。频繁的Full GC消耗的时间很长,会影响大型程序的执行和响应速度。 假如增加老年代空间,需要更多存活对象才能填满老年代。虽然降低Full GC的频率,但是随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长。假如减少老年代空间,虽然Full GC所需时间减少,但是老年代很快被存活对象填满,Full GC频率增加。 所以Survivor区存在的意义就是减少被送到老年代的对象,从而减少Full GC的发生频率。Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。 ## 为什么需要两个Survivor区? 最大的好处就是解决碎片化。假设现在只有一个Survivor区,模拟一下流程: 刚刚新建的对象在Eden区,一旦Eden区满了,触发一次Major GC,Eden区中的存活对象会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。 永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。 ## 新生代中Eden:S0:S1为什么是8:1:1? 首先必须保证S0与S1的一样大,因为S0与S1之间使用了复制算法。复制算法虽然效率高,但是涉及到空间浪费,如果Eden区的比例较小,那么S0与S1的比例较大,则浪费的空间也就越大。