zoukankan      html  css  js  c++  java
  • java jdk 中HashMap的源码解读

    HashMap是我们在日常写代码时最常用到的一个数据结构,它为我们提供key-value形式的数据存储。同时,它的查询,插入效率都非常高。 

    在之前的排序算法总结里面里,我大致学习了HashMap的实现原理,并制作了一个简化版本的HashMap。 今天,趁着项目的间歇期,我又仔细阅读了Java中的HashMap的实现。 

    HashMap的初始化: 

    Java代码 
    1. public HashMap(int initialCapacity, float loadFactor)  
    2.   
    3. public HashMap(int initialCapacity)  
    4.   
    5. public HashMap()  
    6.   
    7. public HashMap(Map<? extends K, ? extends V> m)  

    最近看到几篇精彩的文章:

    存取之美 —— HashMap原理、源码、实践

    Hash碰撞与拒绝服务攻击

    这些文章让我收获良多, 但是有些地方说的不够详细, 在此写下本人对上述文章的总结和理解, 希望可以给需要的朋友带来一些帮助.

    1. 概述

    HashMap在底层采用数组+链表的形式存储键值对.

    在HashMap中定义了一个内部类Entry<K, V>, 该内部类是对key-value的抽象. Entry类包含4个成员: key, value, hash, next. key和value的意义很清晰, hash表示key的hash值, next是指向下一个Entry对象的引用.

    HashMap内部维护了一个Entry<K, V>[] table, 数组table中的Entry元素是一个Entry链表的头结点(理解这一点很重要). 

    2. put/get方法

    向HashMap中添加键值对时, 程序会根据key的hashCode值计算出hash值, 然后对hash值取模, 模数是table.length. 假如取模的结果为index, 则取出table[index]. table[index]可能为null, 也可能是一个Entry对象. 如果为null, 则直接存储. 否则计算key.equals(table[index].key), 如果为false, 就取出table[index].next, 继续调用key的equals方法, 直到equals方法返回true, 或者比较完链表中所有Entry对象.

    Java代码 
    1. public V put(K key, V value) {  
    2.     if (key == null)  
    3.         return putForNullKey(value);  
    4.     // 对hashCode值进行二次hash得到最终的hash值  
    5.     int hash = hash(key.hashCode());  
    6.     // 根据hash值定位数组中的索引位置  
    7.     int i = indexFor(hash, table.length);  
    8.     // 遍历table[i]位置处的链表  
    9.     for (Entry<K, V> e = table[i]; e != null; e = e.next) {  
    10.         Object k;  
    11.         // 如果hash值相同且equals返回true, 则替换原来的value值  
    12.         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
    13.             V oldValue = e.value;  
    14.             e.value = value;  
    15.             e.recordAccess(this);  
    16.             return oldValue;  
    17.         }  
    18.     }  
    19.   
    20.     modCount++;  
    21.     // 如果之前函数没有return, 将该键值对插入table[i]链表中  
    22.     addEntry(hash, key, value, i);  
    23.     return null;  
    24. }  

    理解了put方法, 那么get方法就会很容易理解:

    Java代码 
    1. public V get(Object key) {  
    2.     if (key == null)  
    3.         return getForNullKey();  
    4.     int hash = hash(key.hashCode());  
    5.     // 首先根据hash值计算index, 然后取出index处的链表的头结点. 遍历链表.  
    6.     for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {  
    7.         Object k;  
    8.         if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
    9.             return e.value;  
    10.     }  
    11.     return null;  
    12. }  

      

    3. HashMap的容量和索引位置确定

    前面没有叙述HashMap的容量问题, 是因为容量是与索引位置计算紧密相关的.

    理解HashMap的容量就需要关注成员变量size, loadFactor, threshold.

    size表示HashMap中实际包含的键值对个数.

    loadFactor表示负载因子, loadFactor的值越大, 则对table数组的利用率越大, 相当于节省内存空间. 但是loadFactor的值增大, 同时也会导致hash冲突的概率增加, 从而使得程序效率降低. loadFactor的取值应该兼顾内存空间和效率, 默认值为0.75.

    threshold表示极限容量, 计算公式为threshold = (int)(capacity * loadFactor);  当size达到threshold时, 就需要对table数组扩容.

    HashMap的容量大小就是table.length. 由于java中取模是一个效率低下的操作, 所以出于性能的考虑, HashMap的容量被设计为2的N次方. 如此hash%table.length就可以转换为hash&(table.length-1). 与运算的效率比取模运算高效很多.

    Java代码 
    1. public HashMap(int initialCapacity, float loadFactor) {  
    2.     if (initialCapacity < 0)  
    3.         throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);  
    4.     if (initialCapacity > MAXIMUM_CAPACITY)  
    5.         initialCapacity = MAXIMUM_CAPACITY;  
    6.     if (loadFactor <= 0 || Float.isNaN(loadFactor))  
    7.         throw new IllegalArgumentException("Illegal load factor: " + loadFactor);  
    8.   
    9.     // 计算大于initialCapacity的最小的2的N次方数  
    10.     int capacity = 1;  
    11.     while (capacity < initialCapacity)  
    12.         capacity <<= 1;  
    13.   
    14.     this.loadFactor = loadFactor;  
    15.     // 求出极限容量  
    16.     threshold = (int) (capacity * loadFactor);  
    17.     // table的容量被设计为2的N次方  
    18.     table = new Entry[capacity];  
    19.     init();  
    20. }  

    如果使用无参的构造函数创建HashMap, 则容量默认为16, 负载因子默认为0.75.

    indexFor函数用于确定索引位置:

    Java代码 
    1. static int indexFor(int h, int length) {  
    2.     // 当length为2的N次方时相当于h%table.length, 但效率要高效很多  
    3.     return h & (length - 1);  
    4. }  

    4. rehash

    前面提到过, 当size达到threshold时, 就需要对table数组扩容. 调用put函数向HashMap中插入一个键值对时会调用到addEntry(hash, key, value, i)方法:

    Java代码 
    1. void addEntry(int hash, K key, V value, int bucketIndex) {  
    2.     // 取出索引处的Entry对象  
    3.     Entry<K, V> e = table[bucketIndex];  
    4.     // 更新索引处链表的头结点, 并使新的头结点的next属性指向原来的头结点  
    5.     table[bucketIndex] = new Entry<K, V>(hash, key, value, e);  
    6.     // 当size大于threshold时扩容数组, 容量增加至原来的2倍. 保证table的容量始终是2的N次方  
    7.     if (size++ >= threshold)  
    8.         resize(2 * table.length);  
    9. }  

    resize用于扩容数组. 数组的length增加大了, 那么HashMap中已有的键值对就必须重新进行hash, 这就是rehash. 如果不进行rehash, 就会使得put和get时table数组的length不同, 从而导致get方法无法取出原先put存入的键值对.

    Java代码 
    1. void resize(int newCapacity) {  
    2.     Entry[] oldTable = table;  
    3.     int oldCapacity = oldTable.length;  
    4.     if (oldCapacity == MAXIMUM_CAPACITY) {  
    5.         threshold = Integer.MAX_VALUE;  
    6.         return;  
    7.     }  
    8.   
    9.     Entry[] newTable = new Entry[newCapacity];  
    10.     transfer(newTable);  
    11.     table = newTable;  
    12.     threshold = (int) (newCapacity * loadFactor);  
    13. }  
    14. void transfer(Entry[] newTable) {  
    15.     Entry[] src = table;  
    16.     int newCapacity = newTable.length;  
    17.     // 对已有的键值对进行rehash  
    18.     for (int j = 0; j < src.length; j++) {  
    19.         // 得到j处的链表的头结点  
    20.         Entry<K, V> e = src[j];  
    21.         // 遍历链表  
    22.         if (e != null) {  
    23.             src[j] = null;  
    24.             do {  
    25.                 // 进行rehash  
    26.                 Entry<K, V> next = e.next;  
    27.                 int i = indexFor(e.hash, newCapacity);  
    28.                 e.next = newTable[i];  
    29.                 newTable[i] = e;  
    30.                 e = next;  
    31.             } while (e != null);  
    32.         }  
    33.     }  
    34. }  

    从源码可以看出, rehash对性能的影响是非常大的, 因此我们应该尽量避免rehash的发生. 这就要求我们预估需要存入HashMap的键值对的数量, 根据数量在创建HashMap对象时指定合适的容量和负载因子.

    5. hash碰撞和HashMap的退化

    hash碰撞在HashMap中的表现为: 不同的key, 计算出相同的index. 如果对所有的key调用indexFor方法的返回值都是相同的, 那么HashMap就退化为链表, 这对性能的影响也是非常大的. 几个月前的闹得沸沸扬扬的hash碰撞攻击就是基于这样的原理.

    常用的web框架都会将请求中的参数保存在HashMap(或HashTable)中, 如果客户端根据Web应用框架采用的Hash函数来通过某种Hash攻击的方式获得大量的碰撞, 那么HashMap就会退化为链表, 服务器有可能处理一次请求要花上十几分钟甚至几个小时的时间...

    6. 线程安全

    HashMap是线程不安全的, 如果遍历HashMap的过程中修改了HashMap, 那么就会抛出java.util.ConcurrentModificationException异常:

    Java代码 
    1. final Entry<K, V> nextEntry() {  
    2.     if (modCount != expectedModCount)  
    3.         throw new ConcurrentModificationException();  
    4.     Entry<K, V> e = next;  
    5.     if (e == null)  
    6.         throw new NoSuchElementException();  
    7.   
    8.     if ((next = e.next) == null) {  
    9.         Entry[] t = table;  
    10.         while (index < t.length && (next = t[index++]) == null)  
    11.             ;  
    12.     }  
    13.     current = e;  
    14.     return e;  
    15. }  

    modCount是HashMap的成员变量, 用于表示HashMap的状态. expectedModCount是遍历初始时modCount的值. 如果在遍历过程中改变了modCount的值就会导致modCount和expectedModCount不相等, 从而抛出异常. put, clear, remove等方法都会导致modCount的值改变.

  • 相关阅读:
    关于做项目
    不一样的Android studio
    你认为一些军事方面的软件系统采用什么样的开发模型比较合适?
    关于Android studio
    面向对象建模所用图的简单总结
    浅谈Android 01
    用例图与类图的联系与区别
    面向过程(或者叫结构化)分析方法与面向对象分析方法到底区别在哪里?请根据自己的理解简明扼要的回答。
    你认为一些军事方面的软件系统采用什么样的开发模型比较合适?
    项目答辩后的感想
  • 原文地址:https://www.cnblogs.com/wzhanke/p/4817773.html
Copyright © 2011-2022 走看看