java多线程02-----------------synchronized底层实现及JVM对synchronized的优化
java多线程02-----------------synchronized底层实现及JVM对synchronized的优化
提到java多线程,我们首先想到的就是synchronized关键字,它在我们多线程学习的前期帮助我们解决了绝大多数的问题,但是大多数包括我在内的很多开发人员并不了解它的实现机制,最近看了一些关于synchronized的博文和书籍,在此总结一下。
我们都知道,当一个线程执行synchronized代码块或方法前,都必须获得锁,那么究竟这个锁在什么地方?
想要了解这个问题,我们首相得了解一下对象头。
在jvm中,对象在内存中可以分为三块区域:对象头、实例数据和对齐填充数据。其中java对象头就是实现synchronized的基础。它主要又两部分组成:
1) Mark Word:主要存储对象的HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
2)Class Pointer类型指针:是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
java对象头与锁相关的信息如下所示:
在这里与synchronized相关的就是对象头的Mark Word部分中的重量级锁(标志位为10),java语言为每一个对象设置了一个与之关联的monitor对象,对象头中的重量级锁的指针指向的就是这个monitor对象。在使用synchronzed之后,线程在执行同步代码块之前要获得该monitor对象,当同步代码块执行完之后,释放monitor对象。
(一)synchronized代码块分析
如下面一段synchronized代码。
public class SynchronizedDemo { private static int i = 0; public void appender() { synchronized(SynchronizedDemo.class) { i++; } } }
经过javap –v SynchronizedDemo之后反编译后的查看字节码文件如下:
monitorenter和monitorexit两个命令就是在使用synchronized关键字后独有的命令,分别的含义为对对象的监视器monitor进行获取和释放。当执行monitorenter指令时,当前线程试图获得同步对象的monitor的持有权,当monitor的计数器为0时,可成功获得monitor,并将计时器的值设置为1,这样以来,该线程了就获得了同步对象的monitor,如果该线程已经获得了monitor,他可以重人这个monitor,重入时这个monitor的计数器也会加1,monitorenter和monitorexit指令成对执行。当其他线程发现monitor的计数器不为0时,即其他线程拥有该对象的monitor的持有权时,会被阻塞,直到该线程执行完所有的monitorexit命令,将计数器重新置为0时,其他线程才有机会持有monitor。上图中之所以出现一个monitor却出现两个monitorexit的原因是java语言为保证monitorenter和monitorexit成对出现而在编译时为自动生成一个异常处理器,就是为了保证即使在异常发生时也可以执行monitorexit命令,释放该线程对monitor的持有权。
(二) synchronized方法分析
如下一段代码:
对于同步方法,无需通过字节码命令来实现。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
(三)synchronized与happens-before的关系
Java语言中的happens-before关系中有一条内部多规则(监视器规则),它支出,内部锁的释放happens-before后续每一个对该锁的申请,即锁释放之前的执行结果对后序锁申请操作是可见的。
Synchronized的happens-before关系如下图所示:
图中每一个箭头连接的两个节点就代表他们之间的happens-before关系,黑色箭头是根据happens-before中的程序顺序规则推到出的,蓝色为内部锁(监视器)规则推导出的,红色蓝色是根据传递性规则推导出的。此时就可以得出,操作2 happens-before操作5,根据happens-before的含义,就可知,操作2的执行结果对操作5可见,操作2的执行顺序先于操作5.
(四)锁获取和所释放的内存含义
获得锁:该线程从主内存中将共享变量的值加载入本地内存(刷新处理器缓存),保证线程读到的共享变量的值为最新值。
释放锁:对共享变量的更新刷新到主内存(冲刷处理器缓存)。
(五)synchronized与等待唤醒机制
wait()、notify()和notifyAll()方法必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。
(六)JVM对synchronized的优化
需要注意的是,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。从jdk1.6之后,java官方从jvm层对synchronized做了大量优化,引入了轻量级锁、偏向锁、自旋锁等概念,大大提高了synchronized的效率。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
1)锁消除(Lock Elision):
锁消除是JIT编译器对内部锁的具体实现所做的一种优化,在动态编译同步代码块时,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的所对象是否只能够被一个线程访问而没有被发布到其他线程。如果同步代码块所使用的锁对象通过这种分析证实只能被一个线程访问,那么JIT编译器在编译这个同步代码块的时候并不生成synchronized所表示的锁申请与释放所对应的机器码(monitorenter、monitorexit),这种编译器优化被称为锁消除。
Java标准库中有些类虽然是线程安全的,但是在实际使用中,我们往往不在多个线程间共享这些类的实例,而这些类实现线程安全到时候往往借助于内部锁。这些类就是锁消除优化的常见目标。
在锁消除的作用下,利用ThreadLocal将一个线程安全的对象作为一个线程特有对象来使用,不仅可以避免锁的征用,而且还可以彻底消除这些对象内部所使用的锁的开销。
2)锁粗化(Lock Merging):
锁粗化是JIT编译器对内部锁具体实现所做的一种优化,对于相邻的同步代码块,如果这些同步块使用的是同一个锁实例,那么JIT编译器会将这些同步块合并成一个大的同步块,从而避免了一个线程反复申请释放同一个锁导致的开销。然而锁粗化会导致一个线程持有一个锁的时间变长,从而使同步在该锁上的线程申请锁等待的时间变长。
3)偏向锁(Baised Locking):
偏向锁是java虚拟机对锁实现的一种优化,这种优化基于这样的观测结果:大多数锁并没有被争用,并且这些锁在其整个生命周期内至多只会被一个线程持有。然而java虚拟机在实现monitorenter和monitorexit字节码时需要借助于一个CAS操作,这个操作的代价相对来说比较昂贵。因此,java虚拟机为每个对象维护一个偏好(Bias),即一个对象对应的内部锁第一次被一个线程获得时,那么这个线程就会被记录为该对象的偏好线程(会在对象头的锁记录中存储锁偏向的线程ID),这个线程后续无论是申请锁还是释放锁,都无须借助于原先的原子操作,只需要检测一下对象头的Mark Word中是否存储了指向当前线程的偏向锁,如果测试成功,表示当前线程获得了该锁。
一个锁没有被争用,并不代表仅仅只有一个线程访问该锁,当一个对象的偏好线程以外的其他线程申请该对象的内部锁时,java虚拟机会收回该对象对原偏好线程的偏好,重新设置对象的偏好线程。这个偏好收回和重新分配过程的代价也是比较昂贵的。因此,偏向锁只适用于存在相当大一部分锁并没有被争用的系统当中。
4)自旋锁:
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
5)轻量级锁
如果说偏向锁是只允许一个线程获得锁,那么轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况,轻量级锁的步骤如下:
1)线程1在执行同步代码块之前,JVM会先在当前线程的栈帧中创建一个空间用来存储锁记录,然后再把对象头中的Mark Word复制到该锁记录中,官方称之为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word 替换为指向锁记录的指针。如果成功,则获得锁,进入步骤3)。如果失败执行步骤2)
2)线程自旋,自旋成功则获得锁,进入步骤3)。自旋失败,则膨胀成为重量级锁,并把锁标志位变为10,线程阻塞进入步骤3)
3)锁的持有线程执行同步代码,执行完CAS替换Mark Word成功释放锁,如果CAS成功则流程结束,CAS失败执行步骤4)
4)CAS执行失败说明期间有线程尝试获得锁并自旋失败,轻量级锁升级为了重量级锁,此时释放锁之后,还要唤醒等待的线程