当前位置: 首页 > 科技观察

Java面试官爱问的volatile关键字

时间:2023-03-12 04:48:38 科技观察

在Java相关的求职面试中,很多面试官喜欢考察面试官对Java并发的理解。最后涉及到Java内存模型(JMM)和Java并发编程的一些特性。深入的话,还可以考察JVM的底层实现和操作系统的相关知识。让我们通过一个假设的面试过程来了解更多关于volatile关键字的知识!面试官:你是怎么理解Java并发的?说说你对volatile关键字的理解。据我了解,由volatile修饰的共享变量有以下两个特点:保证不同线程对该变量进行操作时的内存可见性;prohibitinstructionreordering面试官:你能详细解释一下什么是内存可见性,什么是重排序吗?这个要讲的很多,先从Java内存模型说起。Java虚拟机规范试图定义一个Java内存模型(JavaMemoryModel,简称JMM)来屏蔽各种硬件和操作系统之间的内存访问差异,从而使Java程序在各种平台上都能达到一致的内存访问效果。简单的说,因为CPU执行指令的速度非常快,但是访问内存的速度就慢很多,相差不是一个数量级的,所以搞处理器的大佬们都给CPU加了几层缓存..在Java内存模型中,针对上述优化又进行了一波抽象。JMM规定所有的变量都存储在主存中,类似于上面提到的普通内存,每个线程都包含自己的工作内存,可以看成是CPU上的一个寄存器或者缓存,方便理解。因此,线程的操作主要基于工作内存。他们只能访问自己的工作内存,工作前后必须将数值同步回主内存。这让我自己也有点不清楚,于是拿一张纸画出来:当一个线程执行时,它首先从主内存中读取变量值,然后加载到工作内存中的一个副本中,然后传递给执行的处理器。执行完成后,给工作内存中的副本赋值,然后工作内存将值传回主存,更新主存中的值。使用工作内存和主内存,虽然速度加快了,但也带来了一些问题。例如,看下面的例子:i=i+1;假设i的初始值为0,当只有一个线程执行时,结果一定是1,而当两个线程执行时,结果会是2吗?这并不一定如此。可能存在这种情况:线程1:从主存中加载i//i=0i+1//i=1线程2:从主存中加载i//因为线程1还没有将i的值写回mainmemory,所以i还是0i+1//i=1线程1:将i保存到主存线程2:将i保存到主存如果两个线程都按照上面的执行流程,那么i***的值为实际上1。如果***writeback生效慢,你再读i的值,可能是0,就是缓存不一致的问题。以下是您刚才提出的问题。JMM主要是围绕如何处理并发过程中的原子性、可见性和顺序这三个特性而构建的。通过解决这三个问题,就可以解决缓存不一致的问题。volatile与可见性和顺序有关。面试官:这三个功能怎么样?1、原子性:在Java中,读取和分配基本数据类型的操作都是原子操作。所谓原子操作,是指这些操作不能被打断,必须做,否则不执行。例如:i=2;j=i;i++;i=i+1;上面4个操作中,i=2是读操作,肯定是原子操作,j=i你以为是原子操作,其实分两步,一个是读i的值,然后赋值给j,这是2步操作,不是原子操作,i++和i=i+1其实是等价的,读取i值,加1,然后写回主存,也就是一个3-步骤操作。所以,在上面的例子中,很多情况下可能会出现***的值,因为无法满足原子性。这样就只有简单的读取,赋值是原子操作,只能用数字赋值。如果你使用变量,还有一个读取变量值的额外步骤。一个例外是虚拟机规范允许在两个32位操作中处理64位数据类型(long和double),但是***JDK实现仍然实现了原子操作。JMM只实现基本的原子性。像上面i++这样的操作,必须使用synchronized和Lock来保证整个代码的原子性。在线程释放锁之前,必然会把i的值刷回主存。2.可见性(Visibility):说到可见性,Java使用volatile来提供可见性。当一个变量被volatile修改后,对其的修改会立即刷新到主存中,当其他线程需要读取该变量时,会去内存中读取新的值。普通变量不能保证这一点。其实也可以通过synchronized和Lock来保证可见性。在线程释放锁之前,会将共享变量的值刷回主内存,但是synchronized和Lock的开销更大。3.排序(Ordering)JMM允许编译器和处理器对指令进行重新排序,但规定了as-if-serial语义,即无论如何重新排序,程序的执行结果都不能改变。比如下面的程序段:doublepi=3.14;//Adoubler=1;//Bdoubles=pi*r*r;//上面C的语句可以按照A->B->C执行,结果是3.14,也可以按照B->A->C的顺序执行,因为A和B是两个独立的语句,而C依赖于A和B,所以A和B可以重新排序,但是C不能排列在A和B的前面。JMM保证重排序不会影响单线程执行,但在多线程中容易出问题。比如这样的代码:inta=0;boolflag=false;publicvoidwrite(){a=2;//1flag=true;//2}publicvoidmultiply(){if(flag){//3intret=a*a;//4}}如果有两个线程执行上面的代码段,线程1先执行write,然后线程2执行multiply,那么***ret的值一定要为4吗?结果不一定:如图,write方法中1和2重新排序。线程1先给flag赋值为true,然后执行给线程2,ret直接计算结果,再给线程1,此时a赋值为2,明显落后了一步。这时候可以在flag中加上volatile关键字禁止重排序,这样可以保证程序的顺序。也可以使用重量级synchronized和Lock来保证顺序。他们可以确保那个区域的代码都是一次性的。性行为。另外,JMM还有一些与生俱来的有序性,即无需任何手段就可以保证的有序性,通常称为happens-before原则。<>定义了以下happens-before规则:1.程序顺序规则:线程中的每一个操作,happens-before线程中任何后续操作2.监控锁规则:解锁一个线程,happens-before该线程后续的加锁3.volatile变量规则:写入volatile域,happens-before后续读取这个volatile域4.传递性:如果Ahappens-beforeB,Bhappens-beforeC,则Ahappens-beforeC5.start()规则:如果线程A执行ThreadB_start()操作(启动线程B),则A线程的ThreadB_start()happens-beforeB中的任何操作6.join()原理:如果A执行ThreadB.join()并成功返回,那么线程B中的任何操作都发生在线程A成功从ThreadB.join()操作返回之前。7、interrupt()原理:被中断线程代码检测到中断事件发生时,首先调用线程interrupt()方法,可以使用Thread.interrupted()方法检测是否有中断。8.finalize()原则:一个对象初始化的完成首先发生在它的finalize()方法的开始。程序顺序规则的第一个规则是,在一个线程中,所有的操作都是有序的,但是在JMM中,只要执行结果相同,就允许重新排序,这里强调的happens-before也是单线程执行结果的正确性,但多线程执行不能保证相同。第二条规则,monitor规则,其实很好理解,就是在加锁之前,先确认锁已经被释放了,然后再继续加锁。第三条规则适用于所讨论的易失性。如果一个线程先写一个变量,另一个线程再读它,那么写操作必须在读操作之前。第四个规则是happens-before的传递性。接下来的几项不再一一重复。面试官:volatile关键字如何满足并发编程的三大特性?然后我们必须重新审视volatile变量规则:写入一个volatile域happens-before随后读取这个volatile域。这篇文章又带出来了,其实如果一个变量声明为volatile,那么我读取这个变量的时候,总能读到它的最大值,这里的最大值是指不管是哪个其他线程写这个变量的时候,它会立即更新到主存,我也可以从主存中读取刚刚写入的值。也就是说volatile关键字可以保证可见性和有序性。继续以上面的代码为例:inta=0;boolflag=false;publicvoidwrite(){a=2;//1flag=true;//2}publicvoidmultiply(){if(flag){//3intret=a*a;//4}}此代码不仅会受到重新排序的影响,即使1、2不会。3不会实施得那么顺利。假设线程1先执行写操作,然后线程2执行乘法操作。由于线程1在工作内存中将flag赋值为1,不一定马上回写到主存中,所以当线程2执行时,再次从主存中multiply读取。flag值,可能还是false,那么括号里的语句就不会被执行。如果改为如下:inta=0;volatileboolflag=false;publicvoidwrite(){a=2;//1flag=true;//2}publicvoidmultiply(){if(flag){//3intret=a*a;//4}}然后线程1先执行write,线程2执行multiply。根据happens-before原则,这个过程会满足以下3类规则:程序顺序规则:1happens-before2;3发生在4之前;(volatilelimitsinstructionreordering,so1isexecutedbefore2)volatilerules:2happens-before3transitiverules:1happens-before4当写一个volatile变量时,JMM会刷新线程对应的localmemory中的共享变量到mainmemory当读取一个volatile变量时,JMM会刷新线程对应的sharedvariable,线程的localmemory失效,线程接下来会从mainmemory中读取sharedvariable。面试官:volatile的两点内存语义可以保证可见性和顺序,但是能保证原子性吗?首先我的回答是不能保证原子性。如果能保证的话,只是针对单个volatile变量的读写。原子性,但是对于volatile++这样的复合操作却无能为力,比如下面这个例子:inti=0;i<10;i++){newThread(){publicvoidrun(){for(intj=0;j<1000;j++)test.increase();};}.start();}while(Thread.activeCount()>1)//保证前面的线程都执行完Thread.yield();System.out.println(test.inc);}按理说结果是10000,但很可能是一个运行时值小于10000。可能有人会说volatile并不能保证可见性。一个线程对inc的修改应该立即被另一个线程看到!但是这里的inc++操作是一个复合操作,包括读取inc的值并自增。然后写回主存。假设线程A读取到inc的值为10,此时阻塞,因为变量没有被修改,无法触发volatile规则。线程B此时也读取了inc的值,主存中inc的值还是10,自增,然后立即写回主存,即11。此时,它又轮到线程A执行了。由于10存放在工作内存中,所以不断递增,然后写回主存,再次写入11。所以虽然两个线程都执行了两次increase(),但是结果只增加了一次。有人说volatile会使缓存行失效?但是这里线程A在线程B读取inc之前并没有修改inc的值,所以线程B读取的时候还是读取到10,还有人说线程B把11写回主存,不会使缓存失效吗线程A的行?但是线程A的读操作已经做完了,只有在做读操作的时候发现自己缓存的行无效才会去读主存的值,所以这里线程A只能继续做自动递增。综上所述,在这种复合操作的场景下,无法维护原子功能。但是上面设置flag值的例子中的volatile,由于对flag的读写操作都是单步的,所以还是可以保证原子性的。保证原子性只能靠synchronized、Lock,以及concurrent包下的atomic原子操作类,即自增(自增1操作)、自减(减1操作)、基本的加法操作数据类型(加一个数)和减法运算(减一个数)被封装起来,保证这些操作是原子操作。面试官:可以,但是你知道volatile的底层实现机制吗?如果你把带volatile关键字的代码和不带volatile关键字的代码都生成汇编代码,你会发现多了一段带volatile关键字的lock前缀指令的代码。lockprefix指令其实就相当于一个内存屏障,内存屏障提供了以下功能:重排序时,后面的指令不能重新排序到内存屏障之前的位置,这样CPU的Cache就可以写入内存了****写入动作也会导致其他CPU或其他核心使其Cache失效,相当于让新写入的值对其他线程可见。面试官:你在哪里使用volatile?举两个例子好吗?1.状态卷标志,和上面的标志标志一样,再提一下:inta=0;volatileboolflag=false;publicvoidwrite(){a=2;//1flag=true;//2}publicvoidmultiply(){if(flag){//3intret=a*a;//4}}这种对变量的读写操作,标记为volatile可以保证Threads的修改立即可见。Lock与synchronized相比,有一定的效率提升。2.单例模式的实现,典型的双重检查锁定(DCL)null)instance=newSingleton();}}returninstance;}}这是惰性单例模式,对象在使用时创建,为了避免初始化操作的指令重排序,在实例中加入了volatile。