Administrator
Published on 2021-12-08 / 120 Visits
0
0

JVM系列之运行时内存篇

运行时内存篇

说明

jvm-runtime

程序计数器

定义

为了保证程序(在操作系统中理解为进程)能够连续地执行下去,CPU必须具有某些手段来确定下一条指令的地址。而程序计数器正是起到这种作用,所以通常又称为指令计数器

在程序开始执行前,必须将它的起始地址,即程序的一条指令所在的内存单元地址送入PC,因此程序计数器 (PC)的内容即是从内存提取的第一条指令的地址,当执行指令时,CPU将自动修改PC的内容,即每执行一条指令PC增加一个量,这个量等于指令所含的字节数,以便使其保持的总是将要执行的下一条指令的地址

由于大多数指令都是按顺序来执行的,所以修改的过程通常只是简单的对 PC 加 1

当程序转移时,转移指令执行的最终结果就是要修改PC的值,此PC值就是转去的地址,以此实现转移,有些机器中也称RC为指令指针IP: (Instruction Pointer)

特征

JVM 中的程序计数寄存器 (Program Counter Register)中,Register 的命名源于CPu的奇存器,寄存器存储指令相关的现场信息,CPU只有把数据装载到寄存器才能够运行

这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器 (或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的识会,JVM 中的 PC 寄存器是对物理PC寄存器的一种抽象模拟

它是一块很小的内存空间,几乎可以忽路不记。也是运行速度最快的存储区域,不会随着程序的运行需要更大的空间

在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致

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

小结

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

PC寄存器用来存储指向下一条指令的地址,即将要执行的指令代码,执行引擎的字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令

虚拟机栈

栈存在GC吗

无GC,可能OOM

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的,如果采用固定大小的 Java 虚拟机栈,那每一个线程的Java虛拟机栈容量可以在线程创建的时候独立选定,如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError的异常

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

如何设置栈的大小

  • -Xss1024K
  • 一般设置为512k-1024k,取决于操作系统
  • 栈的大小直接决定了函数调用的最大深度和系统创建的线程数
  • 在JDK 1.5 之前,默认的栈大小是256K
  • 在JDK 1.5 之后,默认的栈大小是1024K

栈的单位-栈帧

每个线程都有自己的栈,栈中的数据都是以栈帧(stack flame)的形式存在

方法与栈帧之间存在什么关系

  • 在线程上正在执行的每个方法都各自对应一个栈帧 (Stack Frame),栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧 (栈顶栈帧)是有效的,这个栈帧被称为当前栈帧 (Current Frame ),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(CrrentCLass),如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作

栈的FILO原理

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

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

遵循“先进后出”/“后进先出”原则

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令,另外一种是拋出异常,不管使用哪种方式,都会导致栈帧被弹出

栈帧内部结构

每个栈帧中存储着如下的结构

  • 局部变量表 ( Local Variables)

  • 操作数栈 (Operand stack)

  • 动态链接(Dynamic Linking)

  • 方法返回地址 (Return Address)

  • 一些附加信息

局部变量表

局部变量表也被称之为局部变量数组或本地变量表

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

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

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

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

局部变量表存在线程安全问题吗

不存在,没有共享变量,都是线程独占

关于 slot 的理解

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

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

  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型 (1ong和double)占用两个slot

    • byte、short、char 在存储前被转换为 int, boolean 被转换为int,0 表示 false,非 0 表示true

    • long 和 double 则占据两个Slot

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

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

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可(比如:访问long或double类型变量)

  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列

slot可以重复利用的问题

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

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

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配
  • 我们知道类变量表有两次初始化的机会,第一次是在 “准备阶段”,执行系统初始化对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值
  • 和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义局部变量则必须人为的初始化,否则无法使用

局部变量表与GC ROOTS的关系

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

操作数栈

  • 我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-out)的操作数栈,也可以称之为表达式栈(Expression Stack)
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 code 属性中,为maxstack 的值
  • 栈中的任何一个元素都是可以任意的Java数据类型
    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 操作数栈在方法执行过程中,根据字节码指令,并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作,往栈中写入数据,或是提取数据来完成一次数据访问
  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈,比如:执行复制、交换、求和等操作
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
  • 操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期问进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证

