JVM

简介: 本课程深入讲解JVM虚拟机核心知识,涵盖类加载机制、运行时数据区、对象生命周期、垃圾回收算法及调优实战等内容,帮助开发者夯实Java底层原理,提升系统性能与故障排查能力,助力面试与实际项目应用。

03-JVM虚拟机

灵魂三问:

  • JVM是什么?
    JVM广义上指的是一种规范。狭义上的是JDK中的JVM虚拟机。
  • 为什么要学习JVM?
    • 面试过程中,经常会被问到JVM。
    • 研发过程中,肯定会面临一些重难点问题与JVM有关系。例如:线程死锁、内存溢出、项目性能优化等等。
    • 基础不牢,地动山摇。想深入掌握Java这门语言,JVM始终是绕不过去的那座大山,早晚得攀。
  • 怎么学习JVM?
    JVM虚拟机部分,学习安排如下:
    1. JVM基本常识
    2. 类加载子系统
    3. 运行时数据区
    4. 一个对象的一生(出生、死亡与内涵)
    5. GC垃圾收集器
    6. JVM调优相关工具与可调参数
    7. 调优实战案例

1. JVM虚拟机概述

1.1 JVM 基本常识

什么是JVM?
平时我们所说的JVM广义上指的是一种规范。狭义上的是JDK中的JVM虚拟机。JVM的实现是由各个厂商来做的。比如现在流传最广泛的是hotspot。其他实现:BEA公司 JRocket、IBM j9、zing 号称世界最快 JVM、taobao.vm。
从广义上讲Java,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语言及其相关的程序都属于Java技术体系中的一员。

Java技术体系主要包括如下四个方面:

  • Java程序设计语言
  • Java类库API
  • 来自商业机构和开源社区的第三方Java类库(如Google、Apache等)
  • Java虚拟机:各种硬件平台上的Java虚拟机实现

可以简单类比一下:Java虚拟机是宿主,Java代码开发的程序则寄生在宿主上!

JVM架构图:

  • Class Loader SubSystem(类加载子系统):Loading、Linking(Class File、Verify、Prepare、Resolution)、Initialization
  • Runtime Data Areas(运行时数据区):Stack Area(栈区)、PC Registers(PC寄存器)、Method Area (方法区)、Heap Area (堆区)、Native Method Stack (本地方法栈)
  • Execution Engine(执行引擎):Interpreter(解释器)、JIT编译器、垃圾回收器
  • 其他:Java本地接口(JNI)、本地方法库

1.2 类加载子系统

1.2.1 类加载的时机

类加载主要有四个时机:

  1. 遇到newgetstaticputstaticinvokestatic这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。
    public class Student{
         
      private static int age ;
      public static void method(){
         
      }
    }
    //Student.age
    //Student.method();
    //new Student();
    
  2. 使用java.lang.reflect包方法时,对类进行反射调用的时候。
    Class c = Class.forname("com.hero.Student");
    
  3. 初始化一个类的时候发现其父类还没初始化,要先初始化其父类。
  4. 当虚拟机开始启动时,用户需要指定一个主类(main),虚拟机会先执行这个主类的初始化。

1.2.2 类加载的过程

类加载主要做三件事:

  1. 全限定名称 → 二进制字节流加载class文件
  2. 字节流的静态数据结构 → 方法区的运行时数据结构
  3. 创建字节码Class对象

可以从哪些途径加载字节码?
(Jar、war、JSP生成的class、数据库中二进制字节流、网络中二进制字节流、动态代理生成的二进制字节流等)

1.2.3 类加载器

JVM的类加载是通过ClassLoader及其子类来完成的。
类加载器包括:

  • Bootstrap ClassLoader(启动类加载器):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。由C++实现,不是ClassLoader的子类。
  • Extension ClassLoader(扩展类加载器):负责加载 JAVA_HOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
  • Application ClassLoader(应用程序类加载器):负责加载用户路径classpath上的类库。
  • Custom ClassLoader(自定义加载器):JVM自带的三个加载器只能加载指定路径下的类字节码,如果需要加载应用程序之外的类文件(如本地D盘下的,或网络上的某个类文件),就需要用到自定义类加载器。

检查顺序是自底向上:加载过程中会先检查类是否被已加载,从Custom ClassLoaderBootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只被所有ClassLoader加载一次。
加载的顺序是自顶向下:也就是由上层来逐层尝试加载此类。

自定义类加载器案例
目标:自定义类加载器,加载指定路径在D盘下的lib文件夹下的类。
步骤:

  1. 新建一个需要被加载的类Test.java
    package com.hero.jvm.classloader;
    public class Test {
         
      public void say(){
         
        System.out.println("Hello HeroClassLoader");
      }
    }
    
  2. 编译Test.java到指定lib目录,将生成的Test.class 文件放到D:/lib/com/hero/jvm/classloader文件夹下。
  3. 自定义类加载器HeroClassLoader继承ClassLoader:
    package com.hero.jvm.classloader;
    import java.io.*;
    public class HeroClassLoader extends ClassLoader {
         
      private String classpath;
      public HeroClassLoader(String classpath) {
         
        this.classpath = classpath;
      }
      @Override
      protected Class<?> findClass(String name) throws ClassNotFoundException {
         
        try {
         
          //输入流,通过类的全限定名称加载文件到字节数组
          byte[] classDate = getData(name);
          if (classDate != null) {
         
            //defineClass方法将字节数组数据 转为 字节码对象
            return defineClass(name, classDate, 0, classDate.length);
          }
        } catch (IOException e) {
         
          e.printStackTrace();
        }
        return super.findClass(name);
      }
      //加载类的字节码数据
      private byte[] getData(String className) throws IOException {
         
        String path = classpath + File.separatorChar +
          className.replace('.', File.separatorChar) + ".class";
        try (InputStream in = new FileInputStream(path);
             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
         
          byte[] buffer = new byte[2048];
          int len = 0;
          while ((len = in.read(buffer)) != -1) {
         
            out.write(buffer, 0, len);
          }
          return out.toByteArray();
        } catch (FileNotFoundException e) {
         
          e.printStackTrace();
          return null;
        }
      }
    }
    
  4. 测试自定义类加载器
    package com.hero.jvm.classloader;
    import java.lang.reflect.Method;
    public class TestMyClassLoader {
         
      public static void main(String []args) throws Exception{
         
        //自定义类加载器的加载路径
        HeroClassLoader hClassLoader=new HeroClassLoader("D:\\lib");
        //包名+类名
        Class c=hClassLoader.loadClass("com.hero.jvm.classloader.Test");
        if(c!=null){
         
          Object obj=c.newInstance();
          Method method=c.getMethod("say", null);
          method.invoke(obj, null);
          System.out.println(c.getClassLoader().toString());
        }
      }
    }
    
  5. 输出结果如下:
    Hello HeroClassLoader
    com.hero.jvm.classloader.HeroClassLoader@xxxxxxx
    

