文章目录
- 认识synchronized与volatile关键字
- synchronized与volatile关键字使用
- synchronized与volatile关键字原理
- synchronized与volatile场景以及可以优化的地方
前言:
理解线程安全:执行顺序和内存可见。
执行顺序:目的是控制代码执行(顺序)及是否可以并发执行。
内存可见:控制的是线程执行结果在内存中对其它线程的可见性。根据Java内存模型的实现,线程在具体执行时,会先拷贝主存数据到线程本地(CPU缓存),操作完成后再把结果从线程本地刷到主存。
原子性:一个操作是不可中断的,要么全部执行成功要么全部执行失败。
可见性:指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
有序性:即程序执行的顺序按照代码的先后顺序执行。
一、认识synchronized与volatile关键字
synchronized:是为了解决多线程并发访问共享变量。synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,它还可以保证共享变量的内存可见性与顺序性。
volatile:保证了共享变量的可见性,但并不保证原子性。
二、关键字的使用
2.1synchronized 可以修饰静态方法、实例方法以及代码块。
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
1). 普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁
2). 静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
3). 同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
锁粒度:实例锁与类锁
//**对象锁的两种写法
public synchronized void m1() { //todo}
public void m2{
synchronized (this){ //todo }
}
//类锁两种写法
public class synchDemo{
public static synchronized void m1() { //todo}
public void m2(){
synchronized(synchDemo.class){ // todo }
}
}
两者作用域不同,生命周期不同,类锁相当于在更大范围上锁,而对象锁范围更小,系统消耗也更小。
volatile 只能修饰变量。一段代码引发的思考:
public class VolatileDemo {
public /*volatile*/ static boolean stop=false;
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
int i=0;
while(!stop){
i++;
}
});
thread.start();
System.out.println("begin start thread");
Thread.sleep(1000);
stop=true;
} }
运行之后可以看出:改变stop后,程序并没有停止。原因是 stop变量对线程thread不可见。
可见性: 指的是在多线程环境下,读写同一个变量值可能会出现读线程不能及时获取到写线程写入的最新值。为了解决这个问题可以使用volatile关键字
三、synchronized与volatile原理
在理解两者原理之前需要先了解jvm的内存模型:
注释:Epoch:类似于版本,如果Epoch升级后,偏向锁撤销,重新偏向
首先是对象在jvm内存中的存储结构
由图可以看出在jdk1.6之后java对synchronized关键字进行了优化,在1.6版本之前synchronized的是重量级锁,凡是争抢锁失败的线程将被挂起。而synchronized关键字优化后从无锁状态到重量级锁升级的过程。
无锁-》偏向锁-》轻量级锁-》重量级锁 系统消耗由少到多
无锁状态下存储结构: 线程ID为空,Epoch,对象分代年龄,0 ,01
下图为对象锁的整体视图:
synchronized原理:通过对对象头中的记录实现锁升级,而ObjectMonitor则重量级锁实现的关键。通过上图可以看出对象头中存储的有ObjectMonitor信息。具体的参考synchronized锁升级。
volatile原理:
在理解volatile关键字之前,需要首先了解下计算机发展历程以及可见性问题的本质
计算机中最核心的组件是 CPU、内存、以及 I/O 设备。计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化
-
CPU 增加了高速缓存
-
操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
-
编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程
CPU高速缓存
线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。
缓存一致性:高速缓存的存在以后,每个 CPU 的处理过程:先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题为了解决缓存不一致的问题,在 CPU 层面做了很多事情,
主要提供了两种解决办法
1) 总线锁
2) 缓存锁
总线锁是指在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。最好优化的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁,它核心机制是基于缓存一致性协
议来实现的。
缓存一致性协议:MESI(Modify Exclusive Shard Invalid)
Modify:表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致.
Exclusive : 缓存的独占状态,表示数据只缓存在当前的cpu缓存中,并且没有被修改。
Shard :表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致。
Invalid:表示缓存已经失效。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据。
CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效后才可写,使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果
可见性问题本质:由于 CPU 高速缓存的出现使得 如果多个 cpu 同时缓存了相同的共享数据时,可能存在可见性问题。也就是 CPU0 修改了自己本地缓存的值对于 CPU1 不可见。不可见导致的后果是 CPU1 后续在对该数据进行写入操作时,是使用的脏数据。使得数据最终的结果不可预测。
MESI能够解决缓存一致性问题,但是也存在一定问题:
1、 CPU 缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。
解决方案:cpu 中引入了 Store Bufferes,CPU0 只需要在写入共享数据时,直接把数据写入到 store
bufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。
But 这种情况依然会存在问题:
伪代码块:int shareValue = 5; //cpu0 void exeCpu0(){ value= 10; isFinish = true; } #-----------------# void exeCpu1(){ //cpu1 if(isFinish ){ asset value== 10; } }
假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,并且状态为(E)、而 Value 可能是(S)状态。那么这个时候,CPU0 在执行的时候,会先把 value=10 的指令写入到storebuffer中。并且通知给其他缓存了该value变量的 CPU。在等待其他 CPU 通知结果的时候,CPU0 会继续执isFinish=true 这个指令。而因为当前 CPU0 缓存了 isFinish 并且是 Exclusive 状态,所以可以直接修改isFinish=true这个时候 CPU1 发起 read操作去读取 isFinish 的值可能为 true,但是 value 的值不等于 10。这种情况我们可以认为是 CPU 的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题
从硬件层面很难得知软件直接逻辑的相互依赖,因此需要在软件层面解决:
CPU内存屏障:一般分为读屏障、写屏障和全屏障
读屏障:处理器在读屏障之后的读操作,都在读屏障之后执行。
写屏障:处理器在写屏障之前的所有storebuffer同步到主内存,即写屏障之前的操作对于屏障之后的读、写操作可见。
全屏障:确保屏障之前的内存读写的操作结果提交主内存后再执行屏障之后的操作。
有了内存屏障后,伪代码:
int shareValue = 5; //cpu0
void exeCpu0(){
value= 10;
storeMemoryBarrier(); // 伪代码,插入写屏障,使得value=10强制写入主内存,同时失效其他cpu共享数据
isFinish = true;
}
#-----------------#
void exeCpu1(){ //cpu1
if(isFinish ){
loadMemoryBarrier(); // 伪代码,插入读屏障,使cpu1从主内存获取最新数据
asset value== 10;
}
}
虽然内存屏障与硬件相关,但是JMM【java内存模型】封装了硬件的抽象,提供了统一的操作,屏蔽了底层硬件的实现细节。
线程之间共享变量值都是基于主内存实现的。
Java内存模型的底层实现:简单理解为,通过内存屏障禁止重排序,即使编译器根据具体的底层体系架构,将内存屏障替换成具体的CPU指令,对编译器而言,内存屏障将限制底层所做的重排序优化。编译器将在volatile修饰的字段读写操作前后插入一些内存屏障,从而避免重排序。
JMM解决有序性:
通过一些禁用缓存以及禁止重排序的方法解决可见性和有序性。
例如:volatile、synchronized、final
JMM解决顺序一致性问题:
为了提高程序性能,编译器和处理器都会对指令做重排序,其中处理器的重排序上面已经分析过。从源码到最终执行指令可能经过3中重排序
源代码–》1、编译器重排序–》2、指令级并行重排序–》3、内存系统重排序–》最终执行指令序列
其中2、3属于CPU重排序,这些重排序可能导致可见性问题。
编译器重排序需要遵循几种数据依赖性规则,存在以下规则就编译器不会重排序。
1、a =1;b=a;
或者a=1;a=2;
或者a=b;b=1;
又比如: int a=2; //1
int b=3; //2
int rs =a * b; //3
1和3、2和3存在数据依赖,3不能排在1、2之前否则程序报错。而1和2之间没有依赖,可以重排序
JMM中程序的顺序规则HappensBefore
暂不介绍
四、synchronized锁升级【非公平锁】
无锁升级偏向锁
CAS:Conmpare And Swap【乐观锁是用CAS实现的】
维基百科:比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。 这是作为单个原子操作完成的。 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败。 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应。
由无锁状态升级为偏向锁的过程:
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
1、线程1访问同步代码块时,首先检查锁对象的对象头中是否存储了线程1的threadID【刚开始是无锁状态】,通过CAS替换MarkWord对象头,成功则将对象头中的Thread ID指向Thread1.因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;
2、如果线程1在访问同步代码块结束后,再次访问同步代码块时,检查对象锁的对象头中的偏向锁ID是否是线程1【偏向锁不会主动释放锁】,如果是则可以直接访问。
3、线程2 访问同步代码块,首先检查对象头中的线程ID是否为线程2,显然由于线程1访问过,对象头记录线程ID的是Thread1。需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态。此时线程2通过CAS操作将其设置为偏向锁;如果thread1存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁的取消:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用
-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
轻量级锁膨胀过程
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
偏向锁升级轻量级锁:
1、栈中记录Lock record锁记录:把对象头中的信息复制到栈帧displaced hdr,然后把owner指向锁对象的对象头,而对象头中则记录复制锁记录的地址即使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址。
2、承接上面偏向锁升级轻量级锁时,撤销偏向锁,更改锁标记由“01”改为“00”,同时记录栈中锁记录的指针。
3、如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
注释:自旋:某个线程一直在for循环中空运行,等待获取锁,直到自旋达到一定次数后,线程挂起。
自旋优点:绝大多数线程占用共享资源时间不长,通过指定自旋次数或者自适应自旋,能够在锁释放的时候及时获取锁并执行,避免了线程上下文切换以及线程调度,从而提升性能。
适用场景:线程占用共享资源时间很短
不适用场景:自旋会占用CPU资源,如果线程占用共享资源时间长,存在非常多的线程自旋也会浪费一定的CPU资源。但是可以通过设置jvm参数:preBlockSpin 设置自旋次数或者自适应自旋来解决。自旋达到一定程度后会锁膨胀。
轻量级锁升级重量级锁
1、轻量级锁升级重量级锁与偏向锁升级轻量级锁类似。只不过升级条件有所不同。线程1在通过CAS修改对象头成功后执行同步代码块。此时把栈的锁记录地址写入对象头,同时把对象头指向锁记录的owner。
2、线程2访问同步代码块,通过CAS修改对象头失败。通过自定义自旋次数或者自适应自旋次数,如果获取到锁则执行同步代码块,否则升级为重量级锁。同时把线程2放入阻塞队列,等待线程1执行完毕释放锁后会唤醒同步队列中的线程争抢锁。此处唤醒是唤醒一个线程,非公平锁。
重量级锁:没有获取锁的线程会被阻塞,标准的互斥锁的机制。mutex:互斥
每一个对象都有一个ObjectMonitor,它是实现重量级锁的核心。
场景:线程A启动,线程B启动.线程A获得锁,线程B被阻塞,线程A调用Wait方法,同时释放锁。线程B获取锁,线程B调用notify/notifyAll 唤醒一个/唤醒所有等待队列里面的线程 ,线程B执行完释放锁后,其他唤醒的线程才能争抢锁
ThreadA通过monitorenter指令成功后获得对象锁,之后开始执行同步代码块。ThreadB通过monitorenter指令失败后会等待。
具体ObjectMonitor可以参考:https://www.jianshu.com/p/7f8a873d479c
五、synchronized与volatile对比
(1)volatile本质是通知JVM当前变量在寄存器中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其它线程被阻塞【synchronized是非公平锁】。
(2)volatile只能用在变量上而synchronized可以在实例方法、静态方法、代码块上使用
(3)volatile可以保证可见性,不保证原子性,synchronized可以保证可见性、有序性和原子性。volatile通过禁止指令重排序来保证可见性效果
(4)volatile不会造成线程阻塞, synchronized会造成线程阻塞
(5)当一个域的值依赖于它之前的值时,volatile就无法工作了,如n=n+1、n++等
如果某个域的值受到其它域的值得限制,volatile无法工作,如Range的lower<=upper的限制
使用volatile而不是synchronized的唯一安全的情况是类中只要一个可变的域
(7)volatile效率远比synchronized高,因为volatile不会造成线程阻塞,而synchronized关键字会造成线程阻塞,切换线程上下文以及线程调度的资源消耗高。
六、如何优化锁
1、通过synchronized关键字控制锁粒度
2、jdk1.6之前是重量级锁,优化后锁升级 【无锁化】
无锁化优化示例:
例如以下3中场景:
1、只有一个线程A去访问【根据统计jvm大部分是这种情况】
优化:这种情况下引入偏向锁,对象头存储 ThreadA ID,偏向锁标记 1,但是这种场景不多,大部分是2、3两种情况
2、线程A、线程B交替访问
优化:引入轻量级锁,通过自旋
3、多个线程同时访问
这种情况只有阻塞线程,如果知道应用场景是这个,可以通过jvm参数
启用偏向锁: -XX:+UseBiasedLocking(默认启用)此场景下偏向锁反而使锁升级浪费资源,可以配置关闭。
4、注意:volatile失效的场景
线程通信
线程间的通信机制 :wai、notify、notifyAll
wait作用:1、实现线程阻塞【线程进入阻塞队列】 2、释放当前同步锁
notify: 随机唤醒一个阻塞的线程【wait】
notifyAll: 唤醒所有阻塞的线程进入同步队列去争抢锁。