JVM运行时数据区

JVM运行时数据区

运行时数据区

Java虚拟机栈

每个线程在创建的过程中都会创建一个虚拟机栈,其内部保存着一个一个的栈帧(Stack Frame),每个栈帧对应着一个方法,线程私有的,生命周期与线程一致。

栈是一种快速有效的分配存储地址,访问速度仅次于程序计数器。只有入栈和出栈操作。

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

  • 局部变量 VS 成员变量(属性)
  • 基本类型变量 VS 引用类型变量(类/数组/接口)

栈不存在GC,存在StackOverFlow,当系统内存满了会出现OOM。

-Xss 设置栈的大小

Java方法有两种返回函数的形式,一种是正常的函数返回,使用return指令;另外一种是抛出异常,不管哪种方式都会导致栈帧被弹出。

LV Local Variables 局部变量表

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

  • 局部变量表存储结构是一个存储数字的数组,所以大小在编译期就确定下来了

  • 局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着方法栈帧的销毁而销毁。

  • 存储在栈上,不存在线程安全的问题

  • Slot(槽)是局部变量表最基本的存储单元,32位占一个槽(int byte 引用类型等),64位占两个槽(long double)

  • 非静态方法的局部变量表的第0位存储的是this

OS Operand Stack 操作数栈

操作数栈指的是在方法执行过程中,根据字节码指令向栈中写入数据或提取数据,即入栈Push/出栈Pop

  1. 主要存放计算过程中的中间结果,作为计算过程中的临时存储空间。比如push操作就是将数据压入到操作数栈,然后通过store操作将操作数栈中的数据存储到局部变量表中,load操作就是把局部变量表的值加载到操作数栈中用于计算
  2. 使用数组结构实现,但是只能通过入栈出栈的方式来访问数据,不能直接通过索引访问数据
  3. 操作数栈栈的深度在编译期就确定好了
  4. 如果被调用的方法存在返回值,其返回值也会压入到当前栈帧的操作数栈中,并更新PC寄存器下一条需要执行的字节码指令偏移量。
  5. JVM的解释引擎是基于栈的执行引擎,其中的栈指的是操作数栈。

DL Dynamic Linking 动态链接

指向运行时常量池的方法引用

RA Return Address 方法返回地址

当某方法执行结束后会出栈,出栈的同时将PC存放寄存器的值存放到方法返回地址中

一些附加信息

栈空间不存在GC,当栈空间是固定值时,栈内存用完后会出现内存溢出,会报异常StackOverFlow;当栈的空间是可扩展的情况,所有内存被用光后会出现OutofMemery;

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

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

如果是在执行native方法,则是未指定值(undefined)。

PC寄存器没有GC和OOM。

为什么要有PC寄存器?

因为CPU在不停的切换线程,每次切换线程需要知道即将执行哪一条指令,PC寄存器的作用就是记录将要执行指令的地址。需要准确的记录每一个线程将要执行的下一条指令的地址。

PC寄存器为什么要线程私有?

如果多个线程公用同一个PC寄存器的话,会导致程序执行指令错乱。比如线程1当前执行到第5条指令,线程2执行到第7条指令,此时PC寄存器存储的指令偏移量是7,线程3执行完第n条指令切换到线程1,此时它执行的并不是第5条指令。

一个JVM实例对应一个堆内存,一旦JVM启动,Java堆区就会被创建,同时其空间大小也就确定了。

堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

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

几乎所有的对象实例和数组都分配在堆内存。

堆的划分:

JDK7以及之前版本:新生区(Young)+养老区(Old)+永久代(Perm)

JDK8以及之后版本:新生区+养老区+元空间(MetaSpace)

配置新生代与老年代占比:

默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3,默认情况下新生代与老年代比例是1:2;

默认情况下Eden区与Survivor区的占比是8:1:1,但是jvm存在自适应机制,默认情况有可能不是8:1:1,只有显示指定占比才能确保Eden区与Survivor区的占比是8:1:1(参数设置为:-XX:SurvivorRatio=8)

