JVM是Java跨平台实现的基础,也是每个Java开发程序员必备的技能之一,因为在后期的JVM调优方面,好的调优结果可以直接影响项目的执行效率,因此本篇文章就会带大家搞懂JVM的常见问题。
首先,当一个基础的问题,JVM是如何运行的,具体的运行流程可以观看我下面画的一张图
基于以上的图片可以得知,JVM执行的流程可以分为下面几步:
1.编译器首先会把 .java 源代码编译为 .class 文件,然年通过类加载器将 .class 文件加载到 运行时数据区域。
2.因为 .class 文件是JVM能识别的一套指令集,操作系统并不认识,因此需要执行引擎来将 .class 文件 进行解释为操作系统可以识别的指令。
3.在执行引擎解释.class文件时,会调用本地方法库的方法来辅助执行。
所以啊,JVM是通过:类加载器,执行引擎,本地方法库,运行时数据区四个模块来执行Java程序的。
明白了JVM是如何运行的,那我们就正式进入JVM的探索之旅啦!!!(搬好小板凳坐好了,准备出发了.....)
通常所说的内存布局是指运行时数据区,要与Java内存模型分清楚(JMM),运行时数据区是指:堆,虚拟机栈,本地方法栈,方法区,程序计数器五个规范,这是在JVM虚拟机规范所提出来的。
堆:线程共享的,是JVM中最大的一块区域,存放的是对象的实例
虚拟机栈:线程私有的,存放是方法调用和局部变量(局部变量表,动态链接,操作数栈,方法出口)
本地方法栈:线程私有的,与虚拟机栈类似,存放的是本地方法(c++)
程序计数器:线程私有的,存放的是当前程序进行到了哪一步(当前指令的内存地址)
方法区:线程共享的,存放的是类的结构信息和静态成员变量,常量池等信息。
那什么是Java虚拟机规范呢?
Java虚拟机规范是指各个虚拟机的实现要遵守的一套规则,而Java虚拟机则是规范的具体实现,不同的操作系统都有自己的Java虚拟机实现,因此Java程序在可以一次编写处处可用。
方法区是Java虚拟机规范提出的一个专业名词规范,意味着所有的虚拟机实现都会执行该规范,而永久代是Jdk1.7之前的方法区的称呼(默认是使用的HotSpot虚拟机),在1.8之后,用元空间取代了永久代。
为什么要取消永久代呢?
是因为永久代的空间管理很难在满足需求,会出现OOM的问题,因此用元空间取代了永久代,永久代的改动如下:
1.空间大小可以动态调整,这时用的内存空间使用的是本地内存,而不是堆上的内存。
2.字符串常量池从永久代移动到了堆中,减少了方法区GC的压力。
常量池和字符串常量池都是运行时数据区的一部分,但是二者有以下区别:
1.在jdk1.7之后,将字符串常量池从永久代移动到了堆中,而常量池是存放在元空间中的本地内存中。
2.常量池拥有更多的方法,可以存放字符常量,类,方法,字段的常量,而字符串常量池只可以存放字符串的常量
字符串常量池如下:
堆溢出是指内存中有大量的垃圾对象无法回收,从而造成堆的内存溢出
常见的堆溢出有以下几种情况:
1.内存泄露:例如使用ThreadLocal时,没有主动释放就会导致内存泄漏。
2.无限创建大量对象
3.没有合理设置堆得大小
4.大量的Execl的导入和导出
栈溢出通常是指虚拟机栈溢出,而导致虚拟机栈溢出的主要原因是死循环和无限创建大的对象。
例如以下代码,就是一个典型的栈溢出现象,在 main 方法中循环调用 main 方法,循环产生的大量形参都会在栈空间进行创建,当超过栈空间的大小,就会导致栈空间溢出,发生 OOM。
public class StackOverflowErrorDemo {
public static void main(String[] args) {
main(args); // Exception in thread "main" java.lang.StackOverflowError
}
}
恭喜你,小伙伴已经走到了JVM的进阶关卡了,下面我们就继续向JVM进发吧!!!
小伙伴是不是刚看到这个还有点懵,这什么东西,还是连着三个???小伙伴不要着急,我们慢慢来分析解决。
首先
解释型语言:不需要事先编译成机器码,而是在程序运行时将源代码逐行解释执行,例如JS语言就是典型的解释型语言 优点:跨平台性能好,无需编译 缺点:执行效率低
编译型语言:在程序执行前将源代码编译成机器可识别的机器码,然后执行,只需要编译一次,生成的可执行文件可重复运行。 优点:执行效率高 缺点:跨平台能力有限
而Java语言是属于二者之间的,也叫编译-解释型语言
编译阶段: .Java源文件经过编译器编译为 .calss 文件(字节码文件与平台无关,可以在任何系统的虚拟机上执行)
解释阶段:当程序执行时,java虚拟机会加载字节码,并对字节码进行解释执行(JIT:即时编译),JIT会对频繁执行的字节码编译成机器指令用来提高性能。
那热点代码又是啥?其实很简单,在不同的Jdk版本中,对于热点代码的定义是有所不同的,在 JDK 21 Client模式下为1500次,而在JDK 21 Server 模式下为10000次
热点代码的识别基于两种策略:方法调用次数和回边计数。
对象的生命中周期大致可以分为以下几个阶段
1.加载:根据类的全限定名将其转换为此类的二进制字节流,然后将此二进制流加载到运行时数据区中的方法区,在内存中生成一个这个类的对象,作为类的数据访问入口
2.链接:
2.1:验证:验证阶段主要是验证此类中的数据是否合法,例如验证文件格式,字节码,符号的引用等
2.2:准备:为类中定义的静态变量分配内存并设置类的初始值,此时并不会真正赋值,而是赋默认值
2.3:解析:将常量池中的符号引用转换为直接引用的过程,也就是初始化常量。
3.初始化:此时Java虚拟机才开始真正执行类的业务代码,将主导权交给主程序
4.使用:在程序中使用该类
5.销毁:此类的实例没有引用时就会根据垃圾回收算法回收此类的实例。
所谓的双亲委派模型是指:当一个类加载器收到了类加载的请求,首先类不会自己加载而是将请求委派给父类的加载器,每一次都是如此,最终这个类的加载请求会到达顶级类的类加载器即启动类加载器(BootStrap ClassLoader),当父类无法加载该请求时,子类才会尝试自己记载该请求
使用双亲委派模型的好处:
1.避免类的重复记载
2.更加安全
SPI机制就是“服务提供发现”机制,例如数据库的驱动就是典型SPI机制,用户调用JDBC接口,而各个数据库厂商都会实现JDBC的驱动,因此是从上往下来实现的
一个外置的Tomcat要部署多个应用,多个web应用程序在一个Tomcat容器类运行,而不会造成相互干扰和类冲突,因此Tomcat也打破了双亲委派机制
在jdk1.2以后Java官方提出了四种引用类型,分别为:强引用,软引用,弱引用,虚引用四种
强引用:一般来说,强引用是最常见的,使用Object object = new Object();都是强引用,除非是显示调用System.gc(),一般垃圾回收器不会主动去回收强引用的对象。类似于公司员工的核心员工一样
软引用:软引用是指一些还有用但不是必须回收的对象,类似于公司员工的好员工。只有在内存实在不够用的时候才会去垃圾回收
弱引用:弱引用是指不管内存是否够用,在下一次GC的时候一定会进行垃圾回收的对象,在Java中典型的运用的就是ThreadLocal中value的引用类型就是弱引用,因此为了防止内存泄露,需要爱使用完ThreadLocal后手动调用remove()方法释放内存,类似于公司员工的一般员工一样。
虚引用:在Java中虚引用又称为幻想引用,不能使用虚引用指向的对象,类似于不合格员工。
恭喜小伙伴一路过五关斩六将来到了最后的一部分,JVM成神之路.......
在Java中利用引用计数法和可达性分析算法来判断一个对象是否存活。
引用计数法:给对象增加一个引用计数器,每当有对象引用时计数器+1,当引用失效时计数器-1,当计数器指向0时,表示该对象已经死亡 优点:实现简单,效率高 缺点:无法解决对象之间的循环引用问题
可达性分析:是指通过一个“GC Roots”的根节点开始逐步向下探索,走过的路径称为引用链,当引用链没有与之直接相连的话,就判断该对象是不可存活的。
那么通常又有哪些对象可以成为GC Roots呢?
1.虚拟机栈中引用的对象
2.本地方法栈中引用的对象
3.方法区中静态变量的引用对象
4.方法区中常量的引用对象
在Java中常见的垃圾回收算法有:标记-清除算法,标记-整理算法,复制算法,分代算法等
标记-清除算法:是Java垃圾回收算法中最常见的一种垃圾回收算法,它的核心思想是统一标记可回收的对象,然后统一进行垃圾回收 优点:执行效率比较高,实现简单 缺点:使用标记-清除算法会出现大量的垃圾碎片,如果需要一大片连续的内存空间时候,此种垃圾回收的效率就会打折扣。
标记-整理算法:标记-整理算法其实是标记-清除算法的一种升级,在统一标记后并不会立即执行垃圾回收,而是将存活的对象移动到另一端,然后清理端外的垃圾对象。 优点:不会存在垃圾碎片的问题 缺点:执行效率比较低下,因为在标记后还需要进行整理存活对象到另一端
复制算法:复制算法是标记-整理算法的一种升级,它的目的就是解决标记-整理算法的效率问题,它将可用内存区域按容量划分为两块大小相等的区域,然后在一块区域进行使用,当需要垃圾回收的时候,会将使用区域的存活对象复制到另一块未使用的区域,然后统一清除使用区域的垃圾对象 优点:执行效率高,不会存在垃圾碎片 缺点:内存空间的利用率比较低
分代算法:分代算法就是将堆内存区域划分为新生代,老生代,然后在各自的区域进行垃圾回收的一种算法
分代算法的大致流程如下:
将堆内存区域划分为新生代,老生代,新生代占比1/3,老生代占比2/3,然后新生代区域又细分为3个区域:end区,To Survivor ,From Survivor占比分别为8:1:1 执行流程大致如下: 1.end区+from Survivor区存活的对象放入 To Survivor区
2.清空end区,from Survivor区
3.交换from Survivor区,To Survivor区
每交换一次存活的对象的年龄+1,当对象的年龄到达15时(HostPot默认是15),就会将该对象放进老生代,当老生代的区域不够用时,就会触发Full GC(全局垃圾回收)。
常见的垃圾回收器下图所示:在新生代的Serial,ParNew,parallel Scavenge 在老生代的Serial Old,CMS,Parallel Old 已经后续一直在沿用的默认的垃圾回收器G1
在新生代的垃圾回收器常采用的垃圾回收算法是复制算法,在老生代采用的则是标记-整理算法
Serial,Serial Old是单线程环境下的串行执行的,不支持并发操作,意味着在进行垃圾回收时会阻塞用户线程,直到垃圾收集完成。
ParNew 相当于Serial的升级版本,唯一的区别就是ParNew 采用的并行回收,适用于多线程环境下
Parallel Scavenge,Parallel Old是专注于吞吐量的垃圾回收器 (吞吐量 = 用户线程执行时间/总时间 * 100% = 用户线程执行时间/(GC时间+用户线程执行时间)* 100%)
CMS:专注于最短停顿时间的垃圾回收器
CMS:并发标记清理回收器,是一种获取最短回收停顿时间为目标的垃圾收集器
CMS第一次初始标记,实现用户线程和垃圾回收线程同时执行 ,采用的是标记-清除垃圾回收算法,也会存在STW(Stop the word,全局停顿),是一款老生代的垃圾回收器
优点:低延迟,并发收集 缺点:采用标记-清除算法会产生垃圾碎片
CMS的执行流程如图所示
1.初始标记:只标记GC Roots直接关联的对象,速度很快
2.并发标记:和用户线程同时会执行:GC Roots直接关联的对象继续向下探索,形成一条引用链
3.重新标记:对上一步“并发标记”阶段用户线程还在执行对象的变动进行修正标记
4.并发清除:使用并发-清除算法进行垃圾回收
好了,恭喜小伙伴,已经成功完成了JVM成神之路的所有关卡,与预祝小伙伴在未来的编程生涯中一路畅通,永无BUG!!!
(PS:本文一些图片配图来自于其他网络,若图片作者有看到请联系我删除)