lesson2-java虚拟机之jvm结构

来源:互联网 发布:淘宝美工每天工作任务 编辑:程序博客网 时间:2024/04/19 22:33


网上找了一大圈,对于java虚拟机,还是不清不楚。这张算是比较靠谱的图了。自己参考Java虚拟机规范 Java SE7版,研究了下java虚拟机的结构,浅显理解吧。下面根据这个图,理解下各个部分。


以上是JAVA虚拟机的结构图,这张图对应了很多jvm机制,比如java的类加载和执行机制,比如java的垃圾回收机制。本文专注于java虚拟机的结构。
在了解java虚拟机的结构之前,我们先大概了解下java虚拟机中可以操作的数据类型。

与java语言类似,jvm可以操作的数据类型可以分为两类:原始类型(primitive type,也常被称为原生类型,基本类型)和引用类型(reference type)。jvm希望尽可能多的类型检查能在程序运行之前完成,即编译器应当在编译期间尽最大努力,完成可能的类型检查,使得虚拟机在运行期间无需进行这些操作。
原始数据类型包括数值类型,boolean类型和returnAddress类型。其中数值类型包括整数类型和浮点类型。

整型有byte,short,int,long,均为有符号二进制补码,默认都为0,char型为16位无符号整数表示,指向基本多文种平面的Unicode码点,以UTF-16编码,默认为Unicode的null码点(\u0000)。

boolean类型为true和false,默认是false。boolean类型的数值,在编译之后,都使用jvm中的int数据类型来代替。jvm会将true映射为1,false映射为0。
returnAddress类型表示一条字节码指令的操作码,在虚拟机支持的所有原始类型中,只有该类型不能直接与java语言的数据类型对应。
浮点类型值得强调的一点是,该类型包含5个特殊数值,正数零,负数零,正无穷大,负无穷大,NaN。在浮点数中,整数0和负数0是相等的,但1.0/0.0会产生正无穷大结果,1.0/-0.0会产生负无穷大结果。NaN是无序的,对它进行任何的数值比较和等值测试,都会返回false。任何数字和NaN进行非等值比较都会返回true,除了其本身以外。
引用数据类型包括,类类型,数组类型,接口类型,默认值是null。在引用类型中有一个特殊的值,null。当一个引用不指向任何对象的时候,它的值就是null,在没有上下文的情况下不具备任何实际的类型,但在有上下文时,其可转换为任意的引用类型。
以上是针对虚拟机的数据类型的简要介绍。下面是运行时数据区的解释。
1.PC寄存器
jvm可以支持多条线程同时执行,每一条jvm线程都有自己私有的pc寄存器。在任意的时刻,一条jvm线程只会执行一个方法的代码,这个正在被执行的方法称为该线程的当前方法,如果这个方法不是native的,那么pc寄存器就保存jvm正在执行的字节码指令的地址,如果该方法是native的,那么pc寄存器的值是undefined。pc寄存器的容量至少应当能保存一个returnAddress类型的数据或者一个与平台相关的本地指针的值。
2.Java栈
每一个jvm线程都有自己私有的java虚拟机栈,这个栈和线程同时创建,用于存储栈帧。与传统语言的栈类似,存储局部变量以及中间过程。此外,java栈在方法调用和返回中也扮演了重要角色,因为除了栈帧的出栈和入栈以外,Java栈不再受其他因素的影响,所以栈帧可以在堆中分配,java栈所使用的内存不需要保证是连续的。java栈可以被实现成固定大小的或者是根据计算可动态扩展和收缩的。如果采用固定大小的,那么这个容量应该在jvm线程创建的时候确定。java虚拟机栈发生异常的情况有如下两种:1.线程请求分配的栈容量超过java栈的最大容量,此时会跑出StackOverflowError。2.如果是动态扩展的并且已经尝试过去获取更大的栈空间,但是无法申请到足够的内存去完成扩展,或者在创建新线程时没有足够的内存去创建对应的虚拟机栈,此时会跑出OutOfMemoryError。
3.java堆
java堆是所有jvm线程共享的运行时内存区,也是所有类实例和数组对象分配内存的区域。java堆在虚拟机启动的时候被创建,它存储了被自动内存管理系统(垃圾收集器)所管理的各种对象,这些受管理的对象无需也无法被显示地销毁。java堆的容量可以是固定大小,也可以是程序执行时动态扩展,并在不需要过多空间时自动收缩。java堆所使用的内存不需要保证是连续的。java堆发生异常的情况是,当实际所需的内存超过了自动内存管理系统所能提供的最大容量时,java虚拟机会抛出一个OutOfMemoryError异常。
4.方法区
在java虚拟机中,方法区是可供各个线程共享的运行时内存区。方法区和传统语言中的编译代码存储区或者操作系统的正文段的作用类似,它存储了每一个类的结构信息,例如运行时常量池,字段和方法数据,构造函数和普通方法的字节码,还包括一些类,实例,接口初始化时用到的特殊方法。(特殊方法后面会补充解释)。方法区在虚拟机启动的时候创建,它的容量可以是固定大小,也可以是程序执行时动态扩展,并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的。如果方法区的内存空间不能满足内存分配请求,java虚拟机将抛出一个OutOfMemoryError异常。运行时常量池是class文件中每一个类或接口的常量池表的运行时表示形式(常量池表后面会补充解释)。它包括了若干种不同的常量,从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段的引用。每一个运行时常量池都在虚拟机的方法区中分配。在加载类和接口到虚拟机后,就创建了对应的运行时常量池。会抛出OutOfMemoryError异常。

