HashMap大家通常喜欢拿1.7和1.8进行比较,虽然现在基本都升级成java8了,但是有时候会聊到,有些原理会搞不太清楚,那么就来确认下1.7的各个参数的含义以及怎么进行插入和扩容的
对于HashMap的大致理解
HashMap是由数组加链表组成的,插入元素的时候会先进行计算key的hash值,然后计算对应的数组的下标,找到对应位置,如果没有元素,直接放入数组位置,如果当前位置存在元素,那么把当前节点的下一个节点指向原来的节点,然后进行插入(头插法),如果存在对应的元素,应该替换原来的元素,对于查找的时候也是先计算hash值,找到对应的下标,然后进行对链表的遍历比较,如果有匹配的就进行返回,无返回null
HashMap的扩容 当超过阈值的时候HashMap进行扩容,会申请原来两倍长的数组,然后对节点进行转移,HashMap是线程不安全的,当多线程转移的时候数据可能出错,著名的就是HashMap1.7的扩容死循环问题,因为头插法导致的,再次获取到这个位置Hash的时候就会死循环
大致理解的就是这么多了吗,那么如果再深入问下细节呢,比如HashMap的默认长度,扩容因子是什么,为什么是这么大,HashMap究竟什么时间扩容,是到了扩容数量就扩容吗,是怎么进行扩容的,数据是怎么进行转移的,为什么头插法会产生死循环,什么情况下发生,扩容会进行rehash吗,对象可以作为key吗,如果作为key会有什么问题,怎么解决,为什么会这样?
如果这样细节性追问,容易问到一些不太记得详细的,下面来深入研究下,HashMap1.7的具体执行流程,主要原理。
HashMap的插入
先从HashMap的插入开始看吧,即 put()方法
public V put(K key, V value) {
// 如果数组还没初始化,先初始化数组,初始化的具体可以放后面再仔细看下
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 对于空值的处理,直接放tab[0]了
if (key == null)
return putForNullKey(value);
// hash 对象的hash,然后|右移16位,即常说的高16位|低16位
int hash = hash(key);
// 计算需要存放的位置,直接&数组长度得到一个下标
int i = indexFor(hash, table.length);
// 如果位置不为空
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 遍历找到了相同的key进行替换,然后返回旧值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 走到这就是没替换到相同的key
modCount++;
addEntry(hash, key, value, i);
return null;
}
put 方法简单总结,就是数组未初始化的先进行初始化,然后key为null的直接放到0下标位置,不为null的取hash值,然后根据数组长度得到需要存放位置,然后查看是否有相同的key,有则替换,无则走到下面直接添加,那么接下来分析下初始化数组过程,求hash过程,最后就是addEntry 是怎么进行添加的
初始化map数组过程
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
// 计算最终的capacity,如果无参数构造的map就是16,如果填入的参数,比如5,那么就
// 进行计算得到的是大于等于这个值的最近的一个2的n次幂
int capacity = roundUpToPowerOf2(toSize);
// 计算得到的阈值,这个的capacity * loadFactor 都可以自定义传入,(但是容量如果不是2的n次幂会被转换下)
// 然后对比下最大值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 然后创建对应长度的数组
table = new Entry[capacity];
// 初始化hash值掩码,即参与hash的生成规则,暂时不分析这个
initHashSeedAsNeeded(capacity);
}
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
// 对给到的数字进行校验,超过最大值就默认最大值,小于等于1就默认1,
// 剩下的就走的先把数字 减去1,然后左移动1位(等于*2),这样是为了保证得到一个
// 大于等于的这个数字的2的n次幂,highestOneBit 是为了得到最高位代表的数据
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
初始化map的数组就比较简单,根据容量和扩容因子计算扩容阈值,这里向下取整的,即如果传入的capacity为4,loadFactor为0.8,得到的就是3,创建的是长度为4的数组,计算数组的长度的也算一个点,得到大于等于这个值的2的n次幂,通过-1,然后左移动一位,然后取最高位代表的值,这样得到的就是大于等于源数据的2的n次幂(这里把1除了,因为1直接得1)
hash过程简单分析
final int hash(Object k) {
// 参与运算的掩码
int h = hashSeed;
// 这里直接计算string的的hash,暂不追
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
// 这里掩码和对象的hashCode进行异或运算
h ^= k.hashCode();
//分别将输入值向右移动20和12位。然后,通过对这两个结果进行异或操作,
//将它们与原始值进行混合。接着,再将结果右移7位、4位,并再次与原始值进行异或操作。
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
这里的hash主要就是有个自己的掩码参与运算,然后后面对数据进行向右移动几次位置进行异或,为了打乱hashcode值,使其更加分散,受到某些因素影响较小
addEntry是怎么进行插入节点的
void addEntry(int hash, K key, V value, int bucketIndex) {
// 先判断下是不是超过阈值,并且这次插入hash冲突,如果不冲突,可以先不扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容为原来长度的2倍
resize(2 * table.length);
// 这里扩容之后重新进行了hash
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];
// 这里看起来直接一个entry到对应位置了,实际在Entry的构造方法中
// 把next指针指向了原来的节点,这就是头插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
实际插入节点也比较简单,主要是前面进行了检查没有相同的key,这里直接采用头插法插入了,这里的扩容单独拿出来分析吧resize()方法
扩容resize
当超过原来阈值并且本次插入hash冲突的时候触发的扩容
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 转移数据
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;
// 是不是需要rehash
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;
}
}
}
总结下就是转移数据的时候在遍历数组,然后遍历数组上的链表,然后采用头插法,重新计算下标位置,然后next指针指向新数组的节点,然后把节点放进数组,头插法,这里如果两个线程转移数据,会发生一个已经转移过去的数据,然后另一个线程把他的next指针指向数组上的节点,因为转移数据之后头尾是反过来的,这样就成环了,再次get到这里会死循环
分析下get
其实get基本能在put中体现,因为put的时候需要确保现有里面是不是已经存在key了,如果存在key的话是替换value,然后返回的旧的value,简单看下吧
public V get(Object key) {
// 因为null值特殊对待,直接存放到0位置,这里直接去找
if (key == null)
return getForNullKey();
// getEntry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 先取hash
int hash = (key == null) ? 0 : hash(key);
// 然后找到对应位置,进行遍历,找到就返回,采用的是equals,找不到返回空
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;
}
好了,这样HashMap1.7基本分析完了,那么开头提到的问题基本解决,扩展性问题呢,hashMap对象是不是可以为key,有啥需要注意的,因为是先hash再求的equals,如果不重写对象的hash和equals的话,那么就是对象的地址,这样存进去是可以原对象取出来的,再拿相同属性的对象是不能取出来的,如果重写了hash和equals方法呢,那么可能造成的结果呢,就是拿相同属性的对象可以get到value,但是!!,如果存入之后改变了对象的值,导致hash值发生变化,但是原先存入的不会随着重新hash移动位置,那么拿这个对象就不能获取到值了,虽然他们是同一个对象,equal相同也不行,只能找原来相同的hash和equals进行获取,
HashMap1.7和1.8 的区别
后面再分析下HashMap1.8 的源码,他们之间的区别到底有哪些呢,现在还不清楚具体的区别,先说下之前认知的吧,比如hash算法,扩容机制,头插法 尾插法,红黑树结构,具体的差异等下次分析1.8源码给列一下吧