大家好,我是wave,这次来和大家详细的聊一聊Synchronized这个关键字,希望大家看完可以对synchronized关键字有一个非常全面的了解。
Synchronized基本操作
synchronized主要有三种使用方式:
修饰实例方法: 给一个类上的方法添加synchronized关键字,这个锁会作用于这个类当前的实例对象上,进入同步代码前要获得 当前对象实例的锁
public synchronized void method(){
// do something
}
修饰静态方法: 由于静态方法属于类,就相当于给一个类加了锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁
public synchronized static void method(){
// do something
}
修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
public class Solution {
public void method(){
synchronized (this){
//给当前对象加锁
}
synchronized (Solution.class){
//给Solution这个类加锁
}
Object lock = new Object();
synchronized (lock){
//给Object这个对象加锁
}
}
}
synchronized在JDK早期是一个非常重量级的锁,依靠JVM的指令对代码块进行加锁,所以synchronized是JVM层面的锁,而Lock是用Java代码实现的,所以可以看作是API层面的锁。但是后续JDK对synchronized进行了非常多的优化和升级,目前synchronized与Lock的效率已经相差不多了。
synchronized修饰代码块
synchronized修饰代码块是用两个指令monitorenter、monitorexit完成的,口说无凭,我带大家看一看字节码吧~
public class Solution {
public void method(){
synchronized (this){
System.out.println("synchronized修饰代码块");
}
}
}
首先写一段这样的代码,然后可以通过javap来查看它的字节码。如果使用IDEA的话可以经过一点小小的配置就可以在控制台直接输出字节码了,先给大家看看IDEA应该如何配置 File->setting->Tools->External->左上角的+号,里面中间的三个配置分别为: $
JDKPath$
binjavap.exe-verbose -p -c $
FileClass$
$
OutputPath$
然后编译一下程序,按运行旁边的绿色小锤子就可以了,再右键打开External tool找到刚才配置的工具,就可以在控制台打印出字节码了。



现在我说synchronized加在同步代码块上是用JVM指令来实现的就没啥问题了吧~当JVM执行到monitorenter的时候线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
synchronized修饰方法
public class Solution {
public synchronized void method(){
System.out.println("synchronized 修饰方法");
}
}

可以看到修饰一个方法就会有一个ACC_SYNCHRONIZED标识,代表这个方法是同步方法。
synchronized锁升级
JDK1.6的时候对synchronized进行了大量的优化,synchronized的锁现在有三种形态:偏向锁、轻量级锁、重量级锁。有这三种锁可以适应不同的应用场景,在几乎没有并发的时候偏向锁效率高于轻量级锁与重量级锁,在并发不是特别高的时候锁升级为了轻量级锁,轻量级锁的效率又会高于重量级锁。
那么偏向锁、轻量级锁、重量级锁又是什么? 偏向锁:我认为偏向锁的关键就是“偏”,偏向于第一个访问的线程。也就是说在无竞争的环境下,有一个线程访问的同步代码块,那么这个锁就会偏向这个线程,下次有线程访问的时候就会判断是不是之前访问过的线程访问,这样就会少一次cas的开销。因为第一次有线程访问同步代码块的时候会用cas把线程id写入mark word中。偏向锁会有一个延迟,程序刚启动的5s内不会出现偏向锁,这个我等会儿给大家证明一下,计算过hashcode值的对象不会加偏向锁,因为对象头没有空间放线程id了。 轻量级锁:轻量级锁体现轻量的点就在于自旋,如果线程访问轻量级锁的同步代码块,会cas判断线程id是否一致,不一致会自旋一定的时间一直进行cas,如果cas成功就还是轻量级锁。如果失败了,然后轻量级锁就会升级为重量级锁。 重量级锁:jvm层面的两个标识,加锁解锁都会阻塞其他线程。
Cas操作有三个参数,一个是旧值的地址,一个是旧值,还有一个是新值,如果从旧值地址取出来的值和旧值相等,那么就把旧值改为新值。 mark word是对象头的组成部分,对象头主要是有mark word与klass word,mark word通常会存放线程id信息、垃圾回收年限、锁的状态这些信息。klass word存放的是一个指针,指向对象的实例,也就是在堆里面的地址。通常这个指针为64bit,但是一般都会压缩成32bit。

从上面这张图中我们主要关注一下最后面的那2bit,这两个bit就代表了锁的状态。所以说一个对象有五种状态,无锁、偏向锁、轻量级锁、重量级锁、GC中(表示正在进行垃圾回收)。 理论上2bit只能表示4种状态,那么为什么会有五种状态呢?细心的朋友就能发现偏向锁前一个bit也是用来表示锁状态的,也就是说还有1bit是用来判断这个锁是否偏向。 所以在对象头中无锁是01,轻量级锁是00,GC是11,重量级锁是10,偏向锁是101

