当前位置:   article > 正文

JavaEE进阶(6)多线程进阶——线程相关的面试题(常见的锁策略、CAS、Synchronized 原理、JUC的常见类、原子类、信号量、CountDownLatch、线程安全的集合类)_2.在java中,以下哪种线程同步机制允许多个线程同时读取共享资源,但在写入时互

2.在java中,以下哪种线程同步机制允许多个线程同时读取共享资源,但在写入时互

接上次博客:

目录

常见的锁策略

乐观锁 vs 悲观锁

重量级锁 vs 轻量级锁

自旋锁(Spin Lock)和 挂起等待锁

读写锁

可重入锁 vs 不可重入锁 

公平锁 vs 非公平锁

相关面试题 

(1)你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

(2) 介绍下读写锁?

(3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

(4) synchronized 是可重入锁么?

(5)synchronized 属于哪种锁呢?

CAS

 CAS 伪代码

CAS 有哪些应用 

1) 实现原子类 

2) 实现自旋锁

CAS 的 ABA 问题

ABA 问题引来的 BUG

解决方案

相关面试题

1) 讲解下你自己理解的 CAS 机制

2) ABA问题怎么解决?

Synchronized 原理

基本特点

其他的优化操作

锁升级

1) 偏向锁 ——其实就是“懒汉模式”的另一种体现

2) 轻量级锁

3)重量级锁

锁消除

锁粗化

JUC(java.util.concurrent) 的常见类

Callable 接口

Callable 的用法

ReentrantLock

ReentrantLock 的用法:

ReentrantLock 和 synchronized 的区别:

原子类

 信号量 Semaphore

CountDownLatch

相关面试题

线程安全的集合类

多线程环境使用 ArrayList

1、Collections.synchronizedList(new ArrayList);

2、使用 CopyOnWriteArrayList

多线程环境使用队列

多线程环境使用哈希表

(1) Hashtable(只是简单的把关键方法加上了 synchronized 关键字)

 (2)ConcurrentHashMap

(3)HashTable、HashMap 和 ConcurrentHashMap的区别:


常见的锁策略

实际开发中,涉及到的锁不仅仅是 synchronized 这一种,甚至不仅仅局限于Java中。

乐观锁 vs 悲观锁

乐观锁(Optimistic Locking)和悲观锁(Pessimistic Locking)都是在多用户环境下管理并发访问共享资源的方法,通常用于数据库管理系统和多线程编程中

乐观、悲观是指 “锁的特性”,一类锁,不是具体的一把锁~

悲观乐观,是对后续锁冲突是否激烈(频繁)给出的预测。

如果预测接下来锁冲突的概率不大,就可以少做一些工作,称为乐观锁。

如果预测接下来锁冲突的概率很大,就应该多做一些工作,称为悲观锁

乐观锁:

设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

具体一点来说: 

1. 乐观锁(Optimistic Locking):

  • 基本原理:乐观锁假设并发冲突很少发生,因此不会立即阻止其他用户的访问。相反,它允许多个用户同时读取和修改数据,但在提交更改之前,检查是否有其他用户已经修改了数据。
  • 实现方式:通常通过在数据表中引入版本号(Version)或时间戳(Timestamp)字段来实现。每次更新记录时,版本号会递增,如果多个用户尝试更新相同记录时,只有一个会成功,其他的会失败并需要重新处理。
  • 适用场景:适用于读取频繁、写入冲突较少的情况,以减少锁的竞争,提高并发性能。

2. 悲观锁(Pessimistic Locking):

  • 基本原理:悲观锁假设并发冲突较常发生,因此在用户访问数据时立即将其锁定,防止其他用户同时访问和修改数据。只有当锁释放后,其他用户才能访问数据。
  • 实现方式:通常通过数据库管理系统提供的锁机制来实现,如SELECT ... FOR UPDATE(针对某些数据库)或直接使用事务。
  • 适用场景:适用于写入频繁、写入冲突较多的情况,可以确保数据的完整性和一致性,但可能会降低并发性能。

选择乐观锁还是悲观锁取决于应用程序的需求和特定的并发情况。乐观锁通常用于高并发读取的情况,而悲观锁通常用于需要更强的数据一致性和避免数据冲突的情况。

重量级锁 vs 轻量级

重量级锁(Heavyweight Lock)和轻量级锁(Lightweight Lock)是用于多线程编程中管理共享资源访问的两种不同锁定机制。它们在锁定和解锁的开销、竞争情况以及适用场景等方面有所不同。

轻量级锁,锁的开销比较小;重量级锁,锁的开销比较大。

 乐观锁通常是轻量级锁,悲观锁通常是重量级锁。但是注意!!!只是通常情况下!

一个预测锁冲突的概率,一个是实际消耗的开销。

1. 重量级锁(Heavyweight Lock):
特点:

  • 通常由操作系统内核提供支持,称为内核级别锁。
  • 锁的操作涉及内核态和用户态的切换,因此开销较大。
  • 适用于高竞争情况下的多线程并发,可以确保线程之间的互斥。
  • 锁的管理通常由操作系统负责,因此对应用程序的开发者来说,使用较为简单。
  • 适用场景:当多个线程之间的竞争情况较为激烈,或者需要跨进程的锁时,可以考虑使用重量级锁。

2. 轻量级锁(Lightweight Lock):
特点:

  • 通常由编程语言或运行时库提供支持,是用户态的锁。
  • 锁的操作开销较小,避免了内核态和用户态的频繁切换。
  • 适用于低竞争情况下的多线程并发,对性能的影响较小。
  • 锁的管理通常由应用程序开发者负责,因此需要更多的注意力来确保正确的使用。
  • 适用场景:当多线程之间的竞争情况较轻,或者需要最小化锁的开销时,可以考虑使用轻量级锁。

总之,重量级锁和轻量级锁都有其适用的场景和权衡。选择哪种锁取决于应用程序需求,以及对性能和可维护性的关注。

自旋锁(Spin Lock)和 挂起等待锁

自旋锁(Spin Lock)和挂起等待锁(Mutex,Semaphore)是两种不同的锁机制,用于管理多线程或多进程之间对共享资源的访问。

自旋锁就属于是一种轻量级锁的典型实现;往往是在纯用户态实现的,比如使用一个while循环,不停的检查当前的锁是否被释放。如果没有就继续循环,释放了就获取到锁,从而结束循环。这其实是一个忙等,但是这里消耗CPU但是换来的是更快的响应速度。定时器的忙等是没有必要的,不如让出CPU。

挂起等待锁就属于重量级锁的一种典型实现;要借助系统API来实现,一旦出现锁竞争,就会在内核中触发一系列的动作,比如让这个线程进入阻塞的状态,暂时不参与CPU调度。而阻塞的开销是很大的。

1. 自旋锁(Spin Lock):
特点:

  • 自旋锁是一种基于忙等待的锁,线程在获取锁时会反复检查锁是否可用,而不是进入休眠状态。
  • 当线程尝试获取自旋锁时,如果锁已被其他线程占用,它会不断循环(自旋)等待锁的释放。
  • 自旋锁通常适用于临界区非常短小,线程不愿进入休眠状态的情况下,以避免线程切换的开销。
  • 适用场景:在多核处理器上,临界区非常短小且竞争不激烈的情况下,自旋锁可能是一个高效的选择。

2. 挂起等待锁(Mutex,Semaphore):
特点:

  • 挂起等待锁是一种基于线程挂起和唤醒的锁,线程在获取锁时,如果锁已被其他线程占用,会被挂起等待锁的释放。
  • 挂起等待锁通常能够有效地避免忙等待,但在线程切换和上下文切换方面会产生较大的开销。
  • 常见的实现包括互斥锁(Mutex)和信号量(Semaphore),它们提供了不同的功能和用途。
  • 适用场景:当临界区较大、竞争激烈、线程可以进入休眠状态时,挂起等待锁通常是更合适的选择。

选择自旋锁还是挂起等待锁取决于应用程序的需求、并发情况以及性能要求。自旋锁适合短小的临界区和低竞争情况,而挂起等待锁适合更复杂的情况,需要线程能够安全地休眠等待资源。

读写锁

读写锁(Read-Write Lock)是一种用于多线程编程的同步机制,它允许多个线程同时读取共享资源,但在写操作时必须独占访问资源。读写锁在提高并发性能和资源共享方面非常有用,尤其是当读操作频繁而写操作相对较少的情况下。

这里把加锁分成两种:读加锁和写加锁。

是不是觉得有点熟悉?我们之前讲过数据库的读加锁和写加锁。

但是数据库写加锁是“写的时候不能读”,读加锁是“读的时候不能写”。

但是到了这里就不一样了:

读加锁:读的时候能读,但是不能写;

写加锁:写的时候不能读,也不能写。

读写锁有两种基本模式:

1. 读锁(Read Lock):

  • 多个线程可以同时获得读锁,允许并发读取共享资源。
  • 当没有写锁被持有时,可以有多个读锁同时存在。
  • 读锁不阻塞其他读锁,但会阻塞写锁。

2. 写锁(Write Lock):

  • 写锁是独占锁,只有一个线程可以获得写锁,用于执行写操作。
  • 当写锁被持有时,不允许其他读锁或写锁存在。
  •  写锁会阻塞其他读锁和写锁。

读写锁的主要优点是允许多个线程同时读取数据,提高了并发性能,特别适用于读远远多于写的场景,例如数据库系统中的数据查询。然而,它的缺点是写操作会阻塞其他所有的读和写操作,因此在写入操作频繁的情况下,可能会导致性能下降。

读写锁的使用通常需要注意以下事项:

  1. 在读多写少的场景中使用读写锁可以提高性能。
  2. 在写多的情况下,可能需要考虑其他同步机制,以避免写锁的竞争和阻塞。
  3. 使用读写锁需要确保正确的加锁和解锁顺序,以避免死锁和其他同步问题。
  4. 不要滥用读写锁,只在有明确需求的情况下使用它。 

在Java中,读写锁是通过java.util.concurrent包中的ReentrantReadWriteLock类来实现的。以下是一个简单的示例,展示了如何在Java中使用读写锁: 

  1. import java.util.concurrent.locks.ReadWriteLock;
  2. import java.util.concurrent.locks.ReentrantReadWriteLock;
  3. public class ReadWriteLockExample {
  4. private static int sharedData = 0;
  5. private static ReadWriteLock rwLock = new ReentrantReadWriteLock();
  6. public static void main(String[] args) {
  7. Thread reader1 = new Thread(() -> {
  8. while (true) {
  9. rwLock.readLock().lock(); // 获取读锁
  10. System.out.println("Reader 1 reads sharedData: " + sharedData);
  11. rwLock.readLock().unlock(); // 释放读锁
  12. }
  13. });
  14. Thread reader2 = new Thread(() -> {
  15. while (true) {
  16. rwLock.readLock().lock(); // 获取读锁
  17. System.out.println("Reader 2 reads sharedData: " + sharedData);
  18. rwLock.readLock().unlock(); // 释放读锁
  19. }
  20. });
  21. Thread writer = new Thread(() -> {
  22. while (true) {
  23. rwLock.writeLock().lock(); // 获取写锁
  24. sharedData++;
  25. System.out.println("Writer writes sharedData: " + sharedData);
  26. rwLock.writeLock().unlock(); // 释放写锁
  27. }
  28. });
  29. reader1.start();
  30. reader2.start();
  31. writer.start();
  32. }
  33. }

