Java虚拟机(JVM)内存区域划分详解
最近一直没有怎么更新自己的博客,主要是由于老哥公司最近的一个招标项目忙得焦头烂额,心力憔悴(ಥ_ಥ),趁着项目的空档期来重构一下以前的一篇关于jvm内存区域划分的博客,仔细阅读了一下之前的博客,大量的文字叙述可能对于读者来说,看到的第一眼就不想读下去了,吸收各方面的意见,为了让初学者更好的理解,趁着这段时间还比较自由,就来重构一下这篇博客。
一、java语言的优势
1、跨平台
Java语言的广泛的使用一个最主要的原因是其跨平台的优势,正如其所宣称的Write Once Run Everywhere(一次编写到处执行),而其跨平台运行主要就是依赖于JVM的存在,我们知道同一指令在不同的平台(如:Windows、Linux、Unix)去执行其句柄(handle)是不同的,而凭借着JVM的转换使得我们的程序无需考虑这些。最终实现跨平台的运行。
2、jvm内存管理
学过C++的老哥都清楚,在C++中每块内存管控是有你自己来完成的,比如你new了一个对象申请了一块内存区域,当对象的生命周期结束时,需要手动的free掉你的申请的内存,每块内存的申请和释放都十分小心。相对比而言用Java开发的程序员就幸福多了,因为java中内存的管控是由jvm来完成的,我们申请完内存空间后,不需要手动去释放,jvm会在合适的时间去释放这块内存空间。这样就不会带来由于忘记释放内存而带来内存泄漏。
二、Jvm内存区域的划分
我们知道了java中内存的管理是由Jvm来完成的,那么jvm中内存的区域分为哪几部分呢?内存区域是如何划分的呢?
jvm的内存区域主要分为以下五个部分:程序计数器、虚拟机栈、本地方法栈、方法区、堆,如下图所示:
1. 程序计数器
程序计数器:指向当前线程所执行的字节码指令的地址,通常也叫做行号指示器。
Java是一门解释型的语言,.java文件被javac指令编译成.class的字节码文件。字节码解释器会将编译好的字节码文件解释执行,这也真是java语言可以很好的实现的跨平台的真正原因。字节码解释器工作时,就是通过改变程序计数器的值来选择下一条需要执行的字节码指令,分支,跳转,循环,异常处理等基础功能都需要依赖这个计数器来完成。
java是一门支持多线程的语言,所谓的多线程实则是通过CPU的时间片轮转调度算法来实现的,在一个时间片内,处理器只会执行一个线程中的指令。为了保证线程切换过程时可以恢复到原来的执行位置,每条线程都会有一个独立的程序计数器,各线程之间互不影响,独立存储。
2. Java虚拟机栈
上学的时老师常把java虚拟机的内存分为堆内存(heap)和栈内存(stack),这种分配方式实际上非常的粗糙,实际的内存划分远比这复杂。这么划分其实是为了让程序员理解对象内存分配最密切的这两个区域。所说的栈就是指虚拟机栈,或者说是虚拟机栈中的栈祯的局部变量表。堆部分下文会详细讲解。
虚拟机栈描述的是java方法执行的内存模型,方法的执行的同时会创建一个栈祯,用于存储方法中的局部变量表、操作数栈、动态链接、方法的出口等信息,每个方法从调用直到执行完成的过程,就对应着一个栈祯在虚拟机栈中入栈到出栈的过程。
本文关于虚拟机的内存区域的定义摘自第二版《深入理解java虚拟机》,感兴趣的老哥可以深入的读一读你会收获颇深。
没了解过jvm的老哥可能对个定义还是一头雾水,没关系!我们接下来就举例说明,当调用方法时,虚拟机栈是如何工作的。
首先我们将一个java文件通过javac编译成字节码文件。之后我们通过javap反汇编字节码文件。具体javap -c +字节码文件路径,如下图所示:
下面就是我们编译的方法:methodOne()
private void methodOne() {
int a = 2;
float b = 4.5f;
String name = "ss";
Object object = new Object();
float sum = a + b;
}
javap -c反汇编之后的方法如下:操作码对应含义
public void methodOne();
Code:
0: iconst_2 //将整型2入栈(int a = 2;)
1: istore_1 //将栈顶元素赋值给方法中第一个变量
2: ldc #2 //将4.5入栈(float b = 4.5f;) // float 4.5f
4: fstore_2 //将栈顶元素赋值给方法中第二个变量
5: ldc #3 //将ss入栈 (String name = "ss";) // String ss
7: astore_3 //将栈顶元素赋值给方法中第三个变量
8: new #4 //申请块堆内存,将申请到的堆内存地址压入栈(Object object = new Object();) // class java/lang/Object
11: dup //复制栈顶元素,并将赋值的栈顶元素压入栈
12: invokespecial #1 //初始化对象(注意,此时当前栈顶的数据会出栈)// Method java/lang/Object."<init>":()V
15: astore 4 //当前栈顶元素赋值给方法中,第四个变量
17: iload_1 //将第一个变量值入栈(float sum = a + b;)
18: i2f //将栈顶int类型值转换为float类型值。
19: fload_2 //将第二个变量值入栈
20: fadd //将栈顶两个数值相加结果压入栈
21: fstore 5 //将栈顶的元素出栈赋值给方法中第五个变量
23: return //void函数返回
}
方法的调用过程中,虚拟机栈的操作如下图示:
![这里写图片描述](https://i-blog.csdnimg.cn/blog_migrate/e1e13c8087a014d582166ad36374b98c.png)
首先我们看下面这句在代码在虚拟机栈中的执行过程:
int a = 2;
第一步:当方法被调用时,methodOne()方法入栈,在虚拟栈中为methodOne()方法初始化一个栈帧。
第二步:将整型2入操作数栈
第三步:将操作数栈栈顶元素赋值给方法中第一个变量,即局部变量表中int a=2;被初始化完成。
methodOne()方法的其他语句见上图反汇编的后的代码注释已经给出,在此不再赘述。
接下来我们具体阐述虚拟机栈中每个区域:
(1)局部变量表
局部变量表中存放了方法的参数和方法内部定义的局部变量。boolean、byte、char、short、int、float、reference、returnAddress。
(2)操作数栈
Java虚拟机的解释执行引擎被称为”基于栈的执行引擎”,其中所指的栈就是指:操作数栈。操作数栈也常被称为操作栈。 和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作:压栈和出栈来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,fadd指令就要从操作数栈中弹出两个浮点数(如上图中的float sum = a + b),执行加法运算,其结果又压回到操作数栈中。
(3)动态链接
每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中有大量的符号引用如下图所示:
字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态连接。比如我们的运行时的多态就是动态链接的。在运行时指定属性的类型。如下:
private void methodTwo() {
A a;//运行时指定
a=new A1();
a=new A2();
}
interface A {
}
class A1 implements A {
}
class A2 implements A {
}
(4)方法出口信息
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
注意:
1、当方法嵌套调用时,虚拟机栈的工作如下:
private void methodOne() {
methodTwo();
}
private void methodTwo() {
}
当前线程虚拟机栈的模型如下图所示:
2、当方法递归调用时
private void methodOne() {
methodOne();
}
当我们无限递归时,就会产生我们的StackOverflow异常,栈溢出。
3.本地方法栈
本地方法栈和虚拟机栈很相似,区别在于而本地方法栈是为Native方法服务而虚拟机栈是为java方法服务,在本地方法栈也会抛出StackOverFlowError异常、OutOfMemoryError异常。有兴趣的老哥可以自己深入的研究一下本文不做过多介绍。
4.Java堆
java堆是Java虚拟机所管理的内存中最大的一块。java堆是被所有线程所共享的一块内存区域,虚拟机启动时创建,几乎所有对象的实例都存储在堆中,所有的对象和数组都要在堆上分配内存。
java堆是垃圾收集器(GC)管理的主要区域,java堆中可以划分出多线程私有的缓冲区,但是无论怎么划分对象的实例仍然存储在堆中。java堆允许处于不连续的物理内存空间中,只要逻辑连续即可。堆中如果没有空间完成实例分配无法扩展时将会抛出OutOfMemoryError异常。
JVM 的堆分为两个区域,新生代、老年代。同时新生代又分为eden区,s0(From Survivor)、s1(To Survivor)三个区域,通常Eden和S区域是大小是8:1的关系。注*在有些资料中介绍堆中还有一块区域叫永久代,在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,JVM规范把它描述为堆的一个逻辑部分,但是他却有一个别名叫Non-Heap目的就是为了与堆区分开来。
4.1、新生代
新生代主要存放的是那些很快就会被GC回收掉的朝生夕死的对象,或者不是特别大的对象。(可配置,大于阈值会直接放入老年代)新生代中的GC操作是MinorGC,Eden区的对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到老年代中。
MinorGC采用的是复制算法。复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC最开始的时候,新生代对象只会存在于Eden区和s0区,s1是空的。紧接着进行MinorGC,Eden区中所有存活的对象都会被复制到“s1”,而在“s0”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到老年代中,没有达到阈值的对象会被复制到“s1”区域。经过这次GC后,Eden区和s0区已经被清空。这个时候,“s0”和“s1”会交换他们的角色,也就是新的“s1”就是上次GC前的“s0”,新的“s0”就是上次GC前的“s1”。不管怎样,都会保证名为s1的区域是空的。Minor GC会一直重复这样的过程,直到“s1”区被填满,“s1”区被填满之后,会将所有对象移动到年老代中。
4.2、老年代
老年代则是存放那些在程序中经历了好几次MinorGC仍然还活着的或者特别大的对象。老年代发生的是GC是MajorGC或者FullGC
面试题:(敲黑板!!!)
1、为什么堆区域要分代?
分代的目的就是为了性能的优化,提高GC的效率,反过来想一下,如果没有分代那么当堆发生GC操作时,会怎么做呢?GC会对整个堆进行扫描,找到该回收的对象回收。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样GC的效率会很高。
2、新生代为什么分区?
如果不分区,新生代每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。
5.方法区
方法区与堆一样所有线程所共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。java虚拟机对方法区的限制非常宽松,除了和堆一样不需要连续的内存和可扩展,还可以不实现垃圾收集,相对而言,垃圾收集机制在这个区域出现的较少,当方法区无法分配足够内存时,将会抛出OutOfMemoryError异常。
6.运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口、等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。一般来说,除了保存Class文件中的描述符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,java语言并不要求常量一定只有编译时期才能产生,也就是说并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入常量池,用的较多的如String的intern()方法。
运行时常量池是方法区的一部分,自然当方法区无法分配足够内存时,将会抛出OutOfMemoryError异常。
7.直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但这部分内存也被频繁的使用,而且也会导致OutOfMemoryError异常。
在JDK1.4中新加入了NIO类,引入了一种基于通道的与缓冲区的I/O方式,他可以是使用Native函数直接分配对外内存,然后通过位于堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以显著提高一些性能,因为避免Java堆和Native堆中来回的复制数据。
显然,直接内存不会受到Java堆内存的大小的影响,但是既然是内存,肯定还是受到本机内存大小以及处理器寻址范围限制。当各个内存区域的总和大于物理存储限制,从而导致动态扩展是出现OutOfMemoryError异常。
到此有关Java虚拟计的内存区域以及各个区域的会抛出的异常就介绍完了,如果你认真读并且仔细思考相信你一定能有所收获,你的理解一定不再局限于大学时老师所讲的堆和栈的理解,对Java内存的区域的深入理解对于今后的学习一定会有很大的帮助。同时对OutOfMemoryError异常也会有更为深刻的认知,对今后的工作中遇到的问题解决的更为轻松。
欢迎留言指出问题。期待一起进步!
如有疑问欢迎大家留言指正。祝大家生活愉快。
最后欢迎对Android开发感兴趣的老哥一起讨论。