深入解析synchronized关键字

1. 由一个问题引发的思考

       线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核CPU以及超线程技术来实现线程的并发执行;第二个是线程的异步化执行相比于同步化执行来说,异步执行能够很好的优化程序的处理能力提升并发的吞吐量。但是,这样也会带来很多的麻烦,来看如下代码:

  public class Demo {
    private static int count = 0;
    public static void inc() {
        try {
            Thread.sleep(1);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果: " + count);
    }
}

多个线程对count同一个变量进行修改就会存在一个数据安全性的问题。
       一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及线程中是如何使用这个对象的。所以如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。
那么如何保证线程并行的安全性?
       问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使得线程的并行变成串行,那么就不会存在这个问题了吧?如果要达到这个目的,可以通过加锁的方法,而且这个锁需要实现互斥的特性。Java中提供加锁的方法就有synchronized关键字。

2. 初识synchronized关键字

利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。

2.1 synchronized关键字的基本语法

   1.对于普通同步方法,锁的是当前实例对象
   2.对于静态同步方法,锁的是当前类的Class对象
   3.对于同步方法块,锁的是Synchonized括号里配置的对象。

不同的修饰类型,代表锁的控制粒度。
synchronized关键字“给某个对象加锁”,示例代码:

public Class MyClass {
    public void synchronized method1() {
        // ...
    }
    public static void synchronized method2(){
        // ...
    }
}

等价于:

public class MyClass { 
    public void method1() { 
        synchronized(this) { 
            // ... 
        } 
    }
    public static void method2() { 
        synchronized(MyClass.class) {
            // ...
        } 
    } 
}

实例方法的锁加在对象myClass上;静态方法的锁加在MyClass.class上。

2.2 从字节码层面看synchronized

2.2.1 同步代码块

先看以下同步代码块的一段代码:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("HelloWorld");
        }
    }
}

查看其字节码:


字节码

关于这两条指令的作用,我们直接参考JVM规范中描述:
monitorenter :

  每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
  1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

  执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,
  如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

2.2.2 同步方法

先看下代码:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反编译得到:


编译结果

       从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

2.3 锁是如何存储的

观察synchronized 的整个语法发现,synchronized(lock) 是基于lock这个对象的生命周期来控制锁的粒度的,那么是不是锁的存储和这个lock对象有关呢?

2.3.1 对象在内存中的布局

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)
对象的两种内存布局:

对象的两种内存布局

对象的两种内存布局

探究JVM源码实现
当我们在Java代码中,使用new创建一个对象实例的时候,(hotspot虚拟机)JVM层面会创建一个instanceOopDesc 对象。
Oop模型

       Hotspot 虚拟机采用 OOP-Klass 模型来描述 Java 对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass 用来描述对象实例的具体类型。Hotspot 采用instanceOopDesc 和 arrayOopDesc 来 描述对象 头,arrayOopDesc 对象用来描述数组类型。instanceOopDesc 的定义在 Hotspot 源 码 中 的instanceOop.hpp 文件中,另外,arrayOopDesc 的定义对应 arrayOop.hpp。
instanceOop.hpp

从 instanceOopDesc 代码中可以看到 instanceOopDesc继承自 oopDesc,oopDesc 的定义载 Hotspot 源码中的oop.hpp 文件中

oop.hpp

       在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata,_mark 表示对象标记、属于 markOop 类型,也就是 Mark Word,它记录了对象和锁有关的信息,_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针。
MarkWord
在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,代码如下:
markOop.hpp

Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 种情况
Mark Word里存储的数据

2.3.2 为什么任何对象都可以实现锁?

       Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
       线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。在 hotspot 源码的markOop.hpp 文件中,可以看到下面这段代码:


markOop.hpp

多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。

3. synchronized 锁的升级

       在分析 markword 时,提到了偏向锁、轻量级锁、重量级锁。在分析这几种锁的区别时,我们先来思考一个问题使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

3.1 偏向锁

3.1.1 偏向锁的基本原理

       当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了

3.1.2 偏向锁的获取

  1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
  2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
    a) 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
    b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且 把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
  3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
    a) 如果相等,不需要再次获得锁,可直接执行同步代码块
    b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

3.1.3 偏向锁的撤销

       偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设 置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后
继续执行同步代码块在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而
会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁

偏向锁.png

3.2 轻量级锁

3.2.1 轻量级锁的加锁过程

(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示。
线程堆栈与对象头的状态

(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。


线程堆栈与对象头的状态

5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

3.2.2 轻量级锁的解锁过程

       轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁


轻量级锁及膨胀流程图.png

3.3 重量级锁

synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。


重量级锁的加锁的基本流程

总结:

synchronized特点:保证内存可见性、操作原子性
synchronized影响性能的原因:
1、加锁解锁操作需要额外操作;
2、互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态与内核态的切换的性能代价是比较大的)

参考来源:
《Java并发编程的艺术》
《深入理解Java虚拟机》
https://www.cnblogs.com/RDaneelOlivaw/p/13970242.html
https://mp.weixin.qq.com/s?__biz=MzI3NzM2OTQ5Mg==&mid=2247484280&idx=1&sn=8de305338c5ab348c3e2a784084e4306&chksm=eb660483dc118d95e9bcde15a01103f818ed2fd399989f36dc2d57740a305e91cf986d4f5a64&scene=21#wechat_redirect

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351

推荐阅读更多精彩内容