Java虚拟机(JVM)

一、什么是JVM

JVM是Java Virtual Machine的简称,翻译过来就是java虚拟机。
虚拟机是指通过软件模拟具有完整硬件功能的、运行在一个完全隔离环境中的完整计算机系统。
java能够那么火热,JVM功不可没。java的宣传口号是“一次编写,处处运行”,依靠的就是JVM,那么这个功能是怎么实现的呢?因为当你在IDE上写完代码编译后,JVM会为其生成一个.class文件,也叫字节码。当你把这个字节码放到另外一个系统上,只要都安装了JVM,那么这个java程序也能在另一个系统中运行。可以把JVM比作翻译机,如下图

JVM
JVM有很多的版本,也就是说有很多厂商都会有自己的JVM,比如阿里巴巴的Taobao JVM,HotSpot等,目前HotSpot占领着绝对的主场,有着广泛的用户。即使这样,那如果我们想支持国产的Taobao JVM,会不会造成写的代码就因为虚拟机的不同而造成bug呢?答案是不会,因为句各大厂商在开发JVM的时候都会遵循《Java虚拟机规范》,即使是不同的虚拟机,运行起来也不会有问题。(这就好比五菱宏光和劳斯莱斯,虽然引擎不一样,但不也是加油就能动嘛~~)。

二、JVM运行流程

我们已经知道了java代码为什么能够一次编写,处处执行。那么JVM具体是怎么做的呢?

程序在执行之前会先将java代码转换成字节码(class文件),JVM需要先将字节码通过一定的方式通过类加载器(ClassLoader)把文件加载到内存中运行时数据区(Runtime Data Area),字节码是Java虚拟机的一套指令规范,不能与底层的操作系统直接交互,所以此时需要**执行引擎(Execution Engine)将字节码翻译成对应的底层系统指令交由CPU处理,这个过程中需要用其他语言的接口(一般是C/C++)的接口本地库接口(Native Interface)**来实现整个程序的功能。
总的来看JVM主要包括以下4个部分:

  1. 类加载器
  2. 运行时数据区
  3. 执行引擎
  4. 本地库接口
    我们这主要介绍类加载和运行时数据区。

三、JVM运行时数据区(内存布局)

JVM内存布局
如图,JVM内存布局分为5大部分:

  1. JVM栈区(虚拟机栈)
  2. 本地方法栈
  3. 程序计数器
  4. 元数据区(或者叫方法区、永久代、元空间)

其中堆和方法区是线程共享的,JVM栈区、本地方法栈和程序计数器都是线程私有的。

3.1堆

程序中创建的所有对象都保存在堆中,堆区分为老年代和新生代(老年代占堆空间的2/3,新生代占堆空间的1/3),新生代里面又包括Eden区和两个Survivor区(Eden区占80%,S0和S1分别占10%)。

堆区
一般当创建一个对象时,会将该对象放在Eden区,由于大部分的对象是朝生夕死的,当经历过垃圾回收的折磨后,将还存活的对象放在Survivor中的其中一个(S0区,或称为From区),当Eden区再次进行垃圾回收Mirror GC(采用复制算法)时,会扫描Eden区和From区,并将还存活的对象复制到S1区(也叫To区),并清空From区,如此进行反复,所以From区和To区是不断变换的,且有一个区域一直为空。那老年代呢?闲着?当然不是!

老年代的情况比较特殊,因为老年代一般存储的都是一些老油条,不容易被垃圾回收机制清理,所以我上面说的是一般情况下,那么在哪些情况创建的对象会进入老年代呢?

  • 创建的新对象在年轻代经历过15次垃圾回收后会直接放入老年代(CMS默认为6次),经历一次年龄增加1岁。
  • 大对象直接放入老年代
  • 当一批对象的总大小大于Survivor中一块区域的一半,那么大于等于这批对象里面的最大年龄的对象就会放入老年代。(比如有岁数为1,2,3的一批对象总大小大于了Survivor中一块区域的一半,那么岁数大于等于3的对象会放入老年代)

这段大家可以先理解堆区都有哪几部分,关于垃圾回收的部分后面还会讲到。

3.2Java虚拟机栈

