BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

OpenJDK与HashMap……放心地教这个老家伙一些新(非堆!)技巧

| 作者 Peter Lawrey 关注 6 他的粉丝 , Ben Cotton 关注 0 他的粉丝 ,译者 张卫滨 关注 13 他的粉丝 发布于 2015年5月27日. 估计阅读时间: 58 分钟 | Google、Facebook、Pinterest、阿里、腾讯 等顶尖技术团队的上百个可供参考的架构实例!

OpenJDK的非堆JDK增强提议(JDK Enhancement-Proposal,JEP)试图标准化一项基础设施,它从Java6开始,只能在HotSpot和OpenJDK内部使用。这种设施能够像管理堆内存那样管理非堆内存,同时避免了使用堆内存所带来的一些限制。对于上百万短期存在的对象/值来说,堆内存工作起来是很好的,但是如果你想要增加一些其他的需求,如几十亿的对象/值的话,假若你想避免持续增加的GC暂停,那么你需要做一些更加有创造性的工作。在有些场景下,你还需要完全避免暂停。非堆提供了构建“arenas”内存存储的功能,它遵循自己的规则,并不会影响到GC的暂停时间。两个很容易使用arenas的集合是Queue和HashMap,因为它们具有很简单的对象生命周期,所以编写自己的垃圾收集并不太繁琐。这种集合所带来的好处就是它的大小能够比传统的堆集合大得多,甚至超过主存储器(main memory)的规模,而对暂停时间的影响却微乎其微。相比之下,如果你的堆大小超过了主存储器,那么你的机器就会变得不可用,可能会需要关电源重启。

 

本文将会调查这个JEP的影响,它会让大家熟悉的Java HashMap具备新的非堆功能。简而言之,这个JEP所具有的魔法能够“教会”HashMap(这是一个可爱的家伙old dog)一些新的技巧。这个JEP会要求将来的OpenJDK发布版本与传统Java平台的优先级产生很大的差异:

  1. 将sun.misc.Unsafe中有用的部分重构为一个新的API包
  2. 提倡使用新的API包在非堆的原生内存操作对象上直接进行高性能的原生内存操作。
  3. (通过新的API)提供外部功能接口(Foreign Function Interface,FFI)来桥接Java与操作系统资源(Operating System resource)和系统调用(system call)。
  4. 允许Java运行时借助硬件事务内存(Hardware Transactional Memory)提供者的foci,将低并发的字节码重写为高并发性的speculatively branched机器码。
  5. 移除FUD(坦率的说,这是一种技术上的偏执),它与使用非堆编程策略来实现Java性能的提升有关。最终,基本明确的是这个JEP要求OpenJDK平台要开放性地将其纳为主流,它曾经被视为黑暗的工艺、非堆参与者的秘密组织。

本文力图(以一种通俗和温和的方式)让所有感兴趣的Java开发人员都能有所收获。作者希望即使是新手也能完整地享受本文所带来的这段旅程,尽管在路途上可能会有一些不熟悉的“坑坑洼洼”,但是不要气馁——希望您在位置上安坐直到文章结束。本文会提供一个有关历史问题的上下文,这样你会对下面的问题具备足够的背景知识:

  • 堆HashMap的问题是怎么产生的?
  • 为了应对这些问题,历史上所给出方案的成功/失败之处是什么?
  • 在堆HashMap的使用场景中,依然存在的未解决问题是什么?
  • 新JEP所提供的功能能够带来什么助益(也就是将HashMap变为非堆的)?
  • 对于非堆JEP所没有解决的问题,将来的JEP能够给我们什么期待呢?

那么,让我们开始这段旅程吧。需要记住的一点是在Java之前,哈希表(hash table)是在原生内存堆中实现的,比如说在C和C++中。在一定程度上可以说,重新介绍非堆存储是“老调重弹”,这是大多数当前的开发人员所不知道的。在许多方面可以说,这是一趟“回到未来”的旅行,因此享受这个过程吧!

OpenJDK非堆JEP

针对非堆JEP,已经有了几个提议(submission)。下面的样例展现了支持非堆内存的最小需求。其他的提议尝试提供sun.misc.Unsafe的替代品,这个类是目前的非堆功能所需要的。它们还包含了很多其他有用和有趣的功能。

JEP概述:创建sun.misc.Unsafe部分功能的替代品,这样就没有必要再去直接使用这个库了。

目标:移除对内部类的访问。

非目标: 不支持废弃(deprecated)的方法,也不支持Unsafe尚未实现的方法。

成功指标:实现与Unsafe和FileDispatcherImpl相同的核心功能,并且性能方面要与之保持一致。

驱动力: 目前来讲,Unsafe是构建大规模、线程安全的非堆数据结构的唯一方法。在如下的领域,这种方式会很有用,如最小化GC的影响、跨进程共享内存以及在不使用C和JNI的情况下实现嵌入式数据库,因为使用C和JNI的话,可能会更慢并且更加困难。FileDispatcherImpl目前需要将内存映射为任意的大小。(标准API限制为小于2GB。)

