JVM解析及GC介绍

本文探讨了JVM内存结构(堆、元空间、虚拟机栈、本地方法栈)及其作用,重点讲解了类加载过程和逃逸分析。此外,文章详细剖析了引用计数法和可达性分析算法在垃圾回收中的应用,以及不同类型的GCRoots和G1收集器的特点。
摘要由CSDN通过智能技术生成

JVM

内存模型

元空间,MetaSpace

虚拟机栈

本地方法栈

程序计数器

类加载过程

加载

连接

校验
准备
解析

初始化

类加载机制

双亲委派机制

  • 优点
  • 打破双亲委派机制

逃逸分析

问题:所有的对象都在堆内存里吗?答案显然是否定的,一些只在当前线程中使用的对象,会直接放在栈内存中,那么如何判断一个对象只在当前线程中使用呢?这就需要用到逃逸分析了。逃逸分析的主要作用就是分析新创建出来的对象是否逃逸出了当前栈的使用范围,它的依据有两个:
1、对象被赋值给了堆上对象的变量或者类的静态变量,被其他内存空间中的对象所引用了,那肯定发生了逃逸;
2、对象被传进了不确定的代码中去运行,最简单的场景就是作为方法的返回值返回了,因为不确定后续还会在哪里用到这个对象,所以保守一点,还是分配到堆空间吧。
看待代码:

public class EscapeTest {

	// 静态变量
    public static Object globalVariableObject;
    
    public Object instanceObject;

    public void globalVariableEscape(){
    	// new Object()发生了逃逸,赋值给了静态变量
        globalVariableObject = new Object(); 
    }

    public void instanceObjectEscape(){
    	// new Object()发生了逃逸,赋值给了对象变量
        instanceObject = new Object(); 
    }
    
    public Object returnObjectEscape(){
    	// new Object()发生了逃逸,作为了返回值,其他线程可能会访问
        return new Object();  
    }

    public void noEscape(){
    	// new Object()没有发生逃逸,仅在当前方法中,这个对象会存放在栈空间上
        Object noEscape = new Object(); 
    }

}

GC

判定垃圾的方法

一般判断一个对象是否是垃圾对象有两种方法:引用计数法和可达性分析算法,引用计数法简单但是无法解决循环引用的问题,所以一般都是使用可达性分析算法。

引用计数法

  • 实现
    引用计数法实现比较简单,在对象头部添加一个字段,用来记录该对象被引用的次数,当该对象被引用时就加1,当该对象的引用失效时就减1,在发生GC的时候,只需要判断这个字段是否等于0就知道这个对象是否应该被回收了;

  • 优缺点
    优点:实现简单,高效;
    缺点:无法解决循环引用的问题,举个例子:对象A和对象B相互引用,但是除此之外这两个对象就没有其他被引用的地方了,这两个对象实际上应该都是垃圾对象,但是因为他们相互引用,引用次数的值是大于0的,所以它们两个都不会被回收掉。

可达性分析算法

  • 实现
    1、确定一系列GC Roots对象;
    2、以这些GC Roots对象为起点,通过它们的引用关系向下检索;
    3、检索到的对象就标记为非垃圾对象,到最后未被标记的对象就是需要被回收的垃圾对象了;
GC Roots

在可达性分析算法中,确定GC Roots对象是最重要的一步,哪些对象可以成为GC Roots呢?

  • 线程栈的本地变量(正在执行方法中的变量)
    看下代码:
public class TestGCROOT01 {

    private int _10MB = 10 * 1024 * 1024;
    private byte[] memory = new byte[5 * _10MB];

    public static void main(String[] args) {
        method01();
        System.out.println("--------------");
        System.out.println("第二次gc");
        System.gc();
    }

    public static void method01(){
        TestGCROOT01 test = new TestGCROOT01();
        System.out.println("第一次gc");
        System.gc();
    }
}

执行结果:

