admin管理员组

文章数量:1547489

文章目录

  • 一:JVM常见面试题
    • jvm体系结构预览
      • GC作用域
    • JVM 类加载机制
      • 加载
      • 验证
      • 准备
      • 解析
      • 初始化
      • 有且只有五种情况必须对类进行初始化:
    • 类加载初始化以及加载类的过程
      • processOn流程图
      • 类加载器初始化过程:
    • 类加载过程
      • processOn流程图
      • 类加载属于懒加载说明
      • 代码示例-代码git地址
    • JVM类加载器分类:
      • 1、Bootstrap ClassLoader:
      • 2、Extension ClassLoader:
      • 3、System ClassLoader\APP ClassLoader:
      • 4、自定义加载器:
      • 类加载器示例-代码git地址
    • 自定义类加载器示例:
      • 代码如下
      • 运行结果如下
    • 双亲委派
    • 全盘负责委托机制
    • 双亲委派模型的源码实现:
      • loadClass
      • findClass
    • 为什么这样设计双亲委派机制:
    • 自定义类加载器
    • 打破双亲委派机制
    • Tomcat打破双亲委派机制
    • Tomcat 为什么不能使用双亲委派类加载机制?
    • Tomcat自定义加载器详解
      • tomcat的几个主要类加载器:
      • 从图中的委派关系中可以看出:
    • 模拟实现Tomcat的webappClassLoader加载自己war包应用内不同版本类实现相互共存与隔离
      • 代码如下
      • 结果如下
    • 模拟实现Tomcat的JasperLoader热加载
    • Hotspot源码JVM启动执行main方法流程
    • Java语言的跨平台特性
    • 查看jvm编译后的代码
    • 如何判断对象是否已经死亡-对象内存回收
      • 引用计数法
        • 代码示例
      • 可达性分析算法
        • finalize()方法最终判定对象是否存活
        • 标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
        • 调用finalize方法代码示例
        • 调用finalize方法实现自救代码示例
    • 如何判断一个类是无用的类
    • 引用
      • 1:强引用:
      • 2:软引用:
      • 3:弱引用:
      • 4:虚引用:
    • 什么时候会触发FullGC
      • 老年代空间不足:
      • 永久代或元空间满:
      • System.gc()调用:
      • JVM内部调用:
      • 分配大对象:
      • 并发模式失败:
      • JVM启动参数设置不合理:
      • 统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间
      • 堆中分配很大的对象
    • 避免不必要的Full GC
      • 优化堆大小和各代大小:
      • 选择合适的垃圾收集器:
      • 避免在代码中显式调用System.gc():
      • 监控和分析GC日志:
    • 调优命令有哪些?
      • jps
      • jstat
      • jinfo
      • jmap
      • jstack
      • top命令
      • pidstat
      • jmap -heap 29544 查看某一实例jvm配置
      • 几种主要的JVM参数
      • 堆栈配置相关
      • 垃圾收集器相关
    • 调优工具
    • jvm为什么设置STW(stop the world)机制,能不能不停止用户线程。
    • JVM内存参数设置
    • 关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
      • -XX:MaxMetaspaceSize:
      • -XX:MetaspaceSize:
    • 栈空间大小测试-StockOverFlowTest
      • 不自己设置占空间小,默认1M。
      • 自己设置占空间小,设小一点,-Xss128k
      • 结论:
  • 二:JVM内存分配机制详解
    • JVM整体结构及内存模型
      • 栈:
        • 局部变量表:
        • 操作数栈:
        • 动态链接:
        • 方法出口:
        • 什么情况下会发生栈内存溢出。
      • 程序计数器:
      • 方法区:
      • 本地方发栈:
      • 堆:
    • JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。
      • 共享内存区划分
      • 参数的配置
      • 为什么要分为Eden和Survivor?为什么要设置两个Survivor区?
      • JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代
    • 对象的创建主要流程
      • 1. 类加载检查:
      • 2.分配内存
        • 这个步骤有两个问题:
        • 划分内存的方法:
        • 解决并发问题的方法:
      • 3.初始化
      • 4.设置对象头
        • 32位对象头
        • 64位对象头
      • 5.执行init方法
    • 如何查看对象大小与指针压缩
      • 对象大小可以用jol­core包查看,引入依赖
      • 代码如下
      • 默认开启指针压缩运行结果如下
      • 禁止指针压缩后的结果
      • 结果对比结论
      • 对其填充的必要性:
    • 什么是java对象的指针压缩?
    • 为什么要进行指针压缩?
    • 对象内存分配
    • 对象栈上分配
      • 对象逃逸分析:
      • 标量替换
      • 标量与聚合量:
      • 栈上分配代码示例:
    • 对象在Eden区分配
    • Eden与Survivor区默认8:1:1
      • 内存分配流转代码示例
      • 结果
      • 为allocation2分配内存代码示例
      • 结果
      • 继续额那个jvm中加入数据,执行如下代码验证:
      • 结果如下
    • jvm中 什么情况下对象会进入老年代
      • 提升失败(Promotion Failure):
      • To-space exhausted:
      • 大对象直接进入老年代
      • 长期存活的对象将进入老年代
      • 对象动态年龄判断进入老年代
      • 老年代空间分配担保机制进入老年代
    • 亿级流量电商系统如何优化JVM参数设置(ParNew+CMS)
      • 正常情况下的参数设置
  • 三:垃圾收集器
    • 垃圾回收算法
      • 1:标记-清除算法:
      • 2:复制算法
      • 3:标记-整理算法
      • 4:分代收集算法
    • 垃圾收集底层算法实现
      • 三色标记
        • 多标-浮动垃圾
        • 漏标-读写屏障
        • 写屏障
          • 写屏障实现SATB
          • 写屏障实现增量更新
        • 读屏障
        • 对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
        • 为什么G1用SATB?CMS用增量更新
        • 记忆集与卡表
        • 卡表的维护
    • 垃圾收集器汇总
    • 1:serial(串行)收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
    • 2:Parallel Scavenge收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
    • 3:ParNew收集器(-XX:+UseParNewGC)
    • 4:CMS收集器(-XX:+UseConcMarkSweepGC(old))
      • 初始标记:
      • 并发标记:
      • 重新标记:
      • 并发清理:
      • 并发重置:
      • CMS的相关核心参数
    • 5:G1收集器
  • 四:垃圾收集器-G1收集器(-XX:+UseG1GC)
    • G1收集器原理详解
    • G1收集器一次GC的运作过程大致分为以下几个步骤:
      • 初始标记(initial mark,STW):
      • 并发标记(Concurrent Marking):
      • 最终标记(Remark,STW):
      • 筛选回收(Cleanup,STW):
    • JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:
      • 并行与并发:
      • 分代收集:
      • 空间整合:
      • 可预测的停顿:
    • 总结
    • G1垃圾收集分类
      • YoungGC
      • MixedGC
      • Full GC
    • G1收集器参数设置
    • G1垃圾收集器优化建议
    • 什么场景适合使用G1
    • 每秒几十万并发的系统如何优化JVM
  • 五:垃圾收集器-ZGC收集器(-XX:+UseZGC)
    • ZGC目标
      • 支持TB量级的堆。
      • 最大GC停顿时间不超10ms。
      • 奠定未来GC特性的基础。
      • 最糟糕的情况下吞吐量会降低15%。
    • 不分代(暂时)
    • ZGC内存布局
    • NUMA-aware
    • 颜色指针
      • 每个对象有一个64位指针,这64位被分为:
      • 为什么有2个mark标记?
      • 颜色指针的三大优势:
    • 读屏障
    • ZGC运作过程
      • 并发标记(Concurrent Mark):
      • 并发预备重分配(Concurrent Prepare for Relocate):
      • 并发重分配(Concurrent Relocate):
      • 并发重映射(Concurrent Remap):
    • ZGC存在的问题
      • 浮动垃圾
      • ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。
    • ZGC参数设置
    • ZGC触发时机
    • 如何选择垃圾收集器
      • 下图有连线的可以搭配使用
    • 安全点与安全区域
      • 安全点
      • 安全区域又是什么?
  • 六:JVM调优工具详解及调优实战
  • 七:JVM调优实战&常量池详解&GC日志分析
  • 八:JDK示例代码git地址
  • 九:JDK流程图

一:JVM常见面试题

jvm体系结构预览

GC作用域

JVM 类加载机制

  • JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。

加载

  • 通过类的全限定名来获取定义此类的二进制字节流
  • 将这个类字节流代表的静态存储结构转为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。

验证

  • 这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

  • 为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)。如static int a = 100;静态变量a就会在准备阶段被赋默认值0。
  • 对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。
  • 另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如static final int a = 666; 静态常量a就会在准备阶段被直接赋值为666,对于静态变量,这个操作是在初始化阶段进行的。

解析

  • 将类的二进制数据中的符号引用换为直接引用。
  • 符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。
  • 如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化

  • 初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。
  • 类的初始化的主要工作是为静态变量赋程序设定的初值。如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100。

有且只有五种情况必须对类进行初始化:

  • 使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。
  • 通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。
  • 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。
  • 当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类。
  • 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。
  • 这五种情况被称为“主动引用”。

类加载初始化以及加载类的过程

processOn流程图

