JVM 学习笔记

JVM 笔记

文章目录

  • JVM 笔记
  • 一、JVM 概述
  • 二、类加载子系统
    • 1. 类加载的过程
    • 2. 类加载器的分类
    • 3. 双亲委派机制
  • 三、运行时数据区
    • 1. 线程
    • 2. 程序计数器(又作PC寄存器)
    • 3. 虚拟机栈
      • 1. 栈帧的内部结构
    • 4. 本地方法库(本地方法接口)
    • 5. 本地方法栈
    • 6.堆
    • 7.方法区
  • 四、对象的实例化、内存布局与访问定位
    • 1. 对象的实例化
    • 2. 对象的加载过程
    • 3. 对象的内存布局。
    • 4. 对象的访问定位。
  • 五、直接内存(了解)
  • 六、执行引擎
    • 1. 执行引擎概述
    • 2. Java 代码编译和执行的过程
    • 3. 解释器与编译器
  • 七、String
  • 八、垃圾收集器
    • 1. 垃圾回收概述
    • 2. 垃圾回收相关算法
      • 1. 垃圾标记阶段的算法
      • 2. 对象的 finalization 机制
      • 3. 垃圾清除阶段
    • 3. 垃圾回收其他概念概述
      • 1. System.gc() 的理解
      • 2. 内存溢出(OOM)
      • 3. 内存泄漏(Memory Leak)
      • 4. Stop The World
      • 5. Java 中几种不同的引用
    • 4. 垃圾回收器
      • 1. GC分类与性能指标
      • 2. 七款经典收集器与垃圾分代之间的关系
      • 3 分区 Region:化整为零

一、JVM 概述

什么是虚拟机?

  • 所谓虚拟机就是一台虚拟的计算机。用来执行一系列虚拟计算机指令的软件。大体上虚拟机可分为:系统虚拟机程序虚拟机

什么是 Java 虚拟机?

  • Java 虚拟机是一台执行 Java 字节码的虚拟计算机,它拥有独立的运行机制,其运行的 Java 字节码也未必由 Java 语言编译而成。
  • JVM 平台的各种语言可以共享 Java 虚拟机带来的跨平台性、优秀的垃圾回收器,以及可靠的即时编译器
  • Java 技术的核心就是 Java 虚拟机(JVM,Java Virtual Machine),因为所有的 Java 程序都运行在 Java 虚拟机内部。

Java 代码执行流程图
JVM 学习笔记_第1张图片

JVM 的架构模型

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

  • 基于栈式架构的特点

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

    • 指令集架构则是完全依赖硬件,可移植性差。
    • 性能优秀和执行更高效。
    • 花费更少的指令去完成一项操作。
    • 在大部分情况下,基于寄存器架构的指令集往往都是一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。

由于跨平台的设计,Java 的指令都是根据栈来设计的。

JVM 的生命周期

  1. 虚拟机的启动:Java 虚拟的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
  2. 虚拟机的执行:执行一个所谓的 Java 程序的时候,真真正正在执行的是一个叫做 Java 虚拟机等待进程。
  3. 虚拟机的退出:程序正常执行结束、程序在执行过程中遇到异常或错误而终止、由于操作系统出现错误而导致Java虚拟机进程终止。某线程调用 Runtime 类 halt 方法或 System 类的 exit 方法,并且 Java 安全管理器也允许这样的操作。

二、类加载子系统

类加载子系统结构图
JVM 学习笔记_第2张图片

作用:

  • 负责从文件系统或者网络中加载 Class 文件, class 文件在文件开头有特定的文件标识。
  • ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定
  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量数字常量(这部分常量信息是Class 文件中常量池部分的内存映射)。

1. 类加载的过程

类加载过程图:
JVM 学习笔记_第3张图片

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

  • 链接

    • 验证(Verify):确保 Class 文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。主要包含四种验证:文件格式验证元数据验证字节码验证符号引用验证
    • 准备(Prepare):为类变量分配内存并且设置该变量的默认初始值,即零值。这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化。同时这里也不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量会随着对象一起分配到Java 堆中。
    • 解析(Resolve):将常量池内的符号引用转换为直接引用的过程。事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
  • 初始化

    • 初始化阶段就是执行类构造器方法 () 的过程。
    • 此方法不需定义,是 Java 编译器自动收集类中的所有类变量的赋值动作静态代码中的语句合并而来。
    • 构造器方法中指令按语句在源文件中出现的顺序执行。
    • () 不同于类的构造器。(关联:构造器是虚拟机视角下的())。
    • 若该类具有父类,JVM 会保证子类的 () 执行前,父类的() 已经执行完毕
    • 虚拟机必须保证一个类的() 方法在多线程下被同步枷锁

2. 类加载器的分类