在这个示例中,我们创建了一个ReentrantReadWriteLock实例来管理共享资源sharedData的读写访问。readLock()方法用于获取读锁,而writeLock()方法用于获取写锁。多个线程可以同时持有读锁,但只能有一个线程持有写锁,写锁是互斥的。

这个示例中的读操作示例和写操作示例在不断地循环,演示了多个读操作可以同时进行,但写操作会阻塞其他读写操作。

可重入锁 vs 不可重入锁 

允许同一个线程多次获取同一把锁的锁被称为可重入锁。Java中的Reentrant开头命名的锁都是可重入锁,包括synchronized关键字锁。而Linux系统提供的mutex是一种不可重入锁。

下面是一些关于可重入锁和不可重入锁的更详细说明:

可重入锁(Reentrant Locks)

  1. 可重入性:允许同一线程在持有锁的情况下多次获取锁,而不会导致死锁。这在递归函数中非常有用,因为递归函数可能需要多次获取同一把锁。

  2. Java的Reentrant系列:Java中提供了一系列可重入锁,如ReentrantLock和ReentrantReadWriteLock,它们都支持同一线程多次获取锁。

  3. synchronized关键字锁也是可重入的:在Java中,通过synchronized关键字获得的锁也是可重入的,同一线程可以多次进入被锁保护的代码块。

不可重入锁

  1. 不支持同一线程多次获取锁:不可重入锁不允许同一线程多次获取同一把锁。如果同一线程试图再次获取锁,它将被阻塞,可能导致死锁。

  2. Linux系统的mutex:在Linux系统中,mutex是一种不可重入锁。如果同一线程尝试再次获取mutex锁,它会被阻塞,直到锁被释放。

总的来说,可重入锁允许同一线程多次获取锁,这在某些情况下非常方便,特别是在递归函数中。不可重入锁不支持同一线程多次获取锁,因此在使用时需要格外注意避免死锁等问题。在Java中,通常使用可重入锁的情况更常见。

公平锁 vs 非公平锁

当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待。一旦第一个线程释放锁之后,接下来哪个线程能够拿到锁呢?

公平锁是根据先来后到的原则;

非公平锁是剩下的的锁都均等

公平锁(Fair Lock)和非公平锁(Unfair Lock)是两种不同的锁获取策略,它们影响了多个线程争夺锁时的顺序和公平性。

公平锁(Fair Lock)

  1. 公平性:公平锁的获取策略是按照请求锁的顺序来分配锁,即按照先来先服务的原则。当多个线程等待锁时,锁会按照等待的顺序依次分配给等待的线程。这确保了所有线程都有公平的机会获得锁,避免了饥饿现象。

  2. 实现:在公平锁的实现中,锁会维护一个队列,每个线程按照请求锁的顺序加入队列,并在队列中等待锁的释放。当锁被释放时,会从队列中选择下一个等待的线程来获取锁。

  3. 优点:公平锁确保了锁的公平性,适用于需要公平竞争锁的情况,可以避免某些线程一直获取到锁而其他线程无法获得的情况。

非公平锁(Unfair Lock)

  1. 非公平性:非公平锁的获取策略是不考虑等待线程的顺序,它允许后来的线程插队,有可能导致先来的线程一直获取不到锁,不具备公平性。

  2. 实现:在非公平锁的实现中,线程可以在尝试获取锁时直接获取,而不必等待锁的释放。这有助于提高吞吐量,因为它减少了线程切换和竞争的开销。

  3. 优点:非公平锁在某些情况下可以提高性能,特别是在锁的竞争不激烈、锁的持有时间较短的情况下。它可以减少线程等待的时间。

选择公平锁还是非公平锁取决于应用的需求。如果公平性对于应用很重要,确保所有线程都有公平的机会获得锁,那么可以选择公平锁。如果性能更加关键,而公平性不是首要考虑因素,那么可以选择非公平锁以提高吞吐量。

在Java中,ReentrantLock提供了公平锁和非公平锁的选择,通过构造函数的参数来指定。默认情况下,ReentrantLock是非公平锁。

 操作系统提供的加锁 API 默认情况下就属于“非公平锁”,如果想要实现公平锁,我们还需要引入额外的队列,维护这些线程的枷锁顺序。

相关面试题 

(1)你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加 锁。

乐观锁认为多个线程访问同一个共享变量冲突的概率不大。并不会真的加锁,而是直接尝试访问数 据。 在访问的同时识别当前的数据是否出现访问冲突。

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据。获取不到锁就等待。 

乐观锁的实现可以引入一个版本号。借助版本号识别出当前的数据访问是否冲突。 (实现细节参考上 面的图)。

悲观锁

  1. 加锁:悲观锁认为多个线程访问共享资源可能会引发冲突,所以在访问共享资源之前,先要加锁。这通常通过操作系统提供的互斥锁(如mutex)来实现。只有获取到锁的线程才能继续访问共享资源,其他线程需要等待锁的释放。

  2. 操作数据:一旦线程获得锁,它就可以安全地对共享资源进行读取或写入操作。其他线程需要等待当前线程释放锁后才能访问共享资源。

  3. 释放锁:在完成对共享资源的操作后,线程需要释放锁,让其他线程有机会获取锁并访问共享资源。

悲观锁的实现比较简单,但它会引入一定的性能开销,因为多个线程需要等待锁的释放,可能会导致一些线程长时间等待。

乐观锁

  1. 版本号或时间戳:乐观锁不会立即加锁,而是在访问共享资源时,会读取一个版本号或时间戳等标识,记录当前数据的状态。

  2. 尝试操作数据:线程尝试对共享资源进行操作,但在操作之前不会加锁。它会读取当前的版本号或时间戳,并记录下来。

  3. 检查冲突:在完成对共享资源的操作后,线程会再次读取共享资源的版本号或时间戳,并与之前记录的值进行比较。如果两者不一致,说明在操作期间有其他线程修改了共享资源,产生了冲突。

  4. 处理冲突:如果发现冲突,线程可以选择放弃操作、重试操作,或者采取其他策略来处理冲突。一种常见的处理方式是使用循环重试,直到操作成功或达到一定的重试次数。

乐观锁避免了加锁的性能开销,但需要更复杂的处理来处理冲突。版本号或时间戳通常用于标识数据的状态,以便在多个线程访问同一资源时检测到冲突。

总的来说,选择使用悲观锁还是乐观锁取决于应用场景和性能要求。悲观锁适用于冲突概率较高的情况,而乐观锁适用于冲突概率较低且需要高并发性能的情况。

(2) 介绍下读写锁?

读写锁(Read-Write Lock)读写锁就是把读操作和写操作分别进行加锁,它是一种用于多线程编程的锁机制,它的设计目的是在某些情况下提高并发性能。读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。这样,在读多写少的场景下,可以提高并发性能,因为多个线程可以同时读取数据而不互斥,而写操作会阻塞其他读写操作。

读写锁的基本特性如下:

  1. 读锁和读锁之间不互斥:多个线程可以同时获得读锁,允许并发读取共享资源。

  2. 写锁和写锁之间互斥:只有一个线程可以获得写锁,用于保护写操作,确保写入共享资源的操作是互斥的。

  3. 写锁和读锁之间互斥:当一个线程持有写锁时,其他线程不能获得读锁,以确保写操作和读操作之间的互斥性。

读写锁的应用场景通常是在读操作频繁,而写操作较少的情况下,以提高并发性能。读操作可以同时进行,而写操作会等待所有的读操作完成后才能执行,以保证写操作的一致性。

读写锁的实现可以采用不同的机制,如pthread中的pthread_rwlock_t,Java中的ReentrantReadWriteLock等。程序员需要根据具体的编程语言和库来使用和管理读写锁,以确保线程安全和性能优化。

