1

Java进阶 JVM 内存与垃圾回收篇(一) - Xi-iX

 2 years ago
source link: https://www.cnblogs.com/XiiX/p/16129539.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Java进阶 JVM 内存与垃圾回收篇(一)

1.1 什么是JVM?

定义

  • Java Vritual Machine - java 程序的运行环境(Java二进制字节码的运行环境)

好处

  • 一次编译 ,到处运行
  • 自动内存管理,垃圾回收功能
  • 数据下标越界越界检查

比较

Jvm vs Jre vs JDK

image-20220402212538978

1.2 学习路线

本文主要讲解的是HotSpot VM

HotSpot VM 是目前市面上高性能虚拟机的代表作之一,采用解释器与即时编译器并存的架构

学习主要分为三个部分

此文为第一篇

  1. 内存与垃圾回收篇

    • JVM概述

    • 类加载过程

    • 运行时数据区

    • 内存的分配与回收

  2. 字节码与类的加载篇

  3. 性能调优篇

image-20220408214120874
image-20220402213154596

1.3 Java代码执行流程

image-20220405143914929

1.4 JVM的架构模型

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

这两种架构之间的区别:

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

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

1.5 JVM的生命周期

虚拟机的启动

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

虚拟机的执行

  • 一个运行中的Java虛拟机有着一个清晰的任务:执行Java程序。
  • 程序开始执行时他才运行,程序结束时他就停止。
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。

虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exi t或halt操作。
  • 除此之外,JNI ( Java Native Interface) 规范描述了用JNI Invocation API来加载或卸载Java虛 拟机时,Java虚拟机的退出情况。

1.6 JVM发展历程

Sun Classic VM

早在1996年Java1.0版本的时候,Sun公司发布了一款名为Sun Classic VM的Java虚拟机,它同时也是世界上第一款商用Java虚拟机,JDK1.4时完全被淘汰。

这款虚拟机内部只提供解释器。

如果使用JIT编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。

现在hotspot内置了此虚拟机。

Exact VM

为了解决上一个虚拟机问题,jdk1.2时,sun提供了此虚拟机。

Exact Memory Management:准确式内存管理

  • 也可以叫Non-Conservative/Accurate Memory Management

  • 虚拟机可以知道内存中某个位置的数据具体是什么类型。

具备现代高性能虚拟机的雏形

  • 编译器与解释器混合工作模式

只在Solaris平台短暂使用,其他平台上还是classic vm

  • 英雄气短,终被Hotspot虚拟机替换

HotSpot VM

HotSpot历史

  • 最初由一家名为“Longview Technologies"的小公司设计
  • 1997年,此公司被Sun收购; 2009年,Sun公司被甲骨文收购。
  • JDK1.3时,HotSpot VM成为默认虚拟机

目前Hotspot占有绝对的市场地位称霸武林。

  • 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是
    HotSpot
  • Sun/Oracle JDK和OpenJDK的默 认虚拟机
  • 因此本课程中默认介绍的虚拟机都是Hotspot,相关机制也主要是指HotSpot的GC机
    制。(比如其他两个商用虛拟机都没有方法区的概念)

从服务器、桌面到移动端、嵌入式都有应用。

名称中的HotSpot指的就是它的热点代码探测技术。

  • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
  • 通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡

BEA的JRockit

  • 专注于服务器端应用

    • 它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码
      都靠即时编译器编译后执行。
  • 大量的行业基准测试显示,JRockit JVM是 世界上最快的JVM。

    • 使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达50%) 。
  • 优势:全面的Java运行时解决方案组合

    • JHlockit面向延迟敏感型应用的解决方案JRockit Real Time提供以亳秒或
      微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要

    • MissionContro1服务套件,它是一组以极低的开销来监控、管理和分析生产
      环境中的应用程序的工具。

  • 2008年,BEA被Oracle收购。

  • Oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合的方式是在Hotspot的基础上,移植JRockit的优秀特性。

  • 高斯林:目前就职于谷歌,研究人工智能和水下机器人

IBM的 J9

  • 全称: IBM Technology for Java Virtual Machine,简称IT4J,内部代号: J9

  • 市场定位与Hotspot接近,服务器端、桌面应用、嵌入式等多用途VM

  • 广泛用于IBM的各种Java产品。

  • 目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机。

  • 2017年左右,IBM发布了开源J9 VM,命名为openJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9

2. 类加载子系统

将描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为虚拟机的类加载机制。

image-20220405152932481
image-20220405153433382

类的生命周期

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为链接(Linking)

image-20220411111726350

类加载器子系统的作用

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

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

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

ClassLoader角色

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

2.1 类的加载过程

image-20220405153639661

加载

  1. 通过一个类的全限定名预取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的java. lang.Class对象,作为方法区这个类的各种数据的访问入口

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体 数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口。

链接

  • 验证(Verify)

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

    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

  • 准备(Prepare)

    • 为类变量(即静态变量,static修饰的)分配内存并且设置该类变量的默认初始值,即零值。

    • 这里不包含用final修饰的static, 因为final在编译的时候就会分配了

    • 这里不会为实例变量分配初始化(类还未加载),类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

  • 解析(Resolve)

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

      • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

      • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

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

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

初始化

直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

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

    • <clinit>()此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作静态代码块中的语句(static{}块)合并而来。类变量指的是static修饰的变量,未用static修饰的是实例变量。

      编译器收集的顺序是由语句在源文件中出现的顺序决定的

      public class Test{
      	static {
      		a = 10; // 可以赋值
              System.out.println(a); // 非法前向引用,不能访问
      	}
          static int a = 9; // a初始化为9,因为9的赋值晚于10
      }
      
  • 此方法不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,就不会生成

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

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

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

2.2 类加载器的分类

Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。

在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示:

image-20220411114908811

这四者之间的关系是包含关系,不是上下级关系,也不是继承关系。

启动类加载器

Bootstrap ClassLoader

  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部。

  • 它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar.resources. jar或sun. boot . class.path路径下的内容) , 用于提供JVM自身需要的类

  • 并不继承自java. lang. ClassLoader,没有父加载器。

  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。

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

扩展类加载器

Extension ClassLoader

  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。

  • 派生于ClassLoader类

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

  • 从java .ext . dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/]ib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

应用程序类加载器

System ClassLoader

  • java语言编写,由sun.misc. Launcher$AppClassLoader实现

  • 派生于ClassLoader类

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

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

  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载

  • 通过ClasLoader#getSystemClassLoader()方法可以获取到该类加载器

用户自定义类加载器实现步骤

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

ClassLoader

是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)

2.3 双亲委派机制

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

工作原理

  1. 如果一个类加载器收到了类加载请求它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行
  2. 如果父类加载器还存在其父类加,载器,则进一步向上委托,依次递归,请求最终将到达项层的启动类加载器
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
image-20220407212143233

优势

  1. 避免类的重复加载
  2. 保护程序安全,防止核心API被篡改
    • 自定义 java.lang.String

