22-09-02 西安 JVM 类加载器、栈、堆体系、堆参数调优、GC垃圾判定、垃圾回收算法、对象的finalize机制

这篇文章不少地方都截图了宋红康老师的课件,实在他jvm这块讲的真好。连接地址如下:

尚硅谷宋红康JVM全套教程(详解java虚拟机)_哔哩哔哩_bilibili

JVM入门

1、JVM结构图

JVM是运行在操作系统之上的,它与硬件没有直接的交互

方法区:存储已被虚拟机加载的类元数据信息(元空间)

堆:存放对象实例,几乎所有的对象实例都在这里分配内存

虚拟机栈(java栈):虚拟机栈描述的是Java方法执行的内存模型每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息

程序计数器:当前线程所执行的字节码的行号指示器

本地方法栈:本地方法栈则是为虚拟机使用到的Native方法服务

执行引擎Execution Engine:执行引擎负责解释命令,提交操作系统执行

本地接口Native Interface:作用是融合不同的编程语言为 Java 所用


 2、类加载器ClassLoader和双亲委派模型

负责加载class文件,class文件在文件开头有特定的文件标示,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

类加载器分为四种:前三种为虚拟机自带的加载器。

  • 启动类加载器(Bootstrap)C++

    负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类

  • 扩展类加载器(Extension)Java

    负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包

  • 应用程序类加载器(AppClassLoader)Java

    也叫系统类加载器,负责加载classpath(java.class.path)中指定的jar包及目录中class

  • 用户自定义加载器 Java.lang.ClassLoader的子类,用户可以定制类的加载方式

public class LockDemo {
    public static void main(String[] args) {
        //LockDemo 是用户的自定义类 由应用类加载器加载 它的父类加载器是扩展类加载  扩展类的父类加载器是启动类加载器
        //AppClassLoader
        System.out.println(LockDemo.class.getClassLoader());
        //ExtClassLoader
        System.out.println(LockDemo.class.getClassLoader().getParent());
        //启动类加载器是C++实现的  java中无法获取
        System.out.println(LockDemo.class.getClassLoader().getParent().getParent());

        //String是rt包中的类
        System.out.println("===========================");

        System.out.println(String.class.getClassLoader());
        System.out.println(String.class.getClassLoader().getParent());
        System.out.println(String.class.getClassLoader().getParent().getParent());
    }
}

控制台运行效果:符合预期

打印控制台中的sun.misc.Launcher,是一个java虚拟机的入口应用

各种类加载器所加载的文件

public static void main(String[] args) {
    //应用类加载器会加载 当前项目编译后的classess目录下的所有的文件
    System.out.println("AppClassLoader加载的文件: ");
    System.out.println(System.getProperty("java.class.path"));
    System.out.println("ExtClassLoader加载的文件: ");
    System.out.println(System.getProperty("java.ext.dirs"));
    //启动类加载器加载路径
    System.out.println("BootstrapClassLoader加载的文件: ");
    for (URL url : Launcher.getBootstrapClassPath().getURLs()) {
        System.out.println(url);
    }
}

============================================

双亲委派模型

classloader的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上

工作过程:

  • 1、当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

  • 2、当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。

  • 3、如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;

  • 4、若ExtClassLoader也加载失败,则会使用AppClassLoader来加载

  • 5、如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

捕获式加载

双亲委派模型好处

安全,可避免用户自己编写的类动态替换Java的核心类,如java.lang.String ,防止内存中出现多份同样的字节码(安全性角度)

通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。


3、类加载过程

ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

阶段一:加载阶段

阶段一主要就是生成了Class对象

那么加载的字节码文件来源于哪里?

阶段二:链接

验证

字节码文件在文件开头有特定的文件标识,CA FE BA BE

===================

准备

====================

解析

阶段三:初始化

clinit方法,有类变量的赋值会自动帮我们生成该方法

clinit方法注意点

也就是说:多线程并发情况下一个类只会被加载一次 

===============

一个小测试,非法的前向引用

================ 

init方法

构造器对应字节码文件中 init方法。

任何一个类声明以后,内部至少存在一个类的构造器


4、PC寄存器

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,即 将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。


5、JVM的生命周期

虚拟机的启动

