JDK1.7中HashMap采用的是数组+链表结构保存所有数据,其结构如下图:
在JDK1.8 HashMap源码分析中已经分析了JDk1.8中HashMap的结构更改,但是和JDk1.7之前的HashMap还是有很多共同点,下面我们着重分析不同点。
构造方法
构造方法主要完成出时容量和加载因子的设置,实现如下:
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;//初始阈值为初始容量,与JDK1.8中不同,JDK1.8中调用了tableSizeFor(initialCapacity)得到大于等于初始容量的一个最小的2的指数级别数,比如初始容量为12,那么threshold为16,;如果初始容量为5,那么初始容量为8
init();//空实现
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
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);
}
从上面可以看到,使用无参构造方法时,和JDK1.8的相同,默认的初始容量为16,默认的加载因子为0.75。但是在两个参数的构造方法时,实现稍有不同,关键是初始阈值的赋值。上面注释中已经说明了。
hash方法
JDK1.8中的hash方法如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
而JDK1.7中的hash方法如下:
final int hash(Object k) {
int h = hashSeed;//默认为0
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);
}
从上面可以看到JDk1.8中的hash方法实现简单得多。
基本操作
put方法
HashMap中的put(K k,V v)方法用于将一对键值对插入到哈希表中,返回的键对应的旧的值。实现如下:
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
public V put(K key, V value) {
//哈希表还未被创建时,相同
if (table == EMPTY_TABLE) {
inflateTable(threshold);//创建哈希表
}
//如果键是null,调用putForNullKey方法
if (key == null)
return putForNullKey(value);
//计算hash值
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;
}
//插入键为null的值
private V putForNullKey(V value) {
//可以看到键为null的值永远被放在哈希表的第一个桶中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//一旦找到键为null,替换旧值
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//如果第一个桶中为null或没有节点的键为null的,插入新节点
modCount++;
addEntry(0, null, value, 0);
return null;
}
从上面的代码可以看到put(K k,V v)有几步操作:
1. 如果哈希表还未创建,那么创建哈希表
2. 如果键为null,那么调用putForNullKey插入键为null的值
3. 如果键不为null,计算hash值并得到桶中的索引数,然后遍历桶中链表,一旦找到匹配的,那么替换旧值
4. 如果桶中链表为null或链表不为null但是没有找到匹配的,那么调用addEntry方法插入新节点
下面先分析当哈希表还未创建时调用的inflateTable()方法,其实现如下:
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变量
}
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;
}
从上面可以看到,初始化表是一个方法,其中也是根据初始容量得到不小于自己的最小的2的指数倍数的数,这个方法与1.8中tableSizeFor功能是一样的,所以初始化表时是相同的。
下面再分析addEntry()方法,其中第一个参数是hash值,第二个是键,第三个是值,第四个是哈希表中桶的索引。
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果尺寸已将超过了阈值并且桶中索引处不为null
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容2倍
resize(2 * table.length);
//重新计算哈希值
hash = (null != key) ? hash(key) : 0;
//重新得到桶索引
bucketIndex = indexFor(hash, table.length);
}
//创建节点
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
//将该节点作为头节点
table[bucketIndex] = new Entry<>(hash, key, value, e);
//尺寸+1
size++;
}
从上面可以看到新加的节点将是作为头节点加入到链表中的,这点是与JDk1.8中的区别。另外,1.7的扩容是插入之前之前判断,而1.8是插入之后再判断是否需要扩容,不过都是扩容2倍。下面再来看resize()方法的实现:
//扩容到新容量
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果旧容量已经达到了最大,将阈值设置为最大值,与1.8相同
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);
}
//如果hashSeed变了,那么rehash为true,否则为false
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;
//如果hashSeed变了,需要重新计算hash值
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;
}
}
}
从上面的代码可以看到resize()中需要完成对hashSeed变量的更新,一旦更新成功,那么就需要rehash,否则不需要。而将链表转换时,新表中的链表与原链表中的顺序将会颠倒。这儿可以看到与1.8中的区别,1.7中是通过控制hashSeed的变化导致hash()方法得到的hash值,而JDK1.8中一旦得到了一个键的hash值后,就不会再改变了,而是通过hash&cap==0为区分,将链表分散,而1.7是通过更新hashSeed将旧表中的链表分散。
至此,我们可以发现JDK1.7中很多与1.8的实现区别,如下:
1. JDK1.8中resize()方法在表为空时,创建表,在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表
2. 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表中第一个桶中,这一点两个版本是相同的。
3. 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表了尾部,而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法。
4. addEntry中默认将新加的节点作为链表的头节点,而1.8中会将新加的结点添加到链表末尾
5. 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的分散,而1.8中键的hash值不会改变,rehash时根据hash&cap==0将链表分散
6. 1.8rehash时保证原链表的顺序,而1.7中rehash时将改变链表的顺序
get方法
HashMap的get方法如下:
public V get(Object key) {
//如果键为null,调用getForNullKey方法
if (key == null)
return getForNullKey();
//键不为null,调用getEntry方法
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
if (size == 0) {
return null;
}
//键为null的插入在第一个桶中
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//计算hash值
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;
}
从上面的代码可以看到,get()方法与1.8中差别不大,只是区分出了键是否为null的情况,而1.8中则不区分这种情况。
remove方法
remove()方法如下:
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
//计算hash值
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;
}
从remove()方法可以看到与1.8中的实现也差距不大。
总结
下面对JDK1.7和JDk1.8中HashMap的相同与不同点做出总结。
首先是相同点:
1. 默认初始容量都是16,默认加载因子都是0.75。容量必须是2的指数倍数
2. 扩容时都将容量增加1倍
3. 根据hash值得到桶的索引方法一样,都是i=hash&(cap-1)
4. 初始时表为空,都是懒加载,在插入第一个键值对时初始化
5. 键为null的hash值为0,都会放在哈希表的第一个桶中
接下来是不同点,主要是思想上的不同,不再纠结与实现的不同:
1. 最为重要的一点是,底层结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构
2. 主要区别是插入键值对的put方法的区别。1.8中会将节点插入到链表尾部,而1.7中会将节点作为链表的新的头节点
3. JDk1.8中一个键的hash是保持不变的,JDK1.7时resize()时有可能改变键的hahs值
4. rehash时1.8会保持原链表的顺序,而1.7会颠倒链表的顺序
5. JDK1.8是通过hash&cap==0将链表分散,而JDK1.7是通过更新hashSeed来修改hash值达到分散的目的