[TOC]
1. volatile
1.1 volatile的内存语义
在Java中,volatile关键字有特殊的内存语义。volatile主要有以下两个功能:
- 保证变量的内存可见性
- 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”)
这两个功能都是通过内存屏障实现的
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
在这段代码里,我们使用volatile
关键字修饰了一个boolean
类型的变量flag
。
所谓内存可见性,指的是当一个线程对volatile
修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存 ;当一个线程对volatile
修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这一点上,volatile与锁具有相同的内存效果,volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。 所以上面的代码是线程安全的
单纯的赋值操作是原子性的。
而如果flag
变量没有用volatile
修饰,在step 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false
。
工作内存改了值后, 主内存是怎么通知到其他工作内存该值已被修改了?
答: 基于MESI协议, 缓存一致性协议(MESI只是其中一种)
状态 描述 M(Modified) 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 E(Exclusive) 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。 S(Shared) 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。 I(Invalid) 这行数据无效。 简单来说: cpu为每个变量都记录了状态(MESI四个状态),并通过总线嗅探机制变更每个工作线程中的对应变量的状态,不过这些都是cpu做的,不是JMM做的
嗅探机制工作原理 :每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。
注意:基于 CPU 缓存一致性协议,JVM 实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。(synchronize就没有总线风暴)
总线风暴:需要不断的从主内存嗅探和cas不断循环无效交互导致总线带宽达到峰值,
volatile 关键字,你真的理解吗?_Star’s Tech Blog-CSDN博客_总线嗅探机制
1.2 内存屏障
在linux中也有内存屏障,用的是写屏障 , 它保证了 修改数据和更新指针 之前的线程安全
Java中的内存屏障是什么 - xyyyn - 博客园 (cnblogs.com)
以下只描述jdk1.8中的内存屏障
硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是CPU缓存,如L1,L2等
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略 ,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:
- 在每个volatile写操作前插入一个StoreStore屏障;
- 在每个volatile写操作后插入一个StoreLoad屏障;
- 在每个volatile读操作后插入一个LoadLoad屏障;
- 在每个volatile读操作后再插入一个LoadStore屏障。
再逐个解释一下这几个屏障。注:下述Load代表读操作,Store代表写操作
- LoadLoad:禁止读和读的重排序
- StoreStore:禁止写与写的重排序
- LoadStore:禁止读和写的重排序
- StoreLoad:禁止写和读的重排序 , 它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
Java中的内存屏障是什么_月月鸟的博客-CSDN博客_java 内存屏障
对于连续多个volatile变量读或者连续多个volatile变量写,编译器做了一定的优化来提高性能,比如:
第一个volatile读;
LoadLoad屏障;
第二个volatile读;
LoadStore屏障
总之,屏障能少就少,毕竟叠加屏障时,有些屏障就是多余的了
为什么volatile读前面没有屏障?
答: 按理来说volatile的读写前后都需要加屏障, 但是volatile读前没有加
因为JMM认为读比写的概率要高,所以在volatile写后面加屏障次数就会少,性能更好
从汇编看Volatile的内存屏障 - SegmentFault 思否
更多思考
java并发编程(二)-volatile写操作前为什么不加LoadStore屏障_公众号:大臭-CSDN博客_loadload屏障
JDK8开始,Java在Unsafe类中提供了三个内存屏障函数。
public final class Unsafe{ *** public native void loadFence(); public native void storeFence(); public native void fullFence(); *** }
在JDK9中对JDK定义的三种内存屏障与理论层面划分的四类内存屏障之间的对应进行了说明:
- loadFence = LoadLoad+LoadStore
- storeFence = StoreStore+LoadStore
- fullFence = StoreStore+LoadStore+StoreLoad
由于不同的CPU架构不同,重排序的策略不同,所提供的内存屏障也有差异。???
2. final
2.1 final关键字的知识点
- final成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。final变量一旦被初始化后不能再次赋值(引用不可改变)。
- 本地变量必须在声明时赋值。 因为没有初始化的过程
- 接口中声明的所有变量本身是final的。类似于匿名类
- 在匿名类中所有变量都必须是final变量。java中的String类和Integer类都是final类型的。
- final方法不能被重写, final类不能被继承 , final方法调用时使用的是invokespecial指令。
- final和abstract这两个关键字是反相关的,final类就不可能是abstract的。
- final方法在编译阶段绑定,称为静态绑定(static binding)。
- 将类、方法、变量声明为final能够提高性能,这样JVM就有机会进行估计,然后优化。
final方法的好处:
- 提高了性能,JVM在常量池中会缓存final变量
- final变量在多线程中并发安全,无需额外的同步开销
- final方法是静态编译的,提高了调用速度
- final类创建的对象是只可读的,在多线程可以安全共享
Java中static、final、static final的区别(转) - EasonJim - 博客园 (cnblogs.com)
深入理解final关键字 - 简书 (jianshu.com)
2.2 内存屏障
对于 final 域,编译器和处理器要遵守两个重排序规则:
在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(加了一个storestore屏障)
初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。(加了一个LoadLoad 屏障), 仅针对处理器
就是说 用 对象的引用,和使用对象中的值 这两个操作不能重排序, 先读对象引用再读final域
本来这两种操作是存在间接依赖的,大部分处理器都不会重排序,但是有小部分处理仍会重排序(比如 alpha 处理器)
总结, 从构造函数修改了final域,是能保证线程"安全"的, 构造函数的执行和赋值不能重排序; 构造函数中的final在初始化未完成前,是不可见的
final关键词在各种处理器下的语义:
深入理解final关键字 - 简书 (jianshu.com)
final的内存语义以及jvm的bug… - why技术 - 博客园 (cnblogs.com)
2.3 案例
- java8 的时间类
LocalDateTime
等时间类就是用final来保证的线程安全,包括它的格式化类
它整个类都是final的,每次操作LocalDateTime
对象都是返回一个新的对象,当然也运用了final对构造函数的一个禁止重排序规则
高并发之——SimpleDateFormat类的线程安全问题和解决方案_冰河的专栏-CSDN博客
string 类型
所以string是线程安全的,它每次操作都会返回一个新的对象(不可变的)
Java String类为什么是final的?
为了实现字符串池
为了线程安全
为了实现String可以创建HashCode不可变性
它创建的时候HashCode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
为什么说String是线程安全的 - 咸咸海风 - 博客园 (cnblogs.com)
3. synchronized
3.1 初识
synchronized
的作用主要有三:
- (1)、原子性:所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 被
synchronized
修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。 - (2)、可见性:可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。 synchronized对一个类或对象加锁,这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。
- (3)、有序性:有序性值程序执行的顺序按照代码先后执行。 synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
synchronized详解 - 三分恶 - 博客园 (cnblogs.com)
首先需要明确的一点是:Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。
还有一点需要注意的是,我们常听到的类锁其实也是对象锁。
Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。
synchronized 是非公平锁,可以重入。
synchronized与锁 · 深入浅出Java多线程 (redspider.group)
synchronized不能保证代码块内指令重排序, 这也是为什么双检锁中要加volatile
阿里面试:Java的synchronized 能防止指令重排序吗?_root-CSDN博客
synchronized 是公平锁吗?可以重入吗?详细的来说说 synchronized
3.2 锁原理
Synchronized主要有三种用法:
(1)、修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() { //业务代码 }
(2)、修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态
synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态synchronized
方法,是允许的,不会发生互斥现象,因为访问静态synchronized
方法占用的锁是当前类的锁,而访问非静态synchronized
方法占用的锁是当前实例对象锁。
synchronized void staic method() {
//业务代码
}
- (3)、修饰代码块 :指定加锁对象,对给定对象/类加锁。
synchronized(this|object)
表示进入同步代码库前要获得给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 当前 class 的锁
synchronized(this) {
//业务代码
}
简单总结一下:
synchronized
关键字加到 static
静态方法和 synchronized(class)
代码块上都是是给 Class 类上锁。
synchronized
关键字加到实例方法上是给对象实例上锁。
// ① 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// ② 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this) {
// code
}
}
// ③ 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
//④ 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this.getClass()) {
// code
}
}
//⑤ 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
从效果上来看
①和② 是等价的, 实例方法是被new出来的对象使用,所以synchronize修饰在方法上和锁定this(当前使用这个方法的人,就是new出来的对象嘛)是一样的.
④和⑤是等价的, static方法由类调用,所以④和⑤是一样的,这时锁住的对象是class对象
我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果
synchronized
关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。
3.2.1 synchronized 同步语句块原理
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
javap -c -s -v -l SynchronizedDemo.class
查看汇编代码
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置, monitorexit
指令则指明同步代码块的结束位置。**
当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有权。
第二个 monitorexit 是来处理异常的,正常情况下第一个 monitorexit 之后会执行后面指令,而该指令转向的就是 23 行的return,也就是说正常情况下只会执行第一个 monitorexit 释放锁,然后返回。而如果在执行中发生了异常,第二个 monitorexit 就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个
ObjectMonitor
对象。另外,
wait/notify
等方法也依赖于monitor
对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify
等方法且这些方法在Object对象就有,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执行 monitorexit
指令后,将锁计数器减 1,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
Synchronized原理 - 掘金 (juejin.cn)
jvm指令monitorenter,monitorexit与synchronization关键字_Dreamer 科技-CSDN博客_monitorexit
3.2.2 synchronized 修饰方法原理
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
汇编之后
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
简单总结一下:
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取。
3.3 synchronized同步概念
3.3.1. Java对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
synchronized
用的锁是存在Java对象头里的。
Hotspot 有两种对象头:
- 数组类型,如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头
- 非数组类型:如果对象是非数组类型,则用2字宽存储对象头。
对象头由两部分组成
- Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
- Klass Pointer:类型指针指向它的类元数据的指针。
64 位虚拟机 Mark Word 是 64bit,在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。
扩展阅读: JVM中的Java对象头 - 知乎 (zhihu.com)
3.3.2 监视器(Monitor)
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
- MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
- MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;
那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现。
3.4 synchronized优化
从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
实际上HotSpot JVM 是支持锁降级的
降级的目的和过程:
因为BasicLocking的实现优先于重量级锁的使用,JVM会尝试在SWT的停顿中对处于“空闲(idle)”状态的重量级锁进行降级(deflate)。这个降级过程是如何实现的呢?我们知道在STW时,所有的Java线程都会暂停在“安全点(SafePoint)”,此时VMThread通过对所有Monitor的遍历,或者通过对所有依赖于MonitorInUseLists值的当前正在“使用”中的Monitor子序列进行遍历,从而得到哪些未被使用的“Monitor”作为降级对象。
可以降级的Monitor对象:
重量级锁的降级发生于STW阶段,降级对象就是那些仅仅能被VMThread访问而没有其他JavaThread访问的对象。
3.4.1 偏向锁
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。偏向锁使用了一种等到竞争出现才释放锁的机制,一旦出现竞争,就会撤销偏向锁,并加轻量级锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;
偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:
- 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
- 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
- 唤醒被停止的线程,将当前锁升级成轻量级锁。
所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
-XX:UseBiasedLocking=false
3.4.2 轻量级锁
引入轻量级锁的主要目的是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。若自旋达到一定次数后仍拿不到锁,则会升级为重量级锁,
jvm采用 适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。 具体值与jvm和操作系统有关
也有自旋次数,默认是10次
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成 功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
由图可知, 线程1先拿到轻量级锁,导致线程2自旋升级为重量级锁并阻塞,线程1解锁时发现锁已升级,就用重量级锁的方式释放锁,然后唤醒那些阻塞的线程
3.4.3 重量级锁
重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。
前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
Owner:获得锁的线程称为Owner
!Owner:释放锁的线程
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到Contention List的队列的队首,然后调用park
函数挂起当前线程。
当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive
即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定能获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。
如果线程获得锁后调用Object.wait
方法,则会将线程加入到WaitSet中,当被Object.notify
唤醒后,会将线程从WaitSet移动到Contention List或EntryList中去。需要注意的是,当调用一个锁对象的wait
或notify
方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。
3.4.4 总结锁的升级流程
每一个线程在准备获取共享资源时: 第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。
第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。
第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。
第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。
第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。
另一角度理解:
HotSpot VM采用三中不同的方式实现了对象监视器——Object Monitor,并且可以在这三种实现方式中自动切换。
偏向锁通过在Java对象的对象头markOop中install一个JavaThread指针的方式实现了这个Java对象对此Java线程的偏向,并且只有该偏向线程能够锁定Lock该对象。(偏向锁)
但是只要有第二个Java线程企图锁定这个已被偏向的对象时,偏向锁就不再满足这种情况了,然后呢JVM就将Biased Locking切换成了Basic Locking(基本对象锁)。(轻量级锁)
Basic Locking使用CAS操作确保多个Java线程在此对象锁上互斥执行。如果CAS由于竞争而失败(第二个Java线程试图锁定一个正在被其他Java线程持有的对象),这时基本对象锁因为不再满足需要从而JVM会切换到膨胀锁 - ObjectMonitor。(重量级锁)
不像偏向锁和基本对象锁的实现,重量级锁的实现需要在Native的Heap空间中分配内存,然后指向该空间的内存指针会被装载到Java对象中去。这个过程我们称之为锁膨胀。
3.4.5 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
// java.lang.StringBuffer#append(java.lang.String)
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
每个StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会 “逃逸” 到 concatString()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁消除: 如果加了锁的,但是发现没人会和它竞争,这把锁就会被消除
3.4.6 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
上述代码中连续的append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展 (粗化)到整个操作序列的外部,以上述代码为例,就是扩展到第一个 append()操作之前直至最后一个 append()操作之后,这样只需要加锁一次就可以了。
锁粗化: 发现我们的频繁在一个对象上加锁/释放锁, 就会把这把锁(作用域)放大,全部包起来,一次加锁一次解锁就够了
9 synchronized与锁 · 深入浅出Java多线程 (redspider.group)
synchronized详解 - 三分恶 - 博客园 (cnblogs.com)
扩展阅读: 死磕Synchronized底层实现–概论 · Issue #12 · farmerjohngit/myblog (github.com)