8.3 使用Map

Java 8在Map接口中新引入了几个默认方法(第13章会详细介绍默认方法,目前你可以把它当作接口中预先实现好了的方法)。增加这些新操作的目的是通过提供惯用模式,减少重复实现的开销,以帮助大家编写更加简洁的代码。接下来我们会逐一了解这些新增的操作,首先介绍全新的forEach方法。

8.3.1 forEach方法

一直以来,遍历Map中的键和值都是非常笨拙的操作。实际上,你需要使用Map.Entry迭代器访问Map集合中的每一个元素:

  1. for(Map.Entry<String, Integer> entry: ageOfFriends.entrySet()) {
  2. String friend = entry.getKey();
  3. Integer age = entry.getValue();
  4. System.out.println(friend + " is " + age + " years old");
  5. }

从Java 8开始,Map接口开始支持forEach方法,该方法接受一个BiConsumer,以Map的键和值作为参数。使用forEach方法会让你的代码更简洁:

  1. ageOfFriends.forEach((friend, age) -> System.out.println(friend + " is " +
  2. age + " years old"));

与迭代相关的一个问题是对集合中元素的排序。Java 8引入了几个新的方法,可以方便地对Map中的元素进行比较。

8.3.2 排序

有两种新的工具可以帮助你对Map中的键或值排序,它们是:

  • Entry.comparingByValue
  • Entry.comparingByKey

比如下面的代码:

  1. Map<String, String> favouriteMovies
  2. = Map.ofEntries(entry("Raphael", "Star Wars"),
  3. entry("Cristina", "Matrix"),
  4. entry("Olivia",
  5. "James Bond"));
  6. favouriteMovies
  7. .entrySet()
  8. .stream()
  9. .sorted(Entry.comparingByKey())
  10. .forEachOrdered(System.out::println); ←---- 按照人名的字母顺序对流中的元素进行排序

按照顺序,输出如下:

  1. Cristina=Matrix
  2. Olivia=James Bond
  3. Raphael=Star Wars

HashMap及其性能

为了提升HashMap的性能,Java 8更新了HashMap的内部数据结构。通常情况下,Map的项都存放在依据键的散列值选择的桶(bucket)中。然而,如果大量的键返回同一个散列值,HashMap的性能就会急剧下降,因为桶是由链接列表(LinkedList)实现的,而它的时间复杂度是O(n)。现在,如果桶变得过大,它们就会动态地被排序树替换,新数据结构的查询时间复杂度是O(log(n)),能极大地提高碰撞元素的查询速度。注意,只有当键是字符串或者数字类型的可比较对象时,这种排序树的数据结构变换才可能发生。

还有一种通用模式没有讨论,即你要查找的键在Map中不存在该怎么办。新的getOrDefault方法可以解决这一问题。

8.3.3 getOrDefault方法

你要查找的键在Map中并不存在时,就会收到一个空引用,你需要检查返回值以避免遭遇NullPointerException。处理这种情况的一种通用做法是提供一个默认值。使用getOrDefault方法,你可以轻松地在代码中应用这一思想。getOrDefault以接受的第一个参数作为键,第二个参数作为默认值(在Map中找不到指定的键时,该默认值会作为返回值):

  1. Map<String, String> favouriteMovies
  2. = Map.ofEntries(entry("Raphael", "Star Wars"),
  3. entry("Olivia", "James Bond"));
  4. System.out.println(favouriteMovies.getOrDefault("Olivia", "Matrix")); ←---- 输出James Bond
  5. System.out.println(favouriteMovies.getOrDefault("Thibaut", "Matrix")); ←---- 输出Matrix

注意,如果键在Map中存在,但碰巧被赋予的值是null,那么getOrDefault还是会返回null。此外,无论该键存在与否,你作为参数传入的表达式每次都会被执行。

Java 8还包含了其他几个依据键或值存在或不存在的状况进行相关处理的高级方法。下一节会学习这些新的方法。

8.3.4 计算模式

有些时候,你希望依据键在Map中存在或者缺失的状况,有条件地执行某个操作,并存储计算的结果。例如,你希望缓存某个昂贵操作的结果,将其保存在一个键对应的值中。如果该键存在,就不需要再次展开计算。解决这个问题有三种新的途径:

  • computeIfAbsent——如果指定的键没有对应的值(没有该键或者该键对应的值是空),那么使用该键计算新的值,并将其添加到Map中;
  • computeIfPresent——如果指定的键在Map中存在,就计算该键的新值,并将其添加到Map中;
  • compute——使用指定的键计算新的值,并将其存储到Map中。

