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平台的优先级产生很大的差异:
\\- 将sun.misc.Unsafe中有用的部分重构为一个新的API包\\t
- 提倡使用新的API包在非堆的原生内存操作对象上直接进行高性能的原生内存操作。\\t
- (通过新的API)提供外部功能接口(Foreign Function Interface,FFI)来桥接Java与操作系统资源(Operating System resource)和系统调用(system call)。\\t
- 允许Java运行时借助硬件事务内存(Hardware Transactional Memory)提供者的foci,将低并发的字节码重写为高并发性的speculatively branched机器码。\\t
- 移除FUD(坦率的说,这是一种技术上的偏执),它与使用非堆编程策略来实现Java性能的提升有关。最终,基本明确的是这个JEP要求OpenJDK平台要开放性地将其纳为主流,它曾经被视为黑暗的工艺、非堆参与者的秘密组织。\
本文力图(以一种通俗和温和的方式)让所有感兴趣的Java开发人员都能有所收获。作者希望即使是新手也能完整地享受本文所带来的这段旅程,尽管在路途上可能会有一些不熟悉的“坑坑洼洼”,但是不要气馁——希望您在位置上安坐直到文章结束。本文会提供一个有关历史问题的上下文,这样你会对下面的问题具备足够的背景知识:
\\- 堆HashMap的问题是怎么产生的?\\t
- 为了应对这些问题,历史上所给出方案的成功/失败之处是什么?\\t
- 在堆HashMap的使用场景中,依然存在的未解决问题是什么?\\t
- 新JEP所提供的功能能够带来什么助益(也就是将HashMap变为非堆的)?\\t
- 对于非堆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位的大小和偏移。\\t
- 线程安全结构,如volatile和顺序访问、比较和交换(compare and swap,CAS)操作。\\t
- JVM优化的边界检查,或开发人员控制边界检查。(提供的安全设置允许这样做)\\t
- 在一个缓冲区中,能够为不同的记录重用部分缓冲区。\\t
- 能够将非堆的数据结构映射到这样一个缓冲区之中,在这个过程中,边界检查已经被优化掉了。\
要保留的核心功能:
\\- 支持内存映射文件\\t
- 支持NIO\\t
- 支持将写操作提交到磁盘上。\
替代方案:直接使用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%完整的(单词、页集)索引,最优的算法和数据结构是什么样的?”
\\\\t\t\t\\\t\t\t H.P. Luhn (1896-1964) \\t\t\t | \\t\t\t Luhn写到“hashcode”是基本的运算符(operator)。 \\\t\t\tLuhn写到“关联数组(Associative Array)”是基本的运算对象(operand)。 \\\t\t\t术语“HashMap”(亦称为HashTable)逐渐形成了。 \\\t\t\t注意:HashMap这个词源自出生于1896年的计算机科学家。HashMap真的是个老家伙了! \\t\t\t |
让我们将HashMap的故事从它的起始阶段转移到早期的实际使用阶段,也就是从1950年代中期跳到1970年代中期。
\\\\t\t\t 在其1976年写成的经典著作《算法+数据结构=程序》之中, Niklaus Wirth讨论了“算法”,将其视为基本的“运算符”,并将“数据结构”视为基本的 “运算对象”,对于所有的计算机程序来讲这都是适用的。 \\\t\t\t从那时开始,数据结构领域(HashMap、堆等)的进步是很缓慢的。在1987年,我们确实也看到了Tarjan非常重要的F-Heap突破,但是除此之外,在运算对象方面确实乏善可陈。当然需要记住的是,HashMap最早出现于1953年,已经有超过六十年的历史了! \\\t\t\t然而,在算法社区(Karmakar 1984,NegaMax1989,AKS Primality 2002,Map-Reduce 2006,Grover Quantum搜索 - 2011)却是发展迅速,为计算机基础领域提供了新鲜和强大的运算符。 \\\t\t\t但是在2014年,数据结构领域可能再次会有一些重大的进展。在OpenJDK平台方面,非堆的 HashMap是一个正在不断发展的数据结构。 \\\t\t\t关于HashMap的历史,我们已经介绍了很多的内容。现在,我们开始探索一下如今的HashMap,尤其是看一下在Java中,HashMap当前的三个变种。 \\t\t\t | \\t\t\t\\\t\t\t N. Wirth 1934- \\t\t\t |
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能够带来什么好处。
\\- 锁分片\\t
- 对于java.util.HashMap中独立的子集有一个锁的集合:N个hash桶/N个分段(Segment)锁。(右侧的图中,Segments=3)\\t
- 如果在设计时,想要将高度竞争的锁重构为多个锁,而又不损害数据完整性时,锁分段是非常有用的。\\t
- 对于“检查并执行(check-then-act)”的竞态条件问题,它能够提供并发性更好且非同步的解决方案。\\t
- 问题:该如何同时保护整个集合?(递归)获取所有的锁?\
那么,现在你可能会问:有了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来说,如果想对高性能计算社区保持持续的吸引力,这也是一个完全必要的方案。
\\堆的优势
\\- 以熟悉的方式,很自然地编写Java代码。所有有经验的Java开发人员都能编写这样的代码。\\t
- 安全,不必担心内存访问问题。\\t
- 自动化的GC服务——没有必要自己去管理malloc()/free()操作。\\t
- 对Java锁API和JMM的集成都完全不必再担心。\\t
- 没有序列化/复制的数据要添加到结构体之中。\
非堆的优势
\\- 能够将“Stop The World” GC事件控制到你认为合适的级别。\\t
- 在扩展性方面(当使用堆所造成的影响足够高的时候)要强于堆上的结构。\\t
- 可以用做原生的IPC传输手段(不会有java.net.Socket的IP回路)。\\t
- 在分配方法上的考虑因素:\\t
- 使用NIO DirectByteBuffer,实现到/dev/shm (tmpfs)的映射?\\t\t
- 或者直接使用sun.misc.Unsafe.malloc()?\\t
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):
\\- 文本段(Text,低地址……代码执行的地方)\\t
- 数据(Data,通过sbrk(2)实现从低地址到高地址的增长)\\t
- 栈(从高地址向低地址增长)\
这是在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\u0026lt;String,BondVOInterface\u0026gt;,这种方式会着重强调非堆的优势。
\\ \\\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\