容器的源码(HashMap与Hashtable)

容器的源码(HashMap与Hashtable)

哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景非常丰富,许多缓存技术(比如memcached)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。本文首先介绍了一下哈希表这种数据结构,然后对JDK8的HashMap源码进行分析。

值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。

Map map = Collections.synchronizedMap(new HashMap());

在JDK1.8之前,HashMap采用数组+链表实现,使用链表来处理冲突,同一hash值的节点都存储在一个链表里。但是当hash值相等的元素越来越多时,链表也会越来越长,通过key值依次查找的效率也会越来越低。

为了解决链表过长导致查询效率变低而问题,在JDK1.8中,当链表长度超过阈值(8)时,会将链表转换为红黑树,这样解决了链表太长导致查询变慢的问题,大大减少了查找时间。

整个底层的数据结构和Redis中的字典非常像

  1. JDK1.8之后HashMap底层是数组+链表+红黑树
  2. HashMap线程不安全,我们可以使用Collections.synchronizedMap包装为线程安全HashMap或者使用HashTable,CurrentHashMap
  3. HashMap的默认初始容量为16,加载因子是0.75,填充度达到75%后,会扩容至原来的2倍

总结:1.8之后,同一个hash值的节点大于8时,会转为红黑树存储

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NxoX8nbB-1672825617902)(%E5%AE%B9%E5%99%A8%E7%9A%84%E6%BA%90%E7%A0%81%EF%BC%88HashMap%E4%B8%8EHashtable%EF%BC%89%20817362245ecc4cdc9a4ea6b9d8033e0a/Untitled.png)]

被 transient 所修饰 table 变量

如果大家细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?

这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:

  1. table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间
  2. 同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。

以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。

重要字段:

//实际存储的key-value键值对的个数
transient int size; 

//阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到
int threshold; 

//负载因子,代表了table的填充度有多少,默认是0.75,值越大,空间开销越小,查询成本越大
final float loadFactor; 

//用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
transient int modCount;

构造方法:

HashMap()创建一个初始容量为16,默认加载因子为0.75的空HashMap

HashMap(int initialCapacity)创建一个默认加载因子为0.75,自定义容量的空HashMap

HashMap(int initialCapacity,float loadFactor)创建一个自定义加载因子,自定义容量的空HashMap

HashMap(Map<?extends K,?extends V> m)使用与指定Map相同的映射构造一个新的HashMap

HashMap有4个构造器,最后一个很少使用,这里就不讲了。其他构造器如果用户没有传入initialCapacity 或者loadFactor这两个参数,会使用默认值,initialCapacity默认为16,loadFactory默认为0.75。

但是有一点要注意,在进行初始化操作时,只是初始化了创建数组的相关参数,并没有真正创建动态数组。真正动态数组的创建是在第一次进行数据写入时引发的。

这样实际上是一种懒加载操作,防止了初始化后而不用的内存浪费。

我们在一般情况下,都会使用无参构造方法创建 HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值有时可能达不到我们的要求,这个时候我们就需要手动调参。在 HashMap 构造方法中,可供我们调整的参数有两个,一个是初始容量 initialCapacity,另一个负载因子 loadFactor。通过这两个设定这两个参数,可以进一步影响阈值大小。但初始阈值 threshold 仅由 initialCapacity 经过移位操作计算得出。他们的作用分别如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gsfn2SRz-1672825617904)(%E5%AE%B9%E5%99%A8%E7%9A%84%E6%BA%90%E7%A0%81%EF%BC%88HashMap%E4%B8%8EHashtable%EF%BC%89%20817362245ecc4cdc9a4ea6b9d8033e0a/Untitled%201.png)]

publicHashMap(int initialCapacity,float loadFactor){
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(2^30)
//如果初始容量<0,直接抛异常
if(initialCapacity< 0)thrownew IllegalArgumentException("Illegal initial capacity: "+initialCapacity);//初始容量最大不能超过MAXIMUM_CAPACITY = 1<<30(2^30)
if(initialCapacity> MAXIMUM_CAPACITY)initialCapacity= MAXIMUM_CAPACITY;//如果加载因子<0或者不是浮点数,抛异常
if(loadFactor<= 0|| Float.isNaN(loadFactor))thrownew IllegalArgumentException("Illegal load factor: "+loadFactor);//对初始容量赋值
this.loadFactor= loadFactor;//对阔值赋值
this.threshold= tableSizeFor(initialCapacity);}

tableSizeFor()方法是JDK8出现的,它的作用是返回大于输入参数且最近的2的整数次幂的数
。比如输入3,则返回4;输入5,则返回8。这里的算法很是巧妙,对于性能有很大提升

/**
     * Returns a power of two size for the given target capacity.
     */
