Java之SortedMap体系浅析

222 阅读7分钟

本文JDK源码来自JDK 1.8

1 SortedMap接口

作者是Josh Bloch,主导了Java平台很多特性的设计和实现,包括著名的Java Collections框架,java.math包,还是《Effective Java》一书的作者。

SortedMap接口(从Java 1.2起),映射根据其键的自然顺序排序(即对Comparable接口的实现),或者由创建时提供的Comparator排序。 声明了一下方法:

// 返回对键排序的comparator,如果使用键的自然排序,则返回null
Comparator<? super K> comparator();

// 返回子视图,可包含fromKey且不包含toKey
SortedMap<K,V> subMap(K fromKey, K toKey);

// 返回一个从头部起、到toKey为止(不包含)的子视图
SortedMap<K,V> headMap(K toKey);

// 返回一个从fromKey(可包含))起、到尾部为止的子视图
SortedMap<K,V> tailMap(K fromKey);

// 返回value的集合
Collection<V> values();

2 NavigableMap接口

Java 1.6中新增了NavigableMap,源码中部分描述如下:

A SortedMap extended with navigation methods returning the closest matches for given search targets.
一个SortedMap,扩展了导航方法,返回给定搜索目标的最接近匹配项。

All of these methods are designed for locating, not traversing entries.
所有这些方法都是为定位而设计的,而不是遍历条目。

A NavigableMap may be accessed and traversed in either ascending or descending key order.
NavigableMap可以按升序或降序键来访问和遍历。

NavigableMap是SortedMap的子接口,提供了更多查找方法。

// 返回比给定key小且最接近的那个Entry
Map.Entry<K,V> lowerEntry(K key);

// 返回与给定key相等,或比给定key小且最接近的那个Entry
Map.Entry<K,V> floorEntry(K key);

// 返回与给定key相等,或比给定key大且最接近的那个Entry
Map.Entry<K,V> ceilingEntry(K key);

// 返回比给定key大且最接近的那个Entry
Map.Entry<K,V> higherEntry(K key);

// 返回第一个Entry(升序时即key最小的,降序时即key最大的)
Map.Entry<K,V> firstEntry();

// 返回最后一个Entry(升序时即key最大的,降序时即key最小的)
Map.Entry<K,V> lastEntry();

// 移除第一个Entry,相应的有pollLastEntry
Map.Entry<K,V> pollFirstEntry();

// 返回将原Map排序反转后的一个视图,对此视图的修改,将作用在原Map上。
NavigableMap<K,V> descendingMap();

// 返回Map某个区间的视图,可包含fromKey,但不会含toKey
SortedMap<K,V> subMap(K fromKey, K toKey);

// 返回给定Key之前所有entry的一个视图,不包含toKey
SortedMap<K,V> headMap(K toKey);

// 返回给定Key之后所有entry的一个视图,可包含fromKey
SortedMap<K,V> tailMap(K fromKey);

// 与tailMap(K fromKey)相似,可指定能否包含给定key
NavigableMap<K,V> tailMap(K fromKey, boolean inclusive);

3 TreeMap类

3.1 认识TreeMap

TreeMap,一个基于红黑树的NavigableMap子实现,根据键的自然顺序或在创建Map时提供的Comparator进行排序。为containsKey、get、put和remove操作提供了保证的log(n)时间成本image.png

TreeMap并不像HashMap那样基于散列表实现,因此TreeMap中没有键的哈希冲突,也没有负载因子,也不涉及扩容

TreeMap的主要属性如下:

// 用于维护顺序,如果使用key的自然排序,则它为null
private final Comparator<? super K> comparator;  
  
// 树的根节点  
private transient Entry<K,V> root;

// 节点数量
private transient int size = 0;

// 常量,标记节点颜色
private static final boolean RED = false;  
private static final boolean BLACK = true;

// Entry、Key的集合视图
private transient EntrySet entrySet;  
private transient KeySet<K> navigableKeySet;

TreeMap的创建方式主要有两种: image.png image.png

3.2 put重复key

TreeMap中的重复key,指两个key比较大小时相等,即key1.compareTo(key2)==0,或comparator.compare(key1, key2)==0。而不是指key1.equals(key2)==true。 因此,创建TreeMap未提供comparator时,要求key必须实现Comparable接口。

但是,不要求key的类型覆写equals()hashCode(),因为TreeMap不使用equals()hashCode()image.png 当put一个重复的key时,会用新value替换旧value,而键值对的key不变。 image.png

3.3 注意key对Comparable或Comparator的实现

在put()方法中,通过comparator或自然顺序,从树的root节点开始,逐层比较待插入key与已有entry的key,直到找到合适的位置(或者找到一个相等的key来覆盖它的值)

来看看get(Object key)方法,也是依赖于key的大小比较。 image.png image.png

可见,使用TreeMap时,正确定义key的Comparable接口或Comparator接口,十分重要,特别要明确key的相等条件。

3.4 key不能未null

put()方法中有如下代码:当key==null时,将抛出NullPointerExceptionimage.png 下面示例报错的原因在于,Integer.compareTo()方法需要拆箱,null拆箱会报空指针。 image.png image.png

4 一个反例——错误地Comparator

4.1 key相等时未返回0

假设学校要对某个学科,将学生按成绩排序,先定义Student类:

  public class Student {
    // 用于生成id
    public static AtomicInteger identity = new AtomicInteger(1);
    private String name;
    // 分数
    private Integer score;
    // 唯一学号
    private final Integer id;

    public Student(String name, Integer score) {
      this.id = identity.getAndIncrement();
      this.name = name;
      this.score = Objects.isNull(score) || score < 0 ? 0 : score;
    }

    public String getName() {
      return name;
    }

    public Integer getScore() {
      return score;
    }

    public Integer getId() {
      return id;
    }

    @Override
    public String toString() {
      return name + "-" + id;
    }
  }

