Synchronized和Lock该如何选择

追根溯源和陈明真像是我写这篇文章的初衷,因为网上相关Synchronized和Lock比较的信息很多,错误的也很多,有说WAITING比BLOCKED性能好的,有说ReentrantLock比Synchronized性能更好,也有的说park比wait的性能好的等等,哪个才是真的呢?我认为这些描述的背后是没有透彻的理解Java的并发编程原理。

本文表面上是一篇比较Synchronized和Lock的文章,实质则是以比较二者的线程状态和线程同步算法为切入点,帮助读者深入理解Java的并发处理机制和锁的机制。

线程状态

BLOCKED vs WAITING

让我们从Thread dump说起,在我们使用jstack将打印thread stack的时候,如果采用的是Synchronized进行并发控制的话会看到如下的日志:
jstack-sync.png
如果采用的是ReentrantLock,会看到如下的日志:
jstack-lock.png
类似的如果是调用java.lang.Object.wait()产生的线程等待会看到

    java.lang.Thread.State: WAITING (on object monitor)

如果是sleep或者park产生的线程等待会看到

java.lang.Thread.State: TIMED_WAITING (sleeping)
or
java.lang.Thread.State: TIMED_WAITING (parking)

这些状态我们都可以在JDK的java.lang.Thread.State源码中看到,其中除了上面提到的3种状态,JVM中总共有NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED六种状态,他们之间的相互转化如下图所示:
ThreadState.jpg
这么多状态难免让初学者一脸懵逼,这也是为什么很多人回去追问BLOCKED和WAITING哪个更好的问题,那实际情况又是怎样的呢?请往下看。

线程的阻塞

线程有6种状态? 这好像和我们学习的《计算机操作系统》中描述的不一样啊,是的,从OS的层面来看,线程只有三个基本状态,就绪(Ready)、执行(Running)、阻塞(Blocked)。

这里的阻塞是广义上的阻塞,实际上是包含了上面提到的BLOCKED,WAITING和TIMED_WAITING三个状态:
image.png

所谓的线程阻塞就是指线程因为某种原因放弃了CPU使用权,也即让出了CPU timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得CPU timeslice转到运行(running)状态。从这个意义上来说,WAITING和TIMED_WAITING也是放弃了CPU使用权,也是阻塞态。

为什么要区分

上面已经说过了,从操作系统内核来看,线程都是在阻塞态,没有区别。区别在于由谁唤醒,是操作系统,还是另一个线程。那为什么还要区分成BLOCKED和WAITING两个状态呢,主要有以下原因:
- JVM管理的需要,线程放两个队列里管理,如果别的线程运行出了synchronized这段代码,我只需要去blocked队列,放个出来。而某人调用了notify(),我只需要去waiting队列里取个出来。
- 不同的语义需要,一个是被BLOCKED,一个是主动WATING
- wait/notify或者park/unpark可以实现更灵活的并发控制,例如我可以通过wait/notify比较容易的实现一个producer-consumer

上面是从线程状态的角度对二者进行比较,发现两者的差异只是在JVM里面,在OS的层面并没有什么差异。接下来我们再从算法的角度看看两者的差异。

线程同步算法

堵塞算法(Blocking Algorithm)

上面提到的不管是Synchronized的BLOCKED,还是Lock的WAITING,对应到操作系统的线程状态都是堵塞,其本质上都是基于锁的堵塞算法。JVM实现堵塞有两种方式,一个是通过JVM的自旋(Spin-Waiting),另一个是使用操作系统中的挂起(Suspend)。在基于锁的算法中,如果一个线程在Spin或者Suspend的时候持有锁,那么其他线程都无法执行下去,也就是被阻塞了

自旋和挂起

自旋也叫忙等(busy-wait),采用这种方式的线程不会被挂起,而是会消耗一定的CPU资源去循环check是否可以执行了。
挂起就是把你从CPU的使用中换出,当可用的时候再让你运行。这种换进换出叫着上下文切换,上下文是需要CPU调度开销的,一般是相当于5000~10000个时钟周期,对于主频为1GHz的CPU来说,也就是需要1到2个微秒。

