JVM 性能分析——JVM内存结构(运行时数据区)

概述

每个 JVM 只对应一个 Runtime 实例,即运行时环境。

类的加载 –> 验证 –> 准备 –> 解析 –> 初始化,这几个阶段完成后,就可以被 Java 虚拟机所使用。Java 虚拟机会使用到它的执行引擎 (Execution Engine) 来执行类中的字节码指令。执行引擎会利用 JVM 运行时数据区 (Runtime Data Area) 中的各种区域来支持字节码的执行,如程序计数器、虚拟机栈、本地方法栈、堆、方法区等。下面着重介绍运行时数据区,即JVM内存结构。

内存的作用:

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

  • 通过磁盘或者网络 IO 得到的数据,都需要先加载到内存中,然后 CPU 从内存中获取数据进行读取,也就是说内存充当了 CPU 和磁盘之间的桥梁。

JVM 内存管理概述

  • JVM 内存布局定义了各个内存区域的用途和申请方式。比如程序计数器、虚拟机栈等线程私有区域,是随着线程的创建而创建;而堆内存、方法区等线程共享区域,则是由 JVM 统一管理和分配的。
  • JVM 内存布局规定了堆内存的分代机制,将堆分为新生代、老年代等。不同区域使用不同的垃圾收集算法,提高内存回收的效率;方法区的内存分配和管理策略也由 JVM 内存布局决定。:
  • JVM 内存布局定义了各个内存区域的生命周期和回收策略。比如程序计数器随线程创建或销毁,虚拟机栈随方法调用入栈出栈;堆内存和方法区的内存回收由 JVM 的垃圾收集器负责。

Java 虚拟机内存结构

Java 虚拟机定义了程序运行期间会使用到的运行时数据区:其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁(线程共享)。另外一些与线程的生命周期一致,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁(线程私有)。

java 虚拟机将管理的内存分为五大区域:虚拟机栈或栈区(VM Stacks) 、堆(Heap)、方法区(Method Area)、程序计数器(Program Counter Register)和本地方法栈(Native Method Stack)五个部分。

  • 虚拟机栈、程序计数器和本地方法栈是线程私有的;
  • 堆和方法区是线程共享的。
    在这里插入图片描述
    下图来自阿里巴巴手册 JDK8:
    在这里插入图片描述

运行时数据区

PC 程序计数器

寄存器用于存储指令相关的现场信息,CPU 只有把数据装载到寄存器才能够运行。JVM 中的程序计数寄存器(Program Counter Register),用于记录线程当前执行的字节码指令地址。这里并非是广义上所指的物理寄存器,并不是用于存储对象或数据的内存区域,而是一个用于线程执行指令的辅助数据结构。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。

PC 寄存器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。既没有 GC 垃圾回收,也没有 OOM 内存溢出。

Java 虚拟机可以支持同时执行多个线程。每个 Java 虚拟机线程都有自己私有的PC(程序计数器)寄存器,线程切换时需要保存和恢复相关信息。程序计数器可以看作是字节码的行号指示器,它指示了当前线程执行到字节码的哪一行。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于 Java 虛拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。

使用PC寄存器存储字节码指令地址有什么用呢?或者问为什么使用 PC 寄存器来记录当前线程的执行地址呢?

  1. 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行;

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

PC寄存器为什么被设定为私有的?

我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

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

虚拟机栈(栈区)

1. 简要概述

虚拟机栈出现的意义:

  1. 跨平台性:由于跨平台性的设计,Java 虚拟机使用基于栈的指令集架构,指令操作的对象是操作数栈和局部变量表。不同平台CPU架构不同,所以不能设计为基于寄存器的,栈架构的指令集不依赖于底层硬件平台的寄存器结构和指令集。【如果设计成基于寄存器的,耦合度高,可以对具体的CPU架构进行优化,性能会有所提升,但是跨平台性大大降低】。
  2. 相对于基于寄存器的指令集,基于栈架构的指令集较为简单,减少了编译器的复杂性和开发工作量。这使得编写 Java 虚拟机和 Java 编译器变得相对容易。

