一文弄懂Java和C中动态链接机制

概念

为了做实际的对比,先把概念搞清楚会有很大的帮助。那么,为什么要使用动态链接呢?动态链接是为了解决静态链接的维护和资源利用问题而出现的。那么,什么是静态链接呢?静态链接是指将符号从库拷贝到目标产物中的一种链接方式。那么再进一步,链接又是什么意思2

模块、符号和链接

大多数日常使用的都是高级语言。为了方便管理和关注点分离(Separation of Concern),一个具备一定规模的程序通常会拆分成多个部分(文本)来编写,然后编译成多个不同的模块。这些模块不一定存在于同一个文件中,但是能够通过符号去相互引用。通常来说,一个模块会包含三种符号:

  • 可被其他模块引用的公共符号(符号定义)
  • 本地或私有符号(符号定义)
  • 对其他模块中的公共符号的引用

如果一个程序由多个模块组成,在实际运行之前是需要链接器把多个模块组合起来的,因为只有把符号的定义给到符号的引用,程序才能完整串联起来。根据符号引用去找寻符号定义,并将符号引用替换为符号定义的直接引用,就叫做链接(link,还是很形象的)。本文中还会出现另外一个词,符号解析。我的理解是解析和链接在多数情况下意义相同,只不过解析更具体,链接则可能涵盖了更多内容。比如,Java的类加载流程中链接这个步骤实际可以拆分成校验、准备和解析三个小的步骤。另外,解析有时候用来表达符号定义查找,但不进行实际的替换。

Jietu20170316-152839.jpg

静态链接和动态链接3

与静态绑定和动态绑定的关系很类似,静态链接发生在编译期,而动态链接将链接推迟到了运行期,以期获得更大的灵活性。

这里需要注意的是,发生在编译期的链接并非都是静态链接。同时,链接看似是一个动作,其实可以拆分成多个小动作来执行。

语言层面的对比

上边只是从概念层面对链接及相关的概念做了一下介绍,语言在实现符号链接时有很多选择题可以做。另外,与我们的常识不同,Java世界也是存在编译期静态链接的,同时C的动态链接也是需要静态编译器参与的。所以虽然我一开始的想法是对比动态链接机制,但是编译期也需要涉及一点。

接下来为了便于描述,本文把程序从源代码到目标产物的整个过程都称为编译期,而从目标产物最终到达进程的可执行代码区的过程称为加载期。

由于重点在于对比Java和C在链接机制上的异同,对一些相关的但与链接关系不那么直接的信息,比如实际的编译流程、校验逻辑以及类的加载机制,本文会有所省略,请见谅。

为了便于描述,下文默认以Linux作为运行环境。

编译期的C/C++

Jietu20170316-152920.jpg

不管怎么说,我们写代码的目的都是为了让代码在系统中运行起来。但如果没有静态链接器的参与,源代码编译后只能拿到Object文件。通常来说,Object文件只会包含针对当前特定架构的一系列机器指令,因此Object文件是不能直接执行的。如果Object使用了外部符号,编译器也不会去尝试解析。

静态链接器4参与进来之后,能够链接出两种产物:可执行文件(Executable)、共享库(Shared Library)5。一般来说,这两种产物内部的格式是相同的,比如Linux中的ELF(Executable and Linking Format)6文件。其中主要包含的是符号信息和代码。与共享库相比,可执行文件主要是多了一个既定入口的定义7。不过另一方面,你也可以认为共享库有多个自定义的入口。

Jietu20170316-152903.jpg

除了Object文件和共享库之外,链接器也可以从静态链接库中解析符号。静态链接库(.a, archive),由ar命令把Object文件打包到一起而成,目的是方便静态链接器使用。

静态链接器在解析被代码引用的符号时,会按顺序从Object文件或库中查找。但是对于查找到的符号,不同的来源处理是不同的。针对静态链接库和Object文件中包含的符号,会直接打包到产物中,这就是前文提到的静态链接。而对于共享库中的符号,则不会打包到产物中。细节等到运行期部分再细说。

