JVM--synchroized以及锁升级

目录

1.synchroized 基本用法

1.1语义

1.2 常见用法

2.同步原理

3.锁的优化

3.1自旋锁

3.2自适应自旋锁

3.3锁消除

3.4锁粗化

3.5偏向锁

3.6轻量级锁


1.synchroized 基本用法

1.1语义

  • 原子性:确保线程互斥的访问同步代码

  • 可见性:保证共享变量的修改能够即时可见,其实通过对Java内存模型中“对一个变量unlock操作之前,必须同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或者assign操作初始化变量值”来保证。

  • 有序性:有效的解决重排问题,即“一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”

    更多解释参考:JVM-- Happens-before(先行发生原则)

1.2 常见用法

      从语法上将,synchronized可以把认可一个非null对象作为“锁”,在HotSpot JVM中,锁有一个专门的名字:对象监视器(object Monitor)

synchronized总共有三种用法

  • 当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);

  • 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class 实例,因为Class 数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;

  • 当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;

注意:synchronized内置锁锁一种对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对临界资源的同步互斥访问;是可重入的,其最大的作用是可以避免死锁。如:

子类同步方法调用了父类同步方法,如果没有可重入的特性,则会发生死锁。

2.同步原理

     数据同步需要依赖锁,那锁的同步又依赖谁?synchroized给出的答案谁在软件层面依赖于JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。

      当一个线程访问同步代码块时,首先是需要获得锁才能执行同步代码,当推出或者抛出异常时必须要要释放锁,那么它是如何实现这个机制的呢?可以看一段简单的代码


public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

查看反编译后结果:

反编译结果

    1.monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

          a.如果monitor的进入数为0,则该线程进入monitor,然后进入数设置为1,该线程即为monitor的所有者;

         b.如果线程已经有该monitor,只是重新进入,则进入monitor的进入数+1;如果其他线程已经用了monitor,则该线程进入阻塞状态,知道monitor的进入数为0,再尝试获取monitor的所有权。

     2.monitorexit:执行monitorexit的西安城必须是objectref 所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0 ,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor所阻塞的线程可以尝试去获取这个monitor的所有权。

monitorexit指令出现了两次,第一次为同步正常退出释放锁;第二次为发生异常退出释放锁

       通过上面两段描述,我们应该很清楚看到synchronized的实现原理,synchronized语义底层是通过一个monitor的对象完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常原因。再来看下同步方法块

package com.paddx.test.concurrent;

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

查看反编译后结果

      从反编译解决来看,方法的同步并没有通过指令monitorenter 和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHROIZED标识符。JVM就死活根据该标识符来实现同步方法的同步。

     当方法调用时,调用指令将会检查方法的ACC_SYNCHROIEZED访问标识是否被设置,如果设置来,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完成后释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

    两种同步方式本质上没有区别,只是方法的同步是一种隐式的方法来实现,无需通过字节码来完成。两个指令的执行时JCVM通过调用操作系统的互斥源于mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

3.锁的优化

        从JDK5引入了现代操作系统增加了CAS原子操作(JDK5中并没有对synchronized关键字做优化,而是体现在J.U.中,所以在该版本concurrent包又更好的性能),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK引进的CASE自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰】操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

     锁主要存在四种状态,依次:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态 ,锁剋从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

    在JDK1.6中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UserBiasedLocking来禁用偏向锁。

3.1自旋锁

    线程的阻塞和唤醒需要CPU从用户态转化为核心态,线程的阻塞和唤醒对CPU来讲锁意见负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒锁非常不值得的。

    所以引入了自旋锁,那什么锁自旋锁?

      所谓的自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已经被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或者睡眠状态。

      自旋锁适用于锁保护的临界区域很小的情况,临界区域很小的话,锁占用的时间就很短,自旋等待而不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CUPU处理器的时间。如果持有锁的线程很宽就释放了锁,那么自旋锁的效率就非常好,反之,自旋的线程就会白白浪费掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要又一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

     自旋锁在JDK1.4.2中引入,默认关闭,但是可以使用-XX:UseSipinning开启,在JDK1.6中默认开启。同时自旋的默认次数锁10,可以通过参数-XX:PreBlockSpin来调整。

       如果通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数会带来诸多不便。假如将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),这时候就会很尴尬。于是JDK1.6引入了自适应的自旋锁,让虚拟机变得越来越聪明。