几乎所有的Java对象都是在Eden区被new出来的,如果经过垃圾回收后,新生代的空间还是无法存储大对象就会直接存到老年代。

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

-XX:-UseAdaptiveSizePolicy:关闭自适应的内存分配策略

堆空间大小设置:

-X是JVM的运行参数

-Xms 堆空间初始大小,默认值是物理内存的1/64

-Xmx 堆空间最大内存,默认值是物理内存的1/4,超出后报OOM

建议将-Xms与-Xmx设置为一样值,避免jvm频繁扩容和堆空间释放,造成不必要的性能开销

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

GC按照回收区域分为两类:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:
    • 新生代收集(Young GC/Minor GC):只是新生代的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 目前,只有CMS GC会单独收集老年代的行为
      • 注意:很多时候Major GC会和Full GC混合使用,需要具体分辨是老年代还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代
      • 目前,只有G1 GC会有这种行为,因为G1里面是以Region划分堆空间,新生代和老年代是混合在一起的
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾

何时触发Young GC/Minor GC?

  • 当Eden区满了触发Young GC,将Eden区与Survivor区的无效对象回收,Survivor区满了并不会触发Young GC,Survivor区的回收是被动的
  • Minor GC会引发STW(Stop the World),暂停其他用户的线程,等待垃圾回收接受,用户线程才会恢复运行

何时触发Major GC?

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

何时触发Full GC?

  1. 调用System.gc()时,系统建议执行Full GC,但时不必然执行
  2. 老年代空间不足,Major GC与Full GC混合使用
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区/S0区向s1区复制时,对象大小大于s1的可用内存,则把对象转存到老年代,且老年代的可用内存小于该对象大小

此时会将无用的对象销毁,同时将幸存的对象放到Survivor区(空的区),此时,Eden区是空的了,于此同时,将非空的Survivor区的垃圾对象回收,将存活的对象的引用计数器加1并将对象移动到另一个Survivor区,此时之前非空的Survivor区被清空,谁空谁是To区。

对象何时晋升到老年代?

  • 当Survivor区的对象引用次数达到设置的阈值时会将达到阈值的对象晋升到老年区,阈值设置的参数为-XX:MaxTenuringThreshold=默认值是15
  • 经过多次Young GC后,如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间(S1或者S0)的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
  • 当survivor区无法存放从新生代过来的对象时,也就是survivor区满了时,直接将这个对象放到老年代
  • 新生代无法存储的大对象

内存分配策略:

  • 正常情况
    • 如果对象在Eden出生并经历过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1.对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁,其实每个JVM/每个GC都有所不同)时,就会被晋升到老年代中。
    • 对晋升老年代的年龄阈值,可以通过选项-XX:MaxTenuringThreshold来设置
  • 针对不同年龄段的对象分配原则
    • 优先分配到Eden区
    • 大对象直接分配到老年代
      • 尽量避免出现过多的大对象
    • 长期存活的对象分配到老年代
    • 动态对象年龄判断
      • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间(S1或者S0)的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
    • 空间分配担保
      • HandlePromotionFailure在JDK7之后已经失效,可以认为这个值就是true
      • *在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的空间*
        • 如果大于,则此次Minor GC是安全的
        • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败
          • 如果-XX:HandlePromotionFailure=true,那么会****继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小****。
            • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的
            • 如果小于,则改为进行一次Full GC
      • -XX:HandlePromotionFailure

(Thread Local Allocation Buffer,TLAB)

  • JVM在Eden区为每个线程分配了一个私有缓存区域,能保证线程安全
  • 通过-XX:UseTLAB开启TLAB空间
  • TLAB仅占Eden区的1%,也可以通过-XX:TLABWasteTargetPercent调整TLAB的大小
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试通过加锁的机制确保操作的原子性,从而直接在Eden空间分配内存

对象何时被放到元空间/永久代?

垃圾回收算法:

复制算法:解决碎片化的问题

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

如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可以被优化到栈上分配。

逃逸分析

JDK7以后默认开启了逃逸分析。

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

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

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

  • 栈上分配,栈上分配无需进行GC
  • 同步省略,通过逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有发布到其他线程。如果没有,JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
  • ****分离对象或标量替换*,开启标量替换参数-XX:+EliminateAllocations,默认开启,*允许将对象打散分配到栈上*。标量(Scalar)是指一个无法再分解成更小的数据的数据,例如Java中的基本数据类型的数据。与之相对的是聚合量(Aggregate),Java中的对象就是聚合量。在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,即未发生逃逸,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换*。

开发中能使用局部变量的,就不要使用在方法外定义。

逃逸分析

为什么需要把Java堆分代?

分代的唯一理由就是优化GC性能。有了分代会很明确的回收指定区域的无效对象,而不是遍历所有对象。

关于垃圾回收:频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集。80%的对象都在新生代被收集了。

GC过程

JVM参数设置

JVM参数

方法区(JDK8元空间/JDK7永久代)

  • 方法区(非堆),尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会去选择进行垃圾回收或者进行压缩。方法区看作是一块独立于Java堆的内存空间。

  • 线程共享

  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多类,导致方法区溢出,虚拟机同样会报OOM异常

    • 加载大量的第三方jar包;
    • Tomcat部署的工程过多;
    • 大量动态的生成反射类
  • 元空间使用的是本地内存(不容易出现OOM),永久代使用的jvm的内存

  • 方法区存储的数据可以理解为字节码文件中信息,即几乎字节码中的信息都会被加载到方法区

方法区垃圾回收(JVM规范不强制要求具体的JVM虚拟机去回收方法区):常量池中废弃的常量和不再使用的类型

  • 只要常量池中的常量没有任何地方引用,就可以被回收
  • 回收不再使用的类型,必须满足下面所有条件,条件非常苛刻
    • 该类以及任何派生的子类的所有实例已经被回收
    • 加载该类的类加载器已经被回收
    • 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

方法区设置大小

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

  • Jdk7及以前:

    • 通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M
    • -XX:MaxPermSize来设置永久代的最大可分配空间。32位是64M,64位默认是82M
    • 当JVM加载的类信息容量超过了这个值,会报OOM:PermGen space
  • jdk8及以后

    • 默认值依赖平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。
    • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出OOM
    • -XX:MetaspaceSize设置元空间初始值,一旦触及就会触发Full GC,并卸载没用的类(即这些类对应的类加载器不再存活),阈值会被重置。新的阈值大小取决于释放了多少空间,如果释放的多,就降低阈值,反之则提高阈值。为了减少GC的次数,建议将-XX:MetaspaceSize初始值设置的大一点

