前言
项目中,本人编写的程序在开发时调试都没什么问题,上线运行了一段时间,便出现了请求卡顿的情况,有时甚至会出现崩溃。这一切造成的原因,是因为本人对Java只是浅层面的会用,而对它底层和在JVM中运行的原理知之甚少。众所周知,《深入理解Java虚拟机》是Java开发人员的必修基础书,亡羊补牢,为时不晚,是时候捧起书来好好学一学了。
本章知识点
本章是《深入理解Java虚拟机》的第二章知识梳理,主要内容有:
- 运行时数据区域
- 对象创建过程
- 对象内存布局与访问定位
Java运行时数据区域
JVM为了更好地进行内存管理,将它所管理的内存划分为若干个不同的数据区域,根据《Java虚拟机规范(JavaSE 7版)》的规定,JVM会包括以下几个运行时数据区域:
区域名称 | 是否线程共享 |
---|---|
程序计数器(Program Counter Register) | N |
虚拟机栈(VM Stack) | N |
本地方法栈(Native Method Stack) | N |
堆(Heap) | Y |
方法区(Method Area) | Y |
上表中是否线程共享为N代表着该区域是线程私有的内存,不同线程拥有各自的内存区域且互相不受影响,独立存储。下面,是对各区域的解释说明:
程序计数器
我们在初学JAVA的时候就知道,我们编写的.java格式的源码需要通过javac编译成字节码才能被JVM执行,在这里,程序计数器可以看作是当前线程所执行的字节码的行号指示器,JVM可以通过改变它的值来选取下一条线程中的指令,并且控制分支、循环、跳转等操作都依赖于它。
JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的程序计数器来记录当前线程的执行位置。
如果当前线程正在执行一个Java方法(可以看做是类中的普通方法),这个计数器记录的是正在执行的虚拟机字节码指令地址。如果正在执行的是Native方法(一个Native 方法就是一个java调用非java代码的接口),这个计数器则为空。该内存区域是唯一没有规定任何OutOfMemoryError情况的区域。
虚拟机栈
虚拟机栈也是线程私有的。每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储局部变量表(七种基本数据类型、对象引用类型地址)、操作数栈、动态连接、方法出口等信息。每个方法从调用到执行完成,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
除了64位长度的long和double类型占2个局部变量空间(Slot),其余的数据类型只占用1个;
该内存区域规定了两种异常情况:
- StackOverflowError 线程请求的栈深度大于虚拟机允许的深度
- OutofMemoryError 虚拟机栈动态扩展时无法申请到足够的内存
本地方法栈
本地方法栈类似于虚拟机栈,他们的区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。HotSpot虚拟机把本地方法栈与虚拟机栈合二为一。
Java堆
堆是JVM所管理的内存中最大的一块,目的是存放对象实例,所有线程共享该空间。
为了更好地垃圾回收(GC),通常将该空间划分为新生代和老年代,更细致的划分如使用复制算法会将其划分为Eden空间、From Survivor空间、To Survivor空间。
堆可以处于不连续的物理内存空间中,只需要是逻辑上连续即可。可以通过-Xmx和-Xms控制其空间拓展,当堆无法拓展时,会抛出OutOufMemoryError异常。
方法区
方法区也是线程间共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,其中运行时常量池用于存放编译期生成的各种字面量(如:String a = "aaa"中的"aaa"是字面量)和符号引用(符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可)。
因为HotSpot JVM选择把GC分代收集扩展至该区,所以因为该区域经常被称为永久代。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
直接内存
直接内存并不属于JVM运行时数据区的一部分,而是属于本机内存,该部分内存也会被频繁使用,使用NIO时,会分配直接内存给缓冲区,如果忽略了直接内存并且超出了物理内存的限制,将抛出OutOfMemoryError异常。
HotSpot虚拟机对象探秘
在大概了解了JVM所管理的内存分配区域后,便可以将对象按照各区域的功能进行拆分,放入对应区域中。接下来,我们来看一下对象的创建。
对象的创建
我们在代码中是创建对象很简单,只要通过下面这段代码便完成了对象的创建:
/**
* @author ccoke
*/
public class Student {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public static void main(String[] args) {
Student student = new Student("ccoke", 24);
System.out.println("student name:" + student.name);
}
}
那么在生成对象之前,JVM为我们做了哪些事情?简略步骤如下:
- 监测类加载。虚拟机遇到一条new指令时,检查这个指令是否在方法区常量池中定位到对应类的符号引用,并且检查该符号引用代表的类是否已被加载、解析和初始化过。如果没有,则必须先执行相应的类加载过程。
- 为新生对象分配内存。内存的分配方式可以分为指针碰撞与空闲列表。指针碰撞指的是Java堆中内存绝对规整,所有正在使用的内存都放一边,空闲的放另一边,中间使用一个指针指示分界点,分配内存时把指针向空闲那边挪动一段与对象相等的距离;空闲列表指的是Java堆中的内存不是规整的,已使用的内存和未使用的内存互相交错,虚拟机需要维护一个表,用于记录哪些内存块可用。Java堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定。
- 将分配到的内存空间初始化为零值(如上段代码中,会将name设置为null,age设置为0),并设置对象头。
- 执行
方法,将对象根据程序员的意愿初始化(本人的理解是类似于执行构造函数,如上段代码中将name设置为"ccoke", age设置为24)。
对象的内存布局
这部分是对象在内存中存储区域的补充,在HotSpot虚拟机中,对象在内存中可分为三块区域:对象头、实例数据和对齐填充。
对象头包括两部分信息,第一个部分用于存储对象自身的运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机用这个指针来确定这个对象是那个类的实例。
实例数据用于存储对象的有效信息,也是程序代码中所定义的各种类型的字段内容,HotSpot默认的分配策略中,相同宽度的字段总是被分配到一起,在满足这个的条件下,在父类定义的变量会出现在子类之前。
对象填充没有特别含义,仅仅起着占位符的作用。因为HotSpot的自动内存管理系统要求对象起始地址必须是8字节的整数倍(对象的大小必须是8字节的整数倍),当对象实例部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位
对象生成后,便可以开始对对象进行调用了。一般我们调用对象,目的就是为了获取对象的属性值,或者是为了使用对象对应类提供的方法。前面我们讲过,对象的实例属性再堆中,而类信息(方法)在方法区中,那么,我们如何通过引用变量来获取它们呢?主流的访问对象的方式有使用句柄和直接指针两种。
使用句柄访问对象的方式如下图所示,Java堆中划分出一块内存作句柄池,用于存放对象实例数据的指针与类型数据的指针,栈上的引用变量存储的是对象的句柄地址。这个方式的好处是当对象被移动时,只改变句柄中的实例数据指针,引用变量本身并不需要修改。
而使用直接指针访问对象,跟通过句柄访问对象不同的是, 栈上的引用变量存储的是对象在堆中的地址,这个方式的好处是速度快,节省了一次指针定位的时间开销。
总结
通过本章的学习,我们基本知道了JVM运行时数据区域以及功能,概括一下,程序计数器用来记录当前线程执行字节码的位置;虚拟机栈会在每个方法执行时创建一个用于存储局部变量表、操作数栈、动态连接、方法出口的栈帧;本地方法栈类似于虚拟机栈,它为虚拟机使用到的Native方法服务;堆用于存放对象实例,在垃圾回收中,通常将该空间划分为新生代和老年代,并且它只需要处于逻辑连续的物理内存空间;方法区用于存储已被JVM加载的类信息、常量、静态变量、即时编译器后的代码等数据,运行时常量池包含其中,用于存放编译期生成的字面量与符号引用。除了堆与方法区,其他区域都是线程私有的。接下来我们学习了简单的对象创建过程、对象内存布局与访问定位,加深了对Java内存区域的理解。第2章总体来说没有难度,理解并掌握它,我们下章见!
...
// hey guy!
if( isValuable(this.article) && (like(this.article) || follow("ccoke"))) {
System.out.println("Thank you! XD");
} else {
System.out.println("I will continue to work hard!T.T");
}
...