3.2自适应自旋锁

      JDK1.6 引入了更聪明的自旋锁,即自适应自旋锁。所谓自适应就以为着自旋的次数不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它锁如何进行自适应呢?

在同一个锁对象上,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在允许中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,如果持续100次循环。反之,如果对于某个锁,很少又自旋能给成功,那么在以后要或者这个锁的时候次数会减少甚至省略自旋过程,以免浪费处理器资源。

     有了自适应锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来约准确,虚拟机会变更越来约聪明。

3.3锁消除

      为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,代码要求同步,但JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行消除。

锁消除的判定依据来源于逃逸分析的数据支持

      如果不存在竞争,为什么还要加锁呢?所以锁消除可以节省毫无意义的锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是 也许对于程序员来说这个问题是显而易见的,怎么会明知道不存在数据竞争的代码块前不需要加上同步方法。但是事实上并非如此,虽然没有显示使用锁,但是在使用一些JDK的内置API时,如Stringbuffer\Vector\HashTable等,这时候会存在一些隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){
        vector.add(i + "");
    }

    System.out.println(vector);
}

     在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法VectorTest之外,所以JVM可以大胆的将Vector内部的加锁操作消除。

3.4锁粗化

       原则上,我们在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用中才进行同步,这样做的目的是为了使需要同步的操作数尽可能缩小,如果存在锁竞争,那么等待的线程也能尽快拿到锁。

      在大多数的情况下,上诉观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化:就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

     如上面的示例,vector每次add 的时候都需要加锁操作,JVM检测到同一个对象Vector连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移动到for循环之外。