栈顶缓存技术

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

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

动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接 (Dynamic Linking),比如 invokedynamic 指令,在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里

比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

为什么需要常量池

提供一些符号和常量,便于指令的识别

方法的调用

静态链接与动态链接

方法返回地址

  • 存放调用该方法的PC寄存器的值
  • 一个方法的结束,有2种方式
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息
  • 当一个方法开始执行后,只有两种方式可以退出这个方法
    • 执行引擎遇到任意一个方法返回的字节码指令 (return),会有返回值传递给上层的方法调用者,简称正常完成出口
    • 一个方法在正常调用完成之后究竞需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
  • 在字节码指令中,返回指令包含 ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、 freturn、dreturn以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用
  • 在方法执行的过程中遇到了异常 (Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口,方法执行过程中拋出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
  • 本质上,方法的退出就是当前栈帧出栈的过程,此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去
  • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值

附加信息

本地方法接口

简单地讲,一个 Native Method 就是一个Java调用非Java代码的接口,该方法的实现由非Java语言实现,比如C,这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C中,你可以用extern"C“告知C编译器去调用一个C的函数

现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者 Java 系统管理生产设备,在企业级应用中己经比较少见,因为现在的异构领域问的通信很发达,比如可以使用 Socket 通信,也可以使用web Service等等,不多做介绍

本地方法栈

  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
  • 本地方法栈,也是线程私有的
  • 允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面是相同的)
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个 OutofMemoryError 异常
  • 本地方法是使用C语言实现的,它的具体做法是Native Method stack中登记native方法,在Execution Engine 执行时加载本地方法库
  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈

核心概念

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
  • Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了,是 JVM 管理的最大一块内存空间
  • 堆内存的大小是可以调节的
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
  • 堆,是GC ( Garbage collect ion,垃圾收集器)执行垃圾回收的重点区域
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除

对象都分配在堆上吗

Java 中的对象不一定是在堆上分配的,因为JVM通过逃逸分析,能够分析出一个新对象的使用范围,并以此确定是否要将这个对象分配到堆上,如果JVM发现某些对象没有逃逸出方法,就很有可能被优化成在栈上分配

所有的线程都共享堆?

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

堆的内部结构

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

JDK1.7

jdk1.7

  • Java 7 及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
  • Young Generation Space 是新生区,又被划分为 Eden 区 和 Survivor 区
  • Tenure Generation Space 是养老区
  • Permanent Space是永久区

JDK1.8

jdk1.8-jvm

  • Java 8之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
  • Young Generation Space 是新生区,又被划分为 Eden 区 和 Survivor 区
  • Tenure Generation Space 是养老区
  • Meta Space 是元空间

年轻代与老年代

yandold

  • 存储在JVM中的Java对象可以被划分为两类

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代 (OldGen)

  • 其中年轻代又可以划分为Eden空间、Survivor0 空间和 Survivor1空间(有时也叫做 from 区和 to 区)

  • 几乎所有的对象都是从 Eden 区被 new 出来的

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

如何设置堆内存大小

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就己经设定好了,大家可以通过选项 ”-Xmx” 和 ”-Xms” 来进行设置
    • “-Xms” 用于表示堆区的起始内存,等价于 -XX:InitialHeapsize
    • “-Xmx” 用于表示堆区的最大内存,等价于 -XX:MaxHeapsize
  • 一且堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出 OutofMemoryError 异常
  • 通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
  • heap 默认最大值计算方式:如果物理内存少于192M,那么 heap 最大值为物理内存的一半,如果物理内存大于等于1G,那么heap的最大值为物理内存的1/4
  • heap默认最小值计算方式:最少不得少于8M,如果物理内存大于等于1G,那么默认值为物理内存的1/64,即1024/64=16M,最小堆内存在 jvm 启动的时候就会被初始化

如何设置新生代与老年代的比例

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

可以设置 -Xmn 设置新生代最大内存大小

如何设置 Eden,Survivor 区比例

