JVM-上篇

JVM-上篇

1. 第一章-概述

1.1 JVM生命周期

启动

JVM启动是通过类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的。

执行

程序开始执行的时候JVM开始运行,程序结束时就停止。

执行一个Java程序时,真正执行的是JVM的进程。(注意:不是线程)

退出

如下几种情况会退出:

  • 程序正常执行结束
  • 程序在执行过程中遇到异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止
  • Runtime、System类的exit方法或Runtime类的halt方法被调用
  • JNI加载或卸载Java虚拟机的时候

1.2 JVM发展历程

Sun Classic VM

第一款商用Java虚拟机

Classic 内部只提供解析器

只提供解析器、使用JIT编译器就需要外挂。一旦使用JIT,解析器就不工作

HotSpot内置了该虚拟机

Exact VM

准确式内存管理

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

具有高性能虚拟机雏形:热点探测、编译器与解析器混合工作

Hotspot

JDK1.3开始是默认的虚拟机,具有绝对的市场地位,称霸武林

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

Hotspot是指的是热点探测技术

通过计数器找到最具有编译价值的代码,触发即时编译或或栈上替换

编译器、解析器协同工作在最优化程序响应时间与执行性能间取得平衡

JRockit

专注于服务器端,不关注启动速度,内部不包含解析器实现,全部代码通过即使编译器编译执行

JRockit JVM是世界上最快的JVM

属于BEA公式,被Oracle收购。

J9

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

市场定位和Hotspot最近,服务器端、桌面应用、嵌入式等

有影响力的三大商用虚拟机之一

其他

KVM、CDC、CLDC、Azul VM、BEA Liquid VM、Apache Harmony、Microsoft JVM、Taobao JVM、Graal VM。

2. 第二章-类加载子系统

image-20201120215516187

2.1 类加载子系统作用

image-20201120221030003

  • 类加载器子系统从文件系统或者网络中加载.class文件,.class文件头部有特殊的文件标记
  • ClassLoader只负责.class文件的加载,但是能不能运行有Execution Engine决定
  • 加载的类信息放在方法区的内存空间中。除了类信息外,方法区还存放运行时常量池信息,可能还包括字符串字面量和数字常量。

2.2 类的加载过程

image-20201122184222737

加载Loading
  1. 通过类的全限定类名获取定义此类的二进制字节流
  2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据访问入口
链接Linking
  1. 验证Verification
    • 确保Class文件的字节流中包含信息符合当前虚拟机的要求,保证加载类的正确性
    • 四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证
  2. 准备Prepare
    • 类变量分配内存并设置默认值、即零值
    • 这阶段不会包含final修饰的static,因为final在编译的时候就被分配,准备阶段会显示初始化
    • 这阶段不会为实例变量分配初始化,类变量会在方法区中分配,实例变量随着对象分配在Java堆中
  3. 解析Resolution
    • 将常量池内的符号引用转换为直接引用
    • 解析动作主要包含:类、接口、字段、类方法、接口方法、方法类型等。
    • 对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等
初始化Initialization
  1. 初始化阶段就是执行类构造器方法<clinit>()的过程
  2. 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块合并而来
  3. 构造器方法中的指令按语句出现在源文件中出现的顺序
  4. <clinit>()不同于类的构造器(类的构造器在虚拟机下是<init>())
  5. 如果类中包含父类,JVM会保证子类的<clinit>()位于父类的<clinit>()之后执行
  6. 虚拟机必须保证一个类的<clinint>()方法在多线程下会被同步加锁
补充:加载.class文件的方式
  • 本地文件系统中直接加载
  • 网络中获取:Web Applet
  • zip压缩包中获取:jar、war
  • 运行时生成:动态代理技术
  • 其他文件生成:JSP应用
  • 从专有数据库中提取.class文件,比较少见
  • 从加密文件中获取,防止反编译

2.3 类加载器的类型

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

从概念上说,自定义类加载器一般是指程序中由开发人员自定义的一类类加载器。

但是Java虚拟机规范中并不是这么定义,而是:所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。

image-20201122195242071

注意:上图中的四者之间并不是上下层关系,也不是子类父类关系,而是包含关系。

启动类加载器
  • 【虚拟机自带】

  • 又称引导类加载器,Bootstrap ClassLoader

  • 由C/C++应用编写,嵌套在JVM中

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

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

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

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

扩展类加载器
  • 【虚拟机自带】
  • Java语言编写,有sun.misc.Launcher$ExtClassLoader实现
  • 派生于ClassLoader类、父类加载器为启动类加载器
  • 从java.ext.dirs系统属性指定下的目中加载类库,或者从JDK安装目录下jre/lib/ext子目录下加载类库
  • 如果用户自定义jar放在以上目录也会被扩展类加载器自动加载
应用程序类加载器
  • 【虚拟机自带】
  • Java语言编写,有sun.misc/Launcher$AppClassLoader实现
  • 派生于ClassLoader类,父类加载器为启动类加载器
  • 该类加载器是应用程序中默认的类加载器,一般来说Java应用的类都是有它完成加载
  • 通过ClassLoader#getSystemClassLoader()方法可以获取该类加载器
自定义类加载器

简要对比
加载器类型英译底层实现功能
启动类加载器BootstrapC/C++加载Java核心类库
扩展类加载器Extensionsun.misc.Launcher$ExtClassLoader加载ext等目录下控制类库
应用程序类加载器Applicationsun.misc.Launcher$AppClassLoader加载一般应用编写的类库
自定义类加载器继承ClassLoader

2.4 双亲委派机制

概念

1)一个类加载收到类加载的请求,它不会自己先加载,而是委托给父类加载器去执行加载。

2)如果父类加载器还存在父类加载器,则进一步向上委托,异常递归,直到到达顶层启动类加载器。

3)如果父类加载器可以完成类的加载任务,就返回成功;如果父类加载器无法完成该任务,子类才会尝试进行加载。

4)以上就是双亲委派机制。

优势

使用双亲委派机制可以具有以下优势

  1. 避免类的重复加载

  2. 保护程序安全,防止核心API被篡改

    自定义类:java.lang.String

    自定义类:java.lang.DemoTest

2.5 沙箱安全机制

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

package java.lang;

public class MyInteger {
    public static void main(String[] args) {
        System.out.println("java.lang.MyInteger");
        // java.lang.SecurityException: Prohibited package name: java.lang
    }
}

2.6 其他

Class对象是否为同一各类的必要条件

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

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

就是说JVM中,即使两个类对象(class对象)来源于一个Class文件,被同一个虚拟机加载,但只要加载它们的ClassLoader实例对象不一样,那么这两个类对象就不相同。

类加载器的引用

JVM必须知道一种类型是有启动加载器加载的还是用户加载器加载的。

如果一个类型是有用户加载器加载的,那么JVM就会将这个类加载器的引用作为类型信息的一部分保存在方法区中。

当解析一个类型到另一个类型的引用的时候,JVM需要保证两个类型的加载器是相同的。

类的主动使用和别动使用

Java程序中对类的使用方式可以分为:主动使用和被动使用

主动使用又可以分为一下几种情况:

  • 创建类的实例
  • 访问某个类或接口的静态常量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射
  • 初始化一个类的子类
  • Java虚拟机启动时被标明为启动类的类
  • JDK7开始支持动态语言的支持

除了以上7种情况,其他使用Java类的方式都可以看作是对类的被动使用,都不会导致类的初始化。

3. 第三章-运行时数据区

3.1概要

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

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

image-20201127205453537

JVM的内存中,有些是线程私有的(虚拟机栈、PC寄存器、本地方法栈),有些是线程共享的(方法区、堆)

每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的运行时环境。

如果使用jconsole或者是任何一个调试工具,都能看到后台有许多线程在运行。这些线程不包括调用public void main(String[])的main线程以及所有这个main线程自己创建的线程。虚拟机线程、周期任务线程、GC线程、编译线程、信号调用线程。

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行执行。

Hotspot中,每个线程都与OS的本地线程直接映射。就是说当一个Java线程准备好了,OS的本地线程也同时创建了。Java线程执行后,本地内存创建;Java线程执行终止,本地线程 也会回收。

OS负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,就会调用Java线程中的run方法。

3.2 PC寄存器

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

它是内存中一块很小的内存空间,几乎可以忽略不计。也是运行最快的内存区域。

JVM规范中,每个线程都有自己的PC寄存器,是线程私有的,生命周期与线程一致。

任何时间有个线程都只有一个方法在执行,也就是当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果执行的native方法,则是为指定值(undefined)。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

字节码解析器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

它是唯一一个在Java虚拟机规范中没有规定任何OutOfMenoryError情况的区域。

使用PC寄存器有什么作用?

因为CPU需要在不同线程间切换,当从别的线程切换回来,就需要知道从哪里开始执行。

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

PC寄存器为什么设置为私有?

我们所理解的多线程在实际上在一个特点的时间点上只会执行一个线程的方法,CPU会不断的做任务切换,这样必然会导致中断或恢复,如何保护每次切换回来之后能够正确继续执行呢?这就需要为每个线程分配PC寄存器,用来精确记录当前的正在执行的字节码指令地址,这样就不会出现互相干扰的情况。

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

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

3.3 虚拟机栈

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

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

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

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

Java虚拟机栈是什么?

Java虚拟机栈,以前也叫Java栈。每个线程创建的时候都会创建一个虚拟机栈,其内部保存一个个栈帧,对应着一次次的Java方法调用。

Java虚拟机栈是线程私有的。

生命周期

Java虚拟机栈和线程生命周期一致。

作用

Java虚拟机栈主管Java程序的运行,保存方法的具局部变量(8种数据类型、对象引用地址)、部分结果、并参与方法的调用和返回。

局部变量VS成员变量

基本数据类型VS引用数据类型

优点

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

JVM直接对Java栈的操作只有两个:

  • 每个方法执行,伴随着进栈
  • 执行结束后的出栈工作

对于栈来说,不存在内存回收问题

栈可能出现的异常

前提:Java虚拟机规范运行Java栈的大小是动态或者固定不变

  1. 动态的时候,每个线程Java虚拟机栈的容量可以在创建线程的时候选定。如果线程请求分配的栈容量超过Java虚拟机栈允许 的最大容量,则会抛出StackOverflowError异常。
  2. 动态扩展时,且尝试扩展的时候无法申请足够的内存,或者创建新的线程是没有足够的内存区创建对应的虚拟机栈,Java虚拟机将会抛出一个OutOfMemoryError异常。
栈中存储了什么

每个线程都有自己的栈,栈中的数据都是以**栈帧(Stack Frame)**的格式存在。

每个线程上正在执行的方法都个各自对应一个栈帧(Stack Frame)。

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈运行的原理

JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循"先进后出"的原则。

在一条活动的线程中,一个时间点上,只会有一个数据的栈帧。即只有当前正在执行的方法的栈帧是有效的。这个栈帧称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。

执行引擎运行的所有字节码指令只针对当前的栈帧进行操作。

如果该方法调用了其他方法,对应新的栈帧会创建处理,放在栈的顶部,成为新的当前栈。

不同线程中所包含的栈帧是不允许相互作用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的返回结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,是的前一个栈帧重新成为当前栈帧。

Java方法有两种返回函数的方式:一种是正常函数返回,使用return指令;一种是抛出异常。不管哪种方式返回,都会导致栈帧被弹出。

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

3.4 虚拟机栈-局部变量表

局部变量表也称为:局部变量数组、本地变量表

定义一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包含了各类基本数据类型、对象应用(reference),以及ReturnAddress类型

由于局部办变量表是建立在线程上,是线程的私有数据,因此不存在数据安全问题。

局部变量表的大小是在编译期就确定下来的,并保存在方法的Code属性的maximum local variable数据项中。在方法运行期间是不会改变局部变量表的大小的。

方法嵌套的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数就越多。对一个函数来说,他的参数和局部变量越多,使得局部变量表膨胀,他的栈帧就越大,以满足方法调用需要传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后随着方法栈的销毁,局部变量表也随着销毁。

Slot

局部变量表的最基本存储单元是Slot(变量槽)。

参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

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

byte、short、char在存储前都会被转换为int,boolean也会被转换为int,0是false、非0是true

long和double则占据两个slot

slot的重复利用:

栈帧中的局部变量中的槽是可以重复利用的,如果一个局部变量过来作用域,那么在其作用域之后申明的局部变量就可以复用过期局部变量的槽位,从而达到节省资源的目的。

补充:变量的分类
  1. 按照数据类型分:
    • 基本数据类型
    • 引用数据类型
  2. 按照类中声明的位置分:
    • 成员变量:使用前都经过默认初始化
      • 类变量:linking的prepare阶段给类变量默认赋值
      • 实例变量:随着对象的创建,会在堆空间中分配实例变量的空间,并进行默认赋值
    • 局部变量:必须显示初始化才能使用,否则编译时就报错
补充:其他

在栈帧中,与性能调优关系最为密切的就是局部变量表。方法执行时,虚拟机使用局部变量表完成方法的传递。

局部变量表中的变量也是重要的垃圾回收根节点(GC Root),只要被局部变量表中的直接或间接引用的对象都不会被回收。

3.5 虚拟机栈-操作数栈

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

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

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

