
简介Java敏感词筛选系统源码包是一份面向Java开发者的文本过滤实战项目适合正在学习字符串匹配、数据结构与并发编程的初中级工程师参考。项目围绕敏感词库构建、高效匹配算法、分词处理等核心模块展开覆盖Aho-Corasick、Trie树、KMP、正则表达式等常见技术点可帮助读者理解并复用在社区、论坛等内容安全场景下的敏感词过滤方案。资源包共1454个文件大小16.01MB主体为306个Java源码文件同时包含458个JavaScript、235个HTML与92个CSS推测带有管理界面与前端展示另有29个XML、14个VM、5个YAML等配置文件以及若干图片与字体资源可支撑项目运行与二次开发。目前已有267人学习下载包内目录结构清晰便于按功能模块检索。通过研读源码可掌握完整敏感词系统的工程实现思路包括词库动态更新、AC自动机构建、多线程批量文本处理、异常处理与JUnit测试等结合前端页面可快速调试接口并验证过滤效果适合作为毕业设计、课程项目或企业内部分享的参考素材。1. 先说结论这套 Java 敏感词筛选系统源码好用的不是词库是 DFA做过内容审核的同学都有这种体验线上评论区被广告和垃圾内容刷屏运营拿着截图来找你你打开代码一看用的还是String.contains()一段一段地判断逻辑写得跟连环画一样。敏感词筛选这事儿最容易翻车的不是词库不够全而是算法选错了。这套Java敏感词筛选系统源码.zip拿到手核心是一个基于 DFA确定性有限自动机的敏感词筛选器配合词库加载、替换、命中检测一套完整流程。它解决的是「词库量大、文本量高的时候匹配还能不能快」这个真问题不是新手练手的玩具代码。适合正在做评论系统、聊天室、内容审核后端的 Java 开发也适合面试前拿来做项目经验的应届生。2. 为什么是 DFA 而不是正则和 contains先把匹配原理吃透再用源码2.1 三连问contains 为什么慢正则为什么更靠不住很多人一上来就写if (text.contains(词A) || text.contains(词B))词库只有几十个词的时候这段代码能跑词库一上规模就彻底露馅。contains每次调用都是从文本头部开始逐字符找子串时间复杂度是 O(n * m)n 是文本长度m 是词条长度。如果词库有 5000 个词、每条用户评论 200 个字符最坏情况下一次检查要做5000 * 200 * 平均词长次字符比较一分钟几百条评论就扛不住了。正则更坑。Pattern.matches()看起来挺高级实际上正则引擎在匹配分叉路径时有回溯机制遇到.*或者|并列这种模式在长文本里可能指数级膨胀。我有一次在生产环境看到Pattern.compile((赌|毒|枪|爆|代|办){2})这种写法线上 CPU 直接被打满查了半天才发现是某条 300 字的评论触发了回溯爆炸。更重要的是contains和正则都是「每个词单独跑一遍文本」词与词之间没有任何复用等于同一篇文本被反复扫了几千次。2.2 DFA 的核心思路把词库压缩成一张状态转移图这套源码里用的是 DFA 的思想把所有敏感词构建成一棵 Trie 树前缀树扫描文本时只走一遍每个字符最多查一次哈希表整体匹配复杂度是 O(n) 级别。举个例子词库里有两个词「AD」和「广告」构建出来的树长这样根节点 - A - D结束 根节点 - 广 - 告结束匹配文本「这里有一条广告信息」时从头开始取字符第一个字符「这」在根节点下找不到跳过「里」也找不到「有」找不到「一」找不到取到「广」时发现根节点下有这个分支走进去取「告」发现当前节点的子节点里有且标记了结束状态。此时就知道「广告」命中了整个扫描只遍历了文本一遍。这套源码里树的实现没有用标准 Trie 库而是用了HashMapCharacter, Object嵌套的方式每一层节点就是一个 HashMap键是字符值要么是下一层节点要么是结束标记。这种实现的直接好处是不需要引入第三方依赖坏处是内存用得比专门的数据结构多一点但词库在一两万个词这个量级完全没问题。匹配流程拆开看是这样的public boolean check(String text) { // 从根节点开始curNode 代表当前所在层的 HashMap // 遍历文本的每一个字符逐个走下状态转移 // 一旦碰到词条结束标记立刻返回 true }这段逻辑的关键在于扫描指针不用回退到起点重新找词库的公共前缀被共享了。比如「广告」和「广告位」这两个词共享「广告」前缀匹配完「广告」之后如果下一个字符正好是「位」直接顺着树往下走就命中第二个词不需要重新匹配一遍「广告」。2.3 最小匹配与最大匹配同一个词库两种结果这套源码里有一个选项决定了敏感词怎么被命中命中「最小词条」还是「最大词条」。最小匹配的逻辑是只要当前节点标记了结束立刻返回命中不再往下走。最大匹配是即使当前节点能结束也要继续往下走完整个分支找到最长的那个词条。两者在实战中的差异很明显。举个例子词库里有「广告」和「广告位」两个词输入文本是「这个广告位出租」。最小匹配的结果是命中「广告」替换后变成「这个位出租」最大匹配的结果是命中「广告位」替换后变成「这个出租」、这两个结果放到线上是完全不同的体验——最小匹配更激进漏放的风险小但容易把「广告联盟」这种相对中性的词也干掉最大匹配更贴近语义但因为要往下试探更深的分支碰到词库里有超长词条时耗时略高。参数说明源码里控制这个行为的变量是一开始构建树时设置的模式开关调一次全局生效。小词库选最小匹配大词库或内容偏严肃的平台建议最大匹配。3. 源码工程拆解从 zip 解压到跑通第一个 Demo3.1 解压后的工程结构每个文件是干嘛的把 zip 解开之后目录结构不复杂标准的 Maven 工程布局核心代码集中在src/main/java下面。整套工程不用额外装什么中间件JDK 1.8 以上直接能跑这也是当初我选它的原因之一——不需要私有仓库不需要配 redis纯 JDK HashMap 打通。文件/目录作用src/main/java/filter/core/DfaFilter.javaDFA 核心类负责词库加载、Trie 构建、匹配检测src/main/java/filter/core/WordFilter.java对外门面类封装过滤、替换、命中词列表src/main/java/filter/util/FileUtil.java词库文件读取编码处理在这src/main/resources/dict/sensitive_words.txt默认敏感词库每行一个词src/main/resources/application.properties匹配模式、替换符参数配置src/test/java/filter/DfaFilterTest.javaJUnit 测试用例跑通验证用的先打开application.properties看一眼里面两个参数是上线前必须要确认的# 匹配模式0 最小匹配1 最大匹配 filter.maxMatchModetrue # 替换敏感词的占位符 filter.replaceChar*maxMatchModefalse对应最小匹配true对应最大匹配。replaceChar是脱敏后用来顶替敏感词的字符默认星号实际项目里有人改成「□」或者「***」看业务方要求。3.2 核心类 DfaFilter 的构建流程一树一 Map 一方法DfaFilter是整套源码的中枢内部结构可以概括成「一个根节点、一个加载方法、一个扫描方法」。初始化逻辑包含在带参构造器里类初始化时把词库文件路径传进来构造器内部完成文件读取、文本清洗、Trie 构建三个动作。public class DfaFilter { /** 根节点每一层子节点都是一个 HashMap */ private MapCharacter, Object root; public DfaFilter(String dictPath, boolean maxMatchMode) { this.maxMatchMode maxMatchMode; this.root new HashMap(); ListString words FileUtil.readLines(dictPath); for (String word : words) { addWord(word.trim().toLowerCase()); } } private void addWord(String word) { MapCharacter, Object cur root; for (char c : word.toCharArray()) { Object node cur.get(c); if (node null) { // 当前层级里没有这个字符新建一层 MapCharacter, Object child new HashMap(); child.put(isEnd, false); cur.put(c, child); cur child; } else { // 已有该字符分支顺着往下走 cur (MapCharacter, Object) node; } } // 最后一个字符对应的节点标记为词条结尾 cur.put(isEnd, true); } }这里有个代码层面值得注意的地方每一层节点 HashMap 里都用字符串键isEnd来存结束标记而不是单独用一个布尔变量。这样做的目的是让「结束标记」也变成节点 Map 的一个普通键值扫描时统一走cur.get(key)不用额外判断数据结构类型。代价是 HashMap 里混入了字符串键和 Character 键强转的时候容易踩坑后面在避坑章节里会细说。3.3 词库加载与编码处理不处理 BOM 就等着翻车FileUtil.readLines()这个工具方法看着简单实际上线的坑全藏在里面。textarea 记事本在 Windows 上保存的 txt 大概率是 GBK 编码而源码里默认用 UTF-8 读读出来直接乱码。另外还有一个更隐蔽的UTF-8 文件如果带 BOM字节序标记FileReader读出来的第一行字符串开头会带着一个不可见的\uFEFF字符导致词库第一个词条永远匹配不上。项目里对这两件事的处理方式比较省心加载词库时强制指定 UTF-8读入后先判断 BOM 再去掉。public static ListString readLines(String path) { ListString lines new ArrayList(); try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 去 BOM首行可能带 \uFEFF不影响后续拼接 if (line.startsWith(\uFEFF)) { line line.substring(1); } // 去首尾空白、跳过注释和空行 line line.trim(); if (line.isEmpty() || line.startsWith(#)) { continue; } lines.add(line.toLowerCase()); } } catch (IOException e) { throw new RuntimeException(词库加载失败, e); } return lines; }其中StandardCharsets.UTF_8是 JDK 7 自带的常量不需要额外引包。line.startsWith(#)让词库文件支持注释行方便维护的人在词条后面写业务标记。toLowerCase()是为了匹配时忽略大小写。4. 实战接入把筛选器塞进 Spring Boot 的评论接口4.1 集成方式把 DfaFilter 做成 Spring 单例源码包本身是纯 Java 的没有绑定 Spring但实际项目里接入 Spring Boot 也就十几行代码的事。正确做法是把它注册成一个单例 Bean在应用启动时加载词库后续所有请求复用同一个实例。不要每次请求都 new 一个 DfaFilter——词库上万个词构建 Trie 树是有开销的每次 new 等于把构建成本摊到每次请求上。Component public class SensitiveWordService { private DfaFilter filter; PostConstruct public void init() { // 生产环境词库路径放到 application.yml 里配置 // 这里直接用 classpath 下的默认词库 String dictPath classpath:dict/sensitive_words.txt; filter new DfaFilter(loadPath(dictPath), true); } private String loadPath(String path) { try { return ResourceUtils.getFile(path).getAbsolutePath(); } catch (IOException e) { throw new RuntimeException(词库文件找不到, e); } } }这里说一下loadPath这个方法的必要性new FileInputStream()只认绝对路径或者相对路径不认classpath:前缀如果你的词库在 jar 包内部直接用ResourceUtils.getFile会在打包后报错。这种把 classpath 资源转成绝对路径的写法只在开发环境可靠——生产环境里 jar 包内的资源不是文件系统文件真正上线建议把词库放到外部配置目录比如/data/config/sensitive_words.txt。4.2 对外接口设计过滤、替换、命中检测三件套WordFilter门面类对外暴露三个方法isSensitive(text)判断是否含敏感词、filterText(text)返回替换后的文本、findHitWords(text)返回所有命中的词条。接口划分到这个粒度业务方用起来不需要知道底层是 DFA 还是正则。public String filterText(String text) { StringBuilder result new StringBuilder(text.length()); StringBuilder hitWord new StringBuilder(); MapCharacter, Object cur root; int start 0; // 当前尝试匹配的起点 int i 0; // 当前扫描位置 while (i text.length()) { char c Character.toLowerCase(text.charAt(i)); Object node cur.get(c); if (node ! null) { // 能顺着树往下走记录字符 hitWord.append(c); cur (MapCharacter, Object) node; i; if ((Boolean) cur.get(isEnd)) { // 命中了词条用替换符顶掉原文区域 int begin start; int end i; while (begin end) { result.append(replaceChar); begin; } // 重置状态从命中位置之后继续扫 start i; hitWord.setLength(0); cur root; } } else { // 走不动了要么当前位置开头匹配失败要么匹配到一半失败 result.append(text.charAt(start)); start; i start; hitWord.setLength(0); cur root; } } // 循环结束后补上剩余字符 result.append(text.substring(start)); return result.toString(); }这段代码是整个系统最容易写错的地方我拆开讲几个关键点。start和i两个指针的关系是重点start指向当下这一轮匹配的起点i指向正在试探的字符。当某一轮从头匹配失败时start移动起点i start让扫描回到新的起点重新开始这个回退逻辑保证了漏网率最低。命中词条时hitWord.setLength(0)清空 StringBuilder 缓冲——不要用new StringBuilder()替代因为前者复用数组GC 压力小得多。4.3 参数调优命中词白名单与词库热更新第 4.2 节的实现是纯内存操作一个方法跑完不用落库对评论短文本足够用了。很多从这套源码起步的项目后来都会遇到两个进阶需求白名单和热更新。白名单的场景很典型某段时间平台在推广「兼职理财」课程运营希望「理财」不作为敏感词拦截但「理财诈骗」要拦。实现思路是给filterText加一个参数豁免词集合匹配结束后把豁免词从结果里恢复回来。源码里没有内置这个功能我一般会硬编码一个SetString whitelist在命中词条时先查一次白名单命中白名单就不走替换逻辑。热更新则更关键生产环境词库不可能一直不更新每逢大促、节假日运营会持续往词库里塞新词。直接在原 HashMap 上增删词条也能生效但并发场景下多个线程同时读写 HashMap 会直接死循环。源码里没有涉及并发但你在 Spring Boot 里做成单例之后就必须考虑这个问题。标准做法是词库更新时全量重建一棵树用 volatile 引用指向新树写操作替换引用之后读请求自动切换到新树上。public void reload(String dictPath) { DfaFilter newFilter new DfaFilter(dictPath, true); this.filter newFilter; // filter 用 volatile 修饰 }这段代码把「构建」和「发布」拆成两步构建期间老树照常服务构建完成后把引用一换新老词库无缝切换不需要暂停服务。词库一万个词构建耗时大约几百毫秒一个请求周期内就能完成。5. 避坑盘点编码、绕检、热更新五个真实翻车现场5.1 现象一词库加载后首词匹配永远失败后续词条正常原因出在 UTF-8 BOM。Windows 用户拿记事本编辑词库另存为 UTF-8 时记事本会自动在文件头塞一个\uFEFF这个字符肉眼看不见BufferedReader会把它当作第一个字符读进来。拼接后的第一个词条变成\uFEFF广告树里对应分支根本不会被走到。解决方式在 3.3 节已经写了读取时判断line.startsWith(\uFEFF)并摘除。从那以后我写词库加载BOM 检测这一行永远放在刚读完文件的第一道工序里。5.2 现象二词库量到五万级构建时 JVM 直接 OOM原因倒不是 DFA 这个思路错了而是默认 HashMap 初始化容量太小导致不断扩容。每个节点 HashMap 默认容量 16、负载因子 0.75一万个词、平均词长 4 个字符整棵树几百个字节的节点全是零碎对象内存碎片率高得吓人。解决方式分两层第一层构建节点时明确指定小容量比如三分支以下的节点初始容量给 4分支多的节点给 16第二层词库清洗时把完全重复的词、用前缀能包含的超长词去掉例如「广告」和「广告广告」后者就可以删。这套源码里没做前缀压缩遇到长词库内存确实吃紧但 2 万词以内完全够用。5.3 现象三「代!购」这种带符号的变体词没拦住被运营拿来当证据原因是匹配逻辑是严格的字符级比对词库里存的是「代购」文本里是「代!购」走到感叹号时发现树里没有这个分支直接回退导致漏检。解决思路是在匹配层加一个「噪音字符跳过」机制当前字符不是中文和字母数字时不把它当成匹配树的一部分而是记录它的位置和类型忽略后继续尝试走到下一个有效字符。代价是漏检率低了但误伤率会升高——「买了个表」这种完全正常的句子可能因为含有敏感词子序列而被拦下。解决方式是只对词条首尾字符生成「变形模板」比如「购」的变形模板管住「!购」「购」不扩大到整句。5.4 现象四词库某些词条被拆成两个词命中替换结果多了一半废话原因是词库文件里有的行末尾带着空格addWord之前虽然调了trim()但如果词条本身带有全角空格trim()不管用构建出的树里就多了一个空格分支。匹配时正常文本不可能出现全角空格这个分支永远不会命中。看着无害但更隐蔽的版本是词条中间有不可见字符比如从网页复制词库时混入了软换行符\u200B。处理办法有两个加载时把所有不可见字符统一替换掉或者干脆对词库文件在导入前做一次sed清洗。5.5 现象五运营改完词库调用 reload线上还是旧词库这个问题十次有八次出在「引用没换」上。reload 方法内部如果是在原 HashMap 上put新词条而不是重新构建整棵树那并发请求持有的还是旧引用——如果 DfaFilter 作为单例多线程读的是同一个对象引用看起来改了实际读操作看到的还是原始状态。另一层原因是DfaFilter被注册成 Spring 单例之后类内部持有root这个非 volatile 引用但 Spring 容器对单例的可见性不保证线程间实时可见。解决方式在 4.3 已经写过了reload 里重建整棵树再把引用赋给一个 volatile 字段。如果团队里有人改了词库发现不生效第一件事就是让他检查树是不是重建的。6. 性能压测与词库热更新让筛选器稳定跑在百万词量级6.1 压测方法用真实的词库量和文本长度去跑这套源码值不值得接手最关键的是知道它在真实负载下的表现。我惯用的压测方式非常简单词库量分两档一档 5000 词模拟日常另一档 50000 词压边界文本样本取线上评论的平均长度我用的是 200 字符和 2000 字符两档分开看短文本和超长评论的表现。Test public void perfTest() { // 首次调用先把类加载和初始化耗掉JIT 预热后再计时 DfaFilter filter new DfaFilter(dict/sensitive_words.txt, true); String longText 这是一段模拟的评论内容包含大量正常文字和提高转化率的业务描述; int warmup 10000; for (int i 0; i warmup; i) { filter.filterText(longText); } long start System.nanoTime(); int count 100000; for (int i 0; i count; i) { filter.filterText(longText); } long cost System.nanoTime() - start; System.out.println(平均耗时(ms): cost / 1000.0 / count); }跑完结果大致落在单次过滤 0.02ms 到 0.1ms 之间两万词的词库对匹配耗时的拉高远小于网络 IO瓶颈根本不在过滤这块。如果出现明显劣化优先检查是不是构建树时 HashMap 扩容次数太多而不是匹配逻辑本身的问题。6.2 进阶技巧命中词上下文捕获与脱敏保留基础版 Filter 会把整段命中的字符全部换成*这个在使用中不够灵活。很多做内容审核后台的人需要看到上下文才能判断「广告」是正常业务描述还是垃圾内容。我在这套源码之上加过一个方法findHitContext(text, radius)命中词前后各截取 10 个字符和原文一起返回。实现思路是在filterText循环里记录命中时的start和i然后调用text.substring(Math.max(0, start - 10), Math.min(text.length(), i 10))截取上下文。保留的半径做成了可配置项运营反馈看上下文比看单独词条有效得多。还有一个从这套源码延伸出来的习惯上线之前把编码、BOM、噪音符号绕检、最大匹配四条用例全部固化进 JUnit每次改词库都强制跑一遍。从那以后我每次接内容审核类需求都先把这几条边界用例摆出来再谈功能这套源码让我少走了几个月弯路希望帮到你。本文还有配套的精品资源点击获取