浅谈Java虚拟机(二)—运行时数据区域

一、简介

    在上一篇文章《浅谈Java虚拟机(一)—什么是Java虚拟机》中,我们已经简单了解Java虚拟机是什么,这篇文章我们来一起学习Java虚拟机的运行时数据区域。

    Java程序员都知道,Java虚拟机拥有自己的内存管理机制,在此机制下,我们不再需要像C++程序员那样,为每一个创建的对象手动删除并释放它所占用的内存,凡事都有两面性,这个机制同时带给我们的弊端就是如果不了解Java虚拟机,那么它就成为了一个黑匣子,我们不知道我们创建的对象存放在哪,是否存活,使用完后何时被清理,发生堆栈溢出时更无从查起...

    因此,了解Java虚拟机是如何使用内存,如何划分区域以及每个区域的作用是非常有必要的。

二、运行时数据区域

Java虚拟机运行时数据区

    上图为Java虚拟机规范所规定的运行时数据区域,顾名思义,这是一个规范,相当于接口,不同的虚拟机可以对其中每一个区域有自己的实现方式。

    图中除了运行时数据区域,还展示了从加载字节码文件到调用本地方法的基本流程。首先,由类加载器将字节码(.class)文件加载填充到运行时数据区,运行时数据区为进来的操作指令或数据使用对应的区域分配内存,执行引擎不断得从运行时数据区读取指令,现在的Java字节码机器是读不懂的,因此还必须将字节码转化成平台相关的机器码(也就是系统能识别的0和1),这个过程可以由解释器来执行,也可以由即时编译器(JIT Compiler)来完成,最后通过本地库接口调用本机操作系统的本地方法库,这样就完成了从Java代码到系统执行的全过程。

    GC也存在于执行引擎中。

    下面,将重点详细介绍运行时数据区中各区域的作用和功能。

    2.1 程序计数器

        程序计数器是一块较小的内存空间,且空间大小不会随着程序执行而改变,所以该区域是Java运行时内存区域中唯一一个Java虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域

        它可以看做是当前线程所执行的字节码的行号指示器,例如,执行引擎中的字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令(仅在虚拟机概念模型中,上面也提到,不同虚拟机对于不同区域有不同实现,可能某些虚拟机对于程序计数器的实现和使用有更高效的方式),另外,代码中的分支、循环、跳转、异常处理、线程恢复等都需要依赖程序计数器来完成。

        由于在程序计数器需要在多线程中完成线程恢复的功能,即在CPU切片后线程重新执行时恢复到切片前的正确位置,因此,每条线程都需要有一个独立的程序计数器,各条线程之间互不影响,所以,程序计数器是线程私有内存区域,随着线程的生命周期诞生和消亡。

    2.2 Java虚拟机栈

        Java虚拟机栈与程序计数器一样,也是线程私有的。

        Java虚拟机栈描述的是Java方法执行的内存模型,每个方法被执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

