在 之前的 Java多线程初阶中,我们主要学习的是更贴合于工作实际情况的内容,而在 本文 Java多线程进阶中,我们主要学习的是贴合于面试中的内容。本文以下的内容主要是以理解与记忆为主,接下来,就让我们一起走入 Java多线陈进阶的学习吧 !
一.常见的锁策略
1. 乐观锁 vs 悲观锁
其中,乐观和悲观并不是某一种具体的锁,而是一个具体的锁具有 “乐观” 或 “悲观” 的特性。
乐观:加锁的时候,预测接下来的锁竞争的情况非常激烈,就会针对锁竞争的激烈情况额外做一些工作
悲观:加锁的时候,预测接下来的锁竞争的情况不激烈,就不会针对锁竞争不激烈的情况做额外的工作
锁竞争激烈的情况:有 20个线程同时尝试获取同一把锁,每个线程的加锁频率都特别高,当一个线程尝试进行加锁的时候,这个锁大概率被其他线程占用着
锁竞争不激烈的情况:有 2个线程同时尝试获取同一把锁,每个线程的加锁频率都特别低,当一个
线程进行加锁的时候,这个锁大概率是空闲状态,即没被其他线程所占用
2. 重量级锁 vs 轻量级锁
这两种锁策略是当锁遇到不同的锁竞争场景下(乐观场景 和 悲观场景)的解决方案。
重量级锁:在悲观场景下的应对策略,此时就要付出更大的代价 => 更低效
轻量级锁:在乐观场景下的应对策略,此时付出的代价就比较小 => 更高效
3.挂起等待锁 vs 自旋锁
这两种锁策略是面对上述两种解决方案(重量级锁 和 轻量级锁)的典型实现
挂起等待锁:重量级锁的典型实现,属于操作系统级别,在加锁遇到竞争时,线程就会进入阻塞状态,后续的唤醒就需要系统内核来唤醒
自旋锁:轻量级锁的典型实现,属于应用程序级别,在加锁遇到竞争时,线程一般不会进入阻塞状态,而是通过忙等的方式来进行等待(在 Java多线程初阶中我们提到过,忙等是一种很消耗 cpu 资源的行为,但在乐观场景下,加锁时遇到锁竞争的概率比较小,就算真的不幸遇到了锁竞争,线程也可以很快地就拿到锁。所以,此处的忙等时间会比较短,消耗的 cpu资源也相对较少,这是可以接受的)
其中,我们之前所学的 synchronized 锁 是一种自适应锁,在 JVM 的内部,会帮我们统计好各个锁的竞争激烈程度,当竞争激烈时,synchronized 就会按照轻量级锁(自旋)的方式进行加锁,当竞争不激烈时,synchronized 就会按照重量级锁(挂起等待)的方式进行加锁。
4. 普通互斥锁 vs 读写锁
普通互斥锁(synchronized)的行为分为两种:加锁 与 解锁 ,而读写锁则将加锁操作进行了进一步的细分,其行为分为三种:以读方式加锁、以写方式加锁 和 解锁。
在 Java多线程初阶中,我们知道,当多个线程同时读取同一个数据时,是不会引发线程安全问题的,因为读操作本身就是线程安全的 ,只有涉及了写操作,才会引发线程安全问题。
也就是说,当我们遇到以下这种情况:大部分操作都是读操作,小部分操作是写操作时。如果你在读操作和写操作都加上了普通的互斥锁,这时的锁冲突就会非常严重,会严重地影响性能。这时,我们就可以运用读写锁来解决问题。读写锁,可以确保 读锁 与 读锁 之间不是互斥的,即两个读锁之间不会产生阻塞,只有 读锁 与 写锁 或 写锁 与 写锁 之间会产生互斥,发生阻塞。像这样,我们就可以在保证线程安全的前提下,降低锁冲突的概率,提高性能。
5. 可重入锁 vs 不可重入锁
在之前的 Java多线程初阶中我们知道,synchronized 是 “可重入锁”,即一个线程多次加同一个锁多次并不会产生死锁问题。不可重入锁则相反(即在一个线程多次加同一把锁的情况下会产生死锁问题)。
可重入锁的实现要点:
1.锁要记录下当前是哪个线程拿到了这把锁
2.使用计数器记录下该线程进行了多少次的重复加锁,并在合适的时候进行解锁
6. 公平锁 vs 非公平锁
当一个线程释放所持有的锁之后,这个锁应该以什么样的方式被后面竞争的线程获取呢?现在,有以下两种方式
1.按照先来后到的方式,哪个线程先竞争这把锁,那么就由哪个线程拿到这把锁
2.以概率均等的方式,随机决定由其中的一个线程拿到锁
以第一种方式实现的锁就是公平锁,反之,以第二种方式实现的所就是非公平锁。我们之前所学的synchronized 就是非公平锁。
7.锁升级
锁升级的方向
无锁 => 偏向锁 => 轻量级锁(自旋锁)=> 重量级锁(挂起等待锁)
其中,偏向锁的定义就是在 进行 synchronized 的初期时,并不是真的进行了加锁操作,而是简单做了一个标记,这个标记非常轻量,相比于加锁解锁来说,效率比较高。在该线程运行的过程当中,如果没有其他线程来竞争这把锁,那么当该线程执行到解锁代码时,也只需要简单清除上述标记即可,像这样,该线程的运行过程中就不会涉及到加锁和解锁操作,效率就会比较高。而当有别的线程来竞争这把锁时,该线程就会抢先一步拿到这把锁并进行真正的加锁操作,也就是从偏向锁升级到了轻量级锁。
锁升级时的条件
无锁 => 偏向锁 :代码进入 synchronized 代码块当中
偏向锁 => 轻量级锁 :拿到偏向锁的线程运行过程中有别的线程尝试竞争这把锁
轻量级锁 => 重量级锁 :JVM 发现这把锁的竞争非常激烈时
在 JVM 当中,只提供了 锁升级 而不能 锁降级
8. 锁消除
锁消除是编译器优化的一种体现,在代码编译的过程当中,编译器会判定这个代码逻辑是否真正需要加锁,如果真的不需要加锁,但是在该代码中又有 synchronized 时,编译器就会自动把 synchronized 去掉(编译器进行 锁消除操作 时比较保守,只有当百分之百确定代码不需要加锁操作时才会触发锁消除操作,判定不清楚的情况则不会触发)。
9. 锁粗化
在介绍锁粗化之前,我们先了解一下锁的粒度。
锁的粒度:在加锁与解锁之间,所包含的代码越多,锁的粒度就越粗,所包含的代码越少,锁的粒度就越细。(代码的多少并不是指代码的行数,而是代码实际执行的指令和时间)。
所谓锁的粗化,就是将粒度较细的锁优化为粒度较粗的锁。
当代码中针对三个不同的逻辑都加了不同的三个锁时,我们就可以将这三个逻辑都放入同一个锁中,像这样,我们将三个粒度较细的锁优化为了一个粒度较粗的锁。
注:锁的粒度并不是越粗越好,而是要视情况而定。锁越粗,就会影响到线程的并发程度。如果确实这三件事本身都需要加锁,那么优化为一个粒度较粗的锁是合理的。但是,如果三件事中只有两件事需要加锁,却被优化为了一把锁,就会导致原本可以并行执行的那件事也变成串行执行了,这样就会影响线程的并发程度。
二. CAS
CAS 的全称为 compare and swap,意为比较与交换,一个CAS操作主要包括以下三步
下面,我们假设内存中的原数据V,旧的预期值A,需要修改的新值B
1. 比较A与V是否相等(比较) 2. 如果比较相等,将B写入V(交换) 3. 返回操作是否成功
CAS 本质上是 CPU 的指令(具有原子性),操作系统会将这个指令进行封装并提供一些 api (应用程序编程接口),所以 CAS 就可以在 C++ 中使用了,而由于 JVM 又是又 C++ 实现的,所以JVM 也能够使用这些 api
CAS 最主要的功能就是实现了原子类,比如对 boolean、int、long 等类型进行了封装。像在 Java多线程初阶的内容中,count++ 操作因为不是原子的所以是不安全的,需要进行加锁让其变成原子的来解决多线程问题。但是,我们又知道,加锁的效率是比较低的,于是我们就可以通过 CAS 来实现 count++,在确保性能的同时又保证了线程安全。
使用原子类的目的就是为了避免加锁。
CAS 的一个典型缺陷:ABA问题
使用 CAS 实现线程安全的编程的核心步骤是比较 是否 “相等”,即内存和寄存器中的值是否相等。如果相等,那么就认为在这个线程执行操作的过程中没有其他线程对这个值进行修改,因此接下来的修改操作就是线程安全的,那么就可以将新值写入内存中。
但是,实际上,可能存在以下的这种情况,另一个线程,将内存中的 A 改为了 B,然后又将 B 改为了 A。那么,在第一个线程看来,内存和寄存器中的值是相等的,也就会认为接下来的修改操作是线程安全的并将新值写入内存中,这样就可能会引发线程安全问题,这个问题也称作 ABA 问题。
接下来,我们就来举一个例子来说明这个问题。
就比如说取钱。我们在用手机取钱的时候,在取款的页面点击取款时,由于卡了一下,我们就连续点击了多次取款键,此时就可能出现了两个线程(线程1 和 线程2)来并发执行扣款操作。接下来,我们就按照 CAS 的方式来执行扣款操作。假设我们的账户初始余额为1000。
即最开始时,线程1 和 线程2 的寄存器中保存的余额值 和内存中保存的余额值如下图所示

