单机100万连接,每秒10万次请求服务端的设计与实现(二) - 高性能,低内存,线程安全,GC友好的HashMap

需求定义

其他库未必,但Java的ConcurrentHashMap一直是一个神一般的存在。我不可能写出一个,比ConcurrentHashMap更好的,能够像ConcurrentHashMap一样满足普适性需求的HashMap。因此,本HashMap满足的是一个更加狭隘的需求。使用时也可能会有更多的约束。其使用场景如下:

1 使用long(64位) 或 int(32位)作为key,不支持null key,不支持null value。
2 Value对象中,有key字段,可以从value对象中读到key值,对象处于HashMap中的时候,不允许改变其key。

定义了一个简单的接口描述此类对象


public interface I64Obj {
	long getId();
}

开发目标在前文中已经提过,这里再简单总结一下:
1 规避HashEntry/Node的大量创建和回收
2 提升GC友好性。
3 比系统自带的HashMap/ConcurrentHashMap更低的内存占用。
4 正常使用场景中,不低于HashMap性能的非线程安全实现和不低于ConcurrentHashMap性能的线程安全实现

再起个名字,ConcurrentI64HashMap,可以动手了。

填坑思路

师夷长技以制夷,先看看备受好评的ConcurrentHashMap是怎么实现的。我先读了下Java1.8下,ConcurrentHashMap的代码,发现在1.8下,该map相对于之前版本做了非常大的改动,看上去是完全重写了一遍。其和老版本最大的不同就是引入了红黑树来应对多数据hash冲突的状况(对应的,也导致了Node节点变得更加复杂,占用更多内存),在本HashMap的使用场景中,整形id应该会尽可能规避掉hash值严重冲突的情况(后续会慢慢提到)。因此,我找出了老版的ConcurrentHashMap源码来做主体参考;而用Java1.8的源码,做一些线程安全方面的借鉴。老版的ConcurrentHashMap主要有以下的几个核心思路(可参考文章,探索 ConcurrentHashMap 高并发性的实现机制, 文章虽然有些年头了,但Java 1.8之前的主要实现方式并没有大改变)

1 分段使用了多个锁,ConcurrentHashMap对key的hash值进行了分段,缺省使用了16个继承于 ReentrantLock的Segment 类,理想情况下,每个Segment负责1/16 存储对象的锁定。很大程度上降低了锁争用。
2 用volatile变量,来保证读线程(get()请求)能够及时看到写线程的修改。
3 通过对HashEntry的巧妙使用来降低读操作对锁的占用,使得大部分读操作都无需锁定。

第一步,砍内存

在Java1.8之前的HashMap实现中,每个key-value对,用了一个HashEntry来封装,HashEntry的内容大致如下:


final K key;                      
final int hash;            
volatile V value;       
final HashEntry<K,V> next;  

(在Java1.8版的实现中,使用了一个Node来封装,为了支持红黑树,修改了最后一个next的定义,去掉了final修饰,加上了volatile,内存占用是一样的)

在64字节指针压缩的环境中(本篇文章所有的内存占用分析均按此环境,不再赘述),每个HashEntry的对象头是 8+4 = 12字节,三个引用加一个int是4*3 + 4 = 16字节,加上4字节padding,共32字节。
由于使用了一个Long对象来作为key,这个Long对象需要至少也是12字节对象头+8字节数据 + 4字节padding = 24字节。也就是说,每放一个<Long, Value>的kv对进入Map,会需要占用约40字节的空间。

重新看看需求,第一点便是减少不必要的HashEntry/Node的创建,Java自带的HashMap都用了一个数组来作为最基础的,指向HashEntry/Node的数据结构,如上所述,HashEntry/Node封装了key,hash,value,next(Hash冲突时指向下一个HashEntry/Node的ref);

而在ConcurrentI64HashMap中,由于Value对象本身保存有自身的key,存储结构中,对HashEntry/Node的需求可以大幅下降:

1,在不发生Hash冲突时,不使用HashEntry/Node,将数组直接指向Value
2, 发生冲突时,采用类似传统Map的链表结构,但尾部最后一个Value,也无需用Node封装。

示意如下:

baseArray数组
Value
value
next
baseArray数组
I64MapNode-1
Value-1
Value
value
next
value
next
value
next
baseArray数组
I64MapNode-1
value-1
... ...
Value ...
I64MapNode-n
Value-n
Value

其中,MapNode定义和存储结构的示意如下:


	static final class I64MapNode<T extends I64Obj> implements I64Obj{
		T value;				  //在Map中时,不为Null
		volatile I64Obj next; //在Map中时,不为Null
		
		@Override
		public long getId() {
			return value.getId();
		}
	}
	

当Hash冲突很少时,直接从基础数组,指向Value,新增加一个Value,不需要任何附加的Node对象,比自带的HashMap节省了约40字节内存。假定loadFactor = 0.5,Hash冲突不出现极端情况,和Java自带的HashMap相比较,同等内存占用的情况下,loadFactor至少可以降到小于0.1。低 loadFactor 又可以进一步降低ConcurrentI64HashMap中Hash冲突的概率。

而即存在一定的Hash冲突,如果把链表中的最后一个Value不用Node封装,以及每个MapNode也少用的一个Key对象节省下来的内存作为基础数组使用,也可大幅的降低loadFactor及链表长度。ConcurrentI64HashMap 的 loadFactor 仍可以轻松降低到0.25或更低的水平,却仍只需要比Java自带的Map更低的内存空间。

简单估计一下,在绝大部分使用环境下,ConcurrentI64HashMap应该都可以获得比Java 1.8 之前版本的ConcurrentHashMap更好的性能;排除在loadFactor已经较小的情况下,依然发生严重Hash冲突的这种几乎不存在的极端情况,ConcurrentI64HashMap应可以获得比使用了红黑树的Java1.8 ConcurrentHashMap更好的性能。这里是个非严谨的理论估计,具体的性能优化,详见后文。

第二步,GC友好

在上文,谈到高性能服务器GC三原则的时候,系统自带的HashMap有两大问题,比较直观的是创造了大量的需要回收的对象,相对隐藏的是老年代的可回收对象问题,在一个key-value对放入HashMap中后存活了好几个小时之后,在大部分GC策略中,对应的Key对象,HashEntry/Node对象,都已经移动到了老年代。这时,如果将Value remove出Hashmap, 对应的Key对象和HashEntry/Node对象,基本不会在轻量级的GC过程中被回收,要等到下一次FULL GC才会回收。
虽然,参考上一步的分析,ConcurrentI64HashMap 用了比Java的HashMap少的多的HashEntry/Node对象,带来的垃圾回收问题没有那么严重,但既然写到这里了,就随手写个小对象池,重用一下I64MapNode对象吧。Put和Remove是肯定要加锁的,对象池也就很简单了。流程如下

put时

如果需要I64MapNode,去一个空闲的链表中尝试获取Node,
如果有,拿出来用,如果没有,new一个新的Node用

remove时

如果remove时释放了一个I64MapNode,把它加到空闲链表的头部。

由于I64MapNode本身就定义了一个next引用,当node被移出Map的时候,直接用这个next引用来构建链表就可以了,非常便捷,随手贴几行代码。

put 时


if(unusedNodeChains[lockIdx] != null) {  			//每个锁,都对应有一个I64MapNode对象池
	node = unusedNodeChains[lockIdx];
	unusedNodeChains[lockIdx] = (I64MapNode<T>) node.next;
	… …
} else {
	node = new I64MapNode<T>(……, ……);
}

remove 时


node.next = unusedNodeChains[lockIdx];
unusedNodeChains[lockIdx] = node;

代码简单的一塌糊涂,自己看吧。

第三步,锁

