一  、java虚拟机底层结构详解

         我们知道,一个JVM实例的行为不光是它自己的事,还涉及到它的子系统、存储区域、数据类型和指令这些部分,它们描述了JVM的一个抽象的内部体系结构,其目的不光规定实现JVM时它内部的体系结构,更重要的是提供了一种方式,用于严格定义实现时的外部行为。每个JVM都有两种机制,一个是装载具有合适名称的类(类或是接口),叫做类装载子系统;另外的一个负责执行包含在已装载的类或接口中的指令,叫做运行引擎。每个JVM又包括方法区、堆、Java栈、程序计数器和本地方法栈这五个部分,这几个部分和类装载机制与运行引擎机制一起组成的体系结构图为:

    JVM的每个实例都有一个它自己的方法域和一个堆,运行于JVM内的所有的线程都共享这些区域;当虚拟机装载类文件的时候,它解析其中的二进制数据所包含的类信息,并把它们放到方法域中;当程序运行的时候,JVM把程序初始化的所有对象置于堆上;而每个线程创建的时候,都会拥有自己的程序计数器和Java栈,其中程序计数器中的值指向下一条即将被执行的指令,线程的Java栈则存储为该线程调用Java方法的状态;本地方法调用的状态被存储在本地方法栈,该方法栈依赖于具体的实现。

    下面分别对这几个部分进行说明。

    执行引擎处于JVM的核心位置,在Java虚拟机规范中,它的行为是由指令集所决定的。尽管对于每条指令,规范很详细地说明了当JVM执行字节码遇到指令时,它的实现应该做什么,但对于怎么做却言之甚少。Java虚拟机支持大约248个字节码。每个字节码执行一种基本的CPU运算,例如,把一个整数加到寄存器,子程序转移等。Java指令集相当于Java程序的汇编语言。

Java指令集中的指令包含一个单字节的操作符,用于指定要执行的操作,还有0个或多个操作数,提供操作所需的参数或数据。许多指令没有操作数,仅由一个单字节的操作符构成。


《java虚拟机》所有关键点汇总_jvm




    

JVM寄存器

  1. pc: Java程序计数器;

  2. optop: 指向操作数栈顶端的指针;

  3. frame: 指向当前执行方法的执行环境的指针;。

  4. vars: 指向当前执行方法的局部变量区第一个变量的指针。

JVM栈结构

  局部变量区

每个Java方法使用一个固定大小的局部变量集。它们按照与vars寄存器的字偏移量来寻址。局部变量都是32位的。长整数和双精度浮点数占据了两个局部变量的空间,却按照第一个局部变量的索引来寻址。(例如,一个具有索引n的局部变量,如果是一个双精度浮点数,那么它实际占据了索引nn+1所代表的存储空间)虚拟机规范并不要求在局部变量中的64位的值是64位对齐的。虚拟机提供了把局部变量中的值装载到操作数栈的指令,也提供了把操作数栈中的值写入局部变量的指令。


 运行环境区


在运行环境中包含的信息用于动态链接,正常的方法返回以及异常捕捉。


  操作数栈区


机器指令只从操作数栈中取操作数,对它们进行操作,并把结果返回到栈中


例子展示




上面对虚拟机的各个部分进行了比较详细的说明,下面通过一个具体的例子来分析它的运行过程。

虚拟机通过调用某个指定类的方法main启动,传递给main一个字符串数组参数,使指定的类被装载,同时链接该类所使用的其它的类型,并且初始化它们。例如对于程序:


class HelloApp 


{
public static void main(String[] args) 
{
System.out.println("Hello World!"); 
for (int i = 0; i < args.length; i++ )
{
System.out.println(args[i]);
}
}
}



编译后在命令行模式下键入: java HelloApp run virtual machine

将通过调用HelloApp的方法main来启动java虚拟机,传递给main一个包含三个字符串"run"、"virtual"、"machine"的数组。现在我们略述虚拟机在执行HelloApp时可能采取的步骤。

开始试图执行类HelloApp的main方法,发现该类并没有被装载,也就是说虚拟机当前不包含该类的二进制代表,于是虚拟机使用ClassLoader试图寻找这样的二进制代表。如果这个进程失败,则抛出一个异常。类被装载后同时在main方法被调用之前,必须对类HelloApp与其它类型进行链接然后初始化。链接包含三个阶段:检验,准备和解析。检验检查被装载的主类的符号和语义,准备则创建类或接口的静态域以及把这些域初始化为标准的默认值,解析负责检查主类对其它类或接口的符号引用,在这一步它是可选的。类的初始化是对类中声明的静态初始化函数和静态域的初始化构造方法的执行。一个类在初始化之前它的父类必须被初始化。整个过程如下:

《java虚拟机》所有关键点汇总_jvm_02

二、类的生命周期(上)类的加载和连接


类加载器,顾名思义,类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例。每个这样的实例用来表示一个 Java 类。通过此实例的 newInstance()方法就可以创建出该类的一个对象。实际的情况可能更加复杂,比如 Java 字节代码可能是通过工具动态生成的,也可能是通过网络下载的。基本上所有的类加载器都是 java.lang.ClassLoader类的一个实例。其实我们研究类加载器主要研究的就是类的生命周期

 首先来了解一下jvm(java虚拟机)中的几个比较重要的内存区域,这几个区域在java类的生命周期中扮演着比较重要的角色:


方法区: java的虚拟机中有一块专门用来存放已经加载的类信息、常量、静态变量以及方法代码的内存区域,叫做方法区。

常量池:  常量池是方法区的一部分,主要用来存放常量和类中的符号引用等信息。

堆区:   用于存放类的对象实例。

栈区:   也叫java虚拟机栈,是由一个一个的栈帧组成的后进先出的栈式结构,栈桢中存放方法运行时产生的局部变量、方法出口等信息。当调用一个方法时,虚拟机栈中就会创建一个栈帧存放这些数据,当方法调用完成时,栈帧消失,如果方法中调用了其他方法,则继续在栈顶创建新的栈桢。

类的生命周期

 当我们编写一个java的源文件后,经过编译会生成一个后缀名为class的文件,这种文件叫做字节码文件,只有这种字节码文件才能够在java虚拟机中运行,java类的生命周期就是指一个class文件从加载到卸载的全过程。一个java类的完整的生命周期会经历加载、连接、初始化、使用、和卸载五个阶段,当然也有在加载或者连接之后没有被初始化就直接被使用的情况,这里我们主要来研究类加载器所执行的部分,也就是加载,链接和初始化。如图所示:
                  《java虚拟机》所有关键点汇总_java_03
下面我先简单看一下类加载器所执行的三部分的简单介绍
1加载:查找并加载类的二进制数据 

2、连接 

    –验证:确保被加载的类的正确性 

    –准备:为类的静态变量分配内存,并将其初始化为默认值 

    –解析:把类中的符号引用转换为直接引用 

3、初始化:为类的静态变量赋予正确的初始值 

     从上边我们可以看出类的静态变量赋了两回值。这是为什么呢?原因是,在连接过程中时为静态变量赋值为默认值,也就是说,只要是你定义了静态变量,不管你开始给没给它设置,我系统都为他初始化一个默认值。到了初始化过程,系统就检查是否用户定义静态变量时有没有给设置初始化值,如果有就把静态变量设置为用户自己设置的初始化值,如果没有还是让静态变量为初始化值

类的加载、连接和初始化

《java虚拟机》所有关键点汇总_jvm_04


类的加载

     类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构 。这里的class对象其实就像一面镜子一样,外面是类的源程序,里面是class对象,它实时的反应了类的数据结构和信息。

加载.class文件的方式 

1从本地系统中直接加载 

2通过网络下载.class文件 

3从zip,jar等归档文件中加载.class文件 

4从专有数据库中提取.class文件 

5将Java源文件动态编译为.class文件 

类的加载过程

《java虚拟机》所有关键点汇总_jvm_05

结论:

1类的加载的最终产品是位于堆区中的Class对象 

2Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口 

Java虚拟机给我们提供了两种类加载器:

1、Java虚拟机自带的加载器 

1)根类加载器(使用C++编写,程序员无法在Java代码中获得该类) 

2)扩展加载器,使用Java代码实现 

3)系统加载器(应用加载器),使用Java代码实现 

2、用户自定义的类加载器 

     java.lang.ClassLoader的子类 

     用户可以定制类的加载方式 


我们看一下APIClassLoader的介绍:

   类加载器是负责加载类的对象。ClassLoader 类是一个抽象类。如果给定类的二进制名称,那么类加载器会试图查找或生成构成类定义的数据。一般策略是将名称转换为某个文件名,然后从文件系统读取该名称的类文件。每个class对象都包含一个对定义它的 ClassLoader 的引用。

我们再来看一下Class类的一个方法getClassLoader

public ClassLoader getClassLoader()

