返回信息流> “你是否了解Java中的synchronized和volatile关键字,能否讲一下它们的异同”
相信很多同学在面试的时候都会遇到这个问题,作为Java中著名的线程同步关键字,synchronized和volatile在面试中出现的比例非常之高。今天,作为宅在家中多日的我,也想给大家来分享一下自己对于这两个关键字的一知半解。本文将会从这两个关键字的用法和语义入手,由浅入深的来剖析这两个关键字的原理,并从这个角度来分析一下这两个关键字的异同。
## 写在最前面
> 本文将首先分开介绍synchronized和volatile的用法和语义,并且讲一讲个人对其应用场景的理解,然后将会回归题头问题,聊一聊为何这两个关键字经常会放在一起来讨论,以及个人对于这个问题的解答。最后由于本文基本都是基于个人的阅读和理解总结而来,如果有任何错误,还请各位大大提出来,帮忙斧正。
## 一、synchronized
#### 1. 定义和用法
相信我们都知道,synchronized是Java语法中的一个内置锁的实现,它为程序在并发环境下提供了单一的线程准入通行证,提供了并发场景的一个共享资源访问的解决方案;同时synchronized的作用域为方法和代码块。
通俗而言,synchronized关键字帮我们解决了代码块或者方法上的同步问题,同一时间,只有一个线程能够通过并执行。
#### 2. 锁的对象
> 那么究竟synchronized锁住的是什么东西?
答案:对象。
既然Java是一个面向对象的编程语言,那么其中的锁也将是以对象的形式存在。事实上,从Java对对象的定义来看,在每个对象的头部,都有一个专门的字段,用来记录对象的锁信息,其中就包含了对象锁的状态以及具体锁对象的指针;这里呢,需要额外插播一个小知识,那就是Java的锁依赖的底层是监视器monitor机制,再往下是大名鼎鼎的mutex锁,在这里就不深究。因此,当我们说synchronized锁住的是一个JVM对象时,真正发挥作用的是对象头上所指向的monitor对象。
#### 3. monitor是什么
> 那么monitor是什么样的一种机制,how does it work?
monitor从它的名字上来看就它应该更加强调的是监控,锁反而是其次的。
它里面包含一个计数器,用于记录当前线程获取锁的次数;包含一个entrySet和waitSet,分别记录所有在锁上等待的就绪线程以及已获取了锁,但是调用了wait的线程;包含一个owner线程的指针,记录当前获取到锁资源的线程。(这里面的waitSet是和wait和notify关键字相关的,这里暂时不做深究)
* `临界区`首先,被monitor监控的代码块,叫做临界区,monitor会监控所有进入的线程。
* `锁的可重入性`这样,在工作的时候,每个获取到这把锁的线程,都会触发一次monitorenter操作,计数器+1;如果是同一个线程再次来尝试获取这把锁,依然会触发一次monitorenter,计数器+1,这就是锁的可重入性。
* `资源自动释放`当线程的执行到了临界区的边界,要释放锁资源的时候,触发一次monitorexit操作,计数器-1,也就是说,它并不需要显式的去释放锁资源,都是自动释放的,相比于juc的Lock,它更加安全一点;同时,只有当计数器的技术值减到0时,才代表这个锁真正释放了,其他线程可以来尝试获取锁资源。
* `非公平锁`没有获取到锁资源的线程将会阻塞,都被会记录到entrySet当中;而当锁资源释放的时候,理论上是从entrySet中会选出一个ready的线程,用于竞争锁资源,但是此时此刻,如果正好有一个新的线程进入,也尝试获取锁资源,那么从效率上来看,直接将锁资源交给这个新线程看上去更加方便,于是锁资源就会无情的被这个新线程拿走,等待线程继续阻塞,这就是锁的非公平性。
用一个通俗的例子来讲:
临界区是一个上锁的房间,monitor是管钥匙的门卫,掌管着唯一的一把钥匙,每次要线程要进入这个房间的时候,都先看看门卫那儿有没有钥匙,如果有钥匙,就抄起钥匙开锁进房间;如果没有钥匙,就被门卫安排到等待室里面等。而进入房间的线程,可能还会再看到一个小房间,又被相同的锁给锁住了,这应该难不倒有钥匙的线程,啪啪开锁进房间。线程在房间里面逛够了,决定出房间的时候,他必须把锁给重新锁住,再看看是不是该把钥匙还给门卫。门卫拿到钥匙,刚转头准备去等待室里选一个线程给钥匙,这个时候突然来了一个新的线程,正好门卫又懒得去等待室了,于是麻溜的把钥匙给了新的线程,剩下的线程只能继续在等待室里干瞪眼。
> 事实上,java.util.concurrent包中核心的AQS也是类似的原理使用Java语言重新实现了一套,但是功能上比synchronized更加全面,也更具有伸缩性和扩展性,有时间我也可以来分享一发对于它的理解。
#### 4. 回到synchronized
刚刚讲了一大通的monitor原理,相信大家也对synchronized的机制有了一定的理解了,那么我们回归代码层面,来看看synchronized锁的对象究竟是哪一个。
正如我们之前所说,synchronized一共有两个作用域
##### 作用域为方法
```java
public synchronized void synchronizeMethod() {
// do sth
}
```
在这种作用域下,锁住的对象为this,也就是当前对象;如果方法是静态的,锁住的是当前的Class类对象
##### 作用域为代码块
```java
public void synchronizedStub() {
synchronized(this) {
// do sth
}
}
```
这种作用域下,锁住的对象为synchronized之后指定的对象,可以指定为自定义的对象(该例中为this),也可以是一个Class类对象
这两种作用域从本质上是没有差别的,从字节码上来看,第一种是在方法头上会声明一个锁标志,而第二种则显式的声明了monitorenter和monitorexit操作,在真正执行的时候,都是会去获取monitor锁执行。有兴趣的同学可以尝试反编译查看一下字节码,这里就不继续深究了。
## 二、volatile
#### 1. 语义和用法
volatile可以认为是JVM中提供的最为轻量的多线程同步机制,与synchronized相比它要轻量的多,并且只能够用于修饰变量;同时,它也更加容易被误解
与很多人对它的第一印象不同的是,volatile并不能保证变量的原子性,事实上,volatile和原子性既有关系,又没有那么抢的关系,它能够保证的是,变量在程序执行过程中的可见性和有序性。那么可见性和有序性如何去理解呢,在展开之前,我先来稍微介绍一下一个基本的Java变量是如何在计算机里读写的。
#### 2. 变量
如果我们对计算机做一次针对性的解剖,我们可以发现,如果要运行一个JVM,所使用到最频繁也最关键的部位是CPU和内存。CPU由寄存器和计算单元组成,内存则是纯粹的存储空间。每当一个线程拿到CPU资源,开始工作时,它需要将内存中的变量值load到CPU的寄存器当中,借由计算单元来做必要的处理,然后将变量重新assign为计算结果,并store到寄存器,最后write到内存当中。可以看到,在整个过程中,即使是一个很简单的变量赋值或者数值计算操作,在计算机的处理过程中依然有很多的步骤,这些步骤只能各自保持原子性(甚至一些位数过多的变量这种操作不能保证原子,例如double和long),每一步的操作都有可能被其他线程抢占,造成令人疑惑的结果。
而在真实的场景中,事实上更加复杂:CPU的寄存器的读写速度是远大于内存的,这样的速度Gap往往会拖慢CPU的处理效率,使得大部分的时间都在处理IO;为了解决这样的问题,CPU厂商往往会在CPU上再加上一个高速缓存,用于处理和内存的IO,而由于CPU一般又是一个多核工作模式,因此,整个系统就可以看做是一个小型的分布式应用存储实践了(分布式缓存一致性的讨论又将是另外一个天地),这里面不去深究这里面的缓存一致性问题。俗话说每加一个组件,就会加上一层复杂度和维护难度,整个变量的处理过程将会变得更加复杂并且不可控。
```
1. 线程获取CPU资源,开始从内存中read变量名
2. 线程将变量的值从内存当中load到CPU当中,同时会在高速缓存当中copy一份
3. CPU对变量进行use、assign、store等操作,并不断更新变量在缓存中的值
4. CPU发起write请求,将高速缓存中的变量值通过缓存一致性协议刷新到主内存当中
```
那么在Java当中,每个线程在执行时,也就能够获取到一份自己独立的工作内存,类比于上图中的高速缓存,它用于存储这个线程在执行过程中所有用到的变量副本,并在合适的时机将数据刷新到主内存当中。线程的工作内存的实现与机器和平台相关,但一般都会将工作内存优先放于寄存器和高速缓存当中。
最后,我们来举个典型的案例:
```java
i++;\\等同于i=i+1
```
这里面所执行的操作:
```
1. 变量i的值load到寄存器和高速缓存当中
2. use变量i,新声明一个临时变量j,assign为j=i+1,
3. 将j的值store到寄存器和高速缓存当中
4. 将变量i赋值,assign为i=j
5. 执行write操作,将变量i刷新到主内存当中
```
可以看到,为了执行一个简单的i++操作,其实JVM所做的事情并不那么简单,这也是为什么有些时候,关于变量的读取和操作,会很让人迷惑的原因。
#### 3. 可见性
那么有了以上的基础,我们就可以来好好的理解一下可见性了
可见性有两层含义,第一,被volatile修饰的变量,一旦有修改,会立即刷新到主内存里面,在我们之前对于变量在CPU中的使用可以看做为只要对变量有了assign操作,马上就会write到内存当中;第二,被volatile修饰的变量,一旦被一个线程修改了,其他线程马上就能够读到这个变量修改后的值。这里面的第一点含义比较好理解,可以简单的看做“高速缓存,爷不要了”,并且将变量的赋值和修改(本质上,修改也是一种赋值)操作合并了,必须得一块儿做完。而第二点,虽然本质上也是不要中间缓存来代理操作了,但是它其实是一个多核场景下才最能体现效果,多核多线程场景下,每个CPU都有着自己的线程与高速缓存,这种时候,难免会有变量读取和写入时机的不一致导致的数据不一致的问题,这种时候,volatile登场,最大化保证了每个线程对同一变量值的读取一致性,因此第二点含义其实是第一点的延伸,也是volatile能够一定程度解决线程同步问题的主要原因之一。
但是,但是千万不要以为只要有volatile修饰的变量,那么这个变量就在并发之下高枕无忧了,volatile保证了读,保证了写,都是直接与主内存同步,但是读和写依然是断开的,非原子的,而java中的线程隔离级别是非常松散的(突然想到了MySQL的事务隔离级别,有时间也来攒一篇MySQL事务隔离性相关的文章),很有可能出现一个线程还未写入更改时,另外一个线程读入了这个值,并且去做类似的修改,这样就导致了其实线程之间都拿到了过期的变量去做修改,最典型的场景就是`i++`这样的操作,在多线程的场景下,一旦有多个线程同时来执行`i++`,即使是使用volatile修饰的变量i,也是无法保证`i++`执行的正确性。因此,从这个角度来看,volatile和原子性确实有关系,它只是保障了变量在读和写操作的时候,具有原子性,但是无法保证变量在任何操作的时候是原子的。
#### 4. 有序性
有序性相对于可见性更加难以理解,我尝试用我的语言来做一下解释:
首先在Java代码的执行层面,虚拟机有时候为了更好的性能,会把代码的执行顺序悄悄的改变,举个栗子:
```java
// 共享变量succ
boolean succ;
// 线程A
succ = false;
doSomethingA();
succ = true;
// 线程B
if (succ) {
doSomethingB();
}
```
在某些场景下,机器为了执行速度,线程A中的变量succ可能等不到doSomething()结束就会被赋值成为true了,这样,在线程B执行的时候,很难通过线程A所预想的顺序来控制线程B的执行,导致一些奇妙的事情出现。这里面的原因在于,对于单一的线程A而言,只需要把自己线程内部针对单一变量的操作顺序搞定就行,并不需要关心不同变量的赋值和修改是否和代码顺序一致,我们把线程A的动作我们来细化一下:
```
1. succ=false
2. doSomethingA()
3. succ=true
```
这里面的操作1和操作3是针对同一个变量,它们需要严格的按照顺序执行;而操作2,和操作1和3的变量不是同一个,那么在机器执行的时候,为了方便,并不会把操作2严格的放在1和3之间执行,有可能会放在操作3之后执行。
在这种情形之下,对于线程A而言,所有的操作依然是有序的,但是对于线程B而言,则是乱了套。
那么volatile会为这一切带来什么呢,volatile给变量上了一把锁,保证了这个变量不会根据处理器的喜好来去随意的变更执行顺序,而是义正言辞的按照代码中的顺序来处理,如果我们把刚刚的线程A的场景再扩展一下,并且给succ加上volatile修饰:
```
1. succ=false
2. i=10
3. doSomething()
4. i=20
5. succ = true
```
那么这段代码的执行,就会严格按照步骤1,中间步骤2~4,步骤5,来执行,这里面的中间步骤2~4顺序究竟如何,不用去管,但是它一定会等到中间步骤都执行完毕才去执行最后一个步骤。
这,就是volatile所保证的有序性
而这一切都是怎么做到的呢,这里做一个简单的解释,在汇编层面,变量被修饰之后,会在使用的时候,加上一个lock动作,锁住了寄存器对于这个变量的简化操作,也就禁止了变量所处语句的重排序,相当于一个屏障,在这个屏障之外,重排序随便玩,但是这个屏障是不会动的,也就是说对于每个线程而言都是变量的处理都是在正确的位置上的。具体的细节有兴趣的同学可以去查一查相关的文献或者看下对应的汇编,可以加深一下印象,这里也不再展开了。
## 三、问题的答案
> 经过了这么久的描述,不知道大家是否还记得最初的问题,经过了这么久的原理讲述,这个答案已经显得不是那么重要了,不过在这里我依然会给出我个人对于这个问题的答案,来结束今日份的宅
1. synchronized和volatile都是用于解决线程同步问题的java关键字,它们都能够一定程度上解决资源在多线程场景下的同步问题,单线程场景下,它们的优势发挥不出来,只能增加性能负担
2. synchronized是一个重量级相对较高的锁操作,它占用的资源更多,性能更低,但是在多线程环境下更为安全,能够保证某段代码块只能只能够串行执行。其背后的原理,是Java利用Monitor锁机制,来实现对线程调度的控制。
3. volatile则是一个轻量级的操作,性能相对较好,但是性能要好上多少需要根据具体情况具体分析;它只能保证变量的可见性以及在代码执行中的有序性,并不能保障操作的原子性,也不能保证线程安全
4. volatile所能够实现的内容,理论上synchronized也能够实现,如果没有没有完全理解volatile的话,建议优先使用synchronized,在大部分场景下,安全往往大于性能。
## 四、最后想说的话
#### 最后的最后:
#### 你,没错,靓仔/靓女,就是你!2021届的师弟师妹们!
#### 我们部门的春招开始啦~
#### 面向广大2021届的师弟师妹们,无论你是前端后端还是算法数据Or产品交互,我们都有HC~
#### 部门是新零售事业群下的CBU技术部,部门氛围超好,重视对新人的培养,有创新业务也有守城项目,更有非常nice的师兄师姐带你landing
#### 部门活动多多,无论是篮球羽毛球乒乓球,还是王者吃鸡狼人杀,都能找到属于你的那个位置
#### 有兴趣的同学~~可以拿起你的电话订购~~可以发送简历到fanyang.f@alibaba-inc.com,或者直接通过微信:13067855390
#### 如果你有任何疑问也可以扫描下方二维码进群,24小时答疑,保证解决你的每一个问题
这是一条镜像帖。来源:北邮人论坛 / java / #63393同步于 2020/2/19
该镜像源已超过 30 天没有更新,可能在源站已被删除。
Java机器人发帖
【宅在家中聊技术】也来聊一聊synchronized和volatile
fyghost
2020/2/19镜像同步10 回复
订阅后,新回复会通过你的通知中心匿名送达。
9 条回复
呃,又是老生常谈了。讨论volatile的时候还是不要谈缓存吧。其实和缓存(cache coherence)没什么太大关系,关键是多个读写操作之间的顺序(sequential consistency)。造成错序的原因也不是往缓存理写还是往主存里写,而是多个内存读写操作有没有被CPU硬件以及编译器软件调换顺序。Java从1.5的memory model开始就不讨论local memory和global memory了。你可以看看你们阿里的龙井JVM支持哪个版本。
要是真的想写并发程序的话,实践中最推荐的是java.util.concurrent里的高级同步机制(比如blocking queue, future/promise, count down latch, executor等)。很多时候消息队列比共享内存更容易理解,而且性能也够。
实在不能满足需求,建议用java.util.concurrent.locks.ReentrantLock和Condition,而不是对象自带的锁。对象自带的锁历史很悠久了(java 1.0就有),其实相当于一个ReentrantLock和一个Condition,但性能不如ReentrantLock,而且很多情况下一个Condition又不够,还是自己创建Lock和Condition吧。
如果对性能还有要求,再看看AtomicInteger, AtomicReference等,比volatile功能强大(比如AtomicInteger.getAndIncrement()是原子的,但volatile a++其实是分离的先读后写)。
嗯嗯,这个也是我后面的计划,打算把juc里面的架构来慢慢剖析一遍,这个应该有点多,不过我会慢慢来更新的
本文目的还是为了探讨一下面试中最最常见的一个并发相关问题,篇幅已经很长了,再加那个估计没有人愿意看下去了
【 在 rancho 的大作中提到: 】
: 好文章,但总觉得应该多讲讲和 ReentrantLock 和 synchronized 的异同,这两个对比应该更有对比性。
哈哈,暖羊羊大神也炸出来了
这块儿的知识我的确是有点落后了,稍后我把里面缓存相关的内容更正掉
关于juc,的确是想出一个合集来讨论一下
【 在 nuanyangyang 的大作中提到: 】
: 呃,又是老生常谈了。讨论volatile的时候还是不要谈缓存吧。其实和缓存(cache coherence)没什么太大关系,关键是多个读写操作之间的顺序(sequential consistency)。造成错序的原因也不是往缓存理写还是往主存里写,而是多个内存读写操作有没有被CPU硬件以及编译器软件调换顺序。Java从1.5的memory model开始就不讨论local memory和global memory了。你可以看看你们阿里的龙井JVM支持哪个版本。
: 要是真的想写并发程序的话,实践中最推荐的是java.util.concurrent里的高级同步机制(比如blocking queue, future/promise, count down latch, executor等)。很多时候消息队列比共享内存更容易理解,而且性能也够。
: 实在不能满足需求,建议用java.util.concurrent.locks.ReentrantLock和Condition,而不是对象自带的锁。对象自带的锁历史很悠久了(java 1.0就有),其实相当于一个ReentrantLock和一个Condition,但性能不如ReentrantLock,而且很多情况下一个Condition又不够,还是自己创建Lock和Condition吧。
: ...................