沙箱安全机制

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

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

3. 运行时数据区

image-20220405165020849
image-20220405165150523
  • 红色区域:线程共享
  • 灰色区域:线程私有

Class Runtime:一个Java程序只有一个Runtime实例

3.1 程序计数器

Program Counter Register(PC寄存器)

JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

介绍

  • 一块很小的内存空间,几乎可以忽略不记,运行速度最快的存储区域
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

作用

  • 用来存储指向下一条JVM指令的地址

特点

  • 线程私有,与线程生命周期一致
  • 此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

问题

  1. PC寄存器有什么用?

    因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

    JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

  2. 为什么设定为线程私有?

    为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

    由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

3.2 虚拟机栈

Java Virtual Machine Stacks (Java 虚拟机栈)

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

定义

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

  • 是线程私有的,生命周期与线程一致

  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法(栈的顶部)

  • 主管Java程序的运行,保存方法的局部变量(基本数据类型,引用类型的地址)、部分结果,并参与方法的调用和返回

    • 栈溢出(StackOverflowError, OutOfMemoryError)
    • 垃圾回收不涉及栈
    • 栈内存分配不是越大越好
    • 方法内的局部变量是否线程安全?
      • 如果只有一个线程才可以操作此数据,则是线程安全的
      • 如果多个线程操作此数据,则此数据是共享数据,如果不考虑同步机制,会存在线程安全问题
      • 如果方法内局部变量没有逃离方法的作用访问,则安全
      • 外部传入或者返回到外部,则不安全

特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  • 只存在两种操作:入栈和出
  • 不存在垃圾回收问题

异常

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

    • 如果采用固定大小的栈,可能会出现StackOverflowError异常

      栈帧过多导致栈内存溢出、栈帧过大

      • 递归循环调用
    • 如果采用动态扩展的栈,可能会出现OutOfMemoryError异常

栈的存储单位

  • 栈中的数据以栈帧的格式存在,每个方法对应一个栈帧
  • 栈帧是一个内存区块,是一个数据集
  • 内部结构
    • 局部变量表(Local Variables)
    • 操作数栈(Operand Stack)
    • 动态链接(Dynamic Linking)(指向运行时常量池的方法引用)
    • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
    • 一些附件信息

局部变量表

  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用,以及returnAddress类型
  • 局部变量所需的容量大小是在编译期间确定下来的
  • 最基本的存储单元是Slot(变量槽)32位的类型占一个slot,64位的类型占用两个slot
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
  • 栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

操作数栈

  • 在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈、出栈
  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈,并更新PC寄存器中下一条需要执行的字节码指令
  • Java虚拟机的解释引擎是基于栈的执行引擎,栈就是操作数栈
  • 由于操作数是存储在内存中,因此频繁地执行内存读写会影响执行速度。为了解决这个问题,HotSpot JVM的设计者提出了栈顶缓存(ToS,Top-of-stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。

代码实例

public class operandTest {
    public void test() {
        byte i = 15;
        int j = 8;
        int k = i + j;
    }
}
image-20220406175204135
image-20220406175217291
image-20220406175339245
image-20220406175352815

动态链接

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking) 。比如: invokedynamic指令
  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

方法的调用

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

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

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

虚拟机中提供了以下几条方法调用指令:

  • 普通调用指令:

    1. invokestatic: 调用静态方法,解析阶段确定唯一方法版本

    2. invokespecial: 调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本

    3. invokevirtual: 调用所有虚方法

    4. invokeinterface: 调用接口方法

  • 动态调用指令:

    1. invokedynamic: 动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。

动态类型语言静态类型语言两者的区别:

就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

Lambda的引入使得Java具备了动态类型语言的特性。总体来说还是静态。

方法返回地址

  • 存放调用该方法的pc寄存器的值
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

线程运行诊断

  1. CPU占用过高

    • 用top定位哪个进程对cpu的占用过高
    • ps H -eo pid,tid,%CPU | grep pid
    • jstack pid
      • 可以根据线程id找到有问题的线程,进一步定位到问题代码的源码行号
  2. 程序运行很长时间没有结果

    使用jstack pid查看进程的运行情况,可发现死锁

    程序发生了死锁

3.3 本地方法栈

Native Method Stacks

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。也是线程私有的。

image-20220407103932282

Native Method

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

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

在定义一个native method时,并不提供实现体(有些像定义一个Java interface,因为其实现体是由非java语言在外面实现的。本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序 。

为什么要使用Native Method?

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  1. 与Java环境外交互
    有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。

  2. 与操作系统交互
    JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM**的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。

  3. Sun's Java
    Sun的解释器是用c实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java. lang.Thread的setPriority() 方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows 95的平台上,这个本地方法最终将调用win32 SetPriority() API. 这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library) 提供,然后被JVM调用。

  4. 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用web Service等等,不多做介绍。

概述

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

    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区

    • 它甚至可以直接使用本地处理器中的寄存器

    • 直接从本地内存的堆中分配任意数量的内存。

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

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

heap 线程共享

概述

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。

    • 堆内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB) 。

  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for a1ll class instances and arrays is allocated )

    • 我要说的是:“几乎” 所有的对象实例都在这里分配内存。从实际使用角度看的。
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆,是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

image-20220407113547839

通过new关键字,创建对象都会使用堆内存

内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

image-20220407114212304
image-20220407114342044

设置参数

  1. Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和" -Xms"来进行设置。

    -Xms"用于表示堆区的起始内存,等价于-XX: InitialHeapSize
    -Xmx"则用于表示堆区 的最大内存,等价于-XX :MaxHeapSize

    一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。

  2. 通常会将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

  3. 默认情况下,初始内存大小:物理电脑内存大小/64

    最大内存大小:物理电脑内存大小/4

  4. 查看设置的参数:

    • 方式一:jps / jstat -gc 进程id
    • 方式二:-XX:+PrintGCDetails

年轻代与老年代

  • 存储在JVM中的Jaya对象可以被划分为两类:

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速

    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期
      保持一致。

  • Java堆区进一步细分的话, 可以划分为年轻代(YoungGen) 和老年代(0ldGen)

  • 其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做
    from区、to区)

  • 新生代与老年代的比例:NewRatio 默认是1:2

  • Eden与survivor区的比例:SurvivorRatio默认是8:1:1

  • 几乎所有的对象都是在Eden区被new出来

  • 绝大部分的Java对象的销毁都在新生代进行了

image-20220407140220066

图解对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分
配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考
虑GC执行完内存回收后是否会在内存空间中停生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃
    圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。
  3. 再加载新的对象放到伊甸园区然后将伊甸园中的剩余对象移动到幸存者0区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会
    放到幸存者1区.
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是15次。
    可以设置参数: -XX :MaxTenudingThreshold=<N>进行设置。
  7. 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区\元空间收集
image-20220407142008557
image-20220407142511120

Minor GC Major GC Full GC

