Java并发编程、内存模型与Volatile

 2019-12-10 11:16  阅读(744)
文章分类:Java Core

http://www.importnew.com/24082.html volatile关键字

http://www.importnew.com/16142.html ConcurrentHashMap原理分析

http://www.importnew.com/19612.html Java内存模型

Java内存模型:

关键字:主存、工作内存;堆区、栈区(http://www.importnew.com/19612.html

2019121000166\_1.png

在Java Memory Model中,Memory分为两类,main memory和working memory,main memory为所有线程共享,working memory中存放的是线程所需要的变量的拷贝(线程要对main memory中的内容进行操作的话,首先需要拷贝到自己的working memory,一般为了速度,working memory一般是在cpu的cache中的)。volatile的变量在被操作的时候不会产生working memory的拷贝,而是直接操作main memory,当然volatile虽然解决了变量的可见性问题,但没有解决变量操作的原子性的问题,这个还需要synchronized或者CAS相关操作配合进行。

Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

并发编程的三大概念:原子性,有序性,可见性。

可见性

也就说假设一个对象中有一个变量i,那么i是保存在main memory中的,当某一个线程要操作i的时候,首先需要从main memory中将i 加载到这个线程的working memory中,这个时候working memory中就有了一个i的拷贝,这个时候此线程对i的修改都在其working memory中,直到其将i从working memory写回到main memory中,新的i的值才能被其他线程所读取。从某个意义上说,可见性保证了各个线程的working memory的数据的一致性。 可见性遵循下面一些规则:

  • 当一个线程运行结束的时候,所有写的变量都会被flush回main memory中。
  • 当一个线程第一次读取某个变量的时候,会从main memory中读取最新的。
  • volatile的变量会被立刻写到main memory中的,在jsr133中,对volatile的语义进行增强,后面会提到
  • 当一个线程释放锁后,所有的变量的变化都会flush到main memory中,然后一个使用了这个相同的同步锁的进程,将会重新加载所有的使用到的变量,这样就保证了可见性。

原子性

还拿上面的例子来说,原子性就是当某一个线程修改i的值的时候,从取出i到将新的i的值写给i之间不能有其他线程对i进行任何操作。也就是说保证某个线程对i的操作是原子性的,这样就可以避免数据脏读。 通过锁机制或者CAS(Compare And Set 需要硬件CPU的支持)操作可以保证操作的原子性。

有序性

假设在main memory中存在两个变量i和j,初始值都为0,在某个线程A的代码中依次对i和j进行自增操作(i,j的操作不相互依赖)

12 i++;j++;
12 i++;j++;

由于,所以i,j修改操作的顺序可能会被重新排序。那么修改后的ij写到main memory中的时候,顺序可能就不是按照i,j的顺序了,这就是所谓的reordering,在单线程的情况下,当线程A运行结束的后i,j的值都加1了,在线程自己看来就好像是线程按照代码的顺序进行了运行(这些操作都是基于as-if-serial语义的),即使在实际运行过程中,i,j的自增可能被重新排序了,当然计算机也不能帮你乱排序,存在上下逻辑关联的运行顺序肯定还是不会变的。但是在多线程环境下,问题就不一样了,比如另一个线程B的代码如下

123 if(j==1){    System.out.println(i);}
123 if(j==1){    System.out.println(i);}

按照我们的思维方式,当j为1的时候那么i肯定也是1,因为代码中i在j之前就自增了,但实际的情况有可能当j为1的时候i还是为0。这就是reordering产生的不好的后果,所以我们在某些时候为了避免这样的问题需要一些必要的策略,以保证多个线程一起工作的时候也存在一定的次序。JMM提供了happens-before 的排序策略。这样我们可以得到多线程环境下的as-if-serial语义。 这里不对happens-before进行详细解释了,详细的请看这里http://www.ibm.com/developerworks/cn/java/j-jtp03304/,这里主要讲一下volatile在新的java内存模型下的变化,在jsr133之前,下面的代码可能会出现问题

123456789101112 MapconfigOptions;char[]configText;volatilebooleaninitialized=false;//InThreadAconfigOptions=newHashMap();configText=readConfigFile(fileName);processConfigOptions(configText,configOptions);initialized=true;//InThreadBwhile(!initialized)  sleep();//useconfigOptions
123456789101112 MapconfigOptions;char[]configText;volatilebooleaninitialized=false;//InThreadAconfigOptions=newHashMap();configText=readConfigFile(fileName);processConfigOptions(configText,configOptions);initialized=true;//InThreadBwhile(!initialized)  sleep();//useconfigOptions

jsr133之前,虽然对 volatile 变量的读和写不能与对其他 volatile 变量的读和写一起重新排序,但是它们仍然可以与对 nonvolatile 变量的读写一起重新排序,所以上面的Thread A的操作,就可能initialized变成true的时候,而configOptions还没有被初始化,所以initialized先于configOptions被线程B看到,就产生问题了。

JSR 133 Expert Group 决定让 volatile 读写不能与其他内存操作一起重新排序,新的内存模型下,如果当线程 A 写入 volatile 变量 V 而线程 B 读取 V 时,那么在写入 V 时,A 可见的所有变量值现在都可以保证对 B 是可见的。

结果就是作用更大的 volatile 语义,代价是访问 volatile 字段时会对性能产生更大的影响。这一点在ConcurrentHashMap中的统计某个segment元素个数的count变量中使用到了。

深入理解volatile关键字

1.volatile保证可见性

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2)禁止进行指令重排序。