栈中的任何一个元素都是可以任意的Java数据类型:32bit类型只占一个栈单位深度、64bit类型占用两个栈单位深度。

操作数栈并非采用访问索引的方式来进行数据访问,而是通过标准的入栈、出栈操作来完成一次数据访问。

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

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

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

Jcode

3.6 虚拟机栈-动态链接

动态链接,又称指向运行时常量池的方法引用,栈帧中的一部分,如下图:

图片

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

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

image-20201210222943426

如图中的标记的符号就是表明动态链接到运行时常量池的某个位置。

3.7 方法的调用

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

对应的绑定机制有:早期绑定(Early Binding)、晚期绑定(Late Binding)。

绑定是一个字段、方法或类在符号引用被替换为直接引用的过程,这个过程仅仅发生一次。

1、静态链接、早期绑定

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

2、动态链接、晚期绑定

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

随着高级语言的横空出世,类似Java一样的基于面向对象的编程语言如今越来越多,尽管这类的编程语法风格存在一定差别 ,但是彼此直接始终存在一个共性,那就是支持封装、继承、多态等面向对象等特性。如果一个语言具备多态性,那么就具备早期绑定和晚期绑定。

Java中任何一个方法都具备虚函数的特性,他们相当于C++语言中的虚函数。如果Java程序不希望某个方法拥有虚函数的特征时,可以使用final关键字来标记这个方法。

class Animal{
    public void eat(){
        System.out.println("动物进食");
    }
}
interface Huntable{
    void hunt();
}
class Cat extends Animal implements Huntable{
    public Cat(){
        super();//表现为:早期绑定
    }
    public Cat(String name){
        this();//表现为:早期绑定
    }
    public void eat() {
        super.eat();//表现为:早期绑定
        System.out.println("猫吃鱼");
    }
    public void hunt() {
        System.out.println("捕食耗子,天经地义");
    }
}
public class AnimalTest {
    public void showAnimal(Animal animal){
        animal.eat();//表现为:晚期绑定
    }
    public void showHunt(Huntable h){
        h.hunt();//表现为:晚期绑定
    }
}

3、非虚方法与虚方法

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

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

  • 普通调用指令

    1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
    2. invokespecial:调用<init>、私有方法以及父类方法,解析阶段确定唯一方法版本
    3. invokevirtual:调用所有虚方法
    4. invokeinterface:调用接口方法
  • 动态调用指令

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

    上面普通调用指令都是固化在虚拟机内部,方法的调用执行不可人为干预。而invokedynamic指令则支持有用确定方法版本。

    其中invokestatic、invokespecial指令调用的方法称为非虚方法,其余称为虚方法。

class Father {
    public Father() {
        System.out.println("Father的构造器");
    }
    public static void showStatic(String str) {
        System.out.println("Father " + str);
    }
    public final void showFinal() {
        System.out.println("Father final 方法");
    }
    public void showCommon() {
        System.out.println("Father 普通方法");
    }
}

class Son extends Father {
    public Son() {
        // invokespecial 非虚方法
        super();
    }
    public Son(int age) {
        // invokespecial 非虚方法
        this();
    }
    // 此处不是重写父类的方法,因为静态方法不能被重写
    public static void showStatic(String str) {
        System.out.println("Son " + str);
    }
    private void showPrivate(String str) {
        System.out.println("Son private" + str);
    }
    public void info() { }
    public void display(Father f) {
        // invokevirtual 虚方法
        f.showCommon();
    }
    public void show() {
        // invokestatic 非虚方法
        showStatic("hello");
        // invokestatic 非虚方法
        super.showStatic("World");
        // invokespecial 虚方法
        showPrivate("hello!");
        // invokespecial 虚方法
        super.showCommon();
        // invokevirtual 【非虚方法】
        // 尽管这里是invokevirtual ,但是由于final不能被子类重写,因此是非虚方法
        showFinal();
        // invokevirtual 虚方法
        showCommon();

        GO go = null;
        // invokeinterface 虚方法
        go.methodA();
    }
    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }
}

interface GO {
    void methodA();
}
invokedynamic指令

JVM字节码指令集一值都比较稳定,一直到Java7才增加了一个invokedynamic指令,这是Java为了实现动态类型语言支持而做的一种改进

但Java7中没有直接提供生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具类来产生invokedynamic指令。

直到Java8的Lambda表达式的出现,Java才有invokedynamic指令的直接生成模式。

Java7增加的动态语言类型支持本质上是对Java虚拟机规范的修改,而不是对Java语言规则的修改。

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

动态类型语言和静态类型语言的区别在于:对类型的检查是在编译期还是运行期

编译期检查则是静态类型语言,反之就是动态类型语言。

或者说:静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有。如:

Java:【静态】
String info = "hello";
JS:【动态】
var temp = "hello";
temp = 11;
Python:【动态】
info = 110;

invokedynamic示例

public class Demo08Lambda {
    public void lambda(Fun fun) {
        return;
    }
    public static void main(String[] args) {
        Demo08Lambda lambda = new Demo08Lambda();
        // invokedynamic
        lambda.lambda((str) -> {
            if (str.equals("tobing")) {
                return true;
            } else {
                return false;
            }
        });
    }
}
@FunctionalInterface
interface Fun {
    public boolean fun(String str);
}
方法重写的本质

1、找到操作数栈的第一个元素执行的对象的类型,记为C

2、如果在类型C找到与常量的描述、简单名称都符合的方法,则进行访问权限判断。通过则返回方法的直接引用,查找结束。不通过则返回IllegalAccessError异常。

3、否则,按照继承关系从下往上一次对C的父类进行第2步的搜索、验证。

4、如果始终没找到合适的方法,抛出AbstractMethodError异常。

AbstractMethodError

程序试图修改一个属性或调用一个方法,但是你却没有权限时,一般会引起编译器异常。这个错误发生,说明发生了不兼容的改变。

虚方法表

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

每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

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

image-20201213084404537

3.8 虚拟机栈-方法返回地址

方法返回地址是存放调用该方法的pc寄存器的值。

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

  • 正常执行完成
  • 出现未处理异常,非正常退出

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

本质上,方法的退出就是当前栈帧出栈的过程。此时需要回复上层方法的局部变量表、操作数栈、将返回值压入调用者的栈帧的操作数栈、设置PC寄存器的值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

当一个方法开始执行只有两种方式可以退出这个方法:

1、执行引擎遇到任意一个返回的字节码指令,会有返回值传递给上层的方法调用者,简称:正常完成出口

  • 一个方法在正常调用完成之后究竟需要使用哪一个返回地址指令,还需要通过方法返回值的实际数据类型而定。
  • 在字节码指令中,返回指令包含ireturn、lreturn、freturn、dreturn以及areturn;另外void方法、实例初始化方法、类和接口的初始化方法使用的return指令
  • ireturn(返回值类型是byte、int、short、char、boolean);
  • lreturn(long)、dreturn(double)、freturn(float);

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

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

正常退出

public class Demo09ReturnAddress {
    public char testChar() {
        return 'a'; // ireturn
    }
    public boolean testBoolean() {
        return false;// ireturn
    }
    public int testInt() {
        return 1;   // ireturn
    }
    public short testShort() {
        return 1;   // ireturn
    }
    public long testLong() {
        return 1L;  // lreturn
    }
    public double testDouble() {
        return 1.1; // dreturn
    }
    public float testFloat() {
        return 1.1F;// freturn
    }
    public String testString() {
        return new String(); // areturn
    }
    public Date testDate() {
        return new Date();  // areturn
    }
    static {
        // return
    }
}

异常退出

class DemoException {
    public void testExec() {
        try {
            exec();
        } catch (RuntimeException e) {
            e.printStackTrace();
        }
    }

    public void exec() throws RuntimeException {
        System.out.println("即将抛出异常");
        throw new RuntimeException("抛出测试异常");   // athorw 无return
    }
}

3.9 本地方法

本地方法,Native Method是一个Java调用非Java代码的接口。一个Native方法实现由非Java实现,比如C。这种调用其他原因实现的机制并非只有Java独有,其他很多编程语言也有,比如:C++中可以使用extern “C”告知C++编译器去调用一个C的函数。

在定义一个native method是,并不提供实现体,因为实现是有非Java语言实现。

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

为什么使用Native Method?

Java使用起来很方便,然而在某些领域用Java实现起来并不容易,或者说Java实现起来效率不高。因此在以下场景中我们常常要使用Native Method。

  • 与Java环境外进行交互

    Java应用有时候与外界环境进行交互,这是native方法的主要原因。

    有时候Java需要有底层的操作系统进行操作,native方法就是这样一种机制:它提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。

  • 与操作系统交互

    JVM支持Java语言本身和运行时库,他是Java程序赖以生存的平台,它由一个解析器和一些链接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖一些底层的系统支持。通过使用本地方法,我们得以用Java实现了jre的与底层系统的交互,甚至JVM的一些部分都是用C写的。

  • Sun’s Java

    Sun的解析器使用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是Java实现的,也通过一些本地方法与外界交互。

    如:java.lang.Thread的setPriority方法是Java实现的,但是它实现调用了类的本地方法setPriority0()。这个本地方法是C实现的,并植入JVM内部。

Native Method 现状

随着Java的发展,目前Native 方法使用越来越少,除非是和硬件相关的应用。

3.10 本地方法栈

Java虚拟机用来管理Java方法的栈称为虚拟机栈,用来管理本地方法的栈称为本地方法栈。

本地方法栈是线程私有的。运行被实现为固定大小或可动态扩展的

  • 固定大小:线程请求分配的栈容量超出本地方法栈允许最大容量时,抛出StackOverflowError
  • 动态扩展:线程尝试扩展无法申请足够内存时,抛出OutOfMemoryError

本地方法使用C语言实现,具体做法是在Native Method Stack登记native方法,在Execution Engine执行时加载本地方法库。

当线程调用一个本地方法的时候,会进入一个全新的区域,不再受虚拟机限制的世界。

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

并不是所有的JVM都支持本地方法。因为Java虚拟机规范中并不明确要求本地方法栈的使用语言、具体实现方式、数据结构等。

如果JVM产品不打算支持native方法,也无需实现本地方法栈。

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

3.11 堆

核心概念

一个JVM实例值存在一个堆内存,堆也是内存管理的重要区域。

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

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

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

《Java虚拟机规范》对堆的描述:所有的对象都应该在运行时分配在堆上。

但是,“几乎”所有的对象实例都在这里分配内存。

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

在方法结束后,堆中的对象不会立即被销毁,仅仅在垃圾回收的时候才被移除。

堆是GC执行垃圾回收的重点区域。

内存细分

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

Java7 以及之前堆内存逻辑上被分为3部分:新生区+养老区+永久区:

  • Young Generation Space 新生区 Young、New
  • Tenure Generation Space 养老区 Old、Tenure
  • Permanent Space 永久区 Perm

Java8 以及之后堆内存逻辑上被分为3部分:新生区+养老区+元空间:

  • Young Generation Space 新生区 Young、New
  • Tenure Generation Space 养老区 Old、Tenure
  • Meta Space 源空间 Meta

约定:

新生区=新生代=年轻代

养老区=老年代=老年区

永久代=永久区

堆空间大小的设置

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

-Xms用来设置起始内存大小;-Xmx用来设置堆的最大内存大小。

通常情况下,-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收清理完堆区之后不再重新分配堆区的大小,从而提高性能。

默认情况下:初始内存大小=电脑物理内存/64;最大内存大小=电脑物理内存/16

// -Xmx20m -Xms20m -XX:PrintGCDetails
public class Demo02Heap {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("Start...");
        Thread.sleep(Integer.MAX_VALUE);
        System.out.println("End.....");
    }
}

image-20201215113130163

以上程序将堆空间设置为20M,通过jvisualvmJava堆内存可视化工具可以看出,堆内存的可用总大小也是20M左右

年轻代与老年代

1、年轻代

年轻代中主要包含3个区域:Eden、Survivor1、Survivor0。

Hotspot中,Eden区和两个Survivor区空间的缺省比例是8:1:1。

开发人员可以通过-XX:+SurvivorRatio来调整这个空间比例。

几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象销毁在新生代中进行。

IBM专门研究表明,新生代中80%对象都是朝生夕死。

可以通过-Xmn开配置新生代的最大内存大小,但一般采用默认即可。

2、老年代

老年代和新生代的比例一般情况下分别是2:1。

对象分配的过程

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

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

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

3、然后将伊甸园中的剩余对象移动到幸存者0区。

4、如果再触发垃圾回收,此时幸存者0区和伊甸园区幸存的对象移动到幸存者1区。

5、如果再进行垃圾回收,幸存的对象会放到幸存者0区,下次又是幸存者1区,如此反复。

6、每次幸存下来,都会对对象进行标记,当标记累计到15次,进入养老区养老。

-XX:MaxTenuringThreshold=x 来设置进入养老区经历的时间

GC

图1 对象内存分配流程图

总结:

  • 幸存者0区、幸存者1区:复制之后有交换,谁用谁是TO
  • 垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久代/元空间收集。

GC过程

图 2 对象淘汰示意图
常用的JVM调优工具

JDK命令行、Eclipse:Memory Analyzer Tool、Jconsole、VisualVM、Jprofil er、Java Flight Recorder、GCViewer、GC Easy