JM在进行GC时,并非每次都对上面三个内存区域(新生代老年代、方法区)一起回收的,大部分时候回收的都是指新生代。

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集
(Partial GC), 一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个Jva堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集

    • 老年代收集(Major GC/old GC):只是老年代的垃圾收集。

      • 目前,只有CMS GC会有单独收集老年代的行为。
      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。

      • 目前,只有G1 GC会有这种行为
  • 整堆收集(Full GC):收集整个iava堆和方法区的垃圾收集。

年轻代GC(Minor GC )触发机制

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次 Minor GC会清理年轻代的内存。)

  • 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。

  • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

老年代GC (Major GC/Full GC)触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了。

  • 出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。

    • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。

  • 如果Major GC后,内存还不足,就报OOM了。

  • Major GC的速度一般会比Minor GC慢10倍以上。

Full GC触发机制: (后面细讲)

触发Full GC执行的情况有如下五种:

  1. 调用System. gc()时,系统建议执行Full GC,但是不必然执行

  2. 老年代空间不足

  3. 方法区空间不足

  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存

  5. 由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些。

堆空间分代思想:分代的唯一理由是优化GC性能,避免每次GC都要扫描每个对象

内存分配策略

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden

  • 大对象直接分配到老年代

    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代

  • 动态对象年龄判断

    • 如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保

    • -XX: HandlePromotionFailure

TLAB(Thread Local Allocation Buffer)

为对象分配内存

为什么需要TLAB?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据

  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

定义

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。

  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

  • 所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

image-20220407150606644

说明

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为
    内存分配的首选

  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。

  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通
    过选项“-XX :TLABWasteTargetPercent"设置TLAB空间所占用Eden空间的百分比大小。

  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操
    作的原子性,从而直接在Eden空间中分配内存。

image-20220407150938551

堆空间的参数设置

官网说明书

  • -XX:+PrintFlagsInitial :查看所有的参数的默认初始值

  • -XX: +PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)

    • 具体查看某个参数的指令:
      • jps 查看运行进程
      • jinfo -flag SurvivorRatio 进程id
  • -Xms: 初始堆空间内存 (默认为物理内存的1/64)

  • -Xmx: 最大堆空间内存(默认为物理内存的1/4)

  • -Xmn: 设置新生代的大小。(初始值及最大值)

  • -xx:NewRatio: 配置新生代与老年代在堆结构的占比

  • -XX:SurvivorRatio: 设置新生代中Eden和S0/S1空间的比例

  • -XX:MaxTenuringThreshold: 设置新生代垃圾的最大年龄

  • -Xx:+PrintGCDetails: 输出详细的GC处理日志

    打印gc简要信息: -XX:+PrintGC 或者 -verbose:gc

  • -XX:HandlePromotionFailure: 是否设置空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于, 则此次Minor GC是安全的

  • 如果小于,则虚拟机会查看-Xx: HandlePromot ionFailure设置值是否允许担保失败。

    • 如果HandlePromotionFailure=true, 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。

      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;

      • 如果小于,则改为进行一次Full GC。

    • 如果HandlePromotionFailure=false, 则改为进行一次Full GC。

在JDK6 Update24之 后,HandlePromotionFailure参数不会再影响到虛拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

堆是分配对象存储的唯一选择吗?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

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

逃逸分析

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

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

  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。

  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

能使用局部变量的,就不要使用在方法外定义

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

  2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

    线程同步的代价是相当高的,同步的后果是降低并发性和性能。在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

    标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

    相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

    在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就
    会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

3.5 方法区

image-20220407155839861

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

image-20220407160144488
image-20220407160359718

概述

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap (非堆),目的就是要和堆分开。所以,方法区看作是一块独立于Java堆的内存空间

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。

  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。

  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误: java.lang.OutOfMemoryError: PermGen space或者java.lang.OutOfMemoryError: Metaspace

  • 关闭JVM就会释放这个区域的内存。

image-20220407162225735
  • 到了JDK 8,终于完全废弃了永久代的概念,改用与JRorkit、J9一样在本地内存中实现的元空间(Metaspace)来代替

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

  • 永久代、元空间二者并不只是名字变了,内部结构也调整了。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。

设置方法区大小与OOM

jdk8及以后:

  • 元数据区大小可以使用参数-XX : MetaspaceSize-XX :MaxMetaspaceSize指定,替代上述原有的两个参数。

  • 默认值依赖于平台。windows下,-XX :MetaspaceSize是21M, -XX:MaxMetaspaceSize的值是-1,即没有限制。

  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。
    如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace

  • -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活) ,然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

  • 如果初始化的高水位线设置过低,上述高水位线调整情况会 发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX :MetaspaceSize设置为一个相对较高的值。

如何解决这些OOM?

  1. 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory Overflow)
  2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms) ,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

方法区存储信息

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下: 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

image-20220407163831208
image-20220407163957677
  1. 对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
    ①这个类型的完整有效名称(全名=包名.类名)
    ②这个类型直接父类的完整有效名(对于interface或是java. lang .0bject,都没有父类)
    ③这个类型的修饰符(public, abstract, final的某个子集)
    ④这个类型直接接口的一个有序列表

  2. 域(Filed)信息

    JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

    域的相关信息包括:

    • 域名称、域类型、域修饰符(public, private,
    • protected, static, final, volatile, transient的某个子集)
  3. JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

    • 方法的返回类型(或volid)

    • 方法参数的数量和类型(按顺序)

    • 方法的修饰符(public, private, protected, static, final,synchronized, native, abstract的一个子集)

    • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

    • 异常表( abstract和native方法除外)

      每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,之前有介绍。

比如下列代码:

public class SimpleClass {
	public void sayHello() {
		System.out.println("hello");
	}
}

虽然只有194字节,但是里面却使用了string、System、 PrintStream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多! 这里就需要常量池了!

几种在常量池内存储的数据类型包括:

例如下面这段代码:

public class MethodAreaTest2 {
	public static void main(String[] args) {
		object obj = new object();
}

object foo = new object () ;

将会被编译成如下字节码:

0:new #2           // Class java/ lang/ object
1:dup
2:invokespecial #3 // Method java/ lang/object "<init>"( ) v

概述

  • 运行时常量池( Runtime Constant Pool) 是方法区的一部分。

  • 常量池表(Constant Pool Table)是Class文件的一 部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。

  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

    • 运行时常量池,相对于Class文件常量池的另一重要特征是: 具备动态性
  • 运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。

  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛outOfMemoryError异常。

方法区的演进细节

image-20220407172301308
image-20220407172741287
image-20220407172753421
image-20220407172813073

为什么要使用元空间?

随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace )。

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因有:

  1. 为永久代设置空间大小是很难确定的。

    在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

    Exception in thread dubbo client x.x connector' java.lang.OutOfMemoryError: PermGenspace

    而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

  2. 对永久代进行调优是很困难的。

StringTable为什么要调整?

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

垃圾回收

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》 对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时 期的zGC收集器就不支持类卸载)。

般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的Hotspot虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型

