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

8e0139e8df3528a4460d7e19ca48ee55.pngOpenJDK的非堆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包\\t
  2. 提倡使用新的API包在非堆的原生内存操作对象上直接进行高性能的原生内存操作。\\t
  3. (通过新的API)提供外部功能接口(Foreign Function Interface,FFI)来桥接Java与操作系统资源(Operating System resource)和系统调用(system call)。\\t
  4. 允许Java运行时借助硬件事务内存(Hardware Transactional Memory)提供者的foci,将低并发的字节码重写为高并发性的speculatively branched机器码。\\t
  5. 移除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

a4149a858e409435fbc687f1388dad33.png

\\\t\t\t

H.P. Luhn (1896-1964)

\\t\t\t
\\t\t\t

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

\\\t\t\t

Luhn写到“关联数组(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

46ae760131123a88721232c807917832.png

\\\t\t\t

N. Wirth 1934-

\\t\t\t

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

\\

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

\\

7975ffa7705812220a3207b2b2b7b574.jpg会发生失败的简单假设场景:

\\

- 同步写入

\\

- 非同步读取

\\

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

\\

e3861ffc4802c6af6bb74d857a820c31.png

\\

这是通过在所有的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. 锁分片1d173fe960585554ce4384da0011990b.png\\t
  2. 对于java.util.HashMap中独立的子集有一个锁的集合:N个hash桶/N个分段(Segment)锁。(右侧的图中,Segments=3)\\t
  3. 如果在设计时,想要将高度竞争的锁重构为多个锁,而又不损害数据完整性时,锁分段是非常有用的。\\t
  4. 对于“检查并执行(check-then-act)”的竞态条件问题,它能够提供并发性更好且非同步的解决方案。\\t
  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(及其所有的元素)造成的,这是一种痛苦的体验。这种体验和问题是高性能计算社区所无法忍受的。

\\

1f1db4a1bbdef9dedfd94edfbc823e6d.png在高性能计算社区完全拥抱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堆的布局:按照分代的视角

\\

8fc2575f0fce6a1b8d36d976058edebb.png

\\

垃圾收集使得编写程序容易了许多,但是当面临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开发人员都能编写这样的代码。\\t
  2. 安全,不必担心内存访问问题。\\t
  3. 自动化的GC服务——没有必要自己去管理malloc()/free()操作。\\t
  4. 对Java锁API和JMM的集成都完全不必再担心。\\t
  5. 没有序列化/复制的数据要添加到结构体之中。\

非堆的优势

\\
  1. 能够将“Stop The World” GC事件控制到你认为合适的级别。\\t
  2. 在扩展性方面(当使用堆所造成的影响足够高的时候)要强于堆上的结构。\\t
  3. 可以用做原生的IPC传输手段(不会有java.net.Socket的IP回路)。\\t
  4. 在分配方法上的考虑因素:\\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):

\\
  1. 文本段(Text,低地址……代码执行的地方)\\t
  2. 数据(Data,通过sbrk(2)实现从低地址到高地址的增长)\\t
  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上。这些操作对象存在于核心地址空间上。

\\

772c588efa60f459cd87f95063fce9f9.png

\\

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

\\

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

\\

来自于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\
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值