倚楼听风雨
淡看江湖路

Java关键字volatile浅析 线程安全的利器

关于线程的基本知识,最近老四正在看一本叫做《码农翻身》的书,书籍作者刘欣,书籍主打用故事给技术加点料。老四是在618活动期间网购的,作者一开始是写微信公众号的,一篇《我是一个线程》通过生动形象的故事将线程的基础概念以及为什么要存在线程讲解的淋漓尽致,诸位如果没有关注过公众号的,可以关注一下刘老师,刘欣老师通过两年时间的不断讲故事帮助我们理解了很多技术难点,全网阅读量近1000万次的技术故事,希望你也购买次数好好阅读,这里一并放出《我是一个线程》的在线版以及刘欣老师的"码农翻身"公众号。

刘欣老师"码农翻身"微信公众号二维码:

Java关键字volatile浅析的图片-高老四博客 第1张

老四自己的微信公众号:

Java关键字volatile浅析的图片-高老四博客 第2张

Java关键字volatile浅析的图片-高老四博客 第3张

----------------丑逼分割线----------------

Java中的volatile关键字从字面理解(确保可见性,每次从主存取值)其实不难,但是很多人都用不好,包括老四在内,其实也是很少用它,在jdk1.5之前几乎没有人使用它,他也没有实现保证可见性原则,但是之后,volatitle的重要性越来越大。关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不容易完全被正确、完整地理解,以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争问题的时候一律使用synchronized来进行同步。了解volatile变量的语义对后面了解多线程操作的其他特性很有意义,在本文中老四将尽量为大家去弄清楚volatile的语义到底是什么。在介绍volatile关键字我们需要了解一下物理机内存模型、Java虚拟机的内存模型、并发编程等相关概念。

首先介绍一个概念:TPS(Transactions per Second,每秒事务处理数),它用来衡量服务性能的高低好坏,代表着一秒内服务端平均能相应的请求总数。TPS值与程序的并发能力有着非常密切的关系,计算量相同的任务下,协调的线程并发越是有条不紊的进行着,处理效率就会变高;反之,程序的并发能力会因为线程之间频繁阻塞、死锁而大大降低。这也是为什么提及这个概念的原因。

物理机的内存模型

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代的计算机都会加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存一与处理器之间的缓冲,这样处理器从缓存中获取需要运算的数据,运算完毕后再通过缓存同步回物理内存之中,实现处理器无须等待缓慢的内存、存储读写而浪费时间了。然而,这种情况下也带来一个问题,就是缓存一致性的问题,当多个处理器共享同一内存区域的时候,会带来各自缓存不一致的问题,他们同时写入主存的时候,主存就不知道该听谁的了,这个时候缓存一致性协议又应运而生。除此之外,为了进一步提高高速缓存的效率,处理器还会在保证处理结果与顺序执行结果一致的前提下,进行指令重排序的工作。

Java关键字volatile浅析的图片-高老四博客 第4张

Java内存模型

Java虚拟机规范中试图定义一种Java内存模型.(Java Memory Model, JMM)来屏蔽掉各种硬件和操作系统的内存访问差兄,以实现让Java程序在各种平台下都能达到一致的内在访问效果。这也是Java为什么能做到一次编译,处处运行的原因。它主要的目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。下面这张图你会看到它和物理机的内存模型很是类似。

  • 所有的变量储存在主内存(你可以理解为物理内存)中
  • 每个线程有自己的工作内存,各个线程之间的工作内存不互通,线程交互必须经过主内存

Java关键字volatile浅析的图片-高老四博客 第5张

ps:这里讲的变量与Java编程中的变量不适应的,它包括实例字段、静态字段和数组元素。

Java内存模型定义了8种操作来保证来实现从主存到工作内存以及工作内存如何同步回主存。

  • lock(锁定) 作用于主内存变量,将变量标识为一条线程独占
  • unlock(解锁) 同上,将被线程独占的变量解锁,这样别的线程就能使用了
  • read(读取) 作用于主内存变量,将变量的从主内存传输到工作内存
  • load(载入) 作用于工作内存变量,接收read操作的值仿作工作内存的副本当中
  • use(使用) 作用于内存变量,执行计算
  • assign(赋值) 作用于工作内存变量,执行赋值操作
  • store(存储) 作用于工作内存变量,将计算后的变量值传递给主内存
  • write(写入) 作用于主内存变量,将store得来的值放入到主内存的变量中
