Java Map

Map——广义集合的子集

HashTable是早期Java类库提供的一个哈希表实现,扩展了Dictionary类,类结构上与HashMap明显不同,本身是同步的,不支持null键和值,由于同步导致的性能开销,已经很少被推荐使用。

HashMap是应用广泛的哈希表实现,扩展了AbstractMap类,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持null键和值等。HashMap进行put或者get操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选。在Java 8里,HashMap本身发生了非常大的变化。

LinkedHashMap提供的是遍历顺序符合插入顺序,它的实现是通过为键值对维护一个双向链表。通过特定构造函数可以创建反映访问(put、get、compute)顺序的实例。这种行为适用于一些特定场景,比如构建一个空间占用敏感的资源池,希望可以自动将最不常被访问的对象释放掉,就可以利用LinkedHashMap提供的机制来实现。

TreeMap则是基于红黑树的一种提供顺序访问的Map扩展了AbstractMap类,和HashMap不同,它的get、put、remove操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序Comparable来判断。为了避免模棱两可的情况,自然顺序同样需要符合一个约定,就是compareTo的返回值需要和equals一致。当不遵守约定时,两个不符合唯一性(equals)要求的对象被当作是同一个(compareTo返回了0),这会导致歧义的行为表现。

// 用TreeMap的put方法实现举例
public V put(K key, V value) {
    Entry<K,V> t = …
    cmp = k.compareTo(t.key);
    if (cmp < 0)
        t = t.left;
    else if (cmp > 0)
        t = t.right;
    else
        return t.setValue(value);
        // ...
   }

注:

1. HashTable本身比较低效,它的实现就是将put、get、size等方法加上synchronized,导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。

2. 大部分使用Map的场景,通常是放入、访问、删除,对顺序没有特别要求,HashMap在这种情况下基本是最好的选择。HashMap的性能表现非常依赖于哈希码的有效性,hashCode和equals的一些基本约定如下,

  • equals相等,hashCode一定要相等。
  • 重写了hashCode也要重写equals。
  • hashCode需要保持一致性,状态改变返回的哈希值仍然要一致。
  • equals的对称、反射、传递等特性。

HashMap剖析

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

从非拷贝构造函数的实现来看,这个数组似乎并没有在最初就初始化好,仅仅设置了一些初始值。HashMap是按照 lazy-load 原则在首次使用时被初始化(拷贝构造函数除外)。

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

put方法里只有一个 putVal 调用,putVal方法本身逻辑非常集中,从初始化、扩容到树化,全都和它有关

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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()).length;
    if ((p = tab[i = (n - 1) & hash]) == ull)
        tab[i] = newNode(hash, key, value, nll);
    else {
        // ...
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first 
           treeifyBin(tab, hash);
        //  ... 
     }
}
  • 如果数组是null,resize方法会负责初始化它,这从tab = resize()可以看出。
  • resize方法有两个职责,创建初始存储数组,或者在容量不满足需求的时候,进行扩容(resize)。
  • 在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。
  • if (++size > threshold)
        resize();
  • 具体键值对在哈希表中的位置(数组index)取决于下面的位运算。哈希值的源头并不是key本身的hashCode,而是HashMap内部的另外一个hash方法。为什么这里需要将高位数据移位到低位进行异或运算呢?因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,这种处理可以有效避免类似情况下的哈希碰撞。
  • i = (n - 1) & hash
    
    // 上面说的hash方法
    static final int hash(Object kye) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
    }
  • 前面的链表结构(这里叫bin)会在达到一定阈值时发生树化,为什么HashMap需要对bin进行处理?本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,就会形成一个链表,链表查询是线性的,严重影响存取的性能。在现实世界构造哈希冲突的数据并不复杂,恶意代码可以用这些数据大量与服务器端交互,导致服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。

resize方法,不考虑极端情况(容量理论最大极限由MAXIMUM_CAPACITY指定,数值为 1<<30,2的30次方),可以归纳如下,

  • 阈值等于(负载因子)x(容量),如果构建HashMap时没指定它们,那就用相应的默认常量值。
  • 阈值通常是以倍数进行调整 (newThr = oldThr << 1),根据putVal中的逻辑,当元素个数超过阈值时就调整Map大小。
  • 扩容后,需要将老数组中的元素重新放置到新数组,这是扩容的一个主要开销来源。
final Node<K,V>[] resize() {
    // ...
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
                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数组结构 
   }

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

关于容量选择,如果能知道HashMap要存取的键值对数量,可以预先设置合适的容量大小。具体数值可以根据扩容发生的条件来简单预估,根据前面的分析知道它要符合计算条件,所以预先设置的容量要满足大于“预估元素数量/负载因子”,同时它是2的幂数。

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

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

树化逻辑主要在 treeifyBin 方法中,当 bin 的数量大于 TREEIFY_THRESHOLD 时,

  • 如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单地扩容。
  • 如果容量大于 MIN_TREEIFY_CAPACITY ,则会进行树化改造。
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) {
        //树化改造逻辑
    }
}

同步的包装容器(Collections.synchronizedMap)

HashMap不是线程安全的,并发情况会导致类似CPU占用100%等一些问题,Collections提供的同步包装器利用输入Map构造了另一个同步版本,所有操作虽然不再声明为synchronized方法,但是还是利用了this作为互斥的mutex,没有真正意义上的改进。HashTable或者同步包装版本都用的是粗粒度的同步方式,只适合非高并发场景。

private static class SynchronizedMap<K,V>
    implements Map<K,V>, Serializable {
    private final Map<K,V> m;     // Backing Map
    final Object      mutex;        // Object on which to synchronize
    // …
    public int size() {
        synchronized (mutex) {return m.size();}
    }
 // … 
}

思考个问题,高并发下Map场景该怎么处理呢?

Have Fun

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值