先来说说方法区内常量池之中主要存放的两大类常量:字面量符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、 JSP的重加载等,否则通常是很难达成的。

  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象-一样,没有引用了就必然会回收。关于是否要对类型进行回收, HotSpot虛拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose: class以及-XX:+TraceClass-Loading-X:+TraceClassUnLoading查看类加载和卸载信息

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及0SGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

3.6 小结

image-20220407160048630

3.7 面试题

三面:说一下JVM内存模型吧,有哪些区?分别干什么的?

蚂蚁金服:

Java8的内存分代改进

JVM内存分哪几个区,每个区的作用是什么?

一面: JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?

二面: Eden和Survior的比例分配

jvm内存分区,为什么要有新生代和老年代

字节跳动: .

二面: Java的内存分区

二面:讲讲jvm运行时数据库区

什么时候对象会进入老年代?

JVM的内存结构,Eden 和Survivor比例。

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

一面: Jvm内存模型以及分区,需要详细到每个区放什么。

一面: JVM的内存模型,Java8做了什么修改

JVM内存分哪几个区,每个区的作用是什么?

java内存分配

jvm的永久代中会发生垃圾回收吗?

一面: jvm内存分区,为什么要有新生代和老年代?

4. 对象的实例化

image-20220408150103779

4.1 创建对象的步骤

image-20220408150442276
  1. 加载类元信息

    虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象

  2. 为对象分配内存

    首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。

    指针碰撞:如果内存是规整的,那么虚拟机将采用的是指针碰撞法( Bump The Pointer )来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一股使用带有compact (整理)过程的收集器时,使用指针碰撞。

    如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法
    来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为"空闲列表(Free List)。

    选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有
    压缩整理功能决定。

  3. 处理并发安全问题

  4. 属性的默认初始化

  5. 设置对象头的信息

    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、 锁信息等数据存储
    在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

  6. 属性的显式初始化、代码块初始化、构造器初始化

    在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespecial指令所决定), new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

4.2 对象的内存布局

image-20220408151923768

内存布局

  1. 对象头(Header)

    • 运行时元数据

      • hashcode
      • GC分代年龄
      • 锁状态标志
      • 线程持有的锁
      • 指向类元数据,确定该对象的类型
  2. 实例数据(Instance Data)

  3. 对齐填充(Padding)

实例

public class CustomerTest {
	public static main(String[] args) {
        Customer cust = new Customer();
    }
}
image-20220408152315936

4.3 对象访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?

image-20220408153026814

对象访问的两种方式

  1. 好处:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。

    image-20220408153230370
  2. 直接指针(hotspot JVM采用)

    image-20220408153345499

5. 直接内存

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

  • 直接内存是在Java堆外的、直接向系统申请的内存区间。

  • 来源于NIO(New IO/ Non-Blocking IO),通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。

    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。

    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER)
// 直接分配本地内存空间

使用IO读取文件

image-20220408154216728

使用NIO读取文件

image-20220408154602339

概述

  • 也可能导致OutOfMemoryError异常:Direct Buffer memory

  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

    • 分配回收成本较高
    • 不受JVM内存回收管理
  • 直接内存大小可以通过MaxDirectMemorySize设置

  • 如果不指定,默认与堆的最大值-Xmx参数值一致

6. 执行引擎

image-20220408160831831

6.1 概述

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

  • “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

  • JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。

  • 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。(JIT编译称为后端编译,生成字节码称为前端编译)

执行引擎的工作过程

image-20220408161602942

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的: 输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

6.2 Java代码编译和执行

image-20220408161755415

黄色对应Javac、绿色是解释器、蓝色是编译器

image-20220408161943202

什么是解释器(Interpreter) ,什么是JIT编译器?

  • 解释器: 当Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

  • JIT (Just In Time Compiler) 编译器: 就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言

为什么说Java是半编译半解释型语言?

  • JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。

  • 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

image-20220408162438016

拓展

  • 机器码

    • 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
    • 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
    • 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。
    • 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。
  • 指令

    • 由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。
    • 指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写, 如mov, inc等),可读性稍好
    • 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
  • 指令集

    • 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
    • 如常见的
      • x86指令集,对应的是x86架构的平台
      • ARM指令集,对应的是ARM架构的平台
  • 汇编语言

    • 由于指令的可读性还是太差,于是人们又发明了汇编语言。
    • 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol) 或标号(Label) 代替指令或操作数的地址。
    • 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
      • 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
  • 高级语言

    • 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
    • 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序编译程序
image-20220408163120675
  • 字节码
    • 字节码是一种中间状态(中间码)的二进制代码(文件) ,它比机器码更抽象,需要直译器转译后才能成为机器码
    • 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关
    • 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
      • 字节码的典型应用为Java bytecode

6.3 解释器

image-20220408163535401

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

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。

  • 而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

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

      • Interpreter模块: 实现了解释器的核心功能

      • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令

现状

  • 由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、 Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C++程序员所调侃。
  • 为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
  • 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

6.4 JIT编译器

Hotspot IVM是目前市面上高性能虚拟机的代表作之一。 它采用解释器与即时编译器并存的架构。在Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

既然HotSpot VM中已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?

比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

  • 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。 但编译为本地代码后,执行效率高。

  • 尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java虛拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

  • 同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

概念

  • Java 语言的“编译期” 其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“ 编译器的前端”更准确一些)把.java文件转变成.class文件的过程;

  • 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。

  • 还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler) 直接把.java文件编译成本地机器代码的过程。

image-20220408170648641

热点代码及探测方式

当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用。的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

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

  • 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值, JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能

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

  • 采用基于计数器的热点探测,HotSpot VM将会为每一个 方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter) 。

    • 方法调用计数器用于统计方法的调用次数

    • 回边计数器则用于统计循环体执行的循环次数

方法调用计数器

  • 这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。

  • 这个阈值可以通过虚拟机参数-XX :CompileThreshold来人为设定。

  • 当一个方法被调用时,会先检查该方法是否存在被JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

image-20220408171348256
  • 热度衰减

    • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)

    • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-Xx:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。

    • 另外,可以使用-xx:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边"Back Edge)。显然,建立回边计数器统计的目的就是为了触发OSR 编译。

image-20220408172110568

设置程序执行方式

缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint: 完全采用解释器模式执行程序;
  • -Xcomp: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
  • -Xmixed: 采用解释器+即时编译器的混合模式共同执行程序。

JIT分类

在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪-种即时编译器,如下所示:

  • -client: 指定Java虚拟机运行在Client模式下,并使用C1编译器;

    C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。

  • -server: 指定Java 虚拟机运行在Server模式下,并使用C2编译器。

    C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

不同的优化策略

在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联,去虚拟化、冗余消除。

  • 方法内联: 将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程

  • 去虚拟化: 对唯一的实现类进行内联

  • 冗余消除: 在运行期间把一些不会执行的代码折叠掉

C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:

  • 标量替换: 用标量值代替聚合对象的属性值

  • 栈上分配: 对于未逃逸的对象分配对象在栈而不是堆

  • 同步消除: 清除同步操作,通常指synchronized

