实现原理

在Java中每个对象都可以做为锁,synchronized在Java中,不同的表现形式,会持有不同的锁

  1. 同步方法----(对象实例)
  2. 静态同步方法 ----(类的class)
  3. 同步方法块 ----(传入的对象)
public class SynchronizedTest {
    public synchronized void test1(){

    }

    public void test2(){
        synchronized (this){

        }
    }
}

<span data-type="color" style="color:rgb(85, 85, 85)"><span data-type="background" style="background-color:rgb(255, 255, 255)">利用javap工具查看生成的class文件信息来分析Synchronize的实现  </span></span>

Synchronize-1_thumb-1.jpg | center | 633x513

如上可以看出同步的代码块,被表示为了monitor enter和 monitor exit,同步方法在这里看不出,但是同步方法在VM字节码层面并没有特别的指令来表示同步方法,而是在class的方法表中将access_flag中的synchronized字段设置为1,来表示该方法是同步方法,并使用同步方法的实例对象或者class在JVM内部对象表示Klass作为锁对象。

任何一个对象都会与之对应一个monitor,当他被持有以后,他将处于锁定状态。线程执行到monitor enter的时候,会尝试获取monitor的所有权,即尝试获取锁

Java对象头、Monitor

Synchronized用的锁是存在对象头里的,对象头是什么呢?<span data-type="color" style="color:rgb(85, 85, 85)"><span data-type="background" style="background-color:rgb(255, 255, 255)">Hotspot虚拟机的对象头包含两部分数据:Mark word(标记字段)、klass pointer(类型指针),其中klass pointer是指向元数据的指针,虚拟机就是通过这个指针来确定对象的是哪个Class的实例。</span></span>
Mark word 是存储自身对象运行时的数据,他是实现轻量级锁和偏向锁的关键

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

25bit4bit1bit2bit
对象的HashCode对象的分代年龄是否是偏向锁<span data-type="color" style="color:#F5222D">锁标志位</span>

对象头信息是与对象自身定义数据无关的额外储存成本,考虑到虚拟机的空间效率Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储更多的数据,它会根据对象的状态复用自己的存储空间
变化状态如下(32位虚拟机)

11111111111_2_thumb-1.jpg | center | 747x201

monitor

<span data-type="color" style="color:#F5222D">什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象</span>
<span data-type="color" style="color:#F5222D">所有的Java对象都是天生的Monitor,每个java对象都有成为Monitor的潜质,每个Java对象都带着一把看不见的锁,它叫内部锁或者Monitor锁。</span>
<span data-type="color" style="color:#F5222D">monitor是线程私有的数据结构,每一个线程都有一个monitor record列表,同时还有一个全局可用列表。每个被锁住的对象都会和一个monitor关联(个人理解:每个对象被当做锁,被线程获取到的时候都会与monitor进行关联)</span>
<span data-type="color" style="color:#F5222D">(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段来存放拥有该锁的线程的唯一标识,标识了该锁被这个线程占用。</span>

44444_thumb-1.png | center | 347x479

Owner:初始时为NULL,表示当前没有任何线程拥有该monitor record,当前线程成功拥有该锁后保存该线程的唯一表示,释放锁后又设置为NULL
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有锁住monitor record失败的线程(个人理解:阻塞所有试图获取锁失败的线程)。
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
Nest:用来表示重入锁的计数
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每次释放锁前将全部陷入阻塞的线程唤醒,锁每次只能被一个线程竞争到,所以会引起不必要的上下文切换。(阻塞——就绪——竞争失败——阻塞),从而导致性能严重下降,Candidate只有两种可能的值,0:表示没有需要唤醒的线程,1:表示要唤醒一个继任线程来竞争锁。

若还不明白monitor,请参考下图

image.png | left | 827x252


锁优化

jdk 1.6的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:<span data-type="color" style="color:#F5222D">无所状态、偏向锁状态、轻量级锁状态、重量级锁状态</span>,他们会随着竞争的激烈而逐渐升级。
但是锁可以升级不可以降级,这种策略是为了提高获得锁和释放锁的效率。

自旋锁

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

<span data-type="color" style="color:#F5222D">什么是自旋锁?</span>

所谓自旋锁就是,在线程在竞争锁失败,不会立刻将线程挂起,而是让线程做一段无意义的代码循环(自旋)

自旋等待不能代替阻塞,虽然它避免了线程切换带来的开销,但是它占用了处理器的时间。假如自旋时,其他线程很快就把锁释放掉了,这时候自旋效率会很高,但是持锁线程迟迟无法释放锁,此时自旋线程会占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)都必须有一个限度,如果自旋超过了定义的时间仍没有获取到锁,则应该被挂起。
自旋锁在JDK1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,而在JDK1.6中默认开启,同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;
如果通过参数-XX:preBlockSpin来调整自旋次数,会带来诸多不便(诸多不便是指这种固定的自旋次数),假如我设置了10次的自旋次数,但是在自旋低2次的时候,其他持锁线程就已经释放了锁,这个时候就很尴尬,于是JDK6引入了自适应的自旋锁,让虚拟机会变的越来越聪明。

适应自旋锁

JDK6中引入了更加聪明的自旋锁,即自适应自旋锁。就是意味着自旋次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那么是怎么智能的自旋的呢?线程如果自旋成功,那么下次时,JVM会认为,既然上次都成功了,那么这次也有可能成功,于是会允许自旋次数增加,反之,对于某个锁,自旋一直都是失败的,JVM会认为没必要在这个锁上白费力气,会减少甚至省略自旋过程,直接挂起,节省性能。

锁消除

为了保证数据的完整性,有时我们会对代码进行同步控制,但是在有些情况下,JVM会检测到不可能存在共享数据竞争问题,这时JVM会对同步锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁、释放锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据分析来确定,但是对于我们来说还不清楚么?我们明明知道这块代码不可能存在数据竞争还会在上面加同步吗?
但有时候程序并不是我们所想的那样,我们虽然没有显示的使用锁,但是我们在使用JDK一些内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add方法:

public void test1(){
    Vector<String> vector = new Vector<String>();
    for(int i=0;i<10;i++){
        vector.add("javen"+i);
    }
    System.out.println(vector);
}

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

锁粗化

在编程中我们可能往往在使用锁的时候,尽量将锁的粒度细化,这样可以缩小需要上锁的范围,提高线程的并发速度。如果存在锁的竞争,等待中的线程能更快的拿到锁去执行。
大多数情况下,上述的锁细化是正确的。但是,但是!如果一系列的加锁解锁操作,会导致不必要的性能损耗,所以引入了锁粗化的概念
锁粗化的概念比较好理解,就是将多个连续的加锁、解锁连接到一起,形成一个更大范围的锁,如上面实例中Vector在add操作的时候都需要进行加锁操作,JVM检测到对同一个对象Vector连续加锁、解锁操作,会自动合并成一个更大范围的加锁、解锁操作,即加解锁操作会被移到for循环之外。

轻量级锁

引入轻量级锁的主要目的是在大多没有多线程竞争的前提下,减少传统的重量级锁所带来的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁升级为轻量级锁,则会城市获取轻量级锁,其步骤如下:

获取锁
  1. 判断当前对象是否处于无锁状态(hashcode、0、01)(若是忘记了这个是什么(Mark Word),请参考下图),若是,则JVM首先将在当前线程栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。否则执行步骤3
  2. JVM利用CAS操作尝试将对象的MarkWord更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示轻量级锁状态),执行同步操作;如果失败则执行步骤(3)
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的,则执行同步代码块,否则只能说明该锁对象已经被其他线程枪战了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程会进入阻塞状态

image.png | left | 734x90

释放锁

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

  1. 取出在获取轻量级锁保存在Lock record中的Displaced Mark Word中的数据
  2. 用CAS操作将取出的数据替换到当前对象的Mark Word中,如果成功,则说明卡释放锁成功,否则执行(3)
  3. CAS失败(个人理解:被线程2修改了锁的状态为重量级所致),说明有其他线程尝试过改锁,则需要在释放该锁的时候唤醒被挂起的线程。
    对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内不会存在竞争的”,如果打破这个依据,则除了升级重量级锁互斥开销以外,还有CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

下图是轻量级锁的获取和释放过程

image.png | left | 827x803

偏向锁

引入偏向锁主要目的是:为了在无和县城竞争的情况下尽量减少不必要的轻量级锁执行路径,上面的轻量级锁的加锁解锁操作时需要依赖多次CAS原子指令,那么偏向锁是如何减少不必要的CAS操作呢?我们可以查看Mark Word的解构就明白了,只需要检查是否为偏向锁、锁标识为01及ThreadID即可,处理流程如下:

获取锁
  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是则执行步骤(5),否则执行步骤(3)
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换成当前线程ID,否则执行步骤(4)
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点(safepoint)时,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块
  5. 执行同步代码块

__膨胀过程__:当前线程执行CAS获取偏向锁失败(这一步是偏向锁的关键),表示在该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁所有权。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,并从偏向锁所有者的私有Monitor Record列表中获取一个空闲的记录,并将Object设置__LightWeight Lock状态__并且Mark Word中的LockRecord指向刚才持有偏向锁线程的Monitor record,最后被阻塞在安全点的线程被释放,进入到轻量级锁的执行路径中,同时被撤销偏向锁的线程继续往下执行同步代码。

释放锁

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

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 车厢偏向锁,恢复到无锁状态(01)或者轻量级所的状态

image.png | left | 827x926

重量级锁

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

主要参考了一下别人文章,其中也有自己的理解,如有错误,希望指出,万分感谢
参考资料:

  1. http://cmsblogs.com/?p=2071
  2. https://blog.csdn.net/u012465296/article/details/53022317
  3. http://www.51gjie.com/java/727.html
Last modification:December 18th, 2018 at 01:37 pm
If you think my article is useful to you, please feel free to appreciate