Collectors.toMap中空条目值导致NullPointerException

539

Collectors.toMap方法会在值为null时抛出NullPointerException异常。我不理解这种行为,因为map可以包含值为null的指针并且没有任何问题。是否有充分的理由使得Collectors.toMap不支持值为null的情况?

此外,是否有Java 8中优雅的方式来解决这个问题,或者我应该回归到普通的for循环?

以下是我的问题示例:

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


class Answer {
    private int id;

    private Boolean answer;

    Answer() {
    }

    Answer(int id, Boolean answer) {
        this.id = id;
        this.answer = answer;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public Boolean getAnswer() {
        return answer;
    }

    public void setAnswer(Boolean answer) {
        this.answer = answer;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Answer> answerList = new ArrayList<>();

        answerList.add(new Answer(1, true));
        answerList.add(new Answer(2, true));
        answerList.add(new Answer(3, null));

        Map<Integer, Boolean> answerMap =
        answerList
                .stream()
                .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));
    }
}

堆栈跟踪:

Exception in thread "main" java.lang.NullPointerException
    at java.util.HashMap.merge(HashMap.java:1216)
    at java.util.stream.Collectors.lambda$toMap$168(Collectors.java:1320)
    at java.util.stream.Collectors$$Lambda$5/1528902577.accept(Unknown Source)
    at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
    at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1359)
    at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:512)
    at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:502)
    at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
    at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)
    at Main.main(Main.java:48)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:483)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:134)

这个问题在Java 11中仍然存在。


6
null 一直存在问题,就像在 TreeMap 中一样。也许现在是尝试使用 Optional<Boolean> 的好时机?否则可以进行分割并使用过滤器。 - Joop Eggen
17
“null”可能是键的问题,但在这种情况下,它是值。 - gontard
3
并非所有的映射都会出现null的问题,例如 HashMap 可以有一个 null 键和任意数量的 null 值。您可以尝试使用 HashMap 创建自定义 Collector,而不是使用默认的 Collector - kajacx
6
但默认实现是HashMap - 如堆栈跟踪的第一行所示。问题不在于Map不能容纳null值,而在于Map#merge函数的第二个参数不能为null。 - czerny
1
就个人而言,在给定的情况下,如果输入是并行的,我会选择非流式解决方案或forEach()。下面这些简洁的基于流的解决方案可能会有可怕的性能问题。 - Ondra Žižka
显示剩余2条评论
13个回答

516

您可以通过以下方式解决OpenJDK中的已知问题

Map<Integer, Boolean> collect = list.stream()
        .collect(HashMap::new, (m,v)->m.put(v.getId(), v.getAnswer()), HashMap::putAll);

它并不是非常漂亮,但它能正常工作。结果:

1: true
2: true
3: null

这个教程(this)对我帮助最大。

编辑:

Collectors.toMap不同,如果您有多个相同的键,此方法将默默地替换值,如@mmdemirbas在评论中指出。 如果您不想这样,请查看评论中的链接。


