3万字细致JVM讲解,干货满满~~

257 阅读1小时+

类的加载

一、类的加载机制

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

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

对于数组类的加载,和普通类的加载有所不同。数组类本身不通过类加载器加载,而是由虚拟机直接完成。但是数组类的元素类型(指数组类去除维度之后的类型,如String[] 数组的元素类型就是 String)是靠类加载器加载的

2.类的加载过程

JVM将类的加载分为三个阶段:加载(Load)链接(Link)初始化(Initialize)。其中链接包括(验证、准备、解析

其中,加载、验证、准备、初始化这四个阶段的顺序是确定的。类的加载过程必须按照这种顺序按部就班的“开始”。而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)

  • 1.加载(双亲委派机制
    • 1.通过一个类的全限定名来获取定义此类的二进制字节流。
    • 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 3.在Java堆中生成一个代表这个类的java.lang.Class对象(注意:Class对象是存放在堆区的,不是方法区,这点很多人容易犯错。类的元数据(元数据并不是类的Class对象。Class对象是加载的最终产品,类的方法代码,变量名,方法名,访问权限,返回值,包括类加载器的信息等等都是在方法区的)才是存在方法区的),作为方法区这些数据的访问入口。
  • 2.验证
    • 校验字节码文件的正确性、安全性,包括四种验证:文件格式验证、元数据的验证、字节码验证、符号引用验证
  • 3.准备
    • 为类变量(static修饰的字段变量)分配内存并且设置该类变量的初始值(默认值),这里不包含final修饰的static,因为final在编译的时候就已经分配了。也不会为实例变量分配初始化,实例变量会随着对象分配到堆内存中。
  • 4.静态解析(如有必要)
    • 解析阶段是将常量池中的符号引用转换为直接引用的过程
      • 符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可(前面JVM的模型中,也提到了符号引用,它存在于常量池中,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。看概念可能比较抽象,可以理解为它就是一个代号,就像你有一个大名,同时也有一个小名,但是不管怎么叫指代的都是你本人
      • 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。(简单来说就是在运行时内存中的真实地址)
  • 5.初始化
    • 这是类加载的最后一步,到这才真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则可以根据需要来赋值了。可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。
    • 首先说下类构造器 < clinit > 方法实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不执行接口的 < clinit > 方法。虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。(这也是为什么说饿汉式单例模式是线程安全的,因为类只会加载一次。)类的初始化时机:只有对类主动使用的时候才会触发初始化。主动使用的场景如下:
      • 使用new关键词创建对象时
      • 访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时
      • 反射调用时,会触发类的初始化(如Class.forName())初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化
      • 虚拟机启动时,会先初始化主类(即包含main方法的类)
    • 另外,也有些场景并不会触发类的初始化:
      • 通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。
      • 通过数组来创建对象不会触发此类的初始化,(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)
      • 通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化
  • 6.使用
  • 7.动态解析(如有必要)
  • 8.卸载

3.类加载后方法区存储内容

类被加载到方法区中后主要包含运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。

类加载器的引用:
这个类对类加载器实例的引用

对应class实例的引用:
类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的对象实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。

5 类加载器

类加载过程主要通过类加载器实现,官方来说只有两种类加载器。

  • 引导类加载器(Bootstrap ClassLoader)
    • 负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar\charsets.jar等,由C++实现,不是ClassLoader子类,Java程序无法直接引用。出于安全考虑,Bootstrap引导类加载器只加载包名为java、javax、sun等开头的类
  • 自定义类加载器(广义)(继承ClassLoader这个类)
    • 拓展类加载器(Extension ClassLoader)
      • 负责加载支撑JVM运行的位于JRE的lib目录下的ext拓展目录中的类库,如果用户自己创建的jar包放在此目录下,也会自动由扩展类加载器加载
    • 应用类加载器(App ClassLoader)
      • 负责加载ClassPath路径下的类包,主要加载开发人员写的类,该类加载是程序中默认的类加载器,一般来说,程序员自己编写的类都是由它来完成
    • 自定义类加载器(狭义)(Custom ClassLoader)
      • 负责加载用户自定义路径下的类包

类加载器之间存在层级结构:

4.双亲委派机制

1.定义:

双亲委派机制是指当一个类加载器收到一个类加载请求时,该类加载器首先会把请求委派给上层加载器。每个类加载器都是如此,只有在上层加载器在自己的加载路径下找不到指定类时,下层才会尝试自己去加载。

2. 工作过程

  • 当应用程序类加载器收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给上层加载器Extension ClassLoader去完成。
  • 当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给上层加载器Bootstrap ClassLoader去完成。
  • 如果Bootstrap ClassLoader加载失败(在\lib中未找到所需类),就会让Extension ClassLoader尝试加载。
  • 如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载。
  • 如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。
  • 如果均加载失败,就会抛出ClassNotFoundException异常。

3.双亲委托作用

  • 沙箱安全机制:防止核心类库被篡改,自己写的java.lang.String不会被加载。
  • 避免类的重复加载:当上层加载器已经加载过该类时,下层加载器就没必要重新加载一次,保证被加载类的唯一性

程序计数器(PC寄存器)

  • 作用:记住下一条jvm执行的指令的地址
  • 特点:
    • 线程私有,每一个线程都有各自的程序计数器。
    • 不会存在内存溢出(其他一些,堆,栈,方法区都会存在内存溢出)

两种常见pc寄存器问题

  • 使用pc寄存器存储字节码指令地址有什么用呢?/为什么使用pc寄存器记录当前线程的执行地址?
    • 在多个线程并发执行时,cpu需要不断地切换各个线程,当切走,再切回来时需要知道从哪里继续开始执行。jvm字节码解释器(执行引擎)就需要通过pc寄存器中储存的下一条指令的地址来执行下一条指令。
  • pc寄存器为什么会被设定为线程私有的?
    • 如果是pc寄存器是共享的话,当几个线程并发执行的时候,当第一个线程在用完它自己的时间片时,这时pc寄存器中记住的是第一个线程下一条指令的地址,这时cpu去执行第二个线程,当第二个线程时间片用完后,由于如果pc寄存器是共享的话,就会让pc寄存器存储第二个线程下次该执行的指令的地址,从而把第一个线程的覆盖了,下次执行第一个线程时,不知道从哪里开始。为了能够准确记录各个线程下一行该执行的字节码指令地址,最好的办法就是为每一个线程都分配一个pc寄存器。
  • cpu时间片
    • cpu分配给各个程序的时间,每个线程被分配一个时间段,称作这个线程的时间片

Java Virtual Machine stacks (java 虚拟机栈)

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧组成。
    • 栈帧:线程中所调用的每一个方法,一个方法所占的内存大小就是栈帧
  • 每个线程只能有一个活动栈帧
    • 活动栈帧:对应着当前正在执行的那个方法

问题辨析:

  • 垃圾回收是否涉及栈内存?
    • 不涉及。因为对于栈内存来说其内部都是栈帧,而当方法执行结束后,会自动弹出栈帧,也就会被回收,不需要使用垃圾回收。
  • 栈内存分配越大越好吗?
    • 不是。栈内存大小和可以同时运行线程数量成反比。
  • 方法内的局部变量是否线程安全
    • 当方法中的局部变量作用范围只在当前方法时,方法外取不到该变量,也传入不了对象给该变量。这时就是线程安全
    • 当该变量在方法参数列表声明,并return 该变量。就说明不是线程安全。方法参数列表声明,可能会传入其他线程中同一个变量,导致线程不安全。return 会导致其他线程拿到该变量,导致线程不安全。

栈内存溢出:

  • 栈帧过多
  • 栈帧过大

线程诊断

  • cpu占比过多
    • 定位:
      • 用top定位哪个进程对cpu占比过高
      • ps -H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占比过高)
      • jstack 进程id
        • 可以根据线程id(ps中是十进制,jstack中是十六进制。需要转换)找到有问题的线程,进一步找到问题代码的源码引导
  • 程序运行很长时间没有结果
    • 死锁现象

1.栈中可能出现的异常

栈中可能出现的异常有StackOverflowError异常和OutOfMemoryError。
Java虚拟机规范允许Java栈的大小可以是动态的或者是固定不变的

  • 1.如果采用固定大小的java虚拟机栈,那每一个线程的java虚拟机栈的容量可以在线程创建的时候独立选定。如果线程请求分配的容量超过Java虚拟机栈允许的最大容量,java虚拟机将会抛出一个StackOverflowError异常。
  • 2.如果虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,java虚拟机会抛出OOM异常。

2.设置栈内存的大小 -Xss

  • -Xss1024k // 设置栈的大小为1024k
  • -Xss1m // 设置栈的大小为1M

3.栈帧的内部结构

  • 局部变量表(Local Variables)

    • 局部变量表也称之为局部变量数组或者本地变量表
    • 主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
    • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
    • 局部变量表所需容量大小是在编译期确定下来的,并保存在方法的Code属性的maxumum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的
    • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
    • 关于Slot的理解
      • 参数值的存放总是在局部变量数组的index0开始,到数组长度 -1 的索引结束局部变量表,最基本的存储单元是Slot(变量槽)
      • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
      • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占两个slot
        • byte、short、char在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true
        • long和double则占据两个Slot,使用的是slot的起始索引。
      • 栈帧中的局部变量表中的槽位是可以重用的。如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的
  • 操作数栈 (operand stack)(或表达式栈)

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

    • 每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last – In – First -Out)的 操作数栈,也可以称之为 表达式栈(Expression Stack)

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

      • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈(比如求和的指令-add,会把操作数栈中的数据取出,由jvm的执行引擎翻译成cpu中内部进行相加,然后将返回结果再存入操作数栈中
      • 比如:执行复制、交换、求和等操作
    • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的(这个时候数组是有长度的,因为数组一旦创建,那么就是不可变的)。

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

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

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

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

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

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

  • 动态链接 (Dynamic Linking) (或指向运行时常量池的方法引用)

    • 动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区
    • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令
    • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference)保存在class文件的常量池里。
      • 比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
    • 为什么需要运行时常量池?
      • 因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,节省了空间
      • 常量池的作用:就是为了提供一些符号和常量,便于指令的识别
  • 方法返回地址 (Return Address) (或方法正常退出或者异常退出的定义)

    • 方法返回地址用于存放调用该方法的 pc 寄存器的值
  • 一些附加信息

