JVM总结

JVM

JVM内存模型

程序计数器 : 如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址。如果为native【底层方法】,那么计数器为空。这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域。

虚拟机栈: java方法的执行和结束对应着栈帧的入栈和出栈,

栈帧 : 用于存储局部变量表,操作栈,动态链接,方法出口等信息

局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。

Java虚拟机栈可能出现两种类型的异常:

线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。
虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。

在Hotspot中将本地方法栈和虚拟机栈合二为一

本地方法栈 : 本地方法栈是与虚拟机栈发挥的作用十分相似, 区别是虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务,可能底层调用的c或者c++, 我们打开jdk安装目录可以看到也有很多用c编写的文件,可能就是native方法所调用的c代码。

: 对于大多数应用来说,**堆是java虚拟机管理内存最大的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。**因此需要重点了解下。

实例在堆中( 也就是new的对象 )

方法区: 保存在着被加载过的每一个类的信息;static变量信息也保存在方法区中;
可以看做是将类(Class)的元数据,保存在方法区里;

方法区是线程共享的;当有多个线程都用到一个类的时候,而这个类还未被加载,则应该只有一个线程去加载类,让其他线程等待;
方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。

img

内存溢出和内存泄漏是什么?

简述一下:

溢出: 所需要用的内存大于系统给的内存

泄漏: 某对象不用了但是没被回收

元空间和方法区及永久代的关系

Java8中的JVM元空间是不是方法区?

严格来说,不是。

首先,方法区是JVM规范的一个概念定义,并不是一个具体的实现,每一个JVM的实现都可以有各自的实现;
然后,在Java官方的HotSpot 虚拟机中,Java8版本以后,是用元空间来实现的方法区;在Java8之前的版本,则是用永久代实现的方法区;
也就是说,“元空间” 和 “方法区”,一个是HotSpot 的具体实现技术,一个是JVM规范的抽象定义;
所以,并不能说“JVM的元空间是方法区”,但是可以说在Java8以后的HotSpot 中“元空间用来实现了方法区”。

这个元空间是使用本地内存(Native Memory)实现的,也就是说它的内存是不在虚拟机内的,所以可以理论上物理机器还有多个内存就可以分配,而不用再受限于JVM本身分配的内存了。

jdk1.8的常量池在哪里?

其实,移除永久代的工作从JDK 1.7就开始了。JDK 1.7中,存储在永久代的部分数据就已经转移到Java Heap或者Native Heap。但永久代仍存在于JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap。

JDK1.8对JVM架构的改造将类元数据放到本地内存中,另外,将常量池和静态变量放到Java堆里。HotSpot VM将会为类的元数据明确分配和释放本地内存。在这种架构下,类元信息就突破了原来-XX:MaxPermSize的限制,现在可以使用更多的本地内存。

StackOverflowError

原因 : 函数调用栈太深了,注意代码中是否有了循环调用方法而无法退出的情况

原理
StackOverflowError 是一个java中常出现的错误:在jvm运行时的数据区域中有一个java虚拟机栈,当执行java方法时会进行压栈弹栈的操作。在栈中会保存局部变量,操作数栈,方法出口等等。jvm规定了栈的最大深度,当执行时栈的深度大于了规定的深度,就会抛出StackOverflowError错误。

典型的例子:

public class StackOverFlowDemo {
public static void Foo(){
    Foo();
}

public static void main(String[] args) {
    Foo();
}
}

JVM运行内存详解

Major GC 和 Full GC的区别

为什么要使用本地方法栈?

  • 有时 Java 应用需要与 Java 外面的环境交互,这是本地方法存在的主要原因。你可以想想 Java 需要与一些底层系统,如操作系统或某些硬件交换信息时的情。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解 Java 应用之外的繁琐的细节。
  • 通过使用本地方法我们得以用 Java 实现了 jre 的与底层系统的交互,甚至 JVM 的一些部分就是用 c 写的。还有,如果我们要使用一些 Java 语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。

Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了。是 JVM 管理的最大一块内存空间。堆内存的大小是可以调节的。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收时才会被移除。

堆,是GC(Garbage Collection)执行垃圾回收的重点区域。

所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)。

image-20210715221208704

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为

image-20210715222534835

堆空间的内部结构(JDK7&JDK8)

image-20210715222431571

image-20210715222707075

年轻代与老年代

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM生命周期保持一致。

Java 堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)。

方法区: 存放永久代: (对象一般不会被回收 ,永久代的垃圾收集主要回收废弃常量和无用类)

  1. 老年代 : 存放大对象(很多连续的内存)和长期存活的对象,
  2. 新生代 ( 又分为三个区 ) :
    1. Eden区 ( 内存较大 ) 每次使用都是一个Eden 和 其中一个 Survivor
    2. From Survivor区
    3. To Survivor区

image-20210715223410342

新生代和老年代的比例一般为1:2,默认- XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。

在 Hotspot 中,Eden 空间和另外两个 Survivor空间缺省所占的比例是 8:1:1 当然开发人员可以通过选项“-XX: SurvivorRatlo”调整这个空间比例。比如-XX: SurvivorRatio=8

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。

绝大部分的 Java 对象的销毁都在新生代进行了。

  • IBM 公司的专门研究表明,新生代中 80%的对象都是“朝生夕死”的

年龄计数器

  • 虚拟机通过一个对象年龄计数器来判定哪些对象放在新生代,哪些对象应该放在老生代。
  • 对象每在Survivor中熬过一次Minor GC,年龄就增加1岁,当他的年龄增加到最大值15时,就将会被晋升到老年代中。
  • 如果在Survivor空间中所有相同年龄的对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

图解对象分配过程

  1. 为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
  2. New 的对象先放伊甸园区。此区有大小限制。
  3. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  4. 然后将伊甸园中的剩余对象移动到幸存者0区
  5. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
  6. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
  7. 啥时候能去养老区呢?可以设置次数。默认是 15 次。
    • 可以设置参数:--XX:MaxTenuringThreshold=<N>进行设置
  8. 在养老区,相对悠闲。当养老区内存不足时,再次触发 GC: Major GC,进行养老区的内存清理
  9. 若养老区执行了 Maior GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
    • Java.lang.OutOfMemoryError:Java heap space

[外链图片转存中…(img-QW57m2f2-1646126410788)]

针对幸存者S0,S1区的总结:复制之后有交换,谁空谁是to。

判断一个类是否“无用”

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

如何判断一个对象是否需要被回收?

一般对象是否需要被回收大致分为如下两种算法 :

引用计数器算法

引用计数算法(Reference Counting): 给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,这就是引用计数算法的核心。

优点: 引用计数算法实现简单,判定效率也很高

缺点: 它很难解决对象之间相互循环引用的问题

可达性分析算法

可达性分析算法(Reachability Analysis): 这是Java虚拟机采用的判定对象是否存活的算法。通过一系列的称为“GC Roots"的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

可达性分析的具体步骤:

在分析时,需保证这个对象引用关系不再变化,否则结果将不准确。因此GC进行时需停掉其它所有java执行线程(Sun把这种行为称为‘Stop the World’),即使是号称几乎不会停顿的CMS收集器,枚举根节点时也需停掉线程

系统停下来后JVM不需要一个个检查引用,而是通过OopMap数据结构【HotSpot的叫法】来标记对象引用。

虚拟机先得知哪些地方存放对象的引用,在类加载完时。HotSpot把对象内的偏移量和它是什么类型的数据算出来,在 jit 编译过程中,也会在特定位置记录下栈和寄存器哪些位置是引用,这样GC在扫描时就可以知道这些信息。【目前主流JVM使用准确式GC】

OopMap可以帮助HotSpot快速且准确完成GC Roots枚举以及确定相关信息。但是也存在一个问题,可能导致引用关系变化。

这个时候有个 **safepoint(安全点)**的概念:

  1. HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入。 GC时对一个Java线程来说,它要么处在safepoint,要么不在safepoint。
  2. safepoint不能太少,否则GC等待的时间会很久
  3. safepoint不能太多,否则将增加运行GC的负担

安全点主要存放的位置:
1 循环的末尾
2 方法临返回前/调用方法的call指令后
3 可能抛异常的位置

