本文目录导读:

这是一个经典且棘手的 Java Swing 问题,JEditorPane + HTMLEditorKit + Parser 在并发环境下非常容易出现 ConcurrentModificationException。
核心原因
HTMLEditorKit 内部使用的 Parser(如 javax.swing.text.html.parser.ParserDelegator)在解析 HTML 时,会回调 HTMLEditorKit.ParserCallback 的各种方法(如 handleText, handleStartTag, handleEndTag 等)。
问题发生的典型场景是:
- 主线程(EDT,也就是事件调度线程) 正在使用
JEditorPane.setText()或直接操作文档模型(Document)来显示 HTML。 - 在这个过程中,
Parser正在遍历内部数据结构(如标签栈、属性列表、字符数组)。 - 另一个线程(并非 EDT,而是 EDT 之外的线程)同时尝试:
- 调用
setText()设置新的 HTML。 - 直接修改
Document(例如插入、删除字符)。 - 或者
Parser内部的回调逻辑中,错误地触发了对同一个Document的修改。
- 调用
由于 Java 集合(ArrayList、HashMap 等)在迭代(遍历)时被结构性修改,ConcurrentModificationException 就会抛出。
为什么 JEditorPane 特别容易中招?
- 非线程安全:
JEditorPane和其内部的Document类(如HTMLDocument)不是为多线程并发访问设计的,所有的 Swing 组件操作都应该在 事件调度线程(EDT) 上执行。 - 解析器的内部迭代:
Parser在解析时会创建多个内部迭代器来扫描 HTML 标签和属性,如果用户在ParserCallback方法实现里(handleText方法中)调用setText()或append()来修改文档,就会在解析器迭代过程中修改文档结构,触发异常。
解决方案(优先级从高到低)
确保所有修改都在 EDT 上执行(最推荐)
这是 Swing 的黄金法则,任何对 JEditorPane 及其 Document 的修改,都必须通过 SwingUtilities.invokeLater 或 SwingUtilities.invokeAndWait 提交到 EDT。
// 错误示例:在后台线程中直接修改
new Thread(() -> {
// 模拟耗时计算
String newHtml = "<html><body>新内容</body></html>";
// 这里会抛出 ConcurrentModificationException
editorPane.setText(newHtml);
}).start();
// 正确示例:通过 invokeLater 提交到 EDT
new Thread(() -> {
// 模拟耗时计算
String newHtml = "<html><body>新内容</body></html>";
// 将修改操作提交到事件调度线程
SwingUtilities.invokeLater(() -> {
editorPane.setText(newHtml);
});
}).start();
避免在回调方法中修改文档
如果你重写了 HTMLEditorKit.ParserCallback 的方法(如 handleText),一定要避免在这些方法内部直接调用 setText()、insertString()、remove() 等会修改文档结构的方法,因为此时解析器正在遍历内部结构。
// 错误示例:在回调中修改文档
HTMLEditorKit.ParserCallback callback = new HTMLEditorKit.ParserCallback() {
@Override
public void handleText(char[] data, int pos) {
// 这里会抛出 ConcurrentModificationException!!!
((HTMLDocument) editorPane.getDocument()).setText("修改一下");
}
};
正确做法: 使用 SwingUtilities.invokeLater 将后续操作延迟到 EDT 的空闲时间执行。
@Override
public void handleText(char[] data, int pos) {
String text = new String(data);
if (text.contains("特定内容")) {
// 不要直接修改,而是安排一个延迟任务
SwingUtilities.invokeLater(() -> {
editorPane.setText("<html><body>修改后的内容</body></html>");
});
}
}
使用 DocumentFilter 或 DocumentListener 的注意事项
如果使用 DocumentFilter 或 DocumentListener 来监控文档变化,请确保在它们的回调方法中 不要再次修改同一个文档对象,否则会陷入无限递归或并发修改异常,如果需要修改,使用 SwingUtilities.invokeLater。
同步锁(不太推荐,但可应急)
如果你必须从多个线程间接访问(一个后台线程需要触发解析器的运行),可以使用同步块,但要非常小心死锁。
// 使用一个锁对象
private final Object documentLock = new Object();
public void updateHtmlFromBackgroundThread(String newHtml) {
synchronized (documentLock) {
SwingUtilities.invokeLater(() -> {
editorPane.setText(newHtml);
});
}
}
注意: 这种方法依然强烈推荐只使用 invokeLater,因为 Swing 组件默认就是单线程模型,使用锁会增加复杂度和出错风险。
关闭或替换默认解析器(高级用法)
如果默认的 ParserDelegator 在你的特定场景下总是出问题,你可以在创建 EditorKit 时禁用解析器或使用自定义的更安全的解析器,但这个方法比较极端,通常不需要。
调试技巧
- 检查调用栈: 当异常发生时,查看调用栈,你是从
EDT还是从后台线程调用的?异常是在setText()中触发的,还是在ParserCallback的回调中触发的? - 使用
SwingUtilities.isEventDispatchThread(): 在关键修改操作前打印日志,确认当前线程是否是 EDT。if (SwingUtilities.isEventDispatchThread()) { System.out.println("在 EDT 上执行,安全"); } else { System.out.println("危险!不在 EDT 上!"); } - 简化重现: 创建一个最小的可运行示例,只包含
JEditorPane、一个后台线程和简单的setText调用,看是否复现,如果能复现,99% 是线程问题。
| 场景 | 解决方案 |
|---|---|
后台线程修改 JEditorPane |
必须使用 SwingUtilities.invokeLater() |
在 ParserCallback 中修改文档 |
不允许,改用 invokeLater 延迟执行 |
| 多线程访问 | 强制所有操作在 EDT 上执行,这是 Swing 唯一正确的方式 |
| 复杂文档操作 | 使用 DocumentFilter 并确保不在回调中修改自身 |
最直接、最有效的解决方法是:只要修改 JEditorPane 或它的 Document,就把代码放到 SwingUtilities.invokeLater(() -> { ... }) 里面,所有其他方法都是锦上添花。