堆(所有线程共享)

1.定义

  • Heap 堆
    • 通过 new 关键字,创建对象都会使用堆内存
  • 特点
    • 它是线程共享的,堆中对象都需要考虑线程安全的问题
    • 有垃圾回收机制

2.堆内存诊断

  • 1.jps 工具
    • 查看当前系统中有哪些 java 进程
  • 2.jmap 工具
    • 查看堆内存占用情况 jmap – heap 进程id
  • 3.jconsole 工具
    • 图形界面的,多功能的监测工具,可以连续监测
  • 4.jvisualvm工具

1.堆空间的概述

2.设置堆空间初始值和最大值

通过idea 的-VM参数设置,如下图:

3.堆内存细分

4.堆空间大小的设置

  • 详情请看 Java SE8 官方文档 Ctrl+F 搜索 -Xms

  • 通过JVM参数来设置堆空间的初始值

    • -Xms用来设置堆空间(年轻代+老年代)的初始内存大小
      • -X是jvm的运行参数
      • ms是memory start
      • 通过JVM参数来设置堆空间的最大值
  • 通过JVM参数来设置堆空间的最大值

    • -Xmx用来设置堆空间(年轻代+老年代)的最大内存大小
  • 堆的初始大小(以字节为单位)

    • 此值必须是1024的倍数且大于1 MB。在字母后面加上kK表示千字节,mM表示兆字节,gG表示千兆字节
    • 以下示例说明如何使用各种单位将分配的内存大小设置为6 MB
      • -Xms6291456
      • -Xms6144k
      • -Xms6m
      • 如果未设置此选项,则初始大小将设置为为老一代和年轻一代分配的大小之和。可以使用-Xmn选项或-XX:NewSize选项设置年轻代的堆的初始大小
  • 开发中建议将初始堆内存和最大的堆内存设置成相同的值。

    • 打印GC过程的细节
      • JVM参数设置:-XX:+PrintGCDetails

5.新生代与老年代

  • -XX:NewRatio:设置新生代与老年代的比例
  • -XX:SurvivorRatio:设置新生代中Eden区与survivor区的比例
  • -XX: -UseAdaptivesizePolicy :关闭自适应的内存分配策略( 暂时用不到)
  • Xmn:设置新生代的空间的大小。 ( 一般不设置)

6.图解对象分配的过程

7.常用调优工具

  • JDK命令行
  • Eclipse :Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler(暂时使用)
  • Java Flight Recorder
  • GCViewerGC Easy

8.Minor GC、Major GC与Full GC

  • 年轻代GC(Minor GC) 触发机制:
    • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是,Eden代满,Survivor满不会引发GCI(每次Minor GC会清理年轻代的内存。)
    • 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。这一-定义既清晰又易于理解
    • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
  • 老年代GC (Major GC/Fu11 GC)触发机制:
    • 指发生在老年代的GC, 对象从老年代消失时,我们说“Major GC”或“Fu11 GC”发生了
    • 出现了Major GC, 经常会伴随至少一.次的Minor GC (但非绝对的,在Paral1elScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)、
      • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
    • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
    • 如果Major GC后,内存还不足,就报00M了
  • Fu11 GC,触发Fu1l GC执行的情况有如下五种:
    • 调用System. gc()时,系统建议执行Fu1l GC,但是不必然执行
    • 老年代空间不足
    • 方法区空间不足
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    • 由Eden区、survivor space0 (From Space)区向survivor space1 (ToSpace)区复制时,对象大小大于To Space可 用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小;

9.堆空间分代思想

10.内存分配策略

  • 如果对象在Eden(伊甸园区)出生并经过第一次MinorGC 后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1 。对象在Survivor区中每熬过一次MinorGC ,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。
  • 对象晋升老年代的年龄阈值,可以通过选项-XX: MaxTenuringThreshold来设置。
  • 针对不同年龄段的对象分配原则如下所示
    • 优先分 配到Eden
      • 大对象直接分配到老年代
      • 尽量避免程序中出现过多的大对象长期存活的对象分配到老年代
    • 动态对象年龄判断:
      • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenur ingThreshold中要求的年龄。
    • 空间分配担保:
      • -XX: Handle PromotionFailure

11.对象分配过程: TLAB

  • 为什么有TLAB ( Thread Local Allocation Buffer ) ?
    • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
    • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的;
    • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
  • 什么是TLAB ?
    • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
    • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

12.堆空间常用的VM参数

  • -XX: +PrintFlagsInitial :查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal : 查看所有的参数的最终值(可能会存在修改,
    不再是初始值)
  • -Xms:初始堆空间内存 (默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1 /4)
  • Xmn: 设置新生代的大小。(初始值及最大值)
  • XX: +DoEscapeAnalysis ; 开启逃逸分析
  • XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和SO/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX: +PrintGCDetails:输出详细的GC处理日志
  • -XX:HandL ePromotionFailure:是否设置空间分配担保
  • -XX:+EliminateAllocations: 开启标量替换

12.通过逃逸分析看堆空间的对象分配策略

  • 1.堆是分配对象存储的唯一选择吗?
    • 在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
    • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
    • 此外,前面提到的基于openJDK深度定制的TaoBaoVM,其中创新的GCIH (GCinvisible heap) 技术实现off -heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
  • 2.逃逸分析的概述
    • 如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
    • 通过逃逸分析,JavaHotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
    • 当能够明确对象不会发生逃逸时,就可以对这个对象做一个优化,不将其分配到堆上,而是直接分配到栈上,这样在方法结束时,这个对象就会随着方法的出栈而销毁,这样就可以减少垃圾回收的压力
    • 逃逸分析的基本行为就是分析对象动态作用域:
      • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
      • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中
  • 3.逃逸分析:代码优化
    • 一、栈上分配
      • 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
    • 二、同步省略:
      • 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
    • 三、分离对象或标量替换:
      • 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
  • 4.逃逸分析的缺点
    • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的
    • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
    • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
    • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会 在栈.上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle HotspotJVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
    • 目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串.的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

方法区(所有线程共享)

1.定义

官网地址:Chapter 2. The Structure of the Java Virtual Machine

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法(一般指的是类的构造器)。
方法区是在虚拟机启动时创建的。尽管方法区在逻辑上是堆的一部分,但简单的实现可能会选择不进行垃圾收集或压缩它。本规范不要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算需要扩大,如果不需要更大的方法区域,可以缩小。方法区的内存不需要是连续的。Java 虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,以及在方法区域大小可变的情况下,对最大和最小方法区域大小的控制。如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出 OutOfMemoryError。