1、Jprofiler结合IDEA

下载Jprofiler;

IDEA安装Jprofiler插件;

Minor GC、Major GC、Full GC

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

针对Hotspot VM的实现,他里面的GC安装回收区域又分为两大类型:部分收集(Partical GC)、整堆收集(Full GC)

部分收集:不是完整收集整个Java堆的垃圾收集,其中包含:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。【仅有CMS GC实现】
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。【仅有G1 GC实现】

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

注意:很多时候Major GC会和Full GC混淆使用,具体需要分辨是老年代回收还是整堆回收。

最简单的分代GC策略触发条件

1、年轻代GC(Minor GC)的触发机制

  • 当年轻代空间不足时,会触发Minor GC,这里的年轻代指的是Eden满了,而Survivor满了不会触发GC。
  • 因为Java对象大多都是朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也会很快。
  • Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。

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

  • 指发生在老年代的GC,对象从老年代消失时,我们说:“Major GC”或“Full GC”发生了。
  • 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对)。
  • Major GC的速度一般都会比Minor GC慢10倍以上,STW的时间更长。
  • 如果Major GC后,内存还不足,就报OOM

老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足就触发Major GC

3、Full GC触发机制

  • 调用System.gc()时,系统建议执行Full GC,但不是必然。
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后加入老年代的平均大小大于老年代的可用内存‘
  • 有Eden区,Survivor0区向Survivor1区复制时,对象大小,大于Survivor1区可用内存时,把该对象转存到老年代,且老年代的可用内存小于该对象大小。

Full GC是开发或调优是尽量避免的。

对象分配过程:TLAB

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

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

2、什么是TLAB?

  • 从内存模型而不是垃圾回收的角度,对Eden区间进行划分,JVM为每个线程划分了一个私有缓存区域,他包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一些了的非线程安全问题,同时还可以能够提升内存分配的吞吐量,因此我们可以这种内存分配方式称之为快速分配策略。

3、TLAB补充

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实将TLAB作为分配内存的首选。
  • 在程序中,开发人员可以通过-XX:UserTable设置是否开启TLAB空间。
  • 默认情况下,TLAB空间的内存非常小,仅占整个Eden空间的1%,当然我们可以通过-XX:TLABWasteTargetPrecent设置整个TLAB空间所占用的Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败,JVM就会尝试同使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
堆空间的参数设置
  • -XX:+PrintFlagsInitial:查看所有的参数默认初始值
  • -XX:+PrintFlagsFinal:查看所有参数的最终值
  • -Xms:初始堆空间大小
  • -Xmx:最大堆空间大小
  • -Xmn:设置新生代大小
  • -XX:NewRatio:设置新生代与联动在堆结构的占比
  • -XX:SurvivorRatio:设置新生代Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾最大年龄
  • -XX:+PrintGCDetails:输出详细的GC日志
  • -XX:HandlePromotionFailure:是否设置空间担保
逃逸分析

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

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸技术的成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变的不那么绝对了。

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

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

总之,堆不是对象分配的唯一现在;基于逃逸技术的栈上分配技术和TaoBaoVM的堆外对象分配都不在堆上分配。

2、逃逸分析概述

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

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

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

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

没有逃逸的对象可以在栈上分配,随着方法的出栈而被销毁,不用GC回收。

public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

以上方法中,sb对象可能会在外部别使用,发生方法逃逸。

public static String createString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString()
}

以上方法中,sb对象只在createString方法被使用,没有发生方法逃逸。

JDK7及其之后Hotspot默认开启逃逸分析。由于开启栈上分配一定程度减少了GC的压力,因此:开发中尽量使用局部变量。

3、基于方法逃逸的代码优化

使用逃逸技术,编译器可以做以下优化:

  • 栈上分配。将堆分配转换为栈分配。如果一个对象在子程序中被分配,钥匙指向该对象的指针永远不会逃逸,对象可能是栈上分配的候选,而不是堆分配。
  • 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 分离对象或变量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分可以存储在内存,而是存储在CPU寄存器上。
代码优化之栈上分配

JIT编译器在编译期间可以根据逃逸分析的结果,如果发现一个对象没有逃逸出方法,就可以被优化为栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量也被回收。这样就无需垃圾回收了。

逃逸常发生于:成员变量赋值、方法返回值、实例传递。

栈上分析效果演示:

public class Demo06StackAllocation {
    public static void main(String[] args) throws InterruptedException {
        long startTime = System.currentTimeMillis();
        // 调用方法创建一千万个对象
        for (int i = 0; i < 10000000; i++) {
            allocate();
        }
        long endTime = System.currentTimeMillis();
        System.out.println("花费总时间:" + (endTime - startTime) + "ms");
        // 相当于阻塞main线程
        Thread.sleep(Integer.MAX_VALUE);
    }
    // 注意:此方法中user对象没有发生逃逸,因此很有可能会栈上分配
    public static void allocate() {
        User user = new User();
    }
}
class User {}

以上程序创建了一千万个对象,接下来我们通过调整JVM参数查看:开启逃逸分析以及关闭逃逸分析之间的性能差异。

关闭逃逸分析:

1、添加JVM参数:-Xmx256M -Xms256M -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

2、运行程序,并打开Java VisualVM

image-20201217221644072

图1 未开启逃逸分析JVisualVM内存抽样分析

image-20201217222311502

图2 未开启逃逸分析创建对象花费时间

开启逃逸分析:

1、添加JVM参数:-Xmx256M -Xms256M -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

2、运行程序,并打开Java VisualVM

image-20201217222031902

图3 开启逃逸分析JVisualVM内存抽样分析

image-20201217222124019

图4 开启逃逸分析花费时间

分析

从图1到图4我们可以发现,开启和关闭逃逸分析对该程序的性能还是比较大。

开启了逃逸分析,使得那些:不会发生方法逃逸的对象可以在栈上分配。

在栈上分配的对象在方法出栈时可以自动释放,而不用GC。

因此可以发现开启了栈上分配,该程序没有发生之前的GC;而关闭了逃逸分析,则发生了2次GC。

代码优化之同步省略(同步消除)

线程同步的代价是很高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器借助逃逸分析来判断同步代码块所使用的🔒锁对象是否只能别一个线程访问而没有发布到其他线程。

如果没有,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步。这样可以大大提高并发性和性能。

这个取消同步的过程就叫同步省略,也就锁消除。🔓

如以下程序:

public void m1() {
    Object lock = new Object()
    synchronized(lock) {
   		System.out.println(lock);    
    }
}

由于以上使用lock对象加锁,而lock对象的生命周期只在ml方法中,不会被其他线程访问,因此会在JIT编译阶段被优化掉。优化为如下代码:

public void m1() {
    Object lock = new Object()
	System.out.println(lock);    
}
代码优化之标量替换

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

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

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

public class Demo07ScalarReplace {

    public static void alloc() {
		Point p1 = new Point(1, 2);
        System.out.println("point.x=" + point.x + "point.y=" + point.y);
    }

    public static void main(String[] args) {
		alloc();
    }
}

class Point {
    private int x;
    private int y;
}

以上的代码经过标量替换可以变成:

    public static void alloc() {
		int x = 1;
        int y = 2;
        System.out.println("point.x=" + x + "point.y=" + y);
    }

可以看出,聚合量Point经过逃逸分析,发现它并没有逃逸,就被替换成立两个聚合量。这样可以得到减少堆内存的占用。因为一旦不需要创建对象,那么就不需要再分配堆内存了。

标量替换为栈上分配提供了很好的基础。

我们通过下面代码来测试一下开关标量替换的性能差异

public class Demo07ScalarReplace {
    public static class User {
        public int id;
        public String name;
    }
    public static void alloc() {
        User user = new User();
        user.id = 1000;
        user.name = "tobing";
    }
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费总时间为: " + (end - start) + "ms");
    }
}

关闭标量替换:-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations

image-20201218110100691

图1 关闭标量替换

开启标量替换:-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

image-20201218110456263

图2 开启标量替换

注意:通过-XX:+EliminateAllocations可以开启标量替换【默认是开启的】,逃逸分析是标量替换的基础,因此必须开启逃逸分析才能看到标量替换的效果。

3.12 方法区

栈堆方法区交互关系

图 1 方法区、栈、堆之间的关系
方法区在哪里

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

因此,方法区看作是独立于Java堆的内存空间。

运行时数据区的关系

图2 运行时数据区关系
方法区的基本理解

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

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

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

方法区的对象决定了系统可以保存多少个类,如果系统定义了太对的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:OutOfMemoryError:PermGen Space或者 OutOfMemoryError:Metasapce。

如:加载大量第三方jar包、Tomcat部署工程过多,大量动态发射生成类。

关闭JVM时会释放这个区域的内容。

Hotspot中方法区的演变

JDK7及其以前,习惯上把方法区,称为永久代。

JDK8及其以后,使用元空间取代永久代。

本质上来讲,方法区和永久代并不等价,仅仅是对Hotspot而言。对如何实现方法区,不做统一要求,例如:BEA JRockit、IBM J9中不存在永久代的概念。

事实证明,永久代的实现方式容易导致OOM

到了JDK8,永久废弃了永久代的概念,改用了JRockit、J9一样本地内存实现元空间来代替。

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

《Java虚拟机规范》中规定,如果方法区访问满足新的内存分配需求时,将会抛出OOM异常。

方法区

图3 JDK7/JDK8方法区的一些差异
方法区内存大小的设置

方法区的大小不必是固定的,JVM可以根据应用需求动态调整。

1、JDK7之前

  • 通过-XX:PermSize来设置永久代初始的分配空间。默认值为20.75M
  • 通过-XX:MaxPermSize来设置永久代最大的分配空间。32位默认64M、64位默认82M。
  • 当JVM加载类信息超过这个值,就爆出OutOfMemoryError:PermGen space。

2、JDK8及之后

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

1、要解决OOM异常或者Heap Space异常,一般手段是实现通过内存映像分析工具(如Eclipse Memory Analysis)对dump文出来的堆进行转储快照分析。重点是确认内存中的对象是否必要,也就是分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

2、如内存泄漏,可以进一步通过工具查看泄漏对象的GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收的。掌握了泄漏对象的类型信息,以及GC Roots 的引用链的信息,就可以比较精确的定位出泄漏代码的位置。

3、如果不存在内存泄漏,换句话说就是内存中的对象却是要必须存活,那就该检查虚拟机的堆参数(-Xmx和-Xms),与物理机内存相比,看是否还可以调大,从代码上检查是否存在某些生命周期较长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

3.13 方法区的内部结构

方法区组成

图1 方法区
方法区主要存储什么?

《深入理解Java虚拟机》中对方法区(Method Area)存储内容描述如下:

它用来存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译的代码缓存等。

image-20201218205207265

图2 方法区存储信息
类型信息

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

  1. 类型的完整有效名称(全名=报名+类名)
  2. 类型的直接父类的完整有效名(对于interface或者是java.lang.Object,都没有直接父类)
  3. 类型的直接修饰符(public、abstract、final的某个子集)
  4. 类型的直接接口的一个延续列表

image-20201218224404912

图 3 类型信息(局部)
域信息(Field)

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

域的相关信息包括:域名称、域类型、域修饰符(pubic、private、protected、static、final、volatile、transient的某个子集)

方法信息(Method)

JVM必须保存所有方法的以下信息,同域信息一样包括生命顺序:

  • 方法名称

  • 方法返回类型(或void)

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

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

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

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

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

image-20201218224830192

图4 方法信息(局部)
non-final的类变量
  • 静态类变量和类关联在一起,随着类的加载而加载,成为类数据的逻辑上的一部分。
  • 类变量被类的所有实例所共享,即使没有类实例时,你也可以访问它。
public class Demo02NonFinal {
    public static void main(String[] args) {
        Order order = null;
        // 以下两句会报NullPointException吗? 不会。 为什么呢?
        order.hello();
        System.out.println(order.count);
    }
}
class Order {
    public static int count = 1;
    public static void hello() {
        System.out.println("hello!");
    }
}
全局常量:static final

被声明为final的类变量的处理方法不同,每个全局常量在编译时就会被分配。

class Order {
    public static int count = 1;
    public static final int finalCount = 2333;
    public static void hello() {
        System.out.println("hello!");
    }
}

使用javap命令对以上程序进行反编译。我们可以发现static final修饰静态常量在编译期间就已经被分配。

image-20201219230658586

图 1 全局常量
方法区的垃圾收集

方法区中常量池主要存放两大类常量:字面量符号引用

字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。

符号引用属于编译原理的概念,主要包含下面三类常量:

1)类和接口的全限定类名;

2)字段的名称和描述符;

3)方法的名称和描述符。

Hotspot中虚拟机对常量池的回收策略很明确,只要常量池中的常量没有被任何地方引用,就可以被回收(回收常量和回收Java堆对象很类似)。

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

1)类的所有实例被回收,就是Java堆中不存在该类及其任何派生子类;

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

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

Java虚拟机运行对满足以上条件的无用类进行回收,这里说仅仅是被允许,而不是和对象一样,没有引用就必然被回收。关于是否要对类型回收,Hotspot迅疾通过参数控制。

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

3.14 运行时常量池

运行时常量池与常量池

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

