hashmap学习记录

88 阅读5分钟

HashMap基础

Hashmap的结构是由数组、链表(以及1.8版本新增的红黑树)组成。
节点结构

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; //hash值
    final K key; //键值对中键的值
    V value; // 键值对中的值
    Node<K,V> next;//指向下一个节点的引用

hash运算

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

如果对象为空,则值为0。如果对象非空则在hashmap中的hash值是hashcode和hashconde的前16位做扰动计算。 即(h = key.hashCode()) ^ (h >>> 16)这部分。防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。

hashmap的数据结构

image.png 如图就是一个hashmap的数据结构。
当发生哈希冲突的时候就会比较key再决定是插入的后方还是改值。

红黑树结构的引入是因为如果链表的长度过长的话对于节点的搜索查询就会变得缓慢,所以引入红黑树结构提升搜索速度(查找时间复杂度为 O(logn) )。产生红黑树的条件是该链表的长度大于8并且数组的长度大于64,否则如果不是大于64的话就会扩容而不是生成树结构。

添加节点的过程

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 onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;//创建方法要使用的对象列表引用
    //引用复制,再判断是否为空,为空则调用resize方法扩容。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
        //如果找到的数组中的位置为空,直接创建节点加入即可。p节点引用
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    //否则
        Node<K,V> e; K k;
        //如果原来位置的节点的hash值与要加入的节点的hash值相同
        //并且key值相同或者key非空并且内容不相同
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //将旧的元素整体对象赋值给e,用e来记录
            e = p;
            //否则(hash值不相等的情况)判断是否是树结构
        else if (p instanceof TreeNode)
        //树结构增加节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        //否则就是链表结构,for循环遍历
            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;
                }
                //节点key相同,value替换
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }   
                //若结点为null,则不进行插入操作
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //记录的修改次数增加
    ++modCount;
    //扩容判断
    if (++size > threshold)
        resize();
        //插入后调
    afterNodeInsertion(evict);
    return null;
}

hashmap中的计算

hash值的计算前面已经说过了,下面说说添加节点时的计算,也就是(n - 1) & hash这部分。这部分计算的目的是为了对hash值取余计算在数组中的位置,n为数组的长度,为什么要用n-1?为什么要用与操作?这些问题的关键就在于hashmap需要提升计算的速度。

  • 与操作是位运算,比取模运算要快的多
  • 存在这么一个计算公式:X % 2^n = X & (2^n - 1)。 也就是说当数组的长度为2的n次方时,一个值对其取余和对其值-1做位运算结果一样。
  • 由于数组长度为2的n次方,所以长度-1以16位例用二进制表示就是1111。做与运算就是取hash的后四位。

扩容

当HashMap中的元素个数超过数组长度loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值是0.75,这是一个折中的取值。
怎么进行扩容
当元素个数超过临界值时就会发生扩容,容量变为原来的两倍,节点也要重新计算位置。
hashmap是怎么计算位置的?由上面的内容我们不难看出扩容后hash的取值位又向前提了一位,所以原来的节点只要看这新一位就行了,要么不变,要么变成 旧值+扩容大小。这种方法进一步提升了运算速度。

所以hashmap扩容变为原来的2倍就是为了保持位运算,hashmap创建大小的时候也是为了这个原因所以大小只能初始化为2的n次方。默认大小为16是一个经验值,过小容易碰撞,过大则会浪费空间。

安全问题

1.7,扩容时的头插法在多线程情况下发生循环引用。
1.8 扩容时发生原子性错误,导致写入的数据被覆盖。

怎样实现线程安全?

使用下面三种类:

  • Hashtable
  • ConcurrentHashMap
  • Synchronized Map

hashtable
和hashmap差不多,但里面的方法几乎都用synchronize修饰了,而且部分属性使用volatile修饰。一个线程执行put获取了对象锁,另一个线程要执行get也要获取对象锁,这样第二个线程获取不了,无法执行,这就导致很严重的性能问题,现在hashtable已经被弃置不用了。
ConcurrentHashMap
1.7和1.8不一样,看我写的另一篇。 最常用的
Synchronized Map
内部有一个属性,互斥量mutex:

final Object      mutex; 

具体操作如下:


synchronized (mutex) {``return` `m.``get``(key);}

} 

对这个互斥量加锁实现线程安全。 优点:代码实现十分简单,一看就懂

缺点:从锁的角度来看,基本上是锁住了尽可能大的代码块.性能会比较差.