Java类和类加载

    技术2024-04-17  11

    本文揭开了一个新系列的序幕,涵盖了一系列我称为Java编程动力学的主题。 这些主题的范围从Java二进制类文件格式的基本结构到使用反射的运行时元数据访问,一直到在运行时修改和构造新类。 贯穿所有这些材料的共同思路是,与使用直接编译为本机代码的语言一起使用时,对Java平台进行编程要动态得多。 如果您了解这些动态方面,则可以使用Java编程来完成其他任何主流编程语言都无法比拟的事情。

    在本文中,我将介绍构成Java平台这些动态功能基础的一些基本概念。 这些概念围绕用于表示Java类的二进制格式展开,包括将这些类加载到JVM中时发生的情况。 该材料不仅为该系列的其余文章奠定了基础,而且还向使用Java平台的开发人员展示了一些非常实际的问题。

    二进制类

    使用Java语言的开发人员通常不需要担心源代码在通过编译器运行时所发生的细节。 但是,在本系列中,我将介绍从源代码到执行程序的许多幕后细节,因此,我将首先看一下编译器生成的二进制类。

    二进制类格式实际上是由JVM规范定义的。 通常,这些类表示由编译器从Java语言源代码生成的,并且通常存储在扩展名为.class文件中。 但是,这些功能都不是必不可少的。 已经开发了使用Java二进制类格式的其他编程语言,并且出于某些目的,构造了新的类表示形式并立即将其加载到正在执行的JVM中。 就JVM而言,重要的部分不是源或如何存储,而是格式本身。

    那么这种类格式实际上是什么样的呢? 清单1给出了(非常)短类的源代码,以及编译器输出的类文件的部分十六进制显示:

    清单1. Hello.java的源代码和(部分)二进制文件
    public class Hello { public static void main(String[] args) { System.out.println("Hello, World!"); } } 0000: cafe babe 0000 002e 001a 0a00 0600 0c09 ................ 0010: 000d 000e 0800 0f0a 0010 0011 0700 1207 ................ 0020: 0013 0100 063c 696e 6974 3e01 0003 2829 .....<init>...() 0030: 5601 0004 436f 6465 0100 046d 6169 6e01 V...Code...main. 0040: 0016 285b 4c6a 6176 612f 6c61 6e67 2f53 ..([Ljava/lang/S 0050: 7472 696e 673b 2956 0c00 0700 0807 0014 tring;)V........ 0060: 0c00 1500 1601 000d 4865 6c6c 6f2c 2057 ........Hello, W 0070: 6f72 6c64 2107 0017 0c00 1800 1901 0005 orld!........... 0080: 4865 6c6c 6f01 0010 6a61 7661 2f6c 616e Hello...java/lan 0090: 672f 4f62 6a65 6374 0100 106a 6176 612f g/Object...java/ 00a0: 6c61 6e67 2f53 7973 7465 6d01 0003 6f75 lang/System...ou ...

    在二进制里面

    清单1中显示的二进制类表示的第一件事就是“贝贝网吧”签名标识Java二进制类格式(顺带作为持久-但基本上无法识别-致敬辛勤工作的咖啡师谁跟上开发Java平台的开发人员的精神)。 此签名只是一种简单的方法,可以验证数据块确实确实是Java类格式的实例。 每个Java二进制类,甚至是文件系统中不存在的Java二进制类,都必须以这四个字节开头。

    不要错过本系列的其余部分

    第2部分,“ 介绍反射 ”(2003年6月) 第3部分,“ 应用反射 ”(2003年7月) 第4部分,“ 使用Javassist进行类转换 ”(2003年9月) 第5部分,“ 即时转换类 ”(2004年2月) 第6部分,“ 使用Javassist进行面向方面的更改 ”(2004年3月) 第7部分,“ 使用BCEL进行字节码工程 ”(2004年4月) 第8部分,“ 用代码生成替换反射 ”(2004年6月)

    其余数据的娱乐性较低。 签名后是一对类格式版本号(在这种情况下,对于次要版本0和主要版本46-以十六进制表示的0x2e-由1.4.1 javac生成),然后是常量池中的条目数。 条目计数(在这种情况下为26,或0x001a)后跟实际的常量池数据。 这是存储类定义使用的所有常量的位置。 它包括类和方法名称,签名和字符串(您可以在十六进制转储右侧的文本解释中识别出这些字符),以及各种二进制值。

    常量池中的项是可变长度的,每个项的第一个字节标识项的类型以及应如何对其进行解码。 我不会在这里详细介绍所有内容-如果您有兴趣,可以参考许多参考,从实际的JVM规范开始。 关键在于常量池包含对该类使用的其他类和方法的所有引用,以及该类及其方法的实际定义。 常量池可以轻松地构成二进制类大小的一半或更多,尽管平均比例可能会更少。

    常量池之后是几个引用类本身,其超类和接口的常量池条目的项目。 这些项目后面是有关字段和方法的信息,它们本身表示为复杂的结构。 方法的可执行代码以包含在方法定义中的代码属性的形式出现。 该代码以JVM指令的形式出现,通常称为字节码 ,这是下一节的主题之一。

    咨询专家:Dennis Sosnoski关于JVM和字节码的问题

    如果您对本系列文章中涉及的材料以及与Java字节码,Java二进制类格式或一般JVM问题有关的任何其他内容有任何意见或疑问,请访问由Dennis Sosnoski主持的JVM和Bytecode讨论论坛。

    属性用于Java类格式的几种已定义目的,包括已经提到的字节码,字段的常量值,异常处理和调试信息。 但是,这些目的并不是属性的唯一可能用途。 从一开始,JVM规范就要求JVM忽略未知类型的属性。 这项要求为将来扩展属性的使用提供了灵活性,以用于其他目的,例如提供与用户类一起使用的框架所需的元信息-这种Java派生的C#语言已广泛使用了这种方法。 不幸的是,还没有提供挂钩来在用户级别利用这种灵活性。

    字节码和堆栈

    组成类文件的可执行部分的字节码实际上是一种特殊计算机JVM的机器代码。 之所以称其为虚拟机,是因为它是为在软件而非硬件中实现而设计的。 用于运行Java平台应用程序的每个JVM都是围绕此机器的实现构建的。

    该虚拟机实际上相当简单。 它使用堆栈体系结构,这意味着指令操作数在使用之前先加载到内部堆栈中。 指令集包括所有常规的算术和逻辑运算,以及条件和无条件分支,加载/存储,调用/返回,堆栈操作以及几种特殊类型的指令。 一些指令包括直接编码到指令中的立即操作数值。 其他人直接从常量池中引用值。

    即使虚拟机很简单,实现也不一定如此。 早期(第一代)JVM基本上是虚拟机字节码的解释器。 这实际上是比较简单的,但是从严重的性能问题的困扰-解释代码总是会比执行本机代码需要更长的时间。 为了减少这些性能问题,第二代JVM添加了即时 (JIT)转换。 JIT技术在首次执行之前将Java字节码编译为本地代码,从而为重复执行提供了更好的性能。 当前一代的JVM进一步发展,使用自适应技术来监视程序执行并选择性地优化频繁使用的代码。

    加载课程

    编译为本机代码的C和C ++之类的语言通常在编译源代码后需要链接步骤。 此链接过程将来自单独编译的源文件中的代码与共享库代码合并,以形成可执行程序。 Java语言是不同的。 使用Java语言时,编译器生成的类通常保持不变,直到将其加载到JVM中为止。 即使从类文件构建JAR文件也不会改变这一点-JAR只是类文件的容器。

    将类加载到内存时,链接类不是JVM所执行的工作的一部分,而不是单独的步骤。 这会在最初加载类时增加一些开销,但也为Java应用程序提供了高度的灵活性。 例如,可以将应用程序编写为使用接口,直到运行时才指定实际的实现。 这种后期组装应用程序的绑定方法已在Java平台中广泛使用,其中servlet是一个常见示例。

    JVM规范中详细说明了装入类的规则。 基本原理是,仅在需要时才加载类(或至少看起来是这样加载的-JVM在实际加载中具有一定的灵活性,但必须维护固定的类初始化顺序)。 每个要加载的类可能都有其依赖的其他类,因此加载过程是递归的。 清单2中的类显示了此递归加载的工作方式。 Demo类包括一个简单的main方法,该方法创建一个Greeter实例并调用greet方法。 Greeter构造函数创建Message的实例,然后在greet方法调用中使用它。

    清单2.类加载演示的源代码
    public class Demo { public static void main(String[] args) { System.out.println("**beginning execution**"); Greeter greeter = new Greeter(); System.out.println("**created Greeter**"); greeter.greet(); } } public class Greeter { private static Message s_message = new Message("Hello, World!"); public void greet() { s_message.print(System.out); } } public class Message { private String m_text; public Message(String text) { m_text = text; } public void print(java.io.PrintStream ps) { ps.println(m_text); } }

    在java命令行上设置参数-verbose:class打印出类加载过程的痕迹。 清单3显示了使用此参数运行清单2程序的部分输出:

    清单3.部分-verbose:class输出
    [Opened /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/sunrsasign.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/jsse.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/jce.jar] [Opened /usr/java/j2sdk1.4.1/jre/lib/charsets.jar] [Loaded java.lang.Object from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.io.Serializable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.Comparable from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.CharSequence from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.lang.String from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] ... [Loaded java.security.Principal from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.security.cert.Certificate from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded Demo] **beginning execution** [Loaded Greeter] [Loaded Message] **created Greeter** Hello, World! [Loaded java.util.HashMap$KeySet from /usr/java/j2sdk1.4.1/jre/lib/rt.jar] [Loaded java.util.HashMap$KeyIterator from /usr/java/j2sdk1.4.1/jre/lib/rt.jar]

    这只是最重要部分的部分清单-完整的跟踪记录包含294行,对于此清单,我删除了其中的大部分。 初始加载类集(在这种情况下为279)全部由尝试加载Demo类触发。 这些是每个Java程序使用的核心类,无论它们多么小。 即使从Demo main方法中删除所有代码,也不会影响此初始加载顺序。 但是,所涉及的类的数量和名称会因类库的一个版本而异。

    加载Demo类后的清单部分会更有趣。 此处的序列显示,仅在将要创建Greeter类的实例时才加载该类。 但是, Greeter类使用Message类的静态实例,因此在创建前者的实例之前,还需要加载后者的类。

    加载和初始化类时,JVM内部发生了很多事情,包括解码二进制类格式,检查与其他类的兼容性,验证字节码操作的顺序以及最终构造一个java.lang.Class实例来表示新类。 这个Class对象成为JVM创建的新类的所有实例的基础。 它也是已加载类本身的标识符-您可以在JVM中加载同一二进制类的多个副本,每个副本都有其自己的Class实例。 即使这些副本都共享相同的类名,但它们将是JVM的单独类。

    走出人迹罕至的道路

    JVM中的类加载由类加载器控制。 JVM中内置了一个引导类加载器,用于加载基本Java类库类。 这种特殊的类加载器具有一些特殊功能。 一方面,它仅加载在引导类路径上找到的类。 由于这些是受信任的系统类,因此引导加载程序会跳过对常规(不受信任)类进行的许多验证。

    Bootstrap不是唯一的类加载器。 首先,JVM定义了用于从标准Ja​​va扩展API加载类的扩展类加载器,以及用于从常规类路径(包括应用程序类)加载类的系统类加载器。 应用程序还可以出于特殊目的(例如类的运行时重载)定义自己的类加载器。 这样添加的类加载器是从java.lang.ClassLoader类派生的(可能是间接的),该类提供了从字节数组构建内部类表示形式( java.lang.Class实例)的核心支持。 从某种意义上讲,每个构造的类均由加载它的类加载器“拥有”。 类加载器通常会保留它们已加载的类的映射,以便能够在再次请求时按名称查找一个。

    每个类加载器还保留对父类加载器的引用,以父引导加载器为根定义类加载器的树。 当需要某个特定类的实例(按名称标识)时,最初由哪个类加载器处理该请求,通常在尝试直接加载该类之前,通常先与它的父类加载器进行检查。 如果有多个层的类加载器,这将递归地应用,因此这意味着一个类通常不仅在加载它的类加载器中可见 ,而且对所有后代类加载器可见 。 这也意味着,如果一个类可以由一个链中的多个类加载器加载,则树上最远的那个将是实际加载它的树。

    在许多情况下,Java程序使用多个应用程序类加载器。 一个示例在J2EE框架内。 框架加载的每个J2EE应用程序都需要具有单独的类加载器,以防止一个应用程序中的类干扰其他应用程序。 框架代码本身还将使用一个或多个其他类加载器,以再次防止对应用程序的干扰。 完整的类加载器集构成一个树形结构的层次结构,在每个级别上加载了不同类型的类。

    装载机树

    作为运行中的类加载器层次结构的示例,图1显示了Tomcat Servlet引擎定义的类加载器层次结构。 在这里,Common class loader从Tomcat安装的特定目录中的JAR文件加载,该目录用于服务器和所有Web应用程序之间共享的代码。 Catalina加载器用于Tomcat自己的类,而Shared加载器用于Web应用程序之间共享的类。 最后,每个Web应用程序都会为其专用类获得自己的加载器。

    图1. Tomcat类加载器

    在这种类型的环境中,跟踪用于请求新类的正确加载器可能很麻烦。 因此,将setContextClassLoader和getContextClassLoader方法添加到Java 2平台的java.lang.Thread类中。 通过这些方法,框架可以在运行来自该应用程序的代码时设置要用于每个应用程序的类加载器。

    能够加载独立的类集的灵活性是Java平台的重要功能。 但是,由于此功能很有用,因此在某些情况下可能会造成混乱。 一个令人困惑的方面是处理JVM类路径的持续问题。 例如,在图1所示的Tomcat类加载器层次结构中,Common类加载器加载的类将永远无法直接(按名称)访问Web应用程序加载的类。 将这些联系在一起的唯一方法是通过使用两组类都可见的接口。 在这种情况下,其中包括由Java Servlet实现的javax.servlet.Servlet 。

    当代码由于任何原因在类加载器之间移动时,可能会出现问题。 例如,当J2SE 1.4将用于XML处理的JAXP API移到标准发行版中时,它为许多环境创建了问题,在这些环境中,应用程序以前依赖于加载自己选择的XML API实现。 使用J2SE 1.3,只需在用户类路径中包含适当的JAR文件即可完成此操作。 在J2SE 1.4中,这些API的标准版本现在位于扩展类路径中,因此它们通常将覆盖用户类路径中存在的所有实现。

    使用多类加载器时,其他类型的混淆也是可能的。 图2显示了一个类身份危机的示例,该危机是当接口和关联的实现分别由两个单独的类加载器加载时导致的。 即使接口和类的名称和二进制实现相同,也不能将一个加载器的类实例识别为从另一个加载器实现接口。 通过将接口类I移到系统类加载器的空间中,可以在图2中解决这种混淆。 类A仍然有两个单独的实例,但是都将实现相同的接口I

    图2.班级身份危机

    结论

    Java类定义和JVM规范一起为运行时代码汇编定义了一个非常强大的框架。 通过使用类加载器,Java应用程序可以使用多个版本的类,否则它们会导致冲突。 类加载器的灵活性甚至允许在应用程序继续执行时动态地重新加载已修改的代码。

    启动应用程序时,Java平台在这方面的灵活性成本会更高。 JVM必须先加载数百个单独的类,然后它才能开始执行最简单的应用程序代码。 与频繁使用的小程序相比,这种启动成本通常使Java平台更适合长时间运行的服务器类型的应用程序。 服务器应用程序还受益于代码运行时汇编程序的灵活性,因此Java平台越来越受到此类开发的青睐也就不足为奇了。

    在本系列的第2部分中,我将介绍如何使用Java平台动态基础的另一方面:Reflection API。 反射使您的执行代码可以访问内部类信息。 这对于构建可在运行时连接在一起的灵活代码非常有用,而无需在类之间使用任何源代码链接。 但是,与大多数工具一样,您需要知道何时以及如何使用它以发挥最大优势。 在Java编程动力学的第2部分中,回头查看有效反射的技巧和取舍。


    翻译自: https://www.ibm.com/developerworks/java/library/j-dyn0429/index.html

    Processed: 0.010, SQL: 9