深入理解Java虚拟机

文章目录

理解Java虚拟机

  • JVM整体结构(三层)
  • Java执行流程
  • JVM架构模型(栈、寄存器)
  • JVM生命周期
  • JVM发展历程

OOM内存溢出、JVM GC、

垃圾回收算法、JIT(及时编译器)、底层原理、Run anyway

JAVA和C++重要的区别:垃圾自动回收、内存动态分配

  • Java虚拟机是一台执行Java字节码的虚拟计算机,他拥有独立运行机制,其运行的Java字节码未必由Java语言编译而来

  • JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回收机制,以及可靠的即时编译器。

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

JVM整体结构

  • HotSpot VM是市面上高性能虚拟机的代表作之一。

  • 它采用解释器与即时编译并存的架构

  • 步骤

      1. Class Files 通过 Class Loader System 加载到内存生成大的Class对象,其中涉及到类加载、链接、初始化。
      1. 运行时数据区(Runtime Data Area),其中有 Method Area、Virtual Stack、Native Method Stack、Heap、 Program Counter Register。
      1. 执行引擎(Execution Engine),主要进行的是字节码和机器指令的处理。
      1. 本地方法接口(Native Interface)。
      1. 本地方法库。

    多线程共享堆和方法区
    虚拟机栈、本地方法栈、寄存器是每个线程独有一份

JVM架构模型(栈的指令集架构、寄存器的指令集架构)

两种不同的指令集架构的区别

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

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

JVM的生命周期

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

  • 虚拟机的执行
    一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序。程序开始执行时他才运行,程序结束时他就停止。

所以真正执行一Java程序的时候,执行的是一个叫做Java虚拟机的进程。

SUN Classic VM

解释器和及时编译器不可同时工作,解释器逐行解释,运行速度比较慢,及时编译器对热点代码进行及时编译。

Exact VM

Exact Memory Management(准确式内存管理)

HotSpot VM

Java1.3的时候Hotspot(GC)被称为默认的虚拟机。

JRockit VM

类加载器子系统

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

类的加载过程

Loading -> (Verification -> Preparation -> Resolution) Linking -> Initialization

  1. 加载(Loading)通过一个类的全限定名获取定义类的二进制字节流。

    • 加载.class的方式
      1. 从本地系统中直接加载
      2. 通过网络获取,典型场景:Web Applet
      3. jar、war文件
      4. 运行时计算生成,最多使用的是动态代理技术(proxy)
      5. 由其他文件生成,典型场景是:JSP应用
      6. 从专有数据库中提取.class文件,少见
      7. 从加密文件中获取,典型的防Class文件被反编译的保护措施。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

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

  • 验证
    目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,保证被加载的类的正确性,不会危害虚拟机自身安全。
    主要包含四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

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

      这里不包含final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化。
      这里不会为实例变量分配初始化,类变量会分配在方法区,而实例变量会随着对象一起分配到Java堆中。
    
  • 解析
    将常量池内的符号引用转化为直接引用的过程。
    事实上,解析操作往往会伴随着Java在执行完初始化后再执行
    符号引用是一组符号用来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中,直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

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

以下类没有方法:

  • 没有声明任何类变量、没有static块语句
  • 声明了类变量,但没有对应的变量初始化语句
  • 只有final类变量,且赋值语句是常量(如 final static int danlu = 1;)。这样的赋值会在准备阶段就执行

类加载器的分类

JVM支持两种类加载器,分别是引导类加载器(Bootstrap ClassLoader)和自定义类加载器(USER-Defined ClassLoader)。

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

  • 启动类加载器
    • 该类加载使用C/C++实现,嵌套在JVM内部。
    • 他用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容),用于提供JVM需要的类。
    • 并不继承自java.lang.ClassLoader,没有父加载器(null)。
    • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
    • 出于安全的考虑,只加载java、javax、sun开头的类。
  • 扩展类加载器
    • Java语言编写。
    • 派生于ClassLoader类。
    • 父类加载器为启动类加载器(BootStrap ClassLoader)。
  • 应用程序类加载器
    • Java语言编写。
    • 派生于ClassLoader
    • 父类加载器为拓展类加载器
    • 该类加载是程序中默认的类加载器

为什么要自定义加载类

  1. 隔离加载类
  2. 修改类的加载方式
  3. 拓展加载源
  4. 防止源码泄漏

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

  1. 开发人员通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类的加载器,以满足特殊的需求。
  2. 在编写自定义加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

双亲委派机制

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

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

沙箱安全机制

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

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

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

对类加载器的引用

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

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了JAVA运行过程中内存申请、分配、管理的策略,保证了JVM内部的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异(JRocket J9没有方法区)。

那么HotSpotJVM的内存布局是什么样子的呢?

线程

线程是一个程序里的运行单元,JVM允许一个应用有多个线程并行执行。
Java线程
操作系统本地线程(运行时调用)
HotSpotJVM里,每个线程都与操作系统的本地线程直接映射

  • 当一个Java线程准备好执行后,此时一个操作系统的本地线程也同时创建,Java线程执行终止后,本地线程也会回收。
  • 操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,他就会调用Java线程中的run方法。

守护线程:当JVM中所有的线程都是守护线程的时候,JVM就可以退出了。
普通线程:还有一个或以上的非守护线程则不会退出。

PC寄存器(Programer Counter Register)

Register 的命名源于CPU寄存器,寄存器存储指令相关的现场信息,CPU只有把数据装载到寄存器才能够运行。这里并非广义上所指的物理寄存器,或许将它译为PC计数器更加贴切。JVM的PC寄存器是对物理寄存器的一种抽象模拟

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

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

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

