Java容器专栏: Java容器源码详细解析(面试知识点)
数据结构是哈希桶/哈希表/散列表(即数组+链表),数组每一个位置对应一个桶。在JDK8还引入了红黑树。在某个桶的链表长度大于8且HashMap中元素的个数大于64的时候自动变成一棵红黑树。(如果链表长度大于8,但是HashMap元素个数小于64时,采用扩容来解决)
为什么要引入红黑树?
因为HashMap中存放数据,所以查询也是很频繁的,而链表查询的时间复杂度始终为O(N),当链表过长的时候,查询效率较低。所以使用红黑树来提高查询效率,在红黑树中,增删改查的时间复杂度都是O(log n)。
无序,允许使用null 键和null值,因为key不允许重复,所以只能有一个键为null。
AbstractMap
private static final long serialVersionUID = 362498820763181265L;
//默认容量(数组长度/桶数),左移一位扩大一倍,1向左移位4位 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量(数组长度/桶数),2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子,用于扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//若一个桶中链表的节点数 >=此值(8)时,则此链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;
//一棵红黑树的节点如果 <=此值(6)时 ,退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表若要转成红黑树,除了满足节点数>=8外,还要满足 总节点数>=此值(64)
static final int MIN_TREEIFY_CAPACITY = 64;
//存储元素的数组(是一个个桶)
transient Node[] table;
//将数据转换成set的另一种存储形式,主要用于迭代
//Entry数组来存储key-value对,每一个键值对组成了一个Entry实体
transient Set> entrySet;
//元素总数量
transient int size;
//统计修改次数
transient int modCount;
//临界值,也就是元素总数量size达到临界值时,会对数组进行扩容(增加桶个数)。
int threshold;
//加载因子,用于扩容,默认会设置为上面的DEFAULT_LOAD_FACTOR,也可通过参数设置
final float loadFactor;
下面介绍一下HashMap两个和节点有关的内部类
//Node是HashMap中存放数据实体的静态内部类
//实现了Map接口中定义的Entry接口,可以对Node作为Entry进行操作。
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
}
//当链表转换成红黑树时,对应的Node也转换成TreeNode
static final class TreeNode extends LinkedHashMap.Entry {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
}
HashMap还有其他内部类:KeySet、EntrySet、Value等等,这些可以用于遍历操作。
//创建默认的HashMap(加载因子默认9.75f,容量默认16---在put的时候再初始化容量)
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//创建自定义初始容量的HashMap(使用默认的加载因子)
public HashMap(int initialCapacity) {
//调用下面的构造方法
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//创建自定义初始容量和加载因子的HashMap
public HashMap(int initialCapacity, float loadFactor) {
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;
//约束扩容的临界值threshold的大小应该为 2 的 n 次幂(<=initialCapacity)
this.threshold = tableSizeFor(initialCapacity);
}
从上面构造器可知,HashMap中的属性大部分时候是懒加载的,也就是等到put添加元素的时候再加载。
HashMap的长度都是2的次幂,保证2的次幂可以提高效率
面试题(HashMap优化思路):
如果能够大致预期HashMap大小,那么我们应该初始化时就指定好 capacity ,减少扩容次数,提升 HashMap 的效率,也提高了安全性。(扩容是最耗性能的事,所以要尽量较少扩容,又要根据实际确定好hashMap的容量)
//最后一个构造方法:将Map转换成HashMap
public HashMap(Map extends K, ? extends V> m) {
//加载因子默认值
this.loadFactor = DEFAULT_LOAD_FACTOR;
//调用下面的方法:将Map中的元素设置到HashMap中
//false表示在创建HashMap的时候就调用这个方法,反之表示在创建之后调用
putMapEntries(m, false);
}
//添加元素
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//计算哈希值的方法
static final int hash(Object key) {
int h;
//如果key不为null,值为 key的hashCode 异或 key的hashCode无符号右移16位
//科学研究表明,这样可以减少哈希冲突
/*
hashCode值若高位变化很大,低位变化很小,或者没有变化,那么如果直接用hashCode
和数组长度进行&运算时,很容易造成结果一致,导致哈希冲突
*/
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//添加通过hash值元素
//oonlyIfAbsent如果为false,则可以替换已经存在了的旧值(put方法传入false)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//哈希数组
Node[] tab;
//哈希桶首节点
Node p;
//n为HashMap的容量,i为哈希数组下标
int n, i;
//如果HashMap刚初始化,则对HashMap进行容量的初始化并获取容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过key的hash值计算得到对应的哈希桶下标i ,并且将此哈希桶的首节点赋值给p
//如果对应的哈希桶没有元素(即没有发生哈希冲突)
if ((p = tab[i = (n - 1) & hash]) == null)
//直接插入
tab[i] = newNode(hash, key, value, null);
//若冲突,有下面几种情况
else {
//临时节点
Node e;
//存放首节点p的key
K k;
//1、插入节点的key与首节点的相等(多重判断),则临时节点赋值为首节点p
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//2、若插入的节点不与首节点等价,且首节点是红黑树的节点
else if (p instanceof TreeNode)
//添加相应的红黑树节点
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
//3、插入节点不与首节点等价,且首节点是普通的链表节点
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;
}
}
//这里对插入情况出现重复节点(即key重复)进行的统一处理:覆盖后返回旧值
if (e != null) {
//获取到此节点的旧值
V oldValue = e.value;
//将新节点的新值覆盖旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断插入后是否需要扩容(覆盖旧值的情况不会进这个判断)
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
总结一下,调用put方法存储键值对时:
//扩容方法(返回的是扩容后的table)
final Node[] resize() {
//先获取没插入之前的旧tbale
Node[] oldTab = table;
//旧桶数(数组长度)
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧临界值
int oldThr = threshold;
//新桶数和临界值
int newCap, newThr = 0;
//oldCap > 0,说明不是首次初始化
if (oldCap > 0) {
//如果桶不能再增加了,即数组长度到了最大长度(1 << 30)
if (oldCap >= MAXIMUM_CAPACITY) {
//将临界值设置为int类型的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果旧数组长度>=16且扩容 两倍 后也没超过最大长度
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//则newCap赋值为oldCap的 两倍 ,同时newThr也是oldThr的两倍
newThr = oldThr << 1;
}
//oldCap为0,即数组为空,但是oldThr>0,说明是已经初始化过,是经过删除后导致的
else if (oldThr > 0)
//则新桶数为旧临界值
newCap = oldThr;
//oldCap和oldThr均为0,说明还没初始化,则进行首次初始化,赋予默认值
else {
// 新容量/数组长度/桶数设为16
newCap = DEFAULT_INITIAL_CAPACITY;
// 新临界值设为16*0.75 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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[] newTab = (Node[])new Node[newCap];
//新建一个扩容后的空table
table = newTab;
//将旧table的所有元素复制到新table中
if (oldTab != null) {
……
}
return newTab;
}
怎么扩容
默认负载因子是0.75,即当map中的元素个数(全部桶元素的总和)超过容量的75%时,就会进行扩容。且扩容是增加到旧数组的两倍。
(即按照默认情况下数组长度为16为例,当hashMap中的元素超过12个的时候,就会进行扩容:创建一个长度为32的数组,然后再重新计算各个元素在新数组中对应的位置,注意这里的位置确定在JDK8中不需要像JDK7一样重新计算Hash值,再去求余,而是只需要看原来的hash值新增的bit【假设扩容是N位,扩容后就是N+1位了】是0还是1,0则放在原位置,1则放在“原位置+旧容量”处。将旧table的所有元素按照计算得到的下标,复制到新table中)
JDK7扩容时可能出现的线程安全的问题
多线程resize时可能形成环形链表,导致下一次读取数据的时候可能出现死循环。
(假设两个线程同时扩容,对一个桶中的链表节点重新计算存放位置,那么肯定就会链表节点指针的转移或断开。假设一条链表有2个节点,第一个节点被第一个线程计算后移动到一个新位置上,还没来得即断开和第二个节点之间的指针此线程就挂起了,第二个线程就把第二个节点同样移动到这个位置上,采用的是头插法插入,这样子第二个节点的next指针指向了第一个节点,第一个节点的next指针指向了第二个节点,形成循环)
//通过key获取元素值
public V get(Object key) {
Node e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//通过key和key的hash值匹配节点
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//总是先判断第一个节点(通过hash就可以定位到节点所存在的桶了)
//如果第一个节点就是所要找的节点,直接返回first即可
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//否则,分普通节点和红黑树节点查找匹配
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
使用get方法获取key对应的value时:
//通过key删除元素(节点),若节点存在返回元素值,不存在返回null
public V remove(Object key) {
Node e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node[] tab; Node p; int n, index;
//需要先找到key对应的节点,和上面getNode思路类似
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//如果找到了这个节点,根据普通链表节点和红黑树节点的不同操作删除此节点
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode)node).removeTreeNode(this, tab, movable);
//如果要删的节点是链表头节点
else if (node == p)
//直接将头节点设置为下一个节点
tab[index] = node.next;
else
//否则,则设置上一个节点的next指针指向被删节点的下一个节点
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
线程不安全
获取线程安全的Map:
Collections.synchronizedMap();
参考:https://www.jianshu.com/p/0a70ce2d3b67
//方法一:通过 Map.keySet 遍历 key(可以获取到value)
for (String key : hashMap.keySet()) {
System.out.println("Key: " + key + " Value: " + hashMap.get(key));
}
//方法二:通过 Map.values() 遍历value(不能获取到key)
for (String v : hashMap.values()) {
System.out.println("value:" + v);
}
// 方法三:通过 entrySet 进行遍历,直接遍历出key和value。
//对于 size 比较大的情况下,又需要全部遍历的时候,效率是最高的。
for (Map.Entry entry : entries) {
System.out.println("testHashMap: key = " + entry.getKey() + ";value = " + entry.getValue());
}
//方法四:通过 Map.entrySet 使用 iterator ,满足一边遍历一边删除的场景。
Iterator iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry entry = (Map.Entry) iterator.next();
// 可以删除
iterator.remove();
}