本文主要参考了Java Collection Framework 源码剖析这位博主的专栏,写的很好,感兴趣的可以去看一下!
- TreeMap:基于红黑树实现;
- HashMap:基于哈希表实现;
- HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁;
- LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序;
容器中主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表;
HashMap
1、存储结构
Entry 是构成哈希表的基石,是哈希表所存储的元素的具体形式内部包含了一个Entry类型的数组table;
Entry内部存储着键值对,包含了四个字段,从next字段可以看出Entry是一个链表;
即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // hash(key.hashCode())方法的返回值
final K key;//键值对的键
V value;//键值对的值
Node<K,V> next;//下一个节点
Node(int hash, K key, V value, Node<K,V> next) { //Node的构造函数
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
2、HashCode计算
在JDK1.8的源码中,hashcode的计算:高16位异或低16位
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3、HashMap参数以及扩容机制
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M);
为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证;
HashMap参数:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量是16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量是2的30次方
static final float DEFAULT_LOAD_FACTOR = 0.75f;//负载因子是0.75
static final int TREEIFY_THRESHOLD = 8;//这是一个阈值,当桶(bucket)上的链表数大于这个值时会转成红黑树
static final int UNTREEIFY_THRESHOLD = 6;//也是阈值同上一个相反,当桶(bucket)上的链表数小于这个值时树转链表
static final int MIN_TREEIFY_CAPACITY = 64;//树的最小的容量
初始容量是16,达到阈值扩容,阈值等于最大容量*负载因子,每次扩容2倍,总是2的n次方;
扩容机制:
为了保证HashMap的效率,系统必须要在容量达到threshold进行扩容。扩容操作十分耗时,需要重新计算这些元素在新table数组中的位置并且进行复制处理,看一下源码中的resize()操作;
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 若 oldCapacity 已达到最大值,直接将 threshold 设为 Integer.MAX_VALUE
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return; // 直接返回
}
// 否则,创建一个更大的数组
Entry[] newTable = new Entry[newCapacity];
//将每条Entry重新哈希到新的数组中
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor); // 重新设定 threshold
}
重哈希主要是一个重新计算原HashMap中的元素在新table数组中的位置并进行复制处理的过程
void transfer(Entry[] newTable) {
// 将原数组 table 赋给数组 src
Entry[] src = table;
int newCapacity = newTable.length;
// 将数组 src 中的每条链重新添加到 newTable 中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null; // src 回收
// 将每条链的每个元素依次添加到 newTable 中相应的桶中
do {
Entry<K,V> next = e.next;
// e.hash指的是 hash(key.hashCode())的返回值;
// 计算在newTable中的位置,注意原来在同一条子链上的元素可能被分配到不同的子链
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
4、get源码
HashMap只需要根据key的哈希值定位到table数组的某个特定的桶,查找并返回该key对应的value即可;
以JDK1.7中的代码为例
public V get(Object key){
//若为null,调用getForNullKey方法返回对应的value;
if(key==null)
//从table的第一个桶中寻找key为null的映射;若不存在,直接返回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;
}
//针对键值为NULL的键值对
private V getForNullKey() {
// 键为NULL的键值对若存在,则必定在第一个桶中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
// 键为NULL的键值对若不存在,则直接返回 null
return null;
}
调用HashMap中的get(Object key)的方法后,若返回值是NULL,存在以下两种可能:
- 该key对应的值就是NULL;
- HashMap中不存在该key;
5、put源码
HashMap保存数据的过程,先判断key是否为null,若为null,则直接调用putForNullKey方法;若不为空,则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组中该位置有元素,查找是否存在相同的key,若存在则覆盖原有key的value,否则将该元素保存在链表头部(最先保存的元素放在链表尾部),若table此处没有元素,则直接保存;
public v put(K key,V value){
//当key==null时,调用putForNullKey,并将该键值保存到table上的第一个位置
if(key==null)
return putForNullKey(value);
//根据key的hashCode计算hash值
int hash = hash(key.hashCode());
//计算该键值对在数组中的存储位置(判断在哪个桶)
int i = indexFor(hash,table.length);
//在table的第i个桶上进行迭代,寻找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;//返回旧值
}
}
modCount++;//修改次数+1,快速失败机制
//原HashMap中无该映射,将其添加至链表头部;
addEntry(hash,key,value,i);
return null;
}
void addEntry(int hash,K key,V value,int bucketIndex){
//获取bucketIndex处的链表
Entry<K,V> e = table[bucketIndex];
//将新创建的 Entry 链入 bucketIndex处的链表的表头
table[bucketIndex] = new Entry<K,V>(hash,key,value,e);//参数e,是Entry.next;
//如果size超过threshold,扩充table大小,再散列
if(size++>=threshold)
resize(2*table.length);
}
通过hash()方法取得了Key的hash值,但如何才能保证元素能够均匀的分不到table的每个桶中?
HashMap采用了indexFor方法处理,简单高效。
static int indexFor(int h,int length){
return h&(length-1);//等价于取模运算
}
对NULL键的特别处理:putForNullKey()
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中
private v putForNullKey(V value){
//若key==null,则将其放入table的第一个桶内
for(Entry<K,V> e = table[0];e != null;e = e.next){
if(e.key == null){
//若已经存在key为null的键替换其值,并返回旧值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0,null,value,0);//否则将其添加到table[0]桶内
return null;
}
6、JDK 1.8中的优化(HashMap)
Java8中的改进
- HashMap是数组+链表+红黑树;当链表长度>=8时转化为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能;
- Java8中对于hashMap的扩容不是重新计算所有元素在数组的位置,而是使用2次幂的扩展;元素要么在原位置,要么是在原位置再移动2次幂的位置;
- HashMap在存放自定义类的时候,需要自定义类中的hashCode和equals,通过hash(hashCode)然后模运算,然后定位在Entry数组的下标,遍历之后的链表,通过equals比较有没有相同的key,有就直接覆盖,没有就重新创建一个Entry.
7、常见问题
1、HashMap为什么线程不安全?
主要是由于Hash冲突和扩容导致的;
HashMap在扩容的时候可能会生成环形链表,造成死循环;
HashMap采用链表法来解决Hash冲突当A线程和B线程同时对一个数组位置调用addEntry,两个线程同时得到现在的头结点,那么其中一个线程的写入就会造成另一个线程被覆盖,导致写入操作丢失;
当多个线程检测到总数量超过阈值执行resize操作,各自生成新的数组并且rehash后给map底层的table,最终只会有一个线程生成的新数组被赋给table变量,其它线程均会丢失;
要想实现线程安全,就需要使用collections类的静态方法synchronizeMap()实现;
2、HashMap中的key可以是任意对象或类型吗?
- 可以为null,但不能是可变对象,可变对象的属性改变,HashCode也会发生改变,导致无法查找到Map中的数据;
- 保证对于成员变量的改变能够使得对象的哈希值不变;
3、HashMap为什么可以插入null值
先判断key是否为null,若为null,则直接调用putForNullKey方法去遍历table[0]桶内的链表,寻找e.key == null,没有找到就遍历结束;找到了就采用value去覆盖oldValue,并且返回oldValue;如果在table[0]Entry链表中没有找到就调用addEntry方法添加一个key为null的Entry;
4、HashMap在高并发情况下会出现什么问题?
扩容问题,上面提到了扩容对于HashMap的影响;
扩容问题会引起数据丢失,也会造成链环导致死循环;
多线程扩容,如果线程1和线程2都需要进行扩容;
在线程1中,我们发现这三个entry都落到了第二个桶里面;假设线程1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B];
线程2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A];
此时线程1重新被调度运行,此时的线程1持有的引用是已经被线程2 resize之后的结果;线程1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next;
通过线程2的resize之后,[7,B]的next变为了[3,A]。此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。
5、HashMap和HashSet之间的区别
HashSet基于HashMap来实现,HashSet中存储的是一个对象,其中没有重复的元素;
Hashset 底层是Hashmap 但是存储的是一个对象,Hashset 实际将该元素e 作为key 放入Hashmap,当key 值(该元素e)相同时,只是进行更新value,并不会新增加,所以set 中的元素不会进行改变。