您的位置:首页 > 编程语言 > Java开发

Java HashMap工作原理及实现(二)

2016-05-10 13:07 761 查看

类声明

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


功能和特点

实现
AbstractMap
抽象类。Map的一些操作这里面已经提供了默认实现,后面具体的子类如果没有特殊行为,可直接使用
AbstractMap
提供的实现。
实现
Map
Clone
Serializable
接口。支持拷贝和序列化。支持Map常见的增删查改。
HashMap
是数组和链表的折中,既保证了几乎的时间复杂度,也保证了插入和删除的时间复杂度为。

基本概念

HashMap
内部,采用了数组+链表的形式来组织键值对
Entry <Key,Value>


HashMap
内部维护了一个
Entry[] table
数组,当我们使用 new HashMap()创建一个HashMap时,
Entry[] table
的默认长度为16。
Entry[] table
的长度又被称为这个
HashMap
的容量(
capacity
);

对于
Entry[] table
的每一个元素而言,或为
null
,或为由若干个
Entry<Key,Value>
组成的链表。HashMap中
Entry<Key,Value>
的数目被称为HashMap的大小(
size
);

Entry[] table
中的某一个元素及其对应的
Entry<Key,Value>
又被称为桶(
bucket
);

HashMap的容量(即E
ntry[] table
的大小)*加载因子(经验值0.75)就是
threshhold
,当hashmap的size大于threshhold时,容量翻倍。



基本思想

Hash计算

求key的hash值:
//将key的hashcode高16位和低16位求异或
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


寻找index

index=(n-1) & hash (n表示hashmap数据结构中table数组的长度)

由于hashmap设计中,n总是2的幂次方,(n-1)对应的二进制就是前面全是0,后面全是1,相与后,只留下hash的后几位,正好在长度为n的数组下标范围内,例如:



为什么需要将key的hashcode的高16为与第16为异或?

充分利用key的高位和低位(不然在利用hash求index的时候可能永远也利用不上key的高位,主要是table的长度n的二进制高位都是0,在求
(n-1)&hash
是利用不上key的hash的高位的),以最小的代价来降低冲突的可能性。

原话:we just XOR some shifted bits in the cheapest possible way to reduce systematic lossage, as well as to incorporate impact of the highest bits that would otherwise never be used in index calculations because of table bounds.

根据
Key
hashCode
,可以直接定位到存储这个
Entry<Key,Value>
的桶所在的位置,这个时间的复杂度为O(1);
在桶中查找对应的
Entry<Key,Value>
对象节点,需要遍历这个桶的
Entry<Key,Value>
链表,时间复杂度为O(n);或者遍历红黑树,时间复杂度为O(logn);

那么,现在,我们应该尽可能地将第2个问题的时间复杂度O(n)降到最低,我们应该要求桶中的链表的长度越短越好!桶中链表的长度越短,所消耗的查找时间就越低,最好就是一个桶中就一个
Entry<Key,Value>
对象节点就好了!
这样一来,桶中的
Entry<Key,Value>
对象节点要求尽可能第少,这就要求,HashMap中的桶的数量要多了。

HashMap的桶数目,即
Entry[]table
数组的长度,由于数组是内存中连续的存储单元,它的空间代价是很大的,但是它的随机存取的速度是Java集合中最快的。我们增大桶的数量,而减少
Entry<Key,Value>
链表的长度,来提高从
HashMap
中读取数据的速度。这是典型的拿空间换时间的策略。

但是我们不能刚开始就给HashMap分配过多的桶(即
Entry[] table
数组起始不能太大),这是因为数组是连续的内存空间,它的创建代价很大,况且我们不能确定给HashMap分配这么大的空间,它实际到底能够用多少,为了解决这一个问题,HashMap采用了根据实际的情况,动态地分配桶的数量

动态分配桶的数量,HashMap动态分配桶的数量的策略:

如果

HashMap的大小 > HashMap的容量(即
Entry[] table
的大小)*加载因子(经验值0.75)

则 HashMap中的
Entry[]table
的容量扩充为当前的一倍;然后重新将以前桶中的
Entry<Key,Value>
链表重新分配到各个桶中。

容量翻倍,怎么重新分配解决hash冲突?:容量翻倍后,重新计算每个
Entry<Key,Value>
的index,将有限的元素映射到更大的数组中,减少hash冲突的概率。

