JVM 运行时数据区

一、 内存模型

  • 程序计数器:记录下一条字节码执行指令,实现分支循环跳转,异常处理,线程恢复等功能
  • 虚拟机栈:存储局部变量表、操作数栈、动态链接、方法返回地址等信息。Java方法就调用
  • 本地方法栈:本地方法调用
  • 堆:所有线程共享,几乎所有对象示例都在堆中分配
  • 方法区:用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据

二、栈和堆区别

  1. 物理地址

    • 堆:对象分配物理地址不连续,性能相对栈弱些

      GC考虑分配不连虚,产生算法提升性能:标记—消除、复制、标记—压缩、分代

    • 栈:先进后出,物理地址连续,性能相对堆好些

  2. 内存分配

    • 堆:在运行时分配,大小不固定
    • 栈:局部变量,操作数,动态链接,方法返回地址等信息,程序方法的执行
  3. 程序可见性

    • 堆:所有线程共享,可见
    • 栈:线程私有,只对线程可见,生命周期和线程相同
  • 浅拷贝:增加一个指针指向已有的内存地址
  • 深拷贝:增加一个指针指向新开辟的一块内存空间

原内存发生变化,浅拷贝也随之变化;深拷贝则不会随之改变

静态变量 —> 方法区

静态对象 —> 堆

三、程序计数器 PC寄存器

特点:

  • 运行时数据区中唯一不会出现OOM的区域,没有垃圾回收
  • 每个线程有一个私有程序计数器,线程之间互不影响
  • 程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址
  • 如果正在执行的本地方法,这个计数器值应为空
image-20210311171622548

PC寄存器存储字节码指令地址的作用

  • 因为线程是一个个的顺序执行流,CPU需要不停的切换各个线程,这时候切换回来以后就知道从哪里开始执行
  • JVM 的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样子的字节码指令
  • 记录下一条字节码执行的指令,实现分支循环跳转,异常处理,线程恢复等功能

PC寄存器被设定为私有原因:

  1. CPU 为每个线程分配时间片,多线程在一个特定的时间段只会执行某一个线程的方法,CPU会不停的进行任务切换,线程需要中断、恢复
  2. CPU,多线程,分配时间片,只执行一个线程,CPU任务切换,线程中断恢复
  3. 各个线程,PC寄存器记录。当前字节码指令地址,各个线程之间可进行独立计算,防止相互干扰

四、虚拟机栈

1. 基本概念

  • Java 虚拟机栈,原先也叫 Java栈,每个线程创建都会创建一个虚拟机栈,内部保存一个个栈帧,对应着一次次的 Java方法调用
  • 生命周期和线程的一致
  • 主管 Java程序的运行,保存方法的局部变量(8种基本数据类型),对象的引用地址,部分结果,并参与方法的调用和返回
  1. 快使有效的存储方式,访问速度仅次于程序计数器

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

    • 每个方法执行,伴随着进栈
    • 执行结束的出栈
  3. 栈不存在垃圾回收,当时存在OOM、栈溢出

    Java栈大小是动态或者固定不变的

    • 动态扩展、无法申请到足够的内存OOM
    • 如果是固定,线程请求的栈容量超过固定值,则StackOverFlowError
image-20210311171636514

2. 栈的存储单位

  • 每个线程都有自己的栈,栈中的数据以栈帧格式存储
  • 线程上正在执行的每个方法都各自对应一个栈帧
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各个数据信息
  • 先进后出,后进先出
  • 一条活动的线程中,一个时间点上,只会有一个活动的栈帧,只有当前正在执行的方法的栈顶栈帧是有效的,这个称为当前栈帧,对应的方法是当前方法,对应类是当前类
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  • 如果方法中调用了其他方法,对应的新的栈帧会被创建出来,放在顶端,成为新的当前帧

3. 栈运行原理

  1. 不同线程中包含的栈帧不允许存在相互引用
  2. 当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为新的栈帧

Java方法有两种返回方式

  • 正常函数返回,使用 return 指令
  • 抛出异常,导致栈帧被弹出

4. 栈的内部结构

image-20210311171648585

局部变量表

  • 定义为一个数字数组,主要用于存储方法参数,定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型

  • 局部变量表建立在线程的栈上,是线程私有的,因此不存在数据安全问题

  • 局部变量表容量个数,大小是在编译器确定下来的

  • 存放编译器可知的 8种基本数据类型、引用类型, return address 类型

  • 最基本的存储单元是 slot

    32位占一个,64位占两个slot

  • 表中变量只在当前方法调用中有效,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程

  • 方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

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

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

