HashMap的实现与优化

HashMap的优化与实践

本文是基于作者在github上的Android 问题交流讨论坛提问而产生的一篇文章,也是自己早打算开坑的一篇文章。文章首先介绍了hashMap的一些基本知识,然后介绍了它在JDK8下的实现原理,最后着重介绍了几个面试中处理大数据的方法,文章比较长,我也写了好久,希望各位能够读完并发表意见。

Android 题交流讨论坛是开源达人 Trinea 在gitHub上组建的一个讨论组织,那里的提问与回答都非常靠谱。

HashMap的复杂度

如图是ArrayList/LinkedList/HashMap三个数据结构的复杂度对比,可以看出HashMap整体上性能都非常不错,但是不稳定,为O(N/Buckets),N就是以数组中没有发生碰撞的元素。

获取 查找 添加/删除 空间
ArrayList O(1) O(1) O(N) O(N)
LinkedList O(N) O(N) O(1) O(N)
HashMap O(N/Bucket_size) O(N/Bucket_size) O(N/Bucket_size) O(N)

注:发生碰撞实际上是非常稀少的,所以N/Bucket_size约等于1

HashMap是对Array与Link的折衷处理,Array与Link可以说是两个速度方向的极端,Array注重于数据的获取,而处理修改(添加/删除)的效率非常低;Link由于是每个对象都保持着下一个对象的指针,查找某个数据需要遍历之前所有的数据,所以效率比较低,而在修改操作中比较快。

复杂度是如何考察的?

对于数据结构,在时间上我们需要考察Acessing ,Search, Deletion/Insertion的平均与最差的复杂度。在空间上,我们要考虑维护这个数据结构所占用的内存空间。

常见的数据结构与排序的复杂度都在这里

HashMap的实现

本文以JDK8的API实现进行分析

1. 什么是hash,什么是碰撞?

  • Hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5,SHA1,SSL中的公私钥验证都属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。
  • 碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞;
<code>&gt;就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。</code>

2. HashMap中是如何实现写入与读取的?

HashMap实现了Map接口,保存着K-V这样的集合。我们以put操作为例

2.1. 对key进行Hash计算

在JDK8中,由于使用了红黑树来处理大的链表开销,所以hash这边可以更加省力了,只用计算hashCode并移动到低位就可以了

static final int hash(Object key) {
    int h;
    //计算hashCode,并无符号移动到低位
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

下面给出几个常用的哈希码的算法。

  1. Object类的hashCode.返回对象的内存地址经过处理后的结构,由于每个对象的内存地址都不一样,所以哈希码也不一样。这个是native方法,这个取决于JVM的设计,一般是某种地址的偏移。
  2. String类的hashCode.根据String类包含的字符串的内容,根据一种特殊算法返回哈希码,只要字符串的内容相同,返回的哈希码也相同。
  3. Integer等包装类,返回的哈希码就是Integer对象里所包含的那个整数的数值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可见,2个一样大小的Integer对象,返回的哈希码也一样。
  4. int,char这样的基础类,它们不需要hashCode,如果需要存储时,将进行自动装箱操作,计算方法同上。

2.2. 获取到当前的位置

计算了Hash,我们现在要把它插入数组中了

i = (tab.length - 1) & hash;

通过位运算,确定了当前的位置,因为HashMap数组的大小总是2^n,所以实际的运算就是 (0xfff…ff) & hash ,这里的tab.length-1相当于一个mask,滤掉了大于当前长度位的hash,使每个i都能插入到数组中。

2.3. 生成包装类

这个对象是一个包装类,Node<K,V>,内部有key,value,hash还有next,可以看出来它是一个链表。

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        //getter and setter .etc.
}

2.4. 插入包装类到数组

(1). 如果输入当前的位置是空的,就插进去,如图,左为插入前,右为插入后

