java集合的底层原理(Map的底层原理 一)

迈不过友情╰ 2023-02-14 03:09 74阅读 0赞

此文承接 java集合的底层原理(List的底层原理),具体可以此文的开头讲述,此处简要概述的map的结构如下

Map 接口 键值对的集合 (双列集合)
├———Hashtable 接口实现类, 同步, 线程安全
├———HashMap 接口实现类 ,没有同步, 线程不安全-
│—————–├ LinkedHashMap 双向链表和哈希表实现
│—————–└ WeakHashMap
├ ——–TreeMap 红黑树对所有的key进行排序
└———IdentifyHashMap

一、HashMap

1.1 概述

  1. HashMap基于Map接口实现,元素以键值对的方式存储,并且允许使用null 建和null值, 因为key不允许重复,因此只能有一个键为null,另外HashMap不能保证放入元素的顺序,它是无序的,和放入的顺序并不能相同。HashMap是线程不安全的
  2. java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3liNTQ2ODIyNjEy_size_16_color_FFFFFF_t_70

从上图中可以看出,HashMap底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个HashMap的时候,就会初始化一个数组。

java源码如下:

  1. /**
  2. * The table, resized as necessary. Length MUST Always be a power of two.
  3. */
  4. transient Entry[] table;
  5. static class Entry<K,V> implements Map.Entry<K,V> {
  6. final K key;
  7. V value;
  8. Entry<K,V> next;
  9. final int hash;
  10. ……
  11. }

1.2 HashMap实现存储读取元素

先看存储源码:

  1. public V put(K key, V value) {
  2. //调用putVal()方法完成
  3. return putVal(hash(key), key, value, false, true);
  4. }
  5. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  6. boolean evict) {
  7. Node<K,V>[] tab; Node<K,V> p; int n, i;
  8. //判断table是否初始化,否则初始化操作
  9. if ((tab = table) == null || (n = tab.length) == 0)
  10. n = (tab = resize()).length;
  11. //计算存储的索引位置,如果没有元素,直接赋值
  12. if ((p = tab[i = (n - 1) & hash]) == null)
  13. tab[i] = newNode(hash, key, value, null);
  14. else {
  15. Node<K,V> e; K k;
  16. //节点若已经存在,执行赋值操作
  17. if (p.hash == hash &&
  18. ((k = p.key) == key || (key != null && key.equals(k))))
  19. e = p;
  20. //判断链表是否是红黑树
  21. else if (p instanceof TreeNode)
  22. //红黑树对象操作
  23. e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  24. else {
  25. //为链表,
  26. for (int binCount = 0; ; ++binCount) {
  27. if ((e = p.next) == null) {
  28. p.next = newNode(hash, key, value, null);
  29. //链表长度8,将链表转化为红黑树存储
  30. if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  31. treeifyBin(tab, hash);
  32. break;
  33. }
  34. //key存在,直接覆盖
  35. if (e.hash == hash &&
  36. ((k = e.key) == key || (key != null && key.equals(k))))
  37. break;
  38. p = e;
  39. }
  40. }
  41. if (e != null) { // existing mapping for key
  42. V oldValue = e.value;
  43. if (!onlyIfAbsent || oldValue == null)
  44. e.value = value;
  45. afterNodeAccess(e);
  46. return oldValue;
  47. }
  48. }
  49. //记录修改次数
  50. ++modCount;
  51. //判断是否需要扩容
  52. if (++size > threshold)
  53. resize();
  54. //空操作
  55. afterNodeInsertion(evict);
  56. return null;
  57. }

根据hash值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

hash(int h)方法根据key的hashCode重新计算一次散列。此算法加入了高位计算,防止低位不变,高位变化时,造成的hash冲突。

我们可以看到在HashMap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表,这样就大大优化了查询的效率。

根据上面 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 链的头部——具体说明继续看 addEntry() 方法的说明。

读取元素源码

  1. public V get(Object key) {
  2. if (key == null)
  3. return getForNullKey();
  4. int hash = hash(key.hashCode());
  5. for (Entry<K,V> e = table[indexFor(hash, table.length)];
  6. e != null;
  7. e = e.next) {
  8. Object k;
  9. if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
  10. return e.value;
  11. }
  12. return null;
  13. }

从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

总结起来就是:

  1. HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry\[\] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry

1.3 HashMap的扩容

  1. hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize

所以扩容必须满足两条件

  • 存放新值的时候当前已有元素必须大于阈值;
  • 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值计算出的数组索引位置已经存在值)

    那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

总结:

1、HashMap是基于哈希表的Map接口的非同步实现,允许使用null值和null键,但不保证映射的顺序。
2、底层使用数组实现,数组中每一项是个单向链表,即数组和链表的结合体;当链表长度大于一定阈值(8)时,链表转换为红黑树(在Jdk1.8的优化),这样减少链表查询时间。
3、HashMap在底层将key-value当成一个整体进行处理,这个整体就是一个Node对象。HashMap底层采用一个Node[]数组来保存所有的key-value对,当需要存储一个Node对象时,会根据key的hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Node时,也会根据key的hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Node。
4、HashMap进行数组扩容需要重新计算扩容后每个元素在数组中的位置,很耗性能
5、采用了Fail-Fast机制,通过一个modCount值记录修改次数,对HashMap内容的修改都将增加这个值。迭代器初始化过程中会将这个值赋给迭代器的expectedModCount,在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map,马上抛出异常

二、HashTable

2.1 概述

和HashMap一样,HashTable也是一个散列表,它存储的内容是键值对映射。HashTable继承于Dictionary,实现了Map、Cloneable、java.io.Serializable接口。HashTable的函数都是同步的,这意味着它是线程安全的。它的Key、Value都不可以为null。此外,HashTable中的映射不是有序的。

HashTable的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量就是哈希表创建时的容量。注意,哈希表的状态为open:在发生“哈希冲突”的情况下,单个桶会存储多个条目,这些条目必须按顺序搜索。加载因子是对哈希表在其容量自动增加之前可以达到多满的一个尺度。初始容量和加载因子这两个参数只是对该实现的提示。关于何时以及是否调用rehash方法的具体细节则依赖于该实现。通常,默认加载因子是0.75。

2.2 数据结构

HashTable与Map关系如下

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3liNTQ2ODIyNjEy_size_16_color_FFFFFF_t_70 1

  1. public class Hashtable<K,V>
  2. extends Dictionary<K,V>
  3. implements Map<K,V>, Cloneable, java.io.Serializable

HashTable并没有去继承AbstractMap,而是选择继承了Dictionary类,Dictionary是个被废弃的抽象类

2.3 实现原理

成员变量跟HashMap基本类似,但是HashMap更加规范,HashMap内部还定义了一些常量,比如默认的负载因子,默认的容量,最大容量等。

  1. public Hashtable(int initialCapacity, float loadFactor) {//可指定初始容量和加载因子
  2. if (initialCapacity < 0)
  3. throw new IllegalArgumentException("Illegal Capacity: "+
  4. initialCapacity);
  5. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  6. throw new IllegalArgumentException("Illegal Load: "+loadFactor);
  7. if (initialCapacity==0)
  8. initialCapacity = 1;//初始容量最小值为1
  9. this.loadFactor = loadFactor;
  10. table = new Entry[initialCapacity];//创建桶数组
  11. threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);//初始化容量阈值
  12. useAltHashing = sun.misc.VM.isBooted() &&
  13. (initialCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
  14. }
  15. /**
  16. * Constructs a new, empty hashtable with the specified initial capacity
  17. * and default load factor (0.75).
  18. */
  19. public Hashtable(int initialCapacity) {
  20. this(initialCapacity, 0.75f);//默认负载因子为0.75
  21. }
  22. public Hashtable() {
  23. this(11, 0.75f);//默认容量为11,负载因子为0.75
  24. }
  25. /**
  26. * Constructs a new hashtable with the same mappings as the given
  27. * Map. The hashtable is created with an initial capacity sufficient to
  28. * hold the mappings in the given Map and a default load factor (0.75).
  29. */
  30. public Hashtable(Map<? extends K, ? extends V> t) {
  31. this(Math.max(2*t.size(), 11), 0.75f);
  32. putAll(t);
  33. }

为避免扩容带来的性能问题,建议指定合理容量。跟HashMap一样,HashTable内部也有一个静态类叫Entry,其实是个键值对,保存了键和值的引用。也可以理解为一个单链表的节点,因为其持有下一个Entry对象的引用

2.3 存取实现

存数据

  1. public synchronized V put(K key, V value) {//向哈希表中添加键值对
  2. // Make sure the value is not null
  3. if (value == null) {//确保值不能为空
  4. throw new NullPointerException();
  5. }
  6. // Makes sure the key is not already in the hashtable.
  7. Entry tab[] = table;
  8. int hash = hash(key);//根据键生成hash值---->若key为null,此方法会抛异常
  9. int index = (hash & 0x7FFFFFFF) % tab.length;//通过hash值找到其存储位置
  10. for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {/遍历链表
  11. if ((e.hash == hash) && e.key.equals(key)) {//若键相同,则新值覆盖旧值
  12. V old = e.value;
  13. e.value = value;
  14. return old;
  15. }
  16. }
  17. modCount++;
  18. if (count >= threshold) {//当前容量超过阈值。需要扩容
  19. // Rehash the table if the threshold is exceeded
  20. rehash();//重新构建桶数组,并对数组中所有键值对重哈希,耗时!
  21. tab = table;
  22. hash = hash(key);
  23. index = (hash & 0x7FFFFFFF) % tab.length;//这里是取摸运算
  24. }
  25. // Creates the new entry.
  26. Entry<K,V> e = tab[index];
  27. //将新结点插到链表首部
  28. tab[index] = new Entry<>(hash, key, value, e);//生成一个新结点
  29. count++;
  30. return null;
  31. }

取数据

  1. public synchronized V get(Object key) {//根据键取出对应索引
  2. Entry tab[] = table;
  3. int hash = hash(key);//先根据key计算hash值
  4. int index = (hash & 0x7FFFFFFF) % tab.length;//再根据hash值找到索引
  5. for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {//遍历entry链
  6. if ((e.hash == hash) && e.key.equals(key)) {//若找到该键
  7. return e.value;//返回对应的值
  8. }
  9. }
  10. return null;//否则返回null
  11. }

总结:

1、Hashtable是基于哈希表的Map接口的同步实现,不允许使用null值和null键底层使用数组实现,数组中每一项是个单链表,即数组和链表的结合体
2、Hashtable在底层将key-value当成一个整体进行处理,这个整体就是一个Entry对象。Hashtable底层采用一个Entry[]数组来保存所有的key-value对,当需要存储一个Entry对象时,会根据key的hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry时,也会根据key的hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。
3、HashTable中若干方法都添加了synchronized关键字,也就意味着这个HashTable是个线程安全的类,这是它与HashMap最大的不同点

4、HashTable每次扩容都是旧容量的2倍加2,而HashMap为旧容量的2倍。

发表评论

表情:
评论列表 (有 0 条评论,74人围观)

还没有评论,来说两句吧...

相关阅读