虚拟机栈的缺点:

  1. 指令数量增加:基于栈架构的指令集相对于基于寄存器的指令集,可能需要更多的指令来完成相同的任务。这是因为栈架构需要将操作数从内存加载到栈顶,执行操作后再存回内存,而基于寄存器的指令集可以直接在寄存器中进行操作,减少了内存访问次数。
  2. 性能下降:由于基于栈架构的指令集需要更多的指令来完成相同的任务,并且涉及到内存的频繁访问,可能导致性能下降。相比之下,基于寄存器的指令集可以直接在寄存器中进行操作,减少了内存访问的开销,可能具有更高的执行效率。

栈区是私有的,每个线程包含一个栈区,其他栈不能访问。栈中保存了基本数据类型和对象的引用(不是对象),对象都存放在堆区中。(栈是运行时的单位,而堆是存储的单位)

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型和对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

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

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

设置栈空间大小

  • 可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
    • 如:-Xss 1m 或 -Xss 256k。

虚拟机栈的特点:

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

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

    • 每个方法执行,伴随着进栈(入栈、压栈);
    • 执行结束后的出栈工作;
  • 对于栈来说不存在垃圾回收问题:

    • 栈不需要 GC,但是可能出现 OOM。

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

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

2. 栈帧的内部结构

每个栈帧中存储着:

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

3. 局部变量表

基本概念

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

关于Slot的理解

  1. 参数值的存放总是从局部变量数组索引 0 的位置开始,到数组长度-1的索引结束。
  2. 局部变量表,最基本的存储单元是 Slot(变量槽),局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量。
  3. 在局部变量表里,32 位以内的类型只占用一个 slot(包括 returnAddress 类型),64 位的类型占用两个 slot(long 和 double)。
    • byte、short、char 在储存前被转换为 int,boolean 也被转换为 int,0 表示 false,非 0 表示 true。
    • long 和 double 则占据两个 slot。
  4. JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
  5. 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上。
  6. 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量)
  7. 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。(this也相当于一个变量)。

Slot的重复利用:

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

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

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

4. 操作数栈

操作数栈的作用

  1. 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  2. 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的。
  3. 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性中,为 maxstack 的值。
  4. 栈中的任何一个元素都是可以任意的 Java 数据类型。
    • 32bit 的类型占用一个栈单位深度;
    • 64bit 的类型占用两个栈单位深度;
  5. 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(Push)和出栈(Pop)操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已。
  6. 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
  7. 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  8. 另外,我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

5. 动态链接

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

  1. 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic 指令。
  2. 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 class 文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
    在这里插入图片描述

为什么要用常量池呢?

  • 因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,然后记录其引用即可,节省了空间。

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

6. 方法的调用

静态链接与动态链接

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

  • 静态链接:

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

  • 动态链接:

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

早期绑定与晚期绑定

静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。

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

  • 早期绑定

    早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定

    如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

多态与绑定

随着高级语言的横空出世,类似于 Java 一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。

Java 中任何一个普通的方法其实都具备虚函数的特征,它们相当于 C++ 语言中的虚函数(C++中则需要使用关键字 virtual 来显式定义)。如果在 Java 程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

虚方法与非虚方法

虚方法与非虚方法的区别:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。

  • 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。

  • 其他方法称为虚方法。

虚拟机中调用方法的指令

  1. 普通指令:

    invokestatic:调用静态方法,解析阶段确定唯一方法版本。

    invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本。

    invokevirtual:调用所有虚方法。

    invokeinterface:调用接口方法。

  2. 动态调用指令

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

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

关于 invokedynamic 指令

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

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

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

动态语言和静态语言

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

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

  • 如:

    Java: String info = “mogu blog”; (Java 是静态类型语言的,会先编译就进行类型检查)。

    JS: var name = “shkstart”; var name = 10; (运行时才进行检查)。