3
是的,供应商(第一个参数)的定义是一个不传递任何参数但返回结果的函数,因此您的情况下lambda表达式应该是() -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER),以创建一个不区分大小写的字符串键TreeMap - Brett Ryan
2
这是正确的答案,而且在我看来,JDK应该将其作为默认的非重载版本。也许合并更快,但我还没有测试过。 - Brett Ryan
1
我必须指定类型参数才能编译,如下所示:Map<Integer, Boolean> collect = list.stream().collect(HashMap<Integer, Boolean>::new, (m,v)->m.put(v.getId(), v.getAnswer()), HashMap<Integer, Boolean>::putAll);。我遇到了以下错误:incompatible types: cannot infer type-variable(s) R (argument mismatch; invalid method reference no suitable method found for putAll(java.util.Map<java.lang.Integer,java.lang.Boolean>,java.util.Map<java.lang.Integer,java.lang.Boolean>) method java.util.Map.putAll(java.util.Map) is not applicable (actual and formal argument lists differ in length) - Anthony O.
3
在大输入上,这可能会非常慢。您需要创建一个HashMap,然后为每个条目调用putAll()方法。个人认为,在特定情况下,如果输入是并行的,我会选择非流解决方案或使用forEach()方法。 - Ondra Žižka
10
请注意,此解决方案的行为与原始的 toMap 实现不同。原始实现会检测重复键并抛出 IllegalStatException 异常,但是此解决方案会静默地接受最新的键。Emmanuel Touzery 的解决方案(https://dev59.com/tWAf5IYBdhLWcg3wcyer#32648397)更接近原始行为。 - mmdemirbas
显示剩余6条评论

209
使用Collectors类的静态方法不可能做到这一点。 toMap 的javadoc解释了 toMap 是基于Map.merge:

@param mergeFunction 合并函数,用于解决与同一键相关联的值之间的冲突,作为传递给Map#merge(Object,Object,BiFunction)}

Map.merge 的javadoc说:

@throws NullPointerException 如果指定的键为null且此映射不支持null键,或者或remappingFunction为null

您可以通过使用列表的 forEach 方法来避免 for 循环。
Map<Integer,  Boolean> answerMap = new HashMap<>();
answerList.forEach((answer) -> answerMap.put(answer.getId(), answer.getAnswer()));

但这并不比旧方法简单:

Map<Integer, Boolean> answerMap = new HashMap<>();
for (Answer answer : answerList) {
    answerMap.put(answer.getId(), answer.getAnswer());
}

8
在 merge 的 javadoc 中有明确说明,但在 toMap 的文档中没有提到。 - Jasper
163
从未想过在映射中的空值会对标准API产生如此大的影响,我更愿意将其视为一个缺陷。 - Askar Kalykov
26
实际上,API文档并未说明 Map.merge 的使用。这个我个人认为是实现中的一个缺陷,它限制了一个被忽视但完全可接受的用例。toMap 的重载方法说明了 Map.merge 的使用,但不包括 OP 正在使用的那个方法。 - Brett Ryan
14
@Jasper,甚至有一个错误报告 https://bugs.openjdk.java.net/browse/JDK-8148463 - pixel
16
我不关心它引擎下面有什么。当我阅读Javadoc时,我只关心合同。Javadoc应该说明如果任何元素为空,则会throws NullPointerException - ACV
显示剩余11条评论

32

我写了一个Collector,与默认的Java Collector不同,在处理null值时不会崩溃:

public static <T, K, U>
        Collector<T, ?, Map<K, U>> toMap(Function<? super T, ? extends K> keyMapper,
                Function<? super T, ? extends U> valueMapper) {
    return Collectors.collectingAndThen(
            Collectors.toList(),
            list -> {
                Map<K, U> result = new HashMap<>();
                for (T item : list) {
                    K key = keyMapper.apply(item);
                    if (result.putIfAbsent(key, valueMapper.apply(item)) != null) {
                        throw new IllegalStateException(String.format("Duplicate key %s", key));
                    }
                }
                return result;
            });
}

只需将您的 Collectors.toMap() 调用替换为此函数的调用即可解决问题。


6
允许使用null值并且使用putIfAbsent方法一起使用时,效果不太好。当键映射到null时,它无法检测重复的键。 - Holger

12

是的,我回答晚了,但我认为这可能有助于理解在幕后发生了什么,以防有人想编写其他Collector逻辑。

我尝试通过编写更本地化和直接的方法来解决问题。 我认为这是尽可能直接的方法:

public class LambdaUtilities {

  /**
   * In contrast to {@link Collectors#toMap(Function, Function)} the result map
   * may have null values.
   */
  public static <T, K, U, M extends Map<K, U>> Collector<T, M, M> toMapWithNullValues(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper) {
    return toMapWithNullValues(keyMapper, valueMapper, HashMap::new);
  }

  /**
   * In contrast to {@link Collectors#toMap(Function, Function, BinaryOperator, Supplier)}
   * the result map may have null values.
   */
  public static <T, K, U, M extends Map<K, U>> Collector<T, M, M> toMapWithNullValues(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, Supplier<Map<K, U>> supplier) {
    return new Collector<T, M, M>() {

      @Override
      public Supplier<M> supplier() {
        return () -> {
          @SuppressWarnings("unchecked")
          M map = (M) supplier.get();
          return map;
        };
      }

      @Override
      public BiConsumer<M, T> accumulator() {
        return (map, element) -> {
          K key = keyMapper.apply(element);
          if (map.containsKey(key)) {
            throw new IllegalStateException("Duplicate key " + key);
          }
          map.put(key, valueMapper.apply(element));
        };
      }

      @Override
      public BinaryOperator<M> combiner() {
        return (left, right) -> {
          int total = left.size() + right.size();
          left.putAll(right);
          if (left.size() < total) {
            throw new IllegalStateException("Duplicate key(s)");
          }
          return left;
        };
      }

      @Override
      public Function<M, M> finisher() {
        return Function.identity();
      }

      @Override
      public Set<Collector.Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
      }

    };
  }

}

使用JUnit和assertj进行测试:

  @Test
  public void testToMapWithNullValues() throws Exception {
    Map<Integer, Integer> result = Stream.of(1, 2, 3)
        .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null));

    assertThat(result)
        .isExactlyInstanceOf(HashMap.class)
        .hasSize(3)
        .containsEntry(1, 1)
        .containsEntry(2, null)
        .containsEntry(3, 3);
  }

  @Test
  public void testToMapWithNullValuesWithSupplier() throws Exception {
    Map<Integer, Integer> result = Stream.of(1, 2, 3)
        .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null, LinkedHashMap::new));

    assertThat(result)
        .isExactlyInstanceOf(LinkedHashMap.class)
        .hasSize(3)
        .containsEntry(1, 1)
        .containsEntry(2, null)
        .containsEntry(3, 3);
  }

  @Test
  public void testToMapWithNullValuesDuplicate() throws Exception {
    assertThatThrownBy(() -> Stream.of(1, 2, 3, 1)
        .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null)))
            .isExactlyInstanceOf(IllegalStateException.class)
            .hasMessage("Duplicate key 1");
  }

  @Test
  public void testToMapWithNullValuesParallel() throws Exception {
    Map<Integer, Integer> result = Stream.of(1, 2, 3)
        .parallel() // this causes .combiner() to be called
        .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null));

    assertThat(result)
        .isExactlyInstanceOf(HashMap.class)
        .hasSize(3)
        .containsEntry(1, 1)
        .containsEntry(2, null)
        .containsEntry(3, 3);
  }

  @Test
  public void testToMapWithNullValuesParallelWithDuplicates() throws Exception {
    assertThatThrownBy(() -> Stream.of(1, 2, 3, 1, 2, 3)
        .parallel() // this causes .combiner() to be called
        .collect(LambdaUtilities.toMapWithNullValues(Function.identity(), x -> x % 2 == 1 ? x : null)))
            .isExactlyInstanceOf(IllegalStateException.class)
            .hasCauseExactlyInstanceOf(IllegalStateException.class)
            .hasStackTraceContaining("Duplicate key");
  }

