HashMap源码分析--jdk1.7
1 简介
Jdk1.7的HashMap是使用数组+链表
实现了。
Jdk1.8的HashMap是使用数组+链表+红黑树
实现了。
源码中采用了很多的位运算,里面的逻辑也是令人拍案叫绝~~
在Jdk1.7中HashMap的结构大概长如下图的样子:
2 存储过程
当需要保存数据时,集合内部初始化一个数组,存入的key和value先封装很一个Entry对象,再根据传入的key计算该Entry对象应该挂在数组的哪个位置。如果数组的某个位置已经有元素了,则该位置由新元素替代,新元素的指向的下一个结点为原来的旧元素。如上图的[key1,value1],[key2,value2],[key3,value3]...[key7,value7]是按照顺序插入的,最后可能会形成的结构。
3 几个重要的变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
默认的初始数组容量16,采用的是1左移4位得到。那你为啥不直接写16呢?
transient int size;
集合的容量。
static final int MAXIMUM_CAPACITY = 1 << 30;
集合的最大容量1073741824,10亿多,应该是不会用完吧。。
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子,默认0.75,用来判断集合是否需要扩容会用到。
int threshold;
阈值。用来判断集合是否需要扩容,是根据数组大小和加载因子计算得来的 阈值=数组大小*加载因子。如数组大小为16,加载因子为0.75,阈值就是16*0.75=12,当然不是集合容量达到12就要扩容,还需要一个条件,后面会说明。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
我们说的HashMap的数组指的就是这个变量table,存放的是Entry对象的引用。这个Entry对象就是我们说的链表的结点。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...
}
HashMap的链表结点,记录key,value,下个结点索引next,当前结点key的hash值。
transient int modCount;
记录集合操作的次数。比如增、删、改。
4 深入源码
4.1 新建集合
我们先看一行代码,HashMap内部做了什么?
HashMap<String,String> hashMap = new HashMap<>();
该方法调用了HashMap的构造方法。
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); // 默认数组大小16, 默认加载因子0.75
}
public HashMap(int initialCapacity, float loadFactor) { //16,0.75
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; //加载因子修改为默认的0.75
threshold = initialCapacity; //阈值赋值为16,最后应该是12的,我们先不着急
init();
}
我们的第一行代码主要是确定了加载因子和阈值。这个阈值其实是错误的,为什么呢?前面我们说过,阈值=数组大小*加载因子,这里它直接等于的数组大小16,这个16为了后面新建数组使用的。
4.2 添加第一笔数据
我们再看第二行代码,往集合中put数据。
String s1 = hashMap.put("key1", "我是value1");
调用集合的put方法。
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;
}
在put方法中,我们首先看到了下面的语句。别忘了我们的数组还没有初始化呢,数组就是在这个地方初始化的。
if (table == EMPTY_TABLE) {
inflateTable(threshold); //传入我们不太完美的阈值16,来初始化数组
}
4.2.1 数组初始化
private void inflateTable(int toSize) { //16
int capacity = roundUpToPowerOf2(toSize);//找到大于等于传入数的最小2次幂,我们传入的是16,这里返回的也是16
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//你看到我们的阈值变完美了吗? 16*0.75=12
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
roundUpToPowerOf2
方法很有趣,作用是找到大于等于传入数的最小2次幂,我们传入的是16,这里返回的也是16,也就是找到我们数组的应该新建的大小。
Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1)
就是计算阈值了,我们的阈值现在变为12了,记住这个数字。
initHashSeedAsNeeded
方法的作用不是太清楚,没看出来干嘛的。听有的老师说这个也没用??
4.2.2 计算key的hash值
我们接着回到put方法中。
int hash = hash(key);//我们的"key1"计算的结果为3237927
可以看到,通过我们的key,计算除了一个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();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
4.2.3 计算应该存放在数组中的位置
我们再次回到put方法中,可以看到如下代码:
int i = indexFor(hash, table.length);//indexFor(3237927,16)计算结果为7
该代码是根据我们刚才计算的hash值和新建数组的长度,得到了一个该hash值应该在数组中存放的位置。
如果知道hash算法的话应该清除,即使是相似的元素,计算出来的hash值也可能千差万别。那怎么保证计算出来的位置是在table数组的范围内[0~15]呢,这就是下面算法的绝妙之处了。
static int indexFor(int h, int length) {
return h & (length-1);
}
这个方法是让两个数相与运算。我们的length为16,length-1的二进制形式为0000 1111
,与任何数相与运算结果的范围都在0000 0000
~0000 1111
,也就是范围总在[0~15],刚好是table数组的下标范围。
我们刚才计算的"key1"的hash值为3237927,二进制的后4位为0111
,所以最后计算出来的结果为7。
4.2.4 添加元素
我们还是回到put方法中。
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);//3237927,"key1","我是value1",7
return null;
首先是一个for循环,从table数组中取出要放入位置的元素,判断是否为null,由于我们是第一次存入数据,当然为null,for循环里面的逻辑是不会执行的。for循环里面的逻辑其实是如果已经有相同的key在集合中存在了,就把存入新的value,把原来的value返回。
modCount++;
我们要往集合中添加元素了,操作步骤要加1了。在addEntry方法执行后返回null。下面我们重点介绍addEntry方法。
void addEntry(int hash, K key, V value, int bucketIndex) { //3237927,"key1","我是value1",7
if ((size >= threshold) && (null != table[bucketIndex])) {// (0 >= 12)&&(null != table[7])
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);//3237927,"key1","我是value1",7
}
addEntry方法传入4个参数,分别是key计算出来的hash值
,key
,value
,table下标
。
下面的if判断是判断是否需要扩容。扩容的话,我们的table长度变为原来的2倍,之前存入元素key的hash值有的也会跟着改变。扩容部分下面再说,我们接着往下走。
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex]; //取出原本table数组该位置上指向的元素,没有元素为null
table[bucketIndex] = new Entry<>(hash, key, value, e); //3237927,"key1","我是value1",null
size++; //集合的元素加1 ,现在集合元素数量为1了
}
Entry(int h, K k, V v, Entry<K,V> n) {//Entry的构造方法
value = v;
next = n;
key = k;
hash = h;
}
首先取出原本table数组该位置table[7]上指向的元素,由于之前没有元素,所以取出的为null。
接着创建一个Entry对象,放入我们的table[7]的位置上。
集合大小增加1。
至此,我们的第一行添加代码String s1 = hashMap.put("key1", "我是value1");
执行完毕,当然返回的是null。
4.2.5 添加一个元素后,集合结构
我们有没有办法看到集合再添加完一个元素后的结构呢?答案是可以的。
在put方法要返回的地方执行一段代码,查看集合的结构。
for(int i = 0 ; i< table.length;i++){
System.out.print(i+"-->");
Entry<K, V> e = table[i];
while (null != e){
Object key = e.key;
Object value = e.value;
System.out.print("["+key+","+value+"] -->");
e = e.next;
}
System.out.println("null");
}
给你一个图,让你看看现在HashMap内部是什么样子。
4.3 添加第二笔数据
刚才我们执行了下面的代码,添加了一笔数据。如果我们再添加一笔数据,集合会发生什么呢?
String s1 = hashMap.put("key1", "我是value1");
再添加第二笔数据。
String s2 = hashMap.put("key2", "我是value2");
如果进入put方法中我们会发现,除了不用进行数组初始化操作,其他的步骤和添加第一笔是时一样的。添加第二笔数据后集合的结构如下:
0-->null
1-->null
2-->null
3-->null
4-->null
5-->null
6-->[key2,我是value2] -->null
7-->[key1,我是value1] -->null
8-->null
9-->null
10-->null
11-->null
12-->null
13-->null
14-->null
15-->null
4.4 添加重复的key
现在我么的集合中已经有两个元素了,但是现在如果我再添加一个key2
会发生什么呢?
HashMap<String,String> hashMap = new HashMap<>();
String s1 = hashMap.put("key1", "我是value1");
String s2 = hashMap.put("key2", "我是value2");
String s3 = hashMap.put("key2", "我是value3"); //我们要执行这一行代码了
通过调试我们发现原先的我是value2
被替换成了我是value3
。查看HashMap的结构,也印证了这一点。
4.5 解决存放位置冲突
通过上面的分析我们知道,元素存放的位置需要根据key的hash来计算的,但是不同的has值计算后,得出的要存放数组的同一个位置那应该怎么办呢?
我么接着上面的插入操作,插入了11个元素,现在要插入第12个元素了。
HashMap<String,String> hashMap = new HashMap<>();
String s1 = hashMap.put("key1", "我是value1");
String s2 = hashMap.put("key2", "我是value2");
String s3 = hashMap.put("key2", "我是value3");
hashMap.put("key3", "我是value3");
hashMap.put("key4", "我是value4");
hashMap.put("key5", "我是value5");
hashMap.put("key6", "我是value6");
hashMap.put("key7", "我是value7");
hashMap.put("key8", "我是value9");
hashMap.put("key9", "我是value9");
hashMap.put("key10", "我是value10");
hashMap.put("key11", "我是value11");
hashMap.put("key12", "我是value12");//我们要执行这一行了
插入前先看一下现在集合的状态。
0-->[key4,我是value4] -->null
1-->[key3,我是value3] -->null
2-->[key6,我是value6] -->null
3-->[key5,我是value5] -->null
4-->null
5-->null
6-->[key2,我是value3] -->null
7-->[key1,我是value1] -->null
8-->null
9-->null
10-->[key10,我是value10] -->null
11-->[key11,我是value11] -->null
12-->[key8,我是value9] -->null
13-->[key7,我是value7] -->null
14-->null
15-->[key9,我是value9] -->null
可以看到,数组位置快要被占满了。我们接着插入第12个元素。
在执行到createEntry
方法的时候,发现要插入的位置为[3],但是这个位置已经被[key5,我是value5]所占用。
做法就是新建一个Entry存放[key12,我是value12],并把它的下一个元素指向[key5,我是value5],
而原来数组table[3]的指向由[key5,我是value5]转为了[key12,我是value12]。
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
这种插入方式称为头插入。
插入前后集合状态对比:
4.6 如何扩容
我们刚才的数组大小是16,那什么时候数组的容量会扩容呢?
接着插入数据。
HashMap<String,String> hashMap = new HashMap<>();
String s1 = hashMap.put("key1", "我是value1");
String s2 = hashMap.put("key2", "我是value2");
String s3 = hashMap.put("key2", "我是value3");
hashMap.put("key3", "我是value3");
hashMap.put("key4", "我是value4");
hashMap.put("key5", "我是value5");
hashMap.put("key6", "我是value6");
hashMap.put("key7", "我是value7");
hashMap.put("key8", "我是value9");
hashMap.put("key9", "我是value9");
hashMap.put("key10", "我是value10");
hashMap.put("key11", "我是value11");
hashMap.put("key12", "我是value12");
hashMap.put("key13", "我是value13");
再执行到addEntry
方法的时候,我们看到了如下的代码:
if ((size >= threshold) && (null != table[bucketIndex])) { //(12>=12) && (null != table[3])
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
上面的代码是扩容时会执行到的,if判断是什么意思呢?
size >= threshold :集合的容量大于等于阈值。
我们现在的集合容量为12,阈值也为12,这个条件是满足的。
null != table[bucketIndex] : 要插入的元素位置指向非空。
我们的key为"key13",计算出来的backetIndex为3,而table[3]上刚刚好已经有元素了,不为空,这个条件也是满足的。
接下来是执行数组扩容代码了,标准是数组扩容为原来的2倍
resize(2 * table.length); //resize(2*16)
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);
}
resize方法的功能是:
-
新建一个2倍大的新数组;
-
将旧元素挂到新数组下面;
-
重新计算一个阈值;
其中最有意思的是将旧元素挂到新数组下面这个方法。
transfer(newTable, initHashSeedAsNeeded(newCapacity));
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;
}
}
}
transfer方法
接收一个新数组,一个布尔值(此值是为了重新计算key的hash值,与jvm启动时的一个参数有关,如果没有配置的话默认为false)。rehash的情况请参考java中HashMap的另一面-Djdk.map.althashing.threshold。
transfer方法
会遍历旧的数组,计算原来元素在新的数组上的位置。这是一个非常耗费资源的操作。最后将旧的元素放置在新数组下面,完成扩容操作。
扩容前后新旧数组的对比:
可以看到扩容后原先元素的位置可能会发生变化。