Java 语言中方法重写的本质

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

IllegalAccessError 介绍

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

    比如,你把应该有的 jar 包放从工程中拿走了,或者 Maven 中存在 jar 包冲突。

虚方法表

  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table)来实现,非虚方法不会出现在表中。使用索引表来代替查找。【上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时】

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

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

7. 方法返回地址

  1. 存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:

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

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

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

方法退出的两种方式

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

  1. 正常退出:

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

    2. 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。

  2. 异常退出:

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

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

8. 一些附加信息

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

方法区

方法区(method area)又叫静态区,是 JVM 规范中定义的一个概念,所有 Java 虚拟机线程之间共享方法区域。每个 JVM 只有一个方法区。方法区中包含的都是在整个程序中永远唯一的元素,如所有的 class 信息和 static 变量。(还有些解释方法区是堆区的一部分特殊区域,为区分堆区又叫非堆)。

方法区域类似于C等传统语言编译后代码的存储区域,或类似于操作系统进程中的“文本”段。它用于存储已被虚拟机加载的类型信息、运行时常量池、静态变量、常量、Class 对象引用、方法信息和即时编译器(JIT Compiler)编译后的代码数据等。

方法区的基本理解

方法区主要存放的是 Class,而堆中主要存放的是实例化的对象。

  1. 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。
  2. 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
  3. 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  4. 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace。
    • 加载大量的第三方的 jar 包。
    • Tomcat 部署的工程过多(30~50个)。
    • 大量动态的生成反射类。
  5. 关闭 JVM 就会释放这个区域的内存。
  6. 方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

设置方法区内存大小

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

jdk7 及以前:

  • -XX:PermSize 来设置永久代初始分配空间。默认值是 20.75M。
  • -XX:MaxPermSize 来设定永久代最大可分配空间。32 位机器默认是 64M,64 位机器模式是 82M。
  • 当 JVM 加载的类信息容量超过了这个值,会报异常 OutOfMemoryError: PermGen space。

jdk8 及以后:

  • 元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定,替代上述原有的两个参数。
  • 默认值依赖于平台。windows下,-XX:MetaspaceSize 是 21M,-XX:MaxMetaspaceSize 的值是 -1,即没有限制。|
    与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常 OutofMemoryError: Metaspace。

方法区的内部结构

类信息中除了有类型信息、常量、字段(域)信息、方法信息、即时编译器编译后的代码缓存等描述信息外,还有一项信息是类型的常量池(class 常量池),用于存放编译器生成的各种字面值和符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。

在这里插入图片描述

  • 类型信息包括以下几点:
    ​ 类的修饰符(public、abstract等)。
    ​ 是类还是接口(class、interface);
    ​ 类的全限定类名(比如,java.long.String);
    ​ 类的直接父类的全限定类名;
    ​ 类的直接实现接口的有序列表(因为一个类直接实现的接口可能不止一个,因此放到一个有序表中);

    可以看做是,对一个类进行登记,这个类的名字,他父类的名字、有没有实现接口,权限是什么。

  • 类型的常量池

    每一个 class 文件中,都维护着一个常量池(class 常量池),里面存放着编译时期生成的各种常量、字面值和符号引用;这个 class 常量池的内容,在类加载的时候,被复制到方法区的运行时常量池。

  • 字段信息(域信息)

    jvm 必须在方法区中保存类类型的所有域的相关信息以及域的声明顺序。
    ​ 域声明的顺序;
    ​ 域修饰符(public、private、 protected、static、final volatile、transient等);
    ​ 域类型;
    ​ 域名字。

    注释:域也是成员变量,域必须在类中直接定义,不能包含在方法中定义。域定义格式:【修饰符】 类型名 域名;

  • 方法信息
    ​ 方法声明的顺序;
    ​ 方法修饰符(public static final等);
    ​ 方法返回值类型;
    ​ 方法名字
    ​ 方法参数列表(数量和类型)(有序保存)
    ​ 异常表(方法抛出的异常)
    ​ 方法字节码(就是花括号里的内容,native、abstract 方法除外,)
    ​ 操作数栈和方法栈帧的局部变量区的大小。

  • 类变量(即 static 变量)

    • 非 final 类变量
      在 java 虚拟机使用一个类之前,它必须在方法区中为每个 non-final 类变量分配空间。非 final 类变量存储在定义它的类中;
    • final 类变量(常量)
      由于 final 的不可改变性,因此,final 类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面;final 类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;
  • 对类加载器的引用

    jvm 必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 jvm 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。

    jvm 在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm 需要保证这两个类型的类加载器是相同的。这对 jvm 区分名字空间的方式是至关重要的。

  • 对 Class 实例的引用

    jvm 为每个加载的类都创建一个 java.lang.Class 的实例(存储在堆上)。而 jvm 必须以某种方式把 Class 的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个 Class 对象的引用;

  • 方法表

    为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm 的实现者还可以添加一些其他的数据结构,如方法表。jvm 对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法)。jvm 可以通过方法表快速激活实例方法。