staticfinalinttableSizeFor(int cap){int n= cap- 1;n|= n>>> 1;n|= n>>> 2;n|= n>>> 4;n|= n>>> 8;n|= n>>> 16;return(n< 0)? 1:(n>= MAXIMUM_CAPACITY)? MAXIMUM_CAPACITY: n+ 1;}

对初始化容量的修改

说完了初始阈值的计算过程,再来说说负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键与键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。

put()

public V put(K key, V value) {
  //首先对key进行了Hash,然后直接调用putVal()方法
  return putVal(hash(key), key, value, false, true); 
}

/**
 * Implements Map.put and related methods 
 * 
 * @param hash 键的哈希值
 * @param key  键
 * @param value 值
 * @param onlyIfAbsent 如果为true,不改变已经存在的值
 * @param evict 如果为true,处于创建模式.
 * @return 值
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
  //申明变量 tab:临时数组 p:数组中的节点 n:存放老的容量 i:tab数组的下标
  Node<K,V>[] tab; 
	Node<K,V> p; 
	int n, i;

  //如果table为null或者长度为0,进行初始化分配大小
  if ((tab = table) == null || (n = tab.length) == 0){ 
    n = (tab = resize()).length;
  }

  //(n - 1) & hash 计算出下标,如果该位置为null 说明没有碰撞,将value封装为一个新的Node并赋值
  if ((p = tab[i = (n - 1) & hash]) == null){
    tab[i] = newNode(hash, key, value, null);
  }else { 
		//反之,碰撞了
    Node<K,V> e; K k;
    //首先判断key是否存在,如果是,覆盖原来的值
    if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))){
      e = p;
    }
    //判断是否为红黑树
    else if (p instanceof TreeNode){
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    }else { //是链表
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          //将next指向新的节点
          p.next = newNode(hash, key, value, null);
          //binCount >= TREEIFY_THRESHOLD - 1 binCount>=7,链表长度为8时,转变为红黑树,结束循环
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;  
        }
        //如果链表中已经存在该key,结束循环
        if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        //将e赋值给p,此处没明白为什么,p变量后面没有在使用过
        p = e;
     }
  }  
    if (e != null) { // existing mapping for key
       V oldValue = e.value;
       if (!onlyIfAbsent || oldValue == null) 根据规则选择是否覆盖value
          e.value = value;
       afterNodeAccess(e);
       return oldValue;
    }
  }
  //fail-fast相关,迭代时会保存一份modCount,每次遍历都会比较该值和保存的值是否相等,不相等则抛出异常  
  ++modCount;
  //如果size >阔值,扩容
  if (++size > threshold)
  resize();
  afterNodeInsertion(evict);
  return null; 
}

put()方法涉及的成员变量或成员方法

成员变量transient Node[] table

table是HashMap用来实际存放元素的数组,它在首次使用时会被初始化。它的长度始终是2的幂次方。在某些操作中长度可能为0。

*/**
     * HashMap用来实际存放元素的数组
     */***transient** Node**<**K**,**V**>[]** table**;**

成员变量阔值threshold

/**
     * 触发扩容的值 (容量 * 加载因子).
     *
     * @serial
     */
    int threshold;

静态方法hash()

static final int hash(Object key) {
        int h;
        //先取key的hashCode,然后和其低16位进行异或操作
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
   }

我们知道HashMap的容量是2的幂次方,那么newCap - 1的高位应该全部为0。如果e.hash值只用自身的hashcode的话,那么index只会和e.hash低位做&操作。这样一来,index的值就只有低位参与运算,高位毫无存在感,从而会带来哈希冲突的风险。所以在计算key的哈希值的时候,用其自身hashcode值与其低16位做异或操作。这也就让高位参与到index的计算中来了,即降低了哈希冲突的风险又不会带来太大的性能问题。

简单总结下put()方法做了哪些事情?

  1. 首先判断内部数组是否初始化,如果没有初始化进行初始化操作。
  2. 计算key的数组下标,判断是否发生Hash冲突,如果不冲突,塞值。进入步骤5。
  3. 如果发生Hash冲突,首先判断是否为红黑树,如果是红黑树,塞值。
  4. 如果不是红黑树,说明是链表,将值放入链表,然后判断链表长度,如果长度超过8,将链表转换为红黑树。
  5. modCount++,保障fail-fast
  6. 判断数组长度是否达到阔值,是则进行扩容操作。否结束方法。

