深入理解Java虚拟机系列(一)--Java内存区域和内存溢出异常
前言
我学Java也学了很久了,但是仔细想想,看了那么多的技术博客也好、面经也好,我自己都没有在Java虚拟机这块做一个统一的笔记整理,哪怕这些基础知识懂了,但是我觉得还是要做一个系统性的复习。因此选择读《深入理解Java虚拟机》这本书,并写下了这篇博客。
系列文章目录
一.运行时数据区域
大家知道,对于Java程序员来说,我们不需要在代码中为每一个对象操作他的生命周期,而是由虚拟机来进行内存的自动管理,Java虚拟机在执行Java程序的过程中会把他所管理的内存划分为若干个不同的数据区域。
如图(Java1.8之前):
Java1.8:改变:方法区被整合到堆中,其中一部分被整合到元空间当中。
那么这里先从几个基本概念讲起。
1.1 程序计数器
程序计数器是一块很小的内存区域,并且是内存区域当中唯一的不会发生内存溢出的区域, 他可以看成是当前线程所执行的字节码的行动指示器。
作用:
- 字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因为任何一个时刻,一个处理器只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,需要有一个独立的程序计时器来代表程序执行到哪了。
特点:
- 每个线程都有个独立的程序计数器。
- 在内存当中线程私有,各个线程之间互不影响。
- 唯一不会OOM的区域。
- 如果线程执行的是一个Java方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址。如果执行的是Native方法,那么计数器值为空(Undefined)。
1.2 Java虚拟机栈
虚拟机栈同程序计数器一样是线程私有的,因此他们的生命周期是和线程相同的。虚拟机栈描述的是Java方法执行的内存模型。
虚拟机栈包括:局部变量表、操作数栈、动态链接、方法出口。
其中,局部变量表存放着各种基本数据类型(8大基本数据类型)。
作用:
- Java当中的每一个方法从调用直至执行完成的过程,就对应这一个栈桢在虚拟机栈中入栈到出栈的过程。
- 存放基本数据类型的变量。
Java虚拟机规范对该区域(以及下面的本地方法栈)规定了两种异常情况:
1.StackOverflowError:如果线程请求的栈深度>虚拟机所允许的深度。
2.OutOfMemoryError:如果虚拟机栈在扩展时无法申请到足够的内存。
特点:
- 线程私有。
- 保存各种基本数据类型。
- 内存空间在编译期间完成分配。
1.3 本地方法栈
本地方法栈与上面的虚拟机栈其实是一样的。唯一的区别就是:
- 虚拟机栈为虚拟机执行Java方法做服务。
- 本地方法栈为虚拟机使用的Native方法做服务。
如Object里面的notify()方法就是经过native修饰的,放图(免得以后面试官啥的问你举个例子,说不出来):
当然Object类当中的很多方法也是native修饰的,如hashCode()、getClass()等。
1.4 Java堆
作用:
- 唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
特点:
- 属于虚拟机管理内存当中最大的一块。
- 线程共享。
- 在虚拟机启动的时候创建。
- 作为GC管理的主要区域,因此堆也称为GC堆。(当然,堆其实还能划分为很多区域,这个会在后续文章中详细介绍)
- Java堆允许处于物理上不连续的内存空间中,只要逻辑上连续即可。
1.5 方法区(也称永久代)
作用:
- 方法区用于存储已经被虚拟机加载的类信息、常量、静态变量、即是编译器编译后的代码等数据。
特点:
- 线程共享。
解释下为什么方法区也称为永久代:
永久代和堆本质上并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者使用永久代来实现方法区而已,这样HotSpot的GC可以像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
Java虚拟机规范对方法区的限制非常宽松,除了和堆一样不需要连续的内存和可以选择固定大小或者可拓展外,还可以选择不实现垃圾收集。
讲道理,咱们现在用的都是jdk1.8了,方法区已经被整合到堆里面了,我个人觉得,只要记住,方法区存啥?存常量和静态变量并且是线程共享即可。
1.6 运行时常量池
运行时常量池属于方法区的一部分。Class文件当中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
作用(这两个作用其实就是类加载过程当中的某个步骤):
- 保存Class文件中描述的符号引用。
- 把翻译出来的直接引用存储在运行时常量中。
特点:
- 具备动态性,即不要求常量一定只有在编译期才能产生,而在运行期间也可能将新的常量放入到池中,如String类的intern()方法。
- 受到方法区内存的限制,可能会OOM。
public static void main(String[] args) {
String str1 = new String("hello") + new String("world");
// 将helloworld放入到常量池中
str1.intern();
// 这里先去常量池中看是否有这个变量,发现有了。
// 那么会直接返回赋值给str2,引用也是指向的同一个。
String str2 = "helloworld";
System.out.println(str1 == str2);//true
// 这里的str3指向的是堆中的对象,而str4指向的是常量池中的位置
String str3 = new String("hhh") + new String("lll");
String str4 = "hhhlll";
str3.intern();
System.out.println(str3 == str4);//false
}
1.7 (Java1.8)元空间
元空间是保存元数据的地方,如方法、字段、类、包的描述信息,这些信息可以用于创建文档、跟踪代码中的依赖性、执行编译时检查。
作用:
- 因为原本永久代的GC特别难搞,严重影响了FullGC的性能,于是抛弃了永久代,使用元空间来解决GC的问题。
- 类加载器存储的位置就是元空间,每一个类加载器的存储区域都称作为一个元空间,如果一个类加载器被GC标记为死亡,那么其对应的元空间也会被回收。
特点:
- 元空间使用直接内存,相对来说内存空间大。
- 元空间有单独的元空间虚拟机执行内存分配和垃圾回收。
- 元空间的内存分配由元空间虚拟机负责,采用的形式是组块分配。
组块分配:
元空间虚拟机维护着一个全局的空闲组块列表,当一个类加载器需要元空间内存的时候就从这里面找一块,用完后再释放。类加载器维护的这个组块又分为多个小块,每一块存储一个单元的元信息,分配方式为指针碰撞法。
小总结
Java1.8之前:
- 线程私有:程序计时器(唯一没OOM的区域)、虚拟机栈、本地方法栈。
- 线程共享:堆、方法区。
- 直接内存
Java1.8开始:
- 线程私有:程序计时器、虚拟机栈(8大基本数据类型)、本地方法栈。
- 线程共享:堆(包含方法区,存储常量和静态变量)。
- 直接内存以及元空间
二.HotSpot虚拟机对象探秘
在了解完java虚拟机的内存情况后,接下来了解对象分配、布局和访问的过程。
2.1 对象的创建
一般我们创建一个对象都是通过new一个对象来实现的,那么当虚拟机遇到一条new指令后,会做什么?
- 检查这个指令的参数是否能在常量池当中定位到一个类的符号引用。
- 检查这个符号引用代表的类是否已经被加载、解析、初始化过。
- 若没有,那必须先执行相应的类加载过程。
- 类加载检查通过后,接下来虚拟机将会为新生的对象分配内存。
而分配内存的方式有两种:
- 指针碰撞
在Java堆内存是连续的情况下:
所有用过的内存放在一侧,空闲的内存放在另一侧,中间放着一个指针作为分界点的指示器,那所谓分配内存就是仅仅把这个指针向空闲内存的一侧挪一小段与对象大小相等的距离。
- 空闲列表
在Java堆内存不连续的情况下:(这种方式肯定不能进行指针碰撞了)
虚拟机维护一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并且更新列表上的记录。
其实可以看出来,对象内存的分配方式是根据Java堆是否规整连续来决定。而Java堆是否规整连续由有其采用的GC是否带有整理功能来决定。
- 使用Serial、ParNew等带Compact过程的收集器时,采用指针碰撞。
- 使用CMS等基于Mark-Sweep算法的收集器,采用空闲列表。
其实还有个需要考虑的问题是:对象创建在虚拟机中是非常频繁的行为,如果采用指针碰撞,即通过修改一个指针指向的位置来分配内存,那么在并发情况下也并不是线性安全的。 例如:正在给对象A分配内存,指针还没来得及进行修改,对象B又同时给使用了原来的指针分配内存的情况。
这种问题的解决有两种方案:
- 一种是对分配内存空间的动作进行同步处理:实际上虚拟机采用CAS配合失败重试的方式来保证更新操作的原子性。
- 一种是把内存分配的动作按照线程划分在不同的空间之中执行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB,Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程上的TLAB上分配,只有TLAB用完并分配新的TLAB时候,才需要同步锁定。
回到上述new一个对象后,虚拟机会做什么的问题。
在内存分配完成后,虚拟机会将分配到的内存空间都初始化为0,并且对对象进行必要的设置,例如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希吗、GC分代年龄等信息。然后将这些信息存放在对象头中。 那这些工作完成后,对于虚拟机来说,这个新的对象已经产生了。但是对于Java程序来说,对象创建才刚刚开始,因为init()方法还没完成。只有初始化完成,这样一个真正可用的对象才算完全产生。
2.2 对象的内存布局
在上文我们提到了对象头,那么这里就对其进行展开。HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
重点来说下对象头,对象头包括两部分信息。
2.2.1 对象头的第一部分:Mark Word
第一部分用于存储自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏相关时间戳等。这些数据肯定是有一个长度的对吧,长度如下:
- 32位的虚拟机下(未开启指针压缩)----->:长度为32bit
- 64位的虚拟机下(未开启指针压缩----->:长度为64bit
考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储经量多的信息,它会根据对象的状态复用自己的存储空间。
例如:在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态,那么Mark Word的32bit空间中的安排是这样的:
25bit用来存储对象哈希码。
4bit用于存储对象分代年龄。
1bit固定为0。
2bit用于存储锁标志位。
其中标志位的存储表如下:
2.2.2 对象头的第二部分:类型指针
类型指针即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。另外,如果对象是一个Java数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法得到其大小。
2.2.3 实例数据部分
这里存储的是对象真正存储的有效信息,也是在程序代码中定义的各种类型字段的内容。并且,无论是从父类继承下来的还是在子类中定义的,都需要记录下来。
再讲一下数据的存储顺序。实例数据部分的信息存储受到虚拟机分配策略参数和字段在Java源码中定义顺序所影响。 HotSpot默认的分配策略为:long/doubles、int、short/char、bytes/boolean。可以看出,相同宽度的字段总是被分配到一起,在满足这个前提的条件下,父类中定义的变量会出现在子类之前。
最后唠叨一下,大概记住这一部分属于对象的内存布局之一,负责存储真实的数据信息的即可。
2.2.4 对齐填充部分
这一部分内容其实并不是必然存在的, 也没有什么特别的含义,仅仅起着占位符的作用。因为对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍,所以在对象实例数据部分没有对齐时,需要通过对齐填充部分来进行补全。
2.3 对象的访问定位
建立对象是为了使用对象,我们Java程序则需要通过栈上的reference数据来操作堆上的具体对象。接下来就讲一下对象的访问方式,目前主流的访问方式有使用句柄和直接指针两种。
句柄访问:Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
优势:reference中存储的是稳定的句柄地址,在对象被移动的时候只会改变句柄中的实例数据指针,而reference本身不需要修改。
直接指针:Java堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,reference存储的直接就是对象地址。
优势:速度比句柄访问更快,节省了一次指针定位的时间开销,因为对象访问在Java中非常频繁,因此这类开销积少成多后也是一个非常可观的成本。而Sun HotSpot而言就是使用的直接指针的方式去进行对象访问的。
三.Java内存溢出异常
接下来就模拟各种异常,本章主要是模拟各种异常的场景,而具体的解决方式和排查、工具的选择等问题准备放到后续博客来讲。言归正传,模拟这些异常的前提:先把Java虚拟机的环境参数改一改。
1.创建一个Test类,然后在右上角点击:
2.输入环境参数:
输入后点击保存
-verbose:ge -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
其中限制Java堆的大小为20MB,不可拓展(堆的最小值-Xms和最大值-Xmx都为20MB,则可以避免堆的自动扩展)
模拟Java堆溢出
public class Test {
static class OOMObject{
}
public static void main(String[] args) {
ArrayList<OOMObject> list = new ArrayList<>();
while (true){
// 不断创建对象,让堆的容量被消耗完
list.add(new OOMObject());
}
}
}
结果:
模拟虚拟机栈和本地方法栈溢出
首先说明一点,HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,因此栈容量只用修改-Xss参数即可。
加上环境参数:
-Xss160k
代码:
public class Test {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable{
Test test = new Test();
try {
test.stackLeak();
} catch (Throwable e) {
System.out.println("stack Length:" + test.stackLength);
throw e;
}
}
}
结果:如果说线程请求的栈深度>虚拟机所允许的最大深度,则泡池StackOverflowError异常。
模拟方法区和运行时常量池溢出
添加环境参数:
注意:如果是jdk1.7是可以添加如下参数的,如果是jdk1.8的,那方法区都被整合到堆里面了,以下参数肯定是无效的,只能修改堆的大小(堆中包含了方法区)。
#jdk1.7
-XX:PermSize=10M -XX:MaxPermSize=10M
#jdk1.8
-Xms10M -Xmx10M
代码:
public class Test {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
int i=0;
while (true){
list.add(String.valueOf(i++).intern());
System.out.println(i);
}
}
}
结果:OOM之前发生的一次GC,没有进行任何的回收,也就是GC无法回收,实际上就是GC回收的内存太少,没有内存涨的快,大多数产生了不可回收的内存数据。
如果是jdk1.7(因为我的环境不是1.7的,也懒得装,就把书中的结果拿过来好了),结果如下图:关注PermGen space即可。
模拟本机直接内存溢出
配置直接内存环境变量:
-XX:MaxDirectMemorySize=1M -Xmx5M
代码:
public class TestDirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws IllegalAccessException {
// java不能直接访问操作系统底层,而是通过本地方法来访问。Unsafe类提供了硬件级别的原子操作
Field field = Unsafe.class.getDeclaredFields()[0];
// 作用就是让我们在用反射时访问私有变量
field.setAccessible(true);
Unsafe unsafe = (Unsafe) field.get(null);
while (true) {
// 申请分配内存
unsafe.allocateMemory(_1MB);
}
}
}
结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at com.oom.TestDirectMemoryOOM.main(TestDirectMemoryOOM.java:24)
四.总结
本篇文章主要讲了Java虚拟机的内存区域、虚拟机对象创建的过程、对象的内存布局、对象的访问方式以及模拟几种内存溢出异常。下一篇文章准备从垃圾收集器和内存分配策略角度来讲。