欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

java面试题:谈谈你对volatile的理解

程序员文章站 2024-03-01 18:37:22
...

  最近打算整理下Java面试中频率比较高,相对比较难的一些面试题,感兴趣的小伙伴可以关注下。

Volatile关键字

  volatile是Java虚拟机提供的轻量级的同步机制.何为轻量级呢,这要相对于synchronized来说。Volatile有如下三个特点。

volatile
保证可见性
不支持原子性
禁止指令重排序

  要搞清楚上面列举的名词可见性 原子性 指令重排的含义我们需要首先弄清楚JMM(Java内存模型是怎么回事)

JMM

  JMM规定了内存主要划分为主内存工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存.

java面试题:谈谈你对volatile的理解
  JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

可见性

  各个线程对主内存*享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回主内存中的。
  这就可能存在一个线程A修改了共享变量X的值但还未写回主内存时,另一个线程B又对准内存中同一个共享变量X进行操作,但此时A线程工作内存*享变量X对线程B来说并不是可见,这种工作内存与主内存同步存在延迟现象就造成了可见性问题。
  通过代码来看下可见性的问题

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;

/**
 * 可见性问题分析
 */
public class VolatileDemo1 {
    public static void main(String[] args){
        final MyData myData = new MyData();
        // 开启一个新的线程
        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + "开始了...");
            try{TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {e.printStackTrace();}
            // 在子线程中修改了变量的信息  修改的本线程在工作内存中的数据
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "更新后的数据是:"+myData.number);
        },"BBB").start();
        // 因为在其他线程中修改的信息主线程的工作内存中的数据并没有改变所以此时number还是为0
        while(myData.number == 0){
            // 会一直卡在此处
            //System.out.println("1111");
        }
        System.out.println(Thread.currentThread().getName()+"\t number =  " + myData.number);
    }
}

class MyData{
	// 没有用volatile来修饰
    int number = 0;

    public void addTo60(){
        this.number = 60;
    }

}

效果如下:

java面试题:谈谈你对volatile的理解

  通过volatile来解决此问题

java面试题:谈谈你对volatile的理解

java面试题:谈谈你对volatile的理解

  我们可以发现当变量被volatile修饰的时候,在子线程的工作内存中的变量被修改后其他线程中对应的变量是可以立马知道的。这就是我们讲的可见性

原子性

  原子性是不可分割完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者分割,需要整体完成,要么同时成功,要么同时失败.
  volatile是不支持原子性的,接下来我们可以验证下。

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;

/**
 * 可见性问题分析
 */
public class VolatileDemo2 {
    public static void main(String[] args){
        final MyData2 myData = new MyData2();
        for (int i = 1; i <= 20 ; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myData.addPlusPlus();
                }
            },String.valueOf(i)).start();
        }
        // 等待子线程执行完成
        while(Thread.activeCount() > 2){
            Thread.yield();
        }
        // 在主线程中获取统计的信息值
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.number);
    }
}

class MyData2{
   // 操作的变量被volatile修饰了
    volatile int number = 0;

    public void addPlusPlus(){
        number++;
    }

}

执行的效果
java面试题:谈谈你对volatile的理解

  根据正常的逻辑在开启的20个子线程,每个执行1000遍累加,得到的结果应该是20000,但是我们发现运行的结果大概率会比我们期望的要小,而且变量也已经被volatile修饰了。说明并没有满足我们要求的原子性。这种情况下我们要保证操作的原子性,我们有两个选择

  1. 通过synchronized来实现
  2. 通过JUC下的AtomicInteger来实现

  synchronized的实现是重量级的,影响并发的效率,所以我们通过AtomicInteger来实现。

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 可见性问题分析
 */
public class VolatileDemo2 {
    public static void main(String[] args){
        final MyData2 myData = new MyData2();
        for (int i = 1; i <= 20 ; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myData.addPlusPlus();
                    myData.addAtomicPlus();
                }
            },String.valueOf(i)).start();
        }
        // 等待子线程执行完成
        while(Thread.activeCount() > 2){
            Thread.yield();
        }
        // 在主线程中获取统计的信息值
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.number);
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.atomicInteger.get());
    }
}

class MyData2{
   // 操作的变量被volatile修饰了
    volatile int number = 0;
    // AtomicInteger 来保证操作的原子性
    AtomicInteger atomicInteger = new AtomicInteger();

    public  void addPlusPlus(){
        number++;
    }

    public void addAtomicPlus(){
        atomicInteger.getAndIncrement();
    }

}

效果:

java面试题:谈谈你对volatile的理解
注意:通过效果发现AtomicInteger在多线程环境下处理的数据和我们期望的结果是一致的都是20000.说明实现的操作的原子性。

有序性

  计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分一下3种:

java面试题:谈谈你对volatile的理解

  • 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
  • 处理器在进行重排序时必须考虑指令之间的数据依赖性
  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

案例代码

package com.dpb.spring.aop.demo;

public class SortDemo {
    int a = 0;
    boolean flag = false;

    public void fun1(){
        a = 1;  // 语句1
        flag = true; // 语句2
    }

    public void fun2(){
        if(flag){
            a = a + 5; // 语句3
            System.out.println("a = " + a );
        }
    }
}

注意:在多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

指令重排小结:
  volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
先了解一个概念,内存屏障又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 是保证特定操作的执行顺序
  2. 是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

  由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重新排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

线程安全的总结:

  1. 工作内存和主内存同步延迟现象导致的可见性问题,可以使用synchronized或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其他线程可见。

  2. 对于指令重排导致的可见性问题有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。