HashMap浅析
java7中的hashmap,与数组中元素相同hashcode位置的元素,是以链表的方式存在,链接在元素的头节点处,而java8则是链接在尾节点。(第一个不同点)
首先,会将新加入的元素链到旧元素的头部,然后将这个头部赋值到旧元素的位置,完成新元素加节点的操作。(java7的添加原理)
public class MyHashMap<K,V> {
//自定义一个节点
class Entry<K,V>{
private K k;
private V v;
private Entry<K,V> next;
public Entry() {
}
public Entry(K k, V v, Entry<K, V> next) {
this.k = k;
this.v = v;
this.next = next;
}
public K getK() {
return k;
}
public void setK(K k) {
this.k = k;
}
public V getV() {
return v;
}
public void setV(V v) {
this.v = v;
}
public Entry<K, V> getNext() {
return next;
}
public void setNext(Entry<K, V> next) {
this.next = next;
}
}
private Entry[] table; //自定义存储数组
private static Integer CAPACITY=8; //自定义数组长度
private int size=0; //集合长度,在addEntry中++
public MyHashMap() {
this.table = new Entry[CAPACITY]; //构造方法初始化数组
}
//增加节点
private void addEntry(K key, V value, int i) {
Entry entry = new Entry(key, value, table[i]);
table[i]=entry;
size++;
}
//put方法
public Object put(K key,V value){
//元素基于hashcode在数组中找寻下标
int hash=key.hashCode();
int i=hash%8;
//更新链接的链表 数组这个位置元素;有下个元素;链表中的下一个节点
for(Entry<K,V> entry=table[i];entry!=null;entry=entry.next){
if(entry.k.equals(key)){ //原来的值与传进来的值,key相等
V oldValue=entry.v;
entry.v=value;
return oldValue;
}
}
addEntry(key, value, i);
return null;
}
//get方法
public Object get(Object key){
int hash=key.hashCode();
int i=hash%8; //找到下标
//循环链表
for(Entry<K,V> entry=table[i];entry!=null;entry=entry.next){
if(entry.k.equals(key)){ //判断key
return entry.v;
}
}
return null;
}
//size方法:统计有多少元素
public int size(){
//不之间length数组,也不以循环数组和链表的形式
return size;
}
}
- jdk7与jdk8的区别
- jdk8中会将链表转会转变为红黑树
- 新节点插入链表的顺序不同(7是插头节点,8插尾节点)
- hash算法的简化
- resize逻辑的修改(7扩容会死锁需指定阀值,8不会)
- jdk1.7
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化容量 1向左移4位 即初始化容量16
//【初始化桶大小,因为底层是数组,所以这是数组默认的大小。】
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
//【桶最大值】
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子,用于扩容
//【默认的负载因子】
static final Entry<?,?>[] EMPTY_TABLE = {}; //定义个Entry数组,类似上边手写的,增加了一个hash值
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; //初始化成上边的table
//【table 真正存放数据的数组】
transient int size; //逻辑长度
//【Map 存放数量的大小】
int threshold; //根据加载因子,计算出阀值
//【桶大小,可在初始化时显式指定】
final float loadFactor; //可修改加载因子
//【负载因子,可在初始化时显式指定】
transient int modCount; //和线程并发有关 修改次数?
//273行
public HashMap(int initialCapacity) { //有参构造,主要是赋值用
this(initialCapacity, DEFAULT_LOAD_FACTOR); //初始容量,加载因子
}
//311行 初始化table数组,按照容量大小来算
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize 找到一个2的次方数字
int capacity = roundUpToPowerOf2(toSize);
//修改阀值 容量*加载因子
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity); //计算hash值
}
//486行
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold); //阈值扩容 roundUpToPowerOf2 判断填充容量因子
}
if (key == null) //当key为null时,调用putForNullKey方法,讲value放置在数组第一个位置
return putForNullKey(value);
int hash = hash(key); //传k进入,返回哈希值/哈希码
int i = indexFor(hash, table.length); //确认位置,得到存储在数组中的位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue; //进行交换,出来新值
}
}
modCount++;
addEntry(hash, key, value, i); //往数组添加的方法
return null;
}
//877行
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) { //大过阈值 且 数组放满(1.8取消这一特性)不为空
resize(2 * table.length); //数组扩容 + transfer //数组拷贝
hash = (null != key) ? hash(key) : 0; //哈希计算
bucketIndex = indexFor(hash, table.length); //重新算出位置下标
}
createEntry(hash, key, value, bucketIndex);
}
//895行
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
归纳起来,简单的说HashMap在底层讲k-v当成一个整体Entry对象。HashMap底层采用Entry[]数组来保存k-v对,当需要存储一个Entry对象时,会根据hash算法来决定其在数组中的存储位置,再根据equals方法决定其在链表中的位置;取出时同理。
- jdk1.8
- 思考: 为何1.8使用了红黑树?
树结构
相比链表
插入效率会低些,但查询效率则高许多;红黑树
相比完全平衡二叉树
插入效率高,但查询效率低于完全平衡二叉树
。
其实,红黑树
在链表
和完全平衡二叉树
之间,取了折中。
因为hashmap不单仅是插入,也不单仅是查询,是需要平衡的,所以选用了红黑树。
//改动1:新增两个关键属性
static final int TREEIFY_THRESHOLD = 8; //当链表达到8,会转成红黑树,提升查询效率
//【链表 --> 红黑树】
static final int UNTREEIFY_THRESHOLD = 6; //红黑树节点变成6个,会还原成链表
//【红黑树 --> 链表】
//336行 改动2:因为选择红黑树,可以使用较差的散列性,所以hash算法没1.7繁琐
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//624行 put
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i; //new数组,节点,下标
if ((tab = table) == null || (n = tab.length) == 0) //空数组
n = (tab = resize()).length; //resize 新增初始化功能
if ((p = tab[i = (n - 1) & hash]) == null) //算出数组下标i
tab[i] = newNode(hash, key, value, null);
else { //数组上有元素了
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode) //树节点,元素放到红黑树中(里面是算法层面)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//新增节点,当前情况是链表的情况
for (int binCount = 0; ; ++binCount) {
//改动3:加尾节点原因?要对链表判断,是不是需要树化,需要遍历;反正需要遍历到最后元素,就将新节点加到链表的尾节点
if ((e = p.next) == null) { //表示循环到最后一个节点
p.next = newNode(hash, key, value, null); //正常生成新的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //统计个数,到8-1变成树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { //新值放到数组
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) //改动4.resize的改动,jdk1.7存在扩容改动位置的问题,移动位置会产生死锁问题;1.8顺序固定。
resize();
afterNodeInsertion(evict);
return null;
}
HashMap 与 ConcurrentHashMap 在7,8中变化
1. java7 HashMap
这个仅仅是示意图,因为没有考虑到数组要扩容的情况,具体的后面再说。
大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。
上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
loadFactor:负载因子,默认为 0.75。
threshold:扩容的阈值,等于 capacity * loadFactor
2. java7 ConcurrentHashMap
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。
整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了 “槽” 来代表一个 segment。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrencyLevel: 并行级别、并发数、Segment 数,怎么翻译不重要,理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
3. java8 HashMap
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
Java7 中使用 Entry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。
4. java8 ConcurrentHashMap
Java7 中实现的 ConcurrentHashMap 说实话还是比较复杂的,Java8 对 ConcurrentHashMap 进行了比较大的改动。建议读者可以参考 Java8 中 HashMap 相对于 Java7 HashMap 的改动,对于 ConcurrentHashMap,Java8 也引入了红黑树。
说实话,Java8 ConcurrentHashMap 源码真心不简单,最难的在于扩容,数据迁移操作不容易看懂。