深入理解JVM

深入理解JVM

1 JVM概述

1.1 概述

​ JVM全称Java Virtual Machine,即Java虚拟机。它本身是一个虚拟计算机。Java虚拟机基于二进制字节码执行,由一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆、一个方法区等组成。JVM屏蔽了与操作系统平台相关的信息,从而能够让Java程序只需要生成能够在JVM上运行的字节码文件。通过该机制实现的跨平台性。因此这也是为什么说Java能够做到“一处编译、处处运行”的原因。

1.2 JVM生命周期

​ JVM的生命周期分为三个阶段,分别为:启动、运行、死亡。

  • 启动:

    当启动一个Java程序时,JVM的实例就已经产生。对于拥有main函数的类就是JVM实例运行的起点。

  • 运行:

    main()方法是一个程序的初始起点,任何线程均可由在此处启动。在JVM内部有两种线程类型,分别为:用户线程和守护线程。JVM通常使用的是守护线程,而main()使用的则是用户线程。守护线程会随着用户线程的结束而结束。

  • 死亡:

    当程序中的用户线程都中止,JVM才会退出。

1.3 内存结构

​ JVM内存结构是JVM学习中非常重要的一部分,并且在JDK7和JDK8中也进行了一些改动。

​ 内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存结构规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MOrrtNXk-1687956309736)(assets/image-20201019231114563.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SHrkQEiI-1687956309737)(assets/image-20201019231126133.png)]

虚拟机栈:

​ 线程私有的,虚拟机栈对应方法调用到执行完成的整个过程。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】。不需要进行GC。

本地方法栈:

​ 与虚拟机栈类似。本地方法栈是为虚拟机执行本地方法时提供服务的。不需要进行GC。本地方法一般是由其他语言编写。

程序计数器:

​ 线程私有的。内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-test8dnk-1687956309738)(assets/image-20200801234325253.png)]

​ java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。

​ 那么现在有一个问题就是,当前处理器如何能够知道,对于这个被挂起的线程,它上一次执行到了哪里?那么这时就需要从程序计数器中来回去到当前的这个线程他上一次执行的行号,然后接着继续向下执行。

​ 程序计数器是JVM规范中唯一一个没有规定出现OOM的区域,所以这个空间也不会进行GC。

本地内存:

​ 它又叫做堆外内存,线程共享的区域,本地内存这块区域是不会受到JVM的控制的,也就是说对于这块区域是不会发生GC的。因此对于整个java的执行效率是提升非常大的。

堆:

​ 线程共享的区域。主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

​ 在JAVA7中堆内会存在年轻代、老年代和方法区(永久代)

​ 1)Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用。在Eden区变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区间。

​ 2)Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区。

​ 3)Perm代主要保存保存的类信息、静态变量、常量、编译后的代码,在java7中堆上方法区会受到GC的管理的。方法区【永久代】是有一个大小的限制的。如果大量的动态生成类,就会放入到方法区【永久代】,很容易造成OOM。

​ 为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】给移动到了本地内存上,重新开辟了一块空间,叫做元空间。那么现在就可以避免掉OOM的出现了。

1.4 元空间(MetaSpace)介绍

​ 在 HotSpot JVM 中,永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池,比如Class 和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。

​ 永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即OutOfMemoryError,为此不得不对虚拟机做调优。

​ 那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?

官网给出了解释:http://openjdk.java.net/jeps/122

This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.

移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。

1)由于 PermGen 内存经常会溢出,引发OutOfMemoryError,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。

2)移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。

​ 准确来说,Perm 区中的字符串常量池被移到了堆内存中是在 Java7 之后,Java 8 时,PermGen 被元空间代替,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。比如 java/lang/Object 类元信息、静态属性 System.out、整型常量等。

​ 元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

2 类加载器

2.1 Java文件编译执行的过程

​ 要想理解类加载器的话,务必要先清楚对于一个Java文件,它从编译到执行的整个过程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QLAuITz8-1687956309739)(assets/image-20201020235156423.png)]

  • 类加载器:用于装载字节码文件(.class文件)
  • 运行时数据区:用于分配存储空间
  • 执行引擎:执行字节码文件或本地方法
  • 垃圾回收器:用于对JVM中的垃圾内容进行回收