要弄清楚方法区,需要理解清楚ClassFile,因为类的加载信息都在方法区中。

要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

一个有效的字节码文件中除了包含了类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。

为什么需要常量池

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

如以下代码:

public class Demo03ConstantPool {
    public static void main(String[] args) {
        Object o = new Object();
    }
}

虽然文件本身很小,仅有235字节,但是里面却是用了String、System、PrintStream以及Object等结构。

运行时常量池

运行时常量池是方法区的一部分。

常量池表是Class文件的一部分,用于存放编译器生成的各种字面量引用,这部分内容将在类加载之后存放在方法区的运行时常量池中。

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

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

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

运行时常量池,相对于Class文件中的常量池的一个重要特征是:具备动态性。

运行时常量池类似于传统编程语言中的符号包(Symbol Table),但是他所包含的数据却比符号表要丰富一点。

当创建类或者接口的运行时常量池的时候,如果构造运行时常量池所需要的内存空间超出了方法区的最大值,则JVM会跑出OOM。

方法区演进的细节
JDK版本细节
<= JDK1.6有永久代,静态变量放在永久代上
JDK1.7有永久代,字符串常量池、静态变量移除,保持到堆中
>=JDK1.8无永久代,类型信息、字段、方法、常量保存在本地内存的元空间中,但字符串常量池、静态变量仍在堆中

注意:Hotspot才用永久代的概念。对于JRockit、J9来说,不存在永久代的概念。原则上如何实现方法区属于虚拟机的实现细节。不受《Java虚拟机规范》管束。

方法区jdk6

图1 JDK6方法区

方法区jdk7

图2 JDK7 方法区

方法区jdk8

图3 JDK8 方法区
为什么采用元空间替代永久代

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

2)永久代调优是很难的

StringTable为什么要调整

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

3.15 对象创建

对象实例化

图1 对象的实例化
对象创建的方式
  • new:直接new、Xxx的静态方法(单例等)、XxxBuilder/XxxFactory的静态方法
  • Class的newInstance():反射的方式,只能调用空参数的构造器,权限必须是public【JDK9开始已经显示过时】
  • Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
  • 使用反序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库Objenesis
对象创建的步骤

1、判断兑对象对应的类是否已经加装、链接、初始化

2、为对象分配内存

当内存规整时:指针碰撞

当内存不规整:虚拟机维护一个类表,记录那些空闲;空闲列表法分配。

3、处理并发安全问题

采用CAS配上失败重试保证更新的原子性

每个线程预先分配一块TLAB

4、初始化分配到的空间

所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用

5、设置对象的对象头

6、执行init方法进行初始化

3.16 对象内存布局

对象内存分配

1、对象头(Header)

运行时元数据(Mark Word):哈希值、GC分代年龄、锁🔒状态标志、线程持有的锁、偏向线程ID、偏向时间戳

类型指针:指向类元数据的InstanceKlass,确定该对象所属类型

如果是数组,对象头还会储存数组的长度

2、实例数据(Intance Data)

声明:实例数据是对象真正储存的有效信息,包含程序代码中定义的各种类型的字段(包括从父类继承的和本类的)

规则:相同宽度的数据总是被分配在一起;父类中定义的变量会出现在子类之前;如果CompactField=true:子类的窄变量可能插到父类变量的间隙中

可以节省空间

3、对齐填充(Padding)

不是必须的,没特别含义,仅仅起到占位符的作用。

内存布局图示

图2 对象内存布局

Demo03ObjectStruct

public class Demo03ObjectStruct {
    public static void main(String[] args) {
        Demo02ObjectCreatePlus objectCreatePlus = new Demo02ObjectCreatePlus();
    }
}

Demo02ObjectCreatePlus

public class Demo02ObjectCreatePlus {
    int ind = 1001;
    String name;
    Account account;
    {
        name = "tobing";
    }
    public Demo02ObjectCreatePlus() {
        account = new Account();
    }
}
class Account {}

以上main方法执行时内存的情况

对象内存布局Plus

虚拟机栈:main方法所在的栈记录里了main方法的信息,其中的局部变量记录了objectCreatePlus;

堆空间:objectCreatePlus指向了堆空间中储存的Demo02ObjectCreatePlus实例对象;

实例内存:内存中包含了:对象头、实例数据;

对象头:记录了运行时元数据:哈希值、GC分代算法、锁状态标志等;以及类型指针;

实例数据:记录了父类及其本类的变量;其中name变量是一个字符串类型,指向了字符串常量池(JDK8中字符串常量池移动到了堆空间)

类型指针:类型指针指向了方法区(元空间)

3.17 对象访问定位

创建对象的目的是为了使用它,那么JVM是如何通过栈帧中的对象引用访问到其内部的实例对象的呢?定位、通过栈上的reference访问。

对象访问的方式主要有两种:句柄访问、直接指针访问。

句柄访问

句柄方式

直接指针访问

指针方式

补充:本地内存

概述

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

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

直接内存源自NIO,通过存在堆外内存的DirectByteBuffer操作Native内存。

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

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

Java的NIO库运行Java程序使用直接内存,用于数据缓冲器。

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

直接内存的缺点是:分配回收成本较高;不受JVM内存回收管理。

直接内存大小是可以同MaxDirectMemorySize设置

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

普通IO与NIO

image-20201222101102215

图1 普通IO

image-20201222101200110

图2 NIO

第四章-执行引擎

image-20201222112105439

图 0 执行引擎

4.1 概述

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

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

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

要想让Java程序运行起来,执行引擎的任务就是让字节码指令解析、编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了高级语言翻译为机器语言的译者。

4.2 执行引擎的工作流程

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

image-20201222111935533

图 1 大部分程序转换为物理代码执行前都要以上步骤

4.3 Java代码的编译与执行过程

image-20201222112711162

图2 Java代码的编译

image-20201222112637818

图3 Java代码的执行
问题:什么是解析器(Interpreter),什么是JIT编译器

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

JIT编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

JIT:Just In Time Compiler

问题:为什么说Java是半编译半解析的语言?

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

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

各种概念

机器码:二进制、计算机容易理解、人难以理解、执行速度快 、与CPU密切相关

指令:在机器码基础上出现,可读性好一点,不同硬件平台机器码可能不一样

指令集:指令集合,x86、ARM等。

汇编语言:指令继续抽象,助记符–>操作码,地址符号/标号 --> 指令或操作数的地址

**字节码:**中间状态的二进制代码,比机器码更抽象。是为了实现特定软件运行和软件环境、与硬件环境无关。

执行引擎的理解

图4 解析器与即时编译器

4.4 解析器

JVM设计者初衷单纯是为了满足Java程序跨平台的特性,因此避免采用今天编译方式生成本地机器指令。

image-20201222121810879

图1 字节码文件作为中介
解析器工作机制

解析器真正意义上承担的角色就是一个运行时“翻译者”,将字节码文件中的内容翻译为对应平台的本地机器指令执行。

当下一条字节码指令被解析执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令解析操作。

解析器分类

字节码解析器:纯软件代码模拟,效率底

模板解析器:没一条字节码和模板函数相关联,提高性能

解析器现状

解析器设计实现简单,因此除了Java外很多原因都同样使用解析器执行。但如今基于解析器执行已经成为低效的代名词

为了解决解析器低效的问题,JVM支持一种即时编译的技术。JIT目的是为了避免函数被解析执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使得执行效率大幅度提高。

4.4 Java代码执行的分类

  • 第一种是将源代码编译成字节码文件,如何运行期通过解析器将字节码文件转为机器码执行。
  • 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术,将发放编译成机器码之后执行

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

在今天,Java程序的性能早已今非昔比,以及可以达到与C、C++一较高下的地步。

解析器性能差,为什么还要采用它呢?

既然Hotspot虚拟以及内置了JIT编译器了,那么为什么还需要再使用解析器来“挺累”程序的性能呢?如JRockit就全部依靠JIT。

首先明确:

程序启动后,解析器可以马上发挥作用(响应快),申请编译的时间,立即执行。

编译器想要发挥作用,要把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后执行效率高。

因此:

经JRockit虚拟机程序性能执行起来很高,但是程序启动时必然要花费更长的时间来编译。对于服务端应用,启动时间关注重点,但对于看重启动时间的应用场景,或许需要采用解析器和即使编译器共存来获取一个均衡点。

在并存的模式下:当Java虚拟机启动时,解析器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间,随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获取更高的执行效率。

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

Hotspot的执行方式

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

4.5 JIT编译器

Java语言的编译期是一段不确定的操作过程,因为它可能是指的是前端编译把.java转变为.class文件的过程;

也可能是虚拟机后端运行期编译器(JIT编译器,Just In Time Compile)把字节码转换为机器码的过程。

还可能是指的静态提前编译器(AOT编译器)直接把.java文件编译为本地机器代码的过程。

热点代码及探测方法

1、热点代码

是否启动JIT将字节码转换为对应平台的本地机器指令,需要根据代码被执行的频率而定。

而这些需要被编译为本地代码的字节码被称为**“热点代码”**,JIT编译器在运行时会针对那些频繁被调用的代码进行深度优化,将其直接转换为机器码。

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

如何界定次数多不多呢?依靠热点探测技术。

2、热点探测

HotSpot采用的热点探测技术是基于计数器的热点探测。

采用基于计数器的热点探测,HotSpot会为每一个方法都建立2种不同的计数器,分别为:调用计数器和回边计数器

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

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

方法统计计数器

Client:1500;Server10000;超过阈值就会触发JIT。

存在热度衰减,即一定时间内执行调用次数不足,会热度减半,这端时间称为:半衰周期

方法调用计数器

图1 方法统计计数器
回边计数器

回边计数器

图2 回边计数器

4.6 Hotspot程序执行的方式

缺省情况下,Hotspot采用解析器、编译器混合的方式。开发人员可以根据不同的使用场景,通过命令设置采用不同的的方式。

-Xint:完全采用解析器的方式

-Xcomp:完全采用即时编译器的方式,如果即时编译器出现问题,解析器介入执行

-Xmixed:采用解析器+即时编译器的混合方式

Hotspot中JIT分类

在Hotspot中,内嵌了2个JIT编译器,分别是Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器、C2编译器。开发人员可以通过命令显示指定采用哪种编译器

-client:指定Java虚拟机运行在Client 环境下,采用C1编译器;C1编译器会对字节码进行简单可靠的优化,耗时短。已达到更快的编译速度。

-server:指定Java虚拟机运行在Server环境下,采用C2编译器;C2进行耗时较长的优化,以及激进的优化,但执行效率高

C1、C2的不同优化策略

在不同的编译器上有不同的优化策略

1、C1编译器

  • 方法内联:将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递以及跳转过程。
  • 去虚拟化:对唯一的实现类进行内联。
  • 冗余消除:在运行期把不执行的代码折叠。

2、C2编译器

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

3、分层编译策略

程序解析执行可以触发C1编译器,将字节码翻译成机器码,可以进行简单的优化,也可以加上性能监控,C2编译器会根据性能监控的信息进行激进优化。

不过在JDK7之后,一旦开发人员显示指定“-Server"时,默认将会开启分层编译策略,有C1编译器和C2编译器相互协作共同执行编译任务。

补充

1、写着最后1

JDK10开始,Hotspot引入了一个全新的即时编译器:Graal编译器。

编译效果短短几年就追平了C2编译器,未来可期。但目前仍然属于实验状态。

2、AOT编译器

AOT(Ahead Of Time Complier)编译器,静态提前编译器。

JDK9引入实验性的AOT编译工具jaotc。它借助Graal编译器,将输入的Java类转换为机器码,并存放在动态共享库中。

所谓AOT编译,是与即时编译相对立的概念。即时编译是在程序运行过程中将字节码转换为机器码,并部署至托管环境中。而AOT编译 指的是在程序运行前,将字节码转换为机器码。

但是AOT破坏了”一次编译,到处运行";降低了java链接过程中的动态性。

第五章-字符串

5.1 String 的基本特性

String,字符串使用“”引起来表示。

String s1 = "tobing";					// 字面量方式给字符串赋值
String s2 = new String("helloworld");

String声明为final,不可以被继承。

String实现了Serializable接口:表示字符串是指出序列化的。

String实现了Comparable接口:表示字符串可比较。

String在JDK8以及之前内部采用final char[] value用来储存字符串数据,JDK9采用byte[]

String储存结构的变更

变更说明

Motivation
The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.

Description
We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field. The new String class will store characters encoded either as ISO-8859-1/Latin-1 (one byte per character), or as UTF-16 (two bytes per character), based upon the contents of the string. The encoding flag will indicate which encoding is used.

String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM's intrinsic string operations.

This is purely an implementation change, with no changes to existing public interfaces. There are no plans to add any new public APIs or other interfaces.

The prototyping work done to date confirms the expected reduction in memory footprint, substantial reductions of GC activity, and minor performance regressions in some corner cases.

结论:从JDK8之后开始,String再也不用char[]储存,而是改成了byte[]+编码标记,节约了一些空间。

对于StringBuffer和StringBuilder也有相应的变更。

String不可变性

String代表不可变字符序列,简称:不可变性

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
  • 当对现有字符串进行连接时,也需要重新制定内存区域赋值,不能使用原有的value进行赋值。
  • 当调用String的replace()方法修改指定字符或字符串时,也需要重新制定内存区域赋值,不能使用原有的vlaue进行赋值。
