LinkedHashMap
使用场景
使用HashMap无法保证遍历顺序:遍历顺序与插入(put)顺序不一致。但是有时我们需要保证遍历顺序与插入顺序的一致性。
例如,xx做过一个压缩图片大小的功能,要求当图片大于1000KB时,按10%的比例进行压缩;当图片大于500KB,按50%的比例进行压缩;当图片大于300KB时,按80%比例进行压缩;当图片大于100KB时,按90%比例进行压缩。她的代码大概其是这样的(大意如此,不要追究细节):
Image image = xxxx;
Map<Integer, Float> map = new LinkedHashMap();
// 这里必须保证写入顺序
map.put(1000,0.1);
map.put(500,0.5);
map.put(300,0.8);
map.put(100,0.9);
for(Entry entry : map.entrySet()){
// 如果图片大于指定大小
if(image.size() > entry.getKey()){
// 按指定倍数进行压缩
image.resize(entry.getValue());
}
}
如果使用普通的HashMap,那么上面的代码显然会有问题,因为对map做for循环的顺序与map.put的顺序不一定一样。为了保证顺序一样,这里应该使用LinkedHashMap。
使用示例
LinkedHashMap的使用其实很简单:
Map<String, String> map = new LinkedHashMap();
map.put("first","第一个");
map.put("seconde","第二个");
map.put("third","第三个");
for(Entry entry: map.entrySet()){
System.out.print(entry.getKey());
System.out.print(entry.getValue());
}
for(String key: map.keySet()){
System.out.print(key);
}
for(String value: map.values()){
System.out.print(value);
}
map.foreach((k,v)->System.out.print(k+":"+v));
原理
数据结构
本质上来说,LinkedHashMap保证操作顺序的办法就是把存入Map中的Entry、Key和Value分别用链表串联起来:
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/
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);
}
}
从上面的代码中可以看出:LinkedHashMap的Entry不仅是个链表,而且还是双向链表。双向链表的好处主要有两个,一是可以逆序查找,二是可以提高查询性能。如果关注一下LinkedList,可以发现它的“链表”也是双向链表。
不仅如此,LinkedHashMap还专门为entrySet、keySet和values实现了几个不同的子类:
final class LinkedKeySet extends AbstractSet<K> {}
final class LinkedValues extends AbstractCollection<V> {}
final class LinkedEntrySet extends AbstractSet<Map.Entry<K,V>> {}
此外还有对应的Iterator,用于处理各自的foreach循环:
abstract class LinkedHashIterator {
final class LinkedKeyIterator extends LinkedHashIterator
implements Iterator<K> {}
final class LinkedValueIterator extends LinkedHashIterator
implements Iterator<V> {}
final class LinkedEntryIterator extends LinkedHashIterator
implements Iterator<Map.Entry<K,V>> {}
排序逻辑
LinkedHashMap是继承自HashMap的。当HashMap执行了put、remove或者其它的修改数据的方法后,会执行一些“callback”方法,用于触发LinkedHashMap中的一些处理逻辑,如:
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
上面这个方法是当LinkedHashMap删除某个元素时,用来重新排序的方法。其中的代码逻辑很简单,就是一个双向链表的删除操作。
上面是删除元素时的重排序;那么,在新增元素时是怎样做的呢?先看下面这个方法:
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
上面这个方法看起来像是在新增元素时做重排序的方法。但是,由于LinkedHashMap中removeEldestEntry()方法默认永远返回false,所以实际上这个方法中不会做任何处理。新增元素时的排序方法实际是在这里:
// 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;
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);
// 下略
}
// LinkedHashMap中
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;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
在Java8以后,HashMap中的Entry有可能是TreeNode。LinkedHashMap也实现了对应的newTreeNode方法,并在这个方法中调用了linkNodeLast方法。两段代码都很简单,就不解释了。
另外,在HashMap扩容时,虽然需要对元素做重哈希,但是并不需要对链表做重排序。
“高级”特性
排序规则
默认情况下,LinkedHashMap是按照插入顺序进行排序的。但是,我们可以指定它按照“访问”顺序进行排序。这段逻辑可以参见这段代码:
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<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;
}
}
其中的accessOrder默认为false,但是可以通过构造方法来设定为true。如果将这个字段设置为true,每当一个元素被访问到,LinkedHashMap都会把它挪到链表的末尾去。这样,LinkedHashMap的遍历顺序就变成了“最近最少访问”顺序了。如下面的代码:
// 指定accessOrder为true
Map<String, String> map = new LinkedHashMap(10,1,true);
map.put("a","a");
map.put("b","b");
map.put("c","c");
map.get("b");
map.get("a");
// 问题1:这里会按什么顺序打印出来?
map.foreach((k,v)->System.out::println);
这个accessOrder有什么用呢?看到“最近最少访问”应该就能想到了:做简易的缓存。而且,如果要用作简易缓存,还可以通过重写removeEldestEntry()方法,来让LinkedHashMap在插入新元素时,删除Map中最“老”的元素,实际上也就是删除链表的头结点。
问题:为什么LinkedHashMap中,“最老”的元素一定是链表的头结点?
性能比较
虽然说LinkedHashMap在增删改数据时需要重新维护链表顺序,但是由于链表的增删改操作性能非常好(时间复杂度是常数级别的,一般可以认为是O(1)),所以并不会有太大的额外损耗。
遍历操作上,如果都是对entrySet做遍历,LinkedHashMap与HashMap也相去无几。但是如果对keySet或者values做遍历,这就不一定了。
TreeMap
HashMap的基本结构是数组,但是无法保证遍历顺序。LinkedHashMap的在数组基础上增加了链表,从而使得遍历有序。但是,LinkedHashMap只能按操作(插入或访问)顺序来遍历。如果我们需要按其它顺序来遍历,要怎么办?
TreeMap提供了一个可用的选项:它提供按key值排序的遍历顺序,而排序规则可以自己制定。
使用场景和示例
同样以xx要做的那个功能举例。如果用TreeMap,那么我们大概需要这样写:
Image image = xxxx;
Map<Integer, Float> map = new TreeMap(new Comparator<Integer>{
int compare(Integer o1, Integer o2){
// 之所以取负数,是为了逆序排列
return -(o1.compareTo(o2));
}
});
// 使用TreeMap,这里就可以乱序put了。
map.put(100,0.9);
map.put(1000,0.1);
map.put(300,0.8);
map.put(500,0.5);
for(Entry entry : map.entrySet()){
// 如果图片大于指定大小
if(image.size() > entry.getKey()){
// 按指定倍数进行压缩
image.resize(entry.getValue());
}
}
之所以可以这样使用,是因为TreeMap会将放入其中的元素按key值进行排序,排序规则有两种:在构造TreeMap时指定一个非null的Comparator,或者保证放入其中的Key值都实现了Comparable接口。如果二者同时符合要求,会以Comparator为准。
原理
TreeMap的底层数据结构是红黑树。所以要理解TreeMap的原理,得先把红黑树搞清楚。这里不多说。
性质
底层结构决定了红黑树的性质:有序;增、删、改、查时间复杂度为O(logN)。
EnumMap
使用场景
当key值是某个枚举类时,推荐使用EnumMap。如,我们声明了一个产品代码的枚举ProjectCode,要求每种产品都使用一个不同的还款计算器,这时我们可以这样:
public enum ProjectCode{
P1,P2,P3;
}
public class Order{
private ProjectCode projectCode;
}
public interface RepayCalculator{
void calculate(Order);
}
public class RepayCalculatorAsDispatcher implements{
private Map<ProjectCode, RepayCalculator> repayCalculatorMap
= new EnumMap<>(ProjectCode.class);
public void calculate(Order){
repayCalculatorMap.get(order.getProjectCode())
.calculate(order);
}
}
诚然,这里的repayCalculatorMap也可以用一个HashMap。但是由于我们的key值是枚举类,HashMap的空间和时间复杂度其实都不如EnumMap。
原理
EnumMap比HashMap强的原因,是因为它的底层是一个简单的一维数组。虽然HashMap的底层也是一个数组,但是它的数组实际上是一个二维数组。HashMap要使用二维数组的原因是:它要处理的key值可能有无限多个,无论分配多大的哈希桶,都存在哈希碰撞的风险;但EnumMap没有这个风险:枚举值个数是有限的,是固定的,初始化时分配对应个数的“哈希桶”,就永远不会有哈希碰撞风险。
因为没有哈希碰撞,所以EnumMap的效率比HashMap要高:它的时间复杂度是严格的O(1)。而即使是HashMap,也有可能在某些攻击面前退化为O(n)。 HashMap退化为O(n)的问题在Java8以后已经得到了改进,现在的最差情况是O(logN)。猜猜看为什么。