还有一个点值得提一下:对于可执行文件和共享库,链接的要求是不同的。链接生成可执行文件,要求所有符号都必须能够从Object文件或库中能够找到(resolved)。而共享库则需要加上-Wl,--no-undefined,否则是允许有unresolved符号存在的。8

静态链接库和共享库9

通常认为静态链接库有两个缺点:
1. 当任意被链接的库需要更新时,需要从头重新链接产物。比如需要patch或升级被链接的某个库时。
2. 当被引用的符号固化到产物中后,对文件系统和内存都会是一种浪费。如果所有使用C标准库实现的程序都将C library打包到内部,可以想象文件系统和内存中会存在多少重复的代码。

通过将链接推迟到运行期,共享库和动态链接解决了这两个问题,也引入了一些其他的问题。但静态链接并非一无是处,至少静态链接的产物非常易于使用,目标机器上不需要安装任何依赖,启动时间相对来说也更短。

编译期的Java

通常来说,编译期我们只会接触到Java Compiler(Javac)。它吃的是Java源代码,吐出来的是Class文件。Class文件与ELF很类似,也是以一种固定的格式来存储符号信息、代码及相关信息,同时因为Java是面向对象的,Class文件还包含了类型的信息。Jar包其实与静态链接库有点类似,是作为Class文件的归档存在的(倒是也能把一些资源文件打包到其中)。但不管是Class文件还是Jar包,都无法直接被OS执行:必须通过JVM这个虚拟机来执行(也需要定义好入口)。10

为了拿到可执行文件,就要祭出AOT(Ahead of Time)11Compiler了。其输入也是普通的Class文件或Jar包,但输出却是OS可识别的可执行文件,比如有入口的ELF文件。虽然我对AOT了解不多,但是这个编译器应该是是完成了一些Java层面的静态链接的工作,再加上Bytecode到机器指令的翻译,才使得直接执行成为了可能。最后可能还是会用到动态链接,只不过非Java平台中的动态链接罢了。

我觉得,AOT Compiler和Hotspot JVM的区别主要在于目标Runtime不一样,不同的平台所支持的链接模型也会有所不同。这样说起来,目标平台是Dalvik VM的Android编译器应该是类似的,只是不知道是否需要,了解的同学请不吝赐教。

编译期对比

抛开Java使用bytecode来承载逻辑这一点,从链接的层面来看,两种语言在这个阶段最大的区别(使用方式和运行机制)是:在编译链接可执行文件或共享库时,需要显示声明(如-lpthread)依赖的共享库,而且这部分信息需要静态链接器写入到ELF文件中,以便动态链接器在运行时使用(有不用手动配置的办法么?)。反观Java,我们只需要在编译时将依赖的Jar包或Class文件放到ClassPath下即可(Maven也帮我们做好了)。

我猜这与模块加载的机制和符号组织方式有关系。在C中,当链接器要链接某一个符号时,链接器是不知道符号存在于哪个库中的。确实也可以去挨个儿扫描,但是效率不高,语言的实现者选择让语言的使用者来提供这个信息。而Java中,符号必然是附带了Class的信息(字段或方法属于哪个类),由于名称也是对应的,这样类加载器就知道应该加载哪一个类对应的Class文件,去文件系统拿就好了(或者是网络或内存中)。

运行期的C/C++

当我们触发可执行文件执行时,程序就进入了运行期。

Jietu20170316-152931.jpg

在这个阶段,操作系统会先加载并执行动态链接器,而并非直接运行我们的第一行代码。动态链接器会扫描由静态链接器嵌入到可执行文件中的共享库依赖列表,把可执行文件所依赖的所有共享库都加载到内存中(这里省略了对查找的描述)。若库已经加载到了内存中,则只需要内存映射一下。如果出现某个依赖的库找不到,会出现错误而停止执行。如果共享库依赖了其他共享库,也会触发其他共享库的加载(加载流程是一个广度优先的遍历)。