描述: 为非堆内存提供一个包装类(类似于ByteBuffer),但是具有如下的功能增强。

  • 64位的大小和偏移。
  • 线程安全结构,如volatile和顺序访问、比较和交换(compare and swap,CAS)操作。
  • JVM优化的边界检查,或开发人员控制边界检查。(提供的安全设置允许这样做)
  • 在一个缓冲区中,能够为不同的记录重用部分缓冲区。
  • 能够将非堆的数据结构映射到这样一个缓冲区之中,在这个过程中,边界检查已经被优化掉了。

要保留的核心功能:

  • 支持内存映射文件
  • 支持NIO
  • 支持将写操作提交到磁盘上。

替代方案:直接使用sun.misc.Unsafe。

测试:测试需求应该与目前的sun.misc.Unsafe和内存映射文件相同。还需要额外的测试来证明它与AtomicXxxx类一致的线程安全操作。AtomicXxxx类可以使用这个公开API进行重写。

风险:有很多的开发人员在使用Unsafe,他们可能并不认同合适的替代方案是什么。这意味着这个JEP的范围可能会扩大,或者会创建新的JEP来涵盖Unsafe中的其他功能。

其他JDK: NIO

兼容性:需要保持向后兼容的库。这可以针对Java 7实现,如果有足够兴趣的话,也可以支持Java 6。(当撰写本文的时候,当前的版本是Java 7)

安全性:理想情况下,安全性的风险不应该超过当前的ByteBuffer。

性能和可扩展性:优化边界检查会比较困难。可能需要为这个新的缓冲区添加更多的功能,通过通用的操作来减少损耗,如writeUTF、readUTF。

HashMap简史

“哈希码(Hash Code)”这个术语最早于1953年1月出现在Computing文献之中,H. P. Luhn(1896-1964)在编写IBM内部备忘录时,使用到了这个术语。Luhn试图解决的问题是“给定一个文本格式的单词流,要实现100%完整的(单词、页集)索引,最优的算法和数据结构是什么样的?”

H.P. Luhn (1896-1964)

Luhn写到“hashcode”是基本的运算符(operator)。

Luhn写到“关联数组(Associative Array)”是基本的运算对象(operand)。

术语“HashMap”(亦称为HashTable)逐渐形成了。

注意:HashMap这个词源自出生于1896年的计算机科学家。HashMap真的是个老家伙了!

让我们将HashMap的故事从它的起始阶段转移到早期的实际使用阶段,也就是从1950年代中期跳到1970年代中期。

在其1976年写成的经典著作《算法+数据结构=程序》之中, Niklaus Wirth讨论了“算法”,将其视为基本的“运算符”,并将“数据结构”视为基本的 “运算对象”,对于所有的计算机程序来讲这都是适用的。

从那时开始,数据结构领域(HashMap、堆等)的进步是很缓慢的。在1987年,我们确实也看到了Tarjan非常重要的F-Heap突破,但是除此之外,在运算对象方面确实乏善可陈。当然需要记住的是,HashMap最早出现于1953年,已经有超过六十年的历史了!

然而,在算法社区(Karmakar 1984,NegaMax1989,AKS Primality 2002,Map-Reduce 2006,Grover Quantum搜索 - 2011)却是发展迅速,为计算机基础领域提供了新鲜和强大的运算符。

但是在2014年,数据结构领域可能再次会有一些重大的进展。在OpenJDK平台方面,非堆 HashMap是一个正在不断发展的数据结构。

关于HashMap的历史,我们已经介绍了很多的内容。现在,我们开始探索一下如今的HashMap,尤其是看一下在Java中,HashMap当前的三个变种。

N. Wirth 1934-

java.util.HashMap(非线程安全)

在真正的多线程(Multi-Threaded,MT)并发用户场景下,它会快速失败,并且每次都是如此。所有地方的代码必须使用Java内存模型(Java Memory Model,JMM)的内存屏障策略(如synchronized或volatile)以保证执行的顺序。

会发生失败的简单假设场景:

- 同步写入

- 非同步读取

- 真正并发(2 x CPU/L1)

让我们看一下为什么会发生失败……

假设Thread 1往HashMap中进行写入,而写入的效果存储在CPU 1的一级缓存之中。然后,Thread 2几秒后得以在CPU 2上继续执行,它会读取来自于CPU 2一级缓存中的HashMap——这并不会看到Thread 1的写入,这是因为写入和读取线程中的写读操作之间没有内存屏障操作,而这是共享状态的Java内存模型所需要的。即便Thread 1同步写操作,写操作的效果刷新到了主内存中,Thread 2依然看不到变化的效果,因为读取操作来自于CPU 2的一级缓存。所以,在写入操作上的同步只能避免写入操作的冲突。要满足所有线程的内存屏障操作,你必须还要同步读取。

