JVM基础学习

61 阅读1小时+

这是一篇结合了尚硅谷宋红康老师的课程、《深入理解JVM》、Oracle官方文档等等资料整理而成JVM学习文档。

引言

你是否也遇到过这些问题?

  • 运行着的线上系统突然卡死,系统无法访问,甚至直接OOM!
  • 想解决线上JVM GC问题,但却无从下手。
  • 新项目上线,对各种JVM参数设置一脸茫然,直接默认吧,然后就GG了。
  • 每次面试之前都要重新背一遍JVM的一些原理概念性的东西,然而面试官却经常问你在实际项目中如何调优JVM参数,如何解决GC、OOM等问题,一脸懵逼。

开发人员如何看待上层框架

  • 一些有一定工作经验的开发人员,打心眼儿里觉得SSM、微服务等上层技术才是重点,基础技术并不重要,这其实是一种本末倒置的“病态”。
  • 如果我们把核心类库的API必做数学公式的话,那么Java虚拟机的知识就好比公式的推导过程。

计算机系统体系对我们来说越来越远,在不了解底层实现方式的前提下,通过高级语言很容易编写程序代码。但事实上计算机并不认识高级语言。

架构师每天都在思考什么?

  • 应该如何让我的系统更快?
  • 如何避免系统出现瓶颈?

年薪50万+应该具备怎么样的能力?

  • 参与现有系统的性能优化,重构,保证平台性能和稳定性
  • 根据业务场景和需求,决定技术方向,做技术选型
  • 能够独立架构和设计海量数据下高并发分布式解决方案,满足功能和非功能需求
  • 解决各类潜在系统风险,核心功能的架构与代码编写
  • 分析系统瓶颈,解决各种疑难杂症,性能调优等

我们为什么要学习JVM?

  • 面试的需要

  • 中高级程序员必备技能

    • 项目管理、调优的需要
  • 追求极客的精神

    • 比如:垃圾回收算法、JIT、底层原理

Java VS C++

垃圾收集机制为我们打理了很多繁琐的工作,大大提高了开发的效率。但是,垃圾收集也不是万能的,懂得JVM内部的内存结构、工作机制,是设计高扩展性应用和诊断运行时间问题的基础,也是Java工程师进阶的必备能力。

书籍

  • The Java Virtual Machine Specification-JavaSE8

    The Java Virtual Machine Specification-JavaSE11

    (docs.oracle.com/javase/specs/index/html)

  • Java虚拟机规范

  • 深入理解Java虚拟机

  • 实战Java虚拟机

  • Java虚拟机精讲

  • 码出高效

  • 自己动手写Java虚拟机

JVM与Java体系结构

JVM介绍

特点:

  • 一次编译到处运行
  • 自动内存管理
  • 自动垃圾回收功能

位置:

  • 硬件 -- OS -- JVM -- class文件 -- User
  • JDK > JRE > JVM

整体结构

JRockit虚拟机、J9虚拟机等是没有方法区等。下面以HotSpot为例

Class files --> 类装载器子系统ClassLoader <--> 运行时数据区Runtime Data Area <--> 执行引擎ExecutionEngine、本地方法接口NativeInterface <-- 本地方法库NativeMethodLibrary

运行时数据区:

  • 方法区 MethodArea
  • 虚拟机栈 JVMStack
  • 本地方法栈 NativeMethodStack
  • 堆 Heap
  • 程序计数器 ProgramCounterRegister

执行引擎:

  • 解释器 Interpreter
  • JIT Compiler JIT即时编译器
  • 垃圾回收器 GarbageCollection

Java代码执行流程

Java编译器(词法分析、语法分析、语义分析、字节码生成器)

Java虚拟机(类加载器、字节码校验器、翻译字节码「解析执行」,JIT编译器「编译执行」)

JVM架构模型

Java编译器输入的指令流基本上是一种基于栈的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构

  • 基于栈式架构的特点

    • 设计和实现更简单,适用于资源受限的系统
    • 避开了寄存器分配难题:使用零地址指令方式分配
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现
    • 不需要硬件支持,可移植性更好,更好实现跨平台
  • 基于寄存器架构的特点

    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虚拟机。
    • 指令集架构则完全依赖硬件,可移植性差
    • 性能优秀和执行更高效
    • 花费更少的指令区完成一项操作
    • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令、三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主

执行2+3这种逻辑操作,其不同指令集架构的计算流程如下:

iconst_2
istore_1	// stack push 2
iconst_3
istore_2	// stack push 3
iload_1
iload_2
iadd			// add: 2+3
istore_0	// stack push 5
mov eax, 2	// move 2 to register[eax]
add eax, 3	// add value in eax and 3, then move result back to register

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台来(准确来说是:HotSpotVM的宿主环境已经不局限于嵌入式平台来),那么为什么不将架构更换为基于寄存器的架构呢?

JVM的生命周期

虚拟机的启动

JVM的启动时通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class) 来完成的,这个类时由虚拟机的具体实现指定的。

虚拟机的执行
  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
  • 程序开始执行时,它才运行;程序结束时,它就停止
  • 执行一个所谓的Java程序的时候,真正在执行的是一个叫做JVM的进程
虚拟机退出的情况
  • 程序正常执行结束
  • 程序中执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作
  • 除此之外:JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机的退出情况

JVM发展历程

  • 1996 Sun Classic VM:只提供解释器,JDK1.4时被完全淘汰,hotspot内置了此虚拟机
  • jdk1.2 Exact VM:虚拟机可以知道内存中某个位置的数据具体的类型,热点探测+编译器和解释器混合工作
  • Jdk1.3 HotSpot:JDK6、8中的默认虚拟机:在各个领域都有应用,热点代码探测技术,CodeCache
  • JRockit:专注于服务器端应用,不包含解释器实现,是世界上最快的JVM,JMC(MissionControl)
  • IBM J9:服务器端、桌面应用、嵌入式等多用途,性能快
  • Azul VM和BEA Liquid VM:与特定硬件平台绑定、高性能
  • Apache Harmony:没有被大规模商用,寄了
  • Microsoft JVM:在Windows平台下专门用的虚拟机,性能高,被Sun公司告了
  • TaobaoJVM:由AliJVM团队发布,把生命周期长的对象移除堆外,可以实现多JVM进程共享对象,严重依赖intel的CPU,所以性能高
  • Dalvik VM:是虚拟机,不是Java虚拟机,编译后不是.class文件,是.dex文件,谷歌发布,用于Android系统
  • Graal VM:2018年4月,Oracle公开的,跨语言全栈虚拟机,可能以后替代hotpot

类加载子系统

类加载器子系统的作用

  • 负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识。

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

  • 加载的类信息(DNA元数据模版)存放于一块称为方法区的内存空间。

    除了类信息外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)

begin
if (class xxx hasn't been loaded) {
  ClassLoader load class xxx;
  if (load fail: class file is illegal!!) {
    throw an exception;
  }
}
link class xxx;
initialize class xxx;
call xxx.main();
end

类的加载过程

加载Loading
  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区等运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载.class文件的方式

  • 从本地系统中直接加载
  • 从网络获取(例如:Web Applet)
  • 从zip压缩包读取(成为日后jar、war格式的基础)
  • 运行时计算生成(例如:动态代理技术)
  • 由其他文件生成(例如:JSP生成Servlet)
  • 从专有数据库中提取.class文件
  • 从加密文件中获取(防止class文件被反编译的保护措施)
链接Linking
  1. 验证(Verify)

    • 目的:确保class文件字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全

    • 四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证

    jclasslib、BinaryViewer——CAFEBABE

  2. 准备(Prepare)

    • 为类变量分配内存并且设置默认初始值,即零值

    • 上述不包括static final修饰的常量,因为final修饰导致中准备阶段会显式初始化

    准备阶段不会为实例变量分配初始化:类变量在方法区,而实例变量在堆中

  3. 解析(Resolve)

    • 将常量池内的符号引用转换为直接引用

    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等。

    事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

    符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

public class LinkingTest {
  static int num;
  public static void main(String[] args) {
    System.out.println(num);	// 输出0,因为num在准备阶段被赋零值
  }
}
初始化Initialization
  • 初始化阶段就是执行类构造器方法<clinit>()的过程

  • 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • <clinit>()不同于类的构造器

    构造器是虚拟机视角下的<init>()而不是<clinit>()

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕

  • 虚拟机必须保证一个类的<clinit>()方法中多线程下被同步加锁

public class InitializationTest {
  static int num = 1;
  static {
    num = 2;
    ch = 'B';
    System.out.println(num);	// 编译通过
    System.out.println(ch);		// 编译不通过,非法前向引用
  }
  static char ch = 'A';
  public static void main(String[] args) {
    // 在准备阶段num被赋值0,在初始化阶段在clinit方法中,num先被赋值1,后赋值2
    System.out.println(num);	// 输出2
    System.out.println(ch);		// 输出'A'
  }
}

public class InitializationTest2 {
  int num = 1;	// 没有静态变量赋值动作,字节码文件中没有clinit方法
}

类加载器

类加载器的分类
  • JVM规范中类加载器的分类:

    • 引导类加载器(Bootstrap ClassLoader)
    • 自定义类加载器(User-Defined ClassLoader)

从概念上来说,自定义类加载器一般指的是程序中由开发人员自定义的这些类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

  • 常见的类加载器:

    • Bootstrap Class Loader
    • Extension Class Loader
    • System Class Loader
    • User Defined Class Loader

Bootstrap <-- Extension <-- System <-- User-Defined

这几种类加载器之间的关系是组合关系,而不是继承关系

abstract ClassLoader {
	loadClass(String)
	resolveClass(Class<?>)
	findClass(String)
	defineClass(byte[], int, int)
}

class SecureClassLoader

class URLClassLoader {
	findClass(String)
}

class ExtClassLoader

class AppClassLoader {
	loadClass(String, boolean)
}

ClassLoader <|-- SecureClassLoader
SecureClassLoader <|-- URLClassLoader
URLClassLoader <|-- ExtClassLoader
URLClassLoader <|-- AppClassLoader
class ClassLoaderTest {
	public static void main(String[] args) {
    
    // 获取系统类加载器
    ClassLoader sysClassLoader = ClassLoader.getSystemClassLoader();
    System.out.println(sysClassLoader);	// sun.misc.Launcher$AppClassLoader@18b4aac2

    // 获取其上层:扩展类加载器
    ClassLoader extClassLoader = sysClassLoader.getParent();
    System.out.println(extClassLoader);	// sun.misc.Launcher$ExtClassLoader@1540e19d

    // 试图获取其上层:引导类加载器,获取不到
    ClassLoader bootstrapClassLoader = extClassLoader.getParent();
    System.out.println(bootstrapClassLoader);	// null
    
    // 对于用户自定义类来说:默认使用系统类加载器
    ClassLoader userClassLoader = ClassLoaderTest.class.getParent();
    System.out.println(userClassLoader);	// sun.misc.Launcher$AppClassLoader@18b4aac2

    // 对于Java核心类库中的类:使用引导类加载器加载的
    ClassLoader strClassLoader = String.class.getParent();
    System.out.println(strClassLoader);	// null
    
  }
}
详解三种虚拟机自带的加载器
  • 启动类加载器/引导类加载器(Bootstrap ClassLoader)

    • 这个类加载器是使用C/C++语言实现的,嵌套中JVM内部
    • 它用来加载Java的核心库,用于提供JVM自身需要的类
    • 不继承java.lang.ClassLoader,没有父类加载器
    • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
    • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

Java核心库:JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容

  • 扩展类加载器(Extension ClassLoader)

    • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现
    • 派生于java.lang.ClassLoader
    • 父类加载器为启动类加载器
    • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
  • 应用程序类加载器/系统类加载器(AppClassLoader)

    • Java语言编写,由sun.misc.Launcher$AppClassLoader实现
    • 派生于java.lang.ClassLoader
    • 父类加载器为扩展类加载器
    • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
    • 该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
    • 通过ClassLoader.getSystemClassLoader()方法可以获取该类加载器
// 获取BootstrapClassLoader能够加载的api路径
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL element : urls) {
  System.out.println(element.toExternalForm());
}

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

String extDirs = System.getProperty("java.ext.dirs");
for (String path : extDirs.split(";")) {
  System.out.println(path);
}
用户自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以通过自定义类加载器,来定制类的加载方式。

  • 为什么要自定义类加载器?

    • 隔离加载类
    • 修改类的加载方式
    • 扩展加载源
    • 防止源码泄漏
  • 用户自定义类加载器的实现步骤

    1. 自定义类继承抽象类java.lang.ClassLoader
    2. 在JDK1.2之前,自定义类的加载器总会去继承ClassLoader类并且重写loadClass()方法。在JDK1.2之后,不建议去重写覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,如果重写loadClass()方法会导致双亲委派机制被破坏。
    3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节流的方式,使自定义类加载器编写更加简洁
关于java.lang.ClassLoader
ClassLoader抽象类

ClassLoader是一个抽象类,除了引导类加载器之外的类加载器都需要去继承ClassLoader。

Sun.misc.Launcher是一个Java虚拟机的入口应用

