JVM篇之运行时数据区

JVM篇之运行时数据区

前面已经说过类的加载过程中装载阶段,其实就是通过二进制流读取class,既然已经读取,那么肯定就需要进行存放,那类中变量、方法等具体要放在什么位置?在JVM运行时数据区中是怎么划分的?根据什么划分?每片区域有什么特点?带着这些问题,,往下看。

在说JVM之前,先说下JMM,这两个东西很多情况下容易搞混,百度的时候很多看着是说JMM的,点进去发现其实是讲JVM的。

JMM内存模型

前置知识:在java中每个线程与与操作系统的线程是一一对应的。

JMM是什么?Java Memory Model,简称JMM,指的是java的内存模型。是一种思想,一种概念,一种规范。它屏蔽了各种硬件和操作系统的访问差异的(不同CPU采用的缓存一致性协议也不相同),保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

主要包括规范:

  • 所有的变量都存储在主内存(Main Memory)中。
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
  • 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
  • 不同的线程之间无法直接访问对方本地内存中的变量。

而这些规范在JAVA中则被抽取出来了以下特性:

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

总结来说,JMM规范目的是为了解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

了解:同时为了精准控制工作内存和主内存间的交互,JMM 还定义了八种操作:lock, unlock, read, load,use,assign, store, write。

  • lock:锁定。作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock:解锁。作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read:读取。作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load:载入。作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use:使用。作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign:赋值。作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store:存储。作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write:写入。作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

了解了什么是JMM后,接下来我们再去看看JVM。

JVM(Java Virtual Machine)

从单词上可以看出,JVM整体指的是虚拟机,看下官网介绍的架构:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m1rAPOEU-1666976153144)(D:\personal\日常心得\jvm\系列\img\JVM架构.png)]

下面再放一张大家比较常见的架构图:

img

好吧, 我们最关注的是 运行时数据区(Runtime Data Areas),我们只想知道面试要问的。

运行时数据区

线程共享区域:堆,方法区,线程私有区域:虚拟机栈、本地方法栈、PC寄存器,,这句话已经不想再说了。

区域组成
方法区-Method Area
概念

方法区是各个线程共享的内存区域,在虚拟机启动时创建,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来,主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

tip:回到类加载装载阶段第二步,主要做的就是将类中的静态存储结构转换为方法区的运行时数据结构。也就是说在装载阶段,静态数据结构被存放在了方法区中。

同时要注意的是方法区指的只是一片区域,在JDK1.8之前用的是永久代实现,在1.8之后指的元空间。

在1.8之前永久代其实是堆的一部分,和新生代、老年代地址是连续的,因此也受垃圾回收器管理,但在1.8的实现中,用元空间的概念替代了永久代,同时原来永久代中存放的静态变量和常量池移动至堆中,元空间中保存类的元数据,元空间的最大空间转变为物理机的最大内存,同样的因为不在堆内存中,不再受垃圾管理器管理(但同样可能导致OOM发生,只是这个频率降低了很多,正式因为之前的PermGen区域出现OOM概率比较大,因此1.8之后使用元空间代替原来的永久代,另外一个原因是为了和JRockit 更好的融合)。

方法区设置参数
  • 元数据区大小可以使用参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 指定,替代上述原有的两个参数
  • 默认值依赖于平台。Windows 下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspacaSize 的值是 -1,即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
  • -XX:MetaspaceSize :设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过 MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。
堆(Heap)
概念

(1)Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。

(2)Java对象实例以及数组都在堆上分配。

tip:此时再回头看类装载阶段的第三步:在堆中生成class对象,作为对方法区中的数据访问入口。

堆被所有的线程共享,此内存区域唯一的目的就是存放对象实例,几乎所有的对象实例和数据都在这里分配内存。此区域也是垃圾回收的重点区域,我们后续说的垃圾回收大部分也都是针对堆的。同时为了高效的回收内存,虚拟机把堆内存逻辑划分为3个区域(划分区域的目的也是为了可以高效的进行垃圾回收,优化GC效率):

**新生代:**新创建的对象和未达到老年代年龄的对象都在新生代。

