> 文章列表 > 深度剖析JVM三个面试常考知识点

深度剖析JVM三个面试常考知识点

深度剖析JVM三个面试常考知识点

目录

🐳今日良言:只要你足够努力,生命都会庇佑你

🐇一、JVM内存区域划分

🐇二、类加载过程

🐇三、垃圾回收机制(GC)


🐳今日良言:只要你足够努力,生命都会庇佑你

🐇一、JVM内存区域划分

先来了解一下什么是JVM :

JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。

虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。

常见的虚拟机:JVM、VMwave、Virtual Box。

JVM 和其他两个虚拟机的区别:

1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;

2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进

行了裁剪。

JVM 是一台被定制过的现实当中不存在的计算机。

对jvm有了简单的认识以后,接下来重点介绍一下 JVM 相关的三个面试常考知识点.

JVM内存区域划分:

JVM 是一个应用程序,一个 JVM 就是一个 java 进程,JVM 在启动的时候,会从操作系统这里申请到一整个很大的内存区域.

JVM 就根据需要将整个空间分成5个部分,各个部分都有不同的作用和功能.如下图:

 本地方法栈:

native 表示是 JVM 内部的 C++ 代码.

本地方法栈就是给调用native方法(JVM内部方法)准备的栈空间,存储的是native方法之间的调用关系.

程序计数器:

记录当前线程执行到哪个指令,每个线程有一份.

虚拟机栈:

给 java 代码使用的栈空间.存储的是方法之间的调用关系.

整个栈内部空间,可以认为是包含很多个元素(每个元素表示一个方法),每个元素又叫做"栈帧",每个栈帧里面包含了这个方法的入口地址、局部变量、参数、返回地址等.每个线程有一份.

堆:

整个 JVM 空间最大的区域, new 出来的对象就是在堆上,类的成员变量也是在堆上.

元数据区(又叫方法区):

存储的是类对象、静态成员、常量池等.

这里相关的面试题最主要的考点是,给一段代码,问某个变量在哪个区域上:

1.局部变量在虚拟机栈上.

2.普通成员变量在堆上.

3.静态成员变量在元数据区/方法区.


🐇二、类加载过程

介绍完了 JVM 的内存区域划分,再介绍第二个知识点:类加载过程.

类加载过程就是将一个 .class 文件(字节码文件, .java文件通过javac(java编译器)得到),从文件(硬盘)加载到内存(元数据区)的过程.

主要有以下流程:

 加载

通过双亲委派模型找到 .clas 文件,打开文件,将文件内容读到内存中.

验证

检查 .class 文件的格式对不对. .class 文件是一个二进制文件.

准备

给类对象分配内存空间(在元数据区占个位置),将静态成员设置成0值.

解析

初始化字符串,将符号引用转为直接引用.

初始化

调用构造方法,进行成员初始化,执行静态代码块,代码块,加载父类等...

发生类加载的时机:

1)构造类的实例.

2)调用类的静态方法/使用类的静态属性.

3)加载子类之前先加载其父类. 

 双亲委派模型:

双亲委派模型描述的就是上述加载阶段找 .class 文件的过程.

JVM 默认提供了三个类加载器.

1) BootstrapClassLoader

    负责加载java标准库中的类.(java规范,无论是哪种 JVM 的实现,都会提供的一样的类)

2)ExtendsionClassLoader

    负责加载 JVM 扩展库中的类.(java规范之外,由实现 JVM 的厂商提供的额外的类)

3)ApplicationClassLoader

    负责加载用户项目/用户提供的第三方库 中的类.

上述这三个类存在"父子关系",并不是继承中的父类和子类,而是每个类加载器中有一个parent 属性,指向自己的父亲---类加载器.

上述类加载器配合流程如下:

首先,加载一个类从ApplicationClassLoader开始,但是 ApplicationClassLoader 并不是真的加载,而是交给自己的父类加载,于是 ExtendsionClassLoader 开始加载, 但是也不是真的加载,而是交给自己的父类加载器BootstrapClassLoade,BootstrapClassLoader发现自己的parent属性为null,于是自己开始加载,搜索自己负责的标准库目录相关的类,找到了就加载,找不到就交给自己的子类加载器,于是ExtendsionClassLoader开始加载,搜索自己负责的扩展库目录相关的类,找到了就加载,找不到就交给自己的子类加载器,于是ApplicationClassLoader开始加载,搜索用户项目相关的类,找到了就加载.找不到就交给自己的子类加载器,但是此时ApplicationClassLoader的子类加载器为空,于是报类找不到这样的异常.

大致流程图如下:

为什么要有上述顺序呢?

上述这套顺序其实是出自于 JVM 实现代码的逻辑.

这段代码大概是类似于"递归"的方式写的.

这个顺序最主要的目的就是为了让Bootstrap能够先加载,Application最后加载,这就可以避免因为用户创建了一些奇怪的类,引起不必要的bug.

比如说:一个用户在自己的代码中写了一个 java.lang.String 这个类,按照上面的类加载流程,此时 JVM 加载的还是标准库中的类,不会加载到用户自己写的这个类,这样就能保证,即使出现上述情况,也不会让 JVM 内部的代码混乱,最多就是让用户自己写的这个类不生效. 


🐇三、垃圾回收机制(GC)

接下来介绍一下最后一个知识点:垃圾回收机制.

首先理解一下什么是"垃圾"?

Java中的垃圾指的是:不再使用的内存.

垃圾回收就是将不再使用的内存自动释放.

GC是最主流的解决垃圾回收的一种方式.

GC优点:非常省心,让程序员写代码简单点,不容易出错.

GC缺点:需要消耗额外的系统资源,也有额外的性能开销.

另外,GC这里还有一个比较关键的问题:STW(Stop The World):

如果有时候,内存中的垃圾太多了,此时触发一次GC操作,开销可能非常大,大到可能就把系统资源吃了很多,另一方面,GC回收垃圾的时候可能会涉及到一些 锁操作,导致业务代码无法正常执行,这样的卡顿,极端情况下,可能出现几十毫秒甚至上百毫秒.

GC主要是针对 堆 进行释放的.

GC是以"对象"为基本单位进行回收的!!(不是字节).

GC回收的是整个对象都不再使用的情况.

这样的设定就是为了"简单".

GC实际工作过程:

1.找到垃圾/判定垃圾

2.进行垃圾释放

1.找到垃圾/判定垃圾

  java中通过引用来使用一个对象,如果没有引用指向该对象了,说明这个对象就不再使用了.

  具体如何知道对象是否有引用指向呢? 

  两种典型实现:

  1).引用计数

       给每个对象分配一个计数器,只要有引用指向这个对象就让计数器+1,指向这个对象的引用销毁了,就让这个计数器-1.

         

{Test t = new Test();   // Test 对象的引用计数 1Test t2 = t;           // Test 对象的引用计数 2Test t3 = t;           // Test 对象的引用计数 3
}

        这是一种简单有效的办法,但是会带来一定问题:

         内存空间浪费的多(利用率低):每个对象都要分配一个计数器,如果按4个字节算,代码中

         的对象非常少,无所谓,如果对象特别多,占用的额外空间就会很多,尤其是每个对象比较

         小的情况.  

         如果一个对象1k,此时多4个字节无所谓.

         如果一个对象4个字节,此时多4个字节,体积扩大一倍.

         还存在循环引用的问题:

         