在Hotspot中,Eden空问和另外两个Survivor空问缺省所占的比例是8:1:1,当然开发人员可以通过选项 -XX:SurvivorRatio调整这个空间比例,比如-XX:SurvivorRatio=8

参数小结

  • -Xms -Xmx
  • -Xmn
  • -XX:NewRatio
  • -XX:SurvivorRatio
  • -XX:MaxTenuringThreshold
  • -XX:+PrintGCDetails
  • -XX:HandlePromotionFailure
    • 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则此次Minor GC是安全的,如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次 Minor GC,但这次Minor Gc依然是有风险的,如果小于或者 HandlePromotionFailure=false,则改为进行一次Full GC
    • 在 JDK 6 Update 24之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它,JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC
  • -XX:+PrintFlagsFinal

对象分配

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

  • 针对幸存者S0,S1区的总结:复制之后有交换,谁空谁是 S1
  • 关于垃圾回收
    • 频繁在新生区收集
    • 很少在养老区收集
    • 几乎不在永久区/元空间收集

分配过程

  1. new 的对象先放 Eden 区,此区有大小限制
  2. 当 Eden 的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对 Eden 区进行垃圾回收 (Minor GC/YGC),将 Eden 区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到 Eden 区
  3. 然后将 Eden 中的剩余对象移动到 Survivor 0 区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到 Survivor 0 区的,如果没有回收,就会放到 Survivor 1 区
  5. 如果再次经历垃圾回收,此时会重新放回 Survivor 0 区,接着再去 Survivor 1 区
  6. 啥时候能去养老区呢?可以设置次数,默认是15次,可以设置参数:-XX:MaxTenuringThreshold=<N〉设置对象晋升老年代的年龄阈值
  7. 在养老区,相对悠闲,当养老区内存不足时,再次触发 GC:Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC之后发现依然无法进行对象的保存,就会产生OOM异常 java.lang .OutofMemory Error: Java heap spaceYGC/Minor GC

内存分配原则

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