内存模型,并发编程三大特性,概念:

1.原子性(Atomicity): 一个操作或者多个操作,要么全部执行并不会被打断,要么就全都不执行。比如经典的银行转账操作。Java中由Java内存模型通过read、load、assign、use、store、write等操作直接保证变量的原子性。

2.可见性(Visibility): 当一个线程修改了共享共享变量的值,其他线程能够立刻感知到修改。Java内存模型通过将新值同步回主存(store、write)操作在变量读取之前从主内存刷新变量值实现可见性。我们等会要说的volatile其实就是保证可见性的。等会详细说。继续看。

3.有序性(Ordering): 即程序执行的顺序按照代码的先后顺序执行。然而刚刚提到了,为了提供物理机的高速缓存的效率,有的时候处理器会进行指令重排序,乱序执行,但是结果保证是正确的有序的。其实Java内存模型中也实现了这一特点,但是如何保证程序的最终结果和代码顺序执行结果相同呢?

其实控制指令重排序是在保证单线程操作有序,多线程"乱序"的,在单个线程内观察,所有的操作的都是顺序执行的,但是当你在一个线程中观察其他的线程,那就是无序的,这就是所谓的指令重排序现象,所以,要保证并发程序正确的执行,必须要保证以上三个特性,缺一不可。

Java中volatile关键字

volatile关键字是Java虚拟机中最轻量级的同步机制。但是我们往往都忽略他并一昧的使用synchronized来进行同步操作。

volatile修饰的变量有两种特性你需要知道:

  • 保证该变量对所有线程的可见性,当一个线程修改了变量的值,其他线程可以立即得知。普通变量不能做到这一点,因为普通变量需要通过主内存完成同步。这也是普通变量不能保证同步的原因之一。
  • 禁止指令重排序

举个例子?

以上的代码,flag变量如果不加volatile修饰的话,程序执行一定正确嘛?不一定的,高并发下极有可能导致死循环,因为如当vv2执行赋值操作后还没来得及像主存写入true,此时vv1还在自己的工作内存中做判断,不知道vv2已经对flag的值做了改变,所以一直循环下去。

但是加了volatile之后,vv2修改的flag的值会被立即写入主内存,此时vv1中的工作内存失效,重新从主存中取出最新的flag的值进行判断。

基于以上两点,你还要注意以下几点:

  • volatile修饰的变量在并发运算下不是线程安全的。即volatile保证不了原子性
  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值适用于使用volatile
  • 变量不需要与其他的状态变量共同参与不变约束适用于使用volatile

为什么volatile不能保证原子性呢?我们都知道"i++"不是一个线程安全的操作,一个自增或者自减操作一次需要同read、load、use、assign、store、write一些列操作,我们这里可以预见到,volatile依然能保证主内存时刻是最新的值,并且被其他线程共享,然而在自增的过程中,如果线程A在读取i的值为8,此时阻塞,线程B在这个时候对i进行自增操作并写回主存得到i=9,这个时候线程A中的工作内存因为只是读取而没有修改,所以不阻塞的时候A读取i的值依然是8,它也执行自增操作,两个线程执行了两次,i的值却只加了1次,所以volatile是不保证原子性的。至于你问为什么i=9并且通过volatile已经及时写回主存,线程A为什么还要用8来计算,那是因为根据现行发生原则(见下方介绍),线程A在阻塞之前只进行了读取操作而没有改值,再次操作的的时候依然会使用他自己工作内存中的变量副本。

老四写了一个i++多线程自增操作的代码示例,并且通过三种方法实现如何解决自增原子性的问题,项目代码示例请在文末自助获取。这里说一下三个方法都是什么:

  1. 采用synchronized 同步代码块
  2. 采用Lock操作 lock保证变量被单个线程使用,解锁之后再被其他线程使用
  3. 采用AtomicInteger 利用硬件支持

