热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Clojure运行原理之字节码生成剖析|KeepWritingCodes

Clojure运行原理之字节码生成剖析|KeepWritingCodes

上一篇文章讲述了 Clojure 编译器工作的整体流程,主要涉及 LispReader 与 Compiler 这两个类,而且指出编译器并没有把 Clojure 转为 相应的 Java 代码,而是直接使用 ASM 生成可运行在 JVM 中的 bytecode。本文不会讲解 ASM 的使用细节,而主要讨论 Clojure 编译成的 bytecode 如何实现动态运行时以及为什么 Clojure 程序启动慢,这会涉及到 JVM 的类加载机制。

类生成规则

JVM 设计之初只是为 Java 语言考虑,所以最基本的概念是 class,除了八种基本类型,其他都是对象。Clojure 作为一本函数式编程语言,最基本的概念是函数,没有类的概念,那么 Clojure 代码生成以类为主的 bytecode 呢?

一种直观的想法是,每个命名空间(namespace)是一个类,命名空间里的函数相当于类的成员函数。但仔细想想会有如下问题:

  1. 在 REPL 里面,可以动态添加、修改函数,如果一个命名空间相当于一个类,那么这个类会被反复加载
  2. 由于函数和字符串一样是一等成员,这意味这函数既可以作为参数、也可以作为返回值,如果函数作为类的方法,是无法实现的

上述问题 2 就要求必须将函数编译成一个类。根据 Clojure 官方文档 ,对应关系是这样的:

  • 函数生成一个类
  • 每个文件(相当于一个命名空间)生成一个 __init 的加载类
  • gen-class 生成固定名字的类,方便与 Java 交互
  • defrecorddeftype 生成同名的类, proxyreify 生成匿名的类

需要声明一点的是,只有在 AOT 编译时,Clojure 会在本地生成 .class 文件,其他情况下生成的类是在内存中的。

动态运行时

明确了 Clojure 类生成规则,下面介绍 Clojure 是如何实现动态运行时。这一问题将分为 AOT 编译与 DynamicClassLoader 类的实现两部分。

AOT 编译

Clojure 运行原理之字节码生成剖析 | Keep Writing Codes

$ cat src/how_clojure_work/core.clj

(ns how-clojure-work.core)

(defn -main [& _]
 (println "Hello, World!"))

使用 lein compile 编译这个文件,会在 *compile-path* 指定的文件夹(一般是项目的 target )下生成如下文件:

$ ls target/classes/how_clojure_work/

core$fn__38.class
core$loading__5569__auto____36.class
core$main.class
core__init.class

core$main.classcore__init.class 分别表示原文件的 main 函数与命名空间加载类,那么剩下两个类是从那里来的呢?

我们知道 Clojure 里面很多“函数”其实是用宏实现的,宏在编译时会进行展开,生成新代码,上面的 nsdefn 都是宏,展开后(在 Cider + Emacs 开发环境下, C-c M-m )可得

