自我学习之路(1)---HashMap1.7源码学习

1. 序言

记忆总是会随着时间的流逝而逐渐的遗忘,不管多么深刻的记忆终究会有遗忘的一天,唯有文字的记忆才是永恒!

2. 了解HashMap的必要性

在我们平时的开发中,经常会用到HashMap,而且在面试的过程中更是面试过程中的常客,比如说:
1.为什么HashMap是线程不安全的?
2.元素存放在数组的下标的链表是怎么计算位置的?
3.多线程的条件下会发生什么问题,即“死锁”问题?
4.为什么会出现hash冲突问题以及是怎么解决的?
5.为什么不直接采用hash值作为数组的下标而是用数组的长度减1以后进行& 运算?
6.为什么每次扩容都是2倍(2的N次方)以及为什么初始容量大小需要强制设置成2N次方?
7.什么条件下会进行重新rehash()操作?
8.为什么hasmMap的key,value均可以为null?
因此有必要重新认识一下熟悉而又陌生的它。
注:以下介绍均以 java7版本

3. HashMap简介

  • 3.1 类定义
public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable{
     
    ....
}
  • 3.2 类常用的方法
    在我们平时开法中,用的最多的无非就是增删查改,因此会在下面的文章中详细的进行这几个方法的源码分析。
//增加,修改操作
public V put(K key, V value) {
     
    ....
	}
//删除操作
public V remove(Object key) {
     
    ....
    }
//查找操作
public V get(Object key) {
     
    ....
    }
  • 3.3 类中默认的几个重要的静态参数
//1.参数名称:容量
//1.1默认的初始容量大小=16,即我们常说的hashMap中数组(hash桶)的初始容量大小
//1.2的幂次方<=容量为范围<=2的30次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
//1.3数组的最大长度(并不是hashMap可以存放的数据的大小)
static final int MAXIMUM_CAPACITY = 1 << 30;

//2.加载因子:用于计算是否扩容是的一个重要条件
//2.1加载因子越大,达到扩容的条件越困难,即需要的数据量越多,空间的利用率越高,但是hash冲突的概率越大,查询的效率越慢
//2.1加载因子越小,达到扩容的条件越简单,即需要的数据量越少,空间的利用率越低,但是hash冲突的概率越小,查询的效率变块
//默认的加载因子为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//3.扩容阀值:扩容所需要的达到的必备条件,默认是扩容为原先的两倍大小
//3.1扩容阀值 = 容量 * 加载因子
//3.2 默认的初始扩容阀值 = 1 << 4  * 0.75 = 16*0.75 = 12
int threshold;

4. HashMap的数据结构

基本上大家都知道HashMap是由数组加链表的上数据结构,类似于下图:
自我学习之路(1)---HashMap1.7源码学习_第1张图片
横项就是数组,列项就是链表,其中的每一个框子我们可以当做是一个存放数据的节点,其节点的实现是Entry对象,其对象属性源码如下:

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

从源码我们可以看出,该对象有四个属性,分别对应 HashMap中的 key,value,通过该key的hash算法计算出来的hash值以及一个Entry对象,但属性中的Entry对象指的是链表中下一个元素的信息。

5. HashMap源码分析

下面会分别对HashMap的几个主要方法的源码进行分析。

5.1 HashMap的get()源码

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

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

其实HashMap的get()方法很简单,这里不对其进行详细的介绍,因为get()方法其实就是put()方法的逆向操作,看懂了put方法以后,这个方法就会觉得很简单。