一、使用PC寄存器存储字节码指令地址有什么用?为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程(每个线程独享一份寄存器),这时候切换回来以后,就得知道接着从哪开始继续执行。
JVM的字节码解释器需要通改变寄存器的值来明确下一条应该执行什么样的字节码指令。

二、PC寄存器为什么会被设定为线程私有?
多线程在一个特定的时间段内只会执行其中某一线程的方法,CPU会不停的做任务切换,这样必然导致经常的中断和恢复,为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的办法是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现干扰的情况。

由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某一个线程中的一条指令。这样必然导致经常中断和恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

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

并发:CPU使用权在不同的线程之间切换。
并行:多线程同时执行,在同一时间点进行切面有多条线程
串行:线程排成一串,依次拥有CPU使用权。在JVM中用户线程和垃圾回收线程不能同时执行。

虚拟机栈

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

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

虚拟机栈概述

概念

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

作用

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

  • 局部变量 vs 成员变量(属性)
  • 基本数据变量 vs 引用类型变量(类、数组、接口)
生命周期

生命周期和线程一致

栈中可能出现的异常,但是不存在垃圾回收问题的。

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

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虛拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈深度超过Java虚拟机栈允许的最大深度,Java虛拟机将会抛出一个StackOverflowError异常。(自己调自己)

  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。

栈的存储单位

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

  • 在这个线程上正在执行的每个方法各自对应一个栈帧。

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

类的基本结构:field(属性、字段、域)、method

栈运行原理

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

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

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

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

Java中有两种返回函数的方式,

  • 一种是正常的函数返回,使用return指令
  • 另一种是抛出异常,不管使用哪种方式,都会导致栈帧被弹出
栈帧的内部结构
  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)或表达式栈
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息

局部变量表

  • 局部变量表保存什么?

    局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

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

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

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

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

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

Start PC(声明的下一行) + Length(有效长度) = 当前作用域的范围。

Slot

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

局部变量表最基本的存储单元是slot(变量槽),32位的基本数据类型、引用类型占一个槽,64位(long、double)的基本类型占用两个槽。

JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引可以成功访问到局部变量表中指定的局部变量值。

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

如果需要访问的局部变量表中64bit的局部变量值时,那么只需要第一个索引即可

如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处,其余参数按照顺序进行排列。
这进而解释了为什么静态方法为什么不能使用this,因为this不存在于静态方法的局部变量表中。构造器和实例方法都可以使用this。

**栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量超出了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期过期的局部变量的槽位,从而达到资源利用的目的

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

参数表分配完毕后,再根据方法体内定义的变量的顺序和作用域分配。
类变量表有两次初始化的机会,一次是在准备阶段、另一次是在初始化阶段
和类变量不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为初始化,否则无法使用。

变量的分类
  • 根据数据类型来分
    • 基本数据类型
    • 引用数据类型
  • 根据在类中声明位置来分
    • 成员变量
      在使用前都经历过默认初始化赋值,都具有默认值。
      • 类变量、成员变量(静态修饰):linking的prepare阶段,默认赋值,initial阶段给类变量进行显式赋值,即静态代码块赋值。
      • 实例变量(无静态修饰):随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值。
    • 局部变量
      在使用前必须进行显式赋值,否则编译不通过。
优化

在栈帧中,与性能调优最关系最密切的部分就是局部变量表,在执行方法时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

操作数栈(Operate Stack)

栈可以使用数组或者链表来实现,这里使用数组来实现。这样的话,在使用前数组的长度是固定不可变的,也就是在编译器已经确定了,局部变量表也是编译时确定。

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,或者是表达式栈(Expression Stack)。

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

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
  • 比如复制、交换、求和等操作。

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

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

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

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

操作数栈并非采用访问索引的方式进行数组访问,而只能通过push和pop操作来完成一次数据访问。

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令,操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证。同时在类加载过程中的类检验阶段的数据流分析阶段再次验证。

栈顶缓存技术(Top-of-Stack Cashing)

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

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

动态链接(Dynamic Linking)

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

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

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

直白点说:动态链接就是帧中指向方法区中的运行时常量池的方法引用。

作用:常量池的作用就是为了提供一些符号和常量,便于指令的识别。

方法的调用:解析与分派