那么自旋和挂起哪种更好呢? 我们可以看下《Java Concurrency in Practice》作者的原话:

When locking is contended, the losing thread(s) must block. The JVM can implement blocking either via spin-waiting (repeatedly trying to acquire the lock until it succeeds) or by suspending the blocked thread through the operating system. Which is more efficient depends on the relationship between context switch overhead and the time until the lock becomes available; spin-waiting is preferred for short waits and suspension is preferable for long waits. Some JVMs choose between the two adaptively based on profiling data of past wait times, but most just suspend threads waiting for a lock.

大概的意思是,如果自旋开销小于上下文切换开销,则推荐使用自旋,反之使用挂起。也就是说如果竞争不激励,自旋高效;竞争激励,线程等待时间长,挂起更高效。同样的道理同样适用其它领域:交通信号灯在拥堵的交通状况下能带来更好的吞吐量,但是环岛在低拥堵的情况下能够带来更好的吞吐量。

在JDK 6之前,JVM提供了相关参数去开启自旋,以及设置自旋次数等。但是用户也不知道自旋多少次比较合适,所以JDK1.6引入了自适应的自旋锁:自旋时间不再固定,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

非堵塞算法(Non-Blocking Algorithm)

上面的锁是一种独占式的锁,也叫着悲观锁实现,类似于我要对一行数据修改,采用行级锁的形式(select…for update);而非阻塞算法是一种乐观锁实现,它很乐观,假定修改可以成功,不成功再重试,类似于给数据加上版本号version,

 update t_goods set status=2,version=version+1 where id=#{id} and version=#{version}; 

上面的例子之所以可以用version实现乐观锁,是因为它将一行数据的修改缩小成对一个原子变量version的修改。这种将原子修改的范围缩小到单个变量(primitive)上是实现非阻塞算法的关键,现在,几乎所有的现代处理器中都包含了某种形式的原子读-改-写(Atomic read-modify-write)指令,比如最著名的CAS(Compare-And-Swap),操作系统和JVM正是使用这个指令来实现对原子变量类(AtomicInteger,AtomicReference)来构建高效的非阻塞算法。

当多个线程尝试使用CAS同时更新同一个变量时,只有一个线程能更新变量的值,而其它线程都将失败。然而,失败的线程并不会挂起(这与获取锁的情况不同:获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。而这正是非阻塞的定义。

An algorithm is called non-blocking if failure or suspension of any thread cannot cause failure or suspension of another thread

下面是一个典型的CAS使用,一般情况下CAS总是伴随着一个循环,你可以把它理解为一个显示的自旋

boolean updated = false;
while(!updated){
    long prevCount = this.count.get();
    updated = this.count.compareAndSet(prevCount, prevCount + 1);
}

总结

通过上面的阐述,我们可以看到Synchronized和Lock并没有我们想象的有那么大差异,他们都是利用线程的阻塞(BLOCKING)来实现同步的,都是使用了基于锁的阻塞算法,只不过一个是内置锁(intrinsic lock),一个是显示锁。性能方便也没有什么差异,就未来来看,更可能提升性能的是Synchronized而不是ReentrantLock,因为Synchronized是JVM的内置属性,具备进一步优化的可能性。

虽然Synchronized和Lock在同步机制和性能上无差,但是在使用上还是有些差别的,具体比较内容如下:
Synchronized
- 优点:实现简单,语义清晰,便于JVM堆栈跟踪;加锁解锁过程由JVM自动控制,提供了多种优化方案。
- 缺点:不能进行高级功能(定时,轮询和可中断等)。

Lock
- 优点:可定时的、可轮询的与可中断的锁获取操作,提供了读写锁、公平锁和非公平锁  
- 缺点:需手动释放锁unlock,不适合JVM进行堆栈跟踪。

最后再贴一段Doug Lea大神在书中,关于在Synchronized和ReentrantLock之间进行选择的原话,该如何选择,读者自己去思考吧。

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用Synchronized

猜你喜欢

转载自blog.csdn.net/significantfrank/article/details/80399179