(do
  (in-ns'how-clojure-work.core)
  ((fn*
     loading__5569__auto__
     ([]
       (.clojure.lang.Var
        (clojure.core/pushThreadBindings
          {clojure.lang.Compiler/LOADER
           (.(.loading__5569__auto__ getClass)getClassLoader)}))
       (try
         (refer'clojure.core)
         (finally
           (.clojure.lang.Var(clojure.core/popThreadBindings)))))))
  (if(.'how-clojure-work.core equals 'clojure.core)
    nil
    (do
      (.clojure.lang.LockingTransaction
       (clojure.core/runInTransaction
         (fn*
           ([]
             (commute
               (deref#'clojure.core/*loaded-libs*)
               conj
               'how-clojure-work.core)))))
      nil)))

(defmain(fn*([& _](println"Hello, World!"))))

可以看到, ns 展开后的代码里面包含了两个匿名函数,对应本地上剩余的两个文件。下面依次分析这四个 class 文件

core__init

$ javap core__init.class
public classhow_clojure_work.core__init{
  public static final clojure.lang.Var const__0;
  public static final clojure.lang.AFn const__1;
  public static final clojure.lang.AFn const__2;
  public static final clojure.lang.Var const__3;
  public static final clojure.lang.AFn const__11;
  public static void load();
  public static void __init0();
  public static {};
}

可以看到,命名空间加载类里面有一些 VarAFn 变量,可以认为一个 Var 对应一个 AFn 。使用 Intellj 或 JD 打开这个类文件,首先查看静态代码快

static {
    __init0();
    Compiler.pushNSandLoader(RT.classForName("how_clojure_work.core__init").getClassLoader());
    try {
        load();
    } catch (Throwable var1) {
        Var.popThreadBindings();
        throw var1;
    }
    Var.popThreadBindings();
}

这里面会先调用 __init0 ,先看它的实现:

public static void __init0() {
    const__0 = (Var)RT.var("clojure.core", "in-ns");
    const__1 = (AFn)Symbol.intern((String)null, "how-clojure-work.core");
    const__2 = (AFn)Symbol.intern((String)null, "clojure.core");
    const__3 = (Var)RT.var("how-clojure-work.core", "main");
    const__11 = (AFn)RT.map(new Object[] {
        RT.keyword((String)null, "arglists"), PersistentList.create(Arrays.asList(new Object[] {
            Tuple.create(Symbol.intern((String)null, "&"),
            Symbol.intern((String)null, "_"))
        })),
        RT.keyword((String)null, "line"), Integer.valueOf(3),
        RT.keyword((String)null, "column"), Integer.valueOf(1),
        RT.keyword((String)null, "file"), "how_clojure_work/core.clj"
    });
}

RT 是 Clojure runtime 的实现,在 __init0 里面会对命名空间里面出现的 var 进行赋值。

接下来是 pushNSandLoader (内部用 pushThreadBindings 实现),它与后面的 popThreadBindings 形成一个 binding,功能等价下面的代码:

(binding[clojure.core/*ns* nil
          clojure.core/*fn-loader* RT.classForName("how_clojure_work.core__init").getClassLoader()
          clojure.core/*read-eval true]
  (load))

接着查看 load 的实现:

public static void load() {
    // 调用 in-ns,传入参数 how-clojure-work.core
    ((IFn)const__0.getRawRoot()).invoke(const__1);
    // 执行 loading__5569__auto____36,功能等价于 (refer clojure.core)
    ((IFn)(new loading__5569__auto____36())).invoke();
    Object var10002;
    // 如果当前的命名空间不是 clojure.core 那么会在一个 LockingTransaction 里执行 fn__38
    // 功能等价与(commute (deref #'clojure.core/*loaded-libs*) conj 'how-clojure-work.core)
    if(((Symbol)const__1).equals(const__2)) {
        var10002 = null;
    } else {
        LockingTransaction.runInTransaction((Callable)(new fn__38()));
        var10002 = null;
    }

    Var var10003 = const__3;
    // 为 main 设置元信息,包括行号、列号等
    const__3.setMeta((IPersistentMap)const__11);
    var10003.bindRoot(new main());
}

loading5569auto____36

$ javap core\$loading__5569__auto____36.class
Compiled from "core.clj"
public final classhow_clojure_work.core$loading__5569__auto____36extendsclojure.lang.AFunction{
  public static final clojure.lang.Var const__0;
  public static final clojure.lang.AFn const__1;
  public how_clojure_work.core$loading__5569__auto____36(); // 构造函数
  public java.lang.Object invoke();
  public static {};
}

core__init 类结构,包含一些 var 赋值与初始化函数,同时它还继承了 AFunction ,同名字就可以看出这是一个函数的实现。

// 首先是 var 赋值
public static final Var const__0 = (Var)RT.var("clojure.core", "refer");
public static final AFn const__1 = (AFn)Symbol.intern((String)null, "clojure.core");
// invoke 是方法调用时的入口函数
public Object invoke() {
    Var.pushThreadBindings((Associative)RT.mapUniqueKeys(new Object[]{Compiler.LOADER, ((Class)this.getClass()).getClassLoader()}));

    Object var1;
    try {
        var1 = ((IFn)const__0.getRawRoot()).invoke(const__1);
    } finally {
        Var.popThreadBindings();
    }

    return var1;
}

上面的 invoke 方法等价于

(binding[Compiler.LOADER(Class)this.getClass()).getClassLoader()]
  (refer'clojure.core))

fn__38loading__5569__auto____36 类似, 这里不在赘述。

core$main

$ javap  core\$main.class
Compiled from "core.clj"
public final classhow_clojure_work.core$mainextendsclojure.lang.RestFn{
  public static final clojure.lang.Var const__0;
  public how_clojure_work.core$main();
  public static java.lang.Object invokeStatic(clojure.lang.ISeq);
  public java.lang.Object doInvoke(java.lang.Object);
  public int getRequiredArity();
  public static {};
}

由于 main 函数的参数数量是可变的,所以它继承了 RestFn ,除了 var 赋值外,重要的是以下两个函数:

public static Object invokeStatic(ISeq _) {
    // const__0 = (Var)RT.var("clojure.core", "println");
    return ((IFn)const__0.getRawRoot()).invoke("Hello, World!");
}
public Object doInvoke(Object var1) {
    ISeq var10000 = (ISeq)var1;
    var1 = null;
    return invokeStatic(var10000);
}

通过上面的分析,我们可以发现,每个函数在被调用时,会去调用 getRawRoot 函数得到该函数的实现,这种重定向是 Clojure 实现动态运行时非常重要一措施。这种重定向在开发时非常方便,我们可以用 nrepl 连接到正在运行的服务,动态修改服务的行为,无需重启服务。但是在正式的生产环境,这种重定向对性能有影响,而且也没有重复定义函数的必要,所以可以在服务启动时指定 -Dclojure.compiler.direct-linking=true 来避免这类重定向,官方称为 Direct linking 。可以在定义 var 时指定 ^:redef 表示必须重定向。 ^:dynamic 的 var 永远采用重定向的方式确定最终值。

需要注意的是,var 重定义对那些已经 direct linking 的代码是透明的。

DynamicClassLoader

熟悉 JVM 类加载机制(不清楚的推荐我另一篇文章《JVM 的类初始化机制》)的都会知道,一个类只会被一个 ClassLoader 加载一次,仅仅有上面介绍的重定向机制是无法实现动态运行时的,还需要一个灵活的 ClassLoader,可以在 REPL 做如下实验:

user>(defn foo []1)
#'user/foo
user> (.. foo getClass getClassLoader)
#object[clojure.lang.DynamicClassLoader 0x72d256 "clojure.lang.DynamicClassLoader@72d256"]
user> (defn foo [] 1)
#'user/foo
user> (.. foo getClass getClassLoader)
#object[clojure.lang.DynamicClassLoader 0x57e2068e "clojure.lang.DynamicClassLoader@57e2068e"]

可以看到,只要对一个函数进行了重定义,与之相关的 ClassLoader 随之也改变了。

下面来看看 DynamicClassLoader 的核心实现:

// 用于存放已经加载的类
static ConcurrentHashMap>classCache =
        new ConcurrentHashMap >();

// loadClass 会在一个类第一次主动使用时被 JVM 调用
Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
	Class c = findLoadedClass(name);
	if (c == null) {
		c = findInMemoryClass(name);
		if (c == null)
			c = super.loadClass(name, false);
    }
	if (resolve)
		resolveClass(c);
	return c;
}

// 用户可以调用 defineClass 来动态生成类
// 每次调用时会先清空缓存里已加载的类
public Class defineClass(String name, byte[] bytes, Object srcForm){
	Util.clearCache(rq, classCache);
	Class c = defineClass(name, bytes, 0, bytes.length);
    classCache.put(name, new SoftReference(c,rq));
    return c;
}

通过搜索 Clojure 源码,只有在 RT.java 的 makeClassLoader 函数 里面有 new DynamicClassLoader 语句,继续通过 Intellj 的 Find Usages 发现有如下三处调用 makeClassLoader

  1. Compiler/compile1
  2. Compiler/eval
  3. Compiler/load

正如上一篇文章的介绍,这三个方法正是 Compiler 的入口函数,这也就解释了上面 REPL 中的实验:每次重定义一个函数,都会生成一个新 DynamicClassLoader 实例去加载其实现。

慢启动

明白了 Clojure 是如何实现动态运行时,下面分析 Clojure 程序为什么启动慢。

首先需要明确一点, JVM 并不慢 ,我们可以将之前的 Hello World 打成 uberjar,运行测试下时间,需要注意一点,为了能够与命名空间同名的类,需要在 ns 使用 (:gen-class) 指令。

(ns how-clojure-work.core
  (:gen-class))

(defn -main [& _]
  (println "Hello, World!"))

# 为了能用 java -jar 方式运行,需要在 project.clj 中添加
# :main how-clojure-work.core
$ lein uberjar
$ time java -jar target/how-clojure-work-0.1.0-SNAPSHOT-standalone.jar
Hello, World!

real	0m0.900s
user	0m1.422s
sys	0m0.087s

在启动时加入 -verbose:class 参数,可以看到很多 clojure.core 开头的类

...
[Loaded clojure.core$cond__GT__GT_ from file:/Users/liujiacai/codes/clojure/how-clojure-work/target/how-clojure-work-0.1.0-SNAPSHOT-standalone.jar]
[Loaded clojure.core$as__GT_ from file:/Users/liujiacai/codes/clojure/how-clojure-work/target/how-clojure-work-0.1.0-SNAPSHOT-standalone.jar]
[Loaded clojure.core$some__GT_ from file:/Users/liujiacai/codes/clojure/how-clojure-work/target/how-clojure-work-0.1.0-SNAPSHOT-standalone.jar]
[Loaded clojure.core$some__GT__GT_ from file:/Users/liujiacai/codes/clojure/how-clojure-work/target/how-clojure-work-0.1.0-SNAPSHOT-standalone.jar]
...

把生成的 uberjar 解压打开,可以发现 clojure.core 里面的函数都在,并且在启动时被加载。

Clojure 运行原理之字节码生成剖析 | Keep Writing Codes

这就是 Clojure 启动慢的原因:加载大量用不到的类。

总结

Clojure 作为一门 host 在 JVM 上的语言,其独特的实现方式让其拥动态的运行时的同时,方便与 Java 进行交互。当然,Clojure 还有很多可以提高的地方,比如上面的慢启动问题,另外,JVM 7 中增加了 invokedynamic 指令,可以让运行在 JVM 上的动态语言通过实现一个 CallSite (可以认为是函数调用)的 MethodHandle 函数来帮助编译器找到正确的实现。

参考

  • http://blog.ndk.io/clojure-compilation2.html
  • http://stackoverflow.com/questions/7471316/how-does-clojure-class-reloading-work
  • http://blog.headius.com/2011/10/why-clojure-doesnt-need-invokedynamic.html
Clojure 运行原理之字节码生成剖析 | Keep Writing Codes

本博客使用 disqus 评论系统,但不幸被墙,不会翻墙的小伙伴可以通过上面的公众号与我交流。希望我们的交流能给你我带来些许启发。

PS: 微信公众号,头条,掘金等平台均有我文章的分享,但我的文章会随着我理解的加深不定期更新,建议大家最好去我的博客 liujiacai.net阅读最新版。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 我们


推荐阅读
  • 如何撰写PHP电商项目的实战经验? ... [详细]
  • 在基于.NET框架的分层架构实践中,为了实现各层之间的松散耦合,本文详细探讨了依赖注入(DI)和控制反转(IoC)容器的设计与实现。通过合理的依赖管理和对象创建,确保了各层之间的单向调用关系,从而提高了系统的可维护性和扩展性。此外,文章还介绍了几种常见的IoC容器实现方式及其应用场景,为开发者提供了实用的参考。 ... [详细]
  • 如何使用 net.sf.extjwnl.data.Word 类及其代码示例详解 ... [详细]
  • PHP中元素的计量单位是什么? ... [详细]
  • PHP图床源码:集成化图床管理系统解决方案
    本项目提供了一套集成化的图床管理系统解决方案,适用于需要高效管理图片资源的场景。系统结构简洁,无需复杂的后台支持。主要文件包括 `huluxia.php`、`index.html`、`inews.php`、`kw.php` 和 `zz.php`,每个文件都承担了特定的功能,确保系统的稳定运行和易用性。 ... [详细]
  • BZOJ4240 Gym 102082G:贪心算法与树状数组的综合应用
    BZOJ4240 Gym 102082G 题目 "有趣的家庭菜园" 结合了贪心算法和树状数组的应用,旨在解决在有限时间和内存限制下高效处理复杂数据结构的问题。通过巧妙地运用贪心策略和树状数组,该题目能够在 10 秒的时间限制和 256MB 的内存限制内,有效处理大量输入数据,实现高性能的解决方案。提交次数为 756 次,成功解决次数为 349 次,体现了该题目的挑战性和实际应用价值。 ... [详细]
  • HTML5绘图功能的全面支持与应用
    HTML5绘图功能的全面支持与应用 ... [详细]
  • Java Web开发中的JSP:三大指令、九大隐式对象与动作标签详解
    在Java Web开发中,JSP(Java Server Pages)是一种重要的技术,用于构建动态网页。本文详细介绍了JSP的三大指令、九大隐式对象以及动作标签。三大指令包括页面指令、包含指令和标签库指令,它们分别用于设置页面属性、引入其他文件和定义自定义标签。九大隐式对象则涵盖了请求、响应、会话、应用上下文等关键组件,为开发者提供了便捷的操作接口。动作标签则通过预定义的动作来简化页面逻辑,提高开发效率。这些内容对于理解和掌握JSP技术具有重要意义。 ... [详细]
  • 本研究提出了一种方法,用于判断两个数组中的元素是否相同,而不考虑其顺序。该方法通过检查数组中每个元素的出现次数来实现。具体实现如下:首先验证输入参数是否为数组,然后对两个数组进行排序并逐个比较元素。若所有元素均相等,则返回 `true`,否则返回 `false`。此方法适用于需要忽略顺序的数组比较场景。 ... [详细]
  • 在Python中,通过实现一个便捷的函数来解码Base64编码的数据,并将其转换为数组形式。该函数能够将Base64字符串解码为字节数组,便于进一步处理。例如,可以使用如下代码片段进行解码:`base64_decode_array('6gAAAOsAAAD')`。这为处理二进制数据提供了高效且简洁的方法。 ... [详细]
  • 探讨两种常数卷积的结果与一种常见的洗牌算法错误及其影响
    在编程中,随机打乱数组元素的顺序(即“洗牌”)是一个常见的需求。标准的洗牌算法是Fisher-Yates shuffle,但许多开发者在实现时容易犯错,导致结果不均匀。本文探讨了两种常数卷积的结果,并分析了一种常见的洗牌算法错误及其对随机性的影响。通过详细的实验和理论分析,我们揭示了这些错误的具体表现和潜在危害,为开发者提供改进的建议。 ... [详细]
  • 本书详细介绍了在最新Linux 4.0内核环境下进行Java与Linux设备驱动开发的全面指南。内容涵盖设备驱动的基本概念、开发环境的搭建、操作系统对设备驱动的影响以及具体开发步骤和技巧。通过丰富的实例和深入的技术解析,帮助读者掌握设备驱动开发的核心技术和最佳实践。 ... [详细]
  • 本文详细解析了JSONP(JSON with Padding)的跨域机制及其工作原理。JSONP是一种通过动态创建``标签来实现跨域请求的技术,其核心在于利用了浏览器对``标签的宽松同源策略。文章不仅介绍了JSONP的产生背景,还深入探讨了其具体实现过程,包括如何构造请求、服务器端如何响应以及客户端如何处理返回的数据。此外,还分析了JSONP的优势和局限性,帮助读者全面理解这一技术在现代Web开发中的应用。 ... [详细]
  • PHP中处理回车换行符转换的有效方法与技巧
    PHP中处理回车换行符转换的有效方法与技巧 ... [详细]
  • Understanding the Distinction Between decodeURIComponent and Its Encoding Counterpart
    本文探讨了 JavaScript 中 `decodeURIComponent` 和其编码对应函数之间的区别。通过详细分析这两个函数的功能和应用场景,帮助开发者更好地理解和使用它们,避免常见的编码和解码错误。 ... [详细]
author-avatar
手机用户2602923361
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有