运行时常量池(Run-Time Constant Pool)

​ 运行时常量池用于存放编译期间生成的各种字面量和符号引用,(还有常量)。

​ 每个运行时常量池都是从 Java 虚拟机的方法区域分配的,Java1.8 后的静态变量和字符串常量池移到了堆中。

​ 类或接口的运行时常量池是在 Java 虚拟机创建类或接口时构建的。
在这里插入图片描述
运行时常量池与常量池

  1. 方法区,内部包含了运行时常量池。
  2. 字节码文件,内部包含了常量池。(之前的字节码文件中已经看到了很多Constant pool的东西,这个就是常量池)。
  3. 要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。
  4. 要弄清楚方法区的运行时常量池,需要理解清楚 ClassFile 中的常量池。

常量池:

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

为什么需要常量池?

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

    比如:如下的代码:

    public class SimpleClass {
        public void sayHello() {
            System.out.println("hello");
        }
    }
    
    1. 虽然上述代码只有194字节,但是里面却使用了String、System、PrintStream及Object等结构。
    2. 比如说我们这个文件中有6个地方用到了”hello”这个字符串,如果不用常量池,就需要在6个地方全写一遍,造成臃肿。我们可以将”hello”等所需用到的结构信息记录在常量池中,并通过引用的方式,来加载、调用所需的结构。
    3. 这里的代码量其实很少了,如果代码多的话,引用的结构将会更多,这里就需要用到常量池了。

常量池中有啥?

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

运行时常量池:

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

Java中的常量池区分:

class 常量池中的数据在类加载后存放到运行时常量池。(class 常量池(编译器确定数据)、运行时常量池(类加载确定数据))。

字符串常量池是 class 常量池的一部分。

  1. 类文件中常量池(The Constant Pool),即 class 常量池。

    当 java 文件被编译成 class 文件之后,会在 class 文件中生成我们所说的 class 常量池,class 文件中包含类的版本、字段、方法、接口等描述信息外;还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(文本字符串、被声明为 final 的常量、基本数据类型的值)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。

  2. 运行时常量池(The Run-Time Constant Pool)

    当类加载到内存中后,jvm 就会将 class 常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。运行时常量池相对于 CLass 文件常量池的另外一个重要特征是具备动态性,在运行期间也可以将新的变量放入常量池中,而不是一定要在编译时确定的常量才能放入。最主要的运用便是 String 类的 intern() 方法。

  3. String 常量池

    字符串常量池,即为了避免多次创建字符串对象,而将字符串在 jvm 中开辟一块空间,储存不重复的字符串。(JDK1.7 之前在方法区,JDK1.8 以后移到堆中。)

    在直接使用双引号" "声明字符串的时候, java 都会去常量池找有没有这个相同的字符串,如果有,则将常量池的引用返回给变量。如果没有,会在字符串常量池中创建一个对象,然后返回这个对象的引用。

    使用 new 关键字创建,比如 String a = new String(“hello”);。这里可能创建两个对象:一个是用双引号括起来的 hello,按照上面的逻辑,如果常量池没有,创建一个对象;另一个是必须会创建的,new 关键字必然会在堆中创建一个新对象,最终返回的是 new 关键词创建的对象的地址。

    (当使用 new 运算符创建 String 对象时,JVM 将首先在 SCP(字符串常量池)中检查,该对象是否可用。如果 SCP 内部没有该对象,JVM 将创建两个对象,一个在 SCP 内部,另一个在 SCP 外部。但是如果 JVM 在 SCP内部 中找到相同的对象,它只会在 SCP 外部创建一个对象。)

