【Java】JVM

一、JVM体系结构

1、虚拟机概述

虚拟机(Virtual Machine):一台虚拟的计算机,指一种特殊的软件,他可以在计算机平台和终端用户之间创建一种环境,而终端用户则是基于这个软件所创建的环境来操作软件。虚拟机可划分为:

  • 系统虚拟机:VMWare等,对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台
  • 程序虚拟机:Java虚拟机等,专门为执行单个计算机程序而设计

Java技术的核心就是Java虚拟机 (JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。

Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。

Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指今,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,怎么处理结果放在哪里。

Java虚拟机特点:一次编译,到处运行;自动内存管理;自动垃圾回收功能。

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

2、JVM整体架构

程序在执行之前先要把 java 代码转换成字节码(class 文件),jvm 首先需要把字节码通过一定的方式(类加载器(ClassLoader)) 把文件加载到内存中的运行时数据区(Runtime Data Area) ,而字节码文件是 jvm 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器(执行引擎(Execution Engine)) 将字节码翻译成底层系统指令,再交由CPU 去执行,而这个过程中需要调用其他语言的接口(本地库接口(NativeInterface))来实现整个程序的功能,这就是这 4 个主要组成部分的职责与功能。

3、JVM架构模型

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

  • 优点是跨平台,指令集小,编译器容易实现。
  • 缺点是性能下降,实现同样的功能需要更多的指令。

指令流架构的区别:

  • 基于栈式架构的特点:
    • 设计和实现更简单,适用于资源受限的系统;
    • 避开了寄存器的分配难题: 使用零地址指令方式分配。
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小.
    • 编译器容易实现。
    • 不需要硬件支持,可移植性更好,更好实现跨平台
  • 基于寄存器架构的特点
    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机。
    • 指令集架构则完全依赖硬件,可移植性差
    • 性能优秀和执行更高效;
    • 花费更少的指令去完成一项操作。

在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。务

4、JVM生命周期

JVM(Java虚拟机)的生命周期主要涉及到JVM实例的诞生、运行和消亡。

(1)JVM实例的诞生

当启动一个Java程序时,一个JVM实例就诞生了。任何一个拥有public static void main(String\[\] args)函数的类都可以作为JVM实例的运行启点。main方法作为程序初始化线程的起点,任何其他线程都由其启动。

(3)JVM实例的运行

在JVM实例运行期间,它会执行以下操作:

  • 类加载:
  • JVM通过类加载器将类的字节码文件从文件系统、网络等位置读取到内存中,并在内存中构建出类的原型------类模板对象。
  • 类加载器只负责加载,只要符合文件结构就加载,至于能否运行,则不是它负责的,那是由执行引擎负责。
  • 连接:
    • 连接阶段包括验证、准备和解析三个子阶段。
      • 验证:确保类的字节码符合Java虚拟机规范,防止恶意代码的执行。JVM会对字节码进行各种静态检查,以确保字节码的结构和内容是正确的和安全的。
      • 准备:为类的静态变量分配内存空间并设置默认初始值。例如,数值类型的变量初始化为0,对象类型的变量初始化为null。
      • 解析:将类、接口、字段和方法的符号引用解析为直接引用,即具体的内存地址,以便后续的执行过程中能够直接定位到对应的方法或字段。
  • 初始化:
    • 执行类的初始化代码,包括静态代码块和静态变量的赋值操作。类的初始化是在首次使用类时触发的,包括创建对象实例、调用静态方法等。
  • 程序执行:
    • JVM通过执行引擎读取并执行字节码指令,包括本地方法栈、程序计数器、VM栈和堆内存等各个部分的协同工作。
    • 本地方法栈为虚拟机使用到的Native方法服务。
    • 每个线程都有一个程序计数器,指向方法区中的方法字节码,由执行引擎读取下一条指令。
    • VM栈描述的是Java方法调用的内存模型,每个方法被执行时都会创建一个帧(Frame)用于存储本地变量表、操作栈、动态链接、方法出入口等信息。
    • 堆内存用于存放对象实例,是垃圾回收器管理的主要区域。

(3)JVM实例的消亡

当程序中所有非守护线程都中止时,JVM退出。若安全管理器允许,程序也可以使用Runtime类或者System.exit()方法退出。

5、JVM发展历程

  • Sun Classic VM
    19966年Java1.0版本时发布,世界上第一款商用Java虚拟机。该虚拟机只提供解释器,要使用JIT编译器只能外挂,并且解释器与编译器不能配合工作;
  • Exact VM
    Exact Memory Management:准确式内存管理;在JDK1.2时发布;该虚拟机可以知道内存中某个位置的数据具体是什么类型;只在Solaris平台短暂使用;
  • Sun HotSpot VM
    JDK1.3时,HotSpot称为默认虚拟机;目前Hotspot占有绝对的市场地位;
  • BEA JRockit
    专注于服务端应用,可以不关注程序启动速度,内部不包含解析器实现,全部代码都靠及时编译器编译后运行;JRockit是世界上最快的JVM;
  • IBM J9 VM
    市场定位与Hotspot接近,广泛用于IBM的Java产品
  • KVM、CDC/CLDC Hotspot
    Oracle在Java ME产品线上的两款虚拟机,KVM是CLDC-HI早起产品
  • Azul VM、BEA Liquid VM
    Hotspot、JRockit、J9 是通用平台上的高性能Java虚拟机,Azul VM与BEA Liquid VM是与特定硬件平台绑定、软件件配合的专有虚拟机,是高性能Java虚拟机中的战斗机;
  • Apache Harmony
    IBM与Intel联合研发的开源JVM,是与JDK1.5和JDK1.6兼容的Java运行平台;
  • Microsoft JVM
    微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM;
  • TaoBao JVM
    基于OpenJDK开发的自己的定制版本AlibabaJDK,是基于OpenJDK Hotspot VM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机。taobao VM应用在阿里产品上性能GAP,硬件严重依赖Intel,损失兼容性,提高了性能;、

二、类加载子系统

1、类加载器子系统

类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识。classLoader只负责class文件的加载,至于它是否可以运行,则由ExecutionEngine决定。

加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射)

class file 存在于硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载 JVM 当中来,根据这个模板实例化出 n 个实例。class file 加载到 JVM 中,被称为 DNA 元数据模板。此过程就要有一个运输工具(类加载器 Class Loader)来扮演一个快递员的角色。

2、类加载过程

类加载过程分为三个步骤:加载、链接(验证、准备、解析)、初始化

2.1 加载

加载:将class字节码文件从各个来源通过类加载器装载入内存中,分三步:

  • 通过一个类的全限定名获取定义该类的二进制字节流
  • 将这个字节流所代表的静态存储结构解析成方法区的运行时数据结构(类信息),类信息主要包括:运行时常量池、类型信息、字段信息、方法信息、类加载器的引用(这个类到类加载器实例的引用)、对应class实例的引用等信息。
  • 在堆区(Heap)生成该类的Class对象,作为方法区这个类的各种数据的访问入口

    方法区中的InstanceKlass对象与堆中的java.lang.Class对象相互关联 ,你既可以通过InstanceKlass对象找到对应的java.lang.Class对象,也可以通过java.lang.Class对象找到关联的InstanceKlass对象。
    程序员可以使用 Java 代码拓展的不同的渠道
  • 本地文件:磁盘上的字节码文件
  • 动态代理生成:程序运行时使用动态代理生成
  • 通过网络传输的类:早期的Applet技术使用

类加载的时机(隐式加载和显示加载)

  • 隐式加载:创建类对象、使用类的静态域、创建子类对象、使用子类的静态域

    JVM启动时,BootStrapLoader、ExtClassLoader、AppClassLoader

  • 显示加载:自定义类加载器

    ClassLoader.loadClass(className):只加载和连接、不会进行初始化

    Class.forName(String name, boolean initialize,ClassLoader loader):使用ClassLoader进行加载和连接,根据参数initialize决定是否初始化。

为什么JVM需要在方法区和堆区中各创建一个对象?如果不要堆里的对象,而只留方法区中的对象,使用反射时只去获取方法区中的对象,这样不是能节省一定的内存空间吗?

方法区中的 InstanceKlass 对象是使用C++语言编写的对象,而Java语言一般不能操纵使用C++语言编写的对象,所以JVM 会在堆区中创建一个使用Java编写的Java.lang.Object对象,这样可以在Java代码中获取这个对象。

堆中的 Java.lang.Objec t对象中的字段信息要少于方法区中的 InstanceKlass 对象,而 InstanceKlass 对象中的所有信息对开发者来说并不是所有都需要的,比如InstanceKlass对象中的虚方法表,这个虚方法表是JVM在底层实现多态时而去使用,对开发者而言,完全不需要去使用它,因此基于控制开发者访问部分数据的范围,实现提升数据的安全性。

所以,对于开发者来说,只需要访问堆中的Class对象而不需要访问方法区中所有信息。这样Java虚拟机就能很好地控制开发者访问数据的范围。