当我们进行取款操作时,由于此时 寄存器 和 内存 中的值相等,线程1 可以将内存中的值进行修改(即取款操作,假设将该值修改为500),此时,线程2 的寄存器中的余额值 和 内存中的余额值如下所示

此时,若线程2 想要修改内存中的值,由于寄存器中的值并不等于内存中的值,CAS 修改操作就会失败,也就保证了CAS操作的线程安全。
但是,上述的情景只是最普通的情况,在这些普通情况下确实不会发生线程安全问题。那么?要是在极端情况下呢?以下,就是 CAS 中由于极端情况引起的 ABA问题一种情景。
前置条件不变,还是有两个线程(线程1 和 线程2)来同时进行一个取款操作,与之前不同的是,这次多了一个执行存款操作的线程3。那么,当线程1 执行完取款操作的同时,线程3 同步执行完一次存款操作,存取款的金额相同。那么,各个线程的寄存器中的值和内存中的值的变化就会如下所示

此时,我们发现,在经过 线程1 的取款操作 和 线程3 的存款操作之后,线程2 的 寄存器的值 和 内存中保存的值 又恰巧相等了。那么,当线程2 执行取款操作时,就会认为这次操作是线程安全的,也就可以通过 CAS 操作 继续修改内存中的值为500。但是,我们知道,取款操作在线程1就已经执行成功了,线程2 这次执行属于是 重复执行,这也就是 CAS 中的 ABA 问题
解决 ABA 问题的方法
在上述问题中,我们是使用余额的数值来判定中间是否有线程穿插修改过余额的值,但是,余额的值是可以加也可以减的,这样就有可能导致 ABA 问题,因为在其他线程的穿插修改后该数值可能和未穿插修改的值恰好一致。
为了解决这个问题,我们可以引入版本号的概念,版本号只能加不能减。当有线程修改数据时,版本号就会 +1 ,我们就可以通过版本号的数值来判定中间是否有线程穿插修改过余额的值。像这样,就算最后修改过的余额的值和未修改过的余额的值恰好相同,但版本号是绝对不可能相等的,也就是说本次 CAS 操作 就会失败,也就杜绝了发生 ABA 问题的可能。
三. ReentrantLock 可重入锁
ReentrantLock 和 synchronized 是 并列的关系,都是可重入锁,但二者又略有不同。
ReentrantLock 和 synchronized 之间的区别
1. synchronized 是关键字(内部是 JVM 通过 C++ 实现的),而ReentrantLock 是 Java 标准库中提供的一个类
2. synchronized 是通过代码块来控制加锁和解锁,ReentrantLock 则需要使用 lock/unlock 方法来控制加锁和解锁,使用时需注意 unlock方法不被调用的问题
3.ReentrantLock 除了提供 lock 和 unlock 方法之外 还提供了一个 方法 tryLock
tryLock方法:不会阻塞,加锁成功返回 true ,返回失败返回 false,调用者可以通过对返回值的判定来决定下一步要怎么做。(可以设置超时时间,在等待时间达到超时时间后再返回 true 或 false)。
4. ReentrantLock 提供了公平锁的实现(默认是非公平锁)
5.ReentrantLock 搭配的等待通知机制是 Condition类,相比于 wait/notify,功能更加强大
四. 信号量 Semaphore
信号量本质上是一个 计数器 + 阻塞队列,用来控制多线程对共享资源的访问数量,能协调多个线程之间的资源分配。
信号量表示的是 “可用资源的个数”,当我们申请一个资源时,计数器就会 -1(P操作),释放一个资源,计数器就会 +1(V操作)。当计数器为 0 时,继续申请,就会阻塞等待。
二元信号量:信号量的一种特殊情况,初始值为1,计数器取值要么是1,要么是0。等价于 “锁”。
如果是 普通的初始值为 N 的信号量,就可以限制同时有多少个线程来执行该逻辑。
五. CountDownLatch
当我们使用多线程时,常常将一个大的任务拆分为多个子任务,然后再使用多线程来执行这些子任务,从而提高程序的效率。但是,我们又应该如何去衡量这些子任务都完成了呢?整个任务完成了呢?
CountDownLatch是 JUC 下的线程同步工具,底层基于 AQS,内部持有一个计数器,作用:一个或多个线程等待其他 N 个线程全部完成任务后,再继续执行
使用流程如下:
1. 在构造方法中传入参数,表示该大任务被拆分成了多少个子任务(N个,要等待的线程数量)
2. 在每个线程完成一个子任务后调用 countDown 方法,N - 1
3. 主线程中调用 await 方法,等待所有的子任务执行完毕后等待结束,主线程继续执行剩余逻辑
六. 多线程下使用 ArrayList
在多线程的条件下,我们要想在线程安全的前提下使用 ArrayList,有以下三种方式
1. 在相关逻辑中自行加锁 (推荐使用)
2.使用 Collections synchronizedList(new ArrayList)创建一个线程安全的 ArrayList。该方法返回的 List 的各种关键方法(public 修饰的方法)中都是带有 synchronized 修饰的,所以保证了线程安全,但是也由于加了太多的锁而导致效率降低(不推荐)
3. 使用 CopyOnWriteArrayList 写时拷贝,这个方法并不需要加锁
当有线程对数组进行读取数据操作时,一旦有其他线程进行了写操作,就可能会出现线程安全问题(比如说读到了修改了一半的数据等)。而写时拷贝则是在有线程进行写操作时,如果有其他线程进行读操作,那么就直接读取就版本的数据。像这样,虽然有线程在进行写操作,但由于提供了旧版本的数据,不会影响到其他线程的读操作,保证了数据读取的完整性,即要么读到的是旧版数据,要么读到的是新版数据,并不会出现读到仅修改到一半的脏数据。
保存旧版数据的方式:每次写操作都会对原数据进行一次拷贝,即重新创建一个新数组用于存放新版数据,在整个写操作完成之后,再让指向旧版数据的引用指向新版数据。
这整个过程中并不涉及加锁操作,不会产生阻塞。
但是,上述方案也有明显的缺点:
1. 当数组特别大时,写时拷贝的方案特别低效 !(复制需要消耗大量时间)
2. 当有多个线程同时进行写操作时,也容易出现线程安全问题(比如多个线程同时执行自增操作时,由于线程1 和 线程2 是同时进行修改操作,所以它们读取到的原数组中值是一样的,那么当他们分别在自己复制出的数组进行自增操作时,就会导致两次自增操作结果的覆盖。即得到的新数组的值是原数组只进行了一次自增操作的值,但实际上原数组是进行了两次自增操作)
七. 多线程下使用哈希表
在之前的数据结构中的学习中,我们知道,HashMap 是线程不安全的,而我们还学习了一种线程安全的哈希表 HashTable(给方法中各种 public 方法都加了 synchronized,锁对象为一整个哈希表对象)。但我们知道,HashTable 会因为加了太多的锁而导致效率降低。相比之下,我们更推荐使用 ConcurrentHashMap ,由于其是按照 “桶” 级别进行加锁,而不是给整个哈希加一个全局锁,这就有效地降低了锁冲突的概率,也避免了加大量锁而导致的效率降低问题。
给每个哈希桶加锁的示意图如下:

像这样,我们对每个哈希桶分别加不同的锁,只有在多个线程同时修改同一个哈希桶中的数据时才会发生阻塞等待。而多个线程同时修改不同哈希桶中的数据时本身就是线程安全的,所以无需进行加锁进行阻塞等待。这样有目的地针对哈希表的特性进行加锁的 ConcurrentHashMap ,就在线程安全的基础上使该哈希表的效率尽可能地高。
而在维护 size 这个属性时,ConcurrentHashMap 则使用了原子类来保证线程安全
当 ConcurrentHashMap 要进行扩容操作时,并不会一下子将所有旧的哈希表中的旧元素都搬运到新的哈希表中(因为有时哈希表会非常大,元素很多,一下子全都搬运会消耗很多时间),而是采用了化整为零的方法,将一整个搬运任务分为多次来完成,在触发 “扩容”之后,只先搬运一小部分,后续再进行基本操作时,每次都会再次触发一次搬运,直到搬运完成为止。
ConcurrentHashMap 的核心优化点(总结):
1. 将 锁整个哈希表 优化为 锁哈希表中的每一个锁
2.使用原子类对 size 进行维护
3.针对哈希扩容时采用 “化整为零” 的方式,确保每一个操作加锁的时间不要太长
以上就是本片文章的全部要点,欢迎各位读者阅读与指正 !!!



被折叠的 条评论
为什么被折叠?



