synchronized关键字及wait()/notify()/notifyAll()详解

目录

概念

可重入性

具体用法

字节码实现

同步代码块

同部方法

对象内存布局

对象头

Monitor

synchronized加锁过程

notify()/wait()/notifyAll()

API介绍

wait()/notify()过程

Synchronized的优化

轻量级锁

锁膨胀

自旋优化

偏向锁

锁消除

锁粗化


概念

JDK官网中对synchronized关键字有如下定义,synchronized关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误,如果一个对象对多线程是可见的,那么对该对象的所有读或者写都将通过同步的方式来进行。具体表现如下:

  • synchronized关键字提供了一种锁机制,能够确保共享变量的互斥访问,从而防止数据不一致问题的出现。
  • synchronized关键字包括monitor enter和monitor exit两个JVM指令,它能够保证在任何时候任何线程执行到monitor enter成功之前都必须从主内存中获取数据,而不是从缓存中,在monitor exit运行成功之后,共享变量被更新后的值必须刷入主内存
  • synchronized的指令严格遵守java happens-before规则,一个monitor exit指令之前必定要有一个monitor enter

可重入性

 synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。


具体用法

synchronized修饰的三种对象分别为:静态方法,成员函数和静态代码块。

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

字节码实现

 synchronized总的来说有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。

同步代码块

反编译命令: javap -v SynTest.class

从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

同部方法

该标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,直到该锁被释放。


对象内存布局

 要理解synchronized锁原理,首先要理解Java的对象头和Monitor,在Jvm中对象是分三部分存在的:对象头,实例数据,对齐填充。

对象头

  • Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)。详情如下图所示

  • Class Metadata Address(类型指针)

类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • Array length

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

Monitor

Monitor被翻译为监视器,每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。在hotspot虚拟机中,通过ObjectMonitor类来实现monitor。


synchronized加锁过程

  • 开始Monitor中Owner为null
  • 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只有一个Owner
  • 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2执行完同步代码块的内容,然后通知EntryList中等待的线程来竞争锁,竞争时是非公平的

notify()/wait()/notifyAll()

在虚拟机规范中存在一个wait set(wait set又被称为线程休息室)的概念,至于该wait set是怎样的数据结构,JDK官方并没有给出明确的定义,不同厂家的JDK有着不同的实现方式,甚至相同的JDK厂家不同的版本也存在差异,但是不管怎样线程调用了某个对象的wait方法之后都会被加入与该对象monitor关联的wait set 中,并且释放monitor的所有权。

                                                                                                            摘自《Java高并发编程详解》

API介绍

  • object.wait() 让进入object监视器的线程到waitSet等待
  • object.notify()在object上正在waitSet等待的线程中挑一个唤醒
  • object.notifyAll()让object上正在waitSet等待的线程全部唤醒

wait()/notify()过程

  • Thread-2作为Owner线程的持有者发现条件不满足,调用wait方法,即可进入WaitSet变为WAIZTING状态,并释放锁资源
  • BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
  • BLOCKED线程会在Owner线程释放锁是被唤醒
  • WAITING线程会在Owner线程调用notify或者notifyAll时被唤醒,但唤醒后并不意味着立刻获取锁,仍需进入EntryList重新竞争

Synchronized的优化

从Java5到Java6,HotSpot进行了各种锁优化技术,如自旋锁,锁消除,锁膨胀,轻量级锁,重量级锁等。

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程 访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

轻量级锁对使用者是透明的,语法仍然是synchronized。

工作过程如下:

  • 在代码即将进入同步块的时候,如果此时对象没有被锁定(锁标志位为“01”状态),虚拟机首先将当前线程的栈桢中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

  • 让锁记录中Object reference指向对象,并尝试用cas替换Object的Mark Word,将Mark Word的值存入锁记录

  • 如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,如下所示:

  • 如果cas失败,分两种情况
    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀,锁的标志位改为“10”,此时Mark Word中存储的就是指向重量级锁的指针。
    • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数

  • 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

  • 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
    • 成功:则解锁成功
    • 失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁的解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为对此对象上加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

这时Thread-1加轻量级锁失败(Mark word已经是00,不是无锁状态,所以失败),进入锁膨胀流程

即为Object对象申请Monitor锁,让Object指向重量级锁地址,同时让Monitor中Owner指向Thread-0,然后自己进入Monitor的EntryList BLOCKED中

当Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败,这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,并唤醒EtryList中BLOCKED线程。

自旋优化

重量级锁竞争时,对性能影响最大的就是阻塞问题,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力,JDK1.4中引入了自旋锁优化,如果当前线程自旋成功(即这时候持锁线程已经推出了同步块,释放了锁),当前线程就可以避免阻塞

  • 自旋成功的情况

  • 自旋失败

  • 如果线程中锁被占用的时间很长,自旋只会白白浪费CPU资源,如果自旋超过了限定的次数仍然没有成功获取锁,就会使用传统的方式去挂起线程。
  • 在Java6之后自旋锁是自适应的,如果对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋。
  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
  • Java7之后不能控制是否开启自旋锁

偏向锁

轻量级锁在没有竞争时(只有本线程),每次重入仍然要执行CAS操作,因此Java6中引入了偏向锁来进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头之后发现这个线程ID是自己的就标识没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程持有。

static final Object obj = new Object();
    public static void m1(){
        synchronized (obj){
            // 同步块 A
            m2();
        }
    }
    public static void m2(){
        synchronized (obj){
            // 同步块 B
            m3();
        }
    }
    public static void m3(){
        synchronized (obj){
            // 同步块 c
        }
    }

轻量级锁和偏向锁对比如下:

一旦出现另外一个线程去尝试获取这个锁,偏向模式马上宣告结束,根据当前对象是否处于锁定的状态决定是否撤销偏向(偏向模式为0),撤销后标志位恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态。

该过程理解为,刚开始Object对象无锁,Thread-0执行到同步代码时给Object加偏向锁,在运行过程中,如果Thread-1过来尝试获取锁(这里暗示Object可能会其他线程占有),则把Object升级为轻量级锁,如果Thread-1再次来抢锁,则把Object升级为重量级锁。

ps:面对Thread-1过来抢锁,最后都要把锁升级为重量级锁。

锁消除

虚拟机即时编译器在运行时,对被检测到不可能存在共享数据竞争的锁进行消除。

锁粗化

如果jvm检测到有一串零碎的操作都对同一个对象加锁,将会把锁粗化到整个操作外部,如循环体。

参考资料:《深入理解Java虚拟机》

                   《Java并发78讲》

                    《Java高并发编程详解》

                     https://hujinyang.blog.csdn.net/article/details/82228321

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值