Java Map讲解

Map是Java中的一个接口,常见的实现类有HashMap、LinkedHashMap、TreeMap和ConcurrentHashMap。

各个Map的实现结构

哈希表:数组+链表

HashMap:数组+链表/红黑树

LinkedHashMap:数组+链表+双向链表

TreeMap:红黑树

ConcurrentHashMap:数组+链表/红黑树

HashMap

HashMap有几个构造方法,但最主要的就是指定初始值大小以及负载因子的大小。

默认HashMap的大小为16,负载因子的大小为0.75,且HashMap的大小只能是2次幂。

假设你设置HashMap的初始大小为10,实际上最终HashMap的大小是16;你设置HashMap的初始大小为7,实际上最终HashMap的大小是8。

负载因子的作用

负载因子的大小决定着哈希表的扩容和哈希冲突。比如默认的HashMap大小为16,负载因子为0.75,这意味着数组最多只能放12个元素,一旦超过12个元素,则哈希表需要扩容。怎么算出是12呢?很简单,就是16*0.75。每次put元素进去的时候,都会检查HashMap的大小有没有超过这个阈值,如果超过了,则扩容。

为什么HashMap的大小是2次幂呢?

因为当把一个元素放进HashMap的时候,需要算出这个元素所在的位置(hash),在HashMap里用的位运算来代替取模,位运算能够更加高效的算出该元素所在的位置,而只有HashMap的大小是2次幂时,才能合理的使用位运算。

鉴于HashMap的大小只能是2次幂,所以HashMap扩容的时候都是默认扩展到原来的两倍。另外,扩容操作肯定是需要耗时的,那能不能把负载因子调高一点让HashMap晚点扩容呢,就比如我把负载因子调到1,那么会等到HashMap中有16个元素的时候才扩容,这样是可以的,但是不推荐。负载因子调高了,这意味着哈希冲突的概率会增加,哈希冲突的概率高了,同样耗时,因为查找的速度变慢了。

put元素时,传递的Key怎么计算哈希值的?

先计算出正常的hash值,然后与高16位做异或运算,产生最终的哈希值。这样做的好处是增加了随机性,减少了hash碰撞冲突的可能性。

put和get方法的实现

put的时候,首先对key做hash运算,计算出该key所在的index。如果没碰撞,直接放到数组中,如果碰撞了,需要判断目前数据结构是链表还是红黑树,根据不同情况来进行插入。假设key是相同的,则替换原来的值。最后判断哈希表是否满了,如果满了,则扩容。

get的时候,还是对key做hash运算,计算出该key所在的index,然后判断是否有hash冲突,假设没有冲突直接返回,有冲突则判断当前数据结构是链表还是红黑树,从对应的结构中取出。

什么情况下会用到红黑树?

当数组的大小大于64且链表的大小大于8的时候才会将链表改为红黑树,当红黑树大小为6时,会退化为链表。这里红黑树退化为链表的操作主要出于查询和插入时对性能的考量。

LinkedHashMap

使用场景不多,实际上它继承了HashMap,在HashMap的基础上维护了一个双向链表。有了这个双向链表,我们的插入可以是有序的,这里的有序不是指大小有序,而是插入有序。

LinkedHashMap在遍历的时候实际用的是双向链表来遍历的,所以LinkedHashMap的大小不会影响到遍历的性能。

TreeMap

TreeMap的key不能为null,TreeMap有序是通过Comparator来进行比较的,如果对象没有实现comparator方法,那就使用自然排序。

ConcurrentHashMap

HashMap不是线程安全的,在多线程环境下,有可能会有数据丢失或者获取不到最新数据的情况。

我们想要线程安全,一般使用ConcurrentHashMap,它是线程安全的,且能支持高并发的访问和更新。

ConcurrentHashMap通过给部分加锁和CAS算法来实现同步,在get的时候没有加锁,Node用了volatile来修饰。在扩容时,会给每个线程分配对应的区间,并且为了防止putVal导致数据不一致,会给线程所负责的区间加索。

线程安全的Map实现类还有一个叫HashTable,也可以使用Collections来包装出一个线程安全的Map。但无论是HashTable还是Collections包装出来的map都比较低效(因为是直接在最外层套synchronize),所以一般有线程安全问题考量的,都使用ConcurrentHashMap。

JDK7和JDK8 HashMap的区别

JDK7时,HashMap扩容时是头插法,数据结构是数组+链表

JDK8 时,HashMap扩容时是尾插法,数据结构是数组+链表/红黑树