目录
干货分享,感谢您的阅读!
在Java的并发编程中,锁机制是确保多个线程在共享资源上的安全访问的核心技术。随着多核处理器的普及,如何有效地控制并发访问、避免竞态条件、提高程序的执行效率,成为了开发者必须掌握的重要技能。Java提供了多种锁机制,每种锁都有其独特的应用场景和实现方式。从传统的synchronized
关键字到更高级的ReentrantLock
、ReadWriteLock
、以及StampedLock
,每种锁的使用方式和适用场景都有很大的差异。
理解并掌握这些常用的锁机制,不仅能帮助我们避免并发编程中的常见问题(如死锁、饥饿、活锁等),还能使我们在设计高效、健壮的多线程程序时游刃有余。本文将详细总结并分析Java中常用的锁机制,从基础的锁类型讲解到进阶的使用技巧,帮助开发者深入理解并灵活运用这些锁。无论你是刚刚接触并发编程的新手,还是已经有一定经验的开发者,相信通过本文的学习,你将对Java中的锁机制有更加清晰和深入的认识。
一、Java锁基本概述
(一)锁的基本作用分析
Java中的锁是保证并发编程安全性的重要手段之一。在多线程编程中,锁的作用是保护临界区的代码,避免多个线程同时访问共享资源而导致的数据不一致、线程竞争、死锁等问题。Java中的锁可以分为内置锁(synchronized关键字)和显式锁(ReentrantLock等类),具有不同的特性和适用场景。在使用锁时,需要注意以下几点:
- 选择合适的锁类型。根据应用场景和具体需求选择适合的锁类型,如可重入锁、公平锁、非公平锁、独占锁、共享锁、自旋锁、读写锁、乐观锁和悲观锁等。
- 避免死锁。死锁是指多个线程因为竞争资源而陷入无限等待的状态,必须避免死锁的发生。可以使用锁的顺序控制、超时机制、避免嵌套锁等方法来避免死锁。
- 提高锁的粒度。锁的粒度越小,可以并发执行的代码越多,提高了程序的并发性能。因此,应该尽量将锁的粒度控制在尽可能小的范围内。
- 减少锁的竞争。减少锁的竞争可以提高程序的并发性能。可以使用无锁算法、CAS操作、分段锁等技术来减少锁的竞争。
- 注意锁的性能问题。锁的加锁和释放会带来一定的性能开销,因此需要在锁的使用中考虑性能问题,选择合适的锁类型和合理的锁粒度。
在多线程编程中,锁是一种非常重要的手段,可以保证程序的安全性和并发性能。需要根据实际情况选择合适的锁类型,避免死锁和性能问题。
(二)框架中的应用举例
几个常见的例子:
- Spring Framework:Spring提供了基于AOP(面向切面编程)的声明式事务管理,通过注解或XML配置方式实现对事务的控制,其中就包含了对锁的应用。例如,在Spring的事务管理中,使用悲观锁来保证并发访问数据库时的数据一致性。
- Hibernate ORM框架:Hibernate ORM框架中也有对锁的应用。Hibernate提供了乐观锁机制,使用版本号或时间戳来控制并发访问数据库的数据一致性。
- Netty框架:Netty是一款高性能、事件驱动的网络框架,其中也包含了对锁的应用。Netty使用ReentrantLock来保证线程安全,同时还提供了读写锁和信号量等高级锁机制。
- ZooKeeper分布式协调服务:ZooKeeper是一个分布式协调服务,其中的分布式锁机制就是锁在框架中的应用之一。ZooKeeper提供了基于临时节点和watcher机制的分布式锁实现,通过ZooKeeper实现的分布式锁可以保证分布式系统中的同步访问。
- 并发编程框架:如Java并发编程中的Executor框架、Guava库中的RateLimiter、Disruptor高性能消息队列等都使用了锁机制来保证线程安全和高性能。
锁在框架中的应用非常广泛,包括了事务管理、ORM框架、网络框架、分布式协调服务和并发编程框架等方面。框架的开发者需要根据实际需求选择合适的锁机制,保证框架的线程安全和高性能。
(三)特性分类
其实总的来看,Java锁的全貌很难通过一些指定的特性分类直接就可以划分开的,但是个人觉得在不可不说的Java“锁”事 - 美团技术团队是划分相对已经很全了(因为具有一定的局限性,比如CAS操作的原子类、基于队列的同步器锁等都没有讲师),其总结如下图:
二、乐观锁 VS 悲观锁
(一)定义分析
悲观锁定义
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
Java中,synchronized关键字和Lock的实现类都是悲观锁。
乐观锁定义
乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)
在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
(二)使用场景建议与代码示例
使用场景建议
通过定义可以推导他们的使用场景
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
下面给出一个简单的电商代码示例,用来演示悲观锁和乐观锁的应用。
假设有一个电商网站,用户在购物车中添加商品时,需要判断库存是否充足,如果库存充足,则将商品添加到购物车中;如果库存不足,则提示用户无法购买。在多个用户同时访问时,需要保证数据的一致性和并发性。
悲观锁示例代码一
package org.zyf.javabasic.thread.lock; import org.springframework.stereotype.Service; import org.zyf.javabasic.skills.reflection.dto.Product; import org.zyf.javabasic.thread.test.CartDao; import org.zyf.javabasic.thread.test.ProductDao; import javax.annotation.Resource; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description 简单的电商代码示例,用来演示悲观锁和乐观锁的应用 * @date 2022/5/3 16:16 */ @Service public class ShoppingCartService { @Resource private ProductDao productDao; @Resource private CartDao cartDao; private Lock lock = new ReentrantLock(); public void addToCartWithPessimisticLock(int productId, int quantity) { lock.lock(); try { // 从数据库中查询商品库存 Product product = productDao.getProduct(productId); if (product.getStock() >= quantity) { // 减少库存 productDao.decreaseStock(productId, quantity); // 将商品添加到购物车 cartDao.addToCart(productId, quantity); } else { throw new RuntimeException("库存不足"); } } finally { lock.unlock(); } } }
在上面的代码中,使用了ReentrantLock实现了悲观锁,通过lock和unlock方法保证了同一时刻只有一个线程能够执行addToCartWithPessimisticLock方法。这种方式可以确保数据的一致性,但是会降低并发性能,因为多个线程需要等待获取锁才能继续执行。
悲观锁示例代码二
悲观锁的实现方式不仅仅局限于使用ReentrantLock,还可以使用synchronized关键字、数据库的SELECT...FOR UPDATE语句等等。
下面给出使用synchronized关键字实现悲观锁的示例代码:
package org.zyf.javabasic.thread.lock; import org.springframework.stereotype.Service; import org.zyf.javabasic.skills.reflection.dto.Product; import org.zyf.javabasic.thread.test.CartDao; import org.zyf.javabasic.thread.test.ProductDao; import javax.annotation.Resource; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description 简单的电商代码示例,用来演示悲观锁和乐观锁的应用 * @date 2022/5/3 16:16 */ @Service public class ShoppingCartService { @Resource private ProductDao productDao; @Resource private CartDao cartDao; public synchronized void addToCartWithPessimisticSync(int productId, int quantity) { // 从数据库中查询商品库存 Product product = productDao.getProduct(productId); if (product.getStock() >= quantity) { // 减少库存 productDao.decreaseStock(productId, quantity); // 将商品添加到购物车 cartDao.addToCart(productId, quantity); } else { throw new RuntimeException("库存不足"); } } }
在上面的代码中,使用synchronized关键字实现了悲观锁,可以确保同一时刻只有一个线程能够执行addToCartWithPessimisticLock方法。
当然,使用synchronized的方式相对于ReentrantLock,它的控制范围是更广泛的,如果程序的粒度过大,可能会出现资源等待和死锁等问题。因此,在实际开发中应该根据实际情况选择不同的锁实现方式。
乐观锁示例代码
package org.zyf.javabasic.thread.lock; import org.springframework.stereotype.Service; import org.zyf.javabasic.skills.reflection.dto.Product; import org.zyf.javabasic.thread.test.CartDao; import org.zyf.javabasic.thread.test.ProductDao; import javax.annotation.Resource; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description 简单的电商代码示例,用来演示悲观锁和乐观锁的应用 * @date 2022/5/3 16:16 */ @Service public class ShoppingCartService { @Resource private ProductDao productDao; @Resource private CartDao cartDao; public void addToCartWithOptimisticLock(int productId, int quantity) { // 从数据库中查询商品库存 Product product = productDao.getProduct(productId); if (product.getStock() >= quantity) { // 减少库存 int result = productDao.decreaseStockWithVersion(productId, quantity, product.getVersion()); if (result == 1) { // 将商品添加到购物车 cartDao.addToCart(productId, quantity); } else { throw new RuntimeException("库存不足"); } } else { throw new RuntimeException("库存不足"); } } }
在上面的代码中,使用了乐观锁机制,其中getProduct方法查询商品信息时不会加锁,而是将商品信息的版本号作为参数返回。在更新库存时,使用版本号来判断是否有并发冲突,如果版本号不一致,则说明有其他线程修改了数据,需要重新执行整个操作;如果版本号一致,则可以执行减少库存和添加购物车的操作。这种方式能够提高并发性能,但是需要处理并发冲突,增加了代码的复杂度。
(三)CAS技术分析
基本介绍
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。(解决ABA问题可使用AtomicStampedReference)
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
CAS缺陷
CAS虽然很高效,但是它也存在三大问题。
ABA问题
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。
但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
循环时间长开销大
CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
三、自旋锁 VS 适应性自旋锁
(一)定义分析
自旋锁定义
自旋锁是一种基于忙等待的锁,适用于短时间内占用锁的线程。它的背景是由于在多处理器系统中,线程在等待锁时可能会被操作系统挂起,等到锁可用时需要重新上下文切换,这样会带来一定的开销,尤其是在高并发场景下,上下文切换的开销会非常大,影响程序的性能。
其定义是:当线程尝试获取锁时,如果锁已经被其他线程持有,那么当前线程不会被挂起,而是会一直忙等待,直到获取到锁为止。自旋锁通常使用CPU提供的CAS(Compare-And-Swap)指令来实现。
在Java中,JDK提供了基于CAS实现的自旋锁类:java.util.concurrent.atomic包下的AtomicInteger、AtomicLong、AtomicReference等。此外,ReentrantLock也可以使用CAS来实现自旋锁。
自适应自旋锁定义
自适应自旋锁是自旋锁的一种优化实现,它能够自动调整自旋等待的时间,从而更加有效地利用CPU资源。其背景是:对于不同的锁场景,自旋等待的时间可能会有很大的差异,过短的自旋时间会造成性能浪费,过长的自旋时间会浪费CPU资源。
其定义是:在自旋等待获取锁的过程中,通过一定的算法来动态地调整自旋等待的时间。如果前一次获取锁成功,说明该线程在自旋等待时的时间合适,那么在下一次自旋等待时,可以适当增加自旋等待的时间;如果前一次获取锁失败,说明该线程在自旋等待时的时间过短,那么在下一次自旋等待时,可以适当减少自旋等待的时间。
在Java 6及以上版本中,ReentrantLock和synchronized关键字都支持自适应自旋锁。对于synchronized关键字,自适应自旋锁可以通过JVM的参数“-XX:PreBlockSpin”来设置自旋等待的时间;对于ReentrantLock,可以通过构造函数中的参数“fair”来设置是否开启公平锁模式,以及通过方法“lock()”和“tryLock()”的重载方法来设置自旋等待的最大次数和自旋等待的最大时间。
(二)使用场景与代码示例
使用场景建议
- CAS通常适用于高并发下对单个变量的原子性操作,比如计数器、ID生成器等场景
- 自适应自旋锁适用于高并发下的多线程场景,可以有效减少线程切换的开销,提升程序的性能。特别是在锁竞争不激烈的情况下,自旋等待是比较高效的。但是,在锁竞争激烈的情况下,过长的自旋等待会浪费CPU资源,反而会降低程序的性能。因此,在使用自适应自旋锁时需要根据具体的场景进行调优。
具体代码分析,我们还是依照刚开始的电商背景进行分析如下:
CAS示例代码
package org.zyf.javabasic.thread.lock; import org.springframework.stereotype.Service; import org.zyf.javabasic.skills.reflection.dto.Product; import org.zyf.javabasic.thread.test.CartDao; import org.zyf.javabasic.thread.test.ProductDao; import javax.annotation.Resource; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description 简单的电商代码示例,用来演示悲观锁和乐观锁的应用 * @date 2022/5/3 16:16 */ @Service public class ShoppingCartService { private AtomicInteger count = new AtomicInteger(0); public boolean addGoods(int quantity) { int current; do { current = count.get(); if (current + quantity > 10) { // 商品库存不足 return false; } } while (!count.compareAndSet(current, current + quantity)); return true; } }
在上述代码中,我们使用了AtomicInteger类来实现购物车中商品数量的原子性操作,addGoods()方法通过自旋CAS的方式实现对商品数量的原子性更新。如果商品数量的当前值被其他线程修改了,那么自旋会继续进行,直到操作成功。同时,如果商品库存不足,那么会立即返回false,避免下单失败后出现超卖现象。
自适应自旋锁示例代码
package org.zyf.javabasic.thread.lock; import org.springframework.stereotype.Service; import org.zyf.javabasic.skills.reflection.dto.Product; import org.zyf.javabasic.thread.test.CartDao; import org.zyf.javabasic.thread.test.ProductDao; import javax.annotation.Resource; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description 简单的电商代码示例,用来演示悲观锁和乐观锁的应用 * @date 2022/5/3 16:16 */ @Service public class ShoppingCartService { private Lock lock = new ReentrantLock(); int countnew =0; public boolean addGoods(int quantity) { lock.lock(); try { if (countnew + quantity > 10) { // 商品库存不足 return false; } countnew += quantity; return true; } finally { lock.unlock(); } } }
在上述代码中,我们使用了ReentrantLock来实现购物车中商品数量的并发控制,通过lock()和unlock()方法实现对商品数量的加锁和解锁。在业务逻辑执行前通过lock()方法获取锁,在业务逻辑执行完后通过unlock()方法释放锁。
四、无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁的分类是专门针对synchronized的。
(一)Java对象头中的锁状态:Monitor
Java对象头结构
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁存在Java对象头里,以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
- Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Klass Point:对象指向它的类数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。
下面给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
(二)锁状态定义分析
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
(三)锁升级分析
整体的锁状态升级流程如下:
偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
(四)锁升级的代码展示
偏向锁升级为轻量级锁
package org.zyf.javabasic.thread.lock; / * @author yanfengzhang * @description * @date 2022/5/3 18:05 */ public class BiasedLockDemo { public static void main(String[] args) throws InterruptedException { Object obj = new Object(); Thread.sleep(5000); synchronized (obj) { // 获取偏向锁 System.out.println(Thread.currentThread().getName() + " 持有偏向锁"); Thread.sleep(5000); } System.out.println("--------------------"); synchronized (obj) { // 偏向锁失效,升级为轻量级锁 System.out.println(Thread.currentThread().getName() + " 持有轻量级锁"); } } }
上述代码中,首先创建一个Object对象,并通过synchronized关键字获取偏向锁。然后让当前线程睡眠5秒钟,以便观察偏向锁的状态。接着,再次获取锁时,偏向锁会失效,升级为轻量级锁,控制台输出如下:
main 持有偏向锁 -------------------- main 持有轻量级锁
轻量级锁升级为重量级锁
package org.zyf.javabasic.thread.lock; / * @author yanfengzhang * @description * @date 2022/5/3 18:08 */ public class LightWeightLockDemo { public static void main(String[] args) { Object obj = new Object(); new Thread(() -> { synchronized (obj) { // 获取轻量级锁 System.out.println(Thread.currentThread().getName() + " 持有轻量级锁"); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); new Thread(() -> { synchronized (obj) { // 轻量级锁升级为重量级锁 System.out.println(Thread.currentThread().getName() + " 持有重量级锁"); } }).start(); } }
上述代码中,首先创建一个Object对象,并启动两个线程分别获取锁。第一个线程获取锁时,锁状态为轻量级锁,然后让线程睡眠10秒钟,以便观察锁的状态。在第一个线程睡眠期间,第二个线程尝试获取锁时,由于锁被占用,轻量级锁就会升级为重量级锁,控制台输出如下:
Thread-0 持有轻量级锁 Thread-1 持有重量级锁
五、公平锁 VS 非公平锁
在 Java 中,ReentrantLock 可以通过构造函数指定是否为公平锁,默认为非公平锁,而 synchronized 关键字则是一种非公平锁。
(一)定义分析
公平锁定义
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
公平锁的优点是等待锁的线程不会饿死。
缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
图示举例(来自不可不说的Java“锁”事 - 美团技术团队):
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
非公平锁定义
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
图示举例(来自不可不说的Java“锁”事 - 美团技术团队):
如图所示,对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。
(二)使用场景与代码示例
使用场景建议
公平锁和非公平锁的使用场景取决于具体的应用场景和需求。
- 对于公平锁,由于它能够按照请求的顺序分配锁,因此适用于对于线程获取锁的顺序比较敏感的场景,例如控制并发访问数据库的操作,需要保证各个线程操作的先后顺序。
- 对于非公平锁,由于它能够更快地获取锁,因此适用于锁竞争不激烈、锁占用时间较短的场景,可以提高并发性能。另外,在一些不需要严格的线程执行顺序的场景中,也可以选择使用非公平锁。
代码示例上我们依旧以电商为大背景来分析。
公平锁的应用示例
在订单并发访问场景下,我们希望各个线程按照请求的顺序获取锁,保证访问顺序的正确性。因此,我们可以使用公平锁来实现,例如使用ReentrantLock类的构造函数中指定fair参数为true,即可创建一个公平锁。下面是一个简单的示例代码:
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 18:31 */ public class OrderFairService { // 创建公平锁 private Lock lock = new ReentrantLock(true); // 订单数量 private int orderCount; // 添加订单 public void addOrder() { lock.lock(); try { // 处理订单逻辑 orderCount++; } finally { lock.unlock(); } } // 获取订单数量 public int getOrderCount() { lock.lock(); try { return orderCount; } finally { lock.unlock(); } } }
在上述代码中,我们通过创建一个ReentrantLock实例并指定fair参数为true来创建一个公平锁,然后在addOrder()和getOrderCount()方法中分别使用lock()和unlock()方法来控制并发访问。
非公平锁的应用示例
在订单并发访问场景下,如果各个线程之间的访问没有严格的顺序要求,并且锁竞争不是很激烈,我们可以使用非公平锁来提高并发性能。例如,我们可以使用ReentrantLock类的默认构造函数来创建一个非公平锁,示例代码如下:
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 18:32 */ public class OrderNonFairService { // 创建非公平锁 private Lock lock = new ReentrantLock(); // 订单数量 private int orderCount; // 添加订单 public void addOrder() { lock.lock(); try { // 处理订单逻辑 orderCount++; } finally { lock.unlock(); } } // 获取订单数量 public int getOrderCount() { lock.lock(); try { return orderCount; } finally { lock.unlock(); } } }
在上述代码中,我们创建了一个默认的ReentrantLock实例,然后在addOrder()和getOrderCount()方法中同样使用lock()和unlock()方法来控制并发访问。由于这是一个非公平锁,因此各个线程获取锁的顺序可能不是按照请求的先后顺序,但可以提高并发性能。
六、可重入锁 VS 非可重入锁
synchronized关键字是可重入锁,而ReentrantLock类也是可重入锁的实现
(一)定义分析
可重入锁和不可重入锁是锁的两种基本属性,指的是在同一个线程中是否可以重复获取同一个锁。
可重入锁定义
可重入锁,也称为递归锁,是指线程可以在持有锁的情况下再次获取同一把锁而不会被阻塞。它的作用是避免死锁,并简化了编程模型。在可重入锁的实现中,一般会维护一个计数器来记录当前线程持有该锁的次数,每次获取时计数器加1,释放时计数器减1,只有当计数器变为0时才真正释放锁。
可重入锁的好处在于可以减少锁的竞争,提高代码执行效率。但需要注意,如果计数器未正确维护,可能会导致死锁或资源不释放的问题。
可重入锁举例 (来自不可不说的Java“锁”事 - 美团技术团队):有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。
不可重入锁定义
不可重入锁,也称为普通锁,是指线程在持有锁的情况下,不能再次获取同一把锁,否则会被阻塞。在不可重入锁的实现中,如果当前线程已经持有该锁,那么再次获取该锁时就会被阻塞。
不可重入锁能够保证线程安全,但可能会导致代码效率较低的问题。
不可重入锁举例 (来自不可不说的Java“锁”事 - 美团技术团队):如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
简单举例展示
在选择锁的时候需要根据具体情况选择适合的锁类型,尽量避免死锁和资源不释放的问题,并保证代码的执行效率。
举个例子,如果某个方法中需要多次获取同一把锁,可以使用可重入锁,例如下面的示例代码:
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 18:52 */ public class CounterTest { private int count; private Lock lock = new ReentrantLock(); public void increment() { lock.lock(); try { count++; System.out.println(Thread.currentThread().getName() + ": count=" + count); } finally { lock.unlock(); } } }
而如果多个方法需要获取同一把锁,可以使用不可重入锁,例如下面的示例代码:
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 18:54 */ public class ResourceTest { private Lock lock = new ReentrantLock(); public void methodA() { lock.lock(); try { // do something } finally { lock.unlock(); } } public void methodB() { lock.lock(); try { // do something } finally { lock.unlock(); } } }
(二)使用场景与代码示例
使用场景建议
- 可重入锁:由于可重入锁可以被同一线程多次获取,因此它的递归调用非常方便,同时也很安全。可重入锁适用于那些需要在同一个线程中进行多层次资源申请和释放的场景,例如递归函数或者内部调用其他方法时需要获取同一个锁的场景。
- 不可重入锁:不可重入锁适用于不需要在同一个线程中进行多层次资源申请和释放的场景。由于它不能被同一线程多次获取,因此可以保证线程间的资源竞争,防止死锁的发生。但是需要注意,不可重入锁在使用时需要保证代码的正确性,以免造成死锁或其他异常情况。
下面以电商为背景,举例说明可重入锁和不可重入锁的使用场景和代码实现。
可重入锁示例代码
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 18:59 */ public class OrderRTLockService { private Lock lock = new ReentrantLock(); private int count = 0; public void addOrder() { lock.lock(); try { // 订单号生成 count++; System.out.println(Thread.currentThread().getName() + "生成订单号:" + count); // 其他操作 } finally { lock.unlock(); } } public void deleteOrder() { lock.lock(); try { // 订单删除 System.out.println(Thread.currentThread().getName() + "删除订单号:" + count); count--; // 其他操作 } finally { lock.unlock(); } } }
在上述代码中,通过使用 ReentrantLock 实现可重入锁。addOrder 和 deleteOrder 方法都需要获取同一个锁进行操作,这样可以保证订单号的一致性,同时也保证了线程安全。
不可重入锁示例代码
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 19:00 */ public class GoodsNRTLockService { private Lock lock = new ReentrantLock(false); // 非公平锁 private int stock = 10; public void buyGoods() { lock.lock(); try { if (stock > 0) { stock--; System.out.println(Thread.currentThread().getName() + "购买成功,当前库存:" + stock); } else { System.out.println(Thread.currentThread().getName() + "购买失败,库存不足"); } } finally { lock.unlock(); } } }
在上述代码中,通过使用 ReentrantLock(false) 实现不可重入锁(非公平锁)。buyGoods 方法需要获取同一个锁进行操作,如果库存不足,线程会被阻塞等待,直到锁被释放。由于这是一个不可重入锁,所以同一线程不能重复获取锁,从而避免了死锁的发生。
七、独享锁 VS 共享锁
(一)定义分析
独享锁(也称为排他锁)和共享锁是在多线程并发访问共享资源时,为了保证数据一致性和并发性而使用的锁类型。
独享锁定义
也称为排他锁,同一时间只允许一个线程访问被锁定的资源,其他线程需要等待该线程释放锁后才能访问。独享锁一般用于写操作,比如在数据库中更新或删除数据时使用。
共享锁定义
也称为读锁,允许多个线程同时访问被锁定的资源,但是不允许对该资源进行写操作。共享锁一般用于读操作,比如在数据库中查询数据时使用。
展开说说Synchronized和Lock
独享锁和共享锁的选择应该根据业务需求和数据访问的特点来进行选择。如果业务场景需要频繁进行写操作,则应该选择独享锁,如果业务场景以读操作为主,可以使用共享锁来提高并发性能。
对于Synchronized,它是一种独享锁,也称为内置锁。在Java中,每个对象都有一个内置锁,可以通过Synchronized关键字对对象进行加锁,保证同一时间只有一个线程可以访问被锁定的代码块或方法。因此,在Synchronized的作用下,同一时间只允许一个线程访问被锁定的资源,其他线程需要等待该线程释放锁后才能访问。
对于Lock,它也可以是独享锁,也可以是共享锁。ReentrantLock是独占锁。如果需要使用共享锁,可以使用 ReadWriteLock 接口及其实现类 ReentrantReadWriteLock,其中的 readLock() 方法获取共享读锁,writeLock() 方法获取独占写锁。
(二)使用场景与代码示例
独享锁使用场景建议
- 当资源访问是互斥的,即同一时间只能由一个线程访问时,可以使用独享锁;
- 如果锁的获取时间很短,可以使用synchronized,它是 JVM 内置的锁,使用起来比较方便;
- 如果需要更高的性能和更多的灵活性,可以使用 ReentrantLock,它支持可重入、公平/非公平、中断响应、定时锁等功能;
- 但是,需要注意的是,在使用独享锁时,一定要防止死锁。
共享锁使用场景建议
- 当资源访问可以并发读,但是写操作需要排他时,可以使用共享锁;
- 使用共享锁可以提高并发读的性能,因为多个线程可以同时读取数据;
- 如果需要同时支持读和写操作,可以使用 ReentrantReadWriteLock,它支持多个线程同时读取数据,但写操作需要独占锁;
- 但是,需要注意的是,在使用共享锁时,必须防止写操作和读操作互相等待,导致死锁。
代码示例,以下是电商系统中使用独享锁和共享锁的分析
独享锁示例
在电商系统中,商品的库存是一个重要的资源,需要确保同时只有一个线程能够对其进行修改,以防止出现超卖等问题。这种情况下,可以使用ReentrantLock来实现独享锁,如下所示:
package org.zyf.javabasic.thread.lock; import java.util.concurrent.locks.ReentrantLock; / * @author yanfengzhang * @description * @date 2022/5/3 20:05 */ public class GoodsLockService { private int stock = 0; private ReentrantLock lock = new ReentrantLock(); public void reduceStock(int num) { lock.lock(); try { if (stock >= num) { stock -= num; } } finally { lock.unlock(); } } //其他方法省略... }
在上述代码中,reduceStock方法使用了ReentrantLock来保证对stock属性的修改是原子性的。当一个线程获取到锁之后,其他线程需要等待其释放锁之后才能进行修改。
共享锁示例
假设电商系统中有一个商品评论的功能,多个用户可以同时对同一件商品进行评论,这种情况下可以使用共享锁,如下所示:
package org.zyf.javabasic.thread.lock; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock; / * @author yanfengzhang * @description * @date 2022/5/3 20:05 */ public class GoodsComments { private Map<Long, List<Comment>> commentsMap = new HashMap<>(); private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public void addComment(Long goodsId, Comment comment) { rwLock.writeLock().lock(); try { List<Comment> comments = commentsMap.get(goodsId); if (comments == null) { comments = new ArrayList<>(); commentsMap.put(goodsId, comments); } comments.add(comment); } finally { rwLock.writeLock().unlock(); } } public List<Comment> getComments(Long goodsId) { rwLock.readLock().lock(); try { return commentsMap.get(goodsId); } finally { rwLock.readLock().unlock(); } } //其他方法省略... class Comment { } }
在上述代码中,addComment方法需要对评论进行写操作,因此使用了写锁来进行保护;而getComments方法只是对评论进行读取,因此使用了读锁来进行保护。由于读操作不会对数据进行修改,因此可以同时有多个线程持有读锁,提高了并发度。
八、总结
Java中的锁机制是并发编程中的基石,掌握并合理运用不同类型的锁,可以有效避免并发问题,提升程序的性能与可维护性。通过本文的总结与分析,我们回顾了常见的锁类型:从最基本的synchronized
和ReentrantLock
,到更加细粒度控制的ReadWriteLock
和StampedLock
,每种锁的设计理念和应用场景都具有其独特的优势与适用范围。
首先,synchronized
是最基础的同步手段,它简洁易用,但在复杂场景中可能会带来性能瓶颈。ReentrantLock
提供了更多的控制选项,能够解决synchronized
的许多限制,例如可中断锁、锁的尝试获取等,适用于高并发的应用场景。ReadWriteLock
和StampedLock
则分别在读多写少和高性能需求的场合表现尤为突出,它们通过区分读写操作,提升了并发性能,减少了锁的竞争。
然而,在使用锁时,我们也需要小心死锁、饥饿、活锁等并发问题的发生。因此,在选择和使用锁时,我们不仅要考虑其功能,还需要考虑其对性能的影响、正确性保障以及代码的可读性。
总之,Java中的锁机制丰富而强大,掌握每种锁的特性和适用场景,将为我们的并发编程带来巨大的帮助。在实际开发中,我们应该根据具体的应用需求选择合适的锁,并通过良好的编程习惯,确保程序在并发环境下的正确性和高效性。
参考文献与链接
1.不可不说的Java“锁”事 - 美团技术团队
2.https://www.cnblogs.com/songjilong/p/13710455.html
3.Java CAS 原理剖析 - 掘金
4.Java中的锁_朱小厮的博客-CSDN博客
5.Java并发——关键字synchronized解析 - 掘金
6.聊聊并发(二)——Java SE1.6中的Synchronized_Java_方腾飞_InfoQ精选文章
7.Java多线程(十)之ReentrantReadWriteLock深入分析 - goto-array的个人页面 - OSCHINA - 中文开源技术交流社区
8.java - 解释琐事 : How many live collections are in a typical heap? - IT工具网
9.【聊聊Java】Java中的锁升级过程 — 无锁->偏向锁->轻量级锁->重量级锁-六虎
10."Java并发编程:synchronized详解":https://www.cnblogs.com/dolphin0520/p/3920373.html
11."Java并发编程:ReentrantLock详解":https://www.cnblogs.com/dolphin0520/p/3923167.html
12."Java并发编程:ReadWriteLock详解":https://www.cnblogs.com/dolphin0520/p/3923168.html
13."Java并发编程:Semaphore详解":https://www.cnblogs.com/dolphin0520/p/3920397.html
14."Java并发编程:CountDownLatch详解":https://www.cnblogs.com/dolphin0520/p/3920397.html
15."Java并发编程:CyclicBarrier详解":https://www.cnblogs.com/dolphin0520/p/3920397.html
16."Java并发编程:死锁及其避免方法":https://www.cnblogs.com/dolphin0520/p/3933551.html
17."Java并发编程:锁的性能比较":https://www.cnblogs.com/dolphin0520/p/3938914.html
18.《Java并发编程艺术》
今天的文章 锁的艺术:Java并发中的常用锁策略与实践分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/bian-cheng-ji-chu/99677.html