Java后端高频知识点学习笔记5---JVM(Java虚拟机)

Java后端高频知识点学习笔记5---JVM(Java虚拟机)

参考地址:牛_客_网
https://www.nowcoder.com/discuss/819307

1、JVM内存模型

Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同

JVM

① 程序计数器:线程私有

作用:记录当前线程所执行到的字节码的行号;每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存;是唯一没有OutOfMemoryError情况的内存区域;它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡

程序计数器在哪些地方用到了?

答:字节码解释器工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成;Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器

② Java虚拟机栈:线程私有

描述 Java ⽅法执⾏的内存模型,每个Java方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息;每个Java方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程;Java 虚拟机栈也是线程私有的,每个线程都有各⾃的Java虚拟机栈,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

这些数据类型在局部变量表中的存储空间是以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个;局部变量表所需的内存空间在编译期间完成分配;当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小;

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError;StackOverFlowError:若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最⼤深度的时候,就抛出StackOverFlowError异常(典型的场景有:递归调用和死循环);OutOfMemoryError:若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且当线程请求栈时内存⽤完了,⽆法再动态扩展了,此时抛出OutOfMemoryError异常

在栈上分配对象:大多数对象都在堆上分配内存空间,但是由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致对象在堆上分配不是那么绝对了

③ 本地方法栈:线程私有

本地方法栈和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务;在 HotSpot 虚拟机中本地方法栈和 Java 虚拟机栈合⼆为⼀;本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息

⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常

④ 堆:线程共享

Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建;此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存

堆分区

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap);从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代;新生代再细致⼀点有:Eden空间、Survivor From、Survivor To空间等。

进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存

堆的分代

上图所示的 eden区、s0区、s1区都属于新⽣代,tentired区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1,当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置

⑤ 方法区:线程共享

⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

⽅法区也被称为永久代;永久代是对方法区的一种实现方式。方法区是一种定义,永久代是一种实现

运⾏时常量池:⽅法区的⼀部分;Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)

既然运⾏时常量池是⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常

JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池

直接内存:直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤;⽽且也可能导致 OutOfMemoryError 异常出现

2、JDK1.7和1.8 内存模型的区别

JDK1.7 JVM内存模型
线程私有:Java虚拟机栈、本地方法栈、程序计数器
线程共享:方法区、堆

JDK1.7 JVM内存模型

JDK1.8 JVM内存模型
JDK1.8与1.7最大的区别是1.8将永久代(方法区)取消,取而代之的是元空间;JDK1.7方法区是由永久代实现的,JDK1.8方法区是由元空间实现的,元空间属于本地内存,所以元空间的大小受本地内存的限制

JDK1.8 JVM内存模型

3、Java内存模型(JMM)

Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

主内存:主要对应Java堆中的对象实例数据部分
工作内存:对应于虚拟机栈中的部分区域

Java内存模型(JMM)

4、Java对象的创建过程

① 类加载检查: 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程

② 分配内存: 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存;对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来;分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定

③ 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值

④ 设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息;这些信息存放在对象头中;另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式

⑤ 执⾏ init ⽅法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java程序的视⻆来看,对象创建才刚开始,init()⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ init⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来

注意:执行 init方法,即对象按照程序员的意愿进行初始化;对应到语言层面上讲,就是为属性赋值,这与上面的赋零值不同,这是由程序员赋的值

5、堆内存中对象分配的基本策略

① 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配;当Eden区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

② 大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组;可通过配置设置令大对象直接在老年代分配

目的:避免在Eden区以及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)

③ 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器;如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象的年龄设置为 1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)就将会被晋升到老年代中,年龄阈值可通过配置调整-XX:MaxTenuringThreshold参数

④ 动态年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold参数才能晋升到老年代;如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到配置参数中要求的年龄

⑤ 空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的

如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败;如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC;尽管这次Minor GC是有风险的;如果小于或者设置不允许冒险,这时要改为进行一次Full GC

6、如何判断对象是否死亡

堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)

① 引用计数法

给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤它,计数器就加1;当引⽤失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使⽤的;但是,引用计数法很难解决 对象之间相互循环引用的问题

② 可达性分析算法

通过一些列的称为GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots之间没有任何引用链相连,则证明这个对象是不可用的

