JVM运行时数据区

来源:互联网 发布:优衣库 淘宝 编辑:程序博客网 时间:2024/06/06 16:56

一道题目


首先看一个例子,如果你不仅能正确的选择正确的答案,也能说出其中JVM底层实现的原理,那么下面这篇文章就不需要看了,无需再浪费时间。

package QuinnNorris; class Test {         public static void hello() {                System.out.println("hello");         } } public class MyApplication {         public static void main(String[] args) {                 Test test=null;              test.hello();         } }

A. 能编译通过,并正确运行
B. 因为使用了未初始化的变量,所以不能编译通过
C. 编译能通过,但以错误的方式访问了静态方法
D. 能编译通过,但因变量为null,不能正常运行

正确答案应该是A。

运行时数据区


在计算机系统中我们知道,虚拟机是对操作系统、CPU、主存、I/O和存储设备的抽象。JVM是一种程序虚拟机,虽然不是VM那种操作系统虚拟机,但是别把豆包不当干粮,JVM的内部也对上述几个部分进行了不错的抽象,总的来说,JVM用它在内存中被分配的空间营造出了一种抽象的环境,这种环境就是运行时数据区。实际上,下面的介绍很多都可以映射出计算机系统的概念,如果你对计算机系统的基本知识了解熟悉,JVM的运行时数据区很多地方能看到多核多进程的影子。

运行时数据区被分为多个部分,每个部分有不同的职责。

核心关键——程序计数器(PC)

和计算机系统一样,JVM中也有一种程序计数器也就是PC的概念,并且它们的工作含义相同:指向程序正在运行的位置,根据代码中的循环、分支、跳转等情况,PC也会进行更新,PC指示着程序的下一步执行的位置。

我们知道计算机系统中的PC是CPU的一个寄存器。相比较计算机中的PC,JVM中的PC看起来并不在CPU中存放,也是在内存中分配。每个线程有一个独自的PC,这是很好理解的,类比计算机中每一个核有一套自己的PC,多进程时会有多个PC。而且JVM中的PC只在方法为栈方法时才会记录位置,如果是本地栈的Native方法,那么会将方法的实现和处理交给操作系统而清空PC中的值。

PC不会发生OOM,PC只有一条值,它只是为了记录位置而存在,没有可以添加内容的方法,也就没有OOM的道理。

处理方法——虚拟机栈

在《深入理解JAVA虚拟机》一书中用虚拟机栈来表示JVM中的栈,我个人认为这种方法还是太保守了,但也确实有他的道理,毕竟JVM中的虚拟机栈是可以在计算机内存中堆区分配空间的。实际上抽象后的JVM运行时数据区几乎每个区的数据都可以不连续存放,现在的技术能够支持这种做法,但是后果其实也显而易见,慢。

JVM中的栈是为了用来存放相关方法的信息的,每个线程都拥有个一个栈,这个栈维护了此线程所有方法调用的信息。栈中有一种名为栈帧的数据结构,栈帧用来存放在此线程中调用的方法的信息。简单的说,一个栈中存在多个栈帧,每个栈帧相当于一个方法,某一个时刻,栈最顶层的栈帧表示的是当前调用的方法,如果当前方法又调用了另一个方法,那么另一个方法所代表的栈帧会被压入栈中,反之一个方法结束,代表它的栈帧也会出栈。

栈帧是表述一个方法的结构,那么栈帧中都包括那些信息呢?
栈帧中主要包括几大类信息:
1. 局部变量表:局部变量表维护了与此方法相关的所有变量,主要分为几类,this指针(如果是static方法则没有这个变量)、形参变量、try-catch块中catch的异常变量。基本类型的数据是直接存放数据字面量,对象和数组数据存放引用。
2. 操作数栈:说出来你可能不信,这个不起眼的组成部分是类似CPU的数据处理的存在,操作数栈做数据的计算,如果是一个iadd指令,操作数栈会pop出两个数进行加法。
3. 返回地址:这个信息中维护着此方法是被谁调用的,方法结束之后会回到哪里去。
4. 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
4. 其他信息:根据不同的虚拟机实现,可能会有一些杂乱的信息也会存放在栈帧中,一般不考虑。

栈可能会发生OOM和SOF两种异常报错。SOF是比较常见的错误,当我们写递归死循环时栈帧过多,栈的空间被耗尽,就会抛出这个异常。如果我们在栈部分申请的空间超过原有的空间无法满足,会抛出OOM异常。

处理本地方法——本地方法栈

