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 閱讀最新版。

相關文章
相關標籤/搜索