Java大文件加密实战:AES-GCM流式处理与PBKDF2密钥派生避坑 简介面向Java初学者与课程设计学习者的小型文件加密项目围绕主流对称加密算法的实现与图形界面调用展开可用于理解加密原理、完成课程设计或作为桌面加密工具的入门参考。压缩包共2个文件含1个Java源文件与1份Word课程设计报告整体约146KB源码与文档配套便于对照阅读和二次修改。Java程序集成Blowfish、DES、TripleDES3DES、AES、RC4等算法的加密与解密逻辑并通过图形界面组织文件选择、算法切换与结果输出可帮助读者观察不同算法的调用方式、密钥处理及加解密流程。配套报告从设计目标、功能模块、实现思路与测试结果等方面展开适合课程设计答辩、实验报告撰写及Java加密编程入门参考。已有178人学习可作为小型桌面加密程序的完整案例从中梳理算法封装、界面交互与文档撰写的对应关系。1. 一个 20GB 的备份包让“读进内存再加密”的方案彻底失败某开发者接到的需求很朴素给导出的业务数据文件加一层保护只有拿到口令的人能打开。第一版写得很顺Files.readAllBytes读进来Cipher.doFinal一次性加密再写回去3MB 的样例文件跑得飞快。上线当晚换成 20GB 的归档包堆内存被打爆进程 OOM 退出日志里连一句有意义的报错都没有。这就是 java 文件加密程序设计的真实起点它不只是一个“调个 API”的事背后有一串工程决定——流式处理、密钥派生、头部格式、完整性校验。下面按实际实施的顺序展开先把算法和模式选对再把加解密两端写成能跑的代码然后处理口令与密钥最后把几个能让程序静默出错的坑逐个找出来。适合已经会写 Java、正要给文件加保护的同行也适合接手了别人留下的加密工具、想搞清楚它到底靠不靠谱的人。2. 算法与模式选型AES-GCM、CBC 到底该选哪个选型这一步做错后面代码写得再漂亮都只是把错误封装得更牢固。文件加密的选型要回答两个问题用什么算法用什么模式以及要不要附加完整性校验。这三个决定直接决定了密钥怎么管、头部存什么、出错时能不能发现。2.1 对称加密为什么是文件加密的默认答案非对称加密看起来更“安全”但用在文件加密上基本不可行。以 RSA-2048 为例单次能加密的明文上限是 245 字节连一个文件头都塞不下更现实的问题是速度非对称运算比 AES 慢两到三个数量级加密一个 1GB 的文件要等到怀疑人生。因此业界的通用做法是“信封加密”随机生成一个数据加密密钥DEK用它做对称加密处理文件主体再用接收方的公钥把 DEK 加密后附在文件头里。但如果你的场景是“一个文件配一个口令谁拿到口令谁就能打开”信封那层其实可以省掉。更直接的链路是口令 盐 → PBKDF2 派生 → AES 密钥 → 直接加密文件。链路短需要维护的东西少出问题的面也小。我一般会先问清楚需求是“单方口令保护”还是“多方分发”前者直接上口令派生后者才引入非对称封装。很多项目一上来就搭一套复杂密钥体系最后发现只有一个使用者白白增加了密钥轮换和备份的负担。算法本身没有太多纠结空间AES-256。不要用 DES、3DES块长 64 位生日界只有 32GB 量级也不要用各种“自己设计的异或加密”。有些老项目还在用AES/ECB/PKCS5PaddingECB 会把相同的明文块加密成相同的密文块一张结构规整的图片加密完还能看出轮廓这是典型的模式误用后面第 5 章会单独讲。2.2 GCM 与 CBC 的取舍完整性校验值不值那 16 字节CBC 和 GCM 的核心差别在认证。CBC 只保证机密性密文被篡改一个比特解密出来就是一坨随机垃圾但程序本身不知道出错了——它会把垃圾当明文写进目标文件。GCM 是 AEAD 模式加密时同步算出一个 128 位的认证标签解密时doFinal会校验不匹配就抛AEADBadTagException。这 16 字节的代价换来的是“能发现自己被改了”。维度AES-CBC PKCS5PaddingAES-GCM机密性有有完整性无需额外 HMAC内置 tagIV 要求16 字节必须随机不可预测12 字节绝不可重复单密钥明文上限无硬限制约 64GiB常见误用后果Padding Oracle、密文篡改无感知IV 重用导致明文异或泄露实现复杂度需自行组合 Encrypt-then-MAC一个 Cipher 搞定如果坚持用 CBC必须做 Encrypt-then-MAC先加密再对“IV 密文”算 HMAC-SHA256解密时先验 HMAC 再解密。顺序反了就等于把 Padding Oracle 的入口敞开。这个组合我见过不少实现做成了 MAC-then-Encrypt等于没有保护。GCM 的硬性约束是 IV 不能重复。同一个密钥下两次使用相同的 IV攻击者拿到两段密文异或一下就能消掉密钥流直接得到两段明文的异或更严重的是认证密钥可以被恢复攻击者能伪造任意密文。所以 IV 必须每次加密重新随机生成并且和密文一起存进文件头。另一个容易被忽略的点是 GCM 的单密钥明文上限大约 64GiB超过这个量的文件不能一个密钥一个 IV 从头加密到尾得做分段每段用序号派生不同的 IV段与段之间记录长度。至于填充GCM 是流式模式不需要 Padding直接AES/GCM/NoPadding。看到AES/GCM/PKCS5Padding这种写法说明写代码的人对模式的理解还停留在 CBC 上很多 JDK 实现会直接抛NoSuchAlgorithmException。2.3 大文件不能用 doFinal(byte[])流式加密的边界在哪Cipher.doFinal(byte[])要求一次性拿到完整明文返回完整密文。3MB 的文件没问题200MB 就开始难受20GB 直接 OOM。更麻烦的是这个 API 的错误形态很隐蔽堆内存不足时抛的是OutOfMemoryError属于 Error 而不是 Exception很多catch (Exception e)的写法根本接不住进程就这么没了。流式处理的做法是Cipher.update配合doFinal读一块、喂一块、写一块。Cipher 内部维护状态update返回的字节数可能为 0数据不够一个块也可能是整块输出调用方不能假设“读多少就出多少”。循环结束后调一次doFinal把内部残留的块和GCM 下的认证标签刷出来。这里有个绕不开的问题JDK 自带的CipherInputStream在 GCM 解密时不靠谱。部分版本里doFinal抛出的AEADBadTagException会被吞掉或包装成 IOException 之外的东西表现是“口令输错了程序不报错只是解密出来的文件短了一截”。这是我在生产环境遇到的真实情况排查了大半天才定位到。结论很明确大文件加密自己写 update/doFinal 循环不要用 CipherInputStream 做解密。多写二十行代码换一个确定的错误行为非常值。缓冲区大小也不是随便定的。默认的CipherInputStream内部缓冲区只有 512 字节跑大文件时系统调用次数爆炸吞吐能掉一半。经验值 64KB 到 1MB 之间配合BufferedInputStream/BufferedOutputStream包一层基本能跑满磁盘 IO。具体数字在第 3 章给出并解释。3. 把加解密写成可复现的代码头部格式、分块循环与两个入口理论定完落到代码。这一章给出一套完整可运行的最小实现只用 JDK 自带的 JCE不引第三方库方便你直接复制到一个空工程里跑通再按需替换。3.1 只用 JDK 自带 JCE工程骨架与常量表整个工具就一个类两个公开方法encrypt/decrypt加一个内部类存文件头。先把常量定死因为这些值一旦写进密文文件后面就不能随便改——改了老文件就解不开。import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.io.*; import java.nio.file.*; import java.security.SecureRandom; public final class FileCryptor { // 文件魔数用来快速识别这不是我的格式 private static final byte[] MAGIC {F, C, R, 1}; private static final int VERSION 1; private static final int SALT_LEN 16; // PBKDF2 盐长度 private static final int IV_LEN 12; // GCM 推荐 12 字节 private static final int TAG_BITS 128; // 认证标签 128 位 private static final int ITERATIONS 210_000; private static final int BUF_SIZE 64 * 1024; private FileCryptor() {} // 文件头魔数(4) 版本(1) 迭代次数(4) 盐长(1) 盐 IV长(1) IV private static final class Header { final int iterations; final byte[] salt; final byte[] iv; Header(int iterations, byte[] salt, byte[] iv) { this.iterations iterations; this.salt salt; this.iv iv; } } }几个常量的取值理由SALT_LEN 16是 PBKDF2 的常规值够用且不浪费头部空间IV_LEN 12是 GCM 的标准长度用 16 字节也能跑但 JDK 内部会再做一次 GHASH 派生性能和安全性都不如直接用 12ITERATIONS定在 21 万是兼顾安全性和单次解密耗时的折中第 4 章会展开讲这个数怎么调BUF_SIZE取 64KB是磁盘 IO 和内存占用的平衡点机械盘上再大收益不明显SSD 上 1MB 能再快 10% 左右。SecureRandom这里我故意没写getInstanceStrong()原因在第 5 章第 4 条。3.2 加密端先写头部再喂 Cipher.update加密的顺序很关键头部必须先落盘因为解密端要靠头部里的盐和 IV 才能构造 Cipher。头部是明文的不加密——它本来就是公开参数藏起来没有意义反而增加复杂度。public static void encrypt(Path src, Path dst, char[] password) throws Exception { SecureRandom rnd new SecureRandom(); byte[] salt new byte[SALT_LEN]; byte[] iv new byte[IV_LEN]; rnd.nextBytes(salt); rnd.nextBytes(iv); SecretKey key deriveKey(password, salt, ITERATIONS); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); // CREATE_NEW目标已存在直接失败避免覆盖掉别人的文件 try (InputStream in new BufferedInputStream(Files.newInputStream(src), BUF_SIZE); OutputStream out new BufferedOutputStream( Files.newOutputStream(dst, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), BUF_SIZE)) { // 1) 头部先写解密端才能读到参数 DataOutputStream header new DataOutputStream(out); header.write(MAGIC); header.writeByte(VERSION); header.writeInt(ITERATIONS); header.writeByte(salt.length); header.write(salt); header.writeByte(iv.length); header.write(iv); // 2) 流式加密主体 byte[] buf new byte[BUF_SIZE]; int n; while ((n in.read(buf)) ! -1) { byte[] chunk cipher.update(buf, 0, n); if (chunk ! null chunk.length 0) { out.write(chunk); } } // 3) 刷出 GCM 的认证标签16 字节 byte[] tail cipher.doFinal(); if (tail ! null tail.length 0) { out.write(tail); } } } private static SecretKey deriveKey(char[] password, byte[] salt, int iterations) throws Exception { SecretKeyFactory f SecretKeyFactory.getInstance(PBKDF2WithHmacSHA256); PBEKeySpec spec new PBEKeySpec(password, salt, iterations, 256); try { byte[] raw f.generateSecret(spec).getEncoded(); return new SecretKeySpec(raw, AES); } finally { spec.clearPassword(); // 用完清掉内部持有的口令副本 } }逻辑上分三段派生密钥、写头、循环喂数据。cipher.update的返回值必须判空判长度因为 GCM 在数据不足一个块时可能返回 null 或者 0 长度数组直接out.write(chunk)会 NPE。doFinal()在加密侧返回的是认证标签只有 16 字节不写出去的话解密端一定失败——这个错误现场很容易被误判成“密钥不对”。CREATE_NEW这个选项是我特意加的。早期版本用默认的CREATE跑批处理脚本时路径拼错把上一轮的结果文件覆盖了数据直接没了。改成CREATE_NEW之后重复执行会抛FileAlreadyExistsException是好事至少你知道出问题了。3.3 解密端读头、派生、校验 tag 的完整顺序解密是加密的逆序读头 → 用盐派生密钥 → 用 IV 初始化 → 循环 update → doFinal 校验。最后一步是整个流程唯一的“真伪判定”时刻务必让它抛出能被上层捕获的异常。public static void decrypt(Path src, Path dst, char[] password) throws Exception { try (InputStream in new BufferedInputStream(Files.newInputStream(src), BUF_SIZE)) { Header h readHeader(in); // 位置指针停在密文起始处 SecretKey key deriveKey(password, h.salt, h.iterations); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, h.iv)); try (OutputStream out new BufferedOutputStream( Files.newOutputStream(dst, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), BUF_SIZE)) { byte[] buf new byte[BUF_SIZE]; int n; while ((n in.read(buf)) ! -1) { byte[] chunk cipher.update(buf, 0, n); if (chunk ! null chunk.length 0) { out.write(chunk); } } // 密文最后 16 字节是 tag这一步不匹配就抛 AEADBadTagException byte[] tail cipher.doFinal(); if (tail ! null tail.length 0) { out.write(tail); } } } } private static Header readHeader(InputStream in) throws IOException { DataInputStream din new DataInputStream(in); byte[] magic new byte[4]; din.readFully(magic); if (!java.util.Arrays.equals(magic, MAGIC)) { throw new IOException(不是本工具生成的文件魔数不匹配); } int version din.readUnsignedByte(); if (version ! VERSION) { throw new IOException(不支持的文件版本: version); } int iterations din.readInt(); byte[] salt new byte[din.readUnsignedByte()]; din.readFully(salt); byte[] iv new byte[din.readUnsignedByte()]; din.readFully(iv); return new Header(iterations, salt, iv); }readHeader里用了DataInputStream包一层原始流读完之后原始流的位置正好在密文第一个字节可以直接接着读。这里不能用BufferedInputStream再包一次——会提前预读把密文数据吞进缓冲区导致后续读取错位。如果要缓冲必须全程用同一个BufferedInputStream实例也就是代码里外层包好、内层只做包装不做新缓冲的写法。AEADBadTagException是BadPaddingException的子类捕获的时候建议单独 catch 它给出“口令错误或文件被篡改”的明确提示而不是笼统地报“解密失败”。这两个原因在用户侧的处理方式完全不同前者让人重输口令后者要报警。3.4 缓冲区、进度回调与多线程调用的边界缓冲区大小对吞吐的影响比想象中大。我做过一组粗略对比同一台机器、同一个 4GB 文件、SSD缓冲区平均吞吐说明8KB约 180 MB/sCipher.update 调用次数多64KB约 420 MB/s默认推荐值256KB约 460 MB/s收益开始递减1MB约 470 MB/s内存占用上升收益很小64KB 是个稳妥的默认值。如果你的场景是网络流或者管道反而要调小避免攒够一整块才发出去导致延迟。进度回调在界面上很常见实现要点是“按已处理字节数回调而不是按块数”因为最后一块大小不定。简单做法是在循环里累加n每处理 1% 触发一次回调回调里不要做重活扔给单独的线程或者消息队列。多线程方面有一条硬规矩Cipher实例不是线程安全的。同一个 Cipher 对象被两个线程同时 update输出会错乱而且不报错。正确用法是每个线程Cipher.getInstance出一个新实例或者用ThreadLocalCipher缓存。密钥SecretKey本身是不可变对象可以跨线程共享。还有一个容易被忽略的点char[] password用完要Arrays.fill(password, \0)。字符串形式的密码没法清因为它进了字符串常量池GC 之前一直在内存里躺着。所以公开方法的参数类型用char[]而不是String这是个可以传递给调用方的正确习惯。4. 密钥与口令把“密钥写死”这条路彻底堵上代码能跑通只是及格线。真正决定这套东西能不能上生产的是密钥从哪来、存哪、怎么换。我见过太多项目在这块偷懒最后变成“加密等于没加”。4.1 口令派生密钥PBKDF2 的三个参数怎么定用户给的口令通常只有十几个字符熵不到 60 位直接当 AES 密钥用等于把 256 位安全强度砍到 60 位。PBKDF2 的作用是通过大量迭代把口令“拉长”让暴力破解的成本随迭代次数线性上升。三个参数算法、盐、迭代次数。算法选PBKDF2WithHmacSHA256不要用PBKDF2WithHmacSHA1。JDK 8 之后都支持没有兼容性问题。有些老代码用PBEWithMD5AndDES那个组合在今天已经不该出现。盐必须每个文件独立随机生成16 字节存进文件头。盐的作用是防止彩虹表和“相同口令产生相同密钥”。如果所有文件共用一个固定盐攻击者可以预计算一次然后打穿所有文件。迭代次数是最需要权衡的参数。太低比如 1000等于没加太高会让解密卡顿。我的做法是先定一个可接受的最大延迟比如“单次派生不超过 300ms”然后在目标机器上实测。普通笔记本上 21 万次大约 150~250ms服务器上更快。如果目标设备是低端嵌入式降到 10 万也可以接受但不要低于 10 万。提示迭代次数会写进文件头所以将来调高不影响老文件的解密。这是我把iterations存进头部而不是写死成常量的原因。还有一点PBKDF2 的输出长度定 256 位正好是 AES-256 的密钥长度。写 128 也能跑但那就退化成 AES-128 了没必要。4.2 文件头里该存什么、不该存什么文件头是明文所以它的设计原则是只存解密必需且不敏感的参数。必须存的魔数、版本号、KDF 迭代次数、盐、IV。这五样缺一不可。绝对不能存的口令、派生后的密钥、明文的任何摘要。有人为了“方便校验”在头部存一个明文的 SHA-256这等于给攻击者一个离线验证口令是否正确的判据把 PBKDF2 的保护效果削掉一大半。GCM 的 tag 本身就是最好的校验不需要额外的东西。版本号看起来很形式化但它是唯一能让你在未来升级算法时保持向后兼容的字段。第二版想换 Argon2 或者加分段靠的就是这个字节。我一般会在第一版就把version字段留出来同时在readHeader里对未知版本明确抛异常而不是“尽力解析”。头部的总长度是固定的 414116112 39 字节。这个数字建议写进注释将来有人加字段时至少知道自己在破坏什么。4.3 密钥的三种落地存放方式与各自的失效场景口令派生的方案里“密钥”其实是用户脑子里的口令。但有些场景不适合让人记口令比如服务之间的批量文件交换这时候密钥得存在某个地方。三种常见做法各有各的失效姿势。第一种配置文件里存口令用环境变量覆盖。实现最简单问题是配置文件容易被提交进代码仓库或者被打进容器镜像。缓解办法是启动时校验如果检测到用的是默认值或者空值直接拒绝启动。这个检查看起来多余但它拦住过真实事故。第二种用 Java 的KeyStorePKCS12 格式存密钥用另一个主密码保护。适合单机部署密钥文件权限设成 600。失效场景是运维把 keystore 和密码放在同一台机器的同一个目录里等于把钥匙和锁一起给了人。第三种外部密钥服务程序启动时拉取内存里只保留SecretKey对象。安全性最高代价是引入了一个强依赖密钥服务挂了加密功能全挂。必须做本地短时缓存和降级策略否则一个小故障会放大成业务中断。不管用哪种有一条共同的红线密钥不能出现在日志里。SecretKey.getEncoded()返回的字节数组如果被顺手打进 debug 日志整套加密就形同虚设。我的习惯是在任何打印密钥相关对象的地方先把它转成一个固定字符串***而不是依赖“我应该不会打”。5. 排查现场五个能让加密程序静默出错的坑加密程序最可怕的不是报错而是不报错。下面五条都是“看起来一切正常实际上已经错了”的现场记录按现象、原因、解决的顺序写。5.1 现象解密没报错但文件末尾少了一截现象用CipherInputStream包着解密口令输错的情况下程序正常退出输出文件比原文短而且短得不多有时候只差几 KB。换回正确口令一切正常于是这个 bug 在测试环境里藏了很久。原因CipherInputStream在内部close或读到流末尾时才触发doFinal部分 JDK 版本里这个异常被吞掉或者被转换成了普通的流结束信号。GCM 的认证标签校验失败本该抛AEADBadTagException结果变成了“安静地结束”。解决解密侧一律手写updatedoFinal循环不用CipherInputStream。加密侧用不用无所谓因为加密不会失败。如果一定要用流包装至少在解密结束后额外校验一次明文长度——但长度信息本身也可能被伪造所以根本解法还是自己控制doFinal。5.2 现象同一个文件加密两次密文长得一模一样现象做回归测试时发现同一份明文、同一个口令连续加密两次得到的密文完全相同。测试同学觉得“挺稳定的”实际上这是个严重的安全缺陷。原因IV 被写成了固定值或者用new SecureRandom()时因为某种原因比如种子复用、实例被静态缓存产生了相同的随机序列。还有一种更隐蔽的写法把 IV 存成类里的static final byte[]所有实例共用同一个。解决IV 必须在每次加密时现场生成且不能有任何缓存。写个单元测试守住这条连续加密同一份明文三次断言三次的密文前 39 字节头部两两不同。这个测试很便宜但能挡住绝大多数 IV 重用的问题。顺便说GCM 下 IV 重用不只是“泄露相同前缀”这么简单认证密钥都可能被恢复属于必须零容忍的级别。5.3 现象解密失败后目标文件里躺着一堆半成品明文现象用户输错口令程序提示“解密失败”但去目标目录一看文件已经创建了里面有几百 MB 的乱码数据。用户以为文件坏了反复重试磁盘被垃圾文件占满。原因GCM 的认证是在doFinal时才做的而update阶段已经源源不断地把“解密结果”写进了目标文件。等doFinal抛异常前面写出去的内容已经落盘了。这些内容是错误密钥解出来的垃圾但文件系统不知道。解决先写临时文件doFinal成功后再Files.move原子重命名到目标路径失败就在finally里删掉临时文件。这个改动只有五行代码但它把“失败”变成了一个干净的状态。另外注意临时文件要放在和目标文件同一个分区跨分区的move不是原子操作。5.4 现象程序在容器里启动卡住 30 秒现象本地开发机上跑得好好的打成镜像部署到容器里第一次加密调用要卡半分钟监控上看是线程阻塞CPU 占用极低。原因用了SecureRandom.getInstanceStrong()。这个方法在 Linux 上可能返回/dev/random的实现熵池没攒够时会阻塞等待。容器环境熵源少启动初期尤其明显。这是典型的“本地复现不了”的玄学问题。解决普通场景直接用new SecureRandom()它默认走NativePRNG不会长时间阻塞安全性对生成 IV 和盐完全够用。真需要强随机比如生成长期主密钥再考虑getInstanceStrong同时给容器配置足够的熵源。这个坑我踩过一次之后所有涉及随机的代码都默认用new SecureRandom()并在注释里写清楚为什么不用强随机。5.5 现象并发压测下偶发解密失败现象单线程测试全过压测到 50 并发时每隔几分钟出现一次解密失败错误是AEADBadTagException重试又好了。日志里看不出任何规律。原因Cipher实例被复用。代码里为了“省对象”把 Cipher 提成了成员变量或者放进了静态字段多个线程同时init和update内部状态互相覆盖。GCM 的计数器被打乱后算出来的 tag 自然对不上。这类 bug 的典型特征是“偶发、不可复现、重试能过”。解决每个线程独立的 Cipher 实例用ThreadLocal或者方法内getInstance。同时检查Mac、MessageDigest这些同类对象它们也有一样的线程安全问题。压测时把并发数拉高到生产峰值的两倍跑一轮能提前暴露大部分这类问题。6. 验收一个加密工具篡改测试、耗时基准和一条我坚持的规矩代码写完不算完得有办法证明它是对的。我一般跑三个测试缺一不可。第一个是往返测试随机生成 0 字节、1 字节、恰好 64KB、64KB1 字节、10MB 五组文件加密再解密逐字节比对。边界值专挑块大小附近因为update的输出长度在这里最容易出问题。0 字节文件是个经典陷阱——很多人没测过结果空文件加密后解密出来报错。第二个是篡改测试这是验证 GCM 真的在工作的唯一办法。// 在密文中间随便翻一个 bit解密必须失败 byte[] data Files.readAllBytes(encrypted); data[data.length / 2] ^ 0x01; Path tampered Files.createTempFile(tamper, .bin); Files.write(tampered, data); try { FileCryptor.decrypt(tampered, Paths.get(out.bin), password); throw new AssertionError(篡改后竟然解密成功了认证没生效); } catch (AEADBadTagException expected) { // 正确行为 }同时也要测“改头部”把 IV 的某个字节改掉同样应该失败。如果改完还能解出内容说明你用的根本不是 AEAD 模式或者 tag 校验被绕过了。第三个是耗时基准。不要用System.currentTimeMillis()测单次JIT 还没预热数字没有意义。简单做法是同一个文件跑 5 次取后 3 次的平均或者用 JMH。我关注的指标有两个1GB 文件的加解密总耗时评估用户体验以及 PBKDF2 单次派生耗时决定迭代次数能不能再往上加。测试项判定标准不达标的典型原因往返一致性五组文件全部逐字节相等update 返回值未判空篡改检测任一字节被改都必须抛异常用了 CBC 但没做 MAC1GB 加密耗时参考值 2~4 秒SSD缓冲区太小、用了 CipherInputStreamPBKDF2 单次100~300ms迭代次数设置不当最后说一条我给自己定的规矩任何加密相关的改动都必须先写一个“它会怎么坏”的测试再写实现。上面那五条坑有四个是事后补测试才发现的代价是一次线上数据恢复和两个通宵。加密这个东西的特点是正确的时候它和不存在一样出错的时候它也不吭声等到你发现往往已经晚了。所以宁可多花半天写测试也别指望靠肉眼 review 找出问题。这套方案不复杂几百行代码JDK 自带库就够适合绝大多数“给文件加一层口令保护”的需求。如果你的场景涉及多方分发、密钥轮换或者合规审计那要补的东西还很多但这套骨架可以直接作为起点往上长。希望帮到你。本文还有配套的精品资源点击获取