方法区内部结构

  • 存储类型信息(类/接口/枚举/注解。。。)/常量/静态变量/即时编译器编译后的代码缓存。

    • 类型信息

      • 类型的完整名称(全名=包名.类名)
      • 类型直接父类的完整名称(对于interface或者Object,都没有父类)
      • 类型的修饰符(public abstract final)
      • 类型的直接接口的一个有序列表
    • 域(Filed)信息

      • JVM必须在方法区中保存类型的所有域信息以及域的声明顺序
      • 包括域名称 域类型 域修饰符(public private protected static final volatile transient的某个子集)
    • 方法(Method)信息

      • JVM必须保存所有方法的以下信息
      • 方法名称
      • 方法的返回类型(或void)
      • 方法参数的数量和类型(按顺序)
      • 方法的修饰符(public private protected static final synchronized native abstract的一个子集)
      • 方法的字节码(bytecodes)/操作数栈/局部变量表及大小(abstract和native方法除外)
      • 异常表(abstract和native方法除外)
      • 每个异常处理的开始位置 结束位置 代码处理在程序计数器中的偏移地址 被捕获的异常类的常量池索引
  • 方法区还记录了类是由哪个类加载器加载的

  • non-final的类变量

    • 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
    • 类变量被类的所有实例共享,即使没有类的实例也可以访问它。在准备阶段进行初始化零值,在初始化阶段进行显示赋值。
  • 全局常量(final static修饰的),每个全局常量在编译期就分配了,即在编译期就已经初始化并赋值了。

  • 运行时常量池

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

    • 一个有效的字节码文件中除了包含类的版本信息,字段,方法以及接口等描述信息外,还包含一项信息就是常量池表,包括各种字面量和对类型,域和方法的符号引用。运行时常量池,可以看作一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量(及具体的字符串或者数值都叫做字面量)等类型。

    • 为什么需要常量池?

      • 一个Java源文件中的类,接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存储到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到。
    • 运行时常量池是方法区的一部分;常量池是字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

    • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。

    • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组一样,通过索引来访问。

    • 运行时常量池,相对于Class文件中的常量池的另一个重要特征是:具备动态性。比如,String.intern()

方法栈调用

方法区的演进过程

方法区演进JDK6

方法区演进2

方法区演进8

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

  • 因为永久代设置空间大小很难确定,如果动态加载了太多的类容易出现OOM,****对永久代调优很困难,而元空间不在虚拟机中,使用的是本地内存

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

  • 永久代空间比较小
  • Jdk7中将StringTable放到堆空间,因为永久代的回收频率与回收效率很低,只有在老年代和永久代空间不足时才触发Full GC,而开发中会有大量的字符串被创建,回收效率低,放到堆中能及时回收内存。

静态变量从永久代移动到了堆空间;

成员变量随着类实例化被存储到堆空间中。

局部变量存放在局部变量表中。

本地方法栈

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

本地方法栈也是线程私有的。

当某个线程调用一个本地方法,它就不受Java虚拟机栈的限制,它和Java虚拟机具有同样的权限。

在Hotspot JVM中,直接将Java虚拟机栈与本地方法栈合二为一。

Jvm系统线程

  1. 虚拟机线程
  2. 周期任务线程
  3. GC线程
  4. 编译线程
  5. 信号调度线程

堆栈方法区交互

如何解决OOM?

  1. 要解决OOM异常或者heap space异常,一般要通过工具对dump出来的堆快照进行分析,先分析清楚到底是出现了内存泄露(Memory Leak)还是内存溢出(Memory Overflow)
  2. 如果是内存泄露,通过工具找到对象到GC Roots的引用链,从而准确定位出相关代码
  3. 如果不存在内存泄露,就去检查虚拟机堆参数(-Xmx于-Xms),与机器物理内存对比看是否可以调大,从代码上检查是否存在某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

对象实例内存布局

对象实例化的方式

  1. New或者调用静态方法或者xxxBuilder/Factor,本质还是new调用构造器
  2. Class的newInstance(),反射方式,已过时,只能调用空参构造器,权限必须是public
  3. Constructor的new,反射的方式,可以跳用空参或者带参的构造器,权限没有要求
  4. Object的clone方法,需要实现cloneable接口
  5. 使用序列化,获取二进制流
  6. 使用第三方库Objenesis

对象创建的步骤

  1. 加载类元信息
  2. 对象分配内存
    • 对象占用内存的空间时确定的,除了long和 double占8个字节,基础数据类型和引用类型都占4个字节。
    • 内存规整时,使用指针碰撞方式分配内存(即挨着往堆内存里面放对象实例,同时将指针移动到已用内存与空闲内存中间的位置),比如Serial和ParNew的垃圾收集器时标记整理算法,使用指针碰撞方式。
    • 内存不规整时,即内存碎片化,虚拟机需要维护一个空闲列表,将对象分配到一个足够大的内存空间,并更新空闲列表。比如CMS垃圾收集器使用的就是标记清除算法。
  3. 处理并发安全问题
    • 采用CAS失败重试,区域加锁保证更新的原子性
    • 每个线程先分配一块TLAB空间
  4. 初始化分配到的空间,默认初始化
    • 赋默认初始化值,保证对象实例字段在不赋值的情况下可以直接使用
  5. 设置对象的对象头
  6. 执行init方法进行初始化,调用类的构造器,显示初始化