类加载器初始化过程:

  • 参见类运行加载全过程图可知其中会创建JVM启动器实例sun.misc.Launcher。
  • sun.misc.Launcher初始化使用了单例模式设计,保证一个JVM虚拟机内只有一个sun.misc.Launcher实例。
  • 在Launcher构造方法内部,其创建了两个类加载器,分别是sun.misc.Launcher.ExtClassLoader(扩展类加载器)和sun.misc.Launcher.AppClassLoader(应用类加载器)。
  • JVM默认使用Launcher的getClassLoader()方法返回的类加载器AppClassLoader的实例加载我们的应用程序。

类加载过程

processOn流程图

类加载属于懒加载说明

注意,主类在运行过程中如果使用到其它类,会逐步加载这些类。
jar包或war包里的类不是一次性全部加载的,是使用到时才加载。

代码示例-代码git地址

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/5/22 14:19:54
 * @description 类加载属于懒加载示例说明-类加载顺序
 **/
public class TestDynamicLoad {
    static {
        System.out.println("static方法--加载测试类TestDynamicLoad");
    }

    public static void main(String[] args) {
        new A();
        System.out.println("main方法--加载测试类TestDynamicLoad");
        //B不会加载,除非这里执行 new B()
        B b = null;

    }

    public static class A {
        static {
            System.out.println("static方法--加载测试类A");
        }

        public A() {
            System.out.println("构造方法--初始化测试类A");
        }
    }

    public static class B {
        static {
            System.out.println("static方法--加载测试类B");
        }

        public B() {
            System.out.println("构造方法--初始化测试类B");
        }
    }
}
//运行结果如下
/*
static方法--加载测试类TestDynamicLoad
static方法--加载测试类A
构造方法--初始化测试类A
main方法--加载测试类TestDynamicLoad
*/

具体加载流程见上:JVM 类加载机制

JVM类加载器分类:

1、Bootstrap ClassLoader:

  • 启动类加载器,也叫根类加载器或者引导类加载器,它负责加载Java的核心类库,加载如(%JAVA_HOME%/lib)目录下的rt.jar(包含System、String这样的核心类)这样的核心类库。根类加载器非常特殊,它不是java.lang.ClassLoader的子类,它是JVM自身内部由C/C++实现的,并不是Java实现的。

2、Extension ClassLoader:

  • 扩展类加载器,它负责加载扩展目录(%JAVA_HOME%/jre/lib/ext)下的jar包,用户可以把自己开发的类打包成jar包放在这个目录下即可扩展核心类以外的新功能。

3、System ClassLoader\APP ClassLoader:

  • 系统类加载器或称为应用程序类加载器,是加载CLASSPATH环境变量所指定的jar包与类路径。一般来说,用户自定义的类就是由系统类加载器加载的。

4、自定义加载器:

负责加载用户自定义路径下的类包

类加载器示例-代码git地址

package com.zgs;

import sun.misc.Launcher;

import java.net.URL;

/**
 * @author: guisong.zhang
 * @date: 2024/5/22 14:59:23
 * @description 类加载器示例
 **/
public class TestJDKClassLoader {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());
        System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());
        System.out.println(TestJDKClassLoader.class.getClassLoader().getClass().getName());

        System.out.println();
        ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
        ClassLoader extClassloader = appClassLoader.getParent();
        ClassLoader bootstrapLoader = extClassloader.getParent();
        System.out.println("the bootstrapLoader : " + bootstrapLoader);
        System.out.println("the extClassloader : " + extClassloader);
        System.out.println("the appClassLoader : " + appClassLoader);

        System.out.println();
        System.out.println("bootstrapLoader加载以下文件:");
        URL[] urls = Launcher.getBootstrapClassPath().getURLs();
        for (int i = 0; i < urls.length; i++) {
            System.out.println(urls[i]);
        }

        System.out.println();
        System.out.println("extClassloader加载以下文件:");
        System.out.println(System.getProperty("java.ext.dirs"));

        System.out.println();
        System.out.println("appClassLoader加载以下文件:");
        System.out.println(System.getProperty("java.class.path"));

    }
}

//输出结果
/*
null
sun.misc.Launcher$ExtClassLoader
sun.misc.Launcher$AppClassLoader

the bootstrapLoader : null
the extClassloader : sun.misc.Launcher$ExtClassLoader@6e0be858
the appClassLoader : sun.misc.Launcher$AppClassLoader@18b4aac2

bootstrapLoader加载以下文件:
file:/D:/office/jdk/jre/lib/resources.jar
file:/D:/office/jdk/jre/lib/rt.jar
file:/D:/office/jdk/jre/lib/sunrsasign.jar
file:/D:/office/jdk/jre/lib/jsse.jar
file:/D:/office/jdk/jre/lib/jce.jar
file:/D:/office/jdk/jre/lib/charsets.jar
file:/D:/office/jdk/jre/lib/jfr.jar
file:/D:/office/jdk/jre/classes

extClassloader加载以下文件:
D:\office\jdk\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext

appClassLoader加载以下文件:
D:\office\jdk\jre\lib\charsets.jar;D:\office\jdk\jre\lib\deploy.jar;D:\office\jdk\jre\lib\ext\access-bridge-64.jar;D:\office\jdk\jre\lib\ext\cldrdata.jar;D:\office\jdk\jre\lib\ext\dnsns.jar;D:\office\jdk\jre\lib\ext\jaccess.jar;D:\office\jdk\jre\lib\ext\jfxrt.jar;D:\office\jdk\jre\lib\ext\localedata.jar;D:\office\jdk\jre\lib\ext\nashorn.jar;D:\office\jdk\jre\lib\ext\sunec.jar;D:\office\jdk\jre\lib\ext\sunjce_provider.jar;D:\office\jdk\jre\lib\ext\sunmscapi.jar;D:\office\jdk\jre\lib\ext\sunpkcs11.jar;D:\office\jdk\jre\lib\ext\zipfs.jar;D:\office\jdk\jre\lib\javaws.jar;D:\office\jdk\jre\lib\jce.jar;D:\office\jdk\jre\lib\jfr.jar;D:\office\jdk\jre\lib\jfxswt.jar;D:\office\jdk\jre\lib\jsse.jar;D:\office\jdk\jre\lib\management-agent.jar;D:\office\jdk\jre\lib\plugin.jar;D:\office\jdk\jre\lib\resources.jar;D:\office\jdk\jre\lib\rt.jar;D:\study\my-spring\my_jdk\target\classes;C:\Users\张帅\.config\.cool-request\request\lib\spring-invoke-starter.jar;D:\office\idea\IntelliJ IDEA 2021.3\lib\idea_rt.jar

*/

自定义类加载器示例:

  • 自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是 loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法

代码如下

package com.zgs.demo.jdk;

import java.io.FileInputStream;
import java.lang.reflect.Method;

/**
 * @author zgs
 * @date 2022/12/1 15:30
 * @decription 自定义类加载器
 */