java虚拟机通过引导类加载器(bootstrap ClassLoader)创建一个初始类(initial class)来完成,这个类是由虚拟机的具体实现指定的

虚拟机的执行

一个运行中的java虚拟机有着一个清晰的任务:执行java程序

执行一个java程序的时候,真真正正在执行的是一个叫做java虚拟机进程

虚拟机的退出

有如下几种退出的情况

1.程序正常执行结束

2.程序在运行的过程中遇到了异常或错误而异常终止

3.由于操作系统出现错误而导致java虚拟机进程终止

4.某线程调用Runtime类或System类的exit方法或Runtime类的halt方法


stack栈

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放。

对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。

1、栈帧 Stack Frame

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个方法相关(Method)和方法运行期数据的数据集。

栈帧完整的5部分


2、本地变量表 和 slot槽 

本地变量表/局部变量表 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型

idea安装插件 jclasslib Bytecode viewer

程序如下

public class LocalVariableTest {
    public static void main(String[] args) {
        LocalVariableTest test = new LocalVariableTest();
        int num=10;
        test.test1();
    }

    public void test1(){
        System.out.println("调用test1方法");
    }
}

 start PC + length 描述变量的作用域范围

=============

本地变量表是一个数组,该数组的每一个位置叫做一个slot槽 

当一个实例方法被调用(将要对应一个栈帧)的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot

在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

>byte、short、char在存储前被转换为int
>boolean也被转换为int,0表示false,非0表示true。
>long和double则占据两个Slot

JVM会为局部变量表中的每一个slot都分配一个访问素引,通过这个素引即可成功访问到局部变量表中指定的局部变量值

特别注意

如果当前帧是由构造方法或者实例方法创建的,
那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

这也就解释了为什么静态方法中不能使用this

因为this变量不存在于静态方法的局部变量中
而构造器和实例方法中能使用this,是为因为他们的本地变量表里有this,且其是首位。

静态变量和局部变量的对比

变量按照在类中声明的位置分为:

1.成员变量:在使用前,都经历过默认初始化赋值
            实例变量:随着对象的创建会在堆空间默认赋值
            类变量:在linking的prepare阶段,给类变量默认赋值
            
2.局部变量:在使用前,必须显示赋值。否则,编译不通过


3、操作数栈 Operand Stack

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。

操作数栈什么时候创建出来的

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

虽然是空的,但是人家有“栈深度”,在编译器就定义好这个深度了

操作数栈怎么玩?

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。

操作数栈能干什么

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

尽管操作数栈是基于数组实现的,

但是操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准
的入栈(push)和出栈(pop)操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

另外,我们说Java虚拟机的解释引擎是基于栈执行引擎,其中的栈指的就是操作数栈。


4、栈帧(Stack Frame)压栈和出栈

栈遵循“先进后出”或者“后进先出”原则。

方法的结束:不管是return指令正常返回或抛出异常(指没有处理的异常),都会导致栈帧被弹出

如下图,在一个栈中有2个栈帧(Stack Frame1和Stack Frame2):

Stack Frame2是最先被调用的方法,先入栈, 然后方法 2 又调用了方法1,Stack Frame1处于栈顶的位置,栈帧 2 处于栈底;

执行完毕后,依次弹出Stack Frame1和Stack Frame 2,线程结束,栈释放。

每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的顶部,顶部栈就是当前的方法,该方法执行完毕 后会自动将此栈帧出栈。