虚拟机栈与栈帧

        Java虚拟机栈可以大致看作数据结构中的栈,遵循后进先出(LIFO)的原则,在方法嵌套的调用关系中,最先调用的方法最后执行完成。从执行引擎方面来描述,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

        栈帧可以看作一个Java中的一个对象,一个包装了方法具体信息的特殊对象(其实不是,这样说只是为了帮助理解栈帧是什么,它的实质是描述方法的内存模型),其中:

        局部变量表:变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,这些变量类型有基本数据类型boolean、byte、char、short、int、float以上数据类型为32位长度,在32位虚拟机中刚好占用一个单位的局部变量空间,局部变量空间中的最小单位被称为变量槽Variable Slot,一般简称 Slot,了解即可)、double、long这两个数据类型为明确定义的64位长度,在32位虚拟机中会占用两个Slot),对象引用(reference 类型,一个指向真实对象存储地址的指针,在Java 虚拟机规范中没有明确规定 reference 类型的长度,可能是32位也可以是64位),以及retrunAddress类型32位长度请不要把这个直译为返回地址,它并不是方法出口信息,切莫搞混,这个数据类型目前已经很少见了,它是为字节码指令 jsr、jsr_w 和 ret 服务的,指向了一条字节码指令的地址,以前的虚拟机会用这个实现异常处理,了解即可)。这里,延伸一下,上面提到,虚拟机栈中的栈帧会被执行引擎依次弹出执行,那么,存在于栈帧中局部变量表的对象或引用指向的对象必然是存活的,这也是GC算法中的Root搜索算法(也称作可达性分析算法)中为何会以局部变量表中的对象作为Root节点之一的原因,另外,由于栈帧的弹出,栈中的变量等所占用的内存也会被自动释放,这也致使虚拟机栈不是GC所关注的区域,更多详细的会在后面针对Java虚拟机垃圾回收算法章节中介绍。

        操作数栈:顾名思义,用于执行方法时进行数据操作的栈结构。在做算术运算或者调用其他方法的时候是通过操作数栈来进行参数传递的,例如,执行引擎执行到整数加法的字节码指令时,发现操作数栈中最接近栈顶的两个元素已经存入了两个整型的数值,会将这两个整型值出栈并相加,然后将相加的结果入栈。

        动态链接:因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量池,通过运行时常量池的符号引用(指向堆),完成将符号引用转化为直接引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间(例如方法执行中)转化为直接引用,这部分称为动态连接。

        方法出口:无论是正常执行完成还是抛出异常,方法返回后,需要返回到方法被调用的位置,因此栈帧中保存了返回位置信息。

        Java虚拟机栈可以是固定长度也可以是动态扩展大小,要看具体虚拟机的实现(目前大部分虚拟机都可以动态扩展,同时也支持固定长度)。当虚拟机栈为固定长度时,线程请求的深度大于虚拟机所允许的深度,就会抛出StackOverflowError,也就是常说的爆栈,结合上图的虚拟机栈和栈帧的结构,很容易想到的一种情况就是,当发生无限嵌套方法调用时,例如没有边界或者边界很难达到的递归调用,栈帧就会无限入栈最终导致StackOverflowError;当虚拟机为动态扩展时,扩展过程中无法向虚拟机申请到足够的内存,就会抛出OutOfMemoryError,也就是内存溢出。

    2.3 本地方法栈

        本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,例如一些c文件。

        执行引擎在执行指令过程中加载的本地方法就会压入本地方法栈中

        在Sun HotSpot的实现中,虚拟机栈与本地方法栈合二为一。

        本地方法栈也会抛出StackOverflowErrorOutOfMemoryError

    2.4 Java堆

        Java堆是Java虚拟机所管理的内存中最大的一块,此内存区域的唯一目的就是存放对象实例。

        在Java虚拟机规范描述中,所有的对象实例和数组都要在堆中分配内存空间,但随着技术的发展,如今的Java虚拟机实现做出了大量技术优化,使得这一描述不再绝对。仅管不再是“所有”, 但是绝大多数对象实例和数组仍然还是在堆中分配。

        由于堆中的对象所存放的地址就是各种虚拟机栈栈帧中引用类型所指向的目标地址,因此,Java堆是所有线程共享的内存区域。

        当方法执行完毕,栈帧弹出,引用消失,但对象仍然在堆中, 若再无其他引用指向该对象,那么这个对象不再有用,或者称为不再存活,这样的对象如果大量存在却没有机制来清理,堆中没有内存完成新的实例分配,且同时也无法再动态扩展时,就会抛出OutOfMemoryError异常。幸好,Java虚拟机有自己的清理机制,称为垃圾收集器,上述中的无用对象就会被垃圾收集器处理掉并释放出占用的内存,因此,Java堆是垃圾收集器所管理的主要区域,Java堆又被称为GC堆(Garbage Collected Heap),但是,即使拥有这样的机制,如果一次垃圾清理后,所有对象都被判断为有用,新的实例进来仍然没有空间分配,那么还是会抛出OutOfMemoryError异常。

        由于当前垃圾回收器都是使用的分代收集算法,所以Java堆还可以分为:新生代和老年代,而新生代又可以分为 Eden 空间、From Survivor 空间、To Survivor空间,这样的分法是服务于垃圾回收机制的,并不是Java堆本身,关于垃圾回收,会在后续文章中详细介绍。

    2.5 方法区

        方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量(final static)、静态变量(static)、JIT编译后的代码等数据。

        如何在堆中创建对象?就是从方法区中取的Class模板。

        对于方法区,定义很简单,就上面这一句话,但却存在很多误区,这里将一一阐释。

        首先,再次强调,方法区是Java虚拟机规范中定义的一个“接口”,具体的实现与不同的虚拟机有关,这里还是以主流虚拟机Sun HotSpot为讨论目标,在JDK1.7以前,Sun HotSpot对方法区的实现为永久代(PermGen),永久代是上面提到过的GC分代收集的设计方案扩展,设计团队用永久代来实现方法区就是为了让GC在工作时也能照顾到方法区,这样就不用了专门为方法区设计一套内存管理机制(取名永久代也很顾名思义,方法区中存在的内容大多数都是“永久”存在的,例如常量、类信息)。然而,永久代的设计经常遇到内存溢出(OutOfMemoryError)的问题(原因也很容易想到,GC在方法区清理本身就是一种消耗,再加上方法区中没有太多可清理的内存),因此,设计团队开始逐步放弃永久代,在JDK1.8中,使用元空间(Metaspace)来代替永久代实现方法区,元空间并不在虚拟机中,而是使用本地内存,理论上,元空间的大小仅受本地内存限制,用户可以为元空间设置一个可用空间最大值,如果不进行设置,JVM会自动根据类的元数据大小动态增加元空间的容量,元空间独立于Java虚拟机之外,所以其内存管理由元空间虚拟机管理。最后要提的一点是,元空间对方法区的实现并不代表元空间就没有OutOfMemoryError异常,OutOfMemoryError异常是Java虚拟机规范对方法区的规定,即当方法区无法满足内存分配需求时,就必须抛出该异常,元空间既然是方法区的实现,当然也会遵循。

        上面解释了网上一些关于“元空间取代了方法区”这一错误说法,接下来介绍关于方法区另一个误区较多的区域:运行时常量池

        从图中可以看出,运行时常量池是方法区的一部分,经常容易跟它混淆的是类常量池字符串常量池

        这里另外再额外延伸提一个:对象池,基本类型的包装类有对象池(也有称常量池)的概念,用来缓存被创建出来的值,避免重复创建对象,方便以后使用,它和方法区中的类常量池,运行时常量池没有任何关系。类常量池和运行时常量池有点类似于符号表的概念,包装类的对象池是池化技术的应用,并非是JVM层面的东西,而是 Java 在类封装里实现的,包装类的对象池的作用和字符串常量池类似,但字符串常量池却是JVM层面的技术。

        JVM启动后,类加载器(ClassLoader)会把编译好的.class文件经过一系列加载过程(加载→验证→准备→解析→初始化)将类的字节码信息加载进方法区,这里面除了一些描述信息外(例如,字段信息:修饰符、字段类型、字段名,也就是类似private String name()这段描述信息的提取),还有一项就是常量池(不管是描述信息还是类常量池都是编译期形成的,类加载器只是将他们加载进方法区而已)。

        类常量池中包含了字面量符号引用:

                字面量:包含文本字符串、基本数据类型、声明为final的常量等。

                符号引用:顾名思义,是一种用来描述引用的符号, 符号可以是上述任何一种形式的字面量, 只要使用时能够无歧义的定位到目标即可,包含类的描述符号、方法的描述符号、字段的描述符号(不要与上面的描述信息搞混,例如,对于方法而言,描述信息是方法的标签,即描述它的名字,访问权限,返回类型等,而描述符号是方法的在内存中的位置,即引用,这个是在编译期无法确定的,才会用符号来代替)。

        类常量池会在类加载过程中的解析阶段被放入运行时常量池,方便程序运行时使用,其中的符号引用除了直接被保存进运行时常量池,还会在这个阶段被翻译成直接引用存入(因为类信息已经被加载到内存中,可以知道它的位置,程序运行时就可以从运行时常量池中拿到直接引用找到类、方法、字段)。

        而类常量池字面量中的文本字符串就会在此阶段进入运行时常量池中的字符串常量池,只是字符串常量池在前面提到过的“永久代优化”过程中,于JDK1.7正式从运行时常量池中移除,并放入了Java堆中,其余的东西仍在在运行时常量池中,所以,在JDK1.7以后,类常量池字面量中的文本字符串就会直接进入堆中的字符串常量池,字符串常量池位置的改变也导致了String.intern()这个方法在JDK7前后在使用的结果上出现了一些区别:

String s1=new String("a");

s1.intern();

String s2="a";

System.out.println(s1==s2);

运行结果:

JDK1.6运行结果:false

JDK1.7运行结果:true

        要讲清楚上面这段代码,首先要逐步明白以下几点:

        1.String.intern()的作用:首先去判断该字符串是否在字符串常量池中存在,如果存在返回字符串常量池中的字符串引用,如果在字符串常量池中不存在,先在字符串常量池中添加该字符串,然后返回引用地址。

        2.从第1条中可以看出,字符串常量池中跟Java堆一样也会存储对象,不同的是只存字符串对象。

        3.new String("a")操作只会在Java堆中产生一个"a"字符串对象,不会进入字符串常量池。

        4.一个经典问题:“String s1=new String("a");s1.intern();”,这两句代码在内存中产生几个对象?答案是在JDK1.6中是2个,在JDK1.7中是1个。第一句代码String s1=new String("a")会在堆中产生1个对象,这个毋庸置疑,结合第3条,此时常量池中没有"a"字符串对象,第二句代码s1.intern()调用时,结合第1条和第2条,检查到字符串常量池中没有与"a"字符串equal()相等的对象,则会在字符串常量池中创建该对象,于是,在JDK1.6中,就会产生2个对象。而在JDK1.7中,由于字符串常量池搬到了堆中,很方便就能拿到对象的引用地址,于是,检查到字符串常量池中没有与"a"字符串equal()相等的对象时,不会在字符串常量池中创建一个新对象,而是把指向堆中"a"字符串对象的地址存入,因此,在JDK1.7中,只会产生1个对象。

        5.总结,JDK1.7前后对于前三点并无变化,重点在于第1条中String.intern()返回的引用地址发生了变化,可以看出,在JDK1.7以前只会返回字符串常量池中的对象地址,而在JDK1.7以后,可能返回字符串常量池中的对象地址也可能返回堆中对象的地址,取决于谁先创建。例如,先写String s1="a";s1.intern();,此时不论堆还是字符串常量池中都没有"a"字符串对象,于是就会在字符串常量池中创建一个"a"字符串对象,接着再写String s2=new String("a"),堆中也会产生一个"a"字符串对象(注意,与第4条中情况不同,这种情况下JDK1.7的内存中也会存在两个"a"字符串对象),但是,这之后不论调用s1.intern()还是s2.intern()返回的都是字符串常量池中的对象。反之亦可推导,先写String s1=new String("a"),并调用s1.intern(),以后不论以何种方式创建"a"字符串对象,并调用intern()方法,字符串常量池中不会创建对象,并只会返回指向堆中“a”字符串对象的引用地址。

        理解这5点原理,上面这段代码便迎刃而解了,s1.intern()后,JDK1.6会在字符串常量池中创建一个新的"a"字符串对象,String s2=“a”时,发现字符串常量池中有"a"字符串,直接使用该对象,所以s1指向堆中的对象,s2指向字符串常量池中的对象,它们做“==”判断当然为false;而在JDK1.7中s1.intern()不会在字符串常量池中创建新对象,只会存入指向堆中"a"字符串对象的地址,那么String s2=“a”用的就是同一个地址,它们做“==”判断当然为true。即使上述题目进行再复杂的变形,掌握这5点基本底层原理,都能轻松分析出来。

三、总结

    本章节主要详细介绍了Java虚拟机的内存结构以及各分区的功能,并着重剖析了方法区中的结构,各种Java中的常量池各自的作用以及它们的区别,下一章我们将一起学习Java虚拟机的内存管理机制。  

《浅谈Java虚拟机(一)—什么是Java虚拟机》

《浅谈Java虚拟机(二)—运行时数据区域》

《浅谈Java虚拟机(三)—垃圾回收》

《浅谈Java虚拟机(四)—JVM调优》


本系列文章参考文档:《深入理解Java虚拟机:JVM高级特性与最佳实践》-- 周志明

你可能感兴趣的:(浅谈Java虚拟机(二)—运行时数据区域)