thrSafeHM = Collections.synchronizedMap(hm) ;(粗粒度的锁)

要使用“synchronized”达到高性能的话,竞争出现的机率要比较低。这种场景是非常常见的,因此在很多场景中,这并不会像听上去那么糟糕。但是,如果你要引入竞争的话(多个线程同时尝试操作同一个集合),就会影响到性能了。在最坏的场景下,如果有高频率的竞争,最终的结果可能是多个线程的性能甚至比不上单个线程的性能(没有任何锁定和竞争的操作)。

这是通过在所有的key上粗粒度地阻塞所有mutate()和access()操作实现的,实际上就是在所有的线程操作符上阻塞整个Map操作对象,只有一个线程可以对其进行访问。这导致的了零多线程并发(Zero MT-concurrency),也就是同时只有一个线程在进行访问。这种粗粒度锁的另外一个结果是我们非常不喜欢的一个场景,被称之为高度的锁竞争(High Lock Contention)(参见左图,N个线程在竞争一个锁,但是必须要阻塞等待,因为这个锁被正在运行的一个线程所持有)。

对于这种完全同步、非并发、isolation=SERIALIZABLE(并且总体上来说令人失望)的HashMap,幸好在我们即将到来的OpenJDK非堆JEP中有了推荐的补救措施:硬件事务性内存(Hardware Transactional Memory,HTM)。借助HTM,在Java中编写粗粒度同步阻塞将会再次变得很酷。HTM会帮助将零并发的代码在硬件层面转换为真正并发且100%线程安全的。这会再次变得很酷,对吧?

java.util.concurrent.ConcurrentHashMap(线程安全、更巧妙的锁,但是依然不“完美”)

在JDK 1.5发布的时候,Java程序员发现在核心API中包含了期待已久的java.util.concurrent.ConcurrentHashMap。尽管CHM并不能成为HashMap统一的替代方案(CHM使用更多的资源,在低竞争的场景下可能并不合适),但是它确实解决了其他HashMap所不能解决的问题:实现真正的多线程安全和真正的多线程并发。让我们画图来展现一下CHM能够带来什么好处

  1. 锁分片
  2. 对于java.util.HashMap中独立的子集有一个锁的集合:N个hash桶/N个分段(Segment)锁。(右侧的图中,Segments=3)
  3. 如果在设计时,想要将高度竞争的锁重构为多个锁,而又不损害数据完整性时,锁分段是非常有用的。
  4. 对于“检查并执行(check-then-act)”的竞态条件问题,它能够提供并发性更好且非同步的解决方案。
  5. 问题:该如何同时保护整个集合?(递归)获取所有的锁?

那么,现在你可能会问:有了ConcurrentHashMap和java.uti.concurrent包,高性能计算社区(High Performance Computing community)是否可以将Java作为编程平台来构建方案以解决他们的问题呢?

非常遗憾的是,最为现实的答案依然是“时机尚未成熟”。那么,还存在的问题到底是什么?

CHM有一个问题是有关扩展性和持有中等生命周期(medium-lived)对象的。如果有少量的重要集合使用CHM的话,那么其中有一些可能会非常大。在有些场景下,你会有大量中等存活时间的对象保存在这样的集合中。中等生命周期对象的问题在于它们占用了大部分的GC暂停时间,比起短期存活(short-lived)的对象,它们的成本可能会高上20倍。长期存活的对象会位于老年代,而短期存活的对象在新生代就会死亡,但是中等生命周期的对象会经历所有的survivor空间复制,然后在老年代死亡,这使得它们的复制和最终清理成本很高。理想情况下,你所需要的存储数据的集合对GC的影响是零。

ConcurrentHashMap中的元素在运行时位于Java VM的堆中。CHM位于堆上,因此它是造成Stop-the-World(STW)暂停的重要因素,我们不将其称之为最重要的因素其实也差不多。当STW GC事件发生时,所有的应用程序线程都会经历“难堪的暂停”延迟。这种延迟,是由位于堆上的CHM(及其所有的元素)造成的,这是一种痛苦的体验。这种体验和问题是高性能计算社区所无法忍受的。

在高性能计算社区完全拥抱Java之前,必须要有一种方案驯服堆GC这个怪兽。

这个方案在理论上非常简单:将CHM放在堆外。

当然,该方案也正是这个OpenJDK非堆JEP所要设计支持的。

在深入介绍HashMap非堆生命周期之前,让我们看一下有关堆的细节,这些细节描述了它的不便之处。

 

Heap的简史

Java堆内存是由操作系统分配给JVM的。所有的Java对象都是通过其堆上的JVM地址/标识来进行引用的。堆上的运行时对象引用肯定会位于两个不同的堆区域中的某一个上。这些区域更为正式的叫法是代(generation)。具体来讲:(1)新生(Young)代(包括EDEN区和两个SURVIVOR子空间)以及(2)老年(Tenured)代。(注意:Oracle宣布永久代将会从JDK 7开始逐渐淘汰,并会在JDK 8中完全消除掉)。所有的分代都会导致恐怖的“Stop-the-World”完整垃圾回收事件,除非你使用“无暂停(pause less)”的收集器,如Azul的Zing

