转载

啃碎并发(七):深入分析Synchronized原理

记得开始学习Java的时候,一遇到多线程情况就使用synchronized,相对于当时的我们来说synchronized是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。但是, 随着学习的进行我们知道synchronized是一个重量级锁,相对于j.u.c.Lock,它会显得那么笨重,以至于我们认为它不是那么的高效而慢慢摒弃它

诚然, 随着Javs SE 1.6对synchronized进行的各种优化后,synchronized并不会显得那么重了 。下面来一起探索synchronized的实现机制、Java是如何对它进行了优化、锁优化机制、锁的存储结构和升级过程。

1 基本使用

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。 Synchronized的作用主要有三个

  1. 原子性 :确保线程互斥的访问同步代码;
  2. 可见性 :保证共享变量的修改能够及时可见;
  3. 有序性 :有效解决重排序问题;

从语法上讲, Synchronized可以把任何一个非null对象作为"锁" ,在HotSpot JVM实现中,锁有个专门的名字: 对象监视器(Object Monitor)

Synchronized总共有三种用法:

  1. 当synchronized作用在实例方法时, 监视器锁(monitor)便是对象实例(this)
  2. 当synchronized作用在静态方法时, 监视器锁(monitor)便是对象的Class实例 ,因为Class数据存在于永久带, 因此静态方法锁相当于该类的一个全局锁
  3. 当synchronized作用在某一个对象实例时, 监视器锁(monitor)便是括号括起来的对象实例

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

2 同步原理

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

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

package com.paddx.test.concurrent;

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

查看编译后结果:

啃碎并发(七):深入分析Synchronized原理
  1. monitorenter:每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    1. 如果monitor的进入数为0 ,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
    2. 如果线程已经占有该monitor ,只是重新进入,则进入monitor的进入数加1;
    3. 如果其他线程已经占用了monitor ,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  2. monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者 。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过上面两段描述,我们应该能很清楚的看出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!");
    }
}
复制代码

查看编译后结果:

啃碎并发(七):深入分析Synchronized原理

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

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

其实本质上没有区别, 只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成

3 同步概念

3.1 Java对象头

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

Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit), 但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度 。Java对象头具体结构描述如下:

啃碎并发(七):深入分析Synchronized原理

Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。下图是Java对象头Mark Word部分的存储结构(32位虚拟机):

啃碎并发(七):深入分析Synchronized原理

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率, Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间 ,也就是说, Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据

啃碎并发(七):深入分析Synchronized原理

在64位虚拟机下, Mark Word是64bit大小的 ,其存储结构如下:

啃碎并发(七):深入分析Synchronized原理

对象头的最后两位存储了锁的标志位, 01是初始状态,未加锁 ,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。 偏向锁存储的是当前占用此对象的线程ID而轻量级则存储指向线程栈中锁记录的指针 。从这里我们可以看到,“锁”这个东西, 可能是个锁记录+对象头里的引用指针 (判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较), 也可能是对象头里的线程ID (判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。

啃碎并发(七):深入分析Synchronized原理

3.2 监视器(Monitor)

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步 ,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

  1. MonitorEnter指令:插入在同步代码块的开始位置 ,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
  2. MonitorExit指令:插入在方法结束处和异常处 ,JVM保证每个MonitorEnter必须有对应的MonitorExit;

那什么是Monitor?可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质, 因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁

在代码进入同步块的时候, 如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录”的空间,用于存储锁对象的Mark Word的拷贝 ,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。

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

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

监视器有两种同步方式: 互斥与协作 。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

什么时候需要协作? 比如:

一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。

啃碎并发(七):深入分析Synchronized原理

如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。

注意:

当一个线程释放监视器时, 在入口区和等待区的等待线程都会去竞争监视器 ,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。 只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区 ,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。

4 锁的优化

JDK 1.6对锁的实现引入了大量的优化,如: 自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁 等技术来减少锁操作的开销。

锁主要存在四种状态,依次是: 无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态 ,他们会随着竞争的激烈而逐渐升级。 注意锁可以升级不可降级 ,这种策略是为了提高获得锁和释放锁的效率。

4.1 自旋锁

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

所以引入自旋锁,何谓自旋锁?

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

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

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

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

4.2 适应性自旋锁

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

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之, 如果对于某个锁,很少有自旋能够成功 ,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

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

4.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内部的加锁操作消除。

4.4 锁粗化

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

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

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:

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

4.5 偏向锁

引入偏向锁主要目的是: 为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径 。轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。 那么偏向锁是如何来减少不必要的CAS操作呢 ?首先我们看下无竞争下锁存在什么问题:

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

只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

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

偏向锁引入的一个重要问题是,在多争用的场景下,如果另外一个线程争用偏向对象,拥有者需要释放偏向锁,而释放的过程会带来一些性能开销,但总体说来偏向锁带来的好处还是

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

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;
啃碎并发(七):深入分析Synchronized原理

4.6 轻量级锁

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

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

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

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中 ,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁 ,则需要在释放锁的同时需要唤醒被挂起的线程。

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

啃碎并发(七):深入分析Synchronized原理

4.7 重量级锁

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。 但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间 ,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”

5 锁的优劣

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;如果出现了其他线程竞争则偏向锁就会升级为轻量级锁,如果其他线程通过一定次数的CAS尝试没有成功则进入重量级锁,在这种情况下进入同步代码块就要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。所以使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于HotSpot作者发现的“大多数锁只会由同一线程并发申请”的经验规律。

啃碎并发(七):深入分析Synchronized原理

6 其他参考

http://www.cnblogs.com/mengheng/p/3491304.html http://www.cnblogs.com/paddix/p/5405678.html https://blog.csdn.net/fei33423/article/details/30316377 https://www.jianshu.com/p/c5058b6fe8e5 https://blog.csdn.net/chen77716/article/details/6618779

原文  https://juejin.im/post/5b4eec7df265da0fa00a118f
正文到此结束
Loading...