Java集合框架之HashMap浅析

Java集合框架之HashMap浅析

一、HashMap综述:

1.1、HashMap概述

  • 位于java.util包下的HashMap是Java集合框架的重要成员,它在jdk1.8中定义如下:

  public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable

  • HashMap是基于哈希表的Map接口实现的,它存储的是内容是键值对<key,value>映射。此类不保证映射的顺序,假定哈希函数将元素适当的分布在各桶之间,可为基本操作(get和put)提供稳定的性能。

  • HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。

  • HashMap与Map的关系如下:

    Java集合框架之HashMap浅析

二、HashMap实现原理:

2.1HashMap在JDK1.8以前数据结构和存储原理

2.1.1、HashMap存放对象的流程

  首先我们要知道什么是链表散列?通过数组和链表结合在一起使用,就叫做链表散列。这其实就是hashmap存储的原理
Java集合框架之HashMap浅析
  HashMap的数据结构和存储原理图

Java集合框架之HashMap浅析
可以看出

  • HashMap是一个数组+链表的结构,数组的下标在HashMap中称为Bucket值,每个数组项对应的是一个List,每个List中存放的是一个Entry对象,这个Entry对象是包含键和值的

  • 一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。

  • HashMap其实也是一个线性的数组实现的,所以可以理解为其存储数据的容器就是一个线性数组。这可能让我们很不解,一个线性的数组怎么实现按键值对来存取数据呢?这里HashMap有做一些处理。

  • 首先HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面   

  • HashMap的数据结构就是用的链表散列。那HashMap底层是怎么样使用这个数据结构进行数据存取的呢?分成两个部分:

    • 第一步:HashMap内部有一个entry的内部类,其中有四个属性,我们要存储一个值,则需要一个key和一个value,存到map中就会先将key和value保存在这个Entry类创建的对象中。

     static class Entry<K,V> implements Map.Entry<K,V> {     
       final K key; //就是我们说的map的key     
      V value; //value值,这两个都不陌生     
      Entry<K,V> next;//指向下一个entry对象     
      int hash;//通过key算过来的你hashcode值。
    }
    Entry的物理模型图:

    Java集合框架之HashMap浅析

    • 第二步:构造好了entry对象,然后将该对象放入数组中,如何存放就是这hashMap的精华所在了:通过entry对象中的hash值来确定将该对象存放在数组中的哪个位置上,如果在这个位置上还有其他元素,则通过链表来存储这个元素
  • Hash存放元素的过程:
    • 通过key、value封装成一个entry对象,然后通过key的值来计算该entry的hash值,通过entry的hash值和数组的长度length来计算出entry放在数组中的哪个位置上面
    • 每次存放都是将entry放在第一个位置。在这个过程中,就是通过hash值来确定将该对象存放在数组中的哪个位置上。
  • 总结起来:HashMap使用put(key,value)函数存放对象,当调用put(key,value)方法的时候会发生以下步骤
    • 通过key的hashCode()函数计算key的hash值,再通过hash值得出Bucket值
    • 得出Bucket值后,如果该Bucket值对应的list是一个空列表,那么将生成entry对象,插入到list中,做为list的第一个元素
    • 如果该Bucket值对应的list中已经有其它对象了(如果两个key的hash值一样就会发生),这个时候就发生了碰撞。
    • 发生了碰撞后,新插入的key就会和链表中的其它entry的key进行比较,比较过程源码如下,需要同时用到equals()方法:p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))
    • 从源码中可以看出如果两个key的hashcode一样,并且equals方法比较也相同,那么HashMap就判断这两个key完全一样
    • 如果两个key比较结果一样,由于HashMap不允许同时存在两个相同的key,那么新插入的entry就会覆盖旧entry

2.1.2、rehashing

  • 初始容量和加载因子:默认初始容量是16,加载因子是0.75。容量是哈希表中数组的长度,如文章最开始给的图其容量就为16,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。
  • 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

  • 重新调整HashMap大小存在什么问题

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了,所以不要在多线程的环境下使用HashMap

