JVM——类加载和垃圾回收

目录

前言

JVM简介

JVM内存区域划分

JVM的类加载机制

1.加载

双亲委派模型

2.验证

验证选项

3.准备

4.解析

5.初始化

触发类加载

JVM的垃圾回收策略 GC

一:找     谁是垃圾 

1.引用计数

2.可达性分析  (这个方案是Java采取的方案)。

二:释放垃圾对象

三种典型的策略

JVM实现思路


前言

我们在学习JVM的时候,其实里面的内容是非常之多的,但是里面的大部分内容都是属于八股,想要彻底搞明白,就需要看大量的关于JVM的源代码,JVM的源代码是C++写的。想要深入研究的可以去看看《深入理解Java虚拟机》这本书。

这篇文章主要针对JVM中的常见的面试题来展开。

JVM简介

JVM 是 Java Virtual Machine 的简称,意为 Java虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。
JVM 和其他两个虚拟机的区别:

  1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
  2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。

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

JVM内存区域划分

JVM其实就是一个Java进程,Java进程也就是JVM会从操作系统这里申请一大块内存空间,给Java代码来使用。

JVM从操作系统申请的这块内存空间中,进行进一步的划分,给出了每块划分后的空间的不同用途。

JVM——类加载和垃圾回收_第1张图片

其中,最核心的就是栈、堆、元数据区(方法区)。

  • 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
  • 本地方法栈则是给JVM内部的本地方法来使用的。
  • 堆上存放的就是new出来的对象、成员变量。
  • 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。

需要注意的是,堆和元数据区,在一个JVM 中只存在一份,也就是多个线程共享堆区和元数据区。

栈(本地方法栈和虚拟机栈)和程序计数器则是存在多份的,也就是每个线程都会有一份。

JVM的线程操作和操作系统的线程操作是一对一的关系。也就是说每次在Java代码中创建的线程都会在操作系统中有一个线程与之对应。

这里的面试题主要就是判断某个变量或者对象在JVM的那个区域?

例如下面代码:

void func() {
    Test t1 = new Test();
}

上述代码在一个方法里面我们实例化了一个Test对象。

JVM——类加载和垃圾回收_第2张图片

 func方法是在元数据区以一些二进制的指令来存储的。

我们可以看到t1变量是一个在方法里面定义的,所以他是一个局部变量,局部变量就存储在栈上。

而new Test(); 这个对象的本体则是在堆上的。

其实像这里的关于JVM区域的面试题,我们只需要知道JVM的每个区域都是存储什么东西的就好了。

  • 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
  • 本地方法栈则是给JVM内部的本地方法来使用的。
  • 堆上存放的就是new出来的对象、成员变量。
  • 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。

JVM的类加载机制

对与一个类来说,他的生命周期是这样的:

JVM——类加载和垃圾回收_第3张图片

 前面的5步也是类加载的过程和固定的顺序。我们主要研究前面的5步。

类加载具体就是把一个.class文件,也就是类编译后的文件,加载到内存中,得到了类对象这样的过程就称之为类加载。

一个程序想要运行,就需要把指令和数据加载到内存中。类加载就是做的这个事情。

下面是类加载的5个步骤:

1.加载

这里的加载过程其实简单,就是找到.class文件,然后读取文件的内容。

但是在找.class文件的这个过程中,会有一个非常重要的机制:双亲委派模型

双亲委派模型

在JVM中,加载类需要用到一组特殊的模块:类加载器。

在JVM中,内置了三个类加载器。

  • BootStrap ClassLoader    负责加载Java标准库中的类
  • Extension ClassLoader     负责加载一些非标准的但是是Sun/Oracle扩展库的类
  • Application ClassLoader    负责加载项目中自己写的类、以及第三方库中的类

当具体加载一个类的时候,他的过程是这样的:

需要先给定一个类的全限定类名,"java.lang.String"  这个类名是一个字符串的形式。

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层BootStrap ClassLoader类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

具体可以参考下图:

JVM——类加载和垃圾回收_第4张图片

2.验证

由于.class文件有着明确的数据格式(二进制的),这一阶段的主要目的就是确保Class文件中的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求。

验证选项

文件格式验证

字节码验证

符号引用验证……

3.准备

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

比如下面这样的代码:

public static int value = 123;

此时在准备阶段value的值并不是123,而是0。
 

4.解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。

  • 符号引用:就是字符串常量在.class文件已经存在,但是他们只知道彼此之间的相对位置,并不知道自己在内存中的具体位置。
  • 直接引用:真正的加载到内存中,就会把字符串常量填充到内存中的特定地址上去。此时字符串引用的就是直接引用,(也就是Java中普通的引用)。

5.初始化

在初始化阶段,JVM才真正的执行类中编写的Java代码,将主导权交给应用程序,初始化阶段就是执行类的构造方法的过程。(类要是有父类,就需要先初始化父类,在初始化子类)。

触发类加载

注意:类加载这个动作不是说JVM一启动就会进行加载,因为JVM整体是一个懒加载的策略,也就是非必要,不加载。

以下三种请况就会加载:

  1. 创建了这个类的实例
  2. 使用了这个类的静态方法/静态属性
  3. 使用子类,会触发父类的加载

JVM的垃圾回收策略 GC

Java中的垃圾回收是为了帮助我们自动释放内存的一种机制。

面试题:为什么需要垃圾回收机制

因为在程序运行过程中,会向操作系统申请大量的内存空间,但是这些空间也有可能会消耗尽,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

上面我们谈到了关于JVM的几个区域,那么垃圾回收释放的是那个区域的空间呢?

