Synchronized的几个灵魂拷问

一、synchronized的简单介绍

关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代volatile功能)。简单来说概括就是三个特性:

  • 原子性:确保线程互斥的访问同步代码;
  • 可见性:可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到
  • 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”

二、synchronized应用

1.synchronized使用场景

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

2.synchronized使用的注意事项:

  • 若是对象锁,则每个对象都持有一把自己的独一无二的锁,且对象之间的锁互不影响 。若是类锁,所有该类的对象共用这把锁。
  • 一个线程获取一把锁,没有得到锁的线程只能排队等待;
  • synchronized 是可重入锁,避免很多情况下的死锁发生。
  • synchronized 方法若发生异常,则JVM会自动释放锁。
  • 锁对象不能为空,否则抛出NPE(NullPointerException)
  • synchronized 本身是不具备继承性的:即父类的synchronized 方法,子类重写该方法,分情况讨论:没有synchonized修饰,则该子类方法不是线程同步的。
  • synchronized本身修饰的范围越小越好。毕竟是同步阻塞。

3.synchronized的常见问题

  • 同时访问synchronized的静态和非静态方法,能保证线程安全吗?
    不能,两者的锁对象不一样。前者是类锁(XXX.class),后者是this

  • 同时访问synchronized方法和非同步方法,能保证线程安全吗?
    结论:不能,因为synchronized只会对被修饰的方法起作用。

  • 两个线程同时访问两个对象的非静态同步方法能保证线程安全吗?
    结论:不能,每个对象都拥有一把锁。两个对象相当于有两把锁,导致锁对象不一致。(PS:如果是类锁,则所有对象共用一把锁)

  • 若synchronized方法抛出异常,会导致死锁吗?
    JVM会自动释放锁,不会导致死锁问题

  • 若synchronized的锁对象能为空吗?会出现什么情况?
    锁对象不能为空,否则抛出NPE(NullPointerException)

  • 若synchronized的锁对象能为空吗?会出现什么情况?
    锁对象不能为空,否则抛出NPE(NullPointerException)

三、synchronized原理

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

1、关于Java对象头与Monitor

对象在内存中的布局分为三块区域:1、对象头、2、实例数据和3、对齐填充。

  • 实例变量:存放类的属性数据信息
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
  • Java头对象:Mark Word 和 Class Metadata Address 组成
头对象结构 说明
Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

Mark Word 被设计成为一个非固定的数据结构,默认的存储结构如下:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:

32位JVM的Mark Word可能存储4种数据

由synchronized的对象锁,指针指向的是monitor对象(也称为管程或监视器锁)的地址,所以每个对象都存在着一个 monitor 与之关联,monitor是由ObjectMonitor实现的(C++实现的),源码如下:

ObjectMonitor() {
    ... //其他忽略,核心为如下三个
    _count        = 0; //记录个数
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
  }

可以看到ObjectMonitor中有两个队列,_WaitSet 和 _EntryList 用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),其工作流程大致如下:

  • 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合。
  • 当线程获取到对象的monitor 后进入 _Owner 区域,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1
  • 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。
  • 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

2、synchronized字节码语义

将synchronized修饰的同步代码块利用javap反编译后得到字节码如下:

我们主要需要关注如下:

3: monitorenter  //进入同步方法
//..........省略其他  
13: monitorexit   //退出同步方法
14: goto          22
//省略其他.......
19: monitorexit //退出同步方法

monitorenter指令,线程尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

monitorexit指令,线程执行完毕释放锁,过程如下:

  • monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。
  • monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

3、synchronized方法的底层原理

上面讲的是同步代码块的方式,方法级的同步是隐式,无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。

  • 当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后在方法完成( 无论是正常完成还是非正常完成 )时释放monitor。
  • 在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。

 //省略没必要的字节码
  public synchronized void syncTask();
    descriptor: ()V
    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}

四、synchronized的改进与优化

Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。

在Java 6之后Java官方对从JVM层面对synchronized较大优化,引入了轻量级锁和偏向锁,锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。由轻到重的顺序就是:偏向锁-->轻量级锁-->重量级锁。 JDK 1.6 中默认是开启偏向锁和轻量级锁的。

1.偏向锁

适用于:不存在多线程竞争,而且总是由同一线程多次获得。

偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。对于没有锁竞争的场合,偏向锁有很好的优化效果。

2.轻量级锁

适用于:当锁竞争升级了后,有可能每次申请锁的线程都是不相同的,但时线程交替执行同步块的场合,,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁的思想是依赖经验情况,对绝大部分的锁,在整个同步周期内都不存在竞争。

3.重量级锁

轻量级锁失败后,虚拟机为了避免线程在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4.锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

public class StringBufferRemoveSync {

    public void add(String str1, String str2) {
        //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
        //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
        for (int i = 0; i < 10000000; i++) {
            rmsync.add("abc", "123");
        }
    }
}

syncchronized的深度思考

1、面试官:为什么synchronized无法禁止指令重排,却能保证有序性?
首先,最好的解决有序性问题的办法,就是禁止处理器优化和指令重排,就像volatile中使用内存屏障一样。但是synchronized没有使用内存屏障。

在synchronized这边,加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞。所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的。在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义,所有硬件优化的前提都是必须遵守as-if-serial语义(as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变)。

因为有as-if-serial语义保证,单线程的有序性就天然存在了。

2、既然synchronized是"万能"的,为什么还需要volatile呢?
这个是针对DSL的单例模式来谈的,我们知道对singleton使用volatile约束,保证他的初始化过程不会被指令重排。但是synchronized是无法禁止指令重排和处理器优化的。也就是只看Thread1的话,因为编译器会遵守as-if-serial语义,所以这种优化不会有任何问题,对于这个线程的执行结果也不会有任何影响。但是Thread1内部的指令重排却对Thread2产生了影响。

我们可以说,synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。

参考引用


1、深入理解Java并发之synchronized实现原理
2、☆啃碎并发(七):深入分析Synchronized原理

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