方法名称描述
getParent():Class返回该类加载器的超类加载器
loadClass():Class加载名称为name的类
findClass(name:String):Class查找名称为name的类
findLoadedClass(name:String):Class查找名称为name的已经被加载过的类
defineClass(name:String, b:byte[], off:int, len:int):Class把字节数组b中的内容转换为一个Java类
resolveClass(c:Class<?>)连接指定的一个Java类
获取ClassLoader的途径
  • 获取当前类的ClassLoader:

    clazz.getClassLoader()

  • 获取当前线程上下文的ClassLoader:

    Thread.currentThread().getContextClassLoader()

  • 获取系统的ClassLoader:

    ClassLoader.getSystemClassLoader()

  • 获取调用者的ClassLoader:

    DriverManager.getCallerClassLoader()

双亲委派机制

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

工作原理
  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
-Request->	User-Custom	-1->	Application	-2-> 	Extension		-3-> Bootstrap
<-Class---	ClassLoader	<-6- 	ClassLoader	<-5-	ClassLoader	<-4- ClassLoader
class CustomClassLoader extends ClassLoader {
  
  private ClassLoader parent;
  
  ...
  
 	@Override
  public Class loadClass(String name) {
      if (这个类已经被加载过了) {
          找到这个已经被加载的类
          返回这个类  
      }
      尝试让父加载器加载
      parent.loadClass(name);
      if (父类加载器加载成功) {
          返回父类的加载结果
      }
      只能自己尝试加载这个类了
      findClass(name);
      if (自己加载成功) {
          返回结果
      }
      加载失败
  } 
  
  ...
}
双亲委派的破坏
  1. 历史遗留问题

    • 双亲委派机制是JDK1.2版本推出的,之前的代码都没用这个机制
  2. JavaSPI反向委派(比如JDBC)

    • 使用Service Provider Interface,使用实现类中的接口方法,接口由引导类加载器加载,而引导类加载器又通过委托线程上下文类加载器(一般是AppClassLoader)去加载实现类。
  3. 代码热替换,模块热部署(比如Tomcat)

    • Tomcat监听文件修改时间,如果发现class文件被修改,则重新创建一个应用类加载器去重新加载此模块
    • 在Tomcat容器中,不同应用由不同的类加载器加载,保证隔离性,从而确保不会出现全类名冲突问题
双亲委派机制的优势
  • 避免类的重复加载

  • 保护程序安全,防止核心API被随意篡改

    (比如:自定义java.lang.String、java.lang.ILoveR32)

沙箱安全机制

沙箱安全机制就是类加载器对java核心源代码对保护。比如用户自定义java.lang.String类,然而引导类加载器只会去加载JDK中的文件(rt.jar包中的java.lang.String.class),避免用户文件可能对引导类加载器的破坏。

额外的重要知识

在JVM中表示两个class对象为同一个类,需要2个条件

  • 类的完整全类名必须一致
  • 加载这个类的ClassLoader实例对象必须相同

对类加载器的引用

  • JVM必须知道一个类是由哪个类加载器加载的
  • 如果一个类是用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存中方法区中
  • 当解析一个类中对另一个类的引用时,JVM需要保证这两个类型的类加载器时相同的。(动态链接)

类的主动使用的七种情况:

类的使用分为主动使用和被动使用。主动使用会导致类的初始化Initialization,导致()方法执行,而被动使用不会。除了下面主动使用的情况之外都是被动使用。

  • 创建类的实例

  • 访问某个类的或接口的静态变量,或者对该静态变量赋值

  • 调用类的静态方法

  • 反射(比如:Class.forName("com.yuguo.HelloTest");)

  • 初始化一个类的子类(父类总是优先于子类初始化)

  • Java虚拟机启动时被表明为启动类的类

  • JDK7开始提供的动态语言支持:

    • Java.lang.invoke.MethodHandle实例的解析结果
    • REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

运行时数据区概述及线程

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着OS和应用程序实时运行。JVM内存布局规定类Java中运行过程中的内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨胰腺癌经典的JVM内存布局。

Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中一些会随着虚拟机启动而创建,随着虚拟机退出而销毁;另一些的生命周期与线程绑定,随着线程创建或死亡,这些数据区域创建或销毁。

运行时数据区分类:

  • 线程独占:Program Counter Register、JVM Stack、Native Method Stack
  • 线程共享:Heap、Method Area、Code Cache

JVM实例 与 Runtime实例 一一对应。Runtime使用单例模式。

关于线程:

  • 在Hotspot JVM中,每个线程都与OS的本地线程直接映射。
  • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。
  • OS负责所有线程的CPU资源安排和调度。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

不同的JVM中,Java线程和本地线程映射关系可能不同。映射关系可能是一对一,可能是多对一,可能是多对多。

如果能使用jconsole或者其他调试工具,都可以看到后台有许多线程中运行。后台线程不包括main线程以及所有由main线程自己创建的线程。

Hotspot中的后台线程的主要类别:

  • 虚拟机线程

    • 这种线程操作的执行需要JVM达到安全点
    • 执行类型包括:“stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
  • 周期任务线程

    • 这种线程是时间周期事件的体现(比如中断),一般用于周期性操作的调度执行
  • GC线程

    • 为不同种类的垃圾收集行为提供支持
  • 编译线程

    • 在运行时将字节码编译成本地代码
  • 信号调度线程

    • 线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理

程序计数器

docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.1

Register的命名源于CPU的寄存器。这里并非指的是物理寄存器,或许将其翻译为指令计数器会更加贴切(也称为程序钩子)。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。

作用:PC寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令。

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
  • 在JVM规范中,每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
  • 任何时间点,一个线程都只有一个方法中执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined)。
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 它是唯一一个在Java虚拟机规范中没有规定如何OutOfMemoryError情况的区域。

为什么使用PC Register记录字节码指令地址?

因为CPU在不停的切换各个线程进行调度,需要知道切换后该从那开始执行。所以JVM字节码解释器需要改变PC Register的值来明确下一条应该执行什么样的字节码指令。

CPU看似在并行地执行,实际上是在并发地执行。为了准确记录各个线程的执行指令地址,最好把PC计数器设置为每个线程私有一份,以供各个线程独立计算。

虚拟机栈

概述

栈是运行时的单位,而堆是存储的单位。

  • 每个线程在创建时都会参加一个虚拟机栈,其内部保存一个个栈帧(Stack Frame),对应着一次次的Java方法调用。

  • JVM栈时线程私有的,生命周期和线程一致。

  • JVM栈主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

  • 栈时一种快速有效的分配存储的方式,访问速度仅次于程序计数器。

  • JVM直接对JVM栈的操作只有2个:

    • 每个方法执行,栈帧入栈
    • 执行结束后的出栈工作
  • 对于栈来说,不存在GC问题,但存在OOM问题

  • Java虚拟机规范允许 Java栈的大小时动态的或者固定不变的

    • 如果采用固定大小的Java虚拟机栈,那么每个线程的java虚拟机栈的容量栈线程创建时被独立确定。如果线程请求分配的栈容量超过Java虚拟机栈的最大容量,JVM抛出StackOverflowError异常。
    • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的栈,则JVM抛出OutOfMemoryError异常。
  • 设置栈内存的大小:- Xss size

下面举例:设置栈大小为1024KB的三种写法

-Xss1m
-Xss1024k
-Xss1048576

栈帧

  • 栈中的数据都以栈帧(StackFrame)的格式存在,栈帧是JVM栈的基本单位
  • 在每个线程上正在执行的每个方法都各自对应这个线程对应的虚拟机栈中的一个栈帧
  • 栈帧数一个内存区块,时一个数据集,维系着方法执行过程中的各种数据信息

栈顶栈帧,被称为“当前栈帧(Current Frame)”

栈顶栈帧对应的方法,被称为“当前方法(Current Method)”

定义当前方法的类,被称为“当前类(Current Class)”

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • 不同线程中所包含的栈帧数不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果,给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • Java方法也两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

栈帧的结构:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

栈帧——局部变量表

  • 局部变量表,也称为局部变量数组或本地变量表
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。 这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,由此不存在线程安全问题
  • 局部变量表所需的容量大小是在编译器期间确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

关于Slot的理解

  • 参数值的存放总是在局部变量数组的index=0开始,到array.length-1的索引结束

  • 局部变量表,最基本的存储单元是slot(变量槽)

  • 局部变量表中存放编译器可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型

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

    • byte、short、char在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
    • long、double,则占据两个slot
  • JVM会为局部变量表中的每一个slot分配一个访问索引,通过这个索引即可成功访问到局部变量表中指点的局部变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上。

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:一个long类型变量占了索引为3、4的slot,则使用3即可)

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

public void test(int a) {
long b;
boolean c;
}

0 —— this

1 —— a

2 —— b

3 —— (b)

4 —— c

Slot的重复利用:

如果一个局部变量过了其作用域,那么在其作用域之后申明的新局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

public void test() {
int a = 0;
{
 int b = 0;
 b = a + 1;
}
int c = a + 2;	// 此时可以复用变量b的slot
}

===========>>>>>Maximum local variables: 2

Method字节码:

说明举例
Name名称
Descriptor参数<(Ljava/lang/String;)V>
Access flags访问标识[public static]
Bytecode字节码代码
Exception table异常表
Misc
  • Minor version:2
  • Maximum local variables:3
  • Code length:16
  • LocalVariableTable局部变量表Start PC, Length, Index, Name, Descriptor

    局部变量和成员变量的一个区别:

    • 成员变量值使用前,都经历过默认初始化赋值。

      • 静态变量:linking的preapre阶段隐式赋值,initial时<clinit>显式赋值
      • 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
    • 局部变量值使用前必须进行显式赋值,否则编译不通过

    补充的重要说明:

    • 在栈帧中,与性能调优关系最为密切的部分就是Local Variables。在方法执行时,虚拟机使用局部变量表完成方法的传递。
    • 局部变量表占栈帧较大的空间,如果局部变量表更小,则可以提升方法递归深度
    • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

    栈帧——操作数栈

    Operand Stack

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

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

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

    • 每个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度栈编译期就定义好了,保存在方法的code属性栈,为max_stack的值。

    • 栈中的任何一个元素都可以是任意的Java数据类型。

      • 32bit的类型占用一个栈单位深度
      • 64bit的类型占用两个栈单位深度
    • 操作数栈不能采用索引访问数据,而是遵循栈的标准只进行push、pop操作。

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

    • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类的加载过程中的类检验阶段的数据流分析阶段要再次验证

    • Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的是操作数栈

    Code:
    	stack=2, locals=3, args_size=1	// 操作数栈深度为2,局部变量表容量为3
    

    代码追踪:

    0:		bipush			15	# 操作数栈 push 15
    2:		istore_1				# 操作数栈出栈15,局部变量表存储15
    3:		bipush			8
    5:		istore_2
    6:		iload_1					# 局部变量表的15 入栈 到操作数栈
    7:		iload_2
    8:		iadd						# 操作数栈出栈8、15,执行引擎进行相加,结果23入栈
    9:		istore_3				# 操作数栈出栈23,局部变量表存储23
    10:		return					# return void
    
    bipush # push的数据可以以byte类型存储,但是以32位存储。int i = 8;
    sipush # push的数据可以以short类型存储,但是以32位存储。int i = 800;
    ldc		 # push的数据需要以int类型存储,故以32位存储。
    			 # ldc:load constant pool。int i = 100001;
    

    i++++i有什么区别???

    i++如下

    iinc 1 by 1

    istore_2

    ++i如下

    istore_2

    iinc 1 by 1

    栈顶缓存技术(Top-of-StackCaching)

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

    频繁执行内存读写 必然影响执行速度。所以HotspotJVM的设计者提出了栈顶缓存(ToS)技术,将栈顶元素全部缓存在物理CPU寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率

    动态链接

    帧数据区 == 方法返回地址 + 动态链接 + 一些附加信息

    栈帧中的Current Class Constant Pool Reference ————>> 常量池中的方法引用

    • 每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令
    • 在java文件被编译为class文件时,所有变量、方法引用都作为符合引用(Symbolic Reference)保存在class文件的常量池里。

    比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转为调用方法的直接引用。

    Constant pool:
       #1 = Methodref          #5.#19         // java/lang/Object."<init>":()V
       #2 = Fieldref           #4.#20         // 类的实例变量int num; com/dailytest/reverse/OperandStackTest.num:I
       #3 = Methodref          #4.#21         // com/dailytest/reverse/OperandStackTest.methodA:()V
       #4 = Class              #22            // com/dailytest/reverse/OperandStackTest
       #5 = Class              #23            // java/lang/Object
       #6 = Utf8               num
       #7 = Utf8               I							// 表示int类型
       #8 = Utf8               <init>
       #9 = Utf8               ()V						// 表示方法返回值为void
      #10 = Utf8               Code
      #11 = Utf8               LineNumberTable
      #12 = Utf8               LocalVariableTable
      #13 = Utf8               this
      #14 = Utf8               Lcom/dailytest/reverse/OperandStackTest;
      #15 = Utf8               methodA
      #16 = Utf8               methodB
      #17 = Utf8               SourceFile
      #18 = Utf8               OperandStackTest.java
      #19 = NameAndType        #8:#9          // "<init>":()V
      #20 = NameAndType        #6:#7          // num:I
      #21 = NameAndType        #15:#9         // methodA:()V
      #22 = Utf8               com/dailytest/reverse/OperandStackTest
      #23 = Utf8               java/lang/Object
    

    方法调用——解析与分派

    在JCM中,将符合引用转换为调用方法的直接引用与方法的绑定机制相关。

    • 静态链接:

    当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

    • 动态链接:

    如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此称之为动态链接。

    方法的绑定机制:

    早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

    静态链接 《=====》 早期绑定

    动态链接 《=====》 晚期绑定

    invokespecial		// 调用<init>、私有、父类方法,早期绑定
    invokestatic		// 调用静态方法,早期绑定
    invokevirtual		// 调用虚方法以及final方法,晚期绑定
    invokeinterface	// 调用接口方法,晚期绑定
    
    invokedynamic		// 动态解析出需要调用的方法,然后执行。(JDK7新增)
    
    面向过程语言 -->只具有早期绑定的机制
    C++中的virtual需要显示定义;而Java相反,需要final的需要显示定义
    

    方法类别:虚方法与非虚方法。

    • 非虚方法:

      • 如果方法在编译期就确定了具体调用版本,这个版本在运行时是不可变的。
      • 静态方法、私有方法、final方法、实例构造器、父类方法
    • 虚方法:

      • 类的继承 + 方法的重写

    关于invokedynamic指令

    • JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现 【动态类型语言】 支持二做的一种改进。
    • 但是Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8,Lambda表达式可以直接生成invokedynamic指令。
    • Java7中增加点动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译期。

    动态类型语言和静态类型语言

    对于类型的检查,如果是在编译期进行则为静态类型语言,反之是动态类型语言。

    说的更直白一点:静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息。

    Java: String info = "hello world";

    JS: var info = 10;

    Python: info = 130.5

    方法重写的本质:

    1. 找到操作数栈顶顶第一个元素所执行的对象实际类型,记作 C
    2. 如果类型C中找到与常量中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常
    3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第2步的搜索和验证过程
    4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

    java.lang.IllegalAccessError介绍

    程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

    虚方法表(virtual method table):

    在面向对象的编程中,会很频繁地使用到动态分派,如果在每次动态分派到过程中都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率。因此,为了提高性能,JVM在类的方法区建立一个虚方法表(非虚方法不会出现在表中)。使用索引表来代替查找

    虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

    方法返回地址

    方法返回地址、动态链接、一些附加信息统称为帧数据区。

    存放调用该方法的PC寄存器的值。

    一个方法的结束有两种方式:

    • 正常执行结束:PC计数器的值作为返回地址,即调用该方法指令的下一条指令的地址
    • 出现未处理的异常,非正常退出:通过异常表来确定,栈帧中一般不保存这部分信息

    一个方法在正常调用完成后,使用的是哪一个返回指令,取决于返回值的实际数据类型

    • ireturn:boolean、byte、char、short、int
    • lreturn:long
    • freturn:float
    • dreturn:double
    • areturn:reference
    • return:void、constructor、class initializer、interface initializer

    在方法执行过程中遇到了异常,并且这个异常没有在方法内进行捕获处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称 “异常完成出口”

    方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便再发生异常的时候找到处理异常的代码。

    Exception table:
    form	to		target	type
    4		16		19		any
    54		108		111		Class java/io/IOException
    

    一些附加信息

    栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。比如:对程序调试提供支持的信息。

    本地方法接口

    本地方法的定义:

    一个native method就是一个Java调用非Java代码的接口。即本地方法本身是java方法,但是实现是其它语言。

    这个特征并非Java特有,很多其它编程语言都有这一机制,比如在C++中,可以用extern "C"告知C++编译器去调用一个C语言

    为什么要使用Native Method?

    • 与Java环境外交互:与其它语言可以融合,因为Java诞生之初,C语言如日当天
    • 与操作系统交互:JVM依赖底层操作系统的支持(比如线程)
    • Sun's Java: Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互

    本地方法栈

    本地方法栈用于管理本地方法的调用。

    与JVM栈相同点:

    • 线程私有
    • 允许实现为固定或者课动态扩展内存,遇到容量不足抛出异常相同

    当某一个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存

    并不是所有JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈使用的语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

    在Hot spot虚拟机中,直接将本地方法栈和虚拟机栈合二为一。

    概述

    • 一个JVM实例只存在一个堆内存,所有线程共享堆空间

    • 堆在JVM启动时被创建,创建时其空间大小也就确定了。堆空间的大小可以通过JVM参数调节

    • 《Java虚拟机规范》规定:堆在逻辑上是连续的,在物理上可以不连续

    • 《Java虚拟机规范》对堆的描述:所有的对象实例以及数组都在运行时分配在堆上

      • 实际上并不是所有的对象实例都分配在堆上
      • NIO、直接调用unsafe可以实现把对象分配到直接内存
      • 逃逸分析、栈上分配、标量替换
    • 堆 是垃圾收集器执行垃圾收集的重点区域。在方法结束后,堆中的对象不一定会马上被移除,而在垃圾收集的时候才会被移除

    怎么看Java进程?

    ...\Java\jdk1.8.0_301\bin\jvisualvm.exe

    基于OpenJDK深度定制的TaoBaoVM,其中的GCTH(GC invisible heap0)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

    堆空间分配字节码指令:

    • 分配类对象:new #11
    • 分配基本类型数组:newarray 10
    • 分配对象数组:anewarray #13

    为什么是对于对象、引用的字节码指令前缀是“a”呢?(anewarray、areturn、aload、astore)

    a代表的是address

    堆空间内存大小的设置

    • -Xms12m:设置堆区的起始内存为12m,等价于-XX:InitialHeapSize
    • -Xmx16m:设置堆区的最大内存为16m,等价于-XX:MaxHeapSize
    • 默认情况下,起始内存 = 电脑内存 / 64,最大内存 = 电脑内存 / 4
    • 通常会将-Xms-Xmx参数配置为相同的数值,目的是为了能够在Java垃圾回收后不需要重新分隔计算堆区的大小,从而提高性能
    • 指令jps查看当前运行程序进程
    • 指令jstat查看Java进程的内存使用情况
    C:\Program Files\Java\jdk1.8.0_301\bin>jps
    10704 Launcher
    15856 Application
    13476 RemoteMavenServer36
    7796 MavenServerIndexerMain
    8648 Jps
    13932
    
    C:\Program Files\Java\jdk1.8.0_301\bin>jstat -gc 15856
     S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
    10240.0 10240.0  0.0    0.0   64000.0  25787.1   169472.0     0.0     4480.0 778.0  384.0   76.6       0    0.000   0      0.000    0.000
    
    • S0C代表Surviver 0区内存总量
    • S0U代表Surviver 0区使用的内存量

    为什么分配600M的堆内存,却发现Java堆内存总量Runtime.getRuntime().totalMemory()只返回575M呢?

    因为其中25M是一个Surviver区的内存大小,在程序运行的过程中,两个Surviver区只能使用其中一个。

    常见调优工具:

    • JDK命令行
    • Eclipse: Memory Analyzer Tool
    • Jconsole
    • VisualVM
    • Jprofiler
    • Java Flight Recorder
    • GCViewer
    • GC Easy

    堆内存划分

    Java虚拟机规范并没有对堆空间的划分做任何规定。

    现代垃圾收集器大部分基于分代收集理论设计的

    • 新生代(Young Generation):又分为伊甸区(Eden Space)、幸存0区(Surviver 0/From)、幸存1区(Surviver 1/To)
    • 老年代(Tenure Generation)
    • 几乎所有对象都是先在Eden区分配的(当对象过大时,Eden区可能放不下)

    为什么基于分代来划分堆空间?

    将Java堆细分,目的是为了更好地回收垃圾或更快地分配内存。

    分代收集建立于大多数程序运行的实际情况的经验之上,它建立于两个分代假说之上:

    1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。(80%的对象)

    2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡

    衍生出来的假说三则是为解决“跨代引用”问题提供了思路

    3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数

    配置新生代与老年代在堆结构的占比(一般不需要设置)

    • 默认-XX:NewRatio=2,表示新生代占1分,老年代占2分,即新生代占1/3
    • 比如可以修改配置为-XX:NewRatio=4,表示新生代占1分,老年代占4分

    配置新生代中各个区域的内存比例

    • 在HotSpot中,默认Eden:Surviver 0:Surviver 1 = 8:1:1。即默认-XX:SurvivorRatio=8
    • -XX:-UseAdaptiveSizePolicy关闭自适应内存分配策略(Use前面的减号表示取反,即关闭的意思),如果不关闭这个策略,则-XX:SurvivorRatio参数虽然默认是8,但是实际上会按6来分配内存

    可以用-Xmn配置新生代最大内存大小(一般不需要设置)

    对象的创建

    Object obj = new Object();
    

    对应字节码:

    new	#2				// class java/lang/Object
    
    dup					// 获取调用构造器方法的句柄
    
    invokespecial #1	// Method java/lang/Object."<init>":()V
    
    astore_1
    
    return
    

    创建对象的步骤:

    1. 判断对象对应的类是否加载、链接、初始化

      • new指令参数对应的类型符号引用是否已经被JVM加载,如果没有则使用当前类加载器查找对应的class字节码并尝试加载。
    2. 为对象分配内存

      • 怎么分配取决于堆内存是否规整。规整:指针碰撞(Bump The Pointer);不规则:空闲列表(Free List)
      • 一般,新对象分配在Eden区
      • 处理内存分配的并发问题:CAS重试、TLAB
    3. 初始化分配的空间

      • 所有属性设置默认值,即初始化为0
    4. 设置对象的对象头

    5. 执行init方法进行初始化

    给对象分配的堆内存

    创建的新对象先进Eden区,如果Eden区满了就会触发Young GC(或叫Minor GC),存活对象中年龄较小的会从Eden和From区搬入To区,这些对象年龄加一。年龄较大的对象会进行Promotion晋升,从From区搬入Tenure/Old区。

    默认年龄为15的对象会得到晋升,这个参数可以通过-xx:MaxTenuringThreshold=<N>进行设置。

    特殊情况:

    • 如果YGC完成后,Eden区还是放不下新创建的对象,就会尝试把对象放在Tenure/Old区,如果Tenure/Old区放不下就会先进行Full GC/Major GC再尝试,如果还是放不下(允许自动扩容时会尝试扩容)就会抛出OOME。
    • 同理,如果在YGC的过程中,一些对象从Eden区搬到To区时发现To区放不下这些对象,就会尝试把这些对象放到Tenure/Old区。
    • 动态对象年龄判断:如果Survior区相同年龄的所有对象所占内存空间总和大于Survivor区的一半,则年龄>=该年龄的所有对象可以直接进入老年代。
    • 创建大对象(一般是大数组)会触发GC提前执行,程序中应该尽量避免创建临时的大对象。

    Major GC/Old GC专指对Tenure区的收获,而Full GC常指整个Java堆和方法区的垃圾收获,但两者经常被混淆使用。Mixed GC是收获整个新生代和部分老年代,目前只有G1会有这种行为。

    减少老年代的GC是JVM调优非常重要的一部分,因为GC的过程中有特定阶段需要STW(Stop the World)暂时停止用户线程执行,而老年代的GC开销尤其大,会大大影响程序处理业务逻辑的实时性和吞吐量。

    当Tenure/Old区的内存空间不足时,会触发Major GC/Old GC。Major GC通常比Minor GC慢10倍以上,应该尽量避免程序运行时频繁Major GC。

    当老年代、方法区空间不足时会触发Full GC。System.gc()会建议JVM进行一次Full GC,但不一定会立即执行,开发的生产环境代码应该尽量避免使用System.gc()

    -XX:+PrintGCDetails

    TLAB

    Thread Local Allocation Buffer

    • 堆空间是多线程共享的。程序运行时,多个线程并发地创建对象或数组,此时需要在并发环境下分配堆内存,为了避免线程安全问题需要加锁,但加锁会导致JVM性能低下。
    • 在Eden区中为多个线程分别分配TLAB,让多个线程并行地在这块区域内分配对象,大大提升性能。
    • 默认情况下,TLAB空间的内存非常小,占Eden区的1%(可以通过-xx:TLABWasteTargetPercent来设置所占Eden区百分比大小)。
    • JVM将TLAB作为内存分配的首选(默认开启-XX:UseTLAB),TLAB分配失败后再退化为加锁分配。

    逃逸分析

    定义:一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

    • 通过逃逸分析,Java Hotspot编译器能够分析出一个新对象的引用的作用范围,从而决定是否将这个对象分配在堆上。
    • 当一个对象在一个方法中创建时,如果这个对象只在这个方法内部使用,则认为没有发送逃逸。

    在JDK6update23版本之后,HotSpot默认开启逃逸分析。

    通过逃逸分析,JVM的编译器可以对代码进行优化:

    • 栈上分配。
    • 同步省略。这个对象如果只能被一个线程访问,那么无需synchronized同步
    • 分离对象或标量替换。一个对象的完整数据结构并不是全部都被需要,那么可以进行优化为标量,只分配对象的一部分。

    同步省略示例:

    void bar() {
     Object obj = new Object();
     synchronized(obj) {
         System.out.println(obj);
     }
    }
    |		|		|
    V		V		V
    void bar() {
     Object obj = new Object();
     System.out.println(obj);
    }
    

    标量(Scalar) :一个无法再分解成更小的数据的数据,比如Java中的原始数据类型。相对的,可以被分解为标量的数据称为聚合量(Aggregate) ,比如Java中的对象。

    标量替换示例:

    class Point {
     int x;
     int y;
    }
    int bar() {
     Point p1 = new Point();
     Point p2 = new Point();
     p1.x = 1;
     p2.x = 2;
     return p1.x + p2.x;
    }
    |		|		|
    V		V		V
    void bar() {
     int x1 = 1;
     int x2 = 2;
     return x1 + x2;
    }
    

    JVM相关参数

    • -server:启动server模式,只有载server模型下才可以启用逃逸分析(默认开启)
    • -XX:+DoEscapeAnalysis开启逃逸分析
    • -XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果
    • -XX:+EliminateAllocations开启标量替换(默认开启)

    对象的内存布局

    一个对象在堆空间的内存布局如下(以64位系统为例):

    • 对象头(Header)

      • 运行时元数据(Mark Word)(4字节)

        • 哈希值(Hashcode)
        • GC分代年龄(GC Age)
        • 锁状态标记(Lock Status):无锁、偏向、轻量级、重量级
        • 线程持有的锁(Epoch):用于轻量级锁实现重入功能
        • 偏向线程ID(Biased Thread ID)
        • 偏向时间戳
      • 类型指针(Klass Pointer)(4字节)

      • 数组长度(如果该对象是数组,则存储数组长度)(4字节)

    • 实例数据(Instance Data)

      • 存储成员变量。boolean、byte占1个字节,short、char占2个字节,int、float、类引用占4个字节,long、double占8个字节。相同宽度的字段总是被分配在一起,父类定义的变量会出现在子类之前。如果CompactFields参数位true(默认为true),子类的窄变量可能插入父类变量的间隙。
    • 对齐填充字节(Padding):使得每个对象所占内存空间大小为8字节的倍数。

    对象的访问定位

    +------------------+            +------------------------+
    |                  |            |                        | 
    |  +------------+  |            |  +------------------+  |
    |  | reference  +--+------------+--> InstanceOopDesc  +--+------+
    |  +------------+  |            |  +------------------+  |      |
    |                  |            |                        |      |
    |    Stack Frame   |            |          Heap          |      |
    +------------------+            +------------------------+      |
                                                                    |
                                     +----------------------+       |
                                     |                      |       |
                                     |  +----------------+  |       |
                                     |  | InstanceKlass  <--+-------+
                                     |  +----------------+  |
                                     |                      |
                                     |     Method Area      |
                                     +----------------------+ 
    

    对象访问方式主要有两种

    • 句柄访问:堆空间中有一块区域被称为句柄池,里面存放“对象实例数据指针”与“对象类型数据指针”的映射表。栈上的引用地址指向句柄池中映射表的一项映射数据。
    • 直接指针:被Hotspot采用,对象实例数据中包含“对象类型数据指针”,栈上的引用地址直接指向“对象实例数据”

    优缺点:

    如果采用句柄访问,对象寻址时需要两次寻址,性能较低,而直接指针只需要一次寻址,性能较高。但是,垃圾回收进行内存整理时,如果采用句柄访问,只需要调整句柄池中的指针映射,不需要对栈上的引用进行修改。

    方法区

    概述

    • 全部线程共享的
    • 类似于传统编程语言中存储编译后代码的区域,类似于OS进程中存放代码段的区域
    • 存储每个类的结构数据,比如运行时常量池、字段、方法元信息,方法和构造器的代码(包括静态代码块)
    • 逻辑上来说,方法区是堆空间的一部分,但是在简单的JVM实现中,不会考虑方法区的GC和内存整理。JVM规范并不对“方法区的位置”和“方法区编译后代码的管理策略”进行约束限制
    • 方法区可以设计内存的自动扩容或伸缩,方法区的内存在物理上不要求必须是连续的
    • 内存不够用时抛出OOME

    如何解决OOM?

    • 通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析。重点确认内存中的对象是否必要,即区分是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
    • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。根据对象的类型信息和引用链信息,准确定位造成泄漏的代码位置。
    • 如果是内存溢出。(1)应当检查虚拟机参数(-Xmx-Xms),与机器的物理内存对比,看看是否可以调大.(2)从代码上查看是否有某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期间的内存消耗。

    方法区存储内容:

    • 类型信息
    • 常量
    • 静态变量
    • 即时编译器的代码缓冲

    常量池与运行时常量池

    class字节码中包含了常量池表(Constant Pool Table),常量池表中包含了各种字面量、类型、域、方法的符号引用。

    字节码的常量池表被加载到JVM内存后,存放于方法区的运行时常量池中,JVM位每个加载的类型都维护一个常量池。通过加载中的解析阶段,把字节码中的符号引用转换位真实地址。

    运行时常量池还具备一定动态性(如String.intern()

    Hotspot方法区的演进

    Hotspot方法区的演进:

    • jdk1.6及之前:采用永久代,字符串常量池和静态变量存储在方法区
    • jdk1.7:采用永久代,字符串常量池和静态变量存储在堆
    • jdk1.8及之后:采用元空间,字符串常量池和静态变量存储在堆
    • 使用永久代,更容易OOM(超过-XX:MaxPermSize)。用元空间(使用本地内存)就不容易OOM。

    当时Oracle已经收购了JRocket虚拟机,永久代被替换为元空间,是Hotspot与JRocket融合的效果。因为JRocket的客户原本不需要配置方法区的内存大小。

    1)永久代所需要的内存空间是很难确定的。 在某些场景下,需要动态加载的类比较多,设置空间过小容易触发Full GC或OOME。

    2)对永久代进行调优是很困难的。 判断方法区每个类型是否可回收 是代价很高的行为,Full GC通常耗时很长。应该尽量避免进行Full GC。

    -XX:MetaspaceSize=21M设置初始元空间大小为21M,这也是初始值。一旦触及这个高水位线,Full GC将会被触发并卸载没用的类(已经消亡类加载器加载的类),然后这个高水位线将会重置,新的高水位线的值取决于GC后释放了多少元空间。如果释放空间不足,那么在不超过-XX:MaxMetaspaceSize的情况下适当提高该值。如果释放空间较多,则可以适当降低该值。

    为了避免频繁Full GC,建议把-XX:MetaspaceSize设置相对高一点的值。

    静态变量的值(基础类型数据或对象引用地址)在jdk7及之后的版本的hotspot虚拟机中与Class对象一起存放在堆空间中。注意:几乎所有对象都存放在堆空间中,如果静态变量是对象引用,那么在hotspot虚拟机中总是把对象实例存储在堆空间中。

    方法区的垃圾回收

    Java虚拟机规范没有规定方法区一定要有垃圾回收或内存整理, 比如JDK 11时期的ZGC收集器就不支持类卸载。一般来说方法区的回收效果比较难令人满意,卸载类型的条件相当苛刻。但有时,这个部分区域的回收确实是必要的,以前sun公司的bug列表中,曾出现过的若干个严重的bug就是由于低版本的hotspot虚拟机对此区域未完全回收而导致的内存泄漏。

    方法区的垃圾回收主要包含两个内容:(1)常量,(2)类型

    一个常量如果没有在任何地方被引用,就可以被回收

    一个类型要处于”可回收“状态,条件苛刻

    • 该类型及其子类型的所有实例都已经被回收了。
    • 加载该类型的类加载器已经被回收了。除非是精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类型的信息。

    字符串常量池

    底层

    本小节涉及字符串常量池的实现都是以HotSpot为例的。

    字符串常量池是运行时数据区的一部分,在jdk7之前放在方法区里,在jdk7及之后放在堆里

    • 结构是哈希表,采用拉链法;大小是固定的,不会扩容
    • 使用-XX:StringTableSize可以设置StringTable长度
    • jdk6中,StringTable默认长度为1009
    • jdk7中,StringTable默认长度为60013
    • jdk8中,StringTable默认长度为60013,并且可设置的最小值为1009

    字符串常量池的长度设置的比较小,会导致JVM存储的字符串偏多时,哈希冲突很多,链表很多,定位字符串的时间复杂度由O(1)退化为O(N)。

    为什么长度默认值设定为1009、60013呢?

    因为这两个数值是质数,容易减少哈希冲突得到比较好的哈希桶分布。

    哈希表的长度采用质数还是2的次方数,需要权衡。

    StringTable为什么要调整?

    • 永久代的回收效率很低,在Full GC的时候才会触发。而Full GC是老年代的空间不足、永久代不足时才会触发。
    • 这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低导致永久代内存不足。StringTable放到堆里,能及时回收内存。

    JEP254:jdk9及之后,把String内部的char数组改为了byte数组加字符编码集(通常采用UTF-16编码)以节约内存空间。

    字符串常量池中的字符串是会被垃圾回收的。

    常量池的拼接操作

    常量池的拼接操作,会通过javac优化为StringBuilder的拼接操作,当遇到需要存储在局部变量的情况,则生成String对象并存储到JVM的字符串常量池中。

    String qux = "a" + "b" + "c";
    |		|		|
      javac编译器优化
    |		|		|
    V		V		V
    String qux = "abc";
    // 只创建了"abc"这一个字符串
    
    String a = "a";
    String b = "b";
    String c = "c";
    String combination = a + b + c;
    |		|		|
      javac编译器优化
    |		|		|
    V		V		V
    String a = "a";
    String b = "b";
    String c = "c";
    String combination = new StringBuilder().append(a).append(b).append(c).toString();
    // 创建了"a","b","c","abc"四个字符串,不包括"ab"
    // 如果jdk5之前,即StringBuilder出现之前的版本,则用的是StringBuffer
    
    final String a = "a";
    final String b = "b";
    final String c = "c";
    String combination = a + b + c;
    |		|		|
      javac编译器优化
    |		|		|
    V		V		V
    String a = "a";
    String b = "b";
    String c = "c";
    String combination = "abc";
    // final修饰使得变量a,b,c变成了常量引用
    
    String combination = new String("a") + new String("b");
    |		|		|
      javac编译器优化
    |		|		|
    V		V		V
    var0 = new StringBuilder();
    var1 = "a";
    var1 = new String(var1);
    var0 = var0.append(var1);
    var1 = "b";
    var1 = new String(var1);
    String combination = var0.append(var1).toString();
    // 一共出现了6个对象,
    // 一个常量池中的"a",一个常量池外的"a",
    // 一个常量池中的"b",一个常量池外的"b",
    // 一个常量池外的"ab",一个StringBuilder
    

    String#intern()

    String bar = new String("a") + new String("b");
    bar.intern();
    String fox = "ab";
    System.out.println(bar == fox);
    // 在jdk6中,输出false;在jdk7/8中,输出true
    // 从jdk7开始,字符串常量池从方法区转移到堆了
    // jdk6中,字符串常量池中存放的是字符串数据本体;而在jdk7/8中,字符串常量池放的是字符串地址
    

    G1的字符串去重操作

    JEP192

    背景:对许多Java应用(有大的也有小的)进行测试,发现:

    • 堆存活数据集合里面String对象占25%
    • 堆存活数据集合里面重复的String对象占13.5%
    • String对象的平均长度为45

    许多大型Java应用的瓶颈在于内存,堆上存在重复的String对象必然是一种内存的浪费。

    G1的字符串去重操作

    1. 当垃圾回收器工作时,会访问每个堆上存活的对象。对每一个String对象都会检查是否是候选的去重对象。
    2. 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列中的一个元素,尝试去重它所引用的String对象。
    3. 使用一个哈希表来记录所有的被String对象使用的不重复的char数组。当去重时,会检查这个哈希表,确定堆上是否已经存在一个一模一样的char数组。
    4. 如果存在,String对象会被调整引用那个数组,释放对原来数组的引用,最终使得原数组被垃圾回收掉。
    5. 如果不存在,则会把当前的char数组插入这个哈希表。

    直接内存

    • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
    • 直接内存是Java堆外的,直接向系统申请的内存区间。
    • 也可能导致OOME
    • 比如:Java NIO中的DirectByteBuffer调用native方法操作直接内存

    涉及网络/文件IO时,使用直接内存往往性能更高

    当进行IO读时,需要先把网络或磁盘上的数据先读到OS直接内存上,在拷贝到虚拟机用户内存上。当进行IO写时,需要先把虚拟机中的数据先拷贝到OS直接内存上,再写到网络或磁盘去。如果采用直接内存进行读写,则可以省去读写时各一次的数据拷贝。

    执行引擎

    概述

    执行引擎是Java虚拟机核心的组成部分之一。

    JVM的执行引擎(Execution Engine)的任务是将字节码指令解释/编译为对应平台上的本地机器指令。

    • 前端编译:java代码编译为java字节码
    • 后端编译:java字节码编译为机器指令

    半编译半解释

    Java是半编译半解释型语言。

    程序源码 ---> 词法分析 ---> 单词流 ------------> 语法分析
                                                   |                                                                                                            V
    解释执行 <--- 解释器 <--- 指令流(可选) <------ 抽象语法树
                                                   |
                                                   V
    目标代码 <--- 生成器 <--- 中间代码(可选) <-- 优化器(可选)
    
    • 解释器:把字节码逐行解释(翻译)为机器语言
    • JIT编译器(Just In Time Compiler):把字节码直接编译为机器语言
    • 静态提前编译器(Ahead Of Time Compiler):直接把java代码编译为本地机器代码

    字节码是一种中间状态的二进制代码,它比机器码更抽象,它与硬件环境无关。

    Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器

    • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
    • 模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

    在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。

    • Interpreter模块:实现了解释器的核心功能
    • Code模块:用于管理虚拟机运行时生成的本地机器指令

    由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃。

    为了解决解释器的低效,JVM平台支持一种叫做即时编译的技术。及时编译是将整个函数体编译为机器码,每次函数执行时,只执行编译后的机器码即可。

    HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即使编译器能够互相协作,各自取长补短

    • 当程序启动时,解释器可以马上发挥作用,省去编译的时间,立即执行。
    • 随着时间的推移,根据热点探测功能,更多有价值的字节码被即时编译器编译为机器码,得到更高的执行效率。
    • 解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”

    JRocket VM不包含解释器,全部字节码依赖即时编译器编译后执行。

    机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态能够承当的流量进行切流,可以导致处于冷机状态的服务器因无法承载流量而假死。

    案例:

    在生产环境发布过程中,以分批的方式进行发布。根据机器数量划分为多个批次,每个批次的机器数至多占整个集群的1/8。

    某程序员在发布平台进行分批发布,在输入发布总批次数时,误写为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载机器,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布超过的服务器马上全部宕机。

    JVM程序执行方式和模式

    设置程序执行方式

    • -Xint完全采用解释器模式执行程序
    • -Xcomp完全采用及时编译器模式执行程序
    • -Xmixed采用解释器+即时编译器的混合模式共同执行程序

    Hotspot VM内嵌了两个JIT编译器,分别未Client Compiler、Server Compiler,但大多数情况下我们简称C1编译器和C2编译器。

    • C1编译器的编译效果差,编译效率好

      • 方法内联、去虚拟化、冗余消除
    • C2编译器(C++编写的)的编译效果好,编译效率差

      • 标量替换、栈上分配、同步消除

    设置JVM运行模式

    • 64位操作系统的机器只支持Server模式、C2编译器
    • -client指定JVM运行在Client模式下,并使用C1编译器
    • -server指定JVM运行在Server模式下,并使用C2编译器

    分层编译(Tiered Compilation)策略

    程序解释执行(不开启性能监控)可以触发C1编译,也可以加上性能监控,C2编译器会根据性能监控信息进行激进优化。

    在Java7版本之后,一旦在程序中显示指定命令-server时,默认将开启分层编译,C1编译器和C2编译器协调合作

    自JDK10起,HotSpot又加入了一个全新的即时编译器:Graal编译器。

    JDK9引入了AOT编译器,引入了AOT编译工具jaotc

    热点探测

    是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,取决于代码被调用执行的频率。这些需要被编译为本地代码的字节码,称为 “热点代码” ,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化

    一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被认定为“热点代码”。 由于这种编译方式发生在方法的执行过程中,因此也称之为栈上替换(OSR,On Stack Replacement)

    目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测

    为每个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)回边计数器(Back Edge Counter)

    • 方法调用计数器:统计方法调用次数
    • 回边计数器:统计循环体执行的循环次数

    计数器阈值与热度衰减

    • 默认阈值:Client模式下为1500次,Server模式下为10000次。两个计数器计数之和超过阈值,则会提交即时编译请求
    • 阈值可以通过-XX:CompileThreshold设置
    • 按照默认设置,计数器统计的并不是方法被调用的绝对次数,而是一个相对频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍未达到阈值,则会将当前计数减少一半,这个过程称为计数器热度的衰减(Counter Decay) ,而这段时间就称之为此方法统计的半衰周期(Counter Half Life Time)
    • 进行热度衰减的动作是在虚拟机进行GC时顺便进行的,可以通过设置-XX:-UseCounterDecay关闭热度衰减,让方法计数器统计方法调用的绝对次数。这样只要系统运行时间够长,就可以让绝大部分方法都被编译为本地代码。
    • 另外可以使用-XX:CounterHalfLifeTime参数设置半衰周期,单位是秒。

    垃圾回收

    概述

    			内存动态分配
                    ^^
                    ||
    +---------------++---------------+
    |			    ||		    	 |
    |      Java     ||      C++      |
    |				||			     |
    +---------------++---------------+
                    ||
                    vv
                垃圾收集技术
    

    在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字申请内存,并使用delete关键字释放内存。 好处:可以灵活控制内存释放的时间 坏处:给开发人员带来频繁申请和释放内存的管理负担,带来内存泄漏的风险

    现在,除了Java语言,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。

    自动内存管理:无需开发人员手动参与内存的分配与回收,降低内存泄漏和内存溢出的风险。将程序员从繁重的内存管理中释放出来,可以更专心于业务开发。

    对于Java开发人员而言,自动内存管理就像一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,可能会弱化开发人员在程序出现内存溢出时定位问题和解决问题的能力

    当需要排查内存溢出、泄漏问题时,当垃圾收集成为系统达到更高并发量、吞吐量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节

    早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。

    关于垃圾收集有三个经典问题:

    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?

    什么是垃圾?垃圾是在运行程序中没有任何指针指向的对象。

    垃圾回收分为两个基本阶段:

    • 标记阶段:找到哪些对象是垃圾

      • 引用计数
      • 可达性分析
    • 清除阶段:清除垃圾

      • 标记清除
      • 标记复制
      • 标记整理

    相关知识、概念

    System.gc()

    System.gc()等同于Runtime.getRuntime().gc()

    效果:请求JVM调用一次Full GC,但无法保证垃圾收集器一定即时响应请求进行GC。

    建议:不要在生产环境的应用程序中,主动调用System.gc()Runtime.getRuntime().gc()

    finalization机制

    java.lang,Object#finalize()

    在对象被垃圾回收之前,JVM会尝试先调用这个对象的finalize()方法。

    • finalize()可能导致对象复活
    • finalize()方法的执行时机是没有保障,它由JVM的低优先级线程Finalizer执行,finalize()可能没有执行的机会
    • 一个糟糕的finalize()会严重影响GC性能
    • 一个对象只会被JVM调用一次finalize()
    • 尽量不要重写这个方法,不要依赖这个方法,不要在Java代码中主动调用这个方法

    System.runFinalization()调用失去引用对象的finalize方法

    stop-the-world

    stop-the-world简称STW,指的是GC发生的过程中,产生应用程序的停顿。

    目前,无论哪个垃圾收集器都不能避免STW的发生,STW的时间和垃圾收集的吞吐量是衡量垃圾收集器性能的重要标准。(垃圾收集线程与用户线程不可能完全并行执行)

    并发、并行、串行

    并发

    • 一个CPU不断切换,执行多个任务。
    • 在一个时间段内,多个任务同时执行,但在一个时间点上只有一个任务在执行。

    并行

    • 多个CPU各自执行任务。
    • 多个任务总是在同时执行。

    并行(Parallel )的垃圾收集器

    • 多个垃圾收集线程并行工作,用户线程处于等待状态
    • 如:ParNew、Parallel Scavenge、Parallel Old

    串行(Serial)的垃圾收集器

    • 单线程执行

    并发(Concurrent)的垃圾收集器

    • 用户线程与垃圾收集线程同时执行(不一定是并行,可能是交替执行)
    • 如:CMS、G1
    安全点与安全区域

    安全点(Safepoint)

    程序执行只有在特定位置才能开始GC,这些位置称为安全点。

    安全点需要保证线程暂停时的状态是确定的,并且不会引入不确定性。安全点的选择很重要,如果太少可能导致GC等待时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据”是否具有让程序长时间执行的特征“为标准。比如:选择一些执行时间较长的指令作为SafePoint,如:方法调用、循环跳转、异常跳转等

    如何在GC发生时,使得所有线程都跑到最近的安全点停顿下来?

    • 抢先式中断(目前没有虚拟机采用了):首先中断所有线程。如果有线程不在安全点,就恢复这个线程,让它跑到安全点。
    • 主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

    安全区域(Safe Region)

    引入:

    一般来说,各个线程运行到SafePoint耗时较短。但是可能存在部分线程处于”Sleep“或”Blocked“状态,JVM不可能等待这些线程被唤醒。安全区域(Safe Region)被提出来以解决这类问题。

    安全区域:一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。(一段代码中,不涉及堆操作,例如:创建对象,访问对象字段)

    1. 当线程运行到Safe Region代码时,会标识这个线程。如果此时发生GC,JVM会忽略被标记的线程。
    2. 当线程即将离开Safe Region时,会检查JVM是否完成GC。若完成,则继续运行,否则线程必须等待,直到收到可以离开Safe Region的信号为止。
    引用类型

    jdk 1.2之后,Java对引用概念进行了扩充,从强到弱分为四种引用:强、软、弱、虚。

    强引用(Strong Reference)

    引用的对象不可被回收,内存空间不够时,抛出OOME。

    Object strongRef = new Object();
    

    软引用(Soft Reference)

    引用的对象只有在内存空间不够时,才可以被回收。通常用于缓存场景。

    SoftReference<Object> softRef = new SoftReference<>(new Object());
    

    弱引用(Weak Reference)

    垃圾收集时,发现一个对象只被弱引用引用,则这个对象可以回收。

    WeakReference<Object> weakRef = new WeakReference<>(new Object());
    

    使用示例:Netty的ByteBuff对象池、jdk的ThreadLocalWeakHashMap

    虚引用(Phantom Reference)

    不会影响对象的生存周期。配合ReferenceQueue使用,用于在这个对象被垃圾收集时系统能够收到一个通知回调。(“虚引用"又被称为"幽灵引用"、“幻影引用”)

    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
    Reference<Object> hadBeenCollected = queue.remove();
    

    使用示例:java nio的DirectByteBufferCleaner

    关于引用队列ReferenceQueue

    • JVM负责把引用放入一个链表中,这个链表的头节点是ReferenceQueue中静态变量pending
    • ReferenceQueueclinit静态代码块中,开启了一个最高优先级的守护线程,负责把pending中的引用依次放入它们自己的引用队列ReferenceQueue对象中以成员变量head为头节点的链表中。

    弱引用和虚引用一样,不会阻止对象被GC,而且两者都可以配合ReferenceQueue来追踪对象的垃圾回收。那弱引用的作用和虚引用的作用不是重叠了吗,有了弱引用,为什么还需要虚引用?

    stackoverflow.com/questions/2…

    对于加入引用队列的时机:

    • 弱引用:在GC时发现对象不可达之后,对象执行finalize()之前。
    • 虚引用:在GC时发现对象不可达之后,在对象执行finalize()且没有达成自救之后或曾经达成自救却又一次进入不可达状态之后。换一句话说,在这个对象已经确认可以被垃圾回收时。

    特别的,在JDK8中存在bug,虚引用其实会阻止对象被GC:

    • Reference都持有成员变量referent,其表示引用本体。
    • 当GC时发现对象不可达,则JVM会自动把WeakReferencereferent置为null,并加入ReferenceQueue
    • PhantomReference则永远不会自动把referent置为null,除非应用系统通过反射强行修改成员变量。因此,如果一个对象被虚引用所引用,除非这个虚引用本身,即PhantomReference对象被垃圾回收,否则这个对象不会被垃圾回收。

    终结器引用(FinalReference)

    • 它用于实现对象的finalize()
    • 包私有的引用,提供给JVM内部使用

    标记阶段相关算法

    引用计数

    每个对象有一个引用计数器。每当这个对象被引用,则计数器加一,每当这个对象失去引用,则计数器减一。当这个对象的引用计数为0,则这个对象处于可回收状态。

    优点:实现简单,判定效率高

    缺点:

    • 每个对象需要开销额外内存空间存储引用计数
    • 为了维护正确的引用次数,需要额外时间开销
    • 无法处理循环引用的情况。这是一个致命的缺陷,导致Java垃圾回收器没有采用这种算法

    Python结合了引用计数和可达性分析

    怎么解决采用引用计数而导致无法处理循环引用的问题?弱引用WeakReference

    可达性分析

    又叫根搜索算法、追踪性垃圾收集

    • GC Roots是一组必须活跃的引用。
    • 以GC Roots为起点,按照从上到下的方式搜索被根对象集合所连接的目标对象
    • 内存中存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径被称为引用链(Reference Chain)。如果对象没有与任何引用链相连,则意味着这个对象为垃圾对象。

    GC Roots包含以下元素

    • JVM栈中引用的对象
    • 本地方法栈内JNI引用的对象
    • 类静态属性引用的对象
    • 方法区常量引用的对象
    • 所有持有synchronized锁的对象
    • JVM内部引用,比如:基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器
    • 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
    • 根据所选用的垃圾收集器、当前回收的内存区域不同,还可以有其它对象临时性地加入GC Roots

    分析工作必须在一个能保障一致性的快照中进行,否则无法保证分析的准确性。这也是GC必须stop-the-world的原因,即时是CMS收集器,在枚举根节点时也是必须要停顿的。

    查看程序的GC Roots

    方式1:命令行使用jmap

    C:\Users\ASUS>jps
    7520 Jps
    14036 GCRootsTest
    C:\Users\ASUS>jmap -dump:format=b,live,file=test1.bin 14036
    Dumping heap to C:\Users\ASUS\test1.bin ...
    Heap dump file created
    

    方式2:使用JVisualVM导出

    Remember Set

    两个原因:如果进行Young GC,此时一个新生代对象仅被一个老年代对象引用,而这个老年代对象并没有在任何地方被引用,在这种情况下,这个新生代对象是不可回收的。因为如果它被回收了,那么这个引用它的老年代对象就变成残缺品了,即使这个老年代对象已经是不可达的对象了,但也有可能通过finalization机制复活,所以Young GC时,GC Roots必须包括跨代引用新生代的老年代对象。同时出于性能原因,对整个堆进行可达性分析开销太大,最好在Young GC时只对新生代进行分析。

    老年代对象很多,如果每次进行Young GC时都必须遍历老年代对象,那么开销是巨大的。为了解决这个问题,Hotspot引入了记忆集(Remember Set)。

    • 记忆集属于新生代全局的数据结构。
    • 基于分代假说三:跨代引用相对于同代引用来说仅占极少数。记忆集不需要记录全部跨代引用,而是把老年代划分若干块,标识出哪一块老年代存在跨代引用,此后发生Young GC时则把这几块中的老年代对象加入GC Roots即可。
    • 为了维护记忆集,在对象引用发生改变时需要通过“写屏障(store barrier)”及时修改记忆集,增加了程序运行时的开销。

    记忆集把老年代划分为若干块,划分的粒度,即记录精度通常包含三种:字长精度、对象精度、卡精度

    由于分代假说三,我们不需要太细的精度,在虚拟机实现中,通常采用卡精度,这种记忆集的实现称为卡表(Card Table)。卡表记录的每个内存块被称为卡页(Card Page),hotspot中卡页大小为2的9次幂,即512字节。一个卡页中如果有一个或多个对象含有跨代指针,则这个卡页对应的卡表数组元素为1,称这个元素变脏(Dirty)。

    /**
     * 可以理解为这种类似的数据结构
     */
    class CardTable {
        static int cardPageSize = 2 << 9;
        boolean[] isDirty = new boolean[oldGenSize / cardPageSize];
    }
    

    记忆集还面临“伪共享(False Sharing)”问题

    记忆集不仅解决跨代引用问题,也同样解决了跨区引用问题。跨区引用是指垃圾回收采用了分区算法,不同区之间可能存在跨区引用,而垃圾回收器仅回收其中部分区间的垃圾,例如G1、ZGC、Shenandoah都面临这种问题。

    对于跨区引用问题,卡表的维护开销更大,每个region都维护一个卡表,每个卡表都需要记录哪个region的哪个卡页内存中存在持有到当前卡页中对象引用的对象。

    /**
     * 可以理解为这种类似的数据结构
     */
    class CardTable {
      static int cardPageSize = 2 << 9;
      static class CardPage {
        CardPageId cardPageId;
      }
    ​
       RegionId regionId;
       CardPage[] pages = new CarPage[regionSize / cardPageSize];
       Map<RegionId, Set<CardPageId>> references = new HashMap<>();
    }
    

    由于这种场景下的卡表比较复杂,如果在写屏障处直接尝试更新RSet,那么会造成很大的性能开销,于是引入了一种数据结构:Dirty Card Queue。当对象引用改变时,写屏障把这条记录写入Dirty Card Queue,而不是立即更新RSet。当GC要开始时,依次处理脏表队列中的元素,来更新全部卡表。这样在GC时能够确保卡表处于准确可用的状态。

    并发的可达性分析

    为了降低用户线程的停顿,期望在GC Roots选举之后的对象图遍历阶段,让GC线程与用户线程并发执行,为此引入了三色标记(Tri-color Marking) 法。

    每个对象存在三种状态:

    • 白色:对象尚未被GC访问过
    • 黑色:对象已经被GC访问过,且这个对象的所有引用也都被访问过
    • 灰色:对象以及被GC访问过,但这个对象的所有引用没有全部被访问过

    当用户线程和GC的标记线程并发执行,用户线程可能在同时修改对象的引用关系,此时存在两种后果:

    1. 将原本是垃圾的对象标记为存活,这不是好事,但可以容忍,这些垃圾被称为 “浮动垃圾” ,可以在下次垃圾回收时清除。
    2. 将原本是存活的对象标记为垃圾,这是不可容忍的,必须解决这类问题。

    当且仅当以下两个条件同时成立,会导致存活对象被当作垃圾清理掉:

    1. 插入了一条或多条从黑色对象到白色对象的新引用
    2. 删除了全部从灰色对象到白色对象的直接或间接引用

    解决这个问题通常有两种方案:

    1. 破坏条件一:增量更新(Incremental Update) ,当插入了黑色对象指向白色对象的引用,记录这条插入记录,并发标记结束之后STW,以这些黑色对象为根重新扫描。可以理解为把记录中的黑色对象置灰。
    2. 破坏条件二:原始快照(Snapshot At The Beginning, SATB) ,当删除了从灰色对象到白色对象的引用,记录这条删除记录,并发标记结束之后STW,以这些白色对象为根重新扫描。可以理解为只按照并发标记开始时的引用关系来遍历对象图。

    CMS采用增量更新,G1、Shenandoah采用SATB

    清除阶段算法

    标记清除

    Mark-Sweep

    1960年提出,并应用于Lisp语言

    • 标记:从GC Roots开始遍历,标记可达对象,在对象的Header中进行记录
    • 清除:对堆内存线性遍历,如果发现Header中没有记录为可达的对象,则将其回收

    缺点:

    • 空闲内存不连续,会产生内存碎片,分配内存需要使用空闲列表
    复制

    Copying

    1963年由M.L.Minsky提出

    把可用的内存空间分为大小相同的两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制道未使用的内存块中,清除原本正在使用的内存块中的所有对象。

    优点:

    • 不需要完全遍历内存空间,效率高
    • 内存空间连续,分配内存可用使用指针碰撞

    缺点:

    • 需要浪费一半的内存空间
    • 对象地址移动,需要维护引用关系
    • 如果垃圾对象很少,则需要复制的对象很多,此时复制算法效果不太理想
    标记压缩

    Mark-Compact,也叫标记整理,也叫标记清除压缩算法(Mark-Sweep-Compact)

    1970年前后,G.L.Steele, C.J.Chene, D.s.Wise等研究者提出

    • 标记:从GC Roots开始遍历,标记可达对象,在对象的Header中进行记录
    • 压缩:对堆内存线性遍历,如果发现存活对象,则将其依次按顺序整理到堆空间的一端

    优点:

    • 内存空间连续,分配内存可用使用指针碰撞

    缺点:

    • 垃圾收集效率不高
    • 对象地址移动,需要维护引用关系

    分代收集相关算法

    增量收集

    Incremental Collecting

    目标:减少并发收集垃圾时对应用线程吞吐量的影响

    思想:让垃圾收集线程和应用程序线程交替执行

    缺点:线程切换和上下文转换的消耗,导致垃圾回收总成本上升

    分区

    目标:减少stop-the-world状态下应用线程的停顿时间

    思想:将一块大的内存区域按照对象生命周期长短划分为多个小块,根据目标停顿时间,每次合理回收若干个小区间,而不是整个堆空间

    垃圾回收器

    概述

    JVM规范对垃圾收集器没有进行过多规定,可以由不同厂商、不同版本的JVM来实现。

    垃圾回收器的分类

    • 线程数

      • 串行垃圾回收器:适合单CPU处理器或应用内存较小等硬件平台不是很优越的场景。串行垃圾回收器默认用于Client模式的JVM中。
      • 并行垃圾回收器:适合多CPU处理器,吞吐量高
    • 工作模式

      • 并发式垃圾回收器:应用线程和GC线程交替工作,以减少应用程序的停顿时间
      • 独占式垃圾回收器:开始垃圾回收时,用户线程停止直到垃圾回收完成,更注重垃圾回收的效率、吞吐量
    • 对内存碎片的处理方式

      • 压缩式垃圾回收器:会对存活对象进行压缩整理
      • 非压缩式垃圾回收器:不对存活对象进行压缩整理,存在内存碎片
    • 工作的内存区间

      • 新生代垃圾回收器
      • 老年代垃圾回收器

    评估垃圾回收器的性能指标

    • 吞吐量:运行用户代码的时间占总运行时间的比例。越高越好。
    • 暂停时间:执行垃圾收集时,程序工作线程被暂停的时间。越少越好。
    • 收集频率:相对于应用程序执行,收集操作发生的频率。频率不易太低或太高,需要权衡。
    • 内存占用:Java堆内存的利用率(考虑垃圾回收器进行垃圾收集所需要的额外内存、垃圾收集算法是否导致内存碎片)

    垃圾回收器的发展历史

    • 1999年,随着JDK1.3.1发布了Serial GC。ParNew是Serial的多线程版本
    • 2002年2月26日,随着JDK1.4.2发布了Parallel GC和CMS GC
    • 在JDK6之后,Parallel GC成为HotSpot VM默认GC
    • 2012年,在JDK1.7u4版本中,G1可用
    • 2017年,在JDK9中,G1成为默认垃圾收集器
    • 2018年3月,JDK10发布,G1实现并行性来改善最坏情况下的延迟

    • 2018年9月,JDK11发布,引入Epsilon GC,又称为”No-OP“ GC。同时,引入可伸缩的低延迟垃圾回收器:ZGC(Experimental)
    • 2019年3月,JDK12发布,增强G1,自动返回未用堆内存给OS。同时,引入低停顿时间的Shenandoah GC(Experimental)
    • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给OS
    • 2020年3月,JDK14发布,删除CMS垃圾回收器。扩展ZGC在macOS和Windows上的应用

    7款经典的垃圾收集器

    • 串行:Serial、Serial Old
    • 并行:ParNew、Parallel Scavenge、Parallel Old
    • 并发:CMS、G1

    垃圾回收器组合关系

    OldGen
    YoungGen
    jdk8废弃,jdk9移除
    backup
    jdk8废弃,jdk9移除
    jdk14废弃
    Serial Old
    Parallel Old
    CMS jdk9废弃,jdk14移除
    Serial
    ParNew jdk9废弃,jdk14移除
    Parallel Scavenge
    G1
    

    ParNew和Parallel的框架不一样,所以ParNew可以和CMS配合使用而Parallel不行。

    如何查看程序默认的垃圾回收器

    • -XX:+PrintCommandLineFlags查看命令行相关参数
    • 使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID
    GC分类作用位置算法特点适用场景
    Serial串行YoungCopying响应速度单CPU Client
    Serial Old串行OldMark-Compact响应速度单CPU Client
    ParNew并行YoungCopying响应速度多CPU Server
    Parallel并行YoungCopying吞吐量后台运算
    Parallel Old并行OldMark-Compact吞吐量后台运算
    CMS并发OldMark-Sweep响应速度互联网或B/S业务
    G1并行、并发All HeapCopying、Mark-Compact吞吐量Server

    Serial

    • Serial GC是最基本的、历史最悠久的垃圾回收器。
    • JDK1.3之前回收新生代的唯一选择。
    • Hotspot中Client模式下的默认新生代垃圾回收器是Serial GC,默认老年代垃圾回收器是Serial Old GC
    • Serial GC采用复制算法、串行回收
    • Serial还提供Serial Old用于老年代串行回收,但是Serial Old采用的是标记压缩算法
    • Serial Old作为CMS的后备垃圾回收器
    • 在单CPU场景下,简单高效。在像桌面应用这样内存不大的场景下,效率较高。
    • -XX:+UseSerialGC:在新生代和老年代都使用Serial GC

    ParNew

    • Par是Parallel的缩写,New是指只能处理新生代
    • 复制算法、并行回收
    • -XX:+UseParNewGC:使用ParNew GC
    • -XX:ParallelGCThreads限制线程数量,默认与CPU核数相同的线程数

    Parallel

    • 复制算法、并行回收、吞吐量优先
    • JDK1.6提供了用于老年代的Parallel Old GC,采用标记压缩,并行回收

    有了ParNew,为什么还要有Parallel Scavenge?

    与ParNew不同的是,Parallel回收器的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾回收器。而且Parallel还具有自适应调节策略。

    • 高吞吐量适合后台运算而不需要太多交互的任务。例如:批量处理、订单处理、工资支付、科学计算等。

    • -XX:+UseParallelGC:新生代使用Parallel GC

      • 默认激活老年代使用Parallel Old GC
    • -XX:+UseParallelOldGC:老年代使用Parallel Old GC

      • 默认激活新生代使用Paralell GC
    • -XX:ParallelGCThreads:设置Parallel GC的线程数

      • 若CPU数小于等于8,则默认线程数==CPU数
      • 若CPU大于8,则默认线程数==3 + 5 * CPU_Count / 8
    • -XX:MaxGCPauseMillis:设置最大停顿时间

    • -XX:GCTimeRatio:设置垃圾收集时间占总时间的比例

      • 取值范围为0到100。默认值99,也就是垃圾回收时间不超过1%
    • -XX:+UseAdaptiveSizePolicy:设置是否开启自适应调节策略

      • 为了GC达到目标停顿时间、吞吐量,而动态调节年轻代大小、Eden与Surviver的比例、晋升老年代的年林
      • 默认开启

    CMS

    Concurrent-Mark-Sweep

    在JDK1.5推出,在强交互应用中具有划时代意义,第一款并发GC

    • 标记清除,并发回收、低延迟
    • 只回收老年代
    cpu0-user->|               |-------user------>|-remark->|-------user------->|-user-->
    cpu1-user->|               |-------user------>|-remark->|-------user------->|-user-->
    cpu2-user->|-initial mark->|-concurrent mark->|-remark->|-concurrent sweep->|-reset->
    cpu3-user->|               |-------user------>|-remark->|-------user------->|-user-->
    
    • 初始标记:stop-the-world,标记出GC Roots直接关联到的对象
    • 并发标记:并发执行,从GC Roots直接关联的对象开始遍历查找全部可达对象
    • 重新标记:stop-the-world,修正并发标记阶段因用户程序继续运行而导致的标记变动,避免因为引用变动而把一些可达对象忽略了
    • 并发清除:并发执行,标记清除,由于不需要移动对象,可以并行执行

    为了保证并发清除时,用户线程能够继续执行,所以采用标记清除算法而不是标记压缩算法。

    由于在GC时,应用线程并发执行,可能导致GC过程中的内存溢出。所以CMS在堆内存使用率达到某一阈值时,就要开始进行回收了。要是GC运行期间,预留的内存无法满足程序需要,就会出现Concurrent Mode Failure,此时VM将启用后备方案,临时用Serial Old收集器来进行GC。

    弊端

    • 会产生内存碎片。可能在分配大对象时不得不提前触发Full GC
    • 对CPU资源敏感。因占用线程资源而导致并发执行的应用线程性能下降
    • 无法处理浮动垃圾。无法及时处理并发标记阶段产生的新垃圾,可能导致Concurrent Mode Failure
    • -XX:+UseConcMarkSweepGC:使用CMS

      • 默认激活-XX:+UseParNewGC
    • -XX:CMSInitiatingOccupanyFraction:设置堆内存使用率阈值,达到阈值则启用CMS进行GC

      • JDK5及以前,默认值为68,JDK6及以后,默认值为92,即老年代空间使用率为92%时,触发CMS进行垃圾回收。
    • -XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC时进行内存压缩整理

    • -XX:ParallelCMSThreads:设置CMS线程数

      • 默认启动线程数为(ParallelGCThreads + 3) / 4
    • JDK9废弃CMS,见JEP291
    • JDK14移除CMS,见JEP363

    G1

    Garbage First,官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才承担起“全功能收集器”的重任和期望。Garage First这个名字旨在说明它优先回收更多的垃圾。G1是一款面向服务端应用的垃圾回收器,主要针对配备多核CPU及大容量内存的机器。JDK1.7正式启用G1,移除Experimental标识,JDK9以后成为默认垃圾回收器。

    并行与并发

    • 并行:G1回收期间,可以有多个GC线程并行工作
    • 并发:G1拥有与应用程序交替执行的能力,部分工作并发执行

    分代收集

    • G1依然属于分代型垃圾回收器,它既回收新生代也回收老年代,回收包括Young GC、Mixed GC、Full GC
    • 把堆内存分割为很多不相关的大小相同的区域(Region)。使用不同的Region表示Eden、Surviver、Tenure、Humongous。它不要求一个年龄分区的全部Region都是物理上空间连续的。

    空间整合

    • 内存回收以Region为基本单位,Region之间采用复制算法,从整体来看,可以看作采用了标记压缩算法。避免了内存碎片问题,防止分配大对象时提前触发Full GC。

    可预测的停顿时间模型(即:软实时soft real-time)

    • 能让使用者明确指定:在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒
    • G1有计划地避免回收整个堆空间。由于分区的原因,G1可以缩小回收范围,只选取部分Region进行回收。
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小、回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
    +-----+-----+-----+-----+-----+-----+-----+-----+
    |  E  |     |     |  E  |     |  S  |     |  E  |
    +-----+-----+-----+-----+-----+-----+-----+-----+
    |     |  O  |     |     |  O  |     |     |  E  |
    +-----+-----+-----+-----+-----+-----+-----+-----+
    |  S  |     |     |  E  |     |  O  |  O  |     |    E:eden
    +-----+-----+-----+-----+-----+-----+-----+-----+    S:survivor
    |     H     |        H        |     |     |  O  |    O:old
    +-----+-----+-----+-----+-----+-----+-----+-----+    H:humongous
    

    为什么设置humongous区?

    对于堆中的大对象,默认之间分配到老年代,但如果它恰好生命周期是短期的,则会对垃圾回收器造成负面影响。

    大对象的定义:超过半个Region容量的对象。

    Young GC、Mixed GC、Full GC:

         +----------+    +-----------------+    +----------+
         |          |    |    Young GC     |    |          |
    +----> Young GC +---->        +        +----> Mixed GC +----+
    |    |          |    |  Marking Cycle  |    |          |    |
    |    +----------+    +-----------------+    +----------+    |
    |                                                           |
    +-----------------------------------------------------------+
    |                                                           |
    |                      +---------+                          |
    +......................+ Full GC <..........................+
                           +---------+
    
    • JVM启动时,G1先准备好Eden区。运行过程中,程序创建对象分配内存,当Eden区用尽,G1开始Young GC,stop-the-world进行并行收集,把存活对象复制移动到Survivor区、Old区。
    • 当堆内存使用率达到阈值(默认45%)时,开始并发标记
    • 并发标记完成后开始Mixed GC。对全部新生代进行GC,同时从全部老年代region中选取有价值的一部分进行垃圾回收,移动存活对象到空闲region。

    Young GC过程:

    Young GC全程stop-the-world,并行执行

    1. 扫描根。根据GC Roots进行可达性分析,遍历对象图。
    2. 更新RSet。处理Dirty Card Queue中的cards,从而更新RSet。
    3. 处理RSet。利用更新后的RSet识别老年代引用的新生代对象,进行可达性分析。
    4. 复制对象。将存活对象根据年龄复制到Survivor区或晋升老年代。
    5. 处理引用。处理存活对象的Weak、Phantom等引用。

    为什么不先更新RSet,把可能存在跨代引用的老年代对象加入GC Roots,再进行可达性分析呢?这样就不需要进行2次可达性分析了。

    我不知道为什么。

    Concurrent Marking Cycle过程:

    1. 初始标记:STW,标记GC Root,这个阶段依附于Young GC的执行。
    2. 根区域扫描:并发执行,扫描Survivor Region,查找其中引用的Old对象,此阶段必须在young gc之前完成。
    3. 并发标记:并发执行,对全堆进行可达性分析,期间可能被young gc中断
    4. 重新标记:STW,处理SATB
    5. 清理:STW,只回收全部是垃圾的Region,清洗RSet,识别需要进行Mixed GC的候选Region

    关于初始标记阶段的补充说明:

    初始标记阶段依赖于Young GC,因为G1在清理老年代对象时需要将young-to-old跨代引用作为遍历对象图的根,但是年轻代对象朝生夕死经常变化,使用RSet进行追踪的效果很差,所以会在需要回收老年代垃圾前触发Young GC,让Young GC帮忙收集young-to-old跨代引用。

    关于并发标记阶段的补充说明:

    G1内存规整,所以采用指针碰撞来分配内存。在GC的并发工作期间,为了让用户线程能够正常分配内存,G1为每个Region设计了2个名为TAMS(Top at Mark Start) 的指针,把一部分的空间划分出来用于并发时用户线程的内存分配。这期间,在指针地址以上新分配的对象,G1都把它们当作存活对象。如果此时,用户线程分配内存过多导致内存不足,则会强制冻结用户线程,触发Full GC

    在并发标记阶段,可能由于eden区空间耗尽而触发Young GC,此时Young GC会打断Mixed GC的并发标记阶段,并STW优先完成Young GC,完成后才会恢复并发标记阶段。

    Mixed GC:

    • 经过Concurrent Marking Cycle,只剩下部分有垃圾的Region,不存在全部都是垃圾的Region了。
    • 老年代的内存分为8段来通过8次(通过-XX:G1MixedGCCountTarget设置,默认为8)回收
    • Mixed GC的回收集(Collection Set)包括约八分之一的老年代内存、Eden区、Survivor。
    • Mixed GC算法和Young GC算法一样

    补充说明:

    老年代的内存分为8段来回收,G1优先回收最多垃圾的内存段,垃圾占比超过-XX:G1MixedGCLiveThresholdPercent(默认为65%)的内存片段才会被回收。回收不一定要进行8次,参数-XX:G1HeapWastePercent(默认为10%)可以设置允许浪费的堆内存,当G1发现可以回收的垃圾占比较低,则不再进行Mixed GC。

    Full GC:

    • STW串行进行垃圾回收,JDK10后优化为并行执行

    • 发生Full GC的可能原因

      • 垃圾回收时,发现没有空的Region作为复制算法的to区
      • 并发标记时,用户线程把内存耗尽

    缺点:G1为了垃圾收集而产生的内存占用(Footprint),程序运行时的额外执行负载(Overload)都比CMS更高

    内存占用主要源自复杂的RSet,执行负载主要源自比CMS更复杂的写屏障。

    • -XX:+UseG1GC
    • -XX:G1HeapRegionSize:设置每个Region的大小。值是2的幂,范围为1MB到32MB之间。默认为堆内存的1 / 2048
    • -XX:MaxGCPauseMillis:设置期望的最大GC停顿时间(是期望,实际上不一定能达到),默认为200ms
    • -XX:parallelGCThread:设置STW是并行工作线程数,最大为8
    • -XX:ConcGCThreads:设置并发时的GC线程数,一般为ParallelGCThreads / 4
    • -XX:InitiatingHeapOccupancyPercent:设置触发并发GC周期的Java堆占用率阈值,超过此值就会触发GC,默认为45

    Hotspot VM中,其它GC都是只能使用内置的JVM线程执行GC操作,而G1 GC甚至还可以采用应用线程承当后台GC工作。

    建议不要设置-Xmn-XX:NewRatio等关于年轻代大小的JVM参数,因为固定年轻代大小会覆盖暂停时机的目标,应该让G1自行调整年轻代的大小。

    对于G1的暂停时间目标的设置不要太严苛,除非愿意承受更多的垃圾回收开销和更低的吞吐量。

    Shenandoah GC

    Open JDK 12引入Shenandoah GC,由Red Hat研发。

    Shenandoah GC并没有在OracleJDK中出现,应该是遭到Oracle排挤了。

    低延迟,但吞吐量较低

    ZGC

    openjdk.org/jeps/439

    和Shenandoah GC目标相似,尽可能不影响吞吐量的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内。

    • 基于Region(Page)内存布局(对于ZGC,oracle官方有时候称一个个内存分区为Page,有时称为Region,但本质差别不大)
    • 初期不设分代(JDK11引入),JDK21后设置分代
    • 使用读屏障、染色指针等技术来实现的可并发的标记压缩算法
    • 以低延迟为首要目标

    ZGC基本上只在初始标记GC Root的时候STW。

    分为如下阶段:

    • 初始标记(Pause Mark Start)
    • 并发标记(Concurrent Mark):标记存活对象。
    • 重新标记(Pause Mark End)
    • 并发重定位准备(Concurrent Prepare for Relocate):准备对象重定位的过程。
    • Relocate Start
    • 并发重定位(Concurrent Relocate):执行对象的实际重定位。
    • Concurrent Remap

    colored pointer翻译为“染色指针”更合适还是“彩色指针”更合适呢?

    • 染色指针(colored pointer) 是指向堆中对象的指针,它除了对象的内存地址外,还包含编码对象已知状态的元数据。这些元数据描述了对象是否被认为是活着的、地址是否正确,等等。ZGC 始终使用 64 位对象指针,因此堆的容量上限高达数 TB 。ZGC 使用染色指针来实现 对象中的一个字段对另一个对象的引用。
    • 读屏障(load barrier) 是ZGC 向应用程序注入的一段代码,当应用程序读取一个对象的字段对另一个对象的引用时,就会运行读屏障注入的代码。读屏障负责解释、使用那些存储在对象字段中的染色指针上的元数据,并可能在应用程序使用这个引用的对象之前进行一些操作。
    • 写屏障(store barrier) 是ZGC 向应用程序注入的一段代码,当应用程序把一个引用存储到一个对象的字段里时,就会运行写屏障注入的代码。分代ZGC在染色指针中添加了新的元数据位,这使得写屏障能够确定写入的字段是否包含潜在的跨代指针。染色指针使分代ZGC的写屏障比传统的分代写屏障更高效。

    非分代ZGC与分代ZGC:

    • 非分代ZGC使用内存多重映射技术,而分代ZGC不使用

      • 非分代ZGC使用内存多重映射技术来减少读屏障的开销

    分代ZGC引入写屏障后的相关优化:

    • Fast paths and slow paths

      • 屏障做的工作可以分为fast paths、slow paths
      • fast paths包含GC相关的必要工作,因此把屏障代码直接插入即时编译后的应用程序代码中,确保运行速度够快
      • slow paths只需要在少部分时候执行,因此不需要高度优化。通过染色指针中的一个标记位,保证短时间内不会重复触发一个对象的slow path工作。
    • 最小化读屏障的职责(读屏障触发的频率比写屏障的高非常多,因此读屏障对JVM性能的影响更大。把读屏障的工作转移给写屏障可以提升性能。)

      • 非分代ZGC的读屏障用于

        • 当GC移动对象时,更新指向这些对象的旧指针
        • 标记存活对象。(如果JVM刚刚加载了某对象,那么这个对象当然就可以被读屏障标记为存活。)
      • 分代ZGC的读屏障用于

        • 移除染色指针的元数据位
        • 当GC移动对象时,更新指向这些对象的旧指针
      • 分代ZGC的写屏障用于

        • 添加元数据位,从而创建染色指针
        • 维护记忆集(Remembered Set),处理跨代引用
        • 标记存活对象
    • Remembered-set barrier

      • Young GC时需要访问存储old-to-young跨代引用的老年代对象字段的原因:

        • 作为GC Roots的一部分。避免遍历全对象图,同时确保Young GC的正确。
        • 更新老年代过时指针。GC时会移动对象,原本指向这些移动对象的指针并不会即时更新,而是采用“懒更新”策略,当应用程序读取这些引用时,读屏障将会更新这些过失的旧指针。在一些情况下(比如:Full GC),ZGC必须更新全部过时指针。
      • 字段中包含old-to-young跨代引用的老年代对象字段地址的集合称为remembered set(记忆集)。

      • 写屏障负责为remembered set添加数据(entries)

      • 当一个引用被写入到对象字段中时,这个引用被认为可能是跨代引用。写屏障的slow path会过滤掉年轻代对象的字段,因为我们只需要老年代对象字段。slow path不会根据写屏障拦截到的写入的引用来判断是否属于跨代引用,而是在remembered set被使用的时候,再根据这个被写入的字段当前存储的引用来判断。

      • 写屏障维护记忆集的单次操作特性:两次连续的Young GC标记阶段之间,对于每一个存储字段,只会执行一次写屏障的slow path。一个字段第一次被写入时,进行如下步骤:

        1. fast path检查字段中将要被覆盖的原始值
        1. 将要被覆盖的引用指针上的颜色表明 自前一次Young GC标记阶段以来,这个字段没有被写入
        1. 执行slow path
        1. 发现是跨代引用,字段地址加入到记忆集中
        1. 跨代引用指针被染色,然后存储到字段中。这样以后的fast path就会知道这个字段已经执行过slow path了。
    • SATB marking barrier

      • 与非分代ZGC不同的是,分代ZGC使用SATB算法。在并发标记期间,删除从灰色对象到白色对象到引用时,写屏障会记录这个白色对象。同样的,为了性能,对于每一个被覆盖的存储在字段中的引用,只会在第一次通过slow path被记录,不会重复记录。
    • Fused store barrier checks

      • ZGC把记忆集相关的fast path和GC标记阶段的fast path融合为一个fast path,同样的,其中一个部分失败了就执行slow path。
    • Store barrier buffers

      • 将屏障分为快路径和慢路径,并使用指针着色,减少了对 C++ 慢路径函数的调用次数。分代 ZGC 通过在快路径和慢路径之间设置一个 JIT 编译的中间路径(medium path),进一步降低了开销。
      • medium path:把被覆盖的值和对象字段地址存储到写屏障缓冲区(store barrier buffers)中,然后就直接回到应用程序的代码中,不做额外操作。只有当写屏障缓冲区满时,才会执行slow path。
      • 思想类似G1的dirty car queue
    • Barrier patching

      • 有一些JVM内部变量(如用于指明当前的GC阶段)存储在全局变量或线程局部变量中,当GC阶段发生改变,这些变量值也会随之改变。写屏障和读屏障都会检查、使用这些变量。然而,在不同的CPU架构中检查这些变量的开销是不同的,开销可能很大。
      • 分代ZGC为了进一步减少读、写屏障带来的开销,采用了屏障修补(Barrier patching)。那些全局/线程局部变量中的数值会被机器指令编译为立即数(immediate values),GC切换阶段后的第一次调用方法时,ZGC会“修补”屏障代码,从而修改这些立即数,这意味着屏障代码运行时不需要去查找全局/线程局部变量的数值,而是可以直接使用正确的数值。这进一步提升了性能。

    Double-buffered remembered sets

    很多GC会采用卡表(card table)技术作为记忆集(remembered set)的实现。典型的做法是:每一张卡页(page)用一个byte来表示堆的一部分空间(通常为512 bytes)内是否存在跨代引用。然后在young gc时,遍历卡表,找到全部old-to-young引用指针。

    相比之下,ZGC则采用位图(bitmap)来精确记录存储对象字段的地址,bitmap中每一个bit代表了一个潜在的对象字段地址。每一个老年代region都有连个的记忆集位图,其中一个位图总是活动的,并在应用线程的写屏障中得到更新,另一个位图是“只读”的拷贝版本,当Young GC开始时,ZGC会把两个位图进行交换,然后用其中那个只读拷贝版本的位图来补充gc roots。

    bitmap实现记忆集的优点:应用线程不需要等待位图被清空,因为当gc线程清理其中那个拷贝版本的位图的同时,应用线程操作的是另一个不同的位图,应用线程和gc线程能够互不干扰的工作,因此不需要在两种类型的线程之间插入额外的内存屏障,而其他使用卡表标记的GC,比如G1,需要额外的内存屏障,从而可能导致较差的性能。

    Relocations without additional heap memory

    ZGC把垃圾回收分为2个大体的步骤:标记可达对象、重定向对象。

    由于进行重定向时,ZGC能够得到存活对象充分的信息,ZGC可以根据每个region的细粒度划分重定向工作。ZGC采用复制算法,把一个region存活对象都重定向到另一个空闲的region,清空原region。在没有空闲region可用的情况下,ZGC会选择使用当前region进行标记压缩。

    Dense heap regions

    在将年轻代对象进行重定向的时候,各个region的存活数量和它们的所占内存大小可能不同,例如:最近分配的region通常包含更多的存活对象。

    ZGC会分析年轻代region的稠密度(density),如果密度比较高,说明region可能存在大量的存活对象,对这个region进行垃圾回收的成本高收益低,那么ZGC会选择在本次Young GC中不清理这个region。可能会选择让这个region中的对象全部老化。这大大减少了Young GC的工作量。

    Large objects

    ZGC 已经很好地处理了大对象。通过将虚拟内存与物理内存解耦并过度保留虚拟内存,ZGC 通常可以避免由内存碎片化导致的难以分配大对象的问题(相比之下,G1会遇到这种问题)。

    分代 ZGC 更进了一步:允许在年轻代中分配大对象。由于region可以在不进行重定位的情况下进行老化,因此不必进行开销较大的重定位,就可以直接将大对象划分为老年代。相反,如果大对象是短命的,它们可以在年轻代中被收集;如果是长寿命的,则可以以较低的成本晋升到老年代。

    Full garbage collections

    年轻代中的有的对象会指向老年代中的对象,当Old GC时,这些指针被视为老年代对象图的根。年轻代中的对象经常发生变化,因此年轻到老年代的指针不会被追踪。相反,这些指针通过在老年代标记阶段运行年轻代收集来找到。当年轻代收集发现指向老年代的指针时,会将它们传递给老年代的标记过程。

    这一额外的年轻代收集仍然会像正常的年轻代收集一样执行,并将存活的对象留在存活区域。这带来的一个影响是,年轻代中存活的对象在收集老年代时不会经过引用处理和类卸载。例如,如果一个应用程序释放了对象图的最后一个引用,调用了 System.gc(),然后期望某个弱引用被清除或进入引用队列,或者某个类被卸载,这种情况便会显现出来。为了解决这个问题,当应用程序代码显式请求 GC 时,在老年代收集开始之前,会先执行额外的年轻代收集,以将所有存活对象晋升到老年代。

    GC日志分析

    JVM OptionsDescription
    -XX:+PrintGC输出GC日志
    -XX:+PrintGCDetails输出GC详细日志
    -XX:+PrintGCTimeStamps输出GC时间戳(基准时间形式)
    -XX:+PrintGCDateStamps输出GC时间戳(如:2024-04-08T23:50:59.234+0800)
    -XX:+PrintHeapAtGC在GC前后打印堆信息
    -Xloggc:../logs/gc.log日志文件输出路径

    示例说明:

    [GC (Allocation Failure) [PSYoungGen: 21006K->968K(29696K)] 21006K->17360K(98304K), 0.0121642 secs] [Times: user=0.00 sys=0.02, real=0.01 secs] 
    
    条目示例示例描述
    GC原因Allocation Failure(新生代)分配对象失败
    垃圾回收器及分区PSYoungGenParallel Scavenge GC,新生代
    GC前分区内存占用21006K
    GC后分区内存占用968K
    分区总内存占用29696K新生代总共内存有29696K
    GC前堆内存占用21006K
    GC后堆内存占用17360K
    堆总内存占用98304K堆区总共内存有98304K
    GC耗时0.0121642 secs
    GC用户耗时user=0.00
    GC系统耗时sys=0.02
    GC实际耗时real=0.01

    GC日志分析工具:GCViewer、GCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等

    专有名词

    • JVM、JDK、JRE
    • Garbage Collection
    • Class File
    • Class Loader
    • Runtime Data Area
    • Execution Engine
    • Native Method Interface
    • Native Method Library
    • Heap
    • Method Area
    • Native Method Stack
    • JVM Stack
    • Program Counter Register
    • HotSpot、JRockit、J9
    • 基于栈的指令集架构、基于寄存器的指令集架构
    • DNA元数据模版
    • JNI(Java Native Interface)规范
    • Load, Link, Initialization
    • Verify, Prepare, Resolve
    • clinit, init
    • Bootstrap ClassLoader
    • User-Defined ClassLoader
    • Extension ClassLoader
    • System ClassLoader
    • Application ClassLoader
    • Java的核心库
    • loadClass()与findClass()
    • 双亲委派机制
    • SPI(Service Provider Interface)
    • 热部署、热替换
    • 沙箱安全机制
    • 动态链接
    • Runtime与JVM
    • Java线程
    • 后台线程
    • 虚拟机线程、周期任务线程、GC线程、编译线程、信号调度线程
    • 中断
    • CAS
    • 偏向锁
    • 轻量级锁
    • 重量级锁
    • StackOverflowError
    • OutOfMemoryError
    • Stack Frame
    • Current Frame
    • Current Method
    • Current Class
    • Local Variables
    • Operand Stack
    • Dynamic Linking
    • Return Address
    • slot
    • Maximum local variables
    • 局部变量和成员变量
    • Top-of-StackCaching
    • Current Class Constant Pool Reference
    • 动态类型语言和静态类型语言
    • invokestatic, invokespecial, invokevirtual, invokeinterface, invokedynamic
    • 动态分派
    • 虚方法表
    • 异常完成出口
    • 异常处理表
    • 逃逸分析
    • 栈上分配
    • 同步省略
    • 标量替换
    • 分代收集Generational Collection
    • Tenure/Old
    • Eden
    • Surviver 0, Surviver 1, From, To
    • Promotion
    • Young GC/Minor GC
    • Major GC/Old GC
    • Full GC
    • Mixed GC
    • TLAB
    • Bump The Pointer
    • Free List
    • Header, Instance Data, Padding
    • Mark Word, Klass Pointer
    • 句柄访问
    • 直接指针
    • Metaspace
    • PermGen
    • 常量池
    • 运行时常量池
    • 半编译半解释型
    • 前端编译、后端编译
    • Interpreter
    • JIT Compiler
    • 分层编译(Tiered Compilation)策略
    • 热点代码、热点探测
    • Invocation Counter
    • Back Edge Counter
    • Counter Decay
    • Counter Half Life Time
    • System.gc()
    • Finalization机制
    • stop-the-world
    • Safepoint, Safe Region
    • Strong Reference, Soft Reference, Weak Reference, Phantom Reference
    • FinalReference
    • 引用计数
    • 可达性分析
    • GC Root
    • Remember Set
    • Card Table, Card Page
    • Tri-color Marking
    • Mark-Sweep
    • Copying
    • Mark-Compact
    • Serial GC、Serial Old GC
    • ParNew
    • Parallel Scavenge GC、Parallel Scavenge Old GC
    • CMS
    • Concurrent Mode Failure
    • G1
    • Dirty Card Queue
    • colored pointer, load barrier, store barrier
    • multi-mapped memory