锁是个性能的大坑,参考一篇网上被多次引用的文章,Latency Numbers Every Programmer Should Know
,一次加锁解锁的过程,需要25ns * 2 的时间,而在实际的多核多线程环境中,跨线程加锁并修改数据的过程,通常还包含有不可避免的内存同步过程,很多时候还需加上几十纳秒的内存读写,一次操作需要花掉100多纳秒的时间。

参考绝大部分线程安全的HashMap的实现,加锁,或类似加锁的CAS操作,基本上是不可避免的,也无需太早去考虑传说中Java 1.8之后 synchronized 的性能已经不比 ReentrantLock差这种细枝末节的问题,从大思路上,put和remove就是要加锁的,大家都加锁,你不加锁十有八九是跳坑。但get可就不同了,家家都有不加锁get的套路,如果写了个加锁的get的出来,肯定是满足不了需求分析第4点的性能约束了。

前文给出了《探索 ConcurrentHashMap 高并发性的实现机制》这篇文章的链接,如果读过,那么会发现,这篇文章里面有一节,叫做用 HashEntery 对象的不变性来降低读操作对加锁的需求,里面提到,“HashEntry 中的 key,hash,next 都声明为 final 型。这意味着,不能把节点添加到链接的中间和尾部,也不能在链接的中间和尾部删除节点。这个特性可以保证:在访问某个节点时,这个节点之后的链接不会被改变。”

这篇分析ConcurrentHashMap的文章没有写也没必要写的是,这里还隐含利用了垃圾回收特性保证了HashEntry的可访问性,如果有其他线程在访问某个节点(有引用指向),那么这个节点和它之后的节点就不会被垃圾回收,这个节点之后的链接就可以一直访问下去。

而我写的ConcurrentI64HashMap中,不但value和next不是final,可以被改变,保证不了后续链接的可访问性; 更夸张的是,I64MapNode 还是可重用的,说不定在某线程访问某个I64MapNode时,这个I64MapNode不但已经没保存原有的链表指向,甚至已经被搬到Map中另外一个链表上去,包含并指向了另外的对象。

一个小坑,看起来不麻烦,但填好还是要想想的。

首先,由于ConcurrentI64HashMap低loadFactor的特性,理论上hash应该分布相对均匀,也就是说,大部分节点的数据结构都是类似

数组
Value

的形式,在这种情况下,如无Hash冲突,get请求只需几行无需锁定的代码即可返回。


public final T get(long key) {
	int p = position(key);  //key hash 在数组中的位置
	I64Obj cur;
	if((cur = baseArray[p]) == null) return null;
	//baseArray[p]随时可能被其他线程修改,这里必须先获得baseArray[p] 的引用,再对该引用指向的对象,进行后续的判断/处理
	
	if(cur.getClass() != I64MapNode.class) {  //不是MapNode,是Value对象,比较key,根据结果返回
		if(key == cur.getId()) return (T)cur;
	} else {
		…… ……  //有hash冲突,使用了I64MapNode链表的情况,略
	}
	return null;
}

如果baseArray[p]是一个MapNode,事情就会复杂起来,由于Node的value和next都可能被put或remove操作修改,由于指令重排序问题,get线程不一定能够正确的,按照顺序读出value和next的值。
但既然是指令重排序和内存同步的问题,那么就先用volatile修饰符解决这两个问题。再贴一遍I64MapNode的声明:


static final class I64MapNode<T extends I64Obj> implements I64Obj{
	T value;				  //在Map中时,不为Null
	volatile I64Obj next; //在Map中时,不为Null
	…  …
}

