多线程_volatile关键字

一.volatile简介

单线程的环境中,基本用不到volatile这个关键字,但是在多线程环境中,这个关键字随处可见,总的来说它有三个特性:

  • 可见性
  • 有序性
  • 不保证原子性

二.如何保证可见性

1.何为可见性?

在说volatile的可见性之前,先说说什么是可见性,谈到可见性,就得先说说JMM(java memory model)内存模型,JMM内存模型模型是逻辑上的划分,其实并不是真实存在的。Java线程之间的通信就由JMM控制,JMM决定一个线程对共享变量 的写入何时对另一个线程可见。从抽象的角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memeory)中,每个线程都有有个私有的本地内存(local memory),本地内存中存储了该线程已读/写共享变量的副本。JMM的抽象图如下:
在这里插入图片描述
从上图来看,线程A和线程B之间要进行通信的话,必须要经过两步:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去;
  2. 其次,线程B到主内存中去读取线程A之前已更新过的共享变量。
    在这里插入图片描述
    如上图所示,我们定义的共享变量,是存储在主内存中的,也就是计算机的内存条中,线程A去操作共享变量的时候,并不是直接操作主内存中的值,而是将主内存中的值拷贝回自己的本地内存中,在本地内存中做修改,修改好后,在将值刷新到主内存中。
    假设现在要new 一个person,age是10,这个10是存储在主内存中的,现在两个线程现将10拷贝到自己的本地工作内存中,这时,A线程将10改成了20,然后刷回到主内存中,现在主内存中的值变成了20,。可是B线程并不知道现在主内存中的值改变了,因为A线程所做的操作对B线程是不可见的。我们需要一种机制,即一旦主内存中值发生了改变,就及时通知所有的线程,保证他们对这个变化可见,这就是可见性。

2.为什么volatile能保证可见性?

当变量用volatile修饰时,将会在写操作的后面加一条屏障指令(cpu指令,可以影响数据的可见性),在读操作的前面加一条屏障指令,这样一旦写入完成,就可以保证其他线程读到的是最新值,也就保证了可见性。

3.验证可见性的代码:

public class MyData {
    /**
     * 没加volatile关键字
     */
	int num = 0; 
	/**
	 * 加volatile关键字
	 */
    //volatile int num = 0;
    public int changeNum() {
        return this.num = 10;
    }
}

public class VolatileTest {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread("A") {
            public void run() {
                try {
                    Thread.sleep(3000);
                    // 睡3秒后调用changeNum方法将num改为10
                    System.err.println(Thread.currentThread().getName() +  " update num to " + myData.changeNum());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            };
        }.start();
        // 主线程
        while (myData.num == 0) {
        	System.err.println("不可见性发生,陷入死循环中。。。");
        }
        // 如果主线程读取到的一直都是最开始的0,将造成死循环,这句话将无法输出
        System.err.println(Thread.currentThread().getName()  + " get num value is " + myData.num);
    }
}

以上代码很简单,就是定义一个MyData类,初始化一个num,值为0,然后在main方法中创建另一个线程,将其值改为10,但是这个线程对num的所有操作对main线程是不可见的,所以main线程以为num还是0,因此就会操作死循环.如果num添加了volatile关键字修饰,main线程就会回去到主内存中的最新值,就不会陷入死循环中,这就验证了volatile可以保证可见性.

二.有序性

1.什么叫指令重排?

使用javap命令可以对class文件进行反汇编,可以查看程序底层是如何执行的,像i++这样一个简单地操作,底层会分三步执行,在多线程的情况下,计算机为了提高执行效率,就会对步骤进行重新排序,这个叫指令重排,比如代码:

int a =1;
int b=2;
a=a+3;
b=a+4;

这四句语句,正常的执行顺序就是从上到下依次执行,a的结果是4,b的结果是8,但是在多线程的环境中,编译器指令重排后,执行的顺序就可能变成了1243,这样得出的结果a是4,b是5,这样显然是不正确了.不过编译器在重排的时候也会考虑数据的依赖性,比如执行顺序不可能是2413,因为第四句是依赖a的,使用volatile修饰就会禁止指令重排,让其安装从上到下的顺序执行.

三.不保证原子性

1.volatile不保证原子性解析?

所谓原子性就是一个操作不可能被分割或加塞,要么全部执行要么全部不执行;
java程序在运行时,JVM将java文件编译成了class文件,我们使用javap命令对class文件进行反汇编就可以查看java编译器生成的字节码,最常见的i++问题,其实反汇编后是分三步进行的;

  1. 将i的初始化值装载进工作内存;
  2. 在自己的工作内存中进行自增操作;
  3. 将自己工作内存中的值刷回到主内存;
    我们知道线程的执行具有随机性,假设现在i值是0,有A和B两个线程对其进行++操作,首先两个线程将0拷贝到自己的工作内存中,当线程A在自己的工作内存中进行了加一操作变成了1,还没来得及把1刷回到主内存中,这时B线程抢到cpu执行权了,B将自己工作内存中的0进行自增也变成了1,然后A线程将1刷回到主内存中,这时主内存中的值已经变成了1,然后B线程也将1刷回到主内存,主内存中的值还是1,。本来A和B都对i进行了自增,此时主内存中的值应该是2,但是确是1,出现了写丢失的情况。这是因为i++是一个原子操作,但是却被加赛了其他操作,所以说volatile不保证原子性。

2.volatile不保证原子性代码验证?

public class MyData {

