【八股文系列】JVM

JVM 的内存布局

堆:堆Java虚拟机中最大的一块内存,是线程共享的内存区域,基本上大多数的对象实例数组都是在堆上分配空间(逃逸分析)。堆区细分为Yound区年轻代和Old区老年代,其中年轻代又分为Eden、S0、S1 3个部分,他们默认的比例是8:1:1的大小。

栈:栈是线程私有的内存区域,每个方法执行的时候都会在栈创建一个栈帧,方法的调用过程就对应着栈的入栈和出栈的过程。每个栈帧的结构又包含局部变量表、操作数栈、动态连接、方法返回地址。
局部变量表用于存储方法参数和局部变量。当第一个方法被调用的时候,他的参数会被传递至从0开始的连续的局部变量表中。
操作数栈用于一些字节码指令从局部变量表中传递至操作数栈,也用来准备方法调用的参数以及接收方法返回结果。
动态连接用于将符号引用表示的方法转换为实际方法的直接引用。

元数据:在Java1.7之前,包含方法区的概念,常量池就存在于方法区(永久代)中,而方法区本身是一个逻辑上的概念,在1.7之后则是把常量池移到了堆内,1.8之后移出了永久代的概念(方法区的概念仍然保留),实现方式则是现在的元数据。它包含类的元信息和运行时常量池。

Class文件就是类和接口的定义信息。
运行时常量池就是类和接口的常量池运行时的表现形式。

本地方法栈:主要用于执行本地native方法的区域

程序计数器:也是线程私有的区域,用于记录当前线程下虚拟机正在执行的字节码指令地址

类的加载时机

一个类的生命周期主要为:加载->验证->准备->解析->初始化->使用->卸载 这七个阶段,其中验证、准备、解析这三个阶段有被称为连接阶段。

主动引用
类加载的时机只有以下六种情况:
1.遇见new、getstatic、putstatic以及invokestatic这四条字节码指令时候,如果类型没有初始化则需要先进行初始化。
2.对类型进行反射调用的时候如果类型没有被初始化,则需要先进行初始化。
3.初始化类的时候,其父类没有被初始化,则先初始化其父类。
4.虚拟机启动的时候,虚拟机会先初始化主类(包含main()方法的那个类)。
5.使用jdk7的动态语言支持时,一个java.lang.invoke.MethodHandle实例最后解析的结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,如果这个句柄的类没有被初始化则先触发初始化。
6.一个接口定义了JDK8新加入的默认方法(被default关键字修饰的接口方法),在初始化该接口的实现类的时候,需要先初始化该接口。

被动引用
刚刚介绍的六种情况被称为主动引用,而除此之外所有引用类型的方式都不会触发初始化,而这些则被称为被动引用。
例如:
子类引用父类静态字段不会导致子类初始化。
通过数组来定义引用类不会触发此类的初始化。
常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,所以不会触发初始化。

在这插入一点小知识:常量和类以及静态变量是存储在方法区中,而实例对象则是被存储在Java堆中。栈的话存放了基本变量类型和引用对象的变量。
关于接口初始化,其在初始化的时候和类的初始化不同,其并不需要父接口的初始化,只有真正用到了父接口的时候才会初始化。

new对象的过程

当虚拟机遇见new关键字时候,实现判断当前类是否已经加载,如果类没有加载,
首先执行类的加载机制,加载完成后再为对象分配空间、初始化等。
1.首先校验当前类是否被加载,如果没有加载,执行类加载机制
2.加载:就是从字节码加载成二进制流的过程
3.验证:当然加载完成之后,当然需要校验Class文件是否符合虚拟机规范,跟我们接口请求一样,第一件事情当然是先做个参数校验了
4.准备:为静态变量、常量赋默认值
5.解析:把常量池中符号引用(以符号描述引用的目标)替换为直接引用(指向目标的指针或者句柄等)的过程
6.初始化:执行static代码块(cinit)进行初始化,如果存在父类,先对父类进行初始化
Ps:静态代码块是绝对线程安全的,只能隐式被java虚拟机在类加载过程中初始化调用!(此处该有问题static代码块线程安全吗?)

当类加载完成之后,紧接着就是对象分配内存空间和初始化的过程
1.从常量池找到对应的类元信息,通过类元信息来确定类型和后面需要申请的内存大小,首先为对象分配合适大小的内存空间(大部分分配在堆上)(分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量)
2.接着为实例变量赋默认值
将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值
3.设置对象的头信息,对象hash码、GC分代年龄、元数据信息等
4.然后执行对象内部生成的init方法,初始化成员变量值,同时执行搜集到的{}代码块逻辑,最后执行对象构造方法。