(3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败,立即再尝试获取锁, 无限循环, 直到获取到锁为止。

第一次获取锁失败, 第二次的尝 试会在极短的时间内到来,一旦锁被其他线程释放, 就能第一时间获取到锁。

相比于挂起等待锁,

优点:

没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效

 在锁持有时间比较短的场景下非常有用 

缺点:

如果锁的持有时间较长, 就会浪费 CPU 资源

自旋锁(Spin Lock)是一种锁的实现方式,与传统的互斥锁(如mutex)不同,自旋锁不会让线程进入休眠状态等待锁的释放,而是在获取锁失败时,线程会一直尝试获取锁,不断地在一个循环中“自旋”,直到成功获取锁为止。自旋锁是一种忙等待的锁策略。

自旋锁的优点和使用场景:

优点

  1. 没有放弃 CPU 资源:自旋锁不会导致线程进入休眠状态,因此线程不会让出 CPU 资源,这可以在一些情况下提高锁的竞争性能,尤其是当锁的持有时间非常短的时候。

  2. 响应时间低:一旦锁被其他线程释放,自旋锁的等待线程可以立即获得锁,因此响应时间非常低。

  3. 适用于短暂竞争:自旋锁适用于锁的竞争情况较短暂的场景,因为长时间自旋会浪费大量的 CPU 资源。

缺点

  1. CPU 资源浪费:自旋锁在获取锁失败时会不断地尝试获取锁,如果锁的竞争激烈或者持有锁的时间很长,那么自旋锁会浪费大量的 CPU 资源,降低系统的整体性能。

  2. 不适用于长时间持有锁:自旋锁不适用于长时间持有锁的情况,因为它会导致其他线程在自旋期间无法进入临界区,降低了并发性能。

  3. 不适用于多核心处理器:在多核心处理器上,自旋锁可能会导致多个线程在不同核心上不断自旋,消耗大量的总体 CPU 资源,降低了系统的效率。

因此,自旋锁适用于锁的竞争时间较短,且并发程度不高的情况。在长时间竞争或多核处理器上,应当谨慎使用自旋锁,并考虑使用其他锁策略,如互斥锁或读写锁,以更好地平衡性能和资源消耗。

(4) synchronized 是可重入锁么?

synchronized 是可重入锁(也称为递归锁)。可重入锁允许同一个线程在持有锁的情况下多次进入被锁保护的代码块,而不会导致死锁。

Synchronized 实现可重入锁的方式是通过为每个锁关联一个线程标识以及一个计数器。当一个线程首次获取锁时,标识该线程为锁的持有者,并将计数器设置为1。当同一个线程再次尝试获取同一个锁时,会发现标识与当前线程一致,此时只会将计数器递增,而不会阻塞自己。只有当计数器变为0时,锁才会完全释放,其他线程才有机会获取锁。

这种可重入的特性允许开发者编写更加灵活和复杂的代码,例如在一个方法内部调用另一个加锁的方法,而不必担心死锁或锁的竞争问题。

synchronized属于哪种锁呢?

 对于“悲观乐观”,自适应的;

对于“重量轻量”,自适应的;

对于“自选?挂起等待”,自适应的。

(5)synchronized 属于哪种锁呢?

  1.  对于“悲观乐观”,自适应的;
  2. 对于“重量轻量”,自适应的;
  3. 对于“自选?挂起等待”,自适应的;
  4. 不是读写锁;
  5. 是可重入锁;
  6. 是非公平锁。

初始情况下,synchronized 会预测当前的锁冲突概率不大,此时以乐观锁的模式运行(此时就是轻量级锁,基于自旋锁的方式实现)。

在实际使用过程中,如果发现锁冲突的情况较多,会升级成悲观锁(此时也就是重量级锁,基于挂起等待的方式实现)。

"自适应" 是一个通用的概念,用于描述一种系统或机制具备根据环境或条件的变化来自动调整其行为的能力。在计算机科学和工程领域中,"自适应" 指的是系统、算法或机制可以根据运行时的情况和数据来调整其工作方式,以达到更好的性能、效率、或其他目标。

在锁或并发控制的上下文中,"自适应" 通常意味着锁系统能够根据线程的竞争情况、等待时间、负载等因素来动态选择合适的锁策略,以优化性能。例如,自适应锁可能会在锁竞争不激烈时选择一种轻量级的锁策略,而在锁竞争激烈时切换到一种更重的锁策略,以减少争用和上下文切换。

"自适应" 的概念在计算机科学中有广泛的应用,包括自适应算法、自适应性网络、自适应性操作系统等领域。它有助于系统更好地适应变化的需求和条件,以提高性能、资源利用率和用户体验。

CAS

CAS(Compare and Swap)是一种多线程同步的机制,通常用于实现无锁算法,特别是在多线程环境下对共享变量进行操作的情况下。

比较交换的是内存和寄存器:

  1. 比如有一个内存M,两个寄存器A、B,
  2. CAS(M A B),
  3. 如果M和A的值相同,就把M和B交换,同时整个操作返回true。如果不相同,无事发生,同时整个操作返回false。

交换的本质就是为了把B赋值给M。(寄存器B里面是啥我们不关心,关系的是M的情况)

CAS 操作包含了三个步骤:

  1. 比较(Compare):CAS首先比较内存中的某个值与一个预期值是否相等。这个预期值通常是当前共享变量的值。

  2. 交换(Swap):如果预期值与内存中的值相等,那么CAS会将共享变量的值替换为一个新的值。

  3. 返回(Return):CAS会返回操作是否成功,通常是一个布尔值。如果操作成功,表示原来的预期值与内存中的值相等,CAS已经成功将新值写入了共享变量。如果操作失败,表示有其他线程在这之前修改了共享变量的值。

CAS 是一种无锁的同步机制,因为它不涉及线程的挂起和恢复。它允许多个线程同时尝试更新共享变量,但只有一个线程会成功。其他线程会不断重试,直到成功为止。这种机制使得CAS在一些高并发情况下表现出色,因为它减少了锁竞争的开销。

CAS的经典应用包括实现自旋锁、原子计数器、非阻塞数据结构等。然而,CAS也有一些局限性,例如ABA问题(一个值从A变成B再变成A,在这期间可能有其他操作),以及无法解决某些复杂的并发问题。为了解决这些问题,通常需要结合其他同步机制,如锁或版本号等。

 CAS 伪代码

下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的。这个伪代码只是辅助理解 CAS 的工作流程,不能真正的编译执行,只是让我们能够认识到逻辑:

  1. boolean CAS(address, expectValue, swapValue) {
  2. if (&address == expectedValue) {
  3. &address = swapValue;
  4. return true;
  5. }
  6. return false;
  7. }

CAS其实是一个CPU指令,一个指令就能够完成上述比较交换的逻辑。由此推导出单个CPU指令是原子的!我们就可以使用CAS完成一些操作,进一步替代“加锁”。 这就给编写线程安全的代码引入了新的思路。基于CAS实现的线程安全的方式也称为“无锁编程”。

优点:保证线程安全,同时避免阻塞(效率)

缺点:代码复杂不好理解,只能更适合一些特定场所,不如加锁方式更普适。

CPU指令,又被操作系统封装,提供成API,又被JVM封装,提供成API。即可使用。

CAS 有哪些应用 

1) 实现原子类 

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

  1. AtomicInteger atomicInteger = new AtomicInteger(0); // 相当于 i++
  2. atomicInteger.getAndIncrement();
  1. public class Demo {
  2. public static int count = 0;
  3. public static void main(String[] args) throws InterruptedException {
  4. Thread t1 = new Thread(() -> {
  5. for (int i = 0; i < 50000; i++) {
  6. count++;
  7. }
  8. });
  9. Thread t2 = new Thread(() -> {
  10. for (int i = 0; i < 50000; i++) {
  11. count++;
  12. }
  13. });
  14. t1.start();
  15. ;
  16. t2.start();
  17. t1.join();
  18. t2.join();
  19. System.out.println(count);
  20. }
  21. }

 用了原子类:

  1. import java.util.concurrent.atomic.AtomicInteger;
  2. public class Demo {
  3. public static AtomicInteger count = new AtomicInteger(0);
  4. public static void main(String[] args) throws InterruptedException {
  5. Thread t1 = new Thread(() -> {
  6. for (int i = 0; i < 50000; i++) {
  7. //count++;
  8. count.getAndIncrement();
  9. //++count
  10. count.incrementAndGet();
  11. //count--
  12. count.getAndDecrement();
  13. //--count
  14. count.decrementAndGet();
  15. }
  16. });
  17. Thread t2 = new Thread(() -> {
  18. for (int i = 0; i < 50000; i++) {
  19. //count++;
  20. count.getAndIncrement();
  21. //++count
  22. count.incrementAndGet();
  23. //count--
  24. count.getAndDecrement();
  25. //--count
  26. count.decrementAndGet();
  27. }
  28. });
  29. t1.start();
  30. ;
  31. t2.start();
  32. t1.join();
  33. t2.join();
  34. System.out.println(count.get());
  35. }
  36. }

 

伪代码实现:

  1. class AtomicInteger {
  2. private int value;
  3. public int getAndIncrement() {
  4. int oldValue = value;
  5. while ( CAS(value, oldValue, oldValue+1) != true) {
  6. oldValue = value;
  7. }
  8. return oldValue;
  9. }
  10. }

通过形如上述代码就可以实现一个原子类。

不需要使用重量级锁, 就可以高效的完成多线程的自增操作。 

本来 check and set 这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这 个操作, 也就变成原子的了。

在Java中,有些操作是偏底层的操作,在使用的时候有很多注意事项。

稍有不慎就容易出问题。这些操作就放到 unsafe 中进行归类。

反编译:

native修饰的方法就是“本地方法”,在JVM源码中使用了C++实现的逻辑。涉及到一些底层操作。

结论:原子类里面是基于CAS来实现的。

 

2) 实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权

自旋锁伪代码

  1. public class SpinLock {
  2. private Thread owner = null;
  3. public void lock(){
  4. // 通过 CAS 看当前锁是否被某个线程持有.
  5. // 如果这个锁已经被别的线程持有, 那么就自旋等待.
  6. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
  7. while(!CAS(this.owner, null, Thread.currentThread())){
  8. }
  9. }
  10. public void unlock (){
  11. this.owner = null;
  12. }
  13. }

缺点是cpu开销大,效率低。while循环相当于一直在忙等,锁冲突激烈的话还是不要用了。

CAS还有其他很多应用,是多线程中一种重要技巧。

虽然开发中我们直接使用CAS概率不大,但是还是经常会用到一些内部封装了CAS的操作。

CAS 的 ABA 问题

什么是 ABA 问题?

即CAS进行操作的关键就是通过“值”没有发生变化来作为“没有其他线程穿插执行”判定依据。

但是这种判定方式不够严谨,再更极端的情况下可能有另一个线程穿插进来,把A-->B-->A;

针对第一个线程来说,看起来好像是这个值,没有变化,但是实际上已经被穿插执行过了。

所以ABA问题是一种在使用CAS(Compare-and-Swap)操作时可能出现的并发问题,其特点是虽然共享变量的值在操作之间发生了多次变化,但在最终比较时,值又回到了最初的状态,导致CAS操作误认为没有发生其他线程的干扰。这可能导致CAS操作不正确地成功。

  1. 初始状态:共享变量num的值为A。
  2. 线程t1执行CAS操作,预期值是A,尝试将num的值修改为B。在执行CAS操作之前,t1将num的当前值A存储在oldNum变量中。
  3. 线程t2也操作共享变量num,将其从A修改为B,然后再次将其从B修改回A。
  4. 此时,共享变量num的值虽然在操作之间经历了多次变化(A -> B -> A),但在CAS操作中,线程t1会比较oldNum和当前num的值,发现它们仍然都是A,因此CAS操作可能会成功,尽管实际上在中间有其他线程的干扰。

ABA问题如果真的出现了,其实大部分情况下也不会产生BUG,值毕竟已经改回去了,逻辑上也不一定会产生BUG。

但是在极端情况下,这就不好说了……

ABA 问题引来的 BUG

大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的。但是不排除一些特殊情况。

ABA问题通常在大多数情况下不会引发问题,因为大多数情况下,线程t1执行CAS操作时只关心共享变量的当前值是否等于预期值,而不关心在操作之间共享变量是否经历了其他值的变化。因此,线程t1可能会误认为CAS操作成功,尽管实际上在中间有其他线程的操作。

  1. 内存泄漏:如果共享变量是引用类型,线程t1可能会执行一些与共享变量相关的操作,而在ABA问题之后,共享变量的引用可能已经被其他线程修改。这可能导致共享变量引用的对象无法被垃圾回收,从而导致内存泄漏。

  2. 逻辑错误:在某些应用中,共享变量的值可能表示状态或计数,而不仅仅是引用。如果线程t1在ABA问题后不慎执行了不正确的逻辑,可能导致应用程序的不一致状态或错误计数。

举个例子,当涉及ABA问题时,一个常见的示例是使用CAS操作来处理账户余额,但是在多线程环境下可能出现问题。

场景: 某个在线购物平台的用户账户余额。

  1. 初始状态: 用户账户余额为1000元。

  2. 并发购物操作: 多个线程同时尝试购物,购物金额为100元。

  3. 期望行为: 我们期望每次购物操作只扣除100元,当余额不足时购物操作应该失败。

  4. 异常的过程:

    • 初始时,账户余额为1000元。
    • 线程1和线程2同时尝试购物,它们读取账户余额为1000元,并尝试扣除100元。
    • 线程1成功执行,余额变为900元。
    • 在线程2执行之前,用户的亲戚给了他一笔礼金500元,余额变为1400元。
    • 线程2执行,它读取账户余额为1400元,发现与之前读取的1000元不同,然后扣除100元。
    • 结果账户余额变为1300元,而不是期望的800元。

