2. JVM内存布局

269 阅读10分钟

概述

  • 程序计数器

JVM虚拟机运行时数据区分布

程序计数器(Program Counter Register)

程序计数器也称PC寄存器,是线程私有的内存空间。

程序计数器作用:

  1. 为当前线程执行的字节码提供行号指示
  2. 字节码解释执行时通过改变该计数器中存储的值来选取下一条需要执行的字节码指令
  3. 分支、循环、跳转、异常处理、线程恢复都是依赖该寄存器来完成

程序寄存器相当于每个线程私有的内存空间。每条线程之间寄存器互不影响,独立存储.

工作流程:

  1. 如果线程执行的是一个Java方法,则PC Register记录的是正在执行JVM字节码指令的地址
  2. 如果线程执行的是Native方法,则这个计数器为空(Undefined)

PC Register的内存区域是唯一一个不会导致OutOfMemoryError情况的区域

Java虚拟机栈(Java Virual Machine Stacks)

该区域是线程私有的;且生命周期和线程相同;它描述的是Java方法执行的内存模型;每个Java方法运行时都会创建一个栈帧(Stack Frame); 栈帧中存放局部变量表、操作数栈、动态链接、方法返回等信息;

存放内容: 局部变量表存放了编译期可知的各种基本数据类型(boolean/byte/char/short/int/float/log/double)、对象的引用(reference)、returnAddress指针(指向一条字节码指令的地址)

空间存储和扩张: 64位长度的long和double类型数据会占用2个局部变量空间(slot);其余数据类型占用一个,局部变量占用的空间是编译器就确定下来的,在方法运行期间不会改变

相关异常: JVM虚拟机规范中规定了2种与该区域相关的异常:

  1. 如果线程请求的栈深度大于一定范围,将抛出StackOverflowError异常
  2. 如果虚拟机栈可以动态扩展内存空间,当扩展到无法申请到足够的内存会抛出OutOfMemoryError异常

本地方法栈(Native Method Stack)

作用原理和工作机制和Java虚拟机栈一致,区别是本地方法栈操作的是JVM使用到的Native方法,也会抛出上述2种异常,JDK1.7中HotSpot将这2种方法栈合二为一了。

Java堆(Java Heap)

Java堆是JVM管理的内存中空间最大的一块;Java堆是线程共享的,在虚拟机启动时创建

存放内容:此内存区域主要是存放对象实例,几乎所有的对象实例和数组都在这里分配内存;这也不是绝对的,JIT编译器和逃逸分析技术可以让对象分配栈等其他地方

空间分配和垃圾回收:Java堆是垃圾收集器管理的主要区域

  1. 由于采用了分代回收算法,Java堆可以细分为新生代和老年代
  2. 细分为Eden Space、From Survivor Space、To Survivor Space等
  3. 从内存分配的角度来看,可划分为多个线程私有的缓冲区。

空间扩张和异常:当前主流的虚拟机对堆区都是可扩张的(通过-Xmx和-Xms控制),如果没有可扩展的空间会抛出OutOfMemoryError异常

方法区(Method Area)

方法区是各个线程共享的内存区域

存放内容:方法区存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的字节码等数据。别名:Non-Heap

永久代:对于Hotspot虚拟机而言,方法区也称为永久代(Permanent Generation),原因是将其作为垃圾回收内存的一部分。可以通过 -XX:MaxPermSize 设置永久代的大小

异常:当方法区无法满足内存分配需求,会抛出OutOfMemoryError异常

运行时常量池(Runtime Constant Pool)

存储内容:运行时常量池是方法区中的一部分,用于存放编译期生成的各种字面量和符号引用。除了保持Class文件中描述的符合引用外,还存储编译出来的直接引用

动态性:常量不一定只在编译器产生,不是只有预置的Class文件常量池的内容才能进入方法区运行时常量池,运行期也可能将新的常量放入池中

异常:当常量池无法再申请到内存时会抛出内存溢出异常

直接内存(Direct Memory)

直接内存 也称为堆外内存

Netty等NIO框架会大量调用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用来进行操作,这样能显著提高性能,避免在Java堆和Native堆复制数据.

堆外内存不受JVM堆大小限制,受本机总内存大小及处理器寻址空间的限制

附录

HotSpot虚拟机

主要是想探究一下在HotSpot虚拟机中和以下对象相关的过程:

  • 对象的创建过程
  • 对象的内存分布情况
  • 对象的访问定位过程

对象的创建

普通对象创建步骤:

  1. new关键词指令
  2. 类加载检查:检查是否在常量池中有符合引用,且该符合引用代表的类是否已被加载、解析和初始化过,没有则必须先执行类加载过程
  3. 通常,类加载通过后,对象会先创建在新生代
  4. 划分内存空间
    1. 如果Java堆中内存是绝对规整的,采用指针碰撞(Bump the Pointer)的方式分配内存: 使用过的和未使用的在两边,中间放一个指针作为分界点的指示器,偏移一个对象的内存大小
    2. 如果Java堆中内存不是规整的,空闲和已使用的内存交替存放,这时虚拟机会维护一个空闲列表(Free List),在空闲列表中申请空间
    3. Compact算法类的收集器,如Serial、ParNew等内存分配使用的是指针碰撞
    4. Mark-Sweep算法类的收集器,如CMS等内存分配使用的是空闲列表
  5. 内存分配:原子性的2种解决方案
    1. 将多线程的内存分配动作同步处理:虚拟机采用CAS配上失败重试方式保证指针更新操作的原子性
    2. 为每个线程在Java堆中预分配一小块内存: 该内存是线程独享,称为TLAB(Thread Local Allcation Buffer 本地线程分配缓冲),在TLAB的Buffer空间用完之后,才会使用CAS同步机制进行,可以通过-XX:+/UseTLAB参数指定是否使用TLAB
  6. 内存分配完成后,开始对象初始化
    1. 虚拟机默认将对象的内存空间初始化为零值
    2. 如果使用TLAB,这个操作会提前至TLAB分配内存时
    3. 目的是为了不用为每个对象的实例字段赋初始值
  7. 开始设置对象头(Object Header)的以下属性
    1. 对象属于哪个类的实例
    2. 类的元数据信息
    3. 对象对象的哈希码
    4. 对象的GC分代年龄
    5. 是否启用偏向锁
  8. 这时new指令执行完成,然后执行init指令,就是按照程序员的意愿开始其他的初始化