查看内存中对象

使用 JDK自带的 hsdb 工具查看Java虚拟机内存信息,工具位于 JDK安装目录下lib文件夹中的 sa-jdi.jar 中。

  • 第一步,在IDEA中启动项目,使用 jps 命令获取项目进程ID

    shell 复制代码
    zhangsan@Mac ~ % jps       
    36754 Main
    37112 Launcher
    37113 PersonDemo
    37118 Jps

    项目代码如下:

    java 复制代码
    import java.io.IOException;
    
    public class PersonDemo {
        public static final int i = 1;
    
        public static void main(String[] args) throws IOException {
    PersonDemo personDemo = new PersonDemo();
    System.in.read();
        }
    }
  • 第二步,启动 hsdb 服务

    shell 复制代码
    zhangsan@Mac ~ % java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
  • 第三步,File -> Attach to HotSpot process -> 输入第一步获取的进程ID -> OK

    报错:sun jvm hotspot. debugger. Debuggerexception: Cant attach to the process Could be caused by an incorrect pid or lack of privileges.

    原因1:当前jdk版本和输入的进程pid对应的java版本不同.

    原因2:当前jdk版本问题(猜测:某些版本不支持macos),本人尝试了1.8.321和1.8.311以及一个11一个15版本的都不行.最后换到1.8.231和1.8.191才可以.

    原因3:权限不足,HSDB 启动命令改为 sudo java -cp sa-jdi.jar sun.jvm.hotspot.HSDB

  • 第四步,Tools -> Object Histogram ,然后输入类的全类名找到指定的对象信息

2.2 链接

连接分为三步:

  • 验证 :确保Class文件的字节流中包含的信息符合虚拟机要求,保证被加载类的准确性,并且不会危害虚拟机;
    验证方式主要包括:文件格式验证、元数据验证、字节码指令验证、符号引用验证。
  • 准备 :为类的静态变量分配内存、赋初值
    初值并非实际值,而是系统默认的值,如0、false等,在初始化阶段才会赋实际值
    这里不包括final修饰的static常量,其在编译的时候会给属性添加 ConstantValue 属性,准备阶段直接完成赋值
    实例变量不会分配初始化,类变量会分配在方法区,而实例变量是随对象一起分配在Java堆中
  • 解析 :将符号引用替换为直接引用,也称之为静态链接
    符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,如 #7
    直接引用:可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。而直接引用必须引用的目标已经在内存中存在。

2.3 初始化

初始化阶段会执行静态代码块中的代码,并为静态变量赋值。

初始化阶段就是执行类构造器 <clinit>() 方法的过程。

<clinit>() 方法不需要定义,由javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来。构造器方法中指令按语句在源文件中出现的顺序执行。若该类具有父类,JVM会保证子类的 <clinit>() 执行前,父类的 <clinit>() 已经执行完毕。虚拟机必须保证一个类的<clinit>() 方法在多线程下被同步加锁。

<clinit>() 不同于类的构造器(关联:构造器是虚拟机视角下的())

clinit指令在某些特定的情况下不会出现:

  • 无静态代码块且无静态变量赋值语句。

  • 有静态变量的声明,但是没有赋值语句。

  • 静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。

JVM规定:每个类或接口被首次主动使用时才会对其进行初始化,主动调用包括:

  • 访问类的静态变量或静态方法,注意变量是final修饰的并且等号右边是常量的不会出发初始化
  • 调用Class.forName(String className)
  • new 一个该类的对象时
  • 执行Main方法的当前类

被动引用(除了上述主动调用的Java调用,不会触发初始化):

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

  • 定义对象数组和集合,不会触发该类的初始化,比如:

    复制代码
    Student[] students = new Student[10] ;
  • 引用该类的静态常量,不会导致初始化。这里的常量是指已经指定字面量的常量,对于那些需要一些计算才能得出结果的常量就会导致类加载,比如:

    java 复制代码
    public final static int NUMBER = 5 ; //不会导致类初始化
    public final static int RANDOM = new Random().nextInt() ; //会导致类加载

程序启动时,添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类

3、类加载器

JVM支持两种类加载器:引导类加载器(Bootstrap ClassLoader)和自定义加载器(User-Defined ClassLoader)

JVM虚拟机规范将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器,拓展类加载器(Extension ClassLoader) 与 应用程序类加载器(System ClassLoader)均属于自定义加载器。

JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

3.1 引导类加载器

引导类加载器(启动类加载器,BootStrap ClassLoader)

  • 使用 C/C++语言实现,嵌套在 JVM 内部,并不继承于 java.lang.ClassLoader,没有父加载器;

  • 它用来加载 java 核心类库,用于提供JVM自身需要的类

    复制代码
    URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
  • 负责加载扩展类加载器和应用类加载器,并为他们指定父类加载器(应用父加载器为扩展,扩展父加载器为启动,但在代码里获取扩展父加载器显示为null,因为启动类加载器无法用Java代码获取);

  • 默认加载 java安装目录/jre/lib 下的类文件,比如 rt.jar、tools.jar、resource.jar等。

  • 出于安全考虑,BootStrap启动类加载器只加载名为java、javax、sun等开头的类

  • 通过启动类加载器去加载用户jar包:

    • 方式一:放入jre/lib下进行扩展。
      • 不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载
    • 方式二:使用参数进行扩展
      • 推荐,使用 -Xbootclasspath/a:jar包目录/jar包名 进行扩展

3.2 扩展类加载器

扩展类加载器(Extension ClassLoader)

  • Java 语言编写的,派生于 ClassLoader 类,由 sun.misc.Launcher$$ExtClassLoader实现;

  • 父类加载器为启动类加载器

  • 负责加载java.ext.dirs系统属性所制定的路径下的类库

    java 复制代码
    System.out.println(System.getProperty("java.ext.dirs"));
  • 通过扩展类加载器去加载用户jar包:

    • 方式一:放入 /jre/lib/ext 下进行扩展
      • 不推荐,尽可能不要去更改JDK安装目录中的内容
    • 方式二:使用参数进行扩展
      • 推荐,使用 -Djava.ext.dirs="{jdk路径}/jre/lib/ext分隔符jar包目录" 进行扩展,分隔符:;(windows)、:(macos/linux)

3.3 应用程序类加载器

应用程序类加载器(System ClassLoader)

  • Java 语言编写的,派生于 ClassLoader 类,由 sun.misc.Launcher$AppClassLoader 实现;

  • 父类加载器为扩展类加载器

  • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

  • 程序默认的类加载器,Java应用的类一般由它来加载

    java 复制代码
    System.out.println(ClassLoader.getSystemClassLoader());
    System.out.println(Test.class.getClassLoader());

3.4 自定义类加载器

需要自定义类加载器的场景:

  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载类
  • 防止源码泄露

自定义类加载器的父类加载器由 getSystemClassLoader 方法设置,该方法返回的是 AppClassLoader

自定义类加载器的实现步骤:

  • 开发人员可以通过继承抽象类java.lang.classLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
  • 在JDK1.2之前,在自定义类加载器时,总会去继承classLoader类并重写loadclass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadclass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
  • 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URILClassLoader类,这样就可以避免自己去编写findclass0)方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

3.5 双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委淡模式,即把请求交由父类处理它是一种任务委派模式。
双亲委派机制工作原理:

  • 如果一个类加载器收到了类加载请求,它并不会自已先去加载,而是把这个请求委托给父类的加载器去执行;
  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归请求最终将到达顶层的启动类加载器
  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自已去加载

双亲委派机制优势:

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

沙箱安全机制:

在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lanq\string.class),报错信息说没有main方法就是因为加载的是rt.jar包中的string类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。

Java判断两个Class对象是否为同一个类的条件:

  • 类的完整类名必须一直,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

Classloader 原理:

ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。

  • public Class<?> loadClass(String name)

    类加载的入口,提供了双亲委派机制。内部会调用findClass

  • protected Class<?> findClass(String name)

    由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。

  • protected final Class<?> defineClass(String name, byte\[\] b, int off, int len)

    做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中

  • protected final void resolveClass(Class<?> c)

    执行类生命周期中的连接阶段

