Java 中,volatile+不可变容器对象能保证线程安全么?

发布于 2022-09-04 08:32:47 字数 2602 浏览 13 评论 0

《Java并发编程实战》第3章原文

《Java并发编程实战》中3.4.2 示例:使用Volatile类型来发布不可变对象

在前面的UnsafeCachingFactorizer类中,我们尝试用两个AtomicReferences变量来保存最新的数值及其因数分解结果,但这种方式并非是线程安全的,因为我们无法以原子方式来同时读取或更新这两个相关的值。同样,用volatile类型的变量来保存这些值也不是线程安全的。然而,在某些情况下,不可变对象能提供一种弱形式的原子性。
因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单3-12中的OneValueCache。

程序清单 3-12 对数值及其因数分解结果进行缓存的不可变容器类
@Immutable  
class OneValueCache {  
   private final BigInteger lastNumber;  
   private final BigInteger[] lastFactors;  
 
   public OneValueCache(BigInteger i,  
                        BigInteger[] factors) {  
       lastNumber  = i;  
       lastFactors = Arrays.copyOf(factors, factors.length);  
   }  
 
   public BigInteger[] getFactors(BigInteger i) {  
       if (lastNumber == null || !lastNumber.equals(i))  
           return null;  
       else  
           return Arrays.copyOf(lastFactors, lastFactors.length);  
   }  
} 

对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。
程序清单3-13中的VolatileCachedFactorizer使用了OneValueCache来保存缓存的数值及其因数。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。

程序清单 3-13 使用指向不可变容器对象的volatile类型引用以缓存最新的结果
@ThreadSafe  
public class VolatileCachedFactorizer implements Servlet {  
   private volatile OneValueCache cache =  
       new OneValueCache(null, null);  
 
   public void service(ServletRequest req, ServletResponse resp) {  
       BigInteger i = extractFromRequest(req);  
       BigInteger[] factors = cache.getFactors(i);  
       if (factors == null) {  
           factorfactors = factor(i);  
           cache = new OneValueCache(i, factors);  
       }  
       encodeIntoResponse(resp, factors);  
   }  
} 

与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。
通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得VolatileCachedFactorizer在没有显式地使用锁的情况下仍然是线程安全的。


分析
  • 程序清单3-13中存在『先检查后执行』(Check-Then-Act)的竞态条件。

  • OneValueCache类的不可变性仅保证了对象的原子性。

  • volatile仅保证可见性,无法保证线程安全性。

综上,对象的不可变性+volatile可见性,并不能解决竞态条件的并发问题,所以原文的这段结论是错误的。


更新

疑惑已经解决了。

结论:
cache对象在service()中只有一处写操作(创建新的cache对象),其余都是读操作,这里符合volatile的应用场景,确保cache对象对其他线程的可见性,不会出现并发读的问题。返回的结果是factors对象,factors是局部变量,并未使cache对象逸出,所以这里也是线程安全的。


如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(4

转身泪倾城 2022-09-11 08:32:47
  • cache对象在service()中只有一处写操作(创建新的cache对象),其余都是读操作,这里符合volatile的应用场景,确保cache对象对其他线程的可见性,不会出现并发读的问题。

  • 返回的结果是factors对象,factors是局部变量,并未使cache对象逸出,所以这里也是线程安全的。

め七分饶幸 2022-09-11 08:32:47

一下个人理解:
(1).cache对象在service()中只有一处写操作,但是多个线程都会执行这个写操作。比如A线程带入参数a1执行service方法之后,缓存里是number=a , lastFactors=[a];这时a2线程进入service方法带入a,执行BigInteger[] factors = cache.getFactors(i); 取得了缓存数据[a]进行判断时线程切换,来了个C线程带入参数c执行完了方法。实际上此时的缓存是c和[c].理论上线程B读的值已经是过期的了。。。只是因为“缓存”的业务意义使得这个过期值不会引起程序错误罢了。。。也就是说这个例子的线程安全体现在正好切合了这个业务。。。
(2).OneValueCache的不可变也是有限制的。BigInteger[]类型的数组接受外部参数后,用Arrays.copyOf能使得初始化后值不再变化。但是如果不是BigInteger类型而是其他类型,要保证该类型也是不可变对象才行。

总之我感觉,对于我这么菜的初学者来说,锁还是最好用的。毕竟程序的优化是建立在没有BUG的基础上。万一哪边因为理解偏差导致隐含漏洞就跪了。

莫相离 2022-09-11 08:32:47

请问如果不用volatile修饰这个cache变量会怎么样

燃情 2022-09-11 08:32:47

我觉得这个问题,对于常量类来说,多线程读的操作不存在线程安全问题,但是写的操作要看具体应用场景对于线程安全的定义。比如说《Java并发编程实战》那段,我觉得作者对于线程安全就是只看当前的cache,最后达成一个缓存的目的,至于这个缓存能不能立马用到不在乎,反正肯定有线程会用到的。

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文