返回该类的类加载器。有些实现可能使用 null 来表示根类加载器。如果该类由根类加载器加载,则此方法在这类实现中将返回 null。 


下面我们来看一个小例子来验证一下:

《java虚拟机》所有关键点汇总_java_06

看一下打印结果,一目了然:


《java虚拟机》所有关键点汇总_jvm_07

从上面打印结果可以看出,第一个为null,也就是它用根类加载器加载的,第二个是我们自己写的类,也就是说,我们自己写的那个类用sun.misc.Launcher$AppClassLoader@1372a1a加载器加载的,我们可以看到APP,也就是应用类加载器,也就是系统加载器


还记得我们以前用过的动态代理吧,InvocationHandler,当我们利用proxy对象调用newProxyInstance建立一个代理类时,我们要给他传一个ClassLoader,也就是类加载器,如下:

     《java虚拟机》所有关键点汇总_java_08


  当时我们学习的时候,只知道这里的loader随便给他设置一个类的类加载器就可以。现在我们来想想为什么这里需要一个类加载器呢?我们知道这个newProxyInstance是动态的给我们生成一个代理类,然后根据这个代理类生成一个代理对象。动态生成这个代理类之后我们不得把他加载到内存里吗,加载到内存里我们才可以用他。用什么加载到内存里,只有类加载器,所以我们要给他指定一个类加载器。


      类加载器并不需要等到某个类被首次主动使用时再加载它 。JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误) 如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误 。大家在做web开发的时候有可能会出现这种问题,比如我们在做测试的时候是用的jdk1.6,而我们在部署的时候我们用的是jdk1.5.这时候就很可能汇报LinkageError错误,版本不兼容。


类的连接

类被加载后,就进入连接阶段。连接就是将已经读入到内存的类的二进制数据合并到虚拟机的运行时环境中去。 

验证一个类被加载之后,必须要验证一下这个类是否合法,比如这个类是不是符合字节码的格式、变量与方法是不是有重复、数据类型是不是有效、继承与实现是否合乎标准等等。总之,这个阶段的目的就是保证加载的类是能够被jvm所运行。很多人都感觉,既然这个类都通过编译加载到内存里了,那肯定就是合法的了,为什么还要验证呢,这是因为这里的验证时为了避免有人恶意编写class文件,也就是说并不是通过编译得到的class文件。所以这里验证其实是检查的class文件的内部结构是否符合字节码的要求


准备备阶段的工作就是为类的静态变量分配内存并设为jvm默认的初值,对于非静态的变量,则不会为它们分配内存。有一点需要注意,这时候,静态变量的初值为jvm默认的初值,而不是我们在程序中设定的初值。jvm默认的初值是这样的:
基本类型(intlongshortcharbytebooleanfloatdouble)的默认值为0
引用类型的默认值为null
常量的默认值为我们程序中设定的值,比如我们在程序中定义final static int a = 100,则准备阶段中a的初值就是100


解析:一阶段的任务就是把常量池中的符号引用转换为直接引用。那么什么是符号引用,什么又是直接引用呢?我们来举个例子:我们要找一个人,我们现有的信息是这个人的×××号是1234567890。只有这个信息我们显然找不到这个人,但是通过公安局的身份系统,我们输入1234567890这个号之后,就会得到它的全部信息:比如山东省滨州市滨城区18号张三,通过这个信息我们就能找到这个人了。这里,123456790就好比是一个符号引用,而山东省滨州市滨城区18号张三就是直接引用。在内存中也是一样,比如我们要在内存中找一个类里面的一个叫做show的方法,显然是找不到。但是在解析阶段,jvm就会把show这个名字转换为指向方法区的的一块内存地址,比如c17164,通过c17164就可以找到show这个方法具体分配在内存的哪一个区域了。这里show就是符号引用,而c17164就是直接引用。在解析阶段,jvm会将所有的类或接口名、字段名、方法名转换为具体的内存地址。 


  类的初始化:在类的生命周期执行完加载和连接之后就开始了类的初始化。在类的初始化阶段,java虚拟机执行类的初始化语句,为类的静态变量赋值,在程序中,类的初始化有两种途径:(1)在变量的声明处赋值。(2)在静态代码块处赋值,比如下面的代码,a就是第一种初始化,b就是第二种初始化

《java虚拟机》所有关键点汇总_java_09

      静态变量的声明和静态代码块的初始化都可以看做静态变量的初始化,类的静态变量的初始化是有顺序的。顺序为类文件从上到下进行初始化,想到这,想起来一个很无耻的面试题,分享给大家看一下:

《java虚拟机》所有关键点汇总_jvm_10


大家先看看这里的程序会输出什么?

不知道大家的答案是什么,如果不介意的话可以把你的答案写到评论上,看看有多少人的答案和你一样的。我先说说我刚开始的答案吧。我认为会输出:

counter1 = 1

Counter2 = 1

不知道大家的答案是不是这个,反正我的是。下面我们来看一下正确答案:

《java虚拟机》所有关键点汇总_java_11

不知道你做对没有,反正我刚开始做错了。好,现在我来解释一下为什么会是这个答案。在给出解释之前,我们先来看一个概念:

Java程序对类的使用方式可分为两种 

主动使用

被动使用

所有的Java虚拟机实现必须在每个类或接口被Java程序“首次主动使用”时才初始化他们 

主动使用(六种) 

–创建类的实例 

–访问某个类或接口的静态变量,或者对该静态变量赋值 

–调用类的静态方法 

–反射(如Class.forName(“com.bzu.csh.Test”)) 

–初始化一个类的子类 

–Java虚拟机启动时被标明为启动类的类(Java Test) 

        OK,我们开始解释一下上面的答案,程序开始运行,首先执行main方法,执行main方法第一条语句,调用Singleton类的静态方法,这里调用Singleton类的静态方法就是主动使用Singleton类。所以开始加载Singleton类。在加载Singleton类的过程中,首先对静态变量赋值为默认值,

Singleton=null

counter1 = 0

Counter2 = 0

给他们赋值完默认值值之后,要进行的就是对静态变量初始化,对声明时已经赋值的变量进行初始化。我们上面提到过,初始化是从类文件从上到下赋值的。所以首先给Singleton赋值,给它赋值,就要执行它的构造方法,然后执行counter1++;counter2++;所以这里的counter1 = 1;counter2 = 1;执行完这个初始化之后,然后执行counter2的初始化,我们声明的时候给他初始化为了,所以counter2 的值又变为了0.初始化完之后执行输出。所以这是的

counter1 = 1

counter2 = 0

类初始化步骤

(1)假如一个类还没有被加载或者连接,那就先加载和连接这个类

(2)假如类存在直接的父类,并且这个父类还没有被初始化,那就先初始化直接的父类

(3)假如类中存在初始化语句,那就直接按顺序执行这些初始化语句

       在上边我们我们说了java虚拟机实现必须在每个类或接口被Java程序“首次主动使用时才初始化他们,上面也举出了六种主动使用的说明。除了上述六种情形,其他使用Java类的方式都被看作是被动使用,不会导致类的初始化。程序中对子类的“主动使用”会导致父类被初始化;但对父类的“主动”使用并不会导致子类初始化(不可能说生成一个Object类的对象就导致系统中所有的子类都会被初始化) 


注:调用ClassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

当java虚拟机初始化一个类时,要求它的所有的父类都已经被初始化,但这条规则并不适用于接口。

       在初始化一个类时,并不会先初始化它所实现的接口

       在初始化一个接口时,并不会先初始化它的父接口

      因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态变量时,才会导致该接口的初始化。只有当程序访问的静态变量或静态方法确实在当前类或当前接口中定义时,才可以认为是对类或接口的主动使用 。如果是调用的子类的父类属性,那么子类不会被初始化。


三、java虚拟机的垃圾回收机制


一、Java垃圾回收机制

        Java 的垃圾回收器要负责完成3 件任务:

            1.分配内存

            2.确保被引用的对象的内存不被错误回收

            3.回收不再被引用的对象的内存空间。

        垃圾回收是一个复杂而且耗时的操作。如果JVM 花费过多的时间在垃圾回收上,则势必会影响应用的运行性能。一般情况下,当垃圾回收器在进行回收操作的时候,整个应用的执行是被暂时中止(stop-the-world)的。这是因为垃圾回收器需要更新应用中所有对象引用的实际内存地址。不同的硬件平台所能支持的垃圾回收方式也不同。比如在多CPU 的平台上,就可以通过并行的方式来回收垃圾。而单CPU 平台则只能串行进行。不同的应用所期望的垃圾回收方式也会有所不同。服务器端应用可能希望在应用的整个运行时间中,花在垃圾回收上的时间总数越小越好。而对于与用户交互的应用来说,则可能希望所垃圾回收所带来的应用停顿的时间间隔越小越好。对于这种情况,JVM 中提供了多种垃圾回收方法以及对应的性能调优参数,应用可以根据需要来进行定制。