当你写下这样的代码时,会发现无法从TreeMap中查找任何学生:因为Comparator没有定义score相同时返回0。

public class TreeMapTest {
  public static void main(String[] args) {
    TreeMap<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
      @Override
      public int compare(Student o1, Student o2) {
        // 成绩降序
        return o1.getScore() > o2.getScore() ? -1 : 1;
      }
    });

    Student student1 = new Student("张三", 77);
    Student student2 = new Student("李四", 64);
    Student student3 = new Student("王五", 91);
    Student student4 = new Student("赵六", 77);
    map.put(student1, student1.getId());
    map.put(student2, student2.getId());
    map.put(student3, student3.getId());
    map.put(student4, student4.getId());

    // 输出:[王五-3, 张三-1, 赵六-4, 李四-2]
    System.out.println(map.keySet());
    // 输出:false
    System.out.println(map.containsKey(student4));
  }
}

4.2 判断key相等未使用标识性属性

于是,你修改了Comparator的定义:当score相同时返回0。

    TreeMap<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
      @Override
      public int compare(Student o1, Student o2) {
        // 成绩降序
        if (o1.getScore() == o2.getScore()) {
          return 0;
        }
        return o1.getScore() > o2.getScore() ? -1 : 1;
      }
    });

这下TreeMap确实可以查找任一学生了。但是,意外再次来临:

当执行System.out.println(map.keySet())时,返回了[王五-3, 张三-1, 李四-2],赵六哪去了?

向TreeMap中put重复key时,只会用新值覆盖旧值,而key不变。因为赵六的成绩与张三相等,导致赵六并没有真正放入TreeMap中。

所以,在定义TreeMap的Comparator或Comparable时,除了定义排序规则外,还需思考Key相等的根本条件

上面例子中,当score相等即判定两个Student相同,显然不合适;应当加入Student的标识性信息,如只有id相等时,才会返回0。

    TreeMap<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
      @Override
      public int compare(Student o1, Student o2) {
        // 成绩降序
        if (o1.getScore() == o2.getScore()) {
          // 成绩相同时,按学号升序排序
          return o1.getId() < o2.getId() ? -1 : 1;
        }
        return o1.getScore() > o2.getScore() ? -1 : 1;
      }
    });

5 TreeMap实现一致性路由算法

5.1 什么是一致性路由算法

在分布式系统处理请求路由时,将来自某个IP的多次请求,都会被分发给固定的机器去处理,即一致性路由。

为了使流量在服务集群中分布地更加均匀,避免出现倾斜,往往会将一个服务节点虚拟化为N个,节点的key即ip地址的哈希值,从而形成一个hash环。 image.png 当请求某个服务时,根据请求来源IP的哈希值,从hash环中查找距离最近的一个服务节点,并由这个节点来处理该请求。

5.2 使用TreeMap实现

我们已知:TreeMap能够将Entry排序,查找与给定目标最接近的匹配项。 因此,使用TreeMap能方便地实现一致性路由功能。如下:

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

public class ConsistentHashRoute {
  // 每个server虚拟化后的节点个数
  private static final int VIRTUAL_NODE_NUM = 100;

  private volatile List<String> serverAddressList;
  private volatile TreeMap<Long, String> hashRing;

  public ConsistentHashRoute(List<String> serverAddressList) {
    if (Objects.isNull(serverAddressList) || serverAddressList.isEmpty()) {
      throw new IllegalArgumentException("Servers are empty.");
    }
    this.serverAddressList = serverAddressList;
    this.hashRing = hashRing(this.serverAddressList);
  }

  // 可以使用更好的hash算法,以减少hash冲突
  private static long hash(String key) {
    return key.hashCode();
  }

  // 使用TreeMap实现hash环
  private TreeMap<Long, String> hashRing(List<String> serverAddressList) {
    TreeMap<Long, String> hashRing = new TreeMap<>();
    for (String address : serverAddressList) {
      // 每个节点虚化为100个节点
      for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
        long addressHash = hash(address + "-NODE-" + i);
        hashRing.put(addressHash, address);
      }
    }
    return hashRing;
  }

  public String route(String sourceIp) {
    Map.Entry<Long, String> ceilingEntry = hashRing.ceilingEntry(hash(sourceIp));
    // 存在最接近的一个entry
    if (Objects.nonNull(ceilingEntry)) {
      return ceilingEntry.getValue();
    }
    // 不存在时取第一个entry
    return hashRing.firstEntry().getValue();
  }

  // 追加Server
  public synchronized void addServer(String serverAddress) {
    serverAddressList.add(serverAddress);
    hashRing = hashRing(this.serverAddressList);
  }

  // 移除Server
  public synchronized void removeServer(String serverAddress) {
    serverAddressList.remove(serverAddress);
    hashRing = hashRing(this.serverAddressList);
  }
}

来测试一下,可以看到当Server列表无变化时,固定来源的请求总会被分发给固定的server节点。

import java.util.ArrayList;
import java.util.List;

public class ConsistentHashRouteTest {
  private static final List<String> servers = new ArrayList<>();

  static {
    servers.add("192.168.10.221");
    servers.add("192.168.10.89");
    servers.add("192.168.10.13");
    servers.add("192.10.122.5");
    servers.add("192.10.122.84");
    servers.add("192.10.122.132");
  }

  public static void main(String[] args) {
    ConsistentHashRoute hashRoute = new ConsistentHashRoute(servers);
    String sourceIp = "192.25.13.59";
    for (int i = 0; i < 10; i++) {
      System.out.println(hashRoute.route(sourceIp));
    }
  }
}

image.png