打破双亲委派:

  • 方式一:自定义类加载器,重写loadClass方法
    因为双亲委派机制的实现都是通过这个方法实现的,这个方法可以指定类通过什么加载器来进行加载,所以如果我们改写他的加载规则,就相当于打破了双亲委派机制。默认的过程是这样的,先判断这个类是不是已经被当前层的类加载器加载过了,如果没有加载过就就将该类委派给父类加载器,如果父加载器无法加载再向下传递,回来由自己来进行加载,重写了这个方法以后就能自己定义使用什么加载器了,也可以自定义加载委派机制,也就打破了双亲委派模型。重写loadClass()方法也就有可能连带把findClass(方法也重写。
    Tomcat使用了自定义类加载器来实现应用之间类的隔离。每一个应用会有一个独立的类加载器加载对应的类
  • 方式二:使用线程上下文类加载器
    线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread.setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
    有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如 JNDIJDBCJCEJAXBJBI 等。
  • 方式三,历史上 OSGI 框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载
    OSGi还使用类加载器实现了热部署的.功能

三、运行时数据区域

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存包括的运行时数据区域为:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区。

1、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

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

如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地 (Native) 方法,这个计数器值则应为空 (Undefined)。

此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域,因为它只保存一个8字节的地址。

2、Java虚拟机栈(Java Virtual Machine Stack)

Java虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

Java虚拟机栈是线程私有的,它的生命周期与线程相同。

栈是一种快速有效的内存分配方式,访问速度仅次于程序计数器。对于栈来说不存在垃圾回收问题。
栈帧整体结构:

  • 局部变量表(Local Variables)
  • 操作数栈(operand Stack)(或表达式栈)
  • 帧数据(主要包含动态链接、方法出口、异常表的引用)

栈大小

如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈,大小取决于操作系统和计算机的体系结构.

HotSpot JVM对栈大小的最大值和最小值有要求,过大或过小的设置是无效的。

局部变量过多、操作数栈深度过大也会影响栈内存的大小。

  • 使用 -Xss参数可设置栈内存大小,如 -Xss1m
    Xss单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
  • 使用 -XX:ThreadStackSize 调整标志来配置堆栈大小,如:-XX:ThreadStackSize=1024

在《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
  • 如果Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError 异常。

2.1 局部变量表

  • 局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。
    如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在index为0的slot处,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到后续的Slot上 。
  • 栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。
    如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或doub1e类型变量)
    byte、short、char、boolean 在存储前被转换为int。(比如,boolean,0表示false,非0表示true)
    32位虚拟机为32位,槽为4个字节;64位虚拟机为64位,槽为8个字节
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
    栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题 。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

2.2 操作数栈

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间;
  • 在方法执行过程中,操作数栈根据字节码指令,往栈中写入数据(入栈push)或提取数据(出栈pop)。某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈,比如:执行复制、交换、求和等操作;
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的;
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值;
  • 栈中的任何一个元素都是可以任意的Java数据类型:32bit的类型占用一个栈单位深度,64bit的类型占用两个栈单位深度
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  • Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

方法调用时,数据流转配合过程:

栈顶缓存技术:

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

2.3 帧数据

2.3.1 动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。

当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。

2.3.2 方法出口

方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。

2.3.3 异常表的引用

异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

3、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码) 服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

本地方法栈中登记native方法,在Execute Engine执行时加载本地方法库。

本地方法栈也是线程私有的。

《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的 Java 虚拟机(如 Hot.Spot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutfMemoryError 异常。

4、Java堆

  • Java 堆区在 JVM 启动时的时候即被创建,其空间大小也就确定了,是 JVM 管理的最大一块内存空间。此内存区域的唯一目的就是存放对象实例,Java 世界里"几乎"所有的对象实例都在这里分配内存。
    在《 Java 虚拟机规范》中对 Java 堆的描述是:"所有的对象实例以及数组都应当在堆上分配"。而这里的"几乎"是指从实现角度来看,随着 Java 语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了;
  • Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟机都是按照可扩展来实现的,通过参数-Xms设置堆起始大小(默认 物理电脑内存大小/64)、-Xmx设定堆最大内存大小(默认 物理电脑内存大小/4),一般情况可以将起始值和最大值设置为一致,这样会减少垃圾回收之后堆内存重新分配大小的次数,提高效率)。
  • 根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间
  • 从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread LocalAllocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存;
  • Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作"GC堆"(GarbageCollected Heap)。从回收内存的角度看,现代垃圾收集器大部分都是基于分代收集理论设计的,这些区域的划分仅是一部分垃圾收集器的设计风格,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。到了今天,HotSpot里也出现了不采用分代设计的新垃圾收集器。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
  • 如果在Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出OutOfMemoryError 异常;
  • Java堆是被所有线程共享的一块内存区域。

5、方法区

方法区是存放基础信息的位置。

方法区是各个线程共享的内存区域

多个线程同时加载同一个类时,只能有一个线程能加载该类,其他线程只能等待该线程加载完毕,然后直接使用该类,即类只能加载一次。

方法区主要包含三部分内容:

  • 类的元信息
    方法区是用来存储每个类的基本信息(元信息),一般称之为 InstanceKlass对象,在类的加载阶段完成。
  • 运行时常量池
    保存了字节码文件中的常量池内容。
    字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
  • 字符串常量池
    保存了字符串常量

HotSpot JDK各版本方法区实现:

  • JDK6 及以前,HotSpot 里面的结构有:堆、栈、永久代、程序计数器。在HotSpot中,永久代是方法区的实现,永久代分配内存空间的方式是预先操作。永久代的大小由虚拟机参数 -XX:MaxPermSize=值 来控制。
  • JDK7 将永久代中的静态变量和运行时常量池中的字符串常量池转移到了堆中,也就是说全局变量和其他常量(非字符串常量)还遗留在永久代中。
    将字符串常量池(StringTable)调整至堆区存储的原因:在Full GC的时候才会执行永久代的垃圾回收,而Full GC是老年代的空间不足、永久代不足时才会触发,这就导致永久代回收效率不高,即StringTable回收效率不高,而在开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
  • JDK7及之前,会为每个类加载器分配一个内存列表,它会进行线性分配,整个内存块的大小,取决于类加载器的类型,比如代理和反射对应的类加载器的内存块会小一些,而之前的版本在卸载类的时候,会对这些内存列表进行单独卸载。永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • JDK8 移除了永久代,用元空间代替,将 HotSpot 与 JRockit 合二为一。在原来永久代的东西都被搬到了元空间,所以相对于 JDK7版本,还依然是字符串常量池和静态变量装到了堆中,其余的装到元空间。GC的时候在发现某个类加载器已经具备了回收条件之后,会将整个类加载器以及它相关的整个空间进行卸载,这样可以减少内存碎片,节省GC的扫描以及压缩的时间。
    元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。 可以使用 -XX:MaxMetaspaceSize=值 将元空间最大大小进行限制。
  • String.intern () 在不同版本JDK中表现不同
    在 JDK6 版本中方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中,返回的也是永久代里面这个字符串实例的引用。
    在JDK7及之后版本中由于字符串常量池在堆上,所以intern () 方法会把第一次遇到的字符串的引用放入字符串常量池。

垃圾回收

《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样"永久"存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,其主要目标是降低Full GC,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

根据《 Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

6、直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《 Java虚拟机规范》中定义的内存区域。

在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:

  • Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
  • IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
    现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。

直接内存大小

手动调整直接内存的大小,可以使用 -XX:MaxDirectMemorySize=大小

单位k或K表示千字节,m或M表示兆字节,g或G表示千兆字节。默认不设置该参数情况下,JVM 自动选择 最大分配的大小

创建直接内存:

复制代码
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);

可使用arthas的memory命令查看。

7、栈、堆、方法区

7.1 从线程是否共享角度分析

7.2 栈、堆、方法区交互关系

四、本地方法

一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中可以用extern "c"告知C++编译器去调用一个C的函数。

"A native method is a Java method whose implementation isprovided by non-java code."

在定义一个native method时,并不提供实现体(有些像定义一个Javainterface),因为其实现体是由非iava语言在外面实现的。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合 C/C++程序。

标识符native可以与所有其它的iava标识符连用,但是abstract除外。
为什么要使用Native Method:

  • 与Java环境外交互:有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
  • 与操作系统交互:JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统,通过使用本地我们得以用Java实现了jre的与底层系统的交互,甚至JVM。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
  • Sun's Java:Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。ire大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的 setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority00)。这个本地方法是用c实现的,并被植入JVM内部,在windows 95的平台上,这个本地方法最终将调用win32 setPriority() API。这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

五、自动回收

Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection简称GC)机制。通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收。

线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁。而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存。

如果需要手动触发垃圾回收,可以调用 System.gc() 方法。但是,调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断。

如果想要查看垃圾回收的信息,可以使用 -verbose:gc 参数。

1、方法区的回收

方法区中能回收的内容主要就是不再使用的类。

判定一个类可以被卸载,需要同时满足下面三个条件:

  • 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
  • 加载该类的类加载器已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用。

场景

开发中此类场景一般很少出现,主要在如 OSGi、JSP 的热部署等应用场景中。

每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

2、堆回收

垃圾回收要做的有两件事:

  • 找到内存中存活的对象
  • 释放不再存活对象的内存,使得程序能再次利用这部分空间

2.1 判断堆上对象是否被引用

判断堆上的对象有没有被引用:引用计数法和可达性分析法

引用计数法

引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。

引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:

  • 每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响
  • 存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题。

可达性分析法

Java采用可达性分析算法来判断对象是否可以被回收。可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收。

