《分布式JAVA应用 基础与实践》 第三章 3.1 Java代码的执行机制(一)

Java程序运行在JVM之上,JVM的运行状况对于Java程序而言会产生很大的影响,因此,掌握JVM中的关键机制对于编写稳定、高性能的Java程序至关重要。

JVM规范 定义的标准结构如图3.1所示。

 
(点击查看大图)图3.1  JVM标准结构

以上标准结构是JVM规范中定义的,但各家厂商在实现时不一定会完全遵守。

JVM负责装载class文件并执行,因此,首先要掌握的是JDK如何将Java代码编译为class文件、如何装载class文件及如何执行 class,将源码编译为class文件的实现取决于各个JVM实现或各种源码编译器。class文件通常由类加载器(ClassLoader)来完成加 载;class的执行在Sun JDK中有解释执行和编译为机器码执行两种方式,其中编译为机器码又分为client和server两种模式。Sun JDK为了提升class的执行效率,对于解释执行和编译为机器码执行都设置了很多的优化策略。

Java程序无须显式分配和回收内存,因此JVM如何进行内存的分配和回收也是要关注的问题。

JVM提供了多线程支持,对于分布式Java应用而言,通常要借助线程来实现高并发,因此JVM中线程资源同步的机制及线程之间交互的机制也是需要掌握的。

各厂家在实现JVM时有所区别,本章以Sun JDK 1.6为例来对JVM中的这三个方面进行介绍。

3.1  Java代码的执行机制

要在JVM中执行Java代码,首先要编译为class文件。下面介绍Sun JDK是如何将Java代码编译为class文件的,这种机制通常称为Java源码编译机制。

3.1.1  Java源码编译机制(1)

JVM规范中定义了class文件的格式,但并未定义Java源码如何编译为class文件,各厂商在实现JDK时通常会将符合Java语言规范的 源码编译为class文件的编译器,例如在Sun JDK中就是javac,javac将Java源码编译为class文件的步骤如图3.2所示。

 
(点击查看大图)图3.2  javac编译源码为class文件的步骤

下面简单介绍以上三个步骤:

1. 分析和输入到符号表(Parse and Enter)

Parse过程所做的为词法和语法分析。词法分析(com.sun.tools.javac.parser.Scanner)要完成的是将代码字符 串转变为token序列(例如Token.EQ(name:=));语法分析(com.sun.tools.javac.parser.Parser)要 完成的是根据语法由token序列生成抽象语法树 。

Enter(com.sun.tools.javac.comp.Enter)过程为将符号输入到符号表,通常包括确定类的超类型和接口、根据需要添加默认构造器、将类中出现的符号输入类自身的符号表中等。

2. 注解处理(Annotation Processing)

该步骤主要用于处理用户自定义的annotation,可能带来的好处是基于annotation来生成附加的代码或进行一些特殊的检查,从而节省一些共用的代码的编写,例如当采用Lombok 时,可编写如下代码:

    public class User{ 
        private @Getter String username; 
    }

此功能基于JSR 269 ,在Sun JDK 6中提供了支持,在Annotation Processing进行后,再次进入Parse and Enter步骤。

3. 语义分析和生成class文件(Analyse and Generate)

Analyse步骤基于抽象语法树进行一系列的语义分析,包括将语法树中的名字、表达式等元素与变量、方法、类型等联系到一起;检查变量使用前是否 已声明;推导泛型方法的类型参数;检查类型匹配性;进行常量折叠;检查所有语句都可到达;检查所有checked exception都被捕获或抛出;检查变量的确定性赋值(例如有返回值的方法必须确定有返回值);检查变量的确定性不重复赋值(例如声明为final的 变量等);解除语法糖(消除if(false) {…} 形式的无用代码;将泛型Java转为普通Java;将含有语法糖的语法树改为含有简单语言结构的语法树,例如foreach循环、自动装箱/拆箱等)等。

在完成了语义分析后,开始生成class文件(com.sun.tools.javac.jvm.Gen),生成的步骤为:首先将实例成员初始化器 收集到构造器中,将静态成员初始化器收集为<clinit>();接着将抽象语法树生成字节码,采用的方法为后序遍历语法树,并进行最后的少 量代码转换(例如String相加转变为StringBuilder操作);最后从符号表生成class文件。

上面简单介绍了基于javac如何将java源码编译为class文件 ,除javac外,还可通过ECJ(Eclipse Compiler for Java) 或Jikes 等编译器来将Java源码编译为class文件。

class文件中并不仅仅存放了字节码,还存放了很多辅助jvm来执行class的附加信息,一个class文件包含了以下信息。

结构信息

包括class文件格式版本号及各部分的数量与大小的信息。