字面值(字面量)

是用于表达源代码中一个固定值的表示法。就是像字符串值,基本数据类型的数值,以及它们的包装类的值,以及 final 修饰的变量值(编译期间就有确定的值),简单说就是在编译期间,就可以确定下来的值。(如1、’a’、”string” 等字面意义上的值)。

注意:字面量是指由字母,数字等构成的字符串或者数值,它只能作为右值出现(右值是指等号右边的值,如:int a=123,这里的 a 为左值,123 为右值)。常量和变量都属于变量,只不过常量是赋过值后不能再改变的变量,而普通的变量可以再进行赋值操作。

符号引用:

​ 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可,(就是给某个事物命名的名字,当想找它的时候就只要找这个名字)。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在 Java 中,一个 java 类将会编译成一个class文件。在编译时,java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。

比如 org.simple.People 类引用了 org.simple.Language 类,在编译时 People 类并不知道 Language 类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于 CONSTANT_Class_info 的常量来表示的)来表示 Language 类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。

直接引用:

(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)

(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)

(3)一个能间接定位到目标的句柄

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

符号引用是只包含语义信息,不涉及具体实现的;而解析(resolve)过后的直接引用则是与具体实现息息相关的。

符号引用——>经过解析——>直接引用:符号引用只是一个标记,开始不能代表具体的对象,只用经过解析后,才代表具体的对象(即地址);对于静态的符号引用,在准备阶段就代表具体的对象(即已解析成直接引用)。

堆区

基本概念

jvm 只有一个堆区(heap)被所有线程共享,提供所有类实例和数组对象存储区域,堆内存的大小是可以调整的。堆中不存放基本类型和对象引用,只存放对象本身。(目的是存放实例对象)。

  1. 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。

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

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

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

  5. 《Java虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)

    • 从实际使用角度看:“几乎” 所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)。

    • 最终结论:所有的对象实例都在堆分配内存。

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

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

    • 也就是触发了 GC 的时候,才会进行回收。
    • 如果堆中对象马上被回收,那么用户线程就会受到影响,因为有 stop the word。
  8. 堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

    • 堆需要 GC,存在 OOM。

设置堆内存

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

  • -Xms 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize

  • -Xmx 用于表示堆区的最大内存,等价于 -XX:MaxHeapSize

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

通常会将 -Xms 和 -Xmx 两个参数配置相同的值

  • 原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在 GC 之后调整堆内存给服务器带来压力。

    如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报 OOM。

默认情况下:

  • 初始内存大小:物理电脑内存大小1/64。
  • 最大内存大小:物理电脑内存大小1/4。

堆内存参数设置总结:

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值。
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值。(可能会存在修改,不再是初始值)。
    -xms:初始堆空间内存。(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存。(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比。
  • -XX:SurvivorRatio:设置新生代中 Eden 和 s0/s1 空间的比例。
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄。
  • -XX:+PrintccDetails:输出详细的GC处理日志。
    • 打印gc简要信息:1)-xx:+Printcc;2)-verbose: gc
  • -XX:HandlePromotionFailure:是否设置空间分配担保。