方法区是规范,是一个概念

Oracle的HotSpot虚拟机对其实现有:永久代(1.8之前)和元数据空间(1.8之后)

  • Oracle的HotSpot在1.6中方法区

可以看到其实现叫做永久代,里面存储了常量池,class的属性,以及class的信息,和类加载器,而且要注意一定,此时的方法区的内存占用还是在JVM的内存中,也就是我们给JVM分配的内存大小中,1.8之后就不再是了

  • Oracle的HotSpot在1.8中方法区

2.内存溢出

  • 1.8以前会导致永久代内存溢出
    • 1.8之前设置永久代大小 -XX:MaxPermSize=10m
  • 1.8及其以后会导致元空间内存溢出
    • 1.8设置元空间大小 -XX:MaxMetaspaceSize=10m
  • 案例:
  • 场景:
    • spring 中用到了 cglib 区处理aop
    • mybatis中的mapper接口,需要用到cglib生成代理,关联对应的xml文件,去帮助我们生成对应的mapper的实现类。

运行时常量池

  • 常量池:就是一张表,虚拟机指令根据这张常量表
  • 运行时常量池:常量池是 *.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实的内存地址

StringTable特性

  • 常量池中的字符串只是符号,当第一次执行到响应的代码时,才会变为字符串对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量拼接的原理是编译优化
  • intern方法:
    • 当intern()方法被调用的时候,如果字符串常量池中已经存在这个字符串对象了,就返回常量池中该字符串对象的地址;如果字符串常量池中不存在,就在常量池中创建一个指向该对象堆中实例的引用,并返回这个引用地址(jdk1.7之前会直接将对象赋值到常量池中)。
    • 例题:

StringTable位置

1. 方法区的基本理解

  • 栈,堆和方法区的关系
    •  方法区可以看作是独立于Java堆的一部分,并且也是和堆一样是整个JVM实例共用一份
  • 方法区的基本理解
    • 各个线程共享区域
    • 在JVM启动时被创建,并且物理内存可以不连续
    • 大小可以固定也可以是动态扩展的
    • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类会出现OOM错误
    • 会随着JVM的关闭而释放这一区域的内存
  • 方法区的演进
    • 在JDK 7以前,习惯上把方法区称为永久代(习惯上),而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替。这两个最大的区别就是:元空间不在虚拟机设置的内存中,而是使用本地内存

2. 设置方法区大小

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

  • JDK 7及以前
    • -XX:Permsize 设置永久代初始分配空间
    • -XX:MaxPermsize 设定永久代最大可分配空间
    • OutOfMemoryError:PermGen space OOM错误
  • JDK 8及以后
    • -XX:MetaspaceSize 和 **-XX:MaxMetaspaceSize** 元数据区大小,其默认值依赖于平台。windows下,-XX:MetaspaceSize=21M -XX:MaxMetaspaceSize=-1 即没有限制
    • -XX:MetaspaceSize:设置初始的元空间大小。这个参数可以看作是初始的水位线,一旦达到这个水位线就会进行Full GC。Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
    • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
    • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace

3. 方法区内部结构

1 方法区存储内容:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等

2 方法区的内部结构

  • 类型信息(方法区需要存储每个加载的类(类,接口,枚举,注解)的以下类型信息:)
    • 完整名称(包类.类名)
    • 这个类的直接父类的完整名称(接口和java.long.object没有父类)
    • 这个类型的修饰符(public,abstract,final的某个子集)
    • 这个类型直接接口的一个有序列表
  • 域(属性)信息(JVM需要保存类型的域信息和域的声名顺序)
    • 域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
  • 方法信息(JVM需要保存所有方法的信息及其声明的顺序) * 方法的名称,返回类型,参数(数量类型,按顺序),修饰符 * 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外) * 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
  • non-final的类变量(static)(静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分)
    • 类变量被类的所有实例共享,即使没有类实例时,你也可以访问它
    • 补充说明:全局常量(static final)被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了

4. 运行时常量池和常量池

运行时常量池:在方法区中。常量池:字节码文件中的一部分

1 常量池

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。存放的内容有字面量,字符串值,类引用,字段引用,方法引用

2 运行时常量池

  • 运行时常量池是方法去的一部分。当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
  •  运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

5. 方法区的演变细节

1 永久代和元空间关系

永久代变为元空间是JRockit和HotSpot融合后的结果,因为JRockit没有永久代,所以他们不需要配置永久代。

原因

  • 为永久代设置空间大小是很难确定的,而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存
  • 对永久代进行调优是很困难的。

2 StringTable为什么要调整位置

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

6. 方法区的垃圾回收

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

判定一个类是否还在使用,比较苛刻:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

常见面试题

  • 百度:
    • 三面:说一下jvm内存模型,有哪些区别,分别是干什么的?
  • 蚂蚁金服:
    • Java8的分代改进
    • jvm分哪几个区,每个区的作用
    • 一面:jvm内存分布/内存结构?栈和堆的区别?堆得结构?为什么有两个survivor区?
    • 二面:eden和survivor的比例分配
  • 小米:
    • jvm内存分区,为什么要有新生代和老年代
  • 字节跳动
    • 二面:jvm的内存分区
    • 二面:讲讲jvm运行时数据区?
    • 什么时候对象会进入老年代
  • 京东
    • jvm的内存结构,Eden和survivor比例
    • jvm的内存为什么要分为新生代,老年代,永久代。新生代中为什么要分为Eden和survivor区
  • 天猫
    • 一面:jvm内存模型以及分区,需要详细到每个分区放什么
    • 二面:jvm的内存模型,Java8做了哪些改变
  • 拼多多
    • jvm内存分哪几个区,每个区的作用
  • 美团
    • Java内存分配
    • jvm的永久代会发生垃圾回收吗
    • 一面:jvm内存分区,为什么要有新生代和老年代

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

1.对象的实例化

1.创建对象的方式:(6种)

  • New,最常见的方式
    • 变形1:单例模式,使用Xxx类的一个静态方法调用构造器
    • 变形2:工厂模式,XxxBuilder/XxxFactory的静态方法
  • Class的newInstance(),反射方式,只能调用无参构造器,且权限是public,jdk1.9以后不建议使用了
  • Constructor的newInstance(Xxx),反射方式,能调用有参、无参构造器,不限权限
  • 使用clone(),不调用构造器,当前类需要实现Cloneable接口,实现clone()
  • 使用反序列化,从文件、网络中获取二进制流,生成对象
  • 第三方库Objenesis

2、创建对象的步骤:(6个步骤)

  • 加载类元信息,判断对象对应的类是否加载、链接、初始化,如果没有,那么要加载
  • 为对象分配内存
    • 如果内存规整,那么使用指针碰撞;
    • 如果内存不规整,虚拟机维护一个空存内存列表,遍历列表,找到合适的内存分配;
    • 选择哪种内存分配方式,取决于Java堆是否整规,而Java堆是否规整取决于垃圾回收器是否带有压缩整理功能。
  • 处理并发安全问题,堆是线程共享,如何保证线程安全
    • 采用CAS配上失败重试保证更新的原子性
    • 为每个线程分配TLAB
  • 初始化分配到的空间(零值初始化)
    • 为所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
  • 设置对象的对象头信息
  • 执行init方法进行初始化,属性的显式初始化、代码块中初始化、构造器中初始化(给对象属性赋值四个步骤:①属性的默认初始化;②显式初始化(属性直接赋值);③代码块初始化;④构造器中初始化)

2、对象的内存布局

1、对象头

  • 运行时元数据:存储对象自身的运行时信息
    • 哈希值:用于根据堆中存储对象的地址来查找对象
    • GC分代年龄:age,对象在堆中进入老年代的判断依据
    • 锁状态标志:是否加锁
    • 线程持有的锁
    • 偏向线程ID
    • 偏向时间戳
  • 类型指针:
    • 指向类元数据InstanceClass,就是指向方法区中存储的类型信息
  • 如果对象是一个Java数组,那么需要记录数组长度

2、实例数据

  • 对象中真正的信息,包含程序中定义的各种类型的字段(包括父类继承下来的和自己的),存放规则如下
    • 父类定义的变量出现在子类之前(因为先加载父类)
    • 相同宽度的字段分配在一起
    • CompactFields为true(默认为true),那么子类的窄变量可以插到父类变量的空隙(节省空间)