5、栈溢出(报错StackOverflowError

栈溢出:Exception in thread "main" java.lang.StackOverflowError

栈溢出通常出现在递归调用时。(栈中数据大小超过了默认的1MB)

主线程也是1M,递归忘了结束会出现栈溢出

public static void main(String[] args) {
    //递归调用,测试栈溢出
    a();
}
public static void a(){
    a();
}


6、调整栈内存大小

可以使用-Xss 选项设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度


Heap体系概述

1、为什么要堆分代

Heap 堆:一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。

类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存逻辑上分为三部分:

  • Young Generation Space 新生区 Young/New

  • Tenure generation space 养老区 Old/Tenure

  • Permanent Space 永久区 Perm

也称为新生代(年轻代)、老年代永久代(持久代)。永久区(非堆)就是方法区

为什么需要把Java堆分代?不分代就不能正常工作了吗

其实不分代完全可以,的唯一理由就是优化GC性能

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。
>新生代:有Eden、两块大小相同的Survivor(又称为from/to,s0/sl)构成,to总为空。
>老年代:存放新生代中经历多次GC仍然存活的对象。


2、新生代

新生区是对象的诞生、成长、消亡的区域,一个对象在这里产生,应用,最后被垃圾回收器收集,结束生命。

新生区又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的对象都是在伊甸区被new出来的。

幸存区有两个: 0区和1区。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存者0区。

注意:0区和1区会交换,保证接收伊甸区的是幸存者0区

1.若幸存者0区也满了,再对该区(0区)进行垃圾回收,然后剩余对象移动到 幸存者1 区

2.那如果1 区也满了呢?再次垃圾回收(回到第上步,,0区和一区永远都有其中是一个为空),满足条件后再移动到养老区。

3.若养老区也满了,那么这个时候将产生MajorGC(FullGC),进行养老区的内存清理。若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

========================

如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有2:

(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。

(2)代码中创建了大量大对象【集合、数组】,并且长时间不能被垃圾收集器收集(存在被引用)


3、老年代

经历多次GC仍然存在的对象(默认是15次),老年代的对象比较稳定,不会频繁的GC


4、方法区存什么?

首先明确:只有HotSpot才有永久代。。

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

类型信息

============

域信息 

 ============

方法信息

non-final 的类变量 

需要注意的是:

全局常量:static final
被声明为final的类变量的处理方法则不同,每个全局常量在编译(变成class文件的过程)的时候就会被分配了。 

还有一个非常重要的是:运行时常量池

常量池

在字节码文件中:内部有一个常量池(class文件中信息量最大的)

 为什么提供一个常量池呢

运行时常量池

在方法区中的运行时常量池,对应字节码文件中的常量池

如果出现java.lang.OutOfMemoryError: PermGen space,说明是Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。


5、方法区-不同jdk版本区别

Jdk1.6及之前: 有永久代,静态变量和字符串常量池都在永久代

Jdk1.7: 有永久代,但已经逐步“去永久代”,字符串常量池,静态变量从永久代中移除到了堆中

Jdk1.8及之后无永久代,字符串常量池、静态变量仍在堆中,类型信息、字段、方法、常量保存在本地内存元空间,不在虚拟内存了

面试题:永久代为什么要被元空间替代?

元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下元空间的大小仅受限于本地内存。

1.为永久代设置空间大小是很难确定的,动态类加载过多,会出现Perm区的OOM。

java.lang.OutOfMemoryError:PermGen space

2.为永久代调优是很困难的

jdk7 为什么要调整 String Table

因为永久代的回收频率很低,在full gc的时候才会触发。(full gc是老年代空间不足、永久代空间不足时才会触发)。这就导致String Table 回收效率不高。

而实际在开发中,会有大量的字符串被创建,回收效率低的话,会导致永久代内存不足。

放在堆里的话,相对来说回收的频率高一些


6、YGC、Minor GC新生代收集

YGC和Minor GC完全等价

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

新生代收集(Minor GC/Young GC):只是新生代(Eden\S0,S1)的垃圾收集,属于部分收集

年轻代GC触发机制


7、Major GC、Old GC老年代收集

Major GC 和 Old GC 完全等价,老年代也可能会空间不足,会进行Major GC

老年代收集(Major GC/Old GC):严格来说,Major GC只是老年代的垃圾收集,属于部分收集。但是很多时候Major GC会和Full GC混淆使用

老年代GC触发机制


8、Full GC 整堆收集

整堆收集(FULL GC)新生代、老年代、方法区都要垃圾收集,注意涉及到了方法区

Full GC触发机制

说明:Full GC是开发或调优中尽量要避免的。 


9、内存溢出与内存泄漏

内存溢出OOM(针对堆空间)

内存空间不够时,进行独占式的Full GC之后内存还不够,就会报OOM。是程序崩溃的罪魁祸首之一

造成空闲不足的2个原因:

当然,也不是在任何情况下垃圾收集器都会被触发的

比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集
并不能解决这个问题,所以直接抛出OutofMemoryError,不会触发GC

==========================

内存泄漏

严格意义:

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

宽泛意义:

但实际情况一些疏忽会导致对象的生命周期变得很长甚至导致OOM,叫做宽泛意义上的“内存泄漏”

尽管内存泄漏不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OOM, 导致程序崩溃。

=====---==-==-

内存泄漏举例

一些提供close的资源未关闭导致内存泄漏 数据库连接dataSourse.getConnection(),网络连接socket和io连接必须手动close,否则是不能被回收的。


堆参数调优

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

1、堆空间的常用参数设置

怎么对jvm进行调优?通过参数配

官网: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

java代码查看jvm堆的默认值大小:

//堆的最大值,默认是内存的1/4
System.out.println("max : "+Runtime.getRuntime().maxMemory()*1.0/1024/1024+" MB ");
//堆的当前总大小,默认是内存的1/64
System.out.println("init : "+Runtime.getRuntime().totalMemory()*1.0/1024/1024+" MB ");


2、怎么设置JVM参数

idea运行时设置方式如下:

不加分号,中间有空格。

-Xms10m  -Xmx30m

重新测试这段代码:

System.out.println("max : " + Runtime.getRuntime().maxMemory() * 1.0 / 1024 / 1024 + " MB ");
System.out.println("init : " + Runtime.getRuntime().totalMemory() * 1.0 / 1024 / 1024 + " MB ");

控制台打印如下:


3、查看堆内存详情

配置JVM参数

-XX:+PrintGCDetails   可以打印堆内存信息+GC的情况

-Xmx50m -Xms30m -XX:+PrintGCDetails

配置后如下:

运行如下程序 

System.out.print("最大堆大小:");
System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
System.out.print("当前堆大小:");
System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
System.out.println("==================================================");
byte[] b = null;
for (int i = 0; i < 10; i++) {
    b = new byte[1 * 1024 * 1024];//1MB的数组
}

打印结果:

新生代和老年代的堆大小之和是Runtime.getRuntime().totalMemory();不信的话就跟我学学,要相信科学。。。


4、GC演示

jvm参数配置不变

运行如下程序: 

public static void main(String[] args) {
    System.out.println("=====================Begin=========================");
    System.out.print("最大堆大小:Xmx=");
    System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");
    System.out.print("剩余堆大小:free mem=");
    System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
    System.out.print("当前堆大小:total mem=");
    System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    System.out.println("==================First Allocated===================");
    byte[] b1 = new byte[5 * 1024 * 1024];
    System.out.println("5MB array allocated");
    System.out.print("剩余堆大小:free mem=");
    System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
    System.out.print("当前堆大小:total mem=");
    System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    System.out.println("=================Second Allocated===================");
    byte[] b2 = new byte[10 * 1024 * 1024];
    System.out.println("10MB array allocated");
    System.out.print("剩余堆大小:free mem=");
    System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");
    System.out.print("当前堆大小:total mem=");
    System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    System.out.println("=====================OOM=========================");
    System.out.println("OOM!!!");
    System.gc();
    byte[] b3 = new byte[40 * 1024 * 1024];
}

先说明一个点:

当前堆大小=新生代+老年代

剩余堆大小=当前堆大小-jvm自己也要存很多额外的数据

谈谈你对System.gc() 的理解

1.显示触发Full GC,同时对老年代和新生代进行回收。

2.但是不能确保它什么时候执行(免责声明),仅仅是提醒JVM垃圾回收器要进行一次垃圾回收


5、MAT工具-dump文件分析

MAT是Memory Analyzer 的简称,它是一款功能强大的java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

是免费的,基于Eclipse开发的,可以在http:www.eclipse.org/mat/ 下载并使用MAT

生成dump文件

方式一:命令行使用jmap

方式二:使用JVisualVM导出

方式三:

运行参数改成

-Xmx50m -Xms10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=E:\tmp

-XX:HeapDumpPath:生成dump文件路径。

堆里的内存数据持久化到这里了

Dumping heap to E:\tmp\java_pid29536.hprof ...

=====================

生成的这个文件怎么打开?

方式一:使用MAT工具(推荐)

MAT可以分析heap dump文件。在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。

方式二:jvisualvm.exe 分析堆转储文件

文件-->装入-->选择要打开的文件即可


GC垃圾判定

GC的特点:

  • 次数上频繁收集Young区

  • 次数上较少收集Old区

  • 基本不动Perm区


1、引用计数法

引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

引用计数法优点:

简单,高效,现在的objective-c、python等用的就是这种算法。

引用计数法缺点:

  • 引用和去引用伴随着加减算法,影响性能

  • 很难处理循环引用,相互引用的两个对象则无法释放。

因此目前主流的Java虚拟机都摒弃掉了这种算法


2、可达性算法(根搜索法)

也叫追踪性垃圾收集,解决了循环引用的问题,防止内存泄漏的发生

这个算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该合集引用到的对象,并将其加入到该合集中,这个过程称之为标记。 最终,未被探索到的对象便是死亡的,是可以回收的。

只要你是存活的对象,你都应该直接或者间接的被GC Roots所连接,没连接到的就是垃圾。想想“葡萄”,像不像

在java语言中,GC Roots可以是哪些具体的元素呢?

  • 虚拟机栈(栈帧中的本地变量表)中的引用对象。(最为常见)

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

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

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

  • 所有被同步锁synchronized持有的对象

技巧:

由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。


3、对象的finalize机制

对象在回收之前,涉及到一个方法finalize()的调用

该方法是Object类的方法,可以去重写,通常目的是在对象不可撤销的丢弃之前执行清理操作

public class Object {
    protected void finalize() throws Throwable { }
}

永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用
理由包括下面三点:

  1. 在finalize()时可能会导致对象复活。
  2. finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
  3. 一个糟糕的finalize()会严重影响GC的性能。

虚拟机中对象一般处于3种可能的状态

  1. 可触及:人家就不是垃圾
  2. 可复活:可能在finalize()中复活
  3. 不可触及:真的该死了。。

注意:finalize()只会被调用一次

2次标记

没重写是不可能自救的。。

由优先级低的线程帮我们调用这个方法

跟现有引用链搭上关系了,就是可复活的了


 4、四种引用

强引用:

类似于

Object obj = new Object();//一般下我们创建的普通对象都是强引用的对象

只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象,就算是OOM也不会对强引用的对象回收,死都不收。

对于一个普通的对象(强引用),如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null, 一般认为就是可以被垃圾收集的了。

软引用:

SoftReference 类实现软引用。在系统要发生内存溢出异常之前,才会将这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。软引用可用来实现内存敏感的高速缓存。

//创建软应用对象:在对象构造器中传入的数据是我们想要使用的数据
SoftReference softReference = new SoftReference<byte[]>(new byte[1024*1024*5]);
System.out.println(softReference.get());

弱引用:

WeakReference 类实现弱引用。只被弱引用关联的对象只能生存到下一次垃圾收集发生为止;

在系统GC时,只要发现弱引用,无论内存是否足够都会回收掉只被弱引用关联的对象。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。

弱引用对象相比于软引用对象更容易、更快被GC回收

虚引用:

PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。


垃圾回收算法

在介绍JVM垃圾回收算法前,先介绍一个概念:Stop-the-World

Stop-the-world意味着 JVM由于要执行GC而停止了应用程序的执行,并且这种情形会在任何一种GC算法中发生。当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态直到GC任务完成。事实上,GC优化很多时候就是指减少Stop-the-world发生的时间,从而使系统具有高吞吐 、低停顿的特点。


1、复制算法

该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。

优点:

  • 实现简单

  • 不产生内存碎片

缺点:

  • 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;如果不想浪费一半的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

  • 如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费

年轻代中使用的是Minor GC,这种GC算法采用的是复制算法(Copying)


2、from区和to区

from区和to区会交换,保证空的区永远都是to区,下一次gc的时候。对象去放到to区

伊甸园区满的时候会触发Minor GC,将伊甸园区和幸存者区的对象进行GC,survior区是被动进行gc的

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。空间充足默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。

紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。

对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁。
年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象
会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。

经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色

也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。
不管怎样,都会保证名为To的Survivor区域是空的。

Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲(to)和活动区间(from),而另外80%的内存,则是用来给新建对象分配内存的。

一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。  


3、对象提升(Promotion)规则

 在age=16的时候,进升到老年代,多次GC后依然存活的对象会去老年代


4、标记清除(Mark-Sweep)

“标记-清除”(Mark Sweep)算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。正如名字一样,算法分为2个阶段:

  1. 标记出需要回收的对象,使用的标记算法均为可达性分析算法

  2. 回收被标记的对象。

Mark-Sweep缺点:

  • 效率问题(两次遍历)

  • 空间问题(标记清除后会产生大量不连续的碎片。JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。)


5、标记整理(标记压缩)(Mark-Compact )

标记-整理法是标记-清除法的一个改进版。

同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;

不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。

标记整理优点:

标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

标记整理缺点:

如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。

老年代一般是由标记清除或者是标记清除与标记整理的混合实现。


6、上述三种垃圾回收算法对比

内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

内存整齐度:复制算法=标记整理算法>标记清除算法。

内存利用率:标记整理算法>标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程


7、分代收集

分代回收算法实际上是把复制算法标记压缩的结合,并不是真正一个新的算法

一般分为:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。

年轻代(Young Gen)

年轻代特点是区域相对老年代较小,对象存活率低

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对像大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenure Gen)

老年代的特点是区域较大,对象存活率高

这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。


垃圾收集器

1、垃圾收集器图

如果说垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

垃圾收集器

如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。

JDK 1.8默认垃圾收集器使用 Parallel(年轻代和老年代都是) 

JDK 1.9默认垃圾收集器使用 G1[G First,不要读错了]  

代码中查看使用的垃圾收集器

import java.lang.management.GarbageCollectorMXBean;

//查看使用的垃圾收集器
List<GarbageCollectorMXBean> l = ManagementFactory.getGarbageCollectorMXBeans();
for(GarbageCollectorMXBean b : l) {
    System.out.println(b.getName());
}

控制台打印如下:


2、Serial/Serial Old收集器

Serial  ˈsɪəriəl  直译串行

可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;

新生代复制算法、老年代标记-压缩;垃圾收集的过程中会Stop The World(服务暂停)


3、ParNew 收集器

ParNew收集器收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为完全一一样

ParNew特点是:新生代并行(有多条垃圾回收线程),老年代串行;新生代复制算法、老年代标记-压缩


4、Parallel / Parallel Old 收集器(jdk8默认)

Scavenge直译清扫    Parallel直译并行

Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

参数控制: -XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行


5、CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark)

  • 并发标记(CMS concurrent mark)

  • 重新标记(CMS remark)

  • 并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