public class MyClassLoaderTest {

    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                //defineClass将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

    }

    public static void main(String args[]) throws Exception {
        //初始化自定义类加载器,会先初始化父类ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器AppClassLoader
        MyClassLoader classLoader = new MyClassLoader("F:/test");
        //D盘创建 test/com/tuling/jvm 几级目录,将User类的复制类User1.class丢入该目录
        Class clazz = classLoader.loadClass("com.zgs.demo.jdk.User1");
        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

运行结果如下

双亲委派

  • processOn流程图
  • 当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

全盘负责委托机制

“全盘负责”是指当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,该类所依赖及引用的类也由这个ClassLoder载入。

双亲委派模型的源码实现:

  • processOn流程图
  • 主要体现在ClassLoader的loadClass()方法中,思路很简单:
  • 首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  • 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。
  • 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。

loadClass

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

findClass

protected Class<?> findClass(final String name)
        throws ClassNotFoundException
    {
        final Class<?> result;
        try {
            result = AccessController.doPrivileged(
                new PrivilegedExceptionAction<Class<?>>() {
                    public Class<?> run() throws ClassNotFoundException {
                        String path = name.replace('.', '/').concat(".class");
                        Resource res = ucp.getResource(path, false);
                        if (res != null) {
                            try {
                                return defineClass(name, res);
                            } catch (IOException e) {
                                throw new ClassNotFoundException(name, e);
                            }
                        } else {
                            return null;
                        }
                    }
                }, acc);
        } catch (java.security.PrivilegedActionException pae) {
            throw (ClassNotFoundException) pae.getException();
        }
        if (result == null) {
            throw new ClassNotFoundException(name);
        }
        return result;
    }

为什么这样设计双亲委派机制:

1:一个web程序,大部分代码都是自己写的,这样只是在第一次加载的时候,会绕一个圈子,但到了后面,再需要用的时候就可以直接应用程序类加载器加载出来,效率高。
2:沙箱安全机制,防止篡改核心类,因为类加载器会委派父加载器去加载类,而这些核心类都会由启动类加载器去加载并返回,那么就算我们去写了一个和核心类一模一样的类,也不会加载我们自己写的类,因为启动类已经加载出来并且返回了,就可以保证我们的核心类不被篡改。

package java.lang;

/**
 * @author: guisong.zhang
 * @date: 2024/5/28 10:24:34
 * @description 双亲委派机制-沙箱安全
 **/
public class String {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

自定义类加载器

  • 自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是loadClass(String, boolean),实现了双亲委派机制,
  • 还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法。
  • 自定义类加载器的父加载器为AppClassLoader。
  • 初始化自定义类加载器,会先初始化父类ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器AppClassLoader
package com.zgs;

import java.io.FileInputStream;
import java.lang.reflect.Method;

/**
 * @author: guisong.zhang
 * @date: 2024/5/28 10:55:13
 * @description 自定义类加载器的父加载器为AppClassLoader
 **/
public class MyClassLoaderTest {
    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadBytes(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
            int available = fis.available();
            byte[] bytes = new byte[available];
            fis.read(bytes);
            fis.close();
            return bytes;
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] bytes = loadBytes(name);
                return defineClass(name, bytes, 0, bytes.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        MyClassLoader myClassLoader = new MyClassLoader("D:/study/资料/test");
        Class clazz = myClassLoader.loadClass("com.zgs.User1");
        Object instance = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(instance, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }

}
  • 运行结果如下
测试自定义类加载器
sun.misc.Launcher$AppClassLoader
  • 这是因为有双亲委派机制的存在,自定义类加载器首先会去委托其父加载器应用程序类加载器去加载一层一层的往上调用然后再调用回来。
  • 而这个类再程序中是存在的,所以AppClassLoader就会加载出来并返回,所以结果就是AppClassLoader。
  • 那我们在在项目中删除这个User1类,使其值存在我们代码中指定的文件中,这个时候输出结果就是我们自定义的类加载器。
测试自定义类加载器
com.zgs.MyClassLoaderTest$MyClassLoader

打破双亲委派机制

  • 主要就是重写loadClass方法,自己写demo其实就可以把原始的loadClass方法copy出来删除不需要逻辑,然后指定的需要打破双亲委派机制的类做一个判断,其他的类还是调用原来的loadClass方法,详情见以下代码。
  • 打破双亲委派机制-项目中存在User1.class,文件夹中也存在User1.class,现在即使项目中存在我也要用我自定义的类加载器去加载而不使用AppClassLoader加载。
package com.zgs;

import java.io.FileInputStream;
import java.lang.reflect.Method;

/**
 * @author: guisong.zhang
 * @date: 2024/5/28 16:07:02
 * @description 打破双亲委派机制-项目中存在User1.class,文件夹中也存在User1.class,
 * 现在即使项目中存在我也要用我自定义的类加载器去加载而不使用AppClassLoader加载。
 **/
public class MyClassLoaderTest01 {
    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadBytes(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
            int available = fis.available();
            byte[] bytes = new byte[available];
            fis.read(bytes);
            fis.close();
            return bytes;
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] bytes = loadBytes(name);
                return defineClass(name, bytes, 0, bytes.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

        @Override
        protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
            synchronized (getClassLoadingLock(name)) {
                // First, check if the class has already been loaded
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    if (!name.startsWith("com.zgs")) {
                        c = this.getParent().loadClass(name);
                    } else {
                        c = findClass(name);
                    }

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    }

    public static void main(String[] args) throws Exception {
        MyClassLoaderTest01.MyClassLoader myClassLoader = new MyClassLoaderTest01.MyClassLoader("D:/study/资料/test");
        Class clazz = myClassLoader.loadClass("com.zgs.User1");
        Object instance = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(instance, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}
  • 结果
运行结果:
测试自定义类加载器
com.zgs.MyClassLoaderTest01$MyClassLoader

Tomcat打破双亲委派机制

  • 以Tomcat类加载为例,Tomcat 如果使用默认的双亲委派类加载机制行不行?我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:
  • 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  • 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
  • web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  • web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。

Tomcat 为什么不能使用双亲委派类加载机制?

  • 第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
    • 我们想我们要怎么实现jsp文件的热加载,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。
    • 那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

Tomcat自定义加载器详解

tomcat的几个主要类加载器:

  • commonLoader:
    Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:
    Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:
    各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:
    各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;

从图中的委派关系中可以看出:

  • CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。
  • WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
  • JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。

模拟实现Tomcat的webappClassLoader加载自己war包应用内不同版本类实现相互共存与隔离

代码如下

package com.zgs;

import java.io.FileInputStream;
import java.lang.reflect.Method;

/**
 * @author: guisong.zhang
 * @date: 2024/5/29 10:34:28
 * @description Tomcat打破双亲委派机制
 * 加载不同的版本
 **/
public class MyClassLoaderTest02 {
    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadBytes(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
            int available = fis.available();
            byte[] bytes = new byte[available];
            fis.read(bytes);
            fis.close();
            return bytes;
        }

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] bytes = loadBytes(name);
                return defineClass(name, bytes, 0, bytes.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

        @Override
        protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
            synchronized (getClassLoadingLock(name)) {
                // First, check if the class has already been loaded
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    if (!name.startsWith("com.zgs")) {
                        c = this.getParent().loadClass(name);
                    } else {
                        c = findClass(name);
                    }

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    }

    public static void main(String[] args) throws Exception {
        MyClassLoaderTest01.MyClassLoader myClassLoader = new MyClassLoaderTest01.MyClassLoader("D:/study/资料/test");
        Class clazz = myClassLoader.loadClass("com.zgs.User1");
        Object instance = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(instance, null);
        System.out.println(clazz.getClassLoader().getClass().getName());

        System.out.println("=========================================");

        MyClassLoaderTest01.MyClassLoader myClassLoader1 = new MyClassLoaderTest01.MyClassLoader("D:/study/资料/test1");
        Class clazz1 = myClassLoader1.loadClass("com.zgs.User1");
        Object instance1 = clazz1.newInstance();
        Method method1 = clazz1.getDeclaredMethod("sout", null);
        method1.invoke(instance1, null);
        System.out.println(clazz1.getClassLoader().getClass().getName());
    }
}

结果如下

测试自定义类加载器
com.zgs.MyClassLoaderTest01$MyClassLoader
=========================================
Tomcat打破双亲委派机制--另一个版本-测试自定义类加载器
com.zgs.MyClassLoaderTest01$MyClassLoader
  • 注意:同一个JVM内,两个相同包名和类名的类对象可以共存,因为他们的类加载器可以不一样,所以看两个类对象是否是同一个,除了看类的包名和类名是否都相同之外,还需要他们的类加载器也是同一个才能认为他们是同一个。

模拟实现Tomcat的JasperLoader热加载

  • 原理:后台启动线程监听jsp文件变化,如果变化了找到该jsp对应的servlet类的加载器引用(gcroot),重新生成新的JasperLoader加载器赋值给引用,然后加载新的jsp对应的servlet类,之前的那个加载器因为没有gcroot引用了,下一次gc的时候会被销毁。

Hotspot源码JVM启动执行main方法流程

Java语言的跨平台特性

查看jvm编译后的代码

  • java代码如下
package com.zgs;


/**
 * @author: guisong.zhang
 * @date: 2024/5/22 11:25:14
 * @description TODO
 **/
public class Math {
    public static final int initData = 666;
    public static User user = new User();

    public int compute() {  //一个方法对应一块栈帧内存区域
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }
}
  • javap -c Math.class编译后的代码
Compiled from "Math.java"
public class com.zgs.Math {
  public static final int initData;

  public static com.zgs.User user;

  public com.zgs.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: return

  static {};
    Code:
       0: new           #2                  // class com/zgs/User
       3: dup
       4: invokespecial #3                  // Method com/zgs/User."<init>":()V
       7: putstatic     #4                  // Field user:Lcom/zgs/User;
      10: return
}

如何判断对象是否已经死亡-对象内存回收

引用计数法

  • 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。
  • 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
  • 所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。
代码示例
 public class ReferenceCountingGc {
 Object instance = null;

 public static void main(String[] args) {
 ReferenceCountingGc objA = new ReferenceCountingGc();
 ReferenceCountingGc objB = new ReferenceCountingGc();
 objA.instance = objB;
 objB.instance = objA;
 objA = null;
 objB = null;
 }
 }

可达性分析算法

  • 将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象
  • GC Roots根节点:线程栈的本地变量(局部变量)、静态变量、本地方法栈的变量等等
finalize()方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
  • 第一次标记并进行一次筛选。
    筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,对象将直接被回收。
  • 第二次标记
    如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
调用finalize方法代码示例
package com.zgs;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 15:53:35
 * @description 测试finalize方法拯救自己不被回收
 **/
public class OOMTest {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        int i = 0;
        int j = 0;
        while (true) {
            list.add(new User(i++, UUID.randomUUID().toString()));
            new User(j--, UUID.randomUUID().toString());
        }
    }
}
package com.zgs;


/**
 * @author: guisong.zhang
 * @date: 2024/5/22 11:25:45
 * @description TODO
 **/
public class User {
    private int age;
    private String name;

    public User() {
    }

    public User(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void sout() {
        System.out.println("测试自定义类加载器");
    }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象即将被回收" + this.getAge());
    }
}
  • 会输出很多对象被回收的日志
调用finalize方法实现自救代码示例
package com.zgs;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 15:53:35
 * @description 测试finalize方法拯救自己不被回收
 **/
public class OOMTest {
    public static List<Object> list = new ArrayList<>();
    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        int i = 0;
        int j = 0;
        while (true) {
            list.add(new User(i++, UUID.randomUUID().toString()));
            new User(j--, UUID.randomUUID().toString());
        }
    }
}
package com.zgs;


/**
 * @author: guisong.zhang
 * @date: 2024/5/22 11:25:45
 * @description TODO
 **/
public class User {
    private int age;
    private String name;

    public User() {
    }

    public User(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public void sout() {
        System.out.println("测试自定义类加载器");
    }

    @Override
    protected void finalize() throws Throwable {
        OOMTest.list.add(this);
        System.out.println("对象即将被回收" + this.getAge());
    }
}

如何判断一个类是无用的类

  • 方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
  • 类需要同时满足下面3个条件才能算是 “无用的类” :
    1、该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    2、加载该类的 ClassLoader 已经被回收。
    3、该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

引用

1:强引用:

public static User user = new User();
  • 强引用就像我们生活中的必需品一样,不可少,也是使用最普遍的引用。垃圾回收器绝不会回收它.
  • 我们平时new了一个对象就是强引用,例如 Object obj = new Object();即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。

2:软引用:

  • 内存空间足够的时候不会回收,不足的时候就会回收,软引用可以用来实现内存敏感的高速缓存。
  • 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
  • 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
    (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
    (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
public static SoftReference<User> user = new SoftReference<User>(new User());

3:弱引用:

  • 不管内存空间够不够,在垃圾回收器线程扫描到他说管辖的区域的时候旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存.
  • 具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
public static WeakReference<User> user = new WeakReference<User>(new User());

4:虚引用:

  • 形同虚设,虚引用并不会觉得对象生命周期,如果一个对象仅仅持有虚引用,那么就和他没有任何引用一样,在任何时候都可能被垃圾回收。
  • 如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
  • 虚引用与软引用和弱引用的一个区别在于:
  • 虚引用必须和引用队列联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
  • 软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

什么时候会触发FullGC

  • JVM(Java虚拟机)中的Full GC(全面垃圾收集)是指对整个Java堆(包括年轻代、老年代)以及方法区的垃圾收集。Full GC通常比年轻代的垃圾收集(Minor GC)更耗时,因为它涉及整个堆。以下是一些常见的触发Full GC的情况:

老年代空间不足:

  • 当老年代(Tenured Generation)中的空间不足以容纳新的对象时,JVM会触发Full GC来清理不再使用的对象。

永久代或元空间满:

  • 在JDK 8之前,如果永久代(PermGen)空间不足,会触发Full GC。在JDK 8及以后版本中,永久代被元空间(Metaspace)替代,如果元空间满了,同样会触发Full GC。

System.gc()调用:

  • 当代码中显式调用System.gc()时,通常会触发Full GC。但是,实际行为取决于JVM实现和启动参数(如-XX:+DisableExplicitGC可以禁用这种调用的效果)。

JVM内部调用:

  • JVM在执行某些操作时可能会触发Full GC,例如在进行类卸载时。

分配大对象:

  • 如果要分配的对象非常大,超过了年轻代的最大空间,JVM可能会直接在老年代进行分配。如果老年代也无法满足这种大对象的分配请求,可能会触发Full GC。

并发模式失败:

  • 使用并发标记清除(CMS)垃圾收集器时,如果并发垃圾收集未能在老年代填满之前完成清理,将触发所谓的“并发模式失败”,此时会进行Full GC。

JVM启动参数设置不合理:

  • 某些JVM启动参数配置不当也可能导致频繁的Full GC,例如堆大小设置不合理。

统计得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间

  • 这是一个较为复杂的触发情况,Hotspot为了避免由于新生代对象晋升到旧生代导致旧生代空间不足的现象,在进行Minor GC时,做了一个判断,如果之前统计所得到的Minor GC晋升到旧生代的平均大小大于旧生代的剩余空间,那么就直接触发Full GC。
  • 例如程序第一次触发MinorGC后,有6MB的对象晋升到旧生代,那么当下一次Minor GC发生时,首先检查旧生代的剩余空间是否大于6MB,如果小于6MB,则执行Full GC。
  • 当新生代采用PSGC时,方式稍有不同,PS GC是在Minor GC后也会检查,例如上面的例子中第一次Minor GC后,PS GC会检查此时旧生代的剩余空间是否大于6MB,如小于,则触发对旧生代的回收。
  • 除了以上4种状况外,对于使用RMI来进行RPC或管理的Sun JDK应用而言,默认情况下会一小时执行一次Full GC。可通过在启动时通过- java-Dsun.rmi.dgc.client.gcInterval=3600000来设置Full GC执行的间隔时间或通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc

堆中分配很大的对象

  • 所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。

避免不必要的Full GC

  • 为了减少Full GC的发生,可以采取以下措施:

优化堆大小和各代大小:

  • 合理配置年轻代、老年代以及元空间的大小,使其适应应用程序的需求。

选择合适的垃圾收集器:

  • 根据应用的特点选择合适的垃圾收集器,如G1收集器对于需要低停顿时间的应用可能更合适。

避免在代码中显式调用System.gc():

  • 这种调用通常是不必要的,因为现代JVM的垃圾收集器能够很好地管理内存。

监控和分析GC日志:

  • 通过分析GC日志来了解垃圾收集的行为,从而进行针对性的优化。
  • 通过这些措施,可以有效地管理垃圾收集过程,减少Full GC的发生,从而提高应用程序的性能和响应速度。

调优命令有哪些?

jps

  • jps命令用于查询正在运行的JVM进程

jstat

  • jstat可以实时显示本地或远程JVM进程中类装载、内存、垃圾收集、JIT编译等数据
    /home/tools/jdk1.8.0_181/bin/jstat -gcutil 30386(java进程号) 2000

jinfo

  • jinfo用于查询当前运行这的JVM属性和参数的值

jmap

  • jmap用于显示当前Java堆和永久代的详细信息’
  • 命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件,生成dump的命令为:jmap -dump:live,format=b,file=文文件名(hprof后缀) 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。

jstack

  • jstack用于生成当前JVM的所有线程快照,线程快照是虚拟机每一条线程正在执行的方法,目的是定位线程出现长时间停顿的原因。

top命令

  • 通过top -Hp 23344可以查看该进程下各个线程的cpu使用情况

pidstat

  • pidstat实时查看一个进程的CPU使用情况及上下文切换情况

jmap -heap 29544 查看某一实例jvm配置

几种主要的JVM参数

堆栈配置相关

  • -Xmx3550m: 最大堆大小为3550m。
  • -Xms3550m: 设置初始堆大小为3550m。
  • -Xmn2g: 设置年轻代大小为2g。
  • -Xss128k: 每个线程的堆栈大小为128k。
  • -XX:MaxPermSize: 设置持久代大小为16m
  • -XX:NewRatio=4: 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。
  • -XX:SurvivorRatio=4: 设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
  • -XX:MaxTenuringThreshold=0: 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过- Survivor区,直接进入年老代。

垃圾收集器相关

  • -XX:+UseParallelGC: 选择垃圾收集器为并行收集器。
  • -XX:ParallelGCThreads=20: 配置并行收集器的线程数
  • -XX:+UseConcMarkSweepGC: 设置年老代为并发收集。
  • -XX:CMSFullGCsBeforeCompaction:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次GC以后对内存空间进行压缩、整理。
  • -XX:+UseCMSCompactAtFullCollection: 打开对年老代的压缩。可能会影响性能,但是可以消除碎片

调优工具

  • 常用调优工具分为两类,
  • jdk自带监控工具:jconsole和jvisualvm,
  • 第三方有:MAT(Memory AnalyzerTool)、GChisto。
  • jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存, 线程和类等的监控
  • jvisualvm,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等。
  • MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Javaheap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
  • GChisto,一款专业分析gc日志的工具

jvm为什么设置STW(stop the world)机制,能不能不停止用户线程。

  • 在垃圾收集的过程中,可能会随着程序的运行,随着一个方法执行完了,又会有新的垃圾对象生成,如果这样的话,会导致垃圾回收不完全。

JVM内存参数设置

  • Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
java ‐Xms2048MXmx2048MXmn1024MXss512KXX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eureka‐server.jar

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N

-XX:MaxMetaspaceSize:

  • 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。

-XX:MetaspaceSize:

  • 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整:
  • 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
  • 这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。
  • 由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

栈空间大小测试-StockOverFlowTest

不自己设置占空间小,默认1M。

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/5/31 15:18:08
 * @description 栈空间大小测试类
 * 栈空间默认大小为1m
 * 栈设置值:-Xss128k
 **/
public class StockOverFlowTest {
    static int count = 1;

    static void redo() {
        count++;
        redo();
    }

    public static void main(String[] args) {
        try {
            redo();
        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println(count);
        }
    }
}
  • 输出结果
27878

自己设置占空间小,设小一点,-Xss128k

  • 配置如图所示 VM options
  • 输出结果
1094

结论:

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。

二:JVM内存分配机制详解

JVM整体结构及内存模型

栈:

  • 每个线程独有,方法执行完成后相应的栈帧也会出栈并释放空间。
  • Java虚拟机栈也是线程私有的,生命周期和线程一样,java栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入java栈,每一个函数调用结束后,都会有一个栈帧被弹出。
  • Java虚拟机栈会出现两种错误:
    1:StackOverFlowError:如果java虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前java虚拟机栈允许的深度的时候就会抛出这个错误
    2:OutOfMemoryError:如果java虚拟机栈的内存大小不允许动态扩展,且当线程请求栈时内存用完了,就会抛出这个错。
局部变量表:
  • 当有一个方法执行的时候,就会在栈里面分配一块内存区域,用来存储这个线程中的局部变量,栈是一种先进后出的数据结构,是为了更好的保证内存的合理利用,后执行的方法数据会在上面,执行完就销毁,屯出栈空间,给其他线程使用,反而先进的后退出。因为一般一个方法会调用其他方法,主方法一般都是最后才退出被销毁的。
操作数栈:
  • 存放局部变量值得临时中转区域内存,最终会把之赋值给具体的局部变量,存放到局部变量表中。
动态链接:
  • 可以理解为将解析时的静态链接串起来,在方法区里面去找到原来那些静态链接(直接引用的)内存地址。这些静态的具体的代码。
  • 注意:
    在主方法的栈的局部变量表里面,会存放变量的在对内存中的内存地址。
方法出口:
  • 当方法执行完后,需要回到主方法继续执行,方法出口里面存放的就是主方法里面需要继续回到那一行代码继续执行的信息。
什么情况下会发生栈内存溢出。
  • 栈是线程私有的,他的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息。
  • 局部变量表又包含基本数据类型,对象引用类型如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,方法递归调用产生这种结果。
  • 如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是无法申请到足够的内存去完成扩展,或者在新建立线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将抛出一个OutOfMemory 异常。(线程启动过多)
  • 参数 -Xss 去调整JVM栈的大小

程序计数器:

  • 每个线程独有,每执行玩一行代码,字节码执行引擎就会去修改这个线程中的程序计数器的值,在线程执行过程中,会存在线程切换的情况,程序计数器用来记录当前线程执行到哪里了,当下次切换回来的时候可以继续从这个地方执行,可以把他理解为javap -c汇编之后的前面的具体的数子,可以将它当做代码的标识。实际上是存的某一行代码在内存中的地址。
  • 在多线程的时候,程序计数器记录当前线程的执行位置,当线程被切换回来的时候能知道该线程上次执行到哪里了。各个线程之间计数器互不影响,独立存储,线程私有,不共享。程序计数器不出现OOM(outOfMemoryError)的内存区域,他随着线程创建而创建,线程的结束而消亡。

方法区:

  • 线程共享,运行时常量池(final),静态变量(static),类信息(方法名称,代码等,我们通过javap -v编译后看到的那些代码都是放在方法区当中的),存放对象在堆空间中的内存地址。
  • 方法区也是各个线程共享的区域,用于存储已经被虚拟机加载的类的信息、常量、静态变量,我们通过javap -v编译后看到的那些代码就是放在方法区当中的。

本地方发栈:

  • 每个线程独有,native方法(用c++语言实现),针对本地方法执行分配的内存。
  • 和虚拟机栈的作用差不多,区别是:虚拟机栈为虚拟机执行java方法(也就是字节码服务),而本地方法栈则为虚拟机使用到的Native方法服务。本地方法执行的时候,在本地方法栈会创建一个栈帧。用于存放本地方法的局部变量表、操作数栈。动态链接、出口信息。方法执行完成后相应的栈帧也会出栈并释放空间。

堆:

  • 线程共享,分为年轻代(1/3),老年代(2/3),年轻代又分为Eden(8/10),s0(1/10),s1(1/10)。
  • new的对象一般放在Eden区。
  • 虚拟机中所管理的最大的一块内存,线程共享,存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。现在收集器基本都是采用的分代垃圾收集算法。所有java对还可以细分为新生代和老年代,再细致一点有:Eden空间,From Survivor、ToSurvivor空间等等
  • 堆内存最容易出现OOM的错误:
    1:OutOfMemoryError: GC Overhead Limit Exceeded:当jvm花费很多的时间执行垃圾回收但是只能回收很少的对空间的时候就会发生这个错误。
    2:java.lang.OutOfMemoryError: Java heap space:当堆的空间不足以存放你新建的对象所需要的空间的时候就会报这个错。

JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。

共享内存区划分

共享内存区 = 持久带 + 堆
持久带 = 方法区 + 其他
Java堆 = 老年代 + 新生代
新生代 = Eden + S0 + S1

参数的配置

  • 默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ,可以通过参数 –XX:NewRatio 配置。
  • 默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定)
  • Survivor区中的对象被复制次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold)

为什么要分为Eden和Survivor?为什么要设置两个Survivor区?

  • 如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC,老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。
  • survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
  • 设置两个Survivor区最大的好处就是解决了碎片化,
  • 刚刚新建的对象在Eden中,经历一次MinorGC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;
  • 等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)

JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代

Java堆 = 老年代 + 新生代
新生代 = Eden + S0 + S1

  • 当 Eden 区的空间满了, Java虚拟机会触发一次 Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到 Survivor区。
  • 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年态;
  • 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态。
  • 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代。Major GC 发生在老年代的GC,清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上。

对象的创建主要流程

1. 类加载检查:

  • 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
  • 如果没有,那必须先执行相应的类加载过程。new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。

2.分配内存

  • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
这个步骤有两个问题:

1.如何划分内存。
2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况

划分内存的方法:
  • “指针碰撞”(Bump the Pointer)(默认用指针碰撞)
    如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  • “空闲列表”(Free List)
    如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
解决并发问题的方法:
  • CAS(compare and swap)
    虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
    把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过­XX:+/­UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+UseTLAB),­XX:TLABSize 指定TLAB大小。

3.初始化

  • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头

  • 初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。
  • 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
  • 对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。
  • HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。
  • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位对象头

64位对象头

5.执行init方法

  • 执行init方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
  • 如下图所示Math类的字节码文件中,调用main方法的时候,new Math类的时候,就会效用init方法,由底层c++调用。

如何查看对象大小与指针压缩

对象大小可以用jol­core包查看,引入依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.16</version>
</dependency>

代码如下

package com.zgs;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author: guisong.zhang
 * @date: 2024/5/31 17:08:02
 * @description 通过jol包查看对象大小与指针压缩
 **/
public class JOLSample {
    public static void main(String[] args) {
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());

        System.out.println();
        ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
        System.out.println(layout1.toPrintable());

        System.out.println();
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());
    }

    // ‐XX:+UseCompressedOops 默认开启的压缩所有指针
    // ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
    // Oops : Ordinary Object Pointers
    public static class A {
        //8B mark word
        //4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B
        int id; //4B
        String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
        byte b; //1B
        Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
    }
}

默认开启指针压缩运行结果如下

java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0xf80001e5
 12   4        (object alignment gap)     -- 对齐填充  
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total


[I object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0xf800016d
 12   4        (array length)            0
 12   4        (alignment/padding gap)    -- 对齐填充 
 16   0    int [I.<elements>             N/A
Instance size: 16 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total


com.zgs.JOLSample$A object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                    (object header: class)    0xf800e122
 12   4                int A.id                      0
 16   1               byte A.b                       0
 17   3                    (alignment/padding gap)   -- 对齐填充   
 20   4   java.lang.String A.name                    null
 24   4   java.lang.Object A.o                       null
 28   4                    (object alignment gap)    -- 对齐填充   
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

禁止指针压缩后的结果

  • 禁止指针压缩 VM options
  • 结果
java.lang.Object object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   8        (object header: class)    0x000000001bf51c00
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total


[I object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   8        (object header: class)    0x000000001bf50b68
 16   4        (array length)            0
 16   8        (alignment/padding gap)   -- 对齐填充
 24   0    int [I.<elements>             N/A
Instance size: 24 bytes
Space losses: 8 bytes internal + 0 bytes external = 8 bytes total


com.zgs.JOLSample$A object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   8                    (object header: class)    0x000000001c60ffa0
 16   4                int A.id                      0
 20   1               byte A.b                       0
 21   3                    (alignment/padding gap)   -- 对齐填充
 24   8   java.lang.String A.name                    null
 32   8   java.lang.Object A.o                       null
Instance size: 40 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total

结果对比结论

  • 很多原先四个字节存储的数据都变成了八个字节存储,所以从jdk6默认开启指针压缩,这样每个对象的大小就会减小,同样大小的空间下可以存储更多的对象。

对其填充的必要性:

  • 首先对其填充就是要保证对象的大小是八个字节的整数倍,
  • 这是为什么呢,因为这样我们在进行对象寻址的时候可以更快地找到对象。
  1. 提高 CPU 缓存利用率
    CPU 访问内存时,通常会按照一定的块大小(称为缓存行)来加载内存内容到 CPU 缓存中。如果对象的起始地址没有对齐到缓存行的边界,那么对象可能跨越两个缓存行。这种情况(称为缓存行分裂)会导致 CPU 需要加载额外的缓存行来访问整个对象,从而降低缓存的效率。
  2. 减少内存访问延迟
    当对象地址与 CPU 的内存访问对齐要求一致时,内存访问操作更加高效。不对齐的内存访问可能需要额外的 CPU 周期来处理,从而增加延迟。
  3. 避免假共享(False Sharing)
    假共享是多线程程序中的一个问题,当多个线程访问同一个缓存行中的不同变量时,即使这些变量之间没有逻辑上的共享,也会因为物理位置的接近而导致性能问题。通过对齐填充,可以确保相关的数据结构在内存中的布局更加合理,减少这种问题的发生。
  4. 满足硬件要求
    某些硬件平台对数据的内存对齐有严格要求。不满足这些要求的内存访问可能会导致硬件异常或程序崩溃。在这些平台上,对齐填充确保所有对象的内存地址都符合硬件要求。
  5. 内存管理简化
    对齐填充简化了内存管理的实现。对齐的对象使得内存分配算法更简单,回收和重用内存块也更高效。
  • JVM 的实现细节
    在 JVM 中,对象通常在 8 字节边界上对齐。这意味着对象的起始地址是 8 的倍数。此外,JVM 还可能在对象末尾添加填充,以确保整个对象的大小是对齐边界的倍数,这有助于维持整个堆的对齐。
  • 总之,对齐填充在 JVM 中是一种重要的内存管理策略,它有助于提高程序的性能,确保硬件兼容性,并简化内存管理的实现。对于开发者而言,通常不需要直接处理对齐填充的细节,但理解其背后的原理有助于更好地理解和优化 Java 应用的性能。

什么是java对象的指针压缩?

  • jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
  • jvm配置参数:UseCompressedOops,compressed­­压缩、oop(ordinary object pointer)­­对象指针
  • 启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops
  • ‐XX:+UseCompressedOops 默认开启的压缩所有指针
  • ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer

为什么要进行指针压缩?

  • 在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。
  • 为了减少64位平台下内存的消耗,启用指针压缩功能。
  • 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)。
  • 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间。
  • 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好。

对象内存分配

对象栈上分配

  • 我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。
  • 为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析:

  • 就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
  • 例如以下代码:
package com.zgs.demo.jdk;

public class UserTest {
    public User test1() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
        //TODO 保存到数据库
        return user;
    }

    public void test2() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
        //TODO 保存到数据库
    }
}

  • 很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,
  • test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
  • JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),
  • JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换

  • 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,
  • 这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。

标量与聚合量:

  • 标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),
  • 标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。
  • 而在JAVA中对象就是可以被进一步分解的聚合量。