3、对齐填充(不是必须的,起到占位符的作用,HotSpot要求对象的大小必须是8字节的整数倍,那么不够8整数倍的就要对齐填充。)

3、对象的访问定位

1、实现方式

  • 方法栈中栈帧内部局部变量表中存储着到堆区的索引;
  • 堆区中存储对象实例;
  • 堆区同时存储到方法区中类型的指针

2、句柄访问

  • 在堆区中开辟一个句柄池,栈帧中存放到句柄的索引
  • 句柄中存着实例化对象的指针和类型指针
  • 在访问过程中,通过句柄池得到对象和类型的指针,再去访问
  • **优点:**实例对象在堆内移动时仅需要改句柄池,松耦合
  • 缺点:中间加了句柄池,速度慢

3、直接指针(HotSpot默认)

  • 栈帧中直接存储着指向Java堆中实例化对象的指针
  • 在堆中的实例化对象中,除了存储对象实例数据,还存储着到方法区中类型数据的指针
  • 优点:速度快
  • 缺点:紧耦合

直接内存

一、直接内存概述

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
  • 直接内存是在Java堆外的、直接向系统申请的内存区间;
  • 来源于NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存;
  • 通常,访问直接内存的速度会优于 Java 堆。即读写性能高;因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存; Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,它直接从操作系统中分配,因此不受Java堆大小的限制,但是会受到本机总内存的大小及处理器寻址空间的限制,因此它也可能导致OutOfMemoryError异常出现。在JDK1.4中新引入了NIO机制,它是一种基于通道与缓冲区的新I/O方式,可以直接从操作系统中分配直接内存,即在堆外分配内存,这样能在一些场景中提高性能,因为避免了在Java堆和Native堆中来回复制数据。

二、访问直接内存的速度会优于 Java 堆

1、非直接缓冲区(传统IO)

读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如上图的操作。使用IO,见上图。这里需要两份内存存储重复数据,效率低。

2、直接缓冲区(NIO)

使用NIO时,如上图。操作系统划出的直接缓存区可以被 java 代码直接访问,只有一份。NIO适合对大文件的读写操作。因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存;

代码示例:

 1 public class BufferTest {
 2     private static final int BUFFER = 1024 * 1024 * 1024;//1GB
 3 
 4     public static void main(String[] args){
 5         //直接分配本地内存空间
 6         ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
 7         System.out.println("直接内存分配完毕,请求指示!");
 8 
 9         Scanner scanner = new Scanner(System.in);
10         scanner.next();
11 
12 
13         System.out.println("直接内存开始释放!");
14         byteBuffer = null;
15         System.gc();
16         scanner.next();
17     }
18 }

三、直接内存的OOM与大小设置

  • 也可能导致outofMemoryError异常(Direct buffer memory)
  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx 指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
  • 缺点:
    • 分配回收成本较高
    • 不受 JVM 内存回收管理
  • 直接内存大小可以通过MaxDirectMemorysize设置
  • 如果不指定,默认与堆的最大值 -Xmx 参数值一致

执行引擎-(编译器、JIT)

1、执行引擎概述

执行引擎是JVM核心组成之一,由于操作系统只能识别机器指令,想要在机器上执行程序,不管什么语言最终都需要转换成机器指令。

JVM中的执行引擎主要将字节码指令转换为机器指令并执行

执行引擎的工作流程:

  • 1.执行引擎执行过程中是根据程序计数器中存储的指令地址来执行对应的指令
  • 2.每当执行引擎执行完一条指令,程序计数器就会更新下一条需要被执行的指令地址
  • 3.当方法执行的过程中,执行引擎可能会通过存储在局部变量表中的引用变量访问堆中对应的对象以及通过对象头中的类型指针找到元空间中对应的类型信息。

2、Java代码的编译器

Java代码的编译器有两种,一种是前端编译器,一种是后端编译器

前端编译器,如javac,即将java文件编译成class文件

后端编译器,即将class文件中的字节码指令编译成本地机器指令:

  • 解释器:
    • 当JVM启动时会根据预定义的规范对字节码采用逐行解释的方法执行 将每条字节码指令编译成对应的本地机器指令并执行
  • JIT编译器(即时编译器):
    • 将字节码指令直接编译成本地机器指令,只负责热点代码的编译工作

默认情况下,HotSpot VM采用解释器与JIT编译器并存的架构,开发者可以通过参数设置完全使用解释器,还是完全使用JIT编译器

-Xint 完全使用解释器来执行程序

-Xcomp 完全使用JIT编译器 如果编译出错 解释器会介入执行

-Xmixed 解释器+JIT编译器共同执行程序(默认)

3、解释器

解释器实际上是Java字节码的一个翻译者,它会将字节码指令编译成本地机器指令并执行.

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

字节码解释器:
通过纯软件代码模拟字节码的执行,效率非常低

模板解释器:
每一条字节码和一个模板函数相关联
模板函数能直接生产这条字节码执行时的机器码

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

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

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

由于解释器在设计和实现上非常简单,除Java外,Python,Perl,Ruby等也是基于解释器执行的,基于解释器执行已经沦落为低效的代名词,为了解决这个问题,JVM提供了即时编译技术

4、即时编译器JIT

Java代码执行过程有两种分类:

  • 一类是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件逐行转为机器码执行
  • 一类是直接将字节码编译为机器码,不用一行一行进行执行

即时编译的目的是避免方法被解释执行,将整个方法编译成机器码,每次执行该方法时,只执行经过即时编译后的机器码,这种方式可以使执行效率大幅度提升

虽然解释器效率较低,JIT编译器效率较高,但是这二者在一起工作比起单纯使用JIT编译器的效果更好

因为当程序启动后,解释器可以立刻执行,响应速度快,而JIT编译器需要将热点代码编译成机器指令,判断热点代码需要一定的时间,尽管JRockit VM中没有解释器,字节码全部靠JIT编译器编译后执行,但为此程序的启动一定需要更多的时间,对于服务端应用而言启动时间不是重点,而对于看重启动时间的应用场景中,不能全靠JIT编译完后再执行,启动会很慢

4.1 HotSpot VM中JIT的分类

JDK10之前在HotSpot VM中内嵌两个JIT编译器,分别为Client Compiler(C1编译器)和Server Compiler(C2编译器),开发者可以通过命令来设置JVM运行时使用哪种JIT,但64位机器只能使用Server Compiler

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

-server 指定使用C2编译器
C2编译器会进行更长时间的优化,以及采取更激进的优化策略,耗时长
使得编译后的代码执行效率更高

C1编译器的优化策略:

  • 方法内联:
    • 将方法中调用的方法编译到一起,减少栈帧的生成,参数的传递和跳转
  • 去虚拟化
    • 将唯一的实现类直接编译到一起
  • 冗余消除
    • 运行时把一些不会执行的代码折叠掉

C2编译器的优化策略:

  • 标量替换
    • 用标量替代聚合对象的属性值
  • 栈上分配
    • 对于未逃逸的对象不在堆中而在栈上分配(通过标量替换 而不是创建对象)
  • 同步消除
    • 清除无效同步操作,synchronized块和关键字

4.2 热点代码

当某段字节码指令是否需要被JIT编译器编译成机器指令,是需要根据这段代码被调用执行的频率决定,高频被调用的代码也称为热点代码,JIT编译器在运行期间会对热点代码做出深度优化并将其编译成机器指令缓存到方法区的Code Cache,以提升Java程序的执行性能

热点代码: 一个被多次调用的方法,或者一个方法体内部循环较多的循环体
都可以称为热点代码,通过JIT编译成机器指令

一个方法需要被执行多少次,或一个循环体要执行多少次循环才能算热点代码,这必然需要一个明确的阈值,这个阈值在C1中是1500,在C2中是10000

判断热点代码需要热点探测功能,HotSpot VM所采用的的热点探测方式是基于计数器的热点探测

4.3 热点探测

HotSpot VM为每一个方法建议两个不同类型的计数器,来实现热点探测,分别是方法调用计数器和回边计数器

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

4.4 热度衰减

如果一个程序执行的时间够长,那么所有的方法几乎都可以成为热点代码,被JIT编译并缓存到方法区的CodeCache种,可以使用热度衰减来避免

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频流,即一段时间内方法被调用的次数,当超过一定的时间限度,如果方法的调用次数不足以让它提交给JIT,那这个方法的调用计数器就会减少一半,这个过程称为计数器的衰减,而这段是按称为此方法统计的半衰周期

