非线程安全 Map 简谈

Map 框架

Map 框架集成于 Map 接口,除了早期的 Map 类,其他类集成于 AbstractMap 抽象类,这个类实现了 Map 接口的通用功能。

HashTable 是早期 Java 提供的线程安全哈希映射表,对应于 Vector 类。

LinkedHashMap 采用双向链表 + 哈希映射实现的,兼具两者的优点,可以在常数级时间实现插入、删除、查找等操作,重写 removeEldestEntry 方法可以根据自定义规则清理最不常被访问的对象,简单的 LRU 算法实现可以直接套用这个类。

TreeMap 底层是红黑树和链表。实现了 NavigableMap,有序且实现类似双向队列的 Map 版功能。

LinkedHashMap 和 TreeMap 的顺序

这两个类都能维持一定的顺序,但是这个“顺序却大不相同”。

LinkedHashMap 维持的是访问的顺序, put、get、compute 等操作都算是访问。

TreeMap 维持是整体的顺序,是根据键的顺序关系决定的。这个顺序可以通过 Comparator 或者 Comparable 定制。

hashCode() 和 equals() 及 compareTo()

这三个方法其实是有约定的,写程序时应注意维持一种关系:hashCode 相等的对象,equals() 方法 和 compareTo() 方法返回值应该是 true 和 0。

在 map 里就利用这个前提做了一些事,hashMap 的性能依赖于 hashCode 和 equals 的有效性,在树化后也是用 hashCode 决定记录应该放在树节点的左边还是右边。如果这个前提不成立,map 中的记录就可能分布得不均衡,性能恶化。TreeMap 中要求 compareTo 的返回值的关系需要和 equals 一致,不然就会出现模棱两可的情况。

hashMap 源码简析

内部实现

hashMap 内部可以看作是数组 (Node<K,V>[] table) 和链表的组合成的复合结构,数组被分为一个个桶 (bucket) ,通过哈希值决定了键值在这个数组的寻址;哈希值相同的键值对,则以链表形式存储。在链表长度大小超过阈值 (TREEIFY_THRESHOLD, 8) , 时相应的链表会被改装成红黑树结构。

lazy-load

hashMap 是按照 lazy-load 设计的,她的初始化方法仅仅是设置了一些初始值而已

public HashMap(int initialCapacity, float loadFactor){  
    // ... 
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

初始化和动态扩容都是集中在 resize() 函数中。比如在插入记录时,若是没初始化,会执行 resize() 方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbent,
               boolean evit) {
    Node<K,V>[] tab; Node<K,V> p; int , i;
    if ((tab = table) == null || (n = tab.length) = 0)
        n = (tab = resize()).legth;
    ……
}

以及在元素达到扩容的条件时开始动态扩容

if (++size > threshold)
    resize();

定位记录

具体键值对在哈希表中的位置(数组 index)取决于下面的运算
i = (n - 1) & hash
而这里的 hash 值是 hashMap 二次运算得到的值

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

可以看到这里做了一次处理,将 key 本身的 hashCode 中的高位数据移到低位进行异或运算。

分析 hashMap 的定位数据设计,可以发现:

  1. n 是 table 的长度,是 2 的幂次方,n-1 得到一个二进制低位全是 1 的掩码,与二次哈希后的哈希值进行与操作,即 hashmap 是用二次哈希值得低位作为键值对在数组中的位置。

  2. 因为有些数据计算得出的哈希值差异主要在高位,而在上一点中说了哈希寻址是根据地位来运算的,所以进行了一次高位与低位的运算,避免了类似情况下的哈希碰撞。

  3. hashMap 将性能优化到极致,table 尺寸是 2 的幂次方,使得可以通过位运算进行二次哈希及定位,位运算高速,二次哈希提高性能。

resize() 方法的细节

final Node<K,V>[] resize() {
    // ...
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPAITY)
        newThr = oldThr << 1; // double there
       // ... 
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {  
        // zero initial threshold signifies using defaultsfults
        newCap = DEFAULT_INITIAL_CAPAITY;
        newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;
    }
    if (newThr ==0) {
        float ft = (float)newCap * loadFator;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
    }
    threshold = neThr;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
    table = n;
    // 移动到新的数组结构 e 数组结构 
   }

依据 resize 源码,可以发现:

  • 理论最大容量是 10 亿多个键值对,由 MAXIMUM_CAPACITY 指定,数值是 1<<30,即 2 的 30 次方。
  • 门限值等于(负载因子)x(容量),如果我们构建 HashMap 时没有指定它们,那就是依据相应的常量值 16。
  • 门限值是以倍数进行调整( newThr = oldThr << 1)。配合上一点,可以知道初始不指定初始值的 hashMap,在键值对达到 16 * 0.75 = 12 个时,就会达到门限值,触发 resize() 方法,门限值扩容到 24 。
  • 扩容后需要将老的数组中的元素重新放置到新的数组,这是扩容的主要开销。同时这个扩容是非线程安全的,再此时若是有并发添加记录的操作,可能会触发死循环。

容量、负载因子

容量和负载因子决定了可用的桶的数量,空桶太多会浪费空间,如果使用太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那 hashMap 就退化成一个链表,完全不能提供常数时间的操作性能。

对于容量,如果预先知道要存取的键值对数量,或者数量级别,可以考虑预先设置合适的容量大小。具体数值可以根据扩容发生的条件来做简单的预估,容量的计算条件为:

负载因子 * 容量 > 元素数量

即预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时是 2 的幂数。

对于负载因子:

  • 如果没有特别的要求,不要轻易进行更改,因为 JDK 默认的负载因子是非常符合通用场景的需求的。
  • 如果需要调整,建议不要设置超过 0.75 的数值,因为会显著增加冲突,降低 HashMap 的性能。
  • 如果使用太小的负载因子,按照上面的扩容条件公式,预设的容量值也应该进行调整,否则可能导致更加频繁的扩容,增加无谓的性能开销,本身访问的性能也会受限。

树化

当某个桶中键值对的数量达到树化的门限值,就会触发扩容或者树化,树化的逻辑主要在 putVal 和 treeifBin 中。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 树化改造逻辑
    }
}

根据 treeifBin 中的逻辑,可以理解为当桶中的键值对的数量大于 TREEIFY_THRESHOLD 时:

  • 如果容量小于 TREEIFY_THRESHOLD ,即桶的数量少于 64 个,则会进行简单的扩容。
  • 如果容量大于 TREEIFY_THRESHOLD,则会触发树化改造。

树化的原因很多,提高性能和维护安全是主要的目的。如果在元素放置的过程中,大量的对象哈希冲突,被放到同一个桶里,则会形成一个很长的链表,而链表的查找时间是线性的,会严重影响性能。在现实世界中这种构造哈希冲突导致服务器 CPU 大量占用的攻击,叫做哈希碰撞拒绝服务攻击。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容