首页 技术 正文
技术 2022年11月17日
0 收藏 646 点赞 4,819 浏览 5248 个字

一、由一段代码引出的问题

  首先我们先来看这样一段代码:

 public class VolatileThread implements Runnable{     private boolean flag = true;     @Override     public void run() {         System.out.println("子线程开始执行...");         while(flag){         }         System.out.println("子线程执行结束...");     }     public void setFlag(boolean flag) {         this.flag = flag;     } }
 public class VolatileThreadMain {     public static void main(String[] args) throws InterruptedException {         VolatileThread volatileThread = new VolatileThread();         Thread thread = new Thread(volatileThread);         thread.start();         Thread.sleep(3000);         volatileThread.setFlag(false);         System.out.println("flag改为false");         Thread.sleep(1000);         System.out.println(volatileThread.flag);     } }

  运行结果:

结果分析:

从控制台看出,主线程已经将VolatileThread实例中的flag变量更改为false,按常理来说while(flag)进行判断的时候,读取flag为false应该停止循环然后打印出“子线程执行结束…”,随后程序结束。但是在这里程序却一直不能停止,说明程序一直在循环之中没出来,也就说while(flag)读取到的flag值一直是true,即使主线程已经将flag改为了false。是什么原因造成这么奇怪的现象呢?想要弄清楚这一点,我们有必要先从Java内存模型说起。

二、理解Java内存模型

  首先要说明的是Java内存模型(即Java Memory Model,简称JMM)和JVM内存区域划分(程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区等)是不同的两个概念,Java内存区域本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行java程序实际上是靠一条条线程来完成的,因此每条线程线程在启动时,JVM都会为其创建一个私有的工作空间,我们称其为本地工作内存,每条线程的本地工作内存都是相对独立的,其他线程无法访问。其实这很好理解,因为每条线程都有自己的职责,比如主线程负责执行我们写的代码,GC线程负责垃圾回收等等。试想如果每条线程之间都可以任意的访问其他线程的数据,是不是非常容易引起线程的安全性问题,所以说每条线程都存在这样一个本地工作内存。但是反过来说,凡事也不能做的太绝对,如果每条线程都完全独立于其他线程,那么所有线程间就也无法一起工作了。说到这里,我想起了《Spring In Action》中的一句话,IoC容器的作用就是降低组件之间的耦合性,通过IoC容器的依赖注入让组件之间产生依赖关系,我们可以将IoC容器理解成组件之间的一个媒介。说回线程,既然刚才说到每条线程的本地工作内存对其他线程是不见的,但是每条线程还不能和其他线程完全独立,那么肯定就也需要一个类似媒介的东西。这里我形象的把这种情景比喻成相亲,两个互相不认识的男女,它们之间无法进行通信,但是要想取得联系,就必须通过媒婆来传话,媒婆就是这两个相对隔离的人之间的媒介。由此,就引出了线程间进行通信的媒介–主内存,主内存是共享数据区域,每条线程都可以访问主内存中的数据。经过上边白话的讲解,下面我用专业术语来介绍一下这两个概念。

  • 主内存

  主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 本地工作内存

  主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

  我们用下面这幅图来描述各条线程间的本地工作内存呢和主内存之间的关系:

三、理解线程间的可见性  

  特别需要说明的是,线程是不允许直接操作(主要是指写操作)主内存中的数据的,线程若想操作主内存的数据,必须要先将主内存中的数据读取到自己的本地工作内存中,然后拷贝一个副本,对这个副本进行操作,然后再写回主内存中。另外需要注意的是,线程读取共享数据的时候,也不是每次都从主内从中进行读取,这在操作系统中有一套优化的算法,由于从主内存中读取数据肯定要比从自己的工作内存中读取效率低,所以线程前几次会尝试从主内存中进行读取,并保存一份副本到工作内存中,当从主内存中读取多次后发现总是和工作内存中的副本数据一样时,它之后每次便会优先从选择本地工作内存读取,不确定何时再到主内存中读取。基于上述分析,这种机制会造成一些问题:

  • 如果线程A在本地工作内存中对之前读进来的数据进行了更新,并且把线程A的副本更新成了最新值,但是还差最后一步将副本刷新到主内存没完成的时候,此时线程B主内存读取数据,那么此时线程B读取的依然还是旧的数据(即使线程A确实已经完成了对数据的更新操作),因为线程B是看不见线程A中的数据的。
  • 像上面所说的,如果线程B之前尝试从主内存中读取数据发现总是和副本的一致,那么接下来线程B将会一直读取自己本地工作内存中的副本。即使之后线程A将最新的数据刷新到了主内存,由于线程B一直在读取自己之前读进来的副本,那么主内存中的最新数据线程B依然是看不见的,因为并没人通知它主内存已经更新成了最新值。

