1. 项目介绍

本项目源自OnlineExecutor

本项目基于 SpringBoot 实现了一个在线的 Java IDE,可以远程运行客户端发来的 Java 代码的 main 方法,并将程序的标准输出内容、运行时异常信息反馈给客户端,并且会对客户端发来的程序的执行时间进行限制。

项目中涉及的框架相关知识并不多,主要涉及了许多 Java 基础的知识,如:Java 程序编译和运行的过程、Java 类加载机制、Java 类文件结构、Java 反射等。除此之外,还涉及到了一个简单的并发问题:如何将一个非线程安全的类变为一个线程安全的类。因此,本项目较为适合在比较注重基础的面试中介绍给面试官,可以引出一些 Java 虚拟机,Java 并发相关的问题,较能体现应聘者对于 Java 的一些原理性的知识的掌握程度。在本篇文章中,我们尽可能的将用到的知识简单讲解一下或者给出讲解的链接,以方便大家阅读。

本项目主要有以下几个模块:

  • 实现编译模块: 使用动态编译技术,可将客户端发来的源代码字符串直接编译为字节数组;
  • 实现字节码修改器: 根据 Java 类文件结构修改类的字节码,可将客户端程序对 System 的调用替换为对 System的替代类 HackSystem 的调用;
  • 实现运行模块: 自定义类加载器实现类的加载 & 热替换,通过反射实现 main 方法的运行;
  • 解决多用户同时发送执行代码请求时的并发问题: 通过 ThreadLoacl 实现线程封闭,为每个请求创建一个输出流存储标准输出及标准错误结果;

    2. 运行效果

    README - 图1

    3. 涉及技术

  • Java 动态编译

  • Java 类文件的结构
  • Java 类加载器 & Java 类的热替换
  • Java 反射
  • 如何将一个类变为线程安全类

    4. 项目实现流程

    在线执行 Java 代码的实现流程如下图所示:
    README - 图2
    既然要运行客户端发来的 Java 代码,那么我们首先需要了解 Java 程序编译和运行的过程,然后仿照 Java 程序的真实运行过程来运行客户端发来的 Java 代码。

    5. Java 程序编译和运行的过程

    我们先来看一下 Java 程序编译和运行的过程图:
    README - 图3
    如上图所示,要运行一个 Java 程序需要经过以下两个步骤:

  • 源文件由编译器编译成字节码;

  • 字节码由 Java 虚拟机解释运行。

也正是因为 Java 程序既要编译同时也要经过 JVM 的解释运行,所以说 Java 被称为半解释语言。接下来我们将对以上两个步骤进行详细说明。

5.1 编译

在运行前,我们首先需要将 .java 源文件编译为 .class 文件。Java 编译一个类时,如果这个类所依赖的类还没有被编译,编译器就会先编译这个被依赖的类,然后引用,否则直接引用,如果 Java 编译器在指定目录下找不到该类所其依赖的类的 .class 文件或者 .java 源文件的话,编译器话报“cant find symbol”的 Error。

5.2 运行

Java 类运行的过程可分为两个过程:

  • 类的加载
    • 应用程序运行后,系统就会启动一个 JVM 进程,JVM 进程从 classpath 路径中找到名为 Test.class 的二进制文件(假设客户端发来的类名为 Test),将 Test 的类信息加载到运行时数据区的方法区内,这个过程叫做 Test 类的加载。
    • 上一步过程主要通过 ClassLoader 完成,类加载器会将类的字节码文件加载为 Class 对象,存放在 Java 虚拟机的方法区中,之后 JVM 就可以通过这个 Class 对象获取该类的各种信息,或者运行该类的方法。
  • 类的执行
    • JVM 找到 Test 的主函数入口,开始执行 main 函数。
    • 本项目主要通过 Java 反射来完成这一过程。

在了解 Java 程序的实际运行过程之后,我们接下来要考虑的是:如何在运行过程中实现这一流程?也就是说,我们要在服务器端程序运行的过程中完成客户端代码发来的代码的编译和运行。通过对上图中 Java 程序编译和运行流程进行分析,我们得到以下客户端 Java 源代码执行流程:
README - 图4
通过观察上图可以发现,我们的重点在于实现 StringSourceCompilerJavaClassExecuter 两个类。它们的作用分别为:

  • StringSourceCompiler:将字符串形式的源代码 String source 编译成字节码 byte[] classBytes;
  • JavaClassExecuter:将字节码 byte[] classBytes 加载进 JVM,执行其 main 方法,并收集运行输出结果字符串返回。

    Note: 我们只收集 System.outSystem.err 输出的内容返回给客户端。

接下来,我们将对 StringSourceCompilerJavaClassExecuter 的实现方式进行详解。

5.3 实现编译模块:StringSourceCompiler

通过 JDK 1.6 后新加的动态编译实现 StringSourceCompiler,使用动态编译,可以直接在内存中将源代码字符串编译为字节码的字节数组,这样既不会污染环境,又不会额外的引入 IO 操作,一举两得。

5.4 实现运行模块:JavaClassExecuter

在实现JavaClassExecuter 的过程中,收集代码执行结果时会出现两个问题:

  1. 标准输出设备是整个虚拟机进程全局共享的资源,如果使用 System.setOut()System.setErr() 方法把输出流重定向到自己定义的 PrintStream 对象上固然可以收集输出信息,但这在多线程的情况下显然是不可取的,因为有可能将其他线程的结果也收集了。
  2. 除此之外,允许客户端程序随便调用 System 的方法还存在着安全隐患,比如如果客户端发来的程序中调用了:System.exit(0) 等方法,这对服务器来说是十分危险的。

因此,我们考虑将程序中的 System 类都替换成一个自己写的 HackSystem 类,这样既可以解决多线程下收集输出结果的问题,又可以将 System 中比较危险的调用都改写成抛出异常,以达到禁止客户端程序调用 System 类的目的

尽管客户端发来的程序将对 System 的方法的调用都替换为了 HackSystem 的方法的调用,从而避免了与服务器本身发生资源冲突,可是在同一时刻,可能有多个待运行的程序从客户端发来(假设为程序 A,B,C),对于 A,B,C 三个程序,它们是共享 HackSystem 的,即它们会在 HackSystem 发生资源争夺。最简单的处理方法就是将客户端发来的运行程序的请求完全变成串行的,也就是运行完一个客户端发来的程序再运行另一个,这种方法是完全不可取的,因为可能有一个程序执行了一个超长循环要跑好久,而其他执行的很快的程序只能等着它执行完。

为了解决这个并发问题,我们需要将 HackSystem 变成一个线程安全的类,本项目的问题十分适合通过线程封闭的方式来解决,我们重写了 System 类,并在 PrintStream 中使用了 ThreadLocal 来包装输出流来解决这个问题。

运行模块 JavaClassExecuter 的执行流程如下:

  1. 在类加载器加载编译后的字节码之前,使用 ClassModifier 字节码修改器将编译得到的字节码的常量池中的java/lang/System替换为org/olexec/execute/HackSystem
  2. 然后通过类加载器将字节码加载为 Class 对象。
  3. 最后通过反射调用 Class 对象的 main 方法,并同时限制方法的运行时间。