那么为什么说在满足条件的情况推荐使用volatile关键字而不是synchromized呢?其实我们很难量化两者之间谁更快一些,但是某些情况下,volatile的同步机制性能确实由于锁机制,所以我们在两者之间选择的依据仅仅是volatile能满足我们的使用场景。

PS:Java内存模型对于64位数据(long、double)定义了相对宽松的规定,允许虚拟机不保证64位数据操作的原子性,至于原因知不知道不重要,一般也不会遇到没用volatile声明的long或者double出现半个变量值的情况,就是说明一下。

Java提供了volatile和synchronized两个关键字来保证线程之间的有序性,你会发现,因为synchronized通过实现串行同步块,感觉他很万能,这间接造成了我们大量的使用它,但是大量的使用也为我们带来性能上的影响。另外,单纯的靠这两个关键字来实现有序性,我们发现操作都会比较繁琐,然而这与java的代码风格又不是这样,其实这里面也隐含了Java中自带了一个叫"先行发生原则"帮助我们解决了并发环境下两个操作之间可能存在的冲突问题。先行发生指的是两项操作之间的偏序关系,不需要通过任何手段就能得到有保证的有序性。如果两个两个操作能过从先行发生原则推导出来,就不需要进行同步操作了。

  • 程序次序规则(Program Order Rule) 控制流顺序执行
  • 管程锁定规则(Monitor Lock Rule) 同一个锁解锁先于加锁
  • volatile变量规则(Volatile Variable Rule) 对volatile修饰的变量先写后读
  • 线程启动规则(Thread Start Rule) start()方法先于每个线程操作
  • 线程终止规则(Thread Termination Rule) 所有线程操作先于代码检测到终止
  • 线程中断原规则(Thread Interrupyion) 调用线程中断的方法先于代码检测到中断
  • 对象终结规则(Finalizer Rule) 对象初始化(构造)先于finalize()方法的开始
  • 传递性(Trancitivity) A先于B,B先于C,A就于C
volatile底层实现原理:

可能面试中常会被问到吧。其实他的底层原理在汇编的层面讲是加入volatile以后会多出一个lock前缀指令,它相当于一个内存屏障,这个内存屏障提供如下几个功能来保证可见性。

  • 当执行到这个内存屏障的时候,他前面的指令操作都已经完成
  • 强制将缓存的修改写入主存
  • 如果是写操作,会去让cpu对其他线程中的工作缓存失效,重新读值
volatile适用场景:

说白了点就是在保证了原子性操作基础之上我们就考虑使用volatile关键字

1.状态标记量

2.双重检测(double check)

关于双重检测的示例,老四在之前的设计模式之单例模式的讲解中涉及到volatile的使用,详细代码请参考《浅析设计模式第二十一章之单例模式》的代码示例。

总结之后,我们这回知道了,不要万事都要使用synchronized修饰符,当程序保证了原子性的操作后,我们要尽量使用volatile修饰变量,在大多数情况下,他是效率较高的。

更博不易,如果觉得文章对你有帮助并且有能力的老铁烦请赞助盒烟钱,点我去赞助。或者扫描文章下面的微信/支付宝二维码打赏任意金额,老四这里抱拳了。赞助时请备注姓名或者昵称,因为您的署名会出现在赞赏列表页面,您的赞赏钱财也会被用于小站的服务器运维上面,再次抱拳。

资源下载

隐藏内容:******,购买后可见!

下载价格:0 G币

您需要先后,才能购买资源

欢迎访问高老四博客(glorze.com),本站技术文章代码均为老四亲自编写或者借鉴整合,其余资源多为网络收集,如涉及版权问题请与站长联系。如非特殊说明,本站所有资源解压密码均为:glorze.com。

赞(76) 给你买杜蕾斯
本站原创文章受自媒体平台原创保护,未经允许不得转载高老四博客 » Java关键字volatile浅析
分享到: 更多 (0)
4万+首WAV无损音乐仅需 28 元 赠送24bit合集、DSD音乐、歌手合集、欧美影视原声及本站发布过的音乐

开始你的表演 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下老四,鼓励我更好的创作

支付宝扫一扫打赏

微信扫一扫打赏