细说Java多线程之内存可见性笔记

个人博客网:https://wushaopei.github.io/    (你想要这里多有)

 

说明:多线程的内存可见性涉及到多线程间的数据争用,也涉及到了多线程间的数据可见性

一、共享变量在线程间的可见性

1、可见性介绍:

可见性: 一个线程对共享变量值的修改,能够及时地被其他线程看到。

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

2、Java内存模型(JMM)

Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

在内存模型中:

所有的变量都存储在主内存中;

每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)

内存模型图:

                  

JMM线程操作内存的两条基本的规定:

第一条关于线程与主内存:线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写

第二条关于线程间工作内存:不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要经过主内存来完成。

3、共享变量可见性实现的原理:

线程1对共享变量的修改要想被线程2及时看到,必须要经过如下2个步骤

  • 把工作内存1中更新过的共享变量刷新到主内存中
  • 把住内存中最新的共享变量的值更新到工作内存2中

流程图:

               

二、synchronized实现可见性

由前面可以知道,要实现共享变量的可见性,必须保证两点

  • 线程修改后的共享变量值能够及时从工作内存刷新到主内存中;
  • 其他线程能够及时把共享变量的最新值从住内存更新到自己的工作内存中

1、可见性的实现方式

【1】Java语言层面支持的可见性实现方式

  •  synchronized
  •  volatile

【2】synchronized能够实现的 两个功能

  • 原子性(同步)
  • 可见性

【3】JMM关于synchronized 的两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中;
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁与解锁需要的是同一把锁)

线程解锁前对共享变量的修改在下次加锁时对其他线程可见

【4】线程执行互斥代码的过程:

  1. 获得互斥锁
  2. 清空工作内存
  3. 从主内存拷贝变量的最新副本到工作内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存
  6. 释放互斥锁

【5】指令重排序的概念以及类型

重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化

  1. 编译器优化的重排序(编译器优化)
  2. 指令集并行重排序(处理器优化)
  3. 内存系统的重排序(处理器优化)

示例:指令重排序 可能 造成的结果

        

【6】   as-if-serial

   as-if-serial:无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致(Java编译器、运行时和处理器都会保证Java在单线程下遵循   as-if-serial 语义)

    int num1 = 1;           //第1行代码
    int num2 = 2;           //第2行代码
    int sum = num1 + num2 ; //第3行代码

单线程: 第1、2行的顺序可以重排,但第3行不能

重排序不会给单线程带来内存可见性问题

多线程中程序交错执行时,重排序可能会造成内存可见性问题

2、synchronized实现可见性代码

package mkw.demo.syn;

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();
    }
}

3、synchronized实现可见性分析:

【1】没有进行重排序

    执行结果:result的值为3

             

【2】进行重排序

    执行结果:result的值为0

            

【3】分析:导致共享变量在线程间不可见的原因

  1. 线程的交叉执行
  2. 重排序结合线程交叉执行
  3. 共享变量更新后的值没有在工作内存与主内存间及时更新

【4】 安全代码:

             

【5】synchronized 实现内存可见性的解决方案:

  1. 添加了synchronized 的地方相当于加了一把锁,被添加锁的地方,在一定时间内只能当前线程释放锁,其他线程才有机会进入其中执行代码;
  2. synchronized  使得 同步的情况下,共享变量在被第二次调用前便被同步到了主内存,实现了共享变量的即时更新

         

重点:为啥synchronized原子性可以避免线程交叉执行:因为synchronized加锁在对象上,执行read方法的线程1获得了对象锁,那线程2不能获得对象锁也就不能执行write方法,因为要执行write方法需要获得锁。但是线程1可以继续执行write方法,因为write方法和read方法可以使用同一把锁,synchronized锁可以重入

4、synchronized实现可见性结果分析:

此处执行结果为6的情况进行分析:
1 synchronized完美保证共享变量的可见性
2 但是不加此关键字,并不意味着就不能实现可见性

【1】为何不加synchronized也会执行可见性,主内存及时更新被获取最新值”?

原因有很多个

①即使没有加synchronized,也可能是可见的,在大多数情况都是可见的,因为编译器优化了,会揣摩程序的意图,程序运行很多次,只会有很少的情况不可见。

