Java

Synchronized的几个灵魂拷问

2020-10-04  本文已影响0人  千淘萬漉

一、synchronized的简单介绍

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

二、synchronized应用

1.synchronized使用场景

2.synchronized使用的注意事项:

3.synchronized的常见问题

三、synchronized原理

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

1、关于Java对象头与Monitor

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

头对象结构 说明
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对象),其工作流程大致如下:

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

2、synchronized字节码语义

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

我们主要需要关注如下:

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

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

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

3、synchronized方法的底层原理

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

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

上一篇下一篇

猜你喜欢

热点阅读