堆内存细分

Java 7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区(Young/New)。

    • 新生区又被划分为 Eden 区和 survivor 区。
  • Tenure generation space 养老区(old/Tenure)。

  • Permanent Space 永久区(Perm),存在于方法区。

Java 8 及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space 新生区(Young/New)。

    • 新生区又被划分为 Eden 区和 Survivor 区。
  • Tenure generation space 养老区(old/Tenure)

  • Meta Space 元空间(Meta),存在于本地内存。

约定:新生区=新生代=年轻代;养老区=老年区=老年代;永久区=永久代。

新生代和老年代物理上是属于堆区,永久区物理上是在方法区中。
在这里插入图片描述

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

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。

配置 Eden 和 两个 survivor 的占比:

  • 默认 -XX:SurvivorRatio=8,Eden空间和另外两个survivor空间缺省所占的比例是 8:1:1。

新生代:

  • 几乎所有的 Java 对象都是在 Eden 区被 new 出来的。
  • 绝大部分的 Java 对象的销毁都在新生代进行了,很少在养老区收集,几乎不在永久区/元空间收集。
    • IBM 公司的专门研究表明,新生代中 80% 的对象都是 “朝生夕死” 的。
  • 可以使用选项 ”-Xmn" 设置新生代最大内存大小。
    • 这个参数一般使用默认值就可以了。

新生代、年老代、元数据区(永久区)

java 中最常见的问题之一就是堆内存溢出(OutOfMemoryError: OOM),所以了解 jvm 堆工作及 GC 的原理非常重要。jvm 堆内存从 GC 的角度划分可分为:新生代(eden区、survivor form区和survivor to区)和年老代。

新生代

新生代是用来存放新生的对象。新生代通常占据着堆内存的 1/3 空间。因为 java 对象频繁的创建,所以新生代会频繁的触发 Minor GC 进行垃圾回收。新生区分为 Eden 区、Survivor form 区和 Survivor to 区。

1 个 Eden 区和 2 个 Survivor 区(分别叫 from 和 to)。默认比例为 8:1:1;两个 Survivor 为1:1。
  • Eden 区(伊甸园区):java 新对象的出生地(当然如果新创建的对象占用的内存非常大是,则直接将其分配至年老代),当 Eden 区中的内存不足时,就会触发 Minor GC 对新生代区进行一次垃圾回收。
  • Survivor To 区(幸存1区):用于保留 Minor GC 中的幸存者。
  • Survivor From 区(幸存0区):用于存放上一次 Minor GC 中幸存者,并且作为本次 Minor GC 的被扫描者。

Minor GC 过程:Minor GC 通常采用复制算法。首先将 Eden 区和 Survivor From 区中存活的对象复制到 Survivor To 区之中(如果对象的年龄到达了年老代的标准时则赋值到年老代(通常年龄大于15 即可));然后清空 Eden 区和 Survivor From 区,最后将 Survivor To 区和 Survivor From 区互换,原来的 Survivor To 区变成下一次的 Survivor From 区。

一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

年老代

年老代主要存放在程序中生命周期长的对象。年老代通常占据着堆内存的 2/3 空间。年老代因为其中对象比较稳定,所以 Major GC 不会频繁的执行。在进行 Major GC 之前通常都会先执行一次 Minor GC,Minor GC 执行完后可能会有新生代的对象晋升到年老代之中,然后导致年老代的空间不足才触发 Major GC。当无法找到足够大的连续空间分配给新创建的较大的对象时也会提前触发一次 Major GC 进行垃圾回收来腾出空间。当触发 Major GC 时,GC 期间会停止一切线程等待至 GC 完成。

Major GC 过程:因为年老代每次只回收少量的对象,所以 Major GC 通常推荐采用标记整理算法。首先扫描一次所有的年老代,标记出所有存活的对象和需要回收的对象,将存活的对象移向内存的一端,然后清除边界外的对象。