分层编译(Tiered Compilation) 策略: 程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。

不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server” 时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。

总结

  • 一般来讲,JIT编译出来的机器码性能比解释器高。
  • C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器。

7. StringTable

7.1 String的基本特性

  • String: 字符串,使用一对""引起来表示

  • String声明为final的,不可被继承

  • String实现了Serializable接口:表示字符串是支持序列化的。

  • 实现了Comparable接口:表示String可以比较大小。

  • String在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9时改为byte[]

  • String:代表不可变的字符序列。简称:不可变性。

    • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。

    • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

    • 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

  • 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

  • 字符串常量池中是不会存储相同内容的字符串的

    • String的String Pool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进string Pool的String非常多, 就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。

    • 使用-XX: StringTableSi ze可设置StringTable的长度

    • 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求

    • 在jdk7中,StringTable的长度默认值是60013,1009是可设置的最小值。

7.2 String的内存分配

  • 在Java语言中有8种基本数据类型和一种比较特殊的类型string。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

  • 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的String对象会直接存储在常量池中。

    • 比如: String info = "atguigu.com" ;
  • 如果不是用双引号声明的String对象,可以使用String提供的

    • intern()方法
  • Java 6及以前,字符串常量池存放在永久代。

  • Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即字符串常量池的位置调整到Java堆内

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
  • Java8元空间,字符串常量在堆

StringTable为什么要调整?

  1. permSize默认比较小
  2. 永久代垃圾回收频率低

7.3 字符串的拼接操作

  1. 常量与常量的拼接结果在常量池,原理是编译期优化

  2. 常量池中不会存在相同内容的常量。

  3. 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder

  4. 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

    • intern():判断字符串常量池中是否存在这个值,如果存在,则返回常量池中的地址,如果不存在,则再常量池中加载一个新的,并返回对象的地址
  5. 字符串拼接操作不一定使用的是StringBuilder,如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译器优化,即非StringBuilder的方式。

    public void test4(){
    	final String s1 = "a";
    	final String s2 = "b";
    	String s3 = "ab";
    	String s4 = S1 + s2;
    	System. out.println(s3 == s4);//true
    }
    

    针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。

  6. 注意:虽然编译器会将+号的拼接操作转换为StringBuilder,但是多次拼接会多次转换为StringBuilder,并不是一次,因此会很浪费时间和空间。

7.4 intern()的使用

如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

String myInfo = new String("I love atguigu").intern();

也就是说,如果在任意字符串上调用String. intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:

("a" + "b" + "c").intern() == "abc" 

通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool) 。

new String("ab")会创建几个对象?

  1. new关键字在堆空间创建的
  2. 字符串常量池中的对象。字节码指令:ldc

new String("a") + new String("b")呢?

  1. new Stringbuilder
  2. new String("a")
  3. 常量池中的 "a"
  4. new String("b")
  5. 常量池中的 "b"
  6. toString(): new String("ab") 但是不在字符串常量池中生成 "ab"

分析以下代码:

public class StringIntern {
    public static void main(String[] args) {
        String s = new String("1");
        s.intern(); // 调用前常量池中已有1
        String s2 = "1";
        System.out.println(s == s2); // jdk6、7、8 false
        
        String s3 = new String("1") + new String("1"); // s3变量的记录地址为 new String("11")
        // 调用前常量池中无11
        // 在常量池中生成11
        // jdk6 创建了一个新的对象11,也就有新的地址了
        // jdk7 此时常量中并没有创建11 而是创建了一个指向堆空间new String("11")的地址 
        // 为了节省空间的做法
        s3.intern();
        String s4 = "11"; // 使用的是常量池中11的地址 
        System.out.println(s3 == s4); // jdk6 fasle, jdk7/8 true
    }
}
image-20220408205729957
image-20220408205754111

总结String的intern()的使用

jdk1.6中, 将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址

  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

Jdk1.7起,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址

  • 如果没有,则会把对象的引用地址复制一份, 放入串池,并返回串池中的引用地址

intern()的空间效率测试

for (int i = 0; i < 10000; i++) {
	// 会不断创建new String对象
    arr[i] = new String(String.valueof(i % 10));
    // 虽然也创建了new对象,但是会被GC清理掉,更优
    arr[i] = new String(String.valueof(i % 10)).intern();
}

对于程序中大量存在的字符串,尤其其中存在的很多重复字符串,使用intern()可以节省内存空间

7.5 G1的String去重操作

背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面String对象占了25%

  • 堆存活数据集合里面重复的string对象有13.5%

  • String对象的平均长度是45

许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说:
string1.equals(string2)=true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的strinq对象进行去重,这样就能避免浪费内存。

实现

  • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象

  • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。

  • 使用一个hashtable来 记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。

  • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。

  • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。

8. 内存的分配与回收

大厂的一些面试题

  • 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下cms和g1

  • JVM GC算法有哪些,目前的JDK版本采用什么回收算法

  • G1回收器讲下回收过程

  • GC是什么?为什么要有GC?

  • GC的两种判定方法? CMS收集器与G1收集器的特点。

  • 说一下GC算法,分代回收说下

  • 垃圾收集策略和算法

  • jvm GC原理,JVM怎么回收内存

  • CMS特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?

  • java的垃圾回收器都有哪些,说下g1的应用场景,平时你是如何搭配使用垃圾回收器的
  • 你知道哪几种垃圾收集器,各自的优缺点,重点讲下cms和G1,包括原理,流程,优缺点。

  • 垃圾回收算法的实现原理。

  • 讲一讲垃圾回收算法。

  • 什么情况下触发垃圾回收?

  • 如何选择合适的垃圾收集算法?

  • JVM有哪三种垃圾回收器?

  • 常见的垃圾回收器算法有哪些,各有什么优劣?

  • system.gc()和runtime.gc()会做什么事情?

  • Java GC机制? GC Roots有哪些?

  • Java对象的回收方式,回收算法。

  • CMS和G1了解么,CMS解决什么问题,说一下回收的过程。

  • CMS回收停顿了几次,为什么要停顿两次。

8.1 什么是垃圾?

什么是垃圾( Garbage) 呢?

  • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

  • An object is considered garbage when it can no longer be reached from any pointer in the running program.

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。

8.2 为什么需要GC?

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象

随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

自动内存管理无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

  • 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄露问题让你头疼不已。

自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

8.3 垃圾标记算法

对象存活判断

在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

那么在JVM中究竟是如何标记一个死亡对象呢? 简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。

判断对象存活一般有两种方式: 引用计数算法可达性分析算法

8.3.1 引用计数算法

  • 引用计数算法(Reference Counting) 比较简单,对每个对象保存一个 整型的引用计数器属性。用于记录对象被引用的情况。

  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。

    • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
    • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销

    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销

    • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

      循环引用

      image-20220408222115049

