从android HashMap的源码实现谈谈HashMap的性能问题
来源:互联网 发布:rmvb转mp4 mac 编辑:程序博客网 时间:2024/05/23 18:11
从android HashMap的源码实现谈谈HashMap的性能问题
前言
实际开发中,HashMap可能是我们用的最多的数据结构了。基础稍微扎实一点的开发,问起HashMap的特性,都会说,HashMap是key可以为null的,无序的,非线程安全的。没错,这几点确实是HashMap的特性,但是HashMap究竟为什么是非线程安全的,多线程下会有什么问题,作为“有理想”的程序员,都不应该放过。
预备知识
HashMap,顾名思义,是使用Hash表实现的Map。和Hash表相关的几个概念有:
Hash(哈希)函数,Hash冲突。Hash函数的表达式:Addr = H(key),就是以key为输入,Addr为输出的函数,Addr就是key对应的在哈希表中的地址。当不同的key产生相同的Addr时,就会发生hash冲突。
android中HashMap的实现
android中hashMap的实现和jdk里的大致思路一样,以android的为例。
先看构造函数:
public HashMap() { table = (HashMapEntry<K, V>[]) EMPTY_TABLE; threshold = -1; // Forces first put invocation to replace EMPTY_TABLE } public HashMap(int capacity) { if (capacity < 0) { throw new IllegalArgumentException("Capacity: " + capacity); } if (capacity == 0) { @SuppressWarnings("unchecked") HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE; table = tab; threshold = -1; // Forces first put() to replace EMPTY_TABLE return; } if (capacity < MINIMUM_CAPACITY) { capacity = MINIMUM_CAPACITY; } else if (capacity > MAXIMUM_CAPACITY) { capacity = MAXIMUM_CAPACITY; } else { capacity = Collections.roundUpToPowerOfTwo(capacity); } makeTable(capacity); } public HashMap(int capacity, float loadFactor) { this(capacity); if (loadFactor <= 0 || Float.isNaN(loadFactor)) { throw new IllegalArgumentException("Load factor: " + loadFactor); } /* * Note that this implementation ignores loadFactor; it always uses * a load factor of 3/4. This simplifies the code and generally * improves performance. */ } public HashMap(Map<? extends K, ? extends V> map) { this(capacityForInitSize(map.size())); constructorPutAll(map); }
HashMap提供了四个构造方法,按照上面的代码排列依次是:无参构造、初始容量构造、初始容量+装载因子构造、map构造。这几个构造方法涉及到了几个名词:
threshold : 阀值,容量为0时默认-1
capacity : 容量
loadFactor: 装载因子,默认0.75(3/4)
makeTable()的实现:
private HashMapEntry<K, V>[] makeTable(int newCapacity) { @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity]; table = newTable; threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity return newTable; }
这段代码决定了,threshold阀值 = table大小的3/4(0.75)。
HashMap()构造函数中,出现了
table = (HashMapEntry<K, V>[]) EMPTY_TABLE
这样一行代码,看下table的申明:
transient HashMapEntry<K, V>[] table;
table是一个HashMapEntry的数组,而HashMapEntry的实现:
static class HashMapEntry<K, V> implements Entry<K, V> { final K key; V value; final int hash; HashMapEntry<K, V> next; HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) { this.key = key; this.value = value; this.hash = hash; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V value) { V oldValue = this.value; this.value = value; return oldValue; } @Override public final boolean equals(Object o) { if (!(o instanceof Entry)) { return false; } Entry<?, ?> e = (Entry<?, ?>) o; return Objects.equal(e.getKey(), key) && Objects.equal(e.getValue(), value); } @Override public final int hashCode() { return (key == null ? 0 : key.hashCode()) ^ (value == null ? 0 : value.hashCode()); } @Override public final String toString() { return key + "=" + value; } }
可以看到,HashMapEntry是一个实现了Entry接口,并且封装了get、set相关方法的类,看到HashMapEntry next;这一行了么,这说明,每个HashMapEntry实体都是一个单链表。回过头来,table又是一个HashMapEntry的数组,这么一看,HashMap的数据结构就清晰了:table的每个位置都是一个HashMapEntry单链表。如示意图:
HashMap(int capacity)构造函数中,对capacity做了判断:capacity==0,用默认的空table,capacity
private static final int MINIMUM_CAPACITY = 4;
MAXIMUM_CAPACITY的声明:
private static final int MAXIMUM_CAPACITY = 1 << 30;
也就是说,table的容量应该是在4到1 << 30(1*2^30)之间,小于4按4算,大于1 << 30按1 << 30算。之间的数按照Collections.roundUpToPowerOfTwo(int i)方法的返回值算。从字面上看,这个函数是找出向上离i最近的2的指数幂。这么说可能还是有点晕乎,直接上一段代码测试下:
public void test(){ System.out.println(roundUpToPowerOfTwo(5)); }
输出结果为8。对着这个例子解释就是,离5最近的2的指数幂有两个,就是2^3=8,2^2=4,但是向上(up)的含义就是,从大于i的方向取,也就是8。所以不论是4还是1 << 30还是中间,最后capacity都是2的指数幂。
put方法
@Override public V put(K key, V value) { if (key == null) { return putValueForNullKey(value); } int hash = Collections.secondaryHash(key); HashMapEntry<K, V>[] tab = table; int index = hash & (tab.length - 1); for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) { if (e.hash == hash && key.equals(e.key)) { preModify(e); V oldValue = e.value; e.value = value; return oldValue; } } // No entry for (non-null) key is present; create one modCount++; if (size++ > threshold) { tab = doubleCapacity(); index = hash & (tab.length - 1); } addNewEntry(key, value, hash, index); return null; }
这段代码,内容并不多,总体分为几个部分:
1、如果key是空,则添加以null为key的值;
2、如果key不空,key已经存在,更新value并且返回旧值;如果存在冲突(同一个index),遍历到这个index下面的单链表末尾。
3、如果大小达到阀值,把table扩容两倍
4、添加实体
第1部分,看下putValueForNullKey的实现:
private V putValueForNullKey(V value) { HashMapEntry<K, V> entry = entryForNullKey; if (entry == null) { addNewEntryForNullKey(value); size++; modCount++; return null; } else { preModify(entry); V oldValue = entry.value; entry.value = value; return oldValue; } }
如果之前没有以null为key的实体entryForNullKey,就添加;否则就修改,并返回旧的value。这个很好理解,以null为key的实体只能有一个。
第2部分,先是用转二进制散列值函数得到一个关于key的二进制的散列值:
public static int secondaryHash(Object key) {
return secondaryHash(key.hashCode());
}用key的hashCode去转成二进制的。然后用这个散列值和table长度-1做与运算,得到一个要填充的位置index,找到这个index的entry,然后以entry的hash值和key的quals结果判断是不是同一个entry。
第3部分,数组table的大小达到阀值,就去扩容:
private HashMapEntry<K, V>[] doubleCapacity() { HashMapEntry<K, V>[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { return oldTable; } int newCapacity = oldCapacity * 2; HashMapEntry<K, V>[] newTable = makeTable(newCapacity); if (size == 0) { return newTable; } for (int j = 0; j < oldCapacity; j++) { /* * Rehash the bucket using the minimum number of field writes. * This is the most subtle and delicate code in the class. */ HashMapEntry<K, V> e = oldTable[j]; if (e == null) { continue; } int highBit = e.hash & oldCapacity; HashMapEntry<K, V> broken = null; newTable[j | highBit] = e; for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) { int nextHighBit = n.hash & oldCapacity; if (nextHighBit != highBit) { if (broken == null) newTable[j | nextHighBit] = n; else broken.next = n; broken = e; highBit = nextHighBit; } } if (broken != null) broken.next = null; } return newTable; }
总体来说,就是table容量扩大为两倍,然后重新计算每个entry的位置,并计算出新的实体应在的位置。这个过程很精巧,新表的位置是按照高位去计算新的位置的,把旧的冲突链上(此时整个冲突链已经在新的位置),高位不一样的放到新计算的位置,一样的保留。
第4步,按照之前计算的位置添加实体,但是此时index上可能会有存在的实体:
void addNewEntry(K key, V value, int hash, int index) { table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]); }
HashMapEntry这个构造,第四个参数是下一个实体(next),也就是当前实体table[index]是当前冲突链的链表头。上述的代码可以写成:Entry head = table[index];n.next = head;head = n;所以添加的位置,是冲突链表的头部。
get方法
public V get(Object key) { if (key == null) { HashMapEntry<K, V> e = entryForNullKey; return e == null ? null : e.value; } int hash = Collections.secondaryHash(key); HashMapEntry<K, V>[] tab = table; for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)]; e != null; e = e.next) { K eKey = e.key; if (eKey == key || (e.hash == hash && key.equals(eKey))) { return e.value; } } return null; }
get方法逻辑也很简单,key为空就把之前放进来的对应实体返回,实体也为null就返回null。然后找到这个实体的位置,按照传参进来的key和实体的key指向同一个地址(eKey == key)或者实体的hash值和传参key的hash值相等(e.hash == hash)并且传参key和这个位置上的实体key的equals方法返回truekey.equals(eKey),这样的规则确定是否是同一个。
性能问题分析
分析完上面几个关键的方法,基本可以得出几个结论:
- HashMap是可以以null为key的(代码显而易见);
- HashMap是非线程安全的(也没看到用到sychronized、volatile和CAS);
- HashMap的实际大小总是2的指数幂;
- HashMap数组+单链表的数据结构,其实就是用直接链法处理hash冲突(相关概念可以自行百度);
- HashMap的大小不是一成不变的,而是在达到阀值(数组大小0.75倍),就开始扩容;
- 扩容后的大小是原来的两倍;
- put方法中,e.hash == hash && key.equals(e.key)是判定是否为同一个实体的条件;
- get方法中,eKey == key || (e.hash == hash && key.equals(eKey)是判定是否为同一个实体的条件。
性能问题一,HashMap的大小:
为什么说HashMap的实际大小总是2的指数幂?因为就算初始化的时候不是2的指数幂,roundUpToPowerOfTwo函数也会帮你转。为什么一定要是2的指数幂?这个是由于HashMap使用的散列算法,就是用key的hashCode转成对应的二进制,然后和HashMap的size-1座“&”操作。为什么这样做?举个例子,如果HashMap的设定大小为10,那么roundUpToPowerOfTwo转完大小是2^4 = 16,那么16-1=15,用二进制表示就是1111,此时如果一个实例的二进制哈希码为850873883(仅用来举例),二进制表示是110010101101110100111000011011,两者进行与运算,结果就是截取低四位1011,十进制就是11,也就是进来的key<->value的实体放在11这个位置上。试想如果HashMap的大小不是16而是10,10-1 = 9,二进制表示是1001,那么中间两个0的位置永远不会取到1,也就是2,3,4,6,5,7,这6个位置永远都都不会被算到实际填充的位置,空间利用率不足一半。这样的话就明白为什么用2的指数幂了:散列均匀。
那么问题来了,既然HashMap的实现已经帮我们做了这么多工作,我们是不是直接用就好了,不用管其他的了?明显不是。首先当HashMap的大小过小的时候,会增加Hash冲突的几率;另外如上面分析put方法说道的第三点,当当前的大小达到阀值(默认0.75*size),就会扩容,容量扩大为原来的两倍,扩容的过程会遍历原来的table,把它的元素重新计算在对应的新table中的位置,最坏时间复杂度为O(n^2);而在hash不冲突的场景下,不需要扩容的话,实际的时间复杂度为O(1)(只需要按照得到的index放进去)。所以我们最好给HashMap一个初始值,这个值是2的指数幂,并且它呈上装载因子(默认0.75)后的大小大于我们实际需要的大小。例如,我们实际需要200,那么200/0.75 约等于267,那么实际大于方向靠近267的2的指数幂为2^9 = 512。
性能问题2:重写equals和hashCode方法:
首先明确这两者的关系:
A和B对象equals方法返回true,hasCode方法返回值必然一样;
A和B对象hashCode不一样,那么equals方法必须返回false。
A和B对象hashCode一样,不能判定A equals B。
所以equals方法返回true和hasCode方法返回值一样是充分非必要的关系。
从Collections.secondaryHash的方法看,最终散列的位置index是和key的hashCode有关的,如果key是引用类型对象,且没有重写hashCode,就会很容易出现hash冲突,在put的过程中,发生冲突就会沿着单链表遍历到最后并插入。这个时间复杂度也是O(n)。
同时,put和get的判定都有e.hash == hash && key.equals(e.key),如果不重写equals方法,默认用“==”判定,比较内存地址。如果key是引用对象,则必须是同一个引用才能判定是相同的对象。例如:
public class UserData { public String mUserName; UserData(String userName){ mUserName = userName; } public static void main(String[] arg){ HashMap<UserData,String> map = new HashMap<>(8); map.put(new UserData("yue"),"yue"); String result = map.get(new UserData("yue")); System.out.println("result="+result); } }
输出的结果为null。所以不管是为了满足equals和hashCode充分非必要的关系,还是保障程序的健壮性,都应重写equals。
性能问题3:多线程问题
HashMap是非线程安全的,这点已经毋庸置疑。那多线程条件下,怎么个不安全呢?关键在于扩容的过程。现在已知,不管哪种实现(android、jdk各版本),扩容的过程都是遍历旧的table,然后把旧table中的元素填充到扩容后的table中。伪代码如下:
{ newTable = new Entry[oldCapacity*2]; for(int i = 0;i<oldCapacity;i++){ Entry e = oldTable[i];//取出老的数组上的entry for(遍历冲突链e){ newTable[newIndex];//创建新数组 findNewIndex();//找到新的位置 updateNodes(newTable);//更新冲突链 } } }
不同的jdk有不同方式的实现。其中最经典的jdk1.7的写法:
void transfer(Entry[] newTable, boolean rehash) { //把旧的数据转存到新的table中 int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { //在这有循环,有链表的next节点赋值, Entry<K,V> next = e.next; //flag if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
以经典组合3、7、5,findNewIndex()为retrun key.hashCode() % table长度为例(这个例子很特殊,一会说为什么很特殊)。
假设:
HashMap<Integer,Object> map = new HashMap<>(2);map.put(3,obj1);//node3map.put(7,obj2);//node7map.put(5,obj3);//node5
按照(size++> threshold),那么在put7的时候,会扩容(注意++前置和后置的区别)此时的map的变化(冲突节点头插):
可以发现,原来index = 1的位置上,是Entry(7,obj1)->Entry(3,obj2);
扩容后:index = 3 的位置上,Entry(3,obj2)->Entry(7,obj1);顺序发生了倒置!如图:
假设:线程A和线程B,同时进到了上面标注的flag处。线程B挂起,线程A继续执行,那么A执行完的结果就是上图,在A的本地内存和JMM主内存newTable的情况是:
newTable[0]=null;
newTable[1]=node5;
newTable[2]=null;
newTable[3]=node3->node7(node3.next = node7);
此时线程B唤起开始执行,但是线程B的本地内存中的情况是执行之前的:newTable各个位置上没有值,
oldTable[0] = null;
oldTable[1] = node7->node3(node7.next = node3);
然后继续按mod4计算新的位置,此时node7的新位置应该在newTable的3上,即上述代码中i= 3;此时的e = node7;
然后执行e.next = newTable[3],注意此时的操作,对newTable是一个读操作,在JMM中,读操作都是直接从主内存中读取,所以现在的newTable[3] = node3->node7(node3.next = node7);于是有e.next = node3->node7,刚刚说到,e = node7,所以node7插在了单链表node3->node7之前,此时的newTable[3]= node7->node3->node7;死循环出现,线程B无法停止,耗尽CPU资源,只能重启机器。过程图如下:
从上面的分析看,死循环的根源是:
1、原来的冲突链上的节点rehash(扩容)的时候又冲突在了一起(node3和node7);
2、findNewIndex()这个环节存在节点倒置。
3、多线程下的顺序一致性问题
这三个条件都满足,就可能会出现死循环。
为什么说这个例子很特殊呢?因为初始大小为2,扩容后为4,mod2时,这三个数会直接冲突到一个冲突链上,在扩容后重新计算index,3和7又产生了冲突。仅仅三个操作,就能模拟到不断冲突的情况。当然也许有人说这太特殊了,我们平时不可能这么写,但是谁都没法保证,程序实际使用过程中是否会发生这样的情况,Hash和rehash的算法优化固然可以减少死循环发生的几率,但是一旦发生,就是灾难性的。
回过头来看这个特殊例子在android的doubleCapacity的情况:
初始大小为2,3的hashCode为3,即011,7的hashCode为7,即111,5的hashCode为5,即101,那么在放3和7的时候,index分别为011&001 = 001 ->1,111&001 = 001 ->1,所以3和7是冲突在1的位置上的,由于是头插法,所以oldtable:
oldtable[0]= null;
oldtable[1]= node7->node3;
然后根据
int highBit = e.hash & oldCapacity; HashMapEntry<K, V> broken = null; newTable[j | highBit] = e;
扩容时,node7的highBit为111&010=010->2;node3的highBit为011&010=010->2;
根据
if (nextHighBit != highBit) { if (broken == null) newTable[j | nextHighBit] = n; else broken.next = n; broken = e; highBit = nextHighBit;}
当前后两个相邻的节点的highBit相同时,对这条链内部不作处理,所以node7->node3直接指向了newTable[j | highBit]也就是newTable[2],新来的node5,直接根据index= hash & (tab.length - 1) = 101&011 = 001->1,放在了1的位置上。
这个分析可以看到,android的实现并不会造成节点倒置,不满足死循环的条件2,所以不会导致死循环。但是由于多线程下,每个线程都在更新冲突链,可能会出现put的值和预期不匹配的情况,所以我们仍要关注HashMap的并发问题。
解决这个问题有几个方法:
1、使用HashTable;
2、Collections.synchronizedMap处理HashMap;
3、使用ConcurrentHashMap
前两种性能太差,推荐使用第三种,ConcurrentHashMap使用CAS轻量级锁,性能更好。
- 从android HashMap的源码实现谈谈HashMap的性能问题
- HashMap设计原理、HashMap的数据结构、HashMap源码实现
- 从HashMap的源码来聊聊HashMap吧
- 从源码分析HashMap实现
- 从HashMap到LruCache的源码分析
- 从HashMap到LruCache的源码分析
- HashMap---Android 的HashMap介绍
- HashMap 的性能因子
- 浅析HashMap的实现和性能分析
- HashMap的实现和性能分析
- 从数据结构谈HashMap的实现
- java hashmap的put函数实现源码
- JDK源码阅读之HashMap的实现
- HashMap的实现原理及源码
- Java源码---HashMap的底层实现
- JDK源码阅读:实现自己的HashMap
- HashMap的源码解读
- hashmap的源码
- 《Java编程技巧1001条》360条:用日期函数获得当前日期
- ACRUSH 楼教主的回忆录
- jQuery 遍历
- IIS 中设置文件上传最大长度
- 丛林战争项目十之数据库查询
- 从android HashMap的源码实现谈谈HashMap的性能问题
- 区块链比特币科普
- 对象原型与属性容易混淆的函数
- 一致性hash算法
- maven安装配置
- lesson 10:用两个线程玩猜数字游戏,第一个线程负责随机给出1~100之间的一个整数,第二个线程负责猜出这个数。要求每当第二个线程给出自己的猜测后,第一个线程都会提示“猜小了”、“猜大了”或“猜
- 乐视洗牌,张昭升官,孙宏斌为何力挺张昭的乐视影业?
- pyCharm最新2017激活码
- flask RESTful api学习 WITH POSTMAN调试