二、判断对象是否该被回收算法

        1.引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;任何时刻计数器值都为0时对象就表示它不可能被使用了。这个算法实现简单,但很难解决对象之间循环引用的问题,因此Java并没有用这种算法!这是很多人都误解了的地方。

        2.根搜索算法

通过一系列名为“GC ROOT”的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC ROOT没有任何引用链相连时,则证明这个对象是不可用的。如果对象在进行根搜索后发现没有与GC ROOT相连接的引用链,则会被第一次第标记,并看此对象是否需要执行finalize()方法(忘记finalize()这个方法吧,它可以被try-finally或其他方式代替的),当第二次被标记时,对象就会被回收。

三、Java虚拟机基本垃圾回收算法

1.标记-清除(Mark-Sweep)


此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。它停止所有工作,收集器从根开始访问每一个活跃的节点,标记它所访问的每一个节点。走过所有引用后,收集就完成了,然后就对堆进行清除(即对堆中的每一个对象进行检查),所有没有标记的对象都作为垃圾回收并返回空闲列表。下图 展示了垃圾收集之前的堆,阴影块是垃圾,因为用户程序不能到达它们:

可到达和不可到达的对象 

          《java虚拟机》所有关键点汇总_jvm_12                             
 

标记-清除实现起来很简单,可以容易地回收循环的结构,并且不像引用计数那样增加编译器或者赋值函数的负担。但是它也有不足 ―― 收集暂停可能会很长,在清除阶段整个堆都是可访问的,这对于可能有页面交换的堆的虚拟内存系统有非常负面的性能影响。

标记-清除的最大问题是,每一个活跃的(即已分配的)对象,不管是不是可到达的,在清除阶段都是可以访问的。因为很多对象都可能成为垃圾,这意思着收集器花费大量精力去检查并处理垃圾。标记-清除收集器还容易使堆产生碎片,这会产生区域性问题并可以造成分配失败,即使看来有足够的自由内存可用。此算法需要暂停整个应用,同时,会产生内存碎片。

                    《java虚拟机》所有关键点汇总_java_13                

 

2.复制(Copying)


此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不过出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。

                               

《java虚拟机》所有关键点汇总_java_14




      


3.标记-整理(Mark-Compact)


此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

                          《java虚拟机》所有关键点汇总_java_15        

 

4.增量收集(Incremental Collecting)


实施垃圾回收算法,即:在应用进行的同时进行垃圾回收。不知道什么原因JDK5.0中的收集器没有使用这种算法的。




5.堆内存的分代回收

《java虚拟机》所有关键点汇总_jvm_16

  • 新生代(Young)

新生代包括两个区:Eden区和Survivor区,其中Survivor区一般也分成两块,简称Survivor1 Space 和 Survivor2 Space (或者From Space 和 To Space)。新生代通常存活时间较短,因此基于标记清除复制算法来进行回收,扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和From或To之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从eden到Survior,最后到旧生代。

可以采用串行处理和并行处理器

  • 老年代(Old)

在垃圾回收多次,如果对象仍然存活,并且新生代的空间不够,则对象会存放在老年代。

在老年代采用的是 标记清除压缩算法。因为老年代的对象一般存活时间比较长,每次标记清除之后,会有很多的零碎空间,这个就是所谓的浮动垃圾。当老年代的零碎空间不足以分配一个大的对象的时候,就会采用压缩算法。在压缩的时候,应用需要暂停。

可以采用串行处理,并行处理器,以及并发处理器。

  • 持久代(Permanent)

这部分空间主要存放Java方法区的数据以及启动类加载器加载的对象。这一部分对象通常不会被回收。所以持久代空间在默认的情况下是不会被垃圾回收的。

 

      由于把内存空间分为三块,一般把新生代的GC称为minor GC ,把老年代的GC成为 full GC,所谓full gc 会先出发一次minor gc,然后在进行老年代的GC。

具体的过程如下:

首先想eden区申请分配空间,如果空间够,就直接进行分配,否则进行一次Minor GC。minor GC 首先会对Eden区的对象进行标记,标记出来存活的对象。然后把存活的对象copy到From空间。如果From空间足够,则回收eden区可回收的对象。如果from内存空间不够,则把From空间存活的对象复制到To区,如果TO区的内存空间也不够的话,则把To区存活的对象复制到老年代。如果老年代空间也不够(或者达到触发老年年垃圾回收条件的话)则触发一次full GC。