String s1 = "tobing";
s1 = "tubin";		// 重新复制
s2 += " hello ";	// 连接
s1.replace('o','u');// replace

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

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

String的String Pool(字符串常量池)是一个固定大小的HashTable,默认值大小长度是1009(JDK7)。如果String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长会直接造成调用String.intern时性能大幅度下降。

JDK6、7中,StringTable默认是1009,所以如果常量池中的字符串过多就会导致效率下降很快,此时可以通过参数进行调整。

JDK8中,StringTable默认是60013,也可以通过参数进行调整,但限制最小长度不低于1009。

-XX:StringTableSize可以设置StringTable的长度。

5.2 String的内存分配

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

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

  • 直接通过双引号声明出来的String对象会被放在常量池 中,如:String s = "tobing";
  • 如果不是用双引号声明的对象,可以直接使用String提供的intern方法。

JDK6及以前,字符串常量池放在永久代(PermGen)。

JDK7中Oracle工程师对字符串池的逻辑做了很大的改动,将字符串常量池调整到Java堆中

所有的字符串都保存在堆中,和其他普通对象一样,这样可以让你在进行调优是仅仅需要调整堆的大小。

字符串常量池概念原本使用比较多,但是这个改动使得我们有足够的理由让我们重新考虑Java7中使用String.intern()。

Java8元空间,字符串常量在堆中。

String的基本操作

Java语言规范中要求完全相同的字符串字面量,应该包含统一的Unicode字符序列(包含同一个码点字符序列的常量),并且必须是执行同一个String类实例。

5.3 String拼接操作

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

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

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

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

编译期优化1

public void test01() {
    String s1 = "a" + "b" + "c"; // 编译期优化,编译期就将前转换为:String s1 = "abc";
    String s2 = "abc";
    System.out.println(s1 == s2);// true
}

image-20201224145541647

图1 编译期优化1

编译期优化2

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

image-20201224145727369

图2 编译器优化2

常规拼接

public void test03() {
    String s1 = "a";
    String s2 = "b";
    String s3 = "ab";
    String s4 = s1 + s2; // 本质:new StringBuilder().append(s1).append(s2).toString();
    System.out.println(s3 == s4); // false
}

image-20201224145942093

图3 常规拼接
不同拼接方式的对比

日常开发中我可以通过+以及StringBuilder/StringBuffer方式来拼接字符串,我们都知道后者的性能明显要优于前者,接下来我们通过直观的测试,对比两者究竟有多大差异。

public class Demo07StringCreateCompare {
    public static void main(String[] args) {
        Demo07StringCreateCompare compare = new Demo07StringCreateCompare();
        long start = System.currentTimeMillis();
        //compare.concatByAdd(100000);        // 4022ms
        compare.concatByStringBuilder(100000); // 5 ms
        long end = System.currentTimeMillis();
        System.out.println("一共花费: " + (end - start) + " ms.");
    }
    // 加号+拼接
    public void concatByAdd(int times) {
        String src = "";
        for (int i = 0; i < times; i++) {
            src = src + "a";
        }
    }
    // StringBuilder拼接
    public void concatByStringBuilder(int times) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < times; i++) {
            sb.append("a");
        }
    }
}

从上面的程序运行的结果我们可以看出,StringBuilder方式较于加号方式的性能已经不是一个数量级

原因:

1、从字节码我们可以知道:每次使用src = src + "a";拼接时,每次循环都要新创建String/StringBuilder。而使用StringBuilder方式我们只需要创建一个String和StringBuilder。

2、我们知道对象的创建都是很消耗系统资源的,这样使用加号拼接的方式导致大量的StringBuild、String对象被创建,因此性能会下降 严重。

3、再者,加号拼接的方式在每次循环之后会剩下大量的无用对象,积累足够多是会引发GC,也是对性能的影响。

改进:

因此在实际的开发中,我们要使用StringBuilder方式代替加号拼接的方式来拼接字符串。同时,如果字符串长度上限控制,可以通过制定char长度来创建StringBuilder,这样可以减少StringBuilder内部扩容的操作。

5.4 intern方法

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

如:String msg = new String("tobing").intern();

也就是说,如果在任意字符串上调用String.intern方法,那么其返回的结果执行的类实例,比如和直接以常量形式出现的字符串实例完全相同,因此:("a" + "b" + "c").intern() == "abc"必定为true;

通俗来说,Interned String就是确保字符串在内存中只有一份拷贝(String Intern Pool中),这样就可以节省内存空间,加快字符串操作任务的执行速度。

java.lang.String @NotNull 
public String intern()
Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.
不同方式创建String

1、new String(“ab”)

public class Demo08StringCreate1 {
    public static void main(String[] args) {
        String ab = new String("ab");
    }
}

image-20201224163652119

图1 常规方式

从字节码文件中我们可以看出,new String("ab");一共创建了两个对象,一个是堆中的String对象,一个是字符串常量池中的"ab"

2、new String(“a”) + new String(“b”)

public class Demo08StringCreate {
    public static void main(String[] args) {
        String str = new String("a") + new String("b");
    }
}

问:以上代码在创建的过程中一共创建了多少个对象?

image-20201224162855498

图2 加号对象拼接

从上述字节码文件中我们可以看出,new String("a") + new String("b");一共创建了5个对象:

  • new String(“a”)
  • 字符串常量池中的"a"
  • new String(“b”)
  • 字符串常量池中的"b"
  • StringBuilder

如果再要深入StringBuilder的toString方法,我们可以发现,StringBuilder的toString只创建了一个String对象。

image-20201224163407850

图4 StringBuilder.toString
intern相关问题

1、如下代码中,分别指出Sout输出的结果:

public class Demo09StringInternProblem {
    public static void main(String[] args) {
        String s1 = new String("1");	// 此过程将"1"放入字符串常量池中
        s1.intern();					// 在此之前已经有1,直接返回字符串常量池中的1
        String s2 = "1";				// 拿到的也是字符串常量池中的1
        System.out.println(s1 == s2); 	// JDK6/7/8 false
        
        String s3 = new String("1") + new String("1");	// 这一步执行完,字符串常量池中是没有“11”的
        s3.intern();									// 在字符串常量池中生成“11”
        String s4 = "11";								// 获取字符串常量池中的“11”
        System.out.println(s3 == s4);	// JDK6:false	JDK7/8 true
    }
}

2、解答

上面程序中,者不同的JDK版本执行结果不一样。

首先是第一个sout,在JDK6/7/8都是false。

在代码中:s1储存的是堆内存中的String对象、s2是储存字符串常量池的“1”对象。因此结果是false。

接下来第二个sout,在JDK6是false、JDK7/8是true。

代码中:s1储存的是堆内存中的String对象,而且第一句执行完常量池中并没有“11”。

之所以JDK6和78结果不同的主要是s3.intern()的处理不同:

JDK6中,执行s3.intern()时,是在字符串常量池中新建一个“11”对象,因此s4执行时拿到字符串常量池的也是"11"对象,结果为false。

JDK7/8中,执行s3.intern()时,没有创建“11”,而是创建一个执行new String(“11”)的地址,这样用来节省内存。因此结果为true。

intern1
图1 JDK7、8的intern

3、扩展

将上述代码的第二部分我们转换一下顺序

public static void main(String[] args) {
    String s3 = new String("1") + new String("1"); // 相当于new String("11");
    String s4 = "11";		// 此时字符串常量池中没有“11”,将会在字符串常量池中创建“11”
    s3.intern();			// 此时再执行发现已经存在“11”
    System.out.println(s3 == s4);	// false
}
intern2
图2 不同

对比图1图2我们发现主要不同是s3.intern出现的时机。

intern补充练习

1、练习1

public class Demo10StringInternExam {
    public static void main(String[] args) {
        String x = "ab";        // 将“ab”放入字符串常量池中,并返回执行该的引用
        String s = new String("a") + new String("b");   // 堆中创建,相当于,new String("ab")
        String s2 = s.intern();         // 执行字符串常量池
        System.out.println(s2 == "ab"); // true
        System.out.println(s == "ab");  // false
    }
}

2、练习2

public class Demo10StringInternExam2 {
    public static void main(String[] args) {
        // String s1 = new String("ab");    // 会在字符串常量池中创建“ab”,但是返回堆中的String对象
        String s1 = new String("a") + new String("b");  // 不会在字符串常量池中创建“ab”,返回堆中的String对象
        s1.intern();
        String s2 = "ab";
        System.out.println(s1 == s2);
    }
}

3、总结

以上的本质就是判断字符串是保存在:堆中还是字符串常量池中。

// 第一类:字符串在字符串常量池中保存,但返回的是堆中的对象
String s1 = new String("ab");					// s1 == 堆中 串池有

// 第二类:字符串不会在字符串常量池中保存,返回堆中的对象
String s1 = new String("a") + new String("b");	// s1 == 堆中 串池无

// 第三类:字符串方法区中,但是方法区却是指向字符串常量池,即字符串常量池和堆中的是一样的
String s1 = new String("a") + new String("b");	// 串池中无,堆中有
String s2 = s1.intern();						// 串池中指向堆中,返回【串池和堆一致】
String s3 = "ab";								// 拿串池的 s1 == s2 == s3 == 堆中 == 字符串常量池中
intern总结
  • JDK6中,将字符串尝试放入字符串常量池中:
    • 如果字符串常量池中有,则不会放入。返回已有的字符串常量池中的对象。
    • 如果没有,会把此对象复制一份,返回字符串池常量池,并返回字符串常量池的地址
  • JDK7开始,将字符串对象尝试放入字符串池常量池中:
    • 如果字符串常量池中有,则不会放入。返回已有的字符串常量池中的对象。
    • 如果没有,会把对象的引用地址复制一份,放入字符串常量池中,并返回字符串常量池中的引用地址。

注意:两者不同主要是针对于对象不存在的时候,是复制对象内容,还是复制引用。

intern使用场景

大型的网站平台往往需要存储大量的字符串。比如社交网站,很多人存储:北京市、上海市等信息。这时候如果都调用intern()方法,可以明显减低内存的大小。

5.5 补充

G1的String去重操作

背景:

堆存活3数据集合String占了25%;重复的String有12.5%;String对象平均长度45。

很多大规模的Java应用瓶颈在于内存,从上面数据可以看出,很多浪费源自重复的String。G1垃圾收集器实现自动持续堆重复String对象进行去重,避免内存浪费。

去重步骤:【不准确】

访问堆中存活的String对象,检查是否要去重。

需要去重的插入去重队列,等待去重线程处理。

HashTable记录所有的String内部的char数组,去重线程判断String对象的char是否在HashTable中char数组。

如果存在一模一样的char,则会调整重复的String,让其释放原来的char数组,指向新的,从而使得重复的char数组被回收。

第六章-垃圾回收

6.1 垃圾回收概述

垃圾回收在1960就已经出现,不是Java的伴生物。垃圾收集是Java招牌,极高地提高了开发效率。如今已经是现代语言的标配。那么:

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

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

如果不回收,垃圾会越积越多,最终导致没有空闲空间给其他对象使用,导致内存溢出。

垃圾回收的好处

早期C/C++时代,垃圾基本靠人工(new-申请;delete-释放)。这种方式使得开发人员可以灵活地控制内存释放时间,但是这也给开发人员带来了内存管理的负担。如果开发人员忘记了内存的释放,就很可能会产生内存泄漏,随着时间的推移,垃圾对象不断增多,最终导致程序奔溃。

自动内存分配降低内存溢出和内存泄漏的风险;可以从繁重的内存管理释放出来,更专注于业务开发。

