JVM运行时数据区

26 阅读17分钟

运行时数据区内部结构图

image.png

线程间共享:堆、堆外内存(方法区或永久代或元空间、代码缓存)
每个线程独有:程序计数器、虚拟机、本地方法栈

PC寄存器(程序计数器)

作用:PC寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令

PC寄存器不存在垃圾回收和OOM(内存溢出)问题

虚拟机栈

  • 栈是运行时的单位,而堆是存储的单位
  • 每个线程在创建时都会创建一个虚拟机栈,栈内部保存一个个的栈帧,对应着一次次的方法调用
  • 每个栈帧执行完成后就会出栈(弹栈),所以对栈来说不存在垃圾回收问题,但是栈空间的大小有限,存在OOM(内存溢出)问题,
  • 生命周期:栈的生命周期和线程一致
  • 作用:主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回

栈帧

每个栈帧中存储着:

  • 局部变量表
    • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量
    • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
    • 局部变量表所需的容量大小是在编译期确定下来的,在方法运行期间是不会改变局部变量表的大小的
    • 最基本的存储单元是Slot(变量槽)
    • Slot可重复利用
    • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
  • 操作数栈(或表达式栈)
    • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
    • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
    • 操作数栈的底层是数组结构
    • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的code属性中,为max_stack的值
    • 操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈操作来完成一次数据访问
  • 动态链接(或指向运行时常量池的方法引用)
  • 方法返回地址(或方法正常退出或者异常退出的定义)
  • 一些附加信息

核心概述

  • 一个JVM实例只存在一个堆内存,堆以为是Java内存管理的核心区域
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间
    • 堆内存的大小是可以调节的
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续
  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区
  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上
    • “几乎”所有的对象实例都在这里分配内存
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
  • 堆,是GC执行垃圾回收的重点区域

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

  • Java7及之前堆内存逻辑上分为三部分:新生代-老年代-永久代
  • Java8及之后堆内存逻辑上分为三部分;新生代-老年代-元空间

查看堆内存空间设置的参数

  • 方式一:jps ---> jstat -gc 进程id
  • 方式二:添加虚拟机参数 -XX:+printGCDetails

默认堆空间的大小

  • 初始内存大小:物理电脑内存大小 / 64 (物理电脑内存大小的64分之一)
  • 最大内存大小:物理电脑内存大小 / 4 (物理电脑内存大小的4分之一)

开发中建议将初始堆内存和最大堆内存设置成相同的值,这样可以避免因为GC而产生的内存自动扩容和自动缩减,减小不必要的系统压力

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

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

新生代与老年代在堆结构占比(该参数开发中一般不会调)

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

新生代中,Eden空间和另外两个Survivor空间缺省所占比例是8:1:1(实际上并不是),可以通过 -XX:SurvivorRatio 来调整空间比例,比如-XX:SurvivorRatio=8 image.png

  • 几乎所有的Java对象都是在Eden区被new出来的,但是如果一个对象所占空间大于Eden空间,则对象可能会直接创建在老年代
  • 绝大部分的Java对象的销毁都在新生代进行了
  • 可以使用 -Xmn 设置新生代最大内存大小(这个参数一般使用默认值就可以了)
  • 如果同时指定 -XX:SurvivorRatio 和 -Xmn参数,JVM以-Xmn参数为准

堆空间-对象分配内存空间的过程

概述:

  • new的对象先放Eden区,此区有大小限制
  • 当Eden区空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区
  • 然后将Eden区中的剩余对象移动到幸存者0区(Survivor0区)
  • 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有被回收,就会放到幸存者1区
  • 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区
    • 幸存者区不会主动触发GC,当Eden区触发GC时,垃圾收集器会连带幸存者区一起收集
  • 啥时候能去老年代呢?可以设置次数,默认是15次(特殊情况下,就算未达到15次,也有可能进入老年代)
    • 可以设置参数:-XX:MaxTenuringThreshold=进行设置
  • 在老年代,相对悠闲,当老年代内存不足时,再次出发GC:Major GC,进行老年代的内存清理
  • 若老年代执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常

总结:

  • 针对幸存者s0,s1区:复制之后又交换,谁空谁是to区
  • 关于垃圾回收:频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集

对象分配的特殊情况

  • 当有一个超大对象创建时,会先往新生代Eden区创建,如果Eden区空间不足,会触发Minor GC,如果GC完成后Eden区还是放不下这个超大对象,那会直接在老年代创建,如果老年代也放不下,即报OOM异常
  • 如果一个对象大小超过整个Eden区的大小,那将会直接分配到老年代,不会触发GC

image.png

Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是新生代

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

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

最简单的分代式GC策略的触发条件

  • 新生代GC(Minor GC)的触发机制:
    • 当新生代空间不足时,就会触发Minor GC,这里的新生代满是指Eden区满了,Survivor区满并不会触发GC
    • 因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快
    • Minor GC会引发STW(stop-the-world),暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 老年代GC(Major GC)触发机制:
    • 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了
    • 出现了Major GC,经常会伴随至少一次的Minor 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区、Survivor0区向Survivor1区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
    • 说明:Full GC是开发或调优中尽量要避免的,这样STW的时间会短一些

为什么需要把Java堆分代?不分代就不能正常工作了吗?

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一个地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来

什么是TLAB(Thread Local Allocation Buffer-线程本地分配缓存区)

  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  • 多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • TLAB空间默认开启,默认情况下TLAB空间仅占有整个Eden空间的1%,一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

为什么要有TLAB

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

堆是分配对象存储的唯一选择吗?

  • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了。这也是最常见的堆外存储技术
  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上
  • 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
    • 当一个对象在方法中被定义后,他被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
  • JDK7之后,逃逸分析默认开启状态

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

  1. 栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以考虑不同步
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

方法区/永久代/元空间

  • 《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的时间可能不会选择去进行垃圾收集或者进行压缩。”,但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
  • 所以,方法区看作是一块独立于Java堆的内存空间
  • 在JDK7及之前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代
  • 本质上,方法区和永久代并不等价。仅是对HotSpot虚拟机而言,方法区等价于永久代
  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  • 永久代、元空间二者并不只是名字变了,而是使用本地内存
  • 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

方法区存储什么?

《深入理解Java虚拟机》书中对方法区存储内容描述如下:

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

常量池

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

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

运行时常量池

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

HotSpot中方法区的变化

jdk1.6及之前:有永久代,静态变量存放在永久代上

jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中

jdk1.8及之后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量存在堆空间

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

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

在某些场景下,如果动态加载类过多,容易产生永久代区域的OOM。而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制

2.对永久代进行调优是很困难的

字符串常量池为什么要调整,放到堆中?

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

方法区的垃圾回收

《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集,HotSpot虚拟机实现了方法区垃圾收集

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的