你了解重新调整HashMap大小存在什么问题吗?:多线程的情况下,可能产生条件竞争(race condition)(虽然一般我们不使用HashMap在多线程环境中)。如果在多线程环境中使用HashMap,如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing),即减少了从头部开始遍历到尾部的时间,提高了性能。如果条件竞争发生了,那么就死循环了。

HashMap实现

常量

//默认的初始容量,必须是2的幂。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认装载因子,这个后面会做解释
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//JDK1.8特有
//当hash值相同的记录超过TREEIFY_THRESHOLD,会动态的使用一个专门的treemap实现来代替链表结构,使得查找时间复杂度从O(n)变为O(logn)
static final int TREEIFY_THRESHOLD = 8;

//JDK1.8特有
//也是阈值同上一个相反,当桶(bucket)上的链表数小于UNTREEIFY_THRESHOLD 时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
//JDK1.8特有
//树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32 然后为了避免(resizing 和 treeification thresholds) 设置成64
static final int MIN_TREEIFY_CAPACITY = 64;

//存储数据的Entry数组,长度是2的幂。看到数组的内容了,接着看数组中存的内容就明白为什么博文开头先复习数据结构了
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;

//map中保存的键值对的数量
transient int size;

//Map结构被改变的次数
transient int modCount;

//需要调整大小的极限值(容量*装载因子)。保存的是下次entrySet大小的极限值。
int threshold;

//装载因子,当Map结构中的bucket数等于capacity*loadFactor时,bucket数量翻倍。
final float loadFactor;

构造方法

public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}


有四个构造器,除
HashMap(int initialCapacity, float loadFactor)
都是使用默认的加载因子构造。

HashMap(int initialCapacity, float loadFactor)
中,加载因子是用户设置的,并且根据用户设置的加载因子和容量确定threshold。

确定threshold的方法是
tableSizeFor
,保证
threshhold
是2的幂次方(大于或等于initialCapacity的最小的2的幂次方)。

static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

先将cap-1保证最后的结果是大雨或等于cap的最小的2的幂次方,例如输入的本来就是一个2的幂次方的数,比如4,如果不先-1,则会输出8,-1就会输出4。

为什么每次移动位数的分别是1,2,4,8,16位?先移动一位,并做或运算,将最高位上的二进制
1
移动到次高位;再右移两位,将最高位和次高位上的二进制
11
移动到与次高位相邻的两位上,以此类推,最后保证最改为和比最高位的所有二进制位全部是1,在返回时,+1,就保证这个书是2的幂次方。

为什么没有移动32位?正整数的最大2的幂次方是
$2^16$
次方。

tableSizeFor
是一个求大于或等于给定数的最小2的幂次方的最快方法。实用的算法!

节点数据结构

static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;

Node(int hash, K key, V value, Node<K,V> next) {
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;
}
}


继承自
Map.Entry
,主要功能:节点的初始化,set方法,重写hashCode和equals方法。是所有操作的基础

核心方法

put

public V put(K key, V value) {
//传入key的hash值
return putVal(hash(key), key, value, false, true);
}

