目录
前言
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 和其他两个虚拟机的区别:
- VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
- JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
JVM 是一台被定制过的现实当中不存在的计算机。
JVM内存区域划分
JVM其实就是一个Java进程,Java进程也就是JVM会从操作系统这里申请一大块内存空间,给Java代码来使用。
JVM从操作系统申请的这块内存空间中,进行进一步的划分,给出了每块划分后的空间的不同用途。
其中,最核心的就是栈、堆、元数据区(方法区)。
- 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
- 本地方法栈则是给JVM内部的本地方法来使用的。
- 堆上存放的就是new出来的对象、成员变量。
- 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。
需要注意的是,堆和元数据区,在一个JVM 中只存在一份,也就是多个线程共享堆区和元数据区。
栈(本地方法栈和虚拟机栈)和程序计数器则是存在多份的,也就是每个线程都会有一份。
JVM的线程操作和操作系统的线程操作是一对一的关系。也就是说每次在Java代码中创建的线程都会在操作系统中有一个线程与之对应。
这里的面试题主要就是判断某个变量或者对象在JVM的那个区域?
例如下面代码:
void func() {Test t1 = new Test();
}
上述代码在一个方法里面我们实例化了一个Test对象。
func方法是在元数据区以一些二进制的指令来存储的。
我们可以看到t1变量是一个在方法里面定义的,所以他是一个局部变量,局部变量就存储在栈上。
而new Test(); 这个对象的本体则是在堆上的。
其实像这里的关于JVM区域的面试题,我们只需要知道JVM的每个区域都是存储什么东西的就好了。
- 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
- 本地方法栈则是给JVM内部的本地方法来使用的。
- 堆上存放的就是new出来的对象、成员变量。
- 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。
JVM的类加载机制
对与一个类来说,他的生命周期是这样的:
前面的5步也是类加载的过程和固定的顺序。我们主要研究前面的5步。
类加载具体就是把一个.class文件,也就是类编译后的文件,加载到内存中,得到了类对象这样的过程就称之为类加载。
一个程序想要运行,就需要把指令和数据加载到内存中。类加载就是做的这个事情。
下面是类加载的5个步骤:
1.加载
这里的加载过程其实简单,就是找到.class文件,然后读取文件的内容。
但是在找.class文件的这个过程中,会有一个非常重要的机制:双亲委派模型
双亲委派模型
在JVM中,加载类需要用到一组特殊的模块:类加载器。
在JVM中,内置了三个类加载器。
- BootStrap ClassLoader 负责加载Java标准库中的类
- Extension ClassLoader 负责加载一些非标准的但是是Sun/Oracle扩展库的类
- Application ClassLoader 负责加载项目中自己写的类、以及第三方库中的类
当具体加载一个类的时候,他的过程是这样的:
需要先给定一个类的全限定类名,"java.lang.String" 这个类名是一个字符串的形式。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层BootStrap ClassLoader类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
具体可以参考下图:
2.验证
由于.class文件有着明确的数据格式(二进制的),这一阶段的主要目的就是确保Class文件中的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求。
验证选项
文件格式验证
字节码验证
符号引用验证……
3.准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
比如下面这样的代码:
public static int value = 123;
此时在准备阶段value的值并不是123,而是0。
4.解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
- 符号引用:就是字符串常量在.class文件已经存在,但是他们只知道彼此之间的相对位置,并不知道自己在内存中的具体位置。
- 直接引用:真正的加载到内存中,就会把字符串常量填充到内存中的特定地址上去。此时字符串引用的就是直接引用,(也就是Java中普通的引用)。
5.初始化
在初始化阶段,JVM才真正的执行类中编写的Java代码,将主导权交给应用程序,初始化阶段就是执行类的构造方法的过程。(类要是有父类,就需要先初始化父类,在初始化子类)。
触发类加载
注意:类加载这个动作不是说JVM一启动就会进行加载,因为JVM整体是一个懒加载的策略,也就是非必要,不加载。
以下三种请况就会加载:
- 创建了这个类的实例
- 使用了这个类的静态方法/静态属性
- 使用子类,会触发父类的加载
JVM的垃圾回收策略 GC
Java中的垃圾回收是为了帮助我们自动释放内存的一种机制。
面试题:为什么需要垃圾回收机制
因为在程序运行过程中,会向操作系统申请大量的内存空间,但是这些空间也有可能会消耗尽,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
上面我们谈到了关于JVM的几个区域,那么垃圾回收释放的是那个区域的空间呢?
需要注意的是,栈和程序计数器是每个线程都会有一份的。他们会随着线程的销毁而一起销毁的。
而元数据区里面的存储的类对象,很少会进行销毁。
所以我们释放的就是堆中的空间。上面我们谈到堆中主要就是存放new 出来的对象的。
GC也就是以对象为单位进行释放的。(释放对象)
GC中主要分为两个阶段:
一:找 谁是垃圾
Java通过引用来判断是否是垃圾对象,如果没有引用指向,就判定这个对象是垃圾。
1.引用计数
给对象安排一个额外的空间,保存了一个整数,表示该对象有几个引用指向它。Java实际上并没有采取这样的方案,(Python、PHP采用了这个方案)。
Test t1 = new Test();
此时是有一个引用指向的,所以引用计数器为1。
如果代码变成这样:
Test t1 = new Test();
Test t2 = t1;
也就是说随着引用的增加,计数器就会增加,引用的销毁,计数器就会减少。
当计数器为0时,就会认为该对象没有引用指向了,就是垃圾了。
但是缺点也是很明显:
- 浪费内存空间
- 存在循坏引用的情况
2.可达性分析 (这个方案是Java采取的方案)。
把对象之间的引用关系理解成为了一个树形结构,从一些特殊的起点出发,进行遍历,只要能访问到,是可达的,不是垃圾,再把不可达的当做垃圾即可。
此时通过root这个引用是可以访问到整个树的任意节点的。
可达性分析的关键要点在于要进行上述的遍历,需要有起点的。
起点可以是:
- 栈上的局部变量(每个栈的每个局部变量都是起点)
- 常量池中引用的对象
- 方法区中静态成员引用的对象
可达性分析,总体就是从所有的起点出发,看看该对象里面又通过哪些引用能访问到那些对象,顺藤摸瓜的把所有可以访问的对象都访问一遍,遍历的同时把对象标记为“可达”。
可达性分析,克服了引用计数的两个缺点
但是也是有自己的问题:
- 消耗更多的时间 因此即使某个对象成了垃圾,也不能第一时间发现,因为在扫描的过程中,也是需要时间的。
- 在进行可达性分析的时候,要顺藤摸瓜,一旦这个过程中,当前代码中的对象的引用关系发生了变化,就可以出现bug。
因此为了更好的完成这个顺藤摸瓜的过程,就需要让其他的业务线程都暂停工作!!!(STW)
(STW) stop the world !
但是Java毕竟发展了这么多年,拉进回收这里也是在不断的进行优化,STW这个问题也可以比较好的对付了。
二:释放垃圾对象
三种典型的策略
1:标记清除
如果现在向内存申请了一块下面这样的空间,然后我标出来的就是垃圾对象,需要清除的。
这种策略就是直接把垃圾对象的内存就释放了。
但是这种简单粗暴的方式会产生内存碎片。
内存碎片:申请空间都是连续的整块空间,现在上述图中的空闲空间都是散落在独立的空间里面的。现在空闲总空间可能超过1G,但是我想申请500M,却是申请不了。
2:复制算法
这种方法是把空间分为两部分。一次只使用一半。
复制算法就是把不是垃圾的对象拷贝到一边去,然后在统一释放整个区域。
此时我要释放的是2和4,我就需要把剩下1和3复制到另一边去。然后再把这边全部释放。
复制算法解决了内存碎片的问题,但是也有缺点:
- 内存利用率比较低
- 如果大部分对象都是保留的,垃圾很少,此时的复制成本就比较高
3:标记整理
类似于顺序表删除中间元素,有一个搬运的过程
解决了内存碎片问题但是搬运的整体开销也是比较大的。
JVM实现思路
实际上,JVM的实现方式是结合了上述几种思想之后的方法。
分代回收思想
具体细节:
- 给对象设置年龄这样的概念,用来描述这个对象存在多久了。如果一个对象刚诞生,那么就是0岁。
- 每次进过一次扫描(可达性分析)如果没有被标记为垃圾对象,这是对象年龄就增加一岁。
- 通过年龄来区分这个对象的活动时间。
经验规律:年龄越大的对象,也将会持续存在更长的时间。
针对不同的年龄来采取不同的回收策略
JVM针对这几个区域来执行不同的策略。
1:新创建的对象,放在伊甸区
垃圾回收扫描到伊甸区之后,大多数的对象将会在第一轮扫描下被GC给淘汰掉。
2:如果伊甸区的对象,熬过第一轮GC,就会通过复制算法,拷贝到生存区。
生存区分为两半(大小相等),一次只使用其中的一半。
如果GC在扫描生存区的时候,发现垃圾对象也就淘汰,不是垃圾的,就通过复制算法拷贝到生存区的另一边。
3:当对象在生存区熬过了若干次GC的时候,年龄也变大了。此时就会通过复制算法拷贝到老年代。
4:进入老年代之后,由于年龄都比较大了,被标记为垃圾对象的概念也很小,所以针对老年代的GC扫描也会降低频率。
特殊情况:如果对象非常大,直接进入老年代(大对象进行复制算法,成本非常高,而且大对象也不会很多)。