JMMsynchronized原理可见性有序性happens-beforeCAS(无锁并发)原子性 synchronized的优化
Posted halulu.me
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JMMsynchronized原理可见性有序性happens-beforeCAS(无锁并发)原子性 synchronized的优化相关的知识,希望对你有一定的参考价值。
目录
JMM
Java Memory Model(java内存模型)定义了一套多线程读写共享数据时,对数据可见性、有序性、原子性的规则和保障。
JMM将内存分为主内存和工作内存,共享信息放在主内存中,每个线程有各自的工作线程。
synchronized原理
1、synchronized底层会涉及到一个monitor区,也就是所谓的监视器。
2、每个对象都有自己的一个monitor区,但是monitor只有在使用synchronized同步的时候才会生效,其他普通的对象并不会考虑monitor(字节码指令中体现为monitorenter和monitorexit)。
3、monitor区划分为3个区,owner、entryList、waitSet
owner : monitor监视器的所有者,同一时刻只有一个线程成为owner
entryList : 排队等候区,也就是阻塞
4、当线程t1进入对象的monitor的时候,发现owner区并没有其他线程,就会进入monitor的owner区并进行锁定(字节码体现为monitorenter),其他线程t2进行的时候,只能在EntryList中等待,等到t1执行完毕解锁后才能进行owner区(字节码体现为monitorexit)。
5、必须确保monitor区一致,也就是synchronized的锁对象一致
可见性
可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一 个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况
案例1
为什么停不下来?
1、初始状态,t线程刚开始从主内存中读取带了run的值到工作内存。但是,由于t线程频繁地从主内存中读取run值,JIT即时编译器会将run的值缓存到自己工作内存的高速缓存区,减少对主内存run值得访问,提高效率。
2、mian线程修改了run值,并同步到主存汇总,而t线程依旧是从缓存获取变量的值,结果永远是旧值。
案例2
static boolean run = true;
public static void main(String[] args) throws InterruptedException
Thread thread = new Thread((()->
while (run)
try
System.out.println(1);
Thread.sleep(900);
catch (InterruptedException e)
e.printStackTrace();
));
thread.start();
Thread.sleep(901);
run = false;
1、死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run变量的修改了。
2、这是因为println()方法是用synchronized修饰的,synchronized也能防止从高速缓存中获取数值。
3、解决方法:
1、用volatile修饰变量可以避免线程从自己的工作缓存中查找变量的值,必须到主内存中获取它的最新值,线程操作 volatile变量都是直接操作主内存。
2、volatile只能用在一个写线程,多个读线程的情况(可见性),不能用在多个写线程的情况(原子性)
4、线程的执行是交替的
1、CPU会为每个线程分配一个时间片,当时间片执行完的时候。程序计数器会记录当前指令的执行位置,并跳转到另一个线程继续执行。当另一个线程的时间片用完的时候,CPU又会跳回原来线程的程序计数器所记录的位置继续执行,如此反复。
2、对于共享变量来说,多个线程的上下文切换,有可能会导致共享变量的修改不及时,由此就造成了数据的脏读问题。
3、比如,有2个线程t1和t2,1个共享变量 i ,CPU分配的时间片刚好执行到t1线程中获取i就跳转到t2线程,由此造成共享变量i同时被获取,进而导致修改后的数据并会被其他线程所看见,这也就是所谓的可见性的问题。
volatile限制的是线程不能从缓存而是从主内存获取数值,但是对于t1和t2线程来说,静态共享变量的数值已经获取到了,也就是说两个线程并不需要再次从主内存中获取数值。volatile在此处并没有用武之地。(原子性)
2、synchronized 既能保证可见性,也能保证原子性。但缺点是synchronized是属于重量级操作,性能相对更低。
有序性
1、案例1:
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num =2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结 果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
情况4:线程2 执行了 ready = true ,但是没有执行num = 2,线程1 执行,这回进入 if 分支,结果为 0
情况4的情况就是指令重排,时JIT即时编译器在运行时的优化,这种现象需要执行很多次才能够出现。
2、解决方法
volatile 修饰的变量,可以禁用指令重排
3、有序性的理解
这种现象就是指令重排。同一个线程下,指令重排并不会产生问题,但是在多线程下就有可能影响正确结果,例如著名的double-checked-locking模式实现单例
4、案例2:
INSTANCE = new Singlenton()对应的字节码:
由于指令重排的原因,4,7两步的顺序并不是固定的,比如:
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
解决方法: 对INSTANCE使用volatile修饰,可以禁用指令重排。
happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
1、线程解锁之前对变量的写,对于接下来加锁的其它线程对该变量的读可见
2、线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
3、线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或t1.join()等待它结束)
4、线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
5、对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
CAS(无锁并发)
CAS 即 Compare and Swap ,它体现的一种乐观锁的思想。
比如多个线程要对一个共享的整型变量执行 +1 操作:旧值与结果值得比较
获取共享变量时,为了保证变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,使用于竞争不激烈、多核CPU的场景下。
因为没有使用synchronized,所以线程不会陷入EntryList阻塞中进行上下文切换,这是效率提升的因素之一。
但如果竞争激烈,可以想到比较重试必然频繁发生,反而效率会受到影响。
工作在多核CPU上,这是因为CAS是需要使用CPU时间的(不断重试的过程)。如果CPU只有一个,那么CAS就没法正常工作了。其他线程在修改的时候,当前线程想进行重试也没有CPU时间可以使用。synchronized可以在单CPU下使用,因为阻塞不需要占用CPU的时间片。
CAS底层实现
1、CAS的底层依赖于一个Unsafe类来直接调用操作系统底层的Unsafe指令。通过反射机制获取unsafe对象,再通过unsafe对象的objectFileldOffset()方法获取共享变量的偏移量offset。
2、通过这个偏移量可以获取当前对象的共享变量的数值。
3、旧值与共享变量的数值进行比较,如果比较相同返回true并且旧值加+1
4、如果比较不相同则返回False,重写进行循环,并且把共享变量的最新值赋予旧值再进行比较。
偏移量(offset):偏移量相当于new一个数组。比如对象Object的地址值为0x0001,而Field的地址值为0x0002,那么地址增加的数1就是这个Field的偏移量。偏移量的主要目的是为了获取数值更加方便。
悲观锁和乐观锁
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
原子性
java中静态变量的自增和自减不是原子操作。
juc(java.util.concurrent)中提供了原子操作类,可以提供整数操作自增自减的线程安全,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。
synchronized的优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 、 分代年龄(垃圾会收的寿命) ,当加锁时,这些信息就根据情况被替换为 标记位(标记锁的种类:偏向锁、轻量级锁、重量锁等) 、 线程锁记录指针(CAS) 、 重量级锁指针 、 线程ID 等内容。
轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
多线程是交替进行加锁,并不存在竞争,这就是轻量级锁。
如果多线程是有竞争关系的,那么轻量级锁会升级为重量级锁(锁膨胀)。
两个线程的时间是错开的,这是一个轻量级锁。
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word。
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
重量级锁的优化
重量级锁:通过monitorenter和monitorexit进行加锁解锁操作。
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
自旋锁占用CPU时间,只要在多喝CPU的情况下啊,自旋锁才能发挥优势
java7之后不能控制是否开启自旋功能
偏向锁(轻量级锁的优化)
轻量级锁在没有竞争时,每次重入仍然需要执行CAS操作。Java6中引入了偏向锁来进一步优化:只有第一次使用CAS将现场ID设置到对象的Mark Word头,之后发现这个线程ID是自己就表示没有竞争,不用重新CAS。
偏向锁的缺陷:
1、撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
2、访问对象的 hashCode 也会撤销偏向锁(加偏向锁后,hashCode不再存储在对象头,反而存储在加锁线程里面。当访问hashCode的时候,对象头并没有hashCode,就必须撤销偏向锁)
3、如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2(重偏向),会重置对象的 Thread ID
4、撤销偏向和重偏向都是批量进行的,以类为单位
5、如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
6、可以主动使用-XX:-UseBiasedLocking 禁用偏向锁
其他优化
1、减少上锁时间
同步代码块尽量短。
如果上锁时间比较长,竞争的机会就会增加,轻量器锁会转化为重量级锁。
2、减少上锁的粒度
将一个锁拆分成多个锁提高并发度,例如:ConcurrentHashMap
3、锁粗化
多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化,把多次 append的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
StringBuffer类的append()是synchronized同步方法,多次append的加锁操作会粗化为一次。
4、锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。比如 StringBuffer类的多次append()方法。
5、读写分离
读不需要进行加锁
写需要进行加锁
读写分离,分别加锁
以上是关于JMMsynchronized原理可见性有序性happens-beforeCAS(无锁并发)原子性 synchronized的优化的主要内容,如果未能解决你的问题,请参考以下文章
java并发day03 JMM可见性 有序性 volatile底层原理 happens-before
java并发day03 JMM可见性 有序性 volatile底层原理 happens-before
java并发day03 JMM可见性 有序性 volatile底层原理 happens-before