在日常工作中我们经常用到的容器有许多,其中就包括了map类,而其中最最常用到的就非HashMap,那么HashMap到底是什么的呢?
1.什么是HashMap
HashMap是一个散列桶(数组和链表),它存储的内容是键值对(key-value)映射。
HashMap继承于AbstractMap,实现了Map,Cloneable,Java.io.Serializable接口
HashMap采用了数组和链表的数据结构,在查询和修改方面继承了数组的线性查找和链表的寻址修改
HashMap的实现是不同步的,是非synchronized,所以它不是线程安全的,但是它的速度很快。
HashMap的key,value都可以为null,而HashTable则不能(原因是equlas()方法需要对象,因为HashMap是后出的API经过处理才可以),且HashMap的映射不是有序的。
HashMap的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
通常默认加载因子是0.75,这都是在时间和空间成本上寻求的一种折中。加载因子过高虽然减小了空间开销,但是同时也增加了查询成本(在大多数HashMap类的操作中,包括get和put操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需要的条目数及其加载因子,以便最大限度地减少rehash操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生rehash操作。
上面那些都可以在源码中一一找出
首先HashMap在JDK1.7中是用数组和链表的方式进行存储的,在JDK1.8之后HashMap加入红黑树,提升了自己的查询效率,首先在HashMap的源码中我们可以明确的看到HashMap的成员变量都有什么K,V
其中可以用来存储数据的有table和entrySet,一个是Node
public V put(K key, V value) {
//来自父类的put的方法,在JDK1.7中是直接实现了put方法
//JDk1.7之后HashMap的实现做了改变
//此时通过Key值运用Hash算法先取出散列值
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
//若此时数组为空或长度为0 也就是说我们没有初始化或者个HashMap指定长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果通过对Hash来的值取模,算出p所在数组下标,若在这个下标没有值为空就将值填入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
//如果Hash值相等,K值也相等且不为空,则将值覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) //此处可以看出引入了树
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
//如果p.next为空则将新值插入,此处可以看出引入了链表
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度大于TREEIFY_THRESHOLD -1,就会从链表转为树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
通过对源码的简单学习可以将上面锁总结的几点一一证实,虽然通过链表的方式在一定程度上扩大了存储空间,但是随着链表长度的不断增加,我们查找的时间也变得越来越长,所以HashMap也需要扩容,那么它是怎么扩容的呢?在HashMap初始化的时候若不设置长度,会自动给与一个默认值,那么什么情况下会进行扩容呢?只有在空间不够的时候HashMap会进行扩容,换句话说当我们添加一个元素的时候,发现空间不够了,就会进行扩容,所以我们可以在添加元素的方法中找到扩容的步骤,下面是Hash Map中一些参数的含义
/**
* 默认值
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大值
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认加载因子。当键值对的数量大于 CAPACITY * 0.75 时,就会触发扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 计数阈值。链表的元素大于8的时候,链表将转化为树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 计数阈值。resize操作时,红黑树的节点数量小于6时使用链表来代替树
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。
* 这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 调整大小的下一个大小值(容量*加载因子)。
* @serial
*/
// 此外,如果尚未分配表数组,则此//字段保持初始数组容量,或者表示
// DEFAULT_INITIAL_CAPACITY为零。
int threshold;
/**
* 此HashMap经过结构修改的次数*结构修改是指更改HashMap中的映射数或以
* 其他方式修改其内部结构(例如,* rehash)的修改。
* 该字段用于在* HashMap的Collection-views上快速生成迭代器。
*/
transient int modCount;
在上面的put方法中按我们在最后可以看到,有一个判断if (++size > threshold)此时HashMap中在添加一个新元素,就会超过约定好的扩容临界值从而触发扩容方法resize()
final Node[] resize() {
//新建一个存储空间
Node[] oldTab = table;
//若是新的则为0 否则为需要扩容的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//一般为长度*加载因子(0.75)
int oldThr = threshold;
//初始化新长度和扩容的阀值
int newCap, newThr = 0;
if (oldCap > 0) {
//若长度已经是最大值,无法继续扩容,则把扩容的阀值设置为最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//若新长度小于最大值(旧长度的两倍)且旧长度大于默认值
//新长度为旧长度的2倍,新阀值为旧阀值的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//旧长度为0,且阀值不为0,新长度就等于阀值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//需要扩容的HashMap未初始化,此时初始化HashMap ,长度为默认值,阀值为长度* 0.75
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//若新阀值为0
if (newThr == 0) {
//新长度*加载因子
float ft = (float)newCap * loadFactor;
//新长度若小于最大值且阀值小于最大值,则等于ft,否则为最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新阀值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//初始化容器
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
//若旧HashMap不为空,需要将里面的值进行重新排列
if (oldTab != null) {
//遍历旧HashMap
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//遍历树
((TreeNode)e).split(this, newTab, j, oldCap);
else { //遍历链表
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
通过上面的代码和注释不难看出HashMap每次扩容都为原来的两倍,并且在不为空的时候需要重新遍历HashMap,若创建的时候没有预估需要存放多少元素进入,HashMap的扩容会降低代码的效率。所以在阿里巴巴规范中会要求初始化HashMap的时候需要加上容量,同时也发现了,在HashMap中没有加锁和任何关键字来保证HashMap在多线程中的安全,所以说HashMap是线程不安全的。ConcurrentHashMap是线程安全的,在JDK1.7中ConcurrentHashMap运用segments,分段加锁,在数组中存放的是HashTable,相当于每一个坐标都有一个自己的锁,从而实现了线程安全,且并发数量是segments的长度,但是并发级别是不会变化的,一旦确认就无法改变,但是segment的长度依然可以改变,逻辑和HashMap类似
而在JDK1.8之后ConcurrentHashMap又变为了数组加链表的方式,使用CAS操作和synchronize关键字实现线程安全,同时在链表头部添加特殊字段如forwarding,来实现扩容时的线程安全,
除了上面两个容器,下面不得不说的另外一个容器是ConcurrentSkipListMap,理解ConcurrentSkipListMap的时候需要先理解一下跳表,我们说HashMap里面存储的是数组和链表,ConcurrentSkipListMap在Node中添加了index其中包含了级别Leven和right,Leven将链表分层了,同一级别的Node虽然不一定通过next连接,也可以通过right做关联,这样可以跳过中间的node,减少了next的使用,提升了查询效率,当然同一个Node是可以有多个index这样可以去对应多个级别,跳的也可以更远一点,当进行删除的时候index也会被删除,但是right不会中断,除非被删除的Node的right为null