2.2 类加载器介绍

​ 前面提到过,JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。现有的类加载器基本上都是java.lang.ClassLoader的子类,该类的只要职责就是用于将指定的类找到或生成对应的字节码文件,同时类加载器还会负责加载程序所需要的资源。该类中主要方法如下所示:

方法 解释
loadClass(String name) 加载名称为 name 的类,并返回 java.lang.Class 类的实例
findClass(String name) 查找名称为 name 的类,并返回 java.lang.Class 类的实例
findLoadedClass(String name) 查找名称为 name 的已经被加载过的类,并返回 java.lang.Class 类的实例
defineClass(String name, byte[] b, int off, int len) 把字节数组 b 中的内容转换成 Java 类,并返回 java.lang.Class 类的实例
resolveClass(Class c) 链接到指定类

2.3 类加载机制

2.3.1 概述

​ 类加载器是java.lang.ClassLoader类的子类对象或者C++代码编写的Bootstrap ClassLoader,它们的作用是加载字节码到JVM内存,得到Class类的对象。

​ 类加载器根据各自加载范围的不同,划分为四种类加载器:

  • 启动类加载器(BootStrap ClassLoader):

    该类并不继承ClassLoader类,其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。

  • 扩展类加载器(ExtClassLoader):

    该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。

  • 应用类加载器(AppClassLoader):

    该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。

  • 自定义类加载器:

    开发者自定义类继承ClassLoader,实现自定义类加载规则。

上述三种类加载器的层次结构如下如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u1Zeubg9-1687956309740)(assets/image-20201022112615215.png)]

​ 类加载器的体系并不是“继承”体系,而是委派体系,类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次。

2.3.2 源码分析

加载器的类图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fPW3oQze-1687956309741)(assets/image-20201022105347634.png)]

2.3.2.1 构造方法
public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        //扩展类加载器
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader");
    }

    try {
        //将load属性,设为应用类加载器
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader");
    }

    Thread.currentThread().setContextClassLoader(this.loader);
    String var2 = System.getProperty("java.security.manager");
    if (var2 != null) {
        SecurityManager var3 = null;
        if (!"".equals(var2) && !"default".equals(var2)) {
            try {
                var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
            } catch (IllegalAccessException var5) {
            } catch (InstantiationException var6) {
            } catch (ClassNotFoundException var7) {
            } catch (ClassCastException var8) {
            }
        } else {
            var3 = new SecurityManager();
        }

        if (var3 == null) {
            throw new InternalError("Could not create SecurityManager: " + var2);
        }

        System.setSecurityManager(var3);
    }


2.3.2.2 ExtClassLoader类源码
public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {
    //调用私有方法getExtDirs(),用于加载指定目录下的文件信息
    final File[] var0 = getExtDirs();

    try {
        return (Launcher.ExtClassLoader)AccessController.doPrivileged(new PrivilegedExceptionAction<Launcher.ExtClassLoader>() {
            public Launcher.ExtClassLoader run() throws IOException {
                int var1 = var0.length;

                for(int var2 = 0; var2 < var1; ++var2) {
                    MetaIndex.registerDirectory(var0[var2]);
                }

                return new Launcher.ExtClassLoader(var0);
            }
        });
    } catch (PrivilegedActionException var2) {
        throw (IOException)var2.getException();
    }
}
private static File[] getExtDirs() {
    //该方法内部会加载java.ext.dirs下的文件内容
    String var0 = System.getProperty("java.ext.dirs");
    File[] var1;
    if (var0 != null) {
        StringTokenizer var2 = new StringTokenizer(var0, File.pathSeparator);
        int var3 = var2.countTokens();
        var1 = new File[var3];

        for(int var4 = 0; var4 < var3; ++var4) {
            var1[var4] = new File(var2.nextToken());
        }
    } else {
        var1 = new File[0];
    }

    return var1;
}
2.3.2.3 AppClassLoader类源码
public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {
    
    //该方法内部会加载java.class.path下的文件内容
    final String var1 = System.getProperty("java.class.path下的");
    final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);
    return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {
        public Launcher.AppClassLoader run() {
            URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);
            return new Launcher.AppClassLoader(var1x, var0);
        }
    });
}
2.3.2.4 查看代码的类加载流程
public class ClassLoaderTest {

