JVM学习笔记(五-运行时数据区)

运行时数据区

运行时数据区图解

JVM学习笔记(五-运行时数据区)

程序计数寄存器(Program Counter Register)

概念

通常习惯简称为程序计数器PC寄存器,用于记录下一个字节码指令地址的区域,在运行时数据区当中执行效率最快,没有GC垃圾回收,也没有OOM内存溢出异常,因为只存放一个指令地址信息,而且会随着执行不断地覆盖。每一个线程都会私有一份,不被共享。

问题

  1. 为什么需要PC寄存器?

    在上述概念当中也提到,它是用于存储下一个字节码指令的地址(偏移地址),之所以需要存储,是因为我们知道,CPU是多线程并发执行的,也就是多个线程来回切换执行。举个例子,有两个线程,它们分别是线程1和线程2,假如线程1首先抢到CPU执行权,执行到一半后切换到线程2执行,线程2也执行了一部分后想切换回线程1,但是CPU可不知道它上次执行到哪,那就没办法接着往下执行了。所以就需要有那么一个区域,存储着上次执行后下一个指令的地址。

  2. 为什么是每个线程私有一份PC寄存器,不能共享一份呢?

    我们知道CPU的并发是线程间不断切换执行的,但没个线程的执行情况往往是不一样的,比如线程1执行了1/2,如果PC寄存器是公用的,那么就会把1/2后的下一条指令的地址存放到该寄存器中,然后切换到线程2,而线程2执行了1/3后想把下一指令地址存放起来,那么就会覆盖掉原来线程1的地址,先不说两线程的执行进度不一样,它们的指令更是极大可能不一样的,那么指令地址不断的覆盖,将会导致无法正常地往下执行,所以PC寄存器必须是各线程私有的,不能共享。

虚拟机栈(VM Stack)

概念

既然是栈就会有栈的特点,也就是先进后出或后进先出,只会操作栈顶元素,比如入栈出栈。虚拟机栈由栈帧作为单位,每一个方法对应一个栈帧,栈帧由具体分为局部变量表操作数栈方法返回地址动态链接一些附加信息五个部分,其中方法返回地址动态链接一些附加信息这三个部分也可归纳为帧数据区。虚拟机栈有大小约束,可以通过命令**-Xss来修改具体大小。具体能存放多少个栈帧取决于个栈帧的大小,而栈帧的大小主要受局部变量表操作数栈影响。虚拟机栈也是每个线程独有一份没有GC垃圾回收,但有内存溢出异常(OutOfMemoryError)栈溢出异常(StackOverflowError)**。

JVM学习笔记(五-运行时数据区)

栈帧

如上图,栈帧可分为5个部分,分别是局部变量表、操作数栈、方法返回地址、动态链接和附加信息。其中局部变量表和操作数栈主要影响着栈帧的大小。

局部变量表

顾明思议,用于存放局部变量的表,包括形参和局部声明的变量。由数组实现,其中以变量槽slot来划分存储,一个索引空间对应一个slot类型大小在32位以下的占一个slot,包括引用类型否则占用2个slot局部变量表的大小在编译的时候就确定下来了,而且不会再发生变化。特别地,非静态方法,包括构造器方法,都会默认在首个索引位置Index0存放一个this变量,静态方法则没有。由于局部变量表在栈帧当中,而栈帧又是虚拟机栈的单位,而虚拟机栈又是线程私有的,所以局部变量表不存在线程安全问题。但是,并不意味着该方法的获取到的变量或里面定义的变量就是线程安全,要具体问题具体分析,比如形参是从别处通过值传递得到的,而且不止一个地方处理,那这个变量就成为共享资源,存在线程安全;又比如,在方法内部定义了一个变量,但是这个变量又作为返回值返回出去的,供其他方法处理,那这就存在线程安全了;如果说变量在方法内处理完,并销毁,那就不存在线程安全问题了。

slot:

一个索引位置空间代表一个slot。

  1. byte、short、char在存储前转化成int类型,包括boolean类型也是,0代表false,非0代表true,它们都占据一个slot变量槽。
  2. long和double类型则占据两个slot变量槽。

JVM学习笔记(五-运行时数据区)

操作数栈

操作数栈也是一个栈,虽然也是通过数组来实现,但是不允许通过索引直接获取值,符合栈的特点,只有入栈出栈操作。顾名思义,就是用于操作数值的栈。操作数栈的大小也是在编译时就确定下来了,一样不会发生变化,而且还有可能会出现复用空间。

  • 运作原理

    以在非静态方法中两个整型变量a=1和b=2求和得到sum为例子:

    起初,局部变量表的索引0位置默认存放着this的值操作数栈为空,而PC寄存器存放的是0,但局部变量表和操作数栈的大小都确定下来不会变,如下图中,stack=2代表操作数栈的深度为2,locals=4代表局部变量表大小为4,而最后一个args_size=1则代表有1个形参,这里的形参为this

    1. 由执行引擎逐条读取字节码指令,首先读取指令iconst_1把变量a的值1压入操作数栈当中,成为栈顶;

      该指令后面的数字代表的是值

    2. 然后读取下一条指令istore_1,出栈并把a变量的值1存放到局部变量表索引为1的位置当中;

      该指令后面的数字代表的是局部变量表的索引位置

    3. 接着读取指令iconst_2把b变量的值2压入栈中;

    4. 然后再读取指令istore_2出栈并把变量b的值2存放到局部变量表索引为2的位置当中;

    5. 接着读取指令iload_1获取局部变量表索引为1的变量的值;

    6. 接着读取指令iload_2获取索引为2的变量的值;

    7. 然后读取指令iadd,执行引擎会把该字节码指令翻译成机器码指令,然后让CPU执行求和运算,再把结果压入到操作数栈当中;

    8. 接着读取指令istore_3出栈并把运算结果sum存放到局部变量表索引为3的位置中;

    9. 接着执行iload_3获取索引3的值也就是sum变量;

    10. 最后执行指令ireturn返回sum变量值。

    JVM学习笔记(五-运行时数据区)