在JVM中,将符号引用(#?)转换为调用方法的直接引用与方法的绑定机制相关。
绑定是一个字段、方法或者类在符号引用被替换为直接引用过程,这仅仅发生一次。

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

编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种方式。

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

子类对象的多态性的前提:类的继承关系, 方法的重写

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

  • 普通调用指令
    • invokestatic:调用静态方法,解析阶段确定唯一方法版本
    • invokespecial:调用init方法、私有及父类方法,解析阶段确定唯一方法版本
    • invokevirtual:调用所有虚方法
    • invokeinterface:调用接口方法
  • 动态调用指令
    • invokedynamic:动态解析出需要调用的方法,然后执行。

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

关于invokedynamic指令

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

但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令,直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接生成的方式。

Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是Java平台的动态语言编译器。

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

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之就是动态类型语言。

java是静态类型语言。

再直白一点说就是,静态类型语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特性。

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

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

虚方法表

在面向对象的编程中,会很频繁的使用到动态分配,如果在每次动态分配的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。

因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(Virtual method table)来实现。使用索引来替代查找。

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

虚方法什么时候创建
虚方法表会在类加载==链接阶段(解析)==被创建并初始化,类的变量初始化值准备完成之后,JVM会把该类的方法表也初始化完毕。

方法返回地址(return address)

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

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

  1. 正常执行完成
    1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者。
    2. 一个方法的在正常调用完成后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
    3. 在字节码指令中,返回指令包含ireturn(boolean、byte、char、short、int)、lreturn、freturn、dreturn、areturn(引用类型),另外还有return指令提供声明的void方法、实例初始化方法、类和接口的初始化方法使用。
  2. 出现未处理异常,非正常退出
    1. 在方法执行的过程中遇到异常,并且异常在方法内没有得到处理,也就是只要本方法的异常表中没有搜素到匹配的异常处理器,就会导致方法退出,简称异常完成出口
    2. 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

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

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

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

一些附加信息

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

面试题

  1. 举例栈溢出的情况?

StackOverflowError
-Xss 设置栈的大小 OOM 动态扩容的过程中不足。

  1. 调整栈大小,就能保证不出现溢出?

不能,存在不足的情况。但是可以延缓

  1. 分配的栈内存越大越好吗?

不是,影响到其他的空间。

  1. 垃圾回收是否会涉及到虚拟机栈?

不会,
涉及到垃圾回收的区域:堆、方法区(和虚拟机声明周期相同)
存在Error的区域:本地方法栈、虚拟机栈、堆、方法区

  1. 方法区中定义的局部变量是否线程安全?

内部产生,后内部消亡,则为线程安全
如果提供给别的使用则线程不安全。

本地方法

简单的讲,一个Native Method 就是一个Java调用非Java的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,

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

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

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

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

HotSpot虚拟机直接将本地方法栈和虚拟机栈合二为一

堆是Java虚拟机所管理的内存中最大的一块。Java堆在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。

所有的对象实例以及数组都要在堆上分配内存。

现在搜集器基本采用分代回收算法,
所以Java堆还可以分为:新生代和老年代,再细致的份可以分为Eden空间、Form Survivor空间、To Survivor空间等。
从内存分配角度来看,线程共享的Java堆中可划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

方法区和堆对于进程来说是唯一的,进程对应一个JVM的实例,一个JVM中就有一个运行时数据区。一个JVM实例中只存在着一个堆内存,堆也是Java内存管理的核心区域。

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

《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上他应该是被视为连续的。所有的对象实例以及数组都应该运行时分配在堆上(The heap is the run-time data area from which memory for all class instances and arrays is allocated)。

Java堆中可以可以划分线程私有的缓冲区(TLAB)。

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

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

堆的核心概述

设置堆的大小与OOM

"-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
"-Xmx"用于表示堆区的最大内存,等价于-XX:MaxHeapSize
-X 是jvm运行参数
ms 是memory start的缩写

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

若两个参数配置相同,则意味着为了能够在java垃圾回收机制清理完堆区后不需要重新分割计算堆的大小,从而提高性能。

默认情况下:初始内存大小为物理内存大小的64分之一,最大内存大小为物理电脑内存的4分之一。

查看堆设置的参数:方式一:jps / jstat -进程号
方式二:-XX:+PrintGCDetails(程序执行完后输出)

可以存放对象的区域只有伊甸园区(Eden)和幸存者1区(Survivor 0)或幸存者2区(Survivor 1)。

OOM(内存溢出、错误)

向一个ArrayList中不停的add一个对象。导致堆内存溢出。

年轻代与年老代

存储在JVM的Java对象分为两类:

  • 一类是生命周期比较短的瞬间,这类对象的创建和消亡都比较迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

Java堆区进一步细分的话,分为YoungGen和OldGen。

配置YoungGen和OldGen在堆结构的占比
默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。

在HotSpot中,Eden空间和另外两个Survivor空间省所占比例是8:1:1。开发人员可以通过-XX:SurvivorRatio=8来调整这个空间比例。
-XX:-UseAdaptiveSizePlicy : 关闭自适应的内存分配策略。

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

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

可以使用"-Xmn"设置新生代最大内存大小,这个值一般默认就好了。

图解对象分配过程

对象分配过程与内存分配和内存回收算法密切相关,所以还要考虑GC执行完内存回收后,是否会在内存空间中产生内存碎片。

  1. new的对象放在Eden区,此区有大小限制
  2. 当Eden区内空间填满时,程序又需要创建对象,JVM垃圾回收器将对Eden区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象引用的对象进行销毁,再加载新的对象放到Eden区。
  3. 然后将Eden区中的剩余对象移动到Survivor 0区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到Survivor 0区,如果没有回收,就放到Survivor 1区。
  5. 如果再次经历垃圾回收,此时会重新放到Survivor 0区,接着再去幸存者1区
  6. 如果重复回收15次后,将对象存放在养老区。次数可以通过-XX:MaxTenuringThreshold<N>进行设置。
  7. 在养老区,相对悠闲,当养老区内存不足的时候,再次触发垃圾回收(Major GC)进行养老区的内存清理。
  8. 若养老区执行了(Major GC)之后发现依然无法进行对象保存,就会产生OOM异常。
总结
  • 对于幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

Minor GC、Major GC、Full GC

GC检索哪些是垃圾时,会导致用户线程暂停,所以希望GC出现的情况少,这里主要对Major GC、Full GC进行调优。因为它们两个GC的时间是Minor GC的10倍以上。

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

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

  • 部分收集
    • 新生代(Eden、S0、S1)进行回收采用(Minor GC/ YGC)
    • 老年代进行回收采用(Major GC/ OGC)
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集
    • 收集整个java堆和方法区的垃圾收集(Full GC)。

Minor GC的触发机制:

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代指得是Eden代满,Survivor满不会触发GC。
  • Minor GC非常频繁,一般回收速度也比较快。
  • Minor GC会引发STW,暂停其他用户的线程,等垃圾回收线程结束,线程才恢复运行。

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

  • 指发生在老年代的GC,对象从老年代消失。
  • 出现了Major GC,经常会伴随至少一次的Minor GC。
  • 如果Major GC后,内存还不足,就报OOM。

Full GC触发机制:

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行。
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。

堆空间分代思想

为什么需要把Java堆分代,分代可以优化GC性能。

内存分配策略

如果对象在Eden出生并经过第一MinorGC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1。对象在Survivor区每熬过一次MinorGC,年龄就增加1岁,当他的年龄增加到一定程度(默认15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项-XX:MaxTenuringThreshold来设置。

对象Promotion的规则
针对不同年龄段的对象分配规则如下:

  • 优先分配到Eden
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象的年龄判断
    • 如果Survivor区中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保
    • -XX:HandlePromotionFailure

为对象分配内存:TLAB(Thread Local Allocation Buffer)

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

TLAB是从内存分配的角度来说的,而不是垃圾回收的角度。它对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden区域。

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

小结堆空间参数设置

  • 测试堆空间常用的jvm参数:

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

  • -XX:+PrintFlagsFinal :查看所有的参数的最终值(可能会存在修改,不再是初始值)
    * 具体查看某个参数的指令:
    * jps:查看当前运行中的进程
    * jinfo -flag SurvivorRatio 进程id

  • -Xms:初始堆空间内存 (默认为物理内存的1/64)

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

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

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

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

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

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

  • 打印gc简要信息:① -XX:+PrintGC ② -verbose:gc

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

    • 在发生MinorGC之前,虚拟机会检查年老代最大可用的连续空间是否大于新生代所有对象的总空间
    • 如果大于,则此次MinorGC是安全的。
    • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。
      • 如果为true,那么会继续检查年老代最大可用连续空间是否大于历次晋升到年老代的平均大小。
        • 如果大于则尝试进行一次Minor GC,但仍然有风险。
        • 如果小雨则进行一次Full GC。
      • 如果为false,则改为进行一次Full GC。

    JDK6 Update 24之后的规则变为==只要年老代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

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

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

在特殊情况下,经过逃逸分析(Escape Analysis,这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无须在堆上分配内存,也无需进行垃圾回收,这也是最常见的堆外存储技术。

开发中能使用局部变量(可能存放在栈空间),就不要使用方法外定义

代码优化

启动Server模式,因为在Server模式,才可以启动逃逸分析:-server
开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis
开启标量替换:-XX:+EliminateAllocaions(默认,允许将对象打散分配到栈上,比如对象拥有id和name两个字段,那么这两个字段会被视为两个独立的局部变量进行分配)。
关闭标量替换:-XX:-EliminateAllocaions

  1. 栈上分配

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

    2. 场景:成员变量赋值、方法返回值、实例引用传递。

  2. 同步省略

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

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

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

    public void f(){
        Object hollis = new Object();
        synchronized (hollis){
            System.out.println(hollis);
        }
    }
    //代码中对hollis加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程访问到,所以在JIT编译阶段就会被优化掉。
    public void f(){
        Object hollis = new Object();
        System.out.println(hollis);
    }
  1. 分离对象或标量替换

    1. 有的对象可能不需要作为一个连续的内存结构存储也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
      **标量(Scalar)**是指无法再分解成更小数据的数据,Java中的原始类型就是标量。相对的,可以分解的数据叫做聚合量(Aggregate),Java中的聚合量,因为他可以分解成其他聚合量和标量。

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

方法区

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

方法区看作是一块独立于Java堆的内存空间

方法区的理解

《Java虚拟机规范》中说明:“尽管所有的方法区在逻辑上属于堆的一部分,但是一些简单的实现可能不会选择去进行垃圾收集或进行压缩。”

设置方法区大小与OOM

  1. jar过多,大量动态生成反射类。关闭JVM时会释放方法区。
  2. 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在不在虚拟机设置的内存中,而使用本地内存
  3. 《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
  4. 方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。
    1. jdk8之后元数据区大小可以使用参数-XX:MetaspaceSize=100m-XX:MaxMetaspaceSize=100m指定。
    2. 默认值依赖于平台。
    3. 与永久代不同的是,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出OutOfMemoryError: Metaspace异常。

要解决OOM异常或者heap space的异常,一般手段是首先通过内存映像分析工具对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是有必要的,也就是要先分清楚出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

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

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

方法区的内部结构

Method Area存储内容如下:被虚拟机加载的方法信息、类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

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

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

域(Field)信息:
成员变量(属性)

  1. JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  2. 域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)

方法(Method)信息

  1. 方法名称
  2. 方法的返回类型(void)
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的某个子集)
  5. 方法的字节码(bytecode)、操作数栈、局部变量表及大小(abstract和native方法除外)
  6. 异常表(abstract和native方法除外)
    1. 每个异常的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

方法区内部包含了运行时常量池。
字节码文件,内部包含了常量池。
要想弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

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

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分,类变量被类所有的实例共享,即时没有类实例你也可以访问他

全局常量:final+static

重要:

被声明为final的类变量的处理方法不同,每个全局变量在编译的时候就会被分配了。(编译为字节码.class的时候就赋值)。

如果只被static修饰,会在类加载器加载的准备阶段默认赋值为0(验证 - 准备 - 解析),然后在Initialization阶段对他进行准确赋值。

对于静态代码块的赋值有个
构造方法的赋值

运行时常量池

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

字节码文件,内部包含了常量池(Constant Pool)。

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

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

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

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行时也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法

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

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

为什么需要常量池

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

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

方法区使用举例

非静态方法本地变量表的0的索引处存放this
bipush 500向操作数栈存放数据
istore_1将操作数栈弹出的元素存放在本地变量表为1的位置
iload_1将本地变量表的数据取出来,放到操作数栈。
getStatic #2jvm会根据这个方法的描述,创建新栈帧,方法的参数从操作数栈中弹出来,压入虚拟机栈,然后虚拟机会开始执行虚拟机栈最上面的栈帧。
invokevirtual #3执行完毕后,再继续执行main方法对应的栈帧。

private static byte[] arr = new byte[1024 * 1024 * 100];

静态引用对应的对象实体new byte[1024 * 1024 * 100]始终都存放在堆空间。
但是他的引用名arr,在jdk放在堆空间。

public class StaticObjTest {
    static class Test{
        //静态属性
        //JDK7及以后版本的HotSpot虚拟机 选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储与Java堆中。
        static ObjectHolder staticObj = new ObjectHolder();
        
        //非静态属性 成员变量随着Test对象实例存放在Java堆中
        ObjectHolder instanceObj = new ObjectHolder();

        void foo(){
            //方法内局部变量 存放在foo方法栈帧的局部变量表中
            ObjectHolder localObj = new ObjectHolder();
            System.out.println("done");
        }
    }

    private static class ObjectHolder{}

方法区演进细节

  1. 首先明确:只有HotSpot才有永久代。
  2. HotSpot中的方法区的变化:
jdk版本方法区如何实现
jdk1.6及以前有永久代(permanent generation),静态变量存放在永久代
jdk1.7有永久代,但已逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及以后无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

永久代为什么要被元空间替换?

This is part of JRockit and HotSpot convergence effort. JRockit customers do not need to configure the permanent generation(since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.

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

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

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

一、为永久代设置空间大小是很难确定的。
二、对永久代进行调优很困难。

StringTable为什么要调整?

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

方法区垃圾回收

《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完成实现方法区类型卸载的收集器存在。这部分区域的回收有时又是必要的。

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

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

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

HotSpot虚拟机对常量池的回收策略很明确:只要常量池中的常量没有被任何地方引用,就可以被回收

回收废弃常量与回收Java堆中的对象非常类似。

然而对于判定一个类型是否属于“不再被使用的类”的条件就非常苛刻了,需要满足以下三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的加载器已经被回收,这个条件除非时经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载,否则通常是很难达成的。
  3. 该类对应的Java.lang.Class对象么有在任何地方被引用,无法在任何地方通过发射访问该类方法。

满足上面三个条件,仅仅是“被允许”回收,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot提供了-XX:TraceClassUnLoading查看类加载和卸载信息。

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

总结

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

对象实例化的过程

  1. 加载类元信息
  2. 为对象分配内存
  3. 处理并发问题
  4. 属性的零值初始化
  5. 设置对象头信息(指向方法区里面的类元信息,关联)
  6. 进行的属性的显式初始化、代码块中初始化、构造器中初始化

对象的实例化

  • 对象在JVM是怎么存储的?

  • 对象头信息里面有哪些东西?

对象的内存布局

对象的访问定位

运行时常量池

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

运行池常量池相对于Class文件常量池的另外一个重要的特征是具备动态性,Java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。

直接内存

直接内存(Direct Memory)既不属于运行时数据区,也不属于Java虚拟机内存规范的中定义的内存区域。但是会导致OutOfMemoryError。

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

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

  • 因此处于性能的考虑,读写频繁的场合可能会考虑使用直接内存。
  • Java的NIO库允许Java程序使用直接内存,用于数据缓存区

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

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

JDK1.4之后引入一种基于Channel和Buffer的I/O方式,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java中的DirectByteBuffer对象作为这块内存的引用进行操作。

执行引擎

执行引擎概述

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

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

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

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

Java代码编译和执行过程

  1. 执行引擎在执行过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
  2. 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
  3. 当方法在执行的过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

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

解释器(模版解释器)

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

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

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

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

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

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

JIT编译器

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

Java语言的“编译器”其实是一段“不确定”的操作过程,

  • 因为它可能是指一个前端编译器(其实叫“编译器的前端”更确切一些)把.java文件转变为.class文件的过程。

    • Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)
  • 可能是指虚拟机的后端运行期编译器(Just In Time Compiler),把字节码转变成机器码的过程(中间有个汇编)。

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

    • GUN Compiler for the Java(GCJ)、Excelsior JET
热点代码探测方式

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

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

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

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

采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)回边计数器(BackEdge Counter)
➢方法调用计数器用于统计方法的调用次数
这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是 1500 次,在Server模式下是10000次。超过这个阈值, 就会触发JIT编译。
这个阈值可以通过虚拟机参数:xx: CompileThreshold来人为设定。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交-一个该方法的代码编译请求。

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

热度衰减

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

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

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

AOT编译器

Ahead Of Time Compiler 在程序运行之前,便将字节码转换为机器码的过程。

StringTable

String的基本特性

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

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

String在JDK8及以前内部定义了final char[] value 用于存储字符串数据。JDK9时改为byte[]。这样节约了一些空间。

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

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

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

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

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

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

使用-XX:StringTableSize可设置StringTable的长度(固定不变)。

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

在JDK7中,StringTable的长度默认值是60013,StringSize设置没有要求。

JDK8中,1009是可设置的最小值。

String的内存分配

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

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

  • 直接使用双引号声明出来的对象会直接存储在常量池中。
  • 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。

String的基本操作

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

字符串拼接操作

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

intern():判断字符串常量池中是否存在某个常量值,如果存在则返回常量池中这个值的地址,如果字符串常量池中没有这个常量,则在常量池加载一份,并返回此对象的地址。

StringBuilder的append()的方式添加字符串的效率远远高于使用String的字符串拼接方式。
好处:

  1. StringBuilder的append()方式,自始至终只创建一个StringBuilder的对象。
  2. 而使用字符串拼接的方式创建多个StringBuilder和String对象。
  3. 使用String字符串拼接的方式:内存中由于创建了较多的StringBuilder和Sting对象,内存占用更大。如果进行GC,需要花费更多的时间。

改进的空间:
在实际开发中,如果基本确定要前前后后添加字符串长度不高于某个限定值highLevel的情况下,建议使用构造器:

    StringBuilder str = new StringBuilder(highLevel);

intern()使用

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

JDK1.6中,将这个字符串对象放入常量池中

  • 如果常量池有,则并不会放入,返回已有的常量池的对象的地址
  • 如果没有,会把此对象复制一份,放入常量池,并返回常量池对象地址(产生新对象)。

JDK1.7中,将这个字符串对象放入常量池

  • 如果常量池中有,则不会放入,返回已有的常量池中的对象的地址
  • 如果没有,则会把对象的引用地址复制一份,放入常量池,并返回常量池的引用地址(地址相同)。
    public static void main(String[] args) {
        String aa = new String("wiki");
        aa.intern();
        String bb =  "wiki";
        System.out.println(aa == bb);  // jdk678: false
        

        String cc = new String("wiki") + new String("coder");
        //执行完后,字符串常量池中不存在wikicoder,cc记录的地址是new String("wikicoder")
        cc.intern();
        //在字符串中新创建一个wikicoder
        //jdk6: 创建一个新的对象
        //jdk78: 此时常量池中没有创建,只是存放了指向堆空间的引用
        String dd = "wikicoder";
        System.out.println(cc == dd); //jdk6: false jdk78: true
        
        
        String cc = new String("wiki") + new String("coder");
        //执行完后,字符串常量池中不存在wikicoder,cc记录的地址是new String("wikicoder")
        String dd = "wikicoder";
        cc.intern(); 
     
        System.out.println(cc == dd);  //false

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V6DYBuMy-1593516704126)(media/15904630284151/15934998423443.png)]

