JVM内存模型、垃圾收集原理


JVM虚拟机整体结构

我们先来看一下JVM虚拟机的整体架构图:

 

从整体架构图可以清晰看到JVM虚拟机包含的模块如下:

  • 运行时数据区域

  • 垃圾收集

  • 虚拟机执行子系统

  • 本地方法模块

运行时数据区域

从上面的整体架构图可以清晰看到运行时数据区域包含了多个子模块,下面分别对每个模块作用进行讲解。

堆是JVM主要内存块,JVM进程启动时根据参数或默认配置申请创建,堆主要作用是存放对象实例和数组等数据,堆内存数据是多线程共享的,是垃圾回收器的主要管理目标之一,当堆内存被垃圾收集器收集后仍不够使用时将抛出OOM异常。

其中堆又被细分为新生代和老年代,新生代又进一步细分为Eden块、From Survivor块、To Survivor块,这种划分有助于JVM垃圾回收效率,关于垃圾回收的具体知识后面再讲解,这里只关注数据区域相关的内容。

下面是堆划分结构图:

新生代和老年代的区别是对象经历的垃圾回收次数,大部分对象首次都是优先在新生代Eden中分配创建,当新生代中的对象经历了一定垃圾回收次数(默认是15次,也可以通过参数-XX:PretenureSizeThreshold配置)仍然存活,这种对象将被转移到老年代,当然也存在某些大对象会直接在老年代分配的情况。

方法区

方法区和堆一样也是多线程共享的数据区域,也是垃圾收集器管理目标之一,它主要作用是存储加载的Class类元数据信息(类版本、属性、方法、接口、常量等信息)。因为方法区的数据相对来说很少会被垃圾收集器回收,所以方法区也被称之为永久代,但并不是说永久代的数据垃圾收集器不会去管理,实际上仍然有被回收对象的可能。当方法区无法满足内存分配时,将会抛出OOM异常。

逻辑上方法区内部划分为Class类元数据常量池和运行时常量池,Class文件常量池主要保存Class类信息,而运行时常量池主要保存编译期间就确定的Class类元数据信息中的常量数据(public static final)以及运行期间动态申请的常量数据(例如通过调用String类的intern方法),运行时常量池的好处是复用相同值的字符串或基本类型,减少频繁创建对象导致的垃圾回收工作。

下面是方法区结构图:

 

栈是线程私有的,一个线程一个栈,当线程销毁时栈也同时被销毁,栈主要管理线程的方法执行,当线程中调用每一个方法时会同时创建一个栈帧,栈帧内部保存当前方法的局部变量表、动态链接、操作栈、方法出口等信息,当线程某个方法调用结束时,对应方法的栈帧同时也会被出栈。

下面是栈结构图:

 

栈管理着栈帧的出站和入站,而栈帧内部管理着当前对应方法调用信息,其中栈帧的局部变量表保存当前方法的基本数据类型、引用类型。局部变量表内存大小在编译期间就已经确定,除了64位长度的long和double类型数据会占用2个局部变量Solt外,其它变量类型均只占用1个Solt。

当线程调用方法过多导致栈深度大于虚拟机规范允许的大小时,将抛出StackOverflowError异常。当线程栈内存不足进行扩展时,无法申请到足够的内存时会抛出OOM异常。

本地方法栈

本地方法栈和前面讲的栈功能类似,只是本地方法栈服务的对象是Native方法,而栈服务的对象是Java方法。

程序计数器

程序计数器是线程私有的,它的作用是通过申请一块较小的内存空间来记录对应的线程执行的虚拟机字节码指令地址行号。每条线程对应一个独立的程序计数器,当CPU轮询进行线程切换时,当前执行线程通过程序计数器找到正确执行位置。程序计数器并不记录本地(Native)方法,它只记录Java方法。

直接内存

直接内存不属于JVM运行时堆内存范围,直接内存是直接通过调用JDK Native函数库进行分配的堆外内存,然后通过Java堆里面的某个对象引用(例如DirectByteBuffer)来进行操作,这部分内存无法被垃圾收集器直接识别管理,但它的使用影响当前主机的总内存量。在实际生产环境排查时当发现主机内存使用远远大于JVM堆分配的内存时,就需要注意是否有大量使用直接内存占用了主机总内存的情况了。

运行时数据区域相关参数

下面介绍跟运行时数据区域相关的几个核心JVM配置参数,了解这几个参数有助于JVM调优和问题分析:

参数

作用

默认值

例子

-Xmx

以字节为单位指定堆最大大小。此值必须是1024的倍数且大于2 MB。附加字母k或k表示千字节,m或m表示兆字节,g或g表示千兆字节。对于服务器部署,-Xms和-Xmx通常设置为相同的值。