进行热度衰减的动作是在进行GC时顺便进行的,热度衰减可以通过参数来关闭,让方法计数器统计方法调用的绝对次数,这样只要系统工作的够久,绝大部分方法都会成为热点代码,被JIT编译,前提是元空间的大小足够

垃圾回收概述

1.如何判断对象可以回收

  • 引用计数法(jvm没采用这种,可能会造成循环依赖,释放不了内存,进而导致内存泄漏)
  • 可达性分析算法
    • Java虚拟机采用此算法来探索所有存活的对象
    • 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到表示该对象可以回收

2.四种引用

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

什么是垃圾回收

  • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾
  • 垃圾回收(英语:Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间,垃圾回收最早起源于LISP语言

为什么需要GC

  • 如果不进行垃圾回收,内存迟早要被用完
  • 垃圾回收可以清除内存里的碎片,以便于 JVM 将整理出的内存分配给新对象
  • 业务越来越复杂、庞大,不进行GC程序无法正常运行

Java垃圾回收机制

  • 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险
  • 将应用程序开发人员从手动管理内存中解放出来

缺点:

如果过度依赖自动管理,会弱化Java开发人员在遇到内存溢出时定位问题和解决问题的能力

垃圾回收相关算法

标记阶段 概述

  • 在堆里存放几乎所有的java对象实例,在GC执行之前,首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会执行垃圾回收,释放其占用的内存空间,因此这个过程我们成为垃圾标记阶段
  • 判断对象存活一般有两种方式:引用计数算法 和 可达性分析算法

引用计数算法 Reference Counting

  • 比较简单,堆每个对象保存一个整型的引用计数器属性.用于记录对象被引用的情况
  • 对于一个对象A,只要有任何一个对象引用了A,那么A的引用计数器+1,当引用失效时,计数器-1.只要对象A的引用计数器值为0时,则表示对象A不可能再被引用,即可被回收
  • 优点: 实现简单,垃圾对象便于标识,判断效率高,回收没有延迟性
  • 缺点
    • 需要单独添加字段存储计数器 增加了存储空间开销
    • 每次复制都需要更新计数器,增加了时间开销
    • 最致命的问题是无法处理循环引用的问题,直接导致java的GC没有使用这个标记算法
  • 循环引用 例如 一个变量指针P指向A 其中A引用B B引用C C引用A 内部形成了一个引用链,当一个P指针不再指向A 本质上A已经不会再被使用,但因为循环依赖的关系,导致A的计数器永远不能被减到0,导致永远不能被回收产生内存泄漏

证明HotSpot并没有使用引用计数算法

public class ProveJavaNotUseReferenceCounting {

    private byte[] data = new byte[1024 * 1024 * 5];

    Object ref = null;

    public static void main(String[] args) {
        ProveJavaNotUseReferenceCounting obj1 = new ProveJavaNotUseReferenceCounting();
        ProveJavaNotUseReferenceCounting obj2 = new ProveJavaNotUseReferenceCounting();
        obj1.ref = obj2;
        obj2.ref = obj1;
        obj1 = null;
        obj2 = null;
        System.gc();
    }
}

[GC (System.gc()) [PSYoungGen: 13578K->744K(38400K)] 13578K->752K(125952K), 0.1038107 secs] [Times: user=0.00 sys=0.00, real=0.11 secs]
[Full GC (System.gc()) [PSYoungGen: 744K->0K(38400K)] [ParOldGen: 8K->642K(87552K)] 752K->642K(125952K), [Metaspace: 3277K->3277K(1056768K)], 0.0081173 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5900000, 0x00000000d8380000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5900000,0x00000000d59534a8,0x00000000d7980000)
from space 5120K, 0% used [0x00000000d7980000,0x00000000d7980000,0x00000000d7e80000)
to space 5120K, 0% used [0x00000000d7e80000,0x00000000d7e80000,0x00000000d8380000)
ParOldGen total 87552K, used 642K [0x0000000080a00000, 0x0000000085f80000, 0x00000000d5900000)
object space 87552K, 0% used [0x0000000080a00000,0x0000000080aa0bb0,0x0000000085f80000)
Metaspace used 3283K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K

通过-XX:+PrintGCDetails 查看发现即是obj1 和 obj2有循环引用 当指针obj1和obj2置空的时候GC还是回收了内存,说明HotSpot并没有使用引用计数器作为GC的标记算法

小结

  • 引用计数算法,是很多语言资源回收的选择,如Python支持引用计数和垃圾收集机制.具体哪种更优需要结合场景,业界有大规模的实践中仅保留了引用计数机制,已提高吞吐量
  • java没有选择引用计数,根本原因是因为循环依赖问题
  • Python解决循环引用的方法主要有两种
    • 手动解除 如 obj.ref = None
    • 使用弱引用weakref模块 弱引用在GC一定会被回收.
    • 在计算机程序设计中,弱引用,与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则可能在任何时刻被回收。弱引用的主要作用就是减少循环引用,减少内存中不必要的对象存在的数量。

可达性分析算法

  • 可达性分析算法 也称为根搜索算法,追踪性垃圾收集
  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是算法可以有效的解决在引用数据类型循环引用的问题,防止内存泄漏
  • 相较于引用计数算法,这里可达性分析就是java C#的选择.这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)

思路

  • GCRoots根集合就是一组必须活跃的引用

基本思路

  • 可达性分析是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接,搜索所走过的路径成为引用链(Reference Chain)
  • 可达性分析算法,只有能够被根对象集合直接或间接连接的对象才是存活对象

GC Roots可以是哪些

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

总结

  • 总结一句话就是,除了堆空间外的一些结构,比如虚拟机栈,本地方法栈,方法区,字符串常量池等地方对堆空间进行引用,都可以作为GC Roots进行可达性分析
  • 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收内存区域不同,还可以有其他对象临时性加入,共同构成完成的GC Roots集合.比如 分代收集和局部回收(Partial GC)
  • 如果只针对java堆中的某一个区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是鼓励封闭的,这个区域的对象完全有可能被其他区域对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性

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

注意

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

对象的finalization机制

  • java提供了对象终止(finalization)机制允许开发人员提供对象被销毁之前自定义处理逻辑
  • 当GC发现没有引用指向一个对象的时候,总会先调用这个对象的finalize()方法
  • finalize()方法允许在子类中被重写,用于在对象被回收进行资源释放.通常在这个方法中进行一些资源释放和清理工作.比如关闭文件 关闭套接字 关闭数据库连接等
  • 但GC对一个对象的finalize方法只会调用一次

注意:不要主动调用某对象的finalize()方法!应该交给GC去调用

  • 在finalize()方法可能会导致对象复活
  • finalize()方法执行的时间没有保障,他完全由GC线程决定,极端情况下,若不发生GC,则finalize方法永远不会执行
  • 因为优先级比较低,即是主动调用,也不会因此就直接进行回收
  • 一个糟糕的finalize方法会严重影响GC性能
  • 功能上说finalize方法跟C++的析构函数类似,但是java采用的基于垃圾回收器的自动内存管理机制,所以finalize方法在本质上不同于C++的析构函数

虚拟机对象三种状态

由于finalize方法的存在,虚拟机对象可能存在三种状态

如果所有根节点都无法访问某对象,说明对象已经不在被使用.一般来说,此时对象需要被回收.但事实上,也并非是”非死不可”.一个无法触及的对象有可能在某一个条件下复活自己,如果这样,那么对他的回收就是不合理的.

  • 可触及状态: 从GC Roots集合中可以到达的对象 不能会GC标记回收
  • 可复活状态: 对象所有引用被释放,但是对象finalize方法还没被调用,可能在finalize中复活
  • 不可触及状态: 对象的finalize被调用之后并没有复活.进入不可触及状态,不可触及状态对象不能被复活,因为finalize方法只会调用一次,即对象只能被复活一次

finalization的过程

  • 判断一个对象obj是否可以被回收,至少经历两次标记过程
    • 如果对象obj到GC Roots没有引用链,则进行第一次标记
    • 进行筛选,判断此对象是否有必要执行finalize方法
      • 如果对象obj没有重写finalize()方法,或者finalize方法已经被虚拟机调用过,则虚拟机视为没有必要执行,obj被判断为不可触及
      • 如果obj重写了finalize方法,且未被执行,obj插入到F-Queue队列中,由虚拟机自动创建的低优先级Finalizer线程触发其finalize方法执行
        • finalize方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue的对象进行二次标记
        • 如果obj在finalize方法中与引用链上任何一个对象建立了联系,那么二次标记,obj会被移除即将回收集合,之后如果再出现对象没有被任何引用指向的情况,finalize方法将不会再被调用,对象直接进入不可触及状态. finalize只会被调用一次

代码演示在finazlie复活的情况

public class ReviveTest {

    // 类成员 是GC Roots的集合一员
    public static ReviveTest data;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize 方法调用");
        // 这句话使得对象在finalize中被复活
        data = this;
    }

    public static void main(String[] args) throws InterruptedException {
        data = new ReviveTest();
        data = null;
        System.out.println("第一次执行GC============");
        System.gc();
        TimeUnit.SECONDS.sleep(2);
        if (data == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is alive");
        }

        System.out.println("第二次执行GC============");
        data = null;
        System.gc();
        TimeUnit.SECONDS.sleep(2);
        if (data == null) {
            System.out.println("obj is dead");
        } else {
            System.out.println("obj is alive");
        }
    }
}


