Java 集合:NPE,即使从未添加过 null 值

Java Collections: NPE even though no null value was ever added

提问人:slarti76 提问时间:11/8/2023 最后编辑:Mark Rotteveelslarti76 更新时间:11/14/2023 访问量:79

问:

我最近在我绝对确定这是不可能的情况下获得了 NPE。该类用于一个非常多线程的程序中,所以我知道你应该期望基本上任何事情都是可能的,但仍然如此。

因此,该类定义了以下字段:

private final Set<TcpIpConnection> _connections = new LinkedHashSet<>();

在整个类中,这个集合只在两个地方作:

// some method
  TcpIpConnection tcpipConnection = new ServerConnection(clientSocket, _channels, MyClass.this);
  _connections.add(tcpipConnection);
// some other method
  _connections.remove(connection);

所以我想你会同意,不可能在集合中添加一个。是的,这套设备留在课堂上,永远不会在外面扩散。null

但是现在我有一个测试用例,它有时会在以下语句中因 NPE 而失败,这是类中唯一使用的其他语句:_connections

new ArrayList<>(_connections).stream().forEach(c -> c.close("Server down"));

正如你所看到的,我已经通过首先创建集合的本地副本来阻止 .ConcurrentModificationExceptionArrayList

现在 NPE 出现 ,它必须是之前添加的值 - 但这怎么可能变成 ?c_connectionsnull

需要明确的是,我不是在寻找解决方案——我在流中添加了(或者我可以在初始化器中使用),并且它现在可以保证工作。filter(Objects::nonNull)Collections.synchronizedSet()

这怎么可能呢?是的,多线程访问几乎可以搞砸所有事情,但是将一个不存在的集合放在一个集合中?null

Java 多线程 集合 NullPointerException

评论

1赞 user207421 11/8/2023
请堆栈跟踪。
2赞 Holger 11/8/2023
将集合复制到 like with 并不能保证您不会得到 ,因为复制时可能已经发生了该异常。但是:1.获得数据不是真正的问题,损坏的数据更糟 2.您获得损坏的数据,因为您正在同时修改。当一切都丢失时,即当你想要迭代集合时,考虑线程安全为时已晚。ArrayListnew ArrayList<>(_connections)ConcurrentModificationExceptionConcurrentModificationExceptionLinkedHashSet
0赞 slarti76 11/8/2023
@Holger 谢谢,你是对的,我避免 CME 的方法有缺陷。我得再看一遍。然而,这并不能解释它是如何进入那里的。你写“你得到损坏的数据”——但是当我证明从未添加过时,我怎么能在那里得到一个?即使多个线程同时调用,它们也永远无法添加 ,因为 位于堆栈本地,并且永远不会 。nullnullnull_connections.add(tcpipConnection)nulltcpipConnectionnull
2赞 user207421 11/8/2023
null 到达那里是因为你违反了使用条件。解决方案:不要。我不明白为什么你认为一个明显的空值是不可能的。当你打破规则时,一切皆有可能。
2赞 Holger 11/8/2023
这就是以非线程安全的方式使用可变对象时所得到的 a) “不可能”值,如虚假 b) “不可能”行为,例如在无害的 a 或 c) 异常上无限循环,这是最无害的类别,因为它会立即告诉你有问题。如果我告诉你,在你添加一个元素之前就已经存在了,也许它会有所帮助,你永远不会在单个线程或正确同步的程序中看到它。nullgetHashMapnull

答:

1赞 fxrobin 11/8/2023 #1

正如你提到的,你必须在你的初始值设定项中使用 a。但这还不够:迭代器不安全。Collections.synchronizedSet()

操作在不同线程中未同步的集合可能会弄乱(肯定会)内部状态。(即条目之间的双链接等)LinkedHashSet

一个好方法是将 your 封装到另一个类中,并提供同步方法来添加和删除连接,并且至少返回 Set 的线程安全副本。Set

可能是这样的:

class ConnectionManager
{
  public static ConnectionManager instance = new ConnectionManager();
  
  private Set<TcpIpConnection> connections = new LinkedHashSet<>();

  private ConnectionManager { } // protection against external construction

  public static ConnectionManager getInstance() {
     return instance;
  }

  public synchronized void add(TcpIpConnection connection) {
      this.connections.add(connections);
  }
 
  public synchronized void remove(TcpIpConnection connection) {
      this.connections.remove(connections);
  }

  // returns a synchronized copy and unmodifiable Set (thanx to Holger remark)
  public synchronized  Set <TcpIpConnection> getConnections() {
     return Collections.unmodifiableSet(new LinkedHashSet(this.connections));
  }
 }

然后在您的代码中:

TcpIpConnection tcpipConnection = new ServerConnection(clientSocket, _channels, MyClass.this);
ConnectionManager.getInstance().add(tcpipConnection);

// or

ConnectionManager.getInstance().remove(tcpipConnection);

并使用您的流:

ConnectionManager.getInstance()
                 .getConnections()
                 .stream()
                 .forEach(c -> c.close("Server down"));

评论

1赞 slarti76 11/8/2023
谢谢,这看起来很有希望。但是,我从上面的另一条评论和随后的 Google 研究中了解到,这实际上在这里根本没有帮助,因为它不会同步迭代器。所以也许你应该相应地编辑你的第一句话。Java 真正需要的是一个......Collections.synchronizedSet()ConcurrentLinkedHashSet
1赞 Holger 11/8/2023
@slarti76 没有,因为维护迭代顺序的成本很高,同时又令人怀疑,因为“插入顺序”对于并发插入毫无意义。您可以使用,但必须记住在迭代集合(包括流操作)时使用。或者恢复您的复制方法:ConcurrentLinkedHashSetCollections.synchronizedSet()synchronized(set) { … }_connections = Collections.synchronizedSet(…); … ArrayList<TcpIpConnection> workingCopy; synchronized(set) { workingCopy = new ArrayList<>(_connections); } workingCopy.stream().forEach(c -> c.close("Server down"));
0赞 fxrobin 11/8/2023
@Holger,是的,你是对的。最好返回一个副本,一个同步的副本。Il 将修改我的例子。
0赞 slarti76 11/8/2023
谢谢大家!
0赞 fxrobin 11/9/2023
@slarti76:“所以也许你应该相应地编辑你的第一句话”,是的,我刚刚做到了。也感谢这一点。
1赞 samabcde 11/8/2023 #2

只是为了解释为什么 null 是可能的

以下是参考 jdk-21+35

在 中,它将调用
_connections.toArray() -> HashSet#toArray()(因为 LinkedHashSet 扩展了 HashSet) -> LinkedHashMap#keysToArray()(因为 LinkedHashSet 由 LinkedHashMap 支持)

new ArrayList<>(_connections)

    // HashSet#toArray()
    @Override
    public Object[] toArray() {
        return map.keysToArray(new Object[map.size()]);
    }

在这里,我们可以看到一个 Object[] 是用 length 构造的,这个 Object[] 将被复制到 ArrayList。map.size()

    // LinkedHashMap#keysToArray()
    final <T> T[] keysToArray(T[] a) {
        return keysToArray(a, false);
    }

    final <T> T[] keysToArray(T[] a, boolean reversed) {
        Object[] r = a;
        int idx = 0;
        if (reversed) {
            for (LinkedHashMap.Entry<K,V> e = tail; e != null; e = e.before) {
                r[idx++] = e.key;
            }
        } else {
            for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
                r[idx++] = e.key;
            }
        }
        return a;
    }

以上是我们如何为 Object[] 设置值。 因此,在并发环境中,当我们调用 时,映射大小可能是 2,但是当我们转到 时,映射大小可能是 1(中间的一些线程删除了元素),因此 Object[] 中可能有一些 null 元素。反之亦然。HashSet#toArray()LinkedHashMap#keysToArray()ArrayIndexOutOfBound

结论

在多线程环境中工作时,永远不要假设任何非线程安全类的行为,有 0 保证。

评论

4赞 Holger 11/8/2023
这是一种可能的情况。但是,删除不是遇到 .例如,读取线程可能会读取 或 的初始值 (),而缺少插入线程所做的更新,而它已经看到了更新的 .事实上,内存模型允许读取这些字段的非引用,然后在以后的读取中再次读取。极不可能,但并非不可能......nullnullheade.aftersizenullnull
0赞 slarti76 11/8/2023
谢谢,现在我明白了!我确实尝试查看 API 源代码,但有点迷失;)无论如何,这让它更清楚了 - 我知道我真的必须以不同的方式处理这个问题才能成为线程安全。