栈上分配代码示例:

  • 默认情况下,调小堆的内存大小为15M,开启gc日志后运行
package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/3 16:49:55
 * 栈上分配,标量替换
 * 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
 * <p>
 * 使用如下参数不会发生GC
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * 使用如下参数都会发生大量GC
 * -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 */
public class AllotOnStack {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    private static void alloc() {
        User user = new User();
        user.setAge(1);
        user.setName("zhuge");
    }
}
  • 结论:栈上分配依赖于逃逸分析和标量替换

对象在Eden区分配

  • 大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。我们来进行实际测试一下。
  • 在测试之前我们先来看看 Minor GC和Full GC 有什么不同呢?
  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

Eden与Survivor区默认8:1:1

  • 大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,
  • 把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,
  • JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy

内存分配流转代码示例

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 10:33:30
 * @description 对象在Eden区分配 gc测试
 * 添加运行JVM参数: -XX:+PrintGCDetails
 **/
public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
        allocation1 = new byte[60000 * 1024];
        //allocation2 = new byte[8000*1024];

         /*allocation3 = new byte[1000*1024];
         allocation4 = new byte[1000*1024];
         allocation5 = new byte[1000*1024];
         allocation6 = new byte[1000*1024];*/

    }
}

结果

Heap
 PSYoungGen      total 75264K, used 6484K [0x000000076c180000, 0x0000000771580000, 0x00000007c0000000)
  eden space 64512K, 10% used [0x000000076c180000,0x000000076c7d53f0,0x0000000770080000)
  from space 10752K, 0% used [0x0000000770b00000,0x0000000770b00000,0x0000000771580000)
  to   space 10752K, 0% used [0x0000000770080000,0x0000000770080000,0x0000000770b00000)
 ParOldGen       total 172032K, used 60000K [0x00000006c4400000, 0x00000006cec00000, 0x000000076c180000)
  object space 172032K, 34% used [0x00000006c4400000,0x00000006c7e98010,0x00000006cec00000)
 Metaspace       used 3293K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K
