Java基础笔记(三)---集合框架(4)HashMap源码
(一)HashMap简介
(1)数据结构介绍
HashMap 是一个用于存储Key-Value键值对的集合,每一个键值对也叫做 Entry。
这些键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。HashMap 数组每一个元素的初始值都是 Null。
JDK1.7的HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表用来解决哈希冲突(“拉链法”解决冲突)。
JDK1.8以后在解决哈希冲突时发生变化,由数组+链表+红黑树组成,冲突时先使用链表,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间,具体可以参考 treeifyBin方法。
(2)对HashMap源码理解的简述
在JDK中,HashMap底层早期1.7版本之前的数据结构是“数组+链表”,在1.8版本之后的数据结构改成了“数组+链表+红黑树”。
在HashMap通过key值确定唯一的value,在调用put方法存入值的时候,会通过字符串ASIIC码相加的和计算出hashCode的值,但是hashCode的值太大了不可以直接作为下标存入数组,所以可以采用取模的形式除以数组长度取余数来确定在数组中存放的下标位置,在1.8版本后改进成效率更高的与或运算方式计算数组下标位置。
得到数组下标位置后,如果该位置为空的话就可以直接把Node节点放入,如果该位置不为空的话,也就是出现了哈希冲突,就会使用尾插法把新的Node节点插入到单向链表的尾部,因为HashMap不是线程安全的,所以当多线程高并发的时候,如果使用头插法可能会在扩容的时候造成死循环形成环形链表。
为了解决链表越长查询效率越慢的问题,当链表的长度超过8的时候,先判断数组的长度是否大于64,如果没有大于64就优先resize对数组进行扩容,先不用把链表转成红黑树,当数组长度大于64且链表长度大于8的时候才会把链表转成红黑树的形式。当链表长度小于6的时候会把红黑树转成链表。
随着整体的键值对增多,当实际存放数量HashMap.Size >=阈值threshhold= 数组容量Capacity(默认16) * 负载因子LoadFactor(默认0.75) 时,也就是数量超过12的时候就要对数组进行扩容处理了。
扩容的时候每次都是原来长度的2倍。创建一个新的Entry空数组,长度是原数组的2倍(32)。此时要求新数组的长度必须是2的幂数,这样做是为了减少hash函数的冲突次数,实现一个尽量分布均匀的hash函数。遍历原Entry数组,在JDK1.7版本中会调用Rehash方法把所有的Entry节点重新计算Hash值取模得数组下标,然后存到新数组,因为随着数组长度的改变,计算数组的下标的规则也会改变,所以每个节点都要重新计算。而在JDK1.8版本中进行了优化,通过【table.length-1&hashCode】的方式计算结果,由于每次扩容相当于数组长度的高位多了一个1,新的hash运算取决于【table.length-1&hashCode】结果在这一位上的值是0还是1,如果是0则无需变化位置,如果是1则位置为原位置+原数组长度的位置。
(二)源码中的默认配置和构造函数
(1)默认配置
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表节点转换红黑树节点的阈值, 9个节点转。当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 红黑树节点转换链表节点的阈值, 6个节点转。当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 转红黑树时, table的最小长度。桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 加载因子
final float loadFactor;
// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ... ...
}
// 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
// ...
}
(1)loadFactor负载因子【数组数据疏密程度】
默认为0.75。loadFactor加载因子是控制数组存放数据的疏密程度,loadFactor越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
(2)threshold【数组是否扩容】
threshold表示所能容纳的键值对的临界值,threshold = capacity * loadFactor,当Size>=threshold的时候,那么就要考虑对数组的扩容了,也就是说,这个的意思就是 衡量数组是否需要扩容的一个标准。
(3)size【map实际存放量】
size是hashmap中实际存在的键值对数量
(4)modCount【线程安全CAS】
modCount字段用来记录Hashmap内部结构发生变化的次数
(5)DEFAULT_INITIAL_CAPACITY【默认数组初始化容量】
DEFAULT_INITIAL_CAPACITY默认容量是16,Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
(6)TREEIFY_THRESHOLD【链表转红黑树节点标识值】
默认值为8,链表节点转换红黑树节点的阈值, 9个节点转。当桶(bucket)上的结点数大于这个值时会转成红黑树
(7)UNTREEIFY_THRESHOLD【红黑树转链表点标识值】
默认值为6,红黑树节点转换链表节点的阈值, 6个节点转。当桶(bucket)上的结点数小于这个值时树转链表
(8)MIN_TREEIFY_CAPACITY【链表转红黑树数组节点最小值】
默认值64,转红黑树时,table的最小长度。桶中结构转化为红黑树对应的table的最小大小
(2)构造方法【可自定义“数组初始化容量”和“数组疏密程度”】
HashMap 中有四个构造方法,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值。它们分别如下:
(1)默认构造函数:赋值loadFactor负载因子
(2)包含Map的构造函数:
(3)指定容量大小的构造函数:
(4)指定“容量大小”和“负载因子”的构造函数:对容量大小和负载因子的值进行最大值、最小值和非空的判断
// 默认构造函数。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);//下面会分析到这个方法
}
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 指定“容量大小”和“加载因子”的构造函数
public HashMap(int initialCapacity, float loadFactor) {
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组
(三)源码中的类和接口
(1)Entry接口【JDK1.7】
在JDK1.8之前,使用Entry类来放key-value键值对的,里面有get和set的方法。但是在JDk1.8之后,Entry类改成了继承Entry接口的Node类,也就是key-value值放在Node节点中,然后再把Node节点放进数组、链表或者红黑树中。
(2)Node节点类【JDK1.8】
Node是一个节点类,实现了Entry接口,所以Node可以存放和取出键值对。包含哈希值,key值,value值和next节点。Node节点就是存放put进来的值的,是一个静态内部类,我们要先把键值对放进这个Node对象,然后再对这个Node对象进行操作
// 继承自 Map.Entry<K,V>
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;// 哈希值,存放元素到hashmap中时用来与其他元素hash值比较
final K key;//键
V value;//值
// 指向下一个节点
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
// 重写hashCode()方法
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
// 重写 equals() 方法
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
树节点类源码
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父
TreeNode<K,V> left; // 左
TreeNode<K,V> right; // 右
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red; // 判断颜色
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
// 返回根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
(三)源码中的方法
(1)构造方法里的putMapEntries方法:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 判断table是否已经初始化
if (table == null) { // pre-size
// 未初始化,s为m的实际元素个数
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值,则初始化阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 已初始化,并且m元素个数大于阈值,进行扩容处理
else if (s > threshold)
resize();
// 将m中的所有元素添加至HashMap中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
(2)put方法【JDK1.8】
(1)put方法源码和概括
(1)数组初始化判断
判断数组table是否为空,或者长度为0。如果是空的就要调用resize方法进行初始化扩容,获取到初始化后数组的长度n
(2)下标位置为空,直接放入数组
(n - 1) & hash 确定数组的下标,tab[i = (n - 1) & hash] 获取数组中对应下标的值。如果值是空的,就直接newNode创建Node节点放入
(3)下标位置不为空
判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
(4)判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向5;
(5)遍历table[i],判断链表长度是否大于8,大于8且数组长度大于等于64的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可。如果链表长度大于8但是数组长度小于64,则直接对数组进行resize扩容而不会转成红黑树
(6)插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//实现Map.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;
// 步骤①:tab为空则创建
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
Node<K,V> e; K k;
// 步骤③:节点key存在,直接覆盖value
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// 步骤④:判断该链为红黑树
// hash值不相等,即key不相等;为红黑树结点
// 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 步骤⑤:该链为链表
// 为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
//判断该链表尾部指针是不是空的
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
//判断链表的长度是否达到转化红黑树的临界值,临界值为8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//链表结构转树形结构
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
//判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 步骤⑥:超过最大容量就扩容
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
(2)简单说明和逻辑图
HashMap只提供了put用于添加元素,并且调用putVal方法,putVal方法没有提供给用户使用。
比如调用 hashMap.put(“apple”, 0) ,插入一个Key为“apple”的元素。这时候我们需要利用一个哈希函数来确定Node节点的插入位置(index)。
(3)put方法中计算hash值
首先,我们取得Key的哈希值Key.hashCode来寻找元素在数组中放置的位置,但是哈希值Key.hashCode是一个大于数组长度的值,“apple”的hashCode为93029210,我们不可能直接用这个值去作为数组的下标位置。所以要先对这个哈希值进行处理,也就是调用hash()函数,处理内容为:hashCode值右移16位,把右移后的值与原来的hashCode做异或运算,并返回hash值结果。(其中h>>>16,在JDK1.8中,优化了高位运算的算法,使用了零扩展,无论正数还是负数,都在高位插入0)。
(4)putVal方法中把值放入数组
(1)使用hash值通过异或计算出数组下标
在putVal()方法中,取得上面异或返回的hash值和数组长度n,通过(n-1)&hash(hash和15取异或)获取该对象的键在hashmap中的位置。((n-1)&hash就等价于hash%n,且&运算的效率高于%运算)
(2)把值放入数组
判断此下标位置是不是空着,如果空着,则直接把key和value封装为一个Node对象并存入此数组位置;
假定最后计算出的index下标是2,并且2位置为空,就把Node节点放在数组下标为2的位置,结果如下:
但是如果2位置不为空,就要考虑数据放进链表,往下看~
(3)把值放入红黑树
如果此下标位置元素非空,说明此位置上存在Node对象,那么则判断该Node对象是不是一个红黑树节点,如果是,则将key和value封装成一个红黑树节点,并添加到红黑树上去,在这个过程还会判断红黑树中是否存在当前key,如果存在则更新相应的value;
(4)把值放入链表
因为 HashMap的数组长度是有限的,当put进来的 Node 越来越多时,再完美的 Hash 函数也会出现 index 冲突的情况。例如新 put 进来的 Node 计算后的 index 也为2,此时数组下标为2的位置已经存放了“apple”,也就是出现了hash冲突,这时要先根据hashCode和equals方法判断元素是否重复,如果判断元素重复了就直接覆盖上去,如果判断元素没有重复就要启用链表来存放,使用尾插法把新的Node插入到链表的尾部。
在JDK1.8以后链表的Node插入从头插法改成了尾插法,为什么1.8要改成尾插法呢?因为HashMap不是线程安全的,在单线程操作下不会出现问题,但是在多线程情况下,当数组扩容后进行Rehash的时候,如果两个线程同时Rehash,就可能导致链表会形成死循环。(高并发场景想要线程安全可以选用ConcurrentHashMap)
如果此位置上的Node对象是链表节点,则将key和value封装为一个链表的节点并插入到链表中去; 插入到链表后,会判断链表的节点个数是不是超过了8个,如果超过了8个则把当前位置的链表转化为红黑树;
(插入链表使用的是尾插法,所以需要遍历链表,在这个过程中会判断key是否存在,如果存在则更新相应的value。)
(5)链表转成红黑树
随着数据的插入,链表也会越来越长,效率也会越来越低,这时要考虑把结构转成红黑树。当链表的长度大于8时,链表转成红黑树,进行二叉树排序放置。当红黑树的长度小于6的时候,红黑树转成链表,为什么是8和6?
因为链表的时间复杂度是O(n),红黑树的时间复杂度O(logn),虽然红黑树的复杂度是优于链表的,但是在节点少的时候,红黑树所占空间较大,所以只会在节点足够多的时候才会舍弃链表改用红黑树。通常情况下,链表长度很难达到8,但是特殊情况下链表长度为8,哈希表容量又很大,造成链表性能很差的时候,只能采用红黑树提高性能,这是一种应对策略。
(6)判断是否需要扩容数组
最后判断当前HashMap是否超过阈值,如果超过,则进行扩容操作resize。
(5)补充方法
(1)代码块4:putTreeVal【红黑树put值】
/**
* 红黑树的put操作,红黑树插入会同时维护原来的链表属性, 即原来的next属性
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
// 1.查找根节点, 索引位置的头节点并不一定为红黑树的根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
// 2.将根节点赋值给p节点,开始进行查找
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// 3.如果传入的hash值小于p节点的hash值,将dir赋值为-1,代表向p的左边查找树
if ((ph = p.hash) > h)
dir = -1;
// 4.如果传入的hash值大于p节点的hash值, 将dir赋值为1,代表向p的右边查找树
else if (ph < h)
dir = 1;
// 5.如果传入的hash值和key值等于p节点的hash值和key值, 则p节点即为目标节点, 返回p节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// 6.如果k所属的类没有实现Comparable接口 或者 k和p节点的key相等
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 6.1 第一次符合条件, 从p节点的左节点和右节点分别调用find方法进行查找, 如果查找到目标节点则返回
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 6.2 否则使用定义的一套规则来比较k和p节点的key的大小, 用来决定向左还是向右查找
dir = tieBreakOrder(k, pk); // dir<0则代表k<pk,则向p左边查找;反之亦然
}
TreeNode<K,V> xp = p; // xp赋值为x的父节点,中间变量,用于下面给x的父节点赋值
// 7.dir<=0则向p左边查找,否则向p右边查找,如果为null,则代表该位置即为x的目标位置
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 走进来代表已经找到x的位置,只需将x放到该位置即可
Node<K,V> xpn = xp.next; // xp的next节点
// 8.创建新的节点, 其中x的next节点为xpn, 即将x节点插入xp与xpn之间
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
// 9.调整x、xp、xpn之间的属性关系
if (dir <= 0) // 如果时dir <= 0, 则代表x节点为xp的左节点
xp.left = x;
else // 如果时dir> 0, 则代表x节点为xp的右节点
xp.right = x;
xp.next = x; // 将xp的next节点设置为x
x.parent = x.prev = xp; // 将x的parent和prev节点设置为xp
// 如果xpn不为空,则将xpn的prev节点设置为x节点,与上文的x节点的next节点对应
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// 10.进行红黑树的插入平衡调整
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
6.1 第一次符合条件,从 p 节点的左节点和右节点分别调用 find 方法(见代码块2详解)进行查找,如果查找到目标节点则返回
6.2 否则使用定义的一套规则来比较 k 和 p 节点的 key 的大小,用来决定向左还是向右查找,见代码块5详解。
10.进行红黑树的插入平衡调整,见文末的解释2。
(2)代码块5:tieBreakOrder【hashCode相同时进行比较】
// 用于不可比较或者hashCode相同时进行比较的方法, 只是一个一致的插入规则,用来维护重定位的等价性。
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
定义一套规则用于极端情况下比较两个参数的大小。
(3)代码块6:treeifyBin【链表转红黑树】
/**
* 将链表节点转为红黑树节点
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 1.如果table为空或者table的长度小于64, 调用resize方法进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 2.根据hash值计算索引值,将该索引位置的节点赋值给e,从e开始遍历该索引位置的链表
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
// 3.将链表节点转红黑树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
// 4.如果是第一次遍历,将头节点赋值给hd
if (tl == null) // tl为空代表为第一次循环
hd = p;
else {
// 5.如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性
p.prev = tl; // 当前节点的prev属性设为上一个节点
tl.next = p; // 上一个节点的next属性设置为当前节点
}
// 6.将p节点赋值给tl,用于在下一次循环中作为上一个节点进行一些链表的关联操作(p.prev = tl 和 tl.next = p)
tl = p;
} while ((e = e.next) != null);
// 7.将table该索引位置赋值为新转的TreeNode的头节点,如果该节点不为空,则以以头节点(hd)为根节点, 构建红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
7.将 table 该索引位置赋值为新转的 TreeNode 的头节点 hd,如果该节点不为空,则以 hd 为根节点,构建红黑树,见代码块7详解。
(4)代码块7:treeify【构建红黑树】
/**
* 构建红黑树
*/
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
// 1.将调用此方法的节点赋值给x,以x作为起点,开始进行遍历
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next; // next赋值为x的下个节点
x.left = x.right = null; // 将x的左右节点设置为空
// 2.如果还没有根节点, 则将x设置为根节点
if (root == null) {
x.parent = null; // 根节点没有父节点
x.red = false; // 根节点必须为黑色
root = x; // 将x设置为根节点
}
else {
K k = x.key; // k赋值为x的key
int h = x.hash; // h赋值为x的hash值
Class<?> kc = null;
// 3.如果当前节点x不是根节点, 则从根节点开始查找属于该节点的位置
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
// 4.如果x节点的hash值小于p节点的hash值,则将dir赋值为-1, 代表向p的左边查找
if ((ph = p.hash) > h)
dir = -1;
// 5.如果x节点的hash值大于p节点的hash值,则将dir赋值为1, 代表向p的右边查找
else if (ph < h)
dir = 1;
// 6.走到这代表x的hash值和p的hash值相等,则比较key值
else if ((kc == null && // 6.1 如果k没有实现Comparable接口 或者 x节点的key和p节点的key相等
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
// 6.2 使用定义的一套规则来比较x节点和p节点的大小,用来决定向左还是向右查找
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p; // xp赋值为x的父节点,中间变量用于下面给x的父节点赋值
// 7.dir<=0则向p左边查找,否则向p右边查找,如果为null,则代表该位置即为x的目标位置
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 8.x和xp节点的属性设置
x.parent = xp; // x的父节点即为最后一次遍历的p节点
if (dir <= 0) // 如果时dir <= 0, 则代表x节点为父节点的左节点
xp.left = x;
else // 如果时dir > 0, 则代表x节点为父节点的右节点
xp.right = x;
// 9.进行红黑树的插入平衡(通过左旋、右旋和改变节点颜色来保证当前树符合红黑树的要求)
root = balanceInsertion(root, x);
break;
}
}
}
}
// 10.如果root节点不在table索引位置的头节点, 则将其调整为头节点
moveRootToFront(tab, root);
}
3.如果当前节点 x 不是根节点, 则从根节点开始查找属于该节点的位置,该段代码跟代码块2和代码块4的查找代码类似。
8.如果 root 节点不在 table 索引位置的头节点, 则将其调整为头节点,见代码块8详解。
(5)代码块8:moveRootToFront【将root放到头节点的位置】
/**
* 将root放到头节点的位置
* 如果当前索引位置的头节点不是root节点, 则将root的上一个节点和下一个节点进行关联,
* 将root放到头节点的位置, 原头节点放在root的next节点上
*/
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
// 1.校验root是否为空、table是否为空、table的length是否大于0
if (root != null && tab != null && (n = tab.length) > 0) {
// 2.计算root节点的索引位置
int index = (n - 1) & root.hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
// 3.如果该索引位置的头节点不是root节点,则该索引位置的头节点替换为root节点
if (root != first) {
Node<K,V> rn;
// 3.1 将该索引位置的头节点赋值为root节点
tab[index] = root;
TreeNode<K,V> rp = root.prev; // root节点的上一个节点
// 3.2 和 3.3 两个操作是移除root节点的过程
// 3.2 如果root节点的next节点不为空,则将root节点的next节点的prev属性设置为root节点的prev节点
if ((rn = root.next) != null)
((TreeNode<K,V>)rn).prev = rp;
// 3.3 如果root节点的prev节点不为空,则将root节点的prev节点的next属性设置为root节点的next节点
if (rp != null)
rp.next = rn;
// 3.4 和 3.5 两个操作将first节点接到root节点后面
// 3.4 如果原头节点不为空, 则将原头节点的prev属性设置为root节点
if (first != null)
first.prev = root;
// 3.5 将root节点的next属性设置为原头节点
root.next = first;
// 3.6 root此时已经被放到该位置的头节点位置,因此将prev属性设为空
root.prev = null;
}
// 4.检查树是否正常
assert checkInvariants(root);
}
}
4.检查树是否正常,见代码块9详解。
(6)代码块9:checkInvariants
/**
* Recursive invariant check
*/
static <K,V> boolean checkInvariants(TreeNode<K,V> t) { // 一些基本的校验
TreeNode<K,V> tp = t.parent, tl = t.left, tr = t.right,
tb = t.prev, tn = (TreeNode<K,V>)t.next;
if (tb != null && tb.next != t)
return false;
if (tn != null && tn.prev != t)
return false;
if (tp != null && t != tp.left && t != tp.right)
return false;
if (tl != null && (tl.parent != t || tl.hash > t.hash))
return false;
if (tr != null && (tr.parent != t || tr.hash < t.hash))
return false;
if (t.red && tl != null && tl.red && tr != null && tr.red) // 如果当前节点为红色, 则该节点的左右节点不能同时为红色
return false;
if (tl != null && !checkInvariants(tl))
return false;
if (tr != null && !checkInvariants(tr))
return false;
return true;
}
将传入的节点作为根节点,遍历所有节点,校验节点的合法性,主要是保证该树符合红黑树的规则。
(3)put方法【JDK1.7】
- ①如果定位到的数组位置没有元素 就直接插入。
- ②如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的key比较,如果key相同就直接覆盖,不同就采用头插法插入元素。
public V put(K key, V value)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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;
}
(3)1.8版本和1.7版本的区别
(4)hashCode与equals的使用
(1)hashCode()介绍
hashCode的作用是获取哈希码,也叫做散列码,实际上是返回一个int整数,表示的是这个对象在哈希表中的索引位置。hashCode()方法定义在JDK的Object类中,意味着在Java的所有类中都包含hashCode()方法。
散列表存储的是键值对(key-value),它的特点是:能根据key快速的检索出对应的value,这其中就用到了散列码hashCode,可以确定对象在散列表中的位置。
(2)实际案例
HashSet和HashMap中都不允许加入重复值,就是使用hashCode和equals来判断是否重复的。
当你把对象加入HashMap时,会先计算对象hashCode值来判断对象加入的位置,同时也会与该位置其他已经加入的对象的hashCode值作比较,如果hashCode值不相等,HashMap就会认为对象没有重复出现。
但是如果发现有相同hashCode值的对象,就说明有可能出现重复对象,这时还不能确定,再使用equals方法来检查hashCode相等的对象的内容是否相等,如果equals判断的内容也相同,就可以确定两个对象是相同的,后加入的对象会覆盖之前的对象。如果hashCode或者equals不相同,就说明两个对象不相同,就把这个对象散列到其他位置。
有了hashCode提前找到对象地址进行判断,可以大大减少equals判断的次数,执行速度也就提高了。
(3)hashCode()和equals()的相关规定
- 如果两个对象相等,则hashCode一定也是相同的。但是像个对象拥有相同的hashCode也不一定是相等的,还需要用equals进一步确认
- 如果两个对象相等,对两个对象分别调用equals方法都返回true
- 因此重写equals方法时,也必须重写hashCode方法
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
(4)hash函数(减少碰撞冲突)
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过 HashMap 的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。
HashMap 通过 key 的 hashCode 经过扰动函数 hash() 处理过后得到 hash 值,然后通过 (length - 1) & hash 判断当前元素存放的位置(这里的 length 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果hash值和key值相同的话,直接覆盖,不相同就通过拉链法解决冲突。
使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,换句话说使用扰动函数之后可以减少碰撞。
在hash()函数中通过key值获取到元素的hashCode值,然后hashCode值右移16位,把右移后的值与原来的hashCode做异或运算,并返回hash值结果。(其中h>>>16,在JDK1.8中,优化了高位运算的算法,使用了零扩展,无论正数还是负数,都在高位插入0)
在putVal()方法中,取得上面异或返回的hash值和数组长度n,通过(n-1)&hash(hash和15取异或)获取该对象的键在hashmap中的位置。((n-1)&hash就等价于hash%n,且&运算的效率高于%运算)
(1)JDK1.7的 HashMap 的 hash 方法源码
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
(2)JDK 1.8 HashMap 的 hash 方法源码
(1)方法代码
JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(2)方法说明
对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。
在 JDK1.8 的实现中,还优化了高位运算的算法,将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。
(3)案例一(JDK1.8:异或)
下图是一个简单的例子:
当 table 长度为 16 时,table.length - 1 = 15 ,用二进制来看,此时低 4 位全是 1,高 28 位全是 0,与 0 进行 & 运算必然为 0,因此此时 hashCode 与 “table.length - 1” 的 & 运算结果只取决于 hashCode 的低 4 位,在这种情况下,hashCode 的高 28 位就没有任何作用,并且由于 hash 结果只取决于 hashCode 的低 4 位,hash 冲突的概率也会增加。因此,在 JDK 1.8 中,将高位也参与计算,目的是为了降低 hash 冲突的概率。
(4)案例二(JDK1.7:取模)
HashCode: 通过字符串算出它的ascii 码,进行mod(取模),算出哈希表中的下标
将 lies 算出来的ascii 码相加为429,然后除以数组的长度 10 取模,结果为9。为什么取模不直接存储 429了?因为数组不够长,取模可以节省内存空间。
(3)hash函数过程三步走
(1)拿到 key 的 hashCode 值
(2)将 hashCode 的高位参与运算,重新计算 hash 值
(3)将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
(5)get方法
(1)方法代码
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 数组元素相等
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 桶中不止一个节点
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
(2)方法解析
使用 Get 方法根据 Key 来查找 Value
首先会把输入的 Key 做一次 Hash 映射,得到对应的 index:index = Hash(“apple”)
然后找到数组下标为index的位置,如果此时只有一个数那就找到目标了。但是由于刚才所说的 Hash 冲突,同一个位置有可能匹配到多个Node,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是 “apple”
- 我们查看的是头节点 Entry6,Entry6 的 Key是banana,显然不是我们要找的结果。
- 我们查看的是 Next 节点 Entry1,Entry1 的 Key 是 apple,正是我们要找的结果。
之所以把 Entry6 放在头节点,是因为 HashMap 的发明者认为,后插入的 Entry 被查找的可能性更大。
(3)代码块1:getTreeNode
final TreeNode<K,V> getTreeNode(int h, Object k) {
// 1.首先找到红黑树的根节点;2.使用根节点调用find方法
return ((parent != null) ? root() : this).find(h, k, null);
}
(4)代码块2:find
/**
* 从调用此方法的节点开始查找, 通过hash值和key找到对应的节点
* 此方法是红黑树节点的查找, 红黑树是特殊的自平衡二叉查找树
* 平衡二叉查找树的特点:左节点<根节点<右节点
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
// 1.将p节点赋值为调用此方法的节点,即为红黑树根节点
TreeNode<K,V> p = this;
// 2.从p节点开始向下遍历
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
// 3.如果传入的hash值小于p节点的hash值,则往p节点的左边遍历
if ((ph = p.hash) > h)
p = pl;
else if (ph < h) // 4.如果传入的hash值大于p节点的hash值,则往p节点的右边遍历
p = pr;
// 5.如果传入的hash值和key值等于p节点的hash值和key值,则p节点为目标节点,返回p节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null) // 6.p节点的左节点为空则将向右遍历
p = pr;
else if (pr == null) // 7.p节点的右节点为空则向左遍历
p = pl;
// 8.将p节点与k进行比较
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) && // 8.1 kc不为空代表k实现了Comparable
(dir = compareComparables(kc, k, pk)) != 0)// 8.2 k<pk则dir<0, k>pk则dir>0
// 8.3 k<pk则向左遍历(p赋值为p的左节点), 否则向右遍历
p = (dir < 0) ? pl : pr;
// 9.代码走到此处, 代表key所属类没有实现Comparable, 直接指定向p的右边遍历
else if ((q = pr.find(h, k, kc)) != null)
return q;
// 10.代码走到此处代表“pr.find(h, k, kc)”为空, 因此直接向左遍历
else
p = pl;
} while (p != null);
return null;
}
将 p 节点与 k 进行比较。如果传入的 key(即代码中的参数 k)所属的类实现了 Comparable 接口(kc 不为空,comparableClassFor 方法见代码块3详解),则将 k 跟 p 节点的 key 进行比较(kc 实现了 Comparable 接口,因此通过 kc 的比较方法进行比较),并将比较结果赋值给 dir,如果 dir<0 则代表 k<pk,则向 p 节点的左边遍历(pl);否则,向 p 节点的右边遍历(pr)。
(5)代码块3:comparableClassFor
static Class<?> comparableClassFor(Object x) {
// 1.判断x是否实现了Comparable接口
if (x instanceof Comparable) {
Class<?> c; Type[] ts, as; Type t; ParameterizedType p;
// 2.校验x是否为String类型
if ((c = x.getClass()) == String.class) // bypass checks
return c;
if ((ts = c.getGenericInterfaces()) != null) {
// 3.遍历x实现的所有接口
for (int i = 0; i < ts.length; ++i) {
// 4.如果x实现了Comparable接口,则返回x的Class
if (((t = ts[i]) instanceof ParameterizedType) &&
((p = (ParameterizedType)t).getRawType() ==
Comparable.class) &&
(as = p.getActualTypeArguments()) != null &&
as.length == 1 && as[0] == c) // type arg is c
return c;
}
}
}
return null;
}
(6)HashMap扩容resize与Rehash
HashMap的容量是有限的。当经过多次元素插入,数组的空闲位置越来越少,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。
这时候,HashMap需要扩展它的长度,也就是进行Resize。
(1)方法源码
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
注意点:
(1)在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
(2)每次扩展的时候,都是扩展2倍;
(3)扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。
(4)在putVal()中,我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
(1)如果table==null, 则为HashMap的初始化, 生成空table返回即可;
(2)如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
(3)遍历oldTable:
1-首节点为空, 本次循环结束;
2-无后续节点, 重新计算hash位, 本次循环结束;
3-当前是红黑树, 走红黑树的重定位;
4-当前是链表, JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;
(2)扩容的详细过程
(1)影响发生Resize的因素有两个
Capacity:HashMap的当前长度。HashMap的长度是2的幂。
LoadFactor:HashMap负载因子,默认值为0.75f。
(2)扩容的条件
当数组中的实际存放数量HashMap.Size >=阈值threshhold= 容量Capacity * 负载因子LoadFactor时,就表示可以扩容了,其中:threshhold(阈值)=capacity(数组容量,默认16) * loadFactor(负载因子,默认0.75)。也就是说当数组的实际存放量数量达到了 16 * 0.75 = 12 时,就可以开始对数组进行扩容了。
void addEntry(int hash, K key, V value, int bucketIndex) {
//size:The number of key-value mappings contained in this map.
//threshold:The next size value at which to resize (capacity * load factor)
//数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈值
// 2.底层数组的bucketIndex坐标处不等于null
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//扩容之后,数组长度变了
hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢?
bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。
}
createEntry(hash, key, value, bucketIndex);
}
(3)扩容的步骤
HashMap的resize不是简单的把长度扩大,而是经历以下两个步骤
步骤一:扩容(数组长度变大)
创建一个新的Entry空数组,长度是原数组的2倍(32),然后调用ReHash方法。此时要求新数组的长度必须是2的幂数,这样做是为了减少hash函数的冲突次数,实现一个尽量分布均匀的hash函数。
步骤二:ReHash(原来数组中的数据重新放进新的数组)
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
让我们回顾一下Hash公式:index = HashCode(Key) & (Length - 1)
当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。
(4)扩容的详细过程
(1)先看JDK1.7版本
JDK1.7版本的resize方法
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int) (newCapacity * loadFactor);//修改阈值
}
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
static int indexFor(int h, int length) {
return h & (length - 1);
}
newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后(hash(){return key % table.length;}),有可能被放到了新数组的不同位置上。
(2)JDK1.7版本扩容案例图解
假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
(3)JDK1.8版本做了哪些优化
经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
经过rehash之后,元素的位置要么是在原位置,要么是在原位置加原数组长度的位置。
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
//这里很重要,新的位置为原老所处的位置,为什么扩容之后的位置还是原数组位置呢?
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
//这里很重要,新的位置为原老所处的位置+原数组的长度,为什么是这个值呢?
newTab[j + oldCap] = hiHead;
}
那么为什么是这样的呢?这一步,是一个非常巧妙的地方,也是本文分析的重点。
HashMap的数组长度恒定为2的n次方,也就是说只会为16,32,64,128这种数。源码中有限制,也就是说即使你创建HashMap的时候是写的
Map<String,String> hashMap = new HashMap<>(13);
最后数组长度也会变成16,而不是你的13,会取与你传入的数最近的一个2的n次方的数。
那么明确这一点有什么用呢?HashMap中运算数组的位置使用的是leng-1
那么就是对于初始长度为16的数组,扩容之后为32,对应的leng-1就是15,31,他们所对应的二进制为
15:0000 0000 0000 0000 0000 0000 0000 1111
31:0000 0000 0000 0000 0000 0000 0001 1111
【案例一】
现在我们开始做假设,假设某个元素的hashcode为52:
52:0000 0000 0000 0000 0000 0000 0011 0100
这个52与15运算做按位与运算的的结果是4,用二进制表示如下
0000 0000 0000 0000 0000 0000 0011 0100(52)
0000 0000 0000 0000 0000 0000 0000 1111(15)
0000 0000 0000 0000 0000 0000 0000 0100(与结果是4)
这个52与31做按位与运算的的结果是20,用二进制表示如下
0000 0000 0000 0000 0000 0000 0011 0100(52)
0000 0000 0000 0000 0000 0000 0001 1111(31)
0000 0000 0000 0000 0000 0000 0001 0100(与结果是20)
20=4+16(数组新增的长度正好就是16),扩容后这个节点的下标位置移动了16
【案例二】
现在我们开始做假设,假设某个元素的hashcode为100:
100: 0000 0000 0000 0000 0000 0000 0110 0100
100&15=4
0000 0000 0000 0000 0000 0000 0110 0100(100)
0000 0000 0000 0000 0000 0000 0000 1111(15)
0000 0000 0000 0000 0000 0000 0000 0100(与结果是4)
100&31=4
0000 0000 0000 0000 0000 0000 0110 0100(100)
0000 0000 0000 0000 0000 0000 0001 1111(31)
0000 0000 0000 0000 0000 0000 0000 0100(与结果是4)
也就是说对于HashCode为100的元素来说,扩容后与扩容前其所在数组中的下标均为4。位置并没有改变
【案例分析】
通过上面两个案例可以看出来,经过rehash之后,元素的位置要么是在原位置,要么是在原位置加原数组长度的位置。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
可以看到,扩容之后元素的位置是否改变,完全取决于紫色框框中的运算是为0还是1,为0则新位置与原位置相同,不需要换位置,不为零则需要换位置。
而为什么新的位置是原位置+原数组长度,是因为每次扩容会把原数组的长度*2,那么再二进制上的表现就是多出来一个1
比如原数组16-1二进制为0000 1111
那么扩容后的32-1的二进制就变成了0001 1111
再次扩容64-1就是0011 1111
扩容之后元素的位置是否改变则取决于与这个多出来的1的运算结果,运算结果为0则不需要换位置,运算结果为1则换新位置,新位置为老位置的高位进1,比如对于上诉52来说,老位置为0100,新位置为10100,而每一次高位进1都是在加上原数组长度的过程。
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。
【案例总结】
扩容后节点的数组下标位置要么是原位置,要么是原位置+原数组长度的位置。是由于每次扩容相当于数组长度的高位多了一个1,所以只需要看扩容后的运算结果高位是1还是0就行了,扩容前的不用看,因为扩容后数组的二进制才会多出来1,只需要看多出来的那一位即可。新的hash运算取决于hashCode在这一位上的值是0还是1,如果是0则无需变化位置,如果是1则位置为原位置+原数组长度的位置
(3)扩容的补充提问问题
(1)ReHash的Java代码如下:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//求索引
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
(2)HashMap的默认长度是16 ,自动扩展或初始化时,长度必须是2的幂
(1)目的:服务于从Key映射到index的Hash算法
之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数:index = Hash(“apple”)
(2)如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。
Hash算法的实现采用了位运算的方式
如何进行位运算呢?有如下的公式(Length是HashMap的长度):index = HashCode(Key) & (Length -1)
(3)下面我们以值为“book”的 Key 来演示整个过程:
- 计算 book 的 hashcode,结果为十进制的 3029737,二进制的101110001110101110 1001。
- 假定 HashMap 长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
- 把以上两个结果(Length-1和book的hashcode)做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。(与运算:和,同位上都为1则为1,否则为0
可以说,Hash 算法最终得到的 index 结果,完全取决于 Key 的 Hashcode 值的最后几位。
(3)为什么长度必须是2的幂?
(1)假设 HashMap 的长度是10,重复刚才的运算步骤:
(2)单独看这个结果,表面上并没有问题。我们再来尝试一个新的 HashCode 1011100011101011101011 :
(3)让我们再换一个 HashCode101110001110101110 1111 试试 :
(4)分析结果
虽然 HashCode 的倒数第二第三位从0变成了1,但是运算的结果都是1001。
也就是说,当 HashMap 长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则。
反观长度16或者其他2的幂,Length-1 的值是所有二进制位全为1,比如15是1111,7是111,这种情况下,index 的结果等同于 HashCode 后几位的值。
只要输入的 HashCode 本身分布均匀,Hash 算法的结果就是均匀的。
(4)HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
hashCode()方法返回的是int整数类型,其范围为 -(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值) ~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
解决方案:
(1)HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
(2)在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;
(五)HashMap常用方法测试
package map;
import java.util.Collection;
import java.util.HashMap;
import java.util.Set;
public class HashMapDemo {
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<String, String>();
// 键不能重复,值可以重复
map.put("san", "张三");
map.put("si", "李四");
map.put("wu", "王五");
map.put("wang", "老王");
map.put("wang", "老王2");// 老王被覆盖
map.put("lao", "老王");
System.out.println("-------直接输出hashmap:-------");
System.out.println(map);
/**
* 遍历HashMap
*/
// 1.获取Map中的所有键
System.out.println("-------foreach获取Map中所有的键:------");
Set<String> keys = map.keySet();
for (String key : keys) {
System.out.print(key+" ");
}
System.out.println();//换行
// 2.获取Map中所有值
System.out.println("-------foreach获取Map中所有的值:------");
Collection<String> values = map.values();
for (String value : values) {
System.out.print(value+" ");
}
System.out.println();//换行
// 3.得到key的值的同时得到key所对应的值
System.out.println("-------得到key的值的同时得到key所对应的值:-------");
Set<String> keys2 = map.keySet();
for (String key : keys2) {
System.out.print(key + ":" + map.get(key)+" ");
}
/**
* 如果既要遍历key又要value,那么建议这种方式,应为如果先获取keySet然后再执行map.get(key),map内部会执行两次遍历。
* 一次是在获取keySet的时候,一次是在遍历所有key的时候。
*/
// 当我调用put(key,value)方法的时候,首先会把key和value封装到
// Entry这个静态内部类对象中,把Entry对象再添加到数组中,所以我们想获取
// map中的所有键值对,我们只要获取数组中的所有Entry对象,接下来
// 调用Entry对象中的getKey()和getValue()方法就能获取键值对了
Set<java.util.Map.Entry<String, String>> entrys = map.entrySet();
for (java.util.Map.Entry<String, String> entry : entrys) {
System.out.println(entry.getKey() + "--" + entry.getValue());
}
/**
* HashMap其他常用方法
*/
System.out.println("after map.size():"+map.size());
System.out.println("after map.isEmpty():"+map.isEmpty());
System.out.println(map.remove("san"));
System.out.println("after map.remove():"+map);
System.out.println("after map.get(si):"+map.get("si"));
System.out.println("after map.containsKey(si):"+map.containsKey("si"));
System.out.println("after containsValue(李四):"+map.containsValue("李四"));
System.out.println(map.replace("si", "李四2"));
System.out.println("after map.replace(si, 李四2):"+map);
}
}
(六)补充:红黑树
二叉查找树
学习红黑树之前先了解一下二叉查找树
(1)二叉查找树的特性:
1-左子树上所有结点的值均小于或等于它的根结点的值
2-右子书上所有结点的值均大于或等于它的根结点的值
3-左、右子树也分别为二叉排序树
(2)二叉查找树的缺点:
当你插入节点的时候就会发现它的缺陷,如果插入的值是递减的7654321,因为越来越小,所以不断的往左边子树插,这样左边的单条越来越长,就变成了一个瘸子,也就是失去了平衡性,这样查找的时候比较的次数就会大大增加
也就是说如果使用二叉查找树插入的是一个有序序列,那么二叉排序树就会退化成一个链表。所以红黑树可以让树尽可能的保持平衡,降低树的高度,树的查找性取决于树的高度。
(3)二叉查找树的改进:
为了解决二叉查找树多次插入新节点而导致的不平衡问题,于是引进了【红黑树】
红黑树是一种自平衡的二叉查找树,除了符合二叉查找树的基本特性,还会有一些新增的附加特性
红黑树
(1)红黑树的特性:
1-节点是红色或者黑色
2-根节点是黑色的
3-每个叶子节点都是黑色的空节点(NIL节点)
4-每个红色节点的两个子节点都是黑色的(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5-从任意一个节点到它的每个叶子的所有路径都包含相同数目的黑色节点
当插入或者删除节点的时候,红黑树的规则就有可能会被打破。这时候就会做出一个调整,比如左旋、右旋等等,改变原来红黑树的结构,从而重新满足红黑树的规则,这样也就能让红黑树保持在一个平衡二叉树的状态
什么情况下会破坏红黑树的规则,什么情况下不会破坏规则:
1-不会破坏红黑树的规则的情况
向原来的红黑树插入值为14的新节点,因为父节点15是黑色节点,所以这种情况并不会破坏红黑树的规则,就不需要做出任何调整
2-会破坏红黑树的规则的情况
向原来红黑树插入值为21的新节点,因为父亲节点22是红色节点,因此这种情况打破了红黑树的规则4每个红色节点的两个子节点都是黑色,所以必须做出调整,使这个树重新符合红黑树的规则
当红黑树的规则被破坏之后,应该用什么方法来调整:
调整的方法有两个:【变色】和【旋转】,旋转分为两种【左旋转】和【右旋转】
变色:
为了重新符合红黑树的规则,尝试把红色节点变为黑色,或者把黑色节点变成红色
因为节点21和节点22连续出现了红色,不符合规则4,所以把节点22从红色变成黑色
变完色以后发现这个黑色的27节点打破了规则5,所以发生连锁反应,需要继续把节点25从黑色变成红色
此时仍然没有结束,因为节点25和节点27又形成了两个连续的红色节点,所以需要继续把节点27从红色变成黑色
左旋转:
逆时针(向左)旋转红黑树的两个节点,使父节点被自己的右孩子取代,而自己变成左孩子
右旋转:
顺时针旋转红黑树的两个节点,使得父节点被自己的左孩子取代,自己成为右孩子
红黑树的典型例子:
红黑树的插入和删除包含很多种情况,每一种情况都有不同的处理方式
以刚才插入节点21的情况为例
先变色,把25和它下面的子节点变色
此时节点17和节点25是连续的两个红色节点,不能再接着把17变色了,再变的话13也得变。接下来开始使用【旋转】了
对根节点使用左旋转
根节点必须为黑色,所以接着变色,17变成黑色
然后再对节点13进行一次右旋转,使它的左右平衡
最后根据规则再来变色,把所有不符合规则的颜色变好
总结一下:思路就是刚添加或者删除的时候需要变色一次,变完以后要通过旋转把左右两边的子树变成平衡的,哪边少就往哪边转,当平衡性解决以后再统一的把颜色变成符合规则的
变色->左旋转->变色->右旋转->变色
红黑树在哪些地方被实际应用到:
- JDK的集合类TreeMap和TreeSet底层就是红黑树实现的
- 在java8里,HashMap也在原来的“数组+链表”的结构上加上了红黑树,变成了“数组+链表+红黑树”的结构
- Linux虚拟内存的管理
(七)通过问题梳理关键知识点
(1)介绍HashMap
在JDK中,HashMap底层早期1.7版本之前的数据结构是“数组+链表”,在1.8版本之后的数据结构改成了“数组+链表+红黑树”。
在HashMap通过key值确定唯一的value,在调用put方法存入值的时候,会通过字符串ASIIC码相加的和计算出hashCode的值,但是hashCode的值太大了不可以直接作为下标存入数组,所以可以采用取模的形式除以数组长度取余数来确定在数组中存放的下标位置,在1.8版本后改进成效率更高的与或运算方式计算数组下标位置。
得到数组下标位置后,如果该位置为空的话就可以直接把Node节点放入,如果该位置不为空的话,也就是出现了哈希冲突,就会使用尾插法把新的Node节点插入到单向链表的尾部,因为HashMap不是线程安全的,所以当多线程高并发的时候,如果使用头插法可能会在扩容的时候造成死循环形成环形链表。
为了解决链表越长查询效率越慢的问题,当链表的长度超过8的时候,先判断数组的长度是否大于64,如果没有大于64就优先resize对数组进行扩容,先不用把链表转成红黑树,当数组长度大于64且链表长度大于8的时候才会把链表转成红黑树的形式。当链表长度小于6的时候会把红黑树转成链表。
随着整体的键值对增多,当实际存放数量HashMap.Size >=阈值threshhold= 数组容量Capacity(默认16) * 负载因子LoadFactor(默认0.75) 时,也就是数量超过12的时候就要对数组进行扩容处理了。
扩容的时候每次都是原来长度的2倍。创建一个新的Entry空数组,长度是原数组的2倍(32)。此时要求新数组的长度必须是2的幂数,这样做是为了减少hash函数的冲突次数,实现一个尽量分布均匀的hash函数。遍历原Entry数组,在JDK1.7版本中会调用Rehash方法把所有的Entry节点重新计算Hash值取模得数组下标,然后存到新数组,因为随着数组长度的改变,计算数组的下标的规则也会改变,所以每个节点都要重新计算。而在JDK1.8版本中进行了优化,通过【table.length-1&hashCode】的方式计算结果,由于每次扩容相当于数组长度的高位多了一个1,新的hash运算取决于【table.length-1&hashCode】结果在这一位上的值是0还是1,如果是0则无需变化位置,如果是1则位置为原位置+原数组长度的位置。
(2)描述链表和红黑树的互相转换
为了解决链表越长查询效率越慢的问题,当链表的长度超过8的时候,先判断数组的长度是否大于64,如果没有大于64就优先resize对数组进行扩容,先不用把链表转成红黑树,当数组长度大于64且链表长度大于8的时候才会把链表转成红黑树的形式。当链表长度小于6的时候会把红黑树转成链表。
(3)为什么不用B树B+树等
(4)JDK1.8版本相比于JDK1.7版本有哪些优化点?
(八)ConcurrentHashMap
【1】HashMap会出现什么样的线程安全问题?
(1)JDK1.7版本resize扩容时线程安全问题
JDK7版本中的HashMap扩容时使用头插法,假设此时有元素一指向元素二的链表,当有两个线程使用HashMap扩容的时,若线程一在迁移元素时阻塞,但是已经将指针指向了对应的元素,线程二正常扩容,因为使用的是头插法,迁移元素后将元素二指向元素一。此时若线程一被唤醒,在现有基础上再次使用头插法,将元素一指向元素二,形成循环链表。若查询到此循环链表时,便形成了死锁。而JDK8版本中的HashMap在扩容时保证元素的顺序不发生改变,就不再形成死锁,但是注意此时HashMap还是线程不安全的。
JDK1.7版本在数组扩容的时候可能出现循环链表的情况,这个问题在JDK1.8版本的时候得到了解决。
(2)JDK1.8版本多线程put方法哈希冲突时覆盖问题
假如有两个线程A和B,A希望插入一个key-value到HashMap中,首先会通过A的key得到桶的索引坐标,然后获取该桶的链表头结点,线程A的时间片用完,而此时B线程被调用执行,和线程A一样执行,只不过线程B成功的将数据插入到桶里面。假设线程A插入时候计算的坐标和B线程要插入的索引坐标是一致的,那么当B线程成功插入以后,线程A再次被调用运行的时候,它依然持有原来的链表头,但是它对B线程插入的过程一无所知,那么线程A就会对此坐标上的数据进行覆盖,那么线程B插入的数据就会消失,造成数据不一致的行为。
(3)JDK1.8版本多线程扩容时覆盖问题
在多线程环境下,假设有容器map,其存储的情况如下图所示(淡蓝色为已有数据)。
此时的map已经达到了扩容阈值12(16 * 0.75 = 12),而此时线程A与线程B同时对map容器进行插入操作,那么都需要扩容。此时可能出现的情况如下:线程A与线程B都进行了扩容,此时便有两个新的table,那么再赋值给原先的table变量时,便会出现其中一个newTable会被覆盖,假如线程B扩容的newTable覆盖了线程A扩容的newTable,并且是在A已经执行了插入操作之后,那么就会出现线程A的插入失效问题,也即是如下图中的两个table只能有一个会最后存在,而其中一个插入的值会被舍弃的问题。
【2】Hashtable是怎么解决线程安全问题的?有哪些问题?
通过在put等方法上加synchronized修饰,保证每个线程想调用put添加值的时候必须先获取锁,其他线程阻塞。
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
因为synchronized锁的粒度很大,导致Hashtable的性能很低。上面描述的哈希冲突时覆盖节点,和数组扩容时覆盖数组问题虽然都可以通过加锁解决了,但是大量的正常put操作也被阻塞了,即使在不扩容且没有哈希冲突的时候,也会出现线程阻塞,就导致方法执行的效率很低。
在使用HashTable或者Collection.synchronizedMap,这两者有着共同的问题,那就是性能问题。Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,无论读操作还是写操作,它们都会给整个集合进行加锁,导致同一时间内其他的操作线程只能进入阻塞状态等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!如果以整个容器为一个资源进行锁定,那么就变为了串行操作。而根据hash表的特性,具有冲突的操作只会出现在同一槽位,而与其它槽位的操作互不影响。
基于此种判断,那么就可以将资源锁粒度缩小到槽位上,这样热点一分散,冲突的概率就大大降低,并发性能就能得到很好的增强。
【3】ConcurrentHashMap的数据结构
(1)JDK1.7版本
ConcurrentHashMap由多个 Segment 组合而成。Segment 本身就相当于一个 HashMap 对象。同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是上面的提到的锁分离技术,而每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap的数据存储结构一样。
单一的 Segment 结构如下:
像这样的 Segment 对象,在 ConcurrentHashMap 集合中有多少个呢?有 2 的 N 次方个,共同保存在一个名为 segments 的数组当中。因此整个ConcurrentHashMap的结构如下:
可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。它的核心属性:
其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。Segment是它的一个内部类,主要组成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
// 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
// ...
}
存放元素的 HashEntry也是一个内部类,主要组成如下:
和 HashMap 的 Entry 基本一样,唯一的区别就是其中的核心数据如 value ,以及链表都是 volatile 修饰的,保证了获取时的可见性。
(2)JDK1.8版本
JDK1.8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,抛弃了原有的 Segment 分段锁,并发控制使用Synchronized和CAS来操作实现更加细粒度的锁,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。
JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?
(1)在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
(2)减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
【4】ConcurrentHashMap的方法
(1)put方法
(1)代码内容
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
(2)get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}