写点什么

Java“锁”事

用户头像
中原银行
关注
发布于: 3 小时前
Java“锁”事

引言

在并发编程中,我们经常遇到多个线程操作同一个资源,作为开发者必须考虑如何维护数据一致性,这时候锁的重要性就体现了出来。什么是锁呢,锁是实现访问共享资源的多个线程或进程同步的重要机制,在并发场景下强制对资源进行访问控制,从而解决数据不一致的问题。本文通过对锁进行分类,再使用对比的方式向大家介绍主流锁 Synchronized、Lock 接口的实现类 ReentrantLock 的特性、适用场景以及锁优化方面的知识。

1、锁的基本分类

Java 中的锁有很多种,可以按照不同的设计思想进行分类,下面是对常用锁的基本分类和概述。

1.1 乐观锁和悲观锁

按照是否对资源加锁,可以分为乐观锁和悲观锁。

乐观锁是一种乐观思想,每次去操作数据的时候都认为别的线程不会修改,但是在更新的时候会判断操作过程中其他线程有没有去更新这个数据,在写入时先读取当前版本号,与上一次写入完成后的版本号进行比较,如果一样则更新,否则失败,重复“读->比较->写”的操作。

悲观锁就是悲观思想,每次去拿数据的时候都认为别的线程会修改,所以每次在读写数据的时候都会上锁,这样别的线程想读写这个数据就会阻塞直到拿到锁。Java 中的悲观锁有 Synchronized、ReentrantLock 等。

1.2 公平锁和非公平锁

按照多线程竞争时获取锁的公平性,锁可以分为公平锁和非公平锁。和非公平锁。

公平锁是指多个线程按照申请锁的顺序去获得锁,线程会直接进入阻塞队列中排队,永远都是队列的第一位才能获取锁。

非公平锁是指多个线程去获取锁的时候,都会直接去尝试获取,获取不到,再进入等待队列,如果能获取到,就直接获取。

1.3 重入锁和非重入锁

按照一个线程是否可以多次获取同一个锁,锁可以分为重入锁和非重入锁。

可重入锁指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。JDK 中提供的锁都是可重入锁。

不可重入锁在线程获取锁时只判断锁是否被持有,只要被持有,所有申请锁的线程(包括已获得锁的当前线程)都将等待,直到锁被释放。

2、常用锁分析

在 Java 中,实现锁的手段主要有 synchronized 关键字和 Lock 接口,本章节将围绕这两者进行展开。

2.1 Synchronized

synchronized 是 Java 内置的关键字,提供了一种独占的加锁方式,获取锁和释放锁都由 JVM 实现,是一种悲观、可重入的非公平锁,在语法表示上有三种形式。

(1)修饰实例方法,对当前实例加锁,进入同步代码前要获得当前实例的锁;

(2)修饰静态方法,对当前类对象加锁,进入同步代码前要获得当前类对象的锁;

(3)修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

2.1.1 锁存放的位置

上面一直说的是锁住对象,那么锁究竟在哪里? synchronized 用的锁在 Java 对象头中,对象头在对象的最前端,包含 MarkWord、指向类的指针、数组长度(数组对象)。MarkWord 用于存储对象自身的运行时数据当某个对象被 synchronized 当作锁时,围绕这个锁的一系列操作都和 MarkWord 有关,下图是 32 位虚拟机下 MarkWord 中包含的锁状态及字节位数信息。

2.1.2 Synchronized 实现原理(重量级锁)

Synchronized 是基于 Monitor 实现的,在 HotSpot 虚拟机中,Monitor 是基于 C++的 ObjectMonitor 类实现的,每个 Java 对象都可以关联一个 Monitor 对象,Monitor 对象中包含持有锁的线程信息、等待队列等,其成员主要包括:

(1)_owner:指向持有 ObjectMonitor 对象的线程;

(2)_entryList:存放处于等待锁 block 状态的线程队列;

(3)_waitSet:存放处于 wait 状态的线程队列,调用 wait()方法的线程;

(4)_recursions:线程重入的次数,线程占用锁时,_recursions++,线程退出时,_recursions--。

如果使用 synchronized 给对象加锁之后,该对象的 MarkWord 就被设置指向了 Monitor 对象(重量级锁)的指针,Java 对象与 Monitor 关联。

synchronized 是通过进入和退出 Monitor 来实现锁机制的, 如果同步的是代码块,编译时会直接在同步代码块前加上 Monitorenter 指令,进入 Monitor 对象,代码块后加上 Monitorexit 指令,表示退出退出 Monitor 对象,称为显示同步。如果同步的是方法,常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 Monitor,获取成功之后才能执行方法体,方法执行完后再退出 Monitor,称为隐式同步。

