Java的内存区域在jdk1.6时发生了变化,这里主要介绍jdk1.8的内存区域,同时会指明发生了那些变化。(读书笔记)
一、运行时的数据区域
1. 内存区域划分图示
1.1 JDK1.7之前:
1.2 JDK1.8
如上图所示:
-
线程私有:虚拟机栈、本地方法栈、程序计数器
-
线程共享:堆、元空间(方法区)、直接内存(非运行时数据区的一部分)
2.虚拟机栈
- 虚拟机栈可以简单的理解为执行Java 方法 (也就是字节码) 服务。描述的是 Java 方法执行的内存模型。
- Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
- Java虚拟机会出现两种错误:
StackOverFlowerError
与OutOfMemoryError
(OOM)。StackOverFlowerError
:栈内存溢出错误:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
:堆内存溢出错误:若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出 OutOfMemoryError 错误。
- Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。 Java 方法有两种返回方式:1. return 语句。2. 抛出异常。以上两种方式会导致栈帧弹栈。
3.本地方法栈
- 本地方法栈可以简单的理解为执行本地 方法 (也就是Native) 服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
- 本地方法栈的执行也会创建栈帧。
- 本地方法栈也会有
StackOverFlowerError
与OutOfMemory
错误。
4.程序计数器
- 程序计数器可以看作是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条执行的字节码指令,分支、循环、跳转、异常处理、线程恢复(可以保存线程的执行现场) 等功能都需要依赖这个计数器来完成。
- 程序计数器是线程私有的,每个线程拥有自己的计数器,各计数器互不影响,目的是为了线程切换后能恢复到正确的执行位置。
- 程序计数器是唯一不会出现
OutOfMemory
错误,生命周期与自己的线程相同
5.堆
- 堆是JVM中最大的地方,几乎所有的对象实例都在这里分配内存
- Java的垃圾回收主要在堆中,因此也被称为GC堆,Java8采用的分代垃圾回收算法
JVM垃圾回收。 - 堆内存分为:
- 新生代(Young Generation) :包括Eden区(复制算法,gc后存活的复制进入survivor或老生代(大对象)),Survivor From与Survivor To(复制)
- 老生代(Old Generation): (标记整理)
- 永生代(Permanent Generation) || 元空间(JDK1.8)
6.方法区(元空间)
线程共享,用于存放被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-
方法区与永久代的关系:
- 永久代是HotSpot虚拟机中对方法区的一种实现。
-
JDK1.8移除永久代替换为元空间的原因
-
永久代存在OOM问题:永久代有JVM设置的固定大小,元空间受本机内存的限制,元空间OOM的几率会更小。
-
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了
-
7. 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
- jdk1.7之前,运行时常量池包括字符串常量池在方法区,HotSpot的实现为永久代。
- jdk1.7,字符串常量池从方法区移入堆中,运行时常量池依旧在方法区,HotSpot的实现为永久代。
- jdk1.8,永久代移除,替换为元空间,运行时常量池也移入元空间。
8. 直接内存
直接内存不受java堆分配的限制,受本机总内存大小以及处理器寻址空间的限制。使用过多,依旧会出现OOM问题。
二、HotSpot虚拟机的对象管理
2.1、 对象的创建
-
类加载检测
当虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
类加载机制:。。。 -
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。- 指针碰撞:堆内存规整,用过的内存一边,没用过的另一半,中间一个分界指针,只需要向没用过的内存方向移动指针即可
- 空闲列表:对内存不规整,堆内存维护一个列表,列表记录那块内存可用,分配的时候找一个足够大的内存来划分对象实例。
分配内存的线程安全问题:
- CAS+失败重试机制:虚拟机采用CAS再配上失败重试机制保证更新操作的原子性
- TLAB:为每一个线程预先再EDEN区分配一块内存,JVM再给对象分配内存时首先在TLAB分配,当对象大于TLAB的剩余内存或TLAB的内存用尽时,再采用上述的CAS进行内存分配。
-
初始化零值
分配内存完毕之后,需要将分配的内存空间初始化为零值,保证对象实例字段不赋值也可以直接使用,能访问这些字段的数据类型对应的零值。 -
设置对象头
初始化零值之后,对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头 -
执行init方法
对虚拟机而言,对象已经产生,但java程序而言,对象创建才刚开始,对象需要执行init方法,该方法把对象(实例变量)按照程序中定义的初始赋值进行初始化;
2.2、 对象的内存布局
在HotSpot虚拟机中,对象在内存中可以分为三块区域:对象头、实例数据、对齐填充
- 对象头:包括两部分信息,第一部分存储对象的自身运行时数据(哈希码、GC分代年龄、锁状态的标志等等),另一部分是类型指针,即对象指向它的类元数据的指针。
- 实例数据:存储有效信息,即存储程序所定义的各种类型的字段内容。
- 对齐填充:不必然存在,仅仅起一个占位的作用:由于HotSpot虚拟机要求对象起始地址必须是8字节的整数倍(对象的大小必须是8字节的整数倍)。填充数据不是必须存在的,仅仅是为了字节对齐。
根据“计算机组成原理”,8个字节是计算机读取和存储的最佳实践
3.3 、对象的访问定位
对象的访问由虚拟机决定,主流的有两种:1. 使用句柄、2.直接指针
- 句柄访问:
java会将堆划分出来一部分内存去作为句柄池,reference中存储的就是对象的句柄地址,句柄中则是包含的对象实例的数据的地址和对象类型数据(如对象的类型,实现的接口、方法、父类、field等)的具体地址信息。
例:Object obj = new Object();
Object obj 表示一个本地引用,存储在java栈的本地变量表中,表示一个reference类型的数据。
new Object()作为实例对象存放在java堆中,同时java堆中还存储了Object类的信息(对象的类型、实现接口、方法等)的具体地址信息,这些地址信息所执行的数据类型存储在方法区
- 直接指针访问:
使用直接指针访问,java对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果是访问对象本身的话,就不需要多一次间接访问的开销。
优劣势:
- 句柄:最大的好处是reference中存储的是稳定的句柄地址,在对象被移动(如垃圾回收的移动)时,只会改变句柄中的实例数据指针,而reference本身不需要被修改。
- 指针:最大的好处是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多也是一项即位可观的执行成本。
参考
- 《深入理解java虚拟机 JVM高级特性与最佳实践》第二部分第二章