在垃圾收集的领域,操作是由“收集器”执行的,这些收集器的操作对象就是堆中的目标分代(及其子空间)。收集器会操作在堆的目标分代/空间上。垃圾收集的完整内部细节是另外一个(很大的)主题,在一篇专门的文章中进行了阐述。

就现在来说,记住这一点就够了:如果(任意类型的)某个收集器在任何分代的堆空间上导致“Stop the World”事件,那么这就是一个严重的问题。

这是一个必须要有解决方案的问题。

这是非堆JEP能够解决的一个问题。

让我们近距离地看一下。

Java堆的布局:按照分代的视角

垃圾收集使得编写程序容易了许多,但是当面临SLA目标时,不管是写在书面上的还是隐含的(比如Java Applet停止30秒是不能允许的),Stop-The-World暂停时间都是一个很令人头疼的问题。这个问题非常严重,以至于对于很多Java开发人员来说,这是他们所面对的唯一的问题。值得一提的是,当STW不再是问题的时候,还有很多其他要解决的性能问题。

使用非堆存储的收益在于中等生命周期对象的数量会急剧下降。它甚至还能降低短期存活对象的数量。对于高频率的交易系统,一天之内所创建的对象可能会比Eden区还小,这意味着一天之内甚至不会触发一次minor收集。一旦内存方面的压力降低了,并且有很少的对象能够到达老年代,那么优化GC将会变得非常容易。通常你甚至不需要设置任何的GC参数(除了可能会增加eden的大小)。

借助转移到非堆上,Java应用通常可以宣告完全主宰自己的命运,也就是能够满足性能的SLA期待和条款。

稍等。刚才最后一句话是什么意思?

注意:所有的乘客,请收起您的折叠板并将座椅调至直立状态。这是很值得重复的一句话,也是这个OpenJDK非堆JEP所解决的核心问题所在。

通过将集合(如HashMap)实现非堆,Java应用通常可以宣告完全主宰自己的命运(不再受STW GC“难堪的暂停”事件的摆布),也就是能够满足性能的SLA期待和条款。

这是一个具备实用性的可选方案,在基于Java的高频率交易系统上已经得到了应用。

对于Java来说,如果想对高性能计算社区保持持续的吸引力,这也是一个完全必要的方案。

堆的优势

  1. 以熟悉的方式,很自然地编写Java代码。所有有经验的Java开发人员都能编写这样的代码。
  2. 安全,不必担心内存访问问题。
  3. 自动化的GC服务——没有必要自己去管理malloc()/free()操作。
  4. 对Java锁API和JMM的集成都完全不必再担心。
  5. 没有序列化/复制的数据要添加到结构体之中。

非堆的优势

  1. 能够将“Stop The World” GC事件控制到你认为合适的级别。
  2. 在扩展性方面(当使用堆所造成的影响足够高的时候)要强于堆上的结构。
  3. 可以用做原生的IPC传输手段(不会有java.net.Socket的IP回路)。
  4. 在分配方法上的考虑因素:
    • 使用NIO DirectByteBuffer,实现到/dev/shm (tmpfs)的映射?
    • 或者直接使用sun.misc.Unsafe.malloc()?

HashMap的现状……(通过使用非堆)这个“老家伙”能够解决什么新问题?

OpenHFT HugeCollections (SHM)简介

“非堆”到底是什么?

在下面的图中,阐述了两个JavaVM进程(PID1和PID2),它们试图使用SharedHashMap(SHM)作为进程间通信(inter-process communication,IPC)的设施。图中底部的水平轴展现了完整的SHM OS位置分布域。当进行操作的时候,OpenHFT对象必须要位于OS物理内存的用户地址空间或者内核地址空间。继续深入研究一下,我们知道开始的时候,它们必须是“On-Process”的位置。按照Linux OS的视角来看,JVM是一个a.out(通过调用gcc来生成)。当这个a.out运行时,从Linux进程内部来看,这个运行的a.out有一个PID。 PID的a.out(在运行时)有一个大家所熟知的内部构造, 包含了三个段(segment):

  1. 文本段(Text,低地址……代码执行的地方)
  2. 数据(Data,通过sbrk(2)实现从低地址到高地址的增长)
  3. 栈(从高地址向低地址增长)

这是在OS的角度来看PID。PID是一个正在执行的JVM,这个JVM对其操作对象的可能位置分布有一个自己的视角。

按照JVM的视图,操作对象可能位于On-PID-on-heap(正常的Java)或者On-PID-off-heap(通过Unsafe或NIO的bridge桥接到Linux mmap(2))之中。不管是On-PID-on-heap还是On-PID-off-heap,所有的操作对象依然都还是在用户地址空间中执行。在C/C++中,有API(OS系统调用)能够允许C++操作对象位于Off-PID-off-heap上。这些操作对象存在于核心地址空间上。

