Hashmap引入
类定义
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
hash
Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
两个数有相同的hash值,但是他们不一定相同。两个数的hash值不同,那么他们一定不相同。
两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
优点
先分类再查找,通过计算缩小范围,加快查找速度。
例如:
集合:{3,9,8,12,16}
如果想要查找16,那就需要遍历整个集合,时间复杂度为O(n)
如果使用了hash
比如hash函数为H(key)=key%5;
集合的hash值为{3,4,3,2,1}
那么查找16只需要根据hash后的值1去1号桶查找即可,如果1号桶只有16那么查找效率就是O(1)
散列函数
常见的几种Hash函数:直接定址法、数字分析法、平方取中法、折叠法、随机数法、除留余数法
1)直接定址法:取Key或者Key的某个线性函数值为散列地址。Hash(k) = k,或者Hash(k) = a*k + b, (a\b均为常数).
2)数字分析法:需要知道Key的集合,并且Key的位数比地址位数多,选择Key数字分布均匀的位。
Hash(Key) 取六位:
其中(2、4、6、7、9、13) 这6列数字无重复,分布较均匀,取此六列作为Hash(Key)的值。
Hash(Key1) :225833
Hash(Key2):487741
Hash(Key3):138562
Hash(Key4):342554
3)平方取中法:取Key平方值的中间几位作为Hash地址。因为在设置散列函数时不一定知道所有关键字,选取哪几位不确定。一个数的平方的中间几位和数本身的每一位都有关,这样可以使随机分布的Key,得到的散列地址也是随机分布的 。如下:
4)折叠法:将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。 当Key的位数较多的时候数字分布均匀适合采用这种方案.
具体的叠加方法有两种,移位法和折叠法:
例子:若Key为下列数串,地址位数为7,两种方法的Hash(key)分别如下:
Key:7982374 | 1861215 | 9892154 | 56923
5)随机数法:伪随机探测再散列
具体实现:建立一个伪随机数发生器,Hash(Key) = random(Key). 以此伪随机数作为哈希地址。
6)除留余数法:取关键字被某个除数 p 求余,得到的作为散列地址。
即 H(Key) = Key % p;
哈希冲突
不同Key值对应同一个Hash地址的情况,这种情况叫做哈希冲突。
解决方法:
1)开放定址法:
当冲突发生时,探测其他位置是否有空地址 (按一定的增量逐个的寻找空的地址),将数据存入。根据探测时使用的增量的取法,分为:线性探测、平方探测、伪随机探测等。
新的Hash地址函数为 Hash_new (Key) = (Hash(Key) + d i) mod m;i = 1,2…k (k<= m-1).m表示集合的元素数,i表示已经探测的次数。
-
线性探测(Linear Probing)
d i = a * i + b; a\b为常数。
相当于逐个探测地址列表,直到找到一个空置的,将数据放入。
-
平方探测(Quadratic Probing)
d i = a * i ^ 2 (i <= m/2) m是Key集合的总数。a是常数。
探测间隔 i^2 个单元的位置是否为空,如果为空,将地址存放进去。
-
伪随机探测
d i = random(Key);
探测间隔为一个伪随机数。
2)链表法
将散列到同一个位置的所有元素依次存储在单链表中,或者存储在栈中。具体实现根据实际情况决定这些元素的数据存储结构。
首页Hash地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。T 中各分量的初值均应为空指针。
在拉链法中,装填因子 α 可以大于 1,但一般均取 α ≤ 1。
装填因子(载荷因子)
散列表的载荷因子定义为:
α = 填入表中的元素个数 / 散列表的长度.
α 是散列表装满程度的标志因子。由于表长是定值,α 与“填入表中的元素个数”成正比,所以,α 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,α 越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α 的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
参考自:Hash详解
jdk1.7
HashMap的成员变量
int DEFAULT_INITIAL_CAPACITY = 16:默认的初始容量为16
int MAXIMUM_CAPACITY = 1 << 30:最大的容量为 2 ^ 30
float DEFAULT_LOAD_FACTOR = 0.75f:默认的加载因子为 0.75f
Entry< K,V>[] table:Entry类型的数组,HashMap用这个来维护内部的数据结构,它的长度由容量决定
int size:HashMap的大小
int threshold:HashMap的极限容量,扩容临界点(容量和加载因子的乘积
HashMap的四个构造函数
public HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap
public HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap
public HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap
public HashMap(Map< ? extends K, ? extends V> m):构造一个映射关系与指定 Map 相同的新 HashMa
entry的结构
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
计算hash值
要进行4次位运算 + 5次异或预算(9次扰动)
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
jdk7的hashmap是先扩容再添加元素
扩容要同时满足两个条件
- 存放新值的时候当前已有元素的个数必须大于等于阈值
- 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)
因为上面这两个条件,所以存在下面这些情况
(1)、就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
(2)、当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。
死循环问题
采用头插法添加结点,多线程并发扩容造成死循环:
先看源码
//先看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);
// 如果key已经存在,则替换value,并返回旧值
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;
//检查容量是否到达阈值,到达了要先扩容,把原来的元素移到新map中
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
//实现扩容
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
//对每个元素重新进行hash,将元素移到新map中
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;
}
}
}
假设插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组
假设线程2 在执行到Entry < K,V > next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。
线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点
第一步,移动节点a
第二步,移动节点b
注意,这里的顺序是反过来的,继续移动节点c
这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。
参考自:老生常谈,HashMap的死循环【基于JDK1.7】
jdk1.8
数据结构变成数组+链表+红黑树
结点名称改为Node(实现仍是一样的)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希
final K key; // key
V value; // 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;
}
红黑树结点
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;
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;
}
}
常用API
V get(Object key); // 获得指定键的值
V put(K key, V value); // 添加键值对
void putAll(Map<? extends K, ? extends V> m); // 将指定Map中的键值对 复制到 此Map中
V remove(Object key); // 删除该键值对
boolean containsKey(Object key); // 判断是否存在该键的键值对;是 则返回true
boolean containsValue(Object value); // 判断是否存在该值的键值对;是 则返回true
Set<K> keySet(); // 单独抽取key序列,将所有key生成一个Set
Collection<V> values(); // 单独value序列,将所有value生成一个Collection
void clear(); // 清除哈希表中的所有键值对
int size(); // 返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对
boolean isEmpty(); // 判断HashMap是否为空;size == 0时 表示为空
示例代码:
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
public class HashMapTest {
public static void main(String[] args) {
/**
* 1. 声明1个 HashMap的对象
*/
Map<String, Integer> map = new HashMap<String, Integer>();
/**
* 2. 向HashMap添加数据(成对 放入 键 - 值对)
*/
map.put("Android", 1);
map.put("Java", 2);
map.put("iOS", 3);
map.put("数据挖掘", 4);
map.put("产品经理", 5);
/**
* 3. 获取 HashMap 的某个数据
*/
System.out.println("key = 产品经理时的值为:" + map.get("产品经理"));
/**
* 4. 获取 HashMap 的全部数据:遍历HashMap
* 核心思想:
* 步骤1:获得key-value对(Entry) 或 key 或 value的Set集合
* 步骤2:遍历上述Set集合(使用for循环 、 迭代器(Iterator)均可)
* 方法共有3种:分别针对 key-value对(Entry) 或 key 或 value
*/
// 方法1:获得key-value的Set集合 再遍历
System.out.println("方法1");
// 1. 获得key-value对(Entry)的Set集合
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
// 2. 遍历Set集合,从而获取key-value
// 2.1 通过for循环
for(Map.Entry<String, Integer> entry : entrySet){
System.out.print(entry.getKey());
System.out.println(entry.getValue());
}
System.out.println("----------");
// 2.2 通过迭代器:先获得key-value对(Entry)的Iterator,再循环遍历
Iterator iter1 = entrySet.iterator();
while (iter1.hasNext()) {
// 遍历时,需先获取entry,再分别获取key、value
Map.Entry entry = (Map.Entry) iter1.next();
System.out.print((String) entry.getKey());
System.out.println((Integer) entry.getValue());
}
// 方法2:获得key的Set集合 再遍历
System.out.println("方法2");
// 1. 获得key的Set集合
Set<String> keySet = map.keySet();
// 2. 遍历Set集合,从而获取key,再获取value
// 2.1 通过for循环
for(String key : keySet){
System.out.print(key);
System.out.println(map.get(key));
}
System.out.println("----------");
// 2.2 通过迭代器:先获得key的Iterator,再循环遍历
Iterator iter2 = keySet.iterator();
String key = null;
while (iter2.hasNext()) {
key = (String)iter2.next();
System.out.print(key);
System.out.println(map.get(key));
}
// 方法3:获得value的Set集合 再遍历
System.out.println("方法3");
// 1. 获得value的Set集合
Collection valueSet = map.values();
// 2. 遍历Set集合,从而获取value
// 2.1 获得values 的Iterator
Iterator iter3 = valueSet.iterator();
// 2.2 通过遍历,直接获取value
while (iter3.hasNext()) {
System.out.println(iter3.next());
}
}
}
// 注:对于遍历方式,推荐使用针对 key-value对(Entry)的方式:效率高
// 原因:
// 1. 对于 遍历keySet 、valueSet,实质上 = 遍历了2次:1 = 转为 iterator 迭代器遍历、2 = 从 HashMap 中取出 key 的 value 操作(通过 key 值 hashCode 和 equals 索引)
// 2. 对于 遍历 entrySet ,实质 = 遍历了1次 = 获取存储实体Entry(存储了key 和 value )
运行结果
方法1
Java2
iOS3
数据挖掘4
Android1
产品经理5
----------
Java2
iOS3
数据挖掘4
Android1
产品经理5
方法2
Java2
iOS3
数据挖掘4
Android1
产品经理5
----------
Java2
iOS3
数据挖掘4
Android1
产品经理5
方法3
2
3
4
1
5
添加元素采用尾插法,避免了头插法导致的扩容死循环问题,但是仍无法避免多线程带来的安全性问题。因为多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
put源码
//计算初始容量,通过无符号右移,比如6,二进制110,每次右移后进行或运算。那么最后得到的是111,就是7,然后cap即为8
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//put接口
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//计算hash,先计hashcode,然后高低十六位进行异或(减少hash碰撞)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果没有初始化就先初始化,所以hashmap是懒加载
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果桶的元素直接命中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//判断 table[i]的元素的key是否与 需插入的key一样,若相同则 直接用新value 覆盖 旧value
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) {
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//发现key已存在,直接用新value 覆盖 旧value & 返回旧value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//fail-fast机制
++modCount;
//如果元素个数大于阈值就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
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;
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
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;
}
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
/**
* 插入树形类型的元素
* Tree version of putVal.
*
* 操作:将插入节点的hash值与每个节点的hash值进行比较,
* 如果是小于那下一次插入节点就与当前节点的左子节点比,反之则与右子节点比,
* 直到当前节点的(左或右)子节点为null,将其插入;
* 每当插入一次节点都会调用一次方法balanceInsertion(root, x)将红黑树进行一个平衡操作
*/
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;
//获取树的根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
//h:插入节点的hash值
//p:遍历到的当前节点
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
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;
}
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//balanceInsertion(root, x):此方法是对红黑树进行平衡操作
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
/**
* 插入元素 平衡红黑树的方法
*
* 注:不管遇到一下三种情况任意一种情况就会进行平衡调整 ,反之不需要;如果x节点是第1种情况,必然会经历2,3;如果x节点是第3种情况不会经历1,2;
*
* 1. 插入节点的父节点和其叔叔节点(祖父节点的另一个子节点)均为红色的;
*
* 2. 插入节点的父节点是红色,叔叔节点是黑色,且插入节点是其父节点的右子节点;
*
* 3. 插入节点的父节点是红色,叔叔节点是黑色,且插入节点是其父节点的左子节点。
*
* @param root
* @param x
* @return
*/
static <K, V> TreeNode<K, V> balanceInsertion(TreeNode<K, V> root, TreeNode<K, V> x) {
//将插入的节点涂成红色
x.red = true;
//此时x节点是刚插入的节点
// 这些变量名不是作者随便定义的都是有意义的。
// xp:x parent,代表x的父节点。
// xpp:x parent parent,代表x的祖父节点
// xppl:x parent parent left,代表x的祖父的左节点。
// xppr:x parent parent right,代表x的祖父的右节点。
for (TreeNode<K, V> xp, xpp, xppl, xppr;;) {
//如果x.parent==null证明x是根节点,并将x(根节点)返回;平衡完毕。
if ((xp = x.parent) == null) {
//将根节点涂成黑色
x.red = false;
return x;
//只要进入此if就不会满足注释中3条件的任意一个,直接将root返回;平衡完毕。
} else if (!xp.red || (xpp = xp.parent) == null)
return root;
//若父节点是祖父节点的左子节点,与下面的完全相反,本质是一样的
if (xp == (xppl = xpp.left)) {
//x节点的祖父节点的右子节点(叔叔节点)不为null且是红色,父节点必然也是红色,此时满足第1种情况。
//操作:1.将祖父节点的右子节点(叔叔节点)、父节点涂为黑色
// 2.将祖父节点涂为红色
// 3.将祖父节点赋给x(参照节点的变更)
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
//进入else 说明已经是第2或第3种情况了
} else {
//第2种情况
// 操作:1.标记节点变为x。
// 2.左旋
// 3.x的父节点、x的祖父节点随之变化
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
//第3种情况
//操作 1.将父节点涂黑
// 2.祖父节点涂红
// 3.右旋
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
} else {
//若父节点是祖父节点的右子节点,与上面的完全相反,本质一样的
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
} else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
get源码
// 根据key查询对应的val
public V get(Object key) {
// 定义一个node节点
Node<K,V> e;
// 根据key获取的节点为null返回这个null,否则获取的节点不为null,返回这个节点对应的值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 根据指定的key和val获取节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果哈希表不为空并且key对应的桶上不为空
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) {
// 判断是否是红黑树,是的话调用红⿊树中的getTreeNode方法获取节点
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 返回这个节点
return e;
} while ((e = e.next) != null);
}
}
// 未获取到节点返回null
return null;
}
// 参数h为哈希值,参数k为指定的key
final TreeNode<K,V> getTreeNode(int h, Object k) {
// ((parent != null) ? root() : this)获取跟节点
// 从跟节点开始查找指定的key
return ((parent != null) ? root() : this).find(h, k, null);
}
// h:哈希值。 k:给定的key
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
// 首次遍历this是桶里面的第一个节点
TreeNode<K,V> p = this;
// 循环遍历这棵红黑树
do {
int ph, dir; K pk;
// pl当前节点p的左孩子,pr当前节点p的右孩子
TreeNode<K,V> pl = p.left, pr = p.right, q;
// 将当前节点的哈希值赋值给ph,判断给定的哈希值是否小于当前节点的哈希值
if ((ph = p.hash) > h)
// 将左孩子赋值给p
p = pl;
// 当前节点的哈希值小于给定的哈希值
else if (ph < h)
// 将右孩子赋值给p
p = pr;
// 判断当前节点的key赋值给pk,并且pk与给定的k相等
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
// 找到之后直接返回
return p;
// 判断左孩子是否null
else if (pl == null)
// 将右孩子赋值给p
p = pr;
// 判断右孩子pr是否为null
else if (pr == null)
// 将左孩子pl赋值给p
p = pl;
// 经过compare计算出dir
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
// 如果计算出的dir小于0,将pl左孩子赋值给p,否则将pr右孩子赋值给p
p = (dir < 0) ? pl : pr;
// 递归查找,查找到结果赋值给q
else if ((q = pr.find(h, k, kc)) != null)
// 返回q
return q;
else
// 将左孩子赋值给p
p = pl;
// 判断p是否为空,不为空接着循环
} while (p != null);
// 没有获取到节点直接返回
return null;
}
1、通过hash值获取该key映射到的桶
2、桶上的key就是要查找的key,则直接找到并返回
3、桶上的key不是要找的key,则查看后续的节点:
- 如果后续节点是红黑树节点,通过调用红⿊树的方法根据key获取value
- 如果后续节点是链表节点,则通过循环遍历链表根据key获取value
4、查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更更高。
5、这⾥和插⼊时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等就直接返回。不相等就从⼦树中递归查找。
- 若为树,则在树中通过key.equals(k)查找,O(logn)
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
resize源码
当 HashMap 中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor 的默认值是 0.75。
HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位,所以结点要么就在原来的位置,要么就被分配到 “原位置 + 旧容量” 这个位置。
final Node<K,V>[] resize() {
// oldTab:表示扩容前的哈希表数组
Node<K,V>[] oldTab = table;
// oldCap:表示扩容之前table数组长度
// 如果当前哈希表数组等于null 长度返回0,否则返回当前哈希表数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// oldThr:表示扩容之前的阀值(触发本次扩容的阈值) 默认是12(16*0.75)
int oldThr = threshold;
// newCap:扩容之后的table散列表数组长度
// newThr: 扩容之后,下次再出发扩容的条件(新的扩容阈值)
int newCap, newThr = 0;
// 如果老的哈希表数组长度oldCap > 0
// 如果该条件成立,说明hashMap 中的散列表数组已经初始化过了,是一次正常扩容
// 开始计算扩容后的大小
if (oldCap > 0) {
// 扩容之前的table数组大小已经达到 最大阈值后,则不再扩容
// 且设置扩容条件为:int的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 修改阈值为int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 扩容之前的table数组大小没超过最大值,则扩充为原来的2倍
// (newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
// oldCap >= DEFAULT_INITIAL_CAPACITY 原哈希表数组长度大于等于数组初始化长度16
// 如果oldCap 小于默认初始容量16,比如传入的默认容量为8,则不执行下面代码
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新的扩容阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
// 如果老的哈希表数组长度oldCap == 0
// 说明hashMap中的散列表还没有初始化,这时候是null
// 如果老阈值oldThr大于0 直接赋值
/*
以下三种情况会直接进入该判断:(即,这时候oldThr扩容阈值已存在)
1.new HashMap(initCap,loadFactor);
2.new HashMap(initCap);
3.new HashMap(Map);// 这个传入的map中已经有数据
*/
else if (oldThr > 0) // 老阈值赋值给新的数组长度
newCap = oldThr;
// 如果老的哈希表数组长度oldCap == 0
// 说明hashMap中的散列表还没有初始化,这时候是null
// 此时,老扩容阈值oldThr == 0
else { // 直接使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
// 如果执行到这个位置新的扩容阈值newThr还没有得到赋值,则
// 需要计算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将新的阀值newThr赋值给threshold
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 创建新的散列表
// newCap是新的数组长度---> 32
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 说明:hashMap本次扩容之前,table不为null
if (oldTab != null) {
// 把每个bucket桶的数据都移动到新的散列表中
// 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
for (int j = 0; j < oldCap; ++j) {
// 当前node节点
Node<K,V> e;
// 说明:此时的当前桶位中有数据,但是数据具体是
// 1.单个数据 、 2.还是链表 、 3.还是红黑树 并不能确定
if ((e = oldTab[j]) != null) {
// 原来的数据赋值为null 便于GC回收
oldTab[j] = null;
// 第一种情况:判断数组是否有下一个引用(是否是单个数据)
if (e.next == null)
// 没有下一个引用,说明不是链表,
// 当前桶上只有单个数据的键值对,
// 可以将数据直接放入新的散列表中
// e.hash & (newCap - 1) 寻址公式得到的索引结果有两种:
// 1.和原来旧散列表中的索引位置相同,
// 2.原来旧散列表中的索引位置i + 旧容量oldCap
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;
// 高位链表:扩容之后数组的下标位置等于
// 当前数组下标位置 + 扩容之前数组的长度oldCap 时使用
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 通过上述讲解的原理来计算结点的新位置
do {
// 原索引
next = e.next;
// 这里来判断如果等于true
// e这个结点在resize之后不需要移动位置
// 举例:
// 假如hash1 -> ...... 0 1111
// 假如oldCap=16 -> ...... 1 0000
// e.hash & oldCap 结果为0,则
// 扩容之后数组的下标位置j,与当前数组的下标位置一致
// 使用低位链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 举例:
// 假如hash2 -> ...... 1 1111
// 假如oldCap=16 -> ...... 1 0000
// e.hash & oldCap 结果不为0,则
// 扩容之后数组的下标位置为:
// 当前数组下标位置j + 扩容之前数组的长度oldCap
// 使用高位链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表放到bucket桶里
if (loTail != null) {
loTail.next = null;
// 索引位置=当前数组下标位置j
newTab[j] = loHead;
}
// 将高位链表放到bucket里
if (hiTail != null) {
hiTail.next = null;
// 索引位置=当前数组下标位置j + 扩容之前数组的长度oldCap
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新散列表
return newTab;
}
红黑树也是拆分成高低链表,然后根据链表长度判断是否拆分,如果不用拆分就保持原样的树形
什么时候用到红黑树?为什么要用?
当链表元素>8时且元素个数>=64转化为红黑树,当红黑树中元素小于6时退化为链表。
//使用树而不是列表的 bin 计数阈值。 将元素添加到至少具有这么多节点的 bin 时,bin 会转换为树。 该值必须大于 2 且至少应为 8,以与树移除中关于在收缩时转换回普通 bin 的假设相匹配
static final int TREEIFY_THRESHOLD = 8;
//在调整大小操作期间取消(拆分)bin 的 bin 计数阈值。 应小于 TREEIFY_THRESHOLD,最多为 6 以在移除下进行收缩检测。
static final int UNTREEIFY_THRESHOLD = 6;
原因:
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin包含足够多的节点时才会转成 TreeNodes,是一种以空间换时间的方法。理想情况下随机 hashCode算法下所有bin中节点的分布频率会遵循lambda为0.5的泊松分布,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。
也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以才会选择8这个数字。
总之,树化阈值选择8是在时间和空间中找到一个最好的平衡点。
初始容量?为什么?
默认初始容量是16,且默认初始容量必须是2的次幂。(ps:采用懒加载,第一次使用时才会初始化数组)
先看hash方法
// JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
// 1. 取hashCode值: h = key.hashCode()
// 2. 高位参与低位的运算:h ^ (h >>> 16)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
首先说一下为什么要右移16位:如果当n即数组长度很小,假设是16的话,那么n-1的二进制表示为1111,这样的值和哈希值直接做按位与操作,实际上只使用了哈希值的后4位。当数组的长度很短时,只有低位数的hashcode值能参与运算。而让高16位参与运算可以更好的均匀散列,减少碰撞,进一步降低hash冲突的几率。并且使得高16位和低16位的信息都被保留了。
其次,使用异或可以保留高位和低位的特征,如果使用或就基本都是0了。
那为什么必须是2的幂次?
上面计算出的hash数还要和(map.length-1)进行与操作才能确定最终桶的位置。
因为(2的幂次 - 1)的二进制形式表示都是1,这样在和经过异或运算的h进行按位与运算的时候才可以最多地保留其特性,减少产生哈希碰撞的概率,让数组空间均匀分配。还有一种说法是因为要将结点放到桶里,需要%桶的个数,因为%没有&快,而&需要是2的幂次
如果不是2的幂:
例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;
使用2的幂次可以减少hash碰撞。
而使用&运算是因为计算hash一般是用%的方式,而&比%操作快。
那为什么默认值是16:作为默认容量,过大或过小都不合适,所以采取生产上的经验就设置为16
在阿里巴巴开发手册中建议自己设置初始值,initialSize=(需要存储的元素个数/负载因子)+1
扩容因子?为什么?
负载因子选择0.75主要是在提高空间利用率和减少查询成本的折中下,节点出现在hash桶中遵循泊松分布的情况下,选择0.75。
负载因子过高,如1,虽然减少了空间的开销,提高了空间的利用率,但是链表边长,增加了查询的时间成本,并且hash冲突概率增加。
负载因子过低,如0.5,虽然可以减少查询的时间成本,但空间利用率很低,提高了rehash操作的次数
总的来说,HashMap在负载因子0.75的时候,空间利用率,满足泊松分布,而且避免了相当多的Hash冲突,提升了时间效率。
为什么不直接采用hashCode()处理的哈希码作为hashMap的下标位置?
计算出来的哈希码可能并不在数组大小范围内,从而导致无法匹配位置的情况。解决方法:哈希码 & (数组长度-1)。
哈希码是32位的,其取值范围为-(2^31) ~ 2^31-1之间。
而哈希表的容量范围最大值为2^30.
为什么要对哈希码进行二次处理,扰动计算?
为了进一步提高哈希低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性和均匀性,从而减少hash冲突。
1.7和1.8对比
哈希表如何解决Hash冲突
为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化
为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键
HashMap 中的 key若 Object类型, 则需实现哪些方法?
参考自:Carson带你学Java:深入源码解析HashMap 1.8
为什么HashMap不是线程安全的?
第一点是put时候多线程可能导致数据不一致;
假设有线程A和线程B,一开始线程A希望插入一个键值对到HashMap中,但是计算得到桶的索引坐标,获取到该桶里面的链表头结点后阻塞了,而此时线程B执行,成功地将键值对插入到了HashMap中。假设此时线程A被唤醒继续执行,而线程A保存的插入点正好是线程B插入元素的位置,如此一来ji就覆盖了线程B的插入记录,造成了数据不一致的现象。
第二点是JDK7中HashMap的get操作可能因为resize而引起死锁。
JDK7版本中的HashMap扩容时使用头插法,假设此时有元素一指向元素二的链表,当有两个线程使用HashMap扩容时,若线程一在迁移元素时阻塞,但是已经将指针指向了对应的元素,线程二正常扩容,因为使用的是头插法,迁移元素后将元素二指向元素一。此时若线程一被唤醒,在现有基础上再次使用头插法,将元素一指向元素二,形成循环链表。若查询到此循环链表时,便形成了死锁。而JDK8版本中的HashMap在扩容时保证元素的顺序不发生改变,就不再形成死锁,但是注意此时HashMap还是线程不安全的。
第三,modcount不一致,对比i++
第四,扩容时get可能取到null
第五,无法保证可见性
补充:++size不是原子操作,扩容时机不确定。
如果 一个线程在读取,确定要读哪个地址后,另一个线程删除了这个数据,读取无效
参考自:面试问我,HashMap的默认初始容量是多少,我该怎么说
红黑树
红黑树也是平衡树的一种
所以他满足,左子树的值小于根节点,右子树的值大于根节点
红黑树性质
1.节点是红色或黑色。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
java中实现为TreeMap,TreeSet
为什么hashmap不用二叉查找树或平衡树?
二叉查找树为二分查找,复杂度为O(logN),但是如果所有结点都在左子树这种特殊情况就退化成了查找链表。二叉平衡树去除了这种情况,严格遵循左右子树高度差不超过1。但是如果插入大量结点就需要很多次调整操作。而红黑树又减少了调整。
红黑树查找复杂度为O(logN),添加复杂度为O(logN),所以添加N个结点耗时NlogN。
实现详解:清晰理解红黑树的演变—红黑的含义
推荐阅读:30张图带你彻底理解红黑树
hashmap扩容时如果别的线程删除或修改怎么办
面试题,看了看源码。扩容时会创建一个oldTab,保存之前的table。然后创建一个扩容后2倍容量的newTab,然后table=newTab。这时注意,才开始扩容后元素的迁移。会从oldTab获取元素,然后添加到newTab。
而get,remove等源码里面写的,先创建一个tab=table,就是引用,还是直接操作的tabl。然后你get是否能成功取决于你的resize添加元素快慢。如果快了那么就能在新数组找到,不然就找不到。remove也是一样,并发删除时只会有一个线程删除成功,可能一个也没有。
hashtable
hashtable可以简单理解为多线程版本的hashmap,在常用的get,put,remove等方法上都加了synchronized锁,但是同样也是fail-fast机制。
hashtable初始容量为11,当达到阈值后扩容,扩容因子为0.75,扩容后的数组长度是旧数组长度乘以2加1。采用头插法添加元素。
不能存null值。因为hashtable,concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。
hashtable继承自Dictionary。不能存放null的key和value,添加元素时先看看能不能找到,根据取余数组大小查找桶,找到就更新,不然再判断需不需要扩容,然后从新位置再添加。调用方法进行头插。扩容时大小变成2倍加1,从底向上进行rehash,头插法插入元素。
currenthashmap
1.7是采用的segment数组+hashentry数组+链表
1.8采用Node数组+链表+红黑树
ConcurrentHashMap在实际开发中也用得挺多,我们很多时候把ConcurrentHashMap用于本地缓存,不想每次都网络请求数据,在本地做本地缓存。监听数据的变化,如果数据有变动了,就把ConcurrentHashMap对应的值给更新了。
初始化时,如果设置容量为16那么1.7中容量就是16,但是1.8中会设置为32.
这里没有详细介绍,可以自己去了解一下源码。jdk8的线程协助扩容还是很厉害的思想。
为什么用synchronized替换reentrantlock?
1.首先是jvm对synchronized的优化,无锁、偏向锁、轻量级锁、重量级锁。使得他们性能基本不相差。
2.在1.7时,每个线程锁住一个segment,并发量相当于是除了16
Synchronized是靠对象的对象头和此对象对应的monitor来保证上锁的。
那么这里的这个f是什么呢?它是Node链表里的每一个Node,也就是说,Synchronized是将每一个Node对象作为了一个锁,这样做的好处是什么呢?将锁细化了,也就是说,除非两个线程同时操作一个Node,注意,是一个Node而不是一个Node链表哦,那么才会争抢同一把锁.
如果使用ReentrantLock其实也可以将锁细化成这样的,只要让Node类继承ReentrantLock就行了,这样的话调用f.lock()就能做到和Synchronized(f)同样的效果,但为什么不这样做呢?
请大家试想一下,锁已经被细化到这种程度了,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销.
但如果是ReentrantLock呢?它则只有在线程没有抢到锁,然后新建Node节点后再尝试一次而已,不会自旋,而是直接被挂起,这样一来,我们就很容易会多出线程上下文开销的代价.当然,你也可以使用tryLock(),但是这样又出现了一个问题,你怎么知道tryLock的时间呢?在时间范围里还好,假如超过了呢?
所以,在锁被细化到如此程度上,使用Synchronized是最好的选择了.这里再补充一句,Synchronized和ReentrantLock他们的开销差距是在释放锁时唤醒线程的数量,Synchronized是唤醒锁池里所有的线程+刚好来访问的线程,而ReentrantLock则是当前线程后进来的第一个线程+刚好来访问的线程.
如果是线程并发量不大的情况下,那么Synchronized因为自旋锁,偏向锁,轻量级锁的原因,不用将等待线程挂起,偏向锁甚至不用自旋,所以在这种情况下要比ReentrantLock高效
记录不够详细,建议再去看看别的补充,仅为自己的笔记