对象分配的过程

1.编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
2.如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则3.
3.重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
4.在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,
5.执行一次Young GC(minor collection)。
6.经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代

栈上分配

本质:Java虚拟机提供的一项优化技术
基本思想: 将线程私有的对象打散分配在栈上
优点:1)可以在函数调用结束后自行销毁对象,不需要垃圾回收器,有效避免垃圾回收带来的负面影响
2)栈上分配速度快,提高系统性能
局限性: 栈空间小,对于大对象无法实现栈上分配
技术基础: 逃逸分析和标量替换

TLAB

这是为了加速对象的分配。由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。
空间换时间
局限性: TLAB内存空间位于Eden区。默认TLAB大小为占用Eden Space的1%
,所以大对象无法进行TLAB分配,只能直接分配到堆上

对象的内存布局

对象在内存中布局实际包含3个部分:对象头、实例数据、对齐填充

对象头:
对象头里主要包括几类信息,分别是锁状态标志、持有锁的线程ID、GC分代年龄、对象HashCode,类元信息地址、数组长度;
锁状态标志:对象的加锁状态分为无锁、偏向锁、轻量级锁、重量级锁几种标记。
持有锁的线程: 持有当前对象锁定的线程ID。
GC分代年龄: 对象每经过一次GC还存活下来了,GC年龄就加1。
类元信息地址: 可通过对象找到类元信息,用于定位对象类型。
数组长度: 当对象是数组类型的时候会记录数组的长度。

实例数据
对象实例数据才是对象的自身真正的数据,主要包括自身的成员变量信息,同时还包括实现的接口、父类的成员变量信息。

对齐填充
根据JVM规范对象申请的内存地址必须是8的倍数,换句话说对象在申请内存大小时候8字节的倍数,如果对象自身的信息大小没有达到申请的内存大小,那么这部分是对剩余部分进行填充。

双亲委派模型

1.Bootstrap ClassLoader启动类加载器:默认会去加载JAVA_HOME/lib目录下的jar
2.Extention ClassLoader扩展类加载器:默认去加载JAVA_HOME/lib/ext目录下的jar
3.Application ClassLoader应用程序类加载器:比如我们的web应用,会加载web程序中ClassPath下的类
4.User ClassLoader用户自定义类加载器:由用户自己定义

JDBC为什么要破坏双亲委派模型

在JDBC 4.0之后实际上我们不需要再调用Class.forName来加载驱动程序了,我们只需要把驱动的jar包放到工程的类加载路径里,那么驱动就会被自动加载。
这个自动加载采用的技术叫做SPI,数据库驱动厂商也都做了更新。可以看一下jar包里面的META-INF/services目录,里面有一个java.sql.Driver的文件,文件里面包含了驱动的全路径名。
因为类加载器受到加载范围的限制,在某些情况下父类加载器无法加载到需要的文件,这时候就需要委托子类加载器去加载class文件。
JDBC的Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,比如MySQL驱动包。DriverManager 类中要加载各个实现了Driver接口的类,然后进行管理,但是DriverManager位于 $JAVA_HOME中jre/lib/rt.jar 包,由BootStrap类加载器加载,而其Driver接口的实现类是位于服务商提供的 Jar 包,根据类加载机制,当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。也就是说BootStrap类加载器还要去加载jar包中的Driver接口的实现类。我们知道,BootStrap类加载器默认只负责加载 $JAVA_HOME中jre/lib/rt.jar 里所有的class,所以需要由子类加载器去加载Driver实现,这就破坏了双亲委派模型。
查看DriverManager类的源码,看到在使用DriverManager的时候会触发其静态代码块,调用 loadInitialDrivers() 方法,并调用ServiceLoader.load(Driver.class) 加载所有在META-INF/services/java.sql.Driver 文件里边的类到JVM内存,完成驱动的自动加载

垃圾回收算法

在这里插入图片描述

复制算法(年轻代)
在使用的时候只使用Eden区和S0S1中的一个,每次都把存活的对象拷贝另外一个未使用的Survivor区,同时清空Eden和使用的Survivor,这样下来内存的浪费就只有10%了。
如果最后未使用的Survivor放不下存活的对象,这些对象就进入Old老年代