老年代:被长时间使用的对象,老年代的内存空间应该要比年轻代更大。(与新生代比例:1new:2old,通过–XX:NewRatio参数调整

Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx-Xms 控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

堆内存大小设置

-Xmx 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
-Xms 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

通常情况下降这两个值设置为一样大小,避免内存扩展(为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能)。

默认情况下,初始堆内存大小为:电脑内存大小/64

默认情况下,最大堆内存大小为:电脑内存大小/4

//返回 JVM 堆大小
long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
//返回 JVM 堆的最大内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
新生代(Young Generation)

新生代是所有对象创建的地方。这一区域的垃圾回收称为Minor GC,也叫Y(young)GC,这一区域垃圾回收的频率是最高的。新生代在逻辑也分为3个区域:Eden Memory和两个Survivor Memory(也被称为from/to或s0/s1)。

新生代中三个区域默认比例是8:1:1,可以通过-XX:SurvivorRatio参数进行配置

若在JDK 7中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄

此时 –XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy

在 JDK 8中,不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划

在一个对象被创建后,一般会先进入Eden区(根据大小来进行判定,一些特殊的大对象会直接分配到老年代),如果此时eden区内存不足,会先进行一次GC,对整个新生代进行一次垃圾回收。在此次垃圾回收过程中,S0区域中幸存的对象被移动至S1区域,同时移动的对象年龄+1,同时S1区域中幸存的对象年龄+1,同时判断对象年龄是否达到最大限制(可通过-XXMaxTenuringThreshold设置,最大15),超过最大限制,则将对象移动至老年代区域。移动完毕后,清空S0区域和eden区域,继续尝试进行对象内存分配。同时ServicorS1 和 ServicorS0 互换,原 ServicorS1 成为下一次 GC 时的 ServicorS0区。

思考:

1、为什么XXMaxTenuringThreshold最大只能设置15?

参考对象头结构,是用4位来表示对象年龄,2的4次方为16,从0开始计算,最大为15。

2、对象年龄一定要到15才会进入老年代么?这个问题也可以归纳为什么时候对象会进入老年代?

​ 2.1、对象年龄到达阈值后进入老年代

​ 2.2、大对象直接进入老年代。

​ 思考:多大的对象算大?

​ 可以通过JVM参数(-XX:PretenureSizeThreshold=字节数)进行设置,但是要注意该参数设置只有在垃圾回收器是串行回收器和ParNew才有效,其他垃圾回收器不支持该参数。可以说现在基本上这个参数已经GG,因为这两种垃圾回收器基本上都不会用了……

为什么要直接进入老年代?

大对象需要连续的内存空间,而新生代为了安放大对象可能需要多次进行GC,增加开销;

新生代种伊甸园区和幸存者区常采用复制算法,需要经常复制对象到不同的区域,而大对象在复制时开销较大。

2.3、动态地根据对象地年龄以及新生代空间使用情况选择对象进入老年代。

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

那这句话怎么理解?其实就是加入在新生代中对象年龄从1加到N,此时对象总大小已经大于Survivor空间的一半,就将年龄大于n的对象转移至老年代中。当然这个百分比可以通过参数(-XX:TargetSurvivorRatio)进行调整。

2.4、YGC后的存活对象太多,无法放入S0/S1区中

这个也很好理解,经过YGC后,对象是要放入S0或者S1区域中的,但是此时S0或者S1的大小还是放不下存活的对象,此时这些存活的对象将直接进入老年代中。

2.5、老年代空间分配担保机制

如果YGC后新生代还是放不下存活的对象,此时将进入老年代,但是如果此时老年代剩余空间也不够存放怎么办?

首先,在执行任何一次Minor GC之前,JVM都会先检查一些老年代可用的内存空间,是否大于年轻代所有对象的总大小。

为什么呢?因为最极端的情况下,可能年轻代Minor GC之后,所有对象都存活下来了,那岂不是年轻代所有对象全部进入老年代?如果说发现老年代内存大小是大于年轻代所有对象的,此时就可以放心大胆地对年轻代发起一次Minor GC了。

但是假如执行Minor GC之前,发现老年代的可用内存已经小于了年轻代的全部对象大小了,恰好这个时候Minor GC之后年轻代的对象全部存活下来,全部需要转移到老年代中去,但是老年代内存空间又不够?

所以要在执行YGC之前,先检查老年代空间是否足够,JDK1.7之前要设置参数(-XX:HandlePromotionFailure:是否设置空间分配担保),在1.7之后该参数不会影响到虚拟机的空间担保策略,只要老年代的连续空间大于新生代对象总大小或则历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

进行YGC后可能会出现的情况:

1)Minor GC过后,剩余的存活对象的大小,小于Survivor区的大小,那么此时存活对象进入Survivor区即可