1.2.4 双亲委派模型与打破双亲委派

  1. 什么是双亲委派?
    当一个类加载器收到类加载任务,会先交给其父类加载器去完成。因此,最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,子类才会尝试执行加载任务。

    Oracle 官网文档描述:

    The Java platform uses a delegation model for loading classes. The basic idea is that every class loader has a "parent" class loader. When loading a class, a class loader first "delegates" the search for the class to its parent class loader before attempting to find the class itself.
    —— Oracel Document https://docs.oracle.com/javase/tutorial/ext/basics/load.html

  2. 为什么需要双亲委派呢?
    考虑到安全因素,双亲委派可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子 ClassLoader再加载一次。比如:加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object 对象。

  3. 双亲委派机制源码:

    protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException
    {
         
      synchronized (getClassLoadingLock(name)) {
         
        // 首先,检查class是否被加载,如果没有加载则进行加载
        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;
      }
    }
    
  4. 为什么还需要破坏双亲委派?
    在实际应用中,双亲委派解决了Java 基础类统一加载的问题,但是却存在着缺陷。JDK中的基础类作为典型的api被用户调用,但是也存在api调用用户代码的情况,典型的如:SPI代码。这种情况就需要打破双亲委派模式。举个栗子:数据库驱动DriverManager。以Driver接口为例,Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,由系统类加载器加载这个时候就需要启动类加载器来委托子类来加载Driver实现,这就破坏了双亲委派。

  5. 如何破坏双亲委派?

    • 第一种方式:在 jdk 1.2 之前,那时候还没有双亲委派模型,不过已经有了 ClassLoader 这个抽象类,所以已经有人继承这个抽象类,重写 loadClass 方法来实现用户自定义类加载器,而在 1.2 的时候要引入双亲委派模型,为了向前兼容, loadClass 这个方法还得保留着使之得以重写,新搞了个 findClass 方法让用户去重写,并呼吁大家不要重写 loadClass 只要重写 findClass。这就是第一次对双亲委派模型的破坏,因为双亲委派的逻辑在 loadClass 上,但是又允许重写 loadClass,重写了之后就可以破坏委派逻辑了。
    • 第二种方式:双亲委派机制是一种自上而下的加载需求,越往上类越基础。SPI代码打破了双亲委派。线程上下文类加载器(ThreadContextClassLoader)可以解决基础类去加载用户代码类的问题,从而打破双亲委派。
    • 第三种方式:为了满足热部署、不停机更新需求。OSGI 就是利用自定义的类加载器机制来完成模块化热部署,而它实现的类加载机制就没有完全遵循自下而上的委托,有很多平级之间的类加载器查找。

1.3 运行时数据区

整个JVM构成里面,由三部分组成:类加载系统、运行时数据区、执行引擎。

JVM运行时数据区:

  • 线程共享区域:堆、方法区(运行时常量池)
  • 线程独享区域:虚拟机栈(栈帧:局部变量表、操作数栈、动态链接、方法出口)、程序计数器、本地方法栈

按照线程使用情况和职责分成两大类:

  • 线程独享 (程序执行区域):不需要垃圾回收,包括虚拟机栈、本地方法栈、程序计数器。
  • 线程共享 (数据存储区域):需要垃圾回收,存储类的静态数据和对象数据,包括堆和方法区。

1.3.1 堆

Java堆在JVM启动时创建内存区域去实现对象、数组与运行时常量的内存分配,它是虚拟机管理最大的,也是垃圾回收的主要内存区域 。

内存划分:
核心逻辑就是三大假说,基于程序运行情况进行不断的优化设计。

堆内存为什么会存在新生代和老年代?
分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

内存模型变迁:

  • JDK1.7:Young(Eden、2个Survivor)、Tenured、Perm(永久区)
  • JDK1.8:Young(Eden + 2*Survivor)、OldGen(年老代),用Metaspace(元空间)替换Perm永久区,Metaspace所占用的内存空间在本地内存空间中。
  • JDK1.9:取消新生代、老年代的物理划分,将堆划分为若干个区域(Region),包含逻辑上的新生代、老年代区域。

1.3.2 虚拟机栈

  1. 栈帧是什么?
    栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。 栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

    栈内存为线程私有的空间,每个线程都会创建私有的栈内存,生命周期与线程相同,每个Java方法在执行的时候都会创建一个栈帧Stack Frame。栈内存大小决定了方法调用的深度,栈内存过小则会导致方法调用的深度较小,如递归调用的次数较少。

  2. 当前栈帧
    一个线程中方法的调用链可能会很长,所以会有很多栈帧。只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。

    执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。

  3. 什么时候创建栈帧
    调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。

  4. 栈异常的两种情况:

    • 如果线程请求的栈深度大于虚拟机所允许的深度(-Xss默认1m),会抛出StackOverflowError异常。
    • 如果在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,会抛出OutOfMemoryError异常 【不一定】。
  5. 栈异常案例:

    package com.hero.jvm.memory;
    public class StackErrorMock {
         
      private static int index = 1;
      public void call(){
         
        index++;
        call();
      }
      public static void main(String[] args) {
         
        StackErrorMock mock = new StackErrorMock();
        try {
         
          mock.call();
        } catch (Throwable e){
         
          System.out.println("Stack deep : "+index);
          e.printStackTrace();
        }
      }
    }
    
  6. 运行命令:

    C:\develop\java\jdk1.8.0_251\bin\javac StackErrorMock.java
    C:\develop\java\jdk1.8.0_251\bin\java -Xss1m StackErrorMock
    C:\develop\java\jdk1.8.0_251\bin\java -Xss256k StackErrorMock
    
  7. 运行结果:

    D:\>C:\develop\java\jdk1.8.0_251\bin\java -Xss1m StackErrorMock
    Stack deep:32896
    java.lang.StackOverflowError at StackErrorMock.call(StackErrorMock.java:7)
    at StackErrorMock.call(StackErrorMock.java:7)
    ...
    
    D:\>C:\develop\java jdk1.8.0_251\bin\java -Xss256k StackErrorMock
    Stack deep:2470
    java.lang.StackOverflowError at StackErrorMock.call(StackErrorMock.java:6)
    at StackErrorMock.call(StackErrorMock.java:7)
    ...
    

1.3.3 本地方法栈

本地方法栈和虚拟机栈相似,区别就是虚拟机栈为虚拟机执行Java服务(字节码服务),而本地方法栈为虚拟机使用到的Native方法(比如C++方法)服务。

简单地讲,一个Native Method就是一个Java调用非Java代码的接口。

public class IHaveNatives
{
   