0           0
|           |
1 -> null   1 - > null
|           |
2 -> null   2 - > null
|           | 
..-> null   ..- > null
|           | 
i -> null   i - > new node
|           |
n -> null   n - > null

(2). 如果当前位置已经有了node,且它们发生了碰撞,则新的放到前面,旧的放到后面,这叫做链地址法处理冲突。

0           0
|           |
1 -> null   1 - > null
|           |
2 -> null   2 - > null
|           | 
..-> null   ..- > null
|           | 
i -> old    i - > new - > old
|           |
n -> null   n - > null

我们可以发现,失败的hashCode算法会导致HashMap的性能下降为链表,所以想要避免发生碰撞,就要提高hashCode结果的均匀性。当然,在JDK8中,采用了红黑二叉树进行了处理,这个我们后面详细介绍。

什么是Hash攻击?

通过请求大量key不同,但是hashCode相同的数据,让HashMap不断发生碰撞,硬生生的变成了SingleLinkedList

0
|
1 -> a ->b -> c -> d(撞!撞!撞!复杂度由O(1)变成了O(N))
|
2 -> null(本应该均匀分布,这里却是空的)
|
3 -> null
|
4 -> null

这样put/get性能就从O(1)变成了O(N),CPU负载呈直线上升,形成了放大版DDOS的效果,这种方式就叫做hash攻击。在Java8中通过使用TreeMap,提升了处理性能,可以一定程度的防御Hash攻击。

3. 扩容

如果当表中的75%已经被占用,即视为需要扩容了

(threshold = capacity * load factor ) < size

它主要有两个步骤:

1. 容量加倍

左移1位,就是扩大了两倍,用位运算取代了乘法运算

newCap = oldCap << 1;
newThr = oldThr << 1;

2. 遍历计算Hash

for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //如果发现当前有Bucket
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //如果这里没有碰撞
                    if (e.next == null)
                        //重新计算Hash,分配位置
                        newTab[e.hash & (newCap - 1)] = e;
                    //这个见下面的新特性介绍,如果是树,就填入树
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //如果是链表,就保留顺序....目前就看懂这点
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }

由此可以看出扩容需要遍历并重新赋值,成本非常高,所以选择一个好的初始容量非常重要。

如何提升性能?

  1. 解决扩容损失:如果知道大致需要的容量,把初始容量设置好以解决扩容损失;
    比如我现在有1000个数据,需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作为初始容量。
  2. 解决碰撞损失:使用高效的HashCode与loadFactor,这个…由于JDK8的高性能出现,这儿问题也不大了。
  3. 解决数据结构选择的错误:在大型的数据与搜索中考虑使用别的结构比如TreeMap,这个就是积累了,一般需要key排序时,建议使用TreeMap;

HashMap与HashTable的主要区别

在很多的Java基础书上都已经说过了,他们的主要区别其实就是Table加了线程同步保护

  • HashTable线程更加安全,代价就是因为它粗暴的添加了同步锁,所以会有性能损失。
  • 其实有更好的concurrentHashMap可以替代HashTable

JDK8中HashMap的新特性

如果某个桶中的链表记录过大的话(当前是TREEIFY_THRESHOLD = 8),就会把这个链动态变成红黑二叉树,使查询最差复杂度由O(N)变成了O(logN)。

//e 为临时变量,p为当前的链
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

JDK8在其他地方也有提升,更多的可以看这里

HashMap的装箱空间效率

在笔试题中,一般“内存”是完全能够使用的,而在现实中HashMap空间效率之低,你却不一定知道。

比如定义了一个 HashMap<Long,Long>

1. Long的装箱

在对象头中,加入额外的指针8Bype,加入8Bype的MarkWord(hashcode与锁信息),这里就是16Byte

也就是说,long在装箱后,效率为 8/24 = 1/3

2. Map.Entry的装箱

字段空间: hash(4) + padding(4) + next(8) = 16Byte,这里的padding是字节对齐

对象头: 16Byte,指针+MarkWord

