HashMap原理分析

作者: 徐志毅 | 来源:发表于2018-03-28 16:30 被阅读0次

初探

HashMap在java中的地位似 学界泰斗 深不可测,又如 三岁孩童 众人皆知。一个入门级别的集合类,在平时编程时都感觉so easy,但每每在面试时都感觉so hard。到底HashMap中隐藏着什么奥秘呢,今天尽量为大家揭开面纱!
  • 本文会结合Jdk1.8的优化,对比HashMap和HashTable的区别,来更好的分析HashMap.
  • 本文不纠结各名词的定义,只用追求用通俗的话把事情解释清楚,想更清楚了解概念的自己单独去百度、谷歌。
  • 文中不当之处,还望指正。

认识数据结构

1、数组
                             数组结构示意
 -----------------------------------------------------------------
|  object  |  object  |  object  |  object  |  object  |  object  |
 -----------------------------------------------------------------
     ↑           ↑         ↑          ↑           ↑          ↑
  index1      index2     index3     index4     index5     index6  

数组中的元素存储在一个连续性的内存块中,数组声明必须声明数组长度,因此在new一个数组的时候jvm就能计算出所需空间,并未其分配内存。通过数组的下标访问数据是相当高效的,比链表要快很多,因为根据下标和数组元素大小可直接计算出数据偏移量,直接定位数据。

2、链表
 --------      --------      --------      --------      --------
|  Node  | -> |  Node  | -> |  Node  | -> |  Node  | -> |  Node  |
 --------      --------      --------      --------      --------
            ↑
  指针(引用),也就是经常看到的类似 next 的东西,
  单向链表每个对象持有下一个对象的指针,
  双向链表还会有指向上一个对象的指针。

链表这种数据结构很好定义,HashMap里定义为:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;  //hash值
        final K key;  //键
        V value;      //值
        Node<K,V> next;    //下一个Node指针
}

链表的访问速度比数组要慢很多,因为必须要从前往后遍历的数,要找到第五个必须从第一个往后找,无法直接像数组一样定位。但这中特殊的数据结构,也给链表带来了灵活性,比如插入、删除等操作,可以只用更改前后节点,而数组必须牵一发动全身。
关于数组与链表的比较这里不多说,有兴趣自己去了解,这也是比较基础的东西。

HashMap的数据结构

数组+链表(拉链结构)

     以数组为基础,数组内每个元素都是一个Node链表
 -----------------------------------------------------------------
|   Node   |   Node   |   Node   |   Node   |   Node   |   Node   |
 -----------------------------------------------------------------
      ↓                                ↓  
  --------                          --------     
 |  Node  |                        |  Node  |   
  --------                          --------     
      ↓  
  --------     
 |  Node  |
  --------   

思考两个问题:

  • 如何为每个Node分配指定的数组下标(槽位)?
  • 一个槽位分配到多个Node的时候怎么办?

模拟一个场景来回答上面两个问题:
槽位的确定是根据每个Node的key值的hash取模来确定的,假设node1的key的hash值为23,HashMap里初始化的数组大小为16,那么 23 % 16 = 7,那就将Node1放入tab[7],再假设又来了node2,key值的hash为39, 39 % 16 = 7, 这是tab[7]里已经有了node1,因此将node2放在Node1的后面,即node1.next=node2,这就在数组上构成了链表。
这只是简单的思路模拟,实际的算法还请往下慢慢看

重点:HashMap之所以使用拉链结构,主要基于两个原因。
1.发挥数组的快速下标访问;
2.用链表解决hash槽位冲突

HashMap的初始化

HashMap为我们提供了几个构造方法:

//base jdk1.7
//1.无参构造
public HashMap() {
        // this(16,0.75) 默认capacity=16,load_factor=0.75(影响因子)
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
//2.指定capacity构造
 public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
//3.指定capacity和loadFactor构造
 public HashMap(int initialCapacity, float loadFactor) {
        //...此处省略非法参数校验异常
        this.loadFactor = loadFactor;
       //此处并没有处理capacity,将capacity处理为2的N次方在第一个put的时候触发
        threshold = initialCapacity; 
        init(); //子类实现,钩子
    }

HashMap在初始化时,并没有初始化内部数组,这也是一种惰性策略,内部数组的初始化会在第一次put的时候触发:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);  //填充数组
        }
        //...其他暂时省略
    }

 private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize  
        // 找到大于等于toSize的2次幂
        int capacity = roundUpToPowerOf2(toSize);
        //threshold 阀值,控制实际hashmap的容量,capacity*影响因子
       //hashmap的实际存放元素个数超过threshold时会进行扩容
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }

抛个问题:capacity的大小为什么必须是2的幂次?

HashMap 之 put 方法详解