我们可以看出eden区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用至少几M内存)。假如我们再为allocation2分配内存会出现什么情况呢?

为allocation2分配内存代码示例

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 10:33:30
 * @description 对象在Eden区分配 gc测试
 * 添加运行JVM参数: -XX:+PrintGCDetails
 **/
public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
        allocation1 = new byte[60000 * 1024];
        allocation2 = new byte[8000*1024];

         /*allocation3 = new byte[1000*1024];
         allocation4 = new byte[1000*1024];
         allocation5 = new byte[1000*1024];
         allocation6 = new byte[1000*1024];*/

    }
}

结果

Heap
 PSYoungGen      total 75264K, used 14485K [0x000000076c180000, 0x0000000771580000, 0x00000007c0000000)
  eden space 64512K, 22% used [0x000000076c180000,0x000000076cfa5400,0x0000000770080000)
  from space 10752K, 0% used [0x0000000770b00000,0x0000000770b00000,0x0000000771580000)
  to   space 10752K, 0% used [0x0000000770080000,0x0000000770080000,0x0000000770b00000)
 ParOldGen       total 172032K, used 60000K [0x00000006c4400000, 0x00000006cec00000, 0x000000076c180000)
  object space 172032K, 34% used [0x00000006c4400000,0x00000006c7e98010,0x00000006cec00000)
 Metaspace       used 3293K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K

