Java集合疑问:未添加null值却出现NPE的原因探究
问题描述
我最近在一个原本认为不可能出现NPE的场景中遇到了该异常。这个类运行在高并发多线程程序中,我知道多线程环境下什么都可能发生,但仍对此感到困惑。
该类定义了如下字段:
private final Set<TcpIpConnection> _connections = new LinkedHashSet<>();
在整个类中,这个集合仅在两处被操作:
// 某个方法 TcpIpConnection tcpipConnection = new ServerConnection(clientSocket, _channels, MyClass.this); _connections.add(tcpipConnection); // 另一个方法 _connections.remove(connection);
显然,这里没有任何可能向集合中添加null值,且该集合始终在类内部使用,从未对外暴露。
但现在我的测试用例有时会在以下语句中抛出NPE,这是类中唯一使用_connections的其他语句:
new ArrayList<>(_connections).stream().forEach(c -> c.close("Server down"));
如你所见,我已经通过先将集合复制为ArrayList来避免ConcurrentModificationException。
现在NPE出现在变量c上,而c应该是之前添加到_connections中的值——但它怎么会变成null呢?
需要说明的是,我并非寻求解决方案——我已经在流中添加了filter(Objects::nonNull)(或者也可以在初始化时使用Collections.synchronizedSet()),现在问题已得到解决。
这种情况究竟是如何发生的?没错,多线程访问几乎会搞砸一切,但怎么会在原本没有null的集合中出现null呢?
原因分析
核心问题在于**LinkedHashSet本身不是线程安全的集合**,即使没有主动添加null值,并发的读(迭代复制)和写(add/remove)操作也会破坏其内部的数据结构,导致迭代器返回null。
具体来说:
LinkedHashSet基于双向链表实现,迭代器遍历完全依赖链表节点的引用关系。- 当一个线程执行
new ArrayList<>(_connections)时,会启动集合的迭代器遍历所有元素;与此同时,另一个线程执行remove操作,会修改链表中对应节点的前后引用。 - 这种并发修改会导致迭代器的遍历逻辑出现异常:比如迭代器刚拿到某个节点的引用,该节点就被移除,链表的引用链被打断,迭代器后续遍历可能拿到已经被清理的节点引用(表现为null);或者因为节点的
next/prev被篡改,遍历到不存在的位置返回null。
虽然LinkedHashSet的迭代器是fail-fast设计,但fail-fast的触发条件是检测到modCount和expectedModCount不一致,这并不是100%能捕获所有并发修改场景的——在某些时序下,并发修改不会触发fail-fast,而是导致迭代器返回异常值(比如null),最终引发NPE。
内容的提问来源于stack exchange,提问作者slarti76

