在大学初学Java的时候,老师在教我们如何在控制台打印hello,world之前,就先给我们介绍了这两个知识点:
-
java是一门运行在JVM(Java Virtual Machine)上的语言。JVM负责解释和执行Java程序的字节码,并提供了内存管理、垃圾回收等功能,使得Java程序具备跨平台的特性。
-
Java程序的执行过程是先由编译器将java文本文件编译成class文件,JVM将编译好的class文件加载进内存中解释执行。
当时的我真的是一头雾水甚至觉得没必要了解。毕竟会写代码就行了嘛,搞懂这玩意有什么用呢?但出来实习后,随着每天不断的写bug、debug,越发觉得搞懂这玩意是很有必要的。面经上看来的虽然是重点但毕竟零碎不成体系,所以这几天利用休息时间,通过观看课程和相关文章,系统的了解了一下JVM的相关知识。顺便写篇文章记录一下,方便日后复习。
JVM为什么要进行内存区域划分
在介绍JVM内存模型之前,我们先思考一个问题,为什么JVM要进行内存区域划分?这个问题也好回答,就像是我们自己在家也会把衣服放进衣柜里,把碗筷放进橱柜。这样我们找衣服的时候就不需要把家里翻个底朝天,去衣柜拿就是了,洗完碗把碗放进壁橱也不用担心会弄湿刚洗好的衣服。合理的划分内存可以让我们更加方便高效的处理程序,也可以有效的隔离不同的数据,提高程序的安全性和稳定性。
内存区域的划分还可以帮助JVM进行垃圾回收。通过内存区域划分可以将需要进行垃圾回收的区域和不需要进行垃圾回收的区域分开,这样垃圾回收时可以更加精确的定位到需要回收的垃圾,减少系统性能的消耗和垃圾回收所占用的时间。在不同的区域还可以采用不同的垃圾回收算法,更加灵活的管理内存。
JVM内存的划分
JVM将内存划分成了五个部分。堆内存、方法区、程序计数器、虚拟机栈、本地方法栈。为了方便记忆和理解可以将这五部分分为两种内存区域。一种是线程共享区,一种是线程私有区。
线程共享区
线程共享区中分配的内存被所有的线程共享,而且该区域占用的JVM内存也比较大。 线程共享区包括堆内存和方法区。
堆内存
堆内存是JVM所管理的内存中最大的一块。堆内存为所有线程共享用于存放对象实例。JVM规范中的描述是:所有的对象实例以及数组都要在堆上分配。当然随着技术的发展现在也没那么绝对。
堆内存是垃圾收集器管理的主要区域,由于现在的垃圾收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代。再细致一点还有Eden空间,From Survivor空间、To Survivor空间。不过无论如何划分堆中存储的仍然都是对象实例。另外Java堆是逻辑连续的区域而不是物理连续的区域。也就意味着java堆大小不是由一块磁盘内存的大小固定的而是可拓展的。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区有很多种称呼,为了和堆内存作区分,可以叫它非堆,1.7之前可以叫它永久代,1.7之后则可以称其为元空间。至于为什么从永久代改为成为元空间,是因为之前用永久代实现方法区,经历过多次垃圾回收没被干掉的对象会被放进来,这样会导致方法区可能出现OutOfMemoryError的问题。
1.8之后则使用本地内存来实现方法区,只存储类的元数据,不再存储常量池 ,类的静态变量等数据。而且因为使用本地内存来做方法区,所占内存不再是一个固定大小,可以根据需要自主拓展,上限是本地剩余内存大小,所以一定程度上避免了OOM问题的发生。
线程私有区域
线程私有区域如下图所示:
程序计数器
程序计数器是一块比较小的内存空间,也是JVM中唯一不会发生oom的内存空间。程序计数器可以看做当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成。
由于jvm的多线程是通过轮流切换并分配处理器执行时间的方式来实现,所以在任意时刻,一个处理器内核都只会执行一条线程中的指令。因此为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间的计数器互不影响。
如果正在执行的是Native方法,这个计数器值为空(Undefined)
Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时 都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
方法调用时会创建一个栈帧压入虚拟机栈,由于栈是一个先进后出的结构,所以当在一个方法A运行时调用另一个方法B,会暂停该方法A的执行,直到方法B执行完毕,所对应的栈帧弹出,方法A的栈帧重归栈顶。方法A才会继续执行。这也导致了如果调用过程中一个方法中因异常停止执行,这个虚拟机栈中的所有方法都会停止运行。如果出现异常没有选择用try-catch进行异常处理,而是通过throws进行向上抛出,那么这个异常将会被层层抛出直到Main方法。所以我们程序报错才会弹出那么多异常。
由于方法每次被调用的时候都会创建一个栈帧,所以当递归调用一个没有终止条件的方法或者递归深度大于虚拟机所允许的深度时,会导致虚拟机抛出StackOverflowError异常。
虚拟机栈帧结构
虚拟机栈帧也分为了四层结构,分别是:
1.局部变量:存储基本数据类型和对象引用(4字节)
- 操作数栈:方法中变量进行运算的区域。形如a+b这类的运算需要将a和b的值从局部变量中取出,放入到操作数栈中进行运算,而i++这类操作则是直接在局部变量那块区域完成计算。
3.常量数据的引用:存放常量数据的引用。被final修饰的变量都会被存储到常量池中,所以想要在方法中使用一个常量,需要一个指向该常量的引用。
4.方法返回值地址:方法返回值会存放到计算机的寄存器中,该引用指向寄存器内存地址。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚 拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
一个程序的执行过程如图所示:
对象的内存布局
都写到这了,干脆把对象的内存布局一块写了吧,JVM内存啥样都清楚了不了解JVM里对象是什么样的有点不像话。Java对象在内存中主要包括三个部分:对象头 、实例数据 、对齐填充。
对齐填充主要是为了保证对象占用的字节为8的倍数。
对象头
- MarkWord:一系列标记位(哈希码,分带年龄,锁状态标记等)在64位操作系统中占8字节
2.ClassPoint:对象对应类信息的存储地址。类信息存储在方法区,被每个对象共享。在64位操作系统中占8字节
3.length:数组对象独有,表示数组长度。在64位操作系统中占4字节
实例数据
实例数据就是包含了该对象的所有成员变量。占的内存大小由变量数量和类型决定。其中数据引用类型占8字节。