操作数栈

  • 计算过程中变量临时存储空间,保证计算过程的中间结果

  • 当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

  • 每个操作数栈都会有一个明确的栈深度,用于存储数值,最大深度在编译器就定义好

  • 栈中,32bit类型占用一个栈单位深度,64bit类型占用两个栈单位深度

  • 操作数栈并非采用访问索引方式进行数据访问,而只能通过标准的入栈、出栈操作完成一次数据访问

  • 如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新程序计数器中下一条需要执行的字节码指令

  • Java 虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈

    i++:先将 i 的值加载到操作数栈,再将 i 的值加 1

    ++i:先将 i 的值加 1,再将 i 的值加载到操作数栈

  • 栈顶缓存技术

    • 由于操作数是存储在内尺寸中,频繁的进行内存读写影响执行速度,将栈顶元素全部缓存到物理CPU的寄存器中,依次降低对内存的读写次数,提升执行引擎的执行效率,指令更少,执行速度更快
    • 操作数栈顶元素缓存于寄存器

动态链接

指向运行时常量池的方法引用
  • 每个栈帧内部都包含一个指向运行时常量池中,改帧所属方法引用
  • 包含引用目的:支持当前方法的代码实现动态链接,如invokrdynamic指令
  • 在 java 源文件被编译成字节码文件中时,所有的变量、方法引用都作为符合引用,保存在class文件的常量池中
  • 描述一个方法调用了其他方法:用常量池中指向方法的符合引用来表示。将符合引用转换为调用方法的直接引用
常量池、运行时常量池
  • 常量池在字节码文件中,运行时常量池在运行时的方法区中
  • 存储多一份,供多个方法调用、需记录索引、节省空间
  • 提供符合、常量便于指令识别

方法返回地址

  • 存放调用该方法的pc寄存器的值
  • 方法的结束
    • 正常执行完成
    • 出现未处理异常,非正常退出
  • 无论哪种方式退出,方法退出后,都会返回该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用改方法的指令的下一条指令地址
  • 异常退出的,返回地址是通过异常表来确定,栈帧一般不会保存这部分信息
  • 执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口
  • 本质上,方法的退出就是当前栈帧出栈的过程,此时需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去
  • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何返回值

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

5. 本地方法栈

  • Java 虚拟机栈:Java方法调用;本地方法栈:本地方法调用
  • 本地方法栈,线程私有。
  • 允许被实现成固定或者是可动态扩展的内存大小
    • 内存溢出情况和 Java虚拟机栈相同
  • 使用 C 语言实现
  • 具体做法是 Native Method Stack 中登记 native 方法,在Execution Engine执行时加载到本地方法库
  • 当某个线程调用一个本地方法时,就会进入一个全新,不受虚拟机限制的世界,它和虚拟机拥有同样的权限
  • 并不是所有的 JVM 都支持本地方法,因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言,具体实现方式,数据结构等
  • Hotspot JVM 中,直接将本地方法栈和虚拟机合二为一

