java容器类---HashMap、HashSet

来源:互联网 发布:淘宝店铺客服工资 编辑:程序博客网 时间:2024/05/22 14:56

1. HashMap简介

1.1  HashMap的数据结构

数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。
数组:数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
链表:链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

哈希表:
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法——链法,我们可以理解为“链表的数组” ,如图:

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

1.2 HashMap继承关系

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

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

源码如下:

/**  * The table, resized as necessary. Length MUST Always be a power of two.  */  transient Entry[] table;    static class Entry<K,V> implements Map.Entry<K,V> {      final K key;      V value;      Entry<K,V> next;      final int hash;      ……  }

可以看出,Entry就是数组中的元素,每个 Map.Entry 其实就是一个key-value对,它持有一个指向下一个元素的引用,这就构成了链表。hash一般存储key.hashcode()。

   // 默认的初始容量(容量为HashMap中槽的数目)是16,且实际容量必须是2的整数次幂。        static final int DEFAULT_INITIAL_CAPACITY = 16;           // 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)        static final int MAXIMUM_CAPACITY = 1 << 30;           // 默认加载因子为0.75       static final float DEFAULT_LOAD_FACTOR = 0.75f;           // 存储数据的Entry数组,长度是2的幂。        // HashMap采用链表法解决冲突,每一个Entry本质上是一个单向链表        transient Entry[] table;           // HashMap的底层数组中已用槽的数量        transient int size;           // HashMap的阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*加载因子)        int threshold;           // 加载因子实际大小        final float loadFactor;           // HashMap被改变的次数        transient volatile int modCount; 

2.HashMap 构造器

HashMap 包含如下几个构造器:

HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

当创建一个 HashMap 时,系统会自动创建一个 table 数组来保存 HashMap 中的 Entry,下面是 HashMap 中一个构造器的代码:

// 以指定初始化容量、负载因子创建 HashMap  public HashMap(int initialCapacity, float loadFactor)  {  // 初始容量不能为负数 if (initialCapacity < 0)  throw new IllegalArgumentException( "Illegal initial capacity: " +  initialCapacity);  // 如果初始容量大于最大容量,让出示容量 if (initialCapacity > MAXIMUM_CAPACITY)  initialCapacity = MAXIMUM_CAPACITY;  // 负载因子必须大于 0 的数值 if (loadFactor <= 0 || Float.isNaN(loadFactor))  throw new IllegalArgumentException(  loadFactor);  // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。 int capacity = 1;  while (capacity < initialCapacity)  capacity <<= 1;  this.loadFactor = loadFactor;  // 设置容量极限等于容量 * 负载因子 threshold = (int)(capacity * loadFactor);  // 初始化 table 数组 table = new Entry[capacity];  // ① init();  }

对于 HashMap 及其子类而言,它们采用 Hash 算法来决定集合中元素的存储位置。当系统开始初始化 HashMap 时,系统会创建一个长度为 capacity 的 Entry 数组,这个数组里可以存储元素的位置被称为“桶(bucket)”,每个 bucket都有其指定索引,系统可以根据其索引快速访问该 bucket 里存储的元素。

无论何时,HashMap 的每个“桶”只存储一个元素(也就是一个 Entry),由于 Entry 对象可以包含一个引用变量(就是 Entry 构造器的的最后一个参数)用于指向下一个 Entry,因此可能出现的情况是:HashMap 的 bucket 中只有一个 Entry,但这个 Entry 指向另一个 Entry ——这就形成了一个 Entry 链。如图所示:



3、 HashMap 的常用方法

3.1 存储方法

HashMap 采用一种所谓的“Hash 算法”来决定每个元素的存储位置。HashMap 类的 put(K key , V value) 方法的源代码:

public V put(K key, V value)  {  // 如果 key 为 null,调用 putForNullKey 方法进行处理 if (key == null)  return putForNullKey(value);  // 根据 key 的 keyCode 计算 Hash 值 int hash = hash(key.hashCode());  // 搜索指定 hash 值在对应 table 中的索引  int i = indexFor(hash, table.length); // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素 for (Entry<K,V> e = table[i]; e != null; e = e.next)  {  Object k;  // 找到指定 key 与需要放入的 key 相等(hash 值相同 // 通过 equals 比较放回 true) if (e.hash == hash && ((k = e.key) == key  || key.equals(k)))  {  V oldValue = e.value;  e.value = value;  e.recordAccess(this);  return oldValue;  }  }  // 如果 i 索引处的 Entry 为 null,表明此处还没有 Entry  modCount++;  // 将 key、value 添加到 i 索引处 addEntry(hash, key, value, i);  return null;  }
3.1.1 key==null时

private V putForNullKey(V 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++;        addEntry(0, null, value, 0);        return null;    }
HashMap允许存放null键和null值。key值只允许有一个null,value则可以允许有多个null。

当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。也就是存在key=null时,则table[0]中的Entry存放 key=null value="新值"。记住,key为null的键值对永远都放在以table[0]为头结点的链表中,当然不一定是存放在头结点table[0]中

3.1.2 key!=null时

从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。

上面方法提供了一个根据 hashCode() 返回值来计算 Hash 码的方法:hash(),这个方法是一个纯粹的数学计算,其方法如下:

static int hash(int h) {     h ^= (h >>> 20) ^ (h >>> 12);     return h ^ (h >>> 7) ^ (h >>> 4); }

为什么要经过这样的运算呢?这就是HashMap的高明之处。先看个例子,一个十进制数32768(二进制1000 0000 0000 0000),经过上述公式运算之后的结果是35080(二进制1000 1001 0000 1000)。看出来了吗?或许这样还看不出什么,再举个数字61440(二进制1111 0000 0000 0000),运算结果是65263(二进制1111 1110 1110 1111),现在应该很明显了,它的目的是让“1”变的均匀一点,散列的本意就是要尽量均匀分布


对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用 hash(int h) 方法所计算得到的 Hash 码值总是相同的。接下来程序会调用 indexFor(int h, int length) 方法来计算该对象应该保存在 table 数组的哪个索引处。indexFor(int h, int length) 方法的代码如下:

static int indexFor(int h, int length) {     return h & (length-1); }

这个方法非常巧妙,它总是通过 h &(table.length -1) 来得到该对象的保存位置——而 HashMap 底层数组的长度总是 2 的 n 次方。

h & (table.length-1),这样得到的结果就是一个比length小的正数,我们把这个值叫做index。其实这个index就是索引将要插入的值在table数组中的位置。上面那个hash算法的意义就是希望能够得出均匀的index,这是对HashTable的改进,HashTable中的算法只是把key的 hashcode与length相除取余,即hash % length,这样有可能会造成index分布不均匀。


根据上面put方法的源代码可以看出,当程序试图将一个 key-value 对放入HashMap中时

根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:

如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。

如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。

如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部 


当向 HashMap 中添加 key-value 对,由其key 的 hashCode() 返回值决定该 key-value 对(就是 Entry 对象)的存储位置。当两个 Entry 对象的 key 的 hashCode() 返回值相同时,将由 key 通过 eqauls() 比较值决定是采用覆盖行为(返回 true),还是产生 Entry 链(返回 false)。


上面程序中还调用了 addEntry(hash, key, value, i); 代码,其中 addEntry 是 HashMap 提供的一个包访问权限的方法,该方法仅用于添加一个 key-value 对。下面是该方法的代码:

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);     // 如果 Map 中的 key-value 对的数量超过了极限    if (size++ >= threshold)         // 把 table 对象的长度扩充到 2 倍。        resize(2 * table.length);  // ②}
上面方法的代码很简单,但其中包含了一个非常优雅的设计:系统总是将新添加的 Entry 对象放入 table 数组的 bucketIndex 索引处——如果 bucketIndex 索引处已经有了一个 Entry 对象,那新添加的 Entry 对象指向原有的 Entry 对象(产生一个 Entry 链),如果 bucketIndex 索引处没有 Entry 对象,也就是上面程序①号代码的 e 变量是 null,也就是新放入的 Entry 对象指向 null,也就是没有产生 Entry 链。

根据上面代码可以看出,在同一个 bucket 存储 Entry 链的情况下,新放入的 Entry 总是位于 bucket 中,而最早放入该 bucket 中的 Entry 则位于这个 Entry 链的最末端。
上面程序中还有这样两个变量:
size:该变量保存了该 HashMap 中所包含的 key-value 对的数量
threshold:该变量包含了 HashMap 能容纳的 key-value 对的极限,它的值等于 HashMap 的容量乘以负载因子(load factor)。