	volatile int num=0;
	//验证volatile不保证原子性
	void  addPlus() {
		this.num++;
	}
	
	//验证volatile不保证原子性
	public static void main(String[] args) {
		MyData data1=new MyData();
		for (int i = 0; i <15; i++) {
			new Thread("线程"+i) {
				public void run() {
					try {
						for (int j = 0; j < 1000; j++) {
							data1.addPlus();
						}
					} catch (Exception e) {
						e.printStackTrace();
					}
				};
			}.start();
		}
		//保证上面的线程执行完mian线程再输出结果,大于2,因为默认有main线程和gc线程,
		//使正在运行中的线程重新变成就绪状态,并重新竞争 CPU 的调度权。它可能会获取到,也有可能被其他线程获取到
		while (Thread.activeCount()>2) {
			Thread.yield();
		}
		System.err.println(Thread.currentThread().getName()+"The Num is :"+data1.num);//mainThe Num is :13341或者其他
	}
}

案例是有一个volatile修饰的num=0,现在有15个线程,每个线程对其执行1000次自增操作,理论上执行完,main线程输入的结果应该是15000,但是运行之后发现,每次运行的结果都会小于15000,这就是出现了写丢失的情况。
如何解决呢?

  1. 可以在addPlus方法上添加synchronized;
	 synchronized void  addPlus() {
		this.num++;
	}
  1. 可以使用原子包装类AtomicInteger;
	//volatile int num=0;
	private AtomicInteger num=new AtomicInteger(0);
	//验证volatile不保证原子性
	  void  addPlus() {
		this.num.getAndIncrement();
	}

但是添加Synchronized的方法太重量级了,整个方法都加锁了,第二种比较好,因为AtomicInteger 使用的是CAS算法。

四、哪些地方用到过volatile?

  • 最简单的单例模式
    饿汉式
/**
 * 饿汉式(一上来就创建对象)
 * 优点:线程安全,类加载就是初始化,每次都创建对象
 * 缺点:容易产生垃圾对象,浪费内存
 * @author pc
 *
 */
public class SingletonHungry {

	private static SingletonHungry singletonHungry=new SingletonHungry();
	
	private  SingletonHungry() {
		System.err.println("const method done...");
	}
	public static SingletonHungry getInstance() {
		return singletonHungry;
	}
}
	懒汉式
/**
 * 懒汉式(用的时候创建对象)
 * 优点:第一次调用才初始化,避免了内存浪费
 * 缺点:必须加锁synchronized才能保证单例,但加锁影响效率;
 * @author pc
 *
 */
public class SingletonLazy {

	private static SingletonLazy singleton=null;
	
	private  SingletonLazy() {
		System.err.println("const method done...");
	}
	public static SingletonLazy getInstance() {
		if(singleton==null) {
			singleton=new SingletonLazy();
		}
		return singleton;
	}
}

懒汉式看似很完美,但是多线程的环境下,会出现线程安全问题,测试下:

	public static void main(String[] args) {
		for (int i = 0; i <20; i++) {
			new Thread(()->SingletonLazy.getInstance()).start();
		}
	}

运行结果:
在这里插入图片描述
运行的结果就是打印了5次构造方法,说明创建了5个对象,所以在多线程的环境下这个单例模式是有问题的,可以在方法上添加Synchronized关键字,可以避免,但是会使整个方法加锁,不太好;下面说说双重加锁版单例模式。

  • DCL版单例(doubled-checked locking)/双重锁:所谓双端检索,就是在加锁前和加锁后都用进行一次判断,代码如下
/**
 * 双锁机制
 * 安全且在多线程情况下能保持高性能,getInstance() 的性能对应用程序很关键
 * 优点:线程安全,延迟加载,效率高
 * @author pc
 *
 */
public class SingletonDCL {

	private volatile static SingletonDCL singleton=null;
	
	private  SingletonDCL() {
		System.err.println("const method done...");
	}
	public static SingletonDCL getInstance() {
		//第一次check
		if(singleton==null) {
			synchronized (SingletonDCL.class) {
				//第二次check
				if(singleton==null) {
					singleton=new SingletonDCL();
				}
			}
		}
		return singleton;
	}
}

用synchronized只锁住创建实例部分代码,在加锁前后分别进行判断,确实只创建了一个对象,但是volatile关键字还是要加上的,singleton 采用 volatile 关键字修饰也是很有必要的,因为 singleton = new SingletonDCL(); 这段代码其实是分为三步执行:
1.为 singleton 分配内存空间(预定房间)
2.初始化 singleton(打扫房间)
3.将 singleton 指向分配的内存地址(入住,此时这个对象部位null)
但是由于JVM具有指令重排的特性,执行顺序有可能变成1、3、2,指令重排在单线程的情况下是没有问题的,但是在多线程的环境下会导致一个线程获得还没有初始化的实例,例如,线程A执行了1、3,此时B线程调用getIUniqueInstance()后发现对象不为空,因为返回,但是此时此对象还没有初始化,使用volatile可以禁止JVM的指令重排,保证多线程环境下正常运行。

  • 枚举版单例(最终版)
public enum Singleton {
	INSTANCE;
	public void whateverMethod() {
	}
	public Singleton getInstance() {
		return INSTANCE;
	}
	public static void main(String[] args) {
		Singleton instance1 = Singleton.INSTANCE;
		Singleton instance2 = Singleton.INSTANCE;
		Singleton instance3= Singleton.INSTANCE;
	}
}

优点:它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。
缺点:当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用new。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值