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平台的优先级产生很大的差异:
本文力图(以一种通俗和温和的方式)让所有感兴趣的Java开发人员都能有所收获。作者希望即使是新手也能完整地享受本文所带来的这段旅程,尽管在路途上可能会有一些不熟悉的“坑坑洼洼”,但是不要气馁——希望您在位置上安坐直到文章结束。本文会提供一个有关历史问题的上下文,这样你会对下面的问题具备足够的背景知识:
那么,让我们开始这段旅程吧。需要记住的一点是在Java之前,哈希表(hash table)是在原生内存堆中实现的,比如说在C和C++中。在一定程度上可以说,重新介绍非堆存储是“老调重弹”,这是大多数当前的开发人员所不知道的。在许多方面可以说,这是一趟“回到未来”的旅行,因此享受这个过程吧!
针对非堆JEP,已经有了几个提议(submission)。下面的样例展现了支持非堆内存的最小需求。其他的提议尝试提供sun.misc.Unsafe的替代品,这个类是目前的非堆功能所需要的。它们还包含了很多其他有用和有趣的功能。
JEP概述: 创建sun.misc.Unsafe部分功能的替代品,这样就没有必要再去直接使用这个库了。
目标: 移除对内部类的访问。
非目标: 不支持废弃(deprecated)的方法,也不支持Unsafe尚未实现的方法。
成功指标: 实现与Unsafe和 FileDispatcherImpl 相同的核心功能,并且性能方面要与之保持一致。
驱动力: 目前来讲,Unsafe是构建大规模、线程安全的非堆数据结构的唯一方法。在如下的领域,这种方式会很有用,如最小化GC的影响、跨进程共享内存以及在不使用C和JNI的情况下实现嵌入式数据库,因为使用C和JNI的话,可能会更慢并且更加困难。FileDispatcherImpl目前需要将内存映射为任意的大小。(标准API限制为小于2GB。)
描述: 为非堆内存提供一个包装类(类似于 ByteBuffer ),但是具有如下的功能增强。
要保留的核心功能:
替代方案: 直接使用sun.misc.Unsafe。
测试: 测试需求应该与目前的sun.misc.Unsafe和内存映射文件相同。还需要额外的测试来证明它与AtomicXxxx类一致的线程安全操作。AtomicXxxx类可以使用这个公开API进行重写。
风险: 有很多的开发人员在使用Unsafe,他们可能并不认同合适的替代方案是什么。这意味着这个JEP的范围可能会扩大,或者会创建新的JEP来涵盖Unsafe中的其他功能。
其他JDK: NIO
兼容性: 需要保持向后兼容的库。这可以针对Java 7实现,如果有足够兴趣的话,也可以支持Java 6。(当撰写本文的时候,当前的版本是Java 7)
安全性 :理想情况下,安全性的风险不应该超过当前的ByteBuffer。
性能和可扩展性 :优化边界检查会比较困难。可能需要为这个新的缓冲区添加更多的功能,通过通用的操作来减少损耗,如writeUTF、readUTF。
“哈希码(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, NegaMax 1989, AKS Primality 2002, Map-Reduce 2006, Grover Quantum搜索 - 2011)却是发展迅速,为计算机基础领域提供了新鲜和强大的运算符。 但是在2014年,数据结构领域可能再次会有一些重大的进展。在OpenJDK平台方面, 非堆 的 HashMap 是一个正在不断发展的数据结构。 关于HashMap的历史,我们已经介绍了很多的内容。现在,我们开始探索一下如今的HashMap,尤其是看一下在Java中,HashMap当前的三个变种。 |
N. Wirth 1934- |
在真正的多线程(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的一级缓存。所以,在写入操作上的同步只能避免 写入 操作的冲突。要满足所有线程的内存屏障操作,你 必须还要 同步读取。
要使用“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能够带来什么好处 。
那么,现在你可能会问:有了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非堆生命周期之前,让我们看一下有关堆的细节,这些细节描述了它的不便之处。
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能够解决的一个问题。
让我们近距离地看一下。
垃圾收集使得编写程序容易了许多,但是当面临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来说,如果想对 高性能计算社区 保持持续的吸引力,这也是一个完全必要的方案。
“非堆”到底是什么?
在下面的图中,阐述了两个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):
这是在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 工厂,代码可能会像如下所示:
SharedHashMapshm = 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工厂了,如下所示:
SharedHashMapshmB = 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 ,而是会 返回一个引用 ,这个引用指向了一个新创建的 非NULL 的 V2 占位符。这个引用能够让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的性能慢得 让人无法接受 。
在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所没有提供的都有什么呢?
- OpenHFT+Infinispan的“婚礼日” 计划 (JCACHE的庆典)
下图展现了社区驱动的OpenHFT编程人员在采用/贡献OpenHFT非堆SHM作为完整的JSR-107协作JCACHE操作对象时所需要的很少范围的开发工作(社区驱动的开源JCACHE提供商= RedHat Infinispan )。
(点击图片放大)
在这个旅程接近“最后一站”的时候,我们用一个类比的故事来向你告别,并解答你所关心的问题。
社区驱动的开源非堆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