5.2 HashMap的remove()源码

    public V remove(Object key) {
     
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

虽然说HashMap自身提供了remove()的方法,但是不建议使用自带的remove方法删除元素,因为有时候在使用这个方法的时候会报错。如下

   	HashMap<String,Object> hashMap = new HashMap<String,Object>();
   	hashMap.put("张三",1);
   	hashMap.put("李四",2);
   	hashMap.put("王五",3);
   	hashMap.put("赵六",4);
   	hashMap.put("王八",5);
   	hashMap.put("李九",6);
   	hashMap.put("王二麻子",7);
   	for(String key : hashMap.keySet()) {
     
   		if(key.contains("李")) {
     
   			hashMap.remove(key);
   		}
   	}

报错信息如下

Exception in thread "main" java.util.ConcurrentModificationException
   at java.util.HashMap$HashIterator.nextNode(HashMap.java:1442)
   at java.util.HashMap$KeyIterator.next(HashMap.java:1466)
   at HashMap7.main(HashMap7.java:13)
   

由此我们要想,为什么会报错呢,查看源码得知问题在这里
自我学习之路(1)---HashMap1.7源码学习_第2张图片
为什么会出现这个问题呢,因为我们在put元素进去的时候,modCount++,那么当我们将来能两个元素put完成以后modCount = 2,expectedModCount会初始循环时会赋值,赋的值为2,源码如下:
自我学习之路(1)---HashMap1.7源码学习_第3张图片
现在我们发现了问题,怎么解决这个问题,其实最简单的方法就是利用迭代器去删除,写法如下

Set<Entry<String, Object>> set=hashMap.entrySet();
Iterator<Entry<String, Object>> iterator=set.iterator();
   while(iterator.hasNext()){
     
       Entry<String, Object> entry=iterator.next();
       String name=entry.getKey();
       if(name.contains("李")){
     
           iterator.remove();//这里不能写成hashMap的remove方法
       }
   }

5.3 HashMap的put()源码

    public V put(K key, V value) {
     
       //1.判断当前的HashMap是否需要初始化
       if (table == EMPTY_TABLE) {
     
           inflateTable(threshold);
       }
       //2.如果key==null  将value放入到数组中
       if (key == null)
           return putForNullKey(value);
       //3.计算key对应的hash值
       int hash = hash(key);
       //4.计算key在数组中对应的位置,即对应数组的下标
       int i = indexFor(hash, table.length);
       //5.判断元素是否在链表中是否存在,存在则更新原先的key对应的值
       for (Entry<K,V> e = table[i]; e != null; e = e.next) {
     
           Object k;
           if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
     
               V oldValue = e.value;
               e.value = value;
               e.recordAccess(this);
               return oldValue;
           }
       }

       modCount++;//该步骤就是前面为什么不建议使用HashMap的remove方法中有提及到
       //6,添加元素
       addEntry(hash, key, value, i);
       return null;
   }

从上述的源码中我们可以看出,put方法大概分成了6个小步骤,现在我们就一起分析这六个步骤的行情况。

  • 分析1. 判断当前的HashMap是否需要初始化
     //1.判断当前的HashMap是否为空
     if (table == EMPTY_TABLE) {
     
     	//1.1如果hashMap为空,则初始化hashMap
         inflateTable(threshold);
     }
     
===================1.1 分析=====================
 //1.1如果hashMap为空,则初始化hashMap
  private void inflateTable(int toSize) {
     
     // Find a power of 2 >= toSize
     //a.将hashMap中初始数组的大小设置成2的N次方
     int capacity = roundUpToPowerOf2(toSize);
 	 //b.计算扩容阀值,并数组容量初始化大小   --这段代码简单,就不用分析
     threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
     table = new Entry[capacity];
     //c.该方法在这初始化的时候不做源码分析,在后面的会有说明
     initHashSeedAsNeeded(capacity);
 }