  native public void Native1( int x ) ;
  native static public long Native2() ;
  native synchronized private float Native3( Object o ) ;
  native void Native4( int[] ary ) throws Exception ;
}

为什么需要本地方法?
Java是一门高级语言,我们不直接与操作系统资源、系统硬件打交道。如果想要直接与操作系统与硬件打交道,就需要使用到本地方法了。说白了,Java可以直接通过native方法调用cpp编写的接口!多线程底层就是这么实现的。

1.3.4 方法区

方法区(Method Area)是可供各个线程共享的运行时内存区域,方法区本质上是Java语言编译后代码存储区域,它存储每一个类的结构信息,例如:运行时常量池、成员变量、方法数据、构造方法和普通方法的字节码指令等内容。

方法区的具体实现有两种:永久代(PermGen)、元空间(Metaspace)

  1. 方法区存储什么数据?

    • 第一:Class相关信息(类型信息、方法信息、字段信息、类变量(JDK1.7之后转移到堆中存储)、方法表等)
    • 第二:运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后转移到堆中存储(字面量类型、引用类型等)
    • 第三:JIT编译器编译之后的代码缓存
  2. 永久代和元空间的区别是什么?

    • JDK1.8之前使用的方法区实现是永久代,JDK1.8及以后使用的方法区实现是元空间。
    • 存储位置不同:永久代所使用的内存区域是JVM进程所使用的区域,大小受整个JVM的大小所限制;元空间所使用的内存区域是物理内存区域,大小受物理内存大小的限制。
    • 存储内容不同:永久代存储方法区存储内容中的数据;元空间只存储类的元信息,静态变量和运行时常量池都挪到堆中。
  3. 为什么要使用元空间来替换永久代?

    • 字符串存在永久代中,容易出现性能问题和永久代内存溢出。
    • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
    • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
    • Oracle 计划将HotSpot 与 JRockit 合二为一。
  1. 字符串OOM异常案例
    案例代码:
    package com.hero.jvm.memory; 
    import java.util.ArrayList;
    import java.util.List; 
    public class StringOomMock {
          
    static String base = "string";
    public static void main(String[] args) {
         
     List<String> list = new ArrayList<String>();
     for (int i=0;i< Integer.MAX_VALUE;i++) {
         
       String str = base + base;
       base = str;
       list.add(str.intern()); 
     }
    }
    }
    
  • JDK1.6运行:

    C:\develop\java\jdk1.6.0_45\bin\javac StringOomMock.java
    C:\develop\java\jdk1.6.0_45\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
    

    结果:会出现永久代的内存溢出。

  • JDK1.7运行:

    C:\develop\java\jdk1.7.0_80\bin\javac StringOomMock.java
    C:\develop\java\jdk1.7.0_80\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
    

    结果:会出现堆内存溢出,说明JDK 1.7 已经将字符串常量由永久代转移到堆中。

  • JDK1.8+运行:

    C:\develop\java\jdk1.8.0_251\bin\javac StringOomMock.java
    C:\develop\java\jdk1.8.0_251\bin\java -XX:PermSize=8m -XX:MaxPermSize=8m -Xmx16m StringOomMock
    

    结果:会出现堆内存溢出,且显示 JDK 1.8中 PermSizeMaxPermGen 已经无效,说明JDK 1.8 中已经不存在永久代。

1.3.5 字符串常量池

  1. 三种常量池的比较

    • class常量池:一个class文件只有一个class常量池,包含字面量(数值型、双引号引起来的字符串值等)和符号引用(Class、Method、Field等)。
    • 运行时常量池:一个class对象有一个运行时常量池,包含字面量(数值型、双引号引起来的字符串值等)和符号引用(Class、Method、Field等)。
    • 字符串常量池:全局只有一个字符串常量池,存储双引号引起来的字符串值。
  2. 字符串常量池如何存储数据?
    为了提高匹配速度,即更快的查找某个字符串是否存在于常量池,Java 在设计字符串常量池的时候,搞了一张StringTableStringTable里面保存了字符串的引用。StringTable类似于HashTable(哈希表)。在JDK1.7+,StringTable可以通过参数指定-XX:StringTableSize=99991

    哈希表(Hash table,也叫散列表)是根据关键码值(Key value)而直接进行访问的数据结构,通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。哈希表本质上是一个数组+链表,目的是加快数据查找的速度,存在hash冲突问题(形成链表,增删快、查询慢)。

  3. 字符串常量池如何查找字符串:

    • 根据字符串的hashcode找到对应entry。
    • 如果没有冲突,可能只是一个entry;如果有冲突,可能是一个entry的链表,然后Java再遍历链表,匹配引用对应的字符串。
    • 如果找到字符串,返回引用;如果找不到字符串,在使用intern()方法的时候,会将intern()方法调用者的引用放入到stringtable中。
  1. 字符串常量池案例
    public class StringTableDemo {
         
    public static void main(String[] args) {
         
     HashMap<String, Integer> map = new HashMap<>();
     map.put("hello", 53);
     map.put("world", 35);
     map.put("java", 55);
     map.put("world", 52);
     map.put("通话", 51);
     map.put("重地", 55);
     //出现哈希冲突怎么办?
     //System.out.println("map = " + map);
     test();
    }
    public static void test() {
         
     String str1 = "abc";
     String str2 = new String("abc");
     System.out.println(str1 == str2);//false
     String str3 = new String("abc");
     System.out.println(str3 == str2);//false
     String str4 = "a" + "b";
     System.out.println(str4 == "ab");//true
     String s1 = "a";
     String s2 = "b";
     String str6 = s1 + s2;
     System.out.println(str6 == "ab");//false
     String str7 = "abc".substring(0,2);
     System.out.println(str7 == "ab");//false
     String str8 = "abc".toUpperCase();
     System.out.println(str8 == "ABC");//false
     String s5 = "a";
     String s6 = "abc";
     String s7 = s5 + "bc";
     System.out.println(s6 == s7.intern());//true
    }
    }
    
  1. 总结:
    • 单独使用””引号创建的字符串都是常量,编译期就已经确定存储到String Pool中。
    • 使用new String("")创建的对象会存储到heap中,是运行期新创建的。
    • 使用只包含常量的字符串连接符如”aa”+”bb”创建的也是常量,编译期就能确定已经存储到String Pool中。
    • 使用包含变量的字符串连接如”aa”+s创建的对象是运行期才创建的,存储到heap中。
    • 运行期调用Stringintern()方法可以向String Pool中动态添加对象。

1.3.6 程序计数器

程序计数器(Program Counter Register),也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程回复等都需要依赖这个计数器来完成。

