一、 HashMap介绍

HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。 HashMap 继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。 HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。 HashMap 的实例有两个参数影响其性能:“初始容量” 和 “加载因子”。容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。 通常,默认加载因子是 0.75, 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。

先来复习一下我们常用的几个方法

 

public class HashMapTest {

public static void main(String[] args) {
// TODO Auto-generated method stub
HashMap<String, String> hashMap=new HashMap<>();
//添加方法
hashMap.put("1", "chris");
//遍历方法1_for
Set<String> keys=hashMap.keySet();
for(String key:keys){
System.out.println(key+"="+hashMap.get(key));
}
//遍历方法1_iterator(for和iterator实现原理相同)
Iterator iter = map.keySet().iterator(); 
while (iter.hasNext()) { 
String key = iter.next(); 
String value = map.get(key); 
} 
//遍历方法2_for
Set<Entry<String, String>> entrys= hashMap.entrySet();
for(Entry<String, String> entry:entrys){
String key=entry.getKey();
String value=entry.getValue();
}
//遍历方法2_iterator
Iterator<Entry<String, String>> iterator=hashMap.entrySet().iterator();
while(iterator.hasNext()){
Map.Entry<String, String> entry=iterator.next();
String key=entry.getKey();
String value=entry.getValue();
}
//查询方法
hashMap.get("1");
//删除方法
hashMap.remove("1");    
}

}

 

HashMap类图结构

HashMap源码解析1.7_迭代

 

 

我们知道在Java中最常用的两种结构是数组和模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现。数组的存储方式在内存的地址是连续的,大小固定,一旦分配不能被其他引用占用。它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n),链表的存储方式是非连续的,大小不固定,特点与数组相反,插入和删除快,查询速度慢。HashMap可以说是一种折中的方案吧。

HashMap的基本参数
  • 默认初始容量:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  • 最大容量:static final int MAXIMUM_CAPACITY = 1 << 30;
  • 负载因子(扩容因子):static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 键值对个数:transient int size;
  • 扩容时的阈值,当前容量 * 负载因子:int threshold;
  • 实体,HashMap储存对象的实际实体,由key,value,hash,next组成:Entry
  • 被修改的次数:transient int modCount;
    • put,get,remove,intereator等方法中,都使用了该属性。由于HashMap不是线程安全的,所以在迭代的时候,会将modCount赋值到迭代器的expectedModCount属性中,然后进行迭代,
      如果在迭代的过程中HashMap被其他线程修改了,modCount的数值就会发生变化,
      这个时候expectedModCount和ModCount不相等,
      迭代器就会抛出ConcurrentModificationException()异常

HashMap的构造函数

HashMap共有4个构造函数,如下:

// 默认构造函数。
HashMap()
// 指定“容量大小”的构造函数
HashMap(int capacity)
// 指定“容量大小”和“加载因子”的构造函数
HashMap(int capacity, float loadFactor)
// 包含“子Map”的构造函数
HashMap(Map<? extends K, ? extends V> map)

二、JDK7 中 HashMap 底层原理

HashMap源码解析1.7_红黑树_02

 

 

上图中左边橙色区域是哈希表,右边蓝色区域为链表,链表中的元素类型为 Entry,它包含四个属性分别是:

  • K key
  • V value
  • int hash
  • Entry next

那么为什么会出现数组+链表形式的存储结构呢?这里简单地阐述一下,我们在使用 HashMap.put("Key", "Value")方法存储数据的时候,底层实际是将 key 和 value 以 Entry的形式存储到哈希表中,哈希表是一个数组,那么它是如何将一个 Entry 对象存储到数组中呢?是如何确定当前 key 和 value 组成的 Entry 该存到数组的哪个位置上,换句话说是如何确定 Entry 对象在数组中的索引的呢?通常情况下,我们在确定数组的时候,都是在数组中挨个存储数据,直到数组全满,然后考虑数组的扩容,而 HashMap 并不是这么操作的。在 Java 及大多数面向对象的编程语言中,每个对象都有一个整型变量 hashcode,这个 hashcode 是一个很重要的标识,它标识着不同的对象,有了这个 hashcode,那么就很容易确定 Entry 对象的下标索引了,