那么你如何使用它呢?嗯,只需要像测试所示一样使用它,而不是使用toMap()。这使得调用代码看起来尽可能清晰。

编辑:
实现了Holger下面的想法,并添加了一个测试方法


1
合并器不检查重复键。如果您想避免检查每个键,可以使用类似 (map1, map2) -> { int total = map1.size() + map2.size(); map1.putAll(map2); if(map1.size() < total.size()) throw new IllegalStateException("Duplicate key(s)"); return map1; } 的东西。 - Holger
@Holger 是的,这是真的。特别是因为 accumulator() 实际上确实会检查。也许我应该尝试一些并行流 :) - sjngm

11

这是一个比@EmmanuelTouzery提出的收集器更简单的版本。如果您喜欢,可以使用它:

public static <T, K, U> Collector<T, ?, Map<K, U>> toMapNullFriendly(
        Function<? super T, ? extends K> keyMapper,
        Function<? super T, ? extends U> valueMapper) {
    @SuppressWarnings("unchecked")
    U none = (U) new Object();
    return Collectors.collectingAndThen(
            Collectors.<T, K, U> toMap(keyMapper,
                    valueMapper.andThen(v -> v == null ? none : v)), map -> {
                map.replaceAll((k, v) -> v == none ? null : v);
                return map;
            });
}

我们只需将null替换为自定义对象none,并在完成器中执行反向操作。


9

我稍微修改了Emmanuel Touzery的空值安全的Map Collector实现

这个版本:

  • 允许空键
  • 允许空值
  • 检测重复键(即使它们是null),并像原始JDK实现一样抛出IllegalStateException
  • 当键已经映射到空值时,也会检测重复键。换句话说,将具有空值的映射与无映射分开
public static <T, K, U> Collector<T, ?, Map<K, U>> toMapOfNullables(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper) {
    return Collectors.collectingAndThen(
        Collectors.toList(),
        list -> {
            Map<K, U> map = new LinkedHashMap<>();
            list.forEach(item -> {
                K key = keyMapper.apply(item);
                U value = valueMapper.apply(item);
                if (map.containsKey(key)) {
                    throw new IllegalStateException(String.format(
                            "Duplicate key %s (attempted merging values %s and %s)",
                            key, map.get(key), value));
                }
                map.put(key, value);
            });
            return map;
        }
    );
}

单元测试:

@Test
public void toMapOfNullables_WhenHasNullKey() {
    assertEquals(singletonMap(null, "value"),
        Stream.of("ignored").collect(Utils.toMapOfNullables(i -> null, i -> "value"))
    );
}

@Test
public void toMapOfNullables_WhenHasNullValue() {
    assertEquals(singletonMap("key", null),
        Stream.of("ignored").collect(Utils.toMapOfNullables(i -> "key", i -> null))
    );
}

