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扩容时是尾插法,数据结构是数组+链表/红黑树