对象的内存布局

在HotSpot虚拟机中,对象在内存中的存储布局可分为3块区域

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

对象头分为2部分信息:

  1. Mark Word:存储对象自身的运行时数据
  • HashCode
  • GC分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程ID
  • 偏向时间戳
  1. 类型指针:对象指向它的类元数据的指针,用来判断这个对象是哪个类的实例。JVM不一定保留类型指针。
  2. 数组长度:如果对象是一个Java数组

实例数据:这部分是对象真正存储的有效信息,其存储顺序受虚拟机分配策略参数和字段在Java源码中定义顺序影响

对齐填充:不是必然存在的,起到一种占位符的作用,原因是HotSpot要求所有对象的大小必须是8字节的整数倍

对象的访问定位

对象的访问是通过栈上的reference数据类型操作堆上的具体对象。

reference类型只是JVM中定义的一个对象的引用,但是没有定义如何通过这个引用访问定位、访问堆中的具体对象的位置。

现在主流堆中的访问方式有2种:

  1. 句柄访问

  1. 这种访问方式是将堆区划分为句柄池,reference存储的是句柄地址,句柄中包含了对象实例数据指针和类型数据指针

  2. 好处:当对象被移动时只改变句柄中的实例数据指针,reference中存储的地址不变

  3. 直接指针访问

  1. 直接指针访问的reference存储的就是对象的直接地址
  2. 好处:速度更快,节省一次指针定位的开销,这也是HotSpot实现的方式。

实战内存溢出和堆栈溢出「TODO」

Java堆溢出

溢出原因:Java堆是用于存储对象实例,只要不断的创建对象,且保证GC Roots到对象之间有可达路径避免垃圾回收机制清除这些对象,当对象数量超出最大堆内存容量限制后就会发生溢出

相关参数:

  • -Xms 设置堆初始堆大小
  • -Xmx 设置堆的最大值
  • -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在发生内存溢出时Dump

测试代码:

测试结果:

排查方案:

  • 内存溢出(Memory Leak)和内存泄漏(Memory Overflow)
    • 内存溢出:内存不足
      • PermGenspace 方法区溢出
      • Java heap space 堆溢出
      • unable to create new native thread:由于操作系统线程数量限制
    • 内存泄漏:内存空间使用完了未回收
  • 如果是内存泄漏,查看 GC Roots的引用链,定位代码位置
  • 如果不是,检测参数设置、是否有生命周期长的大对象

虚拟机机栈和本地方法栈溢出

溢出原因:

  • 如果线程请求的栈深度大于虚拟机最大深度,抛出StackOverFlowError异常
  • 如果虚拟机在扩展栈无法申请到足够的内存空间,抛出OutOfMemoryError 相关参数:
  • -Xss设置栈的最大内存容量
  • Xoss 设置本地方法内存大小(HotSpot无用)

递归调用 测试代码:

https://github.com/zlserver/jvm_code/blob/master/%E7%AC%AC2%E7%AB%A0/%E6%B8%85%E5%8D%952-4.txt

运行结果:

构造很多线程 测试代码2:

/**
 * VM Args:-Xss2M (这时候不妨设大些)
 * @author zzm
 */
public class JavaVMStackOOM {
 
       private void dontStop() {
              while (true) {
              }
       }
 
       public void stackLeakByThread() {
              while (true) {
                     Thread thread = new Thread(new Runnable() {
                            @Override
                            public void run() {
                                   dontStop();
                            }
                     });
                     thread.start();
              }
       }
 
       public static void main(String[] args) throws Throwable {
              JavaVMStackOOM oom = new JavaVMStackOOM();
              oom.stackLeakByThread();
       }
}

运行结果2:

方法区和运行时常量池溢出

相关参数:

  • -XX:PermSize 方法区永久代初始大小(1.8无效)
  • -XX:MaxPerSize 方法区最大大小(1.8无效)

String.intern():该方法是一个Native方法,如果字符串常量池中已经包含一个等于该字符串的常量,则返回这个字符串对象,否则就将该字符串加入常量池。

字符串常量池:JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。

字符串常量池溢出

测试代码:

https://github.com/zlserver/jvm_code/blob/master/%E7%AC%AC2%E7%AB%A0/%E6%B8%85%E5%8D%952-6.txt

运行结果(1.8无效): 1.8:

1.6会出现: PermGen space

动态代理cglib溢出

测试代码:

https://github.com/zlserver/jvm_code/blob/master/%E7%AC%AC2%E7%AB%A0/%E6%B8%85%E5%8D%952-9.txt

分析:通过动态代理,在类加载的时候不断加载一个Object的信息

本机直接内存溢出

相关配置:

  • -XX:MaxDirectMemorySize 指定堆外内存大小

测试代码:

https://github.com/zlserver/jvm_code/blob/master/%E7%AC%AC2%E7%AB%A0/%E6%B8%85%E5%8D%952-9.txt

测试结果:(我自己在JDK1.8没有测试出来)

OutOfMemoryError