2.1.3、关于HashMap的容量:

  • HashMap的初始化函数

public HashMap(int initialCapacity, float loadFactor) {  
    ......
    // Find a power of 2 >= initialCapacity  
    // 设置最重的容量为小于initialCapacity的2的整数次幂的最小数
    int capacity = 1;  
    while (capacity < initialCapacity)  
        capacity <<= 1; 
    ......
}

  可以看出capacity被设计成了一个一定为2的整数幂的数,如果initialCapacity设置为11 ,那么最终的capacity就为8

  • 为什么这么设计?

  HashMap中的数据结构是数组+单链表的组合,我们希望的是元素存放的更均匀,最理想的效果是,Entry数组中每个位置都只有一个元素,这样,查询的时候效率最高,不需要遍历单链表,也不需要通过equals去比较K,而且空间利用率最大。那么怎么才能将分布最大的均匀化呢?那就是取余运算%,哈希值 % 容量 = bucketIndex,SUN的大师们是否也是如此做的呢?我们阅读一下这段源码:
static int indexFor(int h, int length) {
  return h & (length-1);
}
  上面这段代码就实现了取余操作
  当容量一定是2^n时,h & (length - 1) == h % length。举个直观的例子,看看这么设计的好处:
    我要将hash值分别为2,4,6的key插入到HashMap中
    如果容量为10那么 (length - 1)的二进制为:1001
    那么h & (length-1)的结果分别为
      2:10 & 1001 = 0
      4:100 & 1001 = 0
      6:110 & 1001 = 0
    所以这3个数的hash值都为0,都发生碰撞了
    如果容量为8那么 (length - 1)的二进制为:111
    那么h & (length-1)的结果分别为
      2:10 & 111 = 2
      4:100 & 111 = 4
      6:110 & 111 = 6
    3个数都没有发生碰撞

2.2jdk1.8后HashMap的数据结构

Java集合框架之HashMap浅析

  • 上图很形象的展示了jdk1.8之后的HashMap的数据结构(数组+链表+红黑树),桶中的结构可能是链表,也可能是红黑树,红黑树的引入是为了提高效率。

2.2.1、HashMap的属性

  • HashMap的实例有两个参数影响其性能。  

  初始容量:哈希表中桶的数量  
  加载因子:哈希表在其容量自动增加之前可以达到多满的一种尺度  
  当哈希表中条目数超出了当前容量*加载因子(其实就是HashMap的实际容量)时,则对该哈希表进行rehash操作,将哈希表扩充至两倍的桶数。  
  Java中默认初始容量为16,加载因子为0.75。
  1)loadFactor加载因子    
  定义:loadFactor译为装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。    
  loadFactor加载因子是控制数组存放数据的疏密程度,loadFactor越趋近于1,那么数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,那么数组中存放的数据也就越稀,也就是可能数组中每个位置上就放一个元素。那有人说,就把loadFactor变为1最好吗,存的数据很多,但是这样会有一个问题,就是我们在通过key拿到我们的value时,是先通过key的hashcode值,找到对应数组中的位置,如果该位置中有很多元素,则需要通过equals来依次比较链表中的元素,拿到我们的value值,这样花费的性能就很高,如果能让数组上的每个位置尽量只有一个元素最好,我们就能直接得到value值了,所以有人又会说,那把loadFactor变得很小不就好了,但是如果变得太小,在数组中的位置就会太稀,也就是分散的太开,浪费很多空间,这样也不好,所以在hashMap中loadFactor的初始值就是0.75,一般情况下不需要更改它。
  2)桶    
  根据前面画的HashMap存储的数据结构图,你这样想,数组中每一个位置上都放有一个桶,每个桶里就是装一个链表,链表中可以有很多个元素(entry),这就是桶的意思。也就相当于把元素都放在桶中。  
  3)capacity    
  capacity译为容量代表的数组的容量,也就是数组的长度,同时也是HashMap中桶的个数。默认值是16。      
  一般第一次扩容时会扩容到64,之后好像是2倍。总之,容量都是2的幂。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

  4)size的含义    
  size就是在该HashMap的实例中实际存储的元素的个数  
  5)threshold的作用    
  threshold = capacity * loadFactor,当Size>=threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是衡量数组是否需要扩增的一个标准。    
  注意这里说的是考虑,因为实际上要扩增数组,除了这个size>=threshold条件外,还需要另外一个条件。    
  什么时候会扩增数组的大小?在put一个元素时先size>=threshold并且还要在对应数组位置上有元素,这才能扩增数组。
  我们通过一张HashMap的数据结构图来分析:

Java集合框架之HashMap浅析      
  当插入一个元素的时候size就加1,若size大于threshold的时候,就会进行扩容。假设我们的capacity大小为32,loadFator为0.75,则threshold为24 = 32 * 0.75,此时,插入了25个元素,并且插入的这25个元素都在同一个桶中,桶中的数据结构为红黑树,则还有31个桶是空的,也会进行扩容处理,其实,此时,还有31个桶是空的,好像似乎不需要进行扩容处理,但是是需要扩容处理的,因为此时我们的capacity大小可能不适当。我们前面知道,扩容处理会遍历所有的元素,时间复杂度很高;前面我们还知道,经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。所以,说尽量避免进行扩容处理,也就意味着,遍历元素所带来的坏处大于元素在桶中均匀分布所带来的好处。   
    要知道hashMap在JDK1.8以前是一个链表散列这样一个数据结构,而在JDK1.8以后是一个数组加链表加红黑树的数据结构。  
    hashMap是一个能快速通过key获取到value值得一个集合,原因是内部使用的是hash查找值得方法。

三、HashMap方法摘要

  • 构造方法摘要

HashMap()
          构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity)
          构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, float loadFactor)
          构造一个带指定初始容量和加载因子的空 HashMap。
HashMap(Map<? extends K,? extends V> m)
          构造一个映射关系与指定 Map 相同的新 HashMap。

  这里有两个很重要的参数:initialCapacity(初始容量)、loadFactor(加载因子)

  • 方法摘要

void    clear()
          从此映射中移除所有映射关系。
 Object    clone()
          返回此 HashMap 实例的浅表副本:并不复制键和值本身。
 boolean    containsKey(Object key)
          如果此映射包含对于指定键的映射关系,则返回 true。
 boolean    containsValue(Object value)
          如果此映射将一个或多个键映射到指定值,则返回 true。
 Set<Map.Entry<K,V>>    entrySet()
          返回此映射所包含的映射关系的 Set 视图。
 V    get(Object key)
          返回指定键所映射的值;如果对于该键来说,此映射不包含任何映射关系,则返回 null。
 boolean    isEmpty()
          如果此映射不包含键-值映射关系,则返回 true。
 Set<K>    keySet()
          返回此映射中所包含的键的 Set 视图。
 V    put(K key, V value)
          在此映射中关联指定值与指定键。
 void    putAll(Map<? extends K,? extends V> m)
          将指定映射的所有映射关系复制到此映射中,这些映射关系将替换此映射目前针对指定映射中所有键的所有映射关系。
 V    remove(Object key)
          从此映射中移除指定键的映射关系(如果存在)。
 int    size()
          返回此映射中的键-值映射关系数。
 Collection<V>    values()
          返回此映射所包含的值的 Collection 视图。
参考文章:
  https://www.cnblogs.com/zhangyinhua/p/7698642.html
  http://blog.csdn.net/lfdanding/article/details/51168588
  http://blog.csdn.net/great_smile/article/details/53119489
  https://www.cnblogs.com/skywang12345/p/3310835.html#a24

上一篇:Java集合框架之TreeMap浅析


下一篇:UVA 393