五、堆

  • 一个 JVM实例只存在一个堆内存

  • JVM 启动就创建,空间大小确定,可调节,参数 -Xms、-Xmx

    • Java堆区在 JVM 启动的时候即被创建,其空间大小也就确认了。堆内存的大小是可调节的
  • 堆内存物理可以不联系,逻辑要连虚

  • 所有线程共享 Java 堆,单个线程可以有 TLAB

  • 所有对象实例、数组应该在运行时分配在堆上

  • 几乎所有的对象实例都在堆分配另外内存。有些对象可能栈上分配:逃逸分析,标量替换

  • 栈帧:保存引用,引用指向对象或数组在堆中的位置

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

    • eden区满,出发GC,进行垃圾回收
    • 如果堆中对象马上被回收,用户线程会受影响
  • 堆是GC执行垃圾回收的重点区域

    image-20210311171701695
  • 堆空间细分为

    image-20210311171713126 image-20210312154302127

    -XX:+PrintGCDetails 可开启打印查看方法区实现

    设置堆内存的大小与 OOM

    • -Xms9m:堆空间的起始内存。X执行 memory start

    • -Xmx9m:堆空间的最大内存。X执行 memory max 超过最大内存将抛出 OOM

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

      在 Java垃圾回收机制清理完堆区后不需要重新分配计算堆区的大小,从而提高性能

    • 默认情况下

      • 初始内存大小:物理电脑内存大小的 1/64
      • 最大内存大小:物理电脑内存大小的 1/4
    • jps命令:查看当前程序运行的进程

    • jstat 查看 JVM 在 gc 时的统计信息

      jstat -gc 进程号

    新生代与老年代

    image-20210312154336682
    • 为什么要有新生代和老年代——优化 gc 性能

      • 根据不同区的特点使用不一样的垃圾回收算法
    • 新生代和老年代空间默认比例:1:2

      image-20210312154400781
      • -XX:NewPatio = 2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3
    • jinfo -flag NewRatio 进程号,查看参数设定值

    • 在HotSpot中,Eden空间和另外两个 Survivor 空间缺省所占的比如时:8:1:1

      • -XX:SurvivorRatio调整这个空间比例
    • 为什么新生代划分 Eden和survivor

      • 如果没有 survivor区,Eden区进行溢出 MinorGC:存活对象 -> 老年代–满 -> MajorGC
      • MajorGC 消耗时间更长,影响程序执行和响应速度
      • survivor存在意义:增加进入老年代的筛选条件,减少送到老年代的对象,减少FullGC的次数
    • 为什么设置两个 survivor区

      • 只有一个survivor区,在第一次Eden区满进行MinorGC,存活对象放到survivor区;第二次Eden区满MinorGC -> survivor区,会产生不连续的内存,无法存放更多的对象

        image-20210312154527005

      • 设置三个四个 survivor 区,则每个被分配的 survivor 空间相对较小,很快被填满。

      • 设置两个 survivor 区,在MinorGC时,可以将Eden区和非空的survivor区中存活的对象以连续存储的方式存入空的survivor区。减少碎片化

        image-20210312154552247

      • 复制算法也是减少碎片化的过程

        image-20210312154609558

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

        • Eden 放不了的大对象,直接进入老年区了
      • IBM 研究表明:新生代 80%的对象都是朝生夕死

      • -Xmn:设置新生代最大内存大小 memory new

      • 新生区的对象默认什么周期超过 15,就会去养老区养老

    • 对象分配一般过程

      1. new 的对象先放 Eden区,放得下直接放入(此区有大小限制 参数 -Xmn 一般默认)

      2. 当创建新对象,Eden空间填满,会出发一次Minor GC/YGC,将Eden不在被其他对象引用的对象进行销毁。将Eden中未销毁的对象移到 survive0 区。survive0区每个对象都有一个年龄计数器,一次回收还存在的对象,年龄加1

      3. 如果Eden有空间,加载的新对象放到 Eden区(超大对象放不下放入老年代)

      4. 再次eden区满,触发垃圾回收,回收eden+survive0,幸存下来的放在 survivor1 区,年龄加1

      5. 再垃圾回收,又会将幸存者放回 survive0 区,依此类推

      6. 超大对象放入老年代,老年代满或放不下,触发majorGC,再放不下,OOM

      7. 可以设置存活次数,默认15次,超过15次,对象将从年轻区步入老年区

        -XX:MaxTenuringThreshold=N 进行设置

      image-20210312154643215

      新生代采用复制算法的目的:为了减少内碎片

      频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间搜集

    • 对象分配特殊过程

      image-20210312154700719
    • 触发YGC,幸存者区就会进行回收,不会主动进行回收

    • 幸存区满了咋办

      • Eden区满了才会触发YGC,幸存区满了不会
      • 如果幸存区满了,新对象可能直接晋升老年代
  • JVM 调优工具

    • Visual VM 实时监控
    • Jprofiler
    • Java Flight Recorder
    • jconsole 对内存、线程和类监控
    • jvisualvm
      • JDK 自带的全能分析工具:内存快照、线程快照、程序死锁、监控内存变化、gc变化
  • MinorGC触发

    • Eden区满触发,Survivor区满不触发,清理Eden+Survivor内存
    • Java对象大多朝生夕灭,MinorGC非常频繁
    • MinorGC会引发 STW
  • 老年代GC(MajorGC/FullGC)触发

    • 老年代空间不足,会触发MinorGC,空间还不足,触发MajorGC或FullGC。还不足OOM

    • 出现MajorGC,经常会伴随至少一次MinorGC

      非绝对,在Parallel Scavenge收集器的收集策略里就直接进行MajorGC的策略选择过程

    • MajorGC速度比MinorGC慢10倍以上,STW时间更长

  • FullGC触发机制

    1. 调用System.gc()时,系统建议执行FullGC,但是不必然执行
    2. 老年代空间不足
    3. 方法区空间不足
    4. MinorGC后进入老年代的平均大小,大于老年代的可用内存
    5. 由Eden区,Survivor 0区向Survivor 1区复制时,对象的大小大于ToSpace可用内存,则把改对象转存到老年代,且老年代的可用内存小于该对象的大小
    6. FullGC是开发或调优中尽量要避免的,这样暂停时间会短一些。
  • Minor GC 针对于新生区,Major GC 针对于老年区,Full GC 针对于整个堆空间和方法区

  • 为对象分配内存TLAB

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

    • 从内存模型而不是垃圾收集的角度,对Eden区域进行划分,JVM为每个线程分配了一个私有缓存区域,包含在Eden空间中

    • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们将这种内存分配方式成为快速分配策略

    • openjdk衍生出来的JVM都提供了TLAB的设计

      image-20210312154726045
    • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实是将TLAB作为内存分配的首选

    • 开发人员通过-XX:UseTLAB设置是否开启TLAB空间

    • 默认情况下,TLAB空间内存非常小,仅占有整个Eden空间的1%,通过-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小

    • 一旦对象在TLAB空间分配内存失败,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

  • 常用 JVM 调优参数

    • -Xms2g:初始化堆大小为2g
    • -Xmx2g:最大堆内存为2g
    • -Xmn:设置新生区内存大小
    • -Xx:NewRatio=2:设置新生代与老年代内存比例为1:2
    • -Xx:SurviveRatio=8:设置eden区与survivor区内存比例为8:1:1
    • -XX:MaxTenuringThreshold:设置分代年龄阈值
    • -XX:+UseParNewGC:指定使用 ParNew + Serial Old垃圾回收器组合
    • -XX:+UseParallelGC:指定年轻代使用Parallel scavenge+parallel Old并行收集器执行内存回收任务
    • -XX:+UseParallelOldGC:默认jdk8开启。默认开启一个,另一个也会被开启(互相激活)
    • -XX:+PrintGC:开启打印 gc 信息
    • -XX:+PrintGCDetails:打印 gc 详细信息

六、方法区

栈、堆、方法区交互关系

image-20210312154759083 image-20210312154825595

方法区定位

  • 《Java虚拟机规范》:尽管所有方法区在逻辑上属于堆一部分,但一些简单实现,可能不会进行垃圾收集或进行压缩。
  • 对于HotSpot,方法区又名:Non0Heap(非堆),目的:区分堆。
  • 方法区看作是一块独立于 Java堆的内存空间

方法和堆的异同

  • 方法区主要存放 Class,堆中主要存放实例化对象
  • 各个线程共享
  • 物理内存空间可以不连续,逻辑空间要连续
  • 可以选择固定大小或者可扩展
  • 方法区大小决定了系统可以保存多少个类,如果类定义太多,导致方法区溢出,JVM同样抛出内存溢出异常 OOM
    • java.lang.OutofMemoryError:PermGen space
    • java.lang.OutofMemoryError:Metaspace
  • 关闭 JVM 就会释放这个区域的内存

可能会 OOM:

  1. 加载大量的第三方 jar 包
  2. Tomcat 部署的工程过多 (30-50个)
  3. 大量动态的生成反射类

HotSpot中方法区的演进

  • HostSpot可看作方法区永久代等价,本质不等价,《Java虚拟机规范》对如何实现方法区,不做统一要求。
  • 在jdk7及以前:方法区 -> 永久代,jdk8开始:永久代 -> 元空间
    • 元空间永久代都是对 JVM 规范中方法区的实现
    • 元空间永久代区别:元空间不在虚拟机中设置内存,使用本地内存(堆外内存)
    • 根据 JVM 规范,如果方法区无法满足新的内存分配需求,将抛出 OOM 异常
  • 永久代更容易导致 Java程序 OOM (超过 -XX:Max:Permsize上限)

方法区的内部结构

image-20210312154915917

