我们最初学习 Java 的时候,遇到多线程我们会知道 synchronized,对于当时的我们来说 synchronized 是保证了多线程之间的同步,也成为了我们解决多线程情况的常用手段。但是,随着我们学习的进行我们知道 synchronized 是一个重量级锁,相对于 Lock,它会显得那么笨重,以至于我们认为它不是那么的高效而慢慢摒弃它。 但是,随着 Javs SE 1.6 对 synchronized 进行的各种优化后,synchronized 并不会显得那么重了。下面跟随 LZ 一起来探索 synchronized 的实现机制、Java 是如何对它进行了优化、锁优化机制、锁的存储结构和升级过程;
Java 对象头和 monitor 是实现 synchronized 的基础!下面就这两个概念来做详细介绍。 1.Java 对象头: Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例; Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等, 它是实现轻量级锁和偏向锁的关键.
2. 什么是 Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为对象监视器。 当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列 Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck Owner:获得锁的线程称为 Owner !Owner:释放锁的线程
下图就是多个线程获取锁的示意图
0_1311821841e55M.gif
新请求锁的线程将首先被加入到 ConetentionList 中,当某个拥有锁的线程(Owner 状态)调用 unlock 之后,如果发现 EntryList 为空则从 ContentionList 中移动线程到 EntryList,下面说明下 ContentionList 和 EntryList 的实现方式:
ContentionList 虚拟队列EntryListContentionList 并不是一个真正的 Queue,而只是一个虚拟队列,原因在于 ContentionList 是由 Node 及其 next 指针逻辑构成,并不存在一个 Queue 的数据结构。ContentionList 是一个先进先出(FIFO)的队列,每次新加入 Node 时都会在队头进行,通过 CAS 改变第一个节点的的指针为新增节点,同时设置新增节点的 next 指向后续节点,而取得操作则发生在队尾。显然,该结构其实是个 Lock-Free 的队列。 因为只有 Owner 线程才能从队尾取元素,也即线程出列操作无争用,当然也就避免了 CAS 的 ABA 问题。
EntryList 与 ContentionList 逻辑上同属等待队列,ContentionList 会被线程并发访问,为了降低对 ContentionList 队尾的争用,而建立 EntryList。Owner 线程在 unlock 时会从 ContentionList 中迁移线程到 EntryList,并会指定 EntryList 中的某个线程(一般为 Head)为 Ready(OnDeck)线程。Owner 线程并不是把锁传递给 OnDeck 线程,只是把竞争锁的权利交给 OnDeck,OnDeck 线程需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot 中把 OnDeck 的选择行为称之为 "竞争切换"。 OnDeck 线程获得锁后即变为 owner 线程,无法获得锁则会依然留在 EntryList 中,考虑到公平性,在 EntryList 中的位置不发生变化(依然在队头)。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列;如果在某个时刻被 notify/notifyAll 唤醒,则再次转移到 EntryList。
jdk1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
自旋锁适应自旋锁线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。 何谓自旋锁? 所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。 自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。 自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 - XX:+UseSpinning 开开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 - XX:PreBlockSpin 来调整; 如果通过参数 - XX:preBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为 10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是 JDK1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。
锁消除JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,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 循环之外。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
JVM 在运行过程会根据实际情况对添加了 Synchronized 关键字的部分进行锁自动升级来实现自我优化。偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。其中识别是不是同一个线程一只获取锁的标志是在上面提到的对象头 Mark Word(标记字段)中存储的。 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。这时候也就成为了原始的 Synchronized 的实现。
以上就是 Synchronized 的实现原理和 java1.6 以后对其所做的优化以及在实际运行中可能遇到的锁升级等,另一种锁 Lock 的实现原理我们在下一文章中进行解析。
来源: http://www.jianshu.com/p/46a874d52b71