第一次gc
[GC (System.gc()) [PSYoungGen: 74793K->52545K(458752K)] 74793K->52553K(983040K), 0.0203420 secs] [Times: user=0.05 sys=0.00, real=0.02 secs] 
[Full GC (System.gc()) [PSYoungGen: 52545K->0K(458752K)] [ParOldGen: 8K->52477K(524288K)] 52553K->52477K(983040K), [Metaspace: 3091K->3091K(1056768K)], 0.0247470 secs] [Times: user=0.02 sys=0.03, real=0.02 secs] 
--------------
第二次gc
[GC (System.gc()) [PSYoungGen: 7864K->32K(458752K)] 60341K->52509K(983040K), 0.0033736 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 32K->0K(458752K)] [ParOldGen: 52477K->1277K(524288K)] 52509K->1277K(983040K), [Metaspace: 3091K->3091K(1056768K)], 0.0098179 secs] [Times: user=0.00 sys=0.01, real=0.01 secs] 
Heap

从执行结果中可以看到:第一次执行gc的时候,因为方法method01正在执行(在虚拟栈中),并且它有一个TestGCROOT01对象test 的引用,所以test对象没有被回收还被放到了老年代中;但是第二次执行gc的时候,method01方法已经执行完了(退出了虚拟栈),没有对象再引用test对象了,这个时候test对象就被判定成垃圾对象回收掉了,老年代占用空间就从52509K降到了1277K;

  • 类的静态变量
    代码:
public class TestGCROOT02 {
    private static int _10MB = 10 * 1024 * 1024;
    private byte[] memory;

    private static TestGCROOT02 t;

    public TestGCROOT02(int size){
        memory = new byte[size];
    }

    public static void main(String[] args) {
        TestGCROOT02 test = new TestGCROOT02(4 * _10MB);
        t = new TestGCROOT02(8 * _10MB);
        test = null;
        System.gc();
    }
}

结果:

[GC (System.gc()) [PSYoungGen: 146473K->1409K(458752K)] 146473K->83337K(983040K), 0.0540357 secs] [Times: user=0.11 sys=0.02, real=0.05 secs] 
[Full GC (System.gc()) [PSYoungGen: 1409K->0K(458752K)] [ParOldGen: 81928K->83196K(524288K)] 83337K->83196K(983040K), [Metaspace: 3089K->3089K(1056768K)], 0.0187061 secs] [Times: user=0.05 sys=0.00, real=0.02 secs] 

因为test=null,所以new TestGCROOT02(4 * _10MB)出的对象会被回收调,它的属性memory也会被回收掉,但是因为t属性是静态属性,它是属于类的,所以new TestGCROOT02(8 * _10MB)的对象并不会被回收(因为有t的引用);

  • 本地方法栈中的变量
    这个和方法栈中的变量含义是一样的,只是一个是我们自己写的普通方法,一个是Java自带的本地方法。

  • 总结
    总结下就是有两类变量(或者说是变量指向的对象)会成为GC Roots:
    1、正在执行的方法中的变量;
    2、类方法的静态变量;
    正在执行的方法分为普通方法和本地方法。

finalize()方法

在可达性分析算法中,如果一个对象被判定为不可达也不是一定会被回收,只要这个对象重写了finalize()方法,并且在finalize()方法中有了新的引用,那么它就可以不用被回收了,具体过程:
1、垃圾对象是否重写了finalize()方法,且finalize()没有被执行过;
2、如果两个条件都满足,那这个垃圾对象会被添加到一条名为F-Queue的队列中去;
3、虚拟机会创建一个低优先级的Finalizer线程去执行这个队列里所有对象的finalize()方法;
4、如果在执行这个方法的过程中,对象又有了先得引用,那它就可以不用被回收了;
5、Finalizer线程并不承诺完全执行完finalize()方法,因为这个方法里可以会出现执行时间过长,甚至死循环的情况,这样就会阻塞后面的执行;
6、如果垃圾对象到最后都没有新的引用,那它就会被回收掉了。

清理垃圾的方法

知道了哪些对象是垃圾对象之后,就需要把这些垃圾对象给清理掉了,清理垃圾的方法大概有几种方法

标记-清除算法

这个算法很简单,分为两部分:标记和清除;
标记:标记出哪些对象是垃圾对象,使用的方法就是我们上面介绍的可达性分析算法;
清除:清除标记为垃圾对象的对象,释放内存;
这个算法的速度很快,但是有个很明显的不足:会产生大量的内存碎片,造成内存浪费,所以一般都不会使用这种算法了。

复制算法

