本系列文章整理全基于对《深入理解Java虚拟机:JVM高级特性与最佳实践》的阅读理解之上,其中大部分的概念解释及说明均来自于此书
这一部分主要了解一下JVM(Java 虚拟机)内存模型及相关区域概念。
概述
JVM内存模型就是java程序在运行过程中的数据存储区域分配,Java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。每个区域都有各自特定的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。Java虚拟机所管理的内存模型如下图所示。
运行时数据区
1. 程序计数器:
Program Counter Register,是一块比较小的内存空间,用来标识当前线程所执行的字节码指令的地址(简单理解就是记录程序执行到哪一行代码)。多线程场景下每条线程拥有自己独立的程序计数器,各条线程之间的计数器互不影响,独立存储,因此这类内存区域我们可以称为“线程私有”的内存区域。
2. Java虚拟机栈(用来存储当前线程运行方法时所需要的数据、指令、返回地址) :
与程序计数器一样,Java Virtual Machine Stacks 也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态连接件,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。Java虚拟机规范中,对这个区域定义了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常:如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
接下来将通过一段代码以及 javap 指令反解析出的类信息来分析虚拟机栈各部分的构成
这里主要通过methodOne方法来分析java虚拟机的相关指令,以及在虚拟机栈中所做的操作:
- 首先分析一下图片左侧箭头所指向的指令的具体含义
0: iconst_0 //将int类型常量0压入操作数栈
1: istore_2 //弹出操作数栈栈顶元素,保存到局部变量表第2个位置
2: iload_1 //从局部变量0中装载int类型值压入操作数栈
3: iload_2 //从局部变量2中装载int类型值压入操作数栈
4: iadd //弹出操作数栈中的前两个int相加,并将结果压入操作数栈顶
5: istore_3 //弹出操作数栈栈顶元素,保存到局部变量表第3个位置
6: aload_0 //从局部变量0中装载引用类型值
第一行代码:申明一个int 变量 j 是由两条字节码码指令 iconst_0 和 istore_2 共同完成的。从上面的字节码解析中可以得知,这个j 是存在局部变量表中的第二个位置上?为什么是第二个位置?
第二行代码:计算 i + j 的值 并赋值给 sum 对应箭头所示的四条字节码指令
第三行代码:Object obj2 = obj ; 先执行了 aload_0 ,这时我们就知道了局部变量中0的位置存的是this(注意只在成员方法中,0的位置存的是this)。
通过上面的图以及具体字节码指令的解释,很容易就弄明白虚拟机这期间都做了什么,接下来看一下虚拟机栈中的结构图。
另:关于 javap指令集的含义可以参考这篇文章:Javap 指令集
3. 本地方法栈:
Native Method Stack 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOIverflowError和OutOfMemoryError异常。
4. Java堆:
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。同时,Java堆又是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在现实时,既可以实现成固定大小的,也可以是可扩展的,主要都是通过-Xmx 和-Xms参数控制,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
5. 方法区 :
Method Area 与Java 堆一样,是各个线程共享的内存区域,用于存放Class的相关信息。如类名,访问修饰符,常量池,字段描述,方法描述等。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap,目的应该是与Java堆区分开来。同样,当方法区无法满足内存分配需求时,也将抛出OutOfMemoryError异常。
6. 运行时常量池:
Runtime Constant Pool 是方法区的一部分。Class文件中,除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符合引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。注意运行时常量池与Class文件常量池一个重要的区别特征就是运行时常量池具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类中的intern()方法。
7. 直接内存:
Direct Memory 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。JDK 1.4中入了NIO(New Input / Output) 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆内外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
HotSpot虚拟机对象
1. 对象的创建:
-
检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有—那必须先执行相应的类加载过程,详情见文章:虚拟机类加载机制
-
分配内存
对象所需内存在类加载完毕之后就可以完全确定,为对象分配内存空间的任务等同于把一块确定的大小的内存从Java堆中划分出来。根据Java堆中内存是否规整主要有两种分配方式:
1. 堆内存规整时:采取“指针碰撞”的分配方式,即所有用过的内存放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存时仅仅需要把指针向空闲空间那边挪动一段与对象大小相等的距离
2. 堆内存不规整时:采取“空闲列表”的分配方式,假设虚拟机中已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。同时在分配内存的时候会出现并发的问题,比如在给A对象分配内存的时候,指针还没有来得及修改,对象B又同时使用了原来的指针进行了内存的分片。面对这种情况有两种解决方式:- 对分配的内存进行同步处理,CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中分配一块小内存,称为本地缓冲区,那个线程需要分配内存,就需要在本地缓冲区上进行,只有当缓冲区用完并分配新的缓冲区的时候,才需要同步锁定
-
对象初始化
在为对象分配内存之后,需要对对象进行一些必要的设置,比如对象所属的类、对象的hash码、对象的GC分代年龄,最后执行对象的init方法,这样一个真正可用的对象才是完成。
2. 对象的内存布局
在HotSpot虚拟机中,对象在内存中储存的布局可以分为3块区域:对象头、实例数据和对齐填充。
-
对象头:
- 储存对象自身的运行时数据:如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
- 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例
-
实例数据:
是对象正常储存的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录下来
-
对齐填充:
对齐填充不是必须的,仅仅起到占位符的作用。例如,对象的大小必须是8字节的整数倍,而对象头刚好是8字节的整数倍(1倍或者2倍),当实例数据没有对齐的时候,就需要通过对齐填充来补全
3. 对象的访问定位
Java程序需要通过栈上的reference数据来操作具体的对象,从而就需要虚拟机知道该通过何种方式去定位,访问堆中对象的位置
-
句柄访问:
Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址。
句柄访问的优势在于,reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
-
直接指针访问:
使用直接指针访问的话,Java堆对象的布局就必须考虑如何访问类型数据的相关信息,而refreence中存储的直接就是对象的地址。
直接指针访问的优势在于,速度更快,节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
4. OutOfMemoryError 异常(OOM)
-
java堆溢出
Java堆用于存储对象实例,只要不断的创建对象,并且保证GCRoots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在数量到达最大堆的容量限制后就会产生内存溢出异常。可通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照。
/*
* vm args : -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOomTest {
static class OomObj{
}
public static void main(String[] args) {
List<OomObj> ooms = new ArrayList<>();
while (true){
ooms.add(new OomObj());
}
}
}
***************************************************************************
输出结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid4580.hprof ...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at test.HeapOomTest.main(HeapOomTest.java:28)
Heap dump file created [28331518 bytes in 0.086 secs]
-
虚拟机栈和本地方法栈溢出
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError;如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
在单线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。
如果是多线程导致的内存溢出,与栈空间是否足够大并不存在任何联系,这个时候每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。解决的时候是在不能减少线程数或更换64为的虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
-
方法区和运行时常量池溢出
因常量池分配在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。
String.intern() 方法,如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
在JDK1.6中, intern方法会把首次遇到的字符串实例复制到永久代,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是一个引用。
而在JDK1.7中,intern()方法的实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。
/*
* vm args :-XX:PermSize=10M -XX:MaxPermSize=10M
*/
public class RuntimeConstansPoolOom {
public static void main(String[] args) {
//使用List保持常量池引用避免回收
List<String> strs = new ArrayList<>();
int i = 0;
while (true){
strs.add(String.valueOf(i++).intern());
}
}
}
************************************************************************
输出结果:
jdk 1.7之后不会输出任何结果
-
本机直接内存溢出
DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆的最大值(-Xmx指定)一样。代码清单2-9越过了DirectByteBuffer类,直接通过反射获取Unsafe实例并进行内存分配(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。
public class DirectMemoryOOM {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
********************************************************************************
输出结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at test.RuntimeConstansPoolOom.main(RuntimeConstansPoolOom.java:29)