2)Minor GC过后,剩余的存活对象的大小,大于Survivor区的大小,但是小于老年代可用内存大小,就直接进入老年代即可

3)Minor GC过后,剩余的存活对象的大小,大于Survivor区的大小,同时大于老年代可用内存大小,此时就会发生“Handle Promotion Failure”的情况,这个时候就会出发一次“Full GC”。

如果FULL GC后空间还是不够怎么办?—>OOM

3、为什么需要Survivor区?只有Eden不行吗?

如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。 这样一来,老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。 老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。 频发的Full GC消耗的时间很长,会影响大型程序的执行和响应速度。

所以Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

4、为什么需要两个Survivor区?

最大的好处就是解决了碎片化。也就是说为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设现在只有一个Survivor区,刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor 区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的, 也就导致了内存碎片化。

永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。

5、新生代中Eden:S1:S2为什么是8:1:1?

新生代中的可用内存:复制算法用来担保的内存为9:1。可用内存中Eden:S1区为8:1 ,即新生代中Eden:S1:S2 = 8:1:1 。

现代的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象大概98%是 “朝生夕死”的 。

6、堆内存中都是线程共享的区域吗?

JVM默认为每个线程在Eden上开辟一个buffer区域,用来加速对象的分配,称之为TLAB,全称:Thread Local Allocation Buffer。

对象优先会在TLAB上分配,但是TLAB空间通常会比较小,如果对象比较大,那么还是在共享区域分配。

TLAB(Thread Local Allocation Buffer):

线程本地分配缓冲区。从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计。尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。


老年代(Old Generation)

这一部分区域垃圾回收称为major GC(FULL GC),通常这一过程需要更多的时间,FULL FC通常也会带动minor GC。

大对象也可能会直接进入该区域(大对象指得是需要大量连续内存空间的对象),这样做的目的是为了避免在Eden区和两个S区域进行大量的内存拷贝。

虚拟机栈(Java Virtual Machine Stacks)

每个线程在创建的时候都会创建一个私有化的虚拟机栈,其内部存储一个个的栈帧,每个栈帧都对应一个执行的java方法,其生命周期和线程一致。

栈的操作只有两个:入栈:方法执行;出栈:方法结束。

因为虚拟机栈的生命周期和线程一致,所以不存在内存回收问题,JVM规范中允许虚拟机栈的大小是固定的或者动态扩展的,但该区域可能存在异常:

如果虚拟机栈采用的是固定大小,即在线程创建的时候指定虚拟机栈的容量,如果线程请求分配的栈容量超过指定值,java虚拟机将抛出 StackOverflowError 异常。

如果采用的是动态扩展的形式,如果新的线程在创建栈的时候内存不够,或者在扩展的时候申请内存不够,将抛出OOM异常。

可以通过-Xss参数指定虚拟机栈的大小。但hostpot并没有实现栈空间的动态扩展,默认栈空间64位系统中是1M。

可以通过java -XX:+PrintFlagsFinal -version | grep ThreadStackSize 查看默认栈空间大小。


本地方法栈(Native Method Stack)

java虚拟机栈用于java方法的调用,而本地方法栈用于管理本地方法(native)的调用。

为什么要有本地方法?

与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。通过本地方法我们可以实现用java操作底层系统交互,本地方法是通过c语言实现的。

PC(Program counter)计数器

硬件层面CPU中也是有计数器的(寄存器),寄存器中存储的是指令相关的线程信息,在JVM中PC计数器主要存储指向下一条指令的地址,即将要执行的指令代码,由执行引擎进行读取。