这个例子中,虽然用户的账户余额发生了多次变化(从1000元到900元,然后到1400元,最后到1300元),但在线程2执行CAS操作时,它可能会错误地认为余额是1000元,因为它并不关心中间的值变化,这就是ABA问题导致的错误。

这个示例说明了在处理金融交易或账户余额等情况下,ABA问题可能导致不一致的结果,因为CAS操作无法捕获到中间值的变化。为了解决这种问题,通常需要引入版本号或其他附加信息,以便更可靠地检测到这种情况。

解决方案

只要让判定的数值按照一个方向增长即可,不要反复横跳~

解决ABA问题的常见方法是引入一个额外的变量,版本号或标记来跟踪共享变量的变化。每次修改共享变量时,都会增加一个版本号或标记。这样,CAS操作不仅比较值,还比较版本号或标记,从而可以检测到ABA问题。如果版本号或标记不匹配,CAS操作将失败,即使值看起来相同。

使用版本号或标记的方式可以让CAS操作不仅比较值,还比较版本号或标记,如果版本号没变,注定没有线程穿插执行,从而防止了ABA问题。

具体步骤如下:

  1. 在共享变量中引入一个版本号或标记,初始为1或其他适当的值。

  2. 在执行CAS操作之前,读取共享变量的当前值和版本号。

  3. 在CAS操作中,除了比较值之外,还比较版本号是否与读取的版本号匹配。

  4. 如果值和版本号都匹配,执行修改操作,并将版本号递增。

  5. 如果版本号不匹配(即中间发生了其他修改),CAS操作失败,需要进行适当的重试或处理。

这种方式确保了CAS操作在检查值的同时也检查了版本号,从而可以检测到ABA问题。只有在值和版本号都匹配的情况下,CAS操作才会成功。

还是刚刚那个例子吗?

  1. 初始状态:我的存款为100,版本号为1。

  2. 并发取款操作:两个线程同时尝试取款50元,每个线程都读取存款值为100,版本号为1,然后尝试将存款值更新为50。

  3. 线程1成功执行取款操作,将存款值更新为50,同时版本号从1增加到2。线程2阻塞等待。

  4. 在线程2执行之前,滑稽的朋友向滑稽老哥转账50元,这使得账户余额变为100,版本号变为3。

  5. 线程2执行,它读取存款值为100,发现与之前读取的100相同,但版本号不同。之前读取的版本号为1,而当前版本号为3,版本号不匹配,线程2认为操作失败,从而避免了重复的取款操作。

总之,这种技术在并发编程中非常常见,被广泛用于实现各种数据结构和算法,以确保线程安全性和数据一致性。

在Java中,java.util.concurrent.atomic包中的原子类通常会使用类似的方式来解决ABA问题。例如,AtomicStampedReference类可以存储一个引用和一个整数标记,以便在CAS操作中同时比较引用和标记,从而防止ABA问题的发生。

在实际开发中,我们一般不会直接使用CAS,都是用封装好的。 

相关面试题

1) 讲解下你自己理解的 CAS 机制

CAS(Compare and Swap)是一种并发编程中的原子操作,其主要思想是通过比较内存中的值与一个期望值,如果它们相等,就将新值写入内存。CAS操作可以在不使用锁的情况下实现多线程间的数据同步和互斥访问。以下是对CAS机制的详细解释:

  1. 原子性操作:CAS是一种原子性操作,它是不可分割的,即在执行CAS操作时,不会被其他线程中断或修改。

  2. 三个基本参数:CAS操作需要三个基本参数:

    • 内存位置:即要被读取和修改的内存地址。
    • 期望值:即当前内存位置的期望值。
    • 新值:即要写入内存位置的新值。
  3. CAS操作步骤

    • 读取内存:CAS首先会读取内存位置的当前值。
    • 比较值:然后,它将读取的值与期望值进行比较。如果相等,说明内存位置的值没有被其他线程改变。
    • 修改内存:如果比较相等,CAS操作会将新值写入内存位置。如果不相等,CAS操作将失败,不执行写入操作。
  4. CAS的原理:CAS操作在底层需要硬件的支持。通常,CPU提供了特定的CAS指令,使得CAS操作可以在一条机器指令中完成。这确保了CAS的原子性,因为其他线程无法在CAS操作的中间阶段插入操作。

  5. 应用场景:CAS主要用于解决多线程环境下的竞争问题,如锁、计数器、数据结构等。它可以提供更高的并发性,因为它不需要线程阻塞等待锁的释放。

  6. CAS的缺点

    • ABA问题:CAS操作可能忽略中间值的变化,因此可能出现ABA问题,需要额外的措施来解决。
    • 自旋等待:CAS操作通常需要在循环中不断尝试,这可能导致CPU的自旋等待,浪费CPU资源。

总之,CAS是一种强大的并发编程工具,它允许多个线程以原子方式读取和更新共享数据,而不需要显式的锁定。它在性能上通常比传统锁机制更有优势,但需要谨慎处理ABA问题和自旋等待的情况。

2) ABA问题怎么解决?

解决ABA问题的常见方法是引入版本号或标记,以确保CAS操作不仅比较值,还比较版本号或标记,从而更可靠地检测到中间值的变化:

  1. 引入版本号或标记:在共享数据结构中引入一个版本号或标记,初始值为1或其他适当的值。
  2. 读取操作:在执行CAS操作之前,进行读取操作,获取共享数据的当前值和版本号。
  3. CAS操作:在CAS操作中,除了比较值之外,还比较版本号或标记是否与读取的值匹配。
  4. 修改操作:如果值和版本号匹配,执行修改操作,并将版本号或标记递增(自增)。
  5. 处理失败情况:如果版本号或标记不匹配(即中间发生了其他修改),CAS操作失败,需要进行适当的重试或处理。

这种方式确保了CAS操作在检查值的同时也检查了版本号或标记,从而可以检测到ABA问题。只有在值和版本号或标记都匹配的情况下,CAS操作才会成功。这种技术在并发编程中非常常见,特别是在需要确保数据一致性和正确性的高并发环境中。例如,Java中的AtomicStampedReference和AtomicMarkableReference就是用来解决ABA问题的原子类,它们同时比较引用和版本号或标记,从而提供更可靠的CAS操作。

Synchronized 原理

基本特点

结合上面的锁策略,我们就可以总结出Synchronized 具有以下特性(只考虑 JDK 1.8):

1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁

 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁。

 3. 实现轻量级锁的时候大概率用到的自旋锁策略

4. 是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

其他的优化操作

锁升级

JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁状态。

会根据情况,进行依次升级。锁升级的过程是单向的,不能再降级了!

1) 偏向锁 ——其实就是“懒汉模式”的另一种体现

第一个尝试加锁的线程,优先进入偏向锁状态。

偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程。

 如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)。 

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别,当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。

偏向锁本质上相当于 "延迟加锁" ,是“懒汉模式”的另一种体现,咱能不加锁就不加锁, 尽量来避免不必要的加锁开销。 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁。

让我用一个图书馆的图书借阅场景来进一步解释偏向锁:

  1. 图书馆的图书是锁:假设我们有一个小图书馆,里面有一本非常受欢迎的书籍,多个读者可以借阅这本书,这本书就相当于一个锁对象。

  2. 第一个借阅者是小明:一开始,小明是第一个尝试借阅这本书的读者,那么这本书可以被标记为偏向于小明,因为只有他借阅它。

  3. 偏向锁不是真的借书:偏向锁的引入并不是真的“借书”,而是在书的封面上做一个“偏向锁的标记”,用来记录这本书属于哪个读者(小明)。

  4. 避免不必要的借书还书开销:由于一开始只有小明借阅这本书,其他读者不需要进行借书还书操作,避免了不必要的开销。

  5. 其他读者尝试借书:如果后续有其他读者(比如小红)也尝试借阅这本书,偏向锁会被取消,因为多个读者开始竞争借阅这本书。

  6. 小明的坚决借书决心:如果小红尝试竞争借阅,那么不管这本书的借阅手续多么繁琐,小明也必须完成这个操作,以确保抢先小红借阅到这本书。

总之,偏向锁适用于单线程或低竞争情况下的锁性能优化,避免了不必要的加锁解锁开销。但如果多个线程竞争这个锁,偏向锁会自动取消,以确保多线程之间的公平竞争。这个机制有助于提高Java应用程序中锁的性能。

2) 轻量级锁

随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态,这也是一种自适应的自旋锁。轻量级锁通过CAS(比较并交换)来实现:

  • CAS检查和更新内存:当一个线程尝试获取轻量级锁时,它会使用CAS操作来检查并更新一块内存,通常是对象头中的一部分。这块内存用于存储锁的信息,例如指向持有锁的线程的引用或标记。

  • CAS操作成功:如果CAS操作成功,意味着该线程成功获取了锁,它认为加锁成功。在这种情况下,该线程可以继续执行临界区的代码。

  • CAS操作失败:如果CAS操作失败,意味着锁已经被其他线程持有,当前线程无法立即获取锁。此时,当前线程不会放弃CPU,而是进入自旋状态,继续尝试获取锁。

  • 自旋等待:自旋是一种忙等待的机制,线程在这里不会进入阻塞状态,而是反复检查锁的状态,看是否能够获取锁。这样可以避免线程进入和退出阻塞状态的开销,但也会浪费CPU资源。

  • 自适应自旋:轻量级锁通常不会一直持续自旋等待,而是采用自适应策略。也就是说,线程会自行调整自旋等待的时间或重试次数,如果在一定时间内或重试次数达到上限仍然无法获取锁,线程会将自旋等待转化为正常的阻塞等待。

总结而言,轻量级锁通过CAS操作实现了一种自适应的自旋锁机制,它可以在一定程度上减少线程进入和退出阻塞状态的开销,提高了锁的性能。然而,自旋锁也需要注意控制自旋等待的时间和次数,以免浪费CPU资源。如果自旋等待时间过长或自旋次数过多,还是会影响性能。因此,它是一种权衡,适用于竞争不激烈的情况。

3)重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁。

重量级锁是一种用于处理高度竞争的锁情况的机制:

  • 竞争激烈:当多个线程竞争同一个锁时,且竞争非常激烈,轻量级锁中的自旋等待可能无法快速获得锁状态。

  • 使用内核提供的mutex:在这种情况下,锁会升级为重量级锁,这意味着它将依赖于操作系统提供的内核级别的mutex(互斥量)。

  • 进入内核态:在执行加锁操作时,线程首先会进入内核态,这是一个高权限的状态,允许线程执行一些操作系统提供的底层操作。

  • 内核判断锁状态:在内核态中,操作系统会判定当前锁是否已经被占用。如果锁没有被占用,那么线程的加锁操作会成功,然后线程将切换回用户态,继续执行。

  • 锁被占用:如果锁已经被其他线程占用,那么加锁操作将失败。在这种情况下,线程将进入锁的等待队列并挂起。线程会等待操作系统的唤醒信号。

  • 等待和唤醒:线程在等待队列中挂起,直到锁被其他线程释放。当其他线程释放锁并希望使用锁的时候,操作系统会唤醒等待队列中的一个或多个线程,尝试重新获取锁。

