答主在面试时频频被问到这个问题,在此做一下总结。看了很多关于HashMap底层原理描述的文章,感觉都有点云里雾里的,这里没有源代码分析,只有你看得懂的原理解释与比较。
1、组成结构:HashMap的底层组成结构分成两个阶段:(1)JDK1.8之前的版本:由数组+链表组成,其中数据类型是Entry;(2)JDK1.8:由数组+链表+红黑树组成,数据类型是Node;
这就是HashMap的原理结构图,上面0 1 2 3 4 5 6是数组,每一个数组空间上还连接有一个链表。
为什么要引入红黑树呢?而不直接用链表呢?
答:首先要明白hash在这里面的作用,我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过这样的方式来得到该元素的内存地址,而这里提到的某个函数,指的就是哈希函数。当两个元素通过哈希函数计算得到的内存地址是一样的时候,也就产生了哈希冲突,而每一个数组单元上的链表就是为了解决这种哈希冲突的,作用就是把所有产生哈希冲突的元素,放在同一个链表上。这里要注意的是,如果哈希冲突的数量过多,就会造成链表上的元素过多,我们都知道链表的查询效率是比较低的,为了解决这种链表元素过多导致的查询效率低的问题,JDK1.8就引入了红黑树,也就是当链表上的元素大于8的时候,链表就自动转换成红黑树,提高查询的效率。
2、HashMap在JDK1.8之前的版本与JDK1.8还有一个重要的区别:1.8之前的版本,链表上插入元素采用的是头插法,JDK1.8采用的是尾插法。
什么是头插法,什么是尾插法呢?
答:假设这里的一个数组单元上有两个元素(1和2):数组单元->1->2
这时候,要插入第三个元素3,头插法是将最新添加的元素放在头部,也就是:数组单元->3->1->2,尾插法是将最新添加的元素放在最末尾,也就是:数组单元->1->2->3,这是他们的区别,头插法显然插入效率最高,那么为什么要将头插法换成尾插法呢?答案就是头插法在多线程的环境下,如果HashMap进行扩容,那么会存在不安全性,会出现环形链表,但是尾插法就不会。
3、头插法为什么在多线程环境下不安全呢?
答:这里举一个最简单的例子,假设存在线程1、线程2,原HashMap中有两个元素,如果再添加一个元素11,就触发扩容机制(这里是如果,真实的不是2个元素以上就触发扩容机制),JDK1.8之前进行扩容是需要对内存地址进行rehash的,也就是每一个数组单元的地址都会产生改变,包括链表上的元素,也就是需要对链表上的元素进行重组,原来是:数组头 -> 5 -> 9现在进行扩容,在多线程的环境下,线程1 线程2同时进行扩容,如果线程1完成了:数组头 -> 5;线程2完成了:数组头 -> 9;这是线程1继续添加,数组头 -> 9 -> 5,这个时候,因为线程1与线程2的数组头地址是一样的,线程2会误认为元素5是新增的,然后通过头插法就会把元素5放到元素9的前面:数组头 -> 5 -> 9,这里我们注意,线程1里面:元素9.next = 元素5;线程2里面:元素5.next = 元素9,它们相互进行了引用,也就是出现了环形链表的现象。至此就解释完了。
如果对你有帮助,请点个赞哈。
2020.8.18更新
ConcurrentHashMap的原理以及与HashMap的比较
这里的ConcurrentHashMap也分JDK1.7和JDK1.8
JDK1.7:
在数据结构上与HashMap类似,但是ConcurrentHashMap是线程安全的,增加了segment分段锁来保证线程安全,这里的分段锁也是与hashtable的一个重要区别,hashtable使用的是synchronized表锁。ConcurrentHashMap的分段锁继承的是ReentrantLock。
在结构上:ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,这是与HashMap的一个区别,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。
JDK1.8:
1.8之后同样与HashMap一样改用了红黑树,但是将分段锁改成了CAS+Synchronized。原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)粒度更小了。