元数据

简单来说,可以认为元数据对应的就是Java源码中"声明"与"常量"的信息,主要有:类/继承的超类/实现的接口的声明信息、域(Field)与方法声明信息和常量池。

方法信息

简单来说,可以认为方法信息对应的就是Java源码中"语句"与"表达式"对应的信息,主要有:字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试用符号信息。

以一段简单的代码来说明class文件格式

public class Foo{ 
    private static final int  MAX_COUNT = 1000 ; 
    private static int  count = 0 ; 
    public int bar() throws Exception{ 
        if(++count > = MAX_COUNT){ 
            count = 0 ; 
            throw new Exception("count overflow"); 
        } 
        return count; 
    } 
}

执行javac -g Foo.java(加上-g是为了生成所有的调试信息,包括局部变量名及行号信息,在不加-g的情况下默认只生成行号信息)编译此源码,之后通过 javap -c -s -l -verbose Foo来查看编译后的class文件,结合class文件格式来看其中的关键内容。

// 类/继承的超类/实现的接口的声明信息 

public class Foo extends java.lang.Object 

SourceFile: "Foo.java" 

// class文件格式版本号,major version: 50表示为jdk 6,49为jdk 5,48为jdk 1.4,只有高版本能执行低版本的class文件,这也是jdk 5不能执行jdk 6编译的代码的原因。 

  minor version: 0 

  major version: 50 

 // 常量池,存放了所有的Field名称、方法名、方法签名、类型名、代码及class文件中的常量值。 

Constant pool: 

const # 1 = Method    #7.#27; //  java/lang/Object." < init > ":()V 

const # 2 = Field     #6.#28; //  Foo.count:I 

const # 3 = class     #29;    //  java/lang/Exception 

const # 4 = String    #30;    //  count overflow 

const # 5 = Method    #3.#31; //  java/lang/Exception." < init > ":(Ljava/lang/String;)V 

… 

const # 34 = Asciz    (Ljava/lang/String;)V; 



// 将符号输入到符号表时生成的默认构造器方法 

public Foo(); 

  … 

// bar方法的元数据信息 

public int bar()   throws java.lang.Exception; 

Signature: ()I 

  // 对应字节码的源码行号信息,可在编译的时候通过-g:none去掉行号信息,

行号信息对于查找问题而言至关重要,因此最好还是保留。 

LineNumberTable: 

   line 9: 0 

   line 10: 15 

   line 11: 19 

   line 13: 29 

  // 局部变量信息,如生成的class文件中无局部变量信息,则无法知道局部变量的名称,并且局部变量信息是和方法绑定的,接口是没有方法体的,所以ASM之类的在获取接口方法时,是拿不到方法中参数的信息的。 

LocalVariableTable: 

   Start  Length  Slot  Name   Signature 

   0      33      0    this       LFoo; 

  Code: 

Stack = 3 , Locals = 1 , Args_size = 1 

// 方法对应的字节码 

0:  getstatic   #2; //Field count:I 

   .. 

   29:  getstatic   #2; //Field count:I 

   32:  ireturn 

    … 

  // 记录有分支的情况(对应代码中if..、for、while等),在下一节"类加载机制"中会讲解这个的作用 

StackMapTable: number_of_entries = 1 

frame_type = 29 /* same */ 

 // 异常处理器表 

Exceptions: 

   throws java.lang.Exception 

.. 

}

从上可见,class文件是个完整的自描述文件,字节码在其中只占了很小的部分,源码编译为class文件后,即可放入jvm中执行。执行时jvm首先要做的是装载class文件,这个机制通常称为类加载机制。

3.1.2  类加载机制

类加载机制是指.class文件加载到JVM,并形成Class对象的机制,之后应用就可对Class对象进行实例化并调用,类加载机制可在运行时 动态加载外部的类、远程网络下载过来的class文件等。除了该动态化的优点外,还可通过JVM的类加载机制来达到类隔离的效果,例如 Application Server中通常要避免两个应用的类互相干扰。

JVM将类加载过程划分为三个步骤:装载、链接和初始化。装载和链接过程完成后,即将二进制的字节码转换为Class对象;初始化过程不是加载类时必须触发的,但最迟必须在初次主动使用对象前执行,其所作的动作为给静态变量赋值、调用<clinit>()等。

整个过程如图3.3所示。

1. 装载(Load)

装载过程负责找到二进制字节码并加载至JVM中,JVM通过类的全限定名(com.bluedavy. HelloWorld)及类加载器(ClassLoaderA实例)完成类的加载,同样,也采用以上两个元素来标识一个被加载了的类:类的全限定 名+ClassLoader实例ID。类名的命名方式如下:

对于接口或非数组型的类,其名称即为类名,此种类型的类由所在的ClassLoader负责加载;

 
图3.3  类加载过程

对于数组型的类,其名称为"["+(基本类型或L+引用类型类名;),例如byte[] bytes=new byte[512],该bytes的类名为:[B; Object[] objects=new Object[10],objects的类名则为:[Ljava.lang.Object;,数组型类中的元素类型由所在的ClassLoader负责加 载,但数组类则由JVM直接创建。

2. 链接(Link)

链接过程负责对二进制字节码的格式进行校验、初始化装载类中的静态变量及解析类中调用的接口、类。

二进制字节码的格式校验遵循Java Class File Format(具体请参见JVM规范)规范,如果格式不符合,则抛出VerifyError;校验过程中如果碰到要引用到其他的接口和类,也会进行加载; 如果加载过程失败,则会抛出NoClassDefFoundError。

在完成了校验后,JVM初始化类中的静态变量,并将其值赋为默认值。

最后对类中的所有属性、方法进行验证,以确保其要调用的属性、方法存在,以及具备相应的权限(例如public、private域权限等)。如果这个阶段失败,可能会造成NoSuchMethodError、NoSuchFieldError等错误信息。

3. 初始化(Initialize)

初始化过程即执行类中的静态初始化代码、构造器代码及静态属性的初始化,在以下四种情况下初始化过程会被触发执行:

1)调用了new;

2)反射调用了类中的方法;

3)子类调用了初始化;

4)JVM启动过程中指定的初始化类。

在执行初始化过程之前,首先必须完成链接过程中的校验和准备阶段,解析阶段则不强制。

JVM的类加载通过ClassLoader及其子类来完成,分为Bootstrap ClassLoader、Extension ClassLoader、System ClassLoader及User-Defined ClassLoader。这4种ClassLoader的关系如图3.4所示。

 
图3.4  Sun JDK ClassLoader继承关系

 

1. Bootstrap ClassLoader

Sun JDK采用C++实现了此类,此类并非ClassLoader的子类,在代码中没有办法拿到这个对象,Sun JDK启动时会初始化此ClassLoader,并由ClassLoader完成$JAVA_HOME中jre/lib/rt.jar里所有class文 件的加载,jar中包含了Java规范定义的所有接口及实现。

2. Extension ClassLoader

JVM用此ClassLoader来加载扩展功能的一些jar包,例如Sun JDK中目录下有dns工具jar包等,在Sun JDK中ClassLoader对应的类名为ExtClassLoader。

3. System ClassLoader

JVM用此ClassLoader来加载启动参数中指定的Classpath中的jar包及目录,在Sun JDK中ClassLoader对应的类名为AppClassLoader。

例如一段这样的代码:

    public class ClassLoaderDemo { 
        public static void main(String[] args) throws Exception{ 
            System.out.println(ClassLoaderDemo.class.getClassLoader()); 
            System.out.println(ClassLoaderDemo.class.getClassLoader().getParent()); 
        System.out.println(ClassLoaderDemo.class.getClassLoader().getParent().getParent()); 
        } 
    }

执行后显示的信息类似如下:

    (sun.misc.Launcher$AppClassLoader) 
    (sun.misc.Launcher$ExtClassLoader) 
    null

按照上面的描述,就可看到典型的System ClassLoader、Extension ClassLoader,而由于Bootstrap ClassLoader并不是Java中的ClassLoader,因此Extension ClassLoader的parent为null。

4. User-Defined ClassLoader

User-Defined ClassLoader是Java开发人员继承ClassLoader抽象类自行实现的ClassLoader,基于自定义的ClassLoader可用 于加载非Classpath中(例如从网络上下载的jar或二进制)的jar及目录、还可以在加载之前对class文件做一些动作,例如解密等。

JVM的ClassLoader采用的是树形结构,除BootstrapClassLoader外,其他的ClassLoader都会有 parent ClassLoader,User-Defined ClassLoader默认的parent ClassLoader为System ClassLoader。加载类时通常按照树形结构的原则来进行,也就是说,首先应从parent ClassLoader中尝试进行加载,当parent中无法加载时,应再尝试从System ClassLoader中进行加载,System ClassLoader同样遵循此原则,在找不到的情况下会自动从其parent ClassLoader中进行加载。值得注意的是,由于JVM是采用类名加Classloader的实例来作为Class加载的判断的,因此加载时不采用 上面的顺序也是可以的,例如加载时不去parent ClassLoader中寻找,而只在当前的ClassLoader中寻找,会造成树上多个不同的ClassLoader中都加载了某Class,并且这 些Class的实例对象都不相同,JVM会保证同一个ClassLoader实例对象中只能加载一次同样名称的Class,因此可借助此来实现类隔离的需 求,但有时也会带来困惑,例如ClassCastException。因此在加载类的顺序上要根据需求合理把握,尽量保证从根到最下层的 ClassLoader上的Class只加载了一次。

