求助:MAC的doFinal方法对相同数据生成不同值的并发场景问题
哈哈,这个问题我之前在并发场景下踩过一模一样的坑!核心问题出在Mac实例不是线程安全的,不管是你用并行流测试的场景,还是Spring响应式应用里把Mac当成单例Bean注入的场景,本质都是多个线程共用了同一个有状态的Mac实例,导致计算结果混乱。
为什么会出现不一致的摘要?
Mac类内部维护了哈希计算的上下文状态(比如中间哈希值),当多个线程同时调用doFinal()时,会互相干扰这个内部状态——比如线程A刚更新了部分状态,线程B就过来修改,最终导致每个线程计算出来的摘要都不是基于完整输入的正确结果。
你的测试代码里用了parallel()并行流,11个线程共用同一个mac实例;而Spring响应式应用里默认Bean是单例的,并发请求时所有请求线程都会复用同一个Mac实例,自然会出现同样的问题。
解决方案
1. 普通并发代码场景(比如你的测试用例)
最直接的方式是让每个线程拥有独立的Mac实例,避免线程间状态干扰。如果担心频繁创建实例影响性能,可以用ThreadLocal来缓存每个线程的Mac实例:
方案1:每个线程创建新实例
public static void main(String[] args) { String key = "test"; byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); IntStream.range(1, 12).parallel().forEach(d->{ try { // 每个线程都创建并初始化自己的Mac实例 SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "HmacSHA512"); Mac mac = Mac.getInstance("HmacSHA512"); mac.init(secretKeySpec); final byte[] bytes = "somestring".getBytes(StandardCharsets.UTF_8); final byte[] doFinal = mac.doFinal(bytes); String digest = Base64.getEncoder().encodeToString(doFinal); LOGGER.info("digest -- {} ", digest); } catch (NoSuchAlgorithmException | InvalidKeyException e) { LOGGER.error("生成HMAC失败", e); } }); }
方案2:用ThreadLocal缓存线程专属实例
public static void main(String[] args) { String key = "test"; byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); // ThreadLocal确保每个线程有自己的Mac实例 ThreadLocal<Mac> macThreadLocal = ThreadLocal.withInitial(() -> { try { Mac mac = Mac.getInstance("HmacSHA512"); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "HmacSHA512"); mac.init(secretKeySpec); return mac; } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("初始化Mac实例失败", e); } }); IntStream.range(1, 12).parallel().forEach(d->{ Mac mac = macThreadLocal.get(); final byte[] bytes = "somestring".getBytes(StandardCharsets.UTF_8); // doFinal会自动重置Mac的内部状态,所以同一线程多次调用也安全 final byte[] doFinal = mac.doFinal(bytes); String digest = Base64.getEncoder().encodeToString(doFinal); LOGGER.info("digest -- {} ", digest); }); }
2. Spring响应式应用场景
Spring默认Bean是单例的,所以绝对不能把Mac实例直接做成单例Bean。推荐以下两种方案:
方案1:将Mac Bean设置为Prototype Scope
让Spring每次获取Bean时都创建新实例,确保每个请求线程拿到独立的Mac:
@Configuration public class HmacConfig { @Value("${hmac.key}") private String hmacKey; @Bean @Scope("prototype") // 每次注入都会创建新实例 public Mac hmacSha512Mac() throws NoSuchAlgorithmException, InvalidKeyException { SecretKeySpec secretKeySpec = new SecretKeySpec(hmacKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512"); Mac mac = Mac.getInstance("HmacSHA512"); mac.init(secretKeySpec); return mac; } }
方案2:用Supplier提供Mac实例
通过Supplier包装,每次需要时创建新实例,更灵活:
@Configuration public class HmacConfig { @Value("${hmac.key}") private String hmacKey; @Bean public Supplier<Mac> hmacSha512MacSupplier() { return () -> { try { SecretKeySpec secretKeySpec = new SecretKeySpec(hmacKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512"); Mac mac = Mac.getInstance("HmacSHA512"); mac.init(secretKeySpec); return mac; } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("创建HMAC实例失败", e); } }; } }
在业务类中注入Supplier<Mac>,每次生成摘要时获取新实例:
@Service public class HmacService { private final Supplier<Mac> hmacSha512MacSupplier; // 构造注入 public HmacService(Supplier<Mac> hmacSha512MacSupplier) { this.hmacSha512MacSupplier = hmacSha512MacSupplier; } public String generateHmac(String data) { Mac mac = hmacSha512MacSupplier.get(); byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); byte[] hmacBytes = mac.doFinal(dataBytes); return Base64.getEncoder().encodeToString(hmacBytes); } }
关键提醒
永远不要在多线程环境下共用同一个Mac、MessageDigest这类有状态的加密实例——它们的设计初衷就是单线程使用,并发场景下必须保证实例的线程隔离性,要么每个线程一个实例,要么使用ThreadLocal缓存,要么在Spring中调整Bean作用域。
内容的提问来源于stack exchange,提问作者Salman