简单解释一下为什么会出现这种情况: 因为给allocation2分配内存的时候eden区内存几乎已经被分配完了,我当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GCGC期间虚拟机又发现allocation1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放allocation1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。

继续额那个jvm中加入数据,执行如下代码验证:

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 10:33:30
 * @description 对象在Eden区分配 gc测试
 * 添加运行JVM参数: -XX:+PrintGCDetails
 **/
public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
        allocation1 = new byte[60000 * 1024];
        allocation2 = new byte[8000*1024];

         allocation3 = new byte[1000*1024];
         allocation4 = new byte[1000*1024];
         allocation5 = new byte[1000*1024];
         allocation6 = new byte[1000*1024];
    }
}

结果如下

Heap
 PSYoungGen      total 75264K, used 17485K [0x000000076c180000, 0x0000000771580000, 0x00000007c0000000)
  eden space 64512K, 27% used [0x000000076c180000,0x000000076d293430,0x0000000770080000)
  from space 10752K, 0% used [0x0000000770b00000,0x0000000770b00000,0x0000000771580000)
  to   space 10752K, 0% used [0x0000000770080000,0x0000000770080000,0x0000000770b00000)
 ParOldGen       total 172032K, used 60000K [0x00000006c4400000, 0x00000006cec00000, 0x000000076c180000)
  object space 172032K, 34% used [0x00000006c4400000,0x00000006c7e98010,0x00000006cec00000)
 Metaspace       used 3293K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K
可以发现这些新的1M的对象都放入了Eden区中。

jvm中 什么情况下对象会进入老年代

  • 在 JVM 中,对象进入老年代(Old Generation 或 Tenured Generation)通常是由以下几种情况引起的:

提升失败(Promotion Failure):

在 Minor GC 过程中,如果新生代中的对象因为老年代的空间不足而无法晋升到老年代,会触发一次 Full GC,尝试清理老年代空间并完成晋升。如果 Full GC 后老年代仍然没有足够空间,这些对象可能会留在新生代,直到下一次 GC。

To-space exhausted:

在使用复制算法的新生代收集器(如 ParNew、G1 Young Generation)中,如果在 Minor GC 过程中 to-space(复制算法中用于存放存活对象的区域)不足以容纳所有存活的对象,这些对象会被晋升到老年代。

通过这些机制,JVM 管理着对象从新生代到老年代的晋升,以优化内存使用和垃圾收集性能。

大对象直接进入老年代

  • 对于大对象(例如大数组),JVM 可能会直接在老年代进行分配,以避免在新生代中频繁复制。这个阈值可以通过 -XX:PretenureSizeThreshold 参数设置,超过这个大小的对象直接在老年代分配。
  • 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,**这个参数只在 Serial 和ParNew两个收集器下有效。**所以如果想要让大对象直接进入老年代,那么只能使用者两个垃圾收集器。
  • 比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代。
  • 为什么要这样呢?
    为了避免为大对象分配内存时的复制操作而降低效率。
  • 验证大对象直接进入老年代,代码示例如下
    1:添加大对象参数 -XX:+PrintGCDetails -XX:PretenureSizeThreshold=10000 -XX:+UseSerialGC(注意这里要搭配UseSerialGC使用)

2:代码如下

package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/4 10:33:30
 * @description 对象在Eden区分配 gc测试
 * 添加运行JVM参数: -XX:+PrintGCDetails
 **/
public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
//        allocation1 = new byte[60000 * 1024];
        allocation2 = new byte[8000*1024];