小结

  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。

  • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。

  • Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。

  • Python如何解决循环引用?

    • 手动解除: 很好理解,就是在合适的时机,解除引用关系。

    • 使用弱引用weakref,weakref是 Python提供的标准库,旨在解决循环引用。

8.3.2 可达性分析算法

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。

相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)。

所谓"GC Roots"根集合就是一组必须活跃的引用。

基本思路

  • 可达性分析算法是以根对象集合(GC Roots) 为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达

  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)

  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。

  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象

image-20220408222910620

GC Roots

由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root

在Java语言中,GC Roots包括以下几类元素:

  • 虚拟机栈中引用的对象

    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内JNI(通常说的本地方法)引用的对象

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

    • 比如: Java类的引用类型静态变量
  • 方法区中常量引用的对象

    • 比如:字符串常量池(String Table)里的引用
  • 所有被同步锁synchronized持有的对象

  • Java虛 拟机内部的引用。

    • 基本数据类型对应的Class对象,一些常驻的异常对象(如: NullPointerException、OutOfMemoryError) ,系统类加载器。
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。

  • 如果只针对Java堆中的某一块区 域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

注意

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。

这点也是导致GC进行时必须川stop The World" 的一个重要原因。

  • 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

8.4 对象的finalization机制

概述

  • Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑

  • 当垃圾回收器发现没有引用指向一个对象,即垃圾回收此对象之前,总会先调用这个对象的finalize()方法。

  • finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

  • 应该交给垃圾回收机制调用。理由包括下面三点:永远不要主动调用某个对象的finalize()方法

    • 在finalize() 时可能会导致对象复活。
    • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
    • 一个糟糕的finalize()会严重影响GC的性能。
  • 从功能上来说,finalize() 方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize ()方法在本质上不同于C++中的析构函数。

  • 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。

如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的: 从根节点开始,可以到达这个对象。
  • 可复活的: 对象的所有引用都被释放,但是对象有可能在finalize()中复活。
  • 不可触及的: 对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对 象不可能被复活,因为finalize()只会被调用一次

以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

判断可回收的具体过程

判定一个对象objA是否可回收,至少要经历两次标记过程:

  1. 如果对象objA到GC Roots没有引用链,则进行第一次标记。

  2. 进行筛选,判断此对象是否有必要执行finalize()方法

    • 如果对象objA没有重写finalize()方法,或者finalize ()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。

    • 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到r-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。

    • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。

8.5 垃圾清除算法

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

目前在JVM中比较常见的三种垃圾收集算法是

  • 标记—清除算法( Mark-Sweep)
  • 复制算法(Copying)(标记—复制算法)
  • 标记一压缩算法(Mark-Compact)

8.5.1 标记—清除算法

执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

  • 标记: Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
  • 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
image-20220409191332470

缺点

  • 效率不算高

  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差

  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表

注意: 何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

8.5.2 复制算法

核心思想

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

有点类似于from to区

image-20220409192012092

优点

  • 没有标记和清除过程,实现简单,运行高效

  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。

  • 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。

特别的

  • 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。

8.5.3 标记—压缩算法

基于老年代垃圾回收的特性,需要使用其他的算法。

执行过程

  • 第一阶段和标记—清除算法一样,从根节点开始标记所有被引用对象

  • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放

  • 之后,清理边界外所有的空间。

标记—压缩算法的最终效果等同于标记—清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记—清除—压缩(Mark- Sweep-Compact)算法。

二者的本质差异在于标记—清除算法是一种非移动式的回收算法,标记—压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。

可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

优点

  • 消除了标记—清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。

  • 消除了复制算法当中,内存减半的高额代价

缺点

  • 从效率上来说,标记—整理算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全程暂停用户应用程序。即: STW

8.5.4 小结

Mark-Weep Mark-Compact Copying

速度 中等 最慢 最快

空间开销 少(碎片) 少(不堆积碎片) 通常需要活对象的2倍大小(不堆积碎片)

移动对象 否 是 是

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段(这里好像不对?),比标记—清除多了一个整理内存的阶段。

8.5.5 分代收集算法

不同生命周期的对象可以采取不同的收集方式,以便提高回收效率

目前几乎所有的GC都是采用分代收集( Generational Collecting) 算法执行垃圾回收的。

在Hotspot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

  • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

  • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。

老年代(Tenured Gen)

  • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。

  • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记—清除或者是标记—清除与标记—整理的混合实现。

    • Mark阶段的开销与存活对象的数量成正比。

    • Sweep阶段的开销与所管理区域的大小成正相关。

    • Compact阶段的开销与存活对象的数据成正比。

以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Seriall old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial old执行Full GC以达到对老年代内存的整理。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

8.5.6 增量收集算法

基本思想

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。

总的来说,增量收集算法的基础仍是传统的标记—清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

8.5.7 分区算法

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制Gc产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若千个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

image-20220410143438310

8.6 垃圾回收的概念

1. System.gc()

  • 在默认情况下,通过system.gc()或者Runtime.getRuntime().gc()的调用,会显式触发FullGC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

  • 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。

  • JVM实现者可以通过System.gc()调用来决定JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()

  • system.runFinalization()强制调用使用引用的对象的finalize()方法

2. 内存溢出(OOM)

  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

  • 由于GC一直在发展,所以一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。

  • 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存,供应用程序继续使用。

  • javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存

  • 这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

    • 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等
    • 在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System. gc()会被调用,以清理空间。
  • 当然,也不是在任何情况下垃圾收集器都会被触发的

    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError

首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:

  1. Java虚拟机的堆内存设置不够

    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms、-Xmx来调整。

  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)对于老版本的Oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关: "java.lang.OutOfMemoryError: PermGen space".随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的00M有所改观,出现OOM,异常信息则变成了:“java. lang . OutOfMemoryError: Metaspace"。 直接内存不足,也会导致OOM。

3. 内存泄漏(Memory LeaK)

  • 也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏

  • 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”

  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。

  • 注意,这里的存储空间并不是指物理内存,而是指虛拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小

image-20220410150256126

举例

  1. 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  2. 一些提供close的资源未关闭导致内存泄漏数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

4. Stop The World

Stop-the-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW

  • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

    • 分析工作必须在一个能确保一致性的快照中进行
    • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
    • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • STW事件和采用哪款GC无关,所有的GC都有这个事件。

  • 哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

  • STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

  • 开发中不要用System.gc(),会导致Stop-the-world的发生。

5. 并发(Concurrent)

  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
image-20220410164445341

6. 并行(parallel)

  • 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
  • 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
  • 适合科学计算,后台处理等弱交互场景
image-20220410164549545

二者对比

  • 并发,指的是多个事情,在同一时间段内同时发生了。

  • 并行,指的是多个事情,在同一时间点上同时发生了。

  • 并发的多个任务之间是互相抢占资源的。

  • 并行的多个任务之间是不互相抢占资源的。

  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行。

  • 否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel)

    • 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
    • 如ParNew、 Parallel Scavenge、 Parallel 0ld;
  • 串行(Serial)

    • 相较于并行的概念,单线程执行。

    • 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

  • 并发(Conlcurrent)

    • 用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
    • 用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上,如: CMS、G1