Java虚拟机栈的生命周期和线程相同,Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧存储局部变量表、操作数栈、动态连接、方法出口等信息。

Java虚拟机栈
1.局部变量表
存放了8大基本数据类型,对象引用。其中在编译期间完成内存大小的分配,且在运行期间不会改变,基本数据类型存放的为值,对象引用存放的为引用指针。
2.操作栈
每个方法会生成一个先进后出的操作栈,主要用于存储计算的临时数据。(这可以参考https://blog.csdn.net/z318913/article/details/123004876 图画的很详细)
3.动态连接
由于OOP的思想中多态的概念使得编译器在编译源代码时无法确定其对象类型,只有在运行是才能确定对象,指向常量池中方法的引用。
4.方法出口
记录方法结束时的出栈地址(正常执行结束时的返回地址或由于报错结束时的异常地址)。

3.3本地方法栈

本地方法栈与虚拟机栈相似,不同的地方是本地方法栈是为虚拟机的Native方法提供服务;Hostpot将虚拟机栈与本地方法栈合二为一

3.4程序计数器

由于CPU在执行的时候会存在时间片切换的概念,所以CPU执行指令是可能会中断的,这时候程序计数器会记录当前线程执行停止的字节码指令位置(行号) 以便于再次切换到该线程之后能够恢复到正确的执行位置而避免重新执行。

程序计数器需要注意以下两点
1.如果执行的是Native方法,计数器值为空。
2.程序计数器内存区域是唯一一个在JVM规范中没有规定任何OOM情况的区域!

3.5方法区

方法区主要用于存储以下几部分:

  1. 类信息
  2. 符号引用
  3. 静态常量
  4. 即时编译器编译后的代码等数据

对于HotSpot来说,jdk1.8之后,字符串常量池被移动到了堆区;此时的方法区被称为元空间,元空间的内存属于本地内存,这样元空间的大小就不再受JVM最大内存参数的影响,而是与本地内存有关。

四、类加载机制

4.1类加载顺序

类加载顺序是这样的:
类加载顺序
我们挨个看看每一步都做了什么:
1.加载
加载是类加载中的第一步,不要把概念混淆了。
加载主要做了以下几个内容:

  1. 获取class字节码文件二进制字节流
  2. 将磁盘文件静态结构载入内存方法区转换为运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

2.验证
验证是连接阶段的第一步,主要目的是为了确保Class文件的字节流中包含的信息符合《Java虚拟机》的全部约束要求,保证这些信息被当成代码运行后不会危害虚拟机自身的安全。
这步主要验证以下几点:

  1. 文件格式验证
  2. 字节码验证
  3. 符号引用验证

3.准备
准备阶段是正式为类中定义的变量(既静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

比如有一个静态变量和成员变量
public static int value = 88;
public boolean flag = true;
此时value的值为0,flag的值为false

4.解析
因为多态的原因此时要将常量池内的符号引用替换为直接引用
5.初始化
此时Java虚拟机真正开始执行类中编写的Java代码,将主导权交给程序。初始化阶段就是执行类构造其方法的过程。

以上就是类加载的主要过程,刚开始记得话记主要步骤,要是主要步骤会记混建议结合做车这一现实中的场景来记忆。
坐车流程

4.2双亲委派模型

不知道大家有没有想过一个问题:当类加载时如果一个父类有两个子类A和B,当A启动时会将A的父类也加载起来,那么当启动B时,还会加载这个父类,相当于这个父类被重复加载了两次。这还只是这个父类只有两个子类,我们都知道Java里面Object是所有类的父类,那Object需要被加载多少次?这种浪费资源的做法在程序员前辈面前是非常碍眼的,所以又了双亲委派模型。
1.什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,首先不会加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是这样,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当加载器反馈无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会去尝试自己去完成加载。
2.双亲委派模型的优点

  1. 避免重复加载类(解决上述问题)
  2. 安全性(如果没有双亲委派模型,我们就可以自己定义一个Object类,在里面写个死循环,想象一下会发生什么?再比如写个病毒。。。但是有双亲委派模型的话就会在一开始就将jdk的系统类子类加载好,你再写一个Object类或其他类也没用)
    3.破坏双亲委派模型
    双亲委派模型很好,但有时也会失效,比如Java中的(Service Provider Interface,服务提供接口)机制中的JDBC实现。

小知识:SPI 全称 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的接口,它可以用来启用框架扩展和替换组件。 SPI 的作用就是为这些被扩展的 API 寻找服务实现。

我们在进行JDBC编程时需要调用DriverManager,DriverManager位于rt.jar下的sql包里(感兴趣的同学去jdk源码找),所以DriverManager由BootStrap类加载器加载,但是DriverManager提供的Driver接口的实现类出现在服务商提供的Jar包中,由子类加载器(线程上下文加载器Thread.currentThread().getContextClassLoader )来加载的。这样就破坏了双亲委派模型(因为双亲委派模型需要将所有类交给父类加载)
在这里插入图片描述

五、JVM垃圾回收

通过上面的学习我们知道,Java中创建的对象和数组都存放在堆里面,堆的大小是有限的,如果创建的所有对象一直呆在堆里面,那么满了就会报异常。所以我们要将无用的对象进行回收。JVM的垃圾回收机制做的就是这么一件事,这里面我们需要知道3个主要内容:(1)找到无用对象(死亡对象);(2)使用什么方法回收?;(3)使用什么工具?

5.1死亡对象的判断算法

常见的判断死亡对象的算法有两种:引用计数算法和可达性分析算法,通过这些算法我们就可以找到死亡对象,完成第一步。

5.1.2引用计数器算法

引用计数器算法就是给对象添加一个引用计数器,当有一个地方引用他,那么计数器+1;当引用失效时,计数器-1 。任何时候计数器为0的对象都被认定为死亡对象。

引用计数器的实现简单,也很高效,Python就采用的这种算法。但是JVM并没有使用,而是使用可达性分析算法。为什么呢?因为当两个对象相互引用,即使两个对象都被置空,也不会被认定为是死亡对象,这就是循环引用问题。

5.1.3 可达性分析算法

引用计数器算法是直接判断对象是否已死,但是可达性 分析是用于判断对象是否存活,然后进行排除。

引用计数器的实现原理就是通过一些“GC Roots”的对象作为起始点,从这些结点向下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Roots 没有任何引用链相连时,说明此对象是不可用的,也就被认为是死亡对象。
问题来了,总不能所有对象都能是GC Roots吧,如果真是这样,那这个算法将没有意义。所以一般将以下几种对象作为GC Roots:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 本地方法栈中JNI(Native方法)引用的对象
  3. 方法区中类静态属性引用的变量
  4. 方法区中常量引用的对象

5.2 垃圾回收算法

知道哪些对象是死亡对象之后,接下来就要想着用什么方法回收这些对象了。

5.2.1标记清除算法

标记清除算法很简单,就是将死亡对象标记出来,垃圾回收的时候跳过没有被标记的区域,清理被标记的对象所在的区域。
标记清除算法
这种算法有两个问题:

  1. 效率问题:标记和清理这两个过程的效率都不高
  2. 空间问题:标记清除后会产生大量的不连续的内存碎片,空间碎片过多导致内存利用率不高,当在之后需要给一个较大的对象分配空间时,无法找到连续空间而不得不提前触发下一次垃圾回收。

5.2.2复制算法

复制算法是为了解决标记清理算法的效率问题的,它将内存分为相等大小的两块,每次只使用其中的一块,当对一块内存进行垃圾回收时,会将这一块的存活对象复制到另一块内存中,然后将这一块内存全部对象回收,也就不会产生内存碎片。
在HotSpot中新生代使用的就是这种算法。因为新生代的大多数对象是朝生夕死的,所以使用这种算法时就更快。
复制算法

5.2.3标记-整理算法

在老年代中,由于对象的存活率更高,所以不适合使用复制算法,而是使用标记-整理算法。
标记-整理算法就是将死亡对象标记出来,然后将存活的对象向一端移动,然后清理另一端的死亡对象。
在这里插入图片描述

5.3 垃圾回收器

在这里插入图片描述
这幅图总结了各个GC回收器使用的算法和显著特点,值得注意的是CMS是支持并发的,G1是可单独使用,不必搭配其它GC回收器。各位同学感兴趣可以去了解每一个回收器具体细节。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

友农

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值