前言
本文已经收录到我的 GitHub 个人博客, 欢迎大佬们光临寒舍:
我的 GitHub 博客 https://lovelifeeveryday.github.io/
学习导图:
一. 为什么要学习内存管理?
Java 与 C++ 之间有一堵由内存动态分配和垃圾回收机制所围成的高墙, 墙外面的人想进去, 墙里面的人出不来
对于 Java 程序员来说, JVM 给我们提供了自动内存管理机制, 不需要既当 "皇帝", 又当 "人民", 不需要人为地给每一个 new 操作写配对的 delete/free 代码, 不容易出现内存泄漏和内存溢出问题. 然而一旦出现内存泄漏和溢出方面的问题, 如果不清楚 JVM 内存的内存管理机制, 那么将很难定位与解决问题. 而且, JVM 的内存管理机制在面试中也是非常重要的考点之一.
综上, 想要更加深入了解 JVM 的奥秘, 探究 JVM 内存管理机制是必不可少的!!!
二. 核心知识点归纳
2.1 JVM 运行时数据区域
JVM 执行 Java 程序的过程: Java 源代码文件 (.java) 会被 Java 编译器编译为字节码文件(.class), 然后由 JVM 中的类加载器加载各个类的字节码文件, 加载完毕之后, 交由 JVM 执行引擎执行
在上述过程中, JVM 会用一段空间来存储执行程序期间需要用到的数据和相关信息, 这段空间就是运行时数据区, 也就是常说的 JVM 内存
JVM 会将它所管理的内存划分为若干个不同的数据区域, 划分结果如图:
可见, 运行时数据区被分为线程私有数据区和线程共享数据区两大类:
线程私有数据区包含: 程序计数器, 虚拟机栈, 本地方法栈
线程共享数据区包含: Java 堆, 方法区(内部包含运行时常量池)
下面将为您详细介绍各个数据区的内容
2.1.1 程序计数器
定义: 当前线程所执行的字节码的行号指示器
如果线程正在执行的是一个 Java 方法, 那么计数器记录的是正在执行的虚拟机字节码指令的地址
如果线程正在执行的是一个 Native 方法, 那么计数器的值则为空
字节码解释器工作时, 就是通过改变这个计数器的值来选取下一条需要执行的字节码指令, 分支, 循环, 跳转, 异常处理, 线程恢复等基础功能都需要依赖这个计数器来完成.
为什么必须是私有: 为了线程切换后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 各条线程之间计数器互不影响, 独立存储, 因此它是线程私有的内存
在《 Java 虚拟机规范》中, 是唯一一个没有规定任何 OutOfMemoryError 情况的区域
2.1.2 Java 虚拟机栈
定义: Java 方法执行的内存模型
每个方法在执行的同时都会创建一个栈帧, 用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息
每个方法从调用直至执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
局部变量表存放了编译期可知的各种基本数据类型, 对象引用类型和 returnAddress 类型, 它所需的内存空间在编译期间完成分配
线程私有的内存, 与线程生命周期相同
一般把 Java 内存区分为堆内存 (Heap) 和栈内存(Stack), 其中『栈』指的是虚拟机栈,『堆』指的是 Java 堆
在 Java 虚拟机规范中, 对这个区域规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出 StackOverflowError 异常
如果虚拟机栈可动态扩展且扩展时无法申请到足够的内存, 将抛出 OutOfMemoryError 异常
2.1.3 本地方法栈
定义: 虚拟机使用到的 Native 方法服务
想要了解 Native 方法的读者, 可以看下这篇文章: Java 中 native 方法
在虚拟机规范中, 对这个区域无强制规定, 由具体的虚拟机自由实现. 与虚拟机栈一样, 本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常
2.1.4 Java 堆
定义: 被所有线程共享的一块内存区域, 在虚拟机启动时创建
作用: 用于存放几乎所有的对象实例和数组
在 Java 堆中, 可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB), 但无论哪个区域, 存储的都仍然是对象实例, 进一步划分的目的是为了更好地回收内存, 或者更快地分配内存
是垃圾收集器管理的主要区域, 也被称做 "GC 堆"(可别叫做垃圾堆 orz)
是 Java 虚拟机所管理的内存中最大的一块
可处于物理上不连续的内存空间中, 只要逻辑上是连续的即可
在 Java 虚拟机规范中, 如果在堆中没有内存完成实例分配, 且堆也无法再扩展时, 将会抛出 OutOfMemoryError 异常
2.1.5 方法区
定义: 与 Java 堆一样, 是各个线程共享的内存区域
作用: 用于存储已被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据
人们更愿意把这个区域称为 "永久代", 它还有个别名叫做 Non-Heap(非堆)
在 JDK7 的 HotSpot 中, 已经把原本放在永久代的字符串常量池, 静态变量移出;
在 JDK8 中, 废弃永久代的概念, 改用元空间;
对用元空间替换永久代的原因感兴趣的话, 可以看下这篇文章: 一文读懂 - 元空间和永久代 https://juejin.im/post/5df5fde36fb9a0162c486c71
永久代 / 元空间 和方法区的区别:
永久代 / 元空间
可看作是方法区的实现
和 Java 堆一样不需要连续的内存和可以选择固定大小或可扩展外, 还可选择不实现 GC
在 Java 虚拟机规范中, 当方法区无法满足内存分配需求时, 将抛出 OutOfMemoryError 异常
2.1.6 运行时常量池
Class 文件中除了有类的版本, 字段, 方法, 接口等描述信息外, 还有一项信息是常量池表, 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放
Q1: 字面量是什么
可以理解为字面意思的常量.
- int a; // 变量
- const int b = 10; //b 为常量, 10 为字面量
- string str = "hello world!"; // str 为变量, hello world! 为字面量
由例子可知, 字面量就是如此容易理解
Q2: 符号引用是什么
可以是任意类型的字面量. 只要能无歧义的定位到目标. 在编译期间由于暂时不知道类的直接引用, 因此先使用符号引用代替. 最终还是会转换为直接引用访问目标
比如: java/lang/StringBuilder
Q3: 运行时常量池是什么
相对于 Class 文件常量池的一个重要特征是具备动态性, 体现在并非只有预置入 Class 文件中常量池的内容才能进入方法区运行时常量池, 运行期间也可能将新的常量放入池中
是方法区的一部分, 会受到方法区内存的限制
在 Java 虚拟机规范中, 当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常
2.1.7 直接内存
它并不是虚拟机运行时数据区的一部分, 也不是《Java 虚拟机规范》中定义的内存区域, 但是这部分内存也被频繁地调用
作用: 避免了在 JAVA 堆和 Native 堆中来回复制数据, 因此在一些场景下能显著提高性能
JDK1.4 中新加入了 NIO 类, 引入了基于通道与缓冲区的 IO 方式, 可以使用 Native 函数库直接分配直接内存(堆外内存), 然后通过 DirectByteBuffer 作为这块内存的引用进行操作
2.2 HotSpot 虚拟机内存对象探秘
在熟悉虚拟机内存划分及其具体内容之后, 为详细了解虚拟机内存中数据的其他细节, 以常用的虚拟机 HotSpot 和常用的内存区域 Java 堆为例, 探讨 HotSpot 虚拟机在 Java 堆中对象分配, 布局和访问的全过程
2.2.1 对象的创建
遇到一个 new 指令后创建过程分三步
1. 类加载检查
检查 new 指令的参数是否能在常量池中定位到一个类的符号引用且该符号引用代表的类是否已被加载, 解析和初始化, 若没有则需先执行相应的类加载, 反之下一步
2. 分配内存
由 Java 堆中的内存是否规整决定如何给新生对象分配可用空间
由堆所采用的垃圾收集器是否带有空间压缩整理的能力决定 Java 堆中的内存是否规整
若规整, 采用 "指针碰撞" 分配方式:
过程: 将用过和空闲的内存放在两边, 中间以一个指针作为分界指示器. 当分配内存时, 就把指针向空闲一边挪动与对象大小相等的距离即可
应用: Serial,ParNew 等带 压缩过程的收集器
若非规整, 采用 "空闲列表" 分配方式:
过程: 维护一个记录可用内存块的列表. 当分配内存时, 就从列表中找到一块足够大的空间划分给对象实例并更新记录
应用: 基于 Mark-Sweep 算法的 CMS 收集器
保证内存分配是线程安全的解决方案:
对内存分配的动作进行同步处理
每个线程在 Java 堆中预先分配一块内存(本地线程分配缓冲 TLAB), 在本线程的 TLAB 上进行分配, 当 TLAB 用完需要分配新的 TLAB 时再同步锁定
3. 设置对象头
将对象的所属类, 找到类的元数据信息的方式, 对象的哈希码, 对象的 GC 分代年龄等信息存放在对象的对象头中
2.2.2 对象的内存分布
分为三块区域
对象头: 包括两部分信息
Mark Word: 用于存储对象自身的运行时数据, 如哈希码, GC 分代年龄, 锁状态标志, 线程持有的锁, 偏向线程 ID, 偏向时间戳等
类型指针: 用于确定这个对象的所属类
实例数据: 存储真正的有效信息, 是程序代码中定义的各种类型的字段内容. 存储顺序会受虚拟机分配策略参数和字段在 Java 源码中定义顺序这两个因素影响.
对齐填充: 占位符, 帮助补全未对齐的对象实例数据部分(保证是 8 字节的倍数), 非必需
2.2.3 对象的访问定位
两种主流的访问方式
通过句柄访问对象
在 Java 堆中划分出一块内存来作为句柄池, reference 存储的是对象的句柄地址, 在句柄中包含了对象实例数据与类型数据各自的具体地址信息
好处: reference 中存储的是稳定的句柄地址, 在对象被移动时只会改变句柄中的实例数据指针, 而 reference 本身不需要修改
通过直接指针访问对象
在 Java 堆对象的布局中考虑如何放置访问类型数据的相关信息, reference 存储的直接就是对象地址
好处: 速度更快, 节省了一次指针定位的时间开销
2.3 实战: OutOfMemoryError 异常
这部分的内容可以看下这篇文章: JVM 内存溢出详解(栈溢出, 堆溢出, 持久代溢出, 无法创建本地线程) https://www.jianshu.com/p/2a2a5ec2af00
三. 课堂小测试
恭喜你! 已经看完了前面的文章, 相信你对 JVM 内存管理机制已经有一定深度的了解, 下面, 进行一下课堂小测试, 验证一下自己的学习成果吧!
Q1: 在 JVM 中, 为什么要把堆与栈分离? 栈不是也可以存储数据吗?
从软件设计的角度看, 栈代表了处理逻辑, 而堆代表了数据, 分工明确, 处理逻辑更为清晰体现了 "分而治之" 以及 "隔离" 的思想.
堆与栈的分离, 使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象). 这样共享的方式有很多收益: 提供了一种有效的数据交互方式(如: 共享内存); 堆中的共享常量和缓存可以被所有栈访问, 节省了空间.
栈因为运行时的需要, 比如保存系统运行的上下文, 需要进行地址段的划分. 由于栈只能向上增长, 因此就会限制住栈存储内容的能力. 而堆不同, 堆中的对象是可以根据需要动态增长的, 因此栈和堆的拆分, 使得动态增长成为可能, 相应栈中只需记录堆中的一个地址即可.
堆和栈的结合完美体现了面向对象的设计. 当我们将对象拆开, 你会发现, 对象的属性即是数据, 存放在堆中; 而对象的行为 (方法) 即是运行逻辑, 放在栈中. 因此编写对象的时候, 其实即编写了数据结构, 也编写的处理数据的逻辑.
Q2: 为啥说堆和 JVM 栈是程序运行的关键
栈是运行时的单位(解决程序的运行问题, 即程序如何执行, 或者说如何处理数据), 而堆是存储的单位(解决的是数据存储的问题, 即数据怎么放, 放在哪儿)
堆存储的是对象. 栈存储的是基本数据类型和堆中对象的引用;(参数传递的值传递和引用传递)
如果文章对您有一点帮助的话, 希望您能点一下赞, 您的点赞, 是我前进的动力
本文参考链接:
《深入理解 Java 虚拟机》第 3 版
Java 中 native 方法
一文读懂 - 元空间和永久代 https://juejin.im/post/5df5fde36fb9a0162c486c71
JVM 内存溢出详解(栈溢出, 堆溢出, 持久代溢出, 无法创建本地线程) https://www.jianshu.com/p/2a2a5ec2af00
要点提炼 | 理解 JVM 之内存管理机制 https://www.jianshu.com/p/cd93567ed868
字面量, 常量和变量之间的区别? https://www.jianshu.com/p/0f2816805da6
元数据(MetaData)
JVM 符号引用和直接引用 https://juejin.im/post/5c78ae3ff265da2dcc800838
一文理解 JVM 虚拟机
来源: https://www.cnblogs.com/xcynice/p/jvm_nei_cun_guan_li.html