学习笔记-深入理解Java中的HashMap数据结构

一:定义

  HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

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

二:构造函数和数据结构

  HashMap提供了三个构造函数:

1 HashMap() 
2 
3 HashMap(int initialCapacity)
4  
5 HashMap(int initialCapacity, float loadFactor)

第一个:构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

第二个:构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

第三个:构造一个带指定初始容量和加载因子的空 HashMap。

那么这两个参数有什么含义呢?在HashMap中有什么作用?我们先来看一下HashMap的数据结构深入理解之后在回过头来看。我们看每次调用map.put方法是,其实我们是往Entry<K,V>[] tab 这个数组里面添加元素的。那么Entry这个又是一个什么呢?

 1  static class Entry<K,V> implements Map.Entry<K,V> {
 2         final K key;
 3         V value;
 4         Entry<K,V> next;
 5         final int hash;
 6 
 7         /**
 8          * Creates new entry.
 9          */
10         Entry(int h, K k, V v, Entry<K,V> n) {
11             value = v;
12             next = n;
13             key = k;
14             hash = h;
15         }
16 }

 

很显然,这其实是一个链表的数据结构。所以我们分析一下,那么HashMap的数据结构是不是就是张的这个样子的呀?  

其实呢,我们在开始的构造函数里面的initialCapacity这个参数,就是指的这个数组的长度了,我们在看一下具有两个参数的那个构造方法。

 1 public HashMap(int initialCapacity, float loadFactor) {
 2         //初始容量不能<0
 3         if (initialCapacity < 0)
 4             throw new IllegalArgumentException("Illegal initial capacity: "
 5                     + initialCapacity);
 6         //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
 7         if (initialCapacity > MAXIMUM_CAPACITY)
 8             initialCapacity = MAXIMUM_CAPACITY;
 9         //负载因子不能 < 0
10         if (loadFactor <= 0 || Float.isNaN(loadFactor))
11             throw new IllegalArgumentException("Illegal load factor: "
12                     + loadFactor);
13 
14         // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
15         int capacity = 1;
16         while (capacity < initialCapacity)
17             capacity <<= 1;
18         
19         this.loadFactor = loadFactor;
20         //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
21         threshold = (int) (capacity * loadFactor);
22         //初始化table数组
23         table = new Entry[capacity];
24         init();
25     }

每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。

三,添加数据

  

 1 public V put(K key, V value) {
 2         //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
 3         if (key == null)
 4             return putForNullKey(value);
 5         //计算key的hash值
 6         int hash = hash(key.hashCode());                  ------(1)
 7         //计算key hash 值在 table 数组中的位置
 8         int i = indexFor(hash, table.length);             ------(2)
 9         //从i出开始迭代 e,找到 key 保存的位置
10         for (Entry<K, V> e = table[i]; e != null; e = e.next) {
11             Object k;
12             //判断该条链上是否有hash值相同的(key相同)
13             //若存在相同,则直接覆盖value,返回旧value
14             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
15                 V oldValue = e.value;    //旧值 = 新值
16                 e.value = value;
17                 e.recordAccess(this);
18                 return oldValue;     //返回旧值
19             }
20         }
21         //修改次数增加1
22         modCount++;
23         //将key、value添加至i位置处
24         addEntry(hash, key, value, i);
25         return null;
26     }

重点来了,我们看一下上面代码第6,8行,一个是通过key计算hash值,一个是通过hash值定位到数组中的位置。

1 static int hash(int h) {
2         h ^= (h >>> 20) ^ (h >>> 12);
3         return h ^ (h >>> 7) ^ (h >>> 4);
4     }
5     
6 static int indexFor(int h, int length) {
7         return h & (length-1);
8     }

对于HashMap的数组而言,我们需要他里面的数据分布的尽量均匀,最好是每一项都有元素。因为分布的太紧张查询蛮,分布的太分散浪费空间。因为我们在构造函数中capacity <<= 1;这样做总是能够保证HashMap的底层数组长度为2的n次方。所以index的方法就相当于是对lenght取模处理。那么我们为什么要取模处理呢?因为length是2的N次方,当length-1的时候,你会发现得到的结果值 进行地位&运算时候,值与原来的hash值相同,而进行高位运算时,其值等于其低位值。所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

那么所以整体上来讲,HashMap的put方法就是:首先会计算key的hash值,然后根据hash值确认在table中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等 ,则将该节点插入该链表的链头。