下面6个编号的段落对上图进行了描述。

#1. 为了更好地阐述上图中的流程,假设 PID 1定义了一个BondVOInterface,它是符合JavaBean约定的。我们想要阐述(按照上图中的数字顺序)如何操作Map<String,BondVOInterface>,这种方式会着重强调非堆的优势。

来自于GitHub:

public interface BondVOInterface {
    /* add support for entry based locking */
    void busyLockEntry() throws InterruptedException;
    void unlockEntry();
    long getIssueDate();
    void setIssueDate(long issueDate); /* time in millis */
    long getMaturityDate();
    void setMaturityDate(long maturityDate); /* time in millis */
    double getCoupon();
    void setCoupon(double coupon);
    // OpenHFT Off-Heap array[ ] processing notice ‘At’ suffix
    void setMarketPxIntraDayHistoryAt(@MaxSize(7) int tradingDayHour, MarketPx mPx);
    /* 7 Hours in the Trading Day:
    * index_0 = 9.30am,
    * index_1 = 10.30am,
    …,
    * index_6 = 4.30pm
    */
    MarketPx getMarketPxIntraDayHistoryAt(int tradingDayHour);
    /* nested interface - empowering an Off-Heap hierarchical “TIER of prices”
    as array[ ] value */
    interface MarketPx {
           double getCallPx();
           void setCallPx(double px);
           double getParPx();
           void setParPx(double px);
           double getMaturityPx();
           void setMaturityPx(double px);
           double getBidPx();
           void setBidPx(double px); 
           double getAskPx();
           void setAskPx(double px); 
           String getSymbol();
           void setSymbol(String symbol); 
    }
}

PID 1(在上图的步骤1中,使用接口)调用了一个OpenHFT SharedHashMap工厂,代码可能会像如下所示:

SharedHashMap shm = new SharedHashMapBuilder()
    .generatedValueType(true)
    .entrySize(512)
    .create(
            new File("/dev/shm/myBondPortfolioSHM"),
            String.class,
            BondVOInterface.class
    );
BondVOInterface bondVO = DataValueClasses.newDirectReference(BondVOInterface.class);
shm.acquireUsing("369604103", bondVO);
bondVO.setIssueDate(parseYYYYMMDD("20130915"));
bondVO.setMaturityDate(parseYYYYMMDD( "20140915"));
bondVO.setCoupon(5.0 / 100); // 5.0%
BondVOInterface.MarketPx mpx930 = bondVO.getMarketPxIntraDayHistoryAt(0);
mpx930.setAskPx(109.2);
mpx930.setBidPx(106.9);
BondVOInterface.MarketPx mpx1030 = bondVO.getMarketPxIntraDayHistoryAt(1);
mpx1030.setAskPx(109.7);
mpx1030.setBidPx(107.6);

现在,会发生一些堆 →非堆的魔法。请仔细观察……在本文所带给您的整个旅程中,将要分享给您的“魔法”是旅程中“最美的风景”:

#2.在运行时,每个进程调用上面的OpenHFT工厂方法时,会生成并编译一个BondVOInterface£native 内部实现,它会完全负责必要的字节位置算法(byte addressing arithmetic),从而实现充分完整的非堆abstractAccess() / abstractMutate()操作符集合(通过该接口的getXX()/setXX()方法,这些方法符合Java Bean的方法签名约定)。它们所造成的效果就是OpenHFT在运行时会使用你的接口并将其编译为实现类,这个实现类会作为具体非堆功能的桥梁。数组(array)也是类似的,会使用基于索引的getter和setter。数组的接口也会像外层接口一样。数组的setter和getter方法签名格式为setXxxxAt(int index, Type t); getXxxxAt(int index); (注意,‘At’后缀同时适用于数组的getter/setter签名)。

这是都是在运行时为你生成的,借助于进程中的OpenHFT JIT编译器。你所要做的就是提供接口。非常酷,对吧?

#3. PID 1然后调用OpenHFT的API shm.put(K, V);,从而按照Key (V = BondVOInterface),将数据写入到非堆的SHM中。我们已经跨过了在[2]中所构建的OpenHFT桥。

我们已经实现了非堆!非常有意思吧?:-)

让我们再从PID 2的视角看一下是怎么做到的。

#4. 只要PID 1完成将数据放到非堆SHM之中,PID 2现在就可以调用完全相同的OpenHFT工厂了,如下所示:

SharedHashMap shmB = new SharedHashMapBuilder()
    .generatedValueType(true)
    .entrySize(512)
    .create(
           new File("/dev/shm/myBondPortfolioSHM"),
           String.class,
           BondVOInterface.class
     );