如今,除了Java之外,一些主流的编程语言(如C#、Python、Ruby等)都开始支持垃圾回收,这也是未来的趋势。

垃圾回收带来的担忧

自动内存回收就像一个黑匣子,如果我们过度依赖”自动“管理,这一定会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力

因此,了解JVM自动内存分配和内存回收的原理就显得尤为重要,这样当我们遇到OOM时,才能根据异常信息快速定位问题。

但需要排查各种内存溢出问题、内存泄漏问题时,对“自动管理的垃圾回收”就要实施必要的监控和调节。

Java内存回收

Java中垃圾回收可以对新生区收集,也可以对养老区收集,甚至是全堆和方法区收集。

Java堆是垃圾收集器的工作重点,收集频率由大到小:Young区(频繁收集) > Old区(较少收集) > Perm区/MateSpace(基本不动)。

6.2 垃圾标记阶段

引用计数法

1、原理

Reference Counting简单,每个对象保存一个整数引用计数属性,记录被应用的情况。

任何一个对象引用了A,A的引用计数就+1;当引用失效,A的引用计数就-1;当引用计数=0,可以进行回收。

2、优点

  • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟。

3、缺点

  • 要单独储存引用计数属性,带来了空间开销。
  • 每次赋值都要更新引用计数属性,带来了时间开销。
  • 最致命的问题,无法解决循环引用的情况,因此Java中并没有使用它。

4、总结

很多语言选择了引用计数法,如Python。

那么Python如何解决循环引用问题呢?手动解除;使用弱引用。

可达性分析算法

可达性分析算法 = 根搜索算法 = 跟踪性垃圾收集

1、原理

可达性分析算法GC Root为起点,从上到下的方式搜索被根对象连接的目标对象是否可达。

使用可达性分析之后,内存中存活的对象都会被根对象集合直接或间接连接,搜索走过的路劲成为引用链(Reference Chain)

如果目标对象没有任何引用链,则是不可达,可以被标记为垃圾。

2、优点

相对于引用计数法,可达性不仅高效、简单,而且能够避免循环引用的问题,防止内存泄漏。

3、GC Root

Java中GC Roots主要包含以下几种元素:

  • 虚拟机栈中引用的对象:如线程调用的方法使用到的参数,局部变量等。
  • 本地方法栈引用的对象
  • 方法区中类静态属性引用的对象:如Java引用的静态变量
  • 方法区中常量引用的对象:字符串常量池里的引用
  • 所有被同步锁持有的对象
  • Java虚拟机栈内部的引用:基本数据类型对应的Class对象、常驻异常方法(NullPointException、OOM)、系统类加载器。
  • 反映虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地方法缓存等。

除此之外,还会根据用于选择的垃圾收集器以及收集区域的不同,选择性地加入其它对象

只针对信息的是,必须要考虑到老年代中引用的对象不能被清楚。

小结:Root采用栈方式存放变量和指针,因此如果一个指针保存了堆内存里面的对象,但是自己又不存放在堆内存中,那就是一个Root。

4、注意

使用可达性分析时,分析工作要在保证一致性的快照中执行,如果不满足这一点,准确性就无法保证。因此在GC时必须要有STW。

image-20201225173304858
图 1 可达性分析

6.3 Finalization机制

Java提供了对象终止机制允许开发人员对对象销毁前执行之定义的处理逻辑

当垃圾对象发现没有引用一个对象,被当做垃圾回收前,总会先去调用这个对象的finalize()方法。

finalize()方法被定义在Object中,允许被重写,用于对象回收时进行资源释放。

通常在这个方法中定义一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用,因为:

  • finalize时可能会导致对象复活。
  • finalize方法的执行时间没有保障,完全由GC线程决定,极端情况下没有发生GC,finalize就没有执行机会。
  • 不正确的finalize会严重影响GC性能。

从功能上,finalize类似C++的析构函数,但本质上完全不同。

对象三状态

正是由于finalize方法的存在,虚拟机中对象可能存在以下三种状态。

  • 可触及:从根节点开始,可以到达这个对象
  • 可复活:对象的所有引用都被释放,但可能在finalize中复活
  • 不可触及:对象的finalize被调用,没有被复活;不可触达的对象不可能被复活,因为finalize只能被调用一次。

从所有根节点都无法访问到某个对象,说明对象不可用了。一般情况下,对象需要被回收,但实际上也并非非死不可,这时可以利用finalize实现复活。

回收过程

判断一个对象(obj)释放可以回收,知识经历两次标记过程:

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

  2. 进行筛选,判断此对象释放有必要执行finalize方法

    1. 如果obj没重写finalize或finalize已经被调用过,则虚拟机视为没有必要执行,直接不可触及;
    2. 如果obj重写finalize,且未被执行过,obj会被插入到F-Queue,由虚拟机自动创建一个低优先级的Finalizer线程触发其finalize方法
    3. finalize方法是对象逃脱死亡的最后机会。

6.4 垃圾清除阶段

在标记阶段区分出了存活对象和死亡对象之后,GC接下来就是要执行回收操作,释放掉那些无用对象占用的内存,以便于有足够内存分配给新的对象。

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

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

标记清除(Mark-Sweep)是一种基础、常见的算法,于1960年提出并应用于Lisp语言。

1、执行原理

当有效内存耗尽时,将会停止程序(SWT),然后执行:标记、清除

  • 标记:Collector从GC Root开始遍历,标记所有被引用的对象。一般是在Header中记录为可达对象。
  • 清除:Collector线性遍历整个堆内存,发现对象头Header没有标记为可达的,则将其回收。

2、缺点

  • 效率不算高
  • GC的时候,会SWT,导致用户体验差
  • 清除出来的空闲空间不是连续的,产生内存碎片。需要维护一个空闲表。

注意:如何理解清除?

清除不是真的将内存全部置为0等,而是把需要删除的对象地址保存在空闲地址表,表示该地址空间是空闲的。当下次有新的对象需要加载的时候,判断垃圾的位置是否足够,足够则存放。

标记清除算法
图1 标记清除算法
复制算法

为解决标记清除的效率缺陷,复制算法(Copying)发明与1963年的论文,后面该算法别成功引入Lisp语言。

1、执行原理

将内存空间分为两块,每次只使用一块。

当垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象。

交换两个内存的角色,最后完成垃圾回收。

2、优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制之后保证空间连续,无内存碎片。

3、缺点

  • 需要占用2倍内存空间
  • 对于G1这种拆分成大量Region的GC,复制不是移动,意味着要维护region之间的对象引用关系,这对内存和时间都不友好。

注意:

复制算法适用于系统中垃圾对象较多,存活数量较少的区域。如年轻代中很多对象朝生夕死。

复制算法
图2 复制算法
标记压缩算法

复制算法的高效是有前提的,即垃圾对象较多,存活的较少,因此复制算法适用于“朝生夕死”的新生区。而在老年代,对象往往都是稳定存活的,此时复制算法已经不适合了。因此,基于老年代的垃圾回收特性,需要使用其他算法–标记压缩算法。

此前我们还提到过标记–清除算法,但是该算法不仅效率底下,还会产生内存碎片。因此JVM设计者在标记–清除的基础上进行了改进,标记–压缩(Mark-Compact)算法应运而生。

标记–压缩算法发布于1970年,很多现代垃圾收集器都使用了Mark-Compact或者其改进版。

1、执行原理

标记压缩算法最终效果等同于标记清除之后再进行内存碎片整理,因此也称为:标记–清除–压缩算法。

第一步和标记–清除基本相同,先对非垃圾对象进行分配。

第二步对标记为存活的对象进行内存整理,这样JVM就可以只需要一个起始内存地址,可以不用维护空闲分区表。

指针碰撞:

将内存分为两边:占用|空闲,中间使用指针标记。当对象分配新内存的时候,维护指针。这种分配方式称为指针碰撞。

2、优点

  • 避免了标记清除对象分配分散的缺点,不用维护空闲分区表
  • 避免了复制算法内存减半的高额代价

3、缺点

  • 效率上,低于复制算法
  • 因为压缩过程中涉及到对象移动,因此要维护对象引用的地址
  • 移动过程中要STW
图3 标记压缩算法
总结
指标Mark-SweepMark-CompactCopying
速度中等最慢最快
空间开销通常是存活对象的2倍
移动对象

没有最好,只有最合适。

6.5 分代收集算法

没有最好,只有最合适。JVM中不同内存区域的对象特征不一样,我们要根据不同特征选择不同的算法。

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

Hotspot基于分代的概念,根据不同区域采用不同的策略:

  • 年轻代(Young Gen)

特点:区域相对较少,对象声明周期短、存活率低、回收频率高。

算法:适用于复制算法,复制算法效率与当前存活对象大小有关系,因此很适合年轻代的回收,而复制算法内存利用率不该,在Hotspot中采用两个survivor来缓解。

  • 老年代:

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

算法:存活大量对象,不适合复制算法。一般采用复制-清除,复制-清除-压缩。

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

Sweep阶段的开销与所管理区域的大小正比

Compact阶段开销与存活对象成正比

如:Hotspot中的CMS,CMS基于Mark-Sweep实现,对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿:当内存回收不佳,将采用Serial Old执行FullGC以达到对老年代内存整理。

增量收集算法

前面提到垃圾收集过程会导致STW,如果收集时间过长就会导致STW更长,严重影响用户体验和系统稳定性。为了解决这个问题,引出了增量收集算法(Incremental Collecting)。

1、基本思想

一次性将很长一段时间的垃圾全部收集完,将会导致STW的时间过长,那么我们可以考虑每次收集一小片区域的内存空间,使得每次的STW时间减少。

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

2、缺点

上面的方式虽然减少了STW的时间,但是会导致收集线程和用户线程切换的频率更高,而线程上下文的切换比较消耗资源,最终导致系统吞吐量下降。

分区算法

相同条件下,堆越大,一次GC花费的时间越小,GC时STW时间越长。考虑将堆分为若干小区域(Region),每次不用全部收集完,而是合理收集部分Region,从而实现减少GC的停顿时间。

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

分区算法G1
图1 分区算法

6.6 垃圾回收常用概念

System.gc()
内存泄漏与内存溢出
System.gc()

默认情况下通过System.gc()调用,会显式触发Full GC,同时对新生代和老年代进行回收,尝试释放垃圾对象。

但是System.gc()调用附带一个免责声明,无法保证垃圾回收的产生。

JVM实现可以通过System.gc()调用来决定JVM的GC行为。而一般情况下垃圾回收是自动进行的,无须手动触发,否则将会很麻烦。一些特殊情况,如我们要进行性能测试,可以调用System.gc()。

内存溢出OOM

内存溢出相对于内存泄漏更加容易理解,内存溢出是引发内存崩溃的主要原因之一。

随着GC的发展,一般情况下,除非内存申请太快,造成垃圾回收跟不上内存消耗的速度,否则不太容易出现OOM。

大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行回来一次独占式Full GC操作,这是会释放大量内存。

Javadoc对OOM的解释:没有空闲内存,并且垃圾收集器也无法提供更多内存。

OOM产生的原因可能有两个:

1)JVM的堆内存设置不够

可能存在内存泄漏;也可能是堆的大小设置不当(可以通过-Xms、-Xmx设置)。

2)代码中创建大量大对象,并且长时间不能被回收

JDK7以及之前,永久代大小很有限,JVM对永久代回收非常不积极,因此当我们不断添加新的类型的时候,永久代出现OOM非常常见,尤其是运行时存在大量动态类型的场合;类似intern字符串缓存占用太多空间也会导致OOM

JDK8及之后,随着元数据的引入,方法区内存已经不再如此窘迫,OOM有所改善。

在OOM之前,通常垃圾收集都会触发,尽可能清理空间。

  • 引用分析中,JVM会尝试释放软引用的对象
  • java.nio.BIts.reserveMemory()方法可以清楚看到,System.gc()会被调用。
内存泄漏Memory Leak

内存泄漏,又称内存渗漏。

严格意义上:只有对象不再被应用程序使用,但GC有无法回收他们的情况,才叫内存溢出。

宽泛意义上:会导致对象生命周期变长甚至导致OOM的。

尽管内存泄漏不会立即导致程序崩溃,但是一旦发生内存泄漏,程序中的可用内存变化慢慢被蚕食,直到消耗完所有内存,最终导致OOM。

注意:此次的储存内存知道是虚拟内存而不是物理内存,虚拟内存大小取决于磁盘交换区设定的大小。

常见内存泄漏情况:

1)单例模式

单例的生命周期和应用程序一样长,因此单例程序中,如果持有外部对象的引用将会导致该外部对象一直无法被释放,导致内存泄漏。

2)提供close的资源未被关闭

数据库连接、Socket连接、IO连接等。

STW

Stop The Word,指的是GC发生过程中,会产生应用程序的停顿。停顿产生时整个应用线程会被暂停,无响应,好像卡死一般。

可达性分析算法中枚举根节点会导致STW。

  • 分析工作必须要在能保证一致性的快照中进行。
  • 一致性指的是分析期间整个执行系统看起来好像被冻结在某个时间上。
  • 如果引用关系不断变化,那么分析得出的结果也无法保证。

STW中断会在GC完毕之后恢复,频繁中断会导致用户体验差。

所有的GC都存在STW

哪怕是G1也不能完全避免STW发生,只能说垃圾收集器越来越有效,效率越来越高,STW尽可能缩短。

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

开发中不要显示调用System.gc(),会导致STW发生。

并发与并行

1、并发Concurrent

OS中,一个时间段内几个程序都处于启动运行到运行外币的之间,并且这个几个程序在同一个处理器运行。

并发不是真正的同时执行,而是CPU把一个时间段划分为若干小的时间,然后在这个区间之间切换,引用切换的速度非常快,看起来像是多个应用程序同事执行。

2、并行Parallel

当系统有一个以上的CPU时,当一个CPU执行一个线程时另外一个CPU可以执行另外一个线程,两个线程互不抢占CPU资源,可以同时执行,我们成为并行(Parallel)。

决定并行的因素不是CPU个是,而是CPU核心数,一个CPU中如果有多个核心也可以实现并行。

3、并行与并发

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

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

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

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

只有在多个CPU或多核CPU才会发生并行,否则都是并发。

4、Java中垃圾回收的并行与并发

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

1)并行(Parallel):指多条垃圾收集器并行工作,但此时用户线程仍处于等待状态。

2)串行(Serial):与并行相对,单线程执行;如果内存不足,则程序执行,JVM启动GC收集,回收完毕,继续程序执行。

image-20201227155503143
图1 垃圾的并发与并行

3)并发(Concurrent):指用户线程垃圾收集线程同时执行(不一定并行,可能是快速交替),垃圾回收线程执行时不会中断用户线程。CMS、G1