PC计数器是一块占用内存很小的区域(几乎可以忽略不计),在JVM规范中,每个线程都有自己的一个程序计数器,是线程私有的,生命周期和线程生命周期一致。任何时间一个线程只有一个方法正在运行,称之为当前方法,如果线程运行的是java方法,PC计数器存储的是指令地址,如果运行的是native方法,此时存储的是undefined。

PC计数器是唯一一个在JVM规范中指定不会出现OOM(OutOfMemoryError)情况的区域。


垃圾回收器

首先要明确的一点是只有jvm中的堆才会发生垃圾回收,但是垃圾回收并不一定只回收对象,也会回收一些废弃的常量和不再使用的类型(这些是在方法区中存放,可参考类卸载过程),整句话其实应该是只有堆才会进行垃圾回收,但垃圾回收作用的位置包括堆、方法区。

怎样判断对象是垃圾

判断一个对象是否是垃圾,常用的两种判断算法:引用计数器和GCRoots可达性分析法

引用计数法

即给每个java对象增加一个引用计数器,只要有引用该对象的,计数器+1,引用失效,计数器-1,虽然这种算法实现简单,判断高效,但这种算法无法解决的问题之一是多个对象之间的相互引用,同时这几个对象作为一个整体又没有被其他对象引用,导致无法被垃圾回收。

GCRoots可达性分析

就是以GCRoots对象作为起点,向下搜索,所有引用GCRoots的即为存活对象,不能被搜索到的标记为垃圾。

能作为GC Root的都有哪些?

类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等。

总结:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。 因为虚拟机栈的周期同线程周期一致,所以虚拟机栈中引用的对象可以简单理解为我们的程序正在使用的对象,因为所有包含的对象都不应该被回收。
  • 方法区中类静态属性引用的对象。 方法区中我们知道保存的是类的元数据, 那么我们定义的一些static修饰的对象引用,也不能被回收。
  • 方法区中常量引用的对象。 就是使用了static final修饰的一些对象引用,也可以作为GCRoots对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。这一部分区域的对象都是用来操作系统底层逻辑的。

可达性分析具体步骤

**1、可达性分析。**当一个对象到GCRoots中没有任何引用时,判断对象为不可达。这里仅仅是判断对象为不可达对象,并代表对象可以被回收。

**2、第一次标记筛选。**对象被判断为不可达之后,进行第一次标记和筛选。筛选的标准是对象是否实现了自身的finalize()方法,如果没有或者finalize()方法已经被执行过,则视为《没必要执行》,判断该对象死亡,如果有准备进入第二次标记和筛选。

**3、第二次标记筛选。**其实就是执行finalize()方法(只会被执行一次),在执行过程中如果依然没有与GCRoots建立连接,则判断对象死亡,等待回收。


什么时候进行垃圾回收

GC是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。当然,我们可以手动进行垃圾回收,比如调用System.gc()方法通知JVM进行一次垃圾回收,但是具体什么时刻运行也无法控制。也就是说System.gc()只是通知要回收,什么时候回收由JVM决定。但是不建议手动调用该方法,因为GC消耗的资源比较大


垃圾收集算法
Mark-Sweep(标记-清除算法)
  • 标记

找出内存中需要回收的对象,并且把它们标记出来

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kS2KQlwl-1666976153147)(D:\personal\日常心得\jvm\系列\img\标记清楚-标记.png)]

此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时

  • 清除
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2KZwRBVi-1666976153147)(D:\personal\日常心得\jvm\系列\img\标记清除-清除.png)]

    清除掉被标记需要回收的对象,释放出对应的内存空间

缺点

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

(1)标记和清除两个过程都比较耗时,效率不高

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

Mark-Copying(标记复制算法)

这种算法其实就是将可用的内存空间分为两部分,每次只使用其中的一块内存。当一块内存使用完了,将里面的对象复制到另外一块内存中,然后把使用过的内存空间一次性全部清除。这样虽然不用考虑内存碎片的问题,只需要按照顺序分配内存,实现简单,运行高效,但问题是可使用的内存空间缩小为原来的一半,空间利用率低,还需要额外的移动数据,而且存活对象较多的时候,效率低下。