7. 安全点与安全区域

  • 程序执行时并非在所有地方都能停顿下来开始GC, 只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint) ”。
  • safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
  • Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?例如线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走” 到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
  • 安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint

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

  • 抢先式中断: ( 目前没有虚拟机采用了)

    • 首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断:

    • 设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

安全区域实际执行

  1. 当线程运行到Safe Region的代码时, 首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
  2. 当线程即将离开Safe Region时, 会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止

8. 引用

我们希望能描述这样一类对象: 当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

  • 强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?
    • 在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference) 、弱引用(Weak Reference)和虚引用 (Phantom Reference) 4种,这4种引用强度依次逐渐减弱
    • 除强引用外,其他3种引用均可以在java. lang. ref包中找到它们的身影。如下图,显示
      了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用

  • 强引用(StrongReference)

    最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new object()"这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

    • 强引用可以直接访问目标对象。
    • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象
    • 强引用可能导致内存泄漏,
  • 软引用(SoftReference)

    在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

    • 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
    • 垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列( Reference Queue)。
    SoftReference<T> userSofRef = new SoftReference<T>(new T);
    
  • 弱引用(WeakReference)

    被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

    弱引用也适合来保存那些可有可无的缓存数据。

    WeakReference<T> userWRef = new WeakReference<T>(new T);
    
  • 虚引用(PhantomReference)

    一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虛引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

    为一个对象设置虛引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

  • 终结器引用

    它用以实现对象的finalize()方法,也可以称为终结器引用

    无需手动编码,其内部配合引用队列使用。

    在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。

9. 垃圾回收器

9.1 性能指标

  • 吞吐量: 运行用户代码的时间占总运行时间的比例

    • 总运行时间: 程序的运行时间+内存回收的时间
  • 垃圾收集开销: 吞吐量的补数,垃圾收集所用时间与总运行时间的比例。

  • 暂停时间: 执行垃圾收集时,程序的工作线程被暂停的时间。

  • 收集频率: 相对于应用程序的执行,收集操作发生的频率。

  • 内存占用: Java堆区所占的内存大小。

  • 快速: 一个对象从诞生到被回收所经历的时间。

吞吐量(Throughout)

  • 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值

  • 即吞吐量=运行用户代码时间/ (运行用户代码时间+垃圾收集时间)。

  • 比如: 虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

  • 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

  • 吞吐量优先,意味着在单位时间内,STW的时间最短: 0.2 + 0.2 = 0.4

暂停时间(pause time)

  • “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态

  • 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。

  • 暂停时间优先,意味着尽可能让单次STW的时间最短: 0.1 + 0.1 + 0.1 + 0.1+ 0.1=0.5

image-20220410194236296

比较

  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做生产性工作。直觉上,吞吐量越高程序运行越快。

  • 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

  • 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。

  • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。

  • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计(或使用) GC算法时,我们必须确定我们的目标: 一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。

现在标准: 在最大吞吐量优先的情况下,降低停顿时间

9.2 垃圾回收器概述

经典垃圾收集器

image-20220410200032265
  • 新生代收集器: Serial、ParNew、Parallel Scavenge

  • 老年代收集器: Serial Old、 Parallel Old、 CMS

  • 整堆收集器: G1

组合关系

image-20220410200229112
  1. 两个收集器间有连线,表明它们可以搭配使用: Serial/Serial Old、Serial/CMS、 ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现"Concurrent Mode Failure" 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial 0ld这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即: 移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Seria10ld GC组合(JEP366 )

  5. (青色虛线)JDK 14中:删除CMS垃圾回收器 (JEP 363 )

为什么要有很多收集器,一个不够吗?

  • 因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

  • 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器

查看默认的垃圾回收器

  • -XX:+PrintCommandLineFlags: 查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令: jinfo -flag 相关垃圾回收器 参数进程ID

9.3 Serial回收器

  • Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。

  • Serial收集器作为Hotspot中Client模式下的默认新生代垃圾收集器。

  • Serial收集器采用复制算法、串行回收和”stop-the-World"机制的方式执行内存回收。

  • 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和"Stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。

    • Serial Old是运行在Client模式下默认的老年代的垃圾回收器

    • Serial Old在Server模式下主要有两个用途

      ①与新生代的Parallel Scavenge配合使用

      ②作为老年代CMS收集器的后备垃圾收集方案

image-20220410201511850

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

  • 优势: 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

    • 运行在Client模式下的虚拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十MB至—两百MB),可以在较短时间内完成垃圾收集(几十ms至—百多ms) ,只要不频繁发生,使用串行回收器是可以接受的。

  • 使用:在HotSpot虚拟机中,使用-XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。

    • 等价于新生代用Serial GC,且老年代用Serial Old GC

总结

  • 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了

  • 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Javaweb应用程序中是不会采用串行垃圾收集器的。

9.4 ParNew回收器

  • 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。

    • Par是Parallel的缩写,New:只能处理的是新生代
  • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收 集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制。

  • ParNew是很多JVM运行在server模式下新生代的默认垃圾收集器。

image-20220410202137491
  • 对于新生代,回收次数频繁,使用并行方式高效

  • 对于老年代,回收次数少,使用串行方式节省资源(CPU并行需要切换线程,串行可以省去切换线程的资源)

由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?

  • ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
  • 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

使用

  • 在程序中,开发人员可以通过选项"-XX: +UseParNewGC"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。

  • -XX: ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。

9.5 Parallel Scavenge回收器

吞吐量优先

HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制

那么Parallel收集器的出现是否多此一举?

  • 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
  • 自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

特点

  • 高吞吐量则可以高效率地利用CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

  • Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器。

  • Parallel Old收集器采用了标记—压缩算法,但同样也是基于并行回收和"Stop-the-World"机制

  • 在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel Old 收集器的组合,在Server模式下的内存回收性能很不错。

  • 在Java8中,默认是此垃圾收集器。

image-20220410203255264

参数设置

  • -XX: +UseParallelGC 手动指定年轻代使用Parallel并行收集器执行内存回收任务。

  • -XX: +UseParallelOldGC 手动指定老年代都是使用并行回收收集器。

    • 分别适用于新生代和老年代。默认jdk8是开启的。

    • 上面两个参数,默认开启一个,另一个也会被开启。 (互相激活)

  • -XX: ParallelGCThreads设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。

    • 在默认情况下,当CPU数量小于8个, ParallelGCThreads的值等于CPU数量。

    • 当CPU数量大于8个, ParallelGCThreads的值等于3+ [5*CPU_ Count]/8]

  • -XX:MaxGdPauseMillis设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。

    • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。

    • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。

    • 该参数使用需谨慎。

  • -XX:GCTimeRatio垃圾收集时间占总时间的比例(=1/(N+1))。用于衡量吞吐量的大小。

    • 取值范围(0, 100)。默认值99,也就是垃圾回收时间不超过1%。

    • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。

  • -XX: +UseAdaptiveSizePolicy设置Parallel Scavenge收集器具有自适应调节策略

    • 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。

    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCT imeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。