//         allocation3 = new byte[1000*1024];
//         allocation4 = new byte[1000*1024];
//         allocation5 = new byte[1000*1024];
//         allocation6 = new byte[1000*1024];
    }
}
3:结果如下
Heap
 def new generation   total 77440K, used 6892K [0x00000006c4400000, 0x00000006c9800000, 0x00000007182a0000)
  eden space 68864K,  10% used [0x00000006c4400000, 0x00000006c4abb348, 0x00000006c8740000)
  from space 8576K,   0% used [0x00000006c8740000, 0x00000006c8740000, 0x00000006c8fa0000)
  to   space 8576K,   0% used [0x00000006c8fa0000, 0x00000006c8fa0000, 0x00000006c9800000)
 tenured generation   total 172032K, used 8000K [0x00000007182a0000, 0x0000000722aa0000, 0x00000007c0000000)
   the space 172032K,   4% used [0x00000007182a0000, 0x0000000718a70010, 0x0000000718a70200, 0x0000000722aa0000)
 Metaspace       used 3293K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 361K, capacity 388K, committed 512K, reserved 1048576K
  可以发现这个8M的对象直接进入了老年代

长期存活的对象将进入老年代

  • 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。
  • 为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间中,并将对象年龄设为1。
  • 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。
  • 对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断进入老年代

  • 当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,
  • 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制进入老年代

  • 年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间,如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了如果有这个参数,
  • 就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。
  • 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,
  • 如果回收完还是没有足够空间存放新的对象就会发生"OOM"当然,
  • 如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,fullgc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

亿级流量电商系统如何优化JVM参数设置(ParNew+CMS)

  • 大型电商系统后端现在一般都是拆分为多个子系统部署的,比如,商品系统,库存系统,订单系统,促销系统,会员系统等等。
  • 我们这里以比较核心的订单系统为例

正常情况下的参数设置

  • 对于8G内存,我们一般是分配4G内存给JVM,正常的JVM参数配置如下:
  • 堆:3G;老年代2G,年轻代1G
  • 方法区:256M
  • Eden:s0:s1=8:1:1;那么Eden=800M,s0=100M,s1=100M.
Xms3072MXmx3072MXss1MXX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
  • 如果按照上述的参数内存设置,那么运行14s后,就会有14x60M对象接近800M对象放入Eden区,Eden区就会被放满。
  • 就会触发Minor GC,就会stop the world,前13秒产生的对象会被回收掉,而最近1秒产生的60M对象会被移动到s区,假如就是s0区。
  • 然后由于对象动态年龄判断机制,这批60M的对象会进入老年代。
  • 按照这个速度,老年代2G的内存几分钟就会被放满,就会频繁的触发full GC,stop the world更久。这肯定是不允许的。
  • 回过头来思考,我们可以发现老年代中的很多对象其实早就没用了,早就是垃圾对象了,然而由于对象动态年龄判断机制,导致起进入了老年代,我们何不让其早点消亡呢。
  • 那么我们可以尝试把年轻代空间调大一点,使其不会轻易地触发对象动态年龄判断机制是对象进入老年代。
  • 于是我们可以更新下JVM参数设置,如下图所示。
Xms3072MXmx3072MXmn2048MXss1MXX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M‐XX:SurvivorRatio=8

  • 这样就降低了因为对象动态年龄判断原则导致的对象频繁进入老年代的问题,因为60M没有超过200M的一半,所以不会触发对象动态年龄判断机制。
  • 其实很多优化无非就是让短期存活的对象尽量都留在survivor里,不要进入老年代,这样在minor gc的时候这些对象都会被回收,不会进到老年代从而导致fullgc。
  • 对于对象年龄应该为多少才移动到老年代比较合适,本例中一次minor gc要间隔二三十秒,大多数对象一般在几秒内就会变为垃圾,完全可以将默认的15岁改小一点。
  • 比如改为5,那么意味着对象要经过5次minor gc才会进入老年代,整个时间也有一两分钟了,如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象,可以移动到老年代,而不是继续一直占用survivor区空间。
  • 对于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),这个一般可以结合你自己系统看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象。
  • 可以适当调整JVM参数如下:
Xms3072MXmx3072MXmn2048MXss1MXX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M‐XX:SurvivorRatio=8XX:MaxTenuringThreshold=5XX:PretenureSizeThreshold=1M
  • 对于JDK8默认的垃圾回收器是-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代),如果内存较大(超过4个G,只是经验值),系统对停顿时间比较敏感,我们可以使用ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)
  • 对于老年代CMS的参数如何设置我们可以思考下,首先我们想下当前这个系统有哪些对象可能会长期存活躲过5次以上minor gc最终进入老年代。
  • 无非就是那些Spring容器里的Bean,线程池对象,一些初始化缓存数据对象等,这些加起来充其量也就几十MB。
  • 还有就是某次minor gc完了之后还有超过一两百M的对象存活,那么就会直接进入老年代,比如突然某一秒瞬间要处理五六百单,那么每秒生成的对象可能有一百多M,再加上整个系统可能压力剧增,一个订单要好几秒才能处理完,下一秒可能又有很多订单过来
  • 我们可以估算下大概每隔五六分钟出现一次这样的情况,那么大概半小时到一小时之间就可能因为老年代满了触发一次Full GC,Full GC的触发条件还有我们之前说过的老年代空间分配担保机制,历次的minor gc挪动到老年代的对象大小肯定是非常小的,
  • 所以几乎不会在minor gc触发之前由于老年代空间分配担保失败而产生full gc,其实在半小时后发生full gc,这时候已经过了抢购的最高峰期,后续可能几小时才做一次FullGC。
  • 对于碎片整理,因为都是1小时或几小时才做一次FullGC,是可以每做完一次就开始碎片整理,或者两到三次之后再做一次也行。
  • 综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如下所示:
1Xms3072MXmx3072MXmn2048MXss1MXX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
2XX:MaxTenuringThreshold=5XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGCXX:+UseConcMarkSweepGC
3XX:CMSInitiatingOccupancyFraction=92XX:+UseCMSCompactAtFullCollectionXX:CMSFullGCsBeforeCompaction=0

三:垃圾收集器

垃圾回收算法

1:标记-清除算法:

  • 分为标记–清除两个阶段,首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
  • 两个很明显的问题
  • 1:效率;如果需要标记的太多,效率不高。
  • 2:空间问题;标记清除后会产生大量的不连续的碎片。

2:复制算法

  • 将内存大小相同的两块,每次使用其中的一块。当这一块内存用完过后,就讲还存活的对象复制到另外一块中,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
  • 复制算法问题:浪费空间,因为始终有一块区域被闲置。

3:标记-整理算法

  • 根据老年代的特点特出的一种标记算法,标记过程与标记-清除算法一样。但那后面不是直接回收可回收对象,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

4:分代收集算法

  • 根据对象存活的周期将内存分为几块,一般将java堆分为新生代和老年代,这样就可以根据各个代的特征选择合适的垃圾收集方法。
  • 比如在新生代中,每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
  • 注意,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。

垃圾收集底层算法实现

三色标记

  • GC ROOT 标记的是非垃圾对象。也就是被引用的对象。
  • 在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。多标是没事的,最多产生浮动垃圾,等下次再回收,而漏标如果不解决的话可能会导致被引用的对象被回收掉,那么就是非常严重的bug了。
  • 这里我们引入“三色标记”来给大家解释下,把Gcroots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
  • 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
  • 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
  • 白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
package com.zgs;

/**
 * @author: guisong.zhang
 * @date: 2024/6/5 17:00:27
 * @description 垃圾收集算法细节之三色标记
 **/
public class ThreeColorRemark {
    public static void main(String[] args) {
        A a = new A();
        //开始做并发标记

        // 1.读
        D d = a.b.d;

        //2.写
        a.b.d = null;

        //3.写
        a.d = d;
    }

    static class A {
        B b = new B();

        D d = null;
    }

    static class B {
        C c = new C();
        D d = new D();

    }

    static class C {

    }

    static class D {

    }
}
多标-浮动垃圾
  • 在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存。
  • 这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。
  • 另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分。
漏标-读写屏障
  • 漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,
  • 有两种解决方案: 增量更新(IncrementalUpdate) 和原始快照(Snapshot At The Beginning,SATB)
  • 增量更新
  • 就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 再将这些记录过的引用关系中的黑色对象为根, 重新扫描一次。 这可以简化理解为, 黑色对象一旦新插入了指向白色对象的引用之后, 它就变回灰色对象了。
  • 原始快照
  • 就是当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
写屏障
  • 给某个对象的成员变量赋值时,其底层代码大概长这样
/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}

  • 所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):
 void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障‐写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障‐写后操作
}
写屏障实现SATB
  • 当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来成员变量的引用对象D记录下来:
 void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象
}
写屏障实现增量更新
  • 当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员变量引用对象D记录下来:
void post_write_barrier(oop* field, oop new_value) {
remark_set.add(new_value); // 记录新引用的对象
}
读屏障
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障‐读取前操作
return *field;
}
  • 读屏障是直接针对第一步:D d = a.b.d,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
  • 现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
  • CMS:写屏障 + 增量更新
  • G1 Shenandoah:写屏障 + SATB
  • ZGC:读屏障
    工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新
  • SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,
  • 而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,
  • 所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
记忆集与卡表
  • 在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。
  • 为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。
  • 事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。
  • 垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
  • hotspot使用一种叫做“卡表”(cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系,可以类比为Java语言中HashMap与Map的关系。
  • 卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”,卡表的每个元素其中还记录着卡页的在内存中的起始地址与终止地址。
  • hotSpot使用的卡页是2^9大小,即512字节
  • 一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0。所以在垃圾收集新生代的时候就回去看卡表中为1的元素,然后就可以找到其在老年代中的具体位置,然后把这些内存空间加入到垃圾收集器扫描的范围当中去。
  • GC时,只要筛选本收集区的卡表中变脏的元素加入GCRoots里。
卡表的维护
  • 卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。Hotspot使用写屏障维护卡表状态。

垃圾收集器汇总

  • 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
  • 虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
  • 试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。

1:serial(串行)收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)

  • 单线程垃圾收集器,她只会使用一条垃圾收集线程去完成垃圾收集工作,它进行垃圾收集的时候,必须暂停其他的所有的工作的线程。直达他收集结束。
  • 它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。
  • Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:
  • 一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,
  • 另一种用途是作为CMS收集器的后备方案。
  • 新生代采用复制算法,老年代采用标记-清除/标记-整理算法。

2:Parallel Scavenge收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))

  • Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
  • Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
  • 所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 ParallelScavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。
  • 相比于ParNew收集器,Parallel Scavenge收集器更注重于提高吞吐量(高效率使用CPU),CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。
  • Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
  • 新生代采用复制算法,老年代采用标记-整理算法。

3:ParNew收集器(-XX:+UseParNewGC)

  • 使用多线程进行垃圾收集外其他行为(控制参数,收集算法,回收策略)和serial收集器一样。
  • ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
  • 新生代采用复制算法,老年代采用标记-整理算法。

4:CMS收集器(-XX:+UseConcMarkSweepGC(old))

  • CMS收集器以用户体验为第一位,争取获取最短的停顿时间为目标的收集器。它是第一款真正意义上的并发收集器。第一次实现了让垃圾收集线程和用户线程同时工作。CMS收集器采用的是标记-清除的算法
  • 优点:并发收集,停顿时间短。
  • 缺点:
    1:对CPU资源敏感,会和服务器抢资源
    2:无法处理浮动垃圾;使用的是标记-清除算法,会导致收集结束的时候,会有大量的空间碎片产生。当然通过参数-XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理。
    3:执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收
  • 从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

初始标记:

  • 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。

并发标记:

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

重新标记:

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

并发清理:

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

并发重置:

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

CMS的相关核心参数

-XX:+UseConcMarkSweepGC:启用cms
-XX:ConcGCThreads:并发的GC线程数
-XX:+UseCMSCompactAtFullCollectionFullGC之后做压缩整理(减少碎片)
-XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
-XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMSGC耗时 80%都在标记阶段
-XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;

5:G1收集器

  • 主要针对配备多颗处理器以及大容量的机器。以及高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
  • 并行和并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU来缩短停顿时间,并且可以通过并发的方式放满java程序(用户线程)不用停顿下来继续执行。
  • 分代收集:G1不要求与其他收集器配合使用能独立管理GC堆,保留了分带的概念。
  • 空间整合:采用标记-整理算法实现收集器,从局部上看是基于复制算法实现的。

四:垃圾收集器-G1收集器(-XX:+UseG1GC)

G1收集器原理详解

  • G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。G1不再存在物理上的分区,取而代之的是一种逻辑上的分区。

  • G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。
  • 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。
  • G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。
  • 默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比
  • 在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。
  • 年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。
  • 一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
  • G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。
  • 在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
  • Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
  • Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
  • 一开始的这些空间是没有具体的属性的,只有默认的5%会标记为Eden和S属性的空间,然后随着慢慢的使用,就会为不同的Region区设置其对应的属性,比如为为Eden或者S或者old或者Humongous。
  • 他不是一开始就规定好了哪一些Region属于什么属性的,而是随着空间的使用才去定义的。

G1收集器一次GC的运作过程大致分为以下几个步骤:

初始标记(initial mark,STW):

  • 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;

并发标记(Concurrent Marking):

  • 同CMS的并发标记

最终标记(Remark,STW):

  • 同CMS的重新标记

筛选回收(Cleanup,STW):

  • 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划。
  • 比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。
  • 这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
  • 不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片
  • (注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)。
  • G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。
  • 比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。
  • 这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。

JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:

并行与并发:

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

分代收集:

  • 虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。

空间整合:

  • 与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

可预测的停顿:

  • 这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。

总结

  • 毫无疑问, 可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
  • 不过, 这里设置的“期望值”必须是符合实际的, 不能异想天开, 毕竟G1是要冻结用户线程来复制对象的, 这个停顿时间再怎么低也得有个限度。
  • 它默认的停顿目标为两百毫秒, 一般来说, 回收阶段占到几十到一百甚至接近两百毫秒都很正常, 但如果我们把停顿时间调得非常低, 譬如设置为二十毫秒, 很可能出现的结果就是由于停顿目标时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。
  • 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

G1垃圾收集分类

YoungGC

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

MixedGC

  • 不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区。
  • 正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC。

Full GC

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

G1收集器参数设置

-XX:+UseG1GC:使用G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
-XX:G1MaxNewSizePercent:新生代内存最大空间
-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%)Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n()以上的对象都放入老年代
-XX:MaxTenuringThreshold:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC-XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了

G1垃圾收集器优化建议

  • 假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。
  • 那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。
  • 或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中
  • 所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc。

什么场景适合使用G1

  1. 50%以上的堆被存活对象占用
  2. 对象分配和晋升的速度变化非常大
  3. 垃圾回收时间特别长,超过1秒
  4. 8GB以上的堆内存(建议值)
  5. 停顿时间是500ms以内

每秒几十万并发的系统如何优化JVM

  • Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,
  • 这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?
  • 很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。
  • 那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
  • G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。

五:垃圾收集器-ZGC收集器(-XX:+UseZGC)

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

ZGC目标

如下图所示,ZGC的目标主要有4个:

支持TB量级的堆。

  • 我们生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有JAVA应用的需求了吧。

最大GC停顿时间不超10ms。

  • 目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的。

奠定未来GC特性的基础。

最糟糕的情况下吞吐量会降低15%。

  • 这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它最大的优点是:
  • 它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。

不分代(暂时)

  • 单代,即ZGC「没有分代」。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。
  • 那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本,后续会优化。

ZGC内存布局

  • ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。
  • ZGC的Region可以具有如图所示的大、 中、 小三类容量:
  • 小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。
  • 中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
  • 大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。
  • 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。
  • 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂。

NUMA-aware

  • NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory AccessArchitecture。
  • UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。
  • NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了。如下图所示:
  • 服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的。

颜色指针

  • Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。

每个对象有一个64位指针,这64位被分为:

  • 18位:预留给以后使用;
  • 1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
  • 1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
  • 1位:Marked1标识;
  • 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
  • 42位:对象的地址(所以它可以支持2^42=4T内存):

为什么有2个mark标记?

  • 每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
  • GC周期1:使用mark0, 则周期结束所有引用mark标记都会成为01。
  • GC周期2:使用mark1, 则期待的mark标记10,所有引用都能被重新标记。
  • 通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。

颜色指针的三大优势:

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
  2. 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
  3. 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

读屏障

  • 之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。
  • 在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。那么我们该如何理解它呢?
  • 看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。
  • 如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。
  • 这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。
  • 那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可:
  • 这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。
  • 后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。
  • 正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:
  • 那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。
  • PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?
  • 答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。

ZGC运作过程

  • ZGC的运作过程大致可划分为以下四个大的阶段:

并发标记(Concurrent Mark):

  • 与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,
  • 与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针中的Marked 0、 Marked 1标志位。

并发预备重分配(Concurrent Prepare for Relocate):

  • 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
  • ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

并发重分配(Concurrent Relocate):

  • 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
  • ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,
  • 并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力
1 ZGC的颜色指针因为“自愈”(SelfHealing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后,
2 这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。

并发重映射(Concurrent Remap):

  • 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。
  • ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。
  • 一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

ZGC存在的问题

浮动垃圾

  • ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。
  • 假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。

ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。

  • 解决方案
  • 目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

ZGC参数设置

  • 启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。
  • 它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成。下图所示是ZGC可以调优的参数:

ZGC触发时机

  • ZGC目前有4中机制触发GC:
    1:定时触发,默认为不使用,可通过ZCollectionInterval参数配置。
    2:预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。
    3:分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
    4:主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。

如何选择垃圾收集器

  1. 优先调整堆的大小让服务器自己来选择
  2. 如果内存小于100M,使用串行收集器
  3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
  4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选
  5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器
  6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC

下图有连线的可以搭配使用

  • JDK 1.8默认使用 Parallel(年轻代和老年代都是)
  • JDK 1.9默认使用 G1

安全点与安全区域

安全点

  • 安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,
  • 比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。这些特定的安全点位置主要有以下几种:
  1. 方法返回之前
  2. 调用某个方法之后
  3. 抛出异常的位置
  4. 循环的末尾
  • 大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位,
  • 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。

安全区域又是什么?

  • Safe Point 是对正在执行的线程设定的。
  • 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。
  • 因此 JVM 引入了 Safe Region。
  • Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。

六:JVM调优工具详解及调优实战

七:JVM调优实战&常量池详解&GC日志分析

八:JDK示例代码git地址

九:JDK流程图

本文标签: 基础JavaJVM