`

Java线程安全兼谈DCL

阅读更多

转载自 ---- http://www.iteye.com/topic/875420

      如果你搜索网上分析dcl为什么在java中失效的原因,都会谈到编译器会做优化云云,我相信大家看到这个一定会觉得很沮丧、很无助,对自己写的 程序很没信心。我很理解这种感受,因为我也经历过,这或许是为什么网上一直有人喜欢谈dcl的原因。如果放在java5之前,从编译器的角度去解释dcl 也无可厚非,在java5的JMM(内存模型)已经得到很大的修正,如果到现在还只能从编译器的角度去解释dcl,那简直就在污辱java,要知道 java的最大优势就是只需要考虑一个平台。你可以完全无视网上绝大多数关于dcl的讨论,很多时候他们自己都说不清楚,除Doug Lea等几个大牛,我不相信谁比谁更权威。

很多人不理解dcl,不是dcl有多么复杂,恰恰相反,而是对基础掌握得不够。所以,我会先从基础讲起,然后再分析DCL。

我们都知道,当两个线程同时读写(或同时写)一个共享变量时会发生数据竞争。那我怎么才能知道发生了数据竞争呢?我需要去读取那个变量,发生数据 竞争通常有两个表现:一是读取到陈旧数据,即读取到虽是曾经写入的数据,但不是最新的。二是读取到之前根本没有写入的值,也就是说读到垃圾。

数据陈旧性

为了读取到另一个线程写入的最新数据,JMM定义了一系列的规则,最基本的规则就是要利用同步。在Java中,同步的手段有synchronized和volatile两种,这里我只会涉及到syncrhonized。请大家先记住以下规则,接下来我会细讲。

规则一:必须对变量的所有写和所有读同步,才能读取到该最新的数据。

先看下面的代码:

Java代码  收藏代码
  1. public   class  A {  
  2.     private   int  some;  
  3.     public   int  another;  
  4.   
  5.     public   int  getSome() {  return  some; }  
  6.     public   synchronized   int  getSomeWithSync() {  return  some; }  
  7.     public   void  setSome( int  v) { some = v; }  
  8.     public   synchronized   void  setSomeWithSync( int  v) { some = v; }  
  9. }  



让我们来分析一个线程写,另一个线程读的情形,一共四种情形。初始情况都是a = new A(),暂不考虑其它线程。

情形一:读写都不同步。

Thread1 Thread2
(1) a.setSome(13)
(2) a.getSome()





这种情况下,即使thread1先写入some为13,thread2再读取some,它能读到13吗?在没有同步协调下,结果是不确定的。从图 上看出,两个线程独立运行,JMM并不保证一个线程能够看到另一个线程写入的值。在这个例子中,就是thread2可能读到0(即some的初始值)而不 是13。注意,在理论上,即使thread2在thread1写入some之后再等上一万年也还是可能读到some的初始值0,尽管这在实际几乎不可能发 生。


情形二:写同步,读不同步

Thread1 Thread2
(1) a.setSomeWithSync(13)
(2) a.getSome()





情形三:读同步,写不同步

Thread1 Thread2
(1) a.setSome(13)
(2) a.getSomeWithSync()





在这两种情况下,thread1和thread2只对读或只对写some加了锁,这不起任何作用,和[情形一]一样,thread2仍有可能读到 some的初始值0。从图上也可看出,thread1和thread2互相之间并没有任何影响,一方加锁并不影响另一方的继续运行。图中也显示,同步操作 相当于在同步开始执行lock操作,在同步结束时执行unlock操作。

情形四:读写都同步

Thread1 Thread2
(1) a.setSomeWithSync(13)
(2) a.getSomeWithSync()





在情形四中,thread1写入some时,thread2等待thread1写入完成,并且它能看到thread1对some做的修改,这时 thread2保证能读到13。实际上,thread2不仅能看到thread1对some的修改,而且还能看到thread1在修改some之前所做的 任何修改。说得更精确一些,就是一个线程的lock操作能看见另一线程对同一个对象unlock操作之前的所有修改,请注意图中的红色箭头。 沿着图中箭头指示方向,箭头结尾处总能看到箭头开始处操作做的修改。这样,a.some[thread2]能看见 lock[thread2],lock[thread2]能看见unlock[thread1],unlock[thread1]又能看见 a.some=13[thread1],即能看到some的值为13。

再来看一个稍微复杂一点的例子:

例子五

Thread1 Thread2
(1) a.another = 5
(2) a.setSomeWithSync(13)
(3) a.getSomeWithSync()
(4) a.another = 7
(5) a.another





thread2最后会读到another的什么值呢?会不会读到another的初始值0呢,毕竟所有对another的访问都没有同步?不会。 从图中很清晰地可以看出,thread2的another至少到看到thread1在lock之前写入的5,却并不能保证它能看到thread1在 unlock写入的7。因此,thread2可以什么读到another的值可能5或7,但不会是0。你或许已经发现,如果去掉图中thread2读取 a.some的操作,这时相当于一个空的同步块,对结论并没有任何影响。这说明空的同步块是起作用的,编译器不能擅自将空的同步块优化掉,但你在使用空的 同步块应该特别小心,通常它都不是你想要的结果。另外需要注意,unlock操作和lock操作必须针对同一个对象,才能保证unlock操作能看到 lock操作之前所做的修改。

例子六:不同的锁

Java代码  收藏代码
  1. class  B {  
  2.     private  Object lock1 =  new  Object();  
  3.     private  Object lock2 =  new  Object();  
  4.   
  5.     private   int  some;  
  6.   
  7.     public   int  getSome() {  
  8.         synchronized (lock1) {  return  some; }  
  9.     }  
  10.   
  11.     public   void  setSome( int  v) {  
  12.         synchronized (lock2) { some = v; }  
  13.     }  
  14. }  

 

Thread1 Thread2
(1) b.setSome(13)
(2) b.getSome()





在这种情况下,虽然getSome和setSome都加了锁,但由于它们是不同的锁,一个线程运行时并不能阻塞另一个线程运行。因此这里的情形和情形一、二、三一样,thread2不保证读到thread1写入的some最新值。

现在来看DCL:

例子七: DCL

Java代码  收藏代码
  1. public   class  LazySingleton {  
  2.     private   int  someField;  
  3.       
  4.     private   static  LazySingleton instance;  
  5.       
  6.     private  LazySingleton() {  
  7.         this .someField =  201 ;                                  // (1)   
  8.     }  
  9.       
  10.     public   static  LazySingleton getInstance() {  
  11.         if  (instance ==  null ) {                                // (2)   
  12.             synchronized (LazySingleton. class ) {                // (3)   
  13.                 if  (instance ==  null ) {                        // (4)   
  14.                     instance = new  LazySingleton();            // (5)   
  15.                 }  
  16.             }  
  17.         }  
  18.         return  instance;                                       // (6)   
  19.     }  
  20.       
  21.     public   int  getSomeField() {  
  22.         return   this .someField;                                 // (7)   
  23.     }  
  24. }  



假设thread1先调用getInstance(),由于此时还没有任何线程创建LazySingleton实例,它会创建一个实例s并返回。 这是thread2再调用getInstance(),当它运行到(2)处,由于这时读instance没有同步,它有可能读到s或者null(参考情形 二)。先考虑它读到s的情形,画出流程图就是下面这样的:



由于thread2已经读到s,所以getInstance()会立即返回s,这是没有任何问题,但当它读取s.someFiled时问题就发生 了。 从图中可以看thread2没有任何同步,所以它可能看不到thread1写入someField的值20,对thread2来说,它可能读到 s.someField为0,这就是DCL的根本问题。从上面的分析也可以看出,为什么试图修正DCL但又希望完全避免同步的方法几乎总是行不通的。

接下来考虑thread2在(2)处读到instance为null的情形,画出流程图:



接下来thread2会在有锁的情况下读取instance的值,这时它保证能读到s,理由参考情形四或者通过图中箭头指示方向来判定。

关于DCL就说这么多,留下两个问题:

  1. 接着考虑thread2在(2)读到instance为null的情形,它接着调用s.someFiled会得到什么?会得到0吗?
  2. DCL为什么要double check,能不能去掉(4)处的check?若不能,为什么?



原子性
回到情形一,为什么我们说thread2读到some的值只可能为为0或13,而不可能为其它?这是由java对int、引用读写都是原子性所决 定的。所谓“原子性”,就是不可分割的最小单元,有数据库事务概念的同学们应该对此容易理解。当调用some=13时,要么就写入成功要么就写入失败,不 可能写入一半。但是,java对double, long的读写却不是原子操作,这意味着可能发生某些极端意外的情况。看例子:

Java代码  收藏代码
  1. public   class  C {  
  2.     private   /* volatile */   long  x;                            // (1)   
  3.   
  4.     public   void  setX( long  v) { x = v; }  
  5.     public   long  getX() {  return  x; }  
  6. }  

 

Thread1 Thread2
(1) c.setX(0x1122334400112233L)
(2) c.getX()



thread2读取x的值可能为0,1122334400112233外,还可能为别的完全意想不到的值。一种情况假设jvm对long的写入是 先写低4字节,再写高4字节,那么读取到x的值还可能为112233。但是我们不对jvm做如此假设,为了保证对long或double的读写是原子操 作,有两种方式,一是使用volatile,二是使用synchronized。对上面的例子,如果取消(1)处的volatile注释,将能保证 thread2读取到x的值要么为0,要么为1122334400112233。如果使用同步,则必须像下面这样对getX,setX都同步:

Java代码  收藏代码
  1. public   class  C {  
  2.     private   /* volatile */   long  x;                            // (1)   
  3.   
  4.     public   synchronized   void  setX( long  v) { x = v; }  
  5.     public   synchronized   long  getX() {  return  x; }  
  6. }  



因此对原子性也有规则(volatile其实也是一种同步)。

规则二:对double, long变量,只有对所有读写都同步,才能保证它的原子性

有时候我们需要保证一个复合操作的原子性,这时就只能使用synchronized。

Java代码  收藏代码
  1. public   class  Canvas {  
  2.     private   int  curX, curY;  
  3.   
  4.     public   /* synchronized */  getPos() {  
  5.         return   new   int [] { curX, curY };  
  6.           
  7.     }  
  8.   
  9.     public   /* synchronized */   void  moveTo( int  x,  int  y) {  
  10.         curX = x;  
  11.         curY = y;  
  12.     }  
  13. }  

 

Thread1 Thread2
(1) c.moveTo(1, 1)
(2) c.moveTo(2, 2)
(3) c.getPos()



当没有同步的情况下,thread2的getPos可能会得到[1, 2], 尽管该点可能从来没有出现过。之所以会出现这样的结果,是因为thread2在调用getPos()时,curX有0,1或2三种可能,同样curY也有 0,1或2三种可能,所以getPos()可能返回[0,0], [0,1], [0,2], [1,0], [1,1], [1,2], [2,0], [2,1], [2,2]共九种可能。要避免这种情况,只有将getPos()和moveTo都设为同步方法。

总结
以上分析了数据竞争的两种症状,陈旧数据和非原子操作,都是由于没有恰当同步引起的。这些其实都是相当基础的知识,同步可有两种效果:一是保证读 取最新数据,二是保证操作原子性,但是大多数书籍都对后者过份强调,对前者认识不足,以致对多线程的认识上存在很多误区。如果想要掌握java线程高级知 识,我只推荐《Java并发编程设计原则与模式》。其实我已经好久没有写Java了,这些东西都是我两年前的知识,如果存在问题,欢迎大家指出,千万不要 客气。

分享到:
评论
1 楼 lzc_java 2013-01-09  
  

相关推荐

    java线程安全总结.doc下载

    Java线程安全是多线程编程中的一个核心概念,尤其在服务器端开发中,理解并掌握线程安全至关重要。线程安全是指当多个线程访问一个对象时,如果这个对象的状态始终保持一致,那么我们就说这个对象是线程安全的。在...

    浅议单例模式之线程安全(转)

    4. 双重检查锁定(DCL,线程安全): 在懒汉式的基础上添加了同步锁,避免了线程安全问题。 ```java public class Singleton { private volatile static Singleton INSTANCE; private Singleton() {} ...

    Java多线程设计模式_清晰完整PDF版 Java多线程设计模式源代码

    Java中双检锁/双重检查锁定(Double-Check Locking,DCL)和静态内部类是实现线程安全单例的常用方法。 5. 状态对象模式:用于在多线程中同步访问对象的状态,例如CountDownLatch、CyclicBarrier和Semaphore等并发...

    Java并行(4):线程安全前传之Singleton1

    然而,早期的DCL在Java早期版本中并不完全线程安全,因为Java内存模型的某些细节可能导致`instance`字段在初始化之前被非原子性地读取。 4. 安全发布 安全发布是指确保对象在完全初始化后被其他线程可见。在DCL模式...

    JAVA多线程模式高清版+DEMO

    在"Java多线程设计模式_清晰完整PDF版"文档中,你可能还会学习到如何结合实际场景选择合适的线程模型,如何优化多线程程序,以及如何调试和处理线程安全问题。这些内容对于提升Java并发编程能力非常有帮助,对于开发...

    线程安全的单例模式

    ### 线程安全的单例模式详解 #### 一、单例模式简介 单例模式(Singleton Pattern)是软件开发中最常用的创建型设计模式之一,它的主要目标是确保一个类只有一个实例,并提供一个全局访问点。单例模式在很多场景下...

    Java线程内存模型的缺陷.docx

    ### Java线程内存模型的缺陷 #### Java内存模型(JMM)概述 Java作为一种高度抽象化的编程语言,致力于提供统一的内存管理模型,以便开发者能够跨平台地编写多线程程序。为此,Java引入了一个核心概念——Java内存...

    Java多线程编程实战指南 设计模式篇.rar

    3. 线程同步:Java提供了synchronized关键字、Lock接口(如ReentrantLock)以及wait()、notify()、notifyAll()等工具来保证线程安全,防止数据竞争。 二、设计模式在多线程中的应用 1. 生产者消费者模式:通过阻塞...

    多线程,高并发.zip

    创建Java线程有两种主要方法:通过实现`Runnable`接口或继承`Thread`类。`Runnable`通常更灵活,因为它允许线程与其他对象共享数据,而不会遇到单继承的限制。 高并发则涉及系统能够同时处理大量请求的能力。在...

    java多线程设计模式

    Java多线程设计模式是Java开发中不可或缺的一部分,它涉及到如何在并发环境下高效、安全地组织程序执行。本文将深入探讨Java多线程设计模式及其应用,帮助开发者理解和掌握这一重要技术。 首先,理解Java多线程的...

    java线程笔记

    在Java线程中,同步是非常重要的概念,用于解决多线程环境下的数据安全问题。Java提供了多种同步机制,包括synchronized关键字、Lock接口(如ReentrantLock)以及java.util.concurrent包中的并发工具类(如Semaphore...

    synchronized与单例的线程安全

    4. 双重检查锁定(DCL):结合了懒汉式的延迟加载和饿汉式的线程安全,既保证了线程安全,又降低了性能影响。 ```java public class Singleton { private volatile static Singleton INSTANCE; private Singleton...

    单例模式(饿汉模式、懒汉模式、DCL单例模式、枚举)

    单例模式是设计模式中的一种,它在...DCL单例模式在性能和线程安全之间找到了平衡;而枚举单例模式则是最安全且推荐的实现方式,适用于大多数情况。在实际开发中,开发者应根据项目特点和性能需求选择合适的单例模式。

    Java 多线程编程核心技术

    7. **线程状态**:Java线程有五种状态,分别是新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和终止(Terminated)。理解这些状态有助于诊断和解决多线程问题。 8. **死锁、活锁与饥饿**:多...

    Java多线程设计模式(带源码)

    Java中常见的单例实现有懒汉式(线程不安全)、饿汉式(线程安全)、双重检查锁定(DCL,线程安全)等。DCL模式通过`volatile`关键字和`synchronized`关键字确保了线程安全,是推荐的多线程环境下的单例实现方式。 ...

    java多线程 清华大学独特详细讲解源代码

    6. **设计模式**:在Java多线程编程中,一些经典的设计模式如生产者消费者模型、线程池模式、双检锁/双重校验锁定(DCL)等,能够帮助我们构建高效、安全的并发程序。 这个"Java多线程设计模式上传文件"可能包含这些...

    DCL常用设计方法

    然而,原始的DCL在Java早期版本中存在线程安全问题,因为编译器的指令重排序可能导致非预期的结果。Java 5之后引入了volatile关键字,解决了这个问题。 2. volatile关键字: volatile确保共享变量在多线程环境中的...

Global site tag (gtag.js) - Google Analytics