volatile关键字详解

    科技2022-08-21  137

    volatile是什么?

    volatile是java虚拟机提供的轻量级的同步机制, volatile有三大特性: 1.保证可见性 2.不保证原子性 3.禁止指令重排

    什么是可见性

    其实就是只要有一个线程对变量操作完成之后写回主内存其他线程立马知道 研究可见性就要知道什么是JMM?

    JMM是什么?

    JMM是java内存模型,是一种抽象的概念并不真实存在,是一套或一组规范,就像中国人说我属羊就知道是什么意思。通过这组规范定义了程序中各个变量的访问方式。

    JMM同步的规定:

    线程解锁前必须把共享变量的值刷新到主内存。线程加锁前必须读取主内存最新值到自己的工作内存加锁解锁是同一把锁。

    可见性分析:(其实就是只要有一个线程对变量操作完成之后写回主内存其他线程 立马知道) 由于jvm运行程序的实体是线程,每个线程jvm都会为其分配一个工作内存,工作内存是每个线程的私有区域,而java内存模型(JMM)规定所有的变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作必须在自己的工作内存中进行,因此线程每次操作变量都要将其从主内存中拷贝到自己的工作内存,然后进行操作,操作完成之后将其写回到主内存。

    上边提到的主内存其实就是我们电脑的内存条,jvm运行内存。

    代码演示:

    class Mydata{ volatile int data = 0; //轻量同步机制。 public void alterData(){ this.data = 60; } } public class volatileDemo { public static void main(String[] args) { Mydata m = new Mydata(); //创建对象就是放到堆里边,堆在主内 存中也就是我们的内存条 new Thread(new Runnable() { @Override public void run() { m.alterData(); //把变量拷贝到自己的工作内存并把值改为60 try{ Thread.sleep(3000); //模仿程序执行时间 }catch(Exception e){ } } }).start(); while(m.data == 0){ //因为这个主内存中的共享变量没有用volatile关键字修饰, // 就没有可见性,其他线程不知道这个变量修改了,因此主线程还认为这个变量是0 //会一直循环在这里代码不会向后执行。 } System.out.println("hahahhahahahhahha"); //只有共享变量加上volatile修饰才会执行。 } }

    代码画图分析:

    总结:

    其实被volatile修饰的变量不会被加载到线程的工作内存,还是在主内存中,当一个线程对其变量进行修改其他线程立马就知道了,因为主内存是线程共享区域。 如果共享变量很多不建议使用volatile关键字修饰变量,会影响程序执行效率。

    可见性:其实就是对共享变量进行修改其他线程立马同步。

    volatile不保证原子性:

    什么是原子性? 其实就是在多线程中保证数据的一致性。

    代码如下:

    public class Mydata { private volatile int number=0; public void setNumber(){ number++; } public static void main(String[] args) { Mydata data =new Mydata(); for(int i=0;i<20;i++){ new Thread(new Runnable() { @Override public void run() { for(int j=0;j<1000;j++){ data.setNumber(); } } },String.valueOf(i)).start(); } //上边20个线程执行完,主线程下来主线程一直等待, // 直到上边的线程执行完就剩主线程和GC线程 while(Thread.activeCount() > 2){ Thread.yield(); //主线程一直处于阻塞。 } System.out.println(Thread.currentThread().getName()+"int type"+data.number); } }

    代码分析:

    如果volatile保证原子性,那么最终输出结果应该是20000,因为20个线程每个线程对data变量操作data++一千次,如果保证原子性最终main线程输出应该是20000。但是运行了很多遍结果都小于20000这是为什么呢?是因为volatile不保证原子性在多线程环境下发生了写丢失,写覆盖。

    为什么会发生写丢失?

    这还要说到JMM(JAVA内存模型),程序运行的实体是线程,线程有各自的工作内存,操作主内存中的变量首先要拷贝到自己的工作内存,由于线程调度极快,有可能一个线程对其操作之后刚要写回主内存出现了挂起,另一个线程刚写完,这个线程又被唤醒,没有拿到主内存最新数据就进行了写操作,导致写覆盖。

    javap命令的介绍

    javap -c 命令可以查看.java文件编译成.class字节码文件内容。字节码文件中jvm有一个数据字典,对应着机器码,转换成二进制代码。不同的操作系统有不同的数据字典。

    怎么解决volatile不保证原子性:

    java中给我们提供了以下类,保证原子性:

    更改代码如下:

    public class Mydata { private volatile int number=0; AtomicInteger integer=new AtomicInteger(); public void setNumber(){ number++; } private void setatoIntrger(){ integer.getAndIncrement(); } public static void main(String[] args) { Mydata data =new Mydata(); for(int i=0;i<20;i++){ new Thread(new Runnable() { @Override public void run() { for(int j=0;j<1000;j++){ data.setNumber(); data.setatoIntrger(); } } },String.valueOf(i)).start(); } //上边20个线程执行完,主线程下来主线程一直等待, // 直到上边的线程执行完就剩主线程和GC线程 while(Thread.activeCount() > 2){ Thread.yield(); //主线程一直处于阻塞。 } System.out.println(Thread.currentThread().getName()+"int type"+data.number); System.out.println(Thread.currentThread().getName()+"AtomicInteger type"+**data.integer**); //20000 } }

    为什么atomicInteger能保证原子性?

    主要是unsafe类,

    //this代表当前对象 //valOffset代表内存偏移量 //1代表内存偏移多少 public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }

    //方法点进去继续看 //你会发现代码跑到了unSafe.class字节码中,这个类中的代码大部分都用native修饰,是比较接近底层的,不是用Java写的用c或者c++写的。

    public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { //var5先获得当前对象的内存地址所指向的值也就类似于c和c++中的指针指向的值。 var5 = this.getIntVolatile(var1, var2); //如果取到当前对象内存所指向的值是var5说明没有其他线程更改就把主内存中的值更改为var5 + var4,其中var4=1; } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }

    上边代码就要聊到CAS?

    什么是CAS

    其实就是比较并交换。就是在多线程中当线程操作完变量想写回主内存时,首先要比较这个主内存中的值是否被其他线程修改,如果没有就写回主内存,如果有就不不写回,类似于乐观锁一样。

    代码如下:

    AtomicInteger atomicInteger = new AtomicInteger(5); atomicInteger.compareAndSet(5,2019); //true System.out.println(atomicInteger.get()); //2019 atomicInteger.compareAndSet(5,2012); //false System.out.println(atomicInteger.get()); //2019

    可以让我们自定义的类变成原子性(CAS)

    AtomicReference<>类

    代码如下:

    public class Cat { private String name; private int age; public Cat(String name,int age){ this.name = name; this.age = age; } @Override public String toString() { return "Cat{" + "name='" + name + '\'' + ", age=" + age + '}'; } public static void main(String[] args) { Cat c1 = new Cat("zhangsan",18); Cat c2 = new Cat("lisi",25); AtomicReference<Cat> atomic = new AtomicReference<>(); atomic.set(c1); //修改成功设置主内存数据为c2 System.out.println(atomic.compareAndSet(c1,c2)+" "+atomic.get().toString()); //修改失败,主内存数据还是c2 System.out.println(atomic.compareAndSet(c1,c2)+" "+atomic.get().toString()); } }

    CAS存在的问题

    1、循环时间长,很耗cpu性能。(如果线程数很对,一直比较都不对就进入很长时间的循环) 2、只能保证一个共享变量的原子操作 3、也就是我们的ABA问题。

    ABA问题:

    比如一个线程one从主内存中取出A,这时候另一个线程two从主内存中也取出A,并且线程two进行了一系列操作将值变成了B,然后线程two又将值变成了A,这时候线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功。线程one认为这个值没有被别的线程所修改,其实已经被two线程修改过了。

    如何解决ABA问题:

    1、加入版本号(类似于时间戳) AtomicStampedReference类

    ArrayList在多线程环境下的问题分析:

    ArrayList我们都知道是线程不安全的。请写出一个ArrayList线程不安全的Demo

    代码如下:

    public static void main(String[] args) { ArrayList<String> list = new ArrayList<>(); for(int i=1;i<=25;i++){ new Thread(new Runnable() { @Override public void run() { list.add(UUID.randomUUID().toString().substring(0,8)); System.out.println(list); } },String.valueOf(i)).start(); } }

    实验结果如果list.add(“a”);添加一个字符不会抛并发修改异常(java.util.ConcurrentModificationException),如果是上面的代码就会抛出并发修改异常。

    bug的处理

    1、故障现象:并发修改异(java.util.ConcurrentModificationException) 2、导致原因: 并发修改导致,比如一个人正在写,另一个人进来争抢,导致数据不一致异常 3、解决方案: 1、可以使用Voctor集合,可以发现Voctor的add方法被synchronized 修饰 2、可以用Collections集合工具类把不安全的ArrayList变成安全的。Collections.synchronizedList(new ArrayList<>()); 3、可以使用java.util.Concurrent包中的CopyOnWriteArrayList(); 4、预防措施:

    为什么CopyOnWriteArrayList类可以保证线程安全?

    源码分析:CopyOnWriteArrayList中的add方法如下

    public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); //获得到老数组 int len = elements.length; //得到老数组长度 Object[] newElements = Arrays.copyOf(elements, len + 1); //创建新数组,长度是老数组长度+1。并把老数组拷贝到新数组中 newElements[len] = e; //在新数组最后一位添加新元素 setArray(newElements); //把引用指向新数组 return true; } finally { lock.unlock(); } }

    采用了写时复制的思想,就是进行写的时候我先复制一份,比如一个人要签自己的名字,首先把原有的复制一份,用lock加上锁,然后扩充一个,把自己名字写上,让原有引用指向自己,最后再释放锁。

    如图所示:

    Volatile禁止指令重排:

    多线程环境中线程交替执行,由于编译器优化重排指令的存在,对没有依赖性的指令进行重排,两个线程中使用的变量不能够保证一致,结果无法确定,无法预测。

    代码如下:

    public class Demo{ int a = 0; boolean flg = false; public void method01(){ a = 1; flg = true; } public void method2(){ if(flg){ a = a+5; System.out.println(a); //多线程环境下,结果有可能是5,也 //有可能是6 } } }

    分析:如果在多线程环境下,编译器优化指令重排之后,flg = true;a = 1;交换了位置,导致线程执行到方法2直接进入a = a+5;这时候a的值还是0,最终结果是5。

    volatile用在什么地方:

    用在单例模式 回顾单例模式:

    饿汉式:

    public class Person { private final static Person person = new Person(); public static Person getInstance(){ return person; } }

    饿汉式是线程安全的(静态变量和全局变量引起的),在对象没初始化就已经创建实例了,因此线程安全,但比较占内存。

    懒汉式:

    class Dog{ private static Dog dog = null; private static Dog getInstance(){ if(dog == null){ //线程1挂起 线程2执行完之后,线程1在这个位置继续执行。 dog = new Dog(); } return dog; } }

    懒汉式存在线程安全,在多线程的环境下有可能创建不同的实例。可以直接在方法上加synchronized 但效率低并且有点大材小用。

    饿汉式代码优化:DCL双端检索机制(Double Check Lock)

    private static volatile Dog dog = null; private static Dog getInstance(){ if(dog == null){ synchronized (Dog.class){ if(dog == null){ dog = new Dog(); } } } return dog; }

    加入了双端检索机制(DCL)但是不一定线程安全,因为有指令重排的存在,加入volitile可以禁止指令重排。

    原因在于某一个线程执行到第一次检测,读取到的dog不为null,dog引用的对象可能没有完成初始化。 dog = new Dog(); //可以分为三步完成。 memory = allocate(); 1.分配对象内存空间。 dog(memory); 2.初始化对象。 dog= memory; 3.设置dog指向刚分配的内存地址,此时dog!=null

    如果指令重排3和2不存在依赖关系,如果2和3交换位置,对象还没初始化完成,引用就指向了这块内存,导致返回的实例为null,有名无实,虽然分配了内存,但是却没有初始化。

    Processed: 0.013, SQL: 9