共享对象

来源:互联网 发布:孙悟空和菩提祖师知乎 编辑:程序博客网 时间:2024/05/21 17:39

1. 共享对象

可见性

可见性涉及的方面:

 

(1) 过期数据

非线程安全的计数器:

/**  * @Title: NotThreadSafetyCounter.java  * @Package visibility  * @Description: TODO  * @author 落叶飞翔的蜗牛  * @date 2017年12月3日 下午6:47:35* @version V1.0  */  package visibility; /** * @author 落叶飞翔的蜗牛 * @date 2017年12月3日 下午6:47:35 */public class NotThreadSafetyCounter {private int counter; /** * @return the counter */public int getCounter() {return counter;} /** * @param counter the counter to set */public void setCounter(int counter) {this.counter = counter;}}


 

 

非线程安全的计数器:

/**  * @Title: ThreadSafetyCounter.java  * @Package visibility  * @Description: TODO  * @author 落叶飞翔的蜗牛  * @date 2017年12月3日 下午6:47:10* @version V1.0  */  package visibility; /** * @author 落叶飞翔的蜗牛 * @date 2017年12月3日 下午6:47:10 */public class ThreadSafetyCounter {private int counter; /** * @return the counter */public synchronized int getCounter() {return counter;} /** * @param counter the counter to set */public synchronized void setCounter(int counter) {this.counter = counter;}}


 

(2) 非原子的64位操作

Java内存模型要求获取和存储操作都为原子的,但是非volatilelongdouble变量,JVM允许将64位的读写操作划分为两个32位的操作。如果读写发生在不同的线程,这种情况读取一个非volatile类型的long或者double就可能出现读取了一个long的高32位和另一个long的低32位。因此在多线程中使用共享的可变的long或者double变量也可能是不安全的。除非将其生命为volatile或者用锁保护起来。

(3) Volatile

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

i = i + 1;

当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。

这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。

比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2i的值写入内存。

最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

1. 原子性

Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

x = 10;         //语句1y = x;         //语句2x++;           //语句3x = x + 1;     //语句4


语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。

语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。

同样的,x++x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

所以上面4个语句只有语句1的操作具备原子性。

2. 可见性

Java提供的弱同步:volatile变量。它确保对一个变量的写入以可以预见的方式告知其他线程。当一个变量被声明为volatile后,编译器会监视这个变量:它是共享的,并且对它的操作不会与其他内存操作一起被重排序。所以读一个volatile变量总是会返回某一线程所写入的最新值。

线程Avolatile变量写入值,随后线程B读取该变量,所有A线程执行对volatile变量执行写操作之前的可见变量,在B线程读取volatile变量后,也会对B线程可见。所以从内存可见性的角度看,写入volatile变量就像是退出了同步块,读取volatile变量就像是进入了同步块。

Volatile语义不足以使自增操作(counter++)成为原子性。因为自增操作是读写改的复合操作。原子变量提供读写改的支持(AtomicLongAtomicInteger等)

加锁可以保证原子性和可见性。Volatile只能保证可见性,不能保证原子性。

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

 

3. 有序性

Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronizedLock来保证有序性,很显然,synchronizedLock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为happens-before原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

·程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

·锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作

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

·传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

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

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

·线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

·对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

8条原则摘自《深入理解Java虚拟机》。

 

(4) 深入剖析volatile

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

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

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

//线程1boolean stop = false;while(!stop){    doSomething();}


 

//线程2

stop = true;

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

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

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

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

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

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

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

volatile能保证有序性吗?

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

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

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

2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

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

//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是可见的。

volatile的原理和实现机制

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

 

发布和逸出

发布一个对象的意思是使它能够被当前范围之外的代码所使用。比如将一个引用存储到其他代码可以访问的地方,在一个非私有的方法中返回这个对象的引用,也可以把它传递到其他类的方法中。

一个对象尚未准备好时就将其发布出去,这种情况就叫做逸出(escape

允许内部可变数据逸出的示例:

/**  * @Title: InnerVariableEscape.java  * @Package publish  * @Description: 内部可变数据逸出* @author 落叶飞翔的蜗牛  * @date 2017年12月3日 下午10:59:35* @version V1.0  */package publish; import java.util.HashSet;import java.util.Set; /** * @author 落叶飞翔的蜗牛 * @date 2017年12月3日 下午10:59:35 */public class InnerVariableEscape {public static HashSet<String> objectSets = new HashSet<String>() {/** * @Fields serialVersionUID : TODO */private static final long serialVersionUID = 5050434981450895395L; {add("销售额");add("销售");add("销售金额");add("销售值");add("销售总金额");add("销售总额");add("销售总值");}}; /** * @Title: getObjects    * @Description: 这种方式发布会出问题。任何一个调用者都可以修改objectSets * @param: @return       * @return: Set<String>       * @throws */public Set<String> getObjects() {return objectSets;}}


 

线程封闭

将对象封闭在一个线程中,这种做法会使得对象自动成为线程安全的,即使被封闭的对象本身并不是线程安全的。

常见的使用线程限制的应用程序是应用池化的JDBC Connection对象。JDBC规范并没有要求Connection对象是线程安全的。在实际应用中,线程总是从池中获取一个Connection对象,并且用它处理一个单一的请求,用完之后把它归还。

每个线程接入连接池以后,从连接池中获取一个连接,放入该线程的ThreadLocal中,在该线程的后续DAO操作中,都是用封装在ThreadLocal中的那个连接,就可以做到对多个DAO操作的事物管理。

栈限制是线程限制的一种特例。本地变量就被限制在执行线程中,它们存在于执行线程栈,而这个栈是不会再线程间共享的,每个线程都有自己的执行栈。

一种维护线程安全的更加规范的方式是使用ThreadLocal,它允许你把每个线程与数值的对象关联在一起。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

 

(5) 参考文献

① http://www.importnew.com/18126.html

 

原创粉丝点击