HashMap特点
- 允许空值和空键,一般情况下能直接根据hashcode找到元素(指除了hash冲突的情况),效率高
- 线程不安全,可以采用ConcurrentHashMap或Collections.synchronizedMap来达到线程安全目的
- 底层采用拉链法(数组+链表(java8中还包括红黑树))
对于HashMap和其它Map的区别,可大致参考如下:
jdk1.7版本HashMap.put()方法
结合put源码来看下,调用map.put()方法时候到底做了哪些事情?
核心步骤:
1、判断存放entry的数组table是否为空数组,为空则先初始化;
2、key如果为空,则统一放在数组中索引为0的位置,因为null的hash值总是0,在putForNullKey(value)方法中可以看到;
3、key不为空,对key进行hash得到"尽可能唯一"的hash值;
4、根据hash值和数组length通过&运算得到该entry在数组中的索引值 i;
5、判断hash值、key是否和数组中已有相同的key,相同则用新值覆盖旧值;
6、不相同则调用addEntry()方法,判断是否需要扩容,然后在table[i]对应位置上实例化Entry;
7、在上述步骤5的for循环判断中,把entry的next总是指向了下一个entry(没有的话就为entry.next=null);
put方法里面解决了两个关键性问题,其一是由hash值和数组长度经过&运算得到的索引值,通过这个索引能快速定位到该entry(key,value)。其二是解决hash冲突的问题。当生成了两个hash值相同但是它们对应的key又不相同时,就发生了hash冲突,这时hashmap的实现里面的链表就发挥作用了,通过代码可知hashmap集合里的数组结构中存放的是Entry,里面有key、value、next、hash属性,这里的next指向的就是下一个entry,也就是说,发生hash冲突的多个Entry,先进的会放在链表头部,通过next属性"链式的"指向下一个entry对象,简图如下:
put方法里面还有一个比较重要的是扩容,当hashmap里的数组长度超过threshold=loadFactory*capacity时(默认0.75*16=12)即开始扩容,核心即新建数组,把之前的数组通过transfer方法写入到新数组中:
jdk1.7版本HashMap get()方法
get方法相对简单,好理解。如果key=null,直接获取数组下标为0的entry,否则根据hash值找到数组中的Entry,如果Entry中的next指向不为空,说明是链表结构,则循环链表判断key是否相等,相等直接返回对应Entry的value值,否则返回null。
这里可以看到,当链表长度非常大的时候(说明hash冲突较多),遍历链表取值操作是比较耗性能的,因为链表索引定位数据慢,修改或新增操作快,尤其是当元素位于链表的最末端时,复杂度是O(n)。
这里要注意的是,作为hashmap对象的key一定要同时重写hashcode和equals方法,,否则可能出现put成功但是却get不到数据的情况。原因很简单,要想保证key元素的唯一性,必须同时覆盖equals和hashcode方法,详见如下测试用例:
public static void main(String[] args) {
Map<User, Integer> map = new HashMap<>();
map.put(new User("tom"), 20);
map.put(new User("jack"), 25);
map.put(new User("tom"), 20);
Set<Map.Entry<User, Integer>> entries = map.entrySet();
for (Map.Entry<User, Integer> entry : entries) {
System.out.println(entry.getKey() + "-" + entry.getValue());
}
}
public static class User {
private String name;
public User(String name) {
this.name = name;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
'}';
}
}
输出结果为:(预期map中name=tom的key对象只有一个的,但是由于User类没有重写equals和hashCode方法……)
User{name='tom'}-20
User{name='tom'}-20
User{name='jack'}-25
HashMap存在哪些问题?
由HashMap源码可知,hashmap存在几点不足的地方:
1、线程不安全,现象表现为
- 并发put碰撞导致数据丢失:多线程同时put时,如果计算出来的hashcode相同就会放在数组的同一位置,可能造成数据丢失;
- 并发put扩容导致数据丢失:多线程同时put发现同时需要扩容,扩容动作涉及到把数组拷贝到新数组中,扩容完成后只会有一个数组被保留下来,也就可能造成数据丢失;
- 并发put导致死循环CPU100%:多线程并发扩容时导致循环链表
2、循环迭代中不允许修改内容;
3、扩容问题,hashmap resize时候涉及到数组拷贝等操作,比较耗资源,所以我们可以预估放在hashmap数据量并在初始化hashmap时候指定其数组容量。HashMap的扩容相关问题也经常在面试中出现:什么时候扩容? 如何扩容?;
jdk1.7版本和1.8版本的HashMap结构图
HashMap从jdk1.7到1.8一个很重要的变化就是jdk8中引入红黑树的结构,由jdk7的数组+链表结构升级为jdk8的数组+链表+红黑树,当链表上的节点数超过了8(默认)会自动将链表结构转换成红黑树结构,用于解决链表过长的问题,查询遍历复杂度也由链表的O(N)降为了红黑树的O(logN)。
链表长度为什么超过8要转为红黑树?
- 默认的是链表结构,并不是一开始就是红黑树结构,因为链表比红黑数占用的空间较少;
- hash冲突导致链表长度真正达到8的概率极小,约为一千万分之一,同时也考虑到红黑树查询比链表快;
其它相关
1、如何解决hash冲突?
通过单链表结构,java8中如果链表长度超过8则转换为红黑树来加快查询
2、hash函数是如何降低hash冲突的?
通过"异或"操作,对hashcode的高16位与低16位进行异或求模,增加了散列度,降低冲突
3、为什么HashMap的初始化容量为2的倍数?
- 和操作系统有关,申请内存时候避免内存碎片;
- 计算机擅长移位操作,不擅长加减乘除操作,移位操作对计算机来说效率高,提高运算速度;
- 参与到hash函数中,能提高hash函数的散列度,减少hash冲突;