=======

优点: 并发收集低停顿

缺点: 产生大量空间碎片、并发阶段会降低吞吐量


6、G1收集器(jdk9默认)

-XX:+UseG1GC 使用G1垃圾回收器

G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多核处理器及大容量内存的机器. 以极高概率满足GC 停顿时间要求的同时,还具备高吞吐量性能特征. 

G1收集器是基于标记整理算法实现的,不会产生空间碎片,可以精确地控制停顿,将堆划分为多个大小固定的独立区域,并跟踪这些区域的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(Garbage First)。

与CMS收集器相比G1收集器有以下特点:

  1. 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。

  2. 分代收集:分代概念在G1中依然得以保留。虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。也就是说G1可以自己管理新生代和老年代了。

  3. 空间整合:由于G1使用了独立区域(Region)概念,G1从整体来看是基于“标记-整理”算法实现收集,从局部(两个Region)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片

  4. 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用这明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

=====

Region

每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色,其中H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。

Remembered Set

为了避免全堆扫描,G1使用了Remembered Set来管理相关的对象引用信息。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏了。


7、ZGC收集器

ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC是Azul System公司开发的 C4(Concurrent Continuously Compacting Collector)收集器

-XX:+UseZGC 使用ZGC垃圾回收器(jdk11以后支持)


8、垃圾回收器比较


9、垃圾回收器选择策略 

  • 如果你想要最小化地使用内存和并行开销,请选serial GC
  • 如果你想要最大化应用程序的吞吐量,请选 Parallel Scavenge + Parallel Old(jdk8默认)
  • 如果你想要最小化GC的中断或停顿时间,请选CMS GC+ParNew

cms并发回收养老区,parnew并行回收新生区

但遗憾的是,在jdk9中被标记为过时,在jdk14中,已经将CMS废弃

猜你喜欢

转载自blog.csdn.net/m0_56799642/article/details/126658742