永久代与元数据区及区别

永久代就是指永久保存的区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候放入永久代。他和存放实例的区域不同,GC 不会在主程序运行期间对永久区进行清理,关闭虚拟机就会释放这个区域的内存。而这也导致了永久区会随着不断增加的 Class 而膨胀,最终导致 OOM 异常。所以在 JAVA8 之后,移除了永久代,用一个叫元数据区的代替了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。元空间和永久代之间最大差异就是元空间使用的不是虚拟机中的内存,而是使用本地内存,这样的好处在于其元空间内存的大小仅仅受限于本地内存的大小,这样就可以避免永久代 OOM 的问题了。

什么情况下永久区会崩? 假设一个启动类,加载了大量的第三方jar包;Tomcat 部署了太多的应用;大量动态生成的反射类。若不断被加载,直到内存满,就会出现 OutOfMemoryError。

在 JDK6 以前,永久代内存溢出抛出 "java.lang.OutOfMemoryError: PermGen space "这个异常。这里的 “PermGen space”其实指的就是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。我们现在通过动态生成类来模拟 “PermGen space”的内存溢出:

在 JDK 1.8 中, HotSpot 已经没有 “PermGen space” 这个区间了,取而代之是一个叫做 Metaspace(元空间) 的东西。永久代与元数据区演变过程:

  • JDK1.6 之前:为永久代,类型信息,字段,方法,常量、静态变量、常量池保存在方法区;
    在这里插入图片描述
    在这里插入图片描述
  • JDK1.7:还有永久代,但是慢慢的退化了,是去永久代阶段,将静态变量、常量池中的字符串常量池保存在堆中,其他信息还是保存在方法区中。
    在这里插入图片描述
    在这里插入图片描述
  • JDK1.8之后:无永久代 了,其他信息保存在元空间。但字符串常量池、静态变量仍然在堆中。
    在这里插入图片描述
    在这里插入图片描述

这里静态变量是指静态变量本身(即静态变量名),而其指向的对象一直是存放在堆中。

JDK 1.7以后,内存溢出抛出 ”java.lang.OutOfMemoryError: Java heap space“ 异常。

从上述结果可以看出,JDK 1.6下,会出现 “PermGen Space” 的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。

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

  1. 随着 Java8 的到来,HotSpot VM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
  2. 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
  3. 这项改动是很有必要的,原因有:
    1. 为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。Exception in thread ‘dubbo client x.x connector’ java.lang.OutOfMemoryError:PermGen space而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
    2. 对永久代进行调优是很困难的。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低 Full GC。(尽可能降低 Full GC)
      • 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 ZGC 收集器就不支持类卸载)。
      • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

字符串常量池 StringTable 为什么要调整位置?

详细看:https://imlql.cn/post/ee2ba71e.html

  1. JDK7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 Full GC 的时候才会执行永久代的垃圾回收,而 Full GC 是老年代的空间不足、永久代不足时才会触发。

  2. 这就导致 StringTable 回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

对象分配原则(对象提升规则)

  • 优先分配到 Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢。
  • 大对象直接分配到老年代:尽量避免程序中出现过多的大对象。
  • 长期存活的对象分配到老年代。
  • 动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保: -XX:HandlePromotionFailure 。在发生 Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次 Full GC。如果小于,则查看HandlePromotionFailure 设置是否允许担保失败﹔如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC。

对象分配过程:

在这里插入图片描述

TLAB

TLAB 为对象分配内存(保证线程安全)。

为什么有 TLAB

  1. 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据;

  2. 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。

  3. 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是 TLAB

TLAB(Thread Local Allocation Buffer)

  1. 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
  2. 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  3. 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。
  4. 每个线程都有一个TLAB空间。
  5. 当一个线程的TLAB存满时,可以使用公共区域(蓝色)的。
    在这里插入图片描述