    public static void main(String[] args) {

        String bootPath = System.getProperty("sun.boot.class.path");
        System.out.println("BootStrap ClassLoader加载的类的路径:------------------ ");
        System.out.println(bootPath.replaceAll(";",System.lineSeparator()));


        System.out.println("ExtClassLoader加载的类的路径:------------------");
        String extPath = System.getProperty("java.ext.dirs");
        System.out.println(extPath.replaceAll(";",System.lineSeparator()));


        System.out.println("AppClassLoader加载的类的路径:------------------");
        String appPath = System.getProperty("java.class.path");
        System.out.println(appPath.replaceAll(";",System.lineSeparator()));
    }
}

运行结果如下所示:

BootStrap ClassLoader加载的类的路径:------------------ 
C:\Program Files\Java\jdk1.8.0_101\jre\lib\resources.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\rt.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\sunrsasign.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\jsse.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\jce.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\charsets.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\jfr.jar
C:\Program Files\Java\jdk1.8.0_101\jre\classes

ExtClassLoader加载的类的路径:------------------
C:\Program Files\Java\jdk1.8.0_101\jre\lib\ext
C:\WINDOWS\Sun\Java\lib\ext

AppClassLoader加载的类的路径:------------------
C:\Program Files\Java\jdk1.8.0_101\jre\lib\charsets.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\deploy.jar
C:\Program Files\Java\jdk1.8.0_101\jre\lib\ext\access-bridge-64.jar

2.3.2 类加载模型

​ 在JVM中,对于类加载模型提供了三种,分别为全盘加载、双亲委派、缓存机制。

  • 全盘加载:

​ 即当一个类加载器负责加载某个Class时,该Class所依赖和引用的其他Class也将由该类加载器负责载入,除非显示指定使用另外一个类加载器来载入。

  • 双亲委派:

​ 即先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。简单来说就是,某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。

  • 缓存机制:

​ 会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。从而可以理解为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

2.4 双亲委派解析

2.4.1 概述

​ 上述已经大致介绍了双亲委派,对于双亲委派具体的工作流程,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XVPYtAM5-1687956309742)(assets/image-20201022114925045.png)]

2.4.2 源码分析

public Class<?> loadClass(String name) throws ClassNotFoundException {
  //调用本类加载器的loadClass(String name, boolean resolve)方法
  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
         // 首先调用当前类加载器的findLoadedClass(name),检查当前类加载器是否已加载过指定name的类
        Class c = findLoadedClass(name);
        
        //判断当前类加载器如果没有加载过
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                
                //判断当前类加载器是否有父类加载器
                if (parent != null) {
                    //如果当前类加载器有父类加载器,则调用父类加载器的loadClass(name,false)方法
         			//父类加载器的loadClass方法,又会检查自己是否已经加载过
                    c = parent.loadClass(name, false);
                } else {
                    
                    //当前类加载器没有父类加载器,说明当前类加载器是BootStrapClassLoader
          			//则调用BootStrap ClassLoader的方法加载类
                    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.
                // 如果调用父类的类加载器无法对类进行加载,则用自己的findClass(name)方法进行加载
                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;
    }
}

2.4.3 JVM为什么采用双亲委派机制

1)通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。

2)为了安全,保证类库API不会被修改

在工程中新建java.lang包,接着在该包下新建String类,并定义main函数

public class String {

    public static void main(String[] args) {

        System.out.println("demo info");
    }
}

​ 此时执行main函数,会出现异常,在类 java.lang.String 中找不到 main 方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8VoKKEkV-1687956309742)(assets/image-20201022120956230.png)]

​ 出现该信息是因为由双亲委派的机制,java.lang.String的在启动类加载器(Bootstrap classLoader)得到加载,因为在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。