标记-整理(Mark-Compact)

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cVMF3GIA-1666976153148)(D:\personal\日常心得\jvm\系列\img\标记整理-标记.png)]

让所有存活的对象都向一端移动,清理掉边界意外的内存。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2psaNViJ-1666976153149)(D:\personal\日常心得\jvm\系列\img\标记整理-整理)]

这种算法虽然不容易产生内存碎片,内存利用率高,但在存活对象多而且分散的情况下,移动次数较多,影响效率。

分代收集算法

前面已经说了三种算法,那JVM中究竟是用哪种呢?

**Young区:**标记复制算法(对象在被分配之后,可能生命周期比较短,Young区复制效率比较高)

**Old区:**标记清除或标记整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)

垃圾收集器

上面介绍了垃圾收集算法,收集算法只是一种算法,还是要具体落地实现的,垃圾收集器就是算法的一种具体实现。

先来看一张收集器对应关系:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I4ggA39D-1666976153150)(D:\personal\日常心得\jvm\系列\img\垃圾收集器对应关系.png)]

Serial

简单说一下这个,基本上知道有这个就行。

这是JDK最早版本使用的单线程收集器。在此GC线程工作时,必须暂停其他所有的工作线程(STW:stop the world)直到GC结束。

优点:简单高效,拥有很高的单线程收集效率

缺点:收集过程需要暂停所有线程

算法:复制算法

适用范围:新生代

应用:Client模式下的默认新生代收集器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CFU6rx0l-1666976153150)(D:\personal\日常心得\jvm\系列\img\Serial收集器.png)]

Serial Old

Serial 老年代版本,工作原理和方式与Serial一致,同样是单线程收集器,但在老年代采用的是 标记-整理算法。

注意:

  1. 在JDK1.5之前可以与PS收集器搭配使用。
  2. 作为CMS在老年代收集器的备用方案,在CMS并行收集发生Concurrent Mode Failure时启用Serial Old收集器,这也是CMS收集器的问题之一,因为一旦转为Serial Old收集器,收集效率和收集时间将会大幅度增加。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-skdwT35k-1666976153151)(D:\personal\日常心得\jvm\系列\img\serial_old)]

ParNew

可以看做是Serial收集器的多线程版,Serial是开启单线程进行GC,也就是只有一条GC线程,ParNew则是开启多条GC线程,缩短了GC时间,但同样的会造成STW,但它是CMS收集器唯一可以进行协作的新生代收集器。

优点:在多CPU时,比Serial效率高。

缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差。

算法:复制算法

适用范围:新生代

应用:运行在Server模式下的虚拟机中首选的新生代收集器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NxyeA3W8-1666976153152)(D:\personal\日常心得\jvm\系列\img\ParNew.png)]

Parallel Scavenge

属于是新生代的垃圾收集器,采用的仍然是复制算法,是一款多线程并行收集器。但与ParNew不同的是ParNew更关注与缩短GC的时间,而PS更关注于系统吞吐量,目标是达到一个可控的吞吐量。

吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)

比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%。若吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用CPU资源,尽快完成程序的运算任务。

1. -XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间。可以把虚拟机在GC停顿的时间控制在MaxGCPauseMillis范围内,如果希望减少GC停顿时间可以将MaxGCPauseMillis设置的很小,但是会导致GC频繁,从而增加了GC的总时间,降低了吞吐量。
2.-XX:GCTimeRatio:设置吞吐量大小,它是一个从0100之间的整数,默认情况下他的取值为99,那么系统将花费不超过1/1+n)的时间用于垃圾回收,也就是1/1+99=1%的时间。
3. GC自适应调节策略:Parallel也称为“吞吐量优先”收集器,此外Parallel还有一个开关参数(-XX:+UseAdaptiveSizePolicy),当这个开关打开,就不需要手动指定新生代的大小、EdenSurvivor区的比例、晋升老年代对象大小等,虚拟机会根据当前系统运行情况,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量,这就是GC自适应调节策略。
Parallel Old

Parallel Old (PO)是Parallel Scavenge(PS)在老年代区域的多线程版本,采用的是标记-整理算法,主要与PS配合使用在注重吞吐量及CPU敏感的系统内使用。

