07、集合之TreeMap和LinkedHashMap
TreeMap
TreeMap的底层数据接口是红黑树,由于红黑树的左小右大的特性,所以TreeMap中的元素会自动根据key进行排序,在初始化TreeMap时,可以传自定义的排序器comparator,如果没有传排序器会默认使用key的compareTo()方法进行比较。因为底层使用的时红黑树,所以TreeMap的get()、put()、remove()方法的时间复杂度都是O(log(n))。
排序的两种方式
- 实现comparable接口,通过compareTo()方法进行排序
- 通过外部排序器 Comparator 进行排序
public class Test {
public static void main(String[] args) {
List<DTO> list = new ArrayList<>();
for(int i = 1; i < 5; ++i) {
list.add(new DTO(i));
}
// 第一种排序方式:实现Comparable接口,通过compareTo()方法进行排序
Collections.sort(list);
System.out.println("排序方式一:" + list);
// 第二种排序方式:通过外部排序器 Comparator 进行排序
Collections.sort(list, Comparator.comparingInt(DTO::getId));
System.out.println("排序方式二:" + list);
}
}
@Data
class DTO implements Comparable<DTO> {
private Integer id;
public DTO(int id) {
this.id = id;
}
@Override
public int compareTo(DTO o) {
return o.getId() - this.id;
}
}
/**
* 运行结果:
* [DTO(id=4), DTO(id=3), DTO(id=2), DTO(id=1)]
* [DTO(id=1), DTO(id=2), DTO(id=3), DTO(id=4)]
*/
TreeMap整体架构
TreeMap有以下重要的属性:
//比较器,如果外部有传进来 Comparator 比较器,首先用外部的
//如果外部比较器为空,则使用 key 自己实现的 Comparable#compareTo 方法
private final Comparator<? super K> comparator;
//红黑树的根节点
private transient Entry<K,V> root;
//红黑树的已有元素大小
private transient int size = 0;
//树结构变化的版本号,用于迭代过程中的快速失败场景
private transient int modCount = 0;
//红黑树的节点
static final class Entry<K,V> implements Map.Entry<K,V> {}
TreeMap新增节点
reeMap 新增节点的步骤:
-
判断红黑树的节点是否为空,为空的话,新增的节点直接作为根节点;
Entry<K,V> t = root; //红黑树根节点为空,直接新建 if (t == null) { // compare 方法限制了 key 不能为 null compare(key, key); // type (and possibly null) check // 成为根节点 root = new Entry<>(key, value, null); size = 1; modCount++; return null; }
-
根据红黑树左小右大的特性,进行判断,找到应该新增节点的父节点;
Comparator<? super K> cpr = comparator; if (cpr != null) { //自旋找到 key 应该新增的位置,就是应该挂载那个节点的头上 do { //一次循环结束时,parent 就是上次比过的对象 parent = t; // 通过 compare 来比较 key 的大小 cmp = cpr.compare(key, t.key); //key 小于 t,把 t 左边的值赋予 t,因为红黑树左边的值比较小,循环再比 if (cmp < 0) t = t.left; //key 大于 t,把 t 右边的值赋予 t,因为红黑树右边的值比较大,循环再比 else if (cmp > 0) t = t.right; //如果相等的话,直接覆盖原值 else return t.setValue(value); // t 为空,说明已经到叶子节点了 } while (t != null); }
-
在父节点的左边或右边插入新增节点;
//cmp 代表最后一次对比的大小,小于 0 ,代表 e 在上一节点的左边 if (cmp < 0) parent.left = e; //cmp 代表最后一次对比的大小,大于 0 ,代表 e 在上一节点的右边,相等的情况第二步已经处理了。 else parent.right = e;
-
着色旋转,达到平衡,结束;
从源码中,我们可以看到:
- 新增节点时,就是利用了红黑树左小右大的特性,从根节点不断往下查找,直到找到节点是 null 为止,节点为 null 说明到达了叶子结点;
- 查找过程中,发现 key 值已经存在,直接覆盖;
- TreeMap 是禁止 key 是 null 值的;
LinkedHashMap
HashMap是无序的,TreeMap根据key进行排序,而LinkedHashMap可以按照元素存放的顺序进行访问。
LinkedHashMap本身是继承 HashMap 的,所以它拥有 HashMap 的所有特性,在此基础上,还提供了两大特性:
- 按照插入顺序进行访问;
- 实现了访问最少最先删除功能,其目的是把很久都没有访问的 key 自动删除;
链表结构
LinkedHashMap的结构源码如下:
// 链表头
transient LinkedHashMap.Entry<K,V> head;
// 链表尾
transient LinkedHashMap.Entry<K,V> tail;
// 继承 Node,为数组的每个元素增加了 before 和 after 属性
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
// 控制两种访问模式的字段,默认 false
// true 按照访问顺序,会把经常访问的 key 放到队尾
// false 按照插入顺序提供访问
final boolean accessOrder;
从源码中可以看出,LinkedHashMap中新增了head、tail分别指向第一个节点和最后一个节点,并且每个节点中新加了before、after分别指向该节点的前后节点,正是这种结构让LinkedHashMap中的节点可以按存放顺序进行访问。
LinkedHashMap支持两种访问策略,分别为按照插入顺序访问策略和访问最少删除策略,两种策略的区别如下:
- 按照插入顺序访问策略,顾名思义,是根据元素保存到LinkedHashMap的顺序访问的,先保存到LinkedHashMap中的元素,在遍历的时候会被先取出来,但是跟LinkedList不一样的是,LinkedHashMap只支持单向访问;
- 访问最少删除策略,该策略会根据元素的访问率,将经常被访问的元素放到队尾,这样很少访问的元素自然就会被移动到队头,然后通过设置删除策略,比如Map中的元素数量大于多少时删除队头元素,从而删除掉很少被访问的元素;
按照插入顺序访问策略
按照顺序新增元素
LinkedHashMap 初始化时,默认 accessOrder 为 false,就是会按照插入顺序提供访问,插入方法使用的是父类 HashMap 的 put 方法,不过覆写了 put 方法执行中调用的 newNode/newTreeNode 和 afterNodeAccess 方法。
newNode/newTreeNode 方法,控制新增节点追加到链表的尾部,这样每次新节点都追加到尾部,即可保证插入顺序了,以 newNode()方法源码为例:
// 新增节点,并追加到链表的尾部
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 新增节点
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
// 追加到链表的尾部
linkNodeLast(p);
return p;
}
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
// 新增节点等于位节点
tail = p;
// last 为空,说明链表为空,首尾节点相等
if (last == null)
head = p;
// 链表有数据,直接建立新增节点和上个尾节点之间的前后关系即可
else {
p.before = last;
last.after = p;
}
}
LinkedHashMap 通过加入头节点first、尾节点tail属性,给每个节点增加 before、after 属性,每次新增时,都把节点追加到尾节点等手段,在新增的时候,就已经维护了按照插入顺序的链表结构了。
按照顺序访问元素
LinkedHashMap 只提供了单向访问,即按照插入的顺序从头到尾进行访问,不能像 LinkedList 那样可以双向访问。
同样的,可以通过迭代器访问LinkedHashMap ,迭代器初始化的时候,默认从头节点开始访问,在迭代的过程中,不断访问当前节点的 after 节点即可。
Map 对 key、value 和 entity(节点) 都提供了迭代的方法,假设需要迭代 entity,就可使用 LinkedHashMap.entrySet().iterator()
这种写法直接返回 LinkedHashIterator ,LinkedHashIterator 是迭代器,调用迭代器的 nextNode 方法就可以得到下一个节点,迭代器的源码如下:
// 初始化时,默认从头节点开始访问
LinkedHashIterator() {
// 头节点作为第一个访问的节点
next = head;
expectedModCount = modCount;
current = null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)// 校验
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after; // 通过链表的 after 结构,找到下一个迭代的节点
return e;
}
在新增节点时,我们就已经维护了元素之间的插入顺序了,所以迭代访问时非常简单,只需要不断的访问当前节点的下一个节点即可
访问最少删除策略
这种策略也叫做 LRU(Least recently used,最近最少使用),大概的意思就是经常访问的元素会被追加到队尾,这样不经常访问的数据自然就靠近队头,然后可以通过设置删除策略,比如当 Map 元素个数大于多少时,把头节点删除,示例代码如下:
public void testAccessOrder() {
// 新建 LinkedHashMap
LinkedHashMap<Integer, Integer> map = new LinkedHashMap<Integer, Integer>(4,0.75f,true) {
{
put(10, 10);
put(9, 9);
put(20, 20);
put(1, 1);
}
@Override
// 覆写了删除策略的方法,我们设定当节点个数大于 3 时,就开始删除头节点
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > 3;
}
};
log.info("初始化:{}",JSON.toJSONString(map));
Assert.assertNotNull(map.get(9));
log.info("map.get(9):{}",JSON.toJSONString(map));
Assert.assertNotNull(map.get(20));
log.info("map.get(20):{}",JSON.toJSONString(map));
}
/**
* 运行结果如下:
* 初始化:{9:9,20:20,1:1}
* map.get(9):{20:20,1:1,9:9}
* map.get(20):{1:1,9:9,20:20}
*/
可以看到,map 初始化的时候,放进去四个元素,但结果只有三个元素,10 不见了,这个主要是因为覆写了 removeEldestEntry 方法,实现了如果 map 中元素个数大于 3 时,就把队头的元素删除,当 put(1, 1) 执行的时候,正好把队头的 10 删除,这个体现了达到设定的删除策略时,会自动的删除头节点。
当调用 map.get(9) 方法时,元素 9 移动到队尾,调用 map.get(20) 方法时, 元素 20 被移动到队尾,这个体现了经常被访问的节点会被移动到队尾。
元素被转移到队尾原理
为什么 get 时,元素会被移动到队尾,源码如下:
public V get(Object key) {
Node<K,V> e;
// 调用 HashMap get 方法
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果设置了 LRU 策略
if (accessOrder)
// 这个方法把当前 key 移动到队尾
afterNodeAccess(e);
return e.value;
}
从上述源码中,可以看到,通过 afterNodeAccess 方法把当前访问节点移动到了队尾,其实不仅仅是 get 方法,执行 getOrDefault、compute、computeIfAbsent、computeIfPresent、merge 方法时,也会这么做,通过不断的把经常访问的节点移动到队尾,那么靠近队头的节点,自然就是很少被访问的元素了。
删除策略
述 demo 在执行 put 方法时,发现队头元素被删除了,LinkedHashMap 本身是没有 put 方法实现的,调用的是 HashMap 的 put 方法,但 LinkedHashMap 实现了 put 方法中的调用 afterNodeInsertion 方法,这个方式实现了删除,源码如下:
// 删除很少被访问的元素,被 HashMap 的 put 方法所调用
void afterNodeInsertion(boolean evict) {
// 得到元素头节点
LinkedHashMap.Entry<K,V> first;
// removeEldestEntry 来控制删除策略,如果队列不为空,并且删除策略允许删除的情况下,删除头节点
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
// removeNode 删除头节点
removeNode(hash(key), key, null, false, true);
}
}