第一次执行GC============ finalize 方法调用 obj is alive 第二次执行GC============ obj is dead 在进行第一次清除的时候,我们会执行finalize方法,然后 对象 进行了一次自救操作,但是因为finalize()方法只会被调用一次,因此第二次该对象将会被垃圾清除。

清除节点

当进行完成标记阶段明确哪些对象是垃圾之后,GC将执行垃圾回收,释放无用对象占用的内存空间,以便有足够的内存空间为新对象分配内存.主流三种垃圾收集算法如下

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

标记清除算法

当堆中有效内存(available memory)被耗尽的时候,就会停止整个程序(Stop The World),GC进行标记和清除工作

  • 标记: Collector 从引用根节点GC Roots开始遍历,标记所有被引用的对象.一般在对象的Header对象头中记录为可达对象. 注意!!!标记的是被引用对象而不是垃圾!!
  • 清除: Collector对堆内存从头到尾进行线性遍历,如果发现对象在Header对象头中没有被标记为可达对象则将其回收

什么是清除?

  • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲地址列表中.下次有新对象需要加载,判断空闲列表中的空间是否够,如果够则覆盖原有的地址
  • 空闲列表的概念在对象分配内存时作用
    • 如果内存规整 采用指针碰撞的方式进行内存分配 内存规整说明已用内存是连续的.指针碰撞则表示一个指针永远指向已用内存的最后一个位置,当需要新分配内存的之后指针下移就是空闲内存的开始位置
    • 如果内存不规整 虚拟机需要维护一个空闲列表,来判断内存空间哪些位置是空的

标记清除算法的优缺点

  • 优点在于实现简单,容易理解
  • 标记清除算法效率不算高
  • 在进行GC的时候,需要STW,用户体验较差
  • 清理出来的空闲空间是不连续的,产生内存碎片,需要维护一个空闲列表

复制算法

  • 将内存空间一分为二,每次只使用其中的一块,在垃圾回收时将正在使用的内存存活对象复制到未使用的那块内存块中,之后清除正在使用内存块的所有对象,交换角色,完成垃圾回收.JVM堆内存中的新生代S0 S1幸存者区使用的就是复制算法

复制算法优缺点

  • 优点
    • 没有标记清除过程,实现简单,运行高效
    • 复制过去保证空间连续性,不会出现内存碎片
  • 缺点
    • 需要两倍的内存空间
    • 对于G1这种分拆成为大量的 region的GC,复制而不是移动,意味着GC需要维护region之间对象的引用关系,不管是内存占用或者时间开销都不小

注意事项

  • 如果系统中存活对象较多,不适合使用复制算法
  • 复制算法适合系统存活对象比较低的时候(比如新生代中的朝生夕死的对象 幸存者区也确实使用的是复制算法,老年代中的大量生命周期很长的对象如果使用复制算法效率就会很低,因为每次GC都需要将对象重新复制到另一块内存中)
  • 在新生代,对于常规应用的垃圾回收,一次性通过可以回收70%-99%的内存空间,回收性价比很高.所以很多商业虚拟机都是用这种算法回收新生代

标记-压缩算法

因为复制算法不适合处理存活对象较多的场景,而标记清除算法是线性遍历时间复杂度高而产生了内存碎片,所以后来提出了标记压缩算法 Mark-Compact结合了之前两种算法的优点

执行过程

  • 第一阶段与标记清除算法一样,从GC Roots开始遍历标记所有为引用的对象
  • 第二阶段将所有存活的对象压缩到内存的一端,按顺序排放,之后清理边界之外的所有空间

标记清理算法和标记压缩算法的区别

  • 本质差异在于标记清除算法是一种非移动式的回收算法,标记压缩算法是移动式的.
  • 是否移动回收存活对象是一项优缺点并存的风险策略,移动带来的各种关联属性地址修改的时间成本,带来的收益就是保证了内存的连续性
  • 标记存活的对象将被整理,按内存地址依次排列,未被标记的对象将被清理,这种情况下JVM只需要维护一个可用内存起始地址即可,不需要像标记清除算法一样维护一个空闲列表

标记压缩算法的优缺点

  • 优点
    • 解决了标记清除算法中内存不连续的问题,JVM只需维护一个可用内存的起始地址即可
    • 解决了复制算法中可用内存减半的问题
  • 缺点
    • 效率上来说标记整理算法低于复制算法
    • 移动对象的同时,如果对象被其他对象引用,还需要调整引用地址
    • 移动过程中会导致STW

小结

不同场景选择不同的算法

分代收集算法

  • 上述算法中,没有一个算法可以完全替代其他算法,每种算法具体自己的优势和缺点,分代收集算法应运而生
  • 分代收集算法,基于不同对象的声明周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。一般是把java堆分为新生代和老年代,这样就可以根据各年代的特点选择使用不同的回收算法,从而提高垃圾回收的效率
  • 在实际生产中会产生大量的对象,其中一些对象是与业务信息相关的,比如Http请求中的Session对象,线程,Socket连接,这类对象跟业务直接挂钩,因此生命周期长.但是有些对象,比如一些临时变量,这些对象生命周期就会比较短,如String,但由于String的不变性,系统会产生大量的这些对象,有些对象设置只使用了一次即可被回收
  • 目前几乎所有的GC都采用了分代收集算法进行垃圾回收
  • 在HotSpot中,基于分代思想,GC所使用的内存回收算法必须结合新生代和老年代各自的特点
    • 新生代 young Gen
      • 区域相对老年代小,对象生命周期短,存活率低,回收频繁
      • 这种情况使用复制算法,速度最快.新生代中的对象生命周期大部分较短,且空间占比较小,更进一步的HotSpot使用了两个Survivor进行缓解,所以在新生代就适合使用复制算法
    • 老年代 Tenured Gen
      • 区域大,对象的周期长,存活率高,回收不及新生代频繁
      • 因为老年代存在大量存活率高的对象,复制算法显然不合适.一般是采用标记清除算法或者标记压缩算法混合实现
        • Mark阶段的时间复杂度与存活对象正相关
        • Sweep阶段时间复杂度与内存区域大小正相关
        • Compact阶段时间复杂度与存活对象正相关
    • 以HotSpot中的CMS回收器为例,CMS基于Mark-Sweep实现,对于对象的回收效率很高.对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施,当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用serial old执行 FullGC以达到对老年代内存整理
    • 分代的思想被现在的虚拟机广泛采用,几乎所有的垃圾回收器都区分新生代和老年代

增量收集算法

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

分区算法

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

垃圾回收相关概念

1.System.gc()的理解

在程序中,调用System.gc()或者Runtime.getRuntime().gc()方法,会显式触发Full GC,同时对年轻代和老年代进行垃圾回收,尝试释放失去引用的垃圾对象所占用的内存空间,但一般情况下,垃圾回收应该是自动进行的,除非一些特殊情况,如测试某模块性能前,可以先调用一次System.gc()

System.gc()实质上是调用了Runtime.getRuntime().gc()方法,而Runtime.getRuntime().gc()本质上执行了一个本地方法gc()

System.gc()附带一个免责声明,它无法保证对垃圾收集器的成功调用,它只是提醒JVM希望进行一次Full GC,但Full GC是否进行,什么时候进行无法保证

来看一个示例:

创建一个对象并重写该对象的finalize()方法
让该对象没有任何的引用关系 成为一个垃圾对象
在程序结束前 主动调用一次System.gc()进行垃圾回收
如果System.gc()触发了Full GC 垃圾对象的finalize()方法将会被执行