===================a 分析=====================
private static int roundUpToPowerOf2(int number) {
     
     //这段的意思就是判断传入的数组大小是否大于等于最大数组容量
     //1.如果大于最大的数组容量,则取最大容量返回最大的数组容量
     //2.否则,(number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1
     	//2.1.判断传入的值是否大于1,如果是Integer.highestOneBit((number - 1) << 1) 
     		//2.1.1   Integer.highestOneBit((number - 1) << 1);
     		// Integer.highestOneBit()这个方法也就是说取最高位为1对应的十进制,(number - 1) << 1 表示 number - 1的结果左移两位
     		//如:number  = 5  那么返回的就是 8
     		// (number - 1) =  4,  4对应的二进制为  0000 0100,然后左移两位就是0001 0000,结果对应的8;
     		//这里只要最高位的二进制,如果低位有1,当成0,即如果是0001 0100,也是8
     	//2.2 否则返回 1
     return number >= MAXIMUM_CAPACITY
             ? MAXIMUM_CAPACITY
             : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
 }
 //这段代码就是前面提及的强制变成2的N次方倍大小的数组容量
  • 分析2. 如果key==null 将value放入到数组中
     private V putForNullKey(V value) {
     
     	//2.1 替换原有key对应的value
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
     
            if (e.key == null) {
     
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;//上面已经有说过
        //2.2 添加key==null的数据,并且放到数组下标是0的位置,具体方法分析会在分析6中有更加详细的介绍
        //这里也就相当于解释了为什么hashMap的key,value可以为null;
        addEntry(0, null, value, 0);
        return null;
    }

===================2.1 分析=====================
		//a. Entry e = table[0];表示去数组下标是0的第一个元素,为什么要取第一个呢?
		//因为在2.2中的添加方法就是说会把key==null的元素放在数组下标为0的链表里面
		//b. e!= null; 如果获取到的第一个元素为空,则说明该位置目前没有元素,则跳出循环
		for (Entry<K,V> e = table[0]; e != null; e = e.next) {
     
			//c. 这里为什么还需要判断key是否为null呢?再添加的时候,不是添加的key==null的元素吗?
			//因为数组下标为0所代表的链表中不是只有key==null的数据,因为它还会存经过计算后,
			//刚好存放在该位置的元素,因此该位置存放的数据不仅仅只有一个key==null的元素
            if (e.key == null) {
     
               //d. 这几步就是满足条件是,替换原有key==null所对应的value,并返回旧的value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
  • 分析3. 计算key对应的hash值
	int hash = hash(key);
	//计算hashCode
    final int hash(Object k) {
     
        int h = hashSeed;//默认参数等于0,一般不会被改变,但是也有可能会被改变,在分析6中会有介绍
        //这里就是如果key满足条件,直接返回对应的hashCode,一般不会被执行 h=0;
        if (0 != h && k instanceof String) {
     
            return sun.misc.Hashing.stringHash32((String) k);
        }
        
        //k.hashCode() 计算出key所对应的hashCode
        //既然这里计算出来hashCode,为什么不直接返回,还需要进行5次异或,4次位运算?
        //这里是为了让其计算出来的hash值更加均匀分布,防止链表太长,从而影响效率
        //如:假如现在有10000个key,但是k.hashCode()出来的值在某几个中大量重复,那么就会造成某几个链表存放了大量元素,
        //而其他链表只存放少数的几个,甚至是空的(这里是1.7的写法,1.8不一样,1.8只用了1次异或,1次位运算)
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
  • 4.计算key在数组中对应的位置,即对应数组的下标
    static int indexFor(int h, int length) {
     
    	//这里代码看起来简单,就一个与运算,但是需要知道为什么要是用&运算?
    	//前面说到了数组大最大长度都是2的N次方,是不是意味着数组下标是不是2的N次方减1,
    	//是有一个范围的,但是hashCode的值是不确定的,有可能会超出数组的长度;
        return h & (length-1);
    }

//如:现在有一个key对应的hashCode转成2进制是:1001 0110 1111 1100,数组长度为16,减1以后其对应的二进制是:0000 0000 0000 1111,
//那么直接将这个值当成数组下标,肯定会数组下标越界,但是如果去采用位运算那么会出现什么情况呢?1.    1001 0110 1111 1100
     &  0000 0000 0000 1111
        0000 0000 0000 1100    十进制等于:12
    1. 得到的结果等于12,这个12刚好在0-15之间,因为第四位全为1,其余高位全为0,不管计算出来的hashCode是多少,都不会超过length-12. 从这也可以看出,元素所放的位置与数组的大小无关,至于其自身的hashCode有关,由于key值是随机的,我们也可以看成key所计算出来的hashcode也是随机,
       也就是说是均匀分布的,最终存放在数组中的位置也是均匀的;
       
如果数组大小不是2N次方呢?数组的长度为7,length-1 = 6 如下
例2.    1001 0110 1111 1100
     &  0000 0000 0000 0110
        0000 0000 0000 0100    十进制:41:结果看起来是不是也满足条件,数组下标没有越界,但是是否有均匀分布呢?
     答案是否应的,因为结果只会有三个值: 02或者4&运算同1才为1,因此当有大量数据时,就会造成大量数据存放在某几个位置,从而造成链表长度太长,影响效率
     问2:既然数组下标是2N次方,为什么不用取余的方式而采用&?
     答案是因为对于计算机而言,位运算比取余要快。
  • 分析5. 判断元素是否在链表中是否存在,存在则更新原先的key对应的值
	  //这段代码在前面的分析2中已经分析过了,原理都是一样的,只是判断条件不一样
     for (Entry<K,V> e = table[i]; e != null; e = e.next) {
     
          Object k;
          if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
     
              V oldValue = e.value;
              e.value = value;
              e.recordAccess(this);
              return oldValue;
          }
      }
  • 分析6. 添加元素
void addEntry(int hash, K key, V value, int bucketIndex) {
     
   	   //判断是否需要扩容,这里的size 会在分析2中的方法每次做+1,并且本次添加元素锁对应的位置不为空
       if ((size >= threshold) && (null != table[bucketIndex])) {
     
       	   //分析1.扩容
           resize(2 * table.length);
           //计算hash值
           hash = (null != key) ? hash(key) : 0;
           //计算对应的数组下标
           bucketIndex = indexFor(hash, table.length);
       }
   		//分析2. 存放添加的元素
       createEntry(hash, key, value, bucketIndex);
   }

====================分析1 扩容===========================
   void resize(int newCapacity) {
     
       Entry[] oldTable = table;
       int oldCapacity = oldTable.length;
       //判断原数组的大小是等于默认的最大值,如果是,则将最大阀值记录成Integer 的最大值
       if (oldCapacity == MAXIMUM_CAPACITY) {
     
           threshold = Integer.MAX_VALUE;
           return;
       }
   	   //创建一个大小为原数组两倍的新数组
       Entry[] newTable = new Entry[newCapacity];
       //分析1.1 initHashSeedAsNeeded(newCapacity) 这个方法就是前面没有讲解,说这里会讲解的,这个方法主要是判断是否需要重新hash
       //分析1.2 transfer()方法将旧数组的元素转移到新数组中******这个方法很重要*********
       transfer(newTable, initHashSeedAsNeeded(newCapacity));//重点方法
       table = newTable;
       //重新计算扩容后的阀值
       threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
   }

******************分析1.1 重新hash********************************
   final boolean initHashSeedAsNeeded(int capacity) {
     
       boolean currentAltHashing = hashSeed != 0;
       boolean useAltHashing = sun.misc.VM.isBooted() &&
               (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
       boolean switching = currentAltHashing ^ useAltHashing;
       if (switching) {
     
           hashSeed = useAltHashing
               ? sun.misc.Hashing.randomHashSeed(this)
               : 0;
       }
       return switching;
   }
   
 *1.7的版本中是有可能重新hash的,但是正常情况下不会:
 	1. 正常情况下,我们不会修改这个参数--sun.misc.VM.isBooted(),这个参数可以配置启动的时候(主要)2. Holder.ALTERNATIVE_HASHING_THRESHOLD 最大值等于Integer.MAX_VALUE3. 上述两个原因都是只是个人猜测,可能是错误的,因为是从结果推的原因。


******************分析1.2 将旧数组的元素转移到新数组中********************************
   void transfer(Entry[] newTable, boolean rehash) {
     
       int newCapacity = newTable.length;	******
       for (Entry<K,V> e : table) {
     
           while(null != e) {
     
               Entry<K,V> next = e.next;******
               if (rehash) {
     
                   e.hash = null == e.key ? 0 : hash(e.key);
               }
               int i = indexFor(e.hash, newCapacity);
               e.next = newTable[i];	******
               newTable[i] = e;		******
               e = next;	******
           }
       }
   }

从分析1.2的代码简单看来,主要就是把旧数组的数据挪到新数组中,采用的头插法(就是将就数组的第一个元素先挪到新数组,依次类推的话,最先挪的数组会成为新数组的最后一个元素),在多线程情况下,头插法可能会存在“死锁”的问题,即循环链表问题。那就让我们以单线程或者多线程的情况下的挪数据的过程,以及多线程情况下为什么会发生循环链表的问题。

单线程情况下的transfer(Entry[] newTable, boolean rehash)方法

不考虑重新hash的情况,因此代码可以简化成如下:

======================单线程情况下==========================
   void transfer(Entry[] newTable, boolean rehash) {
     
      int newCapacity = newTable.length;	******
      for (Entry<K,V> e : table) {
     
          while(null != e) {
     
              Entry<K,V> next = e.next;******
   				。。。//此处省略一些无关紧要的代码
   				
   			  //之所以重新计算数组下标是因为扩容后,数组下标可能会改变,其位置有两种可能,
   			  //要么在原本对应的数组下标位置或者在原先数据下标+原数组长度的位置
   			  //如  原先元素a在原数组下标为3的位置,原数组长度是16,那么扩容后要么在3要么在19,
   			  //这个原因同样是因为数组的长度为2的N次方以及扩容后的长度是原数组的两倍(2的1次方)
              int i = indexFor(e.hash, newCapacity);
              e.next = newTable[i];	******
              newTable[i] = e;	******	
              e = next;	******
          }
      }
  }

注:以下分析时,重新计算数组下标后,数组下标的值不变,即 i=3(不管数组下标是否更改,在多线程环境下均可能会出现问题),并且简化数组默认的长度

  • 执行完①处代码的状态

  • int newCapacity = newTable.length;
    自我学习之路(1)---HashMap1.7源码学习_第4张图片

  • 执行完②处代码的状态
    *Entry next = e.next;

自我学习之路(1)---HashMap1.7源码学习_第5张图片

  • 执行完③处代码的状态

  • e.next = newTable[i];
    自我学习之路(1)---HashMap1.7源码学习_第6张图片

  • 执行完④处代码的状态
    *newTable[i] = e;
    自我学习之路(1)---HashMap1.7源码学习_第7张图片

  • 执行完⑤处代码的状态
    *e = next;
    自我学习之路(1)---HashMap1.7源码学习_第8张图片

  • 然后重复②,③,④,⑤的步骤,最后效果如图
    自我学习之路(1)---HashMap1.7源码学习_第9张图片
    上述过程可以看出,扩容后新数组的元素的顺序会和原数组的顺序相反,这就是头插法的结果,同时也就造成了多线程情况下。可能会产生循环链表问题。

多线程情况下的transfer(Entry[] newTable, boolean rehash)方法

 =====================多线程情况下==========================
    void transfer(Entry[] newTable, boolean rehash) {
     
       int newCapacity = newTable.length;	******
       for (Entry<K,V> e : table) {
     
           while(null != e) {
     
               Entry<K,V> next = e.next;******
					。。。//此处省略一些无关紧要的代码
               int i = indexFor(e.hash, newCapacity);
               e.next = newTable[i];	******
               newTable[i] = e;	******	
               e = next;	******
           }
       }
   }

假设现在有线程e1,e2同时put一个元素,并且两者同时走到了方法①;

  • 1.线程e1,e2走完步骤①,②,效果如下:
    自我学习之路(1)---HashMap1.7源码学习_第10张图片

  • 2.此时线程e1继续往下走,直到线程e1执行完本段代码,此时效果如下
    自我学习之路(1)---HashMap1.7源码学习_第11张图片
    注:
    1.当线程e1执行完以后,e1,next1 均会指向null
    2.这里需要注意的是两个线程会共用拥有一个数组,即原数据,但扩容后都会有的线程内的新数组
    3.由于线程e1执行完后,原数组数据会转移到e1扩容后的数组中,因此e2,next2的当前的指向也会就会变成如图显示

  • 3.此时线程e2继续往下执行,当执行完③以后

  • e.next = newTable[i];
    自我学习之路(1)---HashMap1.7源码学习_第12张图片

  • 3.此时线程e2继续往下执行,当执行完④以后

  • newTable[i] = e;
    自我学习之路(1)---HashMap1.7源码学习_第13张图片

  • 4.此时线程e2继续往下执行,当执行完⑤以后

  • e = next; 自我学习之路(1)---HashMap1.7源码学习_第14张图片

  • 5.当前循环没有结束,因此继续执行②

  • Entry next = e.next;
    自我学习之路(1)---HashMap1.7源码学习_第15张图片
    前面忘记强调一点就是扩容后的数组1,2中,两者的(k1,k2)其实是一样的,都是存的一个指向的地址,其实将nest2的箭头指向标记黄色的位置也是一样的效果,地址是一样的;这样知道下面是为了演示方便。

  • 6.执行③结果

  • e.next = newTable[i];
    自我学习之路(1)---HashMap1.7源码学习_第16张图片

  • 7.执行完④以后

  • newTable[i] = e;
    自我学习之路(1)---HashMap1.7源码学习_第17张图片

在单线程情况下,这里没有详细说明的是newTable[i] = e;执行完以后,其实就是将e放到扩容后的数组位置,由于上一步e.next = newTable[i]; 即e的中的next属性的值已经指向了下一个节点元素,e.next是e的一个属性,这里可能有点难懂,可以去仔细看一下e所代表对象的Entry e,这个类的属性后,就会比较好理解。

  • 8.执行完⑤以后
    e = next;自我学习之路(1)---HashMap1.7源码学习_第18张图片
  • 9.因为e2不等于null,继续循环步骤②
    自我学习之路(1)---HashMap1.7源码学习_第19张图片
  • 10.执行步骤③
    自我学习之路(1)---HashMap1.7源码学习_第20张图片
    从这个图不知道能不能看出口扩容后的端倪出来?是不是可以看出来e2此时等于(K1,V1),但是k1指向的下一个元素居然是自己的上一个元素。还是看不明白的话,我把图改一下就可以看出来了
    自我学习之路(1)---HashMap1.7源码学习_第21张图片
    这样是不是看出来,元素(K2,V2)的下一个元素是(K1,V1),但是(K1,V1)的下一个元素是(K2,V2),这样是不是产生了一个链表,这样就会造成链表中的元素永远都挪不完。

在JDK1.8中,采用的尾插法,即正向遍历数组链表中的元素,往新数组的尾部添加元素,就可以避免循环链表问题,但是1.8的HashMap依旧是线程不安全的,因为没有加锁,如果想用线程安全的HashMap可以用ConcurrentHashMap。

到这里1.7的HashMap源码就介绍完成了,仅仅是个人的理解,但不一定是对的,有错误的地方请指出!

你可能感兴趣的:(java)