再次祥看put方法:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold); //初始化填充数组
        }
        if (key == null)
            //允许放入null键,而HashTable会直接抛出异常
            return putForNullKey(value); 

       //重新计算hash,HashTable直接使用key的hash
       //至于为什么需要重新计算hash,这里涉及位运算,简单的说就是让高位参与取模,
       //使hash散列更均匀,避免hash碰撞
        int hash = hash(key);

       //快速计算在数组中的下标,这里的快速二字是有意义的,利用2的幂次的特性,采用高效的位运算求模,这是上面为什么capacity的大小为什么必须是2的幂次的答案。
        int i = indexFor(hash, table.length);

        //遍历table[i]上的链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key的hash值以及 key的引用地址或key的equals方法 判断key相等后,用新value替换老value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        //代码走到这里说明是个新key-value,需要增加
        modCount++; //modCount 记录hashmap的数据结构变动次数,很多地方会使用这个判断hashmap的是否有增加或者删除等操作。
        addEntry(hash, key, value, i); //新增Entry
        return null;
    }

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length); //触发扩容
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex); //新建Entry
    }

 void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        //这里真正新建,注意一点,新建的Entry在老的Entry前面,而jdk1.8新的在老的后面
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;  //老的Entry,被作为next放在新的后面
            key = k;
            hash = h;
        }

这里差不多该回答capacity的大小为什么必须是2的幂次了,先把 上述代码的 indexFor(hash, table.length); 方法展开:

 static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        //  这里的快速求模算法要求length必须是2的幂次方,不然不好使
        //  常规求模算法效率较这个位运算,效率差太多
        return h & (length-1);
    }

HashMap规定capacity必须为2的幂次,关键原因就是为了提高求模速度,在老的HashTable中,并无此要求,所以其求模还是使用 % ,效率较低。除此之外,jdk1.8还利用capacity的特性,重写了更高效的扩容算法,有兴趣的可以去看1.8的resize()方法;

//HashTable的求模
int index = (hash & 0x7FFFFFFF) % tab.length;

HashMap 之 get 方法详解

读到这里应该对HashMap有了一定的认识,get方法大家心里估计都有个数了,就是取key的hash值求模,找到下标,然后遍历下标上的链表,用hash和equals挨个对比,相同者get到了。

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }

 final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

很简答吧!

HashMap 之 红黑树

在jdk1.8中,将hashmap的链表进行了进一步优化,如果单个槽位上的链表长度大于等于8时,会将链表转化为红黑树。至于红黑树是什么,简单来讲就是一个几乎平衡的二叉查找树,比链表的查找效率高很多,就不展开讲解了。看1.8的代码:

 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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            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) {
                        p.next = newNode(hash, key, value, null); //放在了尾部,与1.7不一样
                           // TREEIFY_THRESHOLD = 8
                        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;
                }
            }
            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 和 HashTable 的一些区别

  • 线程安全:HashTable使用简单粗暴,使用简单的方法级同步锁,而HashMap完全不管线程安全,所以HashMap不适合多线程下使用,非多线程不可,需考虑其他并发集合;
    ps :方法级同步锁锁住的是运行时this对象,不同方法之间也是互斥的!
  • 效率:HashMap的效率不仅仅是快在无锁上,还有index算法,HashMap采用高效位运算计算index,效率更高,再就是1.8的红黑树和resize()优化,也提高了查找效率;
  • Capacity:HashMap要求capacity必须为2的幂次方,HashTable无次要求;
  • 散列:HashTable直接使用key的hash值,而HashMap对key的hash值进行位运算,散列更加均匀;

其实现在HashTable留给我的几乎只剩研究对比价值了,使用时其他新的集合框架都有更优秀的表现。

关于HashMap今天先说到这里,把关键点都说了一下,字字原创,希望对各位有参考价值,喜欢的请点关注哦!

相关文章

  • Java HashMap源码简单解析(JDK 1.8)

    简单分析以下HashMap的原理,put和get方法的原理。 HashMap介绍 HashMap继承Map接口,可...

  • Java集合目录

    一、简述 二、原理分析 HashMap(JDK1.7) HashMap(JDK1.8)

  • ConcurrentHashMap 原理和源码分析(一)

    通过之前几篇文章《HashMap原理和源码分析》 《HashTable原理和源码分析》《LinkedHashMap...

  • HashMap,ArrayMap,SparyArray原理解析

    HashMap原理分析深度解读ArrayMap优势与缺陷

  • HashMap源码分析

    本文主要针对HashMap的以下几个问题展开分析,由浅入深,由使用到原理,上下关联,尽量把HashMap的实现原理...

  • HashMap1.8源码分析

    本文主要有关HashMap1.8源码分析 原理和过程 1:HashMap的原理,内部数据结构如何? 底层使用哈希表...

  • HashMap原理分析

    Java知识中最常用的一类数据结构,采用数组+链表的方式进行数据存储。看一下构造方法,下面是无参数的一个构造方法,...

  • HashMap原理分析

    初探 HashMap在java中的地位似 学界泰斗 深不可测,又如 三岁孩童 众人皆知。一个入门级别的集合类,在平...

  • HashMap原理分析

    参考:https://blog.csdn.net/vampirekalus/article/details/797...

  • HashMap原理分析

    本文基于JDK1.7分析 1.数据结构 数据结构中可以用数组和链表存储数据,它们各有利弊。数组:数组存储区间是连续...

网友评论

    本文标题:HashMap原理分析

    本文链接:https://www.haomeiwen.com/subject/dfxycftx.html