前情提要
Des 是本仓库里一套纯 Java 手写的 DES 加解密工具,没有用
JCE,也没有引入任何第三方加密库。用途很单一:把一批
密文,用户名长度,密码长度 的记录批量解密成
用户名,密码。
代码自同一份 JavaScript 版 DES 移植而来,因此仓库里躺着两份实现:
根目录 Des.java(957
行) |
src/Des.java(315 行) |
|
|---|---|---|
| 内部表示 | int[] + 裸
List,逐行直译风格 |
byte[] +
StringBuilder + ArrayList<byte[]> |
| 方向 | strEnc 和
strDec |
只有
strDec |
| 置换表 | 逐项硬编码赋值 | indices 数组 + 循环 |
| 其它 | —— | switch fallthrough
选密钥段、@Contract 注解 |
两份的算法行为完全一致,连那处非标准的 PC-1 都一致,只是写法不同。不过正是这个写法差异,决定了后面 4 倍的性能差距——这是后话。
批量任务的开销结构是这样的:密文以 16
位十六进制为一个分组,一行密文是 2 个分组、3
段密钥,所以每行要调用 6 次单分组解密
dec()。
加线程没什么用
遇到慢,第一反应当然是并发。CasDec 用 8
线程池逐行处理,看起来很像那么回事。实测下来(5 万行输入):
| 线程数 | 总耗时 | 相对 1 线程 |
|---|---|---|
| 1 | 3890 ms | 1.00× |
| 2 | 2869 ms | 1.36× |
| 4 | 2759 ms | 1.41× |
| 8 | 2975 ms | 1.31× |
8(去掉无意义的
.parallel()) |
3034 ms | 1.28× |
| 16 | 2774 ms | 1.40× |
8 线程比 4 线程还慢,16 线程也毫无起色。为了排除”锁竞争”这个头号嫌疑,把去重和写文件的共享状态整个去掉,只保留解密,结果仍然只有 1.45×(1 线程 4085 ms → 8 线程 2823 ms)。
也就是说,那堆 synchronized 和
ConcurrentHashMap 是背锅的。
瓶颈是分配
排除了锁,第二嫌疑是 GC。-Xlog:gc 打出来的是:
1 | |
暂停占比极小,也不是 GC 的锅。那就上
com.sun.management.ThreadMXBean#getThreadAllocatedBytes
直接量分配:
| 测量项 | 结果 |
|---|---|
generateKeys(密钥编排) |
1.948 µs/次,3488 B/次 |
dec()(单分组解密,含密钥编排) |
12.492 µs/块,65,968 B/块 |
→ dec 的单线程分配速率 |
5.28 GB/s |
strDec(1
块密文,整条路径) |
19.147 µs/块,72,981 B/块,52,227 块/s |
一个 DES 分组的输入输出各只有 64 位,却产生 66 KB
垃圾,比例约 8000 : 1。原因很直白,每个置换函数都在
new int[...]:
1 | |
每轮都要新建 expandPermute(48) / xor(48) /
sBoxPermute(32) / pPermute(32) /
xor(32) / tempRight(32) / key(48)
一堆数组,16 轮乘下来就是 66 KB。推算到批量任务:
1 | |
也就是说,单线程的墙钟时间几乎全部花在”把这 20.5 GB 内存分配出来”上。而内存带宽是全局共享资源,所以加线程并不会更快——第二个结论也就顺理成章了。
顺带说一个意外发现。IDEA 运行配置里有一串
-Xms10G -XX:+UseZGC -XX:ZAllocationSpikeTolerance=5,但它被填在了
PROGRAM_PARAMETERS(程序参数)而不是 VM options 里,而
CasDec.main
根本不接收参数,所以从未生效;实际一直跑在默认 G1 +
8136 MB 堆上。这串参数本身倒是说明作者已经感觉到分配压力了。
问题清单
把所有可疑点摊开,按实测影响排个序:
| # | 问题 | 实测影响 | 修复难度 |
|---|---|---|---|
| P1 | 每个分组分配约 66 KB(临时数组风暴) | 分配速率 5.28 GB/s;吞吐第一瓶颈,并压制线程扩展性 | 中 |
| P2 | 每个分组都重算 16 组子密钥 | 占 dec 耗时
15.59%、3488 B/次 |
低 |
| P3 | 每块 hex→比特 走”字符串 + 64 次 substring/parseInt” | 2.037 µs/块 → 可降到 0.037 µs(54.95×) | 低 |
| P4 | 用 s += ...
逐块累积字符串 |
O(n²):32k 块时 352 ms vs StringBuilder 的 0.21 ms(1652×) | 低 |
| P5 | keyCache 用
byte[] 作键:永不命中且无界增长 |
命中率 0.0000%;5 万行 → 15 万条目;8.3 万条目 GC 后仍占 83 MB | 低 |
| P6 | 线程扩展性差却”看起来”并发 | 1→8 线程仅 1.31×;去掉共享状态也仅 1.45× | 见 P1 |
| P7 | 无界任务队列:每行都 execute
一个任务 |
5 万行提交后队列里还有 49,990 个待执行任务 | 低 |
| P8 | synchronized 套
synchronized + 无意义的 .parallel() |
非主瓶颈,但白白增加争用与复杂度 | 低 |
| P9 | 每行 line.split(",") |
95.4 ns/行 vs indexOf 32.3
ns/行(2.95×) |
低 |
| P10 | 去重表保存完整 用户,密码
字符串 |
内存随唯一记录数线性增长 | 低 |
| P11 | IDEA 把 JVM 参数填到了 PROGRAM_PARAMETERS | -Xms10G -XX:+UseZGC
从未生效 |
极低 |
逐个展开
密钥编排:缓存不如上提
generateKeys 占 dec 的
15.59%,直觉是”缓存起来”。我先按密钥内容做了个
Map<String, int[][]>(键用
Arrays.toString)的
DesCached,结果只快 1.01×(19.147 → 18.950
µs/块)——构造字符串键 + 哈希 + 查表的成本几乎等于重算。
所以正确做法不是缓存,而是把密钥编排上提到分组循环之外。strDec
外层是分组、内层是密钥块,完全可以对每个密钥块只算一次
generateKeys,再把结果传给
decWithKeys(block, keys)。能上提到循环外的,就别缓存。
而 src/Des.java 里那个 keyCache
更有意思:
1 | |
byte[] 用的是 Object
的身份比较,内容相同也是不同的键,所以跨调用必然失效。实测 1 块密文连续
5 万次 strDec,命中率 0.0000%,条目从
20,000 涨到 70,000;真实批量场景(5 万行 × 3 密钥)留下 15
万条目,System.gc() 之后仍占 83
MB——这些对象永久可达,等于一个内存泄漏。
hex 解码
根版走的是”字符串 + 64 次 substring +
parseInt“,src 版已经改成 16 次
parseInt(..., 16),还可以更直接:
1 | |
字符串拼接
javac 会把循环里的每个 s += unit
编译成”新建 StringBuilder → 复制旧串 → 追加 → toString”。隔离实验(每块
8 字符)非常直观:
| 分组数 | 字符数 | s += unit |
StringBuilder |
倍数 |
|---|---|---|---|---|
| 2000 | 16,000 | 1.31 ms | 0.05 ms | 24.5× |
| 8000 | 64,000 | 24.80 ms | 0.10 ms | 236.8× |
| 32000 | 256,000 | 352.22 ms | 0.21 ms | 1652.1× |
无界队列
CasDec 对每一行都
executor.execute(...),而
Executors.newFixedThreadPool 用的是无界
LinkedBlockingQueue。实测 5
万行输入,在”全部行已提交”这一刻队列里还躺着 49,990
个任务——整个文件的行被一次性塞进队列。1.85 MB
的输入,队列持有的对象开销是它的数倍;千万行量级就会变成 GB
级堆占用。修法是有界队列 + CallerRunsPolicy 形成背压:
1 | |
多余的锁与 .parallel()
credMap 本身就是
ConcurrentHashMap,外面再套
synchronized (credMap)
直接退化成全局串行;containsKey + put 应该改成
putIfAbsent 的返回值判重:
1 | |
另外 BufferedReader.lines() 的 Spliterator
不可切分,.parallel() 根本不会并行读取,只会套一层 ForkJoin
调度开销——实测 nopar 3034 ms vs parallel 2975
ms,在噪声范围内,删掉即可。
换成 byte[] 实现
排完 P1,最省事的修法其实就在同一个仓库里:换成
src/Des.java 的 byte[] 实现。同样一份 5
万行输入:
| 实现 | 线程数 | 耗时 | 相对 root/1 线程 | 1→8 线程扩展性 |
|---|---|---|---|---|
根 Des.java(原用) |
1 | 3890 ms | 1.00× | 1.31× |
根 Des.java(原用) |
8 | 2975 ms | 1.31× | |
src/Des.java(改一行) |
1 | 1183 ms | 3.29× | 2.05× |
src/Des.java(改一行) |
8 | 577 ms | 5.16× |
单块指标上,strDec 从 19.147 µs / 72,981 B 降到
4.436 µs / 3,208 B——每块快 4.32×、分配少
22.75×。
两点说明:src/Des.java 只有
strDec,而批处理只需要解密,所以可以直接替换;但它自带 P5
那个 keyCache 泄漏(本次运行留下 150,000
条目),替换时应一并修掉。
更值得注意的是,减少每块分配之后,线程才真正开始起作用——扩展性从 1.31× 恢复到 2.05×。这反过来验证了前面的判断:之前不是线程没用,而是内存带宽这个全局资源已经饱和。
顺带一查:和标准 DES 的差异
优化过程中反复确认过一个前提:这些优化只改实现方式,不改算法。于是很自然想把”这份实现离标准 DES 有多远”也弄清楚。
一言以蔽之:这是一套”把文本按 UTF-16BE 编码后、用改过 PC-1 的 DES、以多段口令做 E-E-E 级联、ECB + 零填充”的私有格式。
| # | 差异 | 标准 DES(FIPS 46-3) | 本实现 | 后果 |
|---|---|---|---|---|
| 1 | PC-1 子密钥编排 | FIPS 表 | D 段的 4 个位组逆序 | 子密钥不同 → 密文不同 → 不互通 |
| 2 | 分组/密钥单位 | 8 个字节 = 64 位 | 4 个 16 位码元 = 64 位 | 等价于对 UTF-16BE 字节做 DES |
| 3 | 多密钥 | 3DES 为 E-D-E | E-E-E 级联,密钥为任意长度口令 | 与 3DES 不是一回事 |
| 4 | 填充/长度 | PKCS#7 等 | 零填充 + 解密时丢弃 U+0000 | 长度须外部携带;NUL 会丢数据 |
| — | 模式 | ECB/CBC/… | ECB,无 IV、无 MAC | 相同明文块产生相同密文块 |
先说一致的部分,都逐项核对过:IP / FP / E / P 置换表、8 张 S
盒、PC-2、循环左移表全部与 FIPS 吻合(src
版把硬编码表改成了 indices 数组 +
循环,数值一一对应),轮函数是教科书式 Feistel,解密时子密钥逆序。
PC-1
1 | |
(根目录 Des.java:802-806
是同一个循环,只是没写那句注释——所以两份文件必须同步改。)
它的效果是:key[0..27](C 半区)与标准完全一致,而
key[28..55](D 半区)是把标准的 4 个位组
[8][8][8][4] 整体倒序成
[4][8][8][8]。用 FIPS 官方测试向量一测就现形:
| 版本 | K1(第 1 个子密钥) | 密文 |
|---|---|---|
| FIPS 46-3 已发布 | 00011011 00000010 11101111 11111100 01110000 01110010 |
85E813540F0AB405 |
| 把 PC-1 换成 FIPS 表后 | 同上 ✅ | 85E813540F0AB405 ✅ |
| 本实现原样 | …11101111 10100110 11100101 11110110
❌ |
CC38B78305003643 ❌ |
前 24 位(来自 C1)三者一致,后 24 位(来自 D1)不一致,与推导完全对应。
需要特别注意的是,不要”顺手修正”。历史密文和输出都是围绕这个私有 PC-1 生成的,改了就让旧数据全部解不开。代码里那句脏话注释,大概就是原作者踩过这个坑留下的。
UTF-16BE
strToBt 把一个 char 的完整 16 位都塞进去,4
个码元 = 64 位。所以 "abcd" 的 64 位是
00 61 00 62 00 63 00 64——即 UTF-16BE 编码的字节,而不是
ASCII 的 61 62 63 64。
这个等价关系实测过:把 PC-1
换成标准表后,strEnc("abcd","1") 与 JCE 用
new SecretKeySpec("1".getBytes(UTF_16BE) 补齐 8 字节, "DES")
的结果完全相同(都是 5D4D01A3BDBCFD91)。
顺带一个坑,落单代理码元无法经字符串编码互转。密钥里若有
U+DFF1 这类未配对代理,Java 字符串本身能保存,但
getBytes(UTF_16BE) 会把它替换成 U+FFFD,而
strToBt 走 charAt
用的是真实位。向外部工具搬运这类密钥时务必用原始字节/位数组。
E-E-E
每一段口令的每一个 4 码元块,都是一把独立的 64 位密钥,按
first → second → third 顺序各做一次完整
DES,解密严格逆序。实测 strEnc("abcd","AB","CD","") =
2C96D4E6C12BDF01,与手算 E-E-E 相同、与 E-D-E 不同。
零填充与 ECB
不足 4 码元用 0 补齐,解密时 byteToString
直接跳过零值码元(if (count != 0)),所以文本可往返,代价是明文里本来就存在的
U+0000 也会被吞掉(实测 a\u0000bc
解出 abc)。ECB 无 IV,相同明文块产生相同密文块。
另外 iterator = length / 16 不校验余数,尾部不完整 hex
会被静默忽略(实测喂 20 个 hex 字符只解出第 1
块)。
互通性矩阵如下:
| 场景 | 结果 |
|---|---|
| 本实现加密 → JCE/OpenSSL 解密 | ❌ |
| JCE/OpenSSL 加密 → 本实现解密 | ❌ |
| 本实现解 3DES(EDE) 数据 | ❌ |
| 本实现解含 NUL 的明文 | ❌ 丢字符 |
| PC-1 换成标准表后 → JCE 解密 | ✅ |
即:UTF-16BE 编码和零填充都能用标准库复现,唯一的”硬墙”就是 PC-1。
两条改造路线
路线 A:保持兼容(除非明确要迁移,否则推荐)。 不动
PC-1。任何”修正”都会让既有密文无法解密。新增功能时必须同时改
src/Des.java 与根 Des.java。
路线 B:迁移到标准 DES(会改变所有历史密文)。
把
generateKeys的 PC-1 换成 FIPS 表(两份文件都要改):1
2
3int[] pc1 = {56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35,
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4, 27, 19, 11, 3};
for (int i = 0; i < 56; i++) key[i] = keyByte[pc1[i]];保持 UTF-16BE 编码(4 码元 = 8 字节)与零填充;
多段口令按块顺序连续 DES(E-E-E),解密逆序;
之后就能直接用标准库,多段口令的等价写法是”每段口令按 4 码元切块,逐块做一次完整的
doFinal“;迁移校验:拿一批旧数据,用”改好的本实现”与”标准库”各解一遍,结果必须逐字节相同,再用 FIPS 官方向量回归一次。
总结
- 先测量,再优化。 靠直觉两次都猜错了(先猜锁,再猜 GC),只有把”分配字节数”量出来才定位到真凶。
- 加线程没用时,先找不可并行化的共享资源。 这里的内存带宽是全局共享的,加线程只是在抢同一条带宽。
- “分配压力大”经常被误诊为”需要 ZGC / 需要大堆”。
IDEA 里那串 10 GB + ZGC 参数既没生效,也本就不该是首选方案——先减少每块的
new,问题自然消失。 - 缓存不是免费的。 用字符串键 + 哈希表缓存子密钥编排,实测只快 1.01×。能上提到循环外的,就别缓存。
- 顺手检查 IDE 运行配置。 JVM 参数填进
PROGRAM_PARAMETERS里不会报错,只会静默失效。 - 性能优化和算法兼容性是两件事。 本次所有优化都只改实现方式、未改算法。
按实测影响排序的优化清单:
| 优先级 | 动作 | 预期收益 | 风险 |
|---|---|---|---|
| 1 | 批量任务改用 src/Des.java 的
byte[] 实现 |
单线程 3.29×、8 线程 5.16×;分配降低 22.75× | 低 |
| 2 | 修
keyCache:删除或改为内容键 |
消除无界增长(5 万行 15 万条目、8.3 万条目占 83 MB) | 低 |
| 3 | 密钥编排上提出分组循环 | 省 dec 的
15.59% 时间与 3488 B/块 |
低 |
| 4 | 任务队列改为有界 + 背压 | 内存从”随文件行数”变为有界,大文件不再有 OOM 风险 | 低 |
| 5 | s += ... →
StringBuilder |
隔离实验 1652×,端到端最高约 1.94× | 低 |
| 6 | hex 解码改为按 nibble 取位 | 每块省 2.04 µs(54.95×) | 低 |
| 7 | 缓冲区复用,把每块分配降到接近 0 | 让线程真正可扩展(当前被内存带宽压制) | 中 |
| 8 | 去重改 putIfAbsent、删掉
.parallel()、线程数默认 ≤4 |
降争用与抖动,可读性提升 | 低 |
| 9 | 每行解析改
indexOf、去重表存哈希、IDEA VM options 归位 |
各为个位数百分比 | 低 |
复现
性能部分在
D:\Code\Des\.qwen\tmp\perf-verify\(Perf.java
微基准、Gen.java 生成输入、CasPerf.java 支持
线程数 [nopar] [nopost] [src]
四种开关并打印队列长度):
1 | |
标准 DES 对照部分在
D:\Code\Des\.qwen\tmp\des-verify\(DesStd.java
仅把 PC-1 换成 FIPS 表,Verify.java 逐项验证
PC-1、UTF-16BE、E-E-E、零填充与边界行为,并与 JCE 对照):
1 | |
注意 Des.java 用了字符串模板
STR."..."(Java 22 预览特性),编译与运行必须带
--enable-preview。另外吞吐类数字存在 ±5%~10%
的运行间波动(8
线程尤其明显),上表取自一次串行执行的完整矩阵;结论(扩展性饱和在
1.3~1.5×、换实现后约 5×)在多次运行中稳定复现。
Reference
- FIPS 46-3, Data Encryption Standard (DES) —— NIST,已于 2005 年 5 月 19 日撤销
- ThreadMXBean
(Java SE 22 & JDK 22) ——
getThreadAllocatedBytes的来源 - Cipher (Java SE 22 & JDK 22) —— 对照实验中 JCE 一侧