一个对象是:new 关键字在堆空间创建的。
另一个对象是:ldc 字符串常量池中的对象。

最后返回给bb的是堆空间中的对象

new String("a") + new String("b")会创建几个对象
  1. new StringBuilder
  2. new String(“a”)
  3. a
  4. new String(“b”)
  5. b
  6. String Builder 的 toString()
    1. new String(“ab”),在字符串常量池中没有ldc,没有生成"ab"。

String Table的垃圾回收

G1中的String去重操作

垃圾回收机制

什么是垃圾

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

为什么需要GC

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完。

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

没有GC就不能保证应用程序的正常运行。而经常造成STW的GC又跟不上实际的需求,所以才会不断的进行GC优化。

早期垃圾回收机制

Java垃圾回收机制

垃圾回收算法

GC:垃圾回收器
标记阶段:识别哪些对象为垃圾(引用计数算法、可达性分析算法)
清除阶段:清除标记的对象(标记清除算法、复制算法、标记-压缩算法)

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

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

标记阶段:引用计数算法

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

当引用计数计器的值为0时,即表示对象A不可能再被使用,可进行回收。

优点:

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

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法的操作,增加了时间开销
  • 引用计数器有一个严重的问题,即无法处理循环引用的情况,这是一条致命的缺陷,导致Java的垃圾回收器中没有使用这类算法。

