0%

Java-synchronized的实现原理与应用

阅读更多

1 前言

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重要了。这些优化包括轻量级锁以及偏向锁等等

2 重量级锁

首先来看一下利用synchronized实现同步的基础:Java中每一个对象都可以作为锁。具体表现为以下三种形式

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是synchronized括号里配置的对象

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性(同步代码块结束后从工作内存刷新到主内存中)

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

当退出或者抛出异常时必须要释放锁,synchronized代码块能自动保证这一点

2.1 重量级锁的实现

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁(无论以何种方式退出同步块,都必须释放锁)。那么锁到底存在哪里呢?锁里面会存储什么信息呢?

从JVM规范中可以看到synchronized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者实现细节不一样。代码块同步是使用monitorentermonitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM中并没有说明,但是方法的同步同样可以使用这两个字节码来实现

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁

2.2 为什么称为重量级

重量级锁是使用操作系统互斥量来实现的。Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统内核的帮忙,这就要从用户态转换到内核态,因此状态转换需要花费很多的处理器时间,对于代码简单的同步块(如被synchronized修饰的get或set方法)状态转换消耗的时间有可能比用户代码执行的时间还要长,所以说synchronized是Java语言中一个重量级的操作。所以JVM的研究人员在1.6的时候花费了大量的时间来优化重量级锁,于是在1.6中出现了轻量级锁,偏向锁,锁消除,适应性自旋锁,锁粗化(自旋锁在1.4就有,只不过默认的是关闭的,JDK 1.6是默认开启的),这些操作都是为了在线程之间更高效的共享数据,解决竞争问题

3 Java对象头

synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字节宽(Word)存储对象头。如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如下表所示

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果当前对象是数组)
  • Class Metadata Address指向一个Klass对象Klass对象Class对象在JVM内部的表示方式,包含了Java class的所有信息,包括注解,构造方法,字段,方法,内部类等等。个人认为这个元数据指针就是为了实现Obejct#getClass()方法,即每个Java对象都能直接访问到其所属类型的信息

Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下表所示

锁状态 25bit 4bit 1bit是否偏向锁 2bit锁标志位
无锁状态 对象的hashCode 对象分代年龄 0 01

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否偏向锁 锁标志位
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥量(重量级锁)的指针 10
GC标记 11
偏向锁 线程ID Epoch 对象分代年龄 1 01

4 锁的优化策略

4.1 锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除,锁消除可以节省毫无意义的请求锁/释放锁的时间。锁消除的依据是逃逸分析的数据支持(逃逸分析的另一用处就是让对象在栈上而非堆中分配空间以提高效率)

如果不存在竞争,为什么还需要加锁呢?变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样:我们虽然没有显式使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作

4.2 锁粗化

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

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

锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

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

5 锁的优化与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁""轻量级锁",在Java SE 1.6中,锁一共有四种状态,级别从低到高依次是:无锁状态偏向锁状态轻量级锁状态重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁之后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

5.1 重量级锁

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

5.2 轻量级锁

"轻量级"是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下(这个前提并不准确,看下面解释),减少传统的重量级锁使用产生的性能消耗

上面说到:轻量级锁适用的场景是:线程交替执行同步块。其实这种说法并不准确。事实上,轻量级锁采用了一种循环+CAS操作的方式进行加锁解锁操作,循环的次数有限制,意味着在这有限的时间内不断地自旋尝试获取锁。如果在这段自旋时间内成功获取到锁,那么其开销是要小于先阻塞然后唤醒的。但是如果在有限的循环次数内,或者说有限的时间内无法获取到锁,那么此时就需要升级成重量级锁,然后阻塞当前线程,避免其一直自旋占用大量的CPU资源

因此轻量级锁的适用的场景是:多线程交替执行同步块代码时,线程之间不存在竞争,或者线程执行同步块代码的速度非常快

获取锁的过程

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

释放锁的过程

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中
    • 如果成功,则说明释放锁成功
    • 否则执行(3)
  3. 如果CAS操作替换失败,此时Mark Word中存放的是指向重量级锁(Monitor,即系统互斥量)的指针,那么对象头中的Mark Word中数据的恢复将由重量级锁的释放来完成。此时需要在释放轻量级锁的同时唤醒被挂起的线程

5.3 偏向锁

我们首先回顾一下轻量级锁的引入是为了提升在没有线程竞争(不存在竞争,或者存在竞争但是同步块执行的效率非常高)的情况下执行同步代码的效率。那么还有一种特殊的情况:始终只有一个线程在执行同步块,在这种情况下,即便使用轻量级锁也是需要多个CAS操作的,所以也有一部分开销,于是JVM研究人员又引入了另一种锁即偏向锁来适用这种情况。偏向锁中的偏就是偏心的"偏",它的意思是让这个锁始终偏向第一个获取它的线程,如果接下来的执行过程中,该锁没有被其他线程获取则持有偏向锁的线程将永远不需要再进行同步

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径(CAS原子指令)。因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可

获取锁过程

  1. 检测Mark Word是否为可偏向状态(锁标识位01,偏向锁标志位1)
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID
    • 如果是,则执行步骤(5)
    • 否则执行步骤(3)
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁(注意,这里的CAS所提供的原值就是0,即没有偏向线程的id,因此当第二个线程执行该CAS操作时必然是失败的)
    • 竞争成功(唯有第一个进行CAS的线程才能成功),将Mark Word的线程ID替换为当前线程ID,执行步骤(5)
    • 否则执行步骤(4)
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块
  5. 执行同步代码块

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

  1. 先暂停持有偏向锁的线程,检查持有偏向锁的线程是否活着
    • 如果线程不处于活动状态,则将对象头设置成无锁状态,并且将偏向标志位设置为0,表示不可偏向
    • 如果线程仍然活着,则将对象头设置成轻量级锁状态(锁标志位00),并且让当前获取偏向锁的线程重新获取一下轻量级锁(因为此时可能正在执行同步代码块,必须保证没有其他线程能够获取升级后的轻量级锁)

另一方面,偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现且尝试一定次数后仍失败时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量级锁,如下图所示

总结一下:偏向锁只适用于在只有一个线程执行同步代码块的情况,如果程序中大部分锁总是被不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下有时候禁用偏向锁反而可以提高性能。

5.4 总结

偏向锁

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

轻量级锁

  • 优点竞争的线程不会阻塞,提高了程序的响应速度
  • 缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU
  • 场景:追求响应时间,锁占用时间很短

重量级锁

  • 优点:线程竞争不使用自旋,不会消耗CPU
  • 缺点:线程阻塞,响应时间缓慢
  • 场景:追求吞吐量,锁占用时间较长

6 参考

  • 《Java并发编程的艺术》