本文主要内容出自周志明老师《深入理解Java虚拟机》一书,是笔者结合自己的理解,提取重点,重新组织排版,再补充了一些内容后,总结的读书笔记。
JVM运行时数据区的划分
线程共享的数据区特征
- 虚拟机启动时创建,生命周期与进程相同
- 内存分配和回收是动态的,GC负责的区域
线程私有的数据区特征
- 线程启动时创建,生命周期与线程相同
- 内存的分配和回收都具备确定性,方法结束或线程结束就回收,不需过多考虑回收问题
程序计数器(Program Counter Register)
一块较小的内存空间,当前线程所执行字节码的行号指示器。
- 线程私有
- JVM 5大数据区中唯一一个没有规定OOM的区域
- 执行Java方法时,计数器记录的是字节码指令的地址;执行Native方法时,计数器值为空(undefined)
为什么需要程序计数器呢?
JVM 的多线程是通过线程轮流切换并分配CPU时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
Java虚拟机栈(Java Virtual Machine Stacks)
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 线程私有,生命周期与线程相同
- StackOverflowError:栈深度大于虚拟机所允许的深度
- OutOfMemorryError:如果虚拟机栈可以动态扩展(大部分虚拟机可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),扩展时无法申请足够内存
经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,其流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的『堆』就是后面即将提到的Java堆,而所指的『栈』就是这里的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
局部变量表
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference)和returnAddress类型(指向了一条字节码指令的地址)。
- 局部变量表的容量以变量槽(Variable Slot)为最小单位
- 64位长度的 long 和 double 类型的数据占用2个slot,其余数据类型只占用1个slot
- 局部变量表所需内存空间在编译期已经确定,在方法运行期间不会改变大小
局部变量表的影响
让我们通过以下示例代码直观地感受一下局部变量表的影响。第一个recursion()没有参数和局部变量,第二个包含3个参数和4个局部变量,因此后者占用更多内存空间,在jvm参数-Xss 128K下分别执行两个方法:
private static int count=0;
public static void recursion(){
System.out.println("count="+count);
count++;
recursion();
}
public static void recursion(int a,int b,int c){
long l1=12;
short sl=1;
byte b1=1;
String s="1";
System.out.println("count="+count);
count++;
recursion(1,2,3);
}
执行第一个无参的recursion()的输出:
count=4495
Exception in thread "main" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
执行第二个有参的recursion()的输出:
count=3865
Exception in thread "main" java.lang.StackOverflowError
at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:564)
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:619)
可见,在同等的栈容量下,局部变量少的函数可以支持更深的调用层次,换句话说,一个线程中可调用的方法数就越多。
本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈的作用类似,区别只是前者为执行Native方法服务,后者为执行Java方法服务。有的虚拟机(如Sun HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一。
- 线程私有
- 和Java虚拟机栈一样,也会抛出StackOverflowError 和 OutOfMemorryError
Java堆(Java Heap)
所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么"绝对"了。
- 线程共享
- OutOfMemorryError:Java heap space
- GC的主要区域,因此也被称作"GC堆"
- JVM所管理的内存中最大的一块
- 虚拟机启动时创建
虚拟机规范对该区的限制
- 可以处于物理上不连续的内存空间中,只要逻辑上连续即可
- 即可以实现成固定大小的,也可以是可扩展的,当前主流虚拟机都是按照可扩展来实现的
方法区(Method Area)
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 线程共享
- OutOfMemorryError:PermGen spage
- GC比较少出现(虚拟机实现时也可以选择不实现GC,但事实证明该区域的GC是必要的)
- 有一个别名叫"Non-Heap"(非堆):虚拟机规范中把方法区描述为堆的一个逻辑部分,为了与Java堆区分开来
浅谈“永久代”(Permanent Generation)
在HotSpot虚拟机上,很多人都更愿意把方法区称为“永久代”,但本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队使用永久代来实现方法区而已。而对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。目前,在HotSpot虚拟机上也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划了,在JDK1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。
虚拟机规范对该区的限制
- 可以处于物理上不连续的内存空间中,只要逻辑上连续即可
- 即可以实现成固定大小的,也可以是可扩展的,当前主流虚拟机都是按照可扩展来实现的
- 可以选择不实现垃圾收集
垃圾收集行为在方法区是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这里的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这里的回收“成绩”难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。
运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
直接内存
并非虚拟机运行时数据区的一部分,也不是Java虚拟机规范定义的内存区域。但这部分也被频繁使用,而且也可能导致OOM。
JDK 1.4中加入的NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的
DirectByteBuffer
对象作为这块内存的引用进行操作。
本机直接内存的分配不会受到Java堆大小的限制,但还是会受到本机总内存大小以及处理器寻址空间的限制。
5大数据区对比
JVM数据区 | 私有/共享 | 创建时机 | 生命周期 | 垃圾收集 | 内存溢出 |
---|---|---|---|---|---|
程序计数器 | 线程私有 | 线程启动时 | 与线程相同 | 无 | 无 |
虚拟机栈 | 线程私有 | 线程启动时 | 与线程相同 | 无 | StackOverflowError OutOfMemoryError |
本地方法栈 | 线程私有 | 线程启动时 | 与线程相同 | 无 | StackOverflowError OutOfMemoryError |
Java堆 | 线程共享 | JVM启动时 | 与进程相同 | 主要区域 | OutOfMemoryError: Java heap space |
方法区 | 线程共享 | JVM启动时 | 与进程相同 | 较少出现 | OutOfMemoryError: PermGen space |
对象初探秘
对象的创建
在Java中,从语言层面上来看,创建对象通常只是一个 new
关键字而已,而在虚拟机中,对象(这里讨论的对象仅限于普通对象,不包括数组和Class对象)的创建又是怎样一个过程呢?
虚拟机遇到一条 new 指令时:
- 执行类加载检查
- 检查指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。
- 若没有,则执行相应的类加载过程。
- 为新生对象分配内存
指针碰撞
假设Java堆中内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指针向着空闲内存那边移动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
空闲列表
如果Java堆中的内存并不是规整的,已使用内存和空闲内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找出一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
如何选择分配方式
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有 压缩整理
功能决定。因此,在使用Serial、ParNew等待Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)、和对齐填充(Padding)。
对象头
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志等,官方称这些数据为 “Mark Word” 。
对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象是哪个类的实例。但并非所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据确定该对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据
实例数据是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。无论是从父类继承下来的,还是子类中定义的,都需要记录。
对齐填充
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM要求对象起始地址必须是8字节的整数倍,话句话说,就是对象大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此,当对象的实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
Java程序需要通过栈(具体是虚拟机栈中的局部变量表)上的reference数据来操作堆上的具体对象。而reference如何定位、访问堆中对象的具体位置,则取决于不同的虚拟机实现。目前主流的访问方式有使用 句柄
和 直接指针
两种。
问题:毫无疑问,局部变量中的reference存放在栈中,那么成员变量中的reference又是存放在哪里?
笔者也是看到这里时感到疑惑,上网查证了很多,但是说法不一,有的认为在栈中(一概而论:对象在堆,引用在栈),有的认为在堆中(比如https://blog.csdn.net/qq_36596145/article/details/76300922),笔者认为在方法区中(具体是方法区中的运行时常量池,因为class文件中有一个常量池,用于存放编译期生成的各种字面量和符号引用,这部分信息在类加载后进入方法区的运行时常量池中存放)。
如果有读者可以给出明确的结论,还请不吝赐教!
句柄式
在Java堆中划分出一块内存用作句柄池,reference中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
使用句柄访问方式的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
直接指针
reference中存储的直接就是对象地址,此时对象的布局中就必须考虑如何放置对象类型数据的指针。
- HotSpot虚拟机采用的就是这种方式。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。由于对象访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。