以这样的方式,跨越了OpenHFT构造的连接桥,获得了完全相同的非堆OpenHFT SHM引用。当然,这假设PID 1和PID 2位于相同的本地主机上,共享通用的/dev/shm视图(并且有相同的权限访问同一个/dev/shm/myBondPortfolioSHM文件)。

#5. PID 2然后就可以调用V = shm.get(K);(每次这都会创建一个新的非堆引用),PID 2也可以调用V2 = shm.getUsing(K, V);,后者会重用你所选择的非堆引用(如果K不是Entry的话,会返回NULL)。在OpenHFT API中,其实还有第三个可以供PID 2使用的get 方法签名:V2 = acquireUsing(K,V);,它的区别在于,如果K 不是一个Entry的话,你所得到的并不是NULL,而是会返回一个引用,这个引用指向了一个新创建的非NULLV2占位符。这个引用能够让PID 2在合适的时候操作SHM的非堆V2 Entry

注意:当PID 2调用V = shm.get(K);时,它会返回一个新的非堆引用。这会产生一些垃圾,但是在丢弃它之前,你能够一直持有对这个数据的引用。然而,当PID 2调用V2 = shm.getUsing(K, V); 或者V2 = shm.acquireUsing(K, V);的时候, 非堆引用转移到了新key的位置上,这个操作跟GC是没有关系的,因为在这里你重复利用了自己的东西。

注意:在此时没有出现复制,只是对非堆空间中数据的位置进行了设置和变更。

 BondVOInterface bondVOB = shmB.get("369604103");
   assertEquals(5.0 / 100, bondVOB.getCoupon(), 0.0); 
   BondVOInterface.MarketPx mpx930B = bondVOB.getMarketPxIntraDayHistoryAt(0);
   assertEquals(109.2, mpx930B.getAskPx(), 0.0);
   assertEquals(106.9, mpx930B.getBidPx(), 0.0);
   BondVOInterface.MarketPx mpx1030B = bondVOB.getMarketPxIntraDayHistoryAt(1);
   assertEquals(109.7, mpx1030B.getAskPx(), 0.0);
   assertEquals(107.6, mpx1030B.getBidPx(), 0.0);

#6. 非堆记录是一个引用,它包装了Bytes以用来进行非堆的操作,同时还包装了一个偏移量(offset)。通过对这两者进行变更,内存中的任何区域都能够访问到,就如同它是你所选择的接口那样。当PID 2操作‘shm’引用时,它要设置正确的Bytes和偏移量,这会通过读取存储在/dev/shm文件中的hash map来进行计算。在getUsing()返回后,对于偏移量的计算就会非常简单并且是内联执行的,也就是说,一旦代码被JIT之后,get()和set()方法就会变为简单的机器码指令,以实现对这些域的访问。只有你所访问的域会被读取或写入,真正的零复制(ZERO-COPY)!太漂亮了!

//ZERO-COPY
  // our reusable, mutable off heap reference, generated from the interface.
  BondVOInterface bondZC = DataValueClasses.newDirectReference(BondVOInterface.class);
  // lookup the key and give me my reference to the data if it exists.
  if (shm.getUsing("369604103", bondZC) != null) {
      // found a key and bondZC has been set
      // get directly without touching the rest of the record.
      long _matDate = bondZC.getMaturityDate();
      // write just this field, again we need to assume we are the only writer.
      bondZC.setMaturityDate(parseYYYYMMDD("20440315"));
      //demo of how to do OpenHFT off-heap array[ ] processing
      int tradingHour = 2; //current trading hour intra-day
      BondVOInterface.MarketPx mktPx = bondZC.getMarketPxIntraDayHistoryAt(tradingHour);
      if (mktPx.getCallPx() < 103.50) {
          mktPx.setParPx(100.50);
          mktPx.setAskPx(102.00);
          mktPx.setBidPx(99.00);
          // setMarketPxIntraDayHistoryAt is not needed as we are using zero copy,
          // the original has been changed.
      }
  }
  // bondZC will be full of default values and zero length string the first time. 
  // from this point, all operations are completely record/entry local,
  // no other resource is involved.
  // now perform thread safe operations on my reference
  bondZC.addAtomicMaturityDate(16 * 24 * 3600 * 1000L); //20440331
  bondZC.addAtomicCoupon(-1 * bondZC.getCoupon()); //MT-safe! now a Zero Coupon Bond.
  // say I need to do something more complicated
  // set the Threads getId() to match the process id of the thread.
  AffinitySupport.setThreadId();
  bondZC.busyLockEntry();
  try {
      String str = bondZC.getSymbol();
      if (str.equals("IBM_HY_2044"))
          bondZC.setSymbol("OPENHFT_IG_2044");
  } finally {
      bondZC.unlockEntry();
}

在上面的图中,非常重要的就是要理解完整的OpenHFT 堆 ←→ 非堆转换是如何实现的。

