In-depth Java synchronized
最先引入并发概念的软件是操作系统,OS 的出现让计算机进入了一个前所未有的时代,而多任务管理让计算机更进一步;多任务管理的 OS 中,最大的价值体现是多个任务之间相互配合完成复杂的事情,这势必会出现多个线程访问同一个资源的情况,比如写同一个文件,当某个线程使用完了共享资源又如何通知其他的线程来执行呢?这里就引出了并发编程的两个核心问题:互斥和同步。互斥是指在同一时刻只允许一个线程访问共享资源;同步是指线程之间如何通信、协作。Java 中提供了很多并发编程的工具,其中有关键字 synchronized 也有 JUC 类库,为 Java 程序员编写并发程序带来很大的便利性。
先说结论:Java 中的 synchronized 和 Lock/Condition 这两种实现的本质都是 Monitor 模型。
Monitor Introduction
什么是 Monitor ? Monitor 翻译成中文一般叫管程,他和 Semaphores 具有同等作用,都可以解决并发编程中的同步和互斥等问题。(下文说什么是 Semaphores 呢?)
In concurrent programming (also known as parallel programming), a monitor is a synchronization construct that allows threads to have both mutual exclusion and the ability to wait (block) for a certain condition to become false. Monitors also have a mechanism for signaling other threads that their condition has been met. A monitor consists of a mutex (lock) object and condition variables. A condition variable essentially is a container of threads that are waiting for a certain condition. Monitors provide a mechanism for threads to temporarily give up exclusive access in order to wait for some condition to be met, before regaining exclusive access and resuming their task.
Monitor 是一种解决同步和互斥的并发编程模型,它将共享资源和对共享资源的操作进行封装,如果要访问共享资源必须先获取 Monitor 上的控制权,而获得控制权的方式是使用类似于 monitor.enter()
这样的行为封装,enter()
提供了对共享资源的实际控制以及维护一些辅助变量的状态,颇具有 OO 的思想。
A monitor is a synchronization mechanism that allows threads to have:
mutual exclusion – only one thread can execute the method at a certain point in time, using locks
cooperation – the ability to make threads wait for certain conditions to be met, using wait-set
Why is this feature called “monitor”? Because it monitors how threads access some resources.
Monitor 的两个重要机制:互斥和同步,为什么叫 monitor,是因为这个同步结构(对象)它监控了线程如何访问共享资源。
只有一个线程可以互斥的访问临界区的代码和数据
在临界区执行的代码可以因为等待条件变量的成立而被阻塞
当条件变量成立时,一个线程可以通知其他的线程,将他们唤醒
Monitor 的核心
共享资源和操作,Monitor 内部维护共享变量(也可以叫做 lock),对外封装获取锁 (enter/acquire) 和释放锁 (exit/release)的操作
Entry set 入口就绪队列,并发进入临界区代码的线程需要在这里排队
条件变量 (condition variables) 和条件等待队列 (condition wait set),可以有多个,当某个条件不满足了会进入条件等待队列等待条件满足后被唤醒
wait 和 notify/notifyAll (signal/signalAll),通知信号,一个线程有办法可以唤醒某个条件下等待的所有线程,让他们进入 entry set 中
以上的代码就是 Monitor 的完整模型,其中条件变量是不受限制的。
上图是对示例代码的补充,这是一个 MESA 模型的 monitor 实现,MESA 模型的主要特点是在 signal 时,当前线程不会立即让出 CPU,而且不保证被唤醒的线程一定可以抢占到 CPU,这个依赖于具体的调度策略,所以在代码实现时会使用 while(buffer is full) {wait(notFull);}
这样的 Loop 的形式。
Monitor 中锁和条件变量是实现的关键,一般加锁我们会使用一个变量(本质上是内存里的一段空间),对该变量的值进行判断,比如 0 和 1,当我们试图将 1 改为 0 成功了,就说明线程获得锁成功,这个操作非常关键,它是能实现多核和单核多线程下并发的重点,那如何实现呢?这个需要由硬件支持,通常情况下 CPU 是支持一些特殊的原子指令的,允许我们在内存上做原子的读-修改-写入操作,比如 test-and-set 指令、compare-and-swap 指令等,这些通常需要推迟到内部锁状态本身的自旋锁定,但这种锁定是非常短暂的。根据不同的实现,原子读-修改-写入指令可能会锁定总线,不让其他核访问,或防止 CPU 中指令的重新排序。那么结合底层的 CAS 原子指令,在多线程竞争 CAS 时,只会有一个线程成功,也就只有一个线程可以获得锁,推广到 Java 的 synchronized,多线程竞争将线程 id CAS 写入被 monitor 对象的对象头中,只有成功的线程才可以进入到临界区,不成功的就要进入被阻塞,进入 entry set。
Reader/Writer 问题
问题描述:一个变量读多写少,需要被多线程访问进行读写,该如何解决?
Semaphores 是一个提供了互斥访问临界区的数据结构,也可以把它理解为原子计数器,Semaphores 支持两种操作,这两种操作都是原语:
P 操作,对 共享计数器执行 -1,当计数器 < 0 时,当前线程进入阻塞状态,等待
V 操作,对共享计数器执行 +1,当计数器 > 0 时,唤醒一个线程
使用 monitor 和 semaphores 分别实现 Reader/Writer 问题
Monitor 的实现
Semaphores 的实现
优缺点
Semaphores 的优点
可以解决并发问题,也相对简单。
Semaphores 的缺点:
Semaphores 需要使用一个共享的全局变量,也就是说在程序的任何地方都可以访问它,所以就有被其他不相干的程序使用的风险,容易导致程序 Bug
semaphore 和被 semaphore 控制的数据之间没有联系,维护代码难度增加
semaphore 被同时用于临界区的互斥和同步,有点耦合
不能控制和保证正确使用 semaphore,有时很难使用并容易出现错误,比如忘记做 P 操作或者 V 操作
Monitor 的优缺点
封装性比较好,易于使用
语言级别支持的 Monitor 可以实现自动的加锁解锁,不容易出现 bug
引入了关注点分离,使用 monitor 隐士的实现了互斥,条件变量作为同步的手段被引入进来,从而让互斥和同步的实现得到了分离
synchronized 基本介绍
synchronized 是 Java 提供的元老级别的实现同步原语和互斥原语的关键字,它可以用来修饰类方法、实例方法和代码块,以此来实现多线程并发读写共享资源的问题。
The Java programming language provides multiple mechanisms for communicating between threads. The most basic of these methods is synchronization, which is implemented using monitors. Each object in Java is associated with a monitor, which a thread can lock or unlock. Only one thread at a time may hold a lock on a monitor. Any other threads attempting to lock that monitor are blocked until they can obtain a lock on that monitor. A thread t may lock a particular monitor multiple times; each unlock reverses the effect of one lock operation.
synchronized 需要引用一个 Java 中的对象,每个对象都会有一个与之关联的 Monitor,这个 Monitor 就是实现同步和互斥的关键,当有线程持有 Monitor 上的锁时,其他线程是无法进入临界区的,只能等待该线程将锁释放。而没有拿到锁的线程会加入到 Monitor 上的一个阻塞队列上,线程状态被设置成 BLOCKED
,当获得锁的线程执行完代码后会自动释放锁,在锁被释放时,会把阻塞队列上的线程移动到就绪队列上等待线程调度,线程状态被设置为 RUNNABLE
。
上面的例子充分体现了 synchronized 如何解决互斥问题,那同步是如何实现的呢?等待-通知机制就是 synchronized 实现多线程之间同步的法宝,Java 中的 Object 对象上有 wait()
, notify()
, notifyAll()
等方法是众所周知的,它们可以在使用 synchronized 的情况下,解决多线程同步的问题,此外,synchronized 利用 Monitor 实现的内部锁会在释放锁时自动的将等待获取锁的线程加入到就绪队列中;调用 wait 和 notify 是一种条件等待场景的使用,比如多线程共享一个 buffer 时,我们可以使用循环不断的轮询 buffer,但这会造成 CPU 空转,浪费资源和降低 CPU 使用率;当然也可以采取 wait-notify 的机制,在触发 buffer 空时,消费线程进入等待状态,在触发 buffer 满时,生产者线程进入等待状态,而当消费线程被激活,消费数据后,可以通知生产者线程可以继续生产了,这样就达成了一种比较有默契的合作。
可见,synchronized 配合 wait/notify 很好的实现了多线程之间的同步和互斥,同步通过 wait 和 notify 机制实现(当然多个线程竞争临界区时,Monitor 可以实现基本的线程同步,但是在需要使用条件变量的场景中就必须配合 wait 和 notify),互斥通过 Monitor 上的锁来实现。
相对于 Monitor 模型,Java 的 synchronized 实现做了简化,synchronized 只支持一个条件等待队列,也就是被引用对象的 wait() 和 notify(),并且 synchronized 阻塞的线程不能被中断,不能超时获取锁等,而 JUC 中各种锁的实现就是对 synchronized 的补充,它增加了更多的灵活性,而 JUC 中锁的实现的本质还是基于 Monitor 模型的。
synchronized 锁的优化
JDK 1.6 对 synchronized 锁做了优化,引入了偏向锁、轻量级锁(自旋锁和自适应自旋锁)等,使得 synchronized 在各种场景下都能有更好的性能。
先试想如果自己要实现锁,该怎么做呢?首先硬件层面的原子性的支持很重要,CPU 指令集为我们提供了 test-and-set 或者 compare-and-swap 的原语支持,能保证在多核 CPU 上安全的去做 CAS 操作,这是前提,调用这个指令需要从用户态切换到内核态,在 OS 层是由系统调用来支持的。
方式 1 while 自旋,直到获取锁为止
有了 CAS 的支持,我们可以采用自旋的方式来实现基本的锁
方式 2 yield + spin 或者 sleep + spin
上面方式的缺点是耗费 CPU 资源,降低了 CPU 利用率,因为 CPU 时间片内都在做无意义的 CAS 操作;解决的办法是在锁竞争失败时不做 busy-wait 的事情,让这个线程让出 CPU 资源;使用 yield 可以主动让出 CPU,稍后再获取 CPU 资源,但是问题是将当前线程移动到所在优先调度队列的末端,如果该线程处于优先级最高的调度队列且该队列只有该线程,那操作系统下次还是运行该线程。当有很多个线程时,也会频发的触发 spin + yield;而 sleep 的缺点是我们并不知道需要 sleep 多久。
park + spin 结合 wait-notify
这种方式比较好的做了权衡,当然要付出线程上下文切换的代价。
结论:对于锁冲突不严重的情况,用自旋锁会更适合,试想每个线程获得锁后很短的一段时间内就释放锁,竞争锁的线程只要经历几次自旋运算后就能获得锁,那就没必要等待该线程了,因为等待线程意味着需要进入到内核态进行上下文切换,而上下文切换是有成本的并且还不低,如果锁很快就释放了,那上下文切换的开销将超过自旋。
目前一般是用自旋+等待结合的形式实现锁:在进入锁时先自旋一定次数,如果还没获得锁再进行等待。
synchronized 引入偏向锁和轻量级锁的原理和上面的结论是相似的,在没有线程竞争的情况下,会首先获取偏向锁,在少量的几个线程交替获取锁时会升级为轻量级锁,存在线程竞争比较激烈时会再次升级为重量级锁,也就是 Monitor,之所以引入不同的锁实现机制,主要还是根据实际的场景尽可能做到性能最优。偏向锁和轻量级锁的实现是不涉及到 Monitor 的,它们是在 Java 的对象头的 Mark Word 上做了些文章。
Java 对象头
Java 的对象头是由 Mark Word 、实例数据、对齐填充,当对象是数组时,还会有一个数组长度的部分。也就是普通对象的 Mark Word 是 2 个字,而数组对象是 3 个字。
锁标志位(lock):区分锁状态,11 时表示对象待 GC 回收状态, 只有最后 2 位锁标识(11)有效。
biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
分代年龄(age):表示对象被 GC 的次数,当该次数到达阈值的时候,对象就会转移到老年代。
对象的 hashcode(hash):运行期间调用 System.identityHashCode() 来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果 31 位不够表示,在偏向锁,轻量锁,重量锁,hashcode 会被转移到 Monitor 中。
偏向锁的线程 ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的 ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
epoch:偏向锁在 CAS 锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM 使用原子操作而不是 OS 互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM 通过 CAS 操作在对象的标题字中设置指向锁记录的指针。
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器 Monitor 的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到 Monitor 以管理等待的线程。在重量级锁定的情况下,JVM 在对象的 ptr_to_heavyweight_monitor 设置指向 Monitor 的指针
关于 Java 内存对象的布局可以使用 org.openjdk.jol:jol-core
这个库来查看。
Java 在 1.6 以前只有基于 Monitor 实现的重量级锁(因为在任何情况下只要进入同步块是需要阻塞的,需要上下文切换,内核态和用户态的切换等),1.6 以后引入了偏向锁和轻量级锁,在特定场景下的锁使用进行了优化。所以 synchronized 中锁的使用过程是一个逐步升级的过程,从无锁到偏向锁,再从偏向锁到轻量级锁,最后到重量级锁,不可逆。
偏向锁
HotSpot 的开发人员经过研究,大多数情况下,锁不仅仅不存在竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需要简单测试一下对象头中的 Mark Word 里是否存着指向当前线程的偏向锁。如果测试成功,表示已经获得了锁;如果测试失败,就需要在看一下 Mark Word 中的偏向锁标识是否为 1,如果为 1 ,则尝试使用 CAS 将对象头的偏向锁指向当前的线程 ID;如果为 0,则使用 CAS 竞争锁。
对于偏向锁的撤销,使用了一种等到竞争出现时才会释放锁的机制,在其他线程尝试竞争偏向锁时,持有锁的线程才会释放锁,在撤销时需要等待全局安全点 safepoint (这是一个没有正在执行的字节码的时间点)。首先会先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程仍然 alive,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁也是一个可以优化的地方,如果我们经过分析,发现应用中大部分使用 synchronized 的地方都存在竞争,可以在 JVM 启动时关闭偏向锁,这样就节省了锁升级和锁撤销的开销。(-XX:-UseBiasedLocking=false 关闭偏向锁,锁会自动进入轻量级锁)
轻量级锁
线程在执行同步之前,JVM 会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,这个被称作 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试 spin 获取锁。
在解锁时,获得锁的线程会尝试使用 CAS 操作将 Displaced mark word 替换回到对象头,如果成功,表示没有竞争,如果失败,表示当前线程存在竞争,锁会膨胀为重量级锁。
自旋会消耗 CPU,为了避免无用的自旋,升级为重量级锁之后就不会再恢复到轻量级锁。
重量级锁
在并发竞争激烈的情况下,锁是会升级为重量级锁,它的实现原理是基于 Monitor 的 MESA 模型,上文提到的 Monitor Introduction 中已经详细的说明了这个模型的原理。
当进入 synchronized 控制的临界区时,会使用 CAS 等方式去竞争修改对象头的 Mark Word,将它设置成指向 Monitor 的指针,但是只有一个线程会修改成功,这样修改成功的线程就获得了锁,否则就进入 entry set(入口等待队列);同时,在线程执行的过程中,可能会等待某些条件成立,这个时候会调用 wait() 让出锁,自己进入 wait set (条件等待队列)进行等待,让出锁之后,在 entry set 中的线程会被调度去竞争获取锁,新的线程会进入,而当条件满足后会调用 notify 去通知在 wait set 中的线程,这里的线程会被唤醒(notify 会唤醒一个,notifyAll 会唤醒所有的),然后会被放入 entry set,这样下次调度时就有可能竞争到锁,然后继续运行,有一点需要特别注意,如果是由于 wait() 被唤醒后重新获取到执行权的线程,会从 wait 调用的地方往下开始执行。
Java 中如何利用 Monitor 实现 synchronized
我们编写的 Java 代码最终都会被编译为字节码,字节码可以被 JVM 解释执行和 JIT 编译执行,比如我们写的方法中的代码会被编译成字节码指令序列,monitorenter
就是一种 Java 的字节码指令,在 JVM 中有很多的 C/C++ 代码实现了这个指令的功能,当执行到这个指令时,会运行对应的 C++ 代码实现的功能。所以,我们可以理解字节码是一种中间层表示,上层的语言可以使用编译器将代码编译成这个中间层的字节码,而运行时的虚拟机可以去解释执行(热点代码使用 JIT 编译执行)字节码,从而将字节码翻译成特定 CPU 架构下的 CPU 指令进行运算,可见字节码屏蔽了底层的 CPU 指令集的差异,针对不同的平台编写对应的虚拟机程序即可。
加了 synchronized 后,Java 会在生成的字节码文件里加入一些字节码指令:
针对 synchronized 代码块,会在临界区代码的前后加上字节码指令
monitorenter
和monitorexit
针对 synchronized 修饰方法时,编译后,会在方法的修饰符上加上
ACC_SYNCHRONIZED
这样,在执行到 synchronized 保护的代码指令时,线程会先试图获取锁(Java 中的每个对象都会关联一个 monitor,获取的是在这个 monitor 上的锁)
When
monitorenter
is encountered by the Java virtual machine, it acquires the lock for the object referred to by objectref on the stack. If the thread already owns the lock for that object, the count that is associated with the lock is incremented. Each timemonitorexit
is executed for the thread on the object, the count is decremented. When the count reaches zero, the monitor is released.
在 Java 虚拟机 (HotSpot) 中,monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp
文件,C++ 实现的
关键点
偏向锁默认是启用的,但是在应用程序启动几秒后才会激活;但是同样可以关闭偏向锁
偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的;有时候使用参数-XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
相关知识点:对象内存布局,指针压缩,栈帧,锁记录 Displaced Mark Word ,Semaphores 等
Reference
Monitor Types - Multithreaded programing with ThreadMentor
版权声明: 本文为 InfoQ 作者【shniu】的原创文章。
原文链接:【http://xie.infoq.cn/article/1bf4229d23b595da3762f944a】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论