2.5 自定义类加载器

​ 对于自定义类加载器的实现也是很简单,只需要继承ClassLoader类,覆写findClass方法即可。

//自定义类加载器,读取指定的类路径classPath下的class文件
public class MyClassLoader extends ClassLoader{

    private String classPath;

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

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


    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;
    }
}
public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:\\workspace\\course\\java97\\redisLock\\src\\main\\java");
        Class clazz = classLoader.loadClass("com.itheima.demo.User");
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

编写一个测试实体类,接着生成该类的字节码文件。

当存在.java文件时,根据双亲委派机制,显示当前类加载器为AppClassLoader,而当将.java文件删除时,则显示使用的是自定义的类加载器。

3 垃圾回收机制

3.1 Java语言的垃圾回收

​ 为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC。

​ 有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。

​ 在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机,对于对象的引用类型可查看第三天的ThreadLocal部分。

​ 换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。

​ 当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。

3.2 什么是垃圾&垃圾定位

​ 简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾。

3.2.1 引用计数法

​ 一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收

String demo = new String("123");

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LixK5oxQ-1687956309743)(assets/image-20200802001502483.png)]

String demo = null;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VFqM6RK4-1687956309744)(assets/image-20200802001515438.png)]

当对象间出现了循环引用的话,则引用计数法就会失效

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9vjKZnaF-1687956309745)(assets/image-20200802001533126.png)]

虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。

优点:

  • 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
  • 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报OOM错误。
  • 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。

缺点:

  • 每次对象被引用时,都需要去更新计数器,有一点时间开销。
  • 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
  • 无法解决循环引用问题,会引发内存泄露。(最大的缺点)

3.2.2 可达性分析算法

​ 现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。

​ 会存在一个根节点【GC Roots】,引出它下面指向的下一个节点,再以下一个节点节点开始找出它下面的节点,依次往下类推。直到所有的节点全部遍历完毕。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a4GxwsZS-1687956309746)(assets/image-20200802121746255.png)]

​ M,N这两个节点是可回收的,但是并不会马上的被回收!! 对象中存在一个方法【finalize】。当对象被标记为可回收后,当发生GC时,首先会判断这个对象是否执行了finalize方法,如果这个方法还没有被执行的话,那么就会先来执行这个方法,接着在这个方法执行中,可以设置当前这个对象与GC ROOTS产生关联,那么这个方法执行完成之后,GC会再次判断对象是否可达,如果仍然不可达,则会进行回收,如果可达了,则不会进行回收。

​ finalize方法对于每一个对象来说,只会执行一次。如果第一次执行这个方法的时候,设置了当前对象与RC ROOTS关联,那么这一次不会进行回收。 那么等到这个对象第二次被标记为可回收时,那么该对象的finalize方法就不会再次执行了。

GC ROOTS:

虚拟机栈中引用的对象

本地方法栈中引用的对象

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

方法区中常量引用对象

3.3 垃圾回收算法

3.3.1 标记清除算法

标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除

1.根据可达性分析算法得出的垃圾进行标记

2.对这些标记为可回收的内容进行垃圾回收

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sTiziipV-1687956309746)(assets/image-20200802123228945.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MuKOculS-1687956309748)(assets/image-20200802123241536.png)]

可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。

同样,标记清除算法也是有缺点的:

  • 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。
  • 重要)通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。

3.3.2 复制算法

​ 复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。

​ 如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Bn0yz5EX-1687956309749)(assets/image-20200802123304797.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TCvrKy0l-1687956309750)(assets/image-20200802123315514.png)]

1)将内存区域分成两部分,每次操作其中一个。

2)当进行垃圾回收时,将正在使用的内存区域中的存活对象移动到未使用的内存区域。当移动完对这部分内存区域一次性清除。

3)周而复始。

优点:

  • 在垃圾对象多的情况下,效率较高
  • 清理后,内存无碎片

缺点:

  • 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低

3.3.3 标记整理算法