JVM 支持俩种类型的类加载器,分别为:

  1. 引导类加载器(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 等开头的类。
  2. 自定义加载器(User-Defined ClassLoader)

自定义加载器又可分为:

  1. 扩展类加载器

    • 派生于 ClassLoader 类。
    • 父类加载器为启动类加载器。
    • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录 jre/lib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在 此目录下,也会自动有扩展类加载器加载。
  2. 应用程序类加载器(系统加载器,ApplicassLoader):我们自己代码写的类就是该加载器加载的。

    • 派生于 ClassLoader 类。
    • 父类加载器为扩展类加载器。
    • 他负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库。
    • 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由他来完成加载的。
    • 通过 ClassLoader #getSystemClassLoader() 方法可以获取到该类加载器。

除此之外用户还可以自定义类加载器,来定制类的加载方式,好处是:

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

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

  1. 继承抽象类 java.lang.ClassLoader 类的方式。
  2. 重写 findClass() 方法,编写类加载逻辑。
  3. 如果没有太复杂的需求,也可直接继承 URLClassLoader 类,这样可避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

获取 ClassLoader 的途径:

  1. 获取当前类的ClassLoader
clazz.getClassLoader()
  1. 获取当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader()
  1. 获取系统的 ClassLoader
ClassLoader.getSystemClassLoader()
  1. 获取调用者的 ClassLoader
DriverManager.getCallerClassLoader()

3. 双亲委派机制

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

工作原理:

  1. 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是吧这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,一次递归,请求最终达到顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
    JVM 学习笔记_第4张图片

优势:

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

在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:

  1. 类的完整类名必须一致,包括包名
  2. 加载这个类的 Classloader (指 ClassLoader 实例对象)必须相同

三、运行时数据区

运行时结构区结构图
JVM 学习笔记_第5张图片
阿里的手册里 结构图:
JVM 学习笔记_第6张图片

Java 虚拟机定义了若干中程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。

灰色的为单独线程私有的,红色的为多个线程共享的。即:

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 线程间共享:堆、对外内存(永久代或元空间、代码缓存)。
    JVM 学习笔记_第7张图片

1. 线程

线程是一个程序里的运行单元。JVM 允许一个应用有多个线程并行的执行。当一个Java 线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java 线程执行中止后,本地线程也会中止。

后台线程在 Hotspot JVM 里主要是以下几个:

  1. 虚拟机线程:
  2. 周期任务线程:
  3. GC线程:
  4. 编译线程:
  5. 信号调度线程。

2. 程序计数器(又作PC寄存器)

什么是 PC 寄存器?

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

使用 PC 寄存器存储字节码指令地址有什么用?

因为 CPU 需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM 字节码解释器就需要通过改变 PC 寄存器的值来`明确下一条应该执行什么样的字节码指令。

PC 寄存器为什么会被设定为线程私有?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器。

3. 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应这一次次的 Java方法调用生命周期和线程一致。主管 Java 程序的运行,它保存方法的局部变量(8中基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回

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

  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

特点:

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
  • JVM 直接对 Java 栈的操作只有两个:每个方法执行,伴随着进栈(入栈、压栈);执行结束后的出栈工作。
  • 对栈来说不存在垃圾回收问题

设置栈的大小:

  • 我们可以使用参数-Xss 选线来设线程的最大栈空间(后面可接单位 k,m)、栈的大小直接决定了函数调用的最大可达深度。
    在这里插入图片描述

栈中存储了什么?

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame) 的格式存在。
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧
  • 栈帧是一个内存空间,是一个数据集,维系着方法执行过程中的各种数据信息

栈运行原理:

  • Java 栈的操作只有两个:对栈帧的压栈出栈,遵循 先进后出,后进先出 的原则。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对于的方法就是当前方法,定义这个方法的类就是当前类
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  • 如果在该方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为新的当前栈

1. 栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态连接(Dynamic Linking)(或指向运行时常量池的方法应用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息
    JVM 学习笔记_第9张图片

1. 局部变量表:

  • 局部变量表也被称为局部变量数组本地变量表
  • 定义为一个数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据包括各类基本数据类型、对象引用、以及 returnAddress 类型。
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套调用的次数由栈的大小决定的。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随着销毁

关于 Solt 的理解:

  • 参数值的存放总是在局部变量数组的 index0 开始,到(数组长度 - 1) 的索引结束。
  • 局部变量表,最基本的存储单元是 Solt (变量槽)
  • 局部变量表中存放编译期克制的各种数据结构类型(8种),引用类型(reference),returnAddress 类型的变量。
  • 在局部变量表里,32位以内的类型只占用一个 solt (包括 returnAddress 类型),64位的类型(long,double)占用俩个solt。
  • JVM 会为局部变量表中的每一个 Solt 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
  • 当一个实例方法被调用时,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Solt 上。
  • 如果需要访问局部变量表中一个 64 bit 的局部变量值时,只需要使用前一个索引即可。如访问long或double类型变量。
  • 如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的solt处,其余的参数按照参数表顺序继续排列。
    JVM 学习笔记_第10张图片

需要注意的是:栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用于,那么在其作用域后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。如下图所示:变量 c 就重复利用了 变量 b 的槽位。

JVM 学习笔记_第11张图片

静态变量和局部变量的对比:

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
  • 类变量表有俩次初始化的机会,第一次是在 “准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在 “初始化” 阶段,赋予程序员在代码中定义的初始值。
  • 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须认为的初始化,否则无法使用。
  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
  • 在局部变量表中的变量也是重要的垃圾回收跟节点,只要被局部变量表中直接或间接引用的对象都不会被回收

2. 操作数栈:

  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个先进后出的操作数栈,也可以称之为表达式栈。

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

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

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

  • 另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

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

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

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

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

    • 32bit 的类型占用一个栈单位深度。
    • 64bit 的类型占用两个栈单位深度。
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。

栈顶缓存技术:

  • 基于栈式架构的虚拟机所使用的零地址指定更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派次数和内存读/写 次数。
  • 由于操作数是存储在内存中,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,Hostpot JVM 的设计者们踢出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器,以此降低对内存的读/写次数,提升执行引擎的执行效率。

3. 动态链接:

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

  • 在Java源文件被编译到字节码文件中,所有的变量的方法引用都作为符号引用(Symbolic Reference) 保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中执行方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

    JVM 学习笔记_第12张图片

方法的调用:

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

  • 静态链接:当一个字节码文件被装载 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
  • 动态链接:如果调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

所对应的方法绑定机制为:早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定:早期绑定就是被指调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就是可以使用静态链接的方法将符号引用转换为直接引用。
  • 晚期绑定:如果被调用的方法在编译期无法确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

非虚方法:

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

虚方法表:

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

那么虚方法表什么时候被创建?

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

4. 方法返回地址:

  • 存放调用该方法的 PC 寄存器的值。
  • 一个方法的结束,有两种方式:正常执行完成和出现未处理的异常,非正常退出。
  • 无论通过哪种方法退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常通过退出的,返回地址是要通过异常 表来确定的,栈帧中一般不会保存这部分信息。
  • 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表值、操作数栈、将返回地址压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
  • 正常完成出口和异常完成出口的区别在于:通常异常完成出口退出的不会给他的上层调用者产生任何的返回值。

5. 一些附加信息:(了解即可)

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

4. 本地方法库(本地方法接口)

一个Native Method就是一个Java 调用的非 Java 代码的接口,在定义一个Native method时,并不提供实现体(有些像定义一个 Java interface),因为其体现是由非 Java 语言在外面实现的。本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序。表示符 native 可以与所有其他的 Java 标识符连用,但是 abstract 除外。有时 Java 应用需要与 Java 外面的环境交互,这是本地方法存在的主要原因。

5. 本地方法栈

  • Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。

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

  • 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)。

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个 OutOfMemoryError 异常。
  • 本地方法是使用C语言实现的。

  • 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。

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

    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
    • 它甚至可以直接使用本地处理器中的寄存器。
    • 直接从本地内存的堆中分配任意数量的内存。
  • 并不是所有的虚拟机都支持本地方法。因为Java虚拟机规范并没有明确的要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需是实现本地方法栈。

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

6.堆

堆的核心概述:

  • 一个进程对应一个 JVM 实例,一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
  • Java 堆区在 JVM 启动的时候即被创建,其空间大小也就是确定了。是 JVM 管理的最大一块内存空间。堆内存的大小是可以调节的。
  • 《Java 虚拟机规范》规定,堆可以处于物理机上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 所有的线程共享Java堆(一个进程内或一个JVM实例内),在这里还可以划分线程私有的缓冲区(TLAB)。

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

  • Java 7 及之前堆内存逻辑上分为三部分:新生代 + 养老代 + 永久区
  • Java 8 及以后堆内存逻辑上分为三部分:新生代 + 养老代 + 元空间

设置堆内存大小与OOM

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

    • "-Xms" 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize
    • “-Xmx” 用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
  • 一旦堆区中的内存大小超过了 “-Xmx” 所指定的最大内存时,将会抛出 OutOfMemoryError异常。

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

  • 默认情况下,初始内存大小:物理电脑内存大小 / 64,最大内存大小:物料电脑内存大小 / 4。

老年代与年轻代:

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

    • 一类时生命周期较短的瞬间对象,这类对象的创建和消亡都非常迅速。
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM 的生命周期保持一致。
  • Java 堆区进一步细分的话,可以划分为年轻代和老年代

  • 其中年轻代又可以划分 Eden 空间、Survivor0 空间和 Surviror1 空间(有时也叫做 from 区、to区)。
    JVM 学习笔记_第13张图片

  • 配置新生代与老年代在堆结构的占比。
    JVM 学习笔记_第14张图片

    • 默认 -XX:NewRation=2,表示新生代占1,老年代占2,新生代占整个堆的 1/3。
    • 可以修改 -XX:NewRation=4,表示新生代占1,老年代占4,新生代占整个堆的 1/5
  • 在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1,可以通过选项 " - XX:SurvivorRatio"调整这个空间比例。比如 -XX:SurvivorRatio=8

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

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

    • IBM 公司的专门研究表明,新生代中 80% 的对象都是 “朝生夕死”。
  • 可以使用选项 “-Xmn” 设置新生代最大内存大小。

    • 这个参数一般使用默认值就可以了。

对象分配过程:

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

  1. new 的对象先放伊甸园区。此区有大小限制。

  2. 当伊甸园的空间填满后,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不在被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。

  3. 然后将伊甸园区中的剩余对象移动到幸存者0区。
    JVM 学习笔记_第15张图片

  4. 如果再次出发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区。

  5. 如果再次经理垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

  6. 啥时候能去养老区呢?可以设置次数,默认是15次。
    JVM 学习笔记_第16张图片

    • 可以设置参数:-XX:MaxTenuringThreshold=进行设置。
  7. 在养老区,相对悠闲。当养老区内存不足时,再次出发 GC:Major GC,进行养老区的内存清理。

  8. 若养老区执行了 Majro GC 之后发现依然无法进行对象的保存,就会产生OOM异常。(java.lang.OutOfMemoryError):Java heap space。

总结:

  • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收费,几乎不在永久区/原空间收集。

流程图:

JVM 学习笔记_第17张图片

Minor GC、Major GC 与 Full GC

JVM 在进行 GC 时,并非每次都对上面三个内存(新生代,老年代,方法区)区域一起回收的,大部分时间回收的都是新生代。
针对 HotSpot JVM 的实现,它里面的GC 按照回收区域又分为两大类型:部分收集(Partial GC)和 整堆收集(Full GC)。

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

    • 新生代收集(Minor GC / Yong GC):只是新生代(Eden、s0、s1)的垃圾收集。
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。 目前,只有 CMS GC 会有单独收集老年代的行为。注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。目前,只有 G1、G2 会有这种行为。
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

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

  • 当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 区满,Survivor 满不会引发 GC。(每次 Minor GC 会清理年轻代的内存)。
  • 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • Minor GC 会引发 STM,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。

老年代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倍以上,STM 的时间更长。
  • 如果 Major GC 后,内存还不足,就报OOM 了。

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

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行。
  • 老年代空间不足。
  • 方法区空间不足。
  • 通过Minor GC 后进入老年代的平均大小大于老年代的可用内存。
  • 由Eden区、survivor space0(From space)区向survivor space1(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
  • 说明:full gc 是开发或调优中尽量要避免的。这样暂停时间会短一些。

为什么需要把Java堆分代?不分代就不能正常工作了吗?

  • 因为不同对象的生命周期不同。70% - 99% 的对象是临时对象。
  • 新生代:有 Eden、两块大小相同的Survivor(又称from/to,s0/s1)构成。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。
  • 不分代完全可以,分代的唯一理由就是优化GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是“朝生夕死”的,如果分代的话,吧新创建的对象放到某一个地方,当GC的时候先把这块存储 "朝生夕死"对象的区域进行回收,这样就会腾出很大的空间出来。

内存分配策略:

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

  • 优先分配到 Eden。
  • 大对象直接分配到老年代,尽量避免程序中出现过多的大对象。
  • 长期存活的对象分配到老年代。
  • 动态对象年龄判断。如果 survivor 区中相同年龄的所有对象大小的总和大于 survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到年龄达到阈值。
  • 空间分配担保。:-xx:HandlePromotionFailure。就是当servivor 区满了,需要分担给老年代区。

内存分配过程:TLAB

什么是 TLAB?

  • 从内存模型而不是垃圾收集的角度,堆 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存空间的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
  • 不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
  • 可以通过 “-XX:UseTLAB” 设置是否开启TLAB空间。
  • 默认情况下,TLAB空间的内存非常小,仅占有整个 Eden空间的 1%,可以通过 “-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
    JVM 学习笔记_第18张图片

为什么有 TLAB(Thread Local Allocation Buffer)?

  • 堆区是线程共享区域,任何线程都可以访问到堆区的共享数据。
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
  • 为了避免多个线程同时操作同一个地址,需要使用枷锁等机制,进而影响分配速度。

TLAB分配流程图:
JVM 学习笔记_第19张图片
堆空间的常用参数设置:

JVM 学习笔记_第20张图片

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

  • 不是。有一种特殊情况,那就是经过逃逸分析(Escape Analysis)(其实就是局部变量,不会被外部方法使用到)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需再堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术。

  • 没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

  • 开发中能使用局部边变量的,就不要使用在方法外定义。
    JVM 学习笔记_第21张图片
    使用逃逸后,编译器可以堆代码做如下优化。

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

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

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

7.方法区

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。

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

栈、堆、方法区的交互关系图:
JVM 学习笔记_第22张图片

在《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上属于堆的一部分,但一些简单的实现可能不会选择区进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。所以,方法区看作是一个独立于Java堆的内存空间。

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。java.lang.OutOfMemoryError:PermGen space(JDK 7) 或者 java.lang.OutOfMemoryError:Metaspace(JDK8)。
  • 关闭JVM就会释放这个区域的内存。

设置方法区内存的大小:

  • 元数据区大小可以使用参数 -XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代上述原有的俩个参数。
  • 默认值依赖于平台。Windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize 的值是-1,即没有限制。
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存,如果元数据区发生溢出,虚拟机一样会抛出异常OutOMemoryError:Metaspace。永久代则使用的是JVM的内存。
  • -XX:MetaspaceSize:设置初始的原空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触发这个水位线,Full GC 将会触发并卸载没用的类(即这些类对应的类加载器不在存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的内存不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过来及回收器的日志可以观察到Full GC 多次调用。为了避免频繁的GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。

方法区(Method Area)存储什么?

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

类型信息:

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)。
  • 这个类型直接父类的完整有效有效名(对于interface或是java.lang.Object,都没有父类)。
  • 这个类型的修饰符(public,abstract,final的某个子集)。
  • 这个类型直接接口的一个有序列表
  • JVM 必须在方法中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)。

JVM必须保存所有方法的一些信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或 void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,fianl,synchronized,natice,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈,局部变量表的大小(bastract和natice方法除外)。
  • 异常表(abstract和natice方法除外),每个异常处理的开始位置,结束位置,代码处理和程序计数器中的偏移地址、被捕获的异常类的常量池索引。

运行时常量池:

JVM 学习笔记_第23张图片

  • 方法区,内部包含了运行时常量池。
  • 字节码文件,内部包含了常量池。

为什么需要常量池?

  • 一个 java源文件中的类、接口、编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存放到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池,比如如下的代码:
public class SimpleClass{
	public void sayHello(){
		System.out.pritln("hello");
	}
}

在这里插入图片描述

虽然只有194字节,但是里面却使用了String,System,printStream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!这里就需要常量池了!常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

运行时常量池的理解:

  • 运行时常量池是方法区的一部分。
  • 常量池(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生产的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 运行时常量池在加载类和接口到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类或接口)都维护了一个常量池。池中的数据项向数组项一样,是通过索引访问的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真是地址。
  • 运行时常量池,相对于Class文件常量池的另一个重要特征是:具备动态性。相对于静态常量池,运行时常量池具有动态性,在程序运行的时候可能将新的常量放入运行时常量池中,比如使用String类的intern方法。
  • 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则JVM会抛出OutofMemoryError异常。

StringTable 为什么要调整?

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

判断存储位置案例:

JVM 学习笔记_第24张图片
三个 new ObjectHolder();都是存储在堆中的。instanceObje 成员变量,随着对象一起存放到堆中的。localObjet 是存放到栈的局部变量表中的,statieObj在jdk1.7之前是存放在永久代中的,JDK1.7之后是存放在堆中了。

JVM 学习笔记_第25张图片

方法区的垃圾回收:

  • 《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区实现垃圾回收。
  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时有确实是必要的。
  • 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

四、对象的实例化、内存布局与访问定位

1. 对象的实例化

  • new

    • 最常见的方式
    • 变形1:XXX的静态方法
    • 变形2:xxxbuilder/xxxfactory的静态方法
  • class的newInstance():反射的方式,只能调用空参的构造器,权限必须是public

  • Constructor的newInstance(xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求

  • 使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone()

  • 使用反序列化:从文件中、网络中获取一个对象的二进制流

  • 第三方库objenesis

2. 对象的加载过程

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

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

    • 如果内存规整:那么虚拟机将采用的是指针碰撞来为对象分配内存。意思是所有用过的的内存在一边,空闲的空间在一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了,如果垃圾收集器选择的是serial、parnew这种基于压缩算法的,虚拟机采用这种分配方式,一般使用带有compat(整理)过程的收集器时,使用指针碰撞。
    • 如果内存不规整:已使用的内存和未使用的内存相互交错,那么虚拟机采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录那些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式称为空闲列表
    • 选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定
  3. 处理并发安全问题(防止多线程挣抢内存)。采用cas失败重试,区域加锁保证更新的原子性。每个线程分配一块tlab,通过-XX:+/-UseTLAB参数来设置,jdk8默认是开启的。

  4. 初始化分配到的空间(零值初始化)。所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。

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

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

3. 对象的内存布局。

  1. 对象头(Header)。对象头包含俩部分:

    • 运行时元数据(Mark Word):锁状态标志、线程持有锁、偏向线程ID、偏向时间戳。如果是数组还需记录数组的长度。
    • 类型指针:指向类元数据 InstanceKlass ,确定该对象所属的类型。
  2. 实例数据(Instance Data):它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)。规则是:相同宽度的字段总是被分配到一起、父类中定义的变量会出现在子类之前、如果 CompacFields 参数为 true(默认为true):子类的窄变量可能插入都父类变量的空隙。

  3. 对齐填充(Padding):不是必须的,也没特别含义,仅仅起到占位符的作用。

举例子说明:

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

public class Customer{
	int id = 1001;
	String name;
	Account acct;

	{
		name = "匿名客户";
	}
	public Customer(){
		acct = new Account();
	}
}

class Account{

}

上述代码的内存结构分布图如下:

JVM 学习笔记_第26张图片

4. 对象的访问定位。

  • 句柄访问。

JVM 学习笔记_第27张图片

  • 直接指针(HotSpot采用)。

JVM 学习笔记_第28张图片

五、直接内存(了解)

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
  • 直接内存是在Java堆外的、直接向系统申请的内存空间。
  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存。
  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存。Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。
  • 也可能导致OutOfMemoryError异常。
  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小、但是系统内存时有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
  • 直接内存大小可以通过MaxDirectMemorySize设置。
  • 如果不指定,默认与堆的最大值-Xmx参数值一致。

缺点:

  • 分配回收成本较高。
  • 不受JVM内存回收管理。

六、执行引擎

1. 执行引擎概述

  • 执行引擎是Java虚拟机核心的组成部分之一。
  • "虚拟机"是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是有软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
  • JVM 的主要任务时负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅是在一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
  • 那么,如果想要一个Java程序运行起来,执行引擎(Exceution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

执行引擎的工作过程:

  • 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
  • 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
  • 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中对象实例信息,以及通过对象头中元数据指针定位到目标对象的类型信息。
  • 从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。

JVM 学习笔记_第29张图片

2. Java 代码编译和执行的过程

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤。
JVM 学习笔记_第30张图片
什么是解释器(Interpreter),什么是JIT编译器?

  • 解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行。
  • JIT(Just In Time Compiler)编译器(即时编译器):就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

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

  • JDK1.0时代,将Java语言定位为 "解释执行"还是比较准确的。再后来,Java也发展出可以直接生产本地代码的编译器。
  • 现在JVM在执行Java代码的时候,通常会将解释执行与编译执行二者结合起来进行。

Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:

JVM 学习笔记_第31张图片

3. 解释器与编译器

编译器和解释器之间的区别:

注:这里的程序指字节码

  • 编译器将一个程序作为一个整体进行翻译,而解释器则一条一条地翻译一个程序。
  • 在编译器的情况下生成中间代码或目标代码。. 而解释器不创建中间代码。
  • 编译器比解释器要快得多,因为编译器一次完成整个程序,而解释器则是依次编译每一行代码。
  • 由于要生成目标代码,编译器比解释器需要更多的内存。
  • 编译器同时显示所有错误,很难检测错误,而解释器则逐个显示每条语句的错误,更容易检测错误。
  • 在编译器中,当程序中出现错误时,它会停止翻译,并在删除错误后重新翻译整个程序。
  • 相反,当解释器中发生错误时,它会阻止其翻译,在删除错误后,翻译将继续.

看到这里可能会有疑问,编译器到底编译的是源程序,还是字节码?

  • Java 语言的 "编译器"其实是一段 "不确定"的操作过程,因为它可能是指一个 前端编译器 (其实叫 “编译器的前端” 更准确一些) 把 .java 文件转变成 .class 文件的过程。是 Sun 的 Javac 、Eclipse JDT 中的增量式编译器(ECJ)。
  • 也可以是指虚拟机的后端运行期编译器JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。HotSpot VM 的 C1、C2 的编译器。
  • 还可能是指使用静态提前编译器AOT 编译器,Ahead Of Time Compiler,JDK9 引入):所谓AOT 编译,是与即时编译相对立的一个概念。即时编译器指的是程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码。而AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。

HotSpot JVM 的执行方式:

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

热点代码及探测方式:

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

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

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

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

    • 方法调用计数器用于统计方法的调用次数。它的默认阈值在 Client 模式下是 1500次,在 Server 模式下是10000次。超过这个阈值就会触发JIT编译器。具体过程为:当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译后的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法嗲用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
    • 回边计数器则用于统计循环体执行的循环次数。

方法调用计数器的热度衰减:

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限制,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程成为方法调用计数器热度的衰减,而这段时间就称之为此方法统计的半衰周期。
  • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便执行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
  • 另外,可以使用 -XX:CounterHalfLifeTime 参数设置半衰期的时间,单位是秒。

HotSpot VM 可以设置程序执行方式:

  • 缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显示地为 Java 虚拟机指定在运行时到底是 完全采用解释器执行还是完全采用即时编译器执行。
  • -Xint:完全采用解释器模式执行程序。
  • -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

HotSpot VM 中的 JIT 分类:

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

  • -client:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器;C1 编译器会对字节码进行简单和可靠的优化,耗时短。已达到更快的编译速度。
  • -server:指定Java 虚拟机运行在 Server 模式下,并使用 C2 编译器。C2 进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

C1 和 C2 编译器不同的优化策略:

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

    • 方法内联:将引用的函数代码编译到引用点出,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
    • 去虚拟化:对唯一的实现类进行内联。
    • 冗余消除:在运行期间吧一些不会执行的代码折叠掉。
  • C2 的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在 C2 上有如下几种优化:

    • 标量替换:用标量值代替聚合对象的属性值。
    • 栈上分配:对于未逃逸的对象分配对象的栈而不是堆。
    • 同步消除:清楚同步操作,通常指 synchronized。

分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。不过在 Java 7 版本之后,一旦开发人员在程序中显示指定命令 “-server” 时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。

七、String

什么是字符串常量池,和运行时常量池有什么关系?

  • 字符串池,全局字符串池,英文也叫String Pool。在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表。 在工作中,String类是我们使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间,这就是我们今天要讨论的核心:字符串常量池。字符串常量池由String类私有的维护。堆里边的字符串常量池存放的是字符串的引用或者字符串(两者都有)。
  • 关系如下图所示:
    JVM 学习笔记_第32张图片

String 的基本特性:

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

  • Sting 实现了 Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小。

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

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

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

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

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

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

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

  • String 的String Pool 是一个固定大小的HashTable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后会直接造成的影响就是当调用String.intern(判断当前字符串是在在常量池中,没有则创建,其实就是放入stringtable 中)时性能会大幅下降。

  • 使用 -XX:StringTableSize 可设置StringTable 的长度。

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

  • 在jdk7中,StringTable的长度默认值是60013,StringTableSize设置没有要求。

  • Jdk8开始,设置StringTable的长度的话,1009是可设置的最小值。

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

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

    • 直接使用双引号声明出来的String对象会直接存储在常量池中。比如String info = “abc”;
    • 如果不是双引号声明的String 对象,可以使用String 提供的intern() 方法
  • Java 6 及以前,字符串常量池存放在永久代。

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

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

字符串拼接操作:

  • 常量与常量的拼接结果在常量池,原理是编译期优化。
  • 常量池中不会存在相同内容的常量。
  • 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
  • 如果拼接的结果调用 intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回次对象地址。

intern() 的使用:

  • 在 jdk1.6中,将这个字符串对象尝试放入串池,如果串池中有,则并不会放入,返回已有的串池中的对象的地址。如果没有,会把次对象复制一份,放入池中,并返回串池中的对象地址。

  • 在 jdk1.7期,将这个字符串对象尝试放入串池,如果串池中有,则并不会放入,返回已有的串池中的对象的地址。如果没有,会把次对象的引用地址复制一份,放入池中,并返回串池中的对象地址。

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

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

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

如何保证变量 S 指向的是字符串常量池中的数据呢?

  • 方式一:

    String s = "abc";
    
  • 方式二:

    String s = new String("abc").intern();
    String s = new StringBuilder("abc").toString().intern();
    

new String(“ab”) 会创建几个对象呢?(俩个)

  • 一个是new 的String(“ab”) 对象,另一个是常量池的 “ab”。
  • 我们可以从字节码来观察,创建了两个对象。
    JVM 学习笔记_第33张图片

new String(“a”) + new String(“b”) 创建了几个对象呢?6 个

  1. new StringBuilder();
  2. new String(“a”)
  3. 常量池中的“a”
  4. new String(“b”)
  5. 常量池"b"
  6. StringBuilder的toString 方法里的 newString(“ab”)。注意:toString() 的调用,在字符串常量池中,没有生产"ab"。
String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2);
  • 返回false,一个是堆里的new String(“1”) 对象,一个是 字符串常量池里的对象。
String s1 = new String("1") + new String("1");
System.out.println(s1.intern() == s1);

  • JDK6中,常量池在永久代中,s1.intern()去常量池中查找"11",发现没有该常量,则在常量池中开辟空间存储"11",返回常量池中的值,s1指向堆空间地址,所以二者不相等。

  • JDK7中,常量池在堆空间,s1.intern()去常量池中查找"11",发现没有该常量,则在字符串常量池中开辟空间,指向堆空间地址,则返回字符串常量池指向的堆空间地址,s1也是堆空间地址,所以二者相等。

String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2);

G1的String 去重操作:

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

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

  • 实现:

    • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
    • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
    • 使用一个hashtabel来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char舒服。
    • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
    • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
  • 命令行选项:

    • UseStringDeduplication(bool):开始String去重,默认是不开启的,需要手动开启。
    • PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息。
    • StringDeduplicationAgeThreshold(uintx):达到这个年龄的String对象被认为是去重的候选对象。

八、垃圾收集器

1. 垃圾回收概述

什么是垃圾?

  • 垃圾是指子运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占用的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至,可能导致内存溢出。

为什么需要GC?

  • 对于高级 语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停生产生活垃圾而从来不进行打扫一样。
  • 除了释放没用的内存,垃圾回收也可以清理内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
  • 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的 正常进行。而经常造成STW的GC又更不上实际的需求,所以才会不断地尝试对GC进行优化。

Java 垃圾回收机制

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。如果没有垃圾回收器,Java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。

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

  • 垃圾回收器可以堆年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。其中,Java堆是垃圾收集器的工作重点。

  • 从次数上来说

    • 频繁收集Young区。
    • 较少收集Old区。
    • 基本不懂Perm区(元空间)。

2. 垃圾回收相关算法

1. 垃圾标记阶段的算法

垃圾标记阶段:对象存活判断

  • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,只有被标记已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称之为垃圾标记阶段。
  • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不在被任何的存活对象继续引用时,就可以宣判为已经死亡。
  • 判断对象存活一般两种方式:引用计数算法可达性分析算法。

方式一:引用计数算法

  • 引用计数算法比较简单,堆每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1.只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
  • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
  • 缺点:他需要单独的字段存储计数器,这样的做法增加了存储空间的开销。每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。

方式二:可达性分析(或根搜索算法、追踪性垃圾收集)

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

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

  • 所谓 “GC Roots” 跟集合就是一组必须活跃的引用。

  • 基本思路:

    • 可达性分析算法是以跟对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
    • 使用可达性分析算法后,内存中的存活对象都会被跟对象集合直接或间接连接着,搜索所走过的路径称之为引用链(Reference Chain)。
    • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
    • 在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象。

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

  • 虚拟机栈中引用的对象:各个线程被调用的方法中使用的参数、局部变量等。

  • 本地方法栈内JNI(通常说的本地方法)引用对象。

  • 方法区中类静态属性引用的对象。比如,Java类的引用类型静态变量。

  • 方法区中常量引用的对象。比如,字符串常量池(StringTable)里的引用。

  • 所有被同步锁synchronized持有的对象。

  • Java虚拟机内部的引用。基本数据类型对象的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。

  • 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

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

    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象加入GC Roots 集合中去考虑,才能保证可达性分析的准确性。
  • 小技巧:由于 Root 采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那就是一个Root。

  • 使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致GC进行时必须""
    Stop The World 的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

2. 对象的 finalization 机制

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

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

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

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

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

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

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

    • 可触及的:从跟节点开始,可以到达这个对象。
    • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。
    • 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize() 只会被调用一次。
  • 以上三种状态中,是由于finalize() 方法的存在,进行的分区。只有在对象不可触及时才可以被回收。

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

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

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

    • 如果兑现objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为 “没有必要执行”,objA被判断为不可触及的。
    • 如果对象objA重写了 finalize() 方法,且还未执行过,那么objA会被插入到 F-Queue 队列中,由一个虚拟机自动创建、低优先级的 Finalizer线程触发其finalize()方法执行。
    • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记时,如果objA在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移除 "即将回收"集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次。

3. 垃圾清除阶段

  • 当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
  • 目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。

标记-清除(Mark-Sweep)算法:

  • 背景:标记-清除算法(Mark Sweep)是一种非常基础和常见的垃圾收集算反,该算法被 J.McCarthy等人在1960年提出并应用于Lisp语言。

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

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

    • 效率不算高。
    • 在进行GC的时候,需要停止整个应用程序,导致用户体验差。
    • 这种方式清理出来的空间内存时不连续的,产生内存碎片。需要维护一个空闲列表。
  • 需要注意的是,这里的清除是指什么呢?

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

复制(Copying)算法:

  • 为了解决标记-清除算法在垃圾收集效率方面的缺陷从而有了复制算法。将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
  • 优点:没有标记和清除过程,实现简单,运行高效。复制过去以后保证空间的连续性,不会出现"碎片" 问题。
  • 缺点:此算法的缺点也是很明显的,就是说需要两倍的内存空间。对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销都不小。
  • 特别的:如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大或者说非常低才行。
  • 应用场景:在新生代中,对常规应用的垃圾回收,一次通过可以回收70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种垃圾算法回收新生代。

标记-压缩(或标记-整理、Mark-Compact)算法。

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

  • 第二阶段:将所有的存活对象压缩到内存的一段,按顺序排放,之后清理边界外所有的空间。
    JVM 学习笔记_第34张图片

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

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

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

  • 优点:

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

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

三种算法对比:

Mark-Sweep Mark-Compact Copying
速度 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的两倍大小(不堆积碎片)
移动对象

分代收集算法:

  • 前面所有这些算法中,并没有一种算法可以完全替代其他算法,他们都具有自己独特的优势和特点。分代收集算法运用而生。
  • 分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采用不用的收集方式,以便调高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
  • 在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期较长。但是还有一些对象,主要是程序运行过程中生产的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次就可回收。
  • 目前几乎所有的GC都是采用分代收集(Generational Collection)算法执行垃圾回收的。在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点:
  • 年轻代中:区域相对老年代较小,对象生命周期短、存活率低、回收频繁。这种情况复制算法的回收整理,速度是最快的。复制算法的效率之和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过HotSpot中的两个survivor的设计得到缓解。
  • 老年代中:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
    • Mark阶段的开销与存活对象的数量成正比。
    • Sweep阶段的开销与所管理区域的大小成正相关。
    • Compact阶段的开销与存活对象的数据成正比。
  • 以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收期期作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old 执行Full GC 以达到对老年代内存的整理。
  • 分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾税后其都区分新生代和老年代。

增量收集算法:

  • 上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop The World 的状态。该状态下,应用程序所有线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即堆实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecion)算法的诞生。
  • 基本思想就是:如果一次间所有的垃圾进行收集处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只手机一下片区域的内存空间,接着切换到应用程序线程。依次反复,知道垃圾收集完成。
  • 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分段的方式完成标记、清理或复制工作。
  • 缺点:使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

分区算法:

  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分隔成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每个小区间都是独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

3. 垃圾回收其他概念概述

1. System.gc() 的理解

  • 在默认情况下,通过System.gc() 或者 Runtime.getRuntime().gc() 的调用,会显式触发Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。
  • 然而 System.gc() 调用附带一个免责声明,无法保证对象垃圾收集器的调用。
  • JVM 实现这可以通过 System.gc() 调用来决定JVM 的GC行为。而一般情况下,垃圾回收应该是自动进行的,无需手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。

2. 内存溢出(OOM)

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

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

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

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

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

    • Java虚拟机的堆内存设置不够。比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较客观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数 -Xms、-Xmx来调整。
    • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。对于老版本的Oracle JDK ,因为永久代的大小是有限的,并且JVM 堆永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字段串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:"java.lang.OutOfMemoryError:PermGen space"。
  • 随着元数据的引入,方法区内存已经不在那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了:"java.lang.OutOfMerryError:Metaspace"。直接内存不足,也会导致OOM。

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

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

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

3. 内存泄漏(Memory Leak)

  • 也称作 “存储渗漏”。严格来说,只有对象不会被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
  • 但实际情况很多时候一些不太友好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的"内存泄"。
  • 尽管内存泄漏并不会立即引起程序奔溃,但是一旦发生内存泄漏,程序中可用的内存就会被逐步蚕食,甚至耗尽所有内存,最终出现OutOfMemoryError异常,导致程序奔溃。
  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
  • 常见的内存泄漏的例子:
    • 单例模式:单例的生命周期和应用程序是一样长的,所有单例程序中,如果持有对象外部对象的引用的话,那么这个外部队形是不能被回收的,则会导致内存泄漏的产生。`
    • 一些提供了close的资源未关闭导致内存泄漏。数据库连接(dataSource.getConnection()),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。

4. Stop The World

  • Stop-the-World ,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序都会被暂停,没有任何响应,有点像卡死的感觉,整个停顿称为STW。
    • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
      • 分析工作必须在一个能确保一致性的快照中进行。
      • 一致性值整个分析期间整个执行系统看起来像被冻结在某个时间点上。
      • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
  • 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。
  • STW 事件和采用哪款GC无关,所有的GC 都有这个事件。
  • 哪怕是G1也不能完全避免Stop-The-World情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
  • STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
  • 开发中不要用System.gc;会导致Stop-The-World的发生。

5. Java 中几种不同的引用

  • 在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4中引用强度依次逐渐减弱。
    • 强引用(StrongReference): 最传统的 “引用"的定义,是指在程序代码之中普遍存在的引用赋值,即类似"Object obj = new Object()” 这种引用关系。无论何种情况下,只要强调引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
    • 软引用(Soft Reference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
    • 弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
    • 虚引用(Phantom Reference):一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

4. 垃圾回收器

1. GC分类与性能指标

  • 吞入量:运行用户代码的时间占总运行时间的比例。总运行时间:程序的运行时间+内存回收的时间。
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:当对于应用程序的执行,收集操作发生的评率。
  • 内存占用:Java 堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。
  • 简单来说主要抓住两点:吞吐量和暂停时间。
  • 再设计(或使用)GC算法时,我们必须确定我们的目标:一个GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的这中。
  • 现在的标准:在最大吞吐量优先的情况下,降低停顿时间。

2. 七款经典收集器与垃圾分代之间的关系

JVM 学习笔记_第35张图片

  • 新生代收集器:

    • Serial:串行回收。
      JVM 学习笔记_第36张图片

      • Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK 1.3 之前回收新生代唯一选择。
      • Serial 收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
      • Serial 收集器采用复制算法、串行回收和“Stop-The-World”机制的方式执行内存回收。
      • 除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集器的Serial Old收集器。Serial Old收集器同样也采用了串行回收和 "Stop-The-World" 机制,只不过内存回收算法使用的是标记-压缩算法。Serial Old 是运行在 Client 模式下默认的老年代的垃圾收集器。在Server模式下主要有两个用途:与新生代的Parallel Scavenge 配合使用 和作为老年代CMS收集器的后背垃圾收集方案。
      • 这个收集器是一个单线程的收集器,但它的"单线程"的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,最重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。
      • 优势:简单而高效(与其他收集器的单线程比)。
      • 在HotSpot虚拟机中,使用 -XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。即 新生代用 Serial GC ,且老年代用 Serial Old GC。
    • ParNew:并行回收。
      JVM 学习笔记_第37张图片

      • 如果说Serial GC 是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本
      • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾后机器之前几乎没有任何区别。ParNew收集器咋年轻代中同样也是采用赋值算法、"Stop-The-World" 机制。
      • ParNew 是很多JVM运行在 Server 模式下新生代的默认垃圾收集器。
      • 对于新生代,回收次数频繁,使用并行方式高效。
      • 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)。
      • 可以通过选项 "-XX:+UseParNewGC"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
      • -XX:parallelGCThreads 限制线程数量,默认开启和CPU数据相同的线程数。
    • 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中,默认是此垃圾收集器。
      • -XX:+UseParallelGC:手动指定年轻代使用Parallel并行收集器执行内存回收任务。
      • -XX:+UseParallelOldGC:手动指定老年代都是使用并行回收回收器。和上面的参数,默认开启一个,另一个也会被开启(互相激活)。
      • -XX:ParallelGCThreads:设置年轻代并行收集器的线程数。一般地,最好和CPU数量相等,以避免过多的线程数影响垃圾收集性能。
      • -XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间(STW的时间),单位是毫秒。
      • -XX:GCTimeRatio:垃圾收集时间占总时间的比例(=1 / (N+1))。用于衡量吞吐量的大小。
      • -XX:+UseAdaptiveSizePolicy:设置Parallel Scavenge 收集器具有自适应调节策略。
  • 老年代收集器:

    • Seral Old:

    • Parallel Old:

    • CMS:低延迟。
      JVM 学习笔记_第38张图片

      • CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集器线程与用户线程同时工作。
      • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停段时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
      • CMS的垃圾收集算法采用标记-清除算法,并且也会"Stop-The-World"。
      • 不幸的是,CMS 作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge 配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
      • 在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。
      • CMS整个过程比之前的手机器要复杂,整个过程分为四个阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
        • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为"Stop The World" 机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。
        • 并发标记(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)执行内存分配。
      • 既然Mark Sweep 会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
        • 因为昂并发清除的时候,用Compact 整理内存的话,原来的用户线程使用的内存还怎么能用呢?要保证用户线程能继续执行,前提是它运行的资源不受影响。Mark Compact 更适合 “Stop The World” 这种场景下使用。
      • CMS的优点:并发收集与低延迟。
      • CMS的弊端:
        • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
        • CMS 收集器堆CPU资源非常敏感。在并发解读那,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
        • CMS收集器无法处理浮动垃圾。可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记解读那如果产生新的垃圾对象,CMS将无法堆这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
      • -XX:+UseConcMarkSweepGC:手动指定使用CMS,收集器执行内存回收任务。开启参数后会自动将 -XX:+UseParNewGC 打开。即:ParNew(Young区用)+CMS(Old区用)+Serial Old的组合。
      • -XX:CMSlinitiatingOccupanyFraction :设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
      • -XX:+uSERcmsCompactAtFullCollection:用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
      • -XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后堆内存空间进行压缩整理。
      • -XX:ParallelCMSThreads :设置CMS的线程数量。ParallelCMSThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,收到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
  • 整堆收集:

    • G1:区域分代化。
      • 由于应用程序所应对的业务越来越庞大、复杂、用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1(Garbage-First)垃圾回收器是在 Java7 update 4之后引入的一个新的垃圾收集器,是当今收集器技术发展的最前沿成果之一。
      • 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
      • 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起"全功能收集器"的重任与期望。
      • G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以提高概率满足GC停顿时间的同时,还兼备高吞吐量的性能特征。
      • 在JDK1.7版本正式启动,移除了Experimental的标识,是JDK 9 以后默认的垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old 组合,被官方称为 “全功能的垃圾收集器”。
      • 与此同时,CMS已经在JDK 9中被标记为废弃(deprecated)。在jdk8中还不是默认的垃圾收集器,需要使用-XX:UseG1GC来启用。
      • 与其他GC收集器相比,G1使用了全新的分区算法,其特点如下:
        • 并行与并发。
          • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW。
          • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
        • 分代收集。
          • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
          • 将堆空间分为若干区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
          • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代。
        • 空间整合。
          • CMS: "标记-清除"算法,内存碎片、若干次GC后进行一次碎片整理。
          • G1将内存划分为一个个的region。内存的回收时以region作为基本单位的。Region之间是赋值算法、整体上实际可看做是标记-压缩算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到联系内存空间而提前触发下一次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-8G之间。
      • G1回收器的参数设置:
        • -XX:+UserG1GC。手动指定使用G1收集器执行内存回收任务。
        • -XX:G1HeapRegionSize。设置每个Region(介绍看下文)的大小。值时2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
        • -XX:MaxGCPauseMillis。设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到哦)。默认值是200ms。
        • -XX:ParallelGCThread 。设置STW时GC线程数的值。最多设置为8。
        • -XX:ConcGCThreads 。设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
        • -XX:InitiatingHeapOccupancyPercent。设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