class Test {Test t = null;
}Test a = new Test();  // 1号对象,引用计数是1Test b = new Test();  // 2号对象,引用计数是1a.t = b;              // a.t也指向2号对象,2号对象引用计数是 2 了.b.t = a;              // b.t也指向1号对象,1号对象引用计数是 2 了.

    接下来,如果a和b引用销毁了,1号对象和2号对象的引用计数都-1,但是结果都是1,不是0,

    虽然不是0,不能释放资源,但是这两个对象都无法访问到了.

    2).可达性分析

    java中的对象都是通过引用来访问的,通常是一个引用指向一个对象,这个对象里的成员又 

    指向其它对象.(比如二叉树的节点)

    可达性分析就是把组织这些所有对象的结构视为是树,就从根节点开始遍历树,所有能访

    问到的节点标记为"可达"(不能访问的就是不可达).

    JVM 自己捏着一个所有对象的名单.通过上述遍历,把可达的标记出来,不可达的就是垃圾

    进行释放.

    可达性分析需要进行类似于"树遍历",这个操作相比于引用计数来说是要慢一些的,但是速度

    慢是没关系的,上述可达性分析遍历操作,并不需要一直执行,只需要每隔一段时间,分析一遍

    就可以了.

    进行可达性分析遍历的起点,称为GCroots,可以作为GCroots的有:

    栈上的局部变量,常量池中的对象,静态成员变量.

    一个代码中,有很多个这样的起点,把每个对象都往下遍历一遍就完成了一次扫描过程.

2.进行垃圾释放

   主要是三种基本做法.

   1)标记清除

      简单粗暴,直接标记垃圾,然后进行释放,但是会带来"内存碎片问题",被释放的空间是零散

      的,不是连续的.

      

     申请空间要求的是连续空间,上述总的空闲可能很大,但是具体的每一个空间又很小,可能

     导致申请大一点的内存就会失败.比如总的空闲内存是10k,分成1k一个,此时申请2k的空

     间就会失败.

   2)复制算法

      解决了"内存碎片问题".将一块大的内存空间分成两部分,用一半丢一半.

      复制算法就是把不是垃圾的对象复制到另一半,然后将整个空间删除.每次触发复制算法,

      都是向另一半进行拷贝.

      

 

        复制算法的缺点:1.空间利用率低(用一半丢一半) 2.(垃圾少的话,复制成本比较大)

3)标记整理

     解决了复制算法的缺点.

     类似于顺序表删除中间元素,会有元素搬运的操作.

      保证了空间利用率,同时也解决了"内存碎片问题".但是这种做法,效率也不高,如果要搬运

      的空间比较大,此时开销也很大.

   

基于上述基本策略,搞了一个复合策略"分代回收"

分代是怎么分的呢?

  分代是基于一个经验规律:如果一个东西,存在的时间比较长了,那么大概率还会持续长时间的存在下去.(要没早就没了)

  上述规律,对于 java 中的对象也是有效的, java 的对象要么生命周期特别长,要么生命周期特别短,根据生命周期的长短,分别使用不同的算法.

  给对象引用一个概念:年龄 (不是以年为单位,而是熬过GC的轮次),年龄越大,这个对象存在的时间就越久.

  熬过GC的轮次是指:经过一轮可达性分析遍历以后,发现这个对象不是垃圾.

  堆划分成两个区域:新生代和老年代

  新生代又划分成三个区域:一个比较大的伊甸区,两个比较小且一样大的幸存区.

  刚 new 出来的对象,年龄是0,放在伊甸区.

  熬过一轮GC以后,就要放到幸存区了.从伊甸区-->幸存区使用复制算法.

  对象到了幸存区以后,就要周期性的接受GC的考验.

  如果变成垃圾就要释放,如果不是垃圾,就要拷贝到另外一个幸存区(两个幸存区同一时刻只能使用一个).在二者之间使用复制算法来回拷贝.由于幸存区体积不大,此处的空间浪费也能接受,

  如果这个对象在两个幸存区来回拷贝很多次了,此时就要进入老年代了.

  老年代都是年纪大的对象,生命周期周期普遍长.

  这个对象在老年代也要周期性的接受GC的扫描,但是扫描频率更低了.

  如果老年代的对象是垃圾了,就要使用标记整理的方式进行释放.