数组替代map实现性能优化
概念
数组map,即在某些情况下使用数组模拟一个map,利用key独一无二的特征可以把数组的下标作为key,value即数组中所存储的内容,这样可以模拟出一个Map类型的map,使用数组模拟map相当于是从数据结构层面提升了代码性能,相关案例如下
- 去除重复字母
- 下一个更大元素
- 同构字符串
数组和map开销对比
hash算法
HashMap的底层数组长度为何总是2的n次方,通过(h = key.hashCode()) ^ (h >>> 16)
得出最终结果,原因是由于hashCode底层算法,生成hashCode的时候会在某些位上呈规律性分布,不做处理可能会导致hash聚集,而通过右移运算符移位16位则是保证这个key的hashCode的二进制数的第16位与高16位共同参与运算,确保得到key的hash值是绝对随机的,最后通过i = (n - 1) & hash
来确保这个节点随机插入到数组的任意位置
插入方面
hashMap入参中没有关于容量size的参数的话默认就使用懒加载,首次插入的时候会给它默认分配一个容量为16的数组,通过高性能hash算法计算出插入位置,然后为插入的键值对生成链表节点,如果数组当前位置为空就直接插入,如果当前数组不为空,首先对当前数组上的元素做判重,判重算法是插入元素的hash值和原先map中已有的元素key的hash值重复,且这两个key的地址一样或通过equals比较返回true,如果被判为重复,则会用新值替换旧值并把旧值返回
如果插入数组位置已经形成红黑树,那么就把元素插入到红黑树里
如果插入数组位置不为空且已经形成链表,那么采用尾插法,通过遍历这条链表获取到链表尾,然后通过链表尾的next指针完成插入,如果发下链表元素大于8就对当前链表进行树化
如果插入的是一个全新的节点,那么map的size++,如果超过阈值会触发扩容,阈值 = 当前容量 * 扩容因子
查找
当你调用 hashMap.get(key) 时,首先会计算该键的哈希值,会使用这个哈希值来确定在内部数组中的索引位置
- 如果位置上只有一个元素,那么查找效率为O(1)
- 如果位置上是一个链表,那么查询效率为O(N),其中N为链表长度
- 如果位置上是一个红黑树,那么查询效率为O(logN),其中N为树中的节点数目
扩容
扩容时机
当HashMap中的元素数量超过了当前数组容量与负载因子的乘积(即容量 * 负载因子)时,会触发扩容操作。默认情况下,负载因子为0.75
扩容因子
负载因子越大,空间开销越大,但查询速度会越快,因为这样大大减少了出现链表和红黑树的概率,查询起来更接近于数组查询,负载因子越小,空间开销越小,但查询速度也会相应变慢
扩容过程
HashMap的底层数组长度为何总是2的n次方,因为hash算法依赖数组的长度这个数值,所以每次扩容都意味着数组的长度会翻倍,所以这一过程是最容易出现OOM的
数据测试
内存开销
测试代码,我的jvm堆空间为,数组存储的上限是(1 << 30),精确数据大约为1.9 * (1 << 29),已经非常接近 (1 << 30) 了
public static void main(String[] args) {int testSize = (1 << 30);int[] arr = new int[testSize];
}
map存储的上限比较复杂,无论如何调整参数,大约都是只能存储不到(1 << 26)数量级的元素,默认负载因子的时候,数组的容量会变为2被的testSize,所以最后是在数组扩容阶段OOM,次数真实的存储容量大约是1.5 * (1 << 25);
public static void main(String[] args) {int testSize = (1 << 30);Map map = new HashMap(testSize);for (int i = 0; i < testSize; i++) {map.put(i, i);}}
如果把负载因子调整为一个不小于1的值,也就是说整个插入过程中数组元素始终固定死是testSize了,那么次数OOM问题会抛出在创建Node对象的过程
public static void main(String[] args) {int testSize = (1 << 30);Map map = new HashMap(testSize, 1);for (int i = 0; i < testSize; i++) {map.put(i, i);}}
综合来说数组所能存储元素的上限大约是map所能存储元素上限的16倍,推测主要是Node对象的空间占用过大,大约是int数值的8倍
插入速度比较
public static void main(String[] args) {Long start1 = System.currentTimeMillis();int testSize = (1 << 25);int[] arr = new int[testSize];for (int i = 0; i < testSize; i++) {arr[i] = i;}long endTime = System.currentTimeMillis();System.out.println("arr spend time:" + (endTime - start1));Map map = new HashMap();Long start2 = System.currentTimeMillis();for (int i = 0; i < testSize; i++) {map.put(i, i);}System.out.println("map spend time:" + (System.currentTimeMillis() - endTime));
使用1 << 25个元素测试,数组平均耗时20ms,map平均耗时1400毫秒,
如果给map指定初始大小和扩容因子,保证整个插入流程没有额外扩容流程的话,那么map的平均耗时为1100毫秒
Map map = new HashMap(testSize, 1);
这种情况其实已经接近当前最优解了,有人会说,继续增大map容器的初始容量,那这样数组上链表和黑红树出现的概率会更小,所以速度会更快,理论是这样,但实际上你再去增大初始容量对速度的提升也很有限了,因为hash算法可以确保hash值的随机性从而保证元素随机分布到数组的任意位置,当map中数组容量和待插入元素数量一致时,数组上形成链表和红黑树的数量会比较小,可能只有1% - 5%的位置有树和链表,对整体插入速度影响不大
同步扩大扩容因子和初始容量,这种样插入过程中依然不会出现扩容的情况,但平均耗时却变成了1700ms,主要这种情况下数组上会出现大量链表和红黑树导致插入速度慢了很多
Map map = new HashMap(testSize / 10, 10);
查找速度比较
public static void main(String[] args) {int testSize = (1 << 25);int[] arr = new int[testSize];for (int i = 0; i < testSize; i++) {arr[i] = i;}Long start1 = System.currentTimeMillis();int tmp = 0;for (int i = 0; i < testSize; i++) {tmp = arr[i];}long endTime = System.currentTimeMillis();System.out.println("arr spend time:" + (endTime - start1));
}
public static void main(String[] args) {Map<Integer, Integer> map = new HashMap(16, 0.5f);int testSize = (1 << 25);for (int i = 0; i < testSize; i++) {map.put(i, i);}Long start = System.currentTimeMillis();int tmp = 0;for (int i = 0; i < testSize; i++) {tmp = map.get(i);}System.out.println("map spend time:" + (System.currentTimeMillis() - start));}
数组平均耗时2ms, map平均耗时500毫秒