可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系。

GC Root对象:

  • 线程Thread对象 ⇒ 引用线程栈帧中的方法参数、局部变量等。
  • 系统类加载器加载的java.lang.Class对象 ⇒ 引用类中的静态变量。
  • 监视器对象,用来保存同步锁synchronized关键字持有的对象。
  • 本地方法调用时使用的全局对象。

2.2 五种对象引用

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用
  • 终结器引用

强饮用

可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,

普通对象就不会被回收。

软引用

软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。

在JDK 1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中。

软引用的执行过程如下:

  • 将对象使用软引用包装起来,new SoftReference<对象类型>(对象)。
  • 内存不足时,虚拟机尝试进行垃圾回收。
  • 如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象。
  • 如果依然内存不足,抛出OutOfMemory异常。
    软引用中的对象如果在内存不足时回收,SoftReference对象本身也需要被回收。 SoftReference提供了一套队列机制用来回收SoftReference对象:
  • 软引用创建时,通过构造器传入引用队列
  • 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列
  • 通过代码遍历引用队列,将SoftReference的强引用删除

弱引用

弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。

在JDK 1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用。

弱引用对象本身也可以使用引用队列进行回收。

虚引用

虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。

终结器引用

终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。

2.3 垃圾回收算法

  • 标记-清除算法(Mark Sweep GC)
  • 复制算法(Copying GC)
  • 标记-整理算法(Mark Compact GC)
  • 分代GC(Generational GC)
2.3.1 标记-清除算法

核心思想:

  • 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
  • 清除阶段,从内存中删除没有被标记也就是非存活对象。
    优点:
  • 实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
    缺点:
  • 碎片化问题,由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
  • 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
2.3.2 复制算法

核心思想是:

  • 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
  • 在垃圾回收GC阶段,将From中存活对象复制到To空间。
  • 将两块空间的From和To名字互换。
    优点:
  • 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
  • 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
    缺点:
  • 内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
2.3.3 标记-整理算法

标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。

核心思想分为两个阶段:

  • 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
  • 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
    优点:
  • 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存。
  • 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间。
    缺点:
  • 整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
2.3.4 分代GC

现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。

分代垃圾收集器将Heap划分为新生代(Young Generation)与年老代 (Old Generation),在JDK1.8 之前还有永久代(Permanent Generation)的概念。新生代又被进一步划分为Eden、From Space 、To Space,其中 From Space 与 To Space 大小相等又称作Survivor Spaces。

  • 年轻代存放存活时间较短的对象。由于新生代垃圾回收相对更加频繁,新生代垃圾回收更加关注垃圾回收的时效性,通常会采用复制算法或标记清除算法处理垃圾回收。
  • 老年代存放存活时间较长的对象。年老代占用内存相对更大,而垃圾回收频繁较低,年老代垃圾回收更加关注垃圾回收的空间性,即垃圾回收后能否释放更多连续的内存,通常会采用压缩算法处理垃圾回收。
    对象分配过程(对象在Eden、Survivor与Old之间进行分配与转移):
  • 任何新对象都被分配到新生代的Eden区,当Eden区域无法容纳新对象时,会触发年轻代的GC,称为Minor GC或者Young GC。
  • Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。接下来,S0会变成To区,S1变成From区。经历过一次Young GC后仍存活的对象,其年龄都会增加1。
  • 如果Minor GC后对象的年龄达到阈值,对象就会被晋升至老年代。
    阈值最大15,默认值和垃圾回收器有关,可通过 MaxTenuringThreshold修改。
  • 当老年代中空间不足,无法放入新的对象时,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
    FULL GC 收集整个堆,包括 young gen、old gen、perm gen(如果存在的话)等所有部分的模式。
  • 若FULL GC后,老年代依旧无法存放对象,就会抛出Out Of Memory异常。

    上图中没有描绘出 Thread Local Allocation Buffer (TLAB)与 Promotion Local Allocation Buffer (PLAB)的细节。