方法返回地址

该区域用于存放该方法的PC寄存器的值,也就是下一条指令的地址。

方法的结束有两种情况:

  1. 正常执行完成
  2. 出现未处理的异常,非正常退出

无论哪种方式退出,都会返回到调用者调用的那个位置。正常退出时,会根据调用者方法的方法返回地址接着执行,而非正常退出则是根据异常处理表来决定接下来的执行,如果没有处理异常,异常处理表就为空,那么就无法接着往下执行。

例子:

我们知道,虚拟机栈的单位是栈帧,一个方法对应一个栈帧,举个例子,有方法1和方法2,先执行方法1,也是把栈帧1压入虚拟机栈当中,假如运行到一半时调用了方法2,方法返回地址便存放方法1执行到的位置也就是该方法的PC寄存器的值,然后把栈帧2压入栈中成为当前栈运行,完成后出栈,栈帧1重新成为栈顶,然后根据方法返回地址接着往下执行。

动态链接

动态链接用于把符号引用转化成直接引用。在字节码文件当中一些方法或常量的引用是根据符号引用来记录的,有一块区域叫常量池(Constant pool),在这个区域里通过类似#1这样的符号记录着引用的方法或常量结构,然后在类加载中的链接步骤中的解析步骤通过动态链接把一些符号引用转化成直接引用,直接指向引用的具体结构。

  • 问题

    1. 为什么不在字节码文件中直接记录具体结构,而采用符号引用?

      因为一个类往往存在很多方法与常量的引用,如果直接记录具体引用结构,那有可能会导致字节码文件变得庞大,而采用符号引用则会节省下大量的资源。

  • 延伸
    • 方法也可以分为两类:

      1. 虚方法

        在编译期无法确定具体被调用的版本的方法,比如所接口方法,抽象方法,等这些方法都可能存在多个版本的实现,在调用时需要动态地去确定调用的版本,这就是虚方法。

      2. 非虚方法

        在编译期就能确定的被调用的版本的方法,而且运行时不会出现改变成别的版本的情况的方法,比如说静态方法、私有方法、final修饰的方法、实例构造器、父类方法(明确用super来调用)这些都是非虚方法,因为这些方法都不会有另一个版本的实现,所以被调用时就能明确下来调用的版本。其他的方法均为虚方法。

    • 4个对应的方法调用指令

      可以大致区分虚方法和非虚方法

      1. invokedstatic:调用静态方法,在解析阶段就能确定下来的唯一调用版本

      2. invokedspecial:调用<init>方法,私有方法和父类方法,解析阶段也能确定唯一的调用版本

      3. invokedvirtual:调用所有的虚方法(除了final修饰的方法,final方法为非虚方法)

      4. invokedinterface:调用接口方法

      5. invokedynamic:动态解析出要调用的方法,然后执行,比如lambda表达式调用

    • 方法的绑定机制

      • 静态绑定(早期绑定)

        说白了就是在编译期就能确定下来要调用的具体目标,而且运行过程中也不会发生变化,那么就可以通过静态链接的方式来吧符合引用转化成直接引用,这就是静态绑定。

      • 动态绑定(晚期绑定)

        在编译期无法确定下来要调用的具体目标,需要在运行过程中找,从而动态地绑定调用目标,这就是动态绑定,也就还是晚期绑定。

    • 方法重写调用的本质与虚方法表的使用

      • 方法重写调用的本质

        比如有一个对象O,通过对象O来调用一个方法1,首先它会查看这个O本身有没有声明这个方法,如果有,接着查看是不是具体的实现,如果不是就抛出调用抽象方法的异常;如果发现没有声明方法1,就会查看它有没有父类,如果没有,那这个方法1显然是不能被调用了,编译也不会通过,如果有父类,就重复上述步骤,查看是否有声明和具体实现,依次类推,从下往上去找方法1具体的实现版本,所以如果父类和子类都存在同一个方法1,那么在不显示调用父类方法的时候就会选择执行子类的方法1,这就是方法重写调用的本质。

      • 虚方法表的使用

        通过对方法重写的本质,我们知道这是一个从下往上查找的过程,如果每次都这样查找,那必然是效率低下的,所以就有那么一个表记录着这些方法所调用的版本,下一次调用就直接查这个表去调用具体版本,这个表就是虚方法表了,它是在方法区当中构建的。

        JVM学习笔记(五-运行时数据区)

        • 问题

          1. 为什么只有虚方法需要建立表,而不需要建立非虚方法表?

            因为非虚方法在编译期就能确定唯一的调用版本,在解析阶段就能直接指向引用,不需要一个个去找,而虚方法则不能在编译期确定唯一调用版本,需要动态解析。

