一, JVM 运行时数据区域概述
Java 相比较于 C/C++ 的一个特点就是, 在虚拟机自动内存管理机制的帮助下, 我们不需要为每一个操作都写像 C/C++ 一样的 delete/free 代码, 所以也不容易出现内存泄漏和内存溢出的问题. 显然, 这里的不容易只是相对而言的, 如果我们想要降低这种代码隐患的发生, 就需要对 Java 虚拟机怎样使用内存有了解, 这样的话就算产生错误, 排查起来也会相对容易. 下面我们来说一说 JVM 运行时数据区域
1, 程序计数器(PC 寄存器): 被看作是当前线程所执行的字节码的行号指示器, 字节码解析的时候就通过改变这个计数器的值来选取下一条需要执行的指令(包括分支, 循环, 跳转, 异常处理等等都依赖 PC). 在多线程程序中, 每条线程都需要拥有一个独立的程序计数器, 所以程序计数器是线程私有的.(如果程序正在执行一个 Java 方法, 那么这个计数器记录的就是当前执行的字节码指令的地址; 如果执行的是一个 Native 方法, 那么计数器为 Undefined), 该内存区域是 JVM 规范中唯一一个没有规定 OutOfMemoryError 的区域.
2,Java 虚拟机栈: 虚拟机栈描述的是 Java 方法执行的内存模型, 这块区域也是线程私有的, 生命周期和线程相同. 每个方法在执行的时候会创建栈帧, 用来存放局部变量表, 操作数栈, 动态链接, 返回值等信息.(我们常说的在 Java 中的栈内存就是指这块区域). 在 JVM 规范中: 如果线程请求栈深度大于虚拟机提供的深度, 那么抛出 StackOutflowError 异常; 如果无法申请得到足够的内存, 会抛出 OutofMemoryError 异常
3, 本地方法栈: 与 Java 虚拟机栈不同的是, 本地方法栈为虚拟机执行 Native 方法服务, 而 Java 虚拟机栈为虚拟机执行 Java 方法服务. 由于 JVM 规范并没有对本地方法栈做强制规定, 所以如同 HotSpot 一样, 直接将本地方法栈和虚拟机栈合二为一. 异常情况和 JAVA 虚拟机栈相同
4,Java 堆: Java 堆是被所有线程所共享的一块内存区域, 在虚拟机启动时候创建, 这块区域的目的就是存放对象实力, 基本上我们所创建的所有的对象实例都要在这里分配内存. 我们下面会详细的介绍 Java 堆
5, 方法区: 方法区和 Java 堆一样, 是各个线程共享的内存区域,
6, 运行时常量池: 运行时常量池是方法区的一部分, 在 Class 文件中 (存在类的版本, 字段, 方法, 接口等) 存在常量池的信息, 作用就是用于存放编译期间生成的各种字面量和符号引用. 受到方法区内存的限制, 当无法申请到内存时候会抛出 OutOfMemoryError 异常
还有一部分就是直接内存, 但是直接内存并不是运行时数据区域的一部分, 在 Java 的 NIO 库中允许 Java 程序员频繁的使用直接内存, 从而提高性能(避免了 Java 堆和 Native 堆之间来回复制数据).
二, 再探 Java 堆, 栈, 方法区
1,Java 堆: Java 堆的存在是为了解决数据存储的问题, 堆中存放了对象实例. 堆也是垃圾收集器管理的主要区域 (所以也可以被称为 GC 堆). 从内存回收的角度来讲, 采用分代回收算法的 JVM, 在堆中可被分为新生代(新生的对象或者年龄不大的对象) 和老年代 (老年对象), 这里面的划分是按照垃圾收集器的次数, 来判断对象的年龄. 新生代中又被分为 Eden 区, s0 区(from 区域),s1 区(to 区域),From 和 To 是两块大小相等, 可以互换角色的区域. 一般来说, 新生的对象会被首先分配在 Eden 区, 然经过一次 GC 之后(如果对象还存活) 会到 from 或者 to 区. 之后类似的每一次回收, 都会加 1, 当对象达到一定年龄后, 会进入老年代.
2, 栈: Java 栈是一个线程私有的空间, 一般情况下一个栈由 3 部分组成: 局部变量表, 操作数栈, 帧数据区.
局部变量表: 里面存放的是报错函数的参数以及局部变量;
操作数栈: 其中保存计算过程中的中间结果, 同时作为计算过程中变量的临时存储空间;
帧数据区: 除了局部变量表和操作数栈之外, 还需要一些数据来支持常量池的解析, 帧数据区中保存着访问常量池的指针, 方便程序访问常量池. 除此之外, 当函数返回或者出现异常时, JVM 必须有一个异常处理表, 方便发送异常的时候找到异常的代码(从而异常处理也是帧数据区的一部分)
3, 方法区: 方法区是一块所有线程共享的内存区域, 他保存了类的信息(类的字段, 方法, 常量池等等), 方法区大小决定了系统可以保存多少个类.
4, 一张简略的图描述一下堆, 栈, 方法区之间的关系
三, 探秘 JVM 堆中对象分配
1, 对象的创建
a)我们从 new 开始, 当虚拟机遇到一条 new 指令的时候, 会首先检查这个指令的参数能否在常量池中找到这个类的符号引用, 并且检查这个类的加载是否被加载, 解析, 初始化过, 如果没有就会按照类加载过程进行相应类的加载.
b)在类加载完毕后, 然后 JVM 对新生对象分配内存, 对象的分配简单而言就是在将一块确定大小的内存从 Java 堆中分配出来.
1堆是完整的(这时候所有使用的内存在一边, 没有使用的内存在一边, 中间放着一个指针作为分界点的指示器): 分配内存就只是把指针向空闲内存那边移动与对象大小一样大的距离(这种方式成为 "指针碰撞")
2堆不是完整的(使用和未用的相互交错): 虚拟机需要维护一个列表, 其中记录的是空间内存的状况, 在分配的时候从空闲内存中找到一块足够的空间划分给对象实例, 然后更新表中的记录信息(这种方式成为 "空闲列表")
c)考虑多线程情况下的对象内存分配
在并发的情况下是线程不安全的, 可能出现正在给对象 A 进行分配, 这时候指针位置还没有来得及改变, 然后这时候对象 B 的内存分配又使用了原来的指针记性分配. 在《深入理解 Java 虚拟机》中讲到两种解决方案
1对对象的分配进行同步处理, 采用 CAS 配置失败重试的方式保证更新操作的原子性
2将内存分配的动作按照线程划分在不同的空间中进行. 即保证每个线程预先在 Java 堆中分配一小块空间(本地线程分配缓冲 TLAB). 线程需要分配的时候, 就先在 TLAB 上面分配, 然后当 TLAB 使用完毕再进行同步锁定.
d)内存分配完成之后, 虚拟机需要将分配的内存初始化为零值, 这一步保证了对象实例字段在 Java 代码中可以不被赋初值就使用, 使得程序能够访问到这些字段的数据类型对应的零值.
e)然后虚拟机需要对对象进行必要的设置, 比如对象是那个类的实例, 类的元数据, 对象哈希码, GC 年龄等等存放在对象头中.
f) 上面执行完毕之后, 从虚拟机角度而言已经产生了心得对象. 但是程序中对象创建还没有执行 < init > 方法, 所有字段均为零值. 所以, 执行完 new 指令, 还需要执行 < init > 方法, 按照程序的角度进行初始化, 才能使用这个对象.
2, 对象的访问定位
我们在介绍堆栈的时候就介绍过堆栈和方法区之间的关系, Java 程序中对象的引用存放在栈 (reference) 中, 引用的实例存放在 Java 堆中(使用栈上面的对象引用来操作堆上面的具体对象), 但是我们并没有定义怎样通过引用去定位, 访问堆中的具体对象位置, 下面介绍句柄和直接指针的方式
a)句柄方式
首先在 Java 堆中分配一块区域作为句柄池, 栈中的 reference 中存放的就是对象的句柄地址信息(句柄中包含的是对象实例数据和类型数据各自的具体地址信息). 使用句柄方式的好处就是 reference 中存储的是稳定的句柄地址信息, 而 reference 本身不需要修改. 下面是句柄方式的简略图
b)直接指针方式:
Java 堆中对象的布局中必须考虑如何放置访问类型数据的相关信息, 而 reference 中存储的直接就是对象的地址. 使用直接指针的方式就是存取速度快, 节省了一次指针定位的时间.
来源: https://www.cnblogs.com/fsmly/p/10356492.html