四、分析引发代码问题的原因

  上边所描述的这些,总结起来就三个字,可见性。线程的可见性问题,也正是由于java内存模型的机制而引发的,了解了这些,我们现在回过头了再看最开始的代码,就非常容易理解了:

  

 public class VolatileThread implements Runnable{     private boolean flag = true;     @Override     public void run() {         System.out.println("子线程开始执行...");         while(flag){         }         System.out.println("子线程执行结束...");     }     public void setFlag(boolean flag) {         this.flag = flag;     } }
 public class VolatileThreadMain {     public static void main(String[] args) throws InterruptedException {         VolatileThread volatileThread = new VolatileThread();         Thread thread = new Thread(volatileThread);         thread.start();         Thread.sleep(3000);         volatileThread.setFlag(false);         System.out.println("flag改为false");         Thread.sleep(1000);         System.out.println(volatileThread.flag);     } }

  

  我们知道,成员变量(存在于堆中)是全局共享的变量,因此在VolatileThread类中,flag存在于共享数据区域即主内存。接下来我们来分析 VolatileThreadMain,第5行当我们启动自定义的线程thread时,线程执行重写的run方法,进入while循环判断flag时,会先将flag读取进thread自己的本地工作内存并保存一个副本。然后就是不断的判断flag然后执行while循环,注意,我在VolatileThreadMain的第6行加的一个休眠3s,这3s看似不长,但是对于线程thread来说,它要做的循环次数要数以万计,这么多次循环判断flag中,flag都没有发生改变,这也就导致了我上面所说的,后边它会优先从自己的副本中读取flag(大家可以自行尝试一下,如果不加休眠,程序是很快就会停下的,就是因为前几次其实线程thread还是会去主内存中读取数据)。即使后边主线程将主内存中的共享数据flag修改成了false,线程thread也不会从主内存读取了,这也就是造成程序一直停止不了的原因。

五、解决可见性问题–volatile关键字

  对于上述可见性问题,java给出了解决办法,使用volatile关键字,volatile的功能有两个:保证可见性和禁止指令重排序。

  1.保证可见性

  保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。其实它的原理是基于内存屏障(有兴趣可以参考我文末推荐的链接)实现了以下两点:

  • 线程读取共享数据时,每次都必须从主内存中读取,不允许从本地工作内存的副本中读取。
  • 线程更新共享数据时,只要更新完成必须强制刷新到主内存中。 

  通过上述两点保证,就完全消除了我在(三)中阐述的由可见性引发的一系列问题。因此在我们的代码中,出现问题的根本原因是线程thread每次没有从主内存中读取最新的flag值,而是从本地工作内存中的副本中读取,才导致程序一直处于循环状态中停不下来,因此我们在这里只需将共享变量flag用volatile修饰,即把VolatileThread中的第3行代码改为private volatile boolean flag = true;这样就能保证主线程修改flag后,线程thread会立即得知结果。大家可以自己尝试一下,这里不再演示。

  2.禁止指令重排序

  volatile除了可以保证可见性外,还可以禁止指令的重排序。由于在java内存模型中提供了happens-before原则(想详细了解可见文末链接)来辅助保证程序执行的原子性、可见性以及有序性的问题,使得重排序问题我们几乎不会遇到。并且鉴于本文重点讨论volatile的可见性问题,这里对指令重拍不再过多赘述,有兴趣可参看文末链接。

六、最后的说明

  很多初学者会分不清volatile和synchronized的区别,不知道分别何时使用它们。在这里我想说明,在线程中有三个概念,分别是原子性、可见性、和有序性。volatile主要解决的是线程间的可见性引发的问题,本文上述已经做了详细描述。而synchronized主要解决的是原子性问题,同时也解决了可见性问题。我们可以认为volatile是synchronized的一个轻量级实现,如果线程间操作的共享数据只存在可见性问题而不存在原子性问题(如本文的例子),我们用volatile修饰共享变量即可(当然也可以用synchronized修饰,因为synchronized也实现了可见性问题),而尽量不用比较重量级的synchronized。但是如果共享变量涉及到原子性引发的问题,那我们就一定要对某些代码进行同步处理了(如synchronized、lock等),这种情况即使使用了volatile照样还是会引发线程安全问题。

  关于线程安全性问题(侧重讲原子性问题)和synchronized的使用方法,可以参看我的另一篇文章:http://www.cnblogs.com/rainie-love/p/8531667.html

  最后,给出volatile和synchronized分别可以修饰在哪里:

  1. volatile:只能修饰共享变量(即成员变量),不可修饰局部变量和方法。
  2. synchronized:可以修饰共享变量(即成员变量)、代码块、方法、静态方法。

  最后强烈给有一些基础的朋友们推荐一篇超级详细的博文,深入剖析了JMM内存模型的原理(深入到操作系统和硬件层面讲解)、线程的三个概念、指令重排序、happens-before原则等等:

  http://blog.csdn.net/javazejian/article/details/72772461

  

相关推荐
python开发_常用的python模块及安装方法
adodb:我们领导推荐的数据库连接组件bsddb3:BerkeleyDB的连接组件Cheetah-1.0:我比较喜欢这个版本的cheeta…
日期:2022-11-24 点赞:878 阅读:9,492
Educational Codeforces Round 11 C. Hard Process 二分
C. Hard Process题目连接:http://www.codeforces.com/contest/660/problem/CDes…
日期:2022-11-24 点赞:807 阅读:5,907
下载Ubuntn 17.04 内核源代码
zengkefu@server1:/usr/src$ uname -aLinux server1 4.10.0-19-generic #21…
日期:2022-11-24 点赞:569 阅读:6,740
可用Active Desktop Calendar V7.86 注册码序列号
可用Active Desktop Calendar V7.86 注册码序列号Name: www.greendown.cn Code: &nb…
日期:2022-11-24 点赞:733 阅读:6,495
Android调用系统相机、自定义相机、处理大图片
Android调用系统相机和自定义相机实例本博文主要是介绍了android上使用相机进行拍照并显示的两种方式,并且由于涉及到要把拍到的照片显…
日期:2022-11-24 点赞:512 阅读:8,132
Struts的使用
一、Struts2的获取  Struts的官方网站为:http://struts.apache.org/  下载完Struts2的jar包,…
日期:2022-11-24 点赞:671 阅读:5,297