先看一段代码,假如线程1先执行,线程2后执行:

12345678 //线程1booleanstop=false;while(!stop){    doSomething();} //线程2stop=true;
12345678 //线程1booleanstop=false;while(!stop){    doSomething();} //线程2stop=true;

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了:

第一:使用volatile关键字会强制将修改的值立即写入主存;

第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);

第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

那么线程1读取到的就是最新的正确的值。

2.volatile不能确保原子性

下面看一个例子:

1234567891011121314151617181920212223 publicclassTest{    publicvolatileintinc=0;     publicvoidincrease(){        inc++;    }     publicstaticvoidmain(String[]args){        finalTesttest=newTest();        for(inti=0;i<10;i++){            newThread(){                publicvoidrun(){                    for(intj=0;j<1000;j++)                        test.increase();                };            }.start();        }         while(Thread.activeCount()>1) //保证前面的线程都执行完            Thread.yield();        System.out.println(test.inc);    }}
1234567891011121314151617181920212223 publicclassTest{    publicvolatileintinc=0;     publicvoidincrease(){        inc++;    }     publicstaticvoidmain(String[]args){        finalTesttest=newTest();        for(inti=0;i<10;i++){            newThread(){                publicvoidrun(){                    for(intj=0;j<1000;j++)                        test.increase();                };            }.start();        }         while(Thread.activeCount()>1) //保证前面的线程都执行完            Thread.yield();        System.out.println(test.inc);    }}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量inc的值为10,

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,也不会导致主存中的值刷新,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过AtomicInteger。

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

3.volatile保证有序性

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

volatile关键字禁止指令重排序有两层意思:

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

2)在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

可能上面说的比较绕,举个简单的例子:

12345678 //x、y为非volatile变量//flag为volatile变量 x=2;       //语句1y=0;       //语句2flag=true; //语句3x=4;        //语句4y=-1;      //语句5
12345678 //x、y为非volatile变量//flag为volatile变量 x=2;       //语句1y=0;       //语句2flag=true; //语句3x=4;        //语句4y=-1;      //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

那么我们回到前面举的一个例子:

123456789 //线程1:context=loadContext();  //语句1inited=true;            //语句2 //线程2:while(!inited){  sleep()}doSomethingwithconfig(context);
123456789 //线程1:context=loadContext();  //语句1inited=true;            //语句2 //线程2:while(!inited){  sleep()}doSomethingwithconfig(context);

前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

volatile的实现原理

1.可见性

处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。

如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。

但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

2.有序性

Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

volatile的应用场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

1)对变量的写操作不依赖于当前值

2)该变量没有包含在具有其他变量的不变式中

下面列举几个Java中使用volatile的几个场景。

①.状态标记量

123456789 volatilebooleanflag=false; //线程1while(!flag){    doSomething();}  //线程2publicvoidsetFlag(){    flag=true;}
123456789 volatilebooleanflag=false; //线程1while(!flag){    doSomething();}  //线程2publicvoidsetFlag(){    flag=true;}

根据状态标记,终止线程。

②.单例模式中的double check

1234567891011121314151617 classSingleton{    privatevolatilestaticSingletoninstance=null;     privateSingleton(){     }     publicstaticSingletongetInstance(){        if(instance==null){            synchronized(Singleton.class){                if(instance==null)                    instance=newSingleton();            }        }        returninstance;    }}
1234567891011121314151617 classSingleton{    privatevolatilestaticSingletoninstance=null;     privateSingleton(){     }     publicstaticSingletongetInstance(){        if(instance==null){            synchronized(Singleton.class){                if(instance==null)                    instance=newSingleton();            }        }        returninstance;    }}
为什么要使用volatile 修饰instance?

主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

1.给 instance 分配内存

2.调用 Singleton 的构造函数来初始化成员变量

3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java并发编程、内存模型与Volatile

相关推荐