判断GC算法是否优秀

  • 吞吐量
    吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 / (执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
  • 最大暂停时间
    最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短
  • 堆使用效率
    不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法
    上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
    一般来说,堆内存越大,最大暂停时间就越长。想要减少最大暂停时间,就会降低吞吐量。不同的垃圾回收算法,适用于不同的场景。

2.4 垃圾回收器

除了G1之外的垃圾回收器必须成对组合进行使用。

2.4.1 Serial GC

串行垃圾收集器(Serial GC)在进行垃圾回收时只有单个GC线程在进行垃圾回收。

上图灰色箭头为应用线程,而黑色箭头为GC线程,应用线程在工作时通常都是多线程,而到过安全点后应用线程停止工作也叫SWT(Stop The World),串行垃圾回收器将开始一个GC线程完成垃圾回收工作。

根据回收分代的不同,串行垃圾回收器通常又分为 Serial NewSerial Old

  • Serial New 负责回收新生代(Young Generation),采用复制算法完成垃圾清理工作,
  • Serial Old 负责回收年老代(Old Generation),采用压缩算法(标记整理)完成垃圾清理工作。
    开启参数:-XX:+UseSerialGC
    优点:单CPU处理器下吞吐量非常出色。实现简单,串行垃圾回收器内部不用维护复杂的数据结构,内存开销也更加小。
    缺点:在STW(Stop The World)时只有单个GC线程在进行垃圾回收工作,垃圾回收的时间通常都会比较长,并且与应用程序占用的内存呈线性增长。
    适用场景:该垃圾回收器比较适合Client端与嵌入设备等占用内存较小的场景。
2.4.2 Parallel GC

Parallel GC 工作时,新生代与年老代都会采用线程并行处理垃圾回收工作。

Parallel GC 分为Parallel Scavenge 与 Parallel Old

  • Parallel Scavenge 负责回收新生代(Young Generation),采用复制算法完成垃圾清理工作。
  • Parallel Old 复制回收年老代(Old Generation),采用压缩算法完成垃圾清理工作。
    Parallel Scavenge是 JDK8 默认的年轻代垃圾回收器,关注的是系统的吞吐量。
    启用参数:-XX:+UseParallelGC、-XX:+UseParallelOldGC
    优点:吞吐量高,支持手动调整最大暂停时间和吞吐量。为了提高吞吐量,虚拟机会动态调整堆的参数。
    缺点:不能保证单次的停顿时间
    适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象比如:大数据的处理,大文件导出
2.4.3 ParNew GC

ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收。

ParNew 用于 新生代,采用复制算法,启用参数:-XX:+UseParNewGC

优点:多CPU处理器下停顿时间较短

缺点:吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用

适用场景:JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用

2.4.4 CMS

CMS(Concurrent Mark Sweep)垃圾回收器关注的是系统的暂停时间。

参数:XX:+UseConcMarkSweepGC

CMS 执行步骤:

  • 初始化标记( Initial-mark):用极短的时间标记出GC Roots能直接关联到的对象。
  • 并发标记(Concurrent Marking):标记所有的对象,用户线程不需要暂停。
  • 重新标记(Remark):由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记。
  • 并发清除(Concurrent Sweeping):清理死亡的对象,用户线程不需要暂停

    CMS 早其版本中初始化标记与重新标记都是由单线程完成的,后期版本可以通过 -XX:+CMSParallelInitialMark-XX:CMSParallelRemarkEnabled 分别将初始化标记与重新标记阶段指定为多线程。
    在CMS中并发阶段运行时的线程数可以通过 -XX:ConcGCThreads 设置,默认值为0,由系统计算得出。
    计算公式:(-XX:ParallelGCThreads定义的线程数 + 3) / 4, ParallelGCThreads是STW停顿之后的并行线程数
    ParallelGCThreads是由处理器核数决定的:当cpu核数小于8时,ParallelGCThreads = CPU核数,否则 ParallelGCThreads = 8 + (CPU核数 -- 8 )*5/8。
    CMS 通常会在某次Young GC后开始CMS的并发工作,因为Young GC过后 CMS Initail-mark 要标记的对象通常会更少。
    CMS 缺点:
  • CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在Full GC时进行碎片的整理。
    这样会导致用户线程暂停,可以使用 -XX:CMSFullGCsBeforeCompaction=N 参数(默认0)调整N次Full GC之后再整理。
  • 无法处理在并发清理过程中产生的"浮动垃圾",不能做到完全的垃圾回收。
  • 如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
    适用场景:大型的互联网系统中用户请求数据量大、频率高的场景
2.4.5 G1

G1(Garbage First)垃圾收集器开启了分区垃圾收集器的先河。G1通过时间预测模型尽可能地满足用户对暂停时间的要求(用户可以通过 -XX:MaxGCPauseMillis=XXX 来指定垃圾收回时最大的暂停时间),G1 利用压缩算法优化回收垃圾更多的分区,所以他被称作垃圾优先(Garbage First)垃圾收集器。

2.4.5.1 G1 内存布局

G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。Region 分为Eden、Survivor、Old区。Region的大小通过 堆空间大小/2048 计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m 指定(其中32m指定region大小为32M),Region size必须是2的指数幂,取值范围从1M到32M。

G1还有一个专门用于存放大对象的Region(默认对象占用内存超过Region大小二分之一的对象),称为Humongous Region。Humongous Region 可能由多个Region构成,但一个Region最多存放一个大对象,当多个Region用于存放一个特别大的对象这些Region内在布局上是连续的。

当经过多次Young GC、Mix GC、Full GC与对象分配后,Eden Region、Survivor Region、Old Region、Humongous Region之间的角色会转变,即原来存有具体某种Generation对象的Region被清空后可以用来存放Eden对象、Survivor对象、Old 对象或是Humongous对象中的某一种。

2.4.5.2 Young GC

年轻代回收(Young GC) 指回收Eden区和Survivor区中不用的对象。

Young GC 会导致STW,G1中可以通过参数 -XX:MaxGCPauseMillis=n(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。
Young GC 触发时机

  • Eden区域空间不足:
    当Eden区域的空间不足以容纳新的对象分配时,会触发Young GC。
    这是最常见的触发条件,也是Young GC的主要触发原因。
  • Heap Target Utilization:
    G1有一个目标利用率(Target Utilization),即希望堆内存达到的使用率。如果年轻代的利用率超过了这个目标利用率,G1可能会提前触发Young GC以降低利用率。
  • Max Pause Time Goal:
    G1有一个最大暂停时间目标(Max Pause Time Goal),即期望的最长GC暂停时间。如果预计下一个Young GC会导致暂停时间超过这个目标,G1可能会提前触发GC以尝试达到目标暂停时间。
    最大暂停时间可通过参数-XX:MaxGCPauseMillis=n(默认200)来设置
  • Promotion Failure:
    如果在复制阶段无法将对象提升到老年代,因为老年代没有足够的空间,这会导致一次失败的晋升(Promotion Failure)。
    这种情况下,G1会触发Young GC,并可能伴随一次Mixed GC(混合垃圾回收),以便清理更多空间。
    YGC 回收流程:
  • 初始标记(Initial Marking):这是一个短暂的STW(Stop-The-World)阶段,G1会标记所有从GC Roots直接可达的对象。
  • 栈中的本地变量:方法执行时创建的对象引用、方法参数、局部变量等。
  • 方法区中的静态变量:类的静态字段中引用的对象。
  • 方法区中的常量:类的常量池中的引用(如字符串字面量)。
  • 本地方法栈中的JNI(Native)引用:由本地代码(C/C++等)持有的Java对象引用。
  • 线程对象中的引用:每个线程可能引用的一些对象,例如线程局部变量。
  • 系统类加载器和引导类加载器:加载了的类本身可以作为GC roots。
  • 根区域扫描(Root Region Scanning):在这个阶段,G1会扫描所有根区域,识别出哪些对象是存活的。
  • 更新RSet(Remembered Sets Update):RSet用于记录跨Region的引用关系。在这个阶段,G1会更新RSet信息,以确保在复制存活对象时能够正确处理这些引用。
  • 对象复制(Object Copying) :G1将Eden区和Survivor区中的存活对象复制到另一个干净的Survivor区。这个过程是并行执行的,以提高效率。
  • 处理引用(Reference Processing):如果启用了软引用、弱引用等,G1会在这个阶段处理它们。
  • 清理(Cleanup) :清理Eden区和原始Survivor区,移除所有未被复制的对象。
  • 回收统计(Collection Statistics) :G1会收集这次Young GC的统计信息,用于指导未来的垃圾回收。
    回收流程整体是STW的,因为耗时比较短,不需要与应用线程并发。ParallelGCThreads 控制并发最大线程数。
2.4.5.3 Mixed GC

Mixed GC 回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成,不会产生内存碎片。
MGC 触发条件:

  • Young GC后,部分对象晋升到到老年代,这时候老年代占用超过-XX:InitiatingHeapOccupancyPercent(默认是45%),则会启动一次Young GC,然后开始并发标记(并发标记确定老年代区域中所有当前可到达的对象),并发标记结束后开始Mixed GC。
    -XX:InitiatingHeapOccupancyPercent
    JDK版本在8b12之前,XX:InitiatingHeapOccupancyPercent 是整个堆使用量与堆总体容量的比值;
    JDK版本在8b12之后(包括大版本9、10、11...),则XX:InitiatingHeapOccupancyPercent 是老年代大小与堆总体容量的比值。
    MGC 回收流程:
  • 初始标记(initial mark):标记Gc Roots引用的对象为存活
  • 并发标记(concurrent mark):将第一步中标记的对象引用的对象,标记为存活
  • 最终标记(remark或者Finalize Marking):标记一些引用改变漏标的对象,不管新创建、不再关联的对象
  • 并发清理(cleanup):将存活对象复制到别的Region,不会产生内存碎片

G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。

注意:如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。

2.4.5.4 写屏障

写屏障(write barrier)可理解为在引用赋值这个写操作前加一个切面,根据切点加入时机不同又可分为 pre-write barrier 与post-write barrier。

java 复制代码
void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field);     // pre-write barrier: for maintaining SATB invariant
  *field = new_value;   // the actual store
    post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}
2.4.5.5 卡表 & 卡页

每个Region区域都会有额外配备一小块内存,这块内存称为卡表(Card Table)。

CardTable 本质上是一种 point-out 数据结构,表示某一区域自己有指向别的区域的引用。

在G1中CardTable由byte数组构成,数组的每个元素称之为卡片/卡页(CardPage)。

CardTale会映射到整个堆的空间,每个CardPage会对应堆中的512B空间。当某个卡页中的对象引用自己Region区域的对象时,会将卡表对应编号位置的字节修改为1,为1的字节被称之为脏卡。

如下图所示,在一个大小为8GB的堆中,那么CardTable的长度为16777215 (8GB / 512B);假设-XX:G1HeapRegionSize参数为2MB,即每个Region 大小为2 MB,则每个Region都会对应4096个CardPage。CardTable将占用16MB额外内存空间。

查找一个对象所在的CardPage 的计算公式:CardPageIndex = (对象的地址 - 堆开始的地址)/ 512

更新卡表状态的底层采用了写屏障技术(具体为写后屏障),当执行对象引用相关的代码时,会在其代码前后插入对应的指令。

写后屏障指令判断到老年代对象引用年轻代对象时,会更改卡表中对应的字节为脏卡,同时会将脏卡放入到一个脏卡队列中,JVM会通过单独的线程,定期读取脏卡队列中的数据,更新记忆集。

在更新完卡表之后,为何不直接把脏卡写入记忆集呢?

这是由于写屏障指令是由用户线程完成的,如果有大量的用户线程修改对象引用关系,会产生线程安全问题,则需要对记忆集进行加锁,加锁之后势必会影响执行效率。因此这里将脏卡先放入脏卡队列,采用单独的线程异步消费,避免影响用户线程。

2.4.5.6 记忆集(RSet)

Young GC 时只会处理新生代对应的Region即 Eden Region与Survivor Region,这有利于降低每次Young GC的时间。但如果 Eden Region与Survivor Region持有老年代的引用呢,难道在Young GC时,要把Heap中所有的Region都遍历一次才能确定Eden Region与Survivor Region有哪些对象才是垃圾吗?这种方式显然是不可取的,这样一来就会拉长Young GC的时间。

有种有效地方法是新生代的每一个Region都维护一个集合记录一下老年代指进来的(point-in)的跨代引用,这样在Young GC时只要看一下这个point-in的集合就行,这个集合便是所谓的记忆集(Remember Set,RSet)。那年老代里面需要这个RSet吗?前面提到每次Mix GC时会回收部分年老代的Region,如果没有这个记忆集的话和Young GC一样同样避免不了要扫描整个年老代的Region,所以年老代的Region也要维护一个point-in的集合,不过个集合记录是Old Region point-in 过来的集合,至于Young Region point-in 过来的则可以不用管。

RSet实际是通过HashMap实现的,该HashMap其key引用了本Region的其他Regionr的地址,value是一个数组,数组的元素是引用方的对象所对应的CardPage在CardTable中的下标。

如上图所示,区域B中的对象y引用了区域A中的对象x,这个引用关系跨了两个区域。y对象所在的CardPage为179,在区域A的RSet中,以区域B的地址作为key,b对象所在CardPage下标79为value记录了这个引用关系,这样就完成了这个跨区域引用的记录。不过这个CardTable的粒度有点粗,毕竟一个CardPage有512B,在一个CardPage内可能会存在多个对象。所以在扫描标记时,需要扫描RSet中关联的整个CardPage,上图的例子是要把CardTable下标为79的CardPage都扫描一遍。