对象的内存布局

  1. 对象头(Header),如果是数组,还需要记录数组的长度
    1. 运行时元数据(Mark Word)
      • 哈希值(HashCode)
      • GC分代年龄
      • 锁状态标志
      • 线程持有的锁
      • 偏向线程ID
      • 偏向时间戳
    2. 类型指针,指向类元数据InstanceKlass,确定该对象所属的类型
  2. 实例数据(Instance Data)
  • 它是对象真正存储的有效信息,包括程序中定义的各种字段(包括父类)
  • 规则
    • 相同宽度的对象总是被分配在一起
    • 父类中定义的变量总是出现在子类前
    • 如果CompactFileds参数是true(默认是true),子类的窄变量会插到父类的变量的空隙,节省空间
  1. 对齐填充(Padding),仅仅起到占位符的作用

对象访问定位

  • 通过局部变量表中的对象引用访问到堆区的对象实例。
  • 对象访问方式
    1. 句柄访问句柄池需要单独开辟空间,当对象实例发生移动时,栈上的引用地址无需修改,只需要修改句柄值的指针即可
    2. 直接指针(HotSpot使用此方式),节省空间,访问快

直接内存

  • 直接内存不是虚拟机的一部分
  • 直接内存是在Java堆外的,直接向系统内存申请的内存空间
  • 通过在堆中的DirectByteBuffer操作Native内存
  • 访问直接内存的性能要高于访问堆内存
    • 读写频繁的场景需要考虑使用直接内存
    • Java NIO库允许Java程序使用直接内存,用于数据缓冲区
  • 直接内存也会出现OOM,OutOfMemory:Direct Buffer Memory
    • 原因:由于直接内存在堆外,它的大小不会直接受限于-Xmx指定的大小,但系统内存有限,Java堆和直接内存的总和依然受限于操作系统的最大内存。
  • 设置直接内存大小:-MaxDirectMemorySize,如果不指定,默认与堆的最大值一致

句柄池

Screen Shot 2021-01-20 at 5.51.58 PM

Screen Shot 2021-01-20 at 8.50.29 PM

面试题

百度

三面:说一下JVM内存模型吧,有哪些区?分别干什么的?

蚂蚁金服:

Java8的内存分代改进

JVM内存分哪几个区,每个区的作用时什么?

一面:JVM内存分布/内存结构?堆和栈的区别?堆的结构?为什么两个survivor区?

二面:Eden和Survivor的比例分配

二面:Java对象头里有什么

小米:

JVM内存分区,为什么要有新生代和老年代

字节跳动:

二面:Java内存分区

二面:讲讲JVM运行时数据区

什么时候对象会进入老年代?

京东:

JVM内存结构,Eden和survivor比例

JVM内存为什么要分成新生代,老年代,持久代,新生代中为什么要分为Eden和Survivor。

天猫:

一面:JVM内存模型以及分区,需要详细到每个区放什么。

一面:JVM的内存模型,Java8做了什么修改

拼多多:

JVM内存分哪几个区,每个区的作用时什么

美团:

Java内存分配

JVM的永久代会发生垃圾回收吗?

一面:JVM内存分区,为什么要有新生代和老年代?

对象在JVM中时怎么存储的?

对象头信息里面有哪些东西?


JVM运行时数据区
http://example.com/2023/03/10/Java/JVM/JVM运行时数据区/
作者
UncleBryan
发布于
2023年3月10日
许可协议