7、判断不可达一定会被回收吗?

即使在可达性分析算法判断不可达的对象,也并非是“非死不可的”

如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法;当对象没有覆盖finalize()方法或者finalize()方法已经被调用过,finalize()方法都不会执行,该对象将会被回收

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,然后由Finalizer线程去执行;GC将会对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么在第二次标记时将会被移除“即将回收”的集合,否则该对象将会被回收(比如:把自己(this关键字)赋值给某个类变量(static修饰)或者对象的成员变量)

GC

8、GC Roots 有哪些

1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等

2、在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量

3、在方法区中常量引用的对象,譬如字符串常量池里的引用

4、在本地方法栈中JNI(即通常所说的Native方法)引用的对象

5、Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器

6、所有被同步锁(synchronized关键字)持有的对象

7、反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

9、强引用、软引用、弱引用、虚引用

⽆论是通过引⽤计数法判断对象引⽤数量,还是通过可达性分析法判断对象的引⽤链是否可达,判定对象的存活都与“引⽤”有关。

JDK1.2之前,Java中引⽤的定义很传统:如果reference类型的数据存储的数值代表的是另⼀块内存的起始地址,就称这块内存代表⼀个引⽤

JDK1.2以后,Java对引⽤的概念进⾏了扩充,将引⽤分为强引⽤、软引⽤、弱引⽤、虚引⽤四种(引⽤强度逐渐减弱)

强引⽤(StrongReference)

强引用就是指类似于“Object obj = new Object()”这类的引用;被强引用关联的对象,垃圾回收器绝不会回收它;当内存空间不⾜时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题

软引⽤(SoftReference)

被软引用关联的对象只有在内存空间不足时才会被回收;软引⽤可⽤来实现内存敏感的⾼速缓存;软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JVM 就会把这个软引⽤加⼊到与之关联的引⽤队列中;在JDK1.2后,提供了SoftReference

弱引⽤(WeakReference)

弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期;在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存;不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象;弱引⽤可以和⼀个引⽤队(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中

虚引⽤(PhantomReference)

"虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期;如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被回收;为一个对象设置虚引用的唯一目的就是这个对象被垃圾回收器回收时收到一个系统通知;在JDK1.2之后,提供了PhantomReference类来实现虚引用

虚引⽤与软引⽤和弱引⽤的⼀个区别在于:虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤;当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是 否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收;程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动

总结:软引用是在垃圾回收之后把引用加入到引用队列中,虚引用是在垃圾回收之前把引用加入到引用队列中

特别注意,在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况较多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出(OutOfMemory)等问题的产⽣

10、垃圾收集算法

① 标记-清除算法

算法分为“标记”和“清除”阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象;它是最基础的收集算法,后续的算法都是对其不⾜进⾏改进得到。这种垃圾收集算带来两个明显的问题:

1、效率问题,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量的增长而降低

2、空间碎片问题(标记清除后会产⽣⼤量不连续的碎⽚,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作)

标记-清除算法

② 标记-复制算法

为了解决效率问题,“复制”收集算法出现了;标记-复制算法简称“复制算法”

将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收

优点:
1、解决了标记-清除算法面对大量可回收对象时执行效率低的问题
2、避免了内存碎片

缺点:将可用空间缩小为原来的一半,空间利用率较低

新生代中大部分对象“朝生夕灭”,熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代内存空间;将新生代划分为一个Eden区和两个Survivor区,大小为8:1:1;每次分配内存时只使用Eden区和其中一个Survivor区。发生垃圾回收时,将Eden和Survior中仍然中存活的对象一次性复制到另外一块Survivor空间中,然后直接清理掉Eden和已经使用过的Survivor区

标记-复制算法

③ 标记-整理算法

根据⽼年代的特点推出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存(复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低;更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用“复制”算法)

标记-整理算法

优点:不会产生内存碎片

缺点:移动存活对象并更新引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(即Stop the world)

④ 分代收集算法

当前虚拟机的垃圾收集都采⽤分代收集算法,根据对象存活周期的不同将内存分为⼏块,⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择标记-复制算法,只需要付出少量存活对象的复制成本就可以完成每次垃圾收集。

⽼年代 的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择 “ 标记 - 清除 ” 或 “ 标记 - 整理 ” 算法进⾏垃圾收集

11、垃圾收集器

垃圾收集器主要有:Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、CMS、G1

① Serial收集器

Serial收集器一个新生代、单线程的收集器,采用标记-复制算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

② ParNew收集器

ParNew收集器一个新生代、多线程的收集器,采用标记-复制算法。只有Serial和ParNew收集器能与CMS配合工作。ParNew收集器是激活CMS后的默认新生代收集器。

③ Parallel Scavenge收集器

Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法实现的。能够进行并行收集的多线程收集器。

④ Serial Old收集器

Serial Old收集器:一个老年代、单线程的收集器,采用标记-整理算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

⑤ Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集(多个GC线程),基于标记-整理算法实现。

⑥ CMS收集器

CMS收集器是老年代收集器,基于标记-清除算法实现的。

⑦ G1收集器

G1收集器:新生代+老年代(将Java堆划分成多个Region),面向全堆的收集器,不需要其他新生代收集器的配合工作。

搭配:
① 新生代Serial+老年代Serial Old
② 新生代ParNew+老年代Serial Old
③ 新生代Parallel Scavenge+老年代Parallel Old
④ 新生代ParNew+老年代CMS(Parallel Scavenge无法与CMS配合)

12、CMS收集器

CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⽽⾮常符合在注重⽤户体验的应⽤上使⽤

CMS(Concurrent Mark Sweep)收集器是HotSpot虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作

从名字中的 Mark Sweep 这两个词可以看出,CMS收集器是⼀种 “标记-清除”算法实现的,它的运作过程相⽐于前⾯⼏种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:(初始标记和重新标记这两步仍然要stop the world)

stop the world:除垃圾收集器线程之外的线程都被挂起。

① 初始标记: 暂停用户线程,并标记下GC Roots能直接关联到的对象,速度很快

② 并发标记: 同时开启GC和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以GC线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅

从GC Root开始对堆中的对象进行可达性分析,找出存活的对象。

(书本:并发标记阶段就是进行GC Roots Tracing的过程)

③ 重新标记: 暂停用户线程,重新标记阶段就是为了修正并发标记期间因为⽤户程序继续运⾏⽽导致标记产⽣变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短。

④ 并发清除: 同时开启⽤户线程和GC线程,清理掉在标记阶段判断的已经死亡的对象。

CMS收集器的优缺点:

从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点:

① 对CPU资源敏感

在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

② ⽆法处理浮动垃圾

CMS在并发清理阶段用户线程还在运行, 还会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法再当次过程中处理,所以只有等到下次gc时候在清理掉,这一部分垃圾就称作“浮动垃圾” 。

③ 它使⽤的回收算法--“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣

空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc

为了解决这个问题,CMS提供了一个开关参数,用于在CMS顶不住要进行full gc的时候开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片没有了,但是停顿的时间变长了

13、G1收集器

G1 (Garbage-First)是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征;被视为 JDK1.7 中 HotSpot虚拟机的⼀个重要进化特征

G1收集器采用“标记-复制”和“标记-整理”;从整体上看是基于“标记-整理”,从局部看,两个region之间是“标记-复制”;

它具备以下特点:

① 并⾏与并发:G1能充分利⽤CPU、多核环境下的硬件优势,使⽤多个CPU(CPU或者CPU核⼼)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执⾏的GC动作,G1收集器仍然可以通过并发的⽅式让java程序继续执⾏

② 分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念

③ 空间整合:与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的

④ 可预测的停顿:这是G1相对于CMS的另⼀个⼤优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为M毫秒的时间⽚段内,消耗在垃圾收集上的时间不得超过N毫秒

G1收集器的运作⼤致分为以下⼏个步骤:

① 初始标记

暂停用户线程,初始标记只是标记下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短

② 并发标记

同时开启用户线程和GC线程,并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,但可与用户程序并发执行。

③ 最终标记

暂停用户线程,最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,这阶段需要停顿线程,但是可以并行执行。

④ 筛选回收

暂停用户线程,筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

[ Region:G1收集器将整个Java堆划分为多个大小相等的独立区域(Region)]

G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region(这也就是它的名字Garbage-First的由来)。这种使⽤Region划分内存空间以及有优先级的区域回收⽅式,保证了GF收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉个旧的Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的

G1收集器只有并发标记不会stop the world

14、为什么CMS采用“标记-清除”算法而不采用“标记-整理”算法

因为CMS作为第一款实现用户线程和收集线程并发执行的收集器!当时的设计理念是减少停顿时间,最好是能并发执行!但是问题来了,如要用户线程也在执行,那么就不能轻易的改变堆中对象的内存地址!不然会导致用户线程无法定位引用对象,从而无法正常运行!而标记整理算法和标记复制算法都会移动存活的对象,这就与上面的策略不符!因此CMS采用的是标记清理算法

15、JDK1.8默认的垃圾回收器

JDK1.8默认的垃圾回收器:UseParallelGC
UseParallelGC 即 Parallel Scavenge + Parallel Old

UseParallelGC:并行收集器,同时运行在多个cpu之间,物理上的并行收集器,跟上面的收集器一样也是独占式的,但是它最大化的提高程序吞吐量,同时缩短程序停顿时间,另外它不能与CMS收集器配合工作

16、为什么新生代和老年代要采用不同的回收算法

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就许选择“标记-复制”算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记--清除”、“标记整理”算法来进行回收;标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低

17、垃圾回收怎么解决跨代引用的问题

记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录

记忆集存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用;这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题

18、类加载过程

当程序要使用某个类时,如果该类还未被加载到内存中
系统会通过 加载、连接、初始化 来实现这个类进行初始化,连接又分为验证、准备、解析

1、加载
JVM查找.class文件并将二进制数据读入内存,放置到JVM的方法区内,然后在堆中创建java.lang.Class对象的过程
类加载阶段:

  • (1)JVM通过一个类的全限定名来将.class文件读入内存,并为之创建一个Class对象
  • (2)任何类被使用时系统都会为其创建一个且仅有一个Class对象
  • (3)这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等

2、验证
验证是连接的第一个阶段

  • 主要的目的是确保被加载的类满足Java虚拟机规范,不会造成安全错误

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证 和 符号引用验证

3、准备
准备是连接的第二个阶段

  • 为类的静态成员分配内存,并设置默认初始值

在准备阶段,进行分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中

4、解析
解析是连接的第三个阶段

  • JVM将常量池内的符号引用替换为直接引用的过程

符号引用:以一组符号来描述所引用得目标,符号可以是任何形式得字面量,只要使用时能无歧义地定位到目标即可

直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能间接定位到目标得句柄;直接引用是和虚拟机实现得内存布局直接相关的

5、初始化
初始化,就是对static修饰的变量或语句块进行初始化

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类;
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行

总结:

  • 类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载(垃圾回收)

19、有哪些类加载器?

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃ java.lang.ClassLoader :

① 启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可

② 扩展类加载器(Extension Class Loader):这个类加载器负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断处这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代

由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件

③ 应用程序类加载器(Application Class Loader):它负责加载用户类路径(ClassPath)上所有的类库

由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以也称“系统类加载器”;开发者同样可以直接在代码中使用这个类加载器

如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

20、双亲委派模型

注意:双亲委派模型,其实就是一种类加载器的层次关系

工作过程:

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

好处:

  • 1、使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一

  • 2、避免了多份同样字节码的加载,内存是宝贵的,没必要保存相同的两份 Class 对象

双亲委派模型

21、使用双亲委派模型的好处

1、使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一

  • 例如:java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,这是因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中所有的 Object 都是这个 Object

2、避免了多份同样字节码的加载,内存是宝贵的,没必要保存相同的两份 Class 对象

  • 例如:执行System.out.println()方法,我们需要一个 System 的 Class 对象,并且只需要一份,如果不使用委托机制,而是自己加载自己的,那么类 A 打印的时候就会加载一份 System 字节码,类 B 打印的时候又会加载一份 System 字节码。而使用委托机制就可以有效的避免这个问题

22、如何打破双亲委派模型

方法1:自定义类加载器,重写ClassLoader类中的loadClass()方法;

方法2:使用线程上下文类加载器 Thread Context ClassLoader

如果不想打破双亲委派模型,就重写ClassLoader类中的findClass()方法即可

posted @ 2021-12-20 20:52  紫薇哥哥  阅读(154)  评论(0编辑  收藏  举报