为什么需要程序计数器?
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(针对多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换(系统上下文切换)后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类内存区域为“线程私有”的内存。

存储的什么数据?
如果一个线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个Native方法,这个计数器的值则为空。

异常:此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。

1.3.7 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中 新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制。

直接内存(堆外内存)与堆内存比较:

  • 直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显。
  • 直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显。

直接内存案例:

package com.hero.jvm.memory;
import java.nio.ByteBuffer;
public class ByteBufferCompare {
   
  public static void main(String[] args) {
   
    //allocateCompare(); //分配比较
    operateCompare(); //读写比较
  }
  /**
   * 直接内存 和 堆内存的 分配空间比较
   * 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
   */
  public static void allocateCompare() {
   
    int time = 1000 * 10000 ; //操作次数,1千万
    long st = System.currentTimeMillis();
    for (int i = 0; i < time; i++) {
   
      //ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
      ByteBuffer buffer = ByteBuffer.allocate(2); //非直接内存分配申请
    }
    long et = System.currentTimeMillis();
    System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" + (et - st) + "ms");
    long st_heap = System.currentTimeMillis();
    for (int i = 0; i < time; i++) {
   
      //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
      ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接内存分配申请
    }
    long et_direct = System.currentTimeMillis();
    System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" + (et_direct - st_heap) + "ms");
  }
  /**
   * 直接内存 和 堆内存的 读写性能比较
   * 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
   */
  public static void operateCompare() {
   
    int time = 10 * 10000 * 10000; //操作次数,10亿
    ByteBuffer buffer = ByteBuffer.allocate(2 * time);
    long st = System.currentTimeMillis();
    for (int i = 0; i < time; i++) {
   
      // putChar(char value) 用来写入 char 值的相对 put 方法
      buffer.putChar('a');
    }
    buffer.flip();
    for (int i = 0; i < time; i++) {
   
      buffer.getChar();
    }
    long et = System.currentTimeMillis();
    System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" + (et - st) + "ms");
    ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 * time);
    long st_direct = System.currentTimeMillis();
    for (int i = 0; i < time; i++) {
   
      // putChar(char value) 用来写入 char 值的相对 put 方法
      buffer_d.putChar('a');
    }
    buffer_d.flip();
    for (int i = 0; i < time; i++) {
   
      buffer_d.getChar();
    }
    long et_direct = System.currentTimeMillis();
    System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) + "ms");
  }
}

输出:

在进行10000000次分配操作时,堆内存 分配耗时:82ms
在进行10000000次分配操作时,直接内存 分配耗时:6817ms
在进行1000000000次读写操作时,堆内存 读写耗时:1137ms
在进行1000000000次读写操作时,直接内存 读写耗时:512ms

直接内存的使用场景:

  • 有很大的数据需要存储,它的生命周期很长。
  • 适合频繁的IO操作,例如:网络并发场景。

1.4 对象的创建流程与内存分配

创建流程

对象创建流程如下:

  1. 开始,检查new指令的符号引用是否能在常量池中定位到一个类,进行常量池检查,判断该类是否已加载。
  2. 若未加载则加载类,之后进行内存分配,内存分配方式有指针碰撞(垃圾收集器不带压缩功能,如CMS)和空闲列表(垃圾收集器带有压缩功能,如Serial、ParNew等)。
  3. 分配内存时需考虑同步问题,可采用分配器加锁或TLAB方式(本地线程分配缓冲)。
  4. 将内存空间初始化为零值。
  5. 设置对象头中的必要信息,如对象属于哪个类的实例、哈希码、对象的GC分代年龄等,以确保能找到类的元数据信息。
  6. 执行init方法,流程结束。

1.4.2 对象内存分配方式

内存分配的方法有两种,不同垃圾收集器适用不同方式:

分配方法 说明 收集器
指针碰撞 内存地址是连续的(新生代) Serial 和 ParNew 收集器
空闲列表 内存地址不连续(老年代) CMS 收集器和 Mark - Sweep 收集器
  • 绝对规整(Serial&ParNew)
  • 不绝对规整(CMS&Mark-Sweep算法)

1.4.3 内存分配安全问题

在分配内存时,可能出现线程安全性问题,即虚拟机给A线程分配内存过程中,指针未修改,此时B线程同时使用了同样一块内存。

在JVM中有两种解决办法:

  1. CAS是乐观锁的一种实现方式,虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  2. TLAB本地线程分配缓冲(Thread Local Allocation Buffer即TLAB):为每一个线程预先分配一块内存。

JVM在第一次给线程中的对象分配内存时,首先使用CAS进行TLAB的分配。当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。

1.4.4 对象怎样才会进入老年代?

内存担保机制是指当新生代无法分配内存时,将新生代的老对象转移到老年代,然后把新对象放入腾空的新生代的机制。

对象内存分配:新生代中,新对象大多数默认进入新生代的Eden区。

进入老年代的条件有四种情况:

  1. 存活年龄太大,默认超过15次(可通过-XX:MaxTenuringThreshold设置)。
  2. 动态年龄判断:MinorGC之后,发现Survivor区中的一批对象的总大小大于了这块Survivor区的50%,那么就会将此时大于等于这批对象年龄最大值的所有对象,直接进入老年代。例如:Survivor区中有一批对象,年龄分别为年龄1+年龄2+年龄n的多个对象,对象总和大小超过了Survivor区域的50%,此时就会把年龄n及以上的对象都放入老年代。这样做是希望那些可能是长期存活的对象,尽早进入老年代,可通过-XX:TargetSurvivorRatio指定相关参数。
  3. 大对象直接进入老年代,前提是使用Serial和ParNew收集器,如字符串或数组,可通过-XX:PretenureSizeThreshold设置,一般设置为1M。这是为了避免大对象分配内存时的复制操作降低效率,避免了Eden和Survivor区的复制。
  4. MinorGC后,存活对象太多无法放入Survivor。

空间担保机制的流程

  • MinorGC前,判断老年代可用内存是否小于新生代全部对象大小,如果小于则继续判断老年代可用内存大小是否小于之前每次MinorGC后进入老年代的对象平均大小。
  • 如果是,则会进行一次FullGC,判断是否放得下,放不下则OOM。
  • 如果否,则会进行MinorGC:
    • MinorGC后,剩余存活对象小于Survivor区大小,直接进入Survivor区。
    • MinorGC后,剩余存活对象大于Survivor区大小,但是小于老年代可用内存,直接进入老年代。
    • MinorGC后,剩余存活对象大于Survivor区大小,也大于老年代可用内存,进行FullGC。
    • FullGC之后,仍然没有足够内存存放MinorGC的剩余对象,就会OOM。