computeIfAbsent的一个应用场景是缓存信息。假设你要解析一系列文件中每一个行的内容并计算它们的SHA-256值。如果你之前已经处理过这些数据,就没有必要重复计算。

设想你已经使用Map实现了一种缓存,现在你使用MessageDigest的实例来计算SHA-256的散列值:

  1. Map<String, byte[]> dataToHash = new HashMap<>();
  2. MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");

接着,你可以遍历已有的数据,并缓存计算的结果:

  1. lines.forEach(line ->
  2. dataToHash.computeIfAbsent(line, ←---- lineMap中查找的键
  3. this::calculateDigest)); ←---- 如果键不存在,就执行该操作
  4. private byte[] calculateDigest(String key) { ←---- 计算给定键散列值的辅助方法
  5. return messageDigest.digest(key.getBytes(StandardCharsets.UTF_8));
  6. }

这一模式对于存储多个值的Map也是非常有帮助的,其可以简化Map的处理。如果你需要向Map>中添加一个元素,那么需要确保该条目已经初始化了。这种模式实施起来会比较烦琐。假设你想要为你的朋友Raphael创建一个电影列表:

  1. String friend = "Raphael";
  2. List<String> movies = friendsToMovies.get(friend);
  3. if(movies == null) { ←---- 检查列表已经完成了初始化
  4. movies = new ArrayList<>();
  5. friendsToMovies.put(friend, movies);
  6. }
  7. movies.add("Star Wars"); ←---- 添加电影
  8. System.out.println(friendsToMovies); ←---- {Raphael: [Star Wars]}

怎样才能用computeIfAbsent替代上面的代码呢?它要具备这样的能力:如果键不存在就计算该键的值,并将其添加到Map中,否则就直接返回当前Map中对应键的值。可以像下面这样使用该方法:

  1. friendsToMovies.computeIfAbsent("Raphael", name -> new ArrayList<>())
  2. .add("Star Wars"); ←---- {Raphael: [Star Wars]}

如果Map中存在键对应的值,并且该值不为空,computeIfPresent方法就计算该键的新值。请注意一个微妙的地方:如果生成结果的方法返回的值为空,那么当前的映射就会从Map中移除。不过,如果你需要从Map中删除一个映射,那新引入的重载版本的remove方法更适合这一任务。下一节会学习该方法。

8.3.5 删除模式

你已经知道使用remove方法可以从Map中删除指定键对应的映射条目。Java 8提供了一个重载版本的remove方法,现在你可以删除Map中某个键对应某个特定值的映射对。之前的版本中,要实现类似的功能,你可能需要编写下面这样的代码(我们并不想贬低汤姆·克鲁斯,不过《侠探杰克2》的口碑实在是太差了):

  1. String key = "Raphael";
  2. String value = "Jack Reacher 2";
  3. if (favouriteMovies.containsKey(key) &&
  4. Objects.equals(favouriteMovies.get(key), value)) {
  5. favouriteMovies.remove(key);
  6. return true;
  7. }
  8. else {
  9. return false;
  10. }

要实现同样的功能,你只需要下面这一行代码。是不是简单直观很多?

  1. favouriteMovies.remove(key, value);

下一节会继续介绍替换和删除Map中元素的方法。

8.3.6 替换模式

Map中提供了两种新的方法来替换其内部映射项,分别是:

  • replaceAll——通过BiFunction替换Map中每个项的值。该方法的工作模式类似于之前介绍过的ListreplaceAll方法;
  • Replace——如果键存在,就可以通过该方法替换Map中该键对应的值。它是对原有replace方法的重载,可以仅在原有键对应某个特定的值时才进行替换。

你可以用下面的方式格式化Map中所有的值:

  1. Map<String, String> favouriteMovies = new HashMap<>(); ←---- 因为要使用replaceAll方法,所以只能创建可变的Map
  2. favouriteMovies.put("Raphael", "Star Wars");
  3. favouriteMovies.put("Olivia", "james bond");
  4. favouriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());
  5. System.out.println(favouriteMovies); ←---- {Olivia=JAMES BOND, Raphael=STAR WARS}