将内存分为两块,只使用其中的一块,GC的时候,将存活的对象复制到另外未使用的一块内存中去,然后就这块使用的内存中的对象全部清理掉;
优点:不会产生内存碎片;
缺点:会有一次对象复制的过程,如果存活的对象较多,效率就会很低了;内存利用率不高,日常只能使用一半的内存空间;
因为年轻代中的对象都是朝生夕死的,可以存活下来的对象很少,所以一般年轻代都会使用这种算法。

标记-整理算法

这种算法是为了解决标记-清除算法中的内存碎片问题,同时也为了避免复制算法中只能使用一半内存的问题;
它前面的部分和标记-清除算法是一样的,只是清除的时候不是直接将垃圾对象清除,而是将存活下来的对象整理到一起,然后将这块内存以外的空间直接清除对象,释放内存空间。

分代收集算法

这个就是现在GC使用的算法,它是多种算法的组合,堆内存一般分为年轻代和老年代;
年轻代中的对象特点是朝生夕死,一次GC可能会清除掉大部分对象,所以年轻代使用的算法是复制算法,而为了避免复制算法只能使用一半内存的缺点,年轻代多出了两个survivor内存块用来存放存活下来的对象;
老年代中的对象特点是存活时间久,占用内存空间大,所以老年代使用的是标记-整理算法。

垃圾收集器

JVM发展这么多年,出现过很多收集器。
按工作线程类型划分可以是:
1、串行运行(单线程):Serial收集器;
2、并发运行(多线程):ParNew收集器、Parallel Scavenge收集器;
3、并行运行(和客户线程同时工作):CMS收集器、G1收集器。
按工作的区域可以划分:
1、年轻代:Serial、ParNew、Parallel Scavenge、G1;
2、老年代:Serial Old、Parallel Old、CMS、G1;
其中Serial收集器包含Serial和Serial Old,Parallel Scavenge收集器包含Parallel Scavenge和Parallel Old。

Serial收集器

Serial是最早版本的收集器了,它是串行运行的,它垃圾回收的线程是单线程的,更糟糕的是它在进行垃圾回收的时候必须STW(Stop The World),用户线程也会被阻塞住,这显然是不能接受的,所以现在基本上都不使用这种收集器了,不过因为它是单线程的,少了线程切换的过程,在运行上是非常简单高效的。

Parallel收集器

Parallel收集器是一个吞吐量优先的收集器,是针对CPU更高使用率的收集器,它还有一个自适应调节策略,可以通过-XX:+UseAdptiveSizePolicy参数来配置开启,开启这个之后我们就无需再配置新生代的大小等等,虚拟机会根据实际运行的数据来动态调节这些参数,以此达到最高的吞吐量。
缺点:Parallel收集器的工作原理非常简单粗暴,直接STW,阻塞所有用户线程,进行垃圾标记然后清理,这样会有一个很大的缺点:会有较长时间的STW,造成较长时间的业务停顿,如果内存空间较大的话,这个时间还会变长,这对于一些重视用户体验的应用显然是不用接受的。

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,它默认使用的线程数和机器的核数是相同的,也可以使用参数-XX:ParallelGCThreads来修改,它主要是为了配置CMS收集器的,CMS收集器是老年代的垃圾收集器,Parallel收集器不能配合CMS收集器作用于年轻代,所以有了ParNew收集器作用于年轻代。

CMS收集器

CMS收集器是第一款真正意义上的并行收集器,它非常注重用户体验(最短回收停顿时间,STW时间),基本上实现了GC线程和用户线程同时工作,但是实际上也是有停顿的,它的工作流程大概是:初始标记(会停顿,但是速度很快)->并发标记(不会停顿)->重新标记(会停顿,且较长时间)->并发清理(不会停顿)。相对于Parallel收集器,CMS收集器的步骤多了很多,这样做的主要目的就是减少STW时间,尽量不去阻塞用户线程,提高用户体验,看下这几步主要做了什么事情:

  • 初始标记:这一步主要是标记GC Roots的直接引用,引用的引用就先不管了,所以这一步虽然会造成STW,但是它的速度很快;
  • 并发标记:继续扫描其他引用,这个过程是最耗时的,一般会占用整个过程时间的80%,但是这个过程是并发进行的,并不会STW,对用户来说是无感知的;
  • 重新标记:在并发标记过程中,用户线程是一直在运行的,引用关系是一直在变化的,这样就可能导致并发标记过程中标记的引用不准确,重新标记就是为了修正不准确的这部分。重新标记会导致STW,且时间比初始标记的时间还要长,但是比并发标记的时间短,CMS收集器最长的STW时间就是这个阶段了;
  • 并发清理:将前面几步标记出来的垃圾进行清理,这一步也是并发进行的,不会造成STW。