​ 标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6FpKaoRb-1687956309750)(assets/image-20200802124108240.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6LSa9taU-1687956309751)(assets/image-20200802124119931.png)]

1)标记垃圾。

2)需要清除向右边走,不需要清除的向左边走。

3)清除边界以外的垃圾。

优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。

3.2.4 分代收集算法

在java8时,堆被分为了两份:新生代和老年代【1:2】,在java7时,还存在一个永久代。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qnZTYpqP-1687956309752)(assets/image-20200825231704058.png)]

对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区【8:1:1】

当对新生代产生GC:MinorGC【young GC】

当对老年代产生GC:FullGC【OldGC】

3.2.4.1 工作机制

1)当创建一个对象的时候,那么这个对象会被分配在新生代的Eden区。当Eden区要满了时候,触发YoungGC。

2)当进行YoungGC后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区。

3)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S0中的对象,移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区。

4)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区。

3.2.4.2 对象何时晋升到老年代

1)对象的年龄达到了某一个限定的值(默认15岁,CMS默认6岁 ),那么这个对象就会进入到老年代中。

2)大对象。

3)如果在Survivor区中相同年龄的对象的所有大小之和超过Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

当老年代满了之后,触发FullGCFullGC同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。

3.4 七种垃圾收集器

​ 前面我们讲了垃圾回收的算法,还需要有具体的实现,在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器、CMS(并发)垃圾收集器、G1垃圾收集器,接下来,我们一个个的了解学习。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-APVcs3Cm-1687956309753)(assets/image-20200802221150600.png)]

3.4.1 Serial收集器

串行垃圾收集器,作用于新生代。是指使用单线程进行垃圾回收,采用复制算法。垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)。其应用在年轻代

​ 对于交互性较强的应用而言,这种垃圾收集器是不能够接受的。因此一般在Javaweb应用中是不会采用该收集器的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yS8vb8pU-1687956309753)(assets/image-20200802214415076.png)]

3.4.2 ParallelNew收集器

​ 并行垃圾收集器在串行垃圾收集器的基础之上做了改进,采用复制算法。将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)。但是对于其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同Serial收集器一样。其也是应用在年轻代。JDK8默认使用此垃圾回收器

​ 当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YHvROGzS-1687956309754)(assets/image-20200802215543670.png)]

3.4.3 Parallel Scavenge收集器

​ 其是一个应用于新生代并行垃圾回收器,采用复制算法。它的目标是达到一个可控的吞吐量(吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间))即虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,吞吐量就是99%。这样可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

  • 停顿时间越短对于需要与用户交互的程序来说越好,良好的响应速度能提升用户的体验。
  • 高吞吐量可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不太需要太多交互的任务。

3.4.4 Serial Old收集器

​ 其是运行于老年代的单线程Serial收集器,采用标记-整理算法,主要是给Client模式下的虚拟机使用。

3.4.5 Parallel Old收集器

​ 其是一个应用于老年代的并行垃圾回收器,采用标记-整理算法。在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器。

3.4.6 CMS垃圾收集器

3.4.6.1 概述

​ CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。

CMS垃圾回收器的执行过程如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qa16TBTY-1687956309755)(assets/image-20200802222734870.png)]

1)初始标记(Initial Mark):仅仅标记GC Roots能直接关联到的对象,速度快,但是需要“Stop The World”

2)并发标记(Concurrent Mark):就是进行追踪引用链的过程,可以和用户线程并发执行。

3)重新标记(Remark):修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要“Stop The World”

4)并发清除(Concurrent Sweep):清除标记为可以回收对象,可以和用户线程并发执行

​ 由于整个过程耗时最长的并发标记和并发清除都可以和用户线程一起工作,所以总体上来看,CMS收集器的内存回收过程和用户线程是并发执行的。

3.4.6.2 CMS收集器缺点

​ 对于CMS收集器的有三个:

  • 对CPU资源敏感:

​ 并发收集虽然不会暂停用户线程,但因为占用CPU资源,仍会导致系统吞吐量降低、响应变慢。

​ CMS的默认收集线程数量是=(CPU数量+3)/4。当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。

  • 无法处理浮动垃圾:

​ 所谓浮动垃圾即在并发清除时,用户线程新产生的垃圾叫浮动垃圾。并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集。如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败。这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生。

  • 垃圾回收算法导致内存碎片:

​ 因为CMS收集器采用标记-清除算法,因此会导致垃圾从内存中被清除后,会出现内存空间碎片化。这样会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。

3.4.7 G1垃圾收集器

3.4.7.1 概述

​ 对于垃圾回收器来说,前面的三种要么一次性回收年轻代,要么一次性回收老年代。而且现代服务器的堆空间已经可以很大了。为了更加优化GC操作,所以出现了G1。

​ 它是一款**同时应用于新生代和老年代、采用标记-整理算法、软实时、低延迟、可设定目标(最大STW停顿时间)**的垃圾回收器,用于代替CMS,适用于较大的堆(>4~6G),在JDK9之后默认使用G1

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:

  1. 第一步,开启G1垃圾收集器
  2. 第二步,设置堆的最大内存
  3. 第三步,设置最大的停顿时间(stw)
3.4.7.2 G1的内存布局

​ G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jzbZvpam-1687956309755)(assets/20161222153407_691.png)]

​ 取而代之的是将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的年轻代、老年代区域。这样做的好处就是,我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。

​ 此时可以看到,现在出现了一个新的区域Humongous,它本身属于老年代区。当现在出现了一个巨大的对象,超出了分区容量的一半,则这个对象会进入到该区域。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区 ,有时候不得不启动Full GC。

​ 同时G1会估计每个Region中的垃圾比例,优先回收垃圾较多的区域。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U0kjS4Sh-1687956309756)(assets/20161222153407_471.png)]

​ 在G1划分的区域中,年轻代的垃圾收集依然采用暂停所有应用线程的方式,将存活对象拷贝到老年代或者Survivor空间,G1收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作

​ 这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样也就不会有cms内存碎片问题的存在了。

3.4.7.3 垃圾回收模式

其提供了三种模式垃圾回收模式: young GC、Mixed GC、Full GC。在不同的条件下被触发。

3.4.7.3.1 Young GC

​ 发生在年轻代的GC算法,一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。

3.4.7.3.2 Mixed GC

​ 当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即mixed gc,该算法并不是一个old gc,除了回收整个young region,还会回收一部分的old region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制。

​ 在CMS中,当老年代的使用率达到80%就会触发一次cms gc。在G1中,mixed gc也可以通过-XX:InitiatingHeapOccupancyPercent设置阈值,默认为45%。当老年代大小占整个堆大小百分比达到该阈值,则触发mixed gc。

其执行过程和cms类似:

  1. initial mark: 初始标记过程,整个过程STW,标记了从GC Root可达的对象。
  2. concurrent marking: 并发标记过程,整个过程gc collector线程与应用线程可以并行执行,标记出GC Root可达对象衍生出去的存活对象,并收集各个Region的存活对象信息。
  3. remark: 最终标记过程,整个过程STW,标记出那些在并发标记过程中遗漏的,或者内部引用发生变化的对象。
  4. clean up: 垃圾清除过程,如果发现一个Region中没有存活对象,则把该Region加入到空闲列表中。
3.4.7.3.3 Full GC

​ 如果对象内存分配速度过快,mixed gc来不及回收,导致老年代被填满,就会触发一次full gc,G1的full gc算法就是单线程执行的serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免full gc.

3.4.7.5 G1的最佳实践

不断调优暂停时间指标

通过XX:MaxGCPauseMillis=x可以设置启动应用程序暂停的时间,G1在运行的时候会根据这个参数选择CSet来满足响应时间的设置。一般情况下这个值设置到100ms或者200ms都是可以的(不同情况下会不一样),但如果设置成50ms就不太合理。暂停时间设置的太短,就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。

不要设置新生代和老年代的大小

G1收集器在运行的时候会调整新生代和老年代的大小。通过改变代的大小来调整对象晋升的速度以及晋升年龄,从而达到我们为收集器设置的暂停时间目标。设置了新生代大小相当于放弃了G1为我们做的自动调优。我们需要做的只是设置整个堆内存的大小,剩下的交给G1自己去分配各个代的大小。