3.5偏向锁

       偏向锁是JDK6中的重要引进,它的目的锁消除数据在无竞争情况下的同步原语进一步提升程序的运行性能。如果说轻量级锁说在无锁竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无锁竞争的情况下把整个同步都消除掉,连CAS操作都不需要去做了。

      偏向锁:在单线程执行代码块时使用的机制,如果在多线程并发环境下(即线程A尚未执行完同步代码块,线程B发起来申请锁的社区),则一定会转化为轻量级锁或者重量级锁。

       在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。如果并发数较大同时同步代码块执行时间较长,则被多个线程同时访问的概率就很大,就可以使用参数:-XX:-UseBiasedLocking来禁止偏向锁(但这个参数,不能针对某个对象锁来单独设置)。引入偏向锁的主要目的是:为了在没有多线程竞争的情况下尽可能减少不必要的轻量级锁执行路径。因为轻量级锁的加锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。轻量锁锁为了在线程交替执行同步代码块时提高性能,而偏向锁则时在只有一个线程执行同步代码块时进一步提高性能。

      偏向锁是如何减少不必要的CAS操作?首先我们看下无竞争下锁存在什么问题:

      现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁、解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法锁,一旦线程第一次获得来监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是设置个变量,如果发现为true则无需再走各种加锁、解锁流程

      CAS为什么会引入本地延迟?这要从SMP(对称多处理器)架构说起,下图大致表明了SMP的架构

     SMP架构:所有的CPU会共享一条系统总线(BUS),考此总线连接主存。每个核心都有自己的一级缓存,各个核心对于BUS对称分布,因此这种结构成为“对称多处理器”。

    而CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,也就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的结构。

     例如:Core1和Core2可能同时把主内存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使得Core2中L1 Cache对应的值“失效”,而Core2 一旦发现自己L1 Cache中的值失效(称为命中缺失)则会通过总线从内存中加载该地址的最新的值,大家通过总线的来回通信称为 “Cache一致性流量” ,因为总线被涉及为固定的“通信能力”,如果Cache一致性流量过大,总线将称为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标表示减少Cache一致性流量。

      而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

      所以当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁。处理流程如下:

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁表示位为01;

  2. 若为可偏向状态,则检测线程ID是否为当前线程ID,如果是则执行步骤5,否则执行步骤3;

  3. 如果检测线程ID不为当前线程,则通过CAS操作竞争锁,竞争成功则将Mark Word的线程ID替换为当前线程ID,否则执行步骤4;

  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当达到全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。

  5. 执行同步代码块。

       偏向锁的释放采用来一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,而是需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点。其步骤如下:

  1. 暂停拥有偏向锁的线程

  2. 判断锁对象是否还处于被锁定状态,否:则恢复到无锁状态(01),以允许其余线程竞争;是:则挂起持有锁的当前线程,并将执行当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式。

      注意:此处,将当前线程挂起再恢复的过程并没有发生锁转移,仍然在当前线程手中,只是穿插来个“将对象头中的线程ID变更为指向锁记录地址的指针”这么个事。

       当对象进入到偏向状态时Mark Word 大部分的空间(23bit)都用于存储持有锁的线程ID了,这部分空间占用了原油存储对象哈系码的位置,那原来对象的哈系码怎么办?在Java 对语言里面一个对象如果计算过哈系码,就应该一直保持不变,否则很多依赖对象哈系码的API都可能存在出错风险,而作为大多数对象哈系码来源的Object::hashCode()方法,返回的是对象一致性哈系码(Identity Hash Code),这个值是能强制保持不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈系码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈系码后,他就再也无法进入偏向锁状态来;而当一个对象正处于偏向锁状态,又收到需要计算其一致性哈系码请求时,他的偏向状态会被立即撤销,并且锁会彭正为重量级锁。在重量级锁的实现中,对象头指向来重量级锁的位置,达标重量级锁的OjectMonitor类里=又字段可以记录非加锁状态(标志位“01”)下的Mark Word,其中自然可以存储原来的哈希码。

     偏向锁可以提高带有同步但无竞争的程序性能,但它统一说一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁总是被多个不通的线程访问,那偏向模式就是多余的。

3.6轻量级锁

      轻量级锁锁JDK6时假如的新型锁机制,它的名字中的“轻量级” 是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被成为“重量级”锁。不过需要强调一点,轻量级锁并不少用来代替重量级锁的,它的设计初衷锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  1. 在线程进入同步代码块时,如果同步对象锁状态为无锁(锁标志位为“01”状态,是否偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced mark word 。此时线程堆栈与对象头的状态如下图所示

  2. 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果成功则指向4,否则执行3

  3. 如果失败,则意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向了当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象锁,那就直接进入同步块继续执行就可以了,否则说明这个锁对象已经被其他线程抢占了。如果出现两条以上线程争用同一个锁的情况,那么轻量级锁将不再有效,必须要膨胀为重量级锁,锁标识的状态值变为10,此时mark word中存储的就是指向重量级锁(互斥量)的指针,后面等待线程也必须进入阻塞状态

  4. 2成功表示当前线程拥有了这个对象的锁,并且 对象Mark Word的锁标志位(Mark Word最后两个比特)将转变为00,表示对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态图下图所示

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 通过CAS操作尝试把线程中复制的Displayed Mark Word对象替换当前的Mark Word

  2. 如果成功,整个同步过程就执行完成来,恢复到无锁状态(01)

  3. 如果替换失败,说明有其他线程尝试获取该所(此时锁已膨胀),那就需要在释放锁的同时,唤醒被挂起的线程。

      对于轻量级锁,其性能提升的依据是“对于绝大部分锁,在整个生命周期内都是不会存在竞争”,如果打破这个依据则除了互斥的开销外,还要有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

锁升级及膨胀流程图

 

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值