浅谈Java多线程之内存可见性

 

目录

学习目标:

可见性介绍:

synchronized实现可见性原理:

synchronized实现可见性代码:

volatile实现可见性:

volatile不能保证原子性:

再谈谈CPU:

CPU的Cache模型

CPU缓存一致性问题:

关于内存屏障:


 

 

学习目标:

可见性介绍:

synchronized实现可见性原理:

优化之后更加符合处理器的特点

synchronized实现可见性代码:

先附上代码:

public class SynchronizedDemo {
	//共享变量
    private boolean ready = false;
    private int result = 0;
    private int number = 1;   
    //写操作
    public void write(){
    	ready = true;	      				 //1.1				
    	number = 2;		                    //1.2			    
    }
    //读操作
    public void read(){			   	 
    	if(ready){						     //2.1
    		result = number*3;	 	//2.2
    	}   	
    	System.out.println("result的值为:" + result);
    }

    //内部线程类
    private class ReadWriteThread extends Thread {
    	//根据构造方法中传入的flag参数,确定线程执行读操作还是写操作
    	private boolean flag;
    	public ReadWriteThread(boolean flag){
    		this.flag = flag;
    	}
        @Override                                                                    
        public void run() {
        	if(flag){
        		//构造方法中传入true,执行写操作
        		write();
        	}else{
        		//构造方法中传入false,执行读操作
        		read();
        	}
        }
    }

    public static void main(String[] args)  {
    	SynchronizedDemo synDemo = new SynchronizedDemo();
    	//启动线程执行写操作
    	synDemo .new ReadWriteThread(true).start();
    	try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
    	//启动线程执行读操作
    	synDemo.new ReadWriteThread(false).start();
    }
}

 为什么共享变量可以不加static?

只要是对同一个对象的操作,多线程访问共享变量是不需要加static的。

这里是同一个外部类对象,然后外部类对象里面有2个内部类对象,相当于main里面的操作导致异步调用了read和write方法,这2个方法是都可以直接获取成员变量的。

同理:如果实现了runnable接口的对象,new了多个Thread,但是传入的是同一个实现了runnable接口的对象,那么共享变量是不需要加static的。

如果class piao extends Thread{

         private static int count = 10; // 如果这里不加static,那么这个变量就是各个线程独有的,不会共享
         public void run() {

                 .........
                if (count > 0) {
                    count--;
                    System.out.println(Thread.currentThread().getName() + "卖出一张票,票还剩" + count);
                }
        }

...}

main方法里面{

        // 这里不是同一个对象,是多个对象

        Thread t1 = new piao();

        Thread t2 = new piao();

        Thread t3 = new piao();

        t1.start();

        t2.start();

        t3.start();

}

2.1和2.2也是可以重排序的,虽然存在控制依赖关系,但是不存在数据依赖关系,只有存在数据依赖关系才不能重排序。这样执行结果有多种,就不一一列举了。

内存可见了,怎么还会执行结果不一致呢?保证了内存可见性并不能保证执行结果一致。这里read()操作和write()操作加了synchronized是原子性的,但是又不保证read()和write()哪个先执行,所以会出现2个结果,如果是先read()执行,那么result就是0,如果write()先执行,那么result就是6。最后通过延时保证write()先执行,结果就是只有6。

加了synchronized,能够保证在主内存和工作内存及时的更新,保证了内存的可见性,但是不加synchronized,也可能内存可见,即工作内存和主内存的值能够更新,但是不能够保证,只是可能,因为编译器采取了优化,可能导致更新不及时。

 

volatile实现可见性:

volatile不能保证原子性:

使用ReentrantLock同步

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class VolatileDemo {

	private Lock lock = new ReentrantLock();
	private int number = 0;
	
	public int getNumber(){
		return this.number;
	}
	
	public void increase(){
		try {
			Thread.sleep(100);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		lock.lock();
		try {
			this.number++;
		} finally {
			lock.unlock();
		}
	}
	
	/**
	 * @param args
	 */
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		final VolatileDemo volDemo = new VolatileDemo();
		for(int i = 0 ; i < 500 ; i++){
			new Thread(new Runnable() {
				
				@Override
				public void run() {
					volDemo.increase();
				}
			}).start();
		}
		
		//如果还有子线程在运行,主线程就让出CPU资源,
		//直到所有的子线程都运行完了,主线程再继续往下执行
		while(Thread.activeCount() > 1){
			Thread.yield();
		}
		
		System.out.println("number : " + volDemo.getNumber());
	}

}

 

 

 

再谈谈CPU:

CPU的Cache模型:

         

       

CPU缓存一致性问题:

 

 

 

关于内存屏障:

为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序

 

1.当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3)

2.当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前(第二行)

3.当第一个操作是volatile写,第二个操作是volatile读时,不能重排序(3,2)

4.当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序(第三列)

 

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
内存屏障可以被分为以下几种类型
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。        在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

       有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
       为了实现JSR-133的规定,Java编译器会这样使用内存屏障。

 

     为了保证final字段的特殊语义,也会在下面的语句加入内存屏障。
     x.finalField = v; StoreStore; sharedRef = x;

 

那么什么才是volatile读,volatile写呢?什么是普通读写呢?

比如int a = 10; // 普通写

int b = a; // 先把a从工作内存读取出来(普通读),接着再写到b里去(普通写)

volatile int c  = 20; // 属于volatile写

volatile int d = c; // volatile读去主存中去读取c的值,同时更新工作内存中的c值,接着再volatile写,写到d中,再将d的值刷到主存

int e = d; // volatile读去主存读取d的值,同时更新工作内存的d值,然后接着普通写,写到e中

volatile int f = e; // 先普通读e的值,接着再volatile写,写到f中,再将f的值立即刷到主存

注意:赋值是写操作,普通读就是从工作内存读,volatile读就是从主存读。

 

从上面例子看出,因为第一步普通读写和第二步volatile读不冲突(不会发生在一行赋值语句),所以可以重排序,也不需要屏障。

比如if (e == f){...}      e是普通读,f是volatile读,可以重排序,最后判断值是否相等。

第一步volatile写(比如volatile int  a = 10)和第二步普通读写不冲突,所以可以重排序,不需要屏障。

==================================================

 

 

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

JMM基于保守策略的JMM内存屏障插入策略:

1.在每个volatile写操作的前面插入一个StoreStore屏障

2.在每个volatile写操作的后面插入一个SotreLoad屏障

3.在每个volatile读操作的后面插入一个LoadLoad屏障

4.在每个volatile读操作的后面插入一个LoadStore屏障

 

 

上图的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了

因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存

 

 

x86处理器仅仅会对写-读操作做重排序

因此会省略掉读-读、读-写和写-写操作做重排序的内存屏障

在x86中,JMM仅需在volatile后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义

这意味着在x86处理器中,volatile写的开销比volatile读的大,因为StoreLoad屏障开销比较大

 

========================Talk is cheap, show me the code=======================

posted @ 2018-10-31 11:07  绿叶萌飞  阅读(296)  评论(0编辑  收藏  举报