默认为物理内存1/64,且小于1G

-Xmx83886080

-Xmx81920k

-Xmx512m

-Xmx1g

 

-Xms

设置堆的初始大小(字节)。此值必须是1024的倍数且大于1 MB。


-Xms6291456

-Xms6144k

-Xms6m

-Xms1g

-Xmn(等同将-XX:NewSize和-XX:MaxNewSize配置为相同值)

设置年轻代内存大小。

建议为堆大小的1/2到1/4之间。

-Xmn256m

-Xmn262144k

-Xmn268435456

-XX:NewRatio(如果配置了-Xmn,则该比率配置不起作用)

设置年轻代是老年代的x分之一。。

默认是2,表示年轻代是老年代的2分之一。

-XX:NewRatio=2

-XX:SurvivorRatio

设置年轻代中eden区与survivor区的比率。

默认值为8,表示eden:s1:s2为8:1:1。

-XX:SurvivorRatio=6

eden:s1:s2=6:1:1

eden=4/6

s1,s2=1/6,1/6

-XX:MetaspaceSize(取代JDK8以前的-XX:PermSize配置)

设置方法区扩容时触发fullgc垃圾回收的阀值。

默认大小取决于平台。具体官方没有明确说明。

-XX:MetaspaceSize=64M

-XX:MaxMetaspaceSize(取代JDK8以前的-XX:MaxPermSize配置)

设置方法区最大内存大小。

默认不限制。

-XX:MaxMetaspaceSize=256m

-Xss

设置线程堆栈大小(字节)。

默认值取决于平台:

例如:Linux/x64 (64-bit): 1024 KB

-Xss1m

-Xss1024k

-Xss1048576

-XX:TargetSurvivorRatio

设置垃圾收集后Survivor所需要的空间百分比(0-100)

默认值为50

-XX:TargetSurvivorRatio=30

-XX:PretenureSizeThreshold

配置晋升老年代年龄

默认15

-XX:PretenureSizeThreshold=13

 

关于JVM更多其他参数可以查阅官方文档:

https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABDJJFI

 

垃圾收集

前面我们介绍了虚拟机运行时数据区域线程私有以及线程共享各个部分,对于垃圾收集器它主要关注的是线程共享的部分(堆、方法区),而线程私有部分(栈、栈帧、本地方法栈、程序计数器)随着线程结束它们也将可以确定被回收。由于线程共享部分由于对象数据被多线程共享,所以要想处理这部分的回收工作首要解决的问题就是确定哪些对象数据是可回收的,下面就来介绍垃圾收集器判断对象存活的算法。

