JDK1.7版本HashMap原理解析(数组+链表)
基本的成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //初始容量,默认16,2^4
static final int MAXIMUM_CAPACITY = 1 << 30; //最大初始量,2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; //负载因子,默认0.75,越小,hash冲突机率越低
static final Entry,?>[] EMPTY_TABLE = {}; //初始化一个Entry空数组
transient Entry
transient int size; //table存储key-value的总数
int threshold; //临界值(HashMap 实际能存储的大小),公式为(threshold = capacit*loadFactor)
final float loadFactor; //负载因子
transient int modCount; //HashMap的结构被修改的次数
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
HashMap初始化:
解析put方法:按照这五步进行解析put方法
1、inflateTable(threshold): 如果table引用指向成员变量EMPTY_TABLE,那么初始化HashMap(设置容量、临界值,新的Entry数组引用)
例子:
Map map = new HashMap(10);
map.put("key","value")
在方法中 roundUpToPowerOf2(9),Integer.highestOneBit((10-1)<<1)
8<<1 ->18 0000 0000 0001 0010
该方法获取最高位为1,其余为0。即 0000 0000 0001 0000 ->16
即容器初始化数组容量为16
2、putForNullKey(value)方法
如果key为null的话,会直接执行以上方法。 若“key为null”,则将该键值对添加到table[0]处,遍历该链表,如果有key为null,则将value替换。没有就创建新Entry对象放在链表表头(没有的话,执行addEntry()方法)
3、获取key的hash,以及获取数组的下标
计算下标
h&(length-1)保证获取的index在数组的范围内(0-15) length-1=15,h=18
计算出下表为2
4、开始进行put操作,key存在的话,更新
5、key不存在的话,添加新的Entry,执行addEntry()方法
扩容方法resize(2*table.length)
以上即jdk1.7版本,HashMap原理基本解析
jdk1.7中HashMap多线程死锁原因以及java8解决原理
假设有俩个节点A和B,并且这俩个节点在原数组的同个下标,rehash后还是在新数组的同一下标
线程一在执行过程中,e为A节点,next为B节点,此时线程暂停阻塞
线程二同样刚开始e为A节点,next为B节点
执行一次while循环
执行第二次whle循环
此时B的next是A,A的next是null,与扩容前的数据位置反了过来
随之,线程一恢复执行(e-A,next-B)
执行第一次while循环
执行第二次while循环
此时,B的next为A,A的next为B
循环会一直下去,形成了一个链环,死锁
jdk1.8中的新特性,引入了俩组指针,能够完美的解决上述形成链环的问题
可以看出在jdk1.8中,当对数组进行扩容迁移的时候,这里定义了俩组指针,分别是低位头和尾,高位头和尾。假设旧数组的长度是16
&运算的结果只存在0和16俩种情况,0是低位,16则是高位,这样就可以将链表直接分成俩快。低位的链表直接迁移到新数组对应的下标,高位链表迁移到index+扩容大小(16)下面的索引位置
总而言之在JDK1.8之后,HashMap底层的数组扩容后迁移的方法进行了优化。把一个链表分成了两组,分成高为和低位分别去迁移,避免了死环问题。而且在迁移的过程中并没有进行任何的rehash(重新记算hash),提高了性能。它是直接将链表给断掉,进行几乎是一个均等的拆分,然后通过头指针的指向将整体给迁移过去,这样就减小了链表的长度
JDK1.7ConcurrentHashMap原理解析
因为HashMap是线程不安全的,在多线程环境下,使用HashMap进行put操作可能会引起死循环。HashTable虽然使用synchronized来保证线程安全,但在线程竞争激烈的情况,Hashtable效率非常低下。当一个线程访问Hashtable的同步方法时,其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put方法,线程2不但使用put方法并且也不能使用get方法获取元素。对于HashTable而言,synchronized是针对整张hash表,即每次锁住整张表让线程独占,相当于所有线程进行读写时都竞争一把锁。
因此引出了ConcurrentHashMap的分段锁技术
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段的数据也能被其他线程访问。另外,ConcurrentHashMap可以做到读取数据不加锁,并且再其内部结构可以让其进行写操作的时候能够将锁的粒度尽量变小。
ConcurrentHashMap是由Segment数据结构和HashEntry数组结构构成,Segment是一种可重入锁ReetrantLock,扮演锁的角色。HashEntry则用于存储键值对的数据。一个ConcurrentHashMap里面包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表的结构。一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构元素。每个Segment守护着一个HashEntry数组里面的元素,当对HashEntry数组的数据进行修改,必须首先获得对应的Segment锁
Segment结构
static final class Segment extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry[] table; //链表数组,数组中的每一个元素代表了一个链表的头部
transient int count; //Segment中元素的数量
transient int modCount //对table的大小造成影响的操作的数量(比如put或者remove操作)
transient int threshold; //阈值,Segment里面元素的数量超过这个值依旧就会对Segment进行扩容
final float loadFactor; //负载因子,用于确定threshold
}
HashEntry结构
ConcurrentHashMap初始化
ConcurrentHashMap的初始化一共有三个参数,一个initialCapacity表示初始的容量,一个loadFactor表示负载参数,最后一个concurrentLevel代表内部的Segment的数量,一经指定,不可改变,后续如果ConcurrentHashMap的元素数量增加导致扩容,不会增加Segment的数量,只会增加Segment种链表数组的容量大小,这样不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。
通过上图,ConcurrentHashMap初始化具体流程,先是根据concurrentLevel来new Segment,这里的Segment的数量是不大于concurrentLevel的最大的2的指数,这样的好处可以采用移位进行hash,提升效率,接下来就是根据initialCapacity确定Segment的容量的大小,每一个Segment的容量大小也是2的指数
ConcurrentHashMap中get()方法
jdk1.8HashMap原理解析
java8对HashMap进行了一些修改,利用了红黑树,由数组+链表+红黑树组成;在java8中当链表元素达到8的时候,会将链表转换为红黑树,时间复杂度为0(logN)
put()方法基本过程解析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 第三个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第四个参数 evict 我们这里不关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// 第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度
// 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {// 数组该位置有数据
Node e; K k;
// 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果该节点是代表红黑树的节点,调用红黑树的插值方法,本文不展开说红黑树
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
// 到这里,说明数组该位置上是一个链表
for (int binCount = 0; ; ++binCount) {
// 插入到链表的最后面(Java7 是插入到链表的最前面)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
// 会触发下面的 treeifyBin,也就是将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果在该链表中找到了"相等"的 key(== 或 equals)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
break;
p = e;
}
}
// e!=null 说明存在旧值的key与要插入的key"相等"
// 对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}