这种解决hash冲突的方法叫做【链地址法】,还有其他的比如:再哈希法,开放定址法,建立一个公共溢出区。

具体的实现过程见addEntry方法。

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

首先,系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。

然后是扩容,  随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度,为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。该临界点在当HashMap中元素的数量等于table数组长度*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。具体扩容的代码很简单,

 

 1 void resize(int newCapacity) {
 2         Entry[] oldTable = table;
 3         int oldCapacity = oldTable.length;
 4         if (oldCapacity == MAXIMUM_CAPACITY) {
 5             threshold = Integer.MAX_VALUE;
 6             return;
 7         }
 8         Entry[] newTable = new Entry[newCapacity];
 9         boolean oldAltHashing = useAltHashing;
10         useAltHashing |= sun.misc.VM.isBooted() &&
11                 (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
12         boolean rehash = oldAltHashing ^ useAltHashing;
13         //transfer函数的调用
14         transfer(newTable, rehash);  
15         table = newTable;
16         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
17     } 
18 
19 void transfer(Entry[] newTable, boolean rehash) {
20         int newCapacity = newTable.length;
21         //这里才是问题出现的关键
22         for (Entry<K,V> e : table) {
23             //遍历旧的Entry数组的每个元素,
24             while(null != e) {
25                 //寻找到下一个节点..
26                 Entry<K,V> next = e.next;  
27                 if (rehash) {
28                     e.hash = null == e.key ? 0 : hash(e.key);
29                 }
30                 //重新计算每个元素在数组中的位置
31                 int i = indexFor(e.hash, newCapacity);  
32                 e.next = newTable[i];  
33                 newTable[i] = e;
34                 e = next;
35             }
36         }

 

 

 

四,读取数据

  通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

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

 

 

 

2018年5月2日更新:

发现当时jdk1.7的版本和现在的版本中HashMap的实现变化还挺大的。所以这里在重新更新一下,把认识到的变化在这里再做一些补充下,避免后面大家看到了这个对大家产生误导。

第一点:

JDK1.8 之后的 HashMap 底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。我们知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。

 

第二点:在hash方面,首先,在高位扰动方面,只是简单的h = h ^ (h >>> 16),没有再做那么多的扰动,就得到了hash值。其次,去掉了indexFor这个专门定位的函数,而是在put,get等操作中直接定位,可以看到这些函数中都有这两行。

1 static final int hash(Object key) {
2         int h;
3         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4     }

 

第三点:就是扩容机制,jdk1.7扩容是直接采用头插将老的数据遍历插入到新的table中。在jdk1.8新版本中,我们来看一下扩容机制。

 1  final Node<K,V>[] resize() {
 2         Node<K,V>[] oldTab = table;//首次初始化后table为Null
 3         int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4         int oldThr = threshold;//默认构造器的情况下为0
 5         int newCap, newThr = 0;
 6         if (oldCap > 0) {//table扩容过
 7              //当前table容量大于最大值得时候返回当前table
 8              if (oldCap >= MAXIMUM_CAPACITY) {
 9                 threshold = Integer.MAX_VALUE;
10                 return oldTab;
11             }
12             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
13                      oldCap >= DEFAULT_INITIAL_CAPACITY)
14             //table的容量乘以2,threshold的值也乘以2           
15             newThr = oldThr << 1; // double threshold
16         }
17         else if (oldThr > 0) // initial capacity was placed in threshold
18         //使用带有初始容量的构造器时,table容量为初始化得到的threshold
19         newCap = oldThr;
20         else {  //默认构造器下进行扩容  
21              // zero initial threshold signifies using defaults
22             newCap = DEFAULT_INITIAL_CAPACITY;
23             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
24         }
25         if (newThr == 0) {
26         //使用带有初始容量的构造器在此处进行扩容
27             float ft = (float)newCap * loadFactor;
28             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
29                       (int)ft : Integer.MAX_VALUE);
30         }
31         threshold = newThr;
32         @SuppressWarnings({"rawtypes","unchecked"})
33         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
34         table = newTab;
35         if (oldTab != null) {
36             for (int j = 0; j < oldCap; ++j) {
37                 HashMap.Node<K,V> e;
38                 if ((e = oldTab[j]) != null) {
39                     // help gc
40                     oldTab[j] = null;
41                     if (e.next == null)
42                         // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
43                         // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
44                         newTab[e.hash & (newCap - 1)] = e;
45                     else if (e instanceof HashMap.TreeNode)
46                         // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
47                         ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
48                     else { // preserve order
49                         // 把当前index对应的链表分成两个链表,减少扩容的迁移量
50                         HashMap.Node<K,V> loHead = null, loTail = null;
51                         HashMap.Node<K,V> hiHead = null, hiTail = null;
52                         HashMap.Node<K,V> next;
53                         do {
54                             next = e.next;
55                             if ((e.hash & oldCap) == 0) {
56                                 // 扩容后不需要移动的链表
57                                 if (loTail == null)
58                                     loHead = e;
59                                 else
60                                     loTail.next = e;
61                                 loTail = e;
62                             }
63                             else {
64                                 // 扩容后需要移动的链表
65                                 if (hiTail == null)
66                                     hiHead = e;
67                                 else
68                                     hiTail.next = e;
69                                 hiTail = e;
70                             }
71                         } while ((e = next) != null);
72                         if (loTail != null) {
73                             // help gc
74                             loTail.next = null;
75                             newTab[j] = loHead;
76                         }
77                         if (hiTail != null) {
78                             // help gc
79                             hiTail.next = null;
80                             // 扩容长度为当前index位置+旧的容量
81                             newTab[j + oldCap] = hiHead;
82                         }
83                     }
84                 }
85             }
86         }
87         return newTab;
88     }

 

 

