Java并发指南4Java中的锁-Lock和synchronized

39次阅读

共计 13314 个字符,预计需要花费 34 分钟才能阅读完成。

Java 中的锁机制及 Lock 类

锁的释放 - 获取建立的 happens before 关系

锁是 java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

下面是锁释放 - 获取的示例代码:

class MonitorExample {int a = 0;     public synchronized void writer() {//1        a++;                             //2}                                    //3     public synchronized void reader() {  //4        int i = a;                       //5        ……}                                    //6} 

根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。假设线程 A 执行 writer()方法,随后线程 B 执行 reader()方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为两类:

  1. 根据监视器锁规则,3 happens before 4。
  2. 根据 happens before 的传递性,2 happens before 5。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。

上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

锁释放和获取的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图如下:

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。下面是锁获取的状态示意图:

对比锁释放 - 获取的内存语义与 volatile 写 - 读的内存语义,可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结:

  • 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。
  • 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

锁内存语义的实现

本文将借助 ReentrantLock 的源代码,来分析锁内存语义的具体实现机制。

请看下面的示例代码:

class ReentrantLockExample {int a = 0;ReentrantLock lock = new ReentrantLock(); public void writer() {    lock.lock();         // 获取锁    try {a++;} finally {lock.unlock();  // 释放锁    }} public void reader () {    lock.lock();        // 获取锁    try {int i = a;        ……} finally {lock.unlock();  // 释放锁    }}}

在 ReentrantLock 中,调用 lock()方法获取锁;调用 unlock()方法释放锁。

ReentrantLock 的实现依赖于 java 同步器框架 AbstractQueuedSynchronizer(本文简称之为 AQS)。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,马上我们会看到,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。下面是 ReentrantLock 的类图(仅画出与本文相关的部分):

ReentrantLock 分为公平锁和非公平锁,我们首先分析公平锁。

使用公平锁时,加锁方法 lock()的方法调用轨迹如下:

  1. ReentrantLock : lock()
  2. FairSync : lock()
  3. AbstractQueuedSynchronizer : acquire(int arg)
  4. ReentrantLock : tryAcquire(int acquires)

在第 4 步真正开始加锁,下面是该方法的源代码:

protected final boolean tryAcquire(int acquires) {final Thread current = Thread.currentThread();    int c = getState();   // 获取锁的开始,首先读 volatile 变量 state    if (c == 0) {if (isFirst(current) &&            compareAndSetState(0, acquires)) {setExclusiveOwnerThread(current);            return true;        }    }    else if (current == getExclusiveOwnerThread()) {int nextc = c + acquires;        if (nextc < 0)              throw new Error("Maximum lock count exceeded");        setState(nextc);        return true;    }    return false;}

从上面源代码中我们可以看出,加锁方法首先读 volatile 变量 state。

在使用公平锁时,解锁方法 unlock()的方法调用轨迹如下:

  1. ReentrantLock : unlock()
  2. AbstractQueuedSynchronizer : release(int arg)
  3. Sync : tryRelease(int releases)

在第 3 步真正开始释放锁,下面是该方法的源代码:

protected final boolean tryRelease(int releases) {int c = getState() - releases;    if (Thread.currentThread() != getExclusiveOwnerThread())        throw new IllegalMonitorStateException();    boolean free = false;    if (c == 0) {free = true;        setExclusiveOwnerThread(null);    }    setState(c);           // 释放锁的最后,写 volatile 变量 state    return free;}

从上面的源代码我们可以看出,在释放锁的最后写 volatile 变量 state。

公平锁在释放锁的最后写 volatile 变量 state;在获取锁时首先读这个 volatile 变量。根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变的对获取锁的线程可见。

现在我们分析非公平锁的内存语义的实现。

非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。

使用公平锁时,加锁方法 lock()的方法调用轨迹如下:

  1. ReentrantLock : lock()
  2. NonfairSync : lock()
  3. AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)

在第 3 步真正开始加锁,下面是该方法的源代码:

protected final boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);}

该方法以原子操作的方式更新 state 变量,本文把 java 的 compareAndSet()方法调用简称为 CAS。JDK 文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。

这里我们分别从编译器和处理器的角度来分析,CAS 如何同时具有 volatile 读和 volatile 写的内存语义。

前文我们提到过,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作重排序。

下面我们来分析在常见的 intel x86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。

下面是 sun.misc.Unsafe 类的 compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o, long offset,                                              int expected,                                              int x);

可以看到这是个本地方法调用。这个本地方法在 openjdk 中依次调用的 c ++ 代码为:unsafe.cpp,atomic.cpp 和 atomicwindowsx86.inline.hpp。这个本地方法的最终实现在 openjdk 的如下位置:openjdk-7-fcs-src-b147-27jun2011openjdkhotspotsrcoscpuwindowsx86vm atomicwindowsx86.inline.hpp(对应于 windows 操作系统,X86 处理器)。下面是对应于 intel x86 处理器的源代码的片段:

// Adding a lock prefix to an instruction on MP machine// VC++ doesn't like the lock prefix to be on a single line// so we can't insert a label after the lock prefix.// By emitting a lock prefix, we can define a label after it.#define LOCK_IF_MP(mp) __asm cmp mp, 0  \                       __asm je L0      \                       __asm _emit 0xF0 \                       __asm L0: inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {// alternative for InterlockedCompareExchange  int mp = os::is_MP();  __asm {mov edx, dest    mov ecx, exchange_value    mov eax, compare_value    LOCK_IF_MP(mp)    cmpxchg dword ptr [edx], ecx  }}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要 lock 前缀提供的内存屏障效果)。

intel 的手册对 lock 前缀的说明如下:

  1. 确保对内存的读 - 改 - 写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4,Intel Xeon 及 P6 处理器开始,intel 在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在 lock 前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读 / 写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低 lock 前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义。

经过上面的这些分析,现在我们终于能明白为什么 JDK 文档说 CAS 同时具有 volatile 读和 volatile 写的内存语义了。

现在对公平锁和非公平锁的内存语义做个总结:

  • 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
  • 公平锁获取时,首先会去读这个 volatile 变量。
  • 非公平锁获取时,首先会用 CAS 更新这个 volatile 变量, 这个操作同时具有 volatile 读和 volatile 写的内存语义。

从本文对 ReentrantLock 的分析可以看出,锁释放 - 获取的内存语义的实现至少有下面两种方式:

  1. 利用 volatile 变量的写 - 读所具有的内存语义。
  2. 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

concurrent 包的实现

由于 java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面四种方式:

  1. A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
  2. A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  3. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  4. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。

Java 的 CAS 会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读 - 改 - 写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读 - 改 - 写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读 - 改 - 写操作的原子指令)。同时,volatile 变量的读 / 写和 CAS 可以实现线程之间的通信。把这些特性整合在一起,就形成了整个 concurrent 包得以实现的基石。如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:

  1. 首先,声明共享变量为 volatile;
  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步;
  3. 同时,配合以 volatile 的读 / 写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic 包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent 包的实现示意图如下:

 

synchronized 实现原理

 

记得刚刚开始学习 Java 的时候,一遇到多线程情况就是 synchronized,相对于当时的我们来说 synchronized 是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。但是,随着我们学习的进行我们知道 synchronized 是一个重量级锁,相对于 Lock,它会显得那么笨重,以至于我们认为它不是那么的高效而慢慢摒弃它。
诚然,随着 Javs SE 1.6 对 synchronized 进行的各种优化后,synchronized 并不会显得那么重了。下面跟随 LZ 一起来探索 synchronized 的实现机制、Java 是如何对它进行了优化、锁优化机制、锁的存储结构和升级过程;

实现原理

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

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

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

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

public class SynchronizedTest {public synchronized void test1(){}     public void test2(){synchronized (this){}}}

利用 javap 工具查看生成的 class 文件信息来分析 Synchronized 的实现  

从上面可以看出,同步代码块是使用 monitorenter 和 monitorexit 指令实现的,同步方法(在这看不出来需要看 JVM 底层实现)依靠的是方法修饰符上的 ACC_SYNCHRONIZED 实现。
同步代码块 :monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个 monitorenter 都有一个 monitorexit 与之相对应。任何对象都有一个 monitor 与之相关联,当且一个 monitor 被持有之后,他将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 所有权,即尝试获取对象的锁;
同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 做为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)

下面我们来继续分析,但是在深入之前我们需要了解两个重要的概念:Java 对象头,Monitor。

Java 对象头、monitor

Java 对象头和 monitor 是实现 synchronized 的基础!下面就这两个概念来做详细介绍。

Java 对象头

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

Mark Word。
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit),但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是 Java 对象头的存储结构(32 位虚拟机):
 
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下(32 位虚拟机):

简单介绍了 Java 对象头,我们下面再看 Monitor。

Monitor

什么是 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 表示要唤醒一个继任线程来竞争锁。
摘自:Java 中 synchronized 的实现原理与应用)
我们知道 synchronized 是重量级锁,效率不怎么滴,同时这个观念也一直存在我们脑海里,不过在 jdk 1.6 中对 synchronize 的实现进行了各种优化,使得它显得不是那么重了,那么 JVM 采用了那些优化手段呢?

锁优化

jdk1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

自旋锁

线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整;
如果通过参数 -XX:preBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为 10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是 JDK1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。

适应自旋锁

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

一、自旋锁的概念
首先是一种锁,与互斥锁相似,基本作用是用于线程(进程)之间的同步。与普通锁不同的是,一个线程 A 在获得普通锁后,如果再有线程 B 试图获取锁,那么这个线程 B 将会挂起(阻塞);试想下,如果两个线程资源竞争不是特别激烈,而处理器阻塞一个线程引起的线程上下文的切换的代价高于等待资源的代价的时候(锁的已保持者保持锁时间比较短),那么线程 B 可以不放弃 CPU 时间片,而是在“原地”忙等,直到锁的持有者释放了该锁,这就是自旋锁的原理,可见自旋锁是一种非阻塞锁。
二、自旋锁可能引起的问题:
1. 过多占据 CPU 时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据 cpu 时间片,导致 CPU 资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃 CPU 时间片阻塞;
2. 死锁问题:试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

锁消除

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

锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,LZ 也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。

轻量级锁

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

  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 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

    • *

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

偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。上面提到了轻量级锁的加锁解锁操作是需要依赖多次 CAS 原子指令的。那么偏向锁是如何来减少不必要的 CAS 操作呢?我们可以查看 Mark work 的结构就明白了。只需要检查是否为偏向锁、锁标识为以及 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)或者轻量级锁的状态;
    • *

下图是偏向锁的获取和释放流程 

重量级锁

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

参考资料

  1. 周志明:《深入理解 Java 虚拟机》
  2. 方腾飞:《Java 并发编程的艺术》
  3. Java 中 synchronized 的实现原理与应用

更多内容请关注微信公众号【Java 技术江湖】

一位阿里 Java 工程师的技术小站。作者黄小斜,专注 Java 相关技术:SSM、SpringBoot、MySQL、分布式、中间件、集群、Linux、网络、多线程,偶尔讲点 Docker、ELK,同时也分享技术干货和学习经验,致力于 Java 全栈开发!(关注公众号后回复”Java“即可领取 Java 基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送作者原创的 Java 学习指南、Java 程序员面试指南等干货资源)

正文完
 0