附加信息区

该区域不一定存在,取决于虚拟机的厂商,主要用于对一些调式的支持或其它说明信息的存放。

本地方法栈(Native Method Stacks)

在了解本地方法栈前,需要先了解什么是本地方法接口,什么是本地方法库

本地方法接口

  • 什么是本地方法接口
    • 本地方法接口就是java中声明带有native修饰符,并且没有方法体的方法,同样支持异常的抛出
    • 虽然称为接口,而且在java声明中也没有方法体,看似一个抽象方法,但实际上它不是一个抽象方法,而且abstract和native修饰符也不能共存,本地方法接口实际上是有具体实现的,只不过具体的实现方法并不是由java编写,而是本地方法库中的方法,也就是c/c++编写的方法。
    • 带有本地方法接口调用的线程将会进入一个全新的世界,它将不受jvm限制,并且与jvm具有相同的权限,这就意味着该线程可以访问虚拟机中的运行时数据区,包括其他线程私有的区域,也就是PC寄存器、虚拟机栈和本地方法栈。

  • 为什么会有本地方法接口

    由于java早期的发展是向c语言靠拢的,而且,jvm是依赖于操作系统之上的,操作系统一般又是由c或c++编写,与硬件打交道,所以不可避免的,java就需要和c语言或c++语言打交道,其表现在于java在某些情况下需要调用c/c++的方法来更好地提高效率或是与硬件打交道。调用的方式就是通过native修饰符所修饰的本地方法接口。

本地方法库

  • 就是用于存放本地方法的库,这些方法都不是java编写,但是可供java本地方法接口调用,是本地方法接口的具体实现。

本地方法栈概念

  • 并不是所有厂商的jvm虚拟机都必须带有本地方法栈,对于一些不存在调用本地方法的就不需要,HotSpot虚拟机则把虚拟机栈和本地方法栈合为一体。
  • 本地方法栈顾名思义就是一个栈,只有入栈出栈的操作,但执行到本地方法接口时,就把该方法压入栈内,执行完成后就进行出栈操作。具体的实现与java虚拟机栈类似。
  • 本地方法栈不存在GC垃圾回收,但存在栈溢出(StackOverflowError)内存溢出(OutOfMemoryError)
  • 带有本地方法接口调用的线程将会进入一个全新的世界,它将不受jvm限制,并且与jvm具有相同的权限,这就意味着该线程可以访问虚拟机中的运行时数据区,包括其他线程私有的区域,也就是PC寄存器、虚拟机栈和本地方法栈。

堆(Heap)

概述

  • 一个进程对应一份运行时数据实例(Runtime),堆是运行时数据区中最大的一片区域,也是虚拟机内存管理的核心区域
  • 堆区在jvm启动的时候就建立,堆的空间大小也是确定下来的,当然,堆的空间大小可以通过命令的方式来调节。
  • 与前面所介绍到的PC寄存器、虚拟机栈、本地方法栈线程私有不同,堆和方法区(JKD8后变为元空间)是线程共享的区域,很自然地,就存在线程安全问题。但是为了提高线程的并发执行效率,在新生代的伊甸区又会划分出一个小区域称为线程私有缓存区(TLAB),是每个线程独有一份的,所以堆并不是完全线程共享。
  • 根据《java虚拟机规范》指出,堆可以处于物理上不连续的内存空间,但是在逻辑上也就是虚拟空间上应该视为是连续的。
  • 在《java虚拟机规范》中指出,所有的对象实例和数组都应该在运行时分配在堆上,如果只针对虚拟机,准确来说是“几乎”,因为随着JVM的不断发展,逃逸分析技术逐渐成熟,对象是有机会栈上分配的。对象和数组可能永远都不会存储在栈中,它们只会在栈帧中记录引用,这个引用指向该对象或数组在堆中的位置。当然,针对HotSpot虚拟机,采用的优化策略是标量替换,对象的分配还都是在堆上的。如果是其他的虚拟机就不好说了,比如说基于OpenJDK深度定制的Taobao VM 就创造了GCIH(GC Invisible Heap)技术,对于生命周期比较长的对象会直接移至堆外,而且GC不会管理GCIH区的对象,这就意味着可以减少GC的次数,进而提升性能
  • 在方法结束后,堆并不会立马把对象移除,而是在垃圾回收的时候检验到对象是垃圾时才会移除,而垃圾回收是在堆内存不够的时候才进行的,而且垃圾回收的次数并不是越多越好,因为垃圾回收会引发STW(Stop The World),这就意味着它会先让用户线程停止执行,等垃圾回收完了以后才接着执行,这就意味着垃圾回收的次数越多,那么就越阻碍用户线程的执行,那用户线程执行的效率自然就会下降,这是要优化避免的,也是后续性能优化的考虑点
  • 堆区存在GC(Garbage Collection 垃圾收集器)垃圾回收OOM内存溢出(OutOfMemoryError)。而且堆是垃圾回收的重点区域