结果1,使用System.gc()成功触发了GC:

结果2,System.gc()并没有触发GC:

上述的测试证实了System.gc()并不能直接触发GC,而是提醒JVM进行GC,至于JVM执不执行GC,什么时候执行无从得知

2 内存溢出和内存泄漏

2.1 内存溢出

内存溢出是引发程序崩溃的罪魁祸首之一,内存溢出会直接引起OOM(OutOfMemoryError),即没有空闲内存,且垃圾收集器也无法提供更多内存空间

1,JVM堆内存设置太小,不够用

2,代码中创建了大量需要长时间存活的对象,占用了堆中大量空间,且不能被回收

3,当创建新对象时发现内存不足后,垃圾收集器进行GC
   GC执行完后,发现内存还是不足


由于GC一直在发展,一般情况下,除非应用程序占用的内存增长速度非常快,造成GC释放内存空间的速度跟不上内存消耗的速度,否则不太容易出现OOM的情况,大多数情况下,GC会进行各年龄段的垃圾回收,实在不行,就进行一次Full GC,这时候会回收大量的内存,供应用程序继续使用

2.2 内存泄漏

内存泄漏即某对象不再被程序使用,但GC又无法回收它所占的空间,尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中可用的内存就会减少,直到所有内存耗尽,最终引起内存溢出,导致程序崩溃

由于JVM使用可达性分析算法,所有处于引用链上的对象都被视为可达的对象,无法被回收,但处于引用链上的某些对象可能不再被使用,但是由于没有断开指向它们的指针,导致它们仍然挂在引用链上,本应该被回收的垃圾被视为了可达对象,它们被占用的空间无法被释放,这就造成了内存泄露

如对于某些变量,可以在方法内定义成局部变量,但是却将其定义为成员,甚至是静态成员,这样做明显地增长了该变量的生命周期,但该变量只在某个方法内起作用,其所占用的空间在使用后迟迟不能释放,这种也可以称为宽泛意义上的内存泄漏

内存泄露的例子

1,单例模式所生产的单例对象,它们的生命周期和应用程序一样长
在程序中,单例对象如果持有对外部对象的引用
那么这个外部对象的生命周期就和单例对象一样长 不能被回收 导致内存泄漏

2,一些资源没有close()导致内存泄漏
如数据库连接,网络连接socket和io等必须手动关闭,否则不能被回收 导致内存泄漏

3.Stop The World的理解

STW指在GC过程中,会产生应用程序的停顿,停顿产生时,整个应用程序线程都会被暂停,这个停顿称为STW

标记垃圾阶段,使用的可达性分析算法会导致所有用户线程停顿,原因:

分析工作必须在一个能确保一致性的快照中进行,即应用程序必须暂停
因为分析过程中,如果应用程序还在执行,那么分析结果的准确性无法保证
避免GC时引用关系又发生变化,导致分析结果出现误差

被STW中断的用户线程会在GC结束后恢复,频繁的STW产生的中断,会给用户一种程序卡顿的感觉,为此要尽量减少STW的发生和STW产生停顿的时长

关于STW的几个说明:

1,STW和采用哪款GC无关,所有的GC都会出现STW
   即使是G1也不能避免STW发生,只能说垃圾回收器越优秀
   回收效率越高,尽可能缩短了STW的停顿时长

2,STW是JVM在后台自动发起和自动完成的
   在用户不可见的情况下,停止了所有正常执行任务的线程

3,开发中不要使用System.gc() 它可能导致STW的发生
   对于STW,越少越好,越短越好

4 安全点和安全区域

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

安全点的选择很重要,安全点太少可能导致GC等待的时间太长,太多可能导致程序运行时的性能问题,大部分指令的执行时间都非常短暂,通常会选择一些执行时间较长的指令作为安全点,如方法调用,循环跳转,异常跳转等

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

有两种方式:

1,抢先式中断(已无虚拟机采用)
首先中断所有线程,如果还有线程不在安全点
就恢复这些不在安全点的线程,让线程跑到安全点

2,主动式中断
设置一个中断标志,程序将准备进行GC时,中断标志变为真
各个线程运行到安全点时主动轮询这个标志
如果中断标志为真,线程将自己中断挂起

安全点的机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点,但是当程序“不执行”时,如线程处于Sleep状态或Block状态,这时线程无法响应JVM的中断请求,走到安全点将自己挂起,JVM也无法将这类线程唤醒,对于这种情况,就需要 安区区域(Safe Region) 来解决

安全区域指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的,可以把安全区域看做被扩展的安全点

实际执行时:
当线程运行到安全区域的代码时,首先标识已经进入了安全区域
如果这段时间内发生了GC,JVM会忽略标识为安全区域状态的线程

当线程即将离开安全区域时,会减产JVM是否已经完成GC
如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开安全区域的信号为止

5 Java中的引用

在Java中将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)这4种引用强度逐渐减弱

强引用(Strong Reference) 
代码中普遍存在的引用赋值 类似Object obj = new Object();这种引用关系
无论任何情况下,只要强引用关系还存在,垃圾收集器就不会回收被引用的对象

软引用(Soft Reference) 
在系统将要发生内存溢出前,将把软引用关联的对象列入回收范围进行第二次回收
如果这次回收后还没有足够的内存,才会抛出OOM

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

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

5.1 强引用 Strong Reference

Java程序中,99%的引用类型是强引用,也就是最常见的普通对象引用,也是默认的引用类型,使用new操作符创建一个新的对象,并将其赋值给一个变量时,这个变量就称为指向该对象的一个强引用

当强引用的对象仍挂在引用链上时,GC就不会回收它,为此强引用是Java内存泄漏的主要原因之一

强引用的特点:
1,强引用可以直接访问目标对象

2,强引用的对象只要仍能被GC Root触及,就不会被回收

3,强引用可能导致内存泄漏

5.2 软引用 Soft Reference

软引用被用来描述一些还有用,但非必须的对象,被软引用关联的对象再系统将要抛出OOM前,将会被列入回收范围,进行第二次回收,将可达的软引用对象也进行回收,回收后内存空间还不足就会抛OOM


软引用通常迎来实现内存敏感的缓存,如高速缓存就有用到软引用,如果还有空闲内存,就暂时保留缓存,当内存不足时再清理掉,这样就保证了使用缓存的同时,不会耗尽内存

软引用的特点:
当内存足够时,不回收软引用的可达对象
内存不足时,回收软引用的可达对象

5.3 弱引用 Weak Reference

弱引用也是用来描述那些非必须对象,只被弱引用关联的对象只能生存到下一次GC发生为止,在系统GC到来时,只要该对象是弱引用,不管堆空间是否足够,不管该对象是否可达,都会直接回收

但是由于GC线程的优先度通常很低,因此并不一定能很快发现持有弱引用的对象,这种情况下,弱引用对象可以存在较长的时间

软引用和弱引用都非常适合保存那些可有可无的缓存数据,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出,当内存充足时,这些数据又可以存在较长的时间,从而加速系统

弱引用的特点:

GC时只要发现弱引用对象,就直接回收

5.4 虚引用 Phantom Reference

虚引用是所有引用类型中最弱的一个,一个对象是否有虚引用的存在,完全不会决定该对象的生命周期,如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时可能被当作垃圾回收

虚引用不能单独使用,也无法通过虚引用获取被引用的对象,当使用虚引用的get()方法试图获取对象时,结果总是null,为一个对象设置虚引用的唯一目的就是跟踪垃圾回收过程,当持有虚引用的对象被回收后,会收到一个系统通知

虚引用在创建时,必须要和引用队列一起使用,当垃圾收集器准备回收一个对象时,发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序该对象已被回收



虚引用的特点:
无法单独使用 不影响对象的生命周期 无法通过虚引用获取对象实例
只能跟踪垃圾回收过程 当持有虚引用的对象被回收后 可以收到一个系统通知

JVM垃圾回收器 七种经典垃圾回收器

垃圾回收器概述

  • 垃圾收集器没有在任何规范中进行过多的规定,可以由不同的厂商,不同版本的JVM来实现
  • 由于jdk的版本处于高速迭代过程中,因此java发展至今已经衍生了众多的GC版本
  • 垃圾回收器分类
    • 按线程数分,可分为串行垃圾回收器和并行垃圾回收器
    • 按工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
    • 按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器
    • 按工作的内存空间分,可分为年起代垃圾收集器和老年代垃圾收集器

