您的位置:首页 > 编程语言 > Java开发

java 深度解析JVM内存分布机制

2017-07-08 15:53 323 查看
Java与C++之间有一堵由内存动态分配垃圾回收机制技术围成的高墙,墙外面的人想进去,墙里面的人想出来。

JVM内存分布机制

像往常一样的某天清晨,程序员来到公司打开电脑,从使用new创建一个对象开始,开始了这一天的工作。当他在电脑屏幕上用new创建一个对象的时候,他不知道,是JAVA虚拟机通过一系列的操作,让这个对象在内存适当的位置上真正的被构建起来。

实际上,可能我们都是上面故事中的程序员,只会用new创建对象,但是对JVM相关内存分布或是对象创建的知识知之甚少。相比较数字逻辑的0和1,计算机系统的大端小端法、浮点数操作,JVM是java程序员能涉及的较近的底层原理,对底层原理的掌握能让我们更好的理解一些语法和机制。

传统意义上的栈内存与堆内存

在谈到JAVA的内存分配时,提及最多的就是栈内存堆内存。很多JAVA初级水平朋友也能都一口“栈内存分配基本变量,堆内存分配new对象”之乎者也说的很顺。传统意义上,我们这样来描述栈内存和堆内存的:

在函数中定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配。当在一段代码块中定义一个变量时,java就在栈中为这个变量分配内存空间,当超过变量的作用域后,java会自动释放掉为该变量分配的内存空间,该内存空间可以立刻被另作他用。

堆内存用于存放由new创建的对象和数组。在堆中分配的内存,由java虚拟机自动垃圾回收器来管理。在堆中产生了一个数组或者对象后,还可以在栈中定义一个特殊的变量,这个变量的取值等于数组或者对象在堆内存中的首地址,在栈中的这个特殊的变量就变成了数组或者对象的引用变量。

简单的说,我们现在达成的共识就是:栈内存在函数中定义的基本类型的变量和对象的引用变量都是在函数的栈内存中分配。堆内存堆内存用于存放由new创建的对象和数组。这种说法是完全没问题的,但是不够精确,对于简单的空间分配足够应付但是对于复杂的问题就无能为力,所以我们迫切的需要了解完整的虚拟机内存分布的知识体系。

JVM运行时数据区

java虚拟机在执行java程序的过程中会把它所管理的内存分成若干个不同的部分,这些不同的区域都有各自的用处,以及创建时间和销毁时间,不同的区域甚至有不同的报错信息。大体上JVM的运行时区域可以分为以下几个部分,我们将依次详细介绍这些部分的具体作用和功能:

程序计数器

java虚拟机栈

本地方法栈

java堆

方法区

运行时常量池

直接内存

我们将详细介绍上述7种区域,其中运行时常量池是方法区的一部分,直接内存不能算作JVM运行时数据区,但是却与JVM有千丝万缕的关系。而且Oracle公司的官方文档也将其分开说明,所以我们将这些都一起分析总结。



程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,在未优化的编译器中他用来指向当前线程执行的字节码的行号。因为是指向当前线程的程序,所以程序计数器是线程私有的,每个线程都有一份程序计数器。

如果线程正在执行一个java方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Native方法(本地方法)那么这个计数器值为空。此内存区域是唯一一个不会发生OutOfMemory的区域。

java虚拟机栈

与程序计数器一样,java虚拟机栈(JAVA Virtual Machine Stacks)也是线程私有的。也就是说虚拟机栈的生命周期和线程是相同的。虚拟机栈是java用来描述方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表操作数栈动态链接方法返回地址等信息。每一个方法从调用到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,java虚拟机栈的默认大小是1024KB。

栈帧(Stack Frame)

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列。

操作数栈

操作数栈也常被称为操作栈,它是一个后入先出栈。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,这种退出方法方式称为正常完成出口。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,且不会给它的调用都产生任何返回值的。无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

在java虚拟机栈中会出现两种异常,一种是线程请求的栈深度大于虚拟机所允许的深度,会抛出StackOverflowError异常。如果虚拟机栈可以动态扩展,在动态扩展中无法申请到足够的内存那么java会抛出OutOfMemory异常。

StackOverflowError异常

每一个JVM线程维护自己的java虚拟机栈,StackOverflowError异常的出现是因为在虚拟机栈中我们无限递归调用了很多方法导致栈空间不够而溢出。再一般的说,就是因为方法调用嵌套层数过大。

public static void main(String[] argv){
int i = getInt(2);

}

public static int getInt(int i){
return getInt(i++);
}


上述例子结果:

Exception in thread “main” java.lang.Stac
dcb8
kOverflowError

OutOfMemoryError异常

OutOfMemory异常和栈溢出异常不同。虚拟机栈可以动态扩展,但如果在动态扩展后的空间仍然无法满足所需的空间则会报OutOfMemoryError错。这种情况在我们平时的代码中很难出现,但是在实际应用环境下,需要使用大量数据时可能会出现该问题。