垃圾收集器的组合关系:

JVM 学习笔记_第39张图片
查看默认的垃圾收集器:

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

3 分区 Region:化整为零

  • 使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region快,每个Region块大小根据堆空间的实际大小而定,整体被控制住1MB到32MB之间,且为2的N次幂,即1MB,2MB,4MB,5MB,16MB,32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
  • 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了。他们都是一部分Region(不需要连续)的集合。通过Region的分配方式实现逻辑上的逻辑。
    JVM 学习笔记_第40张图片
  • 一个 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 (Young GC)。应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
  • 老年代并发标记过程(Concurrent Marking)。当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
  • 混合回收(Mixed GC)。标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC 从老年代区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收期不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,整个老年代Region是和年轻代一起被回收的。
  • 如果需要、单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。
  • 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒进行一次年轻代回收,没31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

G1回收器垃圾回收过程:Remembered Set

  • 一个对象被不同区域引用的问题。
  • 一个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;就可以保证不进行全局扫描,也不会有遗漏。
    JVM 学习笔记_第41张图片

GC日志分析参数:

  • XX:+PrintGC: 输出GC日志。类似 -verbnose:gc。
  • -XX:+PrintGCDetails: 输出GC的详细日志。
  • -XX:+PrintGCTimeStamps :输出GC的时间戳(以基准时间的形式)。
  • -XX:+PrintGCDateSXtamps :输出GC的时间戳(以日期的形式,如2013-05-14T21:53:59.234+0800)。
  • -XX:+PrintHeapAtGC :在日志GC的前台打印出堆的信息。
  • -Xloggc:../logs/gc.log :日志文件的输出路径。

你可能感兴趣的:(java,jvm,java)