标记-整理(指针碰撞)(老年代)
标记-清除(空闲列表)(老年代)
统一标记出需要回收的对象,标记完成之后统一回收所有被标记的对象,而由于标记的过程需要遍历所有的GC ROOT,清除的过程也要遍历堆中所有的对象,所以标记-清除算法的效率低下,同时也带来了内存碎片的问题

CMS

CMS(Concurrent Mark Sweep):CMS收集器是以获取最短停顿时间为目标的收集器,可以并行收集是他的特点,同时他基于标记-清除算法
1.初始标记:标记GC ROOT能关联到的对象,较快只标记一层,需要STW
2.并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,不需要STW
3.重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记(新生代升到老年代,直接分配到老年代),需要STW
4.并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要STW
从整个过程来看,并发标记和并发清除的耗时最长,但是不需要停止用户线程,而初始标记和重新标记的耗时较短,但是需要停止用户线程,总体而言,整个过程造成的停顿时间较短,大部分时候是可以和用户线程一起工作的。

G1

它同CMS相比,G1基于标记-复制算法,不会产生很多内存碎片。 G1的Stop The World更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。(G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的Region数量,从而尽量满足用户设定的目标停顿时间。 停顿预测模型是以衰减标准偏差为理论基础实现的,在这个预测计算公式中:davg表示衰减均值,sigma()返回一个系数,表示信赖度,dsd表示衰减标准偏差,confidence_factor表示可信度相关系数。而方法的参数TruncateSeq,顾名思义,是一个截断的序列,它只跟踪了序列中的最新的n个元素。在G1 GC过程中,每个可测量的步骤花费的时间都会记录到TruncateSeq(继承了AbsSeq)中,用来计算衰减均值、衰减变量,衰减标准偏差等)
G1(Garbage First):G1作为JDK9之后的服务端默认收集器,G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址,大小可以通过-XX:G1HeapRegionSize设置,大小为1~32M,对于大对象的存储则衍生出Humongous的概念,超过Region大小一半的对象会被认为是大对象,而超过整个Region大小的对象被认为是超级大对象,将会被存储在连续的N个Humongous Region中,G1在进行回收的时候会在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先回收收益最大的Region。
G1的回收过程分为以下四个步骤:
1.初始标记:标记GC ROOT能关联到的对象,需要STW
2.并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,扫描完成后还会重新处理并发标记过程中产生变动的对象
3.最终标记:短暂暂停用户线程,再处理一次,需要STW
4.筛选回收:更新Region的统计数据,对每个Region的回收价值和成本排序,根据用户设置的停顿时间制定回收计划。再把需要回收的Region中存活对象复制到空的Region,同时清理旧的Region。需要STW
总的来说除了并发标记之外,其他几个过程也还是需要短暂的STW。
G1的目标是在停顿和延迟可控的情况下尽可能提高吞吐量。
主要问题:复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
为什么转移阶段不能和标记阶段一样并发执行呢?主要G1未能解决转移过程中准确定位对象地址的问题。

ZGC

ZGC:jdk11引入,15转正,也采用标记-复制算法,ZGC在标记、转移和重定位阶段几乎都是并发的,实现停顿时间小于10ms目标。初始标记,并发标记,再标记,并发转移准备,初始转移,并发转移
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

有哪些 GC ROOT

上面提到的标记的算法,怎么标记一个对象是否存活?简单的通过引用计数法,给对象设置一个引用计数器,每当有一个地方引用他,就给计数器+1,反之则计数器-1,但是这个算法无法解决循环引用的问题。
Java通过可达性分析算法来达到标记存活对象的目的,定义一系列的GC ROOT为起点,从起点开始向下开始搜索,搜索走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链相连的话,则对象可以判定是可以被回收的。
而可以作为GC ROOT的对象包括:
1.栈中引用的对象
2.静态变量、常量引用的对象
3.本地方法栈native方法引用的对象

触发 YGC 和 FGC,晋升老年代

当一个新的对象来申请内存空间的时候,如果Eden区无法满足内存分配需求,则触发YGC,使用中的Survivor区和Eden区存活对象送到未使用的Survivor区,如果YGC之后还是没有足够空间,则直接进入老年代分配,如果老年代也无法分配空间,触发FGC,FGC之后还是放不下则报出OOM异常。
YGC之后,存活的对象将会被复制到未使用的Survivor区,如果S区放不下,则直接晋升至老年代。而对于那些一直在Survivor区来回复制的对象,通过-XX:MaxTenuringThreshold配置交换阈值,默认15次,如果超过次数同样进入老年代。
此外,还有一种动态年龄的判断机制,不需要等到MaxTenuringThreshold就能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