本地方法栈

我们之所以在虚拟机栈前面加上java是为了区别本地方法栈(Native Method Stack),本地方法栈和java虚拟机栈非常类似,也是线程私有,也是具有一些信息存储。他们之间唯一的差别就是java虚拟机栈是为了java程序中的方法也就是字节码的方法服务的,而本地方法栈是给Native方法服务的。

JVM并没有强制规定要用什么语言、什么数据结构去实现本地方法栈。正是这一点很多虚拟机用了不同的实现方法。但是逻辑上功能是相同的。

java堆

对于大多数应用来说,java堆(JAVA Heap)是java虚拟机所管理的内存中最大的一块。java堆是被所有线程共享的一块区域,在虚拟机启动时创建。此内存的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。注意这里我们采用了几乎一词,因为随着编译器和虚拟机的发展,类似栈上分配标量替换技术的出现使得部分对象实际上也不是一定在堆上分配。

在其他区域需要进行回收的内容少于java堆,所以java堆是垃圾收集器主要管理的区域。从内存回收的角度来看,现在的收集器基本都采用分代收集算法,所以java堆中还可以细分为新生代老年代。(事实上,如果我们想再划分的细致一些,可以分为Eden空间,From Survivor空间,To Survivor空间等)。除此之外,从内存分配的角度来看,多个线程可能会在java堆中划分出多个线程私有分配缓冲区(Thread Local Allocation Buffer , TLAB )

所以,java堆并不仅是我们想象中的一张白纸,至少也是一张划分成很多区域的白纸。实际上,划分区域与存放内容无关,无论在哪个区域存储的都是对象的实例,之所以进行划分只不过是为了能够让GC进行更好的回收。如果在堆中没内存可以用来完成实例的分配,并且堆也无法再进行扩展时,将会抛出OutOfMemory的异常,索然这种情况极其少见,但是理论上是存在的。

逃逸分析

逃逸分析是目前java虚拟机中比较前沿的优化技术,他并不是直接优化代码的手段,而是为其他优化手段提供依赖的分析技术。简单的说,逃逸分析的基本行为是分析对象动态作用域:当一个对象在方法中被定义后,他可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

栈上分配(Stack Allocation)

java堆中的对相对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾回收机制可以回收堆中不再使用的对象 ,但回收动作无论是从筛选开始还是到回收和整理都耗费时间。如果一个对象被确定不会逃逸出方法之外,那让这个对象在栈上分配是一个不错的想法,对象占用的内存空间就可以随栈帧的出栈而被销毁。

标量替换(Scalar Replacement)

标量是指一个数据已经无法再分解成更小的数据来表示了,java虚拟机中的原始数据(int,double…)都是不能再分解的,所以原始数据都是标量。相对应的,如果一个数据可以继续分解,他就是聚合量,比如java中很多对象都是聚合量而不是标量。那么从栈上分配的概念延伸出来,如果一个对象可以在栈上分配,我们可以不在栈上创建这个对象的整体,而是创建这个对象的多个被使用到的变量代替创建对象,即用多个标量来替换创建该聚合量。

分代收集算法

分代收集算法根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活。而老年代中因为对象存活率高、没有额外空间对它进行分配担保。方法区永久代,回收方法同老年代。如果要具体介绍如何去回收新生代和老年代,势必要设计复制算法、标记-整理、标记-清除等方法,这些内容很难在一篇文章中介绍完,这里不再赘述。

方法区

方法区(Method Area)与java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类的信息、常量、静态变量、即时编译器编译后的代码等数据。有的时候我们也可以将方法区看作是java堆的一个分区。但是方法区很重要,而且与java堆的作用有些相异,所以我们分开总结。

java堆可以细分为新生代和老年代,那么我们可以将方法区称之为永久代,因为方法区的数据很难被GC回收,与其说是GC不对方法区的数据进行回收,实际上是GC对方法区的数据回收效率很差。但是我们知道方法区中主要存放类的信息和静态常量此类数据,所以说我们不需要太关系方法区中数据回收情况如何,在退出虚拟机的时候再结束方法区中的内存也没关系。正如我们所想,字符串常量池就存放在方法区中。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。这部分虽属于方法区,但在具体的环境中的作用很关键。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

之所以说运行时常量池关键,是因为它相对于Class文件常量池的另外一个重要特性是具备动态性,java语言并不要求常量一定只有编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,比如String类的intern方法就为此类操作。运行时常量池为我们提供了一种能够在运行时动态操作常量池的方法。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的的一部分。但这部分内存在项目中也会被频繁的使用,而且也可能导致OOM异常,所以我们一起进行归类。

在JDK1.4中新加入了NIO类,引入了一种基于通道与缓冲区的I/O方法,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的对象堆这块内存进行操作。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java jvm 内存