MinorGC、MajorGC、FullGC 区别

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

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型

  • 部分收集 (Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为
    • 新生代收集 (Minor GC / Young GC):只是新生代(Eden\S0, S1)的垃圾收集
    • 老年代收集 (Major Gc / Old GC):只是老年代的垃圾收集,目前,只有 CMS GC 会有单独收集老年代的行为,注意,很多时候 Major GC 会和 Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集 (Mixed GC):收集整个新生代以及部分老年代的垃圾收集,目前,只有G1 GC会有这种行为
  • 整堆收集 (Full GC):收集整个 java 堆和方法区的垃圾收集

Minor GC 触发机制

  • 当年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 区满,Survivor 满不会引发GC,每次 Minor GC 会清理年轻代的内存
  • 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快,这一定义既清晰又易于理解
  • Minor GC 会引发 STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

Major GC触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们会说 “Major GC” 或 “Full GC”
  • 出现了Major GC,经常会伴随至少一次的 Minor GC,但非绝对,在Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程
  • 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发 Major GC
  • Major GC 的速度一般会比 Minor GC 慢10倍以上,STW 的时间更长
  • 如果 Major GC 后,内存还不足,就报OOM了

Full GC 触发机制

触发 Full GC 执行的情况有如下五种

  • 调用 System.gc() 时,系统建议执行 Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  • 由 Eden 区、Survivor Space0 (From Space)区向 Survivor Space1 (To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:full gc是开发或调优中尽量要避免的,这样暂停时间会短一些

OOM怎么解决

  • 要解决 OOM 异常或 heap space 的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏 (Memory Leak)还是内存溢出(Memory Overflow)
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到池漏对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的,掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置
  • 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-xmx 与-xms)与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗

堆空间分代思想

快速分配策略:TLAB

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

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
  • 所以,多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

什么是TLAB

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  • 据我所知所有 OpenJDK 衍生出来的JVM都提供了TLAB的设计

TLAB设置

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

方法区

栈、堆、方法区的关系

method

方法区在哪里

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩,但对于 Hotspot JVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开,所以,方法区看作是一块独立于Java 堆的内存空间

方法区的理解

  • 方法区 (Method Area) 与Java堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢抛出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space 或者 java.lang.OutOfMemoryError: Metaspace
  • 关闭JVM就会释放这个区域的内存

HotSpot 中方法区的演进

  • 在 jdk7 及以前,习惯上把方法区,称为永久代,jdk8 开始,使用元空间取代了永久代
  • 本质上,方法区和永久代并不等价,仅是对hotspot而言的,《Java虚拟机规范》对如何实现方法区,不做统一要求,例如:BEA JRockit/IBM J9 中不存在永久代的概念
  • 现在来看,当年使用永久代,不是好的选择,导致Java程序更容易OOM(超过 -XX:MaxPermsize 上限)
  • 而到了 jdk8 终于完全废弃了永久代的概念,改用与 JRockit、J9一样在本地内存实现的元空间(Metaspace)来代替
  • 元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现,不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  • 永久代、元空间二者并不只是名字变了,内部结构也调整了
  • 根据 《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

方法区常用参数有哪些

设置方法区内存的大小

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

jdk7及以前

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

idk8及以后

  • 元数据区大小可以使用参数 -XX:Metaspacesize 和 -XX:MaxMetaspacesize 指定,替代上两个参数
  • 默认值依赖于平台,windows下,-XX:Metaspacesize是21M,-XX:MaxMetaspacesize的值是 -1,即没有限制
  • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存,如果元空间发生溢出,虚拟机一样会抛出异常outofMemoryError:Metaspace
  • -XX:Metaspacesize 设置初始的元空间大小,对于一个64位的服务器端JVM来说,其默认 -XX:Metaspacesize 值为21MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 会被触发并卸载没用的类,这些类对应的类加载器也不再存活,然后这个高水位线将会重置,水位线的值取决于GC后释放了多少元空间,如果释放的空间不足,那么在不超过MaxMetaspacesize时,适当提高该值,如果释放空间过多,则适当降低该值
  • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的以观察到 Full GC多次调用,为了避免频繁地GC,建议将 -XX:Metaspacesize设置为一个高的值

方法区都存什么

类型信息

域(field)信息

方法信息

non-final的类变量

运行时常量池

永久代与元空间

  • jdk1.6及之前:有永久代 (permanent generation)
  • jdk1.7:有永久代,但己经逐步“去永久代”,宇符串常量池、静态变量移除,保存在堆中
  • idk1.8及之后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量、静态变量池仍在堆中

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

随着Java8 的到来,HotSpot VM 中再也见不到永久代了,但是这并不意味着类的元数据信息也消失了,这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间 (Metaspace),由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间

这项改动是很有必要的,原因有

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

在某些场景下,如果动态加载类过多,容易产生 Perm 区的OOM,比如某个实际veb工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误

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

对永久代调优是很困难的

StringTable为什么要调整?

jdk7中将 StringTable 放到了堆空间中,因为永久代的回收效率很低,在 full gc 的时候才会触发,而 full gc 是老年代的空间不足、永久代不足时才会触发

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

方法区是否存在GC

  • 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代) 是没有垃圾收集行为的,其实不然,《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)
  • 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的,以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的Hotspot虚拟机对此区域未完全回收而导致内存泄漏
  • 方法区的垃圾回收主要是2部分:常量池废弃的常量和不再使用的类型
  • 先来说说方法区内常量池之中主要存放的两大类常量:宇面量和符号引用,字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等,而符号引用则属于编译原理方面的概念,包含下面三类常量:
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  • Hotspot虛拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
  • 回收废弃常量与回收Java堆中的对象非常类似
  • 判定一个常量是否 “废弃”还是相对简单,而要判定一个类型是否属于 “不再被使用的类”的条件就比较苛刻了,需要同时满足下面三个条件
    • 该类所有的实例都己经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
    • 加载该类的类加载器己经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如osGi、JSP的重加载等,否则是很难达成的
    • 该类对应的java.lang. Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收
  • 关于是否要对类型进行回收,Hotspot虛拟机提供了 -Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClassLoading 和 -XX:+TraceClassUnloading 查看类加载和卸载信息
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及osGi这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备各类型卸载的能力,这保证不会对方法区造成过大的内存压力

内存结构小结

直接内存

StringTable

面试题


Comment