向HashMap中写入数据的过程,简单总结起来分为这么几步:

  • 计算要插入数据的Hash值,并根据该值确定元素的插入位置(即在动态数组中的位置)。
  • 将元素放入到数组的指定位置
    • 如果该数组位置之前没有元素,则直接放入
      • 放入该位置后,数组元素超过扩容阈值,则对数组进行扩容
      • 放入该位置后,数组元素没超过扩容阈值,写入结束
  • 如果该数组位置之前有元素,则挂载到已有元素的后端
    • 如果之前元素组成了树,则挂入树的指定位置
    • 如果之前元素组成了链表
      • 如果加入该元素链表长度超过8,则将链表转化为红黑树后插入
      • 如果加入该元素链表长度不超过8,则直接插入

方法resize()

*/**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */***final** Node**<**K**,**V**>[]** **resize()** **{***//保存一份老的数组*
        Node**<**K**,**V**>[]** oldTab **=** table**;***//老数组的容量,如果老数组为null,则是0,否则取length*
        **int** oldCap **=** **(**oldTab **==** **null)** **?** 0 **:** oldTab**.**length**;***//保存一份老的阔值*
        **int** oldThr **=** threshold**;***//初始化新的容量和阔值*
        **int** newCap**,** newThr **=** 0**;***//如果老的容量>0*
        **if** **(**oldCap **>** 0**)** **{***//如果老的容量达到了最大值,不扩容,并且将阔值设置为了Integer的最大值2的31次方-1*
            **if** **(**oldCap **>=** MAXIMUM_CAPACITY**)** **{**threshold **=** Integer**.**MAX_VALUE**;return** oldTab**;}else** **if** **((**newCap **=** oldCap **<<** 1**)** **<** MAXIMUM_CAPACITY **&&**oldCap **>=** DEFAULT_INITIAL_CAPACITY**)***//首先将老的容量*2赋值给新的容量,然后判断新的容量<MAXIMUM_CAPACITY 并且老的容量大于16,将阔值*2*     
                newThr **=** oldThr **<<** 1**;** *// double threshold*
        **}else** **if** **(**oldThr **>** 0**)** *// 如果老的数组容量<=0,但是阔值>0,直接将阔值赋值给新的容量*
            newCap **=** oldThr**;else** **{**               *// 初始化*
            *//新的容量为DEFAULT_INITIAL_CAPACITY 16*
            newCap **=** DEFAULT_INITIAL_CAPACITY**;***//新的阔值为0.75 * 16 = 12*
            newThr **=** **(int)(**DEFAULT_LOAD_FACTOR ***** DEFAULT_INITIAL_CAPACITY**);}if** **(**newThr **==** 0**)** **{***//防止阔值为0,比较好奇这种情况什么时候会出现,知道的同学还请不吝赐教*
            **float** ft **=** **(float)**newCap ***** loadFactor**;**newThr **=** **(**newCap **<** MAXIMUM_CAPACITY **&&** ft **<** **(float)**MAXIMUM_CAPACITY **?(int)**ft **:** Integer**.**MAX_VALUE**);}***//将新的阔值赋值给成员变量*
        threshold **=** newThr**;***//下面是将创建一个新的Node数组,并将老的数组里面的元素赋值到新的数组,这里不详细解读了。*
        @SuppressWarnings**({**"rawtypes"**,**"unchecked"**})**Node**<**K**,**V**>[]** newTab **=** **(**Node**<**K**,**V**>[])new** Node**[**newCap**];**table **=** newTab**;if** **(**oldTab **!=** **null)** **{for** **(int** j **=** 0**;** j **<** oldCap**;** **++**j**)** **{**Node**<**K**,**V**>** e**;if** **((**e **=** oldTab**[**j**])** **!=** **null)** **{**oldTab**[**j**]** **=** **null;if** **(**e**.**next **==** **null)**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**;}}}}}return** newTab**;}**

Get()(查找)

相比于数据写入,数据读取操作要简单一些。总体过程总结为:

  • 根据要取得key的值,hash出数组中的指定位置
  • 取出指定位置的元素(这时,key的hash值是一样的)
    • 如果key也完全一样,则返回该值,查找结束。
    • 如果key不一样,判断其后面挂载的是树还是列表
      • 如果是树,按照树的方法查找
      • 如果是列表,按照列表的方法查找
public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

使用getNode()方法取值,没有返回null

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    //判断是否有元素,没有返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //每次都会check第一个元素是否命中,命中直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //如果有下一个元素    
        if ((e = first.next) != null) {
            //如果是红黑树,从红黑树中取值
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //遍历链表,直到取到值    
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

Node数据节点解析

我们知道HashMap底层维护了一个Node数组,它是最基础的数据节点,接下来便揭开Node数组的神秘面纱。

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

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

可以看到Node类其实非常简单,维护了四个属性 key、value、key的Hash值和下一个节点。我们看下是怎么用的。

上面的在put()方法中已经提到过,当我们put一个key-value时,如果key不存在,或者说没有发生哈希冲突时,就会new一个新的节点。

看下newNode方法,非常简单就是调用了Node的构造函数

Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
        return new Node<>(hash, key, value, next);
    }

