Synchronized 关键字使用、底层原理

  • 小编 发布于 2019-12-01 01:08:45
  • 栏目:科技
  • 来源:程序员的充电站
  • 9058 人围观

在并发编程中存在线程安全问题,主要原因有:

1.存在共享数据

2.多线程共同操作共享数据

关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。

synchronized关键字最主要的三种使用方式

  • 修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁.访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。即给当前类加锁,会作用于类的所有对象实例
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
  • synchronized应用--双重校验锁实现对象单例(线程安全)

    public class Singleton {
    
     private volatile static Singleton uniqueInstance;
    
     private Singleton() {
     }
    
     public static Singleton getUniqueInstance() {
     //先判断对象是否已经实例过,没有实例化过才进入加锁代码
     if (uniqueInstance == null) {
     //类对象加锁
     synchronized (Singleton.class) {
     if (uniqueInstance == null) {
     uniqueInstance = new Singleton();
     }
     }
     }
     return uniqueInstance;
     }
    }

    注:uniqueInstance需要用volatile防止指令重排

    原因:uniqueInstance = new Singleton 在JVM中需要分三步执行:

    1. 分配uniqueInstance内存空间
    2. 初始化uniqueInstance
    3. 将uniqueInstance指向分配的内存地址

    JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。

    使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

    synchronized 关键字底层原理总结

    synchronized 关键字底层原理属于 JVM 层面

    1.synchronized 同步语句块的情况

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

    查看其字节码文件:


    Synchronized 关键字使用、底层原理

    synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

    2.synchronized 修饰方法的的情况

    public class SynchronizedDemo2 {
     public synchronized void method() {
     System.out.println("synchronized 方法");
     }
    }

    查看其字节码文件:


    Synchronized 关键字使用、底层原理

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

    JDK1.6 之后的底层优化

    DK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销

    锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率

    ①偏向锁

    引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉

    ② 轻量级锁

    倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作

    轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁

    ③ 自旋锁和自适应自旋

    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

    互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

    一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋

    自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来更改

    另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了

    ④ 锁消除

    锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

    ⑤ 锁粗化

    原则上,我们再编写代码的时候,总是推荐将同步快的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

    大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。

    synchronized同步概念

    在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:


    Synchronized 关键字使用、底层原理

  • 实例数据:存放类的属性数据信息,包括父类的属性信息
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
  • 对象头Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度
  • Synchronized用的锁就是存在Java对象头

    Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)

    Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键

    Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。下图是Java对象头 无锁状态下Mark Word部分的存储结构(32位虚拟机):


    Synchronized 关键字使用、底层原理

    Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据

    1.当对象没有被当成锁的时候,这就是一个普通对象,MarkWord记录对象的hashcode,锁标志位为01,是否偏向锁为0

    2.当对象被当作同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是是否是偏向锁标志位为1,前23bit记录抢到了锁的线程id,表示进入偏向锁的状态

    3.当线程A再次试图获取到锁时,JVM发现同步锁对象的标志位为01,是否偏向锁标志位为1,这是属于偏向锁状态,MarkWord中记录的线程id是线程A自己的id,表示线程A已经获取这个偏向锁,可执行同步锁的代码

    4.当线程B试图获取锁时,JVM发现同步锁处于偏向状态,但是MarkWord中线程id记录的不是B线程id,则B线程会先用CAS操作试图获取锁。如果抢锁成功,就把MarkWord中的线程id改为B的id,代表B线程获得了这个偏向锁,可以执行同步代码,如果抢锁失败,执行下一步

    5.偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁升级为轻量级锁,JVM会在当前线程栈中开辟一块单独空间,里面保存的是指向对象锁的MarkWord的指针,同时对象锁的MarkWord中保存指向这片空间的指针,上述两个操作均为CAS操作。如果保存成功,代表线程抢到了同步锁,就把MarkWord中的锁标志位改为00,可以执行同步锁代码。如果失败,执行下一步

    6.轻量级锁抢锁失败,JVM自旋,自旋锁不是一种锁状态,代表不断重试

    7.自旋重试之后依然失败,同步锁升级至重量级锁,锁标志位改为10

    转载请说明出处:五号时光网 ©