实际上HotSpot VM 中 G1的RSet具体实现要比上面说的更加复杂(上面说的只是其中的一种情况,Sparse粒度的情况 )。应用程序中可能存在频繁的更新引用情况,这会使得某些区域的RSet变成popular Region。G1 采用不同粒度的方式来处理RSet Popularity,RSet可分为Sparse、Fine、Coarse三种粒度。不同粒度时RSet内部采用不同的数据结构记录其他Region point-in 进来的引用 ,上面介绍的便是Sparse粒度时的情况。下面是 Evaluating and improving remembered sets in the HotSpot G1 garbage collector论文中给出的G1中 RSet 数据结构的简化定义。

1、Sparse Grained (上面g1_rset数据结构中的 saprse)

稀疏粒度情况时,采用HashMap实现,该HashMap其key引用了本Region的其他Regionr的地址,value是一个数组,数组的元素是引用方的对象所对应的CardPage在CardTable中的下标。
2、Fine Grained (上面g1_rset数据结构中的 fine_grained)

细粒度情况时,同样采用HashMap实现,该HashMap其key引用了本Region的其他Regionr的地址,value是一个位图,位图的最大位数代表一个Region最多能被拆分为多少CardPage,位图上值为1则代表Region上CardPage内有对象引用了RSet 所属Region的对象。
3、Coarse Grained (上面g1_rset数据结构中的 coarse)

粗粒度情况时,采用位图实现,位图的最大位数代表整个Heap能被拆分为多少个Region。位图上值为1则代表其他Region内有对象引用了RSet 所属Region的对象。因为Region的大小是一样的,可以通过Heap的起始地址,计算出位图中每个Region的起始地址。

G1通常利用Refinement Threads 异步维护RSet,每个线程会利用前面介绍的post-write barrier 将跨代引用与Old Generation 到 Old Generation 的引用记录到各自的local log buffer中,当local log buffer满了之后会刷新到全局的 log buffer中,Refinement Threads 专门处理全局的 log buffer来维护RSet,当Refinement Threads 不能有效地处理全局的log buffer时,应用线程将一起处理 log buffer,但这对应用线程的性能有损耗。当垃圾回收过程中如果全局的log buffer还未处理完,GC线程将处这些log buffer。

cpp 复制代码
void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field);     // pre-write barrier: for maintaining SATB invariant
  *field = new_value;   // the actual store
    post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}
2.4.5.7 回收集(CSet)

回收集(Collection Set,CSet),其代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

2.4.5.8 三色标记算法

并发标记过程采用的算法:三色标记算法。

三色标记算法会创建白色、灰色、黑色三个集合,三个集合内分别只存储白色对象、灰色对象、黑色对象。

  • 白色对象,代表尚未开始标记的对象或已完成标记并确认为垃圾的对象;
  • 灰色集合,代表还在标记中的对象,即遍历对象图时已遍历到自己,但还未完成自己引用 对象的遍历;
  • 黑色对象,代表已完成标记并确认为存活的对象(正常情况下,对象标记的颜色变化只能白色变成灰色,灰色变成黑色)。
    一个对象任一时刻只能在白色、灰色、黑色三个集合中的某一个。
    通常三色标记算法的处理流程如下:
  • 起初除GC Roots 外的其他对象全白色集合,将GC Roots直接引用的对象从白色集合内移到灰色集合,此时黑色集合为空。
  • 从灰色集合取出一个灰色对象,依次处理该对象引用的对象。若其未引用任何对象,则直接将其移入黑色集合中;若其引用的对象在白色集合中则将其移入灰色集合,否则直接不处理,当该灰色对象引用的对象全处理完后,再将其移入黑色集合中。
  • 重复第2步的流程直到灰色集合为空。
  • 上面的步骤处理完后,GC Roots与黑色集合内的对象为存活对象,而白色集合内的对象为垃圾对象,最后要做的就是将白色集合内的垃圾对象清理。
    如果应用程序线程与三色标记算法的GC线程一起运行,则可能出现对象错标与漏标。
  • 对象错标:原为是垃圾的对象被标记为黑色认为是存活的,这种情况的出现并不会引起应用程序的错误,只是会将垃圾收集的时间拖延到下一次垃圾回收。
  • 对象漏标:原本要标记为黑色的对象,被遗漏了,没有被标记,最终导致该对象在白色集合中被垃圾回收集给回收掉;这种情况的一旦发生应用程序将出现未知的异常,这个异常可能是无关紧要的也可能是致命的。
    实际上产生漏标一定会满足以下任意一个条件:
  • GC线程标记的过程中,应用线程增加黑色对象到白色对象的引用。
  • GC线程标记的过程中,应用线程删除了灰色对象到白色对象的引用。
    对于第一种情况,利用post-write barrier,记录所有新增的引用关系,然后根据这些引用关系为根重新扫描一遍。
    对于第二种情况,利用pre-write barrier,将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一边。
2.4.5.9 SATB

G1采用 "snapshot-at-the-beginning" (SATB) 算法来处理漏标问题。

SATB 算法确保在并发标记开始后所有的垃圾对象都通过快照被识别出来。在并发标记过程中新分配的对象被认为是存活的对象,不用对他们进行追踪分析,这有利于减小标记的开销。

Region包含了5个指针,分别是bottom、previous TAMS、next TAMS、top和end,

其中previous TAMS、next TAMS是前后两次发生并发标记时的位置,TAMS全称top-at-mark-start

  • 假设第n轮并发标记开始,将该Region当前的top指针赋值给next TAMS,在并发标记标记期间,分配的对象都在next TAMS, top之间,SATB能够确保这部分的对象都会被标记,默认都是存活的
  • 当并发标记结束时,将next TAMS所在的地址赋值给previous TAMS,SATB给 bottom, previous TAMS 之间的对象创建一个快照Bitmap,所有垃圾对象能通过快照被识别出来
  • 第n+1轮并发标记开始,过程和第n轮一样
    SATB保证了在并发标记过程中新分配对象不会漏标。
    但是如果在TAMS某黑色对象B新引用该白色对象W,然后灰色对象G取消对该白色对象W的引用?
    G1通过写屏障来解决这个问题:在引用关系被修改之前,插入一层 pre-write barrier,把原引用保存到satb mark queue中,和RSet的实现类似,每个应用线程都自带一个satb mark queue。在下一次的并发标记阶段,会依次处理 satb mark queue 中的对象,确保这部分对象在本轮GC是存活的。
2.4.5.10 Incremental Update

在并发标记过程中,把赋值的这种新增的引用,做一个集合存起来。 在重新标记的时候会找到集合里面的引用然后重新去扫描,再把源头标记为灰色。

把新增的引用放到集合的时候,会实现一种写屏障的方式。在对象前后通过一个dirty card queue将引用信息存在card中,这个dirty card queue会放在cardtable中,而cardtable是记忆集的具体实现,最终这个引用就会放在记忆集中的。

2.4.5.11 FULL GC

在垃圾收集和处理过程中,还有几种情况下会触发Full GC:

(1)并发模式失效

G1启动并发标记周期,但是在混合gc之前,老年代就被填满了,这时候G1就会放弃标记周期,改为执行Full gc,对应的gc日志为:GC concurrent-mark-abort

解决办法:发生这种失败意味着堆的大小应该增加了,或者G1收集器的后台处理应该更早开始,或者需要调整周期,让它运行得更快(如增加后台处理的线程数)。

(2)晋升失败

G1在进行新生代gc时,老年代没有足够的内存提供给晋升对象,将会触发Full gc。对应的gc日志为:to-space exhausted。解决这种问题的方式是:

a. 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为"目标空间"增加预留内存量。

b. 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。

c. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。

(3) 疏散失败

进行新生代gc时,survivor和老年代没有足够的空间容纳存活的对象。对应的gc日志为: to-space overflow。解决办法与晋升失败的情况是一样的。

(4) 巨型对象分配失败

巨型对象分配失败也会触发Full gc,解决办法:增大regionSize。

(5)metaspace gc

metaspace大小达到阈值(metaspaceSize大小,是动态的),会触发Full gc。

2.4.5.12 G1的调优参数

-XX:+UseG1GC:使用G1收集器

-XX:ParallelGCThreads:指定并行工作的GC线程数,也就是在STW阶段工作的GC线程数。

-XX:ConcGCThreads:指定并发工作的GC线程数,默认是-XX:ParallelGCThreads的四分之一,也就是在非STW期间的GC工作线程数,当然其他的线程很多工作在应用上。当并发周期时间过长时,可以尝试调大GC工作线程数,但是这也意味着此期间应用所占的线程数减少,会对吞吐量有一定影响。

-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区。Region的大小主要是关系到Humongous Object的判定,当一个对象超过Region大小的一半时,则为巨型对象,那么其会至少独占一个Region,如果一个放不下,会占用连续的多个Region。

