synchronized实现原理

    随着我们学的的进行,我们知道synchronized是一个重量级锁,相对于lock,它会显得笨重,以至于我们认为它不是那么高效而慢慢摒弃它。

1.实现原理

   (1) synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入到临界区,同时它还可以保证共享变量的内存可见性。

   (2) JAVA中每一个对象都可以作为锁,这是synchronized实现同步的基础:

        ① 普通同步方法,锁的是当前实例对象。

        ② 静态同步方法,锁的是当前类的class对象。

        ③ 同步方法块,锁的是括号里面的对象。

   (3)当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?如果我们可以通过反编译后的class文件来分析synchronized的实现原理。

        ① 同步代码块:

            monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。

        ② 同步方法:

            synchronized方法会被翻译成不同的方法调用和返回指令如:invokevirtual、areturn指令,在JVM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在class文件的方法表中将该方法的access_flags字段中的synchronized标志位置为1,标识该方法是同步方法并使用调动该方法的对象或该方法所属的class在JVM的内部对象表示Class作为锁对象。

2.Java对象头

    synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:  Mark Word(标记字段)、Class Pointer(类型指针)。其中Class Point是对象指向它的元数据的指针,虚拟机通过这个指针来确定这个对象时那个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述。

3.Mark Word

    Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等待。Java对象头一般站友两个机器码(在32位虚机中,1个机器码==4字节==32bit),但是如果对象时数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

    对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到不你急的空间效率,Mark Word被设计成一个非固定的数据结构以便在绩效的空间内存存储尽量多的数据,它会根据对象的状态服用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。

4.Monitor

    我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与所有Java中的对象一样,所有的对象都是天生的Monitor,每一个Java对象都有成为Monitor的前置,因为在Java的设计中,每一个Java对象自产生后就带了一把看不见的锁,它叫做内部所或Monitor锁。

    Monitor是线程私有的数据结构,每一个线程都有一个可用Monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时 monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,标识该锁被这个线程占用。

    Owner:初始时为NULL标识当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时重新置为NULL。

    EntryQ:关联一个系统互斥锁(semaphore),阻塞所有视图锁住monitor record失败的线程。

    RcThis:标识blocked或waiting在该monitor record上的所有线程的个数。

    Nest:用来实现重入锁的计数。

    HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

    Candidate:用来避免不必要的阻塞或等待线程环形,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程环形所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:   0:标识没有需要环形的线程   1:标识要环形一个几人线程来竞争锁。

5.锁优化

    JDK 1.6时对synchronized进行了优化,添加了大量的锁技术来减少锁操作的开销如:自旋锁、适应性自旋锁、锁消除、锁粗话、偏向锁、轻量级锁等。

6.自旋锁:

    所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否很快释放,如果获取不到所标记就一直进行尝试获取,不会进入锁池中等待。

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

    自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理的资源而没有做任何有意义的工作,带来性能上的浪费。所以自旋锁需要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

    自旋锁JDK1.4.2中引入,默认关闭,但是可以使用-XX:+UseSping开启,在JDK1.6默认开启。同时自旋的默认字数为10此,可以通过参数-XX:preBlockSpin来调整自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚推出的时候就释放了锁(假如你多自选一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

7.适应自旋锁

    JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会在再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

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

8.锁消除

    为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,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内部的加锁操作消除。

9.锁粗化

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

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

    锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作链接在一起,扩展成一个范围更大的锁。如上面的demo会将加锁、解锁操作,移动到for循环之外。

10.轻量级锁

    引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争片鲜果导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

    (1)获取锁

        ① 判断当前对象是否处于无锁状态,若是,则JVM首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方在这个名字加了一个Displaced的前缀);否则执行步骤③。

        ② JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功标识竞争到所,则将所标记变为00,执行同步操作;如果失败则执行步骤③;

        ③ 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则标识当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,所标记位会变成10,后面的等待线程将会进入阻塞状态。

    (2)释放锁

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

        ① 取出在获取轻量级锁保存在Displaced Mark Word中的数据。

        ② 用CAS操作将去除的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行③。

        ③ 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要环形被挂起的线程。

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

11.偏向锁

    引入偏向锁主要目的是:

        为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作时需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以通过查看Mark Work的结构来理解。只需要检查是否为偏向锁、锁标识位以及ThreadID即可,流程如下:

    (1)获取锁

            ① 检测Mark Word是否为可偏向状态,及是否为偏向锁,所表示位为:01。

            ② 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤⑤,否则执行步骤③。

            ③ 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程④。

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

            ⑤ 执行同步代码块。

    (2)释放锁

            偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(当前时间没有正在执行的代码):

            ① 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态。

            ② 撤销偏向锁,恢复到无锁状态(01)或轻量级锁状态。

12.重量级锁

    重量级锁通过对象内部的监视器(monitor)实现,其中monitor订单本质是依赖于底层操作系统的Mutex Lock,操作系统实现线程之间的切换需要从用户态与内核态的切换,切换成本非常高。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值