目录
2.HashMap底层原理(JDK1.8,数组+单向链表+红黑树)
3.ConcurrentHashMap底层原理(JDK1.7,Segment分段+可重入锁ReentrantLock)
4.ConcurrentHashMap底层原理(JDK1.8,CAS+synchronized代码块)
1.HashMap底层原理(JDK1.7,数组+单向链表)
底层:哈希表(数组+单向链表),数组类型Entry[]
Entry对象包含了4个元素
hashcode,key,value,下一个节点的地址
构造器
默认构造器,初始化数组阈值16,数组长度未知,第一次Put方法设置成16,负载因子赋值0.75(扩容阈值threshold是16*0.75=12)
带参构造器,初始化数组阈值是传递过来的initialCapacity,数组长度未知,第一次Put方法设置成2的指数倍(例如,传递24,实际上是32),负载因子是传递的值threshold,扩容阈值是2的指数倍*threshold
put方法(头插法)
小知识点1:当数组为空的时候,put方法开始初始化数组,长度默认16或者2的指数倍,扩容阈值threshold是12或者2的指数倍乘以负载因子
小知识点2:HashMap允许key的值为null,把null当做一个特殊的key,放到了数组下标是0的位置
- 计算hashCode:获取key的hashCode方法,结果进行二次散列和扰动函数,计算出哈希码,尽量保证分散到数组上,减少哈希碰撞。
- 计算数组中的下标位置:h & (length - 1),其中h是上一步的hashCode,length是数组长度(默认16),等效于h %(length - 1)的取余函数(与(&)运算效率高)
- 哈希冲突(哈希碰撞):hashcode相同,导致计算下标位置相同,如果有元素,比较hashCode和key(==和equals()方法,两者只要有一个是true,则true),如果key相同,则用新Value覆盖老value(只替换value,不替换key),返回老Value,否则遵循7上8下(jdk7头插法,jdk8尾插法)
扩容流程
扩容触发条件有两个:
- 当前hashMap的元素个数大于等于阈值的时候
- 要插入的数组位置的元素不等于空
扩容流程:
- 创建2倍的数组:扩容的新数组Entry空数组,长度是原数组的2倍(默认2*16=32)
- 遍历,重新计算所有Key的hashCode和数组下标:两层嵌套循环,先遍历非空的数组元素,然后遍历单个数组元素的链表,判断是否需要重新计算哈希(一般不需要,涉及哈希种子hashseed),结合计算公式,计算出新的数组下标(因为新数组长度变大,key的hashCode计算公式(key.hashCode & (length - 1))也发生了变化),单线程没有问题,多线程下的HashMap在并发情况下的扩容会出现循环链表。所以说HashMap是线程不安全的
- 变量重新赋值:扩容阈值threshold
2.HashMap底层原理(JDK1.8,数组+单向链表+红黑树)
底层:哈希表(数组+单向链表+红黑树),链表和红黑树有可能共存
数组:Node<K,V>[] (这块跟JDK1.7名字不同,本质一样)
红黑树:全称是Red-Black Tree,又称为“红黑树”,它一颗平衡二叉树(左子树跟右子树长度差不多,最长路径不能超过最短路径的二倍)。
特点(算法导论,也叫黑色节点平衡):
- 每个节点要么是黑色,要么是红色
- 根节点是黑色
- 每个叶子节点(NIL)是黑色(叶子节点都是null)
- 每个红色结点的两个子结点一定都是黑色
- 任意一结点到每个叶子结点的路径都包含数量相同的黑结点
- 红黑树并不是一个完美平衡的二叉查找树
- 默认插入的节点的初始颜色一定是红色
是否需要旋转或者变色,如下规律:
- 如果新加入节点的父节点是黑色的,就不需要调整
- 如果父节点是红色,叔叔是空的,需要旋转+变色
- 如果父节点是红色,叔叔是红色,需要父节点和叔叔变黑色,祖节变红色
- 如果父节点是红色,叔叔是黑色,需要旋转+变色
单向链表的时间复杂度:O(n)
红黑树的时间复杂度:O(logn)
一些关键常量
- 默认的Node数组长度16(DEFAULT_INITIAL_CAPACITY = 1 << 4)
- 默认的负载因子是0.75f(DEFAULT_LOAD_FACTOR = 0.75f)
- 默认的树化阈值是8(TREEIFY_THRESHOLD = 8),树化的意思是单向链表长度大于8的时候,转化成红黑树(前提是HashMap所有元素个数大于等于64)
- 默认的树退化阈值是6(UNTREEIFY_THRESHOLD = 6),树化的意思是红黑树的长度小于6的时候,转化成单向链表(前提是HashMap所有元素个数小于64)
- 树化的另一个参数是64(MIN_TREEIFY_CAPACITY = 64),前提是HashMap所有元素个数大于等于64
- Node对象包含了4个元素(这一点跟jdk1.7一样):hashcode,key,value,下一个节点的地址
- size:当前HashMap已包含的元素个数
- modCount:当前哈希表的修改次数,替换元素的不算,插入新元素或者减少元素+=modCount
- threshold:数组长度的阈值
4个构造器(跟jdk1.7基本一样,有点小区别)
对loadFactor和threshold做赋值操作,默认情况下赋值成0.75F和16
不一样的地方是jdk1.7直接赋值,1.8对threshold赋值的时候是取大于initialCapacity并且最小的2的指数倍
put方法
判断1:初始化Node数组(resize()方法中),长度默认是16,这里是懒加载思想,跟jdk1.7一样
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
判断2:计算数组索引,该节点没有Hash碰撞,就意味着是空节点,直接赋值(计算下标的公式: (n - 1) & hash),跟jdk1.7一样
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
判断3:发生了Hash碰撞,如果第一个key相同(这里是比较hashcode和equals),直接覆盖节点操作(无论是链表还是红黑树),跟jdk1.7一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
判断4:发生了Hash碰撞,如果第一个key不相同,并且第一个节点的节点类型是红黑树类型,则进行红黑树插入操作,跟jdk1.7不一样(jdk1.7没有红黑树)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
判断5:发生了Hash碰撞,如果key不同,并且不是红黑树,那就是链表,则循环遍历链表,进行插入操作,如果插入之前长度大于等于8,则再次插入需要树化操作,跟jdk1.7不一样
如果长度小于8,那就是链表操作,要么找到了相同的key,直接赋值Value,要么插入操作,直接尾插法,跟jdk1.7不一样(jdk1.7是头插法)
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
插入新元素之后的size大于阈值,则触发扩容
扩容(跟jdk1.7基本一样,有点小区别,jdk1.7两个条件)
触发条件:是单向链表还是红黑树?满足两个条件,就会转换成红黑树
- 单个数组节点上的链表长度大于等于8
- 整个HashMap的元素个数大于等于64
红黑树的引用是为了解决链化的问题,换句话说是链表太长,单链表查找影响效率了
3.ConcurrentHashMap底层原理(JDK1.7,Segment分段+可重入锁ReentrantLock)
底层:数组Segment+HasMap(哈希表),数组类型Segment[]
原理
要保证HashMap线程安全,最能想到的是Hashtable,把方法都加入synchronized关键字,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,相当于所有线程进行读写时都去竞争一把锁,导致效率非常低下,所以Hashtable废弃了。
- ConcurrentHashMap原理是对数组进行逻辑切分成一个个n个小数组(比如两个元素,共享一把锁),进行分段加锁,那么就N把锁,能在保证线程安全的情况下,提高效率。
- ConcurrentHashMap整体来看就是一个Segment数组,每一个Segment对象等效于一个HashMap的对象,每一个HashMap包含了Entry数组和链表
- 每一个Segment对象,继承了可重入锁ReentrantLock,操作某一个Segment对象需要获取锁,在扩容时也是对某一个Segment对象中的Entry数组进行扩容。
- 这里总结一下,存在两个类型的数组:一个是Segment数组,一个是Entry数组
- 默认并发级别(几把分段锁)是16
构造器
无参构造器:
最后Segment数组的大小是16,每一个Segment对象下的Entry数组大小是2,那么阈值是2*0.75
有参构造器(3个参数):
- initialCapacity:每一个Segment下的Entry数组大小之和,默认是16
- loadFactor:装载因子,默认是0.75F
- concurrencylevel:并发级别,就是几把分段锁,也就是Segment个数,也就是Segment数组的大小,默认是16,最大不超过2的16次方65536
1.先初始化Segment数组,大小用S代表,大小是2的指数倍(例如如果concurrencylevel是17,则Segment数组大小是32),一旦Segment数组大小确定后就不会改变了,后续扩容跟Segment数组没有关系,跟Segment中的Entry数组有关系。
2.然后初始化第一个Segment对象中Entry数组,大小是initialCapacity / S = E,并对E向上取整得E1,最后取大于E1并且最小的2的指数倍S1(例如初始容量是17,Segment数组的大小是4,那么计算后的数向上取整是5,那么Entry数组的大小就是8)
3.将第一个Segment对象放入到Segment数组的第0个位置上去,也就是说只初始化Segment[0]的对象,其他Segment[i]都是空的,底层使用了Unsafe类,调用了C的方法。
Put方法的流程(争取写的细一点)
- 判value是否空:先判断key-value中value不能为空,否则空指针异常
- 计算Segment数组的下标(计算key的hashcode,通过hashcode计算Segment数组的下标(与HashMap类似,但是有差别,差别在于hashCode需要>>>SegmentShif))
- 判断确定位置后的Segment对象是否是空:如果不是空,直接调用Segment.put方法,否则初始化该位置的Segment对象(这里用到了CAS和自旋锁原理)
- 进入Segment.put方法后,内容跟HashMap的put()方法一样的,这里不写了(这里用到了可重入锁ReentrantLock)
- 这里只讲锁机制:tryLock尝试去获取Segment锁(跟lock锁的区别是:tryLock是非阻塞加锁,意思是能加锁就返回true,不能加锁就返回false,不强求,lock是阻塞加锁,加不上锁就卡在这),源码中用了while循环,意思是如果获取不到锁,就去干别的事,别闲着,增强效率(为啥要干别的事呢?因为一是别闲着,二是为了减缓while循环速度,不然cpu要爆炸,三是顺便尝试遍历Entry[0]下的链表所有key,获取key的Entry节点,就算遍历完没获取到,也没关系,后续还会for循环遍历所有Entry数组),如果在获取锁的过程中会在偶次数循环中判断头节点是否发生了变化(为啥判断头节点,因为1.7是头插法),如果变化了就从-1开始重新循环Entry[0]下的链表,如果能循环次数到了64次,如果还没获取到锁就直接阻塞等待获取lock()了,获取到锁之后的操作跟HashMap的put()方法一致了。
扩容跟segment数组无关,跟链表无关,跟每一个Segment对象的Entry[]数组有关
这里不多说了,扩容机制跟HashMap一致,区别是ConcurrentHashMap支持多线程扩容,每一个线程扩容1.7都是头插法
4.ConcurrentHashMap底层原理(JDK1.8,CAS+synchronized代码块)
底层:数组Node[]+链表+ 红黑树
构造器
默认构造器:空,啥也没干,默认Node数组长度16,加载因子0.75f,并发
有参构造器:对sizeCtl变量做赋值操作
sizeCtl变量的含义:
- 0:默认值
- -1:正在创建数组
- -N:表示有N-1个线程正在进行扩容操作
- N:当前数组下一次扩容的阈值
Put方法的流程
- 检查Key或者Value是否为null,为空,抛出空指针异常
- 计算key的hash值((h ^ (h >>> 16)) & 0x7fffffff)
- 如果Node数组是空的,此时才初始化 initTable()
- 如果找的对应的下标的位置为空,使用CAS方式直接new一个Node节点并放入, break;
- 如果对应头结点不为空, 进入同步代码块,Syschronized锁住首结点f
- 如果首节点f的hash值等于-1,说明其他线程在扩容,参与一起扩容
- 首节点f的hash值大于零则说明是链表的头结点,则链表插入,在链表中遍历寻找,如果有相同hash值并且key相同,就直接覆盖,返回旧值 结束;如果没有则就直接放置在链表的尾部,
- 如果首节点f instanceof TreeBin(或者小于0且不等于-1),则说明是红黑树的根节点,则红黑树插入,则说明此节点是红黑二叉树的根节点,调用树的添加元素方法
- 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8,这里需要注意它不是一定会进行红黑树转换, 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
get方法的流程
- 计算 hash 值
- 根据 hash 值找到数组对应位置: (n - 1) & h
- 根据该位置处结点性质进行相应查找
- 如果该位置为 null,那么直接返回 null 就可以了
- 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
- 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树
- 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
如何保证线程安全的
抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁
只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的数组元素的读写,大大提高了并发度。
JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
- 数据结构:jdk1.7采用Segment 数组+数组+链表的数据结构,jdk1.8是数组+链表+红黑树的数据结构。
- 线程安全机制:JDK1.7 采用 Segment 的分段锁(每一个分段锁都是可重入锁ReentrantLock)机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS + synchronized保证线程安全。
- 锁的粒度:JDK1.7 是对其中一个Segment加锁ReentrantLock,JDK1.8 调整为对Node数组的首节点进行加锁synchronized,明显看出jdk1.8锁的粒度更细致。
- 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于等于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
ConcurrentHashMap 迭代器是弱一致性,HashMap迭代器是强一致性
与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。