前言
在Java中为了保证操作线程的安全性,我们引入了锁的概念,但随之而来的性能问题让我们在不愿意放弃安全性保证的前提下提出了优化过的锁。在这篇文章中,荔枝会着重梳理不同的锁的概念和普通锁的执行机制相关知识,同时也会对Java中三个可以操作锁的类的用法和特点进行区分。希望能帮助到有需要的小伙伴~~~
文章目录
三、Synchronized、Lock和ReentrantLock特点和区别
一、线程安全
Java中的锁和MySQL中的锁是类似的,只不过MySQL中的锁是针对于数据库事务,而Java中的锁是用来操作线程事务滴,但最终二者都是要确保数据一致性。也就是说之所以需要给线程加锁,是为了确保线程安全。在正式开始梳理锁的知识之前,我们需要明确线程安全的实现必须保证的三个维度:原子性、有序性和可见性。
什么是线程安全?
如果程序采用多线程的方式来同时运行某一段封装好的代码,如果运行结果与单线程运行的结果是一致的,那么这段程序就称之为线程安全的。
一个简单的例子来理解保证线程安全的必要性
我们定义一个int类型的变量为1,这时候同时开启了两个线程Thread1和Thread2,这两个线程同时执行i++的操作,问这时候i的值是多少?
由于开启了两个线程,我们先假设Thread1执行i++操作成功。看Thread2获取i的时机是在Thread1前还是后,如果是在Thread1返回了i值后,那么i的取值最后就是2;如果是在Thread1返回i值前,那么就会取到1。这时候我们无法确定i的值。
保证线程安全的三个维度
- 原子性:就是该线程的相关操作不会被其它的线程干扰,运行结果与通过同步机制来实现是一致的;
- 可见性: 若一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的;
- 有序性:保证线程内串行语义,避免指令重排等,即保证程序执行的顺序按照代码的先后顺序执行。
实现线程安全
- 原子性(Atomicity):单个或多个操作是要么全部执行,要么都不执行
- Lock:保证同时只有一个线程能拿到锁,并执行申请锁和释放锁的代码
- synchronized:对线程加独占锁,被它修饰的类/方法/变量只允许一个线程访问
- 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
- volatile:保证新值能立即同步到主内存,且每次使用前立即从主内存刷新;
- synchronized:在释放锁之前会将工作内存新值更新到主存中
- 有序性(Ordering):程序代码按照指令顺序执行
- volatile: 本身就包含了禁止指令重排序的语义
- synchronized:保证一个变量在同一个时刻只允许一条线程对其进行lock操作,使得持有同一个锁的两个同步块只能串行地进入、
二、锁的基本概念
了解完线程安全,我们明白了给线程加锁的缘由。那么线程中的锁有哪些呢?按照锁的功能分类,可以将所有的锁分为两类:共享锁(读锁)和排他锁(写锁)。
- 共享锁: 允许多个线程获取读锁,同时访问同一个资源。
- 排他锁:顾名思义该所具有排他性,只允许一个线程获取写锁,不允许同时访问同一个资源。
加锁确实可以解决线程安全的问题,比如加上写锁的话,其实就相当于把并行任务变成串行任务,这样子就会带来并发性能问题。出现性能问题就需要相应改进锁并给出优化方案,Java中有相应的六种锁的优化机制。但是在开始梳理相应的锁优化方法前我们需要明确加锁到底意味着什么?为什么加锁会带来性能问题?
2.1 加锁本质理解
Java中加锁的本质就是不同的线程之间竞争同步状态,可以理解成一个访问资格或者是一个标记,如果一个线程竞争到了资源,那么这个标记就会被修改,其余的线程在竞争中就无法得到这个锁直至该线程释放锁资源。在Synchronized中通过操作系统层面的Mutex机制来实现同步状态,不同的线程之间通过竞争Mutex机制来实现互斥状态的处理。这个过程是通过调用一些内核指令来实现的,那么就不可避免涉及到用户态到内核态的切换,这个切换过程会占用CPU的资源并消耗性能。
切换过程消耗性能原因:
第一个原因是:在用户态到内核态的切换过程中,用户线程会阻塞等待并切换到内核线程来运行,那么就涉及到当前用户态执行指令的上下文的保存以及切换到内核线程后的执行指令,这就涉及到了线程的阻塞唤醒以及上下文的保存,这部分的操作是比较消耗性能滴。
第二个原因是在线程层面,如果某一个线程抢到了锁资源,那么其余线程就会阻塞等待。
线程性能影响的体现:
- 竞争同步状态时涉及上下文切换和保存;
- 线程的阻塞和唤醒,会涉及到切换操作影响性能;
- 并行到串行的改变
2.2 锁的优化机制
在影响线程性能的因素中,我们只能通过优化线程的阻塞和唤醒过程来减少切换操作以提高性能。
2.2.1 锁的粒度优化
通过优化Java中持有锁的粒度大小来改变锁持有时间或者是锁的作用范围,从而减少阻塞和竞争,提高并发性能。在优化锁的粒度时,我们可以将锁的粒度细化到最小范围,即只锁定必要的共享资源,而不是整个对象或方法。Java中中的synchronized和ReenrantLock关键字可以实现细粒度锁。当然也可以通过使用分段锁和减少锁的持有时间来减少锁的竞争。
2.2.2 无锁化编程和乐观锁
无锁化编程(Lock-Free Programming)是一种并发控制策略,它通过避免使用传统的锁(如互斥锁)来实现多线程之间的同步。在无锁化编程中,线程通过原子操作和其他无锁算法来操作共享资源,从而避免了线程间的阻塞和竞争。这意味着即使在高并发的情况下,线程不会因为等待锁而被阻塞,从而提高了并发程序的性能和可伸缩性。无锁化编程的实现通常依赖于硬件原子操作指令或特殊的数据结构(如CAS - Compare-and-Swap),它们允许线程在不使用传统锁的情况下进行原子性的数据修改。CAS是一种原子操作,用于实现多线程环境下的并发控制,它是无锁化编程中的关键机制之一。
缺点:无锁化编程可能会增加代码的复杂性,并且在复杂的情况下,可能会导致更多的线程冲突和错误。
乐观锁通常基于CAS(Compare-and-Swap)操作实现。当线程想要更新共享资源时,它会先读取该资源的当前版本号或时间戳(作为期望值),然后通过CAS操作尝试将新值写入该资源(这里有一个比较的操作)。如果CAS操作成功,说明没有其他线程在此期间修改过该资源,更新成功。如果CAS操作失败,说明其他线程已经修改了资源,此时需要重新读取最新值并再次尝试更新,直到更新成功为止。
Tips:乐观锁主要是通过数据版本和时间戳来控制多线程并发数据修改的安全性。
悲观锁
顾名思义,悲观锁与乐观锁相反,认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程挂起,直到这个线程释放锁然后再竞争锁资源。
2.2.3 偏向锁、重量级锁和轻量级锁(减少锁竞争)
自旋锁
轻量级锁又称为自旋锁,顾名思义就是线程在阻塞前会执行一个循环过程一直请求锁资源访问锁的同步状态。 我们通过一个图来弄清以下这个过程:
在Thread2竞争到锁资源、给Thread1加上自旋锁后Thread1会在线程阻塞之前一直执行自选尝试来竞争锁资源,这样就可以减少线程的阻塞和唤醒次数,提高性能。
缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
自适应自旋
自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
偏向锁
如果给一个线程T1加上偏向锁,当该线程在第一获得锁资源的时候会把该锁偏向T1,在T1再次进入锁时就不需要再次同其它的线程进行竞争了。
重量级锁
可以简单理解重量级锁就是传统的锁模式, 正常进行大量的用户态到内核态的切换和线程阻塞。我们把这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。
2.2.4 锁消除和锁膨胀
锁消除
锁消除其实是编译器或运行时系统在分析代码时,消除掉不会被线程竞争的共享数据的锁的过程。
锁膨胀
锁膨胀,或者叫做锁粗化,就是将多个连续的细粒度锁合并为一个粗粒度锁的优化过程。简单理解在一个循环中我们多层嵌套加锁或者多次进行锁操作会带来更大的性能消耗,因此采用将加锁的范围扩展到整个操作序列的外部的方式来优化,可以看做锁的粒度的增大。
2.2.5 读写锁优化
读写锁的优化过程就是在读多写少的场景下,在读的场景写使用读锁,在写的场景下使用写锁。读锁与读锁之间不互斥、读锁与写锁之间互斥、写锁与写锁之间互斥。
2.2.6 公平锁和非公平锁
公平锁
公平锁是指多个线程按照请求锁的顺序来获取锁。当多个线程同时竞争一个公平锁时,锁将按照它们的请求顺序,根据等待队列的排序来逐个分配给这些线程。如果有线程在等待获取锁,那么在锁释放时,等待时间最长的线程将有更高的优先级获得锁。这种方式可以保证锁的获取是按照公平的原则进行的,避免了线程饥饿的问题。
ReentrantLock fairLock = new ReentrantLock(true); // 创建公平锁
ReentrantLock fairLock = new ReentrantLock(false); // 创建非公平锁
非公平锁
非公平锁是指多个线程尝试获取锁时,没有按照请求顺序来进行获取。在非公平锁中,当锁释放时,任何一个正在等待获取锁的线程都有机会获取到锁,即使其他线程已经在等待了较长时间。
- 优点:非公平锁的性能会高于公平锁
- 缺点:可能导致某些线程一直获取不到锁,出现线程饥饿的问题。
2.3 锁的特性
2.3.1 可重入锁
Java中大部分的锁都是可重入的。重入锁指的是一个线程抢占到一个锁资源并在释放锁之前再次竞争同一把锁的时候无需阻塞等待,直接获取锁资源即可。这种锁是通过递归的方式来实现的,所以又称为递归锁,主要作用就是避免死锁的情况出现。
2.3.2 分布式锁
分布式锁是一种在分布式系统中用于协调多个节点对共享资源进行访问的机制。在分布式环境下,由于多个节点之间的并发操作,需要一种可靠的方法来确保在同一时间只有一个节点能够获取到锁,从而保证共享资源的一致性和正确性。在前面中我们知道Java中可以使用synchronized关键字来为线程加锁,而分布式锁是为了解决在分布式架构下的锁的粒度的问题,面向的是进程维度。
三、Synchronized、Lock和ReentrantLock特点和区别
3.1 Synchronized(关键字)
Synchronized是Java语言提供的内置锁机制,通过在方法或代码块上加上synchronized关键字来实现线程同步。它是一种隐式锁,由JVM自动管理锁的获取和释放。
使用场景:适用于简单的线程同步场景,例如对单个方法或代码块进行同步控制。
特点:Synchronized具有自动加锁和释放锁的功能,但不支持公平锁的获取方式。
3.2 Lock(接口):
Lock是Java的并发API提供的接口,它提供了更灵活的线程同步机制,可以显式地控制锁的获取和释放,支持可重入锁、悲观锁、独占锁、互斥锁、同步锁。
使用场景:适用于复杂的线程同步场景,例如需要手动控制锁的获取和释放,或者需要使用公平锁等特定需求。
特点:Lock需要手动编写获取锁和释放锁的代码,并且支持公平锁和非公平锁两种获取方式。
3.3 ReentrantLock(类):
ReentrantLock是Lock接口的一个实现类,提供了可重入锁、悲观锁、独占锁、互斥锁、同步锁。
使用场景:适用于需要支持可重入性的场景,例如在一个方法中调用另一个方法时,可能需要多次获取同一个锁,同时需要手动开启和销毁锁。
特点:ReentrantLock提供了与Synchronized相似的功能,但更加灵活,可以支持公平锁和非公平锁,以及可中断等特性。
3.4 区别
- Synchronized是Java语言提供的内置锁,Lock是Java的并发API提供的接口,ReentrantLock是Lock接口的一个实现类。
- Synchronized是隐式锁,由JVM自动管理锁的获取和释放,而Lock和ReentrantLock需要显示的调用lock和unlock方法手动编写获取锁和释放锁的代码。
- ReentrantLock支持可重入性,即同一个线程可以多次获取同一个锁,而Synchronized不支持。
- Lock和ReentrantLock可以支持更多的高级特性,如公平锁、非公平锁、可中断锁等。而Synchronized只支持非公平锁。
- 在性能方面,Synchronized在JDK 6以后进行了优化,性能较以前有了很大提升,但在高并发情况下,Lock和ReentrantLock通常比Synchronized更有优势。
总结
荔枝通过一篇文章梳理了锁的创建需求以及由性能问题引出的一系列优化后的锁的概念,接着区分了Java中实现锁和线程异步的三种方法的特点和使用场景。总之梳理下来荔枝对于锁的脉络知识也算有了清晰的理解,具体细节的操作可能需要在实际的项目场景中取应用吧哈哈哈~~~希望小伙伴们读完后能有所收获哈哈哈哈,一起加油吧。
今朝已然成为过去,明日依然向往未来!我是小荔枝,在技术成长的路上与你相伴,码文不易,麻烦举起小爪爪点个赞吧哈哈哈~~~ 比心心♥~~~