1.4.5 案例演示:对象分配过程

  1. 大对象直接进入老年代
    package com.hero.jvm.object;
    /**
    * 测试:大对象直接进入到老年代
    * -Xmx60m -Xms60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
    * -XX:PretenureSizeThreshold
    */
    public class YoungOldArea {
         
    public static void main(String[] args) {
         
     byte[] buffer = new byte[1024*1024*20]; //20M
    }
    }
    
  • -XX:NewRatio=2:新生代与老年代比值
  • -XX:SurvivorRatio=8:新生代中,Eden与两个Survivor区域比值
  • -XX:+PrintGCDetails:打印详细GC日志
  • -XX:PretenureSizeThreshold:对象超过多大直接在老年代分配,默认值为0,不限制
  1. 对象内存分配的过程
    /*
    -Xmx600m -Xms600m -XX:+PrintGCDetails
    */
    public class HeapInstance {
         
    public static void main(String[] args) {
         
     List<Picture> list = new ArrayList<>();
     while (true){
         
       try {
         
         Thread.sleep(20);
       } catch (InterruptedException e) {
         
         e.printStackTrace();
       }
       list.add(new Picture(new Random().nextInt(1024 * 1024)));
     }
    }
    }
    class Picture{
         
    private byte[] pixels;
    public Picture(int length){
         
     this.pixels = new byte[length];
    }
    }
    

1.4.6 案例演示:内存担保机制

案例准备JVM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
分配三个1MB的对象和一个5MB的对象(-Xmn10M表示新生代内存的最大值,包括Eden区和两个Survivor区的总和)。

代码如下:

/**
 * 内存分配担保案例
 */
public class MemoryAllocationGuarantee {
   
  private static final int _1MB = 1024 * 1024;
  public static void main(String[] args) {
   
    memoryAllocation();
  }
  public static void memoryAllocation() {
   
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[1 * _1MB];//1M
    allocation2 = new byte[1 * _1MB];//1M
    allocation3 = new byte[1 * _1MB];//1M
    allocation4 = new byte[5 * _1MB];//5M
    System.out.println("完毕");
  }
}

堆内存分配情况分析过程

  • 担保前的堆空间:allocation1、allocation2、allocation3在新生代,共3MB。
  • 发生Minor GC,触发担保机制:分配allocation4时,新生代空间不足。
  • 担保后的新生代:allocation4放入Eden区。
  • 担保后的老年代:allocation1、allocation2、allocation3进入老年代。

小结

  1. 当Eden区存储不下新分配的对象时,会触发minorGC。
  2. GC之后,还存活的对象,按照正常逻辑,需要存入到Survivor区。
  3. 当无法存入到幸存区时,此时会触发担保机制。
  4. 发生内存担保时,需要将Eden区GC之后还存活的对象放入老年代,后来的新对象或者数组放入Eden区。

1.5 对象内存布局

在堆中,对象的内存布局可以分为三块区域:

  1. 对象头(Header):

    • Java对象头占8byte,如果是数组则占12byte,因为JVM里数组size需要使用4byte存储。
    • 标记字段MarkWord:用于存储对象自身的运行时数据,是synchronized实现轻量级锁和偏向锁的关键,默认存储对象HashCode、GC分代年龄、锁状态等信息,会随着锁标志位的变化而变化。
    • 类型指针KlassPoint:是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,开启指针压缩存储空间4byte,不开启8byte,JDK1.6+默认开启。
    • 数组长度:如果对象是数组,则记录数组长度,占4个byte,如果对象不是数组则不存在。
    • 对齐填充:保证数组的大小永远是8byte的整数倍。
  2. 实例数据(Instance Data):生成对象的时候,对象的非静态成员变量也会存入堆空间。

  3. 对齐填充(Padding):JVM内对象都采用8byte对齐,不够8byte的会自动补齐。

对象头的大小:对象头信息是与对象自身定义的数据无关的额外存储成本。Mark Word被设计成一个非固定的数据结构,会根据对象的状态复用自己的存储空间(JDK1.8)。

1.5.2 案例01

打印空对象的内存布局信息

  1. 引入依赖:

    <dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
    </dependency>
    
  2. 代码:

    import org.openjdk.jol.info.ClassLayout;
    public class ObjLock01 {
         
    public static void main(String[] args) {
         
     Object o = new Object();
     System.out.println("new Object:" +
                        ClassLayout.parseInstance(o).toPrintable());
    }
    }
    
  3. 分析:对象头包含MarkWord(8字节)和类型指针(开启指针压缩时4字节),共12字节;新建Object对象没有实例数据,补充对齐4个字节,总占用16个字节。即对象大小 = 对象头12 + 实例数据0 + 对齐填充4 = 16 bytes。

1.5.3 案例02

打印空对象和赋值后的对象内存布局信息

  1. 代码:

    import org.openjdk.jol.info.ClassLayout;
    public class ObjLock02{
         
    public static void main(String[] args) {
         
     Hero a = new Hero();
     System.out.println("new A:" +
                        ClassLayout.parseInstance(a).toPrintable());
     a.setFlag(true);
     a.setI(1);
     a.setStr("ABC");
     System.out.println("赋值 A:" +
                        ClassLayout.parseInstance(a).toPrintable());
    }
    static class Hero {
         
     private boolean flag;
     private int i;
     private String str;
     public void setFlag(boolean flag) {
         
       this.flag = flag;
     }
     public void setStr(String str) {
         
       this.str = str;
     }
     public void setI(int i) {
         
       this.i = i;
     }
    }
    }
    
  2. 分析:新建对象Hero时,对象头占12个字节(MarkWord占8个+KlassPoint占4个);实例数据中boolean占1个字节(补齐3个),int占4个,String占4个,无需补充对齐。对象的大小 = 12对象头 + 4*3的实例数据 + 0的填充 = 24bytes。

1.6 如何访问一个对象

访问对象有两种方式:

  • 句柄:稳定,对象被移动只要修改句柄中的地址。
  • 直接指针:访问速度快,节省了一次指针定位的开销。

1.6.1 通过句柄访问对象

Java栈的本地变量表中存储句柄池的引用,句柄池中包含到对象实例数据和对象类型数据的指针,通过这些指针访问对象。

1.6.2 通过直接指针访问对象

Java栈的本地变量表中存储直接指向对象实例数据的指针,对象实例数据中包含到对象类型数据的指针,通过该指针访问对象类型数据。

2. JVM垃圾收集器

2.1 GC基本原理

2.1.1 垃圾回收

如果不进行垃圾收集,内存数据很快就会被占满。

2.1.2 什么是垃圾

在内存中,没有被引用的对象就是垃圾。

2.1.3 如何找到这个垃圾

主要有2种方法:引用计数法和根可达算法。

  1. 引用计数法(Reference Counting):
    当对象引用消失时,计数减一,当计数为0时,对象成为垃圾。但该算法不能解决循环引用问题。

    public class Test {
         
      public static void main(String[] args) {
         
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
        object1.object = object2;
        object2.object = object1;
        object1 = null;
        object2 = null;
      }
    }
    class MyObject{
          
      MyObject object;
    }
    
  2. 根可达算法(GCRoots Tracing):
    又叫根搜索算法,从一系列名为“GCRoot”的对象作为起始点,向下搜索,搜索路径称为引用链,当一个对象到GCRoot没有任何引用链相连时,证明此对象不可用。

    可作GCRoots的对象:

    • 虚拟机栈中,栈帧的本地变量表引用的对象。
    • 方法区中,类静态属性引用的对象。
    • 方法区中,常量引用的对象。
    • 本地方法栈中,JNl引用的对象。
  1. 回收过程:
    即使在可达性分析算法中不可达的对象,也并非“非死不可”,要真正宣告死亡,至少要经历两次标记过程:

    • 第一次标记:对象可达性分析后,没有与GC Roots相连接的引用链,会被第一次标记。
    • 第二次标记:第一次标记后进行筛选,判断对象是否有必要执行finalize()方法,在finalize()方法中没有重新与引用链建立关联关系的,将被进行第二次标记,第二次标记成功的对象将被回收,失败则继续存活。
    /**
     * 演示:
     * 1.对象可以在被GC时自我拯救。
     * 2.机会只有一次,对象的finalize()方法只会被系统自动调用一次
     */
    public class finalizeEscapeGC {
         
      public static finalizeEscapeGC SAVE_HOOK = null;
      public void isAlive() {
         
        System.out.println("你瞅啥, 哥还活着 :)");
      }
      @Override
      protected void finalize() throws Throwable {
         
        super.finalize();
        System.out.println("执行 finalize() !");
        finalizeEscapeGC.SAVE_HOOK = this;
      }
      public static void main(String[] args) throws Throwable {
         
        SAVE_HOOK = new finalizeEscapeGC();
        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
         
          SAVE_HOOK.isAlive();
        } else {
         
          System.out.println("哦不, 哥死了 :(");
        }
        //下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
         
          SAVE_HOOK.isAlive();
        } else {
         
          System.out.println("哦不, 哥死了 :(");
        }
      }
    }
    
  1. 对象引用:
    在JDK1.2之后,Java将引用分为强引用、软引用、弱引用、虚引用四种,强度依次减弱。

    | 引用类型 | 被垃圾回收时间 | 用途 | 生存时间 |
    |----------|----------------|----------------------|------------------------|
    | 强引用 | 从来不会 | 对象的一般状态 | JVM 停止时终止 |
    | 软引用 | 内存不足时 | 对象缓存 | 内存不足时终止 |
    | 弱引用 | 正常 GC | 对象缓存 | GC 后终止 |
    | 虚引用 | 正常 GC | 类似事件回调机制 | GC 后终止 |
    | 无引用 | 正常 GC | 对象的一般状态 | GC 后终止 |

  • 强引用:代码中普遍存在,只要强引用还在,对象就不会被GC。

    Object obj = new Object();
    
  • 软引用:非必须引用,内存溢出之前进行回收,可用来实现内存敏感的高速缓存。

    Object obj = new Object();
    SoftReference<Object> sf = new SoftReference<Object>(obj);
    obj = null;
    Object o = sf.get();//有时候会返回null
    System.out.println("o = " + o);
    
  • 弱引用:非必须引用,只要有GC,就会被回收,可监控对象是否已被垃圾回收器标记为即将回收的垃圾。

    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>(obj);
    obj = null;
    //System.gc();
    Object o = wf.get();//有时候会返回null
    boolean enqueued = wf.isEnqueued();//返回是否被垃圾回收器标记为即将回收的垃圾
    System.out.println("o = " + o);
    System.out.println("enqueued = " + enqueued);
    
  • 虚引用:最弱的一种引用关系,垃圾回收时直接回收,无法通过虚引用来取得对象实例,可跟踪对象被垃圾回收的状态。

    Object obj = new Object();
    PhantomReference<Object> pf = new PhantomReference<Object>(obj, new ReferenceQueue<>());
    obj=null;
    Object o = pf.get();//永远返回null
    boolean enqueued = pf.isEnqueued();//返回是否从内存中已经删除
    System.out.println("o = " + o);
    System.out.println("enqueued = " + enqueued);
    

2.1.4 如何清除垃圾

JVM提供3种清除垃圾对象的方法:

  1. 标记清除算法(Mark-Sweep):
    最基本的算法,分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收。缺点是效率不高,会产生大量不连续的内存碎片。

  2. 拷贝算法(Copying):
    为解决效率问题,将可用内存按容量划分为相等的两块,每次只使用其中一块,当这一块内存用完,将存活对象复制到另一块,再清理已使用的内存。商业虚拟机用这种算法回收新生代,HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,当Survivor空间不够用时,需要依赖老年代进行分配担保。优点是没有碎片化,缺点是存在空间浪费。

  3. 标记-整理算法(Mark-Compact):
    标记过程与“标记-清除”算法一样,然后让所有存活的对象都向一端移动,清理掉端边界以外的内存。老年代多采用这种算法,缺点是性能较低。

  4. 分代回收(Generational Collection):
    当前商业虚拟机都采用这种算法,根据对象存活周期不同将内存划分为几块。新生代每次垃圾回收有大量对象失去,选择复制算法;老年代对象存活率高,无人进行分配担保,采用标记清除或者标记整理算法。

2.1.5 用什么清除垃圾

有8种不同的垃圾回收器,用于不同分代的垃圾回收:

  • 新生代回收器:Serial、ParNew、Parallel Scavenge
  • 老年代回收器:Serial Old、Parallel Old、CMS
  • 整堆回收器:G1、ZGC

两个垃圾回收器之间有连线表示它们可以搭配使用,可选的搭配方案如下:

新生代 老年代
Serial Serial Old
Serial CMS
ParNew Serial Old
ParNew CMS
Parallel Scavenge Serial Old
Parallel Scavenge Parallel Old

2.2 串行收集器

2.2.1 基本概念

使用单线程进行垃圾回收的收集器,每次回收时只有一个工作线程,对于并行能力较弱的计算机,串行收集器性能会更好。串行收集器可在新生代和老年代中使用,配置参数-XX:+UseSerialGC表示年轻串行(Serial),老年串行(Serial Old)。

2.2.2 Serial收集器:年轻串行

Serial收集器是新生代收集器,单线程执行,使用复制算法,进行垃圾收集时必须暂停其他所有的工作线程。对于单个CPU的环境,Serial收集器由于没有线程交互的开销,收集效率更高。

2.2.3 Serial Old收集器:老年串行

Serial收集器的老年代版本,单线程收集器,使用“标记-整理”算法。
Safepoint:挂起线程的点主要有循环的末尾、方法返回前、调用方法的call之后、抛出异常的位置。

2.3 并行收集器

2.3.1 Parallel Scavenge收集器