总结而言,重量级锁是一种处理高度竞争的锁情况的机制,它依赖于操作系统提供的内核级别的mutex来实现锁的管理。虽然重量级锁能够处理激烈的竞争,但是由于涉及到内核态和用户态之间的切换,它的性能开销通常比轻量级锁要大。因此,重量级锁适用于高竞争的场景,但在竞争不激烈的情况下,轻量级锁更为高效。

锁升级的过程就是在性能和线程安全直接尽量进行权衡。 不同的锁级别(偏向锁、轻量级锁、重量级锁)在性能和线程安全方面有不同的权衡考虑。锁升级的过程就是根据竞争情况动态地切换锁的级别,以在不同场景下平衡性能和线程安全。在多线程编程中,选择合适的锁级别对于系统的性能和可伸缩性至关重要。因此,开发人员需要根据具体的应用场景和并发情况来选择和优化锁的级别,以达到最佳的性能和线程安全。

锁消除

编译器+JVM 判断锁是否可消除。 如果可以, 就直接消除。

什么是 "锁消除" ?

锁消除也是编译器和JVM在代码执行过程中的一种优化技术,编译器会自动针对你当前写的加锁的代码进行判定,如果它觉得这个场景不需要加锁,此时就会把你写的synchronized给优化掉。

所以它用于判断某些同步锁是否可以被消除。锁消除的目标是提高程序的性能,特别是在没有多线程竞争的情况下,避免不必要的锁开销。

比如说,StringBuilder不带synchronized,StringBuffer带synchronized。不是说你写了synchronized就一定线程安全!

如果是在单个线程中使用StringBuffer,编译器就会自动把synchronized给优化掉!

锁消除的基本思想是在运行时检测代码中的锁,如果编译器和JVM发现某个锁在整个代码执行过程中不会被多个线程竞争,那么它们就会认为这个锁是安全的,并将其消除。这意味着在这种情况下,同步块内的代码可以在没有锁的情况下执行,从而提高程序的性能。

当然,编译器只会在自己非常有把握的时候才会进行锁消除,触发概率不是很高。消除锁是编译阶段触发的,还没到运行时呢!

而对比偏向锁,它则是在运行的时候根据多线程的调度情况的不同来决定的。编译阶段无法判定这个锁是否必要,就只会保守的保留锁。

刚刚提过,一个典型的例子是使用了StringBuffer或StringBuilder的情况。这些类通常包含了同步的方法,如append方法。但是如果在代码中只有单线程在使用它们,编译器和JVM可以检测到没有多线程竞争,因此可以消除掉这些不必要的同步锁,从而提高字符串操作的性能。

  1. StringBuffer sb = new StringBuffer();
  2. sb.append("a");
  3. sb.append("b");
  4. sb.append("c");
  5. sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁。 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销。

需要注意的是,锁消除是由编译器和JVM自动进行的优化,而不是由程序员显式控制的。它通常在即时编译(Just-In-Time Compilation)阶段完成(没到运行的时候),程序员只需编写线程安全的代码,而不需要手动指定锁是否消除。然而,了解锁消除的概念有助于理解性能优化和代码执行过程中的一些细节。

锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。

锁粗化是一种针对锁的优化策略,它可以减少锁的申请和释放次数,提高程序的性能:

  • 锁的粒度:锁的粒度可以分为粗粒度和细粒度。synchronized里面,代码越多,就认为锁的粒度越粗。反之,锁的粒度越细。粗粒度锁是在一个大的代码块上加锁,而细粒度锁是在多个小的代码块上加锁。

  • 多次加锁解锁:在一段逻辑中如果出现多次加锁和解锁操作,例如多次进入临界区并退出,这会导致频繁的锁申请和释放,增加了锁的开销。粒度细的时候能够并发执行的逻辑越多,更有利于充分利用多核CPU资源。但是如果粒度细的锁被反复进行加锁解锁,可能实际效果还不如力度粗的锁(涉及到反复的锁竞争)。

  • 锁粗化的优化:为了减少频繁的锁申请和释放,编译器和JVM可以自动进行锁的粗化优化。这意味着它们会将多个连续的锁操作合并成一个大的锁操作。

  • 避免频繁申请释放锁:锁粗化的目标是避免频繁的申请和释放锁,因为这些操作会增加系统的开销。通过将多个锁操作合并成一个大的锁操作,可以减少锁操作的次数。

  • 粗化策略:锁粗化的策略是在发现连续的加锁解锁操作时,将它们合并成一个大的锁操作。这个大的锁操作覆盖了整个连续代码块,避免了多次加锁解锁的开销。

锁粗化是一种优化策略,用于减少锁操作的次数,提高程序性能。它通过将多次加锁解锁操作合并成一个大的锁操作,避免了频繁的锁申请和释放,特别适用于那些需要频繁进入临界区的情况。在实际开发中,使用细粒度锁是期望能够释放锁以便其他线程使用,但是如果没有其他线程来竞争锁,JVM可能会自动进行锁粗化优化,以避免频繁的加锁解锁操作。

举个例子看看:

假设有一个电商网站,多个用户同时访问该网站的购物车功能,每个用户都有一个独立的购物车对象。在购物车功能中,用户可以往购物车中添加商品,删除商品,以及结算购物车中的商品。

最初的实现是使用了细粒度锁,即每个用户的购物车对象都有一个独立的锁,用于保护购物车的操作。这样,每个用户在访问自己的购物车时都需要获取自己购物车的锁,进行加锁和解锁操作。

  1. public class ShoppingCart {
  2. private List<String> items = new ArrayList<>();
  3. private final Object lock = new Object();
  4. public void addItem(String item) {
  5. synchronized (lock) {
  6. items.add(item);
  7. }
  8. }
  9. public void removeItem(String item) {
  10. synchronized (lock) {
  11. items.remove(item);
  12. }
  13. }
  14. public void checkout() {
  15. synchronized (lock) {
  16. // 执行结算操作
  17. }
  18. }
  19. }

在这个实现中,每个用户在对自己的购物车进行操作时都需要获取自己购物车的锁,这是一种细粒度锁的设计。

然而,如果用户数量非常多,每个用户都在独立的购物车上进行操作,那么加锁和解锁操作的开销会很大,因为每个用户都要频繁地获取和释放锁。

在这种情况下,编译器和JVM可以进行锁粗化优化。它们可以将多个用户的购物车操作合并成一个大的锁操作,即在整个购物车操作过程中只获取一次锁,然后执行完所有操作后再释放锁。

在这个优化后的实现中,购物车的操作在整个方法内部都使用了同一个锁,避免了频繁的锁操作,提高了性能。

JUC(java.util.concurrent) 的常见类

并发(这个包里面的内容多是一些多线程相关的组件,除非显式说明,都不用背,有个大概的印象就可以了~)

Callable 接口

Callable 的用法

Callable 是一个 interface 。 相当于把线程封装了一个 "返回值"。方便我们借助多线程的方式计算结果。

它也是一种创建线程的方式。适合于“想让某个线程执行一个逻辑,并返回结果”。相比之下,Runnable不关注结果。

总结来说就是,Callable 是 Java 中的一个接口,用于表示可以由多个线程并行执行的任务。它类似于 Runnable 接口,但有一个关键的不同点:Callable 任务可以返回一个结果或抛出异常。

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 不使用 Callable 版本

  1. 创建一个类 Result ,包含一个 sum 表示最终结果,lock 表示线程同步使用的锁对象。
  2.  main 方法中先创建 Result 实例,然后创建一个线程 t。在线程内部计算 1 + 2 + 3 + ... + 1000。
  3.  主线程同时使用 wait 等待线程 t 计算结束。 (注意, 如果执行到 wait 之前,线程 t 已经计算完了,就不必等待了)。
  4.  当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果。
  1. static class Result {
  2. public int sum = 0;
  3. public Object lock = new Object();
  4. }
  5. public static void main(String[] args) throws InterruptedException {
  6. Result result = new Result();
  7. Thread t = new Thread() {
  8. @Override
  9. public void run() {
  10. int sum = 0;
  11. for (int i = 1; i <= 1000; i++) {
  12. sum += i;
  13. }
  14. synchronized (result.lock) {
  15. result.sum = sum;
  16. result.lock.notify();
  17. }
  18. }
  19. };
  20. t.start();
  21. synchronized (result.lock) {
  22. while (result.sum == 0) {
  23. result.lock.wait();
  24. }
  25. System.out.println(result.sum);
  26. }
  27. }

可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错。

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本

  1. 创建一个匿名内部类, 实现 Callable 接口。 Callable 带有泛型参数,泛型参数表示返回值的类型。
  2. 重写 Callable 的 call 方法, 完成累加的过程。 直接通过返回值返回计算结果。 
  3. 把 callable 实例使用 FutureTask 包装一下。 
  4. 创建线程, 线程的构造方法传入 FutureTask 。 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算。计算结果就放到了 FutureTask 对象中。
  5.  在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕。 并获取到 FutureTask 中的结果。
  1. import java.util.concurrent.Callable;
  2. import java.util.concurrent.ExecutionException;
  3. import java.util.concurrent.FutureTask;
  4. public class Demo2 {
  5. public static void main(String[] args) throws ExecutionException, InterruptedException {
  6. //定义了任务
  7. Callable<Integer> callable = new Callable<Integer>() {
  8. @Override
  9. public Integer call() throws Exception {
  10. int sum = 0;
  11. for (int i = 1; i <= 1000; i++) {
  12. sum += i;
  13. }
  14. return sum;
  15. }
  16. };
  17. //把任务放到线程中执行
  18. FutureTask<Integer> futureTask = new FutureTask<>(callable);
  19. Thread t = new Thread(futureTask);
  20. t.start();
  21. //此处的 get 就能获取到callable里面的返回结果。
  22. //由于线程是并发执行的,执行到主线程的 get 的时候,t线程可能还没执行完
  23. //没执行完的话,get 就会阻塞
  24. int result = futureTask.get();
  25. System.out.println(result);
  26. }
  27. }

可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多,我们也不必手动写线程同步代码了。

理解 Callable

Callable 和 Runnable 相对, . Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务. Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作.

Callable 和 Runnable 在功能上是相似的,它们都可以被 Thread 类用作目标对象,都是描述一个 "任务"。但是 Callable 具有返回值,而 Runnable 没有。Callable 的 call 方法带有返回值, 所以Callable 通常需要搭配 FutureTask 来使用, FutureTask 用来保存 Callable 的返回结果。 因为Callable 往往是在另一个线程中执行的, 啥时候执行完我们并不确定,此时 FutureTask 就可以负责这个等待结果出来的工作。

 而 Runnable 的 run 方法则没有。