image-20201227155719567
图2 并发垃圾收集
安全区域与安全点

1)安全点(SafePoint)

程序执行时并非所有的地方都能停顿下来开始GC,只能在特定的位置才能停下来,这些位置称为“安全点”。

Safe Ponit的选择很重要,太多会导致运行性能下降,太少则会导致GC等待的时间太长。由于大部分的指令都执行很快,通常会选择“具有程序长时间执行的特征”为标准。

如:选一个较长的指令作为Safe Point,如:方法调用、循环跳转和异常跳转。

2)安全区域(Safe Region)

SafePoint机制可以使得程序在执行的时候,不用太久就可以进入GC的SafePoint。但是如果程序不执行,如程序出于Sleep状态或Blocked状态,这时无法响应JVM的中断请求,这时候无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。这时候要通过Safe Region来解决。

安全区域是指一段代码中,对象的引用关系不会发生改变,这个区域中的任何位置开始GC都是安全。

实际运行时:

当线程运行到Safe Region的代码时,实现标识已经进入SafeRegion,如果这段时间发生两个GC,JVM会忽略标识为SafeRegion状态的线程;

当线程即将离开SafeRegion是,会检查JVM是否已经完成GC,如果完成了,则继续执行,否则线程必须等待直到收到可以安全离开SafeRegion的信号为止。

6.7 再谈引用

强软弱虚引用(Strong、Soft、Weak、Phantom)4种强度依次降低。

除了强引用其他都可以在java.lang.ref包下看到。由于是在lang包,可以直接使用。

引用类型回收时机补充
StrongReference永不回收最普遍的引用,new
SoftReference空间不足回收常用于缓存
WeakReference发生GC即回收常用于缓存
PhantomReference不用回收监听对象回收时给一个通知

注意:此处的回收时回收关联的对象

强引用-不回收

最常见的引用类型,99%都是,是默认的引用类型。

只要强引用可触及就永远不会被回收,即使OOM。

强引用是Java内存泄漏的主要原因。

软引用-内存不足即回收

弱引用用来描述一些还有用的,但非必须的对象。

只被软引用关联的对象,系统将要发生内存溢出异常时,会把这些对象作为回收的范围进行回收,如果回收之后内存仍然不足,就会爆出OOM。

软引用常用于:缓存。如果内存足够就暂时保留,内存不足则释放其占用的内存。

垃圾回收器在某个时刻回收软可达对象的时候,会清除软引用,并可选地把引用存放在一个引用队列(Reference Queue)。

弱引用-发现即回收

也是用来描述非必需的对象。

创建之后一发生GC即被回收。

和软引用一样,在构造弱引用时,可以指定一个引用队列,当弱引用对象被回收的时候,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。

虚引用 -对象回收跟踪

WeakReference,幻影引用、幽灵引用,是所有引用中最弱的一个。

一个对象释放有虚引用,完全不会决定对象的生命周期。如果一个对象持有虚引用,那么它和没引用几乎是一样的,随时都可能被垃圾回收器回收。

虚引用不能单独使用,也无法通过虚引用获取被引用的对象,试图get对象的时候,总是null。

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾的回收过程。不然:能在垃圾被回收的时候收到一个系统通知。

虚引用使用时必须要和一个引用队列一起使用,虚引用创建的时候必须要提供一个引用度列作为参数。当垃圾器准备回收对象的时候,发现它还有虚引用关联,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

虚引用可以跟踪对象回收情况,可以将一些资源释放操作放置在虚引用中执行纪录。

终结器引用

Final Reference,用于实现对象的finalize方法,可以称为终结器引用。

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

第七章-垃圾收集器

7.1 概述

JVM规范中并没有对垃圾收集器有太多的规定,可以有不同厂商、不同版本的JVM实现。

随着JDK的快速发展,GC也有了很多版本。

评估GC的性能指标
  • 吞吐量:运行用户代码占程序运行总时间的比例。(总运行时间=程序运行时间+内存回收时间)
  • 暂停时间:执行垃圾收集时,程序工作线程被暂停的时间。
  • 内存占用:Java堆区占用的大小。
  • 垃圾收集开销:吞吐量的补数,收集站总时间的比例。
  • 收集频率:收集发生的频率。
  • 快速:一个对象从诞生到收集的时间。

内存占用、吞吐量、暂停时间,三种构成了不可能三角形,优秀的收集器最多只能同时实现其中两项。

目前来讲,主要看重点:吞吐量、暂停时间。

性能指标-吞吐量

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

吞吐量优先的情况下,应用程序容忍较高的暂停时间,快速响应不是优先考虑的。

image-20201227175855596
图1 吞吐量优先
性能指标-暂停时间

暂停时间 是应用程序线程暂停,GC执行的时间。

image-20201227180001233
图2 暂停时间优先
吞吐量VS暂停时间

吞吐量适用于“生产性”工作。

低暂停时间适合于交互式应用程序。

两者矛盾的,因此目前是:在最大吞吐量优先的情况下,降低停顿时间。

7.2 垃圾收集器的发展

年份名称特点JDK版本
1999年Serial GC第一款GC1.3.1
2002年Parallel GC 和 CMSParallel成为JDK6Hotspot默认GCJDK1.4.2
2012年G1JDK7
2017年G1G1成JDK9默认垃圾收集器JDK9
2018年G1、ZGCG1在JDK10进行改善JDK10
2019年G1、Shenandoah增强G1、低停顿时间JDK12
2019年ZGC增强ZGCJDK13
2020年删除CMS、扩展ZGCJDK14
7款经典垃圾收集器

串行回收器:Serial、Serial Old

并行回收器:PraNew、Parallel Scavenge、Parallel Old

并发回收器:CMS、G1

之所以有这么多的垃圾收集器,是因为没有万能的收集器,只有最合适的。

垃圾收集器与垃圾分代的关系
图1 经典垃圾收集器
垃圾收集器的组合
垃圾收集器的组合关系
图2 垃圾收集器组合使用
  1. 两个垃圾收集器之间有连线,表示他们可以搭配使用。
  2. 其中Serial Old GC作为CMS GC失败的后背方案
  3. 红色虚线:JDK1.8废弃,JDK1.9完全取消
  4. 绿色虚线:JDK14弃用Parallel Scavenge和SerialOld
  5. 青色虚线:JDK14删除CMS
查看默认垃圾收集器
  • -XX:+PrintCommandLineFlags: 查看命令行相关参数,包含使用的垃圾收集器。
  • jinfo -flag 相关垃圾收集器参数 进程id。
Serial串行垃圾收集器

Serial收集器是最基本、最悠久的垃圾收集器。JDK1.3之后新生代选择。

Serial收集器是HotSpot中Client模式下默认的新生代垃圾收集器。

Serial收集器采用复制算法、串行回收和STW方式执行内存回收。

Serial收集器提供了老年代的垃圾收集器Serial Old,Serial Old采用标记–压缩、串行回收和STW进行内存回收。

Serial Old有两个用途:①与新生代的Parallel Scavenge配合使用 ②作为老年代CMS的后备垃圾收集方案。

Serial回收器
图1 Serial收集器

这个垃圾收集器是一个单线程垃圾收集器,但他的“单线程”意义不仅仅体现他只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它进行垃圾收集的时候,必须暂停其他所有的的工作线程,直到它收集完成。

Serial收集器简单高效,在单个CPU环境下由于没有线程切换的开销,专心进行垃圾收集,可以获得最高的单线程收集效率。

Serial收集器可以通过命令:-XX:+UseSerialGC来指定新生区和老年代使用串行收集器。这时,新生区采用Serial GC,老年代使用Serial Old GC。

ParNew并行垃圾收集器

ParNew收集器是Serial收集器的多线程版本。Par+New ==> Parallel New 新生区

ParNew收集器除了采用并行回收的方式除外,和Serial并无太多区别。ParNew新生代采用复制算法、STW机制。

ParNew收集器可以通过参数-XX:+UseParNewGC手动指定使用。

ParNew收集器可以通过参数-XX:ParallelGCThreads限制线程数量,默认开启和CPU核心数一直。

ParnewQQ截图20201229153542
图2 ParNew并行垃圾收集器

对于新生代,回收次数频繁,采用并行方式高效;

对于老年代,回收次数少,采用串行方式节省资源。(节省线程切换的资源)

【注意】ParNew虽然是并行垃圾收集器,是Serial的并行版本,但性能并不总是比Serial要高。

在单CPU环境下,由于Serial不需要上下文的切换,避免了资源的不必要损耗,这时候Serial比ParNew性能要好。

Parallel Scavenge收集器

Parallel Scavenge收集器同样采用复制算法、STW机制来进行内存回收。

Parallel Scavenge收集器目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器,这是和ParNew的不同。(吞吐量:Throughput)

Parallel Scavenge收集器还具有自适应调节策略,这也是和ParNew的一个重要区别。

ParallelScavenge
图3 Parallel Scavenge收集器

Parallel Scavenge收集器是JDK8默认的垃圾收集器,具有高吞吐量的特点。

高吞吐量可以高效地利用CPU时间,适用于后台运算,低交互的环境。如:批量处理、订单处理、工资支付、科学计算等。

JDK1.6引入了Parallel Old替代了原理老年期的搭档Serial Old。

Parallel Old采用标记压缩算法,同样基于并行回收、STW机制。

Parallel Scavenge参数配置:

  • -XX:+UseParallelGC:手动指定年轻代使用

  • -XX:+UseParallelOld:手动指定老年代使用

  • -XX:ParallelGCThread:指定年轻代并行收集器的线程数。

    CPU核心数 <= 8 :线程数 = 核心数

    CPU核心数 > 8 :线程数 = 3 + [(5 * CPU) / 8]

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

    可以自动调整新生代大小、Eden和Survivor的比例、晋升老年代的对象年龄、已经达到堆大小、吞吐量和停顿时间的平衡点。

  • -XX:MaxGCPauseMillis:设置垃圾收集器的最大停顿时间(STW时间),谨慎使用。

  • -XX:GCTimeRatio:设置垃圾收集时间占总时间的比例,范围(0,100)。

Concurrent Mark Sweep收集器

CMS收集器是从JDK1.5推出的,强交互的划时代的垃圾收集器。

CMS收集器是HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集器线程和用户线程同时工作。

CMS收集器的关注点事尽快缩短垃圾收集时STW的时间,停顿时间短可以提高用户的体验。

互联网网站或B/S系统重视服务响应,适合使用CMS。

CMS收集器采用标记-清除算法,并且也会STW。

不幸的是,CMS作为老年代申请,无法和新生代ParallelScavenge配合工作,因此CMS只能与新生代的ParNew或Serial使用。

CMS
图4 CMS工作原理

CMS工作流程比较复杂,分为4个阶段:标记-->并发标记--> 重新标记-->并发清除

  • 初始阶段(Initial-Mark):仅仅标记处于GC Roots直接关联的对象,会STW,但是时间很短;
  • 并发标记阶段(Concurrent-Mark):从GC Roots直接关联的对象开始,遍历整个图,耗时长,但不用停顿用户线程;
  • 重新标记阶段(Remark):修正并发标记期间的变动,比初始阶段时间稍长,但比并发标记阶段短,会STW,时间短;
  • 并发清除阶段(Concurrent-Sweep):清理标记为垃圾的对象,释放内存空间,不需要移动存活对象,因此和用户线程同时执行。

尽管CMS是并发回收,但是初始标记和重新标记阶段都要STW,但由于两个阶段花费的时间很少,因此整体上是低停顿时间的。

再者,引用垃圾收集阶段用户线程没有中断,因此CMS回收过程过程中要保证用户线程有足够的空间可用,即:不能像他收集器一样,等到满了才收集,而是到达预定的阈值就要开始清除

如果运行CMS期间,预留的内存访问满足程序需要,就会导致:Concurrent Model Failure。这时候需要启动预留方案:Serial Old重新收集,这时候停顿时间会很高。

CMS采用标记-清除,意味着每次收集不可避免存在碎片,这时在为新对象分配的时候就无法使用指针碰撞而只能使用空闲列表来执行内存分配。

这时有人问,既然会造成内存碎片,为什么不采用Mark Compact?

这是因为并发清除的时候,用户线程也在执行,Compact意味着要移动内存,这时候用户线程该怎么办呢?因此Mark Compact适合STW的场景。

CMS优点

并发收集,低延时。

CMS弊端

1)会产生内存碎片:导致并发清除之后用户可用空间不足,访问分配大对象,这是还要触发Full GC。

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

3)CMS收集器无法处理浮动垃圾

CMS常用参数

  • -XX:+UseCMSCompactAtFullCollection:指定执行完FullGC之后对内存进行压缩整理
  • -XX:CMSFullGCsBeforeCompaction:设置执行多少次Full GC之后进行压缩
  • -XX:ParallelCMSThreads:设置CMS线程数量
  • -XX:+UseConcMarkSweepGC:手动指定使用CMS执行内存回收任务
  • -XX:CMSInitiatingOccupanyFraction:设置堆内存占用率阈值

JDK9:CMS标记为Deprecate

JDK14:删除CMS

G1收集器

Java7 Update 4之后,引入了一个新的垃圾收集器G1,是当今垃圾收集器发展最前沿的成果之一。