从源码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点

其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值,这是非常重要的,正是由于Entry才构成了table数组的行为链表

在 Java 语言中,可以理解 hashcode 转化为数组下标是按照数组长度取模运算的,基本公式如下所示:

int index = HashCode(key) % Array.length

实际上,在 JDK 中哈希函数并没有直接采取取模运算,而是利用了位运算的方式来提高性能,

int index = HashCode(key) &( Array.length-1)

在这里我们理解为简单的取模运算。 我们知道了对 Key 进行哈希运算然后对数组长度进行取模就可以得到当前 Entry 对象在数组中的下标,那么我们可以一直调用 HashMap 的 put 方法持续存储数据到数组中。但是存在一种现象,那就是根据不同的 Key 计算出来的结果有可能会完全相同,这种现象叫作“哈希冲突”。既然出现了哈希冲突,那么发生冲突的这个数据该如何存储呢?哈希冲突其实是无法避免的一个事实,既然无法避免,那么就应该想办法来解决这个问题,目前常用的方法主要是两种,一种是开放寻址法,另外一种是链表法。 开放寻址法是原理比较简单,就是在数组里面“另谋高就”,尝试寻找下一个空档位置。而链表法则不是寻找下一个空档位置,而是继续在当前冲突的地方存储,与现有的数据组成链表,以链表的形式进行存储。HashMap 的存储形式是数组+链表就是采用的链表法来解决哈希冲突问题的。具体的详细说明请继续往下看。 在日常开发中,开发者对于 HashMap 使用的最多的就是它的构造方法、put 方法以及 get 方法了,下面就开始详细地从这三个方法出发,深入理解 HashMap 的实现原理。

三、HashMap put、get 方法流程图

这里提供一个 HashMap 的 put 方法存储数据的流程图供读者参考:
HashMap源码解析1.7_红黑树_03

(size >= threshold) && (null != table[bucketIndex])//当当前的元素个数大于等于扩容阈值的时候,并且分配给新元素的这个位置以及有值,则扩容,就是元素个数达到了临界值并且新元素碰撞了,才扩容。

HashMap通过键的hashCode来快速的存取元素。当不同的对象hashCode发生碰撞时,HashMap通过单链表来解决,将新元素加入链表表头,通过next指向原有的元素。
先说说大概的过程:当我们调用put存值时,HashMap首先会获取key的哈希值,通过哈希值快速找到某个存放位置,这个位置可以被称之为bucketIndex。

对于一个key,如果hashCode不同,equals一定为false,如果hashCode相同,equals不一定为true。

所以理论上,hashCode可能存在冲突的情况,也叫发生了碰撞,当碰撞发生时,计算出的bucketIndex也是相同的,这时会取到bucketIndex位置已存储的元素,最终通过equals来比较,equals方法就是哈希码碰撞时才会执行的方法,所以说HashMap很少会用到equals。HashMap通过hashCode和equals最终判断出K是否已存在,如果已存在,则使用新V值替换旧V值,并返回旧V值,如果不存在 ,则存放新的键值对<K, V>到bucketIndex位置。

 

下面我们来看看put的源码:

public V put(K key, V value) { 
//当key为null,调用putForNullKey方法,保存null于table第一个位置中,这是HashMap允许为null的原因 
if (key == null) 
return putForNullKey(value); 
//计算key的hash值 
int hash = hash(key.hashCode()); ------(1) 
//计算key hash 值在 table 数组中的位置 
int i = indexFor(hash, table.length); ------(2) 
//从i出开始迭代 e,找到 key 保存的位置 
for (Entry<K, V> e = table[i]; e != null; e = e.next) { 
Object k; 
//判断该条链上是否有hash值相同的(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; //返回旧值 
} 
} 
//修改次数增加1 
modCount++; 
//将key、value添加至i位置处 
addEntry(hash, key, value, i); 
return null;

}

 