当虚拟机运行Java程序时,它会查找使用存储在方法区中的类型信息。由于所有线程都共享方法区,因此它们对方法区数据的访问必须被设计为是线程安全的。比如,假设同时有两个线程都企图访问一个名为Lava的类,而这个类还没有被装入虚拟机,那么,这时只应该有一个线程去装载它,而另一个线程则只能等待。

  对于每个装载的类型,虚拟机都会在方法区中存储以下类型信息:

  这个类型的全限定名

  这个类型的直接超类的全限定名(除非这个类型是java.lang.Object,它没有超类)

  这个类型是类类型还是接口类型

    这个类型的访问修饰符(public、abstract或final的某个子集)

  任何直接超接口的全限定名的有序列表

  除了上面列出的基本类型信息外,虚拟机还得为每个被装载的类型存储以下信息:

  该类型的常量池

  字段信息

  方法信息

  除了常量以外的所有类(静态)变量

  一个到类ClassLoader的引用

  一个到Class类的引用

  常量池

  虚拟机必须为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量和对其他类型、字段和方法的符号引用。池中的数据项就像数组一样是通过索引访问的。因为常量池存储了相应类型所用到的所有类型、字段和方法的符号引用,所以它在Java程序的动态连接中起着核心的作用。

  字段信息

  对于类型中声明的每一个字段。方法区中必须保存下面的信息。除此之外,这些字段在类或者接口中的声明顺序也必须保存。

  字段名

  字段的类型

  字段的修饰符(public、private、protected、static、final、volatile、transient的某个子集)

  方法信息

  对于类型中声明的每一个方法,方法区中必须保存下面的信息。和字段一样,这些方法在类或者接口中的声明顺序也必须保存。

  方法名

  方法的返回类型(或void)

  方法参数的数量和类型(按声明顺序)

  方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的某个子集)

  除了上面清单中列出的条目之外,如果某个方法不是抽象的和本地的,它还必须保存下列信息:

  方法的字节码(bytecodes)

  操作数栈和该方法的栈帧中的局部变量区的大小

  异常表

  类(静态)变量

  类变量是由所有类实例共享的,但是即使没有任何类实例,它也可以被访问。这些变量只与类有关——而非类的实例,因此它们总是作为类型信息的一部分而存储在方法区。除了在类中声明的编译时常量外,虚拟机在使用某个类之前,必须在方法区中为这些类变量分配空间。

  而编译时常量(就是那些用final声明以及用编译时已知的值初始化的类变量)则和一般的类变量处理方式不同,每个使用编译时常量的类型都会复制它的所有常量到自己的常量池中,或嵌入到它的字节码流中。作为常量池或字节码流的一部分,编译时常量保存在方法区中——就和一般的类变量一样。但是当一般的类变量作为声明它们的类型的一部分数据面保存的时候,编译时常量作为使用它们的类型的一部分而保存。

  指向ClassLoader类的引用

  每个类型被装载的时候,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的,那么虚拟机必须在类型信息中存储对该装载器的引用。这是作为方法表中的类型数据的一部分保存的。

  虚拟机会在动态连接期间使用这个信息。当某个类型引用另一个类型的时候,虚拟机会请求装载发起引用类型的类装载器来装载被引用的类型。这个动态连接的过程,对于虚拟机分离命名空间的方式也是至关重要的。为了能够正确地执行动态连接以及维护多个命名空间,虚拟机需要在方法表中得知每个类都是由哪个类装载器装载的。

  指向Class类的引用

  对于每一个被装载的类型(不管是类还是接口),虚拟机都会相应地为它创建一个java.lang.Class类的实例,而且虚拟机还必须以某种方式把这个实例和存储在方法区中的类型数据关联起来。

  在Java程序中,你可以得到并使用指向Class对象的引用。Class类中的一个静态方法可以让用户得到任何已装载的类的Class实例的引用。

public static Class<?> forName(String className)

  比如,如果调用forName("java.lang.Object"),那么将得到一个代表java.lang.Object的Class对象的引用。可以使用forName()来得到代表任何包中任何类型的Class对象的引用,只要这个类型可以被(或者已经被)装载到当前命名空间中。如果虚拟机无法把请求的类型装载到当前命名空间,那么会抛出ClassNotFoundException异常。

 

  另一个得到Class对象引用的方法是,可以调用任何对象引用的getClass()方法。这个方法被来自Object类本身的所有对象继承:

public final native Class<?> getClass();

  比如,如果你有一个到java.lang.Integer类的对象的引用,那么你只需简单地调用Integer对象引用的getClass()方法,就可以得到表示java.lang.Integer类的Class对象。

  方法区使用实例

  为了展示虚拟机如何使用方法区中的信息,下面来举例说明:

class Lava {    private int speed = 5;    void flow(){            }}
public class Volcano {        public static void main(String[] args){        Lava lava = new Lava();        lava.flow();    }}

  不同的虚拟机实现可能会用完全不同的方法来操作,下面描述的只是其中一种可能——但并不是仅有的一种。

  要运行Volcano程序,首先得以某种“依赖于实现的”方式告诉虚拟机“Volcano”这个名字。之后,虚拟机将找到并读入相应的class文件“Volcano.class”,然后它会从导入的class文件里的二进制数据中提取类型信息并放到方法区中。通过执行保存在方法区中的字节码,虚拟机开始执行main()方法,在执行时,它会一直持有指向当前类(Volcano类)的常量池(方法区中的一个数据结构)的指针。

  注意:虚拟机开始执行Volcano类中main()方法的字节码的时候,尽管Lava类还没被装载,但是和大多数(也许所有)虚拟机实现一样,它不会等到把程序中用到的所有类都装载后才开始运行。恰好相反,它只会需要时才装载相应的类。

  main()的第一条指令告知虚拟机为列在常量池第一项的类分配足够的内存。所以虚拟机使用指向Volcano常量池的指针找到第一项,发现它是一个对Lava类的符号引用,然后它就检查方法区,看Lava类是否已经被加载了。

  这个符号引用仅仅是一个给出了类Lava的全限定名“Lava”的字符串。为了能让虚拟机尽可能快地从一个名称找到类,虚拟机的设计者应当选择最佳的数据结构和算法。

  当虚拟机发现还没有装载过名为“Lava”的类时,它就开始查找并装载文件“Lava.class”,并把从读入的二进制数据中提取的类型信息放在方法区中。

  紧接着,虚拟机以一个直接指向方法区Lava类数据的指针来替换常量池第一项(就是那个字符串“Lava”),以后就可以用这个指针来快速地访问Lava类了。这个替换过程称为常量池解析,即把常量池中的符号引用替换为直接引用。

  终于,虚拟机准备为一个新的Lava对象分配内存。此时它又需要方法区中的信息。还记得刚刚放到Volcano类常量池第一项的指针吗?现在虚拟机用它来访问Lava类型信息,找出其中记录的这样一条信息:一个Lava对象需要分配多少堆空间。

  JAVA虚拟机总能够通过存储与方法区的类型信息来确定一个对象需要多少内存,当JAVA虚拟机确定了一个Lava对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量speed初始化为默认初始值0。

  当把新生成的Lava对象的引用压到栈中,main()方法的第一条指令也完成了。接下来的指令通过这个引用调用Java代码(该代码把speed变量初始化为正确初始值5)。另一条指令将用这个引用调用Lava对象引用的flow()方法。
5.本地方法栈
java虚拟机可能会用到传统的栈(C stack)来支持本地方法,即java语言以外的其他语言编写的方法。这个栈就是本地方法栈。当java虚拟机使用其他语言来实现指令集解释时,也会使用到本地方法栈。如果jvm不支持本地方法,并且自己也不依赖于传统栈,可以无需支持本地方法栈,如果支持,该栈一般在线程创建的时候按线程分配。本地方法栈可以被实现成固定大小的或者是根据计算可动态扩展和收缩的。如果采用固定大小的,那么这个容量应该在jvm线程创建的时候确定。发生异常的情况有如下两种:1.线程请求分配的栈容量超过栈的最大容量,此时会跑出StackOverflowError。2.如果是动态扩展的并且已经尝试过去获取更大的栈空间,但是无法申请到足够的内存去完成扩展,或者在创建新线程时没有足够的内存去创建对应的本地方法栈,此时会跑出OutOfMemoryError。

以上是关于运行时数据区的分类解释。下面对刚刚提到的一些概念进行解释。
6.栈帧
栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接,方法返回值和异常分派。栈帧随着方法的调用而创建,随着方法的结束而销毁,无论方法是正常完成还是异常完成,都算作方法结束。栈帧的存储空间分配在java虚拟机栈之中,每一个栈帧都有自己的本地变量表,操作数栈,和指向改方法所属类的运行时常量池的引用。本地变量表和操作数栈的容量,在编译期确定,并通过方法的code属性保存并提供给栈帧使用,code属性在后面会介绍到。当前栈帧,当前方法,当前类概念略过。如果当前方法调用了其他方法,或者当前方法执行结束,那么这个栈帧就不再是当前栈帧。栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一个线程的栈帧。
7.特殊方法
在java虚拟机层面上,java编程语言中的构造器是以一个名为init的特殊实例初始化的形式出现的。init这个方法的名称是由编译期命名的,因为它并非一个合法的java方法名,不可能通过程序编码实现。实例初始化方法只能在实例初始化期间,通过java虚拟机的invokespecial指令调用,只有在实例正在构造时,实例初始化方法才可以访问。一个类或者接口最多可以包含不超过一个类或者接口的初始化方法,类或者接口就是通过这个方法完成初始化的。这个方法是一个不包含参数的,返回类型为void的方法,名为clinit。
8.异常抛出
抛出异常的操作是java虚拟机中精确定义的程序控制权转移的操作。由java虚拟机执行的每个方法都会配有零个至多个异常处理器,异常处理器描述了其在方法代码中的有效作用范围,能处理的异常类型以及处理异常的代码所在的位置。要判断某个异常处理器是否可以处理某个具体的异常,需要同时检查异常出现的位置是否在异常处理的有效作用范围内,并且出现的异常是否是异常处理器声明的异常处理类型或者其子类型。当抛出异常时,java虚拟机搜索当前方法包含的各个异常处理器,如果能找到,则将代码控制权转移到异常处理器中描述的处理异常的分支中。

如果没有找到,并且当前方法调用期间确实发生了异常,那么当前方法操作的本地变量表,操作数栈都将被抛弃,随后它对应的栈帧出栈,线程的方法栈恢复到该方法调用者的栈帧中。未被处理的异常将在该方法的调用者栈帧中重新被抛出,并在整个方法调用链中不断地重复前面描述的处理过程,如果直到该方法调用栈的顶端,都没有找到合适的异常处理,那整个执行线程都将被终止。搜索异常处理器的顺序是很关键的, 在class文件中,每个方法的异常处理器都存在一张表中,运行时,当有异常抛出之后,java虚拟机会按照class文件中的异常处理器表描述的异常处理器顺序去搜索。需要注意的是,java虚拟机本身不会对异常处理器顺序进行排序,所以java语言中对异常处理的语义,实际上是通过编译器适当安排异常处理器在表中的顺序来协助完成的。只有在class文件中明确定义了异常处理器查找顺序,才能保证无论class文件通过何种途径产生,java虚拟机都能有一致的行为表现。

9.类装载子系统

在JAVA虚拟机中,负责查找并装载类型的那部分被称为类装载子系统。

  JAVA虚拟机有两种类装载器:启动类装载器(bootstrap classloader)和用户自定义类装载器。前者是JAVA虚拟机实现的一部分,后者则是Java程序的一部分。由不同的类装载器装载的类将被放在虚拟机内部的不同命名空间中。

  类装载器子系统涉及Java虚拟机的其他几个组成部分,以及几个来自java.lang库的类。比如,用户自定义的类装载器是普通的Java对象,它的类必须派生自java.lang.ClassLoader类。ClassLoader中定义的方法为程序提供了访问类装载器机制的接口。此外,对于每一个被装载的类型,JAVA虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和所有其他对象一样,用户自定义的类装载器以及Class类的实例都放在内存中的堆区,而装载的类型信息则都位于方法区。

  类装载器子系统除了要定位和导入二进制class文件外,还必须负责验证被导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。这些动作必须严格按以下顺序进行:

  (1)装载——查找并装载类型的二进制数据。

  (2)连接——指向验证、准备、以及解析(可选)。

    ● 验证  确保被导入类型的正确性。

    ● 准备  为类变量分配内存,并将其初始化为默认值。

    ● 解析  把类型中的符号引用转换为直接引用。

  (3)初始化——把类变量初始化为正确初始值。

  每个JAVA虚拟机实现都必须有一个启动类装载器,它知道怎么装载受信任的类。

  每个类装载器都有自己的命名空间,其中维护着由它装载的类型。所以一个Java程序可以多次装载具有同一个全限定名的多个类型。这样一个类型的全限定名就不足以确定在一个Java虚拟机中的唯一性。因此,当多个类装载器都装载了同名的类型时,为了惟一地标识该类型,还要在类型名称前加上装载该类型(指出它所位于的命名空间)的类装载器标识。


1 0
原创粉丝点击