G1适应当今不断扩大的内存和不断增加的处理器数量。

G1的目标是在延迟可控的情况下,尽可能高的吞吐量。

为什么叫G1?

G1是一个并行收集器,把内存分为多个不相关的Region,使用不同的Region表示Eden、Survivor0、Survivor1,老年代等。

G1每次根据收集的时间,优先回收价值较大的Region。

由于这种方式侧重于回收垃圾最大的Region,因此叫:垃圾优先(Garbage First)。

G1的优势

与其他GC相比,G1采用了全新的分区算法,特点如下:

1)并行与并发

并行性:回收时,多个GC线程同时执行,有效利用多核,STW。

并发性:拥有与应用线程交替执行的能力,不会在整个回收阶段一直阻塞用户线程。

2)分代收集

G1依旧属于分代型垃圾收集器,区分老年和新生。但是从堆结构上,不要求整个Eden或年轻代、老年代物理连续,也不再坚持固定大小和数量。

将堆分为若干Region、包含垃圾的Eden等。

G1同时兼顾年轻代和老年代,区别于其他收集器。

3)空间整合

G1将内存划分为一个个Region之后,以Region为单位进行回收,Region之间是复制算法,但整体上可以看做标记-压缩,可以避免内存碎片。可以使得程序可以长时间运行,不至于分配大对象时,由于无法找到连续空间而触发下一次GC。

4)可预测的停顿时间(软实时)

这是相对于CMS来说G1的一大优势,使用者可以指定一个长度M,消耗在垃圾上的时间不能超过N毫秒。

由于G1采用分区的方式,可以动态缩小回收范围。同时G1可以跟踪各个Region的垃圾堆积价值大小,在后台维护了一个优先列表,每次可以优先回收价值最大的Region,确保每次G1可以获取尽可能高的效率。

G1的缺点

相比于CMS,G1的优势还不具备全方位压倒性。在小内存上CMS表现大概率高于G1,而G1大内存的应用上能发挥其优势,平衡点在6-8G之间。

G1参数的设置

  • -XX:+UserG1GC:指定使用G1进行垃圾收集
  • -XX:G1HeapRegionSize:设置每个Region的大小,值是2的幂,范围时1M-32M。
  • -XX:MaxGCPauseMillis:期待GC最大的停顿时间,JVM会尽力满足,但不保证,默认200ms。
  • -XX:ParallelGCThread:STW工作线程数的值,最大设置8。
  • -XX:ConcGCThreads:并发标记的线程数,一半是ParallelGCThread的1/4。
  • -XX:InitiatingHeapOccupancyPrecent:触发并发GC周期的堆占用阈值。超过会触发GC,默认45。

G1的常用操作步骤

  1. 开启G1垃圾收集器
  2. 设置堆的最大内存
  3. 设置最大停顿时间

G1中还提供了三种垃圾收集的模式:YoungGC、MixedGC和FullGC,在不同条件下会触发。

G1使用的场景

服务端应用,大内存、多处理器。

主要引用是降低GC延迟,并具有大堆的应用程序提供解决方案。

在堆大小约6G或更大的时候,可预测的暂停时间可以抵御0.5s(每次清理部分Region,而不是全部,一次,可以保证每次GC停顿时间不会很长)

用来替换JDK1.5的CMS,下列情况G1比CMS好:

  • 超过50%的Java堆被活动数据占用;
  • 对象分配的频率或年代提升频率变化很大;
  • GC停顿时间过长。
分区Region

使用G1是,G1将堆分为2048个大小相等的Region,每个Region根据时间情况而定,整体别控制在1M-32M之间,且为2的幂。

可以通过参数-XX:G1HeapRegionSize设定,所有的Region大小相同,且在JVM生命周期内不会改变。

虽然仍然保留新生代、养老区,但不再需要在物理上连续,它们可以是一部分Region,通过Region动态分配的方式实现逻辑上的连续。

G1
图1 G1Region分区

一个Regin可能是属于Eden、Survivor或者Old/Tenured内存区域。但一个Region只能属于一个角色。

G1垃圾收集器添加了一个新的内存区域,Humongous内存区域,主要用于储存大对象,超过1.5个Region放到H中。

设置H的原因:

对于堆中的大对象,默认会被会放到老年区,但是如果是一个短期存在的大对象,就会对垃圾收集器带来负面影响。

为了解决这个问题,G1划分了H区,用来专门储存大对象,如果H放不下,G1会寻找连续的H区。

为了找到足够的H区,有时候不得不启动Full GC。

G1的大多数行为把H区看作老年代了看待。

7.3 G1垃圾收集器

G1垃圾回收过程

G1垃圾回收过程主要包含以下几个环节:

  1. 年轻代GC(Young GC)
  2. 老年代并发标记过程(Concurrent Marking)
  3. 混合回收(Mixed GC)
  4. 【非必然】单线程、独占式、高强度的Full GC还是继续存在。是针对GC的评估失败提供的一种失败保护机制,即强力回收。
G1垃圾回收的过程
图1 G1垃圾收集的过程

【年轻代GC】应用程序分配内存,当年轻代的Eden区用尽时,年轻代开始回收。G1的年轻代收集阶段是一个并行独占的收集器。回收期间,G1暂停所有的应用线程,启动多线程执行年轻代的回收,然后从年轻代区间移动存活对象到Survivor区或者老年区,或者两者都存在。

【并发标记过程】堆内存达到一定值(默认45%)时,开始老年代并发标记过程。

【混合回收】并发标记过程之后,紧接着一个混合回收期。在此期间,G1从老年代移动存活对象到空闲区,这些空闲区成为老年代的一部分。和年轻代不同,老年代的G1回收和其它GC不同,G1的老年代回收不需要将整个老年代回收,一次只需要扫描、回收一小部分Region就行。同时,老年代Region和年轻代一起被回收。

Remember Set

一个区域Region不可能是孤立的,不同的Region之间会存在相互引用联系。

一个Region中的对象可能被其他任意Region的对象引用,因此在判断对象存活的时候,怎么判断是否可以回收呢?是扫描整个堆吗?

以上的问题在其他的分代收集算法中也存在,显然扫描整个堆很耗费资源,特别是G1。(G1运用于大堆,大堆扫描的范围更大)

为了避免扫描全堆的问题,JVM使用Remember Set。

每一个Region都有一个Remember Set;

每次Reference类型数据写操作的时候,会产生一个Write Barrier暂时中断操作;

中断之后检查写入引用的对象是否和该Reference类型数据在不同的Region;

如果不同Region,则通过CardTable把相关引用信息记录到引用指向对象所在的Region对应的RememberSet中;

当垃圾扫描的时候,将RememberSet作为临时的GC Root;

RSet的方式可以保证全堆扫描,也不会遗漏,其他分代收集算法采用RSet来规避全堆扫描。

G1Rset

图2 Remember Set
G1回收过程一:年轻代回收

JVM启动是,G1先准备好Eden区,程序运行过程中不断创建对象到Eden区,当Eden区耗尽,G1会启动年轻代垃圾回收过程。

  1. 扫描根

    根指的是static比变量执行的对象,正在执行方法调用链上的局部变量等。

    根引用连同Rsset记录外部作为扫描存活对象的入口。(GC Root + Rset)

  2. 更新Rset

    处理Dirty Card Queue中的Card,更新Rest。

    更新完Rset之后,Rset可以准确反映老年代对所在的内存分段中的对象的引用。

    Dirty Card Queue

    对于引用赋值语句:object.field = obj,JVM会在执行之前和执行之后执行特殊的操作。

    在Dirty Card Queue中入队一个保存了对象的引用信息的Card。

    在年轻的回收的时候,会对Dirty Card Queue中所有的Card进行处理,以更新RSet,保证Rset事实准确的反映引用关系。

    为什么不在引用赋值的时候直接更新Rset呢?

    这是为了性能需要,RSet的处理需要线程同步,开销很大,使用队列性能会好很多。

  3. 处理Rset

    通过RSet,识别被老年对象执行Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

  4. 复制对象

    相当于复制阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存端中存活的对象如果未达到阈值就年龄加1,达到阈值就被复制到Old区。

    如果Survivor空间不足,Eden空间中的数据会直接晋升到老年代。

  5. 处理引用

    处理Soft、Weak、Phantom、Final、JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,目标内存的对象都是连续储存的,没有碎片,因此复制的过程可以达到内存整理的效果,减少碎片。

G1回收过程二:并发标记过程
  1. 初始标记阶段

    标记从根节点直接可达的对象。会触发一次年轻代GC,STW。

  2. 根区域扫描

    扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这个一个过程必须咋YoungGC之前完成。

  3. 并发标记

    整个堆中进行并发标记,整个过程可能会被Young GC中断。在并发标记过程中,如果发现区域对象中的所有对象都是垃圾,那么整个区域会被回收。同时并发标记过程中,会计算每个区域的对象活性,即区域中存活对象的比例,以确定回收的优先级。

  4. 再次标记

    由于应用程序持续运行,需要修正上一次的标记结果。此过程会STW。采用比CMS更快的初始快照算法。SATB

  5. 独占清理

    计算各个区域存活对象的比例和GC回收比例,并进行排序,识别可以混合回收的区域。为下一阶段在准备。STW

    此过程不会进行实际的垃圾收集

  6. 并发清理阶段

    识别并清理完全空闲的区域。

G1回收过程三:混合回收

随着越来越多的对象晋升到老年代Old Region,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC。

该算法不是一个Old GC,处理回收整个Young Region,还会回收部分Old Region。

注意:是一部分老年代,而不是全部老年代。

image-20201231102120928
图3 混合回收
G1回收过程四:Full GC

G1初衷就是要避免Full GC。如果以上三个过程不能正常执行,G1会STW,使用单线程的内存回收算法,进行垃圾回收,性能会很长,STW时间很长。

Full GC显然是要避免的,一旦发生Full GC,最好能做出相应的调整。

如堆太小,G1在复制存活对象的时候没有足够的空内存分段可用,就会退回到Full GC。这种情况可用通过增大内存来避免。

导致Full GC的原因可能有两个:

  1. Evacuation的时候没有足够的to-space来存放晋升的对象;
  2. 并发处理过程完成之前空间耗尽
G1使用建议

避免使用-Xmn或-XX:NewRatio等显式社会年轻代大小;

固定年轻代大小会覆盖暂停时间的目标;

暂停时间目标不要太苛刻,否则会严重影响吞吐量。

7种经典垃圾回收器总结
垃圾收集器分类作用位置使用算法特点使用场景
Serial串行新生代复制算法高响应单CPU、Client模式
ParNew并行新生代复制算法高响应多CPU、Server配合CMS
Parallel并行新生代复制算法高吞吐量后台运行、非交互
Serial Old串行老年代标记压缩高响应单CPU、Client模式
Parallel Old并行老年代标记压缩高吞吐量后台运算、非交互
CMS并发老年代标记清除高响应基于互联网B/S
G1并发、并行新、老标记压缩+复制高响应面向服务端应用
Garbage Collector选择
  1. 优先调整堆的大小让JVM自适应完成;
  2. 如果内存小于100M,使用串行收集器;
  3. 单核、单机、没有停顿时间要求选择串行;
  4. 多CPU、要求高吞吐量、允许停顿时间高于1S,选择并行或者JVM自己选择;
  5. 多CPU、要求高响应、低停顿时间,使用并发收集器。官方推荐G1。

7.4 GC日志

通过阅读GC日志,可以了解Java虚拟机内存分配与回收的策略。

内存分配与垃圾回收的相关参数
  • -XX:PrintGC 输出GC日志。类似:-verbose:gc
  • -XX:PrintGCDetails 输出GC的详细日志
  • -XX:PrintGCTimeStamps 输出GC的时间戳(基准时间)
  • -XX:PrintGCDateStamps 输出GC的时间戳(日期时间)
  • -XX:+PrintHeapAtGC 进行GC的前后打印堆的信息
  • -Xloggc:…/logs/gc.log 日志文件的输出路径

1、-XX:PrintGCDetails

image-20201231112401814
图1 GC详细日志信息

参数解析

GC、FullGC:GC的类型

Allocation Failure:GC发生的原因

PSYoungGen:使用Parallel Scavenge并行垃圾收集器的新生代GC前后大小变化

ParOldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小变化

Metaspace:元数据区GC前后大小

xxx secs:指的是GC花费的时间

Times:

​ user:垃圾收集器花费的所有CPU时间

​ sys:花费在等待系统调用或系统事件的时间

​ real:GC开始到结束的时间,包括其他线程占用时间片的时间

日志补充说明

“[GC”和“[Full GC”说明垃圾收集的停顿类型,如果有Full说明发送了STW;

使用Serial收集器在新生代的名称是Default New Generation,因此显示的是“[DefNew”;

使用ParNew收集器在新生代的名称是ParNew,因此显示“[ParNew”

使用Parallel Scavenge 收集器在新生代的名字是“[PSYoungGen”

老年代的收集和新生代道理一样,名字也是收集器决定的

使用G1收集器会显示为“garbage-first heap”

GC日志分析2

图2 日志分析2

GC日志分析3

图3 日志分析3

total = eden + from = 1024 + 512 = 1536

日志分析工具

GCViewer、GCEasy、GCHisto等

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值