多线程学习笔记(二)

多线程并发的所有支持的类都在java.lang.concurrent包中。
    要想理解volatile或者synchronized等关键字的用法,需要先去理解JMM(Java内存模型)是如何处理可见性和有序性两个问题的。同时,我们需要理解,Java内存模型与JVM堆栈内存模型是不一样的,它是一个抽象化的概念。

1、Java内存模型(Java Memory Model)简述:
    内存模型中把内存分为主内存和线程工作内存,线程工作内存是CPU寄存器和高速缓存的抽象描述。CPU在计算过程中读取数据的优先级是:寄存器-高速缓存-内存,即工作内存-主内存的关系。在线程开始前,将主内存的变量拷贝到工作内存中;在线程过程或者完成后,需要将工作内存中的脏数据适时写回到主内存中。

1.1、主内存与工作内存访问规则
    这里定义程序中各个变量的访问规则。(包括实例字段、静态字段和构成数组的元素,不包括局部变量和方法参数)
        1)所有变量都存储在主内存中。
        2)每条线程都有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存的拷贝副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写内存中的变量。
        3)线程之间无法直接访问对方的工作内存的变量,线程间变量的传递均需要通过主内存来完成。
    多线程学习笔记(二)_第1张图片

    主内存和工作内存的原子性交互操作:
        Lock(锁定):作用于主内存中的变量,把一个变量标识为一条线程独占的状态。
        Read(读取):作用于主内存中的变量,把一个变量的值从主内存传输到线程的工作内存中。
        Load(加载):作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中。
        Use(使用):作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎。
        Assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。
        Store(存储):作用于工作内存中的变量,把工作内存中的一个变量的值传送到主内存中。
        Write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
        Unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,之后可被其它线程锁定
    它们关系如下图:
     多线程学习笔记(二)_第2张图片

    规则:

        1)不允许read和load、store和write操作之一单独出现。
        2)不允许一个线程丢弃最近的assign操作,变量在工作内存中改变了之后必须把该变化同步回主内存中。
        3)不允许一个线程没有发生过任何assign操作把数据从线程的工作内存同步回主内存中。
        4)一个新的变量只能在主内存中诞生。
        5)一个变量在同一时刻只允许一条线程对其进行lock操作,但可以被同一条线程重复执行多次。
        6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行read、load操作。
        7)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作。
         8) 对一个变量执行unlock操作前,必须先把该变量同步回主内存中。
        上述构成了JMM中工作内存与主内存中变量的交互方式。举个例子,在Java中执行以下语句: i = 10; 那么线程首先是将变量i作assign赋值操作,存入工作内存中,然后将i变量的值使用store存储和write写入操作写入到主内存中。而不是直接数值10写入主内存当中。
        但我们会想,如何将线程计算前后的内存变量适时地读入或则写出呢?即进行load/read或则store/write操作,因为我们看到,在线程中我们一直用的是主内存的拷贝变量,即缓存(工作内存),如果多个线程进行对主内存同一变量的修改,就会有可能存在缓存不一致的问题,那解决办法就是要保证变量的原子性、可见性和有序性。

1.2、原子性、可见性与有序性
    原子性:原子性即指一个操作或者多个操作要么全部执行,要么不执行(可以理解为事务)。assign、store、read等操作都是原子性操作。可以理解为基本类型的数值赋值操作都是原子类型的,如: x = 1; 就是一个原子操作。但 x = y;不是一个原子操作,它包括3个原子操作,先把y的值从主内存中读入到工作内存中即read/load操作,再进行赋值操作。
    可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其它线程能够看到修改的值。这里使用了缓存一致性协议,当CPU写数据时,发现当前操作的变量是一个共享变量时,即向其它存在该变量副本发出信号将该变量的缓存设置为无效,使得其重新从主内存中读入。
    有序性:即程序执行的顺序按照代码的先后顺序执行。因为系统会进行指令优化,这里需要防止指令重排优化

    那具体到Java中,如何实现原子性、可见性和有序性呢?就是用我们的重点volatile和synchronized,volatile关键字能够保证变量的可见性和有序性,当一个共享变量被volatile修饰时,它会保证修改的值会立刻更新到内存中,当有其它线程需要读取时,它回去内存中读取新值。同时,volatile关键字具有防止指令重排的功能。而synchronized和Lock能够保证除了基本类型赋值之外的更大范围的原子性操作,即事务操作;因为对主内存变量进行锁定和解锁操作,保证同一时刻只有一个线程对该变量读取或者写入操作,另其保持可见性;因为是对线程做获取锁和释放锁操作,也会防止指令重排,即保证有序性。
    补充:在有序性当中,Java内存模型具有一些先天的“有序性”,即不需要通过任何收单就能够保证有序性,通常也称为先行发生原则(happens-before),如果两个操作的执行次序无法从先行发生原则推导出来,那么就不能保证它们的有序性,Java编译器能随意将它们重排序。规则如下:
    1) Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到

    2)程序次序规则:在一个线程内,按照代码控制流顺序,在前面的操作先行发生于后面的操作。

    3)管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。

    4)Volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。

    5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个操作。

    6)线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。

    7)线程中断规则:对线程的interrupt()方法的调用先行发生于被中断线程的代码检测中断事件的发生。

    8)对象终结过则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

    9)传递性:如果操作A先行发生于操作B,操作B现象发生于操作C,那么就可以得出操作A先行发生于操作C的结论。

    解释一下第三条规则,其实就是一个线程先去写一个变量到主内存中,当有其它线程进行读取,则写入操作发生在读操作前面。即做write操作,才能让其它线程做read操作。