在 Java 并发编程中,当我们需要获取线程执行结果时,就会使用 Callable,它被设计成具有返回值的,并且可以抛出异常。

理解 FutureTask

FutureTask 是一个包装器,它接受 Callable 实例作为参数,并将其转换为一个 Runnable,因此它可以提交给 ExecutorService。当运行这个 FutureTask 时,它会在内部调用 Callable 的 call 方法,并保存其返回结果。之后,我们可以使用 FutureTask 的 get 方法来获取这个结果。

以麻辣烫为例:当你在餐厅点了麻辣烫后,厨师开始准备。在这个过程中,你并不直接与厨师进行交互来获取你的麻辣烫,而是得到一张小票。这张小票允许你在适当的时候检查麻辣烫是否已经准备好。在这里,Callable 就像是厨师,它的任务是准备麻辣烫;FutureTask 就像那张小票,允许你查询状态并最终获取麻辣烫(即 Callable 的结果)。

ReentrantLock

"ReentrantLock" 中的 "Reentrant" 表示可重入,这是指这种锁允许同一个线程多次获取同一个锁而不会造成死锁。这与 synchronized 关键字的语义类似,因为 synchronized 也是可重入的,它们都是用来实现互斥效果, 保证线程安全。

可重入锁的主要特点是,同一个线程可以多次获取同一个锁,而不会被自己阻塞。这允许线程在执行某个同步方法时,可以再次调用该方法,而不会被锁阻塞。这种特性对于复杂的程序结构或递归算法非常有用。

ReentrantLock 是 Java 标准库中提供的可重入锁的一种实现,它提供了比 synchronized 更灵活的锁定方式。

与 synchronized 不同,ReentrantLock 可以用于更复杂的锁定需求,例如可以指定锁的公平性、超时等待、可中断锁等特性。

ReentrantLock 的用法:

lock(): 这是ReentrantLock最基本的加锁方法,它会一直等待直到获得锁。如果某个线程已经获得了锁,那么其他线程调用lock()会被阻塞,直到锁被释放。千万不要忘记 unlock( )的调用!!!

  1. ReentrantLock lock = new ReentrantLock();
  2. // 线程1获取锁
  3. lock.lock();
  4. try {
  5. // 执行线程1的代码
  6. // working
  7. } finally {
  8. // 确保锁会被释放
  9. lock.unlock();
  10. }

tryLock(): 这个方法尝试去获取锁,但是如果锁当前被其他线程占用,它不会一直等待,而是会立即返回一个结果,告诉你是否获取锁成功。你也可以传递一个超时时间作为参数,如果在超时时间内没有获取到锁,它会返回false。

  1. ReentrantLock lock = new ReentrantLock();
  2. if (lock.tryLock()) {
  3. try {
  4. // 执行加锁成功后的代码
  5. } finally {
  6. lock.unlock();
  7. }
  8. } else {
  9. // 未获取到锁,处理失败的情况
  10. }

unlock(): 这个方法用于释放锁。它必须在之前获取锁的线程中调用,以确保锁被正确释放。

使用ReentrantLock的好处之一是我们可以在try块中获取锁,然后在finally块中释放锁,这样可以确保无论在获取锁之后发生什么异常,锁都会被正确释放,避免死锁情况。

ReentrantLock还提供了其他方法,如lockInterruptibly()(可以响应中断)、isLocked()(检查是否被某个线程持有锁)等,这些方法增强了锁的灵活性,使其适用于更多不同的多线程场景。

ReentrantLock 和 synchronized 的区别:

  1. 实现方式:

    • synchronized是Java内置的关键字,由JVM直接实现。
    • ReentrantLock是Java标准库中的类,由Java代码实现,依赖于底层操作系统的一些特性。
  2. 手动释放锁:

    • 使用synchronized时,JVM会在代码块执行完毕时自动释放锁。
    • 使用ReentrantLock时,你需要手动调用 unLock()方法来释放锁,因此更加灵活,但也容易忘记释放锁。
  3. 等待策略:

    • 当使用synchronized时,如果无法获取锁,线程会进入阻塞状态并一直等待。
    • ReentrantLock提供的 tryLock()方法,可以尝试获取锁,如果获取不到可以选择等待一段时间后放弃。
    • ReentrantLock在加锁的时候有两种方式:lock——加锁失败,阻塞等待,和synchronized挺像的;tryLock——加锁失败,放弃。所以ReentrantLock给了我们更多的选择空间。
    • ReentrantLock提供了更强大的等待通知机制,搭配了Condition类实现等待通知,可以指定通知对象,而不是像synchronized一样随机通知。
  4. 公平性:

    • synchronized是非公平锁,无法保证等待时间最长的线程优先获取锁。
    • ReentrantLock默认也是非公平锁,但可以通过构造函数参数开启公平锁模式。
  5. 可中断性:

    • ReentrantLock的 lockInterruptibly() 方法允许响应中断,即线程可以在等待锁的过程中被中断。
    • synchronized不支持线程中断。

总的来说,ReentrantLock相比synchronized更加灵活,提供了更多的特性,如可中断性、公平性设置、超时等待等,适用于更复杂的多线程场景。由于其需要手动释放锁,需要谨慎使用,以避免忘记释放锁导致的死锁问题。在大多数情况下,使用synchronized已经足够满足多线程同步的需求,因为它简单、易用且性能表现良好。只有在需要更高级的特性时,才考虑使用ReentrantLock。

虽然ReentrantLock由上述优势,但是在加锁的时候我们还是首选synchronized。

另外synchronized背后还有一系列优化手段。

  1. // ReentrantLock 的构造方法
  2. public ReentrantLock(boolean fair) {
  3. sync = fair ? new FairSync() : new NonfairSync();
  4. }

更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指 定的线程

如何选择使用哪个锁?

选择使用哪个锁的决策可以根据锁竞争程度和需求来进行。

  1. 使用synchronized:

    • 当锁竞争不激烈,且你不需要特殊的功能时,使用synchronized是最简单和高效的选择。
    • synchronized内置于Java,使用方便,不需要手动释放锁。
  2. 使用ReentrantLock :

    • 当锁竞争激烈,且需要更高级的特性时,如可中断性、超时等待、公平性控制,可以考虑使用 ReentrantLock 。
    • ReentrantLock 提供了更多的灵活性,搭配 tryLock() 等方法可以更精细地控制加锁行为。
  3. 选择锁的公平性

    • 如果需要确保线程按照请求的顺序获取锁,可以使用 ReentrantLock 的公平锁模式,通过构造函数参数设置为true来启用。
    • 非公平锁在性能上可能更好,但不能保证线程获取锁的顺序。

最终的选择应该根据具体的应用场景和性能需求来确定。在编写多线程代码时,还要谨慎考虑锁的粒度、避免死锁和饥饿等问题,以确保多线程程序的正确性和性能。

原子类

原子类(Atomic Classes)是Java中用于支持多线程并发操作的一组类,它们内部使用了CAS(Compare-And-Swap)操作来实现原子性操作。这些类提供了一种可靠的方式来处理多线程并发访问共享变量,而无需显式使用锁。

以下是一些常见的原子类:

  1. AtomicBoolean: 用于原子性地操作布尔型变量。可以执行原子的get和set操作。
  2. AtomicInteger: 用于原子性地操作整型变量。可以执行原子的增减、加法、减法、获取等操作。
  3. AtomicIntegerArray: 用于原子性地操作整型数组中的元素。提供了一系列原子性操作,如get、set、add、decrementAndGet等。
  4. AtomicLong: 用于原子性地操作长整型变量。类似于AtomicInteger,提供了原子性的增减、加法、减法、获取等操作。
  5. AtomicReference: 用于原子性地操作引用类型变量。可以原子地获取、设置、比较和交换引用对象。
  6. AtomicStampedReference: 类似于AtomicReference,但它还包含一个标记(stamp)来解决ABA问题。可以原子性地获取、设置、比较和交换引用对象以及标记。

原子类通常用于替代锁来管理共享数据的访问,因为它们具有更低的性能开销。但需要注意,虽然原子类可以确保单个操作是原子性的,但不能解决复合操作的原子性问题,例如检查后操作(check-then-act)。

AtomicInteger 是用于原子性操作整数的类,它提供了一系列常见的方法来执行原子性操作。以 AtomicInteger为例,它的一些常见方法如下:

  • get: 获取当前的整数值。
  • int value = atomicInteger.get();
  • set: 设置整数的新值。
  • atomicInteger.set(42);
  • getAndSet: 先获取当前的整数值,然后设置新的整数值。
  • int oldValue = atomicInteger.getAndSet(42);
  • incrementAndGet: 增加整数的值并获取新值。
  • int newValue = atomicInteger.incrementAndGet();
  • decrementAndGet: 减少整数的值并获取新值。
  • int newValue = atomicInteger.decrementAndGet();
  • getAndIncrement: 先获取当前的整数值,然后增加整数的值。
  • int oldValue = atomicInteger.getAndIncrement();
  • getAndDecrement: 先获取当前的整数值,然后减少整数的值。
  • int oldValue = atomicInteger.getAndDecrement();
  • addAndGet: 增加指定的值并获取新值。
  • int newValue = atomicInteger.addAndGet(5);
  • getAndAdd: 先获取当前的整数值,然后增加指定的值。
  • int oldValue = atomicInteger.getAndAdd(5);
  • compareAndSet: 比较当前值是否等于给定的预期值,如果相等,则设置新值。
  • boolean updated = atomicInteger.compareAndSet(42, 50);

 信号量 Semaphore

信号量就是用来表示可用资源的个数,它在多线程环境中提供了一种有效的控制和协调资源访问的机制。信号量的应用范围很广泛,它可以用来解决各种并发编程问题,如控制线程的数量,限制资源的访问,以及实现生产者-消费者模式等。

停车场的比喻是一个很好的方式来理解信号量的工作原理:

  • 可以把信号量想象成是停车场的展示牌: 当前有车位 100 个。 表示有 100 个可用资源。
  • 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
  • 当有车开出来的时候, 就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作)
  • 如果计数器的值已经为 0 了, 还尝试申请资源,就会阻塞等待,直到有其他线程释放资源。

也就是,信号量本质上是一个计数器。

每次申请一个可用资源,就需要让计数器-1;

每次释放一个可用资源,就需要让计数器+1;(+1-1都是原子的)

在多线程编程中,信号量通常有两个主要操作:

  1. P 操作(等待操作)(使用acquire): 当一个线程需要获取一个可用资源时,它会执行 P 操作。如果可用资源数大于 0,线程会成功获取资源,可用资源数减少;如果可用资源数已经为 0,线程将被阻塞等待,直到有其他线程释放资源。

  2. V 操作(释放操作)(使用release): 当一个线程释放一个资源时,它会执行 V 操作。这会导致可用资源数增加。如果有其他线程在等待资源,它们中的一个将会被唤醒,成功获取资源。

    在使用信号量时,通常是每次释放资源都需要调用一次 release 方法,释放一个资源。这意味着如果你申请了多个资源,就需要相应地多次调用 release 方法来释放这些资源。

    例如,如果你在一个线程中申请了三个资源,那么在使用完这三个资源后,应该分别调用三次 release 方法来释放它们,以便让其他线程能够再次申请和使用这些资源。

    每次调用 release 方法都会增加信号量的计数器,表示有一个额外的资源可用。其他线程可以通过调用 acquire 方法来申请这些资源。

