synchronize早已经没那么笨重

12,643 阅读10分钟

我发现一些同学在网络上有看不少synchronize的文章,可能有些同学没深入了解,只看了部分内容,就急急忙忙认为不能使用它,很笨重,因为是采用操作系统同步互斥信号量来实现的。关于这类的对于synchronize的污点,我打算帮它清洗下。

JVM锁优化

其实jdk1.6对锁的实现已经引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

重量级锁

重量级锁,是JDK1.6之前,内置锁的实现方式。简单来说,重量级锁就是采用互斥量来控制对互斥资源的访问。

历史回顾:在JDK1.6以前的版本,synchronized实现的内置锁都比较重(这也是诸多同学们理解的版本)。JVM中monitorentermonitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

自旋锁

线程的阻塞和唤醒需要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()方法:

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

锁粗化

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

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

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

偏向锁

既然采用了内置锁,只要访问了同步代码,都会涉及获取锁和释放锁的动作。而这种动作都是存在开销的。无论是重量级锁去取得互斥信号量,还是轻量级锁去compare,都会有开销。然后很多时候,被内置锁约束的同步代码段往往只有一个线程去获取“锁”,根本不存在并发访问。那么这时候频繁地加锁和解锁就会有额外的开销。因此偏向锁也应运而生。

在采用偏向锁时,如果一个线程第一次来访问互斥资源,则在对象头和栈帧的锁记录中存储偏向锁的线程ID(可以理解为获取“锁”的动作)。偏向锁在获取锁之后,直到有竞争出现才会释放锁。也就是说,如果长期没有竞争,偏向锁是一直持有锁的。这样,当线程下次再次进入同步块的时候不需要进行任何获取锁的操作,即可访问互斥资源。节约了频繁获取锁和释放锁的开销。

轻量级锁

轻量级锁,顾名思义,相比重量级锁,其加锁和解锁的开销会小很多。重量级锁之所以开销大,关键是其存在线程上下文切换的开销。而轻量级锁通过JAVA中CAS的实现方式,避免了这种上下文切换的开销。当compare失败的时候(理解成没有拿到”锁”),线程不会被挂起;当compare成功的时候,可以直接对互斥资源进行修改(就好像拿到了“锁一样”)。重量级锁使用互斥信号量实现,如果没有拿到互斥信号量(理解成没有拿到“锁”),线程会被挂起;如果拿到互斥信号量则可以直接对互斥资源进行访问。

从以上分析可知,其实是否拿到“锁”对于不同的锁实现方式有着不同的含义。 重量级锁基于互斥信号量实现,则认为拿到互斥信号量即为拿到锁。而CAS操作则通过compare是否成功来判断是否拿到“锁”。 这里的“锁”都不是特指某一具体事物,而是一种“条件”,拿到了“锁”,即意味着满足了“条件”,可以对互斥资源进行访问。当然本质上,无论哪种实现方式,拿到锁之后都会去修改Mark Word,来记录自己确实拿到了锁;释放锁则会清空Mark word中自己的线程ID。

轻量级锁和重量级锁的重要区别是: 拿不到“锁”时,是否有线程调度和上下文切换的开销。

轻量级锁加锁:

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间(Lock 
Record),并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark 
Word。然后线程尝试使用CAS将对象头中的Mark 
Word替换为指向锁记录的指针。如果成功,当前线程获得锁。如果这个更新操作失败了,虚拟机首先会检查
对象的Mark Word是否指向当前线程的栈帧。如果指向,说明当前线程已经拥有了这个对象的锁,那就可以直
接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁
,那轻量级锁就不再有效,要膨胀为重量级锁,Mark Word中存储的就是指向重量级(互斥量)的指针

轻量级锁解锁:

轻量级解锁时,会使用原子的CAS操作来将Displaced Mark 
Word替换回到对象头,如果成功,则整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该
锁,那么其他线程就要在释放锁的同时,唤醒被挂起的线程。

关于轻量级锁的加锁和解锁过程简单来说就是:

  • 尝试CAS修改mark word:如果这步能直接成功,则代价较小,可以直接获取锁
  • 获取锁失败则采用自旋锁来获取锁(CAS修改尝试失败后采取的策略)
  • 自旋锁尝试失败,锁膨胀,成为重量级锁:自旋锁也尝试失败,不得不使用重量级锁,线程也被阻塞。

总结

所以synchronize并有没像之前想象的那么笨重,其实大家可以在大量的源码中都能看到它的身影,包括juc包下的工具类等等,总之存在必有合理之处,望大家善用它。(当然前提必须理解它)