高密度 Java 应用部署的一些实践

阅读数:7070 2014 年 8 月 18 日

传统的 Java 应用部署模式,一般遵循“硬件 -> 操作系统 ->JVM->Java 应用”这种自底向上的部署结构,其中 JEE 应用可以细化为“硬件 -> 操作系统 ->JVM->JEE 容器 ->JEE 应用”的部署结构。这种部署结构往往比较重,操作系统、JVM 和 JEE 容器造成的 overhead 很高,而很多时候一个 Java 应用并不需要跑满整个硬件的资源,导致这种模式的资源利用率是比较低的。

而另一方面,硬件虚拟化技术逐渐成熟:VMware Hypervisor、Xen、KVM、Power LPAR 等技术能够帮助我们在同一个硬件上部署多个操作系统实例,而时下流行的 OS Container 技术如 LXC、Docker 等,则是把操作系统虚拟化为多个实例,实现更轻量级的虚拟化。无论哪个层面的虚拟化,其目的都是对资源利用率更加高效的追求,从而成为如今构建云计算平台底层架构的基础技术。

Java 应用也可以通过同样的思路来实现高密度的部署。JVM 虚拟化是比 OS 虚拟化更高一层的做法,可以更大程度的提高资源利用率,降低平均应用的部署成本。本文将介绍 Multi-tenant JVM 这一方案实现高密度 Java 应用部署的一些特点和思路。

背景介绍

早在 2004 年,Sun 公司就提出过 Java 应用虚拟化这方面的想法。当时 Grzegorz Czajkowski 领导了一个叫做巴塞罗那的研究项目,该项目基于 Java HotSpot 虚拟 1.5 版本开发了 Multi-Tasking Virtual Machine(MVM)。MVM 的目的旨在提高 Java 程序的启动速度,节省内存开销。不过自从 Sun 被甲骨文收购后,我们没有听到关于该项目的任何新的进展。

尽管我们没有看到 MVM 成功产品化,不过它却留下两个 JSR 规范: JSR121 JSR284 。对于 JSR284,目前在 java.net 上有一个实现它的孵化项目。

从 2009 年开始,我所在的 IBM Java 团队开始研究 Java 应用的 SaaS 化方案,即让一个应用实例服务于多个租户。为了保证多个租户在使用同一个应用实例时候数据的隔离,该方案在应用这个层面做了一些 Bytecode Instrument(BCI)的工作,主要通过改写 getstatic/putstatic 使每个租户有独立的类的静态数据拷贝而没有相互影响。但是,该方案在 Bytecode 层面更改带来的额外性能开销, 以及 Java Reflection 等访问带来的安全性 / 正确性的问题。 而且,除了数据上的隔离,也需要针对关键性的资源譬如 CPU、Heap、IO 等资源的使用进行管理,于是该方案下沉到了 JVM 层面,形成现在的多租户JVM(Multi-tenant JVM)方案

Multi-tenant JVM 是 JVM 层面的虚拟化,其思路是把多个 Java 应用部署在同一个 JVM 上,让这些应用共享底层的 GC、JIT、Java 运行时库等基础组件。除了 IBM 的团队之外,爱尔兰的 Waratek 公司也实现了多租户的 JVM。和 IBM Multi-tenant JVM 类似,Waratek 允许多个应用运行在同一个 CloudVM 上,每一个应用运行在一个叫 Java Virtual Container(JVC)的容器里。从现有公开的资料开看,IBM Multi-tenant JVM 是基于 Java 7 的,而 Waratek 是基于 Java 6 的,两者支持的 CPU 架构和平台也有所不同。

此外,JEE 方面在两年前也有讨论计划增加对PaaS 和多租户的支持,这项提议旨在定义PaaS 环境下如何使得JEE 应用支持多租户,保证不同租户在使用这些应用时相互隔离,以及资源方面的管理(如JMS 资源),不过该项提议已经推迟到JEE 8。

除了提升部署密度之外,多租户的另一项好处在于应用启动的加速。快速的程序启动受益于不同的应用共享同一个 JVM,我们称之为 javad。Java 核心的类库在 javad 运行后,不再需要被重新装载和定义。你也许可以用 Nailgun 来加速你的启动时间,但 Nailgun 的问题是没有安全的数据隔离,这包括类的静态数据以及 Java 属性值,而且 Nailgun 在易用性等方面也不如 Multi-tenent JVM。

多租户 JVM 的实现思路

跟传统 JVM 相比,多租户 JVM 的主要工作围绕隔离而进行,其针对 JVM/JDK 的改动主要实现三个方面的目标:

  1. 租户之间的数据隔离
  2. Java 类库支持多租户语境
  3. 资源管理隔离

租户之间的数据隔离

让每个租户应用拥有独立的类静态数据拷贝,这个目标主要通过修改 getstatic/putstatic 字节码指令实现。下面是一个简单的例子:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

每一个运行在 Multi-tenant JVM 上的程序都有不同System.out实例。就java.lnag.System内部实现来说,out 是其类静态变量:

public final class System {

    // The standard input, output, and error streams.
    // Typically, these are connected to the shell which
    // ran the Java program.
    /**
     * Default input stream
     */
    public static final InputStream in = null;
    /**
     * Default output stream
     */
    public static final PrintStream out = null;
    /**
     * Default error output stream
     */
    public static final PrintStream err = null;
……
}

Multi-tenant JVM 对于标准的 JVM 行为进行的更改如下:

  • 每一个租户第一次使用 java/lang/System 时,都会触发它的初始化,也就是<clinit>。而一般的 JVM,java/lang/System 只会被初始化一次。
  • <clinit> 的执行,对于每一个静态成员变量存取,都被重新定向到了具体的租户存贮空间。比如对于out = null赋值,putstatic执行时实际上会找到当前的租户,然后把值存到该租户的空间去 ,getstatic有着类似的道理。