-XX:MaxGCPauseMillis:目标暂停时间(默认200ms),表示每次GC最大的停顿毫秒数,默认值是200ms,VM将调整Java堆大小和其他与GC相关的参数,以使GC引起的暂停时间短于预设值。这个值不能设置的过小,如果设置过小则每一次垃圾处理所能选择的Region区域会减少,这会导致GC次数的增加,可能最后GC的垃圾清理速度赶不上应用产生的速度,那么可能会造成串行的Full GC,这是要极力避免的。所以暂停时间肯定不是设置的越小越好,当然也不能设置的偏大,转而指望G1自己会尽快的处理,这样可能会导致一次全部并发标记后触发的Mixed GC次数变少,但每次的时间变长,STW时间变长,对应用的影响更加明显。

-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent,分别为新生代比例的设定数值的下限和上限,默认值分别为5%和60%。G1会根据实际的GC情况(主要是暂停时间)来动态的调整新生代的大小,主要是调整Eden Region的个数。

-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代,默认值是15。一般新生对象经过15次Young GC会晋升到老年代,巨型对象会直接分配在老年代,同时在Young GC时,如果相同age的对象占Survivors空间的比例超过 -XX:TargetSurvivorRatio的值(默认50%),则会自动将此次晋升年龄阈值设置为此age的值,所有年龄超过此值的对象都会被晋升到老年代,此举可能会导致老年代需要不少空间应对此种晋升。一般这个值不需要额外调整。

-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),如果Mixed GC周期结束后老年代使用率还是超过InitiatingHeapOccupancyPercent值,那么会再次触发全局并发标记过程,这样就会导致频繁的老年代GC,影响应用吞吐量。同时老年代空间不大,Mixed GC回收的空间肯定是偏少的。可以适当调高该值,当然如果该值太高,很容易导致年轻代晋升失败而出发Full GC,所以需要多次调整测试。

-XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。

补充:虽然G1有不少优秀的特性,但是G1在垃圾收集时的内存占用和程序额外负载都比CMS要高,因此具体用什么垃圾收集器还是要从各方面考虑。一般来说,小内存应用上,CMS会比G1更占用;而在大内存的服务器上(6G以上的内存),G1垃圾收集器能够发挥出更大的优势。

七、栈上数据存储

1、数据类型在虚拟机中的实现

在Java中有8大基本数据类型:

Java中的8大数据类型在虚拟机中的实现:

代码示例:

java 复制代码
public void test(){
    byte b = 1;
    short s = 2;
    int i = 128;
    long l = 13L;
    boolean bo = true;
    char c = 's';
    float f = 3.14f;å
    double d = 1.59;
    String str = "str";
    int g = 127;
}

上述代码对应的局部变量表:

boolean、byte、char、short在栈上是不是存在空间浪费?

是的,Java虚拟机采用的是空间换时间方案,在栈上不存储具体的类型,只根据slot槽进行数据的处理,浪费了一些内存空间但是避免不同数据类型不同处理方式带来的时间开销。

同时,像long型在64位系统中占用2个slot,使用了16字节空间,但实际上在Hotspot虚拟机中,它的高8个字节没有使用,这样就满足了long型使用8个字节的需要。但这样的设计能同时满足32位和64位系统的需要,为了兼容性而放弃了一些内存空间。

2、栈中的数据保存到堆上

byte、char、short去掉高位

boolean 只取低位的最后一位保存

3、从堆中加载数据到栈上

堆中的数据加载到栈上,由于栈上的空间大于或者等于堆上的空间,所以直接处理但是需要注意下符号位。

boolean、char为无符号,低位复制,高位补0

byte、short为有符号,低位复制,高位非负则补0,负则补1

八、对象内存布局

对象在堆中的内存布局,指的是对象在堆中存放时的各个组成部分,主要分为以下几个部分:

可通过JOL来查看对象在内存布局情况。

标记字段

标记字段相对比较复杂。在不同的对象状态(有无锁、是否处于垃圾回收的标记中)下存放的内容是不同的,同时在64位(又分为是否开启指针压缩)、32位虚拟机中的布局都不同。

64位开启指针压缩:

64位不开启指针压缩,只是将Cms使用这部分弃用:

32位虚拟机目前使用的场景已经不多,整体结构与64位类似:

九、方法调用的原理

方法调用的本质是通过字节码指令的执行,能在栈上创建栈帧,并执行调用方法中的字节码执行。

1、虚拟机调用指令

  • invokestatic:调用静态方法
  • invokespecial: 调用对象的private方法、构造方法,以及使用 super 关键字调用父类实例的方法、构造方法,以及所实现接口的默认方法。
  • invokevirtual:调用对象的非private方法。
  • invokeinterface:调用接口对象的方法。
  • invokedynamic:用于调用动态方法,主要应用于lambda表达式中。
    Invoke方法的核心作用就是找到字节码指令并执行。

虚方法和非虚方法

  • 如果方法在编译期就确定了具体的调用版本(这个版本在运行时是不可变的),这样的方法称为非虚方法。
  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。
  • invokestatic、invokespecial、final修饰的invokevirtual 调用的方法称为非虚方法,称为虚方法。

虚方法表:

  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
  • 虚方法表创建时间:虚方法表会在类加载的链接阶段(解析阶段)被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

2、静态绑定

  • 编译期间,invoke指令会携带一个参数符号引用,引用到常量池中的方法定义。方法定义中包含了类名 + 方法名 + 返回值 + 参数。
  • 在方法第一次调用时,这些符号引用就会被替换成内存地址的直接引用,这种方式称之为静态绑定。
    静态绑定适用于处理静态方法、私有方法、或者使用final修饰的方法,因为这些方法不能被继承之后重写。
  • invokestatic
  • invokespecial
  • final修饰的invokevirtual

3、动态绑定

对于非static、非private、非final的方法,有可能存在子类重写方法,那么就需要通过动态绑定来完成方法地址绑定的工作。

动态绑定可用于实现多态。

动态绑定是基于方法表来完成的,invokevirtual使用了虚方法表(vtable),invokeinterface使用了接口方法表(itable),整体思路类似。所以接下来使用invokevirtual和虚方法表来解释整个过程。

每个类中都有一个虚方法表,本质上它是一个数组,记录了方法的地址。子类方法表中包含父类方法表中的所有方法;子类如果重写了父类方法,则使用自己类中方法的地址进行替换。

产生invokevirtual调用时,先根据对象头中的类型指针找到方法区中InstanceClass对象,获得虚方法表。再根据虚方法表找到对应的对方,获得方法的地址,最后调用方法。

十、异常捕获的原理

在Java中,程序遇到异常时会向外抛出,此时可以使用try-catch捕获异常的方式将异常捕获并继续让程序按程序员设计好的方式运行。

异常捕获机制的实现,需要借助于编译时生成的异常表。

异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

  • 起始/结束PC:此条异常捕获生效的字节码起始/结束位置。
  • 跳转PC:异常捕获之后,跳转到的字节码位置。
    程序运行中触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。
  • 如果匹配,跳转到"跳转PC"对应的字节码位置。
  • 如果遍历完都不能匹配,说明异常无法在当前方法执行时被捕获,此方法栈帧直接弹出,在上一层的栈帧中进行异常捕获的查询。
    finally的处理方式就相对比较复杂一点了,分为以下几个步骤:
  • finally中的字节码指令会插入到try 和 catch代码块中,保证在try和catch执行之后一定会执行finally中的代码。
  • 如果抛出的异常范围超过了Exception,比如Error或者Throwable,此时也要执行finally,所以异常表中增加了两个条目。覆盖了try和catch两段字节码指令的范围,any代表可以捕获所有种类的异常。在最后需要将异常继续向外抛出。

十一、JIT即时编译器

1、JIT 即时编译器

1.1 编译器 & 解释器

  • 编译器将整个源代码转换为目标代码,然后在执行之前进行链接,生成可执行文件(先翻译,再执行)。这种方式的好处是程序执行速度快,但是编译过程需要一次性完成,如果出现错误则需要重新编译整个程序。
  • 解释器则是逐行解释执行源代码,每执行一行代码都需要进行解析(边翻译,边执行)。这种方式的好处是可以在程序执行中逐步发现错误,程序员可以更快地进行测试和调试。但是解释器运行速度相对慢一些。解释器还可以作为编译器激进优化不成立时的逃生门,让编译器可以进行一些不能保证一定正确的激进优化手段(如加载新类,类型继承结构出现变化、出现罕见陷阱(Uncommon Trap),可以通过逆优化(Deoptimization)退回解释执行状态)

Java早期为实现跨平台,没有采用提前编译技术,而是按序翻译执行字节码指令。

这种方式在频繁调用的场景下,效率很低,因此被c、c++开发者吐槽执行慢。

多次执行的方法或循环体称之为热点代码,为提高热点代码的执行效率,在运行时,虚拟机会将这些代码编译成与本地平台相关的机器码,并进行优化。

完成这个任务的编译器称之为即时编译器(Just In Time Compiler),即 JIT编译器。