当发生hash碰撞的时候,首先是以链表的形式存放。实际上就是创建一个新的Node节点,然后复制给之前的Node元素的next属性。

p.next = newNode(hash, key, value, null);

当链表的长度大于8的时候,转化为红黑树,这个时候其实是把Node链表转变为另外一个数组结构ZreeNode。

/**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    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) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

HashMap的初始容量应该如何指定

同ArrayList一样,我们在new Hashmap()的也最好能够指定它的初始容量大小,目的就是为了提升效率,也能在一定程度上节约内存,那么这个初始容量应该如何指定?看过源码后,相信应该已经知道答案。

在HashMap中有一个成员变量threshold,它的计算方式是初始容量*加载因子。当填充度大于threshold,则会进行扩容。所以如果我们在知道或者大致估计HashMap的存放数量之后,除以0.75,在选择大于此结果的最近的2的幂次方即可(这一步可忽略,因为HashMap会自动帮你完成)。

有的同学可能会有HashMap最小容量是16的错觉,其实并不是,16只是我们在没有指定初始容量后,第一次put元素时初始化的容量。我们完全可以将容量指定为2。

扩容操作

在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。

我们知道扩容分两步:

  1. 计算新桶数组的容量 newCap 和新阈值 newThr
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
  3. 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 如果长度过长,则不进行扩展
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 否则,新长度是原来的两倍
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 创建一个新的数组
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 轮询操作,对所有元素重哈希
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 树元素重hash
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 略:链表元素重hash
                }
            }
        }
    }
    return newTab;
}

第一步操作只需增加一块存储区域而已,而第二步操作则需要消耗巨大的计算资源。如果扩容前已经存在5万个元素,则需要把这5万个元素的hash值重新计算一遍,并根据新的结果移动它的位置。该操作叫做重哈希操作,是一次代价极高的操作。

因此,能提升重哈希的性能变得非常重要。

而数组大小必须是2^n,就可以提升重hash的性能。

Hash函数

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

最终,我们得到一个int数字。而这个数字最终要被映射到数组的某个位置i。

数组长度为n,则i的计算为:

tab[i = (n - 1) & hash]

该计算在HashMap源码中出现了多次。

(n-1)为数组中的最大位置,hash为哈希的结果,两者进行了逻辑与操作。我们到二进制中去理解,那逻辑与操作我们可以理解为hash值的二进制数在(n-1)的二进制数上的求交集操作,我们即为p。最终得到的结果肯定小于等于(n-1)。

那在扩容时,n变为原来的两倍大小,记为m。那在二进制上,m就相当于在n的二进制基础上高位增加了一个1。那么,hash的二进制数和m的二进制数求交集后结果记为q。则除了最高位以外,q和hash值的二进制数在(n-1)的二进制数上的求交集的结果p是一致的。因此,q要么等于p,要么等于p+n。

以上两段的结论简单说来就是:设原来table的长度为a,扩展后变为b,且b=2a。则会将原来table[i]中的元素,经过重hash后,会分拆到新的数组newTable[i]和newTable[i+a]这两个位置上。因此,这样就减少了计算和移动量。

删除

HashMap 的删除操作并不复杂,仅需三个步骤即可完成。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。相关源码如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍历链表,找到待删除节点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 删除节点,并修复链表或红黑树
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

HashTable

Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。

Hashtable也是JDK1.0引入的类,是线程安全的,能用于多线程环境中。

Hashtable同样实现了Serializable接口,它支持序列化,实现了Cloneable接口,能被克隆。

针对Hashtable,我们同样给出几点比较重要的总结,但要结合与HashMap的比较来总结。

1、二者的存储结构和解决冲突的方法都是相同的。

2、HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。

3、Hashtable中key和value都不允许为null,而HashMap中key和value都允许为null(key只能有一个为null,而value则可以有多个为null)。但是如果在Hashtable中有类似put(null,null)的操作,编译同样可以通过,因为key和value都是Object类型,但运行时会抛出NullPointerException异常,这是JDK的规范规定的。

4、Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
5、Hashtable计算hash值,直接用key的hashCode(),而HashMap重新计算了key的hash值,Hashtable在求hash值对应的位置索引时,用取模运算,而HashMap在求位置索引时,则用与运算,且这里一般先用hash&0x7FFFFFFF后,再对length取模,&0x7FFFFFFF的目的是为了将负的hash值转化为正值,因为hash值有可能为负数,而&0x7FFFFFFF后,只有符号外改变,而后面的位都不变。

6.HashTable的所有方法都是synchronized修饰

7.实现方式:
 HashMap用数组+链表+红黑树 <–>
 HashTable用数组+链表

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值