2018.12.3更新

hashMap扩展知识点:

 

1、为什么用HashMap?
HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射
HashMap采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
HashMap是非synchronized,所以HashMap很快
HashMap可以接受null键和值,而Hashtable则不能(原因就是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以)

 

2、HashMap的工作原理是什么?
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node 。

数据结构:

Node[] table=new Node[16]  散列桶初始化,table
class Node {
 hash;//hash值
      key;//键
 value;//值
 node next;//用于指向链表的下一层(产生冲突,用拉链法)

}

以下是具体的put过程(JDK1.8版)
1、对Key求Hash值,然后再计算下标

2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中)

3、如果碰撞了,以链表的方式链接到后面

4、如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表

5、如果节点已经存在就替换旧值

6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)
当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

 

3、有什么方法可以减少碰撞?
扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。)
使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

 

4、HashMap中hash函数怎么是是实现的?
我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式,我们来看看JDK1.8的源码是怎么做的 -- 经过简化处理

static final int hash(Object key) {
    if (key == null){
        return 0;
    }
     int h;
     h=key.hashCode();返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      //其中n是数组的长度,即Map的数组部分初始化长度
     return  (n-1)&(h ^ (h >>> 16));
}

简单来说就是

1、高16bt不变,低16bit和高16bit做了一个异或(得到的HASHCODE转化为32位的二进制,前16位和后16位低16bit和高16bit做了一个异或)

2、(n·1)&hash=->得到下标

 

5、拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

 

6、重新调整HashMap大小存在什么问题吗?
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。(多线程的环境下不使用HashMap)
为什么多线程会导致死循环,它是怎么发生的?
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。1.扩容:创建一个新的Entry空数组,长度是原数组的2倍。2.ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

 

7、HashMap ,HashTable 区别

默认容量不同。扩容不同
线程安全性,HashTable 安全
效率不同 HashTable 要慢因为加锁

 

8、ConcurrentHashMap 原理
1、最大特点是引入了 CAS(借助 Unsafe 来实现【native code】)

CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
Unsafe 借助 CPU 指令 cmpxchg 来实现
使用实例:
1、对 sizeCtl 的控制都是用 CAS 来实现的

1、sizeCtl :默认为0,用来控制 table 的初始化和扩容操作。

-1 代表table正在初始化
N 表示有 -N-1 个线程正在进行扩容操作
如果table未初始化,表示table需要初始化的大小。
如果table初始化完成,表示table的容量,默认是table大小的0.75倍,居然用这个公式算0.75(n - (n >>> 2))。
4、CAS 会出现的问题:ABA

对变量增加一个版本号,每次修改,版本号加 1,比较的时候比较版本号。

9、我们可以使用CocurrentHashMap来代替Hashtable吗?
我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。(CocurrentHashMap在JAVA8中存在一个bug,会进入死循环,原因是递归创建ConcurrentHashMap 对象,但是在1.9已经修复了

 

posted @ 2015-07-09 10:40  小杨ABC  阅读(346)  评论(0编辑  收藏  举报