前言

本文将通过简介Class字节码文件,Class类加载,类加载完后的JVM运行时数据区,运行中的GC垃圾回收等相关知识带大家了解在面试时JVM常问的考点。

注:本文为 转载文章,原文作者:ElasticForce , 原文地址:JVM面试常考点全在这:多图看懂Java虚拟机

本文主线:

①、Class字节码文件

②、Class类加载

③、JVM运行时数据区

④、GC 垃圾回收

⑤、常见的垃圾回收器

Class字节码文件

class文件也叫字节码文件,由Java源代码(.java文件)编译而成的字节码(.class文件)。

Class文件格式:

无符号数:专用的数据类型,以u1、u2、u4、u8来代表1个字节、2个字节、4个字节、8个字节的无符号数。

表:通常以“_info" 结尾,由多个无符号数和其它表构成的复合数据类型。

魔数:标识这个文件是否是一个能被虛拟机所接受的class文件,值是固定的:0xCAFEBABE

副版本号和主版本号:判断是否是当前虚拟机所支持的版本

常量池:可以看作是class文件的资源仓库,包含class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。

Class类加载

类的生命周期

  1. 加载:通过类的 全限定类名 查找并加载类的二进制字节流
  2. 连接:目的是将加载的二进制字节流的静态存储结构转化为方法区的运行时数据结构

    • 验证:确保被加载的class文件中的字节流,符合规范,保证不会对虚拟机造成危害,如类文件结构检查、元数据验证、字节码验证、符号引用验证
    • 准备:为类的静态变量分配内存,并初始化(默认值)
    • 解析:把常量池中的符号引用转换成直接引用,让类能够直接引用到想要依赖的目标
  3. 初始化:执行类构造器< clinit >方法(类构造器不是实例构造器),为类的静态变量赋初始值
  4. 使用:分为主动使用和被动使用,JVM只会在每个类或接口首次主动使用时才初始化该类

    • 主动使用:

      ①、创建类实例

      ②、访问类或接口的静态变量、调用静态方法

      ③、反射调用某个类

      ④、初始化某个类的子类,也会初始化该类

      ⑤、实现的接口包含默认方法,该接口会被初始化

    • 被动使用:

      ①、通过子类访问父类的静态变量,不会初始化子类

      ②、访问类中的常量,不会初始化该类

类加载器:

  • 启动类加载器:负责将<JAVA_HOME>/lib,或者-Xbootclasspath参数指定的路径中的,必须是JVM识别的类库加载进内存(按照名字识别例如rt.jar,不装载不能识别的文件)
  • 扩展类加载器:负责加载<JRE_HOME> /lib/ext,或者java.ext.dirs系统变量所指定路径中的所有类库
  • 应用程序类加载器:负责加载classpath路径中的所有类库

双亲委派模型:

JVM中的ClassLoader除了启动类加载器外,其余的类加载器都应该有自己的父级加载器(如上图所示)。

双亲委派模型的工作流程

  1. 一个类加载器接收到类加载请求后,自己不去尝试加载这个类,而是先委派给父类加载器,层层委派,一直到启动类加载器
  2. 如果父级加载器不能完成加载请求,比如在它的搜索路径下找不到这个类(ClassNotFoundException),就反馈给子类加载器,让子类加载器进行加载
双亲委派模型的作用:保证了Java程序的稳定运作,使得一些公共类只会被加载一次,且防止了核心类库被用户自定义的同名类所篡改

JVM运行时数据区

注意:下图为 JDK1.7 版本:

在JDK1.8中,已经将方法区移除了,使用 元空间 进行代替,并且元空间也不是位于JVM的运行时的数据区中了,而是位于 本地内存 中,本地内存指的是什么呢? 先来看一张大图,就明白本地内存是什么了!

注意:下图为 JDK1.7 版本的:

先对上图进行描述下

当要启动一个Java程序的时候,会创建一个相应的进程;

每个进程都有自己的虚拟地址空间,JVM 用到的内存(包括堆、栈和方法区)就是从进程的虚拟地址空间上分配的。请注意的是,JVM 内存只是进程空间的一部分,除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。

在 JVM 的角度看,JVM 内存之外的部分叫作本地内存。

然后在来看 JDK1.8 版本的一张大图,应该瞬间就会明白了:

程序计数器:

程序计数器也叫PC寄存器(Program Counter),是线程私有的,即每个线程都有一个程序计数器,在线程创建时会创建对应的程序计数器,用来存储指向下一条指令的地址。

程序计数器占用很小的内存空间,JVM规范中没有规定这块内存会OOM。