配置参数:-XX:+UseParallelGC,目标是达到一个可控制的吞吐量。

  • 特点:吞吐量优先收集器,新生代使用并行回收收集器(复制算法),老年代使用串行收集器。
  • 吞吐量 = 运行用户代码时间 /(运行用户代码时间+垃圾收集时间),例如虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,吞吐量就是99%。

2.3.2 Parallel Old收集器

配置参数:-XX:+UseParallelOldGC

  • 特点:Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量,CPU资源敏感的场合,可优先考虑Parallel Scavenge加Parallel Old收集器。

2.3.3 ParNew收集器

配置参数:-XX:+UseParNewGC-XX:ParallelGCThreads=n(设置并行收集线程数,一般与CPU数量相当)。

  • 特点:新生代并行(ParNew),老年代串行(Serial Old),是Serial收集器的多线程版本。单CPU性能不如Serial,因为存在线程交互开销。

2.3.4 CMS收集器

配置参数:-XX:+UseConcMarkSweepGC,应用CMS收集器。

  • 特点:低延迟(减少STW对用户体验的影响),并发收集(可同时执行用户线程),采用标记-清除算法,会产生内存碎片,对CPU资源非常敏感。
  • 尽管CMS是并发回收,但初始标记和重新标记阶段仍需要STW,不过暂停时间不长。

  • 整个过程分为4个主要阶段:

    • 初始标记(Initial-Mark):标记出GCRoots能直接关联到的对象,速度快,会STW。
    • 并发标记(Concurrent-Mark):从GC Roots的直接关联对象遍历整个对象图,耗时较长,不会STW。
    • 重新标记(Remark):修正并发标记期间因用户程序继续运作产生的新的对象记录,停顿时间比初始标记长,但远短于并发标记,会STW。
    • 并发清除(Concurrent-Sweep):清理删除标记的死亡对象,可与用户线程并发。

2.3.5 G1(Garbage-First)收集器

G1是面向服务端应用的垃圾收集器,JDK 8以后被称为“全功能的垃圾收集器”,JDK 9时取代Parallel Scavenge加Parallel Old组合成为服务端模式下的默认垃圾收集器。

  • G1最大堆内存是32MB2048=64G,最小堆内存1MB2048=2GB,低于此值建议使用其它收集器。

  • 特点:

    • 并行与并发:充分利用多核环境硬件优势。
    • 多代收集:可独立管理整个GC堆。
    • 空间整合:基于“标记-整理”算法,局部基于“复制”算法,不会产生内存碎片。
    • 可预测的停顿:能指定垃圾收集时间,代价是回收空间效率降低。
  • G1收集器的运作步骤:
    a. 初始标记:标记GC Roots能直接关联的对象,需停顿线程,耗时短。
    b. 并发标记:从GC Root对堆中对象进行可达性分析,找出存活对象,耗时较长,可与用户程序并发。
    c. 最终标记:修正在并发标记期间因用户程序运作导致标记变动的记录。
    d. 筛选回收:对各个Region的回收价值和成本排序,根据期望GC停顿时间制定回收计划。

  • G1有三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同条件下触发。

  • G1内存划分:取消新生代、老年代的物理划分,将堆划分为若干Region,包含逻辑上的新生代、老年代区域,不用单独设置每个代的空间,不用考虑代内存分配。

  • 局部采用复制算法:新生代垃圾收集暂停所有应用线程,将存活对象拷贝到老年代或Survivor空间,通过区域复制完成清理,避免cms内存碎片问题。

  • Humongous区域:专门存放巨型对象(占用空间超过分区容量50%以上的对象),如果一个H区装不下,会寻找连续的H分区存储。

  • 收集器相关参数:
    -XX:+UseG1GC 
    # 使用 G1 垃圾收集器
    -XX:MaxGCPauseMillis=
    # 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是 200 毫秒。
    -XX:G1HeapRegionSize=n 
    # 设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。
    # 目标是根据最小的 Java 堆大小划分出约 2048 个区域。
    # 默认是堆内存的1/2000。
    -XX:ParallelGCThreads=n 
    # 设置并行垃圾回收线程数,一般将n的值设置为逻辑处理器的数量,建议最多为8。
    -XX:ConcGCThreads=n 
    # 设置并行标记的线程数。将n设置为ParallelGCThreads的1/4左右。
    -XX:InitiatingHeapOccupancyPercent=n 
    # 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。
    

2.3.6 ZGC(Z Garbage Collector)

ZGC在JDK11中引入,JDK15中发布稳定版,是可扩展的低延迟垃圾收集器。

  • 目标:< 1ms 最大暂停时间(jdk < 16 是10ms,jdk >=16 是<1ms),暂停时间不随堆、live-set或root-set大小增加,适用内存大小从8MB到16TB的堆。

  • 特征:并发、基于region、压缩、NUMA感知、使用彩色指针、使用负载屏障。

  • ZGC是基于Region内存布局的,不设分代的,使用读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理算法,以低延迟为首要目标。

  • 相关参数:
    -XX:+UseZGC # 启用 ZGC 
    -Xmx # 设置最大堆内存
    -Xlog:gc # 打印 GC日志
    -Xlog:gc* # 打印 GC 详细日志
    

2.4 Minor GC 、Major GC和 Full GC 的区别

  • 新生代收集(Minor GC/Young GC):目标只是新生代的垃圾收集,非常频繁,回收速度快。
  • 老年代收集(Major GC/Old GC):目标只是老年代的垃圾收集,一般比Minor GC慢10倍以上,目前只有CMS收集器会有单独收集老年代的行为,“Major GC”说法有混淆,需按上下文区分。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
  • 混合收集(Mixed GC):目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。

今日总结

01-JVM基本常识

  • 什么是JVM?广义上的JVM是指一种规范,狭义上的JVM指的是Hotspot类的虚拟机实现。
  • Java语言与JVM的关系:Java语言编写程序生成class字节码在JVM虚拟机里执行。其他语言也可以,比如Scala、Groovy。
  • 学习JVM主要学啥?类加载子系统 --> 运行时数据区 --> 一个对象的一生--> GC垃圾收集器。
  • 学了JVM可以干啥?JVM调优,底层能力决定上层建筑。

02-类加载子系统

  • 类加载四个时机:1.new、getstatic、putstatic、invokestatic。2.反射。3.初始化子类发现父类没有初始化时。4.main函数的类。
  • 类加载主要过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载。
  • 类加载主要做了三件事:
    • 全限定名称 → 二进制字节流加载class文件
    • 字节流的静态数据结构 → 方法区的运行时数据结构
    • 创建字节码Class对象
  • 可以从哪些途径加载字节码:Jar、war、JSP生成的class、数据库中二进制字节流、网络中二进制字节流、动态代理生成的二进制字节流。
  • 类加载器有哪些?启动类加载器BootstrapClassLoader,扩展类加载器ExtensionClassLoader,应用类加载器ApplicationClassLoader,自定义类加载器UserClassLoader。
  • 检查顺序自底向上,加载顺序自顶向下。
  • 什么是双亲委派?当一个类加载器收到加载任务,会先交给其父类加载器去加载。
  • 为何要打破双亲委派?父类加载器加载范围受限,无法加载的类需要委托子类加载器去完成加载。