本地方法栈和虚拟机栈基本相同,不同之处在于本地方法栈中栈帧表示的方法都是被native修饰的本地方法。native方法是什么方法呢?如果你看过JDK的源代码就会发现,在源代码中有很多方法被native修饰了,并且直接;结尾没有任何实现。这就是本地方法,本地方法与操作系统有关,本地方法的实现是机器自己完成的。在本地方法栈中存放关于本地native方法的调用情况。

根据虚拟机规范约定,本地方法栈的实现可以是任意的。因为本地方法栈与操作系统有关,所以虚拟机规范没有严格约束,甚至在最常用的HotSpot(现今Jdk的虚拟机)中本地方法栈和虚拟机栈合二为一了。

同样道理,理论上本地方法栈也会发生OOM和SOF异常。理由同上,不再赘述。

存放对象——JVM堆

堆是运行时数据区中最大的一片区域,几乎所有的对象都在堆上分配。不要把堆想像成一望无际的一大片田野,其实堆上的构造也非常复杂、峰回路转。至少我们要知道有多线程私有的分配缓冲区。随着逃逸分析技术的不断成熟,现在栈上分配标量替换这些也是不错的优化技术,在能够准确的进行GC回收的情况下,这些技术使得不一定所有的对象都在必须堆中被分配。实际上,JVM堆没有什么好分析的地方。需要说的是堆中堆一些GC回收方法,但是如果在这里说GC,那又太多了篇幅不够。虽然堆是最大堆一片空间,但是它的结构并没有栈那么复杂。最后提一句,很显然,堆是线程共享的。

堆会抛出OOM异常。如果我们要求申请的空间过多无法满足的情况下。

存放类信息——方法区

方法区在堆上,但是实际上方法区和堆一点关系也没有,仅仅是位置处在堆上而已。它们的功能毫无相似性。方法区中存放了所有加载进入的class文件的信息、常量、静态变量,当一个类被初始化时会在方法区中相应的位置通过此类的信息初始化一个实例。形象地说,当一个对象类型在栈上被声明并被new创建时,程序在堆上为之分配一块区域,并在方法区中找到信息进行初始化操作。就像研究堆时我们要详细的学习GC回收机制一样,在方法区,我们有必要去了解一些关于Class文件的结构的知识。方法区的信息基本上不会被GC回收,相比较区分于“新生代”、“老年代”我们可以形象的称方法区为老年代。

理论上方法区也会抛出OOM异常,但是这种情况实在是太少见了。

对象创建


我们已经介绍了所有关于运行时数据区的知识,我们下面结合一个简单的创建对象的例子,看一下这些不同的区域是如何相互配合,从而达到创建一个对象的功能的。

  1. 虚拟机遇到一条new语句
  2. 到方法区的常量池中看能否定位到一个类的引用符号(如果代码编译正确的话,理论上应该都能被定位到)
  3. 如果此类没有初始化则进行初始化
  4. 初始化此类之后在堆中分配空间
  5. 在堆中刚分配的空间内设置此对象的对象头信息
  6. 执行方法填充实例数据(方法是构造器方法,用javap反编译java源代码文件即可发现构造器被称之为)

简单的说,对象的创建就这6个大概步骤,其中还有很多细节问题。

在第四步在堆中分配空间时会涉及到很多分配方法,分配空间的方法和GC回收机制、分配方案有关。并且分配内存时涉及到线程安全问题,多个线程完全有可能在同时分配内存从而导致出错,第一种解决方法是CAS配上失败重试方法,第二种是利用线程私有的堆缓存区进行分配,第二种方法每个线程各自有一部分分配的空间,一般情况下不涉及安全问题,每个线程只需要在各自的缓存区中分配即可,当某个缓存区用尽后在同步进行缓存区的更换。

在第五六步中,我们需要额外说明一下,分配完空间的对象分为对象头和实例数据两个部分,对象头中的信息又可以分为两个部分,总的来说对象头中存放的信息有:哈希码、GC分代年龄、锁状态标志、类型指针(此类型指针指向这个对象在方法区中的类元数据)等等…而实例数据即是对象实例中的一些信息,包括父类中继承下来的字段和本身的字段信息。

回归文首的题目


选择A的原因如下:

当在虚拟机栈中声明一个对象类型时,会在堆中分配一块空间,它先在堆中分配了一块Test的空间,然后把分配空间的内容初始化成0,因为是test = null,所以堆的内存空间内什么都不设置,当进行test.hello()的时候,先访问test的空间,因为hello是static方法,所以应该到方法区去找类元数据,但是test的元数据信息存储在对象头上,即使对象是null,对象头也会存在,而且对象头中的类型指针指向类元数据,程序根本不访问test的类内容,直接跳到方法区去找类的static方法了,所以没访问对象就没看到null,没有null就不会报空指针的错,所以能编译能运行,什么错都没有。

原创粉丝点击