JVM

月伴飞鱼 2024-09-21 14:06:01
基础知识
支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!

内存模型

内存结构

堆:

  • 存放对象实例, 几乎所有的对象实例以及数组都在这里分配内存。

虚拟机栈:

  • 栈帧:用于支持虚拟机进行方法执行的数据结构。
    • 存储了方法的局部变量表, 操作数栈,动态连接和返回地址等地址。
    • 每个方法从调用到执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
    • 栈内存为线程私有的空间, 每个线程都会创建私有的栈没存, 生命周期与线程相同。
    • 除了一些 Native方法调用是通过本地方法栈实现的, 其他所有Java方法调用都是栈来实现的。

本地方法栈:

  • 和虚拟机栈非常相似,区别如下:

    • 虚拟机栈执行 Java 方法服务。

    • 本地方法栈使用到的 Native方法(例如 C++ 程序)服务。

方法区:

  • 各个线程共享的内存区域。
  • 存储已经被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存等数据。
  • 具体实现: 永久代,元空间。

永久代和元空间的区别:

  • JDK1.8之前是永久代。

  • JDK1.8之后是元空间。

  • 存储位置:

    • 永久代使用的内存区域是 JVM进程所使用的区域,大小受 JVM的大小限制。
    • 元空间使用的是物理内存区域,元空间大小只受物理内存大小的限制。
  • 存储内容不同:

    • 永久代主要存储方法区存储内容中的数据。
    • 元空间只存储类的元信息,而静态变量和运行时常量池都在堆中。

程序计数器:

  • 一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
  • 每条线程都有一个独立的程序计数器,各线程之间的计数器互不影响,为线程私有。

程序计数器的作用:

  • 多线程的情况下,用来记录当前执行的位置,当线程切换回来时能正确运行。
  • 字节码解释器通过改变程序计数器来以此读取指令,从而实现代码的流程控制: 如: 选择, 循环, 顺序执行, 异常处理等。

程序计数器是唯一不会出现OutOfMemoryError异常的内存区域,其生命周期随着线程的创建而生,线程的销毁为亡。

image-20231024202233389

image-20220803161151906

堆结构

基于分代收集理论设计,分为年轻代和老年代,其中年轻代又分为Eden区和Survivor 0区和Survivor 1区(Survivor0和Survivor1也叫From和To区)

  • 默认情况下,老年代占总的堆内存的2/3,年轻代占1/3

image-20231026171802543

什么E:S0:S1默认是8:1:1

新生代中有98%的对象是朝生夕灭的,每次MionrGC后存活的对象应该小于等于2%。

所以采用复制算法的新生代似乎可以不用将内存分成大小相等的两块了,但考虑到实验偏差以及实际情况的多样性。

默认预留了10%的内存用于存放存活对象。

堆内存大小

初始内存默认为电脑物理内存大小的1/64,最大内存默认为电脑物理内存的1/4,但是堆空间的大小可以调节。

符号引用

在JVM中,一个类的方法名、类名、修饰符、返回值等等都是一系列的符号,而这些符号都是一个个的常量,存储在常量池中。

同时这些个符号被加载到内存后,都有对应的内存地址,而这些内存地址就是直接引用。

动态链接

符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。

另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

方法出口

方法出口就是方法执行完成后,需要返回的一些信息。

一般来说,方法正常退出时,方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。

而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

操作数栈

就是存放方法运行过程中产生的一些临时数据,目的是为了计算,以栈的数据结构进行存储。

局部变量表

方法内部的变量都是存放在局部变量表中。

如果是对象类型,局部变量表中存放的是堆给对象分配的内存地址,也就是指针,而不是对象直接存在局部变量表中。

元空间

在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代(方法区的落地实现)来进行垃圾回收。

JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。

HotSpots取消了永久代,那么是不是也就没有方法区了呢?

  • 不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间(Metaspace)而已。

它和永久代有什么不同的?

存储位置不同:

  • 永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存。

存储内容不同:

  • 在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。
  • 现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。
image-20231021170545015

为什么要废弃永久代,引入元空间?

在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。

它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。

移除永久代是为融合HotSpot VM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。

  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。

废除永久代的好处:

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间,不会遇到永久代存在时的内存溢出错误。

将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。

将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。

对象

对象的生命周期

对象的创建流程:

开始 new一个对象

进行常量池检查

  • 看能否在常量池中定位到这个类的符号引用,定位不到则加载类
  • 看是否加载过这个类,没加载过则加载类

分配内存空间

  • 指针碰撞: GC不带压缩功能,SerialParNew
  • 空闲列表: GC带压缩功能,CMS

内存空间初始化为零值:

  • 保证了对象的实例字段在不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值

必要信息设置

  • 对象类的元数据
  • 对象的哈希码
  • GC分代年龄 -> 对象头

init()

image-20231021164000947

一个对象产生到灭亡的过程

新产生的对象优先分配在Eden区。

  • 当Eden区满了或放不下了进行GC,这时候其中存活的对象会复制到From区。

  • 如果From区放不下则会全部进入老年代,然后Eden内存全部清除。

  • 之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了

    • 这时候将会把Eden区和From区存活下来的对象复制到To区。
  • 同理,如果存活下来的对象To区都放不下,则这些存活下来的对象全部进入年老代

    • 之后回收掉Eden区和From区的所有内存。

如上这样,会有很多对象会被复制很多次(每复制一次,对象的年龄就+1)。

默认情况下:

  • 当对象被复制了15次(这个次数可以通过:-XX:MaxTenuringThreshold来配置),就会进入年老代了。

当年老代满了或者存放不下将要进入年老代的存活对象的时候。

  • 就会发生一次Full GC(这个是最需要减少的,因为耗时很严重)。