虚拟机栈:

虚拟机中每个线程都有自己独有的虚拟机栈,该栈和线程同时创建,用于存储栈帧(Frame),每个方法被调用时会生成一个栈帧压栈,栈帧中存储了局部变量表、操作数栈、动态链接、方法返回地址。

栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。

栈帧随着方法调用而创建,随着方法结束而销毁。

每个栈帧都有自己的:局部变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。

正在执行的栈帧称为当前栈帧,对应的方法则是当前方法,定义了该方法的类就是当前类

局部变量表:

局部变量表存放了编译期可知的各种基本数据类型和引用类型,slot是虚拟机为局部变量分配内存所使用的最小单位,每个slot存放32位及以内的数据,对于64位的long、double数据类型需要使用两个连续的槽位,slot槽位是复用的,可以节省栈帧的空间使用。

局部变量表中的局部变量索引从0开始,当调用方法为实例方法时,第0个局部变量一定是存储的该实例方法所在对象的引用(this关键字),之后根据变量定义的顺序和作用域分配slot。

public class HelloJvm{
    public static int test1(int a,int b){
        return a+b;
    }
    public int test2(int a,int b){
        int c = a+b;
        return a+c;
    }
}
/**
*                slot name Signature
*对于test1来说:    0     a    I     
*                1     b    I     
*
*对于test2来说:    0   this  ../HelloJvm
*                1     a    I 
*                2     b    I     
*                3      c       I
*/

操作数栈:

每个栈帧内部都包含一个称为操作数栈的后进先出(LIFO)栈。操作数栈是用来存放方法在运行时,各个指令操作的数据。

栈帧在刚刚创建时,操作数栈是空的。Java 虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。

比如对于上面的test2方法中指令和对应的操作数栈:
public class HelloJvm{
    public static void main(String[] args){
        HelloJvm h = new HelloJvm()
        int res = h.test2(1,2);
        System.out.println(res);
    }
}
/**
*对于test2的栈帧中:
*局部变量表:0    this ../HelloJvm
*            1      a       I
*            2      b       I
*            3      c       I
*
*指令:    0 iload_1(加载局部变量表中slot1的int数据)
*          1 iload_2(加载局部变量表中slot2的int数据)
*          2 iadd(int相加)
*        3 istore_3(将值存储到局部变量表中slot3的int数据)
*        4 iload_1
*        5 iload_3
*        6 iadd
*        7 ireturn(返回int类型)    
*                
*
*对应的操作数栈:      return 4(栈顶)
*                    4
*                    c=3
*                    a=1
*                    c=3
*                    3
*                    b=2
*                    a=1(栈底)        
*/

动态链接:

每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,以便对当前方法的代码实现动态链接。

在class文件里面,一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用来表示,动态链接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。

方法返回:

方法执行后,有两种方式退出该方法:正常调用完成,执行返回指令。异常调用完成,遇到未捕获异常,不会有方法返回值给调用者。

本地方法栈:

和虚拟机栈结构类似,不过是用来支持native本地方法的执行。

堆区:

VM管理的内存中占比最大的一块内存,在JVM启动时创建,用来存放应用创建的对象和数组,所有的线程共享堆内存。

在JVM中,堆(heap)是可供各个线程共享的运行时内存区域,也是供所有类对象和数组对象分配内存的区域。

堆在运行期动态分配内存大小,堆的容量可以固定,也可以根据程序的需求进行伸缩。

堆中自动进行垃圾回收,无需也不能显式地销毁一个对象。

堆空间组成:

堆中根据对象在内存中存活时间的长短,空间进行了分代;
  1. Java堆 = 新生代 + 老年代
  2. 新生代 = Eden + S0 + S1

堆中默认占比1/3是新生代(Young generation),2/3是老年代(Old generation),可以通过-XX:NewRatio参数调整。

新生代中有伊甸区(Eden区)和幸存者区(Survivor区),Survivor区中又分为两块:From区和To区。
默认比为8:1:1,可以通过-XX:SurvivorRatio参数调整。