对象可回收算法-根搜索算法(GC Roots Tracing

JVM垃圾收集器采用的是根搜索算法来判断对象是否可回收,算法具体思路是:定义一个叫GC Roots列表,凡是在这个列表中存在的强引用对象或跟这个列表中的强引用有关联的其它强引用对象均为存活对象,如果某个强引用对象不存在这个GC Roots列表也没有跟任何GC Roots列表强引用对象有关联,则称为从GC Roots到这个对象不可达,这种对象会被标记为下次垃圾收集时回收目标对象。JVM规范定义GC Roots的列表范围如下:

  • 栈中的强引用对象。

  • 本地方法栈Native中强引用的对象。

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

  • 方法区中的字面常量强引用对象。

前面强调是强引用对象,在JVM规范中或Java开发语言规范中将引用分为下面4种类型,它们强度按顺序依次降低:

  • 强引用:通过直接new出来的对象就是强引用类型对象。

  • 软引用:通过new SoftReference(...)包装的对象为软引用,当内存不够时,这类对象将被无条件回收。

  • 弱引用:通过new WeakReference(...)包装的对象为弱引用对象,这类对象在下次垃圾收集器工作时无条件回收。

  • 虚引用:通过new PhantomReference(...)包装的对象为虚引用对象,这类对象引用是无法使用的。

这里有一点要注意,在垃圾回收前会进行一次finalize判定,如果某个对象判定有必要执行finalize方法,则会被加入到F-Queue队列由单独的线程完成finalize的执行。如果某个对象在finalize方法重新将自己关联上GC Roots,那么这个对象将取消下次被回收的标记。

垃圾收集算法

通过对象回收算法可以知道对象是否可回收,但是具体的回收方式还需要一种算法来实现。下面就来介绍垃圾收集算法。

  • 标记、清除算法:从名字上就很容易理解,先根据对象回收算法标记出哪些对象是需要回收的,然后将之清除,这种算法简单但低效。简单之处在于容易实现,低效之处在于空间碎片多导致释放后的空间不连续,当频繁需要分配大对象时由于找不到连续的空间导致后面产生更多的GC工作。下面是标记、清除算法简单示意图:

  • 复制算法:目前JVM堆中的新生代大部分采用的便是这种算法,它将内存(堆)逻辑上划分为好几块,每块大小不一且可根据参数进行配置,在JVM中的划分在前面讲解堆的时候有给过一个结构图如下:

 

我们前面也提过,这种划分方式有助于垃圾收集,复制算法核心思路是: 将内存逻辑上划分为多个区域,例如新生代划分为3个区域,其中两个survivor总是有一个被留空,另一个survivor和eden存放新创建的对象,当垃圾收集器工作时,将达到老年代标准的存活对象复制到老年代,将仍然要留在新生代区域的存活对象复制到留空的那个survivor中,最后将可回收的对象从eden和另一个survivor中清除掉,这就是算法领域标准的以空间换取时间的策略,因为留空的那个survivor是不存放任何对象的,所以为了减少浪费要合理配置eden和survivor的比例,这里有个问题,如果survivor配置较小导致空间存放不下存活对象时,这种情况会把多出部分存活对象直接分配到老年代,老年代为survivor做担保。如果老年代也放不下时就会触发老年代的垃圾收集算法进行回收。下面是复制算法示意图:

  • 标记、整理算法:目前JVM堆中的老年代大部分采用的便是这种算法。这种算法就跟标记清除算法类似,标记清除算法最后通过清除方式清理可回收对象,而标记整理算法是将存活对象标记后往一个方向移动,然后将可回收部分一次性清掉,这种方式可以减少内存碎片。下面是标记整理算法示意图:

 


垃圾收集器

有了判断可回收对象算法、垃圾收集算法后那么接下来要探讨的是谁来执行,而垃圾收集器便是执行者。JVM虚拟机规范没有规定堆垃圾收集器的实现规范,所以导致不同厂商开发各种垃圾收集器,但不同垃圾收集器都是聚焦在一个问题上:垃圾收集器工作时对JVM虚拟机可用性的影响程度。前面我们讲过,新生代和老年代垃圾收集算法是不通的,所以对应的垃圾收集器也是分别去实现,下面就来分别介绍新生代和老年代垃圾收集器类型。

 

  • Serial收集器:Serial收集器是新生代收集器实现,从名字上就可以看出是串行化的,使用一条线程去收集。注意,这里串行化是相对垃圾收集器线程和JVM用户业务线程的串行化,换句话就是当垃圾收集器线程在工作时,其它用户线程必须等垃圾收集器线程执行完才能继续(Stop The World)。可以试想一下,如果新生代内存配置很大,当垃圾收集时就会需要更长的时间,那么就会导致用户业务全部阻塞等待。这类收集器比较适用于应用程序总体需要内存较小的场景,比如Client模式类的应用就比较合适。合理配置堆内存结构和选择对应的垃圾收集器对于JVM调优至关重要。

  • ParNew收集器:ParNew收集器也是新生代收集器实现,它和Serial收集器基本相同,唯一不同的是Serial使用单线程的方式收集而ParNew使用多线程的方式去收集。由于采用多线程可以提升垃圾收集效率从而减少垃圾收集导致的用户业务线程阻塞时间,所以它是很多Server模式应用新生代收集器首选。基本下面是Serial和ParNew对比图。

 

我们可以通过参数-XX:ParallelGCThreads参数控制该类垃圾收集器线程数量,如果不配置默认情况下使用线程数量和CPU的数量相同。

  • Parallel Scavenge收集器:Parallel Scavenge收集器也是新生代收集器实现,这类收集器跟ParialNew收集一样也是多线程收集器,它和ParialNew不同之处在于它允许用户配置垃圾收集对用户业务线程造成的最大停顿时间,通过提供设置垃圾收集器允许最大停顿时间(-XX:MaxGCPauseMillis,单位:毫秒)或垃圾收集时间占CPU总时间比率(-XX:GCTimeRatio)参数来实现控制,两者配置一个即可。例如配置-XX:MaxGCPauseMillis=1000表示允许停顿1s,配置-XX:GCTimeRatio=90表示垃圾回收时间为:总工作时间 * 1/91 (1/(1+90))。看上去确实很美好,但是如果你指定很小的暂停时间值的话,每次垃圾收集器就会收集一小部分新生代,分多次收集,这样确实满足每次暂停时间,但是带来的是更多的垃圾收集触发频率,总体效率并不会有太多改善,往往理想的值都是需要通过不断修改配置加观察的方式来确定。其中还可以直接配置-XX:+UseAdaptiveSizePolicy开启参数(一般+表示开启,-表示关闭)将新生代大小、eden于survivor比例、晋升老年代年龄等参数交给Parallel Scavenge收集器自适应调节。

  • Serial Old收集器:Serial Old收集器是老生代收集器实现,它实现标记、整理算法,它和Serial收集器一样是单线程串行化收集器,它主要也是适用于Client模式应用。

  • Parallel Old收集器:Parallel Old收集器也是老生代收集器实现,它实现标记、整理算法,使用多线程进行垃圾收集。它通常和新生代Parallel Scavenge收集器组合使用。在Parallel Old收集器出现之前,新生代Parallel Scavenge收集器只能搭配Serial Old老年代收集器使用,由于Serial Old收集器适用于Client模式,所以通常会拖累Parallel Scavenge收集器的优势,后面会通过表格方式给出新生代和老年代收集器允许组合以及建议组合。

  • CMS收集器:CMS收集器也是老生代收集器实现,它实现标记、清除算法,它通过部分并发的方式降低老年代收集时对用户业务线程停顿时间,并发是指垃圾收集器线程工作时用户业务线程不需要暂停。它的原理是:1、先暂停用户业务线程快速标记GC Roots关联的存活的对象;2、并发标记GC Roots Trancing;3、再一次暂停用户业务线程重新标记并发标记期间因用户线程继续运行导致标记变动的部分对象;4、并发清除GC Roots不可达的对象。它的核心思想是将必须要暂停用户业务线程的标记工作抽取出来(1、3步骤),将可以后台任务的方式运行的工作通过并发方式去处理(2、4步骤)。这类收集器工作如下图:

它的优点也是它的缺点,因为它采用部分工作并发的方式,并发并发就会导致跟用户业务线程竞争CPU资源,如果CPU资源较少,那么可能出现频繁线程切换导致用户线程更长时间的卡顿,默认并发线程数量为:(CPU数量 + 3) / 4,当CPU数量不足4个时那么CMS对用户线程影响会较大。其次这类收集器实现的是标记清除算法,前面说过这种算法会产生碎片问题,CMS提供-XX:+UseCMSCompactAtFullCollection开关参数开启垃圾收集完成后进行一次碎片整理工作,但内存碎片整理过程时无法并发的,需要再次暂停用户线程。最后CMS还有个缺点就是因为长时间的并发工作需要预留部分老年代内存给依然运行的用户线程使用,如果CMS工作期间预留内存不够用户线程使用,而CMS并发清除工作还未完成,这时就会导致“Concurrent Mode Failure”失败这种情况默认就会启用Serial Old收集器来进行“Stop The World”模式进行垃圾收集,导致很漫长的用户线程阻塞。CMS默认在老年代内存使用了68%时就会启动,可以通过参数-XX:CMSInitiatingOccupancyFraction来合理调整这个值。

  • G1收集器:G1收集器时垃圾收集技术佼佼者,它和前面介绍垃圾收集器最大的不同是它没有一口吃掉大象而是将堆内存分为多个大小固定的块,并记录这些块的垃圾程序,每次收集时按垃圾程度排序,优先收集垃圾最多的那部分(Garbage First),由于每次收集都是一小块,所以能够较快的速度收集完毕。

垃圾收集器组合参数

前面分别介绍新生代和老年代不同类型的收集器,JVM虚拟机垃圾收集器总是通过组合新生代和老年代来为整个JVM堆内存清理垃圾,下面给出组合启用参数以及建议。

新生代

老年代

参数开关

通过jconsole查看

Serial

Serial Old

-XX:+UseSerialGC。Client模式默认组合。

老年代收集器: MarkSweepCompact

新生代收集器: Copy

Serial

CMS

-XX:+UseConcMarkSweepGC -XX:-UseParNewGC。该组合不推荐使用。

老年代收集器: ConcurrentMarkSweep

新生代收集器: Copy

ParNew

Serial Old

 

-XX:+UseParNewGC。该组合不推荐使用。

老年代收集器: MarkSweepCompact

新生代收集器: ParNew

ParNew

CMS+Serial Old

-XX:+UseConcMarkSweepGC。Serial Old作为CMS “Concurrent Mode Failure”失败后备收集器。

老年代收集器: ConcurrentMarkSweep

新生代收集器: ParNew

Parallel Scanvenge

Serial Old

-XX:+UseParallelGC。

老年代收集器: MarkSweep

新生代收集器: PS Scavenge

Parallel Scanvenge

Parallel Old

-XX:+UseParallelOldGC。Server模式默认组合。

老年代收集器: PS MarkSweep

新生代收集器: PS Scavenge

G1

G1

-XX:+UseG1GC

老年代收集器: G1 Young Generation

新生代收集器: G1 Young Generation

---------------------- 正文结束 ------------------------

长按扫码关注微信公众号

Java软件编程之家

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值