浅谈Java多线程之内存可见性
发布日期:2021-06-30 19:56:07 浏览次数:2 分类:技术文章

本文共 5441 字,大约阅读时间需要 18 分钟。

 

目录


 

 

学习目标:

可见性介绍:

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=======================

转载地址:https://liuchenyang0515.blog.csdn.net/article/details/82956849 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:11. Container With Most Wate(盛最多水的容器)
下一篇:顺时针打印矩阵

发表评论

最新留言

能坚持,总会有不一样的收获!
[***.219.124.196]2024年04月17日 13时41分04秒