CMS(Concurrent Mark Sweep)

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器,又称多并发低暂停的收集器。基于 标记-清除 算法的实现。

  1. 初始标记(CMS initial mark):仅标记GC Root能直接引用的对象,速度快,但仍需STW。
  2. 并发标记(CMS concurrent mark: GC Roots Tracing过程): 进行GC Roots追踪过程,利用多线程对每个GC Root对象进行tracing搜索,在堆中查找其下所有能关联到的对象。
  3. **重新标记(**CMS remark):为了修正并发标记期间,用户程序继续运作而导致标志产生变动的那一部分对象的标记记录,同样仍需STW。
  4. 并发清除(CMS concurrent sweep):利用多个线程对标记的对象进行清除,注意这里没有使用整理(压缩),可能会产生内存碎片。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZxhzcyTQ-1666976153152)(D:\personal\日常心得\jvm\系列\img\CMS.png)]

优点:并发收集、低停顿

缺点

  1. **CMS默认启动线程数是(CPU核数+3)/4。**这就意味着CPU核数越少,GC线程占用的CPU资源越多,从而导致总吞吐量降低,系统变慢。可以通过-XX:ParallelCMSThreads设置CMS的线程数量,但一般设置为电脑CPU数量。

  2. **无法处理浮动垃圾。**浮动垃圾指的是在并发清除阶段用户线程产生的新垃圾,因为在并发清除阶段,用户线程同样在运行,就需要给用户线程预留足够的内存空间,导致CMS收集器并不能等到老年代空间满了在进行垃圾回收,因此JVM提供了-XX:CMSInitiatingOccupancyFraction参数来设置GC触发的百分比和XX:+UseCMSInitiatingOccupancyOnly参数 设置是否启用该触发百分比,启用该百分比,意味着老年代使用空间达到百分比时就触发CMS垃圾回收(JDK1.6之后默认92%),但是当进行并发清除阶段,如果预留的空间不够用户线程使用,就会出现Promotion Failure等失败。这时候JVM将启用备选收集器方案,临时采用Serial Old收集器进行老年代垃圾回收,这是不能接受的。

  3. **可能产生内存碎片。**因为CMS清除阶段采用的是标记—清除算法,不可避免的可能会产生内存碎片,导致可能无法分配大对象而提前触发Full GC,因此CMS提供了-XX:+UseCMSCompactAtFullCollection开关参数,用于在进行一次FULL GC后再执行一个碎片整理过程,但这个阶段是STW的,因此可能造成停顿时间过长,因此CMS又提供了-XX:CMSFullGCsBeforeCompaction参数,指定在进行多少次FULL GC后再进行内存碎片整理(默认0,即每次清除后进行一次整理)

G1

G1收集器是在JDK1.7之后引入的,与其他收集器不同的是,G1收集器拥有自己独有的垃圾回收策略,G1虽然属于分代垃圾回收器,区分老年代和新生代,但它并不要求新生代和老年代的区域必须是连续的,因为它同时采用了分区算法,将整个Java堆划分为多个大小相等的独立区域(Region:-XX:G1HeapRegionSize=_M设置每个region大小)。

G1垃圾收集器的设计原则是“首先收集尽可能多的垃圾(Garbage First)——其实就是优先回收垃圾最多的Region区域”,目标是为了尽量缩短处理超大堆(超过4GB)产生的停顿。因此,G1并不会等内存耗尽(比如Serial 串行收集器、Parallel并行收集器 )者快耗尽(CMS)的时候才开始垃圾回收,而是在内部采用了启发式算法,在老年代中找出具有高收集收益的分区(Region)进行收集。同时 G1 可以根据用户设置的STW(Stop-The-World)停顿时间目标(响应时间优先)自动调整年轻代和总堆的大小,停顿时间越短年轻代空间就可能越小,总堆空间越大。

G1空间分布:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U7Q5qNoS-1666976153153)(D:\personal\日常心得\jvm\系列\img\G1.png)]