(如果不了解volatile,happens-before以及指令重排序,可先阅读并发编程之 Java 内存模型 + volatile 关键字 + Happen-Before 规则

这里,将next加上volatile,并且对get/put/remove操作加上以下约束
1 所有的put 和 remove操作,都先修改value的值,再修改next的值,而get操作,先读取next的值,再读取value的值。从而保证get读取到的next更新一定晚于value的更新。
2 所有的I64MapNode节点,只能从baseArray[p]指向的链表头插入,如下图。

新I64MapNode只能从这里插入
baseArray数组
I64MapNode
... ...
I64MapNode
Value

3 remove节点时,如需移除I64MapNode节点,先构建好新的链表,再对移除节点进行处理(主要是性能优化,并不涉及线程安全)

这样,在用volatile修饰next并遵循约束1后,当get线程读取一个位于baseArray[p]的链表中的I64MapNode时,可能存在以下几种情况。

A 无其他线程修改,正常访问

B 节点正在被移除/放入回收池,分为以下两种情况
B0 节点的value已经被置为null,且被get线程读到,next尚未被更新
B1 节点在回收对象池中,next指向了一个空闲的I64MapNode对象,此时node.value== null (根据happes-before规则,肯定已经更新)

C 节点在被移除放入回收池后,又重新被使用,分为以下情况:
C10 加到了相同的链表的头部,value已经更新并被get线程读取,但next的更新未读到。如果新的value的key正好匹配,返回。
C11 如果加到了相同的链表的头部,新的value的key不匹配,读取到的node.next指向的还是原链表位置(比较罕见),可继续遍历。
C12 如果加到了相同的链表的头部,新的value的key不匹配,读取到的node.next指向回收池内对象(next.value为空)
C13 如果加到了相同的链表的头部,新的value的key不匹配,读取到的node.next指向其他对象(next.value不为空,但next.value.getId()的hash值位置与p不匹配)
C2 加到了其他链表,value已经更新,但value.getId()的hash值,位置与p不匹配

以上的描述可能略有点复杂,其中,B0,B1,C11,C12,C13,C2 六种情况下,get的链表遍历都失败了。总结一下,当同时满足以下四个必须条件时,可以认为当前遍历到的节点 node 是有效的,可以继续遍历下去。


node.value!= null;
position(node.value.getId()) == p  / /position是计算 baseArray[p] 中位置p的函数
node.next != null;
(node.next.getClass() != IMapNode.class && position(node.next.getId())== p)  
	|| (node.next.value!= null && position(node.next.value.getId()) == p)
//node.next 不再是节点,是hash位置正确的value对象,到了链表尾部 ; 或者 node.next指向的下一个节点的value合法,且hash位置正确.

不满足以上条件,说明遍历因为Node节点被其他线程修改而失败

在这一次get的请求失败后。从性能角度考虑,可以先尝试着从baseArray[p]重新进行一次遍历,在较高loadFactor(同时mapSize的绝对值也较大),十来个线程循环争用的情况下进行测试,一般重新遍历一次,即可成功遍历,未在测试环境中观察到需要重新遍历两次才能完成遍历的情况。

于是,暂时将代码设置成尝试两次乐观遍历,如果均失效,那么加锁,再尝试get

性能测试

测试环境

CPU I9-7900X@3.3GHz Max 4.3GHz, 4.5GHz w/Turbo Boost Max 3.0,
32k/32k L1 Cache(per core),1M L2 Cache(per core), 13.5M L3 Cache(1.35M/Core), 10Cores 20Threads.

32G DDR4 2133Mhz, ASUS X299 Mainboard

Ubuntu 18.04

Oracle JDK 1.8.0_171

测试方式

以下是Java1.8 自带的ConcurrentHashMap 和 ConcurrentI64HashMap的性能比较,其中,基准的loadFactor后者是前者的1/2(实际使用时可以更小)
ConcurrentHashMap 使用Long型的key,Value与ConcurrentI64HashMap一样,用一个只包含long id的I64Obj。
Map的大小设置为 1024 * 1024 * 2 (ConcurrentHashMap)和 1024 * 1024 * 4(ConcurrentI64HashMap),两次测试的总对象数分别为 1024 * 1024 * 2 和 1024 * 1024 ,Map中的对象数约为总对象数的一半,预期 loadFactor 分别为 0.5/0.25 和 0.25/0.125
由于Java自带的ConcurrentHashMap在高频Put/Remove时创建了大量的临时对象,频繁触发GC,因此,本测试将-Xms 和-Xmx都设置到了16384M,在16G内存下通过-XX:+PrintGCDetails打印日志观察,两个Map在性能测试环节,都没有GC过程,数据具有可比性。
测试分别在单线程无争用,和8线程密集争用的条件下进行,单次Get/Put/Remove操作平均耗时见下:

测试结果

-ConcurrentHashMapConcurrentI64HashMapConcurrentHashMapConcurrentI64HashMap
LoadFactor0.50.250.250.125
1线程 Get/Put/Remove45/106/87(ns)33/80/73(ns)45/103/86(ns)16/54/58(ns)
8线程 Get/Put/Remove76/150/129(ns)33/143/122(ns)56/211/183(ns)22/126/107(ns)

可以看到,在内存消耗远小于ConcurrentHashMap的情况下,ConcurrentI64HashMap 在Get/Put/Remove三项上,都获得了优于Java自带的ConcurrentHashMap更好的数据,而在loadFactor 同为0.25的情况下,ConcurrentI64HashMap依然具有优势。满足了需求分析中3、4两点。

在测试中也可以明显看到,只要内存不是非常充足,ConcurrentHashMap在高频Put/Remove的情况下,频繁触发GC,而ConcurrentI64HashMap的内存消耗平稳。满足了需求2。至此,需求分析中提出的4点已经全部解决,收工。

关于性能的一些碎碎念

永远不要把这种简单循环下的性能指标当作实际环境下的性能

编译器可能会趁你不注意优化掉一些代码(完全不执行)。
测试环境的CPU缓存命中率会远高于实际环境,现实中一次缓存不命中,可能就要额外消耗100ns,是前面get耗时的10倍。

因此,这类性能测试,只能用来做一些定性的比较,或是对某类操作的耗时做个估测,仅此而已。

对底层可能的实现方式保持敏感

以上的代码中,有一行代码是这样的


if(cur.getClass() != I64MapNode.class) { … … }

在写下这行代码之前,我犹豫过其性能,也想过instanceof,但这两个念头在脑海中仅是一闪而过。我们知道,每个Java对象,在其对象头中,都有指向其Class的引用,那么 getClass() 操作应返回这个引用,而I64MapNode.class 也应是这个引用。对这两个引用做一个 == 判断,应该是个很快的操作,而且大部分情况下应该能够命中缓冲区,不触发附加的内存访问。

用同样的方式来思考instanceof,这应该是一个需要查找类继承树的操作,由于Java多classloader的可能性,动态加载class的灵活性,这个类继承树的查找很难优化的非常快捷。如果判断的正好是 I64MapNode的实例 instanceof I64MapNode 可能能够很快返回,但如果判断的是复杂类继承结构的value instanceof I64MapNode,性能肯定就不尽如人意了。

这时,如果I64MapNode有final修饰符,在jvm优化了这部分的情况下,? instanceof I64MapNode 应该能够和 ?.getClass() == I64MapNode.class 两个判断一样快,而如果jvm没优化这部分,那性能很可能依然不咋样。有兴趣的读者可以自行去测试下。这里仅抛砖引玉。

新建一个小对象通常比从对象池中获取更快

当一个Java线程需要新建一个对象时,假定由类创建对象的先决条件都已经满足,那么,线程将会首先尝试从TLAB(Thread Local Allocation Buffer) 线程的本地对象缓冲区中分配内存,这是一个无线程安全问题的快速分配内存区。可以高速创建小对象。而从缓冲池中去取一个对象,有内存可见性,线程安全,缓冲命中等一系列可能引发性能问题的节点,通常比新建一个小对象更慢。

遗留问题及优化

更完美的代码,几乎永无止境,但生命是有限的。在满足了需求分析中提出的目标之后,虽然心有不甘,但进一步的优化还是先放放吧,该做其他事儿了。在此先列出一些过程中想到的遗留问题,留待以后慢慢优化。

性能作弊大法Unsafe

在进行性能测试时,发现部分条件下,ConcurrentI64HashMap的Put性能明显低于系统自带的版本。简单分析下,系统自带版本很可能使用了一些非加锁的Put方式完成了部分数据的添加,稍微看看源代码,在Java 1.8的ConcurrentHashMap源码里看见了以下内容:


else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null,
                 new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

其中,casTabAt 的代码如下(其中U是Unsafe类的实例):


transient volatile Node<K,V>[] table;
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

简单点说,就是当数组对应的位置无数据时,无需加锁即可直接利用Unsafe类的compareAndSwapObject方法,原子更新数组内的引用。我们知道,传统数组是不支持volatile的,更不要说原子操作。但现在,java.util.concurrent.atomic包中有了AtomicIntegerArray,和AtomicReferenceArray。AtomicReferenceArray可以用来完成这段Unsafe实现的功能,但性能差异就不清楚了,暂不深究,有兴趣的读者可以自己试试。

关于volatile和Get线程数据刷新的一些疑惑

前面的代码中,我使用了volatile关键字来确保happens-before。volatile关键字的介绍中,还提到该关键字通过直接将变量写入内存和从内存中读取来保证可见性,以前我并未仔细思考过这句保证修改后的数据立刻回写,和读取时从内存读到最新数据的实际意义。现在仔细一想,不太对啊,MESI协议不就是用来干这个的么,如果MESI协议无法保证缓存中的修改立刻被其他核心发现并按需更新,那么这个世界已经乱套了。

那么问题就来了,既然有了MESI,volatile难道就是用来做个happens-before控制? 还是说JMM在Cache之外干了点什么?

我又仔细找了一些关于volatile的资料,部分英文文章使用了多CPUs而不是多Cores来说明volatile的作用,也许在部分multi-processers的实现中,跨CPU的缓存更新相对得不到保障?还是说有些比较奇葩的multi-cores CPU也没有保证core间的缓存同步,所以JMM用了一层抽象来做Java代码在各种环境下的一致性保障?

我没有去读最后修订已经在15年前的JSR-133,也没有去读AMD64 Architecture Programmer’s Manual,我初步的理解是,在单CPU多核的X86_64处理器上,数据刷新是有MESI协议作保障的,一颗核心对数据的更改应该能迅速被另一个核心看到;而在多核多CPU的环境中,多CPU之间会用QPI(Intel)或HyperTransport(AMD)总线通讯来保证缓存一致性,所有缓存写都能被所有核心立刻察觉。只有寄存器内的变量是对其他核心不可见的,而如果不同核心之间发生写冲突,应该是要回退的。

这种情况下,如果我们不需要跨线程的happens-before保证,也要不需要单线程特定需求下的指令执行绪控制,在X86环境和其他正常的CPU中,我们无需使用volatile关键字来保障读取到的是最新数据,CPU的底层架构已经保障了这一点。

高loadFactor下的性能问题

当loadFactor 同为0.5时,ConcurrentI64HashMap不具备性能优势(尤其是put和remove操作,但能大幅节省内存),下面的表格罗列了另外两组测试数据:

-ConcurrentHashMapConcurrentI64HashMapConcurrentHashMapConcurrentI64HashMap
LoadFactor0.50.50.1250.125
1线程 Get/Put/Remove45/106/87(ns)46/106/109(ns)40/93/78(ns)16/54/58(ns)
8线程 Get/Put/Remove76/150/129(ns)56/211/183(ns)59/155/118(ns)22/126/107(ns)

简单分析,性能差异很可能来自于系统自带的map的代码优化和对锁使用范围的进一步缩减,另外,从Node中读取key也比从node.value中读取key少一次寻址过程。同上,暂不优化,留到以后再说。

本文所涉及的部分代码,会随着文章进度逐步整理并放到 github上。
其中,高性能基础数据结构的代码见 https://github.com/Lofint/tachyon

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值