也就是说,维护一个Entry需要32Byte的空间

static class Node<K,V> implements Map.Entry<K,V>
{    
    final int hash;    
    final K key;    
    V value;    
    Node<K,V> next;
}

3. 总效率

8/(24 + 32) = 1/7

计算结果可能有差异,本文主要在强调装箱过程造成的损失

在Android中使用SparseArray代替HashMap

官方推荐使用SparseArray([spɑ:s] [ə'reɪ],稀疏的数组)或者LongSparseArray代替HashMap,目前国内好像涉及的比较少,容我先粘贴一段

Note that this container keeps its mappings in an array data structure, using a binary search to find keys. The implementation is not intended to be appropriate for data structures that may contain large numbers of items. It is generally slower than a traditional HashMap, since lookups require a binary search and adds and removes require inserting and deleting entries in the array.

For containers holding up to hundreds of items, the performance difference is not significant, less than 50%.
To help with performance, the container includes an optimization when removing keys: instead of compacting its array immediately, it leaves the removed entry marked as deleted. The entry can then be re-used for the same key, or compacted later in a single garbage collection step of all removed entries. This garbage collection will need to be performed at any time the array needs to be grown or the the map size or entry values are retrieved.

总的来说就是:

  • SparseArray使用基本类型(Primitive)中的int作为Key,不需要Pair<K,V>或者Entry<K,V>这样的包装类,节约了内存;
  • SpareAraay维护的是一个排序好的数组,使用二分查找数据,即O(log(N)),每次插入数据都要进行排序,同样耗时O(N);而HashMap使用hashCode来加入/查找/删除数据,即O(N/buckets_size);
  • 总的来说,就是SparseArray针对Android嵌入式设备进行了优化,牺牲了微小的时间性能,换取了更大的内存优化;同时它还有别的优化,比如对删除操作做了优化;
  • 如果你的数据非常少(实际上也是如此),那么使用SpareArray也是不错的;

在笔试中的使用

1. 查重与分组问题

某公司正在做一个寻找走失儿童的公益项目,现在有一个函数,可以输入两个图片,并返回这个儿童是否重复。请你设计一个系统,帮助他们寻找儿童。

  1. 网友可以同时上传一批图片
  2. 系统能够把所有图片分类并归为一组
  3. 网友上传图片后,网页要尽快返回该照片所在的组。

A:假设你现在有一个机器,请写出你的数据结构与处理流程,设计的思路。
B:如果你有多台机器,如果缩短请求的时间?

A:我们可以把它分解为两个部分,一个是数据结构一个是上传流程。

(1). 对于数据结构来说,一个是对儿童信息进行包装,另一个是实现儿童信息的高效查找。对于儿童信息包装类来说,除了加入儿童的图片,姓名,生日等基本信息外,特别要注意重写equals与hashCode,这个equals就是题目所说的比较函数。对于查找的实现来说,首先我们建立一个HashSet,用于存储儿童信息。网友上传后,服务器通过对图像计算出特征Hash值,并查Hash表,如果HashCode相同,则返回所在的组;如果不相同,就加入hash表中。(2). 对于多图上传问题,使用生产者-消费者阻塞队列就可以实现尽快的依次返回照片所在的组。

B:

  1. 考虑将Hash计算后取余,分配给其他计算机进行存储与查询,我就想到这一个…..其它的嘛,大家帮我想想。
  2. 常规做法,Select反代,管道回调

TOP10的实现

搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。
假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请你统计最热门的10个查询串,要求使用的内存不能超过1G。

这个题目与上个题目类似,我们选择使用HashMap,key为查询值,val为计数,内存使用为 3 * 256 M == 768M < 1G,然后我们不断的put就可以了,伪代码

HashMap<String, Integer> map = new HashMap<String,Integer>();
//如果内存再多点的话,我们就可以把初始化容量凑个1024的整数,减少扩容损失。

while(fileLog.hasNext()){
    String queue = fileLog.next();
    map.put(queue, map.get(queue) + 1);
}

接着使用堆排序遍历即可,堆的大小为10,复杂度为10xO(LogN)。

综上,O(10^7) +10 * O(Log(3×10^6));

使用WeakHashMap作为缓冲

在动态代理等耗时操作中,为了实现复用,使用了HashMap实现缓存,下面的一个例子是Picasso的Transformation操作

public final class PaletteTransformation implements Transformation {
    private static final PaletteTransformation INSTANCE = new PaletteTransformation();
    private static final Map<Bitmap, Palette> CACHE = new WeakHashMap<>();

    public static PaletteTransformation instance() {
        return INSTANCE;
    }

    public static Palette getPalette(Bitmap bitmap) {
        return CACHE.get(bitmap);
    }

    private PaletteTransformation() {
    }

    @Override
    public Bitmap transform(Bitmap source) {
        Palette palette = Palette.generate(source);
        CACHE.put(source, palette);
        return source;
    }

    @Override
    public String key() {
        return "PaletteTransformation";
    }
}

附录一:集合中元素的排序方式:

刚刚对比SparseArray与HashMap,我怕各位绕进去了,讲下Java中集合的排序方式

  • 按照添加/删除的顺序,比如FIFO,FILO,常见的比如Queue,Stack就是按照这个顺序实现的;
  • 按照Hash表进行排序,比如HashMap的table中的元素是通过hash运算随机均匀排列的(至少理论上是),可以通过计算Key的Hash值快速查找到Value
  • 按照自定义的排序,比如按照数字大小,拼音首字母排序,常见的有线性顺序表,二叉查找树,以及高度封装好的TreeMap,它们需要实现Comaparable接口以进行进一步的自定义CompareTo操作。

附录二:hashCode, == ,equals, Comparable的区别?

  • == : 这个就是内存地址的比较,从C语言开始我们就知道这个了。
  • hashCode/equals:对于Object来说,hashCode就是地址的取样运算,而equals就是判断地址是否相同;在实际使用中,特别是在集合(Set)中,特别是HashSet,为了防止插入的是两个相同的值,我们更注重内容上的对比而不是地址的对比,需要通过计算hashCode来判断是否equals,所以两个方法要同时重写,比如String。
  • Comparable: 在集合需要排序(SortSet)的情况下,就需要给对象实现Comparable接口,比如TreeMap。这个在Android开发中实际需要手动写的情况并不多,毕竟包多,在ORM框架中一般都帮你写好了。

HashMap在Android项目中的使用

  1. Bundle与Parcelable,实际上就是Android中对HashMap与Serializable的接口实现(当然底层已经是IPC了);
  2. 反射时使用WeakReference做缓存,比如Retrofit等动态代理框架;
  3. 广播的底层结构是HashMap<IBinder, ReceiverList>;

后记

看这种JDK源码又累又欣赏,特别是if((n.next=p) != null)这样的代码频频出现却忘了运行顺序,说明了自己的基础不足(然并卵,现在已经能写出这种WTF的代码了)。这是我第一次写分析而不是写过程,希望有问题能够提出,无论是文章排版还是技术上的问题都可以提出来。

最后打个广告
我目前正在准备技术面试,所以关于Java所有的文章有个总结,不妨关注一下

Reference

  1. http://bigocheatsheet.com/
  2. https://github.com/android-cn/android-discuss
  3. http://www.ibm.com/developerworks/cn/java/j-jtp08223/
  4. http://www.jianshu.com/p/9a48bcbdfece
  5. http://www.jianshu.com/p/4e13fcdf9ab5
  6. http://blog.csdn.net/zimo2013/article/details/39692245
  7. http://coderbee.net/index.php/java/20131018/519
  8. http://www.guokr.com/blog/747926/
  9. http://blog.csdn.net/v_JULY_v/article/details/6256463


相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部