加载完毕后,动态链接器也不会立刻开始解析符号。鉴于大多数时候并非所有代码都会使用到,也为了使startup时间尽量短(因为每次运行都需要链接),链接器采用了延迟绑定符号(lazy symbol binding)的策略。

为了实现延迟符号绑定,静态链接器会帮忙对共享库中的符号进行特殊处理。静态链接器在链接可执行文件时,会构造一个名为procedure-linking table(PLT)的跳表,并包含到可执行文件中。然后,静态链接器让代码中所有解析到的对共享库中符号(函数)的引用都指向PLT中特定的entry。12

回到运行时,动态链接器会把PLT中所有entries都指向自己内部一个特殊的符号绑定函数。当任意库函数被第一次触发时,动态链接器会恢复控制,然后执行实际的符号绑定:定位到一个符号然后将对应的PLT entry替换为对符号的直接引用。这样之后的请求都会直接调用到对应的符号。这就是C中的动态链接的基本工作机制。

共享库之所以能成为共享库,首先是因为有动态链接这种机制,可以使得符号解析推迟到运行期,这样共享库中的符号就不需要打包到可执行文件中。同时,鉴于库中主要包含系统指令,类似于只读数据,基于内存映射实现库中符号在可执行文件之间共享就顺理成章了。为此,共享库通常也称动态链接共享库(Dynamically linked shared library),或者就称之为动态链接库。

现在大多数情况都会选择使用共享库,因为相对静态链接库,使用动态链接共享库还有一个好处:可以直接升级共享库而达到动态patch的目的,而不用重新链接整个可执行文件。

运行期的Java

Java中符号是按照类来组织的,所以Java的链接模型的核心,就是JVM对类的操作。

java -cp . Main复制代码

执行命令之后,JVM首先会去AppClassLoader加载启动类Main,目的是为了执行其中的main方法(见sun.launcher.LauncherHelper#checkAndLoadMain)。与其他类一样,这个启动类会经过加载、链接、初始化、使用和卸载几个阶段,其中链接又可分为验证、准备和解析三个子阶段。

Java中加载一个类,意味着通过类加载器定位到这个类的Class文件(或对应格式的内容),将其中的信息存储起来,还会构建一个Class类的对象来提供对这些信息的编程式访问。接下来还会有对这些信息的验证和准备,然后才会开始具体的解析动作。

Java中的符号,一开始存储在Class文件中的常量池,在加载完成后则进入JVM为每个类单独维护的运行时常量池。符号的解析,则是指将运行时常量池中的符号引用替换为直接引用。

C中符号解析是延迟化的,按需的。那Java中符号的解析呢?

就虚拟机规范来说,并没有规定解析实际发生的时机。虚拟机实现可以选择在加载完成后就对类中所有的符号进行解析,也可以当类中某个符号实际被使用到时,才触发真正的链接过程。实际上,JVM规范只要求在用于操作符号引用的字节码执行之前,对这些指令所使用的符号引用完成解析就行了,LinkageError这样的链接错误也只能在符号被使用时才能抛出。换句话说,规范要求JVM实现对外表现出来是按需解析符号的。那实际呢?

我看过的多数讲解类生命周期或加载机制的文章都是从类加载讲起的,”当我们主动使用某个类的时候,会触发类加载器这个类的加载”,并且还归纳出六种主动使用类的方式:

  1. 访问某个类或接口的静态变量,或者对该静态变量赋值
  2. 调用类的静态方法
  3. new创建类的实例
  4. 初始化某个类的子类,则其父类也会被初始化
  5. 反射(如Class.forName(“com.xxx.xxx”))
  6. Java虚拟机启动时被标明为启动类的类,就如我们的Main

前边两种方式是我们平时写Java代码使用得很频繁的,由于相应字节码需要使用符号(getstatic,setstatic,invokestatic),到了符号必须链接的时候了。如果需要的符号不存在在内存中(因为对应的类尚未加载),会触发类加载流程。所以,前两种方式实际上是由链接触发类的加载,加载完成后接着就要进行链接,完成所需符号的解析。

第三和第四种是普通的类加载流程及其副作用。第五种方式是另外一种特殊情况,后边再讲。第六种情况是实际上就是用第五种方式实现的。

前文提到过,C是在一开始就确保了所有依赖的模块(共享库)加载到了内存中,但链接却是按需的。Java中对类(符号)的加载是按需的,而链接则分两种情况:

  1. 当前执行的字节码需要解析符号,如果所需要的类尚未加载,则先进入类加载流程,然后完成链接。这种情况下,解析是按需的
  2. 先触发了类加载,JVM可以选择直接完成类中所引用符号的链接,也可以选择按需链接

动态加载

前文提到,Java中模块的加载可能是由符号链接触发的,也可能是因为需要创建类实例导致的。另外还有一种情况,那就是由动态加载机制(其实我觉得用主动加载更能表达目的)触发的,即在运行时由程序决定加载和链接什么符号(以动态链接为基础)。这种能力让语言具备了获取在编译时尚未存在的模块和符号,以支持程序的动态扩展和实现插件机制。

在C中也支持动态加载,我们可以通过动态加载系统函数来加载指定的共享库并使用其中的符号。JNI中对共享库的加载就是基于dlopen函数实现的。但针对这些动态加载的符号的具体链接过程还有有一些小的差别,详见The inside story on shared libraries and dynamic loading ,这里就不展开说了。

实际的使用过程中,Java提供了ClassLoader.loadClassClass.forname(前文提到过的反射)两种方式来完成Class文件的动态加载。

因为是先触发类加载,类中符号解析的时机就不确定了。JVM可以选择提前,也可以选择在符号即将被使用时再完成链接。

动态链接的对比

最后,回到本文的主题。符号解析的前置条件是符号(模块)已经完成了加载,所以其实加载和链接是一个整体(先后顺序不定),都是语言链接模型的一部分。从加载和链接的角度,Java和C中动态链接的不同点有以下这些:

  1. 对于模块加载,C以共享库为单位,而Java则是以Class文件为单位
  2. C的动态链接依赖于静态链接器在编译期写入的共享库依赖列表,而Java不需要
  3. C中可执行文件依赖的所有共享库会在启动时完成加载,而Java的Class是按需加载的
  4. C只支持从本地文件系统中加载共享库(有一套既定的查找规则),而Java的类加载体系除了支持从本地文件系统查找类,还支持自定义类加载器,从而支持程序从任意自定义位置加载类,比如网络、数据库甚至是动态生成类
  5. Java中的Class文件最多只能被称为动态链接库,因为它加载到内存中之后无法在多个JVM间共享

总结

从后往前看,我觉得我对链接的困惑,源自Java的编程模型没有(显示)包含链接这一环13,以至于我对链接的概念太陌生了(也是理解得还不够好)。而且,我感觉自己在很长一段时间内都是假装知道链接是什么意思,同时也模模糊糊地把Library等同于了Jar包。

不懂装懂真可怕,特别是自己。

Ref

  1. The Linking Model, Chapter 8 of Inside the Java Virtual Machine,本文的基础,非常详细,但是不好get到big picture

  2. Library (computing) - Wikipedia

  3. c++ - Static linking vs dynamic linking

  4. Linker (computing) - Wikipedia 概念上很全面

  5. Static, Shared Dynamic and Loadable Linux Libraries 具体细节足够详细

  6. The 101 of ELF Binaries on Linux: Understanding and Analysis

  7. c - Shared libraries vs executable

  8. c++ - Force GCC to notify about undefined references in shared libraries

  9. The inside story on shared libraries and dynamic loading

  10. What is the difference in byte code like Java bytecode and files and machine code executables like ELF?

  11. How do Java AOT compilers work?

  12. PLT and GOT - the key to code sharing and dynamic libraries

  13. Java is Dynamic(ly linked)

码字不易,如有建议请扫码


猜你喜欢

转载自juejin.im/post/5c5266926fb9a049b41ce30b
今日推荐