常用参数命令

  • -Xms(初始内存大小)

    在后面指定数值就能设置大小,有两种指定方式,一种是字节的方式,也就是纯字节数值,一种是带单位的指定如k、m等

    X:代表的是运行时指令的意思

    m:代表的是memory内存的意思

    s:代表的是start初始的意思

  • -Xmx(最大内存大小)

    在后面指定数值就能设置大小,有两种指定方式,一种是字节的方式,也就是纯字节数值,一种是带单位的指定如k或K

    X:代表的是运行时指令的意思

    mx:代表max最大的意思

  • -XX:PrintFlagsInitial

    查看所有的参数对应的默认值

  • -XX:PrintFlagsFinal

    查看所有参数的最终值,也就是如果修改了,就显示修改后的值。还可以通过cmd命令行来实现查看指定参数:先使用命令 jps找到要查看的进程id,然后再通过命令 jinfo flag 参数名 进程id 来查看对应值。

  • -XX:NewRatio

    设置新生代和老年代空间大小的比例默认值为2。例如 -XX:NewRatio=2,就是设置比例为1:2

  • -XX:SurvivorRatio

    设置伊甸区与幸存区空间大小的比例,默认是8,实际测试是6。例如 -XX:SurvivorRatio=8,就是设置比例为8:1:1

  • -XX:MaxTenuringThreshold
    设置幸存区的晋升阈值,默认为15

  • -XX:+PrintGCDetails

    开启详细GC处理日志,默认关闭,其中包括堆空间情况的打印,把+替换成-是关闭

  • -XX:+PrintGC

    开启简约的GC处理日志,默认关闭,更加简洁,而且不会打印堆空间的情况,把+替换成-是关闭

  • -XX:HandlePromotionFailure

    是否开启空间分配担保,值为true或false,jdk7开始,这个参数已经失效

注意

  • 在实际开发当中建议初始内存大小与最大内存大小保持一致,这样可以减少堆的动态扩展和垃圾回收的次数,那么就能一定程度上提高执行效率。
  • 在不设置堆大小时,初始大小和最大大小都有默认值,初始大小的默认值是本地物理内存的64分之一,最大大小默认为4分之一。

堆的划分

从逻辑上堆在jdk7或之前可以分为新生代老年代永久代jdk8后永久代被元空间取代,但一般讨论中是不考虑永久代或元空间的,堆空间的分配也是如此,只会考虑新生代和老年代。所以堆空间的总大小是新生代老年代空间总和

JVM学习笔记(五-运行时数据区)

  • 新生代

    • 新生代与老年代的空间大小比例默认为1:2,可以通过命令来设置,例子:-NewRatio = 4,设置比例为1:4。一般开发中都会保持默认值,除非提前知道有比较多生命周期都比较长的对象,才会考虑调大老年代的空间比例。

    • 还可以通过命令-Xmn来设置新生代的最小空间值,一般都使用默认值,但如果与-NewRatio命令一同使用,最终会按照-Xmn所设置的值来分配空间。

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

    新生代又可划分为:

    • 伊甸区(Eden)

      • 几乎所有的对象都是在伊甸区new出来,有的因为对象比较大,伊甸区可能装不下,那么就会直接存放在老年代中。
      • YGC(Young GC或Minor GC)垃圾回收操作只会由伊甸区触发。当伊甸区后还有对象想进来时,就会主动触发YGC进行垃圾回收,垃圾回收时,会检验对象的引用情况,发现一些已经没有被引用到的对象则会直接销毁它们,而一些还继续被引用的对象则会被存放到空的幸存区中,并记录年龄值,可理解为转移次数,首次会存放到S0区,最终伊甸区会空出来。如果发现转移的对象比较大,幸存区放不下,则可能会直接晋升到老年代当中,年龄值也失去意义,具体的对象分配策略下面有图解说明
    • 幸存0区(Survivor0)

    • 幸存1区(Survivor1)

      • 两个幸存区可简称为S0、S1,它们的空间大小是一样
      • S0与S1在YCG垃圾回收后总是会空出一个可称为to,用于存放下一次垃圾回收后的伊甸区的幸存者,因此在获取堆存储内容大小时,其中一个区是空的,采用的是复制算法
      • 存放到幸存区的对象都会有一个年龄值,记录着转移的次数,当这个值超过某一个指定值时,就会晋升到老年代,这个值称为阈值默认是15,可以修改,通过命令 -XX:MAxTenuringThreshold=具体数值。但是并不只有超过阈值时才会晋升到老年代,根据动态对象年龄判断,当发现同年龄的对象所占的空间超过幸存区的一半时,就会把大于和等于这个年龄的对象都直接晋升到老年区。
      • 幸存区并不会主动触发YGC进行垃圾回收,YGC只能是伊甸区来触发,这不意味幸存区不存在垃圾回收,只不过是被动进行垃圾回收,每次进行垃圾回收时和伊甸区类似,销毁掉已经没有被引用到的对象,被引用到的则是转移到另一个幸存区,并更新年龄值,把空间空出,往后都是这样往往复复地进行直到结束,具体的对象分配策略下面图解说明

      动态对象年龄判断:

      • Survivor区同年龄的对象所占的空间大小超过Survivor区总空间的一半,那么大于和等于这个年龄的对象都会直接晋升到老年代,无需超过阈值。
      • 只要的原因是,一般S0和S1区之间会多次进行复制操作,但是既然这些对象都大于一半了,多次来回复制这部分的对象也会对性能造成一定的影响,所以就直接晋升到老年代了。

      注意

      • 根据HotSpot官方规范,伊甸区与S0、S1区空间大小的比例默认是8:1:1,但在实际测试时发现比例是6:1:1,查阅相关资料发现与自适应机制有关,尝试使用命令-XX:-UseAdaptiveSizePolicy来关闭,但是发现无效,比例依然是6:1:1,必须要显式执行命令XX:SurvivorRatio = 8,才会是8:1:1。
      • 它们之间的比例是可以通过命令来调节的,比如命令 -XX:-SurvivorRatio = 8,则是设置比例为8:1:1。
  • 老年代

    • 新生代与老年代的空间大小比例默认为1:2,可以通过命令来设置,例子:-NewRatio = 4,设置比例为1:4。一般开发中都会保持默认值,除非提前知道有比较多生命周期都比较长的对象,才会考虑调大老年代的空间比例。

    • 老年代存放的对象都是生命周期比较长或者是大小比较大的

    • 老年代中很少会销毁对象,因为一般老年代的空间比较充足。

    • 针对老年代的垃圾回收是Major GC(Old GC),但是很多时候Major GC又和Full GC混淆使用,Full GC虽然时由老年代触发,但是Full GC不仅仅针对老年代进行垃圾回收,它是进行整堆的垃圾回收,甚至包括方法区或元空间,目前只有CMS GC会有单独收集老年代的行为。

  • 对象分配策略图解

    JVM学习笔记(五-运行时数据区)

  • 空间分配担保

    在jdk7前有一个参数命令HandlePromotionFailure,用于设置是否允许空间分配担保失败,设置的值为true或false,jdk7开始这个参数就没有效了

    • 那么什么是空间分配担保呢?

      每次进行Minor GC前,虚拟机都会去检查老年代剩下的空间是否大于新生代存放的对象的总大小,如果大于,那Minor GC是安全进行的,如果小于,那Minor GC就存在风险了,因为老年代有可能空间不足,就要看是否允许允许空间分配担保失败。

      • 允许,也就是HandlePromotionFailure=true

        虚拟机会接着对比老年代剩余的最大可用连续空间与历次晋升到老年代的对象的平均大小

        • 如果发现大于,那就尝试进行Minor GC,但是这次Minor GC依然是存在风险的。

        • 如果发现小于,那就直接触发Full GC进行整堆垃圾回收。

      • 不允许,也就是HandlePromotionFailure=false

        直接触发Full GC来进行整堆垃圾回收。

    上面也提到,在jdk7开始,这个参数命令就失效了,规则变为只要老年代剩余的最大可用连续空间大于新生代对象的总大小或是大于历次晋升老年代的对象的平均大小,就会直接进行Minor GC,否则进行Full GC

  • 问题

    1. 为什么要对堆进行划分呢,不划分就不能运作了吗?

      不划分堆的话肯定是能运作,只是没那么理想。给堆进行划分的唯一理由就是优化GC的性能。我们知道,当堆空间不足的时候,会进行GC垃圾回收,垃圾回收的时候会去判断哪些对象是垃圾,如果不划分区域的话,那么就意味着每次都要逐个去判断,那效率是十分低下的,因为一些生命周期比较长的对象,是没必要每次都去判断它的,因为很多时候它们都暂时不需要销毁,每次都判断它们就显得浪费时间,所以才针对生命周期来划分出新生代和老年代,对于生命周期较短的就放在新生代,只需要对新生代进行频繁垃圾回收即可,而老年代就不需要频繁的去进行垃圾回收了。