事实上,OpenHFT SHM实现在步骤#6中,在运行时会拦截V2 = shm.getUsing(K, V);调用的第二个参数的内容。实质上,SHM实现是这样查询的

(
  ( arg2 instanceof Byteable ) ?
       ZERO_COPY :
       COPY
)

并且它会以零复制的方式执行(通过引用更新),而不是完全复制(COPY)的方式来执行(通过Externalizable)。

非堆引用功能的核心接口就是Byteable,它使得引用能够被(重新)设置。

public interface Byteable {
     void bytes(Bytes bytes, long offset);
}

如果你要实现自己的支持这个方法的类,那么你尽可以实现或生成自己的Byteable类。

现在,就像我们所提到的那样,你可能依然会想“所有的这一切发生地太神奇了”。这里其实会发生很多的事情以实现这个神奇的功能,并且所有事情的发生都是与外部无关的,也就是发生在正在执行的应用进程之内!如果使用运行时编译器(Run-Time-Compiler)的话,它会将我们的BondVOInterface作为输入,OpenHFT内部会确定接口的源代码并对源码进行编译(同样是在进程内),将其编译为OpenHFT所能理解的实现类。如果你不想让这个类在运行时生成的话,那么可以预先生成这个类,并且在构建阶段进行编译。OpenHFT内部会将这个新生成的实现类加载到运行上下文之中。此时,运行时会物理执行所生成的BondVOInterface£native内部类的方法,这些方法也是生成的,以实现零复制操作符的功能,转换为非堆Bytes[]的记录。这项功能是零复制的,只要你在一个线程内执行了线程安全的操作,它就会对另外的线程可见,即便这个另外的线程可能位于其他的进程之中。.

现在,你已经了解了OpenHFT SHM魔法的本质:Java如今有了真正零复制的IPC。

嘛哩嘛哩哄!

性能结果:CHM与SHM

Linux 13.10,i7-3970X CPU @ 3.50GHz,hex core, 32 GB内存。

SharedHashMap -verbose:gc -Xmx64m

And there you have the essence of the OpenHFT SHM magic: Java now has true ZERO-COPY IPC.

Abra Cadabra!

PERFORMANCE RESULTS: CHM vs.SHM

On Linux 13.10, i7-3970X CPU @ 3.50GHz, hex core, 32 GB of memory.

SharedHashMap -verbose:gc -Xmx64m

ConcurrentHashMap -verbose:gc -Xmx30g

当然,CHM比SHM慢438%的主要原因在于CHM会经历长达21.8秒的STW GC。但是从SLA角度来看,问题产生的原因(对于这个诱因没有补救措施)并不重要。从SLA角度来看,事实上CHM就是要慢438%。从SLA的角度来看,在这个测试中,CHM的性能慢得让人无法接受

适配JSR-107:将SHM作为(100%协作的)非堆的JCACHE操作对象

在2014年的第二季度,Java Community Process发布了JSR-107 EG的Release版本JCACHE——Java缓存的标准API/SPI。JCACHE对于Java缓存社区的作用就像JDBC对于Java RDBMS社区的作用一样。JCACHE的核心和本质在于其基础的缓存操作对象接口:javax.cache.Cache<K,V>。如果你仔细看一下这个Cache APi,就会清楚地看到Cache完全就是Map的一个超集(有一些不太实用的差异)。JCACHE的主要目标之一在于帮助交付一个可扩展的(横向扩展和纵向扩展)解决方案,以解决Java数据的本地化、延迟以及缓存的问题。所以,如果JCACHE的主要操作对象是一个Map,并且JCACHE的核心目标之一在于解决数据本地化/延迟的问题,那么采用OpenHFT的非堆SHM作为JCACHE主要操作对象接口实现会带来多大的好处呢?在很多的Java缓存用例之中,OpenHFT非堆SHM的目标都是非常完美的方案。

我们将会用一点时间(请安坐),在本文中分享一下如何将OpenHFT SHM作为完整的JSR-107非堆JCACHE操作对象。在此之前,我们想要澄清一个事实,那就是javax.cache.Cache接口是java.util.Map接口功能的一个超集。我们需要精确地知道“这个超集有多大”?……这会影响到我们要做多少工作才能100%完整彻底地采用SHM作为实现。