简单来说 : 首先GC需要在safepoint安全点进入 在分析时 GC需要STW停掉其他线程来确保对象引用关系不会变化 接下来会通过OopMap数据结构来标记对象引用

在下图可以看到GC Roots左边的对象都有引用链相关联,所以他们不是死亡对象,而在GCRoots右边有几个零散的对象没有引用链相关联,所以他们就会别Java虚拟机判定为死亡对象而被回收。

img

哪些对象可以为GC Roots ?

① 虚拟机栈(栈桢中的本地变量表)中的引用的对象。
② 方法区中的类静态属性引用的对象,一般指被static修饰引用的对象,加载类的时候就加载到内存中。
③ 方法区中的常量引用的对象
④ 本地方法栈中JNI(native方法)引用的对象

即使可达性算法中不可达的对象,也不是一定要马上被回收,还有可能被抢救一下。一个GCRoot不可达的对象,不会立刻被垃圾回收,首先还会判断是否包含了finalize方法,如果有那就先执行finalize方法,如果这个对象在finalize方法中重新又关联到了GCRoots引用链,那么它将不会被回收。

java虚拟机在进行死亡对象判定时,会经历两个过程

  • 一次标记 : 将对象放入 F-Queue 队列( 低优先级的队列 ) 如果在 finalize 方法中该对象重新与引用链上的任何一个对象建立了关联 , 即该对象连上了任何一个对象的引用链,例如this关键字,那么该对象就会逃脱垃圾回收系统
  • 两次标记 : 如果该对象在 finalize 方法中没有与任何一个对象进行关联操作,那么该对象会被虚拟机进行第二次标记,该对象就会被垃圾回收系统回收。值得注意的是finaliza方法JVM系统只会自动调用一次,如果对象面临下一次回收,它的 finalize 方法不会被再次执行。

Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对上面三个内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。

针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个Java 堆的垃圾收集。其中又分为
    • 新生代收集(Minor GC/ Young GC):只是新生代的垃圾收集
    • 老年代收集 (Major GC/Old GC):只是老年代的垃圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为。
      • 注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集。

最简单的分代式GC策略的触发条件

年轻代GC(Minor GC)触发机制:

  • 当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。(每次 Minor GC 会清理年轻代的内存。)
  • 因为 Java 对象大多都具备朝生タ灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解
  • Minor GC会引发 STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

image-20210716100056988

老年代GC(Major GC/Full GC)触发机制:

Major GC通常是跟full GC是等价的,收集整个GC堆。

只收集老年代的GC。只有CMS的concurrent collection是这个模式

  • Major GC 的速度一般会比 Minor GC慢10倍以上,STW 的时间更长。
  • 如果 Major GC 后,内存还不足,就报 OOM 了。

Full GC触发机制:

  1. 调用 System.gc()时,系统建议执行 Full GC,但是不必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过 Minor GC 后进入老年代的平均晋升大小大于老年代的可用内存
  5. 由 Eden 区、survivor space0 (From Space)区向 survivor space1 (To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些。

为什么需要把Java堆分代?

  • 经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。
    • 新生代:有Eden、两块大小相同的Survivor构成,to总为空
    • 老年代:存放新生代中经历多次GC仍然存活的对象
  • 其实不分代完全可以,分代的唯一理由就是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

内存分配策略

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁,其实每个 JVM、每个 GC 都有所不同)时,就会被晋升到老年代中

对象晋升老年代的年龄阈值,可以通过选项-XX: MaxTenuringThreshold 来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到 Eden
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保
    • -XX: HandlePromotionFailure

垃圾回收算法

标记–清除算法(Mark-Sweep)

在这里插入图片描述

优点:算法执行分为两个阶段标记与清除,所有的回收算法,基本都基于标记回收算法做了深度优化

缺点: 效率问题,会产生内存空间碎片(不连续的空间),这也是标记整理最大的弊端 : 碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

复制算法(Copying)

为了解决Mark-Sweep算法的缺陷,Copying 算法就被提了出来。**它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。**当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
img

优点: 比较标记清除算法,避免了回收造成的内存碎片问题,

缺点: 这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。以局部的内存空间牺牲为代价,不过空间的浪费比较小,默认8:1的比例1是浪费的。

