本文首先介绍volatile关键字的实现原理,其次介绍synchronized关键字的实现原理,最后介绍二者的区别。
-
一、volatile关键字
这是一个轻量级线程同步关键字,在使用时,主要是用于修饰变量,在分析volatile关键字时,首先介绍相关的知识:高速缓存,缓存不一致问题以及并发的三种概念。
1、高速缓存
计算机在执行每条指令时,都是在CPU中执行,而在执行过程中,会涉及数据的读写问题,程序在执行时的数据是存放在主存(物理内存)中,每次执行,先要从主存中读取数据,结束后再写入到主存中,这与CPU执行指令的时间相比慢很多,所以,如果每次执行指令都需要与主存交互,会大大增加指令执行时间,从而CPU有了高速缓存。
高速缓存是怎样发挥作用的呢?
有了高速缓存后,每条指令在执行时,先将数据从主存中拷贝一份到高速缓存中,在高速缓存中进行计算,最后将计算的结果刷新到主存中。
2、缓存不一致
基于高速缓存的概念,假设,在一台PC上只有一个CPU和一份高速缓存,则所有进程和线程看到的数都是缓存中的数,不会要出现问题;但现在,服务器通常是多个CPU,更普遍的是每个CPU中有多个内核,而每个内核都维护自己的缓存,就会出现缓存不一致的问题。
举例说明缓存不一致问题,在执行i++时,CPU会将内存中的i值拷贝到高速缓存中,进行运算,然后将高速缓存中的结果刷新到内存中;单核状况下,该代码无问题,但在多核情况下,每条线程可能运行于不同的CPU中,可能会出现:例i= 0;线程1和线程2同时将i值拷贝到各自的高速缓存中,线程1运算结束后刷新到主存中 i= 1,而线程2也将自己运行的结果刷新到主存i= 1,而不是我们希望的 i= 2;即为缓存不一致问题。(称被多个线程访问的变量为共享变量)
3、并发的三个概念
原子性
一个操作或多个操作要么执行且在执行过程中不被任何因素打断,要么不执行。(基本数据读取,赋值(数据赋值给变量)都是原子性操作)。
可见性
多个线程访问一个变量,当一个线程改变了变量的值,其他线程是可以立即看到新值。
有序性
程序执行按照代码的先后顺序。 涉及指令重排序:处理器为提高运行效率,对代码执行顺序进行优化,不保证程序按照代码先后顺序执行,但保证结果与代码顺序执行结果一致。重排序不会影响单线程操作,但对多线程操作可能会产生影响。
所以,在多线程操作下,必须保证原子性,可见性和有序性,否则,可能导致程序运行不正确。
4、volatile关键字实现原理
4.1 volatile关键字的两条原则
一个共享变量被volatile关键字修饰后,就具备了两层意思:
(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
(2)禁止指令重排序。
那具体操作系统是如何实现这两条的呢?通过以下代码进行说明:
//线程1
Boolean flag = false;
while(!false){
do something;
}
//线程2
flag = true;
首先,乍一看,觉得这段代码没问题,细细分析发现可能导致无法中断线程,在线程1操作时,会将变量数据拷贝一份到高速缓存中,当线程2修改了变量值时,未立即写入内存,而是去干其他事,此时线程1未得到变量更改后的值,会一直循环下去。而若给该变量加上volatile关键字,变量会将新值立即返回写入主内存,当线程2修改变量值时,使得线程1的工作内存中的缓存变量无效,所以当线程1需要获取变量值时,要到主存获取,从而保证可见性。
4.2 volatile底层实现原理:
在分析volatile关键字时,我们可以先看看其底层与不加该关键字的区别,从而更深刻理解该关键字。与未加该关键字的变量相比较,二者在字节码文件层面并无区别,在深一个层次,在汇编代码中,可以发现,有volatile修饰的变量进行写操作时,会多出一行代码,主要区别在于,出现了一个lock前缀(相当于一个内存屏障),lock前缀的指令在多核处理器下做了两件事:
(1)将当前缓存中的数据立即写入主存中;
(2)写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。(《Java并发编程的艺术》)
4.3 volatile能保证原子性吗?
不能,举例进行说明:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
按照运行分析,结果应该是100000,然而实际运行结果小于100000的,为什么呢?
由于自增操作不能保证原子性,也就是说它是分割执行的,(读取原值,+1操作,写回主存),有可能出现:线程1取inc值为10后,被阻塞,线程2再取值,发现线程1只读取原值,并未进行修改,不会导致线程2的工作内存中的缓存变量无效,线程获取的inc仍为10,进行+1操作,写回主存,导致两个线程操作,但结果只增加了1;
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
解决办法:使用synchronized或Lock或Atomic类进行操作。
4.4 volatile能保证有序性吗?
可以,由于volatile关键字能禁止进行指令重排序,能在一定程度上保证有序性。
两层意思:
1、当程序执行到volatile修饰的变量时,可以保证在其前面的语句已经执行,且对其后的操作可见,其后的操作未执行;
2、在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
举例说明:
x = 10; 语句1
y = 20; 语句2
flag = true;语句3
x = x+1; 语句4
y = 13; 语句5
分析:x,y为普通变量,flag为volatile修饰,可以保证语句3执行时,语句1,2肯定已经执行,语句4,5肯定未执行,但语句1,2的执行顺序,语句4,5的执行顺序不能保证。
-
二、synchronized关键字
synchronized关键字又被称为重量级锁,此处,先介绍锁的概念,然后介绍对象头的相关概念和CAS(Change And Swap),最后介绍synchronized的实现原理。
1、锁
在Java多线程中会遇到各种锁:偏向锁,自旋锁,可重入锁等,此处简要介绍两种锁:悲观锁和乐观锁,关于偏向锁,自旋锁,轻量级锁,重量级锁会在synchronized中介绍,而公平锁和非公平锁会和可重入锁一起介绍。
悲观锁
(由于在获取数据时,担心数据被改变)每次获取数据都会加锁 (具体实现:synchronized)
使用场景:写操作较多的场景;
缺点:1、在多线程竞争下,加锁和解锁操作会导致较多的上下文切换和调度延时,引起性能问题;
2、一个线程持有锁会导致其他需要此锁的线程挂起。
乐观锁
(由于在获取数据时,不担心数据被改变),获取数据不加锁,在更新数据时判断数据是否被改变,当数据被改变,则不进行数据更新,反之,更新数据。(具体实现:CAS)
使用场景:读操作较多的场景;
2、CAS(Change And Swap)
CAS中包括三个操作数:数据存储地址V, 原始数据A,新数据B;先到地址V上,判断当前位置上的数据是否等于原始数据A,相等则表明数据未被其他线程改变,将当前位置的值改为新数据B;不相等则在地址v重新取数据,比对,相等则改值,不等继续取值比对,直到取到的数据与当前位置的数据相等;
CAS缺点:
(1)ABA问题
线程 1 从内存位置V中取出A。
线程 2 从位置V中取出A。
线程 2 进行了一些操作,将B写入位置V。
线程 2 将A再次写入位置V。
线程 1 进行CAS操作,发现位置V中仍然是A,操作成功。
尽管线程 1 的CAS操作成功,但不代表这个过程没有问题——对于线程 1 ,线程 2 的修改已经丢失。
ABA问题解决:添加版本号(标志)
(2)性能消耗大
在比较并修改V位置的值时,假设其他线程频繁修改该位置值,导致当前比较一致不成功,就会继续占用CPU进行获取数据,修改数据流程,造成CPU资源的无端消耗.
(3)只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,可以循环使用CAS操作保证原子操作,但对多个共享变量操作时,循环CAS无法保证操作的原子性。此时,可以用锁或者将多个共享变量合并成一个共享变量操作。
3、对象头
锁存在与对象头中,对象是数组类型,虚拟机用3个字宽存储对象头,对象是非数组类型,虚拟机用2个字宽存储对象头,在32为虚拟机中,一个字宽相当于4个字节,即32bit.
长度 | 内容 | 说明 |
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
mark word默认存储对象的hashcode和分代年龄以及锁标记位,32位mark word的默认存储结构如下:
| 25bit | 4bit | 1bit | 2bit(锁标志位) |
无锁状态 | 对象的hashcode | 分代年龄 | 是否是偏向锁 | 01 |
在运行期间,Mark word存储结构会随锁标志位发生改变:
锁状态
| 25bit |
4bit
|
1bit |
2bit | |
23bit | 2bit | ||||
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
4、synchronized实现原理
首先,synchronized实现同步的基础:Java中的每个对象都可以作为锁。具体表现为以下三种形式:
修饰静态方法 --->同步类对象
修饰一般方法 --->同步实例对象
修饰代码块 --->对当前代码块加锁,锁的粒度更加细化
Java 虚拟机中的同步(Synchronization)是基于进入和退出Monitor对象实现, 无论是显式同步(有明确的monitorenter和monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法并不是由monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。(《Java并发编程的艺术》)
4.1 锁的升级
为减少加锁和加锁带来的性能消耗,引入了"偏向锁","轻量级锁",在Java SE1.6中,锁共有四种状态,由低到高依次是:无锁状态--->偏向锁--->轻量级--->锁重量级锁,锁状态会随着竞争状态升级,但是不能降级。
4.1.1 偏向锁
(不需要与操作系统协商)大多数情况下,锁不仅不存在多线程竞争下,更多的是一个线程多次获得。偏向锁的目的:当一个线程获得锁后,减小其再次获得锁(重入)的开销,感觉像是有所偏向;而且,JVM对那些存在多线程加锁,但不存在锁竞争的情况也做了优化,比如有些情况,两个线程除了互斥关系,也有可能存在同步关系,被同步的两个线程(一前一后)共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier)
(1)偏向锁的获取
当一个线程访问同步块并获取锁时,会将对象头和栈帧中的所记录里存储偏向的线程ID,该线程进入和退出同步块时不需要花费CAS操作进行加锁和解锁,只需简单测试对象头的Mark word中是否存储着指向当前线程的偏向锁,测试成功,当前线程已经获得锁,测试不成功,需要再测试Mark word中的偏向锁标志是否置为1,若没有设置,使用CAS竞争锁,若设置了,尝试使用CAS将偏向锁指向当前线程。
(2)偏向锁的释放
使用了一种等到竞争出现才释放锁的机制,即当其他线程竞争锁的时候,持有偏向锁的线程才会释放。偏向锁的撤销需要等到全局安全点(这个时间点没有字节码在执行),首先,CPU会使得该线程暂停,检查持有偏向锁的线程是否还活着,若该线程不处于活动状态,将对象头置为无锁状态,若该线程处于活动状态,则拥有该线程的栈会执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word,要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
(3)偏向锁的设置
关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。
4.1.2 轻量级锁
(不需要与操作系统协商)
加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。
解锁
轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程。
自旋锁
定义:让线程执行一个无意义的循环,循环结束就去竞争锁,竞争不到再去进行循环,循环过程该线程一致处于Running状态,而基于JVM的线程调度,会让出时间片,其他线程仍然有申请锁和释放锁的机会。
自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,但是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。所以自旋的次数一般控制在一个范围内。
4.1.3 重量级锁
重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。
4.2 锁的比较
锁类型 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁必须要额外消耗 | 若线程间存在竞争,会带来额外 的锁撤销的消耗 | 只有一个线程访问同步代码块 |
轻量级锁 | 竞争的线程不存在阻塞,提高程序响应速度 | 始终得不到锁的线程会自旋消耗资源 | 追求响应速度,锁占用时间短 |
重量级锁 | 线程竞争不会使用自旋,消耗CPU资源 | 线程阻塞,响应时间缓慢 | 追求吞吐量,锁占用时间长 |
-
volatile与synchronized的区别
1、volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
2、volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
3、volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
4、volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
5、volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。