对象的内存分配方式

指针碰撞:

假设堆中的内存是绝对规整的,所有用过的内存放一边,未使用内存放另一边,中间边界线就可以类比为指针。

  • 内存分配就是把指针向未分配的区域挪一段与对象大小相等的距离,这就是指针碰撞

空闲列表:

如果堆中的内存不是很规整的,已使用和未使用的内存就会相互交错。

这个时候就要维护一个列表来记录所有已使用和未使用的内存块

  • 在分配内存时从列表找到一块足够大的空间划分给对象实例, 并更新内存列表。
分配方法 说明 收集器
指针碰撞 内存地址是连续的(新生代) Serial和 ParNew收集器
空闲列表 内存地址不连续(老年代) CMS收集器和 Mark-Sweep收集器

对象的内存布局

在堆内存中,一个对象的的存储布局可以分为三个区域:

对象头:

  • 存储对象自身的运行时数据(哈希吗+GC分代年龄+锁状态标准)

  • 类型指针: 类元素的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

实例数据:

  • 存储对象真正的有效信息,例如: 非静态变量也会存入堆空间

对齐填充:

  • JVM内对象都采用8byte对齐,不够8byte整数倍的就需要通过对齐填充来补全
image-20231021164641584

如何访问一个对象

对象的访问方式由虚拟机决定,目前主流的访问方式有以下两种:

句柄:

  • 使用句柄的话,堆中会专门划分出一块内存来作为句柄池,reference中存储对象句柄的地址
    • 句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

直接指针:

  • 访问速度快,节省了一次指针定位的开销
  • 直接访问,reference中存储的就是对象的地址,节省了一次指针定位的开销

强、软、弱、虚引用

四种引用的目的是让程序自己决定对象的生命周期。

  • 通过垃圾回收器对这四种引用做不同的处理,来实现对象生命周期的改变。

强引用:

如果一个对象具有强引用,那垃圾收器绝不会回收它。

当内存空间不足,宁愿抛出OutOfMemoryError错误,使程序异常终止。

  • 也不会靠随意回收具有强引用对象来解决内存不足的问题。

如:Object obj = new Object();这种就是强引用。

软引用:

在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。

如果这次回收还没有足够的内存,才会抛出内存溢出异常。

  • 使用软引用的方式是SoftReference

软引用通常用在对内存敏感的程序中。

  • 比如高速缓存就有用到软引用,内存够用的时候就保留,不够用就回收。

弱引用:

在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。

  • 只要垃圾回收,不管内存够不够用,弱引用都会被回收。

使用弱引用的方式是类WeakReference

虚引用:

如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

  • 它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(RefenenceQueue)联合使用。

  • 主要作用是跟踪对象垃圾回收的状态。

  • 提供了一种确保对象被 Finalize 以后,做某些事情的机制。

设置虚引用的唯一目的:

  • 就是在这个对象被回收器回收的时候收到一个系统通知或者后续添加进一步的处理。

空对象占多大内存

开启压缩指针:

  • 默认会占用12个字节,但为了避免伪共享问题,JVM会按照8个字节的倍数进行填充。
    • 所以会填充4个字节变成16个字节长度。
  • 所以:Markword占8个字节、类型指针占4个字节, 对齐填充占4个字节。

关闭压缩指针:

  • 默认会占用16个字节,16个字节正好是8的整数倍,因此不需要填充。

常用参数

-Xms:

  • 最小堆大小(初始化堆),如-Xms256m

-Xmx:

  • 最大堆大小,如-Xmx512m

-Xmn:

  • 新生代大小,如-Xmn是64m

-Xss:

  • 每个线程的堆栈大小

-XX:NewRatio=4:

  • 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)
  • 设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5

-XX:SurvivorRatio=8:

  • 设置年轻代中Eden区与Survivor区的大小比值
  • 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10

-XX:PermSize=100m:

  • 初始化永久代大小为100MB

-XX:MaxTenuringThreshold=15:

  • 设置垃圾对象最大年龄

垃圾回收

GC类型

Minor GC(Young GC):

新生代收集( Minor GC/Young GC ):

  • 当年轻代空间不足时,就会触发Minor GC。

整个年轻代中又可以分为Eden区和survivor区,survivor区又分为survivor0和survivor1。

  • Eden区和Survivor0区和Survivor1区,之间的配比是8:1:1(默认情况下)

年轻代GC回收过程:

  • 当Eden区被对象塞满后就会发生GC。

  • 在eden区中筛选出来有用的对象,把有用的对象复制到survivor0/survivor1,第一次随机分配,假设分配到survivor0,剩下eden区的对象都是垃圾对象,直接干掉。

  • 程序继续执行,eden区的对象又放不下了,又触发Minor GC,这次它不仅会回收eden区,还会回收survivor0区的垃圾对象,把eden区和survivor0区的有用对象一起放到survivor1区,eden区和survivor0区剩余的垃圾对象直接干掉。

  • 程序继续执行,eden区的对象又放不下了,又会触发Minor GC,这次它不仅会回收eden区,还会回收survivor1区的垃圾对象,把eden区和survivor1区的引用对象一起放到survivor0区,eden区和survivor1区剩余的垃圾对象直接干掉。

  • 依次往复循环,直到超过了一定的次数,对象就会放到老年代区,老年代放不下就要执行Full GC了。

老年代收集(Major GC/Old GC):老年代的垃圾收集。

在老年代空间不足时,则触发Major GC。

对象什么情况会进入老年代?

大对象:

需要大量连续内存空间的对象,最典型的大对象就是那种很长的字符串以及数组。

长期存活的对象:

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当他的年龄增加到一定程度(默认是15岁), 就将会被晋升到老年代中。

  • 对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定:

如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保:

在一次安全Minor GC 中,存活的对象不能在另一个Survivor 完全容纳,则会通过担保机制进入老年代。

什么样的对象会被回收?

引用计数法:

给对象中添加一个引用计数器,每当有一个地方引用他时,计数器值就+1。

当引用失效时,计数器值就-1,任何时刻计数器为0的对象就是不可能在被使用。

无法解决对象之间互相引用的情况。

  • A到B,B到C,C到A,相互引用,但是他们垃圾的话,用引用计数是无法被回收的。

可达性分析算法:

通过GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,这些对象就会被当作垃圾从而被回收掉的。

哪些是GCRoots对象的根:

虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象

方法区中的类静态属性引用的对象

方法区中常量引用的对象

本地方法栈中(Native方法)引用的对象

触发Full GC执行的情况?

调用System.gc()时,系统建议执行Full GC,但是不必然执行。

老年代空间不足。

方法区空间不足。

通过Minor GC后进入老年代的平均大小 大于 老年代的可用内存。

  • Eden、Survivor Space0(From Space)区 向Survivor Space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存 小于 该对象大小。

Full GC问题排查

查看GC日志:

  • 通过jstat -gcutil -t pid 1000 1000查看GC日志。

查看堆内存情况:

  • jmap -heap pid查看堆内存情况。

查看哪些类占用的空间多:

  • 通过jmap -hsito pid查看哪些类占用的空间多。

查看堆内存日志:

  • 通过jmap -dump:format=b,file=xxxx.hprofDump查看堆内存日志。
  • 通过MAT内存分析工具分析日志。

空间分配担保机制

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

  • 如果大于,则此次Minor GC是安全的。

  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。

如果HandlePromotionFailure=true,那么会继续 检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。

  • 如果大于,则尝试进行一次Minor GC。

  • 如果小于,则进行一次Full GC。

如果HandlePromotionFailure=false,则进行一次Full GC。

image-20231021164242524

安全点

程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为安全点(Safepoint)。

当JVM触发GC时,首先会让所有的用户线程到达安全点SafePoint时阻塞,也就是STW,然后枚举根节点,即找到所有的GC Roots,然后就可以从这些GC Roots向下搜寻,可达的对象就保留,不可达的对象就回收。

三色标记算法

根据CMS的运行过程来看,它的低停顿是在并发标记并发收集阶段,做成并行,而没有STW

但是这样做会带来一个问题:

  • 由于用户程序的执行,对象的状态很容易发生变化,原本有GC Roots引用的对象
    • 现在没有GC Roots引用了或者原本是垃圾对象,后面又复活了,又不是垃圾对象了
      • 引用状态的改变,对JVM来说是很不可控的一种行为。

为了解决这个问题,JVM引入了三色标记这个解决方案。

GCRoot如果想查找到存活对象,会根据可达分析算法分析,遍历整个引用链

  • 按照是否访问过该对象分成三种不同的颜色:
    • 白色、灰色、黑色。

白色:

  • 对象没有被访问过 (没有被GCRoot扫描过,有可能是为垃圾对象)。

灰色:

  • 对象已经被访问过(被GCRoot扫描过)

    • 且对象中的属性没有被GCRoot扫描,该对象就是为灰色对象。
  • 如果该对象的属性被扫描的情况下,从灰色变为黑色。

黑色:

  • 对象已经被访问过(被GCRoot扫描过),且本对象中的属性已经被GCRoot扫描过
    • 该对象就是为黑色对象。

三色标记算法原理:

image-20231026113802190

在初始阶段的时候,所有的对象都是存放在白色容器中。

初始标记阶段,GCRoot标记直接关联对象置为灰色。

并发标记阶段,扫描整个引用链,有子节点的话,则当前节点变为黑色,子节点变为灰色。

在白色盒子剩下的对象都是为没有被GCRoot关联的对象,可能会被垃圾回收机制清理。

下次GCRoot起点从灰色节点开始计算。

三色标记算法缺陷:

在并发标记阶段的时候,因为用户线程与GC线程同时运行,有可能会产生多标或者漏标。

多标:

  • 在并发标记阶段,把一个GC Roots引用链上的对象已经标记了
    • 但是用户线程没有停止,当方法结束的时候,这个对象链上可能都是垃圾对象,称为浮动垃圾

漏标:

  • 在并发标记阶段,原先已经被扫描过的对象重新有了新的引用,导致无法被扫描。

CMS解决漏标问题:增量更新方式:

当黑色对象插入新的指向白色对象的引用关系的时候,就将这个新插入的引用记录下来

并发标记结束之后,等到重新标记阶段,会stop the world

  • 再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

G1如何解决漏标问题:原始快照STAB方式:

当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用关系记录下来

在并发标记结束之后,等到重新标记阶段

  • stop the world再讲这些记录过的引用关系中的灰色对象为根,重新扫描一次

这样就能扫描到白色对象,将这些白色对象标记成黑色对象

  • 目的就是让这种对象在本次GC中存下来,等待下一轮GC的时候重新扫描,这个对象也有可能是浮动垃圾。

增量更新针对的是新增,原始快照针对的是删除

无论是插入删除,JVM的记录操作都是通过写屏障实现的,STAB是写前屏障,增量更新是写后屏障。

跨代引用

跨代引用是指年老代空间中的对象引用了新生代的对象,或者新生代中的对象引用了年老代中的对象。

面对这种情况,在进行可达性分析扫描存活对象时,不可能从新生代一直扫描至年老代的,因为这样就会出现整堆扫描的情况,效率必然会很低。

在HotSpot虚拟机中,为了解决跨代引用的问题,会专门在内存中开辟一块小空间用于维护这些特殊的引用,从而达到让GC不必扫描整个堆空间的目的,而开辟的这块小空间则被称为记忆集、卡表

记忆集(Remember Set):

为了解决跨代引用的问题,在新生代引入了记录集的数据结构,记录从非收集区到收集区的引用指针集合,避免在通过根可达算法判断对象存活时把整个老年代加入扫描范围。

GC时,GC收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需进行详细的根搜索过程。

卡表(Card Table):

卡表是HotSpot虚拟机中记忆集的实现方式,卡表中记录中记忆集的记录精度、与堆内存区域的映射关系等。

如果有年老代的对象引用了新生代的对象,那么该新生代对象所在区域对应的卡页元素设置为1,反之则为0。

G1以后的GC收集器不分代,所以G1以后的记忆集不是通过数组实现的,而是通过哈希表结构实现。

  • JVM对于卡页的维护也是通过写屏障的方式。

Remembered Set:

G1中实现的一种新的数据结构:简称为RSet,也被称为双向卡表。

在每个Region区中都存在一个RSet,其中记录了其他Region中的对象引用当前Region中对象的关系,也就是记录着谁引用了我的对象,属于points-into结构。

之前的卡表则是属于points-out结构,记录着我引用了谁的对象,在卡表中存在多个卡页,每个卡页记录着一定范围(512KB)的堆。

RSet也好,CardTable也好,都是记忆集的一种具体实现,你也可以将RSet理解成一种CardTable的进阶实现方式。

G1中的RSet本质上就是一个哈希表结构(HashTable),Key为其他引用当前区内对象的Region起始地址,Value则是一个集合,里面的元素为其他Region中每个引用当前区内对象的地址。

实际上G1中的RSet对内存的开销也并不小,当JVM中分区较多且运行时间较长的情况下,这块的内存开销可能会占用到20%以上。

垃圾收集算法

分代收集理论

分代思想就是将分为新生代老年代,然后根据各个年代的特点选择合适的垃圾收集算法

  • 新生代中,每次收集都会有大量的对象(近99%)死去,所以选择复制算法,只需要付出少量对象的复制成本就可以完成每次收集。

  • 老年代中,对象的存活几率较高,复制成本太高,而且没有额外的空间给它分配担保,所以必须使用标记整理算法或标记清除算法进行垃圾收集。

标记-清除算法

标记-清除算法共有两个阶段,分为标记清除

  • 标记存活的对象,也可以反过来,标记所有需要回收的对象。
  • 在标记完成后统一回收未被标记的对象。

两个明显的问题:

  • 效率问题:如果标记的对象太多,那么效率不高。
  • 空间问题:标记清除后,会产生大量不连续的碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

它可以将内存分为大小相同的两块,每次使用其中的一块,当这一块内存使用完后,就会将存活的对象复制到另外一块去,然后把使用过的空间一次性清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收。

把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。

  • 发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上。
  • 然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1:

  • 即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被浪费的。

复制算法的局限性:

  • 最大的弊端就是浪费内存空间

标记-整理算法

其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-整理算法的缺点:

要移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

  • 不移动对象停顿时间会更短,如果从整个程序的吞吐量来看,移动对象会更划算。

HotSpot虚拟机里关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的。

而关注延迟的CMS收集器则是基于标记-清除算法的。

折中方案:

让JVM平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

  • 基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

垃圾收集器

基本概念

串行Serial收集:

  • 所有用户线程停止,单条GC线程回收堆的情况被称为串行回收。

并行Parallel收集:

  • 所有用户线程停止,多条GC线程回收堆的情况(需多核CPU支持)。

独占Monopoly执行:

  • GC工作时,GC线程会抢占所有资源执行,整个应用程序会被停止。

并发Concurrent执行:

  • 用户线程和GC线程同时(交替)执行的情况,不会停下某类线程。

吞吐量:

指CPU用于执行用户代码的时间与CPU总耗时的比值,计算公式为:

  • 吞吐量 = 用户代码执行总时长 /(用户代码执行总时长 + 垃圾回收总时长)。

如JVM在线上执行了100min,其中执行用户代码花费了99min,垃圾回收总用时1min

  • 那么吞吐量则为99min/(99min+1min)=99%

写屏障:

指在赋值操作前后加入一些逻辑处理(类似于SpringAOP面向切面前后置处理的思想)。

GC组合方案:

追求低延迟,用户交互度较为频繁:采用:ParNew + CMS

追求高吞吐,后台计算工作较多:采用:Parallel Scavenge + Parallel Old

Serial收集器

单线程的GC收集器,被称为串行收集器。

该收集器在发生GC时,会产生STW,也就是会停止所有用户线程。

由于会停止其他用户线程,所以在执行GC时并不会出现线程间的切换。

在单颗CPU的机器上,它的清理效率非常高。

一般来说,采用Client模式运行的JVM,选取该款收集器作为内嵌GC是个不错的选择。

ParNew收集器(多线程)

Serial收集器的多线程版本,是作用于新生代区域的收集器。

  • 在整个实现上,除开GC收集阶段会使用多条线程回收外,其他实现几乎与Serial收集器大致相同。

该款GC收集器因为采用了多线程,所以需要多核CPU的支持,该收集器会根据CPU核数,开启不同的GC线程数,从而达到最优的垃圾回收效果(也可通过-XX:ParallelGCThreads参数指定)。

  • 若是单核的机器上运行时,其效率可能不如Serial
  • 如果是以Server模式运行的程序,而老年代又采用了CMS收集器,那么新生代搭配ParNew是个不错的选择。

Parallel Scavenge收集器(多线程)