Python如何解决循环引用:

  • 手动解除:在合适的时机,解除引用关系
  • 使用弱引用weakref,weakref是Python的标准库,旨在解决循环引用。

标记阶段:可达性分析算法

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

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

基本思路:

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

GC Roots包括以下几类元素:

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

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。 比如:分代收集和局部回收(Partial GC)。
如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时。候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

小技巧:
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。

这点也是导致Gc进行时必须"Stop The World" 的一个重要原因。
即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

对象的finalization机制

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

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

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

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

  • finalize()时可能会导致对象复活。

  • finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。

  • 一个糟糕的finalize()会严重影响Gc的性能 。

从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于c++中的析构函数。

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

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

  • 可触及的:从根节点开始,可以到达这个对象。

  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。

  • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一一次。

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

具体过程

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

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

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

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

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

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

MAT与JProfiler的GC Roots溯源

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

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

执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为STW),然后进行两项工作,第一项是标记,第二项是清除。
标记:Collector从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的Header中记录为可达对象
清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

缺点:

  • 效率不够高
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • 这种方法清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表。

清除阶段:复制算法(Copying)

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

  • 没有标记和清除过程,实现简单,运行高效。
  • 复制过去以后保证空间的连续性,不会出现“碎片问题”。

缺点:

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

清除阶段:标记-压缩算法(Mark-Compact)

