Clojure 執行原理之位元組碼生成篇

jiacai2050發表於2019-02-13

上一篇文章講述了 Clojure 編譯器工作的整體流程,主要涉及 LispReader 與 Compiler 這兩個類,而且指出編譯器並沒有把 Clojure 轉為相應的 Java 程式碼,而是直接使用 ASM 生成可執行在 JVM 中的 bytecode。本文將主要討論 Clojure 編譯成的 bytecode 如何實現動態執行時以及為什麼 Clojure 程式啟動慢,這會涉及到 JVM 的類載入機制。

類生成規則

JVM 設計之初只是為 Java 語言考慮,所以最基本的概念是 class,除了八種基本型別,其他都是物件。Clojure 作為一本函數語言程式設計語言,最基本的概念是函式,沒有類的概念,那麼 Clojure 程式碼生成以類為主的 bytecode 呢?

一種直觀的想法是,每個名稱空間(namespace)是一個類,名稱空間裡的函式相當於類的成員函式。但仔細想想會有如下問題:

  1. 在 REPL 裡面,可以動態新增、修改函式,如果一個名稱空間相當於一個類,那麼這個類會被反覆載入
  2. 由於函式和字串一樣是一等成員,這意味這函式既可以作為引數、也可以作為返回值,如果函式作為類的方法,是無法實現的

上述問題 2 就要求必須將函式編譯成一個類。根據 Clojure 官方文件,對應關係是這樣的:

  • 函式生成一個類
  • 每個檔案(相當於一個名稱空間)生成一個<filename>__init 的載入類
  • gen-class 生成固定名字的類,方便與 Java 互動
  • defrecorddeftype生成同名的類,proxyreify生成匿名的類

需要明確一點,只有在 AOT 編譯時,Clojure 才會在本地生成 .class 檔案,其他情況下生成的類均在記憶體中。

動態執行時

明確了 Clojure 類生成規則後,下面介紹 Clojure 是如何實現動態執行時。這一問題將分為 AOT 編譯與 DynamicClassLoader 類的實現兩部分。

AOT 編譯

Clojure 執行原理之位元組碼生成篇

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

(def main (fn* ([& _] (println "Hello, World!"))))複製程式碼

可以看到,ns展開後的程式碼裡面包含了兩個匿名函式,對應本地上剩餘的兩個檔案。下面依次分析這四個class檔案

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 執行原理之位元組碼生成篇
Clojure 版本 Hello World

這就是 Clojure 啟動慢的原因:載入大量用不到的類。

總結

Clojure 作為一門 host 在 JVM 上的語言,其獨特的實現方式讓其擁動態的執行時的同時,方便與 Java 進行互動。當然,Clojure 還有很多可以提高的地方,比如上面的慢啟動問題。另外,JVM 7 中增加了 invokedynamic 指令,可以讓執行在 JVM 上的動態語言通過實現一個 CallSite (可以認為是函式呼叫)的 MethodHandle 函式來幫助編譯器找到正確的實現,這無異會提升程式的執行速度。

參考

Clojure 執行原理之位元組碼生成篇
KeepWritingCodes 微信公眾號

PS: 微信公眾號,頭條,掘金等平臺均有我文章的分享,但我的文章會隨著我理解的加深不定期更新,建議大家最好去我的部落格 liujiacai.net 閱讀最新版。

相關文章