目录
-
简单概念
上面一篇博客分析了Lrucache的实现原理,我们知道他是通过自己实现同步,然后利用LinkedHashMap来实现的Lru功能。这篇文章就分析一下LinkedHashMap,首先说说它的一些基本的概念(基于jdk1.8)。
1 首先它是一个关联数组,哈希表。他不是线程安全的,key和value可以为空,这也是与hashtable一族的最大区别。这个特性也是它的父类HashMap的特性继承而来。
2 linkedHashMap内部维护了一个双向链表,因为这个原因,因此它的遍历顺序是固定的,可以按照存储顺序排序。并且设置参数accessOrder为true可以使它可以按照访问顺序进行输出,这也是实现Lrucache功能的基础。而Hashmap不具有这个功能,它的存储顺序可能会因为扩容的原因导致变化。
LinkedHashMap的基本存储结构如下:
在LinkedHashMap中初始化的时候数组长度是16,加载因子是0.75,所以扩容的阈值是12, 当数组长度大于12就需要扩容了。这个加载因子不能太大,或者太小。太大的话导致数组长度太大,遍历的时间复杂度会越来越大,太小的话会导致频繁的扩容,内存空间会大量损失,所以官方的0.75是时间空间的一个相互兼容的结果。数组中每个元素下面的链表也有一个最大长度,目前默认值是8,如果链表的长度大于8, 那么久将链表改为红黑树方式存储数据。因为红黑树的查询时间复杂度是O(log n)级别。这样操作起来更加高效。
-
源码
介绍了基本概念,在源码里总有一个buckets,他表示数组中的每个元素。一般称为哈希桶,其实也可以称为哈希表。
//这个表示数组的初始大小,使用位移的方式是为了计算更加快捷,源码中很多都是移位操作
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组扩容的最大值
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;
//linkedhashMap中存储节点的类,可以看出在其内部有两个变量,before,after,
//表示它前后的元素。这是维护双向链表的基础
static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> {
LinkedHashMapEntry<K,V> before, after;
LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
//双向链表的头部元素,表示最久未被使用的元素
transient LinkedHashMapEntry<K,V> head;
//双向链表末尾的元素,表示最近的元素
transient LinkedHashMapEntry<K,V> tail;
//这个变量决定链表的存储方式,false按照存储顺序存储,true表示按照访问顺序存储
final boolean accessOrder;
我们先看构造函数:
//声明数组的长度和加载因子
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
//只声明数组长度,
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
//均使用默认值
public LinkedHashMap() {
super();
accessOrder = false;
}
//直接使用另外的map初始化
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
//指定数组长度,加载因子,并且会根据accessOrder的值来确定输出顺序
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
在这里进行数据的初始化,各个变量在我们没有赋值的情况下均使用默认值。默认值在上面已经写明。
接下来我们看put函数:
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);
}
在put的时候,我们首先要获取一个key的hash值,根据这个值来确认哈希桶的位置,我们可以看出,key通过hashCode获取整行值,然后会右移16位(也就是舍弃了低16位)然后与hashCode值进行异或。 这样做的目的就是减少碰撞,使哈希桶的位置能均匀的分配到数组中区,否则如果发生碰撞就会使数组某些位置的哈希桶元素过多。导致链表过长。(异或可以使0,1出现概率均为0.5)。
接下来看看linkedHashMap的增操作:需要明确的是linkedHashMap没有重写put方法,所以以下均是hashmap的方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果是第一次添加,table数组肯定是空,将map的数组结果临时复制给tab,
//首先需要调用resize()函数进行初始化,resize函数既有初始化功能也有扩容的
//的功能,下面会详细讲,只是这一句话利用的是它的初始化功能
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果table已经创建,而需要添加的元素的哈希值对应的数组下标
//的哈希桶为空,那么直接创建一个新的元素存放到数组。此处有内容需要在
//下面详细讲
tab[i] = newNode(hash, key, value, null);
else {
//对应位置的哈希桶不为空,发生了碰撞
Node<K,V> 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<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果对应位置还是链表存储,需要遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//若果p节点的下一个是空,那么直接把数据存储在p的下一个即可
p.next = newNode(hash, key, value, null);
//存储完成之后需要判断是否已经达到了树形阈值
//如果达到了这个值就需要把链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍历过程中找到链表中有个节点的 key 与 当前要插入元素的 key 相同,
//此时 e 所指的节点为需要替换 Value 的节点,并结束循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//遍历之后如果e不为空,那么表示新添加的数据需要替换e的值,这个时候需要返回原值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//这个函数在linkedHashmap中实现,将添加的元素移动到双向链表的尾端
//单纯的hashmap不需要,是实现lru的方法
afterNodeAccess(e);
return oldValue;
}
}
//操作数加1
++modCount;
//如果元素数量加1,大于需要扩容阈值,则需要扩容
if (++size > threshold)
resize();
//hashmap中此函数为空,这个是专门留给linkedhashmap的
//这个方法在linkedhashmap中实现,是实现lrucache的关键
afterNodeInsertion(evict);
return null;
}
在这过程中:p = tab[i = (n - 1) & hash] 当计算新入元素处于哈希桶的位置的时候,我们一般的想法应该是hash值对(capacity )取余,如果以默认值举例,那就是hash值对16取余数,余数为多少。这个元素所在的哈希桶在数组的位置就是多少。在这里hashmap没有用%运算。直接用的(n-1)&hash, 位与运算。15的二进制表示是:01111,其余的高位都是0. 所以如果按位与的话,任何数值的高28位无论是0,或者1,与的结果都是0. 所以无论低四位是什么值,这个结果最大就是1111, 所以他与对15取余效果一样。这也是为什么数组的长度必须是2的N次方的原因。只有2的N次方-1,才可以保证低位全部是1, 这样任何数按位与运算都可以得出与取模一样的结果。例如123&15
0000000000000000000000000 1111011
0000000000000000000000000 0001111
----------------------------------------------------------
0000000000000000000000000 0001011
结果是 11,这与123对16取余是一样的。但是&运算更加快捷简单。我们接下来看看扩容的过程就是resize。
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) {
//如果原来的数组长度已经大于2^30,那么将不再扩容,直接返回原数组
if (oldCap >= MAXIMUM_CAPACITY) {
//将扩容阈值改为Integer.MAX_VALUE就是不再扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的数组长度扩容为原来的2倍,扩容阈值也变为原来的2倍。
//<<1与*2的效果一样。这里也保证了数组长度为2的N次幂
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 {
//直接通过默认值将数组长度指定为16,扩容因子指定为12
newCap = DEFAULT_INITIAL_CAPACITY;
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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//以上就是扩容中解决数组的部分。如果原数组不为空,那么需要重新分配哈希桶的位置
if (oldTab != null) {
//遍历数组中的所有哈希桶,重新分配位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//将原数组下标为j的位置置空
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 { // preserve order
//因为扩容为原来的2倍,所以需要重新计算链表中的每个元素从属的
//哈希桶在数组中的位置,理论上原来的一条链表有可能会拆分成2条
//比如原来容量是16,现在是32,当时下标是1的元素,有可能是17,
//也有可能是1,或者33, 当扩容之后,下标就有可能是1,和17两个了
//差值为一个原来的数组长度,所以lo开头的2个节点表示重新计算之后人处于
//低位的链表的头尾两个指针(比如下标为1),hi开头的2个节点表示处于
//高位的链表的头尾节点.(比如下标17)。 至于e.hash &oldCap的作用
//下面单独讲
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;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//遍历之后分别将两个链表插入对应的数组位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
这里我们讲讲e.hash &oldcap ==0 的作用,首先我们明确oldcap无论是多少。他的二进制只有一位是1, 其余全是0, 咱们用16举例吧。那么它是 0000 10000. 如果一个值a和它按位与为0.那么他的低5位肯定也是0.因为当时扩容之前a是与00000 1111按位与,这个时候如果他的低5位是0, 那么在与扩容之后的n-1按位与的话(本例是0000 11111),得到的结果仍然与原来的值相同,所以凡是与oldcap按位与等于0,那么这个元素在新的数组中仍然处于与原来的数组同样的一个哈希桶之中。如果&oldcap不等于0, 那么他的第五位必是1,他与扩容之后的按位与的结果与原来的值相比就是多了一个10000,也就是16, 所以这个元素所属的哈希桶的位置在原来的位置上要加16,也就是加一个oldcap.
所以整个添加过程如下:
1 当table为空的时候表示第一次添加。那么进行第一次扩容。
2 通过计算存储元素的hash值,并通过计算获取此元素所在哈希桶的数组下标。
3 判断此下标中的哈希桶是否有值,如果为空则直接插入,不为空判断key是否已经存才,如果存在则覆盖原来的值。并返回原值。
4 如果哈希桶中没有存储过这个元素,那么如果这个哈希桶已经变为红黑树,就按照红黑树的原则添加,否则要遍历链表将数据插入链 表的尾部,插入之后要判断链表是否需要改为红黑树。如果是就进行变换。
5 插入成功之后,判断是否需要再次扩容。之后根据结果进行操作。
他的整个过程入下图所示: 这张图来源自java红黑树
以上的内容是hashmap的插入过程,但是linkedHashMap是双向链表的结构,虽然linkedhashmap没有重写插入方法,但是重写了几个方法来实现它的目的。我们一一看看这些函数:
首先newNode()生成节点的方式,这是在插入过程中生成新的节点:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
//生成一个新的节点,然后将它移动到最后
LinkedHashMapEntry<K,V> p =
new LinkedHashMapEntry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMapEntry<K,V> p) {
//临时变量指向双向链表的尾部节点
LinkedHashMapEntry<K,V> last = tail;
tail = p;
//如果尾部节点为空,那么就是第一个节点。这个时候新加入的数据赋给头部节点
if (last == null)
head = p;
else {
//否则将p添加到末尾
p.before = last;
last.after = p;
}
}
这个是linked插入的过程,在hashmap中,还有几个函数是插入之后的回调。如下:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
我们依次看看这几个函数
//根据evict,也就是前文linkedHashMap构造函数中的accessOrder,来判断是否删除双向
//链表中最老的元素,这个是实现lru需要用到的。
void afterNodeInsertion(boolean evict) {
LinkedHashMapEntry<K,V> first;
//判断是否需要删除
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
//根据源码中的注释,这个函数一般返回flase,但是当cache已满的情况下返回true
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
//将添加的元素移动到链表的尾端,一个简单的操作,不做细讲了。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMapEntry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMapEntry<K,V> p =
(LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
综上我们可以大概描述linkedHashmap的添加流程:
到这里我们基本把插入流程描述结束。由于篇幅限制,其他操作在下一篇博客中描述。linkedHashMap源码解析(二)