基于老年代垃圾回收特性产生的。

第一阶段和标记清除算法是一样的,从根节点开始标记所有所有被引用对象。
第二阶段将所有的存活对象压缩到内存的一端,按顺序摆放,之后清理边界外所有空间。

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

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

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

优点:

  • 消除了标记-清除算法当中,内存区域分散的缺点。
  • 消除了复制算法当中,内存减半的高额代价。

缺点:

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

小结

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

分代收集算法(Generational Collecting)(清除)

分代收集算法是基于这样一个事实:不同对象的生命周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和年老代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序云心过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只回收一次即可。

  • 年轻代采用复制算法。

    • 年轻代特点:区域相对于老年代较小,对象生命周期短、存活率低,回收频繁。
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象的大小有关,因此适用于年轻代的回收。而复制算法的内存利用率不高的问题,可以通过hotSpot虚拟机的两个Survivor的设计得到缓解。
  • 老年代采用标记-清除、标记-清除-压缩实现。

    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
    • 这种情况下存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者标记-清除和标记-压缩混合实现。
    • Mark阶段的开销与存活对象数量成正比
    • Sweep阶段的开销与管理区域的大小成正比
    • Compact阶段的开销与存活对象的数据成正比

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

增量收集算法、分区算法(清除)

增量收集算法

