理解jvm

当运行一个Java程序的同时,也就是运行了一个Java虚拟机实例。每个Java程序都运行于某个具体的Java虚拟机实现的实例上。

Java虚拟机的生命周期

一个运行时的Java虚拟机实例的天职就是:负责运行一个Java程序。当启动一个Java程序时,一个虚拟机实例也就诞生了。当该程序关闭退出是, 这个虚拟机实例也就随之消亡。如果在同一个计算机上同时运行三个程序,将得到三个Java虚拟机实例。每个Java程序都运行于它自己的Java虚拟机实例中。

Java虚拟机实例通过调用某个初始类的main()方法来运行一个Java程序,此方法将作为该程序初始线程的启动,任何其他的线程都是由这个初始线程启动的。

在Java虚拟机内部有两种线程:守护线程与非守护线程。守护线程通常是由虚拟机自己使用的,比如执行垃圾收集任务的线程。但是, Java程序也可以把他创建的任何线程标记为守护线程。而Java程序中的初始线程,就是开始于main()的那个,是非守护进程。只要还有非守护线程在运行, 那么这个Java程序也在继续运行(虚拟机仍然存活)。当该程序中所有的非守护线程都终止时,虚拟机实例将自动退出。假若安全管理器允许, 程序本身也能够通过调用Runtime类或者System类的exit()方法来退出。

Java虚拟机体系结构

《理解jvm》

从上图可以看得出来,JVM中包含:

###1. Class Loader 类加载器

所有的class文件必须被加载后才能在jvm中运行.

###2.Runtime Data Areas 运行数据区

运行时数据区分为:method area(本地方法区)、heap(堆)、java stacks(Java 栈)、 pc registers(pc寄存器)、 native method stacks(本地方法栈).

下面来依次解释下上述内容:

Method area(方法区)

在Java虚拟机中,关于被装载类型的信息存储在一个逻辑上被称为方法区的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件, 然后读入这个class文件——一个线性二进制数据流。然后将它传输到虚拟机中。紧接着虚拟机提取其中的类型信息,并将这些信息存储到方法区。 该类型中的类(静态)变量同样也是存储在方法区中。

Java虚拟机在内部如何存储类型信息,这是由具体实现的设计者来决定的。比如,在class文件中,多字节总是以高位在前(即代表较大数的字节在前)的顺序储存。 但是这些数据被引入到方法区后,虚拟机可以以任何方式存储它。假设某个实现是运行在低位优先的处理器上,那么它很可能会把多字节以低位优先的 顺序存储到方法区中。

当虚拟机运行Java程序时,它会查找使用存储在方法区中的类型信息。设计者应当为类型信息的内部设计适当的数据结构,以尽可能在保持虚拟机小巧紧凑的同时加快程序的运行效率。 如果正在设计一个需要在少量内存的限制中操作的实现,设计则可能会决定以牺牲某些运行速度来换取紧凑性。另外一方面, 如果设计一个将在虚拟内存系统中运行的实现,设计者可能会决定在方法去中保存一些冗余的信息,以此来加快执行 速度。 (如果底层主机没有提供虚拟内存,但是提供了一个硬盘,设计者可能会在实现中穿件一个虚拟内存系统。)Java虚拟机的设计者可以根据目标平台的资源限制和需求, 在空间和时间上作出权衡,选择实现什么样的数据结构和数据组织。

由于所有线程都共享方法区,因此它们对方法区数据的访问必须被设计为是线程安全的。比如,假设同时有连个线程都企图访问一个名为Lava的类, 而这个类还没有被装入虚拟机,那么这是只应该有一个线程去装载它,而另外一个线程则只能等待。

方法区的大小不必是固定的,虚拟机可以根据应用的需要动态调整。同样,方法区也不必是连续的,方法区可以在一个堆(甚至是虚拟机自己的堆)中自由分配。 另外,虚拟机也可以允许用户或者程序员指定方法区的初始化大小以及最小和最大尺寸等。

方法区也可以被垃圾收集,因为虚拟机允许通过用户定义的类装载器来动态扩展java程序,因此一些类也会成为程序”不再引用”的类。 当某个类变为不再被引用的类时,Java虚拟机可以卸载这个类(垃圾收集),从而使方法区占据的内存保持最小。

方法区针对具体的语言特性有几种信息是存储在方法区内的:

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

1.这个类型的完全限定名(java.lang.String格式) 2.这个类型的直接超类的全限定名(除非uzhege类型时java.lang.Object,它没有超类) 3.这个类型是类类型还是接口类型 4.这个类型的访问修饰符(public、abstract或final的某个子集) 5.任何直接超接口的全限定名的有序列表

在Java class文件和虚拟机中,类型名总是以全限定名出现。在java源代码中,全限定名由类所属包的名称加上一个“.”,再加上类名组成。 例如,类Object 的所属包为java.lang,那她的全限定名应该是 java.lang.Object,但是在class文件里,所有的“.”都被斜杠“/”替换, 这样就成为java/lang/Object。至于全限定名在方法区中的表示,则因不同的设计者有不同的选择而不同,可以用任何形式和数据结构来表示。

除了上面列出的基本类型信息外,虚拟机还得为每个被装载的类型存储以下信息: 该类型的常量池,字段信息,方法信息,除了常量以外的所有类(静态)变量,一个到ClassLoader的引用,一个到Class类的引用

  • 常量池

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

  • 字段信息

对于类型中声明的每一个方法,方法区中必须保存下面的信息。和字段一样,这些方法法在类或者接口中的声明顺序也必须保存。下面是方法信息的清单: 方法名, 方法的返回类型(或void), 方法参数的数量和类型(按按声明顺序), 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的某个子集).

除了上面的清单中列出的条目之外,如果某个方法不是抽象的和本地的,它还必须保存下面的信息: 方法的字节码(bytecodes), 操作数栈和该方法的栈帧中的局部变量区的大小, 异常表。

  • 类(静态)变量

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

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

  • 指向ClassLoader类的应用

每个类型被加载的时候,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的。如果是用户自定义类装载器装载的, 那么虚拟机必须在类型信息中存储对该装载器的引用。这是作为方法表中的类型数据的一部分保存的。虚拟机会在动态连接期间使用这个信息。 当某个类型引用另外一个类型的时候,虚拟机会请求装载发起引用类型的类装载器类装载来装载被引用的类型。这个动态连接的过程, 对于虚拟机分离命名空间的范式也是至关重要的。为了能够正确地执行动态连接以及维护多个命名空间, 虚拟机需要在方法表中得知每个类都是由哪个类装载器装载的.

  • 指向Class类的引用

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

  • 方法表

为了尽可能提高访问效率,设计者必须仔细设计存储在方法区中的类型信息的数据结构,因此,除了以上讨论的原始类型信息, 实现中还可能包括其他数据结构加快访问原始数据的速度,比如方法表。虚拟机对每个装载的非抽象类,都生成一个方法表, 把它作为类信息的一部分保存在方法区。方法表是一个数组,他的元素是所有他的实例可能被调用的实例方法的直接引用, 包括哪些从超类继承过来的实例方法。(对于抽象类和接口,方法表没有什么帮助,因为程序绝不会生成他们的实例) 运行时可以通过方法表快速搜索在对象中调用的实例方法。

方法区使用示例 为了展示虚拟机如何使用方法区中的信息,我们举个例子,看下面这个类

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

下面的段落描述了某个实现是如何执行Volcano程序中main()方法的字节码中第一条指令的。不同的虚拟机实现可能会用完全不同的方法来操作, 下面描述的只是其中一种可能,但是并不是仅有的一种,下面看一下Java虚拟机是如何执行Volcano程序中main()方法的第一条指令的。

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

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

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

这个符号引用仅仅是一个给出了类Lava的全限定名“Lava”的字符串。为了能让虚拟机尽可能快地从一个名称找到类,设计者应当选择最佳的数据结构和算法。 这里可以采用各种方法,如散列表、搜索树等等。同样的算法可以以用于实现Class类的forName()方法,这个方法根据给定的全限定名返回Class引用。

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

紧接着,虚拟机以一个直接指向方法区Lava类数据的指针类替换常量池第一项(就是那个字符串“Lava”)—-以后就可以用这个指针来快速访问Lava类了。 这个替换过程称为常量池解析,即把常量池中的符号引用替换为直接引用。这是通过在方法区中搜索被引用的元素实现的,在这期间可能又需要装载其他类。 在这里,我们替换掉符号引用的“直接引用”是一个本地指针。

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

Java虚拟机总能够通过存储于方法区的类型信息来实现一个对象需要的内存,但是,某一个特定对象事实上需要多少内存,是跟特定实现相关的。对象在虚拟机内部的表示由实现的设计者来决定的。 当java虚拟机确定了一个Lava对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量speed初始化为默认初始值0.假如Lava类的超类Object也有实例变量,这也会在此时被初始化为相应的默认值。 当把新生成的Lava对象的引用压到栈中,main()方法的第一条指令也完成了。接下来的指令通过这个引用调用Java代码(改代码把speed变量初始化为正确的初始值5 )。另外一条指令将用这个引用调用Lava对象引用的flow()方法。

Heap 堆

java程序在运行时创建的所有类实例或数组都放在同一个堆中。而一个Java虚拟机实例只存在一个堆内存空间,因此所有的线程均共享这个堆。 又由于一个java程序独占一个Java虚拟机实例,因而每个java程序都有他自己的堆内存,他们之间不会相互影响。但是同一个Java 程序的多线程却共享着同一个堆空间,在这种情况下,就得考虑多线程访问对象(堆数据)的同步问题了。

java虚拟机规范中并没有规定具体的实现需要准备多少内存,也没有说他必须怎么去管理他的堆空间,它仅仅告诉设计者java程序需要在堆内存中为对象分配空间, 并且程序本身并不去释放他。因此堆空间的管理(包括垃圾收集)问题得由设计者自己自行去考虑处理方式。对象的引用在很多地方都存在, 如java栈、堆、方法区、本地方法栈,所以垃圾收集技术的设计和使用在很大程度上会影响到运行时数据区的设计。

和方法区一样,堆空间也不必是连续的内存区。在程序运行时,他可以动态的扩展或收缩。事实上,一个实现的方法区可以在堆顶实现。 换句话说就是当虚拟机需要为某个新装载的类分配内存是,类型信息和实际对象可以都在同一个堆上。因此, 负责回收无用对象的垃圾收集器可能也要负责无用类的释放。另外,某些实现允许程序员或用户指定堆的初始化内存大小以及最大、最小值等。

  • 对象的内部表示

Java虚拟机规范并没有规定Java对象在堆中是如何表示的。对象的内部表示也影响着整个堆以及垃圾收集器的设计,他由虚拟机的实现者决定。

java对象中包含的基本数据类型由他所属的类及其所有超类声明的实例变量组成。只要有一个对象引用虚拟机就必须能够快速定位对象实例的数据。 另外,他也必须能够通过该对象引用访问相应的类数据(存储于方法区的类型信息)。因此在对象中通常会有一个指向方法区的指针。

一种可能的堆空间设计就是,把堆分为两个部分:一个句柄池,一个对象池。如下图所示

《理解jvm》

一个对象引用就是一个指向句柄池的指针。句柄池的每个条目有两个部分:一个指向对象实例变量的指针,一个指向方法区中类型数据的指针。 这种设计的好处是有利于碎片的整理,当移动对象池中的对象时,句柄部分只需要更改一下指针指向对象的新地址即可,就是句柄池中的那个指针。 缺点是每次访问对象的实例变量时都要经过两次指针传递。

另一种设计方式是使对象指针直接指向一组数据,而该数据包括对象实例数据以及指向方法区中类数据的指针。

《理解jvm》

这样设计的优缺点正好和前面的相反,他只需要一个指针就可以访问对象的实例数据,但是移动对象就变得更加复杂。 当使用这种堆的虚拟机为了减少内存碎片而移动对象的时候,他必须在整个运行时数据区中更新指向被移动对象的引用。

有如下几个理由要求虚拟机必须能够通过对象引用得到类数据:当程序在运行时需要转换某个对象引用为另一种数据类型时, 虚拟机必须要检查这种转换是否被允许,被转换的对象是否的确是被引用的对象或者他的超类,当程序在执行instanceof操作时, 虚拟机也进行了同样的检查。在这两种情况下,虚拟机都需要查看被引用对象的类数据。最后,当程序中调用某个实例方法时,虚拟机必须进行动态绑定, 换句话说,他不能按照引用类型来决定将要调用的方法,而必须根据对象的实际类,为此,虚拟机必须再次通过对象的引用去访问类数据。

不管虚拟机的实现使用什么样的对象表示法,很可能每个对象都有一个方法表,因为对象表加快了调用实例方法时的效率, 从而对java虚拟机实现的整体性能起着非常重要的作用。但是Java虚拟机规范中并未要求必须是使用方法表,所以并不是所有的实现中都会使用它。 比如那些具有严格内存资源限制的实现,或者他们根本不可能有足够的额外内存资源来存储方法表。如果一个实现中使用方法表, 那么仅仅使用一个指向对象的引用,就可以很快访问到对象的方法表。

《理解jvm》

上图展示了一种把方法表和对象引用联系起来的实现方式。每个对象的数据都包含一个指向特定数据结构的指针,这个数据结构位于方法区.

Java Stacks

每当启动一个新线程时,Java虚拟机都会为它分配一个Java栈。前面我们曾经提到,Java栈出帧为单位保存线程的运行状态。 虚拟机只会直接对Java栈执行两种操作:以帧为单位的压栈或出栈。

某个线程正在执行的方法被称为该线程的当前方法,当前方法使用的栈帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。 在线程执行一个方法时,他会跟踪当前类和当前常量池。此外当虚拟机遇到栈内操作指令时,他对当前帧内数据执行操作。

每当线程调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧。而这个新帧自然就成为了当前帧。在执行这个方法时,他使用这个帧来存数参数、局部变量、中间运算结果等等数据。

Java方法可以以两种方式完成。一种通过return返回的,称为正常返回;一种是通过抛出异常而异常终止的。不管哪种方式返回,虚拟机都会将当前帧弹出Java栈然后释放掉,这样上一个方法的真就成为当前帧了。

Java栈上的所有数据都是此线程私有的。任何线程都不能访问另外一个线程的栈数据,因此我们不需要考虑多线程情况下栈数据的访问同步问题。 当一个线程调用一个方法时,方法的局部变量保存在调用线程Java栈的帧中。只有一个线程能总是访问那些局部变量,即调用方法的线程。

像方法区和堆一样,Java栈和帧在内存中也不是连续的。帧可以分布在连续的栈里,也可以分步在堆里,或者二者兼而有之。 表示Java栈和栈帧的实际数据结构由虚拟机的实现者决定,某些实现允许用户指定Java栈的初始大小和最大最小值。

栈帧由三部分组成:局部变量区、操作数栈和帧数据区(保存一些数据来支持常量池解析、正常方法返回以及异常派发机制)

《理解jvm》

PC Register

对于一个运行中的Java程序而言,其中的每一个线程都有他自己的PC(程序计数器)寄存器,它是在该线程启动时创建的。PC寄存器的大小是一个字长, 因此他既能够持有一个本地指针,也能够持有一个returnAddress。当线程执行某个Java方法时,PC寄存器的内容总是下一条将被执行指令的”地址”, 这里的“地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法, 那么此时PC寄存器的值是“undefined”。

Native Method Stacks

前面提到的所有运行时数据区都是Java虚拟机规范中明确定义的,除此之外,对已一个运行中的Java程序而言,他还可能会用到一些本地方法相关的数据区。 当某个线程调用一个本地方法时,他就进入了一个全新的并且不再受虚拟机限制的世界,本地方法可以通过本地方法接口来访问虚拟机得运行时数据区, 但不止于此,他还可以做任何他想做的事情。比如,他甚至可以直接使用本地处理器中的寄存器,或者直接从本地内存的堆中分配任意数量的内存等等。 宗旨,他和虚拟机拥有同样的权限(或者说能力)。

本地方法本质上是依赖于实现的,虚拟机实现的设计者可以自由地决定使用怎样的机制来让Java程序调用本地方法。

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入java栈。然而当他调用的是本地方法时, 虚拟机会保持Java栈不变,不再在线程的java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。 可以把这看做是虚拟机利用本地方法来动态扩展自己。就如同Java虚拟机的实现在按照其中运行的Java程序的吩咐, 调用属于虚拟机内部的另一个(动态连接的)方法。

如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那个他的本地方法栈就是C栈。我们知道,当C程序调用一个C函数时, 其栈操作都是确定的。传递给该函数的参数已某个确定的顺序压入栈,他的返回值也以确定的方式传回调用者。同样,这就是改虚拟机实现中本地方法栈的行为。

很可能本地方法接口需要回调Java虚拟机中的Java方法(这也是由设计者决定的),在这种情形下,该线程会保存本地方法栈的状态并进入到另一个Java栈。

下图描绘了这种情况,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。这幅图展示了java虚拟机内部线程运行的全景图。 一个线程可能在整个生命周期中都执行Java方法,操作他的Java栈;或者他可能毫无障碍地在Java栈和本地方法栈之间跳转。

《理解jvm》

上图所示,改线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。 图中的本地方法栈显示为一个连续的内存空间。假设这是一个C语言栈,期间有两个C函数,他们都以包围在虚线中的灰色块表示。 第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数被第二个Java方法当做本地方法调用, 而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法)。 最终这个Java方法又调用了一个Java方法(他成为图中的当前方法)。

就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,他可以根据需要动态扩展或者收缩。 某些是实现也允许用户或者程序员指定该内存区的初始大小以及最大最小值。

    原文作者:mxn原创
    原文地址: http://souly.cn/%E6%8A%80%E6%9C%AF%E5%8D%9A%E6%96%87/2015/09/20/%E7%90%86%E8%A7%A3JVM/
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