评估GC的性能指标

  • 吞吐量: 运行用户代码的时间占总时间的比例 (总运行时间: 程序运行时间+内存回收时间)
  • 垃圾收集开销: 吞吐量的补数,垃圾收集器所用时间与总时间的比例
  • 暂停时间: 执行垃圾收集时,程序的工作线程被暂停的时间
  • 收集频率: 相对于应用程序的执行,收集操作发生的频率
  • 内存占用: Java堆区所占的内存大小
  • 快速: 一个对象从诞生到被回收所经历的时间
这三者共同构成一个"不可能三角",三者总体的表现随着技术的进步越来越好,一款优秀的收集器通常最多同时满足其中两项

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

吞吐量(throughput)

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

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

暂停时间(pause time)

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

七种经典的垃圾回收器

  • 串行回收器: Serial,Serial Old
  • 并行回收器: ParNew,Parallel Scavenge,Parallel Old
  • 并发回收器: CMS,G1

垃圾收集器组合关系

其中Serial Old作为CMS出现”Concurrent Mode Failure”失败的后备预案。
(红色虚线)在jdk8时将这两个组合声明为废弃,并在jdk9中完全取消
(绿色虚线)在jdk14中废弃
(绿色虚线)jdk14中,删除CMS垃圾收集器

Serial回收器 串行回收
  • Serial收集器是最基本,历史最悠久的垃圾收集器,jdk1.3之前回收新生代唯一的选择
  • Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器
  • Serial收集器采用复制算法,串行回收”Stop the World”机制的方式执行垃圾回收
  • 除了年起代之外,Serial收集器还提供了用于执行老年代垃圾收集的Serial Old收集器,Serial Old收集器同样也采用了串行回收”Stop the World”机制,只不过内存回收算法使用的是标记-压缩算法
    • Serial Old是运行在Client模式下默认的老年代垃圾回收器
    • Serial Old在Server模式下主要有两个用途: 1.与新生代的Parallel Scavenge配合使用,2.作为老年代CMS收集器的后背垃圾收集器方案
  • 这个收集器是一个单线程的收集器,但它的”单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程区完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有所有的工作线程,直到它收集结束
  • 优势: 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集,自然可以获得最高的单线程收集效率
  • 在HotSpot虚拟机中,使用-XX:+UserSerialGC参数可以指定年轻代和老年代都使用串行收集器,等价于新生代用Serial GC,老年代用Serial Old GC
ParNew回收器 并行回收
  • 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本

  • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别,ParNew收集器在年轻代中同样也是采用复制算法,”Stop the World”机制

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

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

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

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

  • 通过”-XX:+UseParNewGC”手动指定使用ParNew收集器执行内存回收任务,它表示年轻代使用并行收集器,不影响老年代

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

Parallel Scavenge回收器 吞吐量优先
  • HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法,并行回收和”Stop the World”机制
  • 和ParNew收集器不同,Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器
  • 自适应调节策略也是Parallel Scavenge与ParNew一个重要区别
  • 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,因此,常见在服务器环境中使用,例如那些执行批量处理,订单处理,工资支付,科学计算的应用程序
  • Parallel收集器在jdk1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器
  • Parallel Old收集器采用了标记-压缩算法,同样也是基于并行回收和”Stop the World”机制
  • “-XX:+UseParallelGC”手动指定年轻代使用Parallel并行收集器执行内存回收任务
  • “-XX:+UseParallelOldGC”手动指定老年代使用并行回收收集器
  • 上面两个参数分别适用于新生代和老年代,jdk8默认是开启的.上面两个参数,默认开启一个,另一个也会被开启(互相激活)
  • “-XX:ParallelGCThreads”设置年轻代并行收集器的线程数,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能,在默认情况下,CPU数量小于8,ParallelGCThreads的值等于CPU数量,当CPU数量大于8,ParallelGCThreads的值等于3+(5*CPU_COUNT/8)
  • “-XX:MaxGCPauseMillis”设置垃圾收集器最大停顿时间(即STW的时间)单位是毫秒(该参数使用需谨慎)
    • 为了尽可能地把停顿时间控制在MaxGCPauseMillis以内,收集器在工作时会调整Java堆大小或者其他一些参数
    • 对于用户来讲,停顿时间越短体验越好,但是在服务器端,我们注重高并发,整体的吞吐量,所以服务器端适合Parallel,进行控制
  • “-XX:GCTimeRatio”垃圾收集时间占总时间的比例(=1/(N+1)),用于衡量吞吐量的大小
    • 取值范围(0,100),默认99,也就是垃圾回收时间不超过1%
    • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性,暂停时间越长,Radio参数就越容易超过设定的比例
  • “-XX:+UseAdaptiveSizePolicy”设置Parallel Scavenge收集器具有自适应调节策略
    • 在这种模式下,年轻代的大小,Eden和Survivor的比例,晋升老年代的对象年龄等参数会被自动调整,已到达在堆大小,吞吐量和停顿时间之间的平衡点
    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆,目标的吞吐量和停顿时间,让虚拟机自己完成调优工作
CMS回收器 低延迟
  • 在jdk1.5时期,HotSpot退出了一款在强交互应用中几乎认为有划时代意义的垃圾收集器,CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集器线程与用户线程同时工作
  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验.目前很大一部分的Java应用集中在web网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求
  • CMS的垃圾收集算法采用标记-清除算法,并且也会”Stop the World “
  • CMS作为老年代的收集器,却无法与jdk1.4中已经存在的新生代收集器Parallel Scavenge配合工作,所以在jdk1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个
  • 在G1出现之前,CMS使用还是非常广泛的,一直到今天,仍然有很多系统使用CMS GC
  • CMS整个过程比之前的收集器都要复杂,整个过程分为4个阶段,即初始标记阶段,并发标记阶段,重新标记阶段和并发清除阶段
    • 初始标记阶段(Initial-Mark): 在这个阶段中,程序中所有的工作线程都将会因为”Stop the World”机制而出现短暂的暂停,这个阶段主要任务仅仅只是标记出GC Roots能直接关联到的对象,一旦标记完成之后就会恢复之前被暂停的所有应用线程,由于直接关联对象比较少,所以这里速度非常快
    • 并发标记阶段(Concurrent-Mark): 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    • 重新标记阶段(Remark): 由于在并发标记阶段中,程序工作线程会和垃圾收集线程同时运行或交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
    • 并发清除阶段(Concurrent-Sweep): 此阶段清理删除掉标记阶段判定的已经死亡的对象,释放内存空间,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 由于最耗费时间的并发标记与并发清除阶段都是不需要暂停用户线程的,所以整体的回收是低停顿的
  • 尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行”Stop the world”机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要”Stop the World”,只是尽可能地缩短暂停时间
  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用,因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行.要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时虚拟机将启动后备预案: 临时启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了
  • CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象占用的内存空间极有可能是不连续的内存块,不可避免地将会产生一些内存碎片,那么CMS在为新对象分配内存空间时,将无法使用指针碰撞技术,而只能够选择空闲列表执行内存分配
  • CMS的优点:并发收集,低延迟
  • CMS的弊端
    • 会产生内存碎片,导致并发清除后,用户线程可用的空间不足,在无法分配大对象的情况下,不得不提前触发Full GC
    • CMS收集器对CPU资源非常敏感,在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量降低
    • CMS收集器无法处理浮动垃圾,可能出现”Concurrent Mode Failure”失败而导致另一次Full GC的产生,在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或交叉运行的,那么在并发阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间
  • “-XX:+UseConcMarkSweepGC” 手动指定使用CMS收集器执行内存回任务,开启该参数后会自动将-XX:+UseParNewGC打开,即ParNew(Young区)+CMS(Old区)+Serial Old组合
  • “-XX:CMSlnitiatingOccupanyFraction” 设置堆内存使用率的阈值,一旦达到该阈值,便开始回收
    • jdk5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收,jdk6及以后版本默认值为92%
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能,反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器,因此通过该选项便可以有效降低Full GC的执行次数
  • “-XX:+UseCMSCompactAtFullCollection” 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生,不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了
  • “-XX:CMSFullGCsBeforeCompaction” 设置在执行多少次Full GC后对内存空间进行压缩整理
  • “-XX:ParallelCMSThreads” 设置CMS的线程数量
    • CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年起代并行收集器的线程数,当CPU资源比较紧张时,收到CMS收集器线程数的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕
G1收集器 区域化分代式(后续更新)