从上面程序中②号代码可以看出,当 size++ >= threshold 时,HashMap 会自动调用resize 方法扩充 HashMap 的容量每扩充一次,HashMap 的容量就增大一倍。
上面程序中使用的 table 其实就是一个普通数组,每个数组都有一个固定的长度,这个数组的长度就是 HashMap 的容量。


负载因子(load factor):

当创建 HashMap 时,有一个默认的负载因子(load factor),其默认值为 0.75,这是时间和空间成本上一种折衷:

增大负载因子可以减少 Hash 表(就是那个 Entry 数组)所占用的内存空间即是使内存空间得到更充分的运用,但会增加查询数据的时间开销,而查询是最频繁的的操作(HashMap 的 get() 与 put() 方法都要用到查询);

减小负载因子会提高数据查询的性能,但会增加 Hash 表所占用的内存空间。


掌握了上面知识之后,我们可以在创建 HashMap 时根据实际需要适当地调整 load factor 的值;

如果程序比较关心空间开销、内存比较紧张,可以适当地增加负载因子;如果程序比较关心时间开销,内存比较宽裕则可以适当的减少负载因子。通常情况下,程序员无需改变负载因子的值。

3.2、读取方法

当 HashMap 的每个 bucket 里存储的 Entry 只是单个 Entry ——也就是没有通过指针产生 Entry 链时,此时的 HashMap 具有最好的性能:

当程序通过 key 取出对应 value 时,系统只要先计算出该 key 的 hashCode() 返回值,再根据该 hashCode 返回值找出该key 在 table 数组中的索引,然后取出该索引处的 Entry,最后返回该 key 对应的 value 即可。看 HashMap 类的 get(K key) 方法代码:

public V get(Object key)  {  // 如果 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;  // 搜索该 Entry 链的下一个 Entr  e = e.next)  // ① {  Object k;  // 如果该 Entry 的 key 与被搜索 key 相同 if (e.hash == hash && ((k = e.key) == key  || key.equals(k)))  return e.value;  }  return null;  }
从上面代码中可以看出,如果 HashMap 的每个 bucket 里只有一个 Entry 时,HashMap 可以根据索引、快速地取出该 bucket 里的 Entry;在发生“Hash 冲突”的情况下,单个 bucket 里存储的不是一个 Entry,而是一个 Entry 链,系统只能必须按顺序遍历每个 Entry,直到找到想搜索的 Entry 为止——如果恰好要搜索的 Entry 位于该 Entry 链的最末端(该 Entry 是最早放入该 bucket 中),那系统必须循环到最后才能找到该元素。


归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 Hash 算法来决定其存储位置;当需要取出一个 Entry 时,也会根据 Hash 算法找到其存储位置,直接取出该 Entry。由此可见:HashMap 之所以能快速存、取它所包含的 Entry,完全类似于现实生活中母亲从小教我们的:不同的东西要放在不同的位置,需要时才能快速找到它

4、Fail-Fast机制
   我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
   这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

HashIterator() {      expectedModCount = modCount;      if (size > 0) { // advance to first entry      Entry[] t = table;      while (index < t.length && (next = t[index++]) == null)          ;      }  }
在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map:注意到modCount声明为volatile,保证线程之间修改的可见性。

final Entry<K,V> nextEntry() {         if (modCount != expectedModCount)             throw new ConcurrentModificationException(); 

volatile是一个类型修饰符(type specifier)。它是被设计用来修饰被不同线程访问和修改的变量。

5、HashMap的Iterator

    // HashIterator是HashMap迭代器的抽象出来的父类,实现了公共了函数。        // 它包含“key迭代器(KeyIterator)”、“Value迭代器(ValueIterator)”和“Entry迭代器(EntryIterator)”3个子类。        private abstract class HashIterator<E> implements Iterator<E> {            // 下一个元素            Entry<K,V> next;            // expectedModCount用于实现fast-fail机制。            int expectedModCount;            // 当前索引            int index;            // 当前元素            Entry<K,V> current;               HashIterator() {                expectedModCount = modCount;                if (size > 0) { // advance to first entry                    Entry[] t = table;                    // 将next指向table中第一个不为null的元素。                    // 这里利用了index的初始值为0,从0开始依次向后遍历,直到找到不为null的元素就退出循环。                    while (index < t.length && (next = t[index++]) == null)                        ;                }            }               public final boolean hasNext() {                return next != null;            }               // 获取下一个元素            final Entry<K,V> nextEntry() {                if (modCount != expectedModCount)                    throw new ConcurrentModificationException();                Entry<K,V> e = next;                if (e == null)                    throw new NoSuchElementException();                   // 注意!!!                // 一个Entry就是一个单向链表                // 若该Entry的下一个节点不为空,就将next指向下一个节点;                // 否则,将next指向下一个链表(也是下一个Entry)的不为null的节点。                if ((next = e.next) == null) {                    Entry[] t = table;                    while (index < t.length && (next = t[index++]) == null)                        ;                }                current = e;                return e;            }               // 删除当前元素            public void remove() {                if (current == null)                    throw new IllegalStateException();                if (modCount != expectedModCount)                    throw new ConcurrentModificationException();                Object k = current.key;                current = null;                HashMap.this.removeEntryForKey(k);                expectedModCount = modCount;            }           }   

6、HashSet 的实现

对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层采用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,查看 HashSet 的源代码,可以看到如下代码:

public class HashSet<E>  extends AbstractSet<E>  implements Set<E>, Cloneable, java.io.Serializable  {  // 使用 HashMap 的 key 保存 HashSet 中所有元素 private transient HashMap<E,Object> map;  // 定义一个虚拟的 Object 对象作为 HashMap 的 value  private static final Object PRESENT = new Object();  ...  // 初始化 HashSet,底层会初始化一个 HashMap  public HashSet()  {  map = new HashMap<E,Object>();  }  // 以指定的 initialCapacity、loadFactor 创建 HashSet  // 其实就是以相应的参数创建 HashMap  public HashSet(int initialCapacity, float loadFactor)  {  map = new HashMap<E,Object>(initialCapacity, loadFactor);  }  public HashSet(int initialCapacity)  {  map = new HashMap<E,Object>(initialCapacity);  }  HashSet(int initialCapacity, float loadFactor, boolean dummy)  {  map = new LinkedHashMap<E,Object>(initialCapacity  , loadFactor);  }  // 调用 map 的 keySet 来返回所有的 key  public Iterator<E> iterator()  {  return map.keySet().iterator();  }  // 调用 HashMap 的 size() 方法返回 Entry 的数量,就得到该 Set 里元素的个数 public int size()  {  return map.size();  }  // 调用 HashMap 的 isEmpty() 判断该 HashSet 是否为空, // 当 HashMap 为空时,对应的 HashSet 也为空 public boolean isEmpty()  {  return map.isEmpty();  }  // 调用 HashMap 的 containsKey 判断是否包含指定 key  //HashSet 的所有元素就是通过 HashMap 的 key 来保存的 public boolean contains(Object o)  {  return map.containsKey(o);  }  // 将指定元素放入 HashSet 中,也就是将该元素作为 key 放入 HashMap  public boolean add(E e)  {  return map.put(e, PRESENT) == null;  }  // 调用 HashMap 的 remove 方法删除指定 Entry,也就删除了 HashSet 中对应的元素 public boolean remove(Object o)  {  return map.remove(o)==PRESENT;  }  // 调用 Map 的 clear 方法清空所有 Entry,也就清空了 HashSet 中所有元素 public void clear()  {  map.clear();  }  ...  }

由上面源程序可以看出,HashSet 的实现其实非常简单,它只是封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。


HashSet 的绝大部分方法都是通过调用 HashMap 的方法来实现的,因此 HashSet 和 HashMap 两个集合在实现本质上是相同的。

HashMap 的 put 与 HashSet 的 add:

由于 HashSet 的 add() 方法添加集合元素时实际上转变为调用 HashMap 的 put() 方法来添加 key-value 对,当新放入 HashMap 的 Entry 中 key 与集合中原有 Entry 的 key 相同(hashCode() 返回值相等,通过 equals 比较也返回 true),新添加的 Entry 的 value 将覆盖原来 Entry 的 value,但 key 不会有任何改变,因此如果向 HashSet 中添加一个已经存在的元素,新添加的集合元素(底层由 HashMap 的 key 保存)不会覆盖已有的集合元素


参考来源:

java面试题-HashMap原理 

深入Java集合学习系列:HashMap的实现原理

通过分析 JDK 源代码研究 Hash 存储机制

HashMap实现原理分析

【Java集合源码剖析】HashMap源码剖析



0 0
原创粉丝点击