JDK源码/轮子分析:HashMap原理浅解

集合类中很经典很常用的一个:HashMap。
public class HashMap<K, V>
  extends AbstractMap<K, V>
  implements Map<K, V>, Cloneable, Serializab
以上是HashMap 的一个继承实现关系 ,其父类中主要是将Map 定义好及实现一些基本的操作,HashMap是在此基础上的增强。

以上是HashMap 中定义的一些属性。
值为16 的是默认的创建HashMap 时默认的大小 显式地写出来就是  new HashMap(16) 
值为1073741824 的是这个HashMap 最大的大小不能超过此值
值为0.75F 的是一个扩容因子,当空间用到75%的时候就会触发扩容方法,自动为已创建 的HashMap 扩容
大家注意到 table  entrySet 及其后面两个值的修饰关键字都是 transient 瞬态的,也就是序列化不会序列到这几个属性。
下面以问答的形式来学习这个集合类
  1. 该集合存取数据的结构是什么样的?
  2. put操作如何实现?
  3. get操作如何实现?
  4. remove操作如何实现?
  5. size表示的是什么?
  6. modCount 有什么作用?
  7. 数组大小为什么设为2的次方数?
  8. 加载因子是什么?
  9. 为什么说该集合为数据不安全的,因此慎用于多线程中?
  10. 扩容操作做了什么?
回答:
  1、2、3、4
   该集合是成对地存储对象 K-V,K为V的唯一识别,也就是所谓的键,在每一个HashMap对象中,K都是不会重复的,    允许为NULL。
   HashMap 中保存值(Entry)的常用方法 是 put(k,v)。
存储是先调用K的hashCode() 方法计算出K的hashCode,然后再调用HahMap自己的hash(int code)方法进而计算出一个
哈希值 i(这个可以叫做正在put的键值对的hash值)
int i = hash ( paramK .hashCode());

接下来取这i值和HashMap中的 table数组的长度 length-1 进行 &(与)运算,进而得出该Entry 在数组中的位置 j
int j = indexFor ( i , this . table . length );
static int indexFor ( int paramInt1 , int paramInt2 )
  {
    return paramInt1 & paramInt2 - 1;
  }

并不能直接就这样放进去,因为可能数组中的j这个位置已经有值了。由前面可以看到,定位j是靠运算后的hash值来再次运算得到的,就算K不一样,但是由于算法的原因得到一样的j也是不奇怪的,但是我们必需处理这种叫做“冲突”或是“碰撞”的情况。
发现j中有值后,我们就取出j中存在的值,看它里面的k是不是与我们将要put的这个一样:
   一样,则将新的值替换原来的值(这就解释了为什么HashMap中K都是唯一不重复的);
   不一样,那么我就看你j中原来值的next 是不是还有东西。没有的话,那我就new 一个新的Entry 放到table的j位置,新Entry 的next 将指向原来的在j这个位置;next还有东西,那我就继续循环判断下去,存在则替换,不存在new 一个新的放到table 的j 的位置,新Entry 的next 将指向原来J这个位置上的Entry 对象。
   所以说,HashMap存储的样子就是个数组与链表的合体
HashMap 取值的常用方法则是 get(K key)
该方法与put方法基本相同的操作,对K进行运算出 j 再查table 中j 位置的Entry 中的 key 是不是与get(K)中的K相同,相同则返回V,不同就遍历 J所在的整个链,找到即返回,找不到返回null。
HashMap 中的 remove(K k)方法中,删除Entry 之前得先找到要删除的那个Entry ,所以前半部分与get(K k)完全一样。
定位到要删除的对象之后,如果j位置只有一个Entry 非链表,直接清空,如果在链表中,将其从链表关系中去掉,然后再将链接拼接好。最后返回被删除的那个 Entry 。由此我们可以知道看remove后的返回值就可以知道我们删除了什么。
由HashMap 中的几个主要操作方法可以知道,查询无非主要就两种 数组查询 O(1)与链表查询 O(n),所以这里可以知道,影响查询速度的是链表的长度。
size表示的是什么?
size 中保存的是 HashMap 中当前存储的Entry 对象的个数,由于是数组+链表 存储的,则size 不一定是table 中存储的个数。
6 modCount 有什么作用?
   modCount 这个属性记录的当前 HashMap 对象添加或删除的 操作次数 ,添加一次 +1 ,删除一次 +1。
   为什么要保存这么个东西呢,这是由于在迭代的时候每次都会检查前后两次的 modCount 是不是相等的,不相等则会
