Clojure 运行原理之字节码生成篇

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

类生成规则

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

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

  1. 在 REPL 里面,能够动态添加、修改函数,若是一个命名空间至关于一个类,那么这个类会被反复加载
  2. 因为函数和字符串同样是一等成员,这意味这函数既能够做为参数、也能够做为返回值,若是函数做为类的方法,是没法实现的

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

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

须要明确一点,只有在 AOT 编译时,Clojure 才会在本地生成 .class 文件,其余状况下生成的类均在内存中。github

动态运行时

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

AOT 编译

$ 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)可得oracle

(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)))

(def main (fn* ([& _] (println "Hello, World!"))))复制代码

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

core__init

$ javap core__init.class
public class how_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());
}复制代码

至此,命名空间加载类就分析完了。

loading_5569_auto____36

$ javap core\$loading__5569__auto____36.class
Compiled from "core.clj"
public final class how_clojure_work.core$loading__5569__auto____36 extends clojure.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 class how_clojure_work.core$main extends clojure.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 链接到正在运行的 Clojure 程序,动态修改程序的行为,无需重启。
可是在正式的生产环境,这种重定向对性能有影响,并且也没有重复定义函数的必要,因此能够在服务启动时指定-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<String, Reference<Class>>classCache =
        new ConcurrentHashMap<String, Reference<Class> >();

// 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 发现有以下三处调用makeClassLoaderCompiler/compile1Compiler/evalCompiler/load

正如上一篇文章的介绍,这三个方法正是 Compiler 的入口函数,这也就解释了上面 REPL 中的实验:

每次重定义一个函数,都会生成一个新 DynamicClassLoader 实例去加载其实现。

慢启动

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

首先须要明确一点,JVM 并不慢,咱们能够将以前的 Hello World 打成 uberjar,运行测试下时间。

;; (: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 版本 Hello World

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

总结

Clojure 做为一门 host 在 JVM 上的语言,其独特的实现方式让其拥动态的运行时的同时,方便与 Java 进行交互。固然,Clojure 还有不少能够提升的地方,好比上面的慢启动问题。另外,JVM 7 中增长了 invokedynamic 指令,可让运行在 JVM 上的动态语言经过实现一个 CallSite (能够认为是函数调用)的 MethodHandle 函数来帮助编译器找到正确的实现,这无异会提高程序的执行速度。

参考

KeepWritingCodes 微信公众号

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

相关文章
相关标签/搜索