GC垃圾收集

JVM进行GC收集时,并非每次都会针对三个分区(新生代,老年代;方法区或元空间)进行收集,大部分都是针对新生代的收集。

针对HotSpot,按照GC收集区域可以分为两大类型:部分收集(Partial GC)整堆收集(Full GC)

部分收集:

Minor GC(Young GC)
  • 只是新生代的收集,而且由伊甸区主动触发收集,幸存区被动收集,当伊甸区满时就会触发。
  • 收集速度较快,但触发次数越少越好。
Major GC(Old GC)
  • 只是老年代的收集,当老年代满时就会主动触发
  • 收集的速度一般比Minor GC慢10倍以上,也就是相应的STW时间更长,所以减少Major GC触发的次数也是调优的关键
  • 一般触发Major GC前,都至少会进行一次Minor GC,不是必然会触发,只是很多时候都会先对新生代进行垃圾回收,也就是当老年代空间不足时,会先尝试对新生代进行垃圾回收,看看空间是否还不足,不足再出发Major GC
  • 目前,只有CMS GC收集器会有单独针对老年代收集的行为
  • Major GC和Full GC往往会被混淆使用,但是Major GC并不像Full GC那样进行整堆回收,所以具体需要分析是部分收集行为,还是整堆收集行为。
  • Major GC后,内存还不足,就会报OOM内存溢出异常
Mixed GC
  • 混合收集,针对整个新生代部分老年代的收集
  • 目前,只有G1 GC收集器会有这种行为

整堆收集:

Full GC
  • 一般会有如下几种情况触发:

    1. 调用System.gc()方法,系统会建议执行Full GC,但不是必然触发
    2. 老年代空间不足
    3. 方法区或元空间空间不足
    4. 在对象分配策略进行中涉及到老年代时(参照上面的对象分配策略图)
  • 触发后会进行整堆的收集,这里的整堆包括新生代老年代方法区元空间

  • 收集的速度也是比较慢,一般比Minor GC慢10倍以上,意味着STW时间更长,同样是调优的关键

