美文网首页
Espresso: Brewing Java For More

Espresso: Brewing Java For More

作者: CocoAdapter | 来源:发表于2019-03-07 23:06 被阅读0次

    Abstract

    NVM 具有 byte-addressable, near-DRAM latency, disk-like persistence 特点,推动了软件栈和编程模型的革新。但是如何在 JVM 这种程序员不直接操作底层的环境下,如何使用 NVM 还不是很明确。
    Espresso 扩展了 Java 和 运行时,让程序员可以方便高效地使用 NVM 来进行持久化工作。

    • Espresso 提供了一个通用持久化堆,Persistent Java Heap (PJH),操作这个堆与操作普通 Java 堆类似。同时,实现了恢复机制来保证元数据的一致性。
    • Espresso 提供了一个新的持久化编程模型,Persistent Java Object (PJO),来让程序员方便地持久化数据。

    Introduction

    NVM 实际上已经面世多年,广义上 SSD 用的 NAND-FLASH,DRAM 配上电源都属于 NVM。通过 native code 来利用 NVM 的研究工作已经很多,但如何在拥有自己的 Runtime 的高级编程语言环境下使用 NVM,研究较少。原因在于,尽管 Runtime 带来了诸如自动内存管理、移植性、方便编程等好处,但是对底层的抽象层的存在,也给利用 NVM 带来了麻烦。

    JPA 提供了持久化数据的一个粗粒度的编程模型,以一些事务 API 的形式,但是 JPA 的目标存储设备是磁盘这样的低速设备,在持久化过程中做了很多转换工作来把 Java Object 序列化。Intel 的 Persistent Collections for Java (PCJ),提供了一个细粒度的编程模型,来让程序员持久化 Java Object。但是,

    • PCJ 的类型系统是一套自己定义的类型,与现有程序并不兼容;
    • 除此之外,PCJ 使用 native 接口来管理持久化数据,效率很低。

    两种方式对于程序员来说都是需要的,希望系统能同时提供粗粒度和细粒度的操作。

    Espresso, 统一持久化框架,同时支持粗粒度和细粒度,兼容大部分 Java 原生数据结构,性能更好。
    PJH 基于 NVM,无缝隙地存储持久化的 Java Objects。PJH 提供了与操作普通 Java 堆上的对象类似的语法,pnew,不需要修改数据结构。
    PJH 需要处理 crash,提供给程序员一个安全的 Runtime 环境。关键即是实现 crash-consistent allocation 和 deallocation (GC),来保证 PJH 的元数据是 crash-consistent 的。

    这里说明一下什么叫 crash-consistent, 以及为什么 PJH 会出现这个问题
    crash-consistent 是一种强度的 consistency,具体可以查阅相关文献,广泛存在于存储系统中,包括文件系统、数据库系统。简单来说,就是要求系统程序对于可能存在的 crash,需要保证 crash 后数据处于 consistent 的状态,不能出现读出来一些垃圾数据,或者只写了部分数据。
    PJH 因为操作 NVM,数据提交给 NVM 后,NVM 会执行一系列非原子的操作。这个过程中,如果 crash 掉了,很有可能一些数据处于部分被更新的状态,程序再此启动后,必须有种机制来使得系统处于一致性状态。以 WAL 技术为例,如果日志已经写完了,那么就 redo 写操作;如果日志本身都没写完,实际要修改的数据并没有被修改。

    PJO 提供了一种粗粒度的持久化方案,PJO 基于 PJH,替代 JPA 方式。但 PJO 采用跟 JPA 一样的注解和 API,从而提供后向兼容性,并做了一些性能优化。

    PJH 和 PJO 实现基于 OpenJDK 8。在以 JPA 和 PCJ 的性能对比测试中,测试结果非常出色。

    综上,本论文的贡献包括:

    • PJH 方便程序员细粒度地持久化对象
    • PJO 方便程序员粗粒度地持久化对象

    其中细粒度和处理度是从程序员的角度来讲的,TODO

    Background and Motivation

    通过 JPA 实现的粗粒度的持久化

    首先说明 JPA 供应商 DataNucleus 的工作原理来说明:首先,给要持久化的类添加 @persistent 注解,DataNucleus 会通过字节码插桩技术 (enhance) 来转换任意类为实现了 Persistable 接口的类,额外包括一些接口方法,getter/setter,属性。

    类似于 CGLib 的动态代理实现原理,Android 上使用广泛的 GreenDao 与 DataNucleus 类似。

    上述转换只是为了方便框架进行统一处理,从 Java Object 到 RDBMS,还得需要通过 SQL 语句来完成。这样就存在一个转换 SQL 的过程。

    JPA 在 NVM 上的低效:转换成 SQL 这个过程性能开销非常大

    通过 PCJ 实现的细粒度的持久化

    PCJ 和一些类似的程序,设计上存在一些问题,不能很好地服务于 Java 环境。

    Seperated type system

    PCJ 实现了一套自己的类型系统,基类为 PersistentObject,只有直接或间接继承该类的类,才能被持久化到 NVM。这就导致了很麻烦的源代码不兼容问题。

    Off-heap data mangement

    PCJ 采用 native off-heap 的设计,通过 NVML (一个提供对 NVM ACID 操作的 C 库) 来管理持久化到 NVM 的数据。因为这部分堆外内存需要 PCJ 自己来管理,包括同步、GC,这部分工作带来的开销很大。
    在文中的实验中,真正的数据部分操作只占了 1.8%,

    • 元数据更新占了 36.8%。主要是类型信息,而在 Java 堆上,类型信息是通过指针访问由 JVM 放在方法区的操作完成的。

    Java 中的 new 关键字到底执行了什么?
    创建一个 Java 对象需要三步:声明引用变量,实例化,初始化对象实例
    声明引用变量:与 C/C++ 的变量声明类似,只是分配了一个指针的空间。
    实例化:就是“创建一个 Java 对象”,分配内存并返回指向该内存的引用。
    初始化:就是调用构造方法,对类的实例数据赋初值。

    对于 Object obj = new Object();
    “Object obj”这部分的语义将会反映到 JVM 栈的本地变量表中,作为一个 reference 类型数据出现。而“new Object()”这部分的语义将会反映到 Java 堆中,形成一块存储了 Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存。
    一般来说,包括对象头、实例数据和对齐填充(可能存在)三个部分。其中对象头包括 Mark Word, 元数据指针和数组长度(如果是数组对象)。
    根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。对象只是通过一个指针指向了这部分数据。

    • GC 的数据维持占了 14.8%。因为 PCJ 需要自己管理 GC,而普通的 Java 堆使用的是更成熟的 GC,更快。
    • 事务占比超过 20%。这部分性能损耗来自底层同步元语和日志。这部分是必须的,但是可以优化:比如采用 JVM 中的做法,利用对象头中的 bits 来保存同步信息,同时讲事务操作交给更成熟的 Java library 来做。

    Requirements for Persistence Management in Java

    • Unified persistency: 框架需要同时支持粗粒度和细粒度的持久化,从而支持大部分应用程序开发。
    • High performance: 框架需要低性能损耗,从而使得 NVM 带来的性能提升不被抵消掉。
    • Backward compatibility: 框架不能对现有的 JVM、Database、Application 等要求太大的重编码,从而方便移植。

    Persistent Java Heap

    PJH 扩展了原有的 Java Heap,原有的堆在 DRAM 上,PJH 在 NVM上。PJH 和 Java Heap 都属于 JVM 规范中的堆区。

    OpenJDK 8 的 JVM 实现中 维持了一个 MetaSpace 来作为JVM 规范的方法区,用的堆外内存。包括两个部分:Klass Metaspace 和 NoKlass Metaspace。其它内存布局信息请查阅相关资料

    PJH 不分代,因为需要持久化的数据一般生命周期很长。PJH 的 GC 由 Parallel Scavenge GC 处理老年代的算法来操作:Mark-Compact 标记整理算法。

    PJH 主要包括:

    • metadata area
      维持堆相关的元信息,确保堆的正常加载和 crash-consistency。包括,起始虚拟地址,堆大小,堆顶指针,和其它用于可恢复GC的元数据。
    • name table
      存放了两种入口的字符串映射: Klass 和 root。
      根据类的全限定名,可以通过查该表判断是否已经加载该类。当一个对象被分配在 NVM 上时,首先要加载其对应的类。这一点与 JVM 的类加载机制一致,同时,本文的实现中这部分工作也是由 JVM 来做的。
      Root 我没看懂 TODO

    前面提到过 Object obj = new Object(); JVM 究竟会执行什么。那里存在一个前提,就是 Object 类已经被 JVM 加载了,实际上 Object 类也的确会被 JVM 在初始化阶段加载,因为它是 Class 类的父类。但是对于普通的类,new 操作会先去常量池里找是否该类已经被加载了,如果没有,则执行加载过程;否则才会执行上述流程。

    • Klass segment
      与 MetaSpace 中的 Klass Metaspace 类似,存放的是 Klass,即 class 文件的运行时数据结构。
    • data heap
      与 Java Heap 类似,存放的是实际对象数据。

    Language Extension: pnew

    pnew 和 new 的语法规则和语义相似,除了:

    • 对象会被初始化在 NVM上
    • 对象的属性不会被分配存储空间。也就是说,只包含对象头和类型指针。如果要同时持久化属性域,必须手动在构造函数中迭代调用 pnew。TODO 那如果在实例初始化之后再对属性域调用 pnew,那对象的内存布局又是怎样的呢?

    在 Java 中,每个 .class 文件加载后,都会生成一个一个(静态)常量池,用于存储字面量和符号引用。对于每个类全限定名(符号引用的一种),都由一个 key-value 结构进行存储,key 为类全限定名,value 的值需要等到解析阶段,确定了其对应的 Klass 的内存地址后,再设置值。

    如果一个类连续被分别加载到 JVM 和 PJH,那么会生成两个 Klass,这两个 Klass 是不同的,一个在 JVM 的 MetaSpace 里,一个在 PJH 的 Klass segment 里。对象头里的 Klass pointer 指向的是不同的地方。JVM 真正执行的是自己管理的 MetaSpace 的那个 Klass,所以即使使用 pnew ClassA, ClassA 也会被加载到 MetaSpace 里。JVM 在常量池中只能设置一个地址,所以后面设置的地址会覆盖掉前面设置的地址。(TODO 这里感觉理解的有问题,按理说每个类都有自己的常量池的,通过 new 和 pnew 进行加载的类,不应该是不同的常量池么?如果是为了内存优化,那 NVM 中的 Klass Segment 和 JVM MetaSpace 中的 Klass MetaSpace 有什么区别呢?)

    这样一来,同一个类,通过 new 和 pnew 实例化,最后指向的就是不同的类,就会抛 ClassCastException。文中引入了一个 Alias 的概念,当两个 Klass 是逻辑上一个类,只是存储在不同的地方,就互为 Alias。Aliases 通过在 Klass 区增加一个属性来实现。共享 metadata,比如静态变量,方法,也就是说,PJH 上存的 Klass 和 JVM 中的 Klass 对于绝大部分元数据,只有一个副本,TODO 剩下哪些数据不同,不是很清楚。 因为引入了新的类型机制,所以 JVM 的类型检查也做了相应的修改:包括子类型检查、ClassLoader Check。这部分会带来一定的开销,但是大部分类都都不需要 Alias,影响几乎没有。

    Heap Management

    PJH 提供了一些 API 来操作 PJH。PJH 是根据一个字符串名称来进行加载的,文中说用了一个 external name manager 来实现。在 loadHeap 的时候,JVM 区 name manger 里查给定名称的 PJH,然后根据 Metadata area 里的 address hint 和 heap size 来访问 PJH。

    我的理解:这里的 name manger 相当于就是个文件系统,路径名就是 PJH 名,具体磁盘块就对应 NVM 中的地址。因为在单个程序中,进程在虚拟地址空间中的内存布局是固定的,NVM 也被映射到内存中的固定位置,所以 address hint 可以再被查找。

    JVM 在映射 PJH 到虚拟内存的时候,有可能原来的地址,被 Java Heap 给占了,这个时候,PJH 自身的 address hint 必须被调整,PJH 中的数据的相关地址也需要调整。PJH 需要做一次全局扫描,来更新相关地址信息。
    因为 64 位地址空间很大,一般来说虚拟地址冲突很少。
    在 map 成功后,JVM 重新执行类加载。但是如果直接执行类加载,生成的 Klass 在内存中几乎可以肯定与之前 PJH 在的时候的位置不一样。一个不一样,那 PJH 中存的对象引用、Klass 引用等都不一样了就。为了避免这种情况,在虚拟地址空间中,Klasses 占据一块固定的地址段,每个 Klass 加载后的虚拟地址是固定的。这样一来,一旦完成对 Klass 的初始化,所有对象里的 Klass 指针也就合法了。

    Root-related APIs

    TODO 没懂作用何在。Root objects marks some known locations of persistent objects and can be used as entry points to
    access PJH especially when a PJH instance is reloaded.

    Referential Integrity

    出于性能的考虑,PJH 允许将对象和属性分开存储在 NVM 和 DRAM 中。但是这样做违背了 Java 的引用完整性原则,试想在程序 crash 后,re-load 的 PJH 中的对象的引用,指向的内存地址是合法的吗?如果要按原状恢复 DRAM 中的实例变量,那又会带来很大的开销。因此,文中给出了三种不同等级的内存安全策略:

    • User-guaranteed safety
      程序员需要自己小心指向 DRAM 的引用,避免直接访问。这样做提供了最好的性能,但可能造成一些错误。

    就好比把边界检查交给程序员做而不是由运行时来做,缓冲区溢出在 C/C++ 平台上饱受诟病,这也是 Java 为什么由 Runtime 来做检查的原因。

    • Zeroing safety
      默认,PJH 在 reloading 之前,所有对象引用指针会被置为 null。但是这会遍历整个 PJH,从而带来性能开销。可以通过卡表 (Card Table) 来优化,也可以通过另开一个进程来并行处理这部分的工作,从而从关键路径上移掉这个耗时操作。
    • Type-based safety
      限制 PJH 中的对象只能指向 PJH 中的对象。

    Persistence Guarantee

    pnew 只执行了对象的持久化,并不包括属性。为了持久化对象的属性,需要新的 API。首先通过反射获取到属性对象,因为 PJH 中的对象默认并没有属性,所以只能通过反射区访问这些属性。然后设值。再然后,flush 到 NVM 上。flush 操作只能操作 8 位的数据来保证原子性。同时,底层用了内存屏障来提供写可见性,类似 volatile 关键字。

    我们知道 32 位虚拟机中的 long 操作不是原子的,那么可以推断这里的魔改 JVM 肯定是基于 64 位虚拟机,并且没有考虑 32 位虚拟机的情况。

    同时,也提供了一个粗粒度的 flush 方法,定义在 Object 类中,只用了一个内存屏障,会将 Object 中的属性值全部存储到 NVM 上。这个方法适合不在意持久化顺序的那些语义。
    不同平台下,flush 方法的实现可能不同,详见论文。

    Crash-consistent Heap

    跟所有存储系统一样,PJH 也要处理一致性的问题。Espresso 从分配和垃圾回收两方面来保证一致性。

    Crash-consistent Allocation

    对象的实例化与 new 类似,

    • 在常量池中寻找 Klass
    • 分配内存空间,并更新 top 指针根据
    • 初始化对象头
      这个过程中会存在一致性问题的地方就在于 PJH 中 top 指针的更新,和对象头中 klass 指针的更新,这些可以通过 cache flush 和内存屏障来保证。

    Crash-consistent Garbage Collection

    虽然是重用了 PSGC 中针对老年代的回收算法,但是也需要一点更改来避免一致性问题。

    A Brief review of PSGC

    PSGC 对于老年代,采用标记-整理算法。整个老年代被分为很多 region,GC 一共分为三个阶段:

    • mark phase: 根据可达性算法分析结果,标记所有存活对象。标记结果通过一个 read-only 的 bitmap 结构,mark bitmap 来存储,以减少内存开销。
    • summary phase: 计算存活对象在 GC 后的新内存地址。该操作具有幂等性。也就是说,根据同一个 mark bitmap,无论计算多少次,都会得到相同的结果。
    • compact phase: GC 线程把存货对象复制到新的内存地址上去。复制是并行的,但是一个 region 只会被一个 worker thread 处理(但 worker thread 一对多)。复制完后,再更新每个 object 所用到的引用。

    Crash-consistent GC

    PSGC 存在的问题就是,在 compact phase,内存处于不一致的状态。因此,当 crash 发生后,需要继续从 crash point 继续 compact。
    具体的实现机制为:

    • 在 phase 2 完成后,制作快照
      对 PJH 制作快照并存在 NVM 上。如果 crash 掉了,那么根据这个快照重新计算存活对象的目标迁移地址。快照制作完后,如果开始 compact,标记 PJH 为 compactting,从而当发生 crash 的时候,可以知道 PJH 是否处于一致的状态。
    • 一个基于时间戳的算法来推测并从 crash 中恢复
      快照只能提供 copy 的目标地址信息,并不能告知 crash 发生后,对象是否开始 copy,或者 copy 了一半。
      所以,通过重用对象头里的一个 bit 来实现时间戳。PJH 维护一个全局的时间戳,一开始的时候,每个对象里的时间戳与全局保持一致。但是 compact phase 开始后,全局时间戳加 1,每一个对象完成迁移后,自己的时间戳加 1。这样一来,当 crash 发生在 compact 阶段时,Espresso 可以根据快照和对象头里的时间戳,很快检查出哪些对象需要重新执行迁移。

    个人感觉这个算法是一种优化措施,而不是必须的,因为也可以 crash 后重新执行整个 compact phase。

    Recovery

    在 loadHeap 的时候会检查是否处于一致性状态,否的话,执行:

    • 加载 snapshot
    • 重新计算 destination 地址
    • 执行 compact

    最后返回 PJH 实例。

    Persistent Java Object

    PJH 只保证堆上元数据的 crash-consistency,应用数据仍可能面临不一致的问题。所以,希望能有一个提供 ACID 的上层框架。但是这面临着一些 challenge:

    • Java 程序和 JVM 之间的语义差距
      ACID 在上层可能就是一条命令,底层需要执行很多操作?TODO 不确定整个语义差具体怎样解释
    • JPA 很好,但是带来的性能开销很大
      之前是面对数据库,中间可能还要经过网络,所以 JPA 中涉及的一些开销相比这些大 I/O 操作,都不是很大的问题。但是现在 NVM 速度很快,这部分开销的占比就很大。

    Espresso 提供了一个 Persistent Java Object (PJO) 框架来解决这个问题。核心思想是与 JPA 类似,定义一套规范,来指导 PJO 供应商持久化对象到 NVM 上。

    在文中,作者修改了 DataNucleus 的源代码,具体的持久化工作是修改后的 DataNucleus 在做:

    • 增强待持久化的对象,添加一个实例变量 StateManager 来管理元数据和控制访问。这个实例变量对应用时透明的。
    • 生成一个 DBXXXX 对象,按 PJO (JPA) 规范创建那些需要持久化的实例变量,引用指针指向之间对象所指向的内存。
    • 在 NVM 上生成 DBXXXX 对象。
    • 解除对临时数据变量的引用。DRAM 中的对象的引用指向 NVM 中的数据域,从而节约内存。

    Evaluation

    Experiment setup

    主要讲了 implementation 的工作构成,和实验环境,详见论文

    Compare with PCJ

    • 与 PCJ 提供类似的数据结构
    • 通过简单的 undo log 提供与 PCJ 类似的 ACID 保证。

    结论:
    create/set 操作快很多,因为 PCJ 需要很多元数据更新操作;get 仍然比 PCJ 快,但因为 PCJ 没那么多元数据操作了,没那么慢了,快的没那么夸张。
    PCJ 的性能损耗主要是 GC 和 事务相关的操作上。而这些操作在 PJH 的设计中,分别通过 JVM 和 PJO 实现了。

    Compare with JPA

    用 JPA Benchmark 来做测试,比较 PJO 和 JPA 的效率(实际上是对比的魔改后的 DataNucleus 和魔改前的效率比)。一个对照组和两个实验组

    • JPA, H2, 在 NVM 上 (H2-JPA) 吞吐量最低
    • PJO, H2, 在 NVM 上 (H2-PJO) 吞吐量中
      这里的 NVM 实际上是 JPH
    • PJO, H2, 在 DRAM 上 (H2-PJO-v) 吞吐量高
      这里的 PJO 是工作在 DRAM 上的

    PJO 可以大幅减少转换成 SQL 语句的开销,同时,DB 的执行也加快了,可以被视为是 JDBC 接口改为 DBPersistable 接口带来的接口改变。

    因为不清楚 PJO 具体规范的内容,按理说应该没有 SQL 转换耗时了的,可能因为 DB 还有自己的 SQL 操作。这个接口转换带来的性能提升,可能跟接口的复杂度,需要进行不同的反射操作等等有关。

    Microbenchmark

    Heap loading time

    与内存的安全性等级有关,在 User-guaranteed 模式下,因为 load 一个 PJH 并不需要访问 PJH 内部的内容,所以不论 PJH 多大,都是常数时间;在 Zero 模式下,因为 loading 的时候只需要遍历所有 Klass,查看所属的 Klass 是否已经初始化,这是个常数时间的操作,在 Klass 较少的时候,这个系数很小。整体时间与对象数线性相关。

    Recoverable GC

    • PSGC 因为对堆进行分代,所以还存在新生代的回收,而修改过的 PSGC 不存在这个问题。通过把堆增大到4G,新生代不会触发 GC,这个时候的 PSGC 和 修改过的 PSGC 耗时基本相同。
    • 在 GC 过程中发送 SIGKILL 信号终止 JVM,然后重新运行,来测试可恢复性。性能与老年代 GC 大致相当。

    Related Work

    相关文章

      网友评论

          本文标题:Espresso: Brewing Java For More

          本文链接:https://www.haomeiwen.com/subject/elfcpqtx.html