关于并行修改Java Protobuf及其扩展字段的安全性咨询(版本>3.17)
哥们,直接给你划重点:这种并行修改同一个Protobuf Builder的操作绝对不安全,哪怕你改的是完全不相关的普通字段或者扩展字段,Protobuf的Builder从设计之初就没考虑线程安全,3.17+版本也没有改变这个核心设计。
为啥这么说?你得知道Protobuf的Builder内部藏着不少共享状态——比如记录字段是否被修改的标记位、存储扩展字段的哈希表,这些结构的操作全都是非原子的,也没有加同步锁。举个实际的例子:当你调用setFirstLeaf的时候,Builder会更新内部的字段状态标记,这个过程如果同时有另一个线程在改secondLeaf或者扩展字段,很可能会导致标记位错乱,最后build出来的消息要么字段值被莫名覆盖、要么直接抛出ConcurrentModificationException,甚至出现一些说不清楚的半初始化状态,排查起来贼费劲。
看你贴的代码,两个异步任务直接操作同一个topBuilder:
CompletableFuture.supplyAsync(() -> buildFirstLeaf(topBuilder)); CompletableFuture.supplyAsync(() -> buildSecondLeaf(topBuilder));
这种写法风险拉满,哪怕buildFirstLeaf只碰firstLeaf,buildSecondLeaf只碰secondLeaf,也完全逃不过并发竞争的坑。
给你两个靠谱的解决方案:
最推荐的方式:线程隔离处理子消息
让每个异步任务单独生成自己负责的子消息实例,最后在主线程里统一把这些实例设置到主Builder中。这样每个任务完全不共享Builder,彻底规避并发问题,还能保留并行处理的优势。代码大概是这样:// 每个任务返回对应的子消息,不碰主Builder CompletableFuture<FirstLeaf> firstFuture = CompletableFuture.supplyAsync(this::buildFirstLeaf); CompletableFuture<SecondLeaf> secondFuture = CompletableFuture.supplyAsync(this::buildSecondLeaf); // 主线程等待所有任务完成,然后组装 FirstLeaf firstLeaf = firstFuture.join(); SecondLeaf secondLeaf = secondFuture.join(); TopMessage finalMsg = TopMessage.newBuilder() .setFirstLeaf(firstLeaf) .setSecondLeaf(secondLeaf) // 要是有扩展字段,也在主线程统一设置 .build();迫不得已的备选:手动加同步锁
如果你因为某些特殊原因必须共享Builder(真心不推荐),那得自己给每个操作Builder的代码块加同步控制,比如用synchronized把对Builder的操作包起来:CompletableFuture.supplyAsync(() -> { synchronized(topBuilder) { buildFirstLeaf(topBuilder); } return null; }); CompletableFuture.supplyAsync(() -> { synchronized(topBuilder) { buildSecondLeaf(topBuilder); } return null; });但要注意,加了同步之后,两个任务其实变成串行执行了,并行的意义基本就没了,所以除非万不得已,别选这个。
另外再提一句扩展字段的情况:哪怕你并行修改的是完全不同的扩展字段,Builder内部存储扩展的哈希表本身是非线程安全的,并发修改一样会出问题,比如丢数据、抛异常,所以本质上和普通字段的问题是一样的。
总的来说,Protobuf Builder就是给单线程用的,并发操作绝对要不得,老老实实用线程隔离的方式组装消息才是正路。
内容来源于stack exchange