报ConcurrentModificationException 异常
意思就是不能在迭代的时候进行 前面提到的三个对HashMap 的操作。(但是我们可以用Iterator 中的remove()方法进行删除)
数组大小为什么设为2的次方数?
当我们 new 一个HashMap给它指定长度为4时,那就是说我们初始化 table 的长度只有4。
首先我们要明白一件事:
       table 的4个位置并非全部都会用到,因为我们使用的下标的是通过计算得到的,如果某个下标永远也计算得不到那它永远也不会有值存进去。为了能尽可能多地使用table 中的位置,所以我们计算的算法要尽可能地设计好。那么我们接下来就看计算下标的方法:
    int i = hash ( paramK .hashCode());
    int j = indexFor ( i , this . table . length );

  static int indexFor ( int paramInt1 , int paramInt2 )
  {
    return paramInt1 & paramInt2 - 1;
  }

indexFor 这一计算数组下标的方法中,是将计算得到的hash值和数组长度-1 进行与运算。
为什么是数组的长度-1呢,这就要先看数组的大小设计了。
public HashMap( int paramInt , float paramFloat )
  {
    if ( paramInt < 0) {
      throw new IllegalArgumentException( "Illegal initial capacity: " + paramInt );
    }
    if ( paramInt > 1073741824) {
      paramInt = 1073741824;
    }
    if (( paramFloat <= 0.0F) || (Float. isNaN ( paramFloat ))) {
      throw new IllegalArgumentException( "Illegal load factor: " + paramFloat );
    }
    int i = 1;
    while ( i < paramInt ) {
      i <<= 1;
    }
    this . loadFactor = paramFloat ;
    this . threshold = (( int )( i * paramFloat ));
    this . table = new Entry[ i ];
    init();
  }

以上是HashMap 的构造方法,由此我们可以看到数组的大小永远在 1-1073741824之间,
while ( i < paramInt ) {
      i <<= 1;
    }
这一段代码是取i 的值的代码,构造HashMap 是并不是直接取传入的值当table 的大小,而是取的i 。
i由1开始,不停地由左位移,也就是每次乘2,所以最后得到的的i 肯定是2的倍数,也就是偶数,那么table的长度-1 得到的肯定是奇数。
我们的定位数组下标的方法其实就是个取模(取余)运算,为啥取余?因为如果数组总长为4,那么对4取模,得到的肯定比4小,不会超下标。而对数组长度如果是2的次方数的进行取模运算刚好可以用,还是效率很好的运算(位运算就是机器的源本计算方法)
那我们为什么要限定数组长度为2的次方呢。注意,的倍数-1肯定得到奇数,所以现在的问题是,对奇数进行&运算有什么好处呢。请看下面:
   奇数与偶数转化为二进制最大 的区别是最后一位数:奇数最后一位永远是1,偶数最后一位高远是0;
那么任意数与奇数进行&运算时可以得到任意小于等于两者中最小的数;而任意数与偶数进行&运算时,只能得到任意小于等于两者中最小的数中的偶数,最后一位永远不可能得出一个1来,这就是奇数的好处,使得运算结果的范围扩大了很多。
知道上面的原理是第一步。为了得到奇数,我完全可以将数组的长度设为偶数就行啦,何必要那么严格,为偶数不止,还必需是2的N次方,刚才我们看的是最后一位,现在我们看下其他位。6-1=5 0101     8-1=7 0111   10-1=9  1001  16-1=15  1111  
有没有发现,2的次方数-1后得到的奇数都是每位都有1占满的,而普通偶数则有部分位为0的情况,由此可以知道,2的次方数-1得到的奇数进行&运算时比普通的范围又要大很多。
这就是为什么数组的大小要设为2 的次方数的原来,为了尽可能利用数组 的位置,为什么要尽可能利用数组的位置?因为数组的位置上查询快啊。对了,如果new HashMap 的时候如果不设定大小,是有个默认值的,看前面截图可以知道它为16(2^4)
    
  8 加载因子是什么?