Java 类库支持多租户语境

这部分主要通过改造类库实现,具体的功能包括:

  • System.exit(code) 调用只会使当前租户退出,而不会令整个 JVM 退出。而租户申请的一些诸如 File/Socket 句柄之类系统资源,会随着租户的推出而被释放。
  • 租户 A 不可能通过类似如下
ThreadGroup group = Thread.currentThread().getThreadGroup();
ThreadGroup parent = group.getParent();

枚举线程的办法获得租户 B 的线程。不同租户的线程分属于不同的线程组。

  • Java 属性值的隔离,比如同样的语句 System.getProperty("name") 对于不同的租户可能是不同的值。

资源管理隔离

这是 Multi-tenant JVM 很重要的功能。在 Multi-tenant JVM 上,Heap/CPU/Disk IO/Net IO 这些资源的使用是受资源策略保护的,比如你可以去限制某个租户它的 CPU 最少可以使用 20%,而在系统空闲时,最大可以用到 100%。

Multi-tenant JVM 通过 Token Bucket 来对 IO(Disk/Net)和 CPU 资源进行管理。对于 IO 而言,Multi-tenant JVM 截获 IO 有关的 OS API 调用,使得 IO 发生之前受制于我们预先规定的资源策略。我们举个网络 IO 的例子,例如 Java 程序从 Socket 的读取操作,JDK 内部的实现通过 JNI 实际上会对应到系统的 API 调用

ssize_t recv(int sockfd, void *buf, size_t len, int flags);    

在 recv 调用发生之前,Multi-tenant JVM 通过资源策略保证租户的 IO 使用带宽不会超过给它设定的限制。关于网络 IO,我们这里有一个很好的演示

用简单的-Xlimit:netIO=6M参数限制运行在 Multi-tenant JVM 上的 Ftp Server 带宽上限读写各为 6Mib/s。

关于对 CPU 管理,Multi-tenant JVM 实现的基本的思路是,把租户线程所花费的 CPU 时间量化为 Tokens,运行时每一个租户线程都会被周期性检查是否其当前 CPU 时间的使用超过了给它设定的限制。如果超过,当前线程会被挂起,直到满足限制为止。周期性检查的代码是由 Multi-tenant JVM 插入到租户线程里去的,对于用户程序而言完全是透明的。

Multi-tenant JVM 对于 Heap 的管理建立在 Balanced GC Policy 基础之上。同一般的 Java 程序类似,你可以使用 -Xms/-Xmx 为租户程序设定最大 / 最小的堆内存值。Balanced GC Policy 基于 Region 对 Heap 进行管理,每个租户程序根据 -Xms/-Xmx 的设定来为其分配 Region,而租户对象的分配也必然只能发生在它自己拥有的区域内。

多租户 JVM 的用法与限制

IBM 发布的 Java 7 R1 默认支持多租户 JVM,在命令行上添加-Xmt参数即可启用。由于多租户 JVM 对 JVM 的变更,JNI Native Libraries、JVMTI 以及 GUI programs 在多租户状态下的使用是受限制的。Multi-tenant JVM 并未实现对 JNI 的隔离,所以不同的租户应用不能装载依赖同样的 JNI Native Lib,所有发生在 JNI Native Lib 里的 IO,不会受限于该租户资源消费策略。同样的情况适用于 CPU 以及 Memory。

Multi-tenant JVM 目前没有实现对 JVMTI Agent 的改造用以支持我们前面所描述的静态数据的隔离,这可能会对用户如果想调试 Java 核心类库代码(不是用户代码)造成困扰。

关于 GUI,Multi-tenant JVM 没有实现底层对于 UI 程序消息队列的隔离,所以不支持在同一个 Multi-tenant JVM 运行大于 1 个的 GUI 程序。

还有一点,不要在非 Daemon 线程里写 “暴力”的死循环代码,例如:

while(true)
{  
try () {
    ....
} catch(Throwable t) {
{
}

}

最后需要注意的是,当开启 IO 资源控制时,尽量一次写出更多的字节,避免影响程序的 IO 性能。

总结

Multi-tenant JVM 目前在应用启动时间和更小的内存占用开销方面已经被证实有效。根据目前的一些基准测试结果来看,对于简单的应用,相较于一般 JVM,Multi-tenant JVM 可以获得 5~6 倍的运行个数。后续计划发布的版本仍然会集中在提高启动时间、更小的内存开销这两个方面,也会陆续有一些性能的报告发布。

长远来看,Multi-tenant JVM 会基于用户、IBM 产品线以及技术社区等的反馈,做进一步的提高,以及解决一些目前所存在的局限性,比如对于 JNI 隔离的支持,JVMTi 的多租户支持等等。

作者介绍

李三红,IBM 资深软件工程师,Multi-tenant JVM 项目技术负责人,目前供职于 IBM Java 技术中心,从事多租户 Java 虚拟机相关的研发工作。九年多的 Java 开发经验,2008 年加入 IBM,参与基于 OSGi 框架的安全方面的开发,2010 年加入 Java 技术中心,参与 IBM Java 虚拟机 J9 的开发。在 Java 技术领域拥有多项专利以及在 developerWorks 上发表十余篇文章。他的微博: @sanhong_li IBM Java 技术中心微博: @IBM_JTC

李三红将在 2014 年 10 月 16-18 日的 QCon 上海大会上就本话题进行分享


感谢杨赛对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论