通过源码我们可以清晰看到HashMap保存数据的过程为:

 

1)首先判断key是否为null,若为null,则直接调用putForNullKey方法

 

private V putForNullKey(V value) {
for (HashMapEntry<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++;
addEntry(0, null, value, 0);
return null;}

 

从代码可以看出,如果key为null的值,默认就存储到table[0]开头的链表了。然后遍历table[0]的链表的每个节点Entry,如果发现其中存在节点Entry的key为null,就替换新的value,然后返回旧的value,如果没发现key等于null的节点Entry,就增加新的节点

2)计算key的hashcode(hash(key.hashCode())),再用计算的结果二次hash(indexFor(hash, table.length)),找到Entry数组的索引i,这里涉及到hash算法,最后会详细讲解


3)遍历以table[i]为头节点的链表,如果发现hash,key都相同的节点时,就替换为新的value,然后返回旧的value,只有hash相同时,循环内并没有做任何处理

4)modCount++代表修改次数,与迭代相关,在迭代篇会详细讲解

 

5)对于hash相同但key不相同的节点以及hash不相同的节点,就增加新的节点(addEntry())

void addEntry(int hash, K key, V value, int bucketIndex) { 
//获取bucketIndex处的Entry 
Entry<K, V> e = table[bucketIndex]; 
//将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry 
table[bucketIndex] = new Entry<K, V>(hash, key, value, e); 
//若HashMap中元素的个数超过极限了,则容量扩大两倍 
if (size++ >= threshold) 
resize(2 * table.length);

}

 

这里新增加节点采用了头插法,新节点都增加到头部,新节点的next指向老节点
这里涉及到了HashMap的扩容问题,随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度,为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。该临界点在当HashMap中元素的数量等于table数组长度*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。

 

void resize(int newCapacity) {
HashMapEntry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}

HashMapEntry[] newTable = new HashMapEntry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

}

 

从代码可以看出,如果大小超过最大容量就返回。否则就new 一个新的Entry数组,长度为旧的Entry数组长度的两倍。然后将旧的Entry[]复制到新的Entry[].

void transfer(HashMapEntry[] newTable) {
int newCapacity = newTable.length;
for (HashMapEntry<K,V> e : table) {
while(null != e) {
HashMapEntry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}

}

 

在复制的时候数组的索引int i = indexFor(e.hash, newCapacity);重新参与计算

 

 

 这里提供一个 HashMap 的 get 方法获取数据的流程图供读者参考:

HashMap源码解析1.7_红黑树_04

 

public V get(Object key) {  
        // 若为null,调用getForNullKey方法返回相对应的value  
        if (key == null)  
            return getForNullKey();  
        // 根据该 key 的 hashCode 值计算它的 hash 码    
        int hash = hash(key.hashCode());  
        // 取出 table 数组中指定索引处的值  
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {  
            Object k;  
            //若搜索的key与查找的key相同,则返回相对应的value  
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
                return e.value;  
        }  
        return null;  

在这里能够根据key快速的取到value除了和HashMap的数据结构密不可分外,还和Entry有莫大的关系,在前面就提到过,HashMap在存储过程中并没有将key,value分开来存储,而是当做一个整体key-value来处理的,这个整体就是Entry对象。同时value也只相当于key的附属而已。在存储的过程中,系统根据key的hashcode来决定Entry在table数组中的存储位置,在取的过程中同样根据key的hashcode取出相对应的Entry对象

 

四、常见的 HashMap 的迭代方式

在实际开发过程中,我们对于 HashMap 的迭代遍历也是常见的操作,HashMap 的迭代遍历常用方式有如下几种:

  • 方式一:迭代器模式
Map<String, String> map = new HashMap<>(16);
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, String> next = iterator.next();
    System.out.println(next.getKey() + ":" + next.getValue());
}

 



  • 方式二:遍历 Set>方式
Map<String, String> map = new HashMap<>(16);
for (Map.Entry<String, String> entry : map.entrySet()) {
    System.out.println(entry.getKey() + ":" + entry.getValue());
}

 



  • 方式三:forEach 方式(JDK8 特性,lambda)
Map<String, String> map = new HashMap<>(16);
map.forEach((key, value) -> System.out.println(key + ":" + value));

 

 
  • 方式四:keySet 方式
Map<String, String> map = new HashMap<>(16);
Iterator<String> keyIterator = map.keySet().iterator();
while (keyIterator.hasNext()) {
    String key = keyIterator.next();
    System.out.println(key + ":" + map.get(key));
}

 

 

把这四种方式进行比较,前三种其实属于同一种,都是迭代器遍历方式,如果要同时使用到 key 和 value,推荐使用前三种方式,如果仅仅使用到 key,那么推荐使用第四种。


五.hashMap.remove方法:
public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.getValue());
    }