3.5 可视化GC日志分析工具-GC Easy

public class SerialDemo {

    public static void main(String[] args) throws Exception {
        List<Object> list = new ArrayList<Object>();
        while (true){
            int sleep = new Random().nextInt(100);
            if(System.currentTimeMillis() % 2 ==0){
                list.clear();
            }else{
                for (int i = 0; i < 10000; i++) {
                    Properties properties = new Properties();
                    properties.put("key_"+i, "value_" + System.currentTimeMillis() + i);
                    list.add(properties);
                }
            }

            Thread.sleep(sleep);
        }
    }
}

设置参数输出gc日志:-XX:+UseParallelGC -XX:+UseParallelOldGC -XX:+PrintGCDetails -Xms128m -Xmx128m -Xloggc:gc.log

GC Easy是一款在线的可视化工具,易用、功能强大,网站:

http://gceasy.io/

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8j2lNSz3-1687956309757)(assets/1537803536253.png)]

上传后,点击“Analyze”按钮,即可查看报告。

JVM Heap Size

​ 这一部分分别使用了表格和图形界面来展示了JVM堆内存大小

​ 左侧分别展示了年轻代的内存分配分配空间大小(Allocated)和年轻代内存分配空间大小的最大峰值(Peek),然后依次是老年代(Old Generation)、元数据区(Meta Space)、堆区和非堆区(Young + Old + Meta Space)总大小。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9suS4boH-1687956309757)(assets/1537804265054.png)]

Key Performance Indicators

这一部分是关键的性能指标

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mvnRkRyo-1687956309758)(assets/image-20200817184526587.png)]

  • Throughput表示的是吞吐量
  • Latency表示响应时间
    • Avg Pause GC Time 平均GC时间
    • Max Pause GC TIme 最大GC时间

Interactive Graphs

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QxDz3Fvf-1687956309758)(assets/image-20200817185141660.png)]

第一部分为:回收后堆的内存图,从图中可以看出,随着GC的进行,垃圾回收器把对象都回收掉了,因此堆的大小主键增大。

第二部分为:回收前堆的使用率,随着程序的运行,堆的使用率越来越高,堆被对象占用的内存越来越大。

第三部分为:GC的持续时间。

第四部分为:GC回收掉的垃圾对象的大小。

第五部分为:年轻代的内存分配情况

第六部分为:老年代的内存分配情况

第七部分为:元数据区内存分配情况

第八部分为:堆内存分配和晋升情况

GC Statistics

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qic7Wrqm-1687956309759)(assets/image-20200817185501527.png)]

左图:表示的是堆内存中Minor GC和Full GC回收垃圾对象的内存。
中图:总计GC时间,包括Minor GC和Full GC,时间单位为ms。
右图:GC平均时间,包括了Minor GC和Full GC。

其他

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XKrbedLf-1687956309760)(assets/image-20200817185537326.png)]

分别表示的是总GC统计,MinorGC的统计,FullGC的统计,GC暂停程序的统计。

GC Causes

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nDkYAegz-1687956309760)(assets/image-20200817185618995.png)]

GC花费的时间统计

4 JVM调优实践

​ 对于一个系统要部署上线时,则一定会对JVM进行调整,很少会不经过任何调整直接上线。否则很容易出现线上系统频繁FullGC造成系统卡顿、CPU使用频率过高、系统无反应等问题。

4.1 服务器性能指标

​ 对于一个应用来说通常重点关注的性能指标主要是吞吐量、响应时间、QPS、TPS等、并发用户数等。而这些性能指标又依赖于系统服务器的资源,如:CPU、内存、磁盘IO、网络IO等。对于这些指标数据的收集,通常可以根据Java本身的工具或指令进行查询,详情参照第一天。

1)CPU:

CPU资源一般可以使用vmstat来采样(例如每秒采样一次: vmstat 1)查看CPU上下文切换。如下:

$ vmstat 1 

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 284628 234036 859100    0    0     1    15    0    1  3  1 96  0  0
 0  0      0 284536 234036 859204    0    0     0     0 4952 9461  4  2 95  0  0
 0  0      0 284536 234036 859208    0    0     0   200 5081 9768  3  3 94  1  0
 0  0      0 284568 234036 859212    0    0     0    16 5126 9757  3  2 96  0  0
 0  0      0 284900 234036 859236    0    0     0     0 5431 10230  4  3 94  0  0
 1  0      0 285608 234036 859256    0    0     0     0 5325 10005  6  2 92  0  0
 0  0      0 285452 234036 859256    0    0     0     0 5037 9653  3  1 96  0  0
 0  0      0 285484 234036 859264    0    0     0    60 5068 9599  3  1 96  0  0
 0  0      0 285452 234036 859264    0    0     0    36 5163 9825  4  2 94  0  0
  • us:用户占用CPU的百分比
  • sy:系统(内核和中断)占用CPU的百分比
  • id:CPU空闲的百分比
  • in: 系统中断数
  • cs: 每秒上下文切换次数
  • r: 可运行进程数,包括正在运行(Running)和已就绪等待运行(Waiting)的。在负载测试中,其可接受上限通常不超过CPU核数的2倍。

CPU使用率通常用us + sy来计算,一般大于80%说明,CPU资源出现瓶颈。

2)内存

根据上述信息,其中已经输出了内存的信息

  • free: 系统可用内存,对于稳定运行的系统,free可接受的范围通常应该大于物理内存的20%。
  • so/si : 每秒从内存写入到SWAP的数据大小/每秒从SWAP读取到内存的数据大小。如果出现频繁的swap交换,会影响系统性能,需要一起注意。
  • swpd:系统当前的swap空间占用。可以和so/si 综合分析。如果swpd为0 ,内存资源没有成为瓶颈。

3)磁盘

对于磁盘,首要关注使用率,IOPS和数据吞吐量,在Linux服务区,可以使用iostat来获取这些数据。

$ iostat -dxk 1
Linux 4.4.0-63-generic  _x86_64 
Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     2.69    0.07    2.21     1.28    28.96    26.53     0.00    1.85    1.19    1.87   0.31   0.07

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    2.00    0.00    12.00     0.00    12.00     0.02   10.00   10.00    0.00  10.00   2.00

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00    29.00    0.00    2.00     0.00   124.00   124.00     0.00    2.00    0.00    2.00   2.00   0.40
  • %util: 衡量device使用率的指标。处理I/O请求的时间与统计时间的百分比.大于60%的话,会影响系统性能。
  • r/s, w/s :每秒处理,读、写的请求数量。
  • rkB/s,wkB/s :每秒读/写的数据大小。

4.2 JVM参数调优

​ 对于JVM调优,主要就是调整年轻代、年老大、元空间的内存空间大小及使用的垃圾回收器类型。

1)设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。

-Xms:设置堆的初始化大小

-Xmx:设置堆的最大大小

2) 设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。Java官方通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满

的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。

-XX:SurvivorRatio

3)年轻代和老年代默认比例为1:2。可以通过调整二者空间大小比率来设置两者的大小。

-XX:newSize   设置年轻代的初始大小
-XX:MaxNewSize   设置年轻代的最大大小,  初始大小和最大大小两个值通常相同

4)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

-Xss   对每个线程stack大小的调整,-Xss128k

5)一般一天超过一次FullGC就是有问题,首先通过工具查看是否出现内存泄露,如果出现内存泄露则调整代码,没有的话则调整JVM参数。

6)系统CPU持续飙高的话,首先先排查代码问题,如果代码没问题,则咨询运维或者云服务器供应商,通常服务器重启或者服务器迁移即可解决。

7)如果数据查询性能很低下的话,如果系统并发量并没有多少,则应更加关注数据库的相关问题。

8)如果服务器配置还不错,JDK8开始尽量使用G1或者新生代和老年代组合使用并行垃圾回收器。

你可能感兴趣的:(jvm,内存优化)