复制也有一定的效率与空间成本,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低, 所以复制算法一般用于较少对象的内存的复制( 例如: 年轻代 )

标记整理算法(Mark-Compact)

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact 算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

在这里插入图片描述

优点: 避免了,空间的浪费,与内存碎片问题。

缺点: 整理时复制有效率成本。

分代收集算法(Generational Collection)

上面的只是对其他算法进行介绍 为分代收集算法做铺垫

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。
它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)新生代(Young Generation)

  • 老年代的特点 : 是每次垃圾收集时只有少量对象需要被回收

  • 新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

  • 目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

  • 而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

  • 注意,在堆区之外还有一个代就是永久代(Permanet Generation)它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。

垃圾收集器

年轻代收集器
Serial、ParNew、Parallel Scavenge(读音:拍若乐 死该猪)
老年代收集器
Serial Old、Parallel Old、CMS收集器
特殊收集器
G1收集器[新型,不在年轻、老年代范畴内] — JDK1.7发布的商用垃圾收集器

不同收集器的组合方式 :

收集器,连线代表可结合使用

并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

并发收集:指多条垃圾收集线程与用户线程同时进行(但不一定是并行的,有可能交替进行工作)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。

年轻代收集器

默认情况下jdk1.8的默认垃圾回收器的组合为:Parallel Scavenge+Parallel Old!

Serial

Serial收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程,直到它结束(Stop The World)。Serial收集器是针对新生代的收集器,采用的是Copying算法。

优点: 实现简单高效

缺点: 会给用户带来停顿

img

ParNew

ParNew收集器是Serial收集器的多线程版本,使用多个线程进行垃圾收集。

img

Parallel Scavenge

Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量

吞吐量 :
该收集器重点关心的是吞吐量【吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间) 如果代码运行100min垃圾收集1min,则为99%】

img

老年代收集器

Serial Old

Serial Old收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本**(并行收集器)**,使用多线程和Mark-Compact算法

CMS

CMS(Concurrent Mark Sweep)收集器 是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。【重视响应,可以带来好的用户体验,被sun称为并发低停顿收集器】

启用CMS:-XX:+UseConcMarkSweepGC

正如其名,CMS采用的是"标记-清除"(Mark Sweep)算法,而且是支持并发(Concurrent)的
它的运作分为4个阶段:

  • 初始标记:标记一下GC Roots能直接关联到的对象,速度很快
  • 并发标记:GC Roots Tarcing过程,即可达性分析
  • 重新标记:为了修正因并发标记期间用户程序运作而产生变动的那一部分对象的标记记录,会有些许停顿,时间上比较一般为 初始标记 < 重新标记 < 并发标记
  • 并发清除

以上初始标记和重新标记需要stop the world(停掉其它运行java线程)

img

之所以说CMS的用户体验好,是因为CMS收集器的内存回收工作是可以和用户线程一起并发执行。

总体上CMS是款优秀的收集器,但是它也有些缺点。

  • cms堆cpu特别敏感,cms运行线程和应用程序并发执行需要多核cpu,如果cpu核数多的话可以发挥它并发执行的优势,但是cms默认配置启动的时候垃圾线程数为 (cpu数量+3)/4,它的性能很容易受cpu核数影响,当cpu的数目少的时候比如说为为2核,如果这个时候cpu运算压力比较大,还要分一半给cms运作,这可能会很大程度的影响到计算机性能。

  • cms无法处理浮动垃圾,可能导致Concurrent Mode Failure(并发模式故障)而触发full GC

  • 由于cms是采用"标记-清除“算法,因此就会存在垃圾碎片的问题,为了解决这个问题cms提供了 -XX:+UseCMSCompactAtFullCollection选项,这个选项相当于一个开关【默认开启】,用于CMS顶不住要进行full GC时开启内存碎片合并,内存整理的过程是无法并发的,且开启这个选项会影响性能(比如停顿时间变长)

    浮动垃圾:由于cms支持运行的时候用户线程也在运行,程序运行的时候会产生新的垃圾,这里产生的垃圾就是浮动垃圾,cms无法当次处理,得等下次才可以。

缺点总结 : 1. CPU敏感 2. 无法处理浮动垃圾 3.可能有垃圾碎片