1.谁进行空间担保?
JVM 使用分代收集算法,将堆内存划分为年轻代和老年代,两块内存分别采用不同的垃圾回收算法,空间担保指的是老年代进行空间分配担保
2.什么是空间分配担保?
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
如果大于,则此次 Minor GC 是安全的
如果小于,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;如果小于或者 HandlePromotionFailure=false,则改为进行一次 Full GC。
3.为什么要进行空间担保?
是因为新生代采用复制收集算法,假如大量对象在 Minor GC 后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而 Survivor 空间是比较小的,这时就需要老年代进行分配担保,把 Survivor 无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行 Full GC 来让老年代腾出更多空间。

JVM 调优

1.为了打印日志方便排查问题最好开启GC日志,开启GC日志对性能影响微乎其微,但是能帮助我们快速排查定位问题。-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
2.一般设置-Xms=-Xmx,这样可以获得固定大小的堆内存,减少GC的次数和耗时,可以使得堆相对稳定
3.-XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出的时候自动生成内存快照,方便排查
4.-Xmn设置新生代的大小,太小会增加YGC,太大会减小老年代大小,一般设置为整个堆的1/4到1/3
5.设置-XX:+DisableExplicitGC禁止系统System.gc(),防止手动误触发FGC造成问题

安全点/安全区域

OopMap 是做什么的?有什么好处?
我们知道,一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )。使用 OopMap 可以「「避免全栈扫描」」,加快枚举根节点的速度。但这并不是它的全部用意。它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC (即使用准确式内存管理,虚拟机可用知道内存中某个位置的数据具体是什么类型) 。

什么是安全点?
从线程角度看,安全点可以理解成是在「「代码执行过程中」」的一些「「特殊位置」」,当线程执行到这些位置的时候,说明「「虚拟机当前的状态是安全」」的。比如:「「方法调用、循环跳转、异常跳转等这些地方才会产生安全点」」。如果有需要,可以在这个位置暂停,比如发生GC时,需要暂停所有活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。那么如何让线程在垃圾回收的时候都跑到最近的安全点呢?这里有「「两种方式」」:
「抢先式中断」
抢先式中断:就是在stw的时候,先让所有线程「「完全中断」」,如果中断的地方不在安全点上,然后「「再激活」」,「「直到运行到安全点的位置」」再中断。
「主动式中断」
主动式中断:在安全点的位置打一个标志位,每个线程执行都去轮询这个标志位,如果为真,就在最近的安全点挂起。

安全区域是什么?解决了什么问题
刚刚说到了主动式中断,但是如果有些线程处于sleep状态怎么办呢?
为了解决这种问题,又引入了安全区域的概念安全区域是指「「在一段代码片中,引用关系不会发生改变」」,实际上就是一个安全点的拓展。当线程执行到安全区域时,首先标识自己已进入安全区域,那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为“安全区域”状态的线程了,该线程只能乖乖的等待根节点枚举完或者整个GC过程完成之后才能继续执行

JMM 内存模型 内存屏障

再来说内存屏障的问题,volatile修饰之后会加入不同的内存屏障来保证可见性的问题能正确执行。这里写的屏障基于书中提供的内容,但是实际上由于CPU架构不同,重排序的策略不同,提供的内存屏障也不一样,比如x86平台上,只有StoreLoad一种内存屏障。
StoreStore屏障,保证上面的普通写不和volatile写发生重排序
StoreLoad屏障,保证volatile写与后面可能的volatile读写不发生重排序
LoadLoad屏障,禁止volatile读与后面的普通读重排序
LoadStore屏障,禁止volatile读和后面的普通写重排序

本身随着CPU和内存的发展速度差异的问题,导致CPU的速度远快于内存,所以现在的CPU加入了高速缓存,高速缓存一般可以分为L1、L2、L3三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和CPU的重排序导致了原子性和有序性的问题,JMM内存模型正是对多线程操作下的一系列规范约束,因为不可能让陈雇员的代码去兼容所有的CPU,通过JMM我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了Java程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。
原子性:Java内存模型通过read、load、assign、use、store、write来保证原子性操作,此外还有lock和unlock,直接对应着synchronized关键字的monitorenter和monitorexit字节码指令。
可见性:可见性的问题在上面的回答已经说过,Java保证可见性可以认为通过volatile、synchronized、final来实现。
有序性:由于处理器和编译器的重排序导致的有序性问题,Java通过volatile、synchronized来保证。

猜你喜欢

转载自blog.csdn.net/qq798280904/article/details/130820729
今日推荐