这是一张网上广为流传的锁升级图,看懂这张图绝对面试锁升级可以让面试官刮目相看,但是个人觉得这张图还是有点晦涩难懂。想要更深入的了解锁升级的过程就研究一下这张图吧~(如果看不清其实网上随便搜搜都能搜出来。。。当然也可以在公众号留言:锁升级图)
JOL打印对象头
说了这么多虚无缥缈的东西,能不能直观的看一看什么样的场景下synchronized会有锁升级呢?来,Java中有一个jar包叫jol-core,可以用这里面的API把对象的对象头打印出来。
无锁
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
public class JOLExample1 {
public static void main(String[] args) throws Exception {
A a = new A();
out.println(VM.current().details());
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}

这里例子中对象A并没有加任何锁,所以肯定是无锁状态的,从控制台打印的结果也可以看到,VALUE的值就是对象头的值,前两位打印结果就是01,确实是无锁。怎么样,我没骗你吧~ 可能细心的小伙伴又发现了,我们前面说的是对象头最后两位是代表锁状态,为什么打印出来的又是前两位代表锁状态了呢? 这里涉及到一个计算机中的概念叫大端模式与小端模式,现在的计算机都是小端模式,也就是字数据的低字节则存放在高地址,所以就产生了这样的结果。
偏向锁
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
//没有竞争,理论上是偏向锁
public class JOLExample2 {
static A a;
public static void main(String[] args)throws Exception{
a = new A();
out.println("befre lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() throws InterruptedException {
synchronized (a){
System.out.println("我也不知道要打印什么");
}
}
}
可以看到上面这个例子虽然给A加了synchronized,但是并没有竞争,根据我们面的分析就是偏向锁了,所以打印出来会是101吗?

神奇的发现还是01,表示无锁。为什么呢?这里是因为偏向锁会有一个4s的延迟,修改虚拟机参数或者睡眠5s就可以看到101了 (XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0)
public class JOLExample2 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);//先停一个5s
a = new A();
out.println("befre lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() throws InterruptedException {
synchronized (a){
System.out.println("我也不知道要打印什么");
}
}
}

看到没有,千呼万唤的偏向锁终于出来了
轻量级锁
public class JOLExample3 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);
a = new A();
out.println("befre lock");
new Thread(()->{
sync();
}).start();
Thread.sleep(5000);//保证上面这个线程执行完成
out.println("after lock");
//out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("lock over");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() {
synchronized (a){
try {
out.println(ClassLayout.parseInstance(a).toPrintable());
}catch (Exception e){
}
}
}
}

我们从图中可以看出来,一个线程给a加了锁之后还是偏向锁,因为没有竞争。而有另外一个线程又给a加了锁之后,就会变成轻量级锁了(注意这里其实还是没有竞争),退出synchronized代码块之后又会变成无锁的状态,因为轻量级锁会有一个锁撤销的过程。
重量级锁
public class JOLExample3 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);
a = new A();
out.println("befre lock");
new Thread(()->{
sync();
}).start();
//Thread.sleep(5000);//保证上面这个线程执行完成
out.println("after lock");
//out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("lock over");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() {
synchronized (a){
try {
out.println(ClassLayout.parseInstance(a).toPrintable());
}catch (Exception e){
}
}
}
}

只需要在前面轻量级锁的案例中把睡眠5s注释就可以产生两个线程的竞争,即主线程与新建的线程发生竞争。可以看到打印的结果就是10,表示重量级锁。
synchronized的底层实现
首先来看看它是由那些部分组成的 Wait Set:那些调用wait方法被阻塞的线程放置在这里 contention List:竞争队列,所有请求锁的线程首先被放在竞争队列 entry list:contention中那些有资格的会被移入contention list ondeck:任意时刻,最多有一个线程在竞争资源,该线程为ondeck owner:当前获取到资源的线程
再来详细说一下synchronized的大致工作流程 JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。 Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。 Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。 OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify或者 notifyAll 唤醒,会重新进去 EntryList 中。处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完(Linux 内核下采用pthread_mutex_lock 内核函数实现的)。 Synchronized 是非公平锁。Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
ENDING
看完这些大家应该对synchronized有一个非常全面且深入的了解吧~希望面试官问到你synchronized的时候你能用这篇文章学到的知识“手撕”面试官。
本次分享就到这里结束了,对于这样一篇干货满满的文章不点赞你的心不会痛吗~
学习群成立啦~
往期推荐
原文始发于微信公众号(FingerDance):干货满满的synchronized详解!!!
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/26769.html