final Entry<K,V> removeEntryForKey(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
        int i = indexFor(hash, table.length);
        HashMapEntry<K,V> prev = table[i];
        HashMapEntry<K,V> e = prev;
 
        while (e != null) {
            HashMapEntry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }
 
        return e;}

六.总结

1.HashMap结合了数组和链表的优点,使用Hash算法加快访问速度,使用散列表解决碰撞冲突的问题,其中数组的每个元素是单链表的头结点,链表是用来解决冲突的


2.HashMap有两个重要的参数:初始容量和加载因子。这两个参数极大的影响了HashMap的性能。初始容量是hash数组的长度,当前加载因子=当前hash数组元素/hash数组长度,最大加载因子为最大能容纳的数组元素个数(默认最大加载因子为0.75),当hash数组中的元素个数超出了最大加载因子和容量的乘积时,要对hashMap进行扩容,扩容过程存在于hashmap的put方法中,扩容过程始终以2次方增长。


3.HashMap是泛型类,key和value可以为任何类型,包括null类型。key为null的键值对永远都放在以table[0]为头结点的链表中,当然不一定是存放在头结点table[0]中。

4.哈希表的容量一定是2的整数次幂

5.   jdk1.7中插入数据采用的是头插法,也就是新来的元素会加在链表的开头,类似于栈,后来居上。因为开发者认为后加的元素可能被用到的几率更大,所以头插法可以快速查询。
当然这也带来了安全隐患,就是在多线程环境下,可能会出现死循环。

为了解决这个问题,jdk8采用了尾插法。
不过HashMap本身就不是线程安全的,所以不建议在多线程下用。

6.      1.8的实现在1.7的基础上,增加了红黑树,HashMap的底层数据结构为数组+链表+红黑树
为什么要加红黑树呢,因为发现不管扩容机制有多好,依然会出现大量链表导致查询效率低下,所以在插入时,依然按照链表插入,这里不同于7,8里的插入时插入在尾部。当发现链表节点数到达8时(同时满足数组长度达到64,如果数组长度没达到64会先扩容),会将此链表转成红黑树。如果扩容后发现红黑树节点个数减少到了6,那么又会转换成链表。
除了底层数据结构,8还做了很多性能上的优化,比如扩容时不再重新计算哈希值和索引,直接用高低位的一种比较可直接得出索引,速度会更快。原计算索引加扩容前桶的容量,即扩容后新的索引。

7.哈希冲突的解决方法:开放地址法,链地址法,公共溢出区,再哈希法。

8.

       1.8中对HashMap做了哪些修改?

  • 由数组+链表的结构改为数组+链表+红黑树。
  • 优化了高位运算的hash算法:h^(h>>>16)
  • 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
  • 不会在出现死循环问题。

9.

为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?

  • 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。

 10.

不用红黑树,用二叉树可以吗?

  • 可以,但是在一些极端情况下,会退化成一条线性结构
11.

可变类作为key可能发生什么问题

  • 取不出来值
12.

如何实现自定义类作为key?

  • 只要搞定两个问题即可:重写hashcode和equals,类不可变