2、volatile

    一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,就具备了两层语义:
        1)可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,能够给其它线程立即可见。
        2)有序性:禁止进行指令重排。

例子:

public class VolatileTest {
 //共享变量
 boolean stop = false;
 
 public static void main(String args[]) {
  final VolatileTest test = new VolatileTest();
  //线程1
  new Thread(new Runnable() {
   
   public void run() {
    while(!test.stop) {
     System.out.println("Thread " + Thread.currentThread().getName() + " is stopped");
    }
   }
  }).start();
  //线程2
  new Thread(new Runnable() {
   public void run() {
    test.stop = true;
    System.out.println("Thread" + Thread.currentThread().getName() + " 改变了stop的值");
   }
  }).start();
 }
}
这是一段典型的代码,线程1先执行,线程2后执行。你会发现每次执行的时候线程1打印出来的语句数量都会不一样,甚至有可能一直打印下去,造成死循环。这是因为线程2在改变了stop变量的值之后,还没来得及写入主内存中,线程1不知道线程2对变量的更改,因此会一直循环下去。

如果我们使用了volatile关键字,则就可以保证线程2stop变量的修改能够给线程1可见。这是因为,volatile关键字强制将修改立刻写入主内存并对使用该变量的线程的缓存无效,线程1只能往主内存取值,那么就能够读取到最新的值。

但是,volatile不能保证原子性,我们可以这样理解,一个变量从工作内存写入到主内存或者从主内存读入到工作内存是要分两部进行如store/write或者read/load操作,每一个都是原子操作,但是合起来就不是一个原子操作了。举个例子,被volatile修饰的共享变量,修改后往主内存中写入,但是在进行store操作后CPU时间片用完了,那么就会进入等待阶段,其它的线程读取这个共享变量时就不是最新的值了。导致结果不一致。我们看一段代码就会明白了:


public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<100;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

或者你会想,100个线程自增1000,那么最后应该是100*1000 = 100000才对,但是实际上的结果一直都是少于100000的。原因就是不能保证从主内存读入工作内存或者从工作内存写入主内存是原子性,所以结果也就不对了。那我们应该怎么用volatile这个关键字呢?通常来说,使用volatile必须具备以下2个条件:
    1)对变量的写操作不依赖于当前值
    2)该变量没有包含在具有其他变量的不变式中

举个例子:
volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

我们如果仅仅只需要一个布尔值,并用于指示一次重要的时间,例如完成初始化或请求停机。那么就可以使用了,即一次性安全发布(one-time safe publication)。但如inc++这种自增的方式是不合适的,因为它破坏了volatile使用的第一个条件。

我们再举个例子,双重检查锁定实现单例问题:

    public class Singleton {  
     
        private static volatile Singleton instance = null;  
     
        private Singleton(){}  
     
         
     
        public static Singleton getInstance() {  
     
           if(instance == null) {  
     
               synchronized(Singleton.class) {  
     
                  if(instance == null) {  
     
                      instance = new Singleton();  //不加volatile防止重排序会导致instance虽然不为null,但还没有完成对象初始化
     
                  }  
     
               }  
     
           }  
     
           return instance;  
     
        }  
     
    } 

上述例子是正确的例子,同时使用volatile关键字和synchronized关键字锁定class对象,既能保证性能,同时也不会出现不正确状态的实例返回。但是,如果我们上述的例子如果instance没有被volatile修饰呢?看上去也是很正确的呀,但其实是会有错误的,因为编译器会发生 指令重排

我们来看一下如果不加volatile关键字,会出现问题的一行语句:  
instance = new Singleton(); 
在线程中初始化一个对象会分为四个步骤:
    1)分配对象的内存空间
    2)初始化对象
    3)设置变量指向内存空间
    4)初次访问对象
在没有违反intra-thread semantics即在单线程内不改变程序的执行结果,就可以进行重排序,在这里第2步骤会有可能跟第3步进行重排序,即先设置instance变量指向内存空间,然后再进行初始化,那么当其他线程访问getInstance方法时,发现instance是不为空(因为已经设置instance变量指向了内存空间了),但是还没初始化完毕,导致获取了一个不正确的instance实例,所以需要使用volatile关键字阻止指令重排。当然,如果要实现单例模型还可以实现私有静态内部类来完成。

更好的理解双重检查问题请点击: http://ifeve.com/double-checked-locking-with-delay-initialization/

参考:
http://www.cnblogs.com/dolphin0520/p/3920373.html
http://ifeve.com/talk-to-my-understanding-of-the-java-memory-model/
http://www.cnblogs.com/yshb/archive/2012/06/15/2550367.html
http://ifeve.com/double-checked-locking-with-delay-initialization/

你可能感兴趣的:(JAVA,多线程,Java,多线程,singleton,volatile)