/**
* hash key的hash值
* key 键
* value 值
* onlyIfAbsent true时,不改变已经存在的值
* evict false时,table在创建模式中
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// tab为空则创建table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;

// 计算index,当index所在bucket没有数据null,则直接将index位置设置为传入的key-value。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 节点存在,并且key值相等,直接覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//节点中的数据为TreeNode的实例,则是使用红黑树优化的结构
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//节点中的数据不是TreeNode的实例,是普通的单链表结构
else {
for (int binCount = 0; ; ++binCount) {
//不断遍历,没有找到相同的key,则直接加到链表或的后一个节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) //-1 for 1st 超过TREEIFY_THRESHOLD,则将链表变为树结构,提高冲突链效率
treeifyBin(tab, hash);
break;
}
//如果找到key,后面直接覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 找到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;
}

put函数大致的思路为:

1. 对key的hashCode()做hash,然后再计算index;

2. 如果没碰撞直接放到bucket里;

3. 如果碰撞了,以链表的形式存在buckets后;

4. 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;

5. 如果节点已经存在就替换old value(保证key的唯一性)

6. 如果bucket满了(超过load factor*current capacity),就要
resize


resize

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;

if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//探测:容量翻倍后还是小于MAXIMUM_CAPACITY,并且原来的容量大于等于默认容量。则threshold翻倍,容量翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 初始化的容量被加入到threshold中,则新的容量等于就得threshold
newCap = oldThr;
else { // threshold=0,即threshold未被使用过。
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//表明
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> 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<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> 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;
}
(e.hash & oldCap) == 0
是扩容的关键点,因为容量扩展为原来的两倍,相当于
oldCap<<1
,所以计算hash时,需要考虑的二进制位数向高位多增加了一位(相当于求hash的掩码由以前的前x位为0,后32-x位1变为前x-1位0,32-x+1位1),为了避免重复计算hash(key)和
(n-1)&hash
,直接判断key的hash在增加位上的值是否为1(通过
e.hash & oldCap
,得到增加位上,key的hash值。),如果为1,索引的二进制位的增加位也为1,如果为0,则索引的增加位也是0。既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket。 
例如:





其中增加位为红色。

经过扩容重新分配 ,原来在一个bucket的index 5,分配到不同的index=21的bucket,避免与index=5的key冲突,提高了查询的效率。

resize的策略:

1. 容量超过最大容量,容量不变,threshold变为最大整数

2. 容量翻倍后还是小于最大容量,并且原来的容量大于等于默认容量。则threshold翻倍,容量翻倍(大多数情况)。

3. 初始化了容量和threshold,新的容量=原来的threshold

4. 容量和threshold均为使用过(常见情况),则直接分配默认的容量和threshold。

5. 将原来的数据重新调整分配到新的table中。

6. 根据原来每个hash值是否有冲突,和冲突节点是否是树结构保存,分为不同的方式。

putMapEntries

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}

putMapEntries是一个默认访问权限的final类型函数,表示该函数只能在它所在的包内访问,并且该方法不能被重载。

java访问权限复习:

java的访问权限有:public,protected,private,默认。

public是公开访问,所有的包中的类均可访问;
protected是继承访问,对于同一个包的类,这个类的方法或变量是可以被访问的;对于不同包的类,只有继承于该类的类才可以访问到该类的方法或者变量;
private只能在该类本身中被访问,在类外以及其他类中都不能显示地进行访问;
默认访问权限是包访问权限,只有本包内的类可以访问。

get

public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//直接命中,返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//冲突链是树结构
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//冲突链是单链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

getNode的核心流程:

bucket里的第一个节点=key,直接命中;
如果有冲突,则通过key.equals(k)去查找对应的entry
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。

final关键字:

修饰变量:变量的引用不能变,但是可以改变引用值;成员变量必须在构造器中初始化;
修饰函数:把方法锁定,以防任何继承类修改它的含义;提高效率效率。在早期的Java实现版本中,会将final方法转为内嵌调用。但是如果方法过于庞大,可能看不到内嵌调用带来的任何性能提升。在最近的Java版本中,不需要使用final方法进行这些优化了。
修饰类:类不能被继承。

常见问题

参考:博主不争:HashMap的工作原理

为什么String, Interger这样的wrapper类适合作为键?

String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
我们可以使用自定义的对象作为键吗?

这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
我们可以使用CocurrentHashMap来代替Hashtable吗?Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。

总结

HashMap是线程不安全的,如果想使用线程安全的,可以使用Hashtable;它提供的功能和Hashmap基本一致。HashMap实际上是一个Hashtable的轻量级实现;
允许以Key为
null
的形式存储
<null,Value>
键值对;
HashMap的查找效率非常高,因为它使用Hash表对进行查找,可直接定位到Key值所在的桶中;
使用HashMap时,要注意HashMap容量和加载因子的关系,这将直接影响到HashMap的性能问题。加载因子过小,会提高HashMap的查找效率,但同时也消耗了大量的内存空间,加载因子过大,节省了空间,但是会导致HashMap的查找效率降低。
通过对key的
hashCode()
进行hashing,并计算下标
( n-1 )& hash
,从而获得buckets的位置。如果产生碰撞,则利用
key.equals()
方法去链表或树中去查找对应的节点。
在JDK8里,新增默认为8的
TREEIFY_THRESHOLD
閥值,当一个桶里的Entry超过閥值,就不以单向链表而以红黑树来存放以加快Key的查找速度。

本文转载自:点击打开链接

相关

重识java-LinkedHashMap2016年3月9日在“java”中
重新认识TreeMap2016年3月9日在“java”中
重新认识javaArrayList2016年3月4日在“重识java”中
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: