Synchronized实现原理和锁优化

 

Synchronized的三种用法

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性 
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础。

synchronized 常见的三种用法如下:

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号里面的对象

只有同个类的类锁之间、同个对象的对象锁之间才会互斥。

类锁和对象锁之间不互斥。

通过如下代码来分析下synchronized 获取的是哪个对象的锁

public class SynTest {
    private static List<String> list = new ArrayList<String>();
    //当前实例的锁
    public synchronized void add1(String s){
        list.add(s);
    }
    //SynTest.class 锁
    public static synchronized void add2(String s){
        list.add(s);
    }
    //SynTest.class 锁
    public void add3(String s){
        synchronized(SynTest.class){
            list.add(s);
        }
    }
    //当前实例的锁
    public void add4(String s){
        synchronized(this){
            list.add(s);
        }
    }
}


普通同步方法,锁是当前实例对象 
add1 方法是synchronized的第一种用法,因为它是普通同步方法,所以获取当前实例的锁。 
add2 方法是synchronized的第二种用法,因为它是静态同步方法,所以获取SynTest.class的锁。 
add3 方法是synchronized的第三种用法。指定锁:SynTest.class。 
add4 方法是synchronized的第三种用法。指定锁:this(当前实例)。

结论:

add1和add4方法的锁都是 当前实例,所以add1和add4 可以实现方法互斥 
add2和add3方法的锁都是SynTest.class,所以add2和add3可以实现方法互斥。 
**注意:**add1和add2两个synchronized是不互斥的,因为他们不是同一把锁。只有同一把锁才会互斥。

Synchronized 原理

我们通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:

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

反编译结果:

反编译

关于这两条指令的作用,我们直接参考JVM规范中描述:

monitorenter :

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor's entry count is zero, then tries again to gain ownership.

这段话的大概意思为:

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

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
monitorexit: 

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

这段话的大概意思为:

执行monitorexit的线程必须是objectref所对应的monitor的所有者

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

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

我们再来看一下同步方法的反编译结果:

源代码:

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

反编译结果:

反编译

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

运行结果解释

有了对Synchronized原理的认识,再来看上面的程序就可以迎刃而解了。

代码段2结果:

虽然method1和method2是不同的方法,但是这两个方法都进行了同步,并且是通过同一个对象去调用的,所以调用之前都需要先去竞争同一个对象上的锁(monitor),也就只能互斥的获取到锁,因此,method1和method2只能顺序的执行。

代码段3结果:

虽然test和test2属于不同对象,但是test和test2属于同一个类的不同实例,由于method1和method2都属于静态同步方法,所以调用的时候需要获取同一个类上monitor(每个类只对应一个class对象),所以也只能顺序的执行。

代码段4结果:

对于代码块的同步实质上需要获取Synchronized关键字后面括号中对象的monitor,由于这段代码中括号的内容都是this,而method1和method2又是通过同一的对象去调用的,所以进入同步块之前需要去竞争同一个对象上的锁,因此只能顺序执行同步块。

Java对象头

synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式,可能值如下所示:

这里写图片描述 
无锁状态 : 对象的HashCode + 对象分代年龄 + 状态位001

Monitor Record

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

Monitor Record
Owner
EntryQ
RcThis
Nest
HashCode
Candidate

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

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

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

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

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

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

总结

Synchronized是Java并发编程中最常用的用于保证线程安全的方式,其使用相对也比较简单。但是如果能够深入了解其原理,对监视器锁等底层知识有所了解,一方面可以帮助我们正确的使用Synchronized关键字,另一方面也能够帮助我们更好的理解并发编程机制,有助我们在不同的情况下选择更优的并发策略来完成任务。对平时遇到的各种并发问题,也能够从容的应对。

===================

以下内容转载自:JAVA synchronized实现原理以及其中锁优化的归纳总结

synchronized内部实现原理其实上面都有讲了,转载这篇文章主要是锁优化讲的挺好。
在这里我先对下面内容做一个解读:

偏向锁: 偏向锁认为,获取锁的总是同一个线程

轻量级锁:轻量级锁认为,大多数情况下不会出现锁竞争,即使出现了锁竞争,获取锁的线程也能很快释放锁。获取不到锁的线程可以通过自旋等待一段时间,不会陷入阻塞状态。

偏向锁的流程

在锁对象的对象头中有一个ThreadId字段,如果字段是空,第一次获取锁的时候就把自身的ThreadId写入到锁的ThreadId字段内,把锁内的是否是偏向锁状态位置设置为1。下次获取锁的时候,直接查看ThreadId是否和自身线程Id一致,如果一致就认为当前线程已经取得了锁无需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。

轻量级锁流程

线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并把对象头中的Mark Word复制到锁记录空间中,官方称为Displaced Mark Word,然后线程尝试持有CAS把对象头中的Mark Word替换成指向锁记录的指针,如果成功,当前线程获得锁,如果失败表示有其他线程竞争锁,当前线程尝试使用自旋来获取锁,获取失败就升级成重量级锁。

===============================================

在java中存在两种锁机制,分别是synchronized和Lock。Lock接口和实现类是JDK5添加的内容,而synchronized在JDK6开始提供了一系列的锁优化,下面总结一下synchronized的实现原理和涉及的一些锁优化机制

1.synchronized内部实现原理

synchronized关键字在应用层的语义是可以把任何一个非null对象作为锁,当synchronized作用在方法上时,锁住的是对象实例(this),作用在静态方法上锁住的就是对象对应的Classs实例,由于Class实例存在于永久代,因此静态方法锁相当于类的一个全局锁,当synchronized作用在一个对象实例上,锁住的就是一个代码块 
ps:在HotSpot JVM中 monitor被称作对象监视器

当有多个线程同时请求某个对象监视器时,对象监视器会设置几种状态来区分请求的线程:

  1. Contention List:所有请求锁的线程被首先放置在该竞争队列中
  2. Entry List:Contention List 中有机会获得锁的线程被放置到Entry List
  3. Wait Set:调用wait()方法被阻塞的线程被放置到Wait Set中
  4. OnDeck:任何一个时候只能有一个线程竞争锁 该线程称作OnDeck
  5. Owner:获得锁的线程成为Owner
  6. !Owner:释放锁的线程

转换关系如下图:

这里写图片描述

新请求锁的线程被首先加入到Contention List中,当某个拥有锁定线程(Owner状态)调用unlock之后,如果发现Entry List为空就从ContentionList中移动线程到Entry List中

Contention List和Entry List的实现方式

1.Contention List虚拟队列

Contention List并不是一个真正的Queue,而是一个虚拟队列,原因是Contention List是由Node和next指针逻辑构成,并不存在一个Queue的数据结构。Contention List是一个后进先出的队列,每次添加Node时都会在队头进行,通过CAS改变第一个节点的指针在新增节点,同时设置新增节点的next指向后继节点,而取线程操作发生在队尾。

只有Owner线程才能从队尾取元素,线程出队操作无竞争,避免CAS的ABA问题

这里写图片描述

2.Entry List

EntryList与ContentionList逻辑上同属等待队列,ContentionList会被线程并发访问,为了降低对 ContentionList队尾的争用,而建立EntryList。Owner线程在unlock时会从Contention List中迁移线程到Entry List,并会指定Entry List中某个线程(一般是第一个)为OnDeck线程。Owner线程并不是把锁传递给 OnDeck线程,只是把竞争锁的权利交给OnDeck,OnDeck线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”

OnDeck线程在获得锁后变成Owner线程,无法获得锁则会继续留在Entry List中,但是在Entry List中的位置不会发生改变。如果Owner线程被wait方法阻塞,就转移到WaitSet队列;如果在某个时刻被notify/notifyAll唤醒就会再次被转移到Entry List中

2.sychronized中的锁机制

介绍锁机制之前先介绍看一下同步的原理

同步的原理

JVM规范规定JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处, JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

java对象头的概念

java对象的内存布局包括对象头,数据和填充数据

数组类型的对象头使用3个字宽存储,非数组类型使用2个字宽存储,一个字宽等于四字节(32位)

这里写图片描述

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位

这里写图片描述

在运行期间随着锁标志位的变化存储的数据也会变化

这里写图片描述

64位JVM下Mark Word大小的64位的 存储结构如下

这里写图片描述

偏向锁的锁标记位和无锁是一样的,都是01,但是有单独一位偏向标记设置是否偏向锁。

轻量级锁00,重量级锁10,GC标记11,无锁 01.

1.自旋锁 Spin Lock

处于Contention List,Entry List和Wait Set中的线程均属于阻塞状态,阻塞操作由操作系统完成(在Linux系统下通过pthread_mutex_lock函数),线程被阻塞后进入内核调度状态,这个会导致在用户态和内核态之间来回切换,严重影响锁的性能。

解决上述问题的方法就是自旋,原理是: 
当发生争用时,若Owner线程能在很短的时间内释放锁,则那些正在争用线程可以稍微等等(自旋),在Owner线程释放锁之后,争用线程可能会立即获得锁,避免了系统阻塞

但是Owner运行的时间可能会超出临界值,争用线程自旋一段时间无法获得锁的话会停止自旋进入阻塞状态。 
因此自旋锁对于执行时间很短的代码块有性能提高。

线程自旋的时候可以执行几次for循环,可以执行几条空的汇编指令,目的是占着CPU不放,等待获取锁的机会。 
因此自旋的时间很重要,如果过长会影响整体性能,过短达不到延迟阻塞的目的。HotSpot认为最佳的时间是一个线程上下文切换的时间,但是目前只实现了通过汇编暂停集合CPU周期。

其他自旋锁的优化:

  • 如果平均负载小于CPU的个数则一直自旋
  • 如果超过CPU个数一半个线程正在自旋,则后面的线程会直接阻塞
  • 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  • 如果CPU处于节点模式就停止自旋
  • 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  • 自旋时会适当放弃线程优先级之间的差异

那synchronized实现何时使用了自旋锁? 
答案是在线程进入ContentionList时,也即第一步操作前。 
线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平。 
还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。自旋锁由每个监视对象维护,每个监视对象一个

2.偏向锁(Biased Lock)

主要解决无竞争下的锁性能问题

按照之前HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如等待队列的CAS操作)。CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象偏向这个线程,之后的多次调用可以避免CAS操作。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,把锁恢复到标准的轻量级锁。

流程是这样的 偏向锁->轻量级锁->重量级锁

简单的加锁机制:

每个锁都关联一个请求计数器和一个占有她的线程,当请求计数器为0时,这个锁可以被认为是unheld的,当一个线程请求一个unheld的锁时,JVM记录锁的拥有者,并把锁的请求计数加1,如果同一个线程再次请求这个锁是,请求计数器就会加一,当线程退出synchronized块时,计数器减一。当计数器为0时,释放锁。

偏向锁的流程

在锁对象的对象头中有一个ThreadId字段,如果字段是空,第一次获取锁的时候就把自身的ThreadId写入到锁的ThreadId字段内,把锁内的是否是偏向锁状态位置设置为1。下次获取锁的时候,直接查看ThreadId是否和自身线程Id一致,如果一致就认为当前线程已经取得了锁无需再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。

但是偏向锁也有一个问题,就是当锁有竞争关系的时候,需要解除偏向锁,使锁进入竞争的状态

这里写图片描述

对于偏向锁的抢占问题,一旦偏向锁冲突,双方都会升级会轻量级锁。之后就会进入轻量级的锁状态

这里写图片描述

偏向锁使用的是一种等到竞争出现才释放锁的机制,所以在其他线程尝试获取竞争偏向锁时,持有偏向锁的线程才会释放锁,释放锁需要等到全局安全点(在该时间点上没有字节码在执行)

消除偏向锁的过程是: 
先暂停偏向锁的线程,尝试直接切换,如果不成功,就继续运行,并且标记对象不适合偏向锁,锁升级成轻量级锁。

关闭偏向锁: 
偏向锁在jdk6和7中是默认开启的,但是总是在程序启动几秒钟后才激活 
可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay=0 
同时也可以使用参数来关闭偏向锁-XX:-UseBiasedLocking=false

轻量级锁

加锁流程:

线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并把对象头中的Mark Word复制到锁记录空间中,官方称为Displaced Mark Word,然后线程尝试持有CAS把对象头中的Mark Word替换成指向锁记录的指针,如果成功,当前线程获得锁,如果失败表示有其他线程竞争锁,当前线程尝试使用自旋来获取锁,获取失败就升级成重量级锁。

解锁流程:

会使用CAS操作来把Displaced Mark Word替换回对象头,如果成功表示没有竞争发生,如果失败,升级成重量级锁。

这里写图片描述

锁的优缺点比较

偏向锁: 
优点:加锁解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 
缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗 
适用场景:适合只有一个线程访问同步快的场景

轻量级锁: 
优点:竞争的线程不会阻塞,提高程序的响应速度 
缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU 
适用场景:追求响应时间 同步块执行速度非常快

重量级锁: 
优点:线程竞争不适用自旋 不会消耗CPU 
缺点:线程阻塞 响应时间缓慢 
适用场景:追求吞吐量 同步块执行时间长

ps:偏向锁和轻量级锁理念上的区别:

  • 轻量级锁:在无竞争的情况下使用CAS操作去消除同步使用的互斥量
  • 偏向锁:在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了

这里写图片描述

总结

在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、 
偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

  • 锁粗化(Lock Coarsening) 减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
  • 锁消除(Lock Elimination) 通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步快以外被其他线程共享的数据的锁的保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(减少堆上上GC开销)
  • 偏向锁(Biased Locking) 是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟
  • 轻量级锁 (Lightweight Locking) 这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒
  • 适应性自旋(Adaptive Spinning) 当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁 
    (mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁),进入到阻塞状态。

对于synchronized加锁的完整过程描述:

  1. 检查Mark Word里存放的是否是自身的ThreadId,如果是,表示当前线程处于偏向锁,无需加锁就可获取临界资源
  2. 如果不是自身的ThreadId,锁升级,使用CAS来进行切换,新的线程根据MarkWord里现有的ThreadId,通知之前线程暂停,之前线程把MarkWord的内容设置为空
  3. 两个线程都把对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作把共享对象的MarkWord的内容修改为自己新建的记录空间的地址的方式竞争MarkWord
  4. 成功执行CAS的获得资源,失败的进入自旋
  5. 自旋在线程在自旋过程中,成功获得资源则整个状态依然处于轻量级的锁状态
  6. 如果自旋失败进入重量级锁的状态,自旋的线程进行阻塞,等待之前的线程完成并唤醒自己


参考: Java并发编程:Synchronized及其实现原理 - liuxiaopeng - 博客园 
转载自:Synchronized——实现原理、底层优化_WSYW126的博客-CSDN博客_synchronized 的实现原理以及锁优化? 

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值