热门标签 | 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阅读最新版。


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


推荐阅读
  • 初识java关于JDK、JRE、JVM 了解一下 ... [详细]
  • 线程漫谈——线程基础
    本系列意在记录Windwos线程的相关知识点,包括线程基础、线程调度、线程同步、TLS、线程池等。进程与线程理解线程是至关重要的,每个进程至少有一个线程,进程是线程的容器,线程才是真正的执行体,线程必 ... [详细]
  • 本文介绍了深入浅出Linux设备驱动编程的重要性,以及两种加载和删除Linux内核模块的方法。通过一个内核模块的例子,展示了模块的编译和加载过程,并讨论了模块对内核大小的控制。深入理解Linux设备驱动编程对于开发者来说非常重要。 ... [详细]
  • UNIX高级环境编程 第11、12章 线程及其属性
    第11章线程11.2线程概念线程资源:线程ID,一组寄存器,栈,调度优先级和策略,信号屏蔽字,e ... [详细]
  • 本文探讨了C语言中指针的应用与价值,指针在C语言中具有灵活性和可变性,通过指针可以操作系统内存和控制外部I/O端口。文章介绍了指针变量和指针的指向变量的含义和用法,以及判断变量数据类型和指向变量或成员变量的类型的方法。还讨论了指针访问数组元素和下标法数组元素的等价关系,以及指针作为函数参数可以改变主调函数变量的值的特点。此外,文章还提到了指针在动态存储分配、链表创建和相关操作中的应用,以及类成员指针与外部变量的区分方法。通过本文的阐述,读者可以更好地理解和应用C语言中的指针。 ... [详细]
  • 本文讨论了clone的fork与pthread_create创建线程的不同之处。进程是一个指令执行流及其执行环境,其执行环境是一个系统资源的集合。在调用系统调用fork创建一个进程时,子进程只是完全复制父进程的资源,这样得到的子进程独立于父进程,具有良好的并发性。但是二者之间的通讯需要通过专门的通讯机制,另外通过fork创建子进程系统开销很大。因此,在某些情况下,使用clone或pthread_create创建线程可能更加高效。 ... [详细]
  • 预备知识可参考我整理的博客Windows编程之线程:https:www.cnblogs.comZhuSenlinp16662075.htmlWindows编程之线程同步:https ... [详细]
  • Java自带的观察者模式及实现方法详解
    本文介绍了Java自带的观察者模式,包括Observer和Observable对象的定义和使用方法。通过添加观察者和设置内部标志位,当被观察者中的事件发生变化时,通知观察者对象并执行相应的操作。实现观察者模式非常简单,只需继承Observable类和实现Observer接口即可。详情请参考Java官方api文档。 ... [详细]
  • 先看官方文档TheJavaTutorialshavebeenwrittenforJDK8.Examplesandpracticesdescribedinthispagedontta ... [详细]
  • 第七课主要内容:多进程多线程FIFO,LIFO,优先队列线程局部变量进程与线程的选择线程池异步IO概念及twisted案例股票数据抓取 ... [详细]
  • 微信公众号:内核小王子关注可了解更多关于数据库,JVM内核相关的知识;如果你有任何疑问也可以加我pigpdong[^1]jvm一行代码是怎么运行的首先,java代码会被编译成字 ... [详细]
  • 作者一直强调的一个概念叫做oneloopperthread,撇开多线程不谈,本篇博文将学习,怎么将传统的IO复用pollepoll封装到C++类中。1.IO复用复习使用p ... [详细]
  • 主线:设计窗口类注册窗口类产生窗口显示窗口更新窗口消息循环(将消息路由到窗口中去处理)。APPMODUL.CPP源文件被编译链接进入项目,从APPMOD ... [详细]
  • 篇首语:本文由编程笔记#小编为大家整理,主要介绍了一个线程多次调用一个函数相关的知识,希望对你有一定的参考价值。 ... [详细]
  • 不知道你是否还记得之前在进程中的信号处理时,提到过阻塞信号集与未决信号集的概念,如果你已经忘记了,请参考《阻塞信号与未决信号》一文回忆一下 ... [详细]
author-avatar
手机用户2602890095
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有