接着7中说到的
数组中只能存4个Entry ,如果存入HashMap 中的的数据超过4条,那么将会存到链表中,数据越多,链表越长。前面我们已经知道,链表的长度是影响查询速度的因素。因此,为了不影响性能,我们必需得给它设置一个限度值。比如,可以设为最大存储数量为数组的长度的2倍、1.5倍、3倍、0.75倍、0.5倍,等,反正是要设置的。一开始的截图中我们可以看到,HashMap 的默认最大存储数量是数组长度的0.75倍,0.75这个属性就 是加载因子。当存储数量大于 table.length * 0.75 时,HashMap 就会自动扩容为原来的2倍。
至于为什么是0.75,我们先来分析这个加载因子的大小影响到什么,我们要时刻对事件抱着一种蝴蝶效应的想法才能找出原因。
打个比方,数组的原来长度为10,如果加载因子是0.75,那当存储数量达到8的时候就会自动扩空,即使前面存储的全部存储到了数组中,没有产生链表,那还有三个空着,扩容了,就浪费了三个。如果加载因子是0.5,那就会浪费5个。如果加载因子是1.5,那么就会最少有5个是在链表中的,查询性能下降了。有人说那设为1啊,全用完再加载。太天真了,我前面假设的是在先全部填满数组的理想情况。但实际中这种状态发生的概率太小了。如果我们想把长度为10的数组全部填满,说不定要存储100个数才能达到,这样碰撞的次数是太大了。如果我们要填满5个,说不定10个就行了,这就是差别。
由此我们可以总结得到,加载因子影响着性能和存储空间的浪费程度。两都不能兼得,我们要平衡。
然而,为什么是0.75 ,这个值,我只能告诉你们,是经验值.....前人总结的..
9 为什么说该集合为数据不安全的,因此慎用于多线程中?

10 扩容操作做了什么?
     先看下面一个HashMap 在内存中存储的样子(本人的理解,key 的存储样子忽略,请注意在value 和next上,因为key 如果是个对象或字符串的话也不是下而显示的样子,存储的应该也是引用,为了简化而为之)
     那么扩容的话我们要做什么呢,扩容我们是扩大数组的长度,也就是对HashMap 中的table 属性操作,所以首先可以知道 的是02、03是不会动到的,01是肯定要动的。比如由长度为4扩为长度为8,那么它需要在另一块地方找出连续的8个位置给新的table。
然后需要做的就是,1.把在原来table的三位住户搬到新的地方去,当然是一个一个地取出来,重新按新的table长度计算出新的数组下标(hash值还是和原来一样的,虽然源码中还是对其计算了一次,可能是保个底),然后在计算出的位置上存入该存的Entry 的引用(房间地址),依次存完。2.将原来栈中的存储原来table 的引用改为新table 的引用。
     由此可见扩容要做的事是很多的,事实上扩容是特别耗时的。一般的话最好的情况就是能预知大概的存储量是多少,然后new 的时间给定大小,以免在持续的添加过程中自动产生扩容操作,影响性能。

HashMap<String,Object> hm = new HashMap<String,Object>(4);
            hm .put( "key1" , 1020);
            hm .put( "key2" , new Person( "小王" , "21" ));
            hm .put( "key3" , 50.2);


           
上面的问题解决 完了,那么最后来个有意思的东西吧。
    序列化问题
一开篇的时候我是不是特别提醒了有好几个属性是瞬态( transient)的,瞬态是什么意思呢,简单粗暴地说就是不支持序列化的。就是你要序列化一个HashMap 对象时那几个属性是不会到序列化后的地方去的。大家可以发现,不能序列化的这几个属性都是一个HashMap 中最重要的部分了。
为什么不能序列化 呢,我们看这四个属性 table EntrySet size modCount ,这四个中首要的应该是table 我们new 的时候也是开辟table 的空间,其他三个都中在此基础上才有得以存在的意义,故我们就来看为什么table 不能序列化。我们由前面都知道了,每一个Entry 与table 的下标联系是靠计算得到,其中的计算就用到了哈希算法,我们都知道K中的hashCode()这个算法若没有特别的覆写实现,那它就是用Object 中hashCode()方法,而这一方法是本地方法,也就是和Java 环境有关,若环境一变就有可能得到不同的值,那么我们这个定位方法在序列化后 就有可能失效了。
  



猜你喜欢

转载自blog.csdn.net/mottohlm/article/details/79067148