03-运行时数据区

  • 堆:JVM启动是创建的最大的一块内存区域,对象、数组、运行时常量池都在这里。
  • 内存划分:Eden、2个Survivor、老年代。
  • 为什么要划分新生代与老年代?基于分代收集理论里的两大假说,弱分代和强分代假说。提升垃圾收集的效率。
  • 内存模型变迁史:JDK1.7 ---取消永久代,多了元空间---> JDK1.8 ---取消新生代与老年代物理划分---> JDK1.9。

  • 虚拟机栈:栈空间为线程私有,每个线程都会创建栈内存,生命周期与线程相同。线程内的栈内存占满了会出现StackOverflowError。

  • 栈帧是什么?栈帧(Stack Frame)是用于支持虚拟机进行方法执行的数据结构。

  • 本地方法栈:与虚拟机栈类似,区别在于本地方法栈为本地方法服务,也就是native方法。

  • 方法区:方法区的实现有两种:永久代(PermGen)、元空间(Metaspace)。

  • 方法区存什么数据?类型信息,方法信息,字段信息,类变量信息,方法表,指向类加载器的引用,指向Class实例的引用。
  • 永久代和元空间有什么区别?存储位置不同、存储内容不同。
  • 为什么要使用元空间来替换永久代?基于性能、稳定性、GC垃圾收集的复杂度考虑,当然也有Oracle收购了Java原因。

  • 三种常量池:class常量池、运行时常量池、字符串常量池。

  • 字符串常量池如何存储数据?使用哈希表【哈希冲突,哈希碰撞等】。
  • 字符串常量池如何查找字符串?类似于HashMap。

  • 程序计数器:存储当前线程执行时的字节码指令地址。为什么需要程序计数器?因为系统的上下文切换。

  • 直接内存:相对堆内存,直接内存申请空间更耗时,直接内存IO读写的性能要优于普通的堆内存。

04-对象的生命周期

  • 对象创建的流程:①常量池检查、②分配内存空间、③初始化零值、④设置对象头内元数据信息。
  • 内存分配方式:指针碰撞(Bump the Pointer)、空闲列表(Free List)。
  • 内存分配安全性问题及解决方案:本地线程分配缓冲TLAB、乐观锁CAS。
  • 新对象大多数默认进入新生代的Eden区。
  • 对象进入老年代的四种主要情况:存活年龄太大(默认超过1
相关文章
|
3月前
|
人工智能 算法 API
把「想法」编译成「现实」:魔搭&AMD开发者实践专场完整回顾
8月2日下午,魔搭社区ModelScope 联手 AMD,在杭州办了场有料有趣的「Agent × MCP」开发者实践专场!
201 0
|
canal 关系型数据库 MySQL
es添加索引命令行和浏览器添加索引--图文详解
es添加索引命令行和浏览器添加索引--图文详解
408 1
|
7月前
|
人工智能 JSON 安全
MCP Server 实践之旅第 1 站:MCP 协议解析与云上适配
本文深入解析了Model Context Protocol(MCP)协议,探讨其在AI领域的应用与技术挑战。MCP作为AI协作的“USB-C接口”,通过标准化数据交互解决大模型潜力释放的关键瓶颈。文章详细分析了MCP的生命周期、传输方式(STDIO与SSE),并提出针对SSE协议不足的优化方案——MCP Proxy,实现从STDIO到SSE的无缝转换。同时,函数计算平台被推荐为MCP Server的理想运行时,因其具备自动弹性扩缩容、高安全性和按需计费等优势。最后,展望了MCP技术演进方向及对AI基础设施普及的推动作用,强调函数计算助力MCP大规模落地,加速行业创新。
1796 77
|
7月前
|
存储 人工智能 监控
一键部署 Dify + MCP Server,高效开发 AI 智能体应用
本文将着重介绍如何通过 SAE 快速搭建 Dify AI 研发平台,依托 Serverless 架构提供全托管、免运维的解决方案,高效开发 AI 智能体应用。
6078 64
|
3月前
|
C# 图形学
【Unity3D实例-功能-下蹲】角色下蹲(二)穿越隧道
本文介绍如何在Unity3D中实现角色下蹲功能,通过CharacterController组件实现一键下蹲与实时碰撞检测,避免角色穿越隧道穿模问题,提升游戏体验。
67 0
|
3月前
|
机器学习/深度学习 人工智能 PyTorch
三周内转型AI工程师学习计划
3周AI转型计划:掌握数学、机器学习与深度学习基础,熟练使用Python、PyTorch/TensorFlow。完成2-3个CV/NLP项目,构建GitHub博客,强化LeetCode刷题与模拟面试。每日高效学习9小时,聚焦实战与面试准备,助力快速入行AI。
202 0
|
3月前
|
安全 Java C语言
JUC
简介:本文详解Java并发编程核心机制,涵盖CAS原理及其在AtomicInteger等类中的应用,探讨ABA问题、自旋开销及多变量原子操作限制,并介绍Unsafe类与AQS同步框架,帮助开发者深入理解无锁与阻塞同步实现原理。
129 0
|
4月前
|
存储 Java 测试技术
Java基础 - 面向对象
面向对象编程是Java的核心,包含封装、继承、多态三大特性。封装隐藏实现细节,提升代码可维护性与安全性;继承实现类间IS-A关系,支持代码复用;多态通过继承、重写与向上转型,实现运行时方法动态绑定,提升系统扩展性与灵活性。
|
3月前
|
存储 缓存 安全
Java 并发
本节介绍了Java并发编程的核心问题及解决机制。由于CPU、内存和I/O速度差异,多线程环境下会出现可见性、原子性和有序性三大问题。Java通过JMM(Java内存模型)提供volatile、synchronized等关键字及Happens-Before规则,保障线程安全。
|
4月前
|
存储 缓存 安全
集合 Collection
Java集合框架包含List、Set和Map三大接口。List如ArrayList和LinkedList,支持有序可重复元素;Set如HashSet和TreeSet,保证元素唯一性;Map如HashMap和TreeMap,以键值对存储数据。ArrayList基于动态数组,查询快而增删慢;LinkedList基于链表,适合频繁插入删除。HashMap底层为数组+链表/红黑树,利用哈希优化存取效率;ConcurrentHashMap通过分段锁实现线程安全。LinkedHashSet保持插入顺序,TreeSet支持排序。选择合适集合可提升程序性能与可维护性。
136 0
下一篇
开通oss服务