多个线程竞争锁时,竞争成功的线程被标记为 owner,并将 Monitor 的进入数 recursions 置为 1,此线程已拥有该 Monitor 的所有权,允许它重新进入 Monitor,重新进入时 recursions 继续累加,竞争失败的线程会在 entryList 队列中阻塞等待。能退出 Monitor 的线程,一定是拥有当前对象的 Monitor 所有权的线程,线程退出时,recursions 减 1,如果 recursions 为减为 0,当前线程就不再拥有 Monitor 的所有权,entryList 中阻塞的线程可以尝试获取该 Monitor 的所有权。如果 owner 线程调用 wait()方法,则其释放对象锁并进入 waitSet 中等待被唤醒,owner 被置空,entryList 中阻塞的线程再次竞争锁。

2.2 Lock

与 Synchronized 不同,Lock 是 JDK 提供的一个接口,而我们在开发中需要直接面对的通常只有它的实现类 ReentrantLock、ReentrantReadWriteLock。ReentrantLock 是一种默认非公平但可以实现公平的悲观、可重入锁。它提供了多种 API,如下图所示。

ReentrantLock 相关的类图如下图所示,ReentrantLock 中有三个静态内部类,分别是 Sync、FairSync 和 NonfairSync,ReentrantLock 构造方法默认实现 NonfairSync,传入参数 ture 可以实现 FairSync,FairSync 和 NonfairSync 均继承自 Sync,Sync 又继承自 AbstractQueuedSynchronizer,获取锁和释放锁主要逻辑在这个 AQS 中。

如下图所示,在 AQS 中,存储阻塞线程的结构是双向链表和 int 型变量 state(锁状态值),它们都被 volatile 修饰。

那么线程是如何获取锁的呢?整个 ReentrantLock 的加锁过程,可以分为三个阶段:1、尝试加锁;2、加锁失败,进入队列;3、线程入队列后,进入阻塞状态。以 NonfairSync 为例,获取锁的入口是 lock.lock()方法,释放锁的入口是 lock.unLock()方法。具体获取和释放的逻辑如下图所示。

3、锁优化

3.1 勤劳的 JVM

JDK1.6 之前,synchronized 是重量级锁,线程在获取和释放锁时操作系统都需要在用户态与内核态之间切换,性能比较低,经过后续版本中 JVM 的优化,性能得到了显著的提升。JVM 优化方式主要包含锁膨胀、锁消除、锁粗化、锁细化、适应性自旋。

3.1.1 锁膨胀

锁膨胀是指 synchronized 从无锁一直升级到重量级锁的过程。

无锁表示没有对资源进行锁定,所有线程可以访问同一资源,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生。

偏向锁是指在在大多数情况下,锁不存在多线程竞争,总是由一个线程获得,这种情况下线程会获得偏向锁,偏向锁偏向于第一个获得锁的线程。首个线程获取锁后,会在 MarkWord 中设置偏向锁的线程 ID,同一个线程进入同步块时检测 MarkWord 中的线程 ID 和访问的线程 ID 是否一致,一致的话表示没有其他线程干扰,可以直接进入同步块。

轻量级锁是指当锁是偏向锁的时候被另一个线程所访问,线程 ID 比较出现不一致的情况,偏向锁就会膨胀为轻量级锁,其他线程会通过自旋的形式(循环尝试)获取锁,不会阻塞。

重量级锁是指当锁为轻量级锁的时候,线程自旋到一定次数还没有获取到锁或者自旋时第三个线程访问锁,短时间自旋效果比较好,长时间自旋会消耗 CPU 资源,所以这种情况下获取不到锁的线程就应该进入阻塞,该锁膨胀为重量级锁。

3.1.2 锁消除

锁消除指的是在某些情况下,JVM 借助逃逸分析技术检测同步代码块的锁对象是否只能被一个线程访问而不会暴露给其他线程,如果通过分析只能被一个线程访问,就会将这段代码所属的同步锁消除掉。我们用以下测试案例验证下 StringBuffer 和 StringBuilder 的性能差异。

从测试结果可以看出,StringBuffer 和 StringBuilder 的性能没有明显区别,在测试方法中,sb 对象属于局部变量,作用域只在方法内部,不会被外部线程访问,没有逃逸,不存在线程安全问题,JVM 对 StringBuffer 的 append 方法进行了锁消除。如果 return sb,外部线程可能访问到 sb,就可能加锁,如果 return sb.toString(),发布的就不是 sb 对象,不会加锁。

3.1.3 锁粗化

锁粗化是指 JVM 如果检测到对同一个对象执行了连续的加锁和解锁的操作,或者在循环体中加锁,就会将锁的范围扩大到整个操作的外部,从而提升程序的执行效率。

3.1.4 锁细化

与锁粗化相反,锁细化是指 JVM 如果检测到加锁的代码块或方法中只有一小部分需要同步,那么就将锁细化到这一小部分代码上,减小锁粒度。

3.1.5 适应性自旋  

适应性自旋是指线程在获取一个已经被占有的锁时,自动进入循环状态,自旋的时间(次数)不固定。如果在同一个锁对象上,该线程上次自旋成功获得过锁,JVM 会认为这次自旋也是很有可能成功,允许自旋更多次数或者更长时间。如果线程自旋很少成功获得过锁,之后尝试获取这个锁时可能不再进行自旋,直接进入阻塞。

3.2 Synchronized VS Lock

在介绍了 jvm 锁优化后,我们看下 synchronized 和 lock 的区别。

1、synchronized 是 Java 中的一个关键字,是 Java 的内置特性,是 JVM 层面的实现。Lock 是 Java 中的一个接口,是 JDK 层面的实现,有许多实现类和相关类。

2、两者都是可重入锁。synchronized 只能是非公平锁,不能设置超时时间,线程执行完代码块自动释放锁,或者线程执行异常时释放锁,不能主动解锁,等待锁的线程无法响应中断。Lock 实现类 ReentrantLock 可以指定是公平还是非公平锁,提供了可定时的、可轮询的与可中断的锁获取操作,释放锁时必须在 finally 块中调用 unlock 方法去释放锁,否则锁无法释放。

3、synchronized 语法比较简洁,但是不能由开发者控制取锁释放锁的过程。Lock 实现类 ReentrantLock 在语法上略微繁琐,但提供了丰富的 api,以应对不同的场景,可以主动控制取锁、释放锁以及响应中断,在使用中比 synchronized 更为灵活。

4、在 JDK1.6 及以后,synchronized 经过优化,如果没有大量的线程竞争时,两者在性能上没有明显的区别。当资源竞争非常激烈时,synchronized 就会锁膨胀为重量级锁,Lock 的性能要高于 synchronized,在具体使用时根据实际情况选择。

3.3 分段锁

分段锁的设计目的是细化锁的粒度,与上面说的锁细化不同,分段锁不是 JVM 在锁优化中的功能,而是一种设计思想,主要应用在集合中,当操作不需要更新整个集合的时候,就仅仅针对集合中的一部分数据进行加锁操作,锁粒度小。

CurrentHashMap 底层就用了分段锁,在版本演进中锁的粒度由 Segment 到 Node,越来越小。而其他一些线程安全的集合,比如 Vector、HashTable、Collections.synchronized 方法返回的线程安全的集合,锁粒度都在整个集合或锁对象上,锁粒度大,与 CurrentHashMap 的区别体现在这里。

4、总结

本文主要介绍了 Java 锁方面的一些基本分类,锁的主要实现手段 synchronized 和 Lock 的实现类 ReentrantLock,并对两者进行了使用方式和性能上的比较。Java 本身对锁的设计和封装已经降低了它的使用难度,作为开发者需要对其原理有一定的了解,便于在工作中根据不同的业务场景选择不同的锁,提高程序的运行效率。除了文章中介绍的锁之外,Lock 的实现类还有读写锁 ReadWriteLock,对读操作和写操作进行分离,CurrentHashMap 分段锁的演进和实现原理也非常精巧,感兴趣的读者可以了解下它的使用方式和实现原理。

参考文献

《深入理解 Java 虚拟机》

《Java 并发编程的艺术》

《Java 并发编程实战》

https://xie.infoq.cn/article/da31f834180773eb37cd1b30a


文章转自https://mp.weixin.qq.com/s?__biz=MzkyOTIxMzc3Mw==&mid=2247496274&idx=1&sn=5a7d5b9c9eb860769cceb323145778ed&chksm=c20e4e70f579c766d851dca18ce21c1498848ae5505b887f49682e9c3459cd4776cae8513bd3&mpshare=1&scene=2&srcid=0918D6b4Dg4Pl4lxqeMehDAX&sharer_sharetime=1631936798437&sharer_shareid=b21d60a4a54f965acee8d0dfe21d19bc#rd

发布于: 3 小时前阅读数: 21
用户头像

中原银行

关注

打造科技驱动、创新引领的数字化未来银行。 2020.02.06 加入

中原银行是河南省属法人银行,总部位于河南省郑州市。我行坚持“科技立行、科技兴行”,秉承“稳健 创新 进取 高效”理念,发展移动金融、线上金融,提升综合金融服务能力。 官方网站:http://www.zybank.com.cn/

评论

发布
暂无评论
Java“锁”事