TLAB 再说明

  1. 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
  2. 在程序中,开发人员可以通过选项 “-XX:UseTLAB” 设置是否开启 TLAB 空间。
  3. 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置 TLAB 空间所占用 Eden 空间的百分比大小。
  4. 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
  • 分配过程

在这里插入图片描述

逃逸分析概述

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段。

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

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

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

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

    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

    • 如下:

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

      public void my_method() {
      	v v = new V();
      	//use v
      	//......
          v=null;
      }
      

      蓝色代码块发生逃逸的对象,黄色代码块没有发生逃逸的对象。

    在这里插入图片描述

使用逃逸分析,编译器可以对代码做如下优化:

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

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

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

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

    • 标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
    • 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
    • 在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,是线程私有的,其区别是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 native 方法服务(本地方法指其他语言如C/C++写的外部程序,可以被 Java 调用)。

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

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

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

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

总结

总结三个存储空间存放的信息:

  1. 类变量(还包括静态的方法,块等,即 static 修饰)

    符号引用,字面值,以及静态变量,静态方法等的具体空间在方法区(准备阶段确定在方法区);

    (方法区存放与类相关的信息,一些永久信息)

  2. 实例对象

    实例对象的具体空间在堆区(初始化阶段确定在堆区);

    (堆区存放实例对象)

  3. 非静态方法(包括非静态方法(没有static修饰)中的局部变量)

    非静态方法执行(以及其中的局部变量等)具体空间在栈区(方法调用执行时确定在栈区,其实方法在编译后就确定了具体空间大小)。

    (栈区存放临时的量)

位置是否有Error是否存在GC
PC计数器不存在
虚拟机栈有,SOF不存在
本地方法栈(在HotSpot的实现中和虚拟机栈一样)有,SOF不存在
有,OOM存在
方法区存在

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

下面涉及了对象的访问定位

  1. Person 类的 .class 信息存放在方法区中;

  2. person 变量存放在 Java 栈的局部变量表中;

  3. 真正的 person 对象存放在 Java 堆中;

  4. 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的。

    在这里插入图片描述

Native 关键字

使用 native 关键字,说明 Java 的作用范围不够了。native 关键字告诉 JVM 调用的该方法是在外部定义,由其他语言编写的(如调用底层 C 或 C++ 语言等的库)。因此在 JVM 内存区域中专门开辟了一块标记区域(本地方法栈),用来登记 native 方法。

在调用 native 方法的时候,会进入本地方法栈,然后调用本地方法接口(JNI),通过 JNI 加载本地方法库中对应的方法去执行。

native 是用做 java 和其他语言(如C/C++)进行协作时用的。一个 native 方法就是一个 Java 调用非 Java 代码的接口。一个 native 方法是指该方法的实现由非 Java 语言实现,比如用C或C++实现。

在定义一个 native 方法时,并不提供实现体(比较像定义一个Java Interface),因为其实现体是由非 Java 语言在外面实现的。

主要是因为 JAVA 无法对操作系统底层进行操作,但是可以通过 JNI(java native interface)调用其他语言来实现底层的访问。

用 Java 代码调用本地的 C/C++ 程序步骤如下:

  1. 编写带有 native 声明的方法的 java 类,生成 .java 文件;(注意这里出现了 native 声明的方法关键字)
  2. 使用 javac 命令编译所编写的 java 类,生成 .class 文件;
  3. 使用 javah -jni java类名,生成扩展名为 h 的头文件,也即生成 .h 文件;
  4. 使用 C/C++(或者其他编程想语言)实现本地方法,创建 .h 文件的实现,也就是创建.cpp 文件实现 .h 文件中的方法;
  5. 将 C/C++ 编写的文件生成动态连接库,生成 dll 文件。
  6. 在 Java 中用 System.loadLibrary() 方法加载第五步产生的动态链接库文件,这个 native() 方法就可以在 Java 中被访问了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值