新生代用来放新分配的对象,每次使用Eden区和一块Survivor区。新生代中经过一次垃圾回收后,没有回收掉的对象,将被复制到另外一块Survivor区,同时对象年龄+1。(新生代GC算法是 复制算法

经过多次GC后,当仍存活对象年龄达到指定值,将进入老年代。

因此老年代存储对象比新生代对象年龄大得多。此外,老年代也存储着大对象,也就是当大对象产生时,可能会直接从新生代进入老年代。

堆中的对象内存分布:

对象的组成:

  • 对象头
  • 实例数据:该对象实际存储数据的地方
  • 对齐填充:JVM以字节为最小单位分配,所以要求对象的大小必须是8的倍数,这部分是为了填充位数对齐到8的倍数,没特殊意义,也不一定存在。
注意:数组对象相比于普通对象,在对象头中要多一个数组长度的字宽。
对象头:

堆、栈、方法区的交互:

对象的访问定位:

JVM中只规定了reference类型是一个指向对象的引用,但没有规定这个引用具体如何去定位、访问堆中对象的具体位置。

目前两种常见的访问方式为: 句柄、直接指针
句柄:

句柄方式: Java堆中为句柄池划分一块内存,reference中存储了对应句柄的地址,在句柄中存储对象的实例数据和类元数据的地址,也就是通过两次寻址就能访问到对应的数据。

直接指针:

直接指针方式: 而reference存储的就直接是对象的地址,而访问对象类型数据的指针存放在堆中。

注意: 直接指针的方式比句柄更快,少了一次指针定位的操作。

方法区:

JDK1.7 及之前版本是存在方法区的,而在 JDK1.8 时就将其移除了;

用来保存虚拟机装载的类的结构信息,运行时常量池、字段、方法、方法字节码,是各个线程共享的内存区域。

JVM规范把方法区描述为堆的一个逻辑部分,但它有一个别名:非堆(Non-heap), 是为了与堆内存分开。

方法区中存放的是什么?

在Java虛拟机中,方法区(method area)是可供各个线程共享的运行时内存区域。它存储了每一个类的结构信息,例如:运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法。

JDK8之前,方法区的实现是永久代,也叫持久代,是属于 JVM虚拟机的内存空间

由于这些原因:

  1. 永久代的大小不好指定。
  2. 永久代会给GC带来不必要的复杂度,回收效率降低
  3. 字符串存放在永久代中容易导致性能问题和内存溢出(字符串常量池从JDK7开始,从方法区移动到堆中)。
  4. JRockit和Hotspot的整合(JRockit没有永久代)
因此从JDK8开始,去掉了永久代,取而代之的是 元空间 (meta space),元空间并不属于JVM虚拟机内部内存,而是直接使用的 本地内存 ;本地内存上面有介绍的。

GC 垃圾回收

GC是什么?

在系统运行过程中,会产生一些无用的对象,这些对象占据着一定的内存,如果不对这些对象清理回收无用对象的内存,可能会导致内存的耗尽,所以垃圾回收机制回收的是内存。

对象回收判断:

所谓垃圾,就是指内存中已经不再被使用到的内存空间就是垃圾。

垃圾判定的算法有两种: 引用计数法、根搜索算法

引用计数法:

给对象添加一个引用计数器,有其他对象引用该对象就+1,引用失效时就-1。当引用计数器为0时,表示不再被任何对象引用,就成为了垃圾。

这种方式实现简单高效,但是有一个致命缺点:不能解决循环引用问题。

比如对象A和对象B互相引用,双方的引用计数器都为1,但是A和B都不再被使用了,却没被垃圾回收。

根搜索算法(也叫 可达性分析):

也叫 可达性分析法 ,从根节点(GCRoots)开始向下搜索对象节点,搜索过的路经称为引用链,当一个对象到根之间没有连通,就表明该对象不可用。

常见的可作为 GC Roots 的对象包括:

  • 虚拟机栈(栈帧局部变量表)中引用的对象
  • 方法区类静态属性引用的对象
  • 方法区中常量引用的对象
  • 同步锁(synchronized关键字)中持有的对象
  • 本地方法栈中调用的对象

不过对于根搜索算法来说,每次GC前都遍历所有的GCRoots并不现实。所以Hotspot引入了一个 OopMap 的映射表来实现准确式GC,这个OopMap可以用来记录对象间的引用关系。

JVM在类加载完成后,计算出对象内的什么偏移量上有什么引用,然后记录在OopMap中。垃圾判断时不用再从根节点遍历,而是直接从OopMap中扫描。

JVM可以通过OopMap快速实现GCRoots枚举,但是同样OopMap也存在一个问题,就是在程序运行中,引用关系可能随时变化,如果每条指令都去维护OopMap,这样做的成本很高。

所以JVM引入了 安全点 (Safe Point),在安全点时才去记录变化的OopMap。同时JVM要求,只有当前线程到了安全点后,才能暂停下来进行GC。

而对于在一段代码内,引用关系不会变化,任何时间进行GC都是安全的,这块区域称之为安全区域 (Safe Region) 。

判断一个对象是否是垃圾:

①、根搜索算法判断

②、否有必要执行finalize方法

③、经过前两个步骤后对象仍然不被使用,就属于垃圾

如果该对象没有覆盖重写finalize(),又或者是重写了finalize(),但是被调用过一次了(finalize方法只能被运行一次),就没有必要执行finalize()。

如果是第一次执行finalize(),会将其放入FinalizerThread的ReferenceQueue中,此时这个对象成为Finalizer,暂时不会被回收(仍然被引用),当finalize()执行完成后,就不再有任何引用,可以被垃圾回收。

因此可以在finalize方法中进行对象自救(方法内再次让该对象被引用),来实现豁免gc,但只能自救一次。

不过并不推荐重写finalize(),因为在对象进入队列后,还是占用着堆空间的内存。

引用分类:

Java中引用分为四类:

  • 强引用(StrongReference)
  • 软引用(SoftReference)
  • 弱引用(WeakReference)
  • 虚引用(PhantomReference)
不同的引用体现了不同的可达性状态和对垃圾回收的影响。

强引用:

像Object a = new A()这样的普通对象引用,只要还有强引用指向一个对象,就表明对象还存活,不可被回收。对于一个对象,如果没有其他的引用关系,只要超过了作用域,或者将引用赋值为null,就表示可以回收,但是具体回收时机还是看垃圾回收策略。

软引用:

用java.lang.ref.SoftReference来实现,比强引用弱一点的引用,如果内存空间足够,就不会被回收,只有在垃圾回收后内存还不够,才会对软引用对象进行回收。可以转化为强引用。

弱引用:

用WeakReference来实现,用来表示一种不是必须的对象,比软引用还要弱,并不能豁免垃圾回收,每次垃圾回收时会被回收掉。可以转化为强引用。

虚引用:

用PhantomReference来实现,也被称为幽灵引用或幻影引用,是最弱的引用,不能通过这个引用访问对象,可以用来确保对象执行finalize()后,来实现某些机制,比如垃圾回收的跟踪。垃圾回收时会被回收掉。

垃圾回收算法:

标记-清除算法:

标记清除算法(Mark Sweep)分为标记和清除两个阶段,先标记可回收的对象,然后统一回收这些对象。

  • 优点:实现简单
  • 缺点:效率不高,会产生不连续的内存碎片,可能导致分配大对象时连续的空间不足会引发GC

复制算法:

复制算法(Copying)会把内存分成两块完全相同的区域,每次只使用其中一块,另一块暂时不用。当触发GC时,就把使用的这块上还存活的对象拷贝到另外一块,然后清除掉这块区域,去使用另外一块。

  • 优点:实现简单,效率高,不担心内存碎片
  • 缺点:空间利用率低,每次只能使用一半的内存空间

标记-整理:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KPENmS7L-1606056723598)(https://cdn.jsdelivr.net/gh/l...]

标记整理算法(Mark Compact),和标记清除类似,也有相同的标记过程,不过回收时并不是直接清除,而是让存活的对象都向一端移动,然后清除掉边界以外的内存。虽然整理的效率要低于直接清除,但是通常不会产生内存碎片。

分代收集:

之前提到了堆中内存是运用的分代思想,分为新生代和老年代,因此GC也是分代收集的。

  • MinorGC(YoungGC):发生在新生代的GC
  • MajorGC(OldGC):发生在老年代的GC(除了CMS收集器,其他收集器会同时收集新生代)
  • MixedGC:G1收集器特有,收集整个新生代以及部分老年代
  • FulIGC:收集整个Java堆和方法区的GC

新生代使用的是复制算法,将内存分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden区和其中一块Survivor区,也就是每次使用90%的新生代内存。

发生GC时,把存活的对象复制到另一块Survivor区。如果对象超过另一块Survivor区上限,则直接进入老年代。

而老年代多选用标记整理算法,因为复制算法空间浪费验证,并且在存活对象比较多的时候,效率较低。因此老年代一般不会选用复制算法,而是选用标记整理或标记清除。

垃圾收集器:

下图展示堆中新生代和老年代中可以使用的各种垃圾回收器。

Serial + SerialOld:

Serial(串行)收集器,是一个单线程收集器,特点是是简单高效,对于单CPU来说,没有多线程的切换交互的开销,可能会更高效,是默认的Client模式下的新生代收集器。

SerialOld是它的老年代版本,使用标记整理算法。

在垃圾收集时,需要停止其他工作线程,也就是 STW (Stop-The-World):

STW是Java中一种全局暂停的现象,也就是用户线程暂停运行,多是由于GC造成。过多的STW,导致服务长时间停止,没有响应,用户体验极差。

而垃圾清除的算法的选择,GC收集器的不断进化,就是为了减少STW的时间

Parallel Scavenge + ParallelOld:

Parallel Scavenge收集器,使用多线程进行垃圾回收,在垃圾收集时会STW,应用于新生代的,使用复制算法的并行收集器。特点是着重关注吞吐量,动态调整以提供最合适的停顿时间或者最大吞吐量,能够最高效率的利用CPU,适合CPU资源敏感的应用。

ParallelOld是Parallel Scavenge的老年代版本,使用标记整理算法。

ParNew + CMS:

ParNew收集器,是Serial的多线程版本,和Parallel Scavenge收集器类似,使用多线程进行垃圾回收,在垃圾收集时会STW。

在并发能力好的CPU环境里,STW的时间要比Serial短。但对于单cpu或并发能力差的CPU,由于多线程切换的开销,效率可能低于Serial收集器。

ParNew是Server模式下的首选新生代收集器,通常和CMS收集器配合使用:

CMS(Concurrent Mark and Sweep)并发标记清除收集器,垃圾回收时有四个步骤:

  1. 初始标记:只标记GCRoots能直接关联到的对象,会进行 STW,但速度快,STW时间短
  2. 并发标记:从直接关联对象开始追踪标记
  3. 重新标记:修正在并发标记期间,因程序运行导致变动的标记,此时也会STW
  4. 并发清除:并发回收垃圾对象

CMS可以实现低停顿,用户线程和GC线程并发执行。不过CMS清除不了浮动垃圾,可能导致Full GC,且CMS采用的是标记清除算法,产生的内存碎片,在为大对象分配空间时也会导致Full GC 。

CMS的备用收集器为SerialOld,当CMS出现问题时,就使用SerialOld收集器作为老年代的收集器。

G1收集器:

G1(Garbage First)收集器是适用于 服务端应用 的收集器,G1不是只对某个代进行回收,它是对 整个堆 中的对象进行回收。 G1的设计目标是适应当前的多CPU、 多核环境的硬件优势,进一步缩短STW的时间。

G1采用的是 标记整理和复制算法

不过G1和前面的收集器有些不同,它把内存划分成多个独立的固定大小的区域( Region ),在HotSpot中,堆被分为了2048个Region,每个Region大小在1~32M之间,Region是垃圾回收的基本单位。

虽然G1保留了分代思想,也有新生代和老年代,不过并不隔离,每个Region可以属于任意代。

G1还设置了 Humongous 区域,专门用来存储大对象。在HotSpot中,当对象大小超过Region的1/2时,需要用一个或多个连续的Region存放该对象,这种区域就是Humongous。

G1最重要的特性:可预测停顿,也就是用户能指明一个时间段,要求G1尽量在这个时间段完成垃圾回收。通过 -XX:MaxGCPauseMillis=n参数配置。

G1维护了一个优先级列表,它会跟踪各个Region里面垃圾的价值大小,根据用户设置的停顿时间内,回收垃圾带来的收益进行优先级判断,选择出允许时间内价值最大的Region,从而保证在有限时间内的高效收集 。

跟CMS收集器类似的是,G1的MixedGC也分为四个阶段:

  1. 初始标记:只标记GCRoots能直接关联到的对象,会触发STW,同时伴随着一次普通的YoungGC,新生代的回收也是初始标记。
  2. 并发标记:从GCRoots开始向堆中对象进行可达性分析判断 。
  3. 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象,会触发STW 。
  4. 筛选回收:根据用户期望停顿时间来进行价值最大化的回收,会触发STW 。

对于G1来说,YoungGC只回收新生代,而MixedGC会回收新生代和老年代。因此对于MixedGC的初始标记,共用了YoungGC的STW。

筛选回收时,G1会对筛选出的区域,选择需要留下的对象拷贝到新的区域,其余被选中的对象全部清除掉。

❤ 关注 + 点赞 + 收藏 + 评论 哟

如果本文对您有帮助的话,请挥动下您爱发财的小手点下赞呀,您的支持就是我不断创作的动力,谢谢!

您可以VX搜索【木子雷】公众号,坚持高质量原创java技术文章,值得您关注!

往期文章回顾

一文让你彻底明白JVM参数该怎么设置


木子雷
213 声望268 粉丝

Web后端码仔,记录生活,分享技术!