②因为当时定义说加synchronized一定会可见性,而不加也没说一定不会,只是有可能不会,因为现在Java做了一些优化:尽量实现可见性;但是不能保证每次都成功,只是成功概率比较大99%,但还是有1%的情况会失败。所以处于安全考虑,尽量加synchronized关键字100%成功。

【2】有时候依然不存在线程交叉情况,但还是会先执行第二个线程,因为第一个线程把CPU让位出来,所以为了避免这种情况,可以在第一个线程后附上代码:sleep(1000);1秒之后才有机会执行线程2。

【3】synchronized+sleep();黄金搭档。

三、volatile实现可见性

1、volatile能够保证可见性

volatile关键字:

  • 能够保证volatile变量的可见性
  • 不能保证volatile变量复合操作的原子性

volatile 如何实现内存可见性:

深入来说:通过加入内存屏障和禁止重排序优化来实现的。

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
  • 对volatile变量执行读操作时,会在读操作前后加入一条load屏障指令

执行引擎对 volatile 的操作:

通俗地讲:volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总能看到该变量的最新值。

线程 读、 写 volatile 变量的过程

【1】线程写volatile变量的过程:

  1.  改变线程工作的内存中volatile变量副本的值
  2. 将改变后的副本的值从工作内存刷新到主内存

【2】线程读volatile变量的过程:

  1. 从主内存中读取volatile变量的最新值到线程的工作内存中
  2. 从工作内存中读取volatile变量的副本

volatile不能保证volatile变量复合操作的原子性:

       

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资源
//直到所有的子线程都运行完了,主线程再继续往下执行
while(Thread.activeCount()>1){
        Thread.yield();
}

理论来讲,最后的值应该是500,但是因为num++;不是原子操作,且volatile关键字又没有原子性,所以偶尔会出现<500的情况。

2、程序分析

num++不是原子操作,原子操作意为(所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch  --百科),volatile能保证可见性,但是在多线程调度时 num++ 被拆分为 

 1)从主存中读取num值;

 2) i = num + 1;

 3) 写回i 到主存的num

基于 number = 5 的分析:

1、线程A读取到的number 为 5;

2、线程B读取到的number 为 5;

3、线程B 执行加1 操作, number ++

4、线程B写入最新的 number 为 3 中的  5+ 1 = 6;

5、线程A 执行加 1 操作没有向主内存读取共享变量,故 依旧是 由 原始变量  number = 5 开始 加 1 操作,即 此时 number  = 5+ 1 ;

6、线程 A 写入最新的 number  值 ,此时内存中的 只是 将 线程B 的 6 改成了 线程 A 的6 ,实际上只是同值的 覆盖,而非 递增

安全性解决方案:

保证number自增操作的原子性:

  • 使用synchronized关键字

  • 使用ReentrantLock(java.until.concurrent.locks包下)

  • 使用AtomicInteger (vava.util.concurrent.atomic包下)

3、保证 number 变量在线程中的原子性

【1】用synchronized 保证 number 变量在线程中的原子性

        public synchronized void increase(){
		try {
			Thread.sleep(100);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
			this.number++;
	
	}

【2】用ReentrantLock 实现number 变量在线程中的原子性

     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();
		}
	}

4、volatile适用场合

要在多线程中安全的使用volatile变量,必须同时满足:

a)对变量的写入操作不依赖其当前值

    不满足:number++、count=count*5等

    满足:boolean变量、记录温度变化的变量等

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

不满足:不变式low<up

5、synchronized和volatile比较

a)volatile不需要加锁,比synchronized更轻便,不会阻塞线程

b)从内存可见性的角度来讲,volatile的读相当于加锁,volatile的写相当于解锁

c)synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性

补充:

【1】对于64位(long、double)变量的读写可能不是原子操作:
.Java内存模型允许JVM将没有被volatile修饰的64位数据类型的读写操作划分为两次32位的读写操作来进行

导致问题:有可能会出现读取到"半个变量"的情况
解决方法:加volatile关键字

【2】问:即使没有保证可见性的措施,很多时候共享变量依然能够在主内存和工作内存见得到及时的更新?

答:一般只有在短时间内高并发的情况下才会出现变量得不到及时更新的情况,因为CPU在执行时会很快滴刷新缓存,所以一般情况下很难看到这种问题.

posted @ 2019-12-03 20:31  维宇空灵  阅读(387)  评论(0编辑  收藏  举报