G1

G1( garbage first : 尽可能多收垃圾,避免full gc )收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

g1的特别之处在于它强化了分区,弱化了分代的概念,是区域化、增量式的收集器,它不属于新生代也不属于老年代收集器, 用到的算法为标记-清理、复制算法

同cms一样也是关注降低延迟,是用于替代cms功能更为强大的新型收集器,因为它解决了cms产生空间碎片等一系列缺陷。

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
  • 分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。
  • 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1收集器大致可分为如下步骤

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

并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)

最终标记:为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)

筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)

img

  • g1通过并发(并行)标记阶段查找老年代存活对象,通过并行复制压缩存活对象【这样可以省出连续空间供大对象使用】。
  • g1将一组或多组区域中存活对象以增量并行的方式复制到不同区域进行压缩,从而减少堆碎片,目标是尽可能多回收堆空间【垃圾优先】,且尽可能不超出暂停目标以达到低延迟的目的。
  • g1提供三种垃圾回收模式young gc、mixed gc 和 full gc,不像其它的收集器,根据区域而不是分代,新生代老年代的对象它都能回收。

G1为什么能建立可预测的停顿时间模型?

因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。

mixed GC【g1特有】

混合GC

收集整个young gen以及部分old gen的GC。只有G1有这个模式

怎么理解g1,适用于什么场景

G1 核心关键词: 自适应

G1 GC 是区域化、并行-并发、增量式垃圾回收器,相比其他 HotSpot 垃圾回收器,可提供更多可预测的暂停。增量的特性使 G1 GC 适用于更大的堆,在最坏的情况下仍能提供不错的响应。

G1 GC 的自适应特性使 JVM 命令行只需要软实时暂停时间目标的最大值以及 Java 堆大小的最大值和最小值,即可开始工作。

g1不再区分老年代、年轻代这样的内存空间,这是较以往收集器很大的差异,所有的内存空间就是一块划分为不同子区域,每个区域大小为1m-32m,最多支持的内存为64g左右,且由于它为了的特性适用于大内存机器。

g1回收时堆内存情况, 如下图:

在这里插入图片描述

适用场景:

  1. 像cms能与应用程序并发执行,GC停顿短【短而且可控】,用户体验好的场景。
  2. 面向服务端,大内存,高cpu的应用机器。【网上说差不多是6g或更大】
  3. 应用在运行过程中经常会产生大量内存碎片,需要压缩空间【比cms好的地方之一,g1具备压缩功能】。

空间分配担保

在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次 Minor GC 是安全的
  • 如果小于,则虚拟机会查看-XX: HandlePromotionFailure 设置值是否允许担保失败。
    • 如果 HandlePromotionFailure=true,那么会继续检査老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的
      • 如果小于,则改为进行一次 Full GC。
    • 如果 HandlePromotionFailure= false,则改为进行一次 Full GC

在 JDK6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

CMS收集器是否会扫描年轻代

会,在初始标记的时候会扫描新生代。

虽然cms是老年代收集器,但是我们知道年轻代的对象是可以晋升为老年代的,为了空间分配担保,还是有必要去扫描年轻代。

为什么复制算法要分两个Survivor,而不直接移到老年代

这样做的话效率可能会更高,但是old区一般都是熬过多次可达性分析算法过后的存活的对象,要求比较苛刻且空间有限,而不能直接移过去,这将导致一系列问题(比如老年代容易被撑爆)

常用回收器参数设置:

img

新生代什么样的情况会晋升为老年代

对象优先分配在Eden区,Eden区满时会触发一次minor GC

对象晋升规则

  • 长期存活的对象进入老年代,对象每熬过一次GC年龄+1(默认年龄阈值15,可配置)。

  • 对象太大新生代无法容纳则会分配到老年代

  • eden区满了,进行minor gc后,eden和一个survivor区仍然存活的对象无法放到(to survivor区)则会通过分配担保机制放到老年代,这种情况一般是minor gc后新生代存活的对象太多。

  • 动态年龄判定,为了使内存分配更灵活,jvm不一定要求对象年龄达到MaxTenuringThreshold(15)才晋升为老年代,若survior区相同年龄对象总大小大于survior区空间的一半,则大于等于这个年龄的对象将会在minor gc时移到老年代