你有没有觉得,这里的“阻塞等待”就有一种锁的感觉?

其实,锁本质上就属于一种特殊的信号量!所就是可用资源为 1 的信号量,加锁操作——P,1-->0;解锁操作——V,0--->1。你可以把它看作是一种二元信号量。 

操作系统提供了信号量的实现,提供了API,JVM封装了这样的API,就可以在Java代码中使用了:

public Semaphore(int permits, boolean fair) 构造函数:

  • 这个构造函数创建一个Semaphore对象,可以指定许可证的数量(permits)以及是否启用公平模式(fair)。
  • 如果fair参数为true,则表示启用公平模式,Semaphore将按照等待的线程顺序来分配许可证,以确保线程按照其等待的顺序获取许可证。
  • 如果fair参数为false,则表示不启用公平模式,Semaphore将以非公平的方式分配许可证,等待线程可能以竞争的方式获取许可证。

public Semaphore(int permits) 构造函数:

  • 这个构造函数创建一个Semaphore对象,只需指定许可证的数量(permits)。
  • 与第一个构造函数不同,它没有公平模式选项,因此创建的Semaphore对象将以非公平的方式分配许可证。
  1. public static void main(String[] args) throws InterruptedException {
  2. Semaphore semaphore = new Semaphore(4);
  3. semaphore.acquire();
  4. System.out.println("第一次P操作");
  5. semaphore.acquire();
  6. System.out.println("第二次P操作");
  7. semaphore.acquire();
  8. System.out.println("第三次P操作");
  9. semaphore.acquire();
  10. System.out.println("第四次P操作");
  11. semaphore.release();
  12. }

 

  1. import java.util.concurrent.Semaphore;
  2. class Demo{
  3. public static void main(String[] args) throws InterruptedException {
  4. Semaphore semaphore = new Semaphore(4);
  5. semaphore.acquire();
  6. System.out.println("第一次P操作");
  7. semaphore.acquire();
  8. System.out.println("第二次P操作");
  9. semaphore.acquire();
  10. System.out.println("第三次P操作");
  11. semaphore.acquire();
  12. System.out.println("第四次P操作");
  13. semaphore.acquire();
  14. System.out.println("第五次P操作,但是没有资源了,阻塞等待中……");
  15. semaphore.release();
  16. }
  17. }

 

  1. public static void main(String[] args) throws InterruptedException {
  2. Semaphore semaphore = new Semaphore(4);
  3. semaphore.acquire();
  4. System.out.println("第一次P操作");
  5. semaphore.acquire();
  6. System.out.println("第二次P操作");
  7. semaphore.acquire();
  8. System.out.println("第三次P操作");
  9. semaphore.acquire();
  10. System.out.println("第四次P操作");
  11. semaphore.release();
  12. semaphore.acquire();
  13. System.out.println("第五次P操作");
  14. }

 

你会不会有这样的疑问?

我都是 release了一个资源,为啥第一种写法不可以,还在阻塞?而第二种就可以?

第一次,release()方法只是简单地增加了可用许可证的数量,但不会自动唤醒等待的线程。其他线程需要再次调用acquire()方法来争夺许可证。这就是为什么在我的代码中,即使在调用semaphore.release()后,后续的semaphore.acquire()仍然会被阻塞,因为没有其他线程来竞争那个许可证。

 换言之,整个代码被塞住了,其实我们都没有执行到semaphore.release()……

所以最好是在每次调用acquire之后在适当的时候调用release来释放许可证:

  1. Semaphore semaphore = new Semaphore(4);
  2. semaphore.acquire();
  3. System.out.println("第一次P操作");
  4. semaphore.release(); // 释放一个许可证
  5. semaphore.acquire();
  6. System.out.println("第二次P操作");
  7. semaphore.release(); // 释放一个许可证
  8. // 继续这个过程...

再举个例子:

  • 创建 Semaphore 示例,初始化为 4,表示有 4 个可用资源。
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后,释放资源。 观察程序的执行效果。
  1. import java.util.concurrent.Semaphore;
  2. public class Demo4 {
  3. public static void main(String[] args) {
  4. Semaphore semaphore = new Semaphore(4);
  5. Runnable runnable = new Runnable() {
  6. @Override
  7. public void run() {
  8. try {
  9. System.out.println("申请资源");
  10. semaphore.acquire();
  11. System.out.println("我获取到资源了");
  12. Thread.sleep(1000);
  13. System.out.println("我释放资源了");
  14. semaphore.release();
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. };
  20. for (int i = 0; i < 20; i++) {
  21. Thread t = new Thread(runnable);
  22. t.start();
  23. }
  24. }
  25. }

在软件开发中,有时候需要控制多个线程或进程对共享资源的访问,以确保线程安全和资源的合理分配。这时,可以使用信号量来实现资源的申请和释放。 

  1. 控制线程对临界区的访问:多个线程需要访问一个临界区,但只能有一个线程访问。一个信号量可以用来控制对该临界区的访问。

  2. 限制资源的并发访问:例如,一个数据库连接池中有多个数据库连接,每个线程需要从池中获取连接并使用它,然后释放连接。信号量可以用来限制并发获取连接的数量,以防止连接池被过度使用。

  3. 控制任务的并发执行:如果有一组任务需要并发执行,但你想限制同时执行的任务数量,可以使用信号量来控制任务的并发度。

CountDownLatch

CountDownLatch 是一个在多线程编程中常用的同步工具,其主要用途是在多个线程协作完成一系列任务时,用来判定任务的进度是否已经完成。

比如需要把一个大任务拆分成一个一个小的任务,让这些任务并发去执行。我们就可以使用CountDownLatch来判定说当前的这些任务是否已经完成。

比如说下载一个文件,就可以使用多线程下载。

很多的下载工具速度不快,相比之下有一些专业的下载工具就可以成倍的提高下载速度(IDM)。

大部分的下载工具和资源服务器只有一个链接,服务器往往会对于连接传输的速度有限制,而 IDM就是采取了一个多线程下载的方式,每个线程都建立一个连接。此时就需要把任务分割为小任务。

CountDownLatch 主要方法:

  • await(): 当一个线程调用 await() 方法时,它会被阻塞,等待其他线程完成任务。只有当CountDownLatch中的计数减为零时,await() 方法才会返回,线程继续往下执行。
  • countDown(): 调用 countDown() 方法告诉CountDownLatch当前子任务已经完成。每次调用 countDown() 方法都会将计数减一。一旦计数减为零,等待在 await() 方法上的线程将被唤醒,继续执行后续操作。
  1. import java.util.concurrent.CountDownLatch;
  2. public class Demo5 {
  3. public static void main(String[] args) throws InterruptedException {
  4. //当前有10个选手参数,await就会在10次调用完countDown之后才能继续执行
  5. CountDownLatch countDownLatch=new CountDownLatch(10);
  6. for(int i =0;i<10;i++) {
  7. int id = i;
  8. Thread t = new Thread(() -> {
  9. System.out.println("thread" + id);
  10. try {
  11. Thread.sleep(500);
  12. } catch (InterruptedException e) {
  13. throw new RuntimeException(e);
  14. }
  15. //通知说当前的任务都执行完毕
  16. countDownLatch.countDown();
  17. });
  18. t.start();
  19. }
  20. countDownLatch.await();
  21. System.out.println("所有的任务都执行完了!");
  22. }
  23. }

你会发现线程的执行顺序是不确定的。这是因为线程的调度和执行是由操作系统和JVM管理的,取决于多个因素,包括系统负载、线程调度策略等。因此,我们观察到的结果是无序的,不同次运行可能产生不同的线程执行顺序。 

如果我们希望线程按照特定顺序执行,可以使用ExecutorService中的submit方法,将任务按照特定顺序提交给线程池执行。这样可以确保线程按照提交的顺序执行。

  1. import java.util.concurrent.CountDownLatch;
  2. import java.util.concurrent.ExecutorService;
  3. import java.util.concurrent.Executors;
  4. public class Demo6 {
  5. public static void main(String[] args) throws InterruptedException {
  6. ExecutorService executor = Executors.newFixedThreadPool(10);
  7. CountDownLatch countDownLatch = new CountDownLatch(10);
  8. for (int i = 0; i < 10; i++) {
  9. int id = i;
  10. executor.submit(() -> {
  11. System.out.println("thread " + id);
  12. try {
  13. Thread.sleep(500);
  14. } catch (InterruptedException e) {
  15. throw new RuntimeException(e);
  16. }
  17. countDownLatch.countDown();
  18. });
  19. }
  20. countDownLatch.await();
  21. System.out.println("所有的任务都执行完了!");
  22. executor.shutdown();
  23. }
  24. }

 如果单单把 for 循环改为循环9次,那么会阻塞等待:

  1. import java.util.concurrent.CountDownLatch;
  2. public class Demo5 {
  3. public static void main(String[] args) throws InterruptedException {
  4. //当前有10个选手参数,await就会在10次调用完countDown之后才能继续执行
  5. CountDownLatch countDownLatch=new CountDownLatch(10);
  6. for(int i =0;i<9;i++) {
  7. int id = i;
  8. Thread t = new Thread(() -> {
  9. System.out.println("thread" + id);
  10. try {
  11. Thread.sleep(500);
  12. } catch (InterruptedException e) {
  13. throw new RuntimeException(e);
  14. }
  15. //通知说当前的任务都执行完毕
  16. countDownLatch.countDown();
  17. });
  18. t.start();
  19. }
  20. countDownLatch.await();
  21. System.out.println("所有的任务都执行完了!");
  22. }
  23. }

 

相关面试题

线程同步的方式有哪些?

线程同步是多线程编程中的一个重要概念,它指的是协调多个线程的执行顺序,以确保它们按照一定的规则和顺序访问共享资源,以避免数据竞争和不确定性的结果。

synchronized, ReentrantLock, Semaphore、CountDownLatch 等都可以用于线程同步。

线程安全的集合类

原来的集合类, 大部分都不是线程安全的。

Vector, Stack, Hashtable, 是线程安全的(属于是上古时期Java引入的集合类了,现在不建议使用,快要被淘汰了), 其他的集合类不是线程安全的。

针对这些线程不安全的集合类,要想在多线程环境下使用,就需要考虑好线程安全问题了。

你第一个想到的肯定是加锁,这也是最常见的方法。

同时,标准库也给我们提供了一些搭配的组件来保证线程的安全。

多线程环境使用 ArrayList

1、Collections.synchronizedList(new ArrayList);

List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。

 synchronizedList 的关键操作上都带有 synchronized。

Collections.synchronizedList() 方法返回一个包装了原始List的新List,该新List的关键操作(如添加、删除、遍历等)都是通过synchronized关键字进行同步的,以确保多个线程可以安全地访问和修改列表。

简单来说就是这个方法会返回一个新的对象,这个新的对象就相当于给ArrayList套了一层壳,这层壳就是在方法上直接使用synchronized的。单线程下使用,可以就用本体,但是多线程下就可以套上外壳。

使用Collections.synchronizedList()有一些优点和限制:

优点:

  • 简单易用:它不需要开发者手动编写同步代码,因此使用起来比较简单。
  • 基本线程安全:对于基本的读取和写入操作,它提供了线程安全的保障。

限制:

  • 性能:虽然Collections.synchronizedList()提供了线程安全,但在高度并发的情况下,性能可能会受到影响,因为多个线程需要竞争同一个锁。
  • 不能保证复合操作的原子性:尽管单个操作是线程安全的,但如果需要执行多个操作来实现复合操作(例如检查再增加),则可能需要额外的同步。
  • 迭代时需要手动同步:如果在迭代时需要对集合进行修改,开发者仍需要手动同步。

如果对性能有更高要求或需要支持更复杂的操作,考虑使用java.util.concurrent包中的并发集合类,如CopyOnWriteArrayList或ConcurrentLinkedQueue,它们提供更好的性能和更丰富的功能。这些并发集合类通常使用更高级的同步策略来提供更好的并发性。

2、使用 CopyOnWriteArrayList

CopyOnWriteArrayList是Java中的一种并发容器,它的主要特点是在写操作时进行拷贝(复制)操作,以确保写操作不会影响正在进行的读操作,从而实现读写分离,从而提供了一种在特定场景下非常有用的线程安全机制。

比如,两个线程使用同一个ArrayList,可能会读,也可能会修改。如果两个线程要读,就直接读。但是如果某个线程需要进行修改,就要把ArrayList复制出一份副本。修改线程其实是在修改副本。与此同时,另一个线程仍可以读取数据(从原来的那份数据读)。一旦这边修改完毕,就会使用修改好的这份数据去替代掉原来的数据(往往就是一个引用赋值,极快的速度)。

上述的是个过程进行修改,就不需要加锁了。

以下是关于CopyOnWriteArrayList的一些优点和缺点:

优点:

  • 高并发读: CopyOnWriteArrayList适用于读多写少的场景,因为读操作不需要锁,可以并发进行,不会阻塞其他读操作。
  • 无需显式锁定: 由于写操作在底层进行了拷贝,因此写操作不会影响正在进行的读操作,这意味着在大多数情况下,不需要显式地进行锁定。
  • 线程安全: CopyOnWriteArrayList是线程安全的,因此不需要开发者编写额外的同步代码。

缺点:

  • 内存占用: 由于每次写操作都会创建一个新的副本,因此会占用较多的内存。这对于大型数据集可能会成为一个问题。
  • 写操作延迟: 写操作需要复制整个数据集,因此写操作的性能通常较低,并且有一定的延迟。这使得CopyOnWriteArrayList不适用于写操作频繁的场景。
  • 不适用于实时性要求高的场景: 由于写操作的延迟,新写入的数据不会立即对所有线程可见,因此不适用于实时性要求非常高的应用。
  • 更适合于一个线程修改,而不是多个线程同时修改。不然会乱套。

总之,CopyOnWriteArrayList是一种适用于读多写少场景的并发容器,它通过牺牲写操作的性能来提供读操作的高并发性。在合适的应用场景下,它可以是一种非常有用的工具,比如“服务器的配置更新”(可以通过配置文件来描述配置的详细内容。这个配置本身不会很大。配置的内容会被读到内存中,再由其他的线程读取这里的内容。但是修改这个配置内容往往只有一个线程来修改)。当然,我们也需要根据具体需求权衡其优缺点。如果写操作频繁或要求实时性较高,可能需要考虑其他并发容器或同步机制。

多线程环境使用队列

1) ArrayBlockingQueue 基于数组实现的阻塞队列

2) LinkedBlockingQueue 基于链表实现的阻塞队列

3) PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列

4) TransferQueue 最多只包含一个元素的阻塞队列(不常用)

多线程环境使用哈希表

HashMap 本身不是线程安全的

在多线程环境下使用哈希表可以使用: Hashtable 和 ConcurrentHashMap。

(1) Hashtable(只是简单的把关键方法加上了 synchronized 关键字)

Hashtable保证线程安全,主要就是给方法加上synchronized,直接加到方法上(相当于给this加锁)。也就是说,只要两个线程在操作同一个Hashtable,就会出现锁冲突。

这相当于直接针对 Hashtable 对象本身加锁: 

  • size 属性也是通过 synchronized 来控制同步,也是比较慢的;
  •  一旦触发扩容, 就由该线程完成整个扩容过程;
  • 这个过程会涉及到大量的元素拷贝,效率会非常低

但是实际上,对于哈希表来说,锁不一定非要这么加。有些情况其实是不涉及到线程安全问题的。 

 (2)ConcurrentHashMap

还记得哈希冲突吗?就是两个不同的key映射到了同一个数组的下标上,出现哈希冲突。

两个解决方案,重新找空闲位置(不常用,麻烦);使用链表来解决。

那么以使用链表为例,按照“在两条链表上修改元素并且不考虑触发扩容的前提下”,这个时候线程就是安全的。相比之下,如果两个线程操作的是同一个链表,才比较容易出问题!

综上,如果两个线程操作的是不同链表,就根本不许用加锁;只有操作的同一个链表才需要加锁(一个哈希表有很多链表,两个线程恰好同时访问同一个链表的情况本身就比较少)(锁通:用每个链表的头结点作为锁对象)。

ConcurrentHashMap最核心的改进就是把一个全局的HashMap的大锁,改成了每个链表一把独立的小锁,从而大幅度降低锁冲突的概率。

ConcurrentHashMap 的一些其他重要改进和重要实现细节:

  1. 使用局部锁桶: ConcurrentHashMap 在Java 8 之前使用了一种分段锁。在Java 8 之前ConcurrentHashMap就是基于分段锁的方式实现的。从Java8之后就成了直接在链表头节点加锁的形式。不一样的是,分段锁是多个链表共用一把锁将整个哈希表分成多个小块,每个小块称为一个桶(bucket),并为每个桶分配一个独立的锁。这样,不同的线程可以同时访问不同的桶,大大减小了锁竞争的概率,提高了并发性能。
  2. CAS 操作和 volatile: 在读操作中,ConcurrentHashMap 并没有使用显式锁,而是依赖于CAS(比较并交换)操作和volatile关键字来确保读操作的可见性和一致性。这使得读操作非常高效,不需要阻塞其他线程。
  3. ConcurrentHashMap充分利用到了CAS特性,把一些不必要的枷锁环节给省略加锁了。比如,使用哈希表的时候往往需要使用一个变量记录哈希表中的元素个数。此时就可以使用CAS原子操作来修改元素个数,以减少加锁步骤。
  4. ConcurrentHashMap还有一个激进的操作,针对读操作没有加锁。意味着读和读之间、读和写指向都不会有锁竞争。那我们就会问了,是否会存在“读到一个修改了一般的数值?”ConcurrentHashMap在底层编码的时候比较谨慎的处理了一些细节,不然修改的时候会避免使用++--这些而非原子的操作,使用 = 进行修改,本身就是原子的,所以读的时候要么读到的就是写之前的旧值,要么就是写之后的新值,不会出现一个读到一半的值。
  5. 优化扩容方式,化整为零: 本身哈希表或者Hashtable在扩容的时候就需要把所有的元素都拷贝一遍,如果元素很多,拷贝就会比较耗时。用户访问1000次,999次都很流程,但是最后一次卡了,可能就是这次触发了扩容,容易卡顿。所以,在扩容时,ConcurrentHashMap 采用了一种化整为零的方式。当需要扩容时,它不会一次性将所有数据都搬移到新数组中,而是创建一个新的数组,然后逐渐将元素从旧数组搬移到新数组。这个过程会在后续的插入和查找操作中依次进行,减少了扩容时的性能开销,避免这单此操作过于卡顿。在扩容期间,新数组和旧数组同时存在。每个来操作 ConcurrentHashMap 的线程都会参与搬运的过程,每个线程负责搬运一小部分元素。这种方式分摊了搬运的成本,减少了线程之间的竞争。

 ConcurrentHashMap 的基本使用方法和基本的HashMap完全一样

(3)HashTable、HashMap 和 ConcurrentHashMap的区别:

HashTable、HashMap 和 ConcurrentHashMap 都是在Java中用于存储键值对的数据结构,但它们在性能、线程安全性和用法上有一些关键区别:

1、线程安全性:

  • HashTable:HashTable 是线程安全的,所有操作都是同步的,这意味着多个线程可以同时访问和修改它,但会有一些性能开销。它适用于多线程环境,但不推荐在性能要求较高的场景中使用。
  • HashMap:HashMap 不是线程安全的,它不提供内置的同步机制。如果多个线程同时访问一个 HashMap 并且至少有一个线程在修改它,那么就可能会导致不确定的结果或抛出异常。如果需要在多线程环境中使用 HashMap,可以使用 Collections.synchronizedMap 方法将其包装成线程安全的版本,但这会降低性能。
  • ConcurrentHashMap:ConcurrentHashMap 是专门为多线程环境设计的,它提供了更高的并发性能。它使用了分段锁(Segment)的机制,不同的段可以由不同的线程同时访问,从而减少了锁竞争。这使得 ConcurrentHashMap 在高并发环境中表现出色,而不会像HashTable那样产生大量的性能开销。

2、性能:

  • HashTable:HashTable 在单线程环境下性能可能较好,但在高并发环境下性能较差,因为所有操作都需要进行同步。
  • HashMap:HashMap 在单线程环境和低并发环境下性能较好,但在高并发环境下可能需要额外的同步措施,否则可能导致数据不一致。
  • ConcurrentHashMap:ConcurrentHashMap 在高并发环境中性能最好,因为它可以支持多个线程同时读取和写入不同的段,从而减少了锁竞争。

3、允许null键和值:

  • HashTable:不允许存储null键或值。
  • HashMap:允许存储一个null键,多个null值。
  • ConcurrentHashMap:允许存储null键和null值。

4、迭代器:

  • HashTable 和 HashMap 的迭代器是快速失败的,即如果在迭代过程中修改了映射,会抛出ConcurrentModificationException异常。
  • ConcurrentHashMap 的迭代器不是快速失败的,允许在迭代期间进行修改。

5、初始容量和负载因子:

  • HashTable 和 HashMap 都有初始容量和负载因子,用于控制它们的大小和性能。
  • ConcurrentHashMap 没有负载因子的概念,可以在初始化时指定估计的并发级别。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/647190
推荐阅读
相关标签
  

闽ICP备14008679号