1.2 C1、C2与Graal编译器

在JDK1.8中 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器。

  • C1编译器
    C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求,C1也被称为 Client Compiler。
    C1编译器几乎不会对代码进行优化。
  • C2编译器
    C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为Server Compiler。
    但是C2代码已超级复杂,无人能维护!所以才会开发Java编写的Graal编译器取代C2( JDK10开始 )
    C1即时编译器和C2即时编译器都有独立的线程去进行处理,内部会保存一个队列,队列中存放需要编译的任务。
    C1和C2 协作:
  • 先由C1执行过程中收集所有运行中的信息,方法执行次数、循环执行次数、分支执行次数等等,然后等待执行次数触发阈值(分层即时编译由JVM动态计算)之后,进入C2即时编译器进行深层次的优化。
  • 方法字节码执行数目过少,先收集信息,JVM判断C1和C2优化性能差不多,那之后转为不收集信息,由C1直接进行优化。
  • C1线程都在忙碌的情况下,直接由C2进行优化。
  • C2线程忙碌时,先由2层C1编译收集一些基础信息,多运行一会儿,然后再交由3层C1处理,由于3层C1处理效率不高,所以尽量减少这一层停留时间(C2忙碌着,一直收集也没有意义),最后C2线程不忙碌了再交由C2进行处理。

1.3 分层编译

在 Java7之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。

Java7及以后引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,当然我们也可以通过参数强制指定虚拟机的即时编译模式。

在Java8 中,默认开启分层编译(mixed mode):

bash 复制代码
user@Mac ~ % java -version
java version "1.8.0_361"
Java(TM) SE Runtime Environment (build 1.8.0_361-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.361-b09, mixed mode)

使用"-Xint"参数强制虚拟机运行于只有解释器的编译模式:

bash 复制代码
user@Mac ~ % java -Xint -version
java version "1.8.0_361"
Java(TM) SE Runtime Environment (build 1.8.0_361-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.361-b09, interpreted mode)

使用"-Xcomp"强制虚拟机运行于只有 JIT 的编译模式下:

bash 复制代码
user@Mac ~ % java -Xcomp -version
java version "1.8.0_361"
Java(TM) SE Runtime Environment (build 1.8.0_361-b09)
Java HotSpot(TM) 64-Bit Server VM (build 25.361-b09, compiled mode)

JVM 的执行状态分为了 5 个层次:

  • 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
  • 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
  • 第 2 层:也称为 C1 编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
  • 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
  • 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

1.4 热点代码

热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。

JVM提供了一个参数"-XX:ReservedCodeCacheSize",用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。

如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。

bash 复制代码
user@Mac ~ % java -XX:+PrintFlagsFinal | grep CodeCacheSize
uintx InitialCodeCacheSize    = 2555904       {pd product}
uintx ReservedCodeCacheSize   = 251658240     {pd product}

1.5 热点探测

在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是"热点方法"。

虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

  • 方法调用计数器
    用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次,可通过 -XX: CompileThreshold 来设定

    bash 复制代码

zs@Mac ~ % java -XX:+PrintFlagsFinal | grep CompileThreshold

intx CompileThreshold = 10000 {pd product}

uintx IncreaseFirstTierCompileThresholdAt = 50 {product}

intx Tier2CompileThreshold = 0 {product}

intx Tier3CompileThreshold = 2000{product}

intx Tier4CompileThreshold = 15000 {product}

复制代码
- 回边计数器
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为"回边"(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,在服务端模式下是10700。

回边计数器阈值 = 方法调用计数器阈值 ×(OSR比率 - 解释器监控比率)/100

= CompileThreshold * ( OnStackReplacePercentage - InterpreterProfilePercentage ) / 100

= 10000 * (140 - 33) / 100

= 10700

复制代码
```bash
zs@Mac ~ % java -XX:+PrintFlagsFinal | grep -E 'OnStackReplacePercentage | InterpreterProfilePercentage'
intx InterpreterProfilePercentage      = 33  {product}
intx OnStackReplacePercentage  = 140 {pd product}

2、编译优化技术

JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。

2.1 方法内联

方法内联(Method Inline)指的是方法体中的字节码指令直接复制到调用方的字节码指令中,节省了创建栈帧的开销。

并不是所有的方法都可以内联,内联有一定的限制:

  • 方法编译之后的字节码指令总大小 < 35字节,可以直接内联。(通过-XX:MaxInlineSize=值 控制)
  • 方法编译之后的字节码指令总大小 < 325字节,并且是一个热方法。(通过-XX:FreqInlineSize=值 控制)
  • 方法编译生成的机器码不能大于1000字节。(通过-XX:InlineSmallCode=值 控制)
  • 一个接口的实现必须小于3个,如果大于三个就不会发生内联。
    设置 VM 参数 -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 来观察方法内联情况。
  • -XX:+PrintCompilation //在控制台打印编译过程信息
  • -XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
  • -XX:+PrintInlining //将内联方法打印出来
    提高方法内联:
  • 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

2.2 锁消除

如果对象只在单线程中使用,那么同步锁可能会被消除,提高程序性能。

  • -XX:+EliminateLocks开启锁消除(jdk1.8默认开启)
  • -XX:-EliminateLocks 关闭锁消除

2.3 锁粗化

如果检测到同一个对象执行了连续的加锁和解锁的操作,则会将这一系列操作合并成一个更大的锁,从而提升程序的执行效率。

例如,for循环内部锁改为for循环外部锁。

2.4 逃逸分析

逃逸分析用于确定对象动态作用域是否超过当前方法或线程。

通过逃逸分析,编译器可以决定一个对象的作用范围,从而进行相应的优化。

  • -XX:+DoEscapeAnalysis 开启逃逸分析(jdk1.8默认开启)
  • -XX:-DoEscapeAnalysis 关闭逃逸分析

2.5 栈上分配

如果编译器可以确定一个对象不会逃逸出方法,它可以将对象分配在栈上而不是堆上。在栈上分配的对象在方法返回后就会自动销毁,不需要进行垃圾回收,提高了程序的执行效率。

3.5 标量替换

如果对象不会被外部访问,并且这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。

  • -XX:+EliminateAllocations. 开启标量替换(jdk1.8默认开启)
  • -XX:-EliminateAllocations 关闭标量替换

十二、虚拟机参数

参数名 参数含义 示例
-Xms 设置堆的最小和初始大小,必须是1024倍数且大于1MB 比如初始大小6MB的写法: -Xms6291456、-Xms6144k、-Xms6m
-Xmx 设置最大堆的大小,必须是1024倍数且大于2MB 比如最大堆80 MB的写法: -Xmx83886080、-Xmx81920k、-Xmx80m
-Xmn 新生代的大小 新生代256 MB的写法: -Xmn256m、-Xmn262144k、-Xmn268435456
-XX:SurvivorRatio 伊甸园区和幸存区的比例,默认为8 新生代1g内存,伊甸园区800MB,S0和S1各100MB 比例调整为4的写法:-XX:SurvivorRatio=4
-XX:+PrintGCDetails 打印GC日志
-XX:+UseSerialGC 新老年代都适用串行回收器(Serial、Serial-Old)
-XX:+UseParNewGC 新生代使用ParNew,老年代使用串行回收器
-XX:+UseG1GC 启用G1垃圾回收器
-XX:MaxGCPauseMillis=n 设置每次垃圾回收时的最大停顿毫
-XX:GCTimeRatio=n 设置吞吐量为n(用户线程执行时间 = n/n + 1)
-XX:+UseAdaptiveSizePolicy 设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小

十三、面试题

https://www.bilibili.com/video/BV1r94y1b7eS/?vd_source=790064bbddfdcc0ac718cde0409a28b9

二十、参考资料

相关推荐
小江的记录本2 分钟前
【JVM虚拟机】JVM调优:常用JVM参数、调优核心指标、OOM排查、GC日志分析、Arthas工具使用(附《思维导图》+《面试高频考点清单》)
java·jvm·spring boot·后端·python·spring·面试
金銀銅鐵15 分钟前
[Java] 用图形化界面演示 iadd, isub, iconst_<i> 指令的效果
java·后端·python
J2虾虾33 分钟前
Spring AI Alibaba文档
java·人工智能·spring
YikNjy39 分钟前
break和continue
java·开发语言·算法
SomeOtherTime40 分钟前
Geojson相关(AI回答)
java·前端·python
日月云棠1 小时前
10 Integer —— 最常用的整数包装类深度解析
java·后端
秋91 小时前
java项目中cpu飙升排查及解决方法
java·开发语言
野生技术架构师1 小时前
牛客网2026最新大厂Java高频面试题精选(附标准答案)
java·开发语言
PH = 71 小时前
JAVA的SPI机制
java·开发语言
一 乐1 小时前
高校实习信息发布网站|基于Spring Boot的高校实习信息发布网站的设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·论文·毕设·高校实习信息发布网站