类加载过程

加载

加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。类加载由JVM提供的类加载器完成

验证

验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致

  • 文件格式验证 : 验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理
  • 元数据验证 : 对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。
  • 字节码验证 : 最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。
  • 符号引用验证 : 主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。

准备

类准备阶段负责为类的静态变量分配内存,并设置默认初始值, 非静态变量不会分配内存;

解析

将类的二进制数据中的符号引用替换成直接引用

  • 符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。

  • 直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。

    举例 : 比如拿到某人身份证号123456879(符号引用 也就是占位符的意思) , 我们得不到什么有价值的信息,但是去公安局查询的话就能查到这个人的精准家庭地址( 直接引用 )

初始化

初始化是为类的静态变量赋予正确的初始值

如果类中有语句:private static int a = 10,它的执行过程是这样的

  • 首先字节码文件被加载到内存后先进行链接的验证这一步骤,验证通过后准备阶段
  • 给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0
  • 然后到解析,到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。

img

img

类的加载时机

  1. 创建类的实例,也就是new一个对象
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(Class.forName(“com.chen.demo”))
  5. 初始化一个类的子类(会首先初始化子类的父类)
  6. JVM启动时标明的启动类,即文件名和类名相同的那个类

除此之外,下面几种情形需要特别指出:

对于一个 final 类型的静态变量,如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。反之,如果final类型的静态Field的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。

类加载器

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会被再次载入了。

一个类的唯一标志: 类名.包名.类加载器名

根类加载器

  • 它用来加载 Java 的核心类,是用原生代码来实现的
  • 并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)
  • 由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作

扩展类加载器

  • 它负责加载JRE的扩展目录
  • lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。
  • 由Java语言实现,父类加载器为null。

系统类加载器

被称为系统(or 应用)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。

程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。

如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。

类加载器加载Class步骤

  1. 检测此Class是否载入过,即在缓冲区中是否有此Class,返回对应的java.lang.Class对象,否则进入第2步。

  2. 没有父类加载器 or 父类是根类加载器 or 本身就是根类加载器,则跳到第4步

  3. 如果父类加载器存在,则进入第3步。

  4. 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。

  5. 请求使用根类加载器去载入目标类,如果载入成功则返回对象,否则抛异常。

  6. 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。

  7. 从文件中载入Class,成功后跳至第8步。

  8. 抛出ClassNotFountException异常。

  9. 返回对应的java.lang.Class 对象。

  • 判断缓冲区是否有此Class, 有返回,没有就判断是否有父类加载器
  • 父类存在则用父类加载器去加载, 加载成功返回jlc对象, 否则使用当前类加载器寻找Class文件, 如果找到, 则载入, 然后加载成功, 否则抛出CNFE异常
  • 如果本身是根类加载器: 加载成功返回jlc对象, 否则抛出CNFE异常

类加载机制

全盘负责

当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

双亲委派

所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。

通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。

img

双亲委派机制 工作原理 : 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成

双亲委派机制的优势:

  • 可避免类重复加载 : Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次
  • 安全 : java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的 java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

缓存机制

缓存机制将会保证所有加载过的Class都会被缓存
当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。

这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

TLAB (Thread Local Allocation Buffer)

TLAB ( Thread local Allocation Buffer ) 本地线程分配缓存 , 这是一个线程专用的内存分配区域, 可以解决内存分配冲突的问题;

虚拟机参数 -XX:UseTLAB

在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小

TLAB的本质

其实是三个指针管理的区域:starttopend,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。

在这里插入图片描述

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。 从这一点看,它被翻译为 线程私有分配区更为合理一点
当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。但是如果分配一次以后内存还是不够的话, 则直接移入Eden区

TLAB的缺点

  • TLAB通常很小,所以放不下大对象。

    TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
    TLAB空间还剩一点点没有用到,有点舍不得。(比如100kb的TLAB,装了80KB,又来了个30KB的对象)
    所以JVM开发人员做了以下处理,设置了最大浪费空间。

    1.当剩余的空间小于最大浪费空间,那该TLAB属于的线程在重新向Eden区申请一个TLAB空间。进行对象创建,还是空间不够,那你这个对象太大了,去Eden区直接创建吧!

    2.当剩余的空间大于最大浪费空间,那这个大对象请你直接去Eden区创建,我TLAB放不下没有使用完的空间。
    Eden空间够的时候,再次申请TLAB没问题 ; 但是如果不够,Heap的Eden区要开始GC

  • TLAB允许浪费空间,导致Eden区空间不连续,积少成多。以后还要人帮忙打理

逃逸分析

何为"逃逸"(Escape)?

当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。

例子 :

public StringBuilder escapeDemo1(String a, String b) {
	StringBuilder stringBuilder = new StringBuilder();
      stringBuilder.append(a);
      stringBuilder.append(b);
      return stringBuilder;
}

上述代码则会发生逃逸

如果改成 :

public String escapeDemo2(String a, String b) {
     StringBuilder stringBuilder = new StringBuilder();
     stringBuilder.append(a);
     stringBuilder.append(b);
     return stringBuilder.toString();
}

就不会发生逃逸

什么是逃逸分析

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析(Escape Analysis)算是目前Java虚拟机中比较前沿的优化技术了。

逃逸分析的原理

Java本身的限制(对象只能分配到堆中),为了减少临时对象在堆内分配的数量,我会在一个方法体内定义一个局部变量,并且该变量在方法执行过程中未发生逃逸,按照JVM调优机制,

  • 优化前 : 首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行
  • 优化后 : 针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。如此操作,是优化前在堆中,优化后在栈中,从而减少了堆中对象的分配和销毁,从而优化性能。

总结: 不逃逸的对象, 只存在栈中, 减少了堆压力, 从而得到优化效果;

逃逸的方式

  1. 方法逃逸: 在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。
  2. 线程逃逸: 这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

逃逸分析的好处

如果一个对象不会在方法体内,或线程内发生逃逸(或者说是通过逃逸分析后,使其未能发生逃逸)

逃逸分析后的三大优点 :

  • 栈上分配 : 一般情况下,不会逃逸的对象所占空间比较大,如果能使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力

  • 同步消除 : 如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

  • 标量替换 :

    Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。

    如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

相关JVM参数

在JDK 6u23以上是默认开启,这里将设置重新明确一下:

  • 强制开启:-server -XX:+DoEscapeAnalysis -XX:+PrintGCDetail -Xmx10m -Xms10m

  • 关闭逃逸分析:  -server -XX:-DoEscapeAnalysis -XX:+PrintGCDetail -Xmx10m -Xms10m

创建对象的内存分配

  1. 编译器通过逃逸分析判断对象是在栈上分配还是堆上分配,如果是堆上分配则进入下一步。(开启逃逸分析需要设置jvm参数)
  2. 如果tlab可以放下该对象则在tlab上分配,否则进入下一步。
  3. 重新申请一个tlab,再尝试存放该对象,如果放不下则进入下一步。
  4. 在eden区加锁,尝试在eden区存放,若存放不下则进入下一步。
  5. 执行一次Young GC。
  6. Young GC后若eden区仍放不下该对象,则直接在老年代分配。
  • 同步消除 : 如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

  • 标量替换 :

    Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。

    如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

相关JVM参数

在JDK 6u23以上是默认开启,这里将设置重新明确一下:

  • 强制开启:-server -XX:+DoEscapeAnalysis -XX:+PrintGCDetail -Xmx10m -Xms10m

  • 关闭逃逸分析:  -server -XX:-DoEscapeAnalysis -XX:+PrintGCDetail -Xmx10m -Xms10m

创建对象的内存分配

  1. 编译器通过逃逸分析判断对象是在栈上分配还是堆上分配,如果是堆上分配则进入下一步。(开启逃逸分析需要设置jvm参数)
  2. 如果tlab可以放下该对象则在tlab上分配,否则进入下一步。
  3. 重新申请一个tlab,再尝试存放该对象,如果放不下则进入下一步。
  4. 在eden区加锁,尝试在eden区存放,若存放不下则进入下一步。
  5. 执行一次Young GC。
  6. Young GC后若eden区仍放不下该对象,则直接在老年代分配。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值