0
点赞
收藏
分享

微信扫一扫

快速上手JVM系列(三)——JVM内存结构与堆区GC机制

大雁f 2022-04-19 阅读 107

🚀 引言

文章目录

JDK1.8之前

image-20220415193159652

JDK1.8之后的改变

注:

  • 直接内存不是运行时数据区的一部分

  • JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

image-20220417175207241

程序计数器PC

程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

作用:当前线程行号指示器

  • 在Java虚拟机的概念模型里,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

特性

  • 程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域
  • 生命周期随着线程的创建而创建,随着线程的结束而死亡

面试题

🚀

👼 答:由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存

Java虚拟机栈

Java 虚拟机栈描述了 Java 方法运行过程的内存模型

作用:方法调用内存模型

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化

每一个函数调用结束后,都会有一个栈帧被弹出,Java 方法有两种返回方式:

  1. return 语句
  2. 抛出异常

不管哪种返回方式都会导致栈帧被弹出

特性

  • 与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同

    由于 Java 虚拟机栈是与线程对应的,数据不是线程共享的(也就是线程私有的),因此不用关心数据一致性问题,也不会存在同步锁的问题

  • 运行速度特别快,仅仅次于 PC 寄存器

  • Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

    • StackOverFlowError 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误

      注:出现 StackOverFlowError 时,内存空间可能还有很多

    • OutOfMemoryError 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出 OutOfMemoryError 错误。

虚拟机栈结构

Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息

如图所示

image-20220417141258470

  • 在java编译成class文件的时候,局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变

  • 主用于存放方法参数和方法内部定义的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型

Slot

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

  • JVM 虚拟机会为局部变量表中的每个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  • 栈帧中的局部变量表中的槽位(Slot)是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

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

  • 操作数栈和局部变量表一样,在编译时期就已经确定了该方法所需要分配的明确的栈深度
  • 栈顶缓存技术:由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理 CPU 的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
  • 操作数栈的每一个元素可用是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2
  • 并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问
  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接。
  • 动态链接:如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接。

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中方法的符号引用为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态方法,私有方法等),这种转化称为静态解析,另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。由于篇幅有限这里不再继续讨论解析与分派的过程,这里只需要知道静态解析与动态连接的区别就好

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

  • 执行引擎遇到任意一个方法返回的字节码指令:传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口。
  • 方法执行过程中遇到异常: 无论是java虚拟机内部产生的异常还是代码中thtrow出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址。方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈每条整pc计数器的值指向调用该方法的后一条指令
  • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做 C。如果在类型 C 中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验。
  • 如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
  • 否则,按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

本地方法栈

  • 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈

  • 在 HotSpot 虚拟机中和虚拟机栈合二为一

  • 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

  • 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

堆区

堆是Java 虚拟机所管理的内存中最大的一块,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存(几乎是因为可能存储在栈上,另见逃逸分析)

特性

  • 在虚拟机启动时创建,线程共享,整个 Java 虚拟机只有一个堆,所有的线程都访问同一个堆

  • 在虚拟机启动时创建

  • 堆的大小既可以固定也可以扩展,但对于主流的虚拟机,堆的大小是可扩展的,因此当线程请求分配内存,但堆已满,且内存已无法再扩展时,就抛出 OutOfMemoryError 异常

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

垃圾回收的主要区域

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)

从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为

  • 年轻代

    可以分为Eden空间、Survivor0空间和Survivor1空间,两个 Survivor 区都属于新生代为了区分,这两个 Survivor 区域按照顺序被命名为 from Survivorto Survivor

  • 老年代

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存,如图所示

image-20220417234455298

新生代与老年代

老年代比新生代生命周期长

调JVM参数,默认为-XX:NewRatio=2,表示新生代与老年代空间默认比例 1:2:,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

可以通过选项 -XX:SurvivorRatio 调整空间比例,如**-XX:SurvivorRatio=8**

浅谈堆中对象分配策略

对象优先在 eden 区分配

为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率,大对象直接进入老年代

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)

长期存活的对象进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。

为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器

  1. 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄加1(初始年龄为0)
  2. 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中

动态年龄计算的代码如下

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
    //survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
    ...
}

🏭 额外补充说明:关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书,如果你去 Oracle 的官网阅读相关的虚拟机参数,-XX:MaxTenuringThreshold=threshold这里有个说明

浅谈Hotspot的GC机制

GC方式

(1)部分收集Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:

  • 新生代收集(Minor GC):只是新生代的垃圾收集
  • 老年代收集(Major GC):只是老年代的垃圾收集,只有CMS的concurrent collection是这个模式
  • 混合收集 (Mixed GC) : 收集整个新生代以及部分老年代的GC。只有G1有这个模式

(2)整堆收集(Full GC:收集整个java堆和方法区的垃圾收集

年轻代GC触发机制

  1. 大部分清空 Java 对象都是在 Eden 区被 new 出来的(详见本节堆中对象分配策略)

    🐰 如果创建新对象时,Eden 空间填满了,就会触发 GC:Major GC,将 Eden 不再被其他对象引用的对象进行销毁,再加载新的对象放到 Eden 区,特别注意的是是 Eden 空间填满了才会触发 Minor GC 的,而Minor GC只是顺便清理 Survivor

  2. 将 Eden 中剩余的对象移到 Survivor0 区,对象的年龄加 1(初始年龄为0)

  3. 如果再次触发垃圾回收,此时上次存活的对象,放在 Survivor0 区的,如果没有回收,就会放到 Survivor1 区,年龄+1

  4. 再次经历垃圾回收,又会将幸存者重新放回 Survivor0 区,依次类推,长期存活的对象会进入老年代(详见本节堆中对象分配策略)

注:如果幸存者区满了,对象直接放入老年代,如果此时打开了自适应开关,GC结束后会调整新生代的大小

  • minor GCeden区满时触发,minor GC执行以后,部分存活对象会进入老年代,导致老年代占用率升高
  • Survivor区域是被动GC,不会主动GC
  • 因为Java对象大多都生命期短,所以Minor GC 非常频繁,一般回收速度也比较快,这一定义既清晰又利于理解
  • Minor GC 会引发STW(Stop the World),暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行

老年代GC触发机制

这个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执行的情况有以下五种

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  2. 老年代空间不足
  3. 元空间不足
  4. 通过Minor GC后进入老年代的平均大小,大于老年代的可用内存
  5. 由Eden区,Survivor S0(from)区向S1(to)区复制时,对象大小由于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

堆区常见错误

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

元空间

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

运行时常量池

  • 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
  • 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误

JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

结论:jdk1.8版本的字符串常量池存放的是字符串对象和字符串常量池,元空间的常量池寻访的是引用

直接内存

直接内存并不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常

  • JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据
  • 本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制
  • 直接内存申请空间耗费更高的性能
  • 直接内存读取 IO 的性能要优于普通的堆内存。
  • 直接内存作用链: 本地 IO -> 直接内存 -> 本地 IO
  • 堆内存作用链:本地 IO -> 直接内存 -> 非直接内存 -> 直接内存 -> 本地 IO
举报

相关推荐

0 条评论