ClassLoader抽象类提供了几个关键的方法:

loadClass

此方法负责加载指定名字的类,ClassLoader的实现方法为先从已经加载的类中寻找,如没有,则继续从parent ClassLoader中寻找;如果仍然没找到,则从System ClassLoader中寻找,最后再调用findClass方法来寻找;如果要改变类的加载顺序,则可覆盖此方法;如果加载顺序相同,则可通过覆盖 findClass来做特殊的处理,例如解密、固定路径寻找等。当通过整个寻找类的过程仍然未获取Class对象时,则抛出 ClassNotFoundException。

如果类需要resolve,则调用resolveClass进行链接。

findLoadedClass

此方法负责从当前ClassLoader实例对象的缓存中寻找已加载的类,调用的为native的方法。

findClass

此方法直接抛出ClassNotFoundException,因此要通过覆盖loadClass或此方法来以自定义的方式加载相应的类。

findSystemClass

此方法负责从System ClassLoader中寻找类,如未找到,则继续从Bootstrap ClassLoader中寻找,如果仍然未找到,则返回null。

defineClass

此方法负责将二进制的字节码转换为Class对象,这个方法对于自定义加载类而言非常重要。如果二进制的字节码的格式不符合JVM Class文件的格式,则抛出ClassFormatError;如果生成的类名和二进制字节码中的不同,则抛出 NoClassDefFoundError;如果加载的class是受保护的、采用不同签名的,或者类名是以java.开头的,则抛出 SecurityException;如果加载的class在此ClassLoader中已加载,则抛出LinkageError。

resolveClass

此方法负责完成Class对象的链接,如果链接过,则会直接返回。

当Java开发人员调用Class.forName来获取一个对应名称的Class对象时,JVM会从方法栈上寻找第一个ClassLoader, 通常也就是执行Class.forName所在类的ClassLoader,并使用此ClassLoader来加载此名称的类。JVM为了保护加载、执行 的类的安全,它不允许ClassLoader直接卸载加载了的类,只有JVM才能卸载,在Sun JDK中,只有当ClassLoader对象没有引用时,此ClassLoader对象加载的类才会被卸载。

根据上面的描述,在实际的应用中,JVM类加载过程会抛出这样那样的异常,这些情况下掌握各种异常产生的原因是最为重要的,下面来看类加载方面的常见异常。

1. ClassNotFoundException

这是最常见的异常,产生这个异常的原因为在当前的ClassLoader中加载类时未找到类文件,对位于System ClassLoader的类很容易判断,只要加载的类不在Classpath中,而对位于User-Defined ClassLoader的类则麻烦些,要具体查看这个ClassLoader加载类的过程,才能判断此ClassLoader要从什么位置加载到此类。

例如直接在代码中执行Class.forName("com.bluedavy.A"),而当前类的classloader下根本就没有该类所在的jar或没有该class文件,就会抛出ClassNotFoundException。

2. NoClassDefFoundError

该异常较之ClassNotFoundException更难处理一些,造成此异常的主要原因是加载的类中引用到的另外的类不存在,例如要加载A,而A中调用了B,B不存在或当前ClassLoader没法加载B,就会抛出这个异常。

例如有一段这样的代码:

public class A{ 

private B b = new B(); 

}

当采用Class.forName加载A时,虽能找到A.class,但此时B.class不存在,则会抛出NoClassDefFoundError。

因此,对于这个异常,须先查看是加载哪个类时报出的,然后再确认该类中引用的类是否存在于当前ClassLoader能加载到的位置。

3. LinkageError

该异常在自定义ClassLoader的情况下更容易出现,主要原因是此类已经在ClassLoader加载过了,重复地加载会造成该异常,因此要注意避免在并发的情况下出现这样的问题。

由于JVM的这个保护机制,使得在JVM中没办法直接更新一个已经load的Class,只能创建一个新的ClassLoader来加载更新的 Class,然后将新的请求转入该ClassLoader中来获取类,这也是JVM中不好实现动态更新的原因之一,而其他更多的原因是对象状态的复制、依 赖的设置等。

4. ClassCastException

该异常有多种原因,在JDK 5支持泛型后,合理使用泛型可相对减少此异常的触发。这些原因中比较难查的是两个A对象由不同的ClassLoader加载的情况,这时如果将其中某个A对象造型成另外一个A对象,也会报出ClassCastException。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值