为了解决STW问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。

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

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

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

分区算法

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

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

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

目前还在发展中的前沿GC都是复合算法,并且具备并行和并发兼备。

垃圾回收相关概念

System.gc()的理解

无需手动触发,通知“垃圾回收器”回收垃圾(Full GC),具体回收不回收由垃圾收集器的算法决定。

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

System.runFinalization()会强制调用使用引用的对象的finalize()方法。

内存溢出和内存泄漏(Memory Leak)

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

代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

在抛出OOM Error之前,通常垃圾收集器会被触发,尽其所能的清理出空间。
在引用机制分析中,涉及到JVM会尝试回收软引用指向的对象等。

内存泄露:严格来说,只有对象不再被程序用到了,但GC又不能回收他们的情况,这才叫内存泄漏。
很多时候一些不太好的实践(或忽略)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做广泛意义上的“内存泄漏”。

内存泄漏举例
  1. 单例模式
    1. 单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄露的产生。
  2. 一些提供close的资源未关闭导致内存泄漏
    1. 数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

Stop The World

减少STW

垃圾回收的并行与并发

并发(Concurrent)

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,而这几个程序都是在同一个处理器上运行。

并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理速度非常快,只要时间间隔处理得当,即可让用户感觉是多个程序同时在运行。

并行(Parallel)

当一个系统有一个以上的CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行。

垃圾
  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待线程。
    • ParNew、Parallel Scavenge、Parallel Old
  • 串行(Serial)
    • 相较于并行的概念,单线程执行垃圾回收。
    • 如果内存不够,则线程暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
  • 并发(Concurrent)指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
    • 用户程序在继续运行,而垃圾收集线程运行于另一个CPU上
    • 如:CMS、G1。

安全点与安全区域

安全点(Safepoint)

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

Safe Point的选择很重要,如果太少可能导致GC等待时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特性”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。

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

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

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?例如线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看作是扩展了了的SafePoint。

实际执行时:

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

强引用

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

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

强引用的特点:

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

软引用(SoftReference)

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

弱引用(WeakReference)

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

弱引用是用来描述那些非必需对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当系统GC时,不管系统堆空间是否充足,都会回收掉只被弱引用关联的对象

弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。**弱引用对象更容易、更快被GC回收。

虚引用(PhantomReference)

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

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

不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法获取对象时总为null。

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

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中记录和执行

终结器引用(Final Reference)

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

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

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

垃圾回收器

语法层面、API层面、底层

GC分类与性能指标

GC分类

线程数分,可以分为串行垃圾回收器和并行垃圾回收器。

  • 串行会回收是指在同一个时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。

    • 在诸如多个CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端Client模式下的JVM中
    • 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
  • 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-The-world”机制。

工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间
  • 独占式垃圾回收器一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器

  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩处理,消除回收后的碎片
  • 非压缩式的垃圾回收器不能进行这一步操作
    工作的内存区间分为年轻代垃圾回收器和老年代垃圾回收器
GC性能指标
  • 吞吐量:运行客户代码的时间占总运行时间的比例
    • 总运行时间:程序运行时间 + 内存回收时间
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例
  • 暂停时间:SWT时间
  • 收集频率:相对于应用程序的执行,收集操作发生的频率
  • 内存占用:Java堆区所占的内存大小
  • 快速:一个对象从诞生到被回收所经历的时间。
吞吐量

含义:CPU用于运行用户代码的时间和CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间)

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

暂停时间
吞吐量VS暂停时间

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

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

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

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

不同的垃圾回收器概述

Garbage Collection
Garbage Collector

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1
7款经典收集器与垃圾分代之间的关系
  • 新生代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1;

两个收集器之间有线连接表示可以搭配使用,
Serial Old作为CMS出现“Concurrent Mode Failure”失败的后备预案。

提供了多种方案,选择最合适的垃圾回收器。

使用-XX:PrintCommandLinFlags查看命令行相关参数(包括使用的垃圾回收器)。
使用jinfo -flag 相关垃圾回收器参数 进程ID

Serial回收器:串行回收(JDK3)

Serial收集器是最基本、历史最悠久的垃圾收集器,是JDK1.3的唯一选择,单核CPU。

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

Serial收集器年轻代采用复制算法、串行回收和“Stop-the-world”机制的方式执行内存回收。

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样采用串行回收和“Stop-the-world”机制,只不过内存回收算法使用的是“标记-压缩”算法(在结束的时候,防止碎片化,若分配新的方法,采用指针碰撞)。

  • Serial Old运行在Client模式下默认的是老年代垃圾回收器
  • Serial Old在Server模式下有两个用途:
    • 与新生代的Parallel Scavenge 配合使用。
    • 作为老年代CMS收集器的后备收集方案

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

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

开启方式:-XX:+UseSerialGC,表明新生代使用Serial,老年代使用Serial Old。

ParNew回收器:并行回收(JDK4)弃用

ParNew = Par(Parallel) + New(新生代), 只能处理新生代

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

ParNew收集器在年轻代中同样采用复制算法、“Stop-The-World”机制

ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
对于新生代:回收频繁,使用并行回收效率高。
对于老年代:回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)

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

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

