HashMap
首先我们来总体来看下HashMap内部的大体结构,然后在逐个分析其实现和细节。
HashMap散列的基本原理
在具体分析HashMap的源码之前我们先简要的介绍下HashMap的实现散列的基本原理。首先Map的接口需要实现一个Entry的键值对,而hashMap则建造了一个键值对的数组,而且这个键值对时机上是单向链表的形式。当然散列函数有两个基本的参数一个是capacity一个是loadFactor。以上基本的数据结构知识。
在对对象进行散列的时候,首先通过散列函数来计算其散列值,然后根据散列值计算其在键值对数组中的坐标。
那要实现一个好的散列表。需要两个解决两个问题:
- 如何设计散列函数尽可能减小散列冲突
- 在发生散列冲突的时候如何解决。
当然以上两个问题是数据结构中的典型问题如果展开讨论实在太多。这里仅仅介绍HashMap是如何解决散列问题的。
如何设计散列函数尽可能减小散列冲突
针对一个对象是如何得到一个散列的桶的位置的,在Hashmap里主要通过三个部分来减小散列冲突- 第一部分,首先根据Object.hashcode得到一个散列值,Object.hashCode是一个native方法。一般情况下可以认为是该对象的地址信息散列得到的,也就是相当于是对象的ID,同一个对象有相同的ID。这样得到的散列值还是比较合理的.
- 第二部分,桶的数量设计。一般由哈希值得到桶的位置都是将哈希值除以桶的数量得到的余数就是桶的位置。一般来说想要尽可能的减少散列冲突有两类办法,一类是使用素数数量的桶,例如hashTable,一类是使用2的幂次数量的桶,例如hashmap,hashmap使用2的次幂的桶有个好处,就是可以用位运算来算,只要将散列值和桶的数量-1相与就是桶的位置不需要除。这样相对来说速度快一些。hashmap里有个静态方法indexof就是用来做这个的。具体下文会说到。
- 第三部分,hashseed,第二部分中曾经说道通过将哈希值与桶的数量-1相与得到桶的位置。但是这样做有一个小的问题。当哈希值非常大,而桶的数量很小的时候回出现仅仅依靠哈希值的低位来散列的结果。这样即使散列值做的很好耶没有办法得到很好的散列。这时hashseed的作用就体现出来的,hashseed通过右移部分哈希值,然后将其亦或得到的结果进行在进行定位桶的位置。这样做就综合考虑了高位和低位的值。从而减小了散列冲突的可能性。此外由于java的语言特性,对于String的情况其hashseed需要额外设计。
在发生散列冲突的时候如何解决。
HashMap采用的就算用一个单向链表来组织数据,对于相同的桶的值还需遍历单项链表来查找数据。由此可以看到,doc里介绍HashMap的迭代器的时候说过,其迭代器的迭代速度跟桶的数量和元素的数量都有关。根据的就是这里的原理。
HashMap还提供了Key、value和Entry的视图以方便某些操作。
HashMap的域
首先说一下基本的域:
private transient Set<Map.Entry<K,V>> entrySet = null;
transient int hashSeed = 0;
final float loadFactor;
transient int modCount;
transient int size;
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
int threshold;
从名字基本上就可以得到字面意思。这里就不多赘述了。
但是几个比较容易混淆的地方还是重点解释一下:
- 装填因子
装填因子指的是表内元素的个数和桶数量的比值。
- capacity
域里面没有这个,但是在随后的内容里capacity表示桶的数量,也就是table.length; - size
指的就是元素的数量。
- capacity
此外还有一些静态类常量
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final Entry<?,?>[] EMPTY_TABLE = {};
static final int MAXIMUM_CAPACITY = 1 << 30;
private static final long serialVersionUID = 362498820763181265L;
基本上就是默认值,和常用的量,ALTERNATIVE_HASHING_THRESHOLD_DEFAULT 比较难理解一些,但是这里也解释不清楚。放到Holder内部类的地方再做解释。
HashMap的内部类
HashMap.Entry<K,V>
Entry是Map接口的要求。Entry是HashMap中间一个键值对。除了Map.Entry<K,V>
本身提供的方法以外还有几个HashMap.Entry<K,V>
提供的更多的方法。
HashMap.Entry<K,V>
一个带参的构造函数在实现上做成一个类似链表的结构,包含有next在构造时添加。
HashMap的域访问类型均为包可见,其中Key为final不可更改。所有实现的Map.Entry<K,V>
方法均为final不可重写。自身额外提供的方法为setValue以及toString为public访问,而recordAccess和recordRemove为hook方法,当新建子类并继承自HashMap.Entry时,当删除或者覆盖HashMap已经有的K时调用,当然在HashMap里这两个方法是没有任何内容的,这中hook方法我印象中在模板方法模式中用的比较多。
此外还需要注意一点hashMap.Entry<K,V>
是一个静态类。说实话我没明白为什么要申明成静态类。此前也曾经说明静态类的三个特点:
- 静态内部类跟静态方法一样,只能访问静态的成员变量和方法,不能访问非静态的方法和属性,但是普通内部类可以访问任意外部类的成员变量和方法
- 静态内部类可以声明普通成员变量和方法,而普通内部类不能声明static成员变量和方法。
- 静态内部类可以单独初始化
HashMap.Entry的方法没有什么特别值得说明的这里主要说2个:
- hashCode():
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
从源码里可以看到这里的Hashcode实际上是调用key和value的Object的hashCode方法得到的hashCode交出来的结果。而不是Key的HashCode或者Object的HashCode。
- equals()
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
可以看到equals比较的实际上是两个Entry的内容是否相等。
HashIterator<E>
HashIterator就是HashMap的迭代器,是抽象类,同时在HashMap中有3个迭代器类继承自HashIterator<E>
这个三个实现的子类仅仅是覆盖了next方法,分别返回key、value以及Entry三种。
HashIterator的域有4个内容,分别表示修改次数,当前下标,当前迭代器位置,下一个迭代器位置。
首先这里了解一下HashIterator的工作原理。在HashMap中有个Entry<K,V>[] table
这里存放了HashMap生成的键值对,当然其容量就是HashMap中桶的容量,当然每个table的位置可以是有键值对的,也可以是null。在使用迭代器迭代的时候,实际上就是将这个table[]从头到尾的遍历,(当然如果根据Key来寻址那就不需要这么长时间)。理解这样的原理那再看方法就简单的多了。
首先构造器就是从0开始找到当前第一个table中非null的键值对。而next实际上就是找到最下一个非null的键值对。Remove操作就是删除当前的Entry,当然这里使用的是委托HashMap的方法完成的。
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
public final boolean hasNext() {
return next != null;
}
final Entry<K,V> nextEntry() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Entry<K,V> e = next;
if (e == null)
throw new NoSuchElementException();
if ((next = e.next) == null) {
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
current = e;
return e;
}
public void remove() {
if (current == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
Object k = current.key;
current = null;
HashMap.this.removeEntryForKey(k);
expectedModCount = modCount;
}
此外还需要说明的是这三个子类包括都是private final类型的,可以通过newKeyIterator()、newValueIterator()、newEntryIterator()方法来方法。当然这个几个方法都是包可见类型。实际上如果需要使用这三个子类并不是直接访问的,而是通过EntrySet和KeySet以及Values几个类来创建迭代器后使用。这个几个迭代器对public类型的访问者仅仅使用迭代器的类型来操作即可。
EntrySet和KeySet以及Values
同样这几个类也是private final的类。但是他们均可以通过HashMap的方法创建,再由着三个集合类来创建迭代器就可以访问上一小节所述迭代器了。当然这几个类也分别实现了集合类的接口,但是大多数方法均由HashMap的方法代理完成。这里不再赘述。
Holder
Holder目前的内容我不是完全搞清楚。这里尝试解释一下,首先Holder是一个静态内部类。但是这个静态内部只有两个东西。一个是静态的final变量,一个是一段静态代码。问题在于这一点为什么要写一个静态内部来写静态代码块而不是直接写静态代码块静态变量?因为这个Holder是可选项而不是必须要初始化的过程。
写在静态内部类里的静态代码块未必会调用,而只有在外界方法调用的时候才会调用。
而这个静态类只有通过initHashSeedAsNeeded来调用,而initHashSeedAsNeeded只有在HashMap的桶的容量变换时才调用。
此外我们再来看下Holder的作用,Holder的ALTERNATIVE_HASHING_THRESHOLD这个参数的意义,我不是非常理解。目前我的想法是这个ALTERNATIVE_HASHING_THRESHOLD标识了一定的域值,当HashMap内部容量大于这个值的时需要对hashSeed做调整而不能使用0。(也就只有在扩容的生活会考虑到)当然ALTERNATIVE_HASHING_THRESHOLD这个值默认情况是HashMap的一个静态类常量,static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
但是这个静态类常量也是可以根据虚拟机参数的设定来更改的。这也就是Holder这个类这段静态代码的意义了。可以通过调整虚拟机的参数来设定这个域值。
当然以上都是我个人基于现有资料的猜想具体是不是就是这样的原因还需要进一步学习。
private static class Holder {
/**
* Table capacity above which to switch to use alternative hashing.
*/
static final int ALTERNATIVE_HASHING_THRESHOLD;
static {
String altThreshold = java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
"jdk.map.althashing.threshold"));
int threshold;
try {
threshold = (null != altThreshold)
? Integer.parseInt(altThreshold)
: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
// disable alternative hashing if -1
if (threshold == -1) {
threshold = Integer.MAX_VALUE;
}
if (threshold < 0) {
throw new IllegalArgumentException("value must be positive integer.");
}
} catch(IllegalArgumentException failed) {
throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
}
ALTERNATIVE_HASHING_THRESHOLD = threshold;
}
}
HashMap的方法
静态方法
indexFor
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
这个方法意思就是h为hash值,而length为桶的长度。当然按照典型的hashcode的方法,就是用h除以length的余数就是下标,不过对于hashMap有一些特殊,由于通道长度必然为2的幂,这样做效率要比求余数快的多,因为是位操作。稍微想一下就明白了。比如length是4,那如果h是0-3则返回的值就是0-3,如果是h=4则返回0,h=5则返回1,以此类推。
roundUpToPowerOf2
这方法就是一般用在再散列的时候。目的就是将桶的数量固定在2的幂。
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
HashMap构造器
构造器的话一共有四个,大体上可以分为两大类,一类是构造一个空的map一个是根据已有的map构造一个新的map;
首先构造一个空的map,有三个构造器,但最终都是委托HashMap(int,float)完成构造的
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;
threshold = initialCapacity;
init();
}
基本就是验证一下参数约束,然后设定一下参数就行。最后有一个init()方法,作为子类的hook方法,这个方法在clone和序列化的生活也调用了。
其次就是由一个已经存在的map来构造HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
先构造一个空的HashMap然后在将所有的元素放进这个HashMap。但是在构造新的HashMap的生活选择已存在map的尺寸和装填因子计算出来的大小和默认初始化大小中的大值作为同的大小。装填因子不变。
然后根据threshold扩大桶的容量,再讲所有的元素装入。
这里需要重点说一下这两个函数是如何工作的
HashMap的Hash
hash函数的内容在第一节已经说的差不多了这里简单看下源码就好。首先如上文所说。由object 的hashCode来得到哈希值。然后通过hashseed来相与后做位移异或操作。然后返回就是hash值。当然对于String类型的对象有专门的Hash函数。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
HashMap的初始化
HashMap的初始化扩容是通过如下函数实现,输入参数为需要的容量,这个函数首先将其向上扩展至2的幂作为capacity即桶的数量,然后再根据装填因子来计算下次扩容的大小。
新建一个Entry数组并使table指向这个新数组。
然后会根据这个容量对hashseed进行调整。
需要注意的是inflateTable通常只在初始化的时候使用,也就是table==EMPTY_TABLE的时候使用。或者指定容量的构造的使用。
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
这里再提一下hashseed,这个hashseed主要是用来解决散列冲突的问题的。
而这个initHashSeedAsNeeded主要解决hashSeed的初始化问题。一般情况下hashseed就是0。只有当capacity达到一定程度时才会使用初始化hashseed。如果需要初始化则通过sun.misc.Hashing.randomHashSeed(this)
得到一个随机数作为hashseed。
final boolean initHashSeedAsNeeded(int capacity) {
boolean currentAltHashing = hashSeed != 0;
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
HashMap的get
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
HashMap是可以用null作为key的,但是hashMap也有一个规定,当null作为散列的key的时候只能作为entry数组中的0位置的桶。
如下,当key是null的生活,会遍历table[0]链表的所有Entry找到key为null的键值对,如果没有则返回。
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
然后是getEntry的实现:这个就简单很多了。首先既然不是null就可以得到hash值,然后根据hash值和通的数量得到当前的散列值对应的桶的位置,然后遍历桶的所有键值对,如果键值对的大小事。此外还有一点我不是很明白。这里为什么要用e.hash==hash这步?我本来认为可能会出现相同的hash值,但是为什么不直接使用key和value的值来判断多次一举。既然key是唯一的话.
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
HashMap的put
put的前半部分跟get差不多,如果找到了相同的key就更改值,如果没找到就调用addEntry方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
对于addEntry的方法有几点需要注意的吗,这个方法还需要判断是否是需要扩容。如果当前的size大于域值,而且当期的桶有其他值则需要扩容,扩容就是讲桶扩大两倍。然后重新计算key的散列值对应的桶,然后在根据这个桶上加上一个新的键值对。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
resize首先判断当前的table的长度是否是MAXIMUM_CAPACITY。如果是则将域值调整到Integer。MAX_VALUE;并直接返回而不扩大。如果不是则进行扩容,首先创建一个指定大小的新数组。然后调用transfer对原先的数组进行在散列,然后将table指向当前的数组。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer是一个再散列函数,resize在调用再散列函数的时候判断了一下是否需要对当前的hashseed重新计算。如果hashseed变化了,则需要对每个Entry的hash重新计算。,在散列的过程和添加一个新的Entry类似。不再赘述。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
最后说一下createEntry。这个方法没什么特别的就算找到当前的桶然后添加Entry而且,由于Entry添加时自动添加在链表首部的。
这里还有个额外的有意思的地方,首先对于expectedModCount是先++再操作,而对于size是先操作在++。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
HashMap的删除操作
删除操作提供了两个外部的接口一个是remove(key)的方法,一个是从Entryset提供的remove操作删除的是Entry。
先说第一个:
找到Entry的位置。
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
然后是removeEntryForKey。这是一个Final方法。首先根据hash值散列到应该在的桶的位置,然后找到对应的元素删除之,同时还要考虑如果是第一个键值对就是Entry的可能。
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
第二个是EntrySet内提供的操作。提供在EntrySet的remove操作里。基本上就是在第一个操作的翻版。
final Entry<K,V> removeMapping(Object o) {
if (size == 0 || !(o instanceof Map.Entry))
return null;
Map.Entry<K,V> entry = (Map.Entry<K,V>) o;
Object key = entry.getKey();
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
if (e.hash == hash && e.equals(entry)) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
HashMap的其他注意事项
关于Key为容器时的情况。
如果HashMap的是一个容器那在使用get或者containsKey之类的时候会怎么样?
比如说如HashMap在比较两个容器内的值的时候是根据内容和顺序来排序的。
比如下面的代码输出是true;
List<String> list = new ArrayList<>();
list.add("123");
list.add("234");
HashMap<List<String>, Integer> map = new HashMap<List<String>,Integer>();
map.put(list, 1);
List<String> list2 = new LinkedList<>();
list2.add("123");
list2.add("234");
System.out.println(map.containsKey(list2));
关于final
对于加上final的仅仅是相当于当前的引用不在改变,但是容器内部的元素是可以删减的。这对所有容器都是这样的。