线程私有缓存区TLAB

  • 概述

    TLAB全称Thread Local Allocation Buffer,它是从新生代中的伊甸区划分出来的,是每个线程独有一份,所以伊甸区并不是完全的线程共享,TLAB空间大小默认占伊甸区总大小的1%,超过规定的大小就会考虑共享区,可以通过命令来设置。当有新对象申请时,如果TLAB放得下,都会优先在TLAB处分配内存。

    JVM学习笔记(五-运行时数据区)

  • 为什么需要再划分出TLAB呢?

    在不考虑TLAB的情况下,伊甸区完全线程共享,那就意味着所有访问都要采用加锁机制来保证线程安全,但是一旦加锁,那么线程并发的效率就会受到影响,因此就划分出一片很小的区域,给每个线程私有,当有新对象申请分配内存时优先考虑TLAB,如果放得下,就无需与其他线程加锁访问了,这样就可以尽可能地提高内存分配的吞吐量,进一步提高效率。

逃逸分析

  • 什么是逃逸分析

    逃逸分析就是在编译时判断一些对象的创建和使用是不是只在一个方法当中完成,如果是,那么就可以说这个对象没有发生逃逸,如果不是,也就是这个对象在其他方法中被使用到了,就可以说这个对象发生了逃逸。逃逸分析技术还不完全成熟,但已经可以根据它来进行相关的优化了,实际测试时也发现确实执行速度变快了。jdk7开始,HotSpot是默认开启逃逸分析的,jdk7之前则需要通过命令来开启。

  • 基于逃逸分析的代码优化策略

    逃逸分析有什么用呢?逃逸分析是为了更好的优化性能。我们知道在HotSpot中,对象是在堆中分配存储的,堆又是内存管理的核心,必要时,需要进行GC垃圾回收,但是垃圾回收又会影响用户线程的执行效率,所以我们要尽量减少GC的次数,尤其是Major GC和Full GC。那么如果一些对象只会在各自的方法中创建并使用,不需要与其他方法共享,也就不需要与其他线程共享,那是不是就可以考虑存储在栈当中呢,既不用考虑线程同步问题,也会一定程度上减少GC的次数,因为栈是不存在GC的,一个方法对应一个栈帧,方法执行完,就会出栈,那对应的对象就会被销毁。

    • 栈上分配

      主要是考虑到栈只有入栈和出栈操作,通过逃逸分析来判断对象是否发生逃逸,如果发现没有,那么使用栈空间来分配存储是比较理想的,因为不需要考虑同步的问题,也不需要考虑GC垃圾回收,这样就能减轻堆分配的负担,执行效率也会大大提升。

    • 同步省略

      我们知道,当多个线程操作相同的对象时,是需要加锁来保证线程安全的,而这个锁可以是任意的对象,而且要求这把锁对不同的线程来说是同一把锁,但有些时候,通过逃逸分析发现,作为锁的对象并没有被其他线程访问到,始终只被同一个线程访问,那么虚拟机就会把这个同步给省略掉,也就是消除这把锁,来尽可能地提高执行效率,甚至有些时候发现所谓的同步代码块中操作的对象只会被同一个线程访问操作,那么同样也会消除掉同步锁。所以编写代码时可以重点关注一下,虽然虚拟机会消除,但如果显式地加锁还是会对性能有一定的影响。

    • 标量替换

      说标量前需要先了解聚合量。聚合量就是可以在被拆分的数据。那不能再拆分的就是标量。java中的对象就是聚合量,因为对象可以拆分出一个个属性,甚至有的属性还可以接着拆分,逐步拆分下去就会得到一个个所谓的标量。在JIT编译阶段,经过逃逸分析后如果发现存在没有逃逸的对象,那么就会把这些对象拆分成若干个标量存储在对应栈帧中的局部变量表中,这样就能减轻堆分配的压力,尽可能地减少GC次数,提高执行效率,自然而然地就能提升性能,HotSpot虚拟机采用的就是这种方式。

  • 结论

    1. 在实际开发时,能使用局部变量实现的,就不要使用方法外创建对象的方式来实现,尽可能地减轻堆分配的负担。
    2. HotSpot虚拟机采用的优化策略是标量替换,所以针对HotSpot来说,堆的确是对象分配的唯一选择,因为栈帧存放的不是对象,而是标量。

方法区(Method Area)

概述

  • 根据官方规范说明,java虚拟机提供了一个区域叫方法区,它是线程共享的区域,所以存在线程安全问题类的加载是加锁的。

  • 方法区是用于存储类的结构信息,比如说运行时常量池属性字段方法数据,以及方法和构造函数的代码,比如**<init><clinit>**。

  • 方法区是一个抽象的规范,具体可以有不同的实现,虽然在逻辑上属于堆划分的一部分,但是对于方法区的一些简单实现是可以选择不进行GC垃圾回收或压缩的,而堆则是需要的,所以实现时会把方法区单独划分出来,针对于HotSpot,方法区还有一个别名:Non-Heap(非堆)

  • 方法区在JVM启动时就创建,空间大小可以是固定的,也可以是动态扩展的。针对HotSpot虚拟机,在jdk7及以前,方法区的实现是永久代,它的内存空间使用的是虚拟机的内存,而到了jdk8,永久代被元空间所取代,使用的是本地内存

  • 方法区的空间大小决定能够加载多少个类,如果要加载的类过多,那么就可能会报OOM异常jdk7及以前是OutOfMemoryError:PermGen space ,而到了jdk8就变成 OutOfMemoryError:Metaspace

  • 与堆一样,方法区在物理内存可以是不连续的。

  • 针对HotSpot虚拟机,方法区存在GC垃圾回收压缩OOM内存溢出异常

  • JVM关闭后就会释放相应的内存

虚拟机栈、堆、方法区的大致联系

JVM学习笔记(五-运行时数据区)

HotSpot方法区的演进

  • 针对HotSpot虚拟机,jdk7及以前,我们习惯地称方法区为永久代,使用的是虚拟机的内存,但到了jdk8,永久代就已经被元空间完全取代,使用的是本地内存。
  • 需要知道的是,方法区并不等同于永久代,因为永久代这个概念只是针对HotSpot虚拟机,也就是其他虚拟机可能都没有永久代这个说法,就比如JRockit虚拟机和J9虚拟机就没有永久代的概念。
  • JRockit可以说是公认的运行速度最快的服务端虚拟机,被oracle所收购,它的方法区的内存用的就是本地内存。也许oracle公司有把HotSpot和JRockit合并的想法,于是让HotSpot的方法区用元空间实现,使用的也是本地内存。
  • 降低OOM异常出现的概率也是演进的目的之一,因为永久代使用的是虚拟机内存,以windows为例,默认的初始空间大小为20.75M,32位系统的最大空间大小为64M,64为系统的则是82M,即使是支持动态扩展,也是十分有限的。而到了元空间,使用的是本地内存,默认初始大小为21M,最大大小就是本地内存,这可以很明显地降低OOM异常出现的概率。

空间大小设置

永久代
  • -XX:PermSize

    设置初始空间大小,针对Windows,默认是20.75M

  • -XX:MaxPermSize

    设置最大空间大小,针对Windows32位的默认最大空间为64M64位的默认最大空间为82M

元空间
  • -XX:MetaspaceSize

    设置初始空间大小,针对Windows,默认是21M

  • -XX:MaxMetaspaceSize

    设置最大空间大小,针对Windows,默认是**-1**,意思是没有限制

小结
  • 元空间与永久代不同,默认情况下,虚拟机可耗尽系统的可用内存,内存不足时,一样会报OutOfMemoryError:Metaspace
  • 在不设置初始值的情况下,默认是21M,那么就可以把21M看成是水平线,一旦触碰到这个水平线,就会触发Full GC,除去没用的类,同时还会重置一个新的水平线,如果GC完还发现内存不足,就会在不超过最大空间限制的条件下适当地提高水平线,如果发现清完后空间十分充足,就会自动地进行适当压缩水平线。
  • 由于Full GC极大地影响性能,所以为了优化,减少Full GC的次数,初始空间大小尽可能设置得大一点

方法区的内部结构

以比较经典的方法区实现为例,方法区主要存放的是类型信息(比如类,接口,枚举类,注解),运行时常量池,静态变量,域信息(属性信息),JIT代码缓存,方法信息。这只是经典方法区的例子,随着后续演进会有些许的变化。

这些信息都是通过类加载器子系统把class字节码文件上的信息内容加载到方法区当中,编译后的字节码文件上已经记录了很多的信息

字节码文件信息
  • 类型信息

    1. 类的全名,包括包名路径的全称
    2. 类的修饰符,如public,private,protect,abstract,final等
    3. 类的直接父类类名,包括包名路径的全称
    4. 类的实现类型有序列表,实现的接口会有序的记录着
  • 域(属性)信息

    1. 域声明的顺序
    2. 域名称
    3. 域的类型
    4. 域的修饰符

    比较特别的全局常量在编译的时候已经记录了相应的值,也就是在字节码文件已经有记录。

  • 方法信息

    1. 方法的名称
    2. 方法的修饰符
    3. 方法的返回类型
    4. 方法参数信息的列表,包括个数,类型与名称,按顺序记录
    5. 方法对应的操作数栈的深度,局部变量表的大小
    6. 异常处理表,其中记录着每个异常处理的起始位置以及终止位置,代码处理指令在程序计数器的指令地址,以及捕获的异常类在常量池中的引用。
  • 常量池

    以#数字为索引符号的形式记录着一些字面量引用,比如要用到的类名,类型,常量的字面量,字节码文件中的字节码指令以符号索引的方式指明要引用的常量,类加载时,会根据符号的引用信息找到相应结构或常量的真实地址,并指向该地址对应的就是方法区当中的运行时常量池。

运行时常量池
  • 运行时常量池就是根据字节码文件中的常量池进行类加载后在方法区当中的运行时的表现形式,也是类似符号表的形式,但是其中所蕴含的数据要比符号表丰富。
  • 运行时常量池比较重要的特征是具备动态性,意味着它随着运行可以动态的变化,表现在常量的动态指向。

方法区的演进细节

针对HotSpot虚拟机

jdk6及以前

有永久代,静态变量存放在永久代当中

JVM学习笔记(五-运行时数据区)

jdk7

有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移到了堆当中存储

JVM学习笔记(五-运行时数据区)

jdk8

元空间取代永久代,类型信息,域信息,方法信息,常量存放在元空间中,但字符串常量池、静态变量仍存放到堆当中

JVM学习笔记(五-运行时数据区)

  • 问题

    1. StringTable字符串常量池的位置为什么要调整到堆中呢?

      在jdk6及以前,字符串常量池存放在永久代中,到了jdk7,就调整到堆当中。因为考虑到永久代的回收率很低,只有在Full GC时才会触发,一方面Full GC只有在老年代和永久代空间满时才会去触发,次数少,一方面Full GC的又会极大影响性能,在实际开发中,我们往往需要创建大量的字符串常量,由于回收率低,就很容易导致空间不足,频繁的空间不足就会触发Full GC影响性能,甚至是报OOM异常,因此把字符串常量池存放到堆当中,堆的回收率相对高一些,可以及时回收内存,避免频繁造成空间不足。

    2. 静态变量本身存放在哪?

      举例子:public static Object object1 = new Object();

      public Object object2 = new Object();

      两句代码所new出来的都是实例对象,两个实例对象毫无疑问都存放在堆当中,但对于变量本身,也就是变量名object1,object2的存放有所讲究,对于非静态变量object2,无论是jdk6还是7还是8都是随着实例对象存放在堆当中的,毫无疑问。而对于静态变量object1,在jdk6及以前,是存放在永久代当中的,但到了jdk7开始,就存放到堆当中了。

方法区的垃圾回收

  • 根据java虚拟机官方规范,方法区的一些简单的实现是可以选择不进行垃圾回收和压缩的,而针对HotSpot虚拟机,方法区是实现了垃圾回收和压缩的。

  • 方法区的垃圾回收回收的是什么呢,回收的是常量池中已经不被引用的常量和无需引用到的类型,也就是类,接口等。常量比较好实现,但对于类型的回收,也就是类的卸载往往是不理想的,也就是吃力不讨好,必要,但是往往实现的效果不尽人意。主要原因是把一个类型定义为“垃圾”的要求是十分苛刻的

  • 类型被定义为垃圾的条件

    1. 引用该类型的实例对象已经被全部回收销毁,也就是该类在堆中的实例对象以及派生类实例对象已经不存在。
    2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi,JSP的重加载等,否则通常是很难达成的。
    3. 该类的java.lang.Class对象在其他地方没有被引用,也就是无法在其它地方使用反射的方式访问该类的方法。
    • 在同时满足以上三个条件的时候,才仅仅是允许卸载对应的类,也就是不像对象那样没有引用就必然卸载,而仅仅是允许,所以是比较难去达到很好的效果的。
    • 在一些大量运用反射,动态代理,OGLib等字节码框架等等频繁运用到自定义类加载器的场景。通常都要求虚拟机具备卸载类的能力,确保不会对方法区的内存给予极大的负担。

对象的创建,内存布局与访问

经过对运行时数据区的学习,可以大致的了解各个区域大致的内容和作用,这里主要总结和演示对象在创建的步骤,以及对象的内存布局

对象创建的步骤

  1. 判断对象对应的类型是否已经被加载,如果没用则通过相应的类加载器加载,当然包括父类以及接口等

  2. 给对象分配内存

    • 如果内存规整

      采用指针碰撞的方式分配内存。即堆中的存储规整,内存分配连续,那么就在对应已用内存尾部的指针后分配连续的内存给对象,然后指针在往后移,指向新的末尾位置。

    • 如果内存不规整

      采用空闲列表分配。也就是堆的存储比较零散,连续的内存空间比较零散,那么虚拟机就要维护一个空闲列表,记录零散的连续空间,拿对象的大小去找适合的连续空间。

  3. 处理同步问题

    • 采用CAS失败重试,区域加锁保证更新的原子性
    • 划分TLAB区域,线程私有
  4. 给对象进行默认初始化操作,赋上默认初始值,对应的就是类加载中的准备阶段。

  5. 设置对象头信息

  6. 显式初始化对象,对应的就是类加载中的初始化阶段

对象的内存布局

对象的内存布局可划分为对象头、实例数据、对齐填充

对象头
  • 运行时元数据

    其中记录着哈希值,方便栈帧中局部变量表的引用指向;GC年龄值,用于晋升操作;锁状态标志,用于标志是否为锁;还有线程持有的锁、偏向线程ID、偏向时间戳等

  • 类型指针

    用于指向方法区中对应的类型信息

实例数据

记录该对象真正存储的信息,包括程序代码中定义的各个类型的字段,包括从父类继承拥有的字段。

对齐填充

不是必须的,起到占位符的作用

图解

JVM学习笔记(五-运行时数据区)

对象的访问

句柄访问

需要在堆中划分出一块句柄池区域,用于维护对象的引用

  • 特点

    1. 需要在堆中分配句柄池区域

    2. 相对直接指针的访问效率差一些,因为加多了一层,局部变量表需要先在句柄池中找到相应的指针,在通过该指针去找实例对象

    3. 局部变量表的引用指向相对稳定,对于运行时的对象位置的动态变化,比如GC后内存的压缩规整导致对象位置变化,不需要频繁地去更新局部变量表,只需更新句柄池的对应指针

  • 图解

    JVM学习笔记(五-运行时数据区)

直接指针

局部变量表的引用直接指向堆中的实例对象,这是HotSpot虚拟机采用的方式

  • 特点

    1. 效率相对句柄访问要好,少了句柄池中间层
    2. 对于运行时堆中实例对象位置的动态变化,局部变量表需要进行对应的更新
  • 图解

    JVM学习笔记(五-运行时数据区)