优点和缺点
  • 优点:
    CMS收集器进行GC的整体时间相比Parallel收集器其实是更长的,但是造成的STW时间大大缩短了,这对于用户体验的提升是至关重要的。
  • 缺点:
    1、浮动垃圾:并发清理是并发进行的,用户线程的运行可能会在这一步产生垃圾,这部分垃圾只能等到下次gc的时候才能回收了;
    2、内存碎片:CMS采用的回收算法是标记-清理算法,这个算法虽然运行比较快,但是会造成大量的内存碎片,不过可以通过参数-XX:+UseCMSCompactAtFullCollection让JVM在完成清理之后进行内存整理,参数-XX:CMSFullGCsBeforeCompaction配置多少次Full GC之后进行内存整理;
    3、concurrent mode failure错误:因为CMS收集器进行垃圾回收是并发进行的,这个过程中用户线程是一直在运行的,如果在这个过程中恰好产生了一个大对象,老年代已经没有空间去存放这个对象了,那就会再一次触发full gc,就会触发concurrent mode failure错误了,这个时候JVM会切换成serial old收集器进行gc,会导致长时间的STW,这种情况应该是尽量避免的,可以通过参数-XX:CMSInitiatingOccupancyFraction配置老年代占用比例时进行Full GC,这是一个百分比例值,默认值是80,JVM的默认值是92;
    4、CMS默认是不会对永久代进行垃圾回收的,可以通过参数-XX:+CMSPermGenSweepingEnabled配置进行永久代的垃圾回收,参数-XX:+CMSClassUnloadingEnabled配置卸载不用的类。
三色标记
  • 黑色:对象所有的引用都扫描过了;
  • 灰色:对象还有引用没有被扫描过;
  • 白色:没有被扫描过或者不可达(垃圾对象);
  • 开始时,非GC Roots节点都是白色的,扫描结束之后所有的节点非黑即白;
  • 黑色节点不可能直接连接白色节点,因为有连接就说明有引用关系,黑色节点的含义是引用都被扫描过了,所以与黑色节点连接的只可能是黑色或者灰色;
  • 并发标记和并发清理阶段产生的垃圾默认都是黑色的,这一部分属于浮动垃圾,下次gc再做清理;
  • 在并发标记阶段,如果黑色节点引用了白色节点,那会把黑色节点变成灰色,然后在重新标记阶段重新扫描引用。
写屏障(write barrier)

在并发清理的过程中,有一个问题:因为并发清理的时候,gc线程和用户线程是同步执行的,那如果这个时候用户线程重新引用了之前未被标记的对象(垃圾对象),也就是让垃圾对象复活了怎么办?gc线程如果再去清理这部分对象肯定就会有问题,因为这部分对象已经不是垃圾对象,被重新引用了,这个时候就需要用写屏障来将引用关系记录到标记栈。
写屏障的概念有点像AOP,是在引用关系变化的前后添加操作,做到功能增强,例如:

// 这是一个赋值操作
void oop_field_store(oop* field, oop new_value) {
	*field = new_value; // 赋值操作
}
// 写屏障会在这个赋值操作前后添加操作
void oop_field_store(oop* field, oop new_value) {
	pre_write_barrier(field); // 写屏障‐写前操作
	*field = new_value;
	post_write_barrier(field, value); // 写屏障‐写后操作
}
记忆集(Remember Set)和卡表(Cardtable)