在程序中,使用-XX:+UseParNewGC手动指定使用ParNew收集器执行回收任务,它表示年轻代使用并行收集器,不影响老年代。同时可以使用-XX:ParallelGCThread限制线程数量,默认开启和CPU数据相同的线程数。

Parallel Scavenge回收器:吞吐量优先(JDK6)

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

目标:

  • 可控制的吞吐量(Throughput),也被称为吞吐量优先的垃圾回收器
  • 自适应调节策略

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

同时在JDK1.6提供了用于执行老年代垃圾回收的Parallel Old收集器,采用标记-压缩算法但是同样也是基于并行回收的“Stop-the-world”机制

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

JDK8默认使用此垃圾回收器

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

  • 默认情况下,当CPU核心数小于8个,ParallelGCThread的值等于CPU核心数
  • 当CPU数量大于8个,ParallelGCThread的值等于 3 + [5 * CPU_Count]/8

-XX:MaxGCPauseMillis设置垃圾收集器最大停顿时间(即STW的时间)单位是毫秒。
-XX:GCTimeRatio垃圾收集时间占总时间的比例,用于衡量吞吐量的大小。
-XX:UseAdaptiveSizePolicy设置Parallel Scavenge回收器具有自适应调节策略
这种模式下,年轻代的大小、Eden和Survive的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间的平衡点

CMS回收器:低延迟(JDK7)并发

在JDK1.5时期,HotSpot推出了一款在应用中几乎可认为有划时代意义的垃圾回收器CMS(Current-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程和用户线程同时工作

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

  • 目前有很大一部分的Java应用集中在互联网或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常适合这类应用的需求。

CMS垃圾收集算法采用“标记-清除”算法,并且会Stop-the-world

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

  1. 初始标记(STW)
    1. Initial-Mark阶段:在这个阶段中,程序中所有线程都将会因为“Stop-the-world”机制而出现短暂的暂停,这个阶段的主要任务仅仅是标记出GC Root能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里速度非常快
  2. 并发标记
    1. Concurrent-Mark阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记(STW)
    1. Remark阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行和交叉运行,因此为了修正并发标记期间,因用户程序运作而导致标记产生变动的那一部分的标记记录 ,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清理
    1. Concurrent-Sweep阶段:此线程清理删除标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
  5. 重制线程

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

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

优点:

  • 并发收集
  • 低延迟

缺点:

  • 采用了标记-清除算法,会产生内存碎片,不能采用(Bump the Pointer),需要维护一个空闲列表(Free List)来执行内存分配。同时,并发清楚后,用户线程可用空间不足。无法分配大对象的情况下,不得不提前触发Full GC

    • 不采用标记-压缩算法,因为要保证用户线程能继续执行,前提是它运行的资源不受影响。
  • CMS收集器对CPU资源非常敏感。在并发阶段,他虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低

  • CMS无法处理浮动垃圾。

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

使用ConcMarkSweepGC进行老年代的回收会自动触发ParNewGC进行新生代的回收

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

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

小结

如果要最小化的使用内存和并行开销,选择Serial GC
如果最大化的应用程序的吞吐量,选择Parallel GC(JDK 8)
如果最小化GC中断或停顿时间,选择CMS GC

G1回收器:区域化分代式(JDK9)

在延迟可控的情况下获得尽可能高的吞吐量

因为G1是一个并行回收器,他把堆内存分割成很多不相关的区域(Regin)物理上不连续的。使用不同的Region来表示Eden、S0、S1、Old。

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

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

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

  • 并行与并发

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

    相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(FootPrint)还是程序运行时的额外执行负载(Overload)都要比CMS高。

    1. 开启G1垃圾收集器
    2. 设置堆的最大内存
    3. 设置最大停顿时间

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

G1 回收器垃圾回收过程
  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Current Marking)
  • 混合回收(Mixed GC)
  • 如果需要,但线程、独占式、高强度Full GC还是继续存在的,他针对GC的评估失败提供了一种失败保护机制,即强力回收。

应用程序分配内存,当年轻代的Eden区用尽时,开始年轻代回收过程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survive区间或者老年区间,也有可能是两个区间都会涉及

当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间, 这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1 回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31 个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

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; 就可以保证不进行全局扫描,也不会有遗漏。
回收过程:年轻代GC
  • 第一阶段, 扫描根。

根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。

  • 第二阶段,更新RSet。

处理dirty card queue中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用

  • 第三阶段,处理RSet。

识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。第四阶段,复制对象。

  • 第四阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到old区中空的内存分段。如果Survivor空间不够,Eden空间的 部分数据会直接晋升到老年代空间。

  • 第五阶段,处理引用。

处理Soft,Weak,Phantom, Final, JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

垃圾回收器总结

垃圾收集器分类作用位置使用算法特点适用场景
Serial串行运行新生代复制算法响应速度优先单CPU下的Client模式
ParNew并行运行新生代复制算法响应速度优先多CPU下的Server模式、配合CMS使用
Parallel并行运行新生代复制算法吞吐量优先适用于后台运算而不需要太多的交互场景
Serial Old串行运行老年代标记-压缩算法响应速度优先单CPU下的Client模式
Parallel Old并行运行老年代标记-压缩算法吞吐量优先适用于后台运算而不需要太多的交互场景
CMS并发运行老年代标记-清理算法响应速度优先使用于互联网的或B/S模式
G1并发、并行运行复制、标记-压缩算法响应速度优先面向服务端应用

GC日志分析

垃圾回收器的新发展

Shenandoah GC 低延迟(10ms),吞吐量明显下降。

ZGC 基于Region内存布局的,(暂时)不设分代的,使用读屏障、染色指针和内存多映射等技术可以实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值