9.6 CMS回收器

概述

  • 在JDK 1.5时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器: CMS (Concurrent-Mark- Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

    • 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
  • CMS的垃圾收集算法采用标记—清除算法,并且也会"Stop-the-world"

不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1. 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Seria1收集器中的一一个。

在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。

image-20220410204752297

CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发
标记阶段、重新标记阶段和并发清除阶段。

  • 初始标记(Initial-Mark) 阶段

    在这个阶段中,程序中所有的工作线程都将会因为“Stop- the -World"机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快

  • 并发标记(Concurrent-Mark)阶段

    从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

  • 重新标记(Remark) 阶段

    由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

  • 并发清除(Concurrent -Sweep)阶段

    此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World"机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop -the-World",只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案: 临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS收集器的垃圾收集算法采用的是标记—清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配。

image-20220410205507704

有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?

  • 答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响。

    Mark Compact更适合“Stop the World"这种场景下使用

CMS的优点

CMS的弊端

  1. 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。

  2. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。

  3. CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure"失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。

设置参数

  • -XX: +UseConcMarksweepGC手动指定使用CMS收集器执行内存回收任务。

    • 开启该参数后会自动将-XX: +UseParNewGC打开。

      即: ParNew (Young区用) +CMS(Old区用) +Serial old的组合。

  • -XX:CMSlnitiatingOccupanyFraction设置堆内存使用率的阀值,一旦达到该阈值,便开始进行回收。

    • JDK5及以前版本的默认值为68%,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数。
  • -XX: +UseCMSCompactAtFullCollection用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理。

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

    • CMS默认启动的线程数是(ParallelGCThreads+3)/4, ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

JDK9 CMS被标记为Deprecate

JDK14 正式删除CMS垃圾回收器

小结

  • 如果你想要最小化地使用内存和并行开销,请选Serial GC

  • 如果你想要最大化应用程序的吞吐量,请选Parallel GC

  • 如果你想要最小化GC的中断或停顿时间,请选CMS GC

9.7 G1回收器

区域分代化

既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First (G1) GC?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1 (Garbage- First)垃圾回收器是在Java7 update 4之后引入的一一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time) ,同时兼顾良好的吞吐量。

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫做Garbage First (G1)呢?

  • 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。

  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region) ,所以我们给G1一个名字: 垃圾优先(Garbage First) 。

G1 (Garbage- First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimental的标识,是JDK 9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器

与此同时,CMS已经在JDK 9中被标记为废弃(deprecated) 。在jdk8中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC来启用。

特点

  • 并行与并发

    • 并行性(回收线程并行): G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
    • 并发性(用户与回收线程并发): G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
  • 分代收集

    • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区,但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
    • 堆空间分为若干个区域(Region) , 这些区域中包含了逻辑上的年轻代和老年代
    • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代
  • 空间整合

    • CMS: “标记清除”算法、内存碎片、若干次GC后进行一次碎片整理
    • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记—压缩(Mark-Compact )算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
  • 可预测的停顿时间模型

    (即:软实时soft real-time)

    这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M亳秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率
    • 相比于CMS GC, G1未必能做到CMs在最好情况下的延时停顿,但是最差情况要好很多。

缺点

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(Overload)都要比CMS要高。
  • 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6- 8GB之间。

参数设置

  • -XX: +UseG1GC手动指定 使用G1收集器执行内存回收任务。

  • -XX: G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的 1/2000

  • -XX: MaxGCPauseMillis设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms

  • -XX:ParallelGCThread设置STW工作线程数的值。最多设置为8

  • -XX: ConcGCThreads设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右

  • -XX: InitiatingHeapOccupancyPercent设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45%。

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

第一步: 开启G1垃圾收集器

第二步: 设置堆的最大内存

第三步: 设置最大的停顿时间

G1中提供了三种垃圾回收模式: YoungGC、 Mixed GC和Full GC,在不同的条件下被触发。

使用场景

  • 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

  • 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案

  • 如: 在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒; (G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)

  • 用来替换掉JDK1.5中的CMS收集器

    在下面的情况时,使用G1可能比CMS好

    • 超过50%的Java堆被活动数据占用

    • 对象分配频率或年代提升频率变化很大

    • GC停顿时间过长(长于0.5至1秒)

  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区Region

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。

image-20220410215838917

一个region有可能属于Eden, Survivor 或者Old/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,S表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

设置H的原因

对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。

回收过程

G1 GC的垃圾回收过程主要包括如下三个环节:

  • 年轻代GC (Young GC)
  • 老年代并发标记过程 (Concurrent Marking )
  • 混合回收 (Mixed GC)(年轻和老年代)
  • (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
image-20220410220915968

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子: 一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

Remembered Set

  • 一个对象被不同区域引用的问题(老年代对象可能引用Eden区)

  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

    • 在其他的分代收集器,也存在这样的问题(而G1更突出)

    • 回收新生代也不得不同时扫描老年代?

    • 这样的话会降低Minor GC的效率;

解决方法

  • 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
  • 每个Region都有一个对应的Remembered Set
  • 每次Reference类型数据写操作时,都会产一个Write Barrier 暂时中断操作
  • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region (其他收集器:检查老年代对象是否引用了新生代对象)
  • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中
  • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set; 就可以保证不进行全局扫描,也不会有遗漏。
image-20220410222258632

1. 年轻代GC

  • JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
  • 年轻代垃圾回收只会回收Eden区和Survivor区
  • YGC时, 首先G1停止应用程序的执行(Stop-The-World) ,G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。
image-20220410222915740

然后开始如下回收过程

第一阶段:扫描根GC Roots

  • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。

第二阶段:更新RSet

  • 处理dirty card queue中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用

    (使用card的原因是,Rset的处理需要线程同步,开销大,先记录在card里,然后统一更新,会更好)

第三阶段:处理RSet

  • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象

第四阶段:复制对象

  • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段, Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。

第五阶段:处理引用

  • 处理Soft,Weak,Phantom,Final, JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

2. 并发标记过程

  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC

  2. 根区域扫描(Root Region Scanning) :G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成

  3. 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行) ,此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

  4. 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法: snapshot-at-the-beginning (SATB)

  5. 独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。

    • 这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。

3. 混合回收

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

image-20220410224530411

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX: G1MixedGCCountTarget设置)被回收。

混合回收的回收集(Collection Set) 包括八分之一的老年代内存分段,Eden区内存分段,Survivor区 内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次。有一个阈值-XX: G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

4. Full GC

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World) ,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?

比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full gc,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  1. Evacuation的时候没有足够的to-space来存放晋升的对象;
  2. 并发处理过程完成之前空间耗尽。

9.8 总结

image-20220410225548070

周志华:深入理解JAVA虚拟机第三版

宋红康JVM


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK