Synchronized和Lock该如何选择

本文通过对Synchronized和Lock的深入对比分析,揭示了Java并发编程中的核心原理,特别是线程状态的变化及其背后的线程同步算法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

追根溯源和陈明真像是我写这篇文章的初衷,因为网上相关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

### synchronized Lock 的区别及使用场景 #### 1. 基本概念 `synchronized` 是 Java 中用于实现线程同步的关键字,它在 JVM 层面实现锁机制。`Lock` 是一个接口,提供了比 `synchronized` 更加灵活的锁机制[^4]。 #### 2. 功能对比 - **锁的获取与释放** `synchronized` 在方法或代码块执行完毕后自动释放锁,而 `Lock` 需要显式调用 `lock()` 方法获取锁,并在 `finally` 块中调用 `unlock()` 方法释放锁。 - **可中断性** `synchronized` 不支持锁的中断,而 `Lock` 提供了 `tryLock()` `lockInterruptibly()` 等方法,允许线程在等待锁时被中断[^4]。 - **超时功能** `synchronized` 不支持超时机制,而 `Lock` 提供了 `tryLock(long timeout, TimeUnit unit)` 方法,允许线程在指定时间内尝试获取锁。 - **性能表现** 在简单的同步场景下,`synchronized` 的性能通常优于 `Lock`,因为其由 JVM 优化。然而,在需要复杂锁控制的场景下,`Lock` 的灵活性使其成为更好的选择[^1]。 #### 3. 使用示例 ##### (1) `synchronized` 示例 ```java public class Counter { private int count; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } } ``` ##### (2) `Lock` 示例 ```java import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class Counter { private int count; private final Lock lock = new ReentrantLock(); public void increment() { lock.lock(); // 显式获取锁 try { count++; } finally { lock.unlock(); // 显式释放锁 } } public int getCount() { lock.lock(); try { return count; } finally { lock.unlock(); } } } ``` #### 4. 使用场景 - **简单同步需求** 如果只需要简单的线程同步,且不需要复杂的锁控制(如中断、超时等),可以优先使用 `synchronized`,因为它更简洁且性能较好[^1]。 - **复杂同步需求** 如果需要更高级的锁控制(如公平锁、读写锁、锁中断等),则应选择 `Lock` 接口[^4]。 - **性能敏感场景** 在高并发场景下,如果需要对锁进行优化(如减少锁竞争),可以考虑使用 `Lock` 提供的多种锁实现(如 `ReentrantLock` 或 `ReadWriteLock`)[^1]。 #### 5. 注意事项 - 使用 `Lock` 时必须确保在 `finally` 块中释放锁,否则可能导致死锁。 - `synchronized` 是不可重入的锁,而 `ReentrantLock` 支持重入,即同一个线程可以多次获取同一把锁[^3]。 --- ###
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值