方法区存储什么

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

    image-20210312154930976
  • 对于每个加载的类型(类、接口、枚举、注解)JVM 必须在方法区存储以下信息

    • 这个类的修饰符(public、abstract..)
    • 这个类的完整有效名称
    • 这个类型直接父类的完整有效名称(接口则为 Object 类)
    • 这个类型直接接口的一个有序列表
    • 例如:public class MethodInnerStrucTest extends Object implements Comparable<String> Serializable
  • 域 Field 信息

    • 域修饰符
    • 域类型
    • 域名称
    • 例如:public int num = 10;
  • 域信息特殊情况

    • 类变量
    • 全局常量
  • 方法信息

    • 方法修饰符
    • 返回类型 void对应 void.class
    • 方法名称
    • 方法参数的数量和类型
    • 方法的字节码、操作数栈、局部变量表及大小(native、和abstract除外)
    • 异常表,记录每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
  • 运行时常量池和常量池

    • 方法区内部包含了运行时常量池
    • 字节码文件内部包含了常量池
      • 运行时将常量池加载到方法区,就是运行时常量池
      • 执行时,将常量池中的符号引用转换为直接引用
    • 加载类的信息在方法区,需要理解字节码文件
    • 要弄清方法区的运行时常量池,需要理解字节码文件中的常量池
    • 运行时常量池,相对class文件常量池:具备动态性
  • 常量池

    • 字节码文件包含:类的版本信息、字段、方法以及接口等描述信息
    • 还包含常量池表,包括编译生成各个字面量和对类型、域和方法的符号引用
    • 为什么要用常量池
      • 一个 java源文件中的类、接口、编译后产生字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码里
      • 可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接会用到运行时常量池
      • 编译产生字节码文件需要大量数据支持,不能存在字节码文件中,存到常量池里,字节码包含指向常量池的引用
    • 常量池存什么
      • 数量值
      • 字符串值
      • 类引用
      • 字段引用
      • 方法引用

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

  • 运行时常量池

    image-20210312155000151
    • 是方法区的一部分
    • 常量池表是 class 字节码文件一部分,用于存放编译生成各个字面量和对类型、域和方法的符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中
    • 创建:在加载类和接口到虚拟机后,就会创建对应的运行时常量池
    • 当创建类或接口的运行时常量池,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值。则 JVM 会抛出OOM异常
    • JVM 为每个已加载的类好了接口都维护一个运行常量池,池中的数据像数组项引用,通过索引访问
    • 运行时常量池,相对于 class 文件常量池:具备动态性

HotSpot虚拟机对象

