【大家好,我是爱干饭的猿,本文重点介绍JVM 执行流程、JVM 运行时五大数据区、JVM 类加载过程、分类、双亲委派模型、死亡对象的判断算法:引用计数法、可达性分析算法、垃圾回收算法:分代算法。
后续会继续分享其他重要知识点总结,如果喜欢这篇文章,点个赞👍,关注一下吧】
上一篇文章:《【SSM】SpringBoot 统一功能处理(重点:Spring 拦截器实现与原理)》
🤞目录🤞
💡前置知识:
- IDEA (Integrated Development Environment,IDE):指Java集成开发环境的软件。
- JDK(Java Development Kit),指java开发的工具。
- JRE(Java Runtime Environment),指Java运行环境。
- Java SE(Java Standard Edition)Java 标准版
- Java EE(Java Platform,Enterprise Edition)Java企业级应用程序版本
💡1. 编写Java 代码的一生
1.1 开发阶段
1. 源码(source code)数据一定要存储在某个介质上,并且希望是持久化的存储,所以一般就是保存在硬盘。抽象成文件形式(一般以*.java结尾,称为java源文件)保存在一个文件中。
2. 但写在一起容易很乱,对人类(程序员)来说不好理解,所以一般会分开成为一组*.java文件,这些文件中有指令(以方法、静态方法、构造器、初始化代码块等中的语句所代表)、数据(以属性(有值)、静态属性(有值)、字面量等代表),数据全部放在源码文件中。
3. 但是管理起来不方便,所以结构性的数据单独存放,保存成数据库中的数据独立成文件(资源文件)视频、音频、文本、图片。
4. 然后利用别人已经写好的一个程序,这类程序一般称为编译器程序(compiler) 进行编译。使*.java -> *.class 按照一定格式(JVM规范中有定义),存储的程序文件。
5. 然后开始进入运行时阶段
1.2 运行时阶段
对于目前的冯诺依曼体系: CPU(运算器+控制器)、内存、IO设备硬件的核心就是CPU。CPU只能和内存做直接的数据交换。
我们现在的程序数据放在硬盘中,要让运行时电脑运行我们的程序,实际就时让该电脑的CPU运行我们程序中的指令数据。
问题:CPU无法直接和硬盘(IО设备)做直接的数据交换,所以我们应该?
CPU只能和内存中的数据打交道,现在数据又放在硬盘中,那我们要做的就是先把数据从硬盘中读取到内存中。 这个过程是以类为单位(某个*.class 文件)进行读取的,这个过程就被称为类的加载(Class Load)。
这个加载的过程也得依赖某些指令来执行,这些指令(程序)就被称为类的加载器(ClassLoader) 这些指令是属于JVM程序的一部分,换言之,JVM中有进行必要的类的加载的职责。
类加载的方式是:一次一个类文件的加载,按需进行加载
对于我们目前来说:类文件的数据 = 类的信息+常量池+方法(方法的信息+指令(字节码形式) ClassLoader要加载一个类,主要就是要加载这些数据到内存中。
JVM会按需进行类的加载,哪什么情况下需要一个类?
前提:用到了一个类,但是这个类还不在内存中,就需要加载(换言之,如果已经在内存中,没必要第二遍加载)
1.使用一个类,进行对象的实例化时(构造对象时)
2.使用一个类时,会触发使用这个类的父类("父类":类的继承、接口的实现、接口的继承)用到了子类,必然用到了父类
3.使用静态的内容(静态属性、静态方法)
以Main类作为主类启动JVM。那请问,需要加载 Main类么?
需要,用到了Main 的静态方法(main方法)
那 Object类这会儿要加载么?
需要,因为Object是Main的父类! 要加载Main和Object类
💡2. JVM 执行流程
程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式类加载器(ClassLoader) 把文件加载到内存中运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调 用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部 分的职责与功能。
编辑
类加载器<->内存空间<->执行引擎
执行引擎(Execution Engine):就是对CPU的模拟
2.1 执行引擎执行过程:
1.读取PC中保存的值(一般就是个地址)
⒉.根据PC中的值,去内存中(方法区),读取一条指令(字节码)
3.执行具体具体的字节码
4.默认情况下PC的值自动+1(语句自动执行下一条,当然有些字节码就是修改PC值)
2.2 JVM的大体启动过程
控制权是如何交到我们手中的(我们的 main方法的第一条语句(字节码)是如何被执行起来的)
java.exe com.demo.Main以这个类的 main方法作为程序的启动入口
1.【OS】收集要启动进程的信息程序是C/Program Files/Java/jdk/bin/java.exe,参数是com.demo.Main
2. 【OS】根据程序,启动进程,执行java.exe当时写的程序入口(C语言里的 main 函数)
3.【JVM】读取参数,找OS申请必要的内存(malloc)、创建必要的执行引用和类加载器
4. 【JVM】执行引擎,要求类加载器进行com.demo.Main类的加载
5.【JVM】创建主线程,把PC的值设置成com.demo.Main类下的static main 的第一条指令的地址
6. 【VM】开启执行引擎的指令执行循环,执行第一条语句
7. 【Java App】开始我们代码的执行
8. ...只到所有前台线程退出
9. 【VM】进行必要的资源回收
10. 【VM】进程退出
2.3 JIT Just ln Time (即时编译)
提示执行效率的一套机制。 实际上JVM在执行过程中,可以发现有些字节码执行的比其他的更频繁这个时候,再去按照字节码翻译的模式,效率就低了,所以,会在运行期间,即时地把这些热点字节码直接编译成本地的机器码(就像C语言一样)速度就能提升。
JVM的执行,就有个预热的阶段的。就像运动前的热身一样,让自己的状态达到最好,效率才最高。
💡3. JVM 运行时数据区
为什么要划分区域以及区域是做什么用的?
逻辑上划分区域,便于为不同区域指定专门的用途方便人类理解。
JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:
编辑
3.1 堆(Heap) (线程共享):
堆砌对象的地方。堆中的数据以对象为基本单位进行管理。 属性空间是随着对象走的,所以 逻辑上,我们认为属性是保存在堆区的。
3.2 Java虚拟机栈(stack)(线程私有)
以栈帧(frame)为基本单位。 一个方法要执行的时候,分配给属于这个方法本次执行的栈帧,方法执行结束后,栈帧空间被回收。栈帧中保存的就是该方法本次执行需要的数据。逻辑上,临时变量就是保存在栈区的。 局部变量 随着方法本次执行出现,本次执行结束消亡。
3.3 本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用 的。
3.4 PC程序计数器(线程私有)
程序计数器的作用:用来记录当前线程执行的行号的。 程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。保存下一条要执行的指令的位置Program Counter
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
3.5 方法区
存放方法,主要就是指令数据,以字节码为代表的指令数据。也会有部分附属的方法基本信息。——扩展起来认为,就是保存类的信息。逻辑上,认为类的相关数据放这里。 逻辑上,静态属性放在方法区。 方法区以类为单位,类中还能方法为基本单位。
方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 的。
在《Java虚拟机规范中》把此区域称之为“方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域 叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace)。
运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用。
字面量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值。
符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
💡4.JVM 类加载
4.1 类加载过程
编辑
1. Loading (加载)
根据要加载的类名,找到对应的类文件(*.class)
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2. Linking (链接)
验证类文件合法,没有错误(还得考虑安全问题)解析数据(按照规范格式)
1) 验证 验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机 规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。
2)准备 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
3)解析 解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
类里面用到了很多用字符串字面量写死的数据,比如"Ljava/lang/Object"但实际程序(JVM)执行中,需要的java.lang.Object在内存中对应的数据 所以,要把 com.demo.Main和java.lang.Object根据字面量"链接"起来
3. lnitializing (初始化)
将类放到内存的指定位置后,进行类里的必要数据的初始化(主要是静态属性),我们自己写的静态属性的赋值和静态代码块就是在这个阶段完成的。并且一定是先执行父类的初始化完成之后,才会进行子类的初识化!!
4.2 类加载器分类
默认情况下有哪些类加载器(ClassLoader)不同的类,由不同的类加载(因为类和类的地位的不平等的)
- Boostrap ClassLoader(启动类加载器):加载 SE 下的标准类(java.lang.String、java.utilList、java.io.InputStream) rt.jarruntime.jar
- Extenttion ClassLoader (扩展类加载器):加载SE下的扩展类
- Application ClassLoader(应用类加载器):我们写的类、我们通过maven 或者其他工具引入的第三方类
类名俗称:Main
权威类名(Canonical Name) : com.peixinchen.demo.Main
JVM进行类的加载时,保证一个类只会在内存中存在一份(粗略地可以当成类只被加载一次(类是可以被卸载的)
JVM内部:类加载器+权威类名,确定一个类是否存在
4.3 双亲委派模型
站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:
- 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;
- 另外一种就是其他所有的 类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
1. 什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
2. 双亲委派模型的优点
1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那 么在 B 类进行加载时就不需要在重复加载 C 类了。
2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模 型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户 自己提供的因此安全性就不能得到保证了。
3. 破坏双亲委派模型
双亲委派模型虽然有其优点,但在某些情况下也存在一定的问题,比如 Java 中 SPI(Service Provider Interface,服务提供接口)机制中的 JDBC 实现。
当我们进入它的 getConnection 源码是却发现,它在调用具体的类实现时,使用的是子类加载器(线 程上下文加载器 Thread.currentThread().getContextClassLoader )来加载具体的数据库数据库包 (如 mysql 的 jar 包)
💡5.垃圾回收
- Garbage Collector(垃圾回收器)
- Garbage Collect(垃圾回收)
GC逻辑上把内存的使用权和所有权分离了。我们只享受一段内存的使用权,没有所有权。
好处:不需要不考虑内存释放的问题 坏处:内存的直接彻底和我们无缘了。
5.1 思考哪些区域是GC的重点
根据Runtime Data Area,我们分别看看哪些区域是GC的重点:
1.PC区域。
分配:创建一个新线程时。 回收:这个PC对应线程的最后一条指令执行结束之后。
PC一定和一个线程关联着的,只要线程活着,PC就一定需要。分配和回收时机非常简单,明确,所以并不需要GC做过多参与。
2.栈区域。
分配:当执行一个方法的调用时分配栈帧。 回收:当该方法的return 时候,回收栈帧
每个线程有自己的栈,创建线程时为其分配栈空间。线程执行结束后回收栈空间。栈上的栈帧的分配和回收时机。 分配和回收时机非常简单,明确,所以并不需要GC做过多参与。
3.方法区(含运行时常量池)
分配:类的加载的时候。回收:类的卸载的时候
相对来说也不是太复杂,复杂的地方类的卸载条件其实很复杂。暂时先不去探讨方法区中类的卸载问题。 我们一般认为类是不会卸载(这个断言优点武断)。
所以在Java7之前,方法区在hotspot 的实现中被称为永久代(permanent area) 但是随着时代的发展,类的卸载变得相对频繁了,所以,方法从Java8就不叫永久代了,逻辑上还是方法区,但一般拆分了 有些数据组织成对象的形式放到堆里去了 有些数据方法直接内存区域中了,这个内存称为元空间(metaspace)
4. 堆
分配:实例化一个对象的时候。回收:该对象一定没有再被使用的时候。
判断一个对象有没有使用是非常复杂的一件事情,很多时候,我们要对该问题做近似解,而不是精确解。所以我们需要对堆内存进行管理。
5.2 死亡对象的判断算法
GC:堆内存的管理。堆上的内存是以对象为基本单位进行管理。GC:垃圾对象的回收问题。
如何判断对象是垃圾对象:不会再被使用的对象
1. 引用计数法
引用计数描述的算法为:给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
- 增加:每多一个引用指向该对象(进行了引用赋值)。
- 减少:引用出现的位置:
1. 栈帧中(局部变量),方法执行结束,引用生命周期消亡。
2. 出现在类中(静态属性),类被卸载的时候,引用生命周期消亡。
3. 对象的死亡引起的连锁反应 对象死亡,对象中的引用失效,导致该引用指向的对象的,可能引起其他对象继续死亡。
4. 引用还在,但指向其他对象去了
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如PHP语言、CPython、Objective-C、C++的智能指针中的共享指针就采用引用计数法进行内存管理。
但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题。
例如:
void method(){ Person s = new Person(); //对象.ref_count == 1 s.p = s; //对象.ref_count == 2 } // 运行结束 对象.ref_count == 1
永远不可能被其他人用到了ref_count永远不会变成0。
2. 可达性分析算法
编辑
在Java语言中,可作为GC Roots的对象包含下面几种:
1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;
2. 方法区中类静态属性引用的对象;
3. 方法区中常量引用的对象;
4. 本地方法栈中 JNI(Native方法)引用的对象。
我们现在已经找到了垃圾对象了,所以,探讨如何进行垃圾回收(内存空间的回收)
5.3 垃圾回收算法
1. 标记-清除算法
"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种思路并对其不足加以改进而已。
2. 复制算法
"复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使 用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后 再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配 时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。
3. 标记-整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用 复制算法。 针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步 骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
编辑
编辑
由于有了整理步骤的存在,使得GC变得复杂,耗时变得无法接受,所以,需要进行设计,来进行GC性能的优化。
4. 分代算法
分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略, 从而实现更好的垃圾回收。
当前 JVM 垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。
在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。
编辑
5. 对象的一生
1.诞生于伊甸区。
2.年关已至(发生了GC),其实Eden的大部分(99%)对象都死去了,活下来的对象被复制到生存区 由于活下来的对象很少,所以,这样复制,只需要很小的代价就可以完成。 同时,伊甸区就没有活对象了。所以,整个视为完全空闲,不需要进行整理。(为什么要这么弄的原因)。
3.直到一个对象成年之前,一直持续这个操作 SA可用,SB不可用 GC 到了之后,让SB可用,SA不可用,使用刚才相同的方式进行处理。前提:大部分对象还是会死去!把SA中活下来的对象,复制到SB区,SA直接标记为完全可用。 下次,SA变成可用,SB变不可用。
4.当我们的对象,14岁时,再遇到了GC,则变成15岁,视为成年,如果还或者,则从生存区,移动到老年代。新生代的过程就和我们没关系了。
5.成年之后的对象就不用记年龄了,如果遇到GC,老年代的GC按照本办法:先回收+再整理。
6.我们的对象会在老年代度过余生,直到它死去。
6. 分代
- 只针对新生代的GC,被称为新生代GC (Minor GC) ;
- 只针对老年代的GC,被称为老年代GC (Major GC) ;
- 针对整个堆的GC,称为全GC(Full GC)。
大部分情况下,只会进行Minor GC。随着Minor GC的进行,会有越来越多的对象进入老年代。
由于老年代的GC成本较大,所以一般会尽量减少老年代GC。
随着老年代对象越来越多,直到某个阈值时,才会进行Major Gc。
由于大部分情况下,Major GC总是由于某次Minor GC引起的,所以,Major GC发生的时候,一般也代表了Full GC。所以,一些语境下,Major GC == Full GC。
7. 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗?
1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝 生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。
2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC, 经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行 Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
8. 为什么对象经历了 N 次(一般情况默认是 15 次)到老年代?
15岁成年,是因为Hotspot 内部实现对象的时候,用了4个bit来记录年龄。年龄就是0-15。
分享到此,感谢大家观看!!!
如果你喜欢这篇文章,请点赞加关注吧,或者如果你对文章有什么困惑,可以私信我。
🏓🏓🏓