1. JVM的内存结构
JVM的内存结构主要是指Java程序在运行时的数据区的划分. 它主要由虚拟机栈, 本地方法栈, Java堆, 方法区, 程序计数器这五部分组成. 这五部分, 虚拟机栈/本地方法栈/程序计数器是线程私有的, Java堆和方法区是线程共享的.
下面我们就来逐一介绍一下这五部分.
(1) 虚拟机栈
虚拟机栈是线程私有的, 所以它的生命周期与线程相同.
在Java程序的执行过程中, 每调用一个方法, 就会创建一个栈帧. 栈帧是描述Java方法执行的内存模型, 它用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 而每一个方法的调用到结束, 都对应着一个在虚拟机栈中从入栈到出栈的过程.
在虚拟机栈中, 最重要的就是局部变量表.
局部变量表
局部变量表存放了编译器可知的八种基本数据类型, 对象引用(引用类型), returnAddress类型(指向了一条字节码指令的地址).
double和long类型的数据会占用两个局部变量空间, 其余的数据类型只占1个.
局部变量表所需的内存空间是在编译期就已经完成分配了的, 也就是说, 当进入到一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的, 在方法运行期间是不会改变局部变量表的空间的大小的.
虚拟机栈区域的异常
在这个区域中规定了两种异常情况: StackOverflowError异常和OutOfMemoryError异常.
(1) StackOverflowError: 当当前线程所请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常.
代码举例: 如下方法的调用将会抛出StackOverflowError异常
public class Test {
private int stackLength = 1;
// stackLeak()方法的调用将会引发StackOverflowError异常
public void stackLeak() {
stackLength++;
stackLeak();
}
}
(2) OutOfMemoryError : 当虚拟机栈动态扩展时无法申请到足够的空间的时候, 就会抛出OutOfMemoryError异常.
代码举例: 在一个方法中while(true){}循环创建线程, 将会引发OutOfMemoryError异常.
(2) 本地方法栈
本地方法栈是线程私有的.
关于本地方法栈就比较好解释, 它和虚拟机栈发挥的作用是很类似的, 区别不过就是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务, 而本地方法栈是为了虚拟机执行Native方法(使用非Java语言编写的方法)服务.
(3) Java堆
Java堆是线程共享的.
对于大多数应用来说, Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块. Java堆在虚拟机启动时创建.
Java堆的目的就是存放对象实例. 也就是说, 几乎所有的对象实例以及数组都要在堆上分配内存(因为随着JIT编译器(即时编译器)的发展, 所有的对象都要在堆上分配内存也变得不时那么绝对了).
Java堆也是垃圾收集器管理的主要区域. 关于Java里面垃圾回收机制, 在本系列的下一篇博客中将会进行详细探讨.
Java堆区域的异常
Java堆区域的异常就是OutOfMemoryError. 如果当前实例没有在堆中完成实例分配, 并且堆再也无法扩展的时候, 将会抛出OutOfMemoryError异常.
代码示例:
public class HeapOOM{
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
(4) 方法区
方法区是线程共享的.
方法区主要是存放虚拟机加载的类信息, 常量, 静态变量, JIT(即时编译器编译后的代码)等数据.
在方法区中比较特殊的就是它的垃圾回收机制. 它可以选择不实现本区域的垃圾回收, 相对而言, 垃圾回收在这个区域是比较少出现的, 所以很多人将方法区的数据称为“永久代”(这与垃圾回收机制的回收算法相关, 在下一篇博客中将会详细介绍).
方法区中有一块很重要的区域, 就是运行时常量池.
运行时常量池
在Java文件编译后生成的Class文件中除了有类的版本, 字段, 方法, 接口等描述信息之外, 还有一部分信息是常量池. 常量池用于存放编译期生成的各种字面量和符号引用, 这部分数据将在类加载后进入方法区的运行时常量池.
还记得在之前提过的字符串常量和-127~128之间的int类型的数值吗? 它们所保存的地方就是常量池.
运行时常量池相对于Class文件常量池的另外一个重要特性就是具备动态性. Java语言并不要求常量一定只有编译期才能产生, 也就是非预置进Class文件中常量池的内容才能进入方法区的运行时常量池, 运行期间也可能将新的常量放入池中. 这种特性被用的最多的就是String类的intern()方法.
在new一个String类型的变量的时候, 调用intern()方法就会将这个变量添加到运行时常量池中, 当后面再使用字面常量赋值创建一个与该变量相等的String类型的对象的时候, 就会直接将该变量的地址返回, 而不会再在堆上重新创建一个String对象.
方法区的异常
方法区的异常就是OutOfMemoryError异常. 当方法区无法再申请到足够的空间的时候, 就会抛出OutOfMemoryError异常.
(5) 程序计数器
程序计数器是线程私有的.
程序计数器是一块比较小的内存空间. 它的主要作用就是作为当前线程所执行的字节码的行号指示器. 也就是说, 它要时时刻刻地记录当前线程当前执行到哪里了.
因为要保证各个线程是独立运行且不相互干扰, 所以每个线程都有自己的程序计数器.
但是需要注意的是, 程序计数器仅仅是在执行Java方法的情况下才会记录线程的正在执行的虚拟机字节码指令的地址; 如果是Native方法 , 程序计数器的值为空(Undefined).
程序计数器是JVM内存区域中唯一一个没有规定OutOfMemoryError异常的区域.
2. 对象的内存布局
(1) 对象的创建
在Java中创建一个对象仅仅是通过一个new关键字, 但是在这背后究竟JVM都做了哪些事呢? 下面就来看一看Java中对象的创建过程.
1. 虚拟机在遇到一条new指令的时候, 首先会去检测这个指令的参数是否能在常量池中定位到一个类的符号引用, 并且检测这个符号引用代表的类是否已经被加载/解析/初始化过, 如果没有, 就执行类加载过程.
2. 在类加载过后, JVM将为新生对象分配内存. 对象所需的内存就会确定下来. 为对象在Java堆上分配内存的方法有两种: 指针碰撞和空闲列表.
- 指针碰撞: 这种分配内存的方法是将整个Java堆被一个临界指针分为两段, 一段是已分配出去的内存, 另一段是未分配的内存. 当为新生对象分配内存的时候, 就将临界指针向未分配的那段内存移动新生对象所需的内存大小的距离.
- 空闲列表: 如果Java堆并不像指针碰撞方法情况下那样规整, 而是已分配内存和未分配内存相互交错, 那么JVM就需要维护一张”空闲列表”, 这张表上记录着哪些内存已经被分配, 哪些未分配. 当有新生对象需要分配内存的时候, 就从列表种找出一块足够大的空间划分给新生对象, 并更新”空闲列表”上的记录.
3. 接下来JVM就要将分配到的内存空间都初始化为零值, 并且将这个对象的基本设置记录在对象的对象头中. 比如这个对象是哪个类的实例, 如何才能找到类的元数据信息, 对象的哈希码, 对象的GC分代年龄等信息.
4. 其实到现在为止, 对JVM来说, 一个对象已经产生了. 但是在程序员看来, 对象的创建才刚刚开始. 此时, 这个新生对象要执行方法, 按照程序员的意愿对它进行初始化. 这样, 一个真正可用的对象才算创建完成.
2. 对象的内存布局
在HotSpot(一种虚拟机, 也是本系列博客讨论的虚拟机)JVM中, 对象在内存中的存储布局分为3个区域: 对象头, 实例数据, 对齐填充.
(1) 对象头
对象头主要包含两部分信息: 自身的运行时的数据(比如哈希码, GC分代年龄, 锁状态标志, 线程持有的所, 偏向线程ID, 偏向时间戳等)和类型指针(即对象指向它的类元数据指针, JVM通过这个指针来确定这个对象是哪个类的实例). 另外, 如果这个对象是一个Java数组, 那么在对象头中还要有一块数据用于记录数组长度.
(2) 实例数据
实例数据就是对象真正存储的有效信息, 也就是在程序代码中所定义的各种类型的字段内容(即从父类中继承下来的数据和在子类中定义的数据).
(3) 对齐填充
它仅仅是起着占位符的作用, 与操作系统中内存字节对齐一样, 是为了提高寻址效率. 在HotSpot VM的自动内存管理系统中要求对象的起始地址必须是8字节的整数倍, 当对象的实例数据部分没有字节对齐时, 就通过对齐填充来补全.
3. 对象的访问定位
前面说完了对象是如何创建的, 那么它是怎样访问的呢?
我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象. 而reference访问堆上的对象的位置有两种方式 : 使用句柄和直接指针.
(1) 使用句柄
如果是使用句柄来访问Java对象, JVM会在Java堆中划分出一块句柄池的内存区域, 栈中的reference存储是句柄池中对象的句柄地址, 而句柄地址里面存储对象在堆上真正的地址.
这种方式的好处就是在垃圾回收时移动对象时只会更改句柄池中实例的地址, 而reference的值不需要改变. 但是它的坏处是访问速度较慢, 因为要经过两次指针定位.
(2)直接指针
如果是使用直接指针来访问Java对象, 那么栈中的reference中存储的就直接是对象在堆中的真正地址.
这种方式的好出就是访问对象的速度快, 节省了一次指针定位, 由于Java对象的访问非常频繁, 所以积累下来节省了一笔很可观的开销. 但是它的坏处就是在对象移动时需要去更改栈中reference中的值.