对象的实例化

  • 创建对象的方式

    1. new
      • 最常见的方式
      • 变形:Xxx的静态方法
      • XxxBuilder/XxxFactory的静态方法
    2. Class 的 newInstance
      • JDK 9标记过,反射的反射之类调用空参的构造器,权限必须是public
    3. Constructor 的 newInstance
      • 反射的方式,可以调用带参的构造器,权限没有要求
    4. 使用 clone
      • 不调用任何构造器,当前类需要实现 Cloneable 接口,实现 cloen 方法
    5. 使用反序列化
      • 从文件、网络等获取一个对象的二进制流
    6. 第三方库 Objenesis
  • 创建对象的步骤

    1. 判断对像对应的类是否被常量池加载

      当虚拟机遇到一条字节码new指令时。首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否被加载解析初始化过。如果没有,在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为key值进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常

    2. (类加载通过后)为对象分配内存

      • 首先计算对象占用空间大小,接着在堆中划分一块内存给新对象

        • 如果实例成员变量是引用变量,仅分配引用变量空间,即4个字节大小
        • 如果Java堆内存中不规整,虚拟机就必须维护一个列表,记录哪些内存可用,哪些不可用。分配的时候在列表中找一个足够大的空间分配,然后更新列表。这种分配方式叫空闲列表(Free List)。–>标记-清除算法
        • 假设Java 堆中内存是规整的,所有被使用过的内存放在一边,空闲的内存放在另一边,中间放一个指针作为分界点指示器。那么内存分配就是指针指向空闲的方向,挪动一段与对象大小相等的距离。指针碰撞(Bump The Pointer)。–>标记-清除-压缩算法
      • 选择哪种由 Java堆是否规整决定,Java堆是否规整由所采用的垃圾收集器是否带有空间压缩整理的能力决定

        • 当使用Serial、ParNew等带有压缩整理过程的收集器,指针碰撞简单高效
        • 当使用CMS基于清除(Sweep)算法收集器时,只能采用空闲列表来分配内存;(CMS为了能在多数情况下分配内存更快,设计了一个Linear Allocatioin Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面仍可使用指针碰撞方式分配)
    3. 处理并发安全问题

      • 对象创建是非常频繁的行为,还需要考虑并发情况下,仅仅修改一个指针所指向的位置也是不安全的,例如正在给对象A分配内存,指针还未修改,对象B又使用原来的指针分配内存。解决问题有两种可选方案:: CAS同步处理、本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)

        • 对分配内存空间的动作进行同步处理。实际上虚拟机采取CAS配上失败重试的方式保证更新操作的原子性。
        • 把内存分配的动作按照线程划分到不同的空间中进行,每个线程在Java堆中,预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
      • 虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定。

    4. 初始化分配到空间

      内存分配完成后,虚拟机将分配到的内存空间(不包括对象头)都初始化为零值。如果使用了TLAB,这个工作可以提前到TLAB分配时进行。这步操作保证对象的实例字段在Java代码中,可以不赋初始值就直接使用,程序可以访问到字段对应数据类型所对应的零值。

    5. 设置对象的对象头

      接下来Java虚拟机还要对对象进行必要的设置,例如对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码(实际上对象的HashCode会延后真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放到对象的对象头(Object Header)

    6. 执行init方法初始化

      上面工作完成后,从虚拟机角度来说,一个新的对象已经产生了,但是从Java程序的视角来说,对象创建才刚刚开始,对象的构造方法(Class文件中init()方法)还未执行,所有字段都是默认的零值。new指令之后接着执行init方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全构造出来

对象的内存布局

  1. 对象头

    • 包括两个部分
      • 这部分数据的长度在32位和64位的虚拟机(未开启指针压缩中)分别是32bit和64bit,【Mark Word】运行时元数据
        • 哈希值
        • GC分代年龄
        • 锁状态标志
        • 线程持有的锁
        • 偏向线程ID
        • 偏向时间戳
        • 对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,根据对象状态的不同,Markword可以复用自己的空间
      • 类型指针 Klass Word
        • 即对象执行它的类型元数据的指针,Java虚拟机通过这个指针来确认该对象属于哪个类的实例
      • 说明:如果是数组,还需要记录数组的长度
  2. 实例数据

    对象的实例数据部分,是对象的真正存储的有效信息,即我们在程序代码中定义的各种类型的字段内容,无论是父类继承下来,还是子类中定义的字段都要记录下来。

    1. 这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
    2. 分配策略参数-XX:FieldsAllocationStyle
    3. HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)
    4. 从默认的分配策略中可以看出,相同宽度的字段总被分配到一起存放。
    5. 在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。
    6. 如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认也是true),那么子类中较窄的变量也允许插入父类变量的空隙之间,以节省空间。
  3. 对齐填充

    仅起占位符作用

    • 因为 HotSpot 虚拟机字段内存管理系统,要对对象的起始地址要求8字节的整数倍
    • 对象头已经精心设计为8字节的整数倍,1倍或者2倍
    • 对象实例数据部分若未对齐,需对齐填充

对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象

  • 直接指针:指向对象,代表一个对象在内存中的起始地址

    image-20210312155036755
  • 句柄:可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的证书内存地址

    image-20210312155052962
  • 使用句柄最大好处:reference中存放稳定句柄地址,在对象被移动时(垃圾收集)只改变句柄中实例数据指针,reference本身不改变。
  • 使用指针最大好处:速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,所以积少成多也是一项可观的执行成本。(HotSpot所采用)

七、直接内存

  • 不是虚拟机运行时数据区的一部分,也不是 Java虚拟机规范中定义的内存区域
  • 直接内存是在 Java堆外的,直接向系统申请的内存区间
  • 来源于 NIO (non-blocking IO),通过存在堆中的 DirectByteBuffer操作Native内存
  • 通常,访问直接内存的速度会优于 Java堆,即读写性能高
    • 因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存
    • Java 的 NIO库允许使用 Java程序直接使用内存,用于数据缓冲区
  • 也可能导致 OOM 异常
    • 直接内存在堆外,所以大小不受限与 -Xmx 指定的最大堆大小
    • 当时内存系统是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
  • 缺点
    1. 分配回收成本较高
    2. 不受 JVM 内存回收管理
  • 直接内存大小可以通过 MaxDirectMemorySize设置
  • 如果不指定,默认与堆的最大值 -Xmx 参数一致