需要注意的是,栈和程序计数器是每个线程都会有一份的。他们会随着线程的销毁而一起销毁的。

而元数据区里面的存储的类对象,很少会进行销毁。

所以我们释放的就是堆中的空间。上面我们谈到堆中主要就是存放new 出来的对象的。

GC也就是以对象为单位进行释放的。(释放对象)

GC中主要分为两个阶段:

一:找     谁是垃圾 

Java通过引用来判断是否是垃圾对象,如果没有引用指向,就判定这个对象是垃圾。

1.引用计数

给对象安排一个额外的空间,保存了一个整数,表示该对象有几个引用指向它。Java实际上并没有采取这样的方案,(Python、PHP采用了这个方案)。

Test t1 = new Test();

JVM——类加载和垃圾回收_第5张图片

 此时是有一个引用指向的,所以引用计数器为1。

如果代码变成这样:

Test t1 = new Test();
Test t2 = t1;

JVM——类加载和垃圾回收_第6张图片

 也就是说随着引用的增加,计数器就会增加,引用的销毁,计数器就会减少。

当计数器为0时,就会认为该对象没有引用指向了,就是垃圾了。

但是缺点也是很明显:

  1. 浪费内存空间
  2. 存在循坏引用的情况

2.可达性分析  (这个方案是Java采取的方案)。

把对象之间的引用关系理解成为了一个树形结构,从一些特殊的起点出发,进行遍历,只要能访问到,是可达的,不是垃圾,再把不可达的当做垃圾即可。

JVM——类加载和垃圾回收_第7张图片

 此时通过root这个引用是可以访问到整个树的任意节点的。

可达性分析的关键要点在于要进行上述的遍历,需要有起点的。

起点可以是:

  1. 栈上的局部变量(每个栈的每个局部变量都是起点)
  2. 常量池中引用的对象
  3. 方法区中静态成员引用的对象

可达性分析,总体就是从所有的起点出发,看看该对象里面又通过哪些引用能访问到那些对象,顺藤摸瓜的把所有可以访问的对象都访问一遍,遍历的同时把对象标记为“可达”。

可达性分析,克服了引用计数的两个缺点

但是也是有自己的问题:

  • 消耗更多的时间 因此即使某个对象成了垃圾,也不能第一时间发现,因为在扫描的过程中,也是需要时间的。
  • 在进行可达性分析的时候,要顺藤摸瓜,一旦这个过程中,当前代码中的对象的引用关系发生了变化,就可以出现bug。

因此为了更好的完成这个顺藤摸瓜的过程,就需要让其他的业务线程都暂停工作!!!(STW)

(STW)   stop the world !

但是Java毕竟发展了这么多年,拉进回收这里也是在不断的进行优化,STW这个问题也可以比较好的对付了。

二:释放垃圾对象

三种典型的策略

1:标记清除

 如果现在向内存申请了一块下面这样的空间,然后我标出来的就是垃圾对象,需要清除的。

JVM——类加载和垃圾回收_第8张图片

 这种策略就是直接把垃圾对象的内存就释放了。

但是这种简单粗暴的方式会产生内存碎片。

内存碎片:申请空间都是连续的整块空间,现在上述图中的空闲空间都是散落在独立的空间里面的。现在空闲总空间可能超过1G,但是我想申请500M,却是申请不了。

2:复制算法

这种方法是把空间分为两部分。一次只使用一半。

复制算法就是把不是垃圾的对象拷贝到一边去,然后在统一释放整个区域。

JVM——类加载和垃圾回收_第9张图片

 此时我要释放的是2和4,我就需要把剩下1和3复制到另一边去。然后再把这边全部释放。

JVM——类加载和垃圾回收_第10张图片

 复制算法解决了内存碎片的问题,但是也有缺点:

  • 内存利用率比较低
  • 如果大部分对象都是保留的,垃圾很少,此时的复制成本就比较高

3:标记整理

类似于顺序表删除中间元素,有一个搬运的过程

JVM——类加载和垃圾回收_第11张图片 解决了内存碎片问题但是搬运的整体开销也是比较大的。

JVM实现思路

实际上,JVM的实现方式是结合了上述几种思想之后的方法。

分代回收思想

具体细节:

  • 给对象设置年龄这样的概念,用来描述这个对象存在多久了。如果一个对象刚诞生,那么就是0岁。
  • 每次进过一次扫描(可达性分析)如果没有被标记为垃圾对象,这是对象年龄就增加一岁。
  • 通过年龄来区分这个对象的活动时间。

经验规律:年龄越大的对象,也将会持续存在更长的时间。

针对不同的年龄来采取不同的回收策略

JVM——类加载和垃圾回收_第12张图片

JVM针对这几个区域来执行不同的策略。

1:新创建的对象,放在伊甸区

垃圾回收扫描到伊甸区之后,大多数的对象将会在第一轮扫描下被GC给淘汰掉。

2:如果伊甸区的对象,熬过第一轮GC,就会通过复制算法,拷贝到生存区。

生存区分为两半(大小相等),一次只使用其中的一半。

如果GC在扫描生存区的时候,发现垃圾对象也就淘汰,不是垃圾的,就通过复制算法拷贝到生存区的另一边。

3:当对象在生存区熬过了若干次GC的时候,年龄也变大了。此时就会通过复制算法拷贝到老年代。

4:进入老年代之后,由于年龄都比较大了,被标记为垃圾对象的概念也很小,所以针对老年代的GC扫描也会降低频率。

特殊情况:如果对象非常大,直接进入老年代(大对象进行复制算法,成本非常高,而且大对象也不会很多)。

你可能感兴趣的:(JVM,jvm,后端,java)