一款作用于新生代的多线程GC收集器,但与ParNew收集器不同的是:

  • ParNew通过控制GC线程数量来缩短程序暂停时间,更关心程序的响应时间
  • Parallel Scavenge更关心的是程序运行的吞吐量,更注重一段时间内,用户代码执行时长与程序执行总时长的占比。

PS收集器可以通过-XX:MaxGCPauseMillis-XX:GCTimeRatio参数精准控制GC发生时的时间以及吞吐量占比。

PS收集器还可以通过开启-XX:+UseAdaptiveSizePolicy参数,让JVM启动自适应的GC调节策略,开启该参数后,JVM会根据当前系统的运行状态调整吞吐比与GC时间,从而确保能够提供最合适的停顿时间和吞吐量。

Serial Old(MSC)收集器(单线程)

Serial Old(MSC)Serial收集器相同,同样是一款单线程串行回收的收集器。

不同的是:MSC是一款作用于年老代空间的收集器,它采用标记-整理算法对年老代空间进行回收。

同时,该款收集器也可作为CMS的备用收集器使用。

Parallel Old收集器(多线程)

Parallel Old则是Parallel Scavenge收集器的年老代版本,同样采用多线程进行并行收集,其内部采用标记-整理算法。

与新生代的PS收集器相同的是:PO同样追求的是吞吐量优先

ShenandoahGC

JDK12推出了ShenandoahGC收集器,它与G1、ZGC收集器一样,都是基于分区结构实现的一款收集器。

和ZGC对比,相同的是:它们的停顿时间都不会受到堆空间大小的影响。

ZGC收集器

JDK11时推出垃圾回收器ZGC,是一款基于分区概念的内存布局GC器。

  • 是真正意义上的不分代收集器,无论是从逻辑上,还是物理上都不再保留分代的概念。

ZGC主要是超低延迟与吞吐量,在实现时,ZGC也会在尽可能堆吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾回收的停顿时间限制在10ms以内的低延迟。

引入ZGC的目的主要有如下四点:

奠定未来GC特性的基础。

为了支持超大级别堆空间(TB级别),最高支持16TB

在最糟糕的情况下,对吞吐量的影响也不会降低超过15%。

GC触发产生的停顿时间不会偏差10ms

CMS收集器

