美文网首页Java
Synchronized的几个灵魂拷问

Synchronized的几个灵魂拷问

作者: 千淘萬漉 | 来源:发表于2020-10-04 11:02 被阅读0次

一、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原理

相关文章

  • Synchronized的几个灵魂拷问

    一、synchronized的简单介绍 关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行...

  • AQS的几个灵魂拷问

    java并发包下很多API都是基于AQS来实现的加锁和释放锁等功能的,比如ReentrantLock、Reentr...

  • volatile的几个灵魂拷问

    要想讲清楚volatile关键字,这时候就应该主动从内存模型开始讲起,然后说原子性、可见性、有序性的理解,铺垫好这...

  • HashMap的几个灵魂拷问

    之前是写过一篇HashMap的原理文章的,比较基础 java基础之数据结构3(Map篇)[https://www...

  • Mysql的几个灵魂拷问(三)

    今天这篇就来讲讲Mysql中比较高频的锁和事务吧。 一、Mysql锁事 1、锁的类型有哪些呢 总的来说,InnoD...

  • Mysql的几个灵魂拷问(四)

    Mysql前面已经把基础和原理部分铺垫的差不多了,现在要来讲讲的是Sql优化和调优部分了,这个基本是Mysql拷问...

  • Mysql的几个灵魂拷问(二)

    今天这篇主要是针对索引,开篇前先对Mysql数据库的性能有个整体的认识,一般来讲8c16g的数据库qps在1000...

  • Mysql的几个灵魂拷问(一)

    开发对于数据库的了解可不能局限于CURD,数据库的技能复杂度也不是仅仅写几个复杂的sql语句,这个Mysql系列就...

  • Mysql的几个灵魂拷问(五)

    这一篇继续讲SQL的优化问题,在常规应用开发中,Mysql的单表性能都是够用的,从量级来看,一般以整型值为主的表在...

  • 线程池的几个灵魂拷问(二)

    线程池虽然在并发编程里很强大,但线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是...

网友评论

    本文标题:Synchronized的几个灵魂拷问

    本文链接:https://www.haomeiwen.com/subject/hkuwuktx.html