G1垃圾回收步骤:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UZO457bM-1666976153154)(D:\personal\日常心得\jvm\系列\img\G1垃圾回收.png)]

  1. 初始标记(Initial Marking)【STW】。暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快。(同CMS)
  2. 并发标记(Concurrent Marking)。并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象集合的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。(同CMS)
  3. 重新标记/最终标记(Final Marking)【STW】。重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录**,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。(同CMS)
  4. 筛选回收(Live Data Counting and Evacuation)【STW】。筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划

**举个例子:**比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,通过回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在我们指定的范围内。

这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

回收算法:不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中。

**PS:**这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。

**PS:**CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本

筛选回收如何实现?:G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region。比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。

特点:

  1. 并行与并发:G1能充分利用多CPU、多核环境使用多个CPU或CPU核心来缩短STW(Stop-The-World)停顿时间。
  2. 分代GC:G1物理上不分代,但逻辑上仍然有分代的概念。
  3. 空间整理:G1 在回收过程中,不会像CMS那样在若干次GC后需要进行碎片整理,G1采用了有效复制对象的方式,减少空间碎片。
  4. 可预见性:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型【后台维护的优先列表】,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数**-XX:MaxGCPauseMillis**指定)内完成垃圾收集。 默认的停顿目标为两百毫秒,一般不需要调整。如果我们把停顿时间调得非常低,譬如设置为二十毫秒, 很可能每次只能回收很小的一部分内存, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能

ZGC

JDK11新引入的ZGC收集器,不管是物理上还是逻辑上,ZGC中已经不存在新老年代的概念了会分为一个个page,当进行GC操作时会对page进行压缩,因此没有碎片问题。

  1. 可以达到10ms以内的停顿时间要求
  2. 支持TB级别的内存
  3. 堆内存变大后停顿时间还是在10ms以内
垃圾收集器分类
串行收集器

Serial和Serial Old

只能有一个垃圾回收线程执行,用户线程暂停。适用于内存比较小的嵌入式设备 。

并行收集器[吞吐量优先]

Parallel Scanvenge、Parallel Old

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

初始标记(Initial Marking) 标记以下GC Roots能够关联的对象,并且修改TAMS的值,需要暂停用户线程

并发标记(Concurrent Marking) 从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行

最终标记(Final Marking) 修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂 停用户线程

筛选回收(Live Data Counting and Evacuation) 对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。适用于科学计算、后台处理等若交互场景 。

并发收集器

[停顿时间优先]->CMS、G1

用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的时候不会停顿用户线程的运行。

适用于相对时间有要求的场景,比如Web 。

G1垃圾收集分类

YoungGC
  YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。

PS:所以G1垃圾收集器刚开始年轻代只占堆内存百分之5,会随着每次计算回收时间而增加,最多不超过百分之60。

如果仅仅GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set,作用是跟踪指向某个heap区内的对象引用。

在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。

但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可

需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在G1 中又引入了另外一个概念,卡表(Card Table)。一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址。默认情况下,每个卡都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外RSet也将这个数组下标记录下来。一般情况下,这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

Young GC 阶段:

  • 阶段1:根扫描
    静态和本地对象被扫描
  • 阶段2:更新RS
    处理dirty card队列更新RS
  • 阶段3:处理RS
    检测从年轻代指向年老代的对象
  • 阶段4:对象拷贝
    拷贝存活的对象到survivor/old区域
  • 阶段5:处理引用队列
    软引用,弱引用,虚引用处理

MixedGC【混合收集】
  不是FullGC,老年代的堆占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值则触发,回收所有的年轻代和部分老年代(根据筛选回收阶段计算优先级后排序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC。

Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

它的GC步骤分2步:

  1. 全局并发标记(global concurrent marking)
  2. 拷贝存活对象(evacuation)

在进行Mix GC之前,会先进行global concurrent marking(全局并发标记)。 global concurrent marking的执行过程是怎样的呢?

在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。global concurrent marking的执行过程分为五个步骤:

  • 初始标记(initial mark,STW)
    在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关。
  • 根区域扫描(root region scan)
    G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
  • 并发标记(Concurrent Marking)
    G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断
  • 最终标记(Remark,STW)
    该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。
  • 清除垃圾(Cleanup,STW)
    在这个最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

Full GC
  停止系统程序,然后采用单线程进行标记、清理、压缩整理以空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值