@Test
public void toMapOfNullables_WhenHasDuplicateNullKeys() {
    assertThrows(new IllegalStateException("Duplicate key null"),
        () -> Stream.of(1, 2, 3).collect(Utils.toMapOfNullables(i -> null, i -> i))
    );
}

@Test
public void toMapOfNullables_WhenHasDuplicateKeys_NoneHasNullValue() {
    assertThrows(new IllegalStateException("Duplicate key duplicated-key"),
        () -> Stream.of(1, 2, 3).collect(Utils.toMapOfNullables(i -> "duplicated-key", i -> i))
    );
}

@Test
public void toMapOfNullables_WhenHasDuplicateKeys_OneHasNullValue() {
    assertThrows(new IllegalStateException("Duplicate key duplicated-key"),
        () -> Stream.of(1, null, 3).collect(Utils.toMapOfNullables(i -> "duplicated-key", i -> i))
    );
}

@Test
public void toMapOfNullables_WhenHasDuplicateKeys_AllHasNullValue() {
    assertThrows(new IllegalStateException("Duplicate key duplicated-key"),
        () -> Stream.of(null, null, null).collect(Utils.toMapOfNullables(i -> "duplicated-key", i -> i))
    );
}

对重复消息进行了轻微改进,以反映Java 11中的消息,该消息还包括重复值:U value = valueMapper.apply(item); if (map.containsKey(key)) { throw new IllegalStateException(String.format("Duplicate key %s (attempted merging values %s and %s)", key, map.get(key), value)); } map.put(key, value); - M. Justin

4
如果值是字符串类型,那么以下代码可能会起作用: map.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> Optional.ofNullable(e.getValue()).orElse("")))

7
只有在您允许修改数据的情况下,这才有效。下游方法可能期望空值而不是空字符串。 - Sam Buchmiller

4

很抱歉重新打开一个旧问题,但由于最近编辑时说Java 11仍存在“问题”,我觉得我想指出这一点:

answerList
        .stream()
        .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));

由于map不允许null作为值,因此会给您提供空指针异常。 这是有道理的,因为如果在地图中查找键k并且不存在,则返回的值已经是null(请参阅javadoc)。 因此,如果您能够在k中放入值null,则地图看起来会表现出奇怪的行为。
正如评论中有人所说,通过使用过滤器很容易解决这个问题:
answerList
        .stream()
        .filter(a -> a.getAnswer() != null)
        .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));

以这种方式,不会向地图中插入null值,但是当查找地图中没有答案的id时,仍将得到null作为“值”。希望大家都能理解。

8
如果地图不允许空值,那么这是有道理的,但实际上它是允许的。您可以使用answerMap.put(4, null);而没有任何问题。您提出的解决方案可以得到与插入null值相同的结果,如果值不存在时调用anserMap.get()也一样。然而,如果您迭代地图的所有条目,则显然存在差异。 - Jasper

3
根据 Stacktrace
Exception in thread "main" java.lang.NullPointerException
at java.util.HashMap.merge(HashMap.java:1216)
at java.util.stream.Collectors.lambda$toMap$148(Collectors.java:1320)
at java.util.stream.Collectors$$Lambda$5/391359742.accept(Unknown Source)
at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1359)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:512)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:502)
at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)
at com.guice.Main.main(Main.java:28)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:483)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:134)

当调用map.merge

        BiConsumer<M, T> accumulator
            = (map, element) -> map.merge(keyMapper.apply(element),
                                          valueMapper.apply(element), mergeFunction);

在开始时它会进行null检查。

if (value == null)
    throw new NullPointerException();

我不经常使用Java 8,所以我不知道是否有更好的解决方法,但是修复它有点困难。
您可以这样做:
使用filter过滤所有NULL值,在JavaScript代码中检查服务器是否没有发送任何答案来表示它没有回答该ID。
类似于以下内容:
Map<Integer, Boolean> answerMap =
        answerList
                .stream()
                .filter((a) -> a.getAnswer() != null)
                .collect(Collectors.toMap(Answer::getId, Answer::getAnswer));

或者使用peek,它用于逐个更改流元素。使用peek,您可以将答案更改为对map更加可接受的内容,但这意味着需要稍微调整您的逻辑。

听起来如果您想保留当前设计,则应避免使用Collectors.toMap


2
保留所有问题id,稍作修改
Map<Integer, Boolean> answerMap = 
  answerList.stream()
            .collect(Collectors.toMap(Answer::getId, a -> 
                       Boolean.TRUE.equals(a.getAnswer())));

我认为这是最好的答案 - 它是最简洁的答案,而且它修复了NPE问题。 - LConrad
技巧是equals()处理null,所以上面就足够了。 - sigirisetti

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接