CMS(Concurrent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器。

  • 它是HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

它是标记-清除算法实现,整个过程可以分为以下步骤:

image-20231026112357922

初始标记:

  • 暂停所有的其他线程stop the world,并记下GC Roots直接引用的对象,速度很快。

并发标记:

  • 从GC Roots的直接关联对象开始遍历整个对象链的过程,这个过程很长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
    • 因为用户程序继续执行,可能会有导致已经标记过的对象状态发生变化。

重新标记:

  • 为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。
    • 这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
    • 主要用到三色标记里的增量更新算法做重新标记。

并发清理:

  • 开启用户线程,同时GC线程开始对未标记的区域做清扫。
    • 这个阶段如果有新增对象会被标记为黑色,不做任何处理。

并发重置:

  • 重置本次GC过程中的标记数据。

CMS的缺点

对CPU的资源敏感,会和服务器抢资源。

无法处理浮动垃圾,就是在并发标记和并发清理阶段产生的垃圾对象,只能等下一次GC来收集。

它使用的是标记-清除算法,那么就会导致收集结束后,会产生大量的内存碎片,不利于后续对象的存储。

G1收集器

G1(Garbage First)是一款面向服务器的垃圾收集器,主要针对配置多核处理器以及大容量内存的机器

  • 满足GC停顿时间要求的同时,还具备高吞吐量性能特征

JDK9 开始默认使用 G1 垃圾收集器


内存划分

G1将堆划分为多个大小相等的独立的Region区域

一般Region区的大小等于堆空间的总大小除以2048

  • 比如目前的堆空间总大小为8GB,就是8192MB/2048=4MB,那么最终每个Region区的大小为4MB

  • 也可以用参数-XX:G1HeapRegionSize强制指定每个Region区的大小

新生代和老年代

默认新生代对堆内存的初始占比是5%

  • 如果堆大小为8GB,那么年轻代占据400MB左右的内存,对应大概是100个Region
    • 可以通过-XX:G1NewSizePercent设置新生代初始占比

在Java程序运行中,JVM会不停的给新生代增加更多的Region

  • 但是最多新生代的占比不会超过堆空间总大小的60%,可以通过-XX:G1MaxNewSizePercent调整

新生代中的Eden区和Survivor区对应的Region区比例也跟之前一样,默认8:1:1

  • 假设新生代现在有400个Region,那么整个新生代的占比则为Eden=320,S0/From=40,S1/To=40

G1中的年老代晋升条件和之前的无差,达到年龄阈值的对象会被转入年老代的Region区中

不同的是对于大对象的分配,在G1中不会让大对象进入年老代:

  • 在G1中由专门存放大对象的Region区叫做Humongous

  • 如果在分配对象时,判定出一个对象属于大对象,那么则会直接将其放入Humongous区存储

在G1中,判定一个对象是否为大对象的方式为:

对象大小是否超过单个普通Region区的50%:

  • 如果超过则代表当前对象为大对象,那么该对象会被直接放入Humongous

  • 比如:目前是8GB的堆空间,每个Region区的大小为4MB,当一个对象大小超过2MB时则会被判定为属于大对象

如果程序运行过程中出现一个巨型对象,当一个Humongous区存不下时,可能会横跨多个Region区存储它

Humongous区存在的意义:

可以避免一些短命的巨型对象直接进入年老代,节约年老代的内存空间,可以有效避免年老代因空间不足时的GC开销

  • 当堆空间发生全局GC(FullGC)时,除开回收新生代和年老代之外,也会对Humongous区进行回收

什么场景下适合采用G1收集器的建议

堆空间内50%以上的内存会被存活占用的应用

分配速度和晋升速度特别快的应用

至少8GB以上堆内存的应用

采用原本分代收集器GC时间会长达1s+的应用

追求停顿时间在500ms以内的应用

GC类型

YoungGC:

在G1中,当新生代区域被用完时,G1首先会大概计算一下回收当前的新生代空间需要花费多少时间

  • 如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么不会触发YoungGC

    • 而是会继续为新生代增加新的Region区用于存放新分配的对象实例
  • 用户未显式通过-XX:MaxGCPauseMills参数设定GC预期回收停顿时间值,那么G1默认为200ms

直至某次Eden区空间再次被放满并经过计算后:

  • 此次回收的耗时接近-XX:MaxGCPauseMills参数设定的值,那么才会触发YoungGC

  • YoungGC被触发时:

    • 首先会将目标Region区中的存活对象移动至幸存区空间(Survivor-From区标志的Region

同时达到晋升年龄标准的对象也会被移入至年老代Region中存储

  • G1收集器在发生YoungGC时,复制移动对象时是采用的多线程并行复制,以此来换取更优异的GC性能

MixedGC:

当整个堆中年老代的区域占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值后触发MixedGC

发生该类型GC后:

  • 会回收所有新生代Region

  • 部分年老代Region区:会根据期望的GC停顿时间选择合适的年老代Region区优先回收

  • 以及大对象Humongous

正常情况下,G1垃圾收集时会先发生MixedGC,主要采用复制算法

  • 在GC时先将要回收的Region区中存活的对象拷贝至别的Region区内
    • 拷贝过程中,如果发现没有足够多的空闲Region区承载拷贝对象,此时就会触发一次Full GC

FullGC:

当整个堆空间中的空闲Region不足以支撑拷贝对象或由于元数据空间满了等原因触发

  • 在发生FullGC时,G1首先会停止系统所有用户线程,然后采用单线程进行标记、清理和压缩整理内存
    • 以便于清理出足够多的空闲Region来供下一次MixedGC使用

但该过程是单线程串行收集的,因此这个过程非常耗时(ShenandoahGC中采用了多线程并行收集)

  • G1收集器中并没有FullGC,G1中的FullGC是采Sserial old FullGC

GC过程

image-20231026110901495

G1收集器在发生GC时执行过程大致会分为四个步骤(主要指MixedGC):

初始标记(InitialMark):

  • 先触发STW,然后使用单条GC线程快速标记GCRoots直连的对象

并发标记(ConcurrentMarking):

  • 与CMS的并发标记过程一致,采用多条GC线程与用户线程共同执行,根据Root根节点标记所有对象

最终标记(Remark):

  • 同CMS的重新标记阶段(也会STW),主要是为了纠正并发标记阶段因用户操作导致的错标、误标、漏标对象

筛选回收(Cleanup):

  • 先对各个Region区的回收价值和成本进行排序,找出 回收价值最大 的Region优先回收
  • 筛选回收阶段在G1收集器中是会停止所有用户线程后,采用多线程并行回收的

假设此时年老代空间共有800Region区,并且都满了,所以此刻会触发GC

但根据GC的预期停顿时间值,本次GC只能允许停顿200ms,而G1经过前面的成本计算后,大致可推断出:

  • 本次GC回收600Region区恰好停顿时间可控制在200ms左右
  • 那么最终就会以 回收600Region区 为基准触发GC
    • 这样则能尽量确保GC导致的停顿时间可以被控制在我们指定的范围之内

G1会在后台维护着一个优先列表

  • CollectionSet(CSet),它记录了GC要收集的Region集合,集合里的Region可以是任意年代的

在G1中回收算法都是采用复制算法,都会将一个Region区中存活的对象复制到另外一个Region区内

G1缺点:

G1需要记忆集 (卡表) 来记录新生代和老年代之间的引用关系

  • 这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多
  • 而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率

CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是6GB到8GB

类加载机制

当JVM需要用到某个类时,虚拟机会加载它的.class文件,加载了相关的字节码信息后,会为它创建对应的Class对象,这个过程就被称为类加载。

类加载过程被分为三个步骤,五个阶段,分别为加载、验证、准备、解析以及初始化。

加载、验证、准备、初始化这四个阶段的顺序是确定的。

  • 解析阶段不一定,在某些情况下可以在初始化阶段之后再开始(动态绑定或晚期绑定)。

image-20231021143234009

加载步骤

指通过完全限定名查找Class文件二进制数据并将其加载进内存的过程:

  • 通过完全限定名查找定位.class文件,并获取其二进制字节流数据。
  • 把字节流所代表的静态存储结构转换为运行时数据结构。
  • 在堆中间中为其创建一个Class对象,作为程序访问这些数据的入口。

验证阶段

主要用于确保被加载的Class正确性,检测Class字节流中的数据是否符合虚拟机的要求,确保不会危害虚拟机自身安全。

验证阶段主要包括四种验证:

  • 文件格式验证、元数据验证、字节码验证以及符号引用验证。

准备阶段

准备阶段主要是为类中声明的静态变量分配内存空间,并将其初始化成默认值(零值)。

  • 在这里进行的内存分配仅包括类成员(static成员),而实例成员则会在创建具体的Java对象时被一起分配在堆空间中。
  • 同时也不包含使用final修饰的static成员,因为final在编译的时候就会分配了,准备阶段会显示初始化。

解析阶段

解析阶段主要是把类中对常量池内的符号引用转换为直接引用的过程。

  • 解析操作往往会伴随着JVM在执行完初始化之后再执行。

符号引用:

  • 用一组符号来描述引用的目标,符号引用的字面量形式明确定义在 Java虚拟机规范 的Class文件格式中。

直接引用:

  • 直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

符号引用转直接引用的过程,主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等7类符号引用进行(分别对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等)。

初始化步骤

初始化步骤中,主要是对类的静态变量赋予正确的初始值,也就是在声明静态变量时指定的初始化值以及静态代码块中的赋值。

  • 本质上就是执行类构造器方法<clinit>()的过程。

使用、卸载

当一个类完整的经过了类加载过程之后,在内存中已经生成了Class对象,同时在Java程序中可以通过它开始创建实例对象使用。

当一个Class对象不再被任何一处位置引用,即不可触及时,Class就会结束生命周期,该类加载的数据也会被卸载。

Java虚拟机自带的类加载器加载的类,在虚拟机的生命周期中始终不会被卸载,因为JVM始终会保持与这些类加载器的引用,而这些类加载器也会始终保持着自己加载的Class对象的引用,所以对于虚拟机而言,这些Class对象始终是可以被触及的。

  • 由用户自定义的类加载器加载的类是可以被卸载的。

类加载器

Bootstrap引导类加载器

引导类加载器也被称为启动类加载器或根类加载器

  • BootstrapClassLoader

引导类加载器使用C++语言实现,是JVM自身的一部分

  • 主要负责将<JAVA_HOME>\lib路径下的核心类库
  • -Xbootclasspath参数指定的路径下的jar包加载到内存中。

Extension拓展类加载器

位于HotSpot源码目录中的sun.misc.Launcher$ExtClassLoader位置。

它主要负责加载<JAVA_HOME>\lib\ext目录下

  • 或者由系统变量-Djava.ext.dir指定位路径中的类库。

Application系统类加载器

位于HotSpot源码目录中的sun.misc.Launcher$AppClassLoader位置。

它负责加载系统类路径java -classpath

  • -D java.class.path指定路径下的类库,也就是classpath路径。

该类加载器是程序的默认类加载器

  • 可以通过ClassLoader.getSystemClassLoader()方法可以直接获取到它。

自定义类加载器

如果需要自定义类加载器,只需要继承ClassLoader

  • 但继承ClassLoader需要自己重写findClass()方法并编写加载逻辑

在自定义ClassLoader的子类时,会有两种做法:

  • 重写loadClass()方法

  • 重写findClass()方法

loadClass()最终调用的还是findClass()方法

  • 如果想打破双亲委派模型,那么就重写整个loadClass方法

  • 如果不想打破双亲委派模型,那么只需要重写findClass方法

线程上下文类加载器

很多服务提供者接口(Service Provider Interface,SPI),这些接口允许第三方为它们提供实现

  • 如常见的SPIJDBC、JNDI

这些SPI的接口属于Java核心库,一般存在rt.jar包中

  • 由启动类加载器(Bootstrap)加载
  • SPI的第三方实现代码则是作为Java应用所依赖的jar包被存放在classpath路径下。

由于SPI接口中的代码需要加载第三方实现类并调用其相关函数

  • SPI的核心接口类是由启动类加载器(Bootstrap)加载的,Bootstrap加载器无法直接加载SPI的实现类。

SPI提供了一种动态的服务发现机制(约定),只要第三方在编写实现类时

  • 在工程内新建一个META-INF/services/目录并在该目录下创建一个与服务接口名称同名的文件
  • 那么在程序启动的时候,就会根据约定去找到所有符合规范的实现类
    • 然后交给线程上下文类加载器进行加载处理。

线程的上下文类加载器默认设置的就是系统类加载器(System)。

image-20231021144123067

双亲委派机制

双亲委派类加载过程

App尝试加载一个类时,它不会直接尝试加载这个类

  • 首先会在自己的命名空间中查询是否已经加载过这个类
    • 如果没有会先将这个类加载请求委派给父类加载器Ext完成

Ext尝试加载一个类时,它也不会直接尝试加载这个类

  • 也会在自己的命名空间中查询是否已经加载过这个类
    • 没有的话也会先将这个类加载请求委派给父类加载器Bootstrap完成

如果Bootstrap加载失败,这个需要被加载的类不在Bootstrap的加载范围内

  • 那么Bootstrap会重新将这个类加载请求交由子类加载器Ext完成

如果Ext加载失败,这个类也不在Ext的加载范围内

  • 最后会重新将这个类加载请求交给子类加载器App完成

如果App加载器也加载失败这个类根据全限定名无法查找到

  • 则会抛出ClassNotFoundException异常。

此处的父子关系并非继承关系,而是采用组合关系来实现

image-20231026173048245

双亲委派机制的优势

可以避免一个类在不同层级的类加载器中重复加载

  • 如果父类加载器已经加载过该类了,那么就不需要子类加载器再加载一次

沙箱安全机制:

可以保障Java核心类的安全性问题

  • 比如通过网络传输过来一个java.lang.String类,需要被加载时,通过这种双亲委派的方式
    • 最终找到Bootstrap加载器后,发现该类已经被加载
    • 从而就不会再加载传输过来的java.lang.String类,而是直接返回Bootstrap加载的String.class

这样可以有效防止Java的核心API类在运行时被篡改

  • 从而保证所有子类共享同一基础类,减少性能开销和安全隐患问题

双亲委派的实现原理

所有的类加载器都间接的继承自ClassLoader

  • 包括Ext、App类加载器(Bootstrap除外,因为它是C++实现的)

双亲委派模型的实现逻辑全在于loadClass()方法

  • ExtClassLoader加载器是没有重写loadClass()方法,
  • AppClassLoader加载器虽然重写了loadClass()方法,但其内部最终还是调用父类的loadClass()方法

无论是ExtClassLoader还是AppClassLoader加载器

  • 其本身都未打破ClassLoader.loadClass()方法中定义的双亲委派逻辑
  • Bootstrap、Ext、App这些JVM自带的类加载器都默认会遵守双亲委派模型
// sun.misc.Launcher类 → AppClassLoader内部类 → loadClass()方法
 public Class loadClass(String name, boolean resolve)
     throws ClassNotFoundException
 {
     int i = name.lastIndexOf('.');
     if (i != -1) {
         SecurityManager sm = System.getSecurityManager();
         if (sm != null) {
             sm.checkPackageAccess(name.substring(0, i));
         }
     }
     // 依旧调用的是父类loadClass()方法
     return (super.loadClass(name, resolve));
 }

打破双亲委派机制

需要重写loadClass方法,在loadClass方法中不委托给父类尝试着进行加载,直接在当前的类加载器进行加载。

打破双亲委派机制的场景:

  • 一个Tomcat可能部署多个应用,不同的应用可能依赖的同一个第三方类库的不同版本(会造成很多大量的文件路径相同的类)
  • 这种情况下就不能通过双亲委派机制去加载,要保证每个应用的类库是独立的,相互隔离。

即时编译

Java编译器经过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢很多。

  • 为了解决这种效率问题,引入了 JIT(Just In Time ,即时编译) 技术。

当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是热点代码:

  • JIT会把部分热点代码翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

热点检测

目前主要的热点代码识别方式是热点探测,HotSpot虚拟机中采用的主要是基于计数器的热点探测。

  • 采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数。

  • 某个方法超过阀值就认为是热点方法,触发JIT编译。

编译优化

JIT在做了热点检测识别出热点代码后,除了会对其字节码进行缓存,还会对代码做各种优化。

  • 逃逸分析,锁消除,锁膨胀,方法内联等。

逃逸分析

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

有了逃逸分析,可以判断出一个方法中的变量是否有可能被其他线程所访问或者改变,基于这个特性,JIT做了一些优化:

  • 标量替换,栈上分配

通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

标量替换

如果经过逃逸分析,发现一个对象不会被外界访问,经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。

栈上分配

经过逃逸分析技术发现这个对象并没有被外部引用且仅在当前线程使用,那么它就会将该对象分配在栈上。

锁消除

逃逸分析某些没有被外部方法或者其他线程引用的情况下,会将某些锁消除。

方法内联

对于可以内联的方法,直接复制到调用者代码中,减少方法调用次数和性能消耗。

常见问题

问题排查工具

https://fastthread.io/

https://gceasy.io/

https://heaphero.io/

服务器上查看堆栈信息工具

登陆上具体机器,执行命令:

  • wget https://raw.github.com/oldratlee/useful-scripts/release-2.x/bin/show-busy-java-threads

下载脚本:

  • sh 脚本,即可得到最忙线程信息

内存泄漏

对象不会再被程序用到了,但是GC又不能回收他们的情况,叫内存泄漏。

内存泄漏举例:

单例模式:

  • 单例的生命周期和应用程序一样长,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏。

资源未关闭 :

  • 如:数据库连接,网络连接和IO连接必须手动Close,否则是不能被回收的。

内存溢出

元空间溢出

如果元空间发生内存溢出,会造成操作系统的内存溢出。

元空间溢出主要是由于加载的类太多,或者动态生成的类太多。

堆外内存溢出

这里的堆外内存指的是 Java 应用程序通过直接方式从操作系统中申请的内存。

在 Java 中可以通过相应的 API 去申请直接内存,通过监控工具,能够看到内存占用的明显增长。

为了限制这些危险的内存申请,可以通过设置相应的参数进行调整。

栈溢出

线程请求的栈深度大于虚拟机允许的最大深度。

虚拟机在扩展栈深度时,无法申请到足够的内存空间:OutOfMemoryError

性能调优

何时进行JVM调优?

遇到以下情况,就需要考虑进行JVM调优了:

  • Heap内存(老年代)持续上涨达到设置的最大内存值
  • Full GC 次数频繁
  • GC 停顿时间过长(超过1秒)
  • 应用出现OutOfMemory等内存异常
  • 系统吞吐量与响应性能不高或下降

JVM调优基本原则:

大多数的Java应用不需要进行JVM优化

大多数导致GC问题的原因是代码层面的问题导致的(代码层面)

上线之前,应先考虑将机器的JVM参数设置到最优

减少创建对象的数量(代码层面)

减少使用全局变量和大对象(代码层面)

优先架构调优和代码调优,JVM优化是不得已的手段(代码、架构层面)

分析GC情况优化代码比优化JVM参数更好(代码层面)

JVM调优目标:

延迟:

  • GC低停顿和GC低频率

没有FullGC

高吞吐量

JVM调优步骤:

一般情况下,JVM调优可通过以下步骤进行:

  • 分析GC日志及Dump文件,判断是否需要优化,确定瓶颈问题点
  • 确定JVM调优量化目标
  • 确定JVM调优参数(根据历史JVM参数来调整)
  • 依次调优内存、延迟、吞吐量等指标
  • 对比观察调优前后的差异
  • 不断的分析和调整,直到找到合适的JVM参数配置
  • 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪

JVM调优建议

年轻代和老年代的比例需要结合实际场景调整

  • 由于老年代的GC成本通常都会比年轻代的成本要高许多。
  • 所以建议适当地通过Xmn命令区设置年轻代的大小,最大限度的降低对象晋升到老年代的情况。

合理设置Eden区和Survivor区比例

  • 合理分配Eden区和Survivor区,尽量不要让对象进入老年代。
支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!