有时候会发生跨代引用,比如老年代的对象引用了年轻代的对象,虽然发生的概率很小,但是还是会发生,这样就会导致一个问题:每次minor gc的时候都需要扫描整个老年代,这效率很低,而且很没有必要,这个时候记忆集的作用就出来了。

  • 记忆集:记忆集准确地来说只是一种记录某一块非收集区域是否存在指向收集区域指针的数据结构,收集器通过记忆集就可以判断这块区域是否有跨代引用,无需了解跨代引用指针的细节;
  • 卡表:记忆集只是一个数据结构,不是具体实现,在hotspot中记忆集的具体实现是卡表,卡表使用一个字节数组实现:CARD_TABLE[],数组中的每一个元素代表着一块区域的大小,区域大小为2^9,也就是512K,只有这块区域中有一个对象跨代引用了,数组中的值就会变成1,这块区域中的所有对象都会加入到GCRoots中。卡表的维护也是通过写屏障来维护的。

G1收集器

G1收集器针对的是更大内存的垃圾回收,它在物理上已经没有了分代的概念,但是在逻辑上还是保留了分代,所以它还是一个分代收集器,它的工作原理是:

  • 将整个堆内存划分为了2048个大小相等的独立区域(Region);
  • 年轻代,老年代不在有固定的内存空间,每个Region是存放年轻代对象还是存放老年代对象是变化的;
  • 大对象不再存放到老年代,而是新增了一块Humongous区域,专门用来存放大对象,大对象的概念:大于Region块大小一半的对象,比如:堆内存大小为4G,那每一个Region块的大小就是4G/2048=2M,大对象就是大于1M的对象;
  • 大对象可能会横跨多个Region来存放;
  • 垃圾回收的过程和CMS收集器的过程很相似,只是垃圾回收的时候不一样;
  • G1整体上采用的是标记整理算法,但是从局部上看更像是复制算法,他会将一个Region块中的非垃圾对象复制到邻近的Region块中,然后将这个Region块清理掉;
  • 指定期望的停顿时间:这个是G1收集器最大的特点了,它允许用户通过-XX:MaxGCPauseMillis参数来指定用户希望的最大GC停顿时间;
  • 为了满足这个期望的停顿时间,G1不会一次清理所有的Region块,而是根据优先级一次清理部分Region块。G1会记录每个Region块的回收时间、记忆集中的脏卡数量(跨代引用数量)等等信息,然后计算出Region块的衰减平均值,根据这个衰减平均值来确定Region块回收的优先级;
  • 年轻代默认占堆内存的5%,也就是差不多100个Region块,但是这个占比是不断变大的,最大不能超过60%;
  • G1有三种垃圾收集:Young GC、Mixed GC和Full GC;
  • Young GC相当于CMS的minor GC,只会清理年轻代的Region,但是Young GC不是年轻代的Region装满了就会触发,而是它装满了只会G1会计算一个清理年轻代Region块所需要的时间,如果这个时间远小于配置的-XX:MaxGCPauseMillis值,那么只会扩大年轻代Region块的比例,不会触发Young GC,直到清理时间和-XX:MaxGCPauseMillis值接近了或者年轻代Region比例达到60%才会触发Young GC;
  • Mixed GC相当于CMS的Full GC,会回收年轻代Region、老年代Region和Humongous Region,回收过程中会将Region块中存活的对象复制到邻近的Region块中,如果没有足够多的空Region来保存拷贝的对象,就会触发一次Full GC;
  • Full GC会触发STW,然后采用单线程去清理垃圾对象,好空闲出一部分Region供Mixed GC使用,这个过程不仅会STW而且相当耗时,所以尽量不要触发Full GC;
  • 可以看出来,G1收集器中最重要的就是-XX:MaxGCPauseMillis参数的配置了,因为配置的过小会导致每次GC只能回收少量Region块的空间,导致内存空间回收速度小于消耗速度,垃圾慢慢累积就会触发Full GC,性能降低;而配置过大会导致Young GC只有当年轻代Region占比到60%才会触发,这样年轻代对象过多,Survivor区放不下直接进入老年代,老年代空间会被快速耗尽,频繁触发Mixed GC;
  • -XX:MaxGCPauseMillis默认是200ms,建议配置在这个值上下浮动;
  • 发张图看看G1的内存划分(网上的):

在这里插入图片描述

收集器的选择

  • 内存小于100M或者单核的,使用串行收集器就可以了;
  • 多核,内存小于4G可以用parallel收集器,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G的就是ZGC吧;
  • 尽量让JVM自己选择,JVM比我们更懂GC收集器。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值