HashMap、HashTable、ConcurrentHashMap详解
三种散列表的公共部分
三者都是存储键值对的Key-Value
key会被映射到数组索引, Entry对象则是数组中对应的值。
Key通过Hash算法得到哈希码(HashCode), 通过哈希码与数组中的索引对应。
因此所有的键值对Hash表都是无序储存的。
键值对的查找过程: (hashCode()计算出索引,通过键的equals方法找到对应的键值对, 返回值对象)先对键做一个hashCode()的计算来得到它在bucket数组(这里的计算方法略有区别)中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。
负载因子为什么是0.75而不是1或者0.5?
-
因为如果是0.5的话, 散列表会比较稀疏, 更大程度上避免了哈希碰撞的可能性, 索引效率高, 此时会对空间造成浪费
-
如果是1的话, 散列表会变得密集, 空间利用率提高了, 但是哈希碰撞的可能性变大了, 链表和红黑树会变得复杂, 索引效率会变低
-
经检测, 0.7-0.75的索引效率是最高的
HashTable
-
采用数组和链表, 1.8依然是数组和链表
-
线程安全的:使用synchronized来保证线程安全, 但是读写的时候会锁住整张表
-
扩容机制: HashTable的初始大小是11,扩容通过rehash()来实现, 当HashTable的数组元素个数达到临界值的时候, 就会调用rehash来进行扩容, 扩容后的大小是2*oldSize + 1, 临界值就是数组大小乘以负载子(loacFactor) , 通常负载因子默认为0.75。因此数组元素个数达到数组大小的0.75倍时, 就会进行扩容。
-
计算index方法:(取模方法) index = (hash & 0x7FFFFFFF) % tab.length,采用取模方法,减少哈希碰撞, 使数组分布更均匀, 但是效率比HashMap的位运算低。
-
链表依然采用头插法,而没有像HashMap在jdk1.8中为了避免并发情况下扩容死锁改为尾插法,因为头插法的效率比尾插法更高,同时HashTable是线程安全的, 并发下不会出现扩容死锁的情况。
HashMap
-
线程不安全的
-
扩容机制:初始容量默认16, 元素个数达到临界值后扩容, 新的容量为 2*oldSize, 大小一定为2的n次幂 , 临界值依然是数组容量 * loadFactor(负载因子)
-
扩容resize():
-
1.初始化数组table
-
2.当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中
-
具体实现
-
1.通过判断旧数组的容量是否大于0来判断数组是否初始化过
- 否:进行初始化
- 判断是否调用无参构造器,
- 是:使用默认的大小和阙值
- 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数
-
是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中
-
-
-
put的过程
- 通过hash() 计算出键的hashcode, 再通过indexFor() 方法计算出对应的索引
- 如果散列表为空, 调用resize()初始化散列表
- 添加元素到散列表中
- 如果没有发生hash碰撞, 直接添加到散列表中
- 如果发生碰撞
- 无论是红黑树还是链表, 如果equals相同, 则替换旧的键值对
- equals不同
- 链表, 循环遍历整个链表, 知道链表的某个节点为空(jdk1.8尾插法, 但是在jdk1.7采用的是头插法会引起并发死锁), 查看链表长度是否达到8以及容量是否大于64, 如果满足条件, 将链表转化成红黑树
- 红黑树插入方法
- 如果元素个数大于临界值(数组容量*loadFactor), 则通过resize()进行扩容
-
为什么扩容必须是2的幂
- 为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。
- 输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字
-
计算index方法:index = hash & (tab.length – 1)
ConcurrentHashMap
-
线程安全的, jdk1.7利用分段锁来提高并发程度, jdk1.8采用CAS和synchronized 来保证并发线程安全
- 如何保证线程安全的,
- jdk1.7保证线程安全: 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(因为Segment实现了ReentrantLock, 读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- jdk1.8保证线程安全的方式:
- 储存Map数据的数组时被volatile关键字修饰,一旦被修改,其他线程就可见修改。因为是数组存储,所以只有改变数组内存值是才会触发volatile的可见性
- 进行put操作的时候通过hashcode计算出的索引位置没有哈希冲突, 采用自旋+CAS操作。如果还在进行扩容操作就先进行扩容。如果存在hash冲突, 就会使用synchronized 给首节点进行加锁, 如果是链表结构采用尾插法插入尾端, 如果是红黑树按照红黑树的结构进行插入。如果hash冲突形成链表长度超过8, 而且数组长度超过64,就会将链表转化成红黑树, 如果只是链表长度超过8, 数组长度没有超过64,就会进行扩容操作。如果添加成功就调用addCount()方法统计size, 来检查是否需要扩容
- 一句话总结: 存储Map的数组被volatile关键字修饰, 一旦修改, 其他线程就可见修改, 方便进行CAS操作。读操作不加锁, 关键是写操作,如果索引位置没有哈希冲突, 采用自旋+CAS操作进行写操作, 如果有哈希冲突, 通过synchronized给首节点进行加锁, 后面的操作和HashMap类似(链表和红黑树以及扩容判断)
- 如何保证线程安全的,
-
ConcurrentHashMap的键和值不能为空
- 原因: 当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key)的时候,m可能已经不同了。
-
扩容机制:
-
jdk1.7的扩容机制:
- 进行写操作和扩容操作时候, 会对Segment进行加锁, 但是只会影响这一个Segment, 不同的Segment依然可以进行并发操作, 提高了并发效率, 解决了线程安全问题(一个Segment相当于一个HashMap)
-
jdk1.8的扩容机制:
-
sizeCtl 和 ForwordNode 在扩容中起关键作用
- sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。
- -1 代表table正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- 其余情况:
- 如果table未初始化,表示table需要初始化的大小。
- 如果table初始化完成,表示table的容量,默认是table大小的0.75倍
-
当数组元素个数超过临界值的时候, 就会进行扩容操作, 同时将sizeCtl的值修改。扩大后的长度和HashMap是一样的, 依然是原数组容量的2倍。扩容操作开始之后, 会将旧数组上的值迁移到新数组中,迁移完成的节点和空节点会被设置成ForwordNode节点, 其他线程进行读写操作时, 若节点是ForwordNode节点 , 也会加入到扩容操作。如果节点是正常节点, 读操作(get方法)依然和之前一样进行读操作, 写操作(put和remove)如果当前索引如果没有出现哈希碰撞, 采用CAS+自旋的方式进行写操作, 如果有哈希碰撞, 则进行扩容操作。
-
-