我们介绍的替换模式仅支持单一Map。如果需要合并两个Map并替换中间的值该怎么办呢?可以使用新的merge方法来完成该任务。

8.3.7 merge方法

假设你需要合并两个临时的Map,它们可能是两个不同联系人群构成的Map。可以像下面这样,使用putAll完成这一任务:

  1. Map<String, String> family = Map.ofEntries(
  2. entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
  3. Map<String, String> friends = Map.ofEntries(
  4. entry("Raphael", "Star Wars"));
  5. Map<String, String> everyone = new HashMap<>(family);
  6. everyone.putAll(friends); ←---- 复制friends的所有条目到everyone
  7. System.out.println(everyone); ←---- {Cristina=James Bond, Raphael= Star Wars, Teo=Star Wars}

只要你的Map中不含有重复的键,这段代码就会工作得非常好。如果你想要在合并时对值有更加灵活的控制,那么可以考虑使用Java 8中新引入的merge方法。该方法使用BiFunction方法处理重复的键。例如,Cristina同时在“家庭”和“朋友”这两个群里,但其在不同群中对应的电影不同:

  1. Map<String, String> family = Map.ofEntries(
  2. entry("Teo", "Star Wars"), entry("Cristina", "James Bond"));
  3. Map<String, String> friends = Map.ofEntries(
  4. entry("Raphael", "Star Wars"), entry("Cristina", "Matrix"));

可以用merge方法结合forEach来解决该冲突。下面这段代码连接了键重复的两部电影名:

  1. Map<String, String> everyone = new HashMap<>(family);
  2. friends.forEach((k, v) ->
  3. everyone.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2)); ←---- 如果存在重复的键,就连接两个值
  4. System.out.println(everyone); ←---- 输出{Raphael=Star Wars, Cristina=JamesBond & Matrix, Teo=Star Wars}

注意,merge方法处理空值的方法相当复杂,在Javadoc文档中是这么描述的:

如果指定的键并没有关联值,或者关联的是一个空值,那么[merge]会将它关联到指定的非空值。否则,[merge]会用给定映射函数的[返回值]替换该值,如果映射函数的返回值为空就删除[该键]。

还可以用merge执行初始化检查。例如,你有一个记录电影被观看了多少次的Map。你得先检查代表某电影的键存在于Map中,之后才可以增加它的值:

  1. Map<String, Long> moviesToCount = new HashMap<>();
  2. String movieName = "James Bond";
  3. long count = moviesToCount.get(movieName);
  4. if(count == null) {
  5. moviesToCount.put(movieName, 1);
  6. }
  7. else {
  8. moviesToCount.put(moviename, count + 1);
  9. }

采用新的方法,这段代码可以重写如下:

  1. moviesToCount.merge(movieName, 1L, (key, count) -> count + 1L);

传递给merge方法的第二个参数是1L。Javadoc文档中说该参数是“与键关联的非空值,该值将与现有的值合并,如果没有当前值,或者该键关联的当今值为空,就将该键关联到非空值”。因为该键的返回值是空,所以第一轮里键的值被赋值为1。接下来的一轮,由于键已经初始化为1,因此后续的操作由BiFunction方法对count进行递增。

你已经学完了Map接口的新特性。Map的“嫡亲”ConcurrentHashMap也新增了功能。我们会在接下来的内容中学习。

测验8.2

请思考,下面这段代码实现了什么功能,可以使用哪些惯用方法对它进行简化:

  1. Map<String, Integer> movies = new HashMap<>();
  2. movies.put("JamesBond", 20);
  3. movies.put("Matrix", 15);
  4. movies.put("Harry Potter", 5);
  5. Iterator<Map.Entry<String, Integer>> iterator =
  6. movies.entrySet().iterator();
  7. while(iterator.hasNext()) {
  8. Map.Entry<String, Integer> entry = iterator.next();
  9. if(entry.getValue() < 10) {
  10. iterator.remove();
  11. }
  12. }
  13. System.out.println(movies); ←—— {Matrix=15, JamesBond=20}

答案:可以对Map的集合项使用removeIf方法,该方法接受一个谓词,依据谓词的结果删除元素。

  1. movies.entrySet().removeIf(entry -> entry.getValue() < 10);