-Cache必须要提供而基本HashMap所没有提供的都有什么呢?

  • 清除(Eviction)、过期(Expiration)
  • 弱引用(WeakRef)、强引用(StrongRef)(其实这与非堆Cache实现无关)
  • 本地化角色(Locality Role)(如Hibernate L2)
  • EntryProcessors
  • ACID事务
  • 事件监听(Event Listener)
  • “Read Through”操作(同步/异步)
  • “Write Behind”操作(同步/异步)
  • JGRID相关的功能(JSR-347
  • JPA相关的功能

- OpenHFT+Infinispan的“婚礼日” 计划 (JCACHE的庆典)

下图展现了社区驱动的OpenHFT编程人员在采用/贡献OpenHFT非堆SHM作为完整的JSR-107协作JCACHE操作对象时所需要的很少范围的开发工作(社区驱动的开源JCACHE提供商=RedHatInfinispan)。

(点击图片放大)

结论:非堆HashMap的现在和未来……“直到奶牛不干了,回家的那一天”

在这个旅程接近“最后一站”的时候,我们用一个类比的故事来向你告别,并解答你所关心的问题。

社区驱动的开源非堆HashMap提供商以及JCACHE提供厂商(包括商业的和开源的)之间的业务关系可以是和谐且互相协作的。在为终端用户提供更为愉悦的非堆体验方面,它们中的每一个都扮演着重要的角色。非堆HashMap提供者可以交付核心的非堆HashMap(作为JCACHE的)操作对象。JCACHE厂商(包括商业的和开源的)可以采纳这个操作对象到他们的产品之中,然后提供核心的JCACHE(和基础设施)。

这种关系就类似于奶牛(也可以说是乳业农场主,核心操作对象即牛奶的生产者)与奶制品公司(牛奶操作的生产者,操作集合={巴氏杀菌、脱脂、1%、2%、各占一半等等})之间的关系。这两个组合(奶牛和乳业公司)结合起来能够生产出终端用户更为喜欢的产品,这要优于两者(奶牛和乳业公司)不进行合作的场景。终端用户对这两者都需要。

但是要给终端用户一个“购买者注意!”的提示:

如果有人遇到商业厂商有志于交付闭源的HashMap/Cache解决方案,并且宣称他们闭源的非堆操作对象要“优于”开源社区驱动的方式,那么,只需要记住这一点:

乳业公司并不制造牛奶。只有奶牛才会制造牛奶。

奶牛会一直生产牛奶,24/7,并且完全没有其他的干扰。乳业公司能够让牛奶更加美味(各占一半、2%、1%、脱脂)……所以,他们确实有机会扮演重要的角色……但是他们并不生产牛奶。现在,开源的“奶牛”正在生产非堆HashMap这种“牛奶”。如果商业解决方案厂商认为他们制造的那种牛奶更加美味,那么尽可以去做,这样的努力是所有人都欢迎的。但是,这些供应商不应该宣称他们自己的牛奶是“更好”的牛奶。只有奶牛才会生产最好的牛奶。

总之,考虑到Java为高性能计算社区所带来的改变是很令人兴奋的。事情确实有了很多的变化,而且所有的变化都是往更好的方向发展。

并发包之中,从不断改善的现代GC方案之中,从非阻塞I/O功能之中,从Sockets Direct Protocol的原生RDMA,JVM intrinsics之中,……,再到原生的Caching、OpenHFT的SHM作为原生的IPC通信方式以及该OpenJDK非堆JEP所呼吁的机器级别HTM辅助功能(machine level HTM-assist feature),有一件事是确定的:OpenJDK平台社区在提升性能方面确实有着很高的优先级。

来看一下HashMap这个可爱的家伙现在能够做些什么吧!借助于OpenJDK、OpenHFT和Linux,非堆HashMap在“较低的位置”(也就是原生OS)有了新朋友。

现在不会受到STW GC的任何干扰了,HashMap作为重要的HPC数据结构操作对象,获得了重生。HashMap,保持永远年青吧!

感谢你们陪伴我们的旅程,希望你喜欢这个经历。下次再见。

关于作者

Peter K. Lawrey是Higher Frequency Trading Ltd.的首席咨询顾问,以及OpenHFT项目的领导者。他是Java Community Process的成员,目前在活跃的JCP专家组参与定义分布式数据网格(Distributed Data Grids,JSR-347)的Java标准API。他是具有500个成员的Performance Java Users’ Group的创建者(目前该Google群组已经有1500多位成员——译者注),以及技术博客“Vanilla Java”(230篇文章,3百万的站点点击)的作者。Peter毕业于Melbourne University,拿到了两个学位,分别是计算机科学和电气工程。Peter在StackOverflow的问答响应排名中位居前三。最近的五年来,他致力于开发、支持以及提供咨询,并在欧洲和美国东部为高频率的交易系统提供培训。

 

Ben D. Cotton III 是J.P.Morgan Chase & Co.的IT咨询顾问,目前在UHPC Linux超级计算机上使用Java数据网格技术,以判断和计算实时的流动资产风险。Ben毕业于Rutgers University,获得了计算机科学的学位。在职业生涯的最初11年中,他在AT&T贝尔实验室工作,编写ellMac32-ASM/C/C++代码来支持无数的专用通信、网络分析以及提供协议,最近的14年中他在编写Java代码,实现低延迟、高吞吐、事务性、固定收益、金融衍生物、电子交易、清算、定价以及风险控制的系统。和Peter一样,Ben也是JSR-347 EG的成员。

 

查看英文原文:OpenJDK and HashMap …. Safely Teaching an Old Dog New (Off-Heap!) Tricks

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT