【多线程】(六)Java并发编程深度解析:常见锁策略、CAS、synchronized原理、线程安全集合类和死锁详解

文章目录

  • 一、常见锁策略
    • 1.1 乐观锁和悲观锁
    • 1.2 读写锁
    • 1.3 重量级锁和轻量级锁
    • 1.4 自旋锁
    • 1.5 公平锁和非公平锁
    • 1.6 可重入锁和不可重入锁
  • 二、CAS
    • 2.1 什么是CAS
    • 2.2 CAS的实现原理
    • 2.3 CAS应用
    • 2.4 ABA问题
  • 三、synchronized原理
    • 3.1 synchronized锁的特点
    • 3.2 加锁工作过程
    • 3.3 锁消除和锁粗化
  • 四、JUC(java.util.concurrent)的常见类和接口
    • 4.1 ReentrantLock锁
    • 4.2 原子类
    • 4.3 线程池
    • 4.4 信号量SemaPhore
    • 4.5 Callable接口
    • 4.6 CountDownLatch
  • 五、线程安全的集合类
    • 5.1 多线程环境使用ArrayList
    • 5.2 多线程环境使用队列
    • 5.3 多线程环境使用哈希表Hashtable和ConcurrentHashMap
  • 六、死锁
    • 6.1 死锁的概念
    • 6.2 死锁的产生情况
    • 6.3 死锁的产生条件
    • 6.4 死锁的避免


一、常见锁策略

1.1 乐观锁和悲观锁

乐观锁:

假设多个线程之间的冲突是低概率事件,因此在读取数据时不会加锁,只有在更新数据时进行加锁和冲突检测。常用的乐观锁包括版本号控制和CAS(Compare 按到 Swap)操作。

悲观锁:

假设多个线程之间的冲突是高概率事件,因此在读取数据时也会加锁,其他线程需要等待锁的释放synchronized 初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。而ReentrantLock一直使用悲观锁策略。

1.2 读写锁

在多线程环境中,多个线程同时读取数据不会产生线程安全问题;但是存在写入操作的时候,写入与读取、写入与写入这些操作之间都有可能引发线程安全问题。如果对这两个场景都进行加锁操作的话,势必会产生极大的性能损耗,尤其是存在大量的读操作情景下,所有就引入了读写锁。

读写锁允许多个线程同时读取共享数据,此时不会加锁;但是当存在写操作时,所有的线程都需要等待写锁的释放读写锁适用于读锁远远多于写锁的情况,可以提高并发性能。常见的读写锁实现有ReentrantReadWriteLock

总而言之,一个线程对于数据的访问,包括以下情况:

  • 多个线程读取同一个数据,此时没有线程安全问题,不需要加锁;
  • 多个线程对同一个数据进行读取和写入操作,存在线程安全问题,需要加锁;
  • 多个线程对同一个数据进行写操作,存在线程安全问题,需要加锁。

1.3 重量级锁和轻量级锁

重量级锁:
加锁机制重度依赖于操作系统提供的mutex,它会涉及线程的上下文切换以及内核态与用户态之间的切换,对系统性能的开销较大。重量级锁适用于多个线程竞争同一个锁的情况。

轻量级锁:
轻量级锁是为了在没有多个线程竞争的情况下提高性能而设计的锁机制。它使用了CAS操作来避免进程的上下文切换内核态与用户态之间的切换。对系统的开销较小。但是当多个线程竞争同一个锁时,轻量级锁会升级为重量级锁

1.4 自旋锁

自旋锁(Spin Lock)是一种基于忙等待的锁,一个线程在没有竞争到锁的时候不会立即阻塞,而是通过循环不断的尝试去获取锁。自旋锁适用于锁占用时间短,线程冲突率低的情况。

自旋锁伪代码:

while (抢锁(lock) == 失败) {}

自旋锁是一种典型的 轻量级锁 的实现方式。

  • 优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。(而挂起等待的时候是不消耗 CPU 的)

1.5 公平锁和非公平锁

公平锁:
公平锁是按照线程的申请顺序来获取锁的,即和排队一样先到先得。当一个线程释放锁的时候,等待时间最长的线程将获取锁的访问权。公平锁保证了锁的获取是按照线程的排队顺序来获取的。

非公平锁:
非公平锁不保证线程的获取顺序是按照线程的申请顺序进行的,它允许新来的线程在等待线程中插队,比如优先级队列。有可能导致先来的线程一直无法获取到锁,但是非公平锁相比于公平锁具有更高的吞吐量

总之,公平锁和非公平锁都有各自的优势和适用场景。公平锁可以避免饥饿问题,但会导致线程切换频繁,性能相对较低非公平锁在性能上具有优势,但可能会导致某些线程长时间无法获取到锁

1.6 可重入锁和不可重入锁

可重入锁:

可重入锁允许同一个线程多次获取同一个锁。如果一个线程已经获得了某个锁,那么它可以再次获取该锁,而不会被自己所持有的锁所阻塞。可重入锁可以避免死锁,并简化了编程模型

不可重入锁:

不可重入锁指一旦一个线程获得了该锁,再次尝试获取锁时会被阻塞。不可重入锁通常需要显式地释放锁才能再次获取它,否则会造成死锁

大多数锁机制都是可重入的,包括 synchronized 关键字和 ReentrantLock可重入锁提供了更便利的锁操作,并避免了某些潜在的编程错误。不可重入锁一般较少使用,因为它需要手动释放锁,并容易导致死锁问题。

二、CAS

2.1 什么是CAS

CAS(Compare and Swap)是一种并发控制机制,用于实现多线程环境下的原子操作。它是通过比较内存中的值与期望的值,如果相等则交换,否则不做任何操作CAS是一种乐观锁技术,不需要使用传统的锁机制来保证线程安全。

例如,假设内存中的原始数据为 A,旧的预期值为 B,需要修改的新值为 C:

  1. 比较 A 与 B 是否相等。(比较)
  2. 如果结果相等,则将 B 写入 A。(交换)
  3. 返回操作是否成功。

以下是一段 CAS 的伪代码:

需要注意的是,下面写的代码不是原子的,真实的 CAS 是一个原子的硬件指令完成的。这个伪代码只是辅助理解 CAS 的工作流程。

boolean CAS(address, expectedValue, swapValue) {
	if (&address == expectedValue) {  // 比较内存地址中的值与期望值
	    &address = swapValue;  // 如果相等,则将交换值写入内存地址
	    return true;  // 操作成功,返回 true
	}
	return false;  // 操作失败,返回 false
}
  • 这段代码描述了CAS的基本思想。它首先比较内存地址中的值与期望值,如果相等,则将交换值写入内存地址,并返回 true 表示操作成功。如果不相等,则不做任何操作,并返回 false 表示操作失败。

  • 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

2.2 CAS的实现原理

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来说:

  • Java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafeCAS 依赖的是 JVM 针对不同的操作系统实现的 Atomic::cmpxchg(比较并交换);
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 CPU 硬件提供的 lock 机制保证其原子性。

总而言之,CAS的实现原理是通过硬件和软件层面的配合来实现的。硬件提供了原子指令和锁机制,而软件层面的JVM使用了底层的CAS操作实现,依赖于处理器和操作系统提供的特性来保证CAS操作的原子性。

2.3 CAS应用

1. 实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于CAS这种方式来实现的。典型的就是 AtomicInteger 类,其中:

  • getAndIncrement 相当于 i++ 操作;
  • incrementAndGet 相当于 ++i 操作;
  • getAndDecrement 相当于 i-- 操作;
  • decrementAndGet 相当于 --i 操作。

例如,getAndIncrement的伪代码实现:

class AtomicInteger {
	private int value;
	public int getAndIncrement() {
		int oldValue = value;
		while ( CAS(value, oldValue, oldValue + 1) != true) {
			oldValue = value;
		}
		return oldValue;
	}
}

2. 实现自旋锁

自旋锁是基于 CAS 实现的更灵活的锁,其伪代码如下:

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

2.4 ABA问题

1. 什么是ABA问题

ABA问题是CAS操作的一个潜在问题。ABA问题指的是,在CAS操作中,如果一个值原来是A,后来变成了B,然后又变回了A,那么CAS操作就可能会误判为成功。这是因为CAS只比较了值,并没有考虑过程中的变化。例如下面的情况:

假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A。

  1. 接下来,线程 t1 想使用 CAS 把 num 值改成 Z,那么就需要:

    • 先读取 num 的值,记录到 oldNum 变量中;
    • 然后使用 CAS 判定当前 num 的值是否为 A,如果为 A,就修改成 Z。
  2. 但是,在 t1 执行这两个操作之间,t2 线程把 num 的值从 A 改成了 B,又从 B 改成了 A。

  3. 到这一步,t1 线程无法区分当前这个变量始终是 A,还是经历了一个变化过程,但与oldNum比较的值是相等的,就进行了交换。

【多线程】(六)Java并发编程深度解析:常见锁策略、CAS、synchronized原理、线程安全集合类和死锁详解_第1张图片
2. ABA问题引发的BUG

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

假如张三有100元存款,想从ATM机中取出50元,当按下取钱按钮的时候因为网络延迟,导致张三以为没有按成功,因此又按了一次按钮,此时就创建了两个线程,来并发的来执行取50元这个操作

在正常情况下,我们所期望的就是一个线程执行 -50 成功;而另一个线程执行 -50 失败。
如果使用 CAS 的方式来执行这个扣款过程就有可能出现问题。

正常过程:

  1. 存款为100元,线程1 获取到当前存款的值为100,期望更新为50;线程2 也获取到当前存款的值为100,期望更新为50。
  2. 线程1 使用CAS先执行扣款成功,存款被改成了50。
  3. 线程2 也使用CAS尝试扣款,发现此时的存款50与获取的旧值100不相等,因此执行失败。

异常过程:

  1. 存款为100元,线程1 获取到当前存款的值为100,期望更新为50;线程2 也获取到当前存款的值为100,期望更新为50。
  2. 线程1 使用CAS先执行扣款成功,存款被改成了50。
  3. 在 线程2 执行扣款操作之前,张三的朋友还钱给张三,向他的账户转了50元,此时张三的余额就又变成100元了。
  4. 线程2 尝试执行扣款操作,发现此时余额与刚才获取的旧值100相等,于是扣款50元,余额也变成50了。

在这个异常过程中,两次都扣款成功了,但是张三却只拿到了50元,另外50缺丢失了,这就是ABA问题所引发的BUG。

3. ABA问题的解决方式

给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。

  • CAS 操作在读取旧值的同时,也要读取版本号;
  • 真正修改的时候:
    • 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
    • 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。

例如针对上面的场景:

假如张三有100元存款,想从ATM机中取出50元,当按下取钱按钮的时候因为网络延迟,导致张三以为没有按成功,因此又按了一次按钮,此时就创建了两个线程,来并发的来执行取50元这个操作

  1. 存款为100元,线程1 获取到当前存款的值为100,版本号为1,期望更新为50;线程2 也获取到当前存款的值为100,版本号为1,期望更新为50。
  2. 线程1 使用CAS先执行扣款成功,存款被改成了50,版本号修改为2。
  3. 在 线程2 执行扣款操作之前,张三的朋友还钱给张三,向他的账户转了50元,此时张三的余额就又变成100元了,版本号更新为3。
  4. 线程2 尝试执行扣款操作,发现此时余额与刚才获取的旧值100相等,但是旧版本号1与当前版本号3不相等,于是扣款失败。

三、synchronized原理

3.1 synchronized锁的特点

结合前文的锁策略,可以总结出synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 乐观锁和悲观锁切换:在开始阶段,synchronized锁是乐观锁,它假设线程之间的冲突是低概率事件,不会立即阻塞其他线程。但如果发生锁冲突,synchronized锁会转换为悲观锁,即锁定对象并阻塞其他线程,以保证线程安全。

  2. 轻量级锁和重量级锁转换synchronized锁开始时使用轻量级锁实现,它通过CAS操作来避免线程的上下文切换和内核态与用户态之间的切换,对系统性能开销较小。然而,如果锁被持有的时间较长,轻量级锁会升级为重量级锁,这会涉及到线程的上下文切换和内核态与用户态之间的切换,对系统性能开销较大。

  3. 自旋锁策略:在实现轻量级锁的过程中,synchronized锁可能会使用自旋锁策略。自旋锁是一种基于忙等待的锁,线程在获取锁时不会立即阻塞,而是通过循环不断尝试获取锁,减少线程上下文切换的开销。

  4. 不公平锁synchronized锁是一种不公平锁,它不保证线程获取锁的顺序与线程的申请顺序一致。当多个线程同时竞争锁时,具有更高优先级的线程有较大概率先获取到锁。

  5. 可重入锁synchronized锁是一种可重入锁,也称为递归锁。可重入锁允许同一个线程多次获取同一个锁而不会被阻塞,避免了死锁的发生。同一个线程在持有锁的时候,可以多次进入由该锁保护的代码块。

需要注意的是,以上特点适用于JDK 1.8及之前的版本。在JDK 9及以后的版本中,synchronized锁的实现做了一些改进,例如引入了偏向锁、轻量级偏向锁等机制,以进一步提高性能和并发能力。

3.2 加锁工作过程

在Java中,synchronized锁的工作过程可以分为以下几个阶段,JVM会根据情况进行逐步升级:

【多线程】(六)Java并发编程深度解析:常见锁策略、CAS、synchronized原理、线程安全集合类和死锁详解_第2张图片

  1. 无锁状态:开始时,对象的锁状态是无锁状态。多个线程可以并发地访问该对象,不需要进行加锁操作。

  2. 偏向锁状态:当一个线程第一次访问一个同步块时,JVM会将锁升级为偏向锁状态。此时,JVM会将线程ID记录在对象的锁记录(Lock Record)中,并标记为偏向锁。之后,该线程再次进入同步块时,不需要重新竞争锁,直接获取锁,提高了性能。

  3. 轻量级锁状态:如果一个线程尝试获取偏向锁时,发现锁记录的线程ID不是自己的,表示存在竞争。JVM会尝试将偏向锁升级为轻量级锁状态。在轻量级锁状态下,JVM会通过CAS(Compare and Swap)操作尝试获取锁,如果成功则进入临界区,如果失败则进入自旋等待状态。

  4. 重量级锁状态:如果在轻量级锁状态下,自旋等待仍然无法获得锁,JVM会将锁升级为重量级锁状态。在重量级锁状态下,JVM会使用操作系统的互斥量(Mutex)来实现锁的操作,即线程在获取锁时会进入阻塞状态,等待操作系统的调度。

需要注意的是,锁状态的升级是逐步发生的,并且是不可逆的。一旦锁升级到更高级别的状态,就不能再降级回低级别状态。锁的升级过程是由JVM自动管理的,开发人员不需要手动干预

3.3 锁消除和锁粗化

锁消除(Lock Elimination)和锁粗化(Lock Coarsening) 是针对synchronized锁的优化技术,旨在提高并发性能和减少锁开销。

  1. 锁消除(Lock Elimination):在某些情况下,JVM可以通过静态分析和逃逸分析等技术判断出一段代码中的锁实际上是不必要的,不会引发多线程竞争问题。在这种情况下,JVM可以消除这些不必要的锁操作,从而减少锁的开销。锁消除可以在一定程度上提高并发性能。

例如,当一个对象只被单个线程访问,并且不会逃逸到其他线程中时,JVM可以判断出该对象不会引发竞争,因此可以消除相应的锁操作。

  1. 锁粗化(Lock Coarsening)锁粗化是将多个连续的锁操作合并为一个更大的锁操作,以减少锁的粒度和开销。当JVM发现一段代码中多次获取和释放锁的操作是连续的,并且没有其他的干扰操作时,可以将这些锁操作合并为一个大的锁操作。这样可以减少获取和释放锁的次数,降低锁开销。
    【多线程】(六)Java并发编程深度解析:常见锁策略、CAS、synchronized原理、线程安全集合类和死锁详解_第3张图片

实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁,这种情况 JVM 就会自动把锁粗化,避免频繁申请、释放锁。

四、JUC(java.util.concurrent)的常见类和接口

4.1 ReentrantLock锁

ReentrantLocksynchronized一样,都是可重入锁,但是ReentrantLock的使用更加灵活可控,原因在于它可以手动的进行加锁和解锁操作

ReentrantLock 的用法:

  • lock():加锁,如果获取不到锁就一直阻塞等待;
  • trylock(timeout):加锁,如果获取不到锁,等待指定的一段时间之后就放弃加锁;
  • unlock(): 解锁,注意配合finally代码块使用,确保一定能够解锁。

例如:

ReentrantLock lock = new ReentrantLock();
	
lock.lock(); 
try {  
	// working  
} finally {  
	lock.unlock()  
} 

ReentrantLocksynchronized 的区别:

  • synchronized 是一个关键字,是 JVM 内部实现的(基于 C++ 实现);ReentrantLock 是标准库的一个类,在 JVM 外实现的(基于 Java 实现)。
  • synchronized 使用时不需要手动释放锁;ReentrantLock 使用时需要手动释放,使用起来更灵活,但是也容易遗漏 unlock
  • synchronized 在申请锁失败时,会一直阻塞等待;ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃等待。
  • synchronized 是非公平锁;ReentrantLock 默认是非公平锁,但是可以通过构造方法传入一个 true 开启公平锁模式。
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
	sync = fair ? new FairSync() : new NonfairSync();
}
  • ReentrantLock 更强大的唤醒机制。synchronized 是通过 Objectwait / notify 实现等待-唤醒,每次唤醒的是一个随机等待的线程; ReentrantLock 搭配 Condition 类实现等待-唤醒,可以更精确控制唤醒某个指定的线程。

如何选择使用哪一个锁:

  • 锁竞争激烈程度低的时候,使用 synchronized,效率更高,自动释放更方便。
  • 锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更灵活控制加锁的行为,而不是一直阻塞等待。
  • 如果需要使用公平锁的时候,选择使用 ReentrantLock

4.2 原子类

原子类内部使用CAS(Compare and Swap) 操作来实现线程安全的原子操作,相比于加锁实现的方式,原子类的性能通常更高

以下是常见的原子类:

原子类 描述
AtomicBoolean 提供原子更新boolean类型的操作。
AtomicInteger 提供原子更新int类型的操作。
AtomicIntegerArray 提供原子更新int数组类型的操作。
AtomicLong 提供原子更新long类型的操作。
AtomicReference 提供原子更新引用类型的操作。
AtomicStampedReference 提供带有版本标识的原子更新引用类型的操作。

这些原子类提供了在多线程环境中进行原子操作的方法,可以避免线程之间的竞争和冲突,从而实现线程安全的数据访问和更新。使用这些原子类,我们可以避免使用显式锁和同步的开销,提高并发性能和线程安全性。

例如,对于AtomicInteger,常见的方法有:

方法 描述
addAndGet(int delta) 将当前值与给定的增量相加,并返回相加后的结果。
decrementAndGet() 将当前值减1,并返回减1后的结果。
getAndDecrement() 返回当前值,并将当前值减1。
incrementAndGet() 将当前值加1,并返回加1后的结果。
getAndIncrement() 返回当前值,并将当前值加1。
get() 获取当前值。
set(int newValue) 设置为给定的值。
getAndSet(int newValue) 设置为给定的值,并返回旧值。
compareAndSet(expectedValue, newValue) 如果当前值等于期望值,则将其设置为给定的新值,并返回设置是否成功。

4.3 线程池

关于线程池可见文章:【工厂模式和线程池】。

4.4 信号量SemaPhore

Semaphore是一个计数信号量,用于控制同时访问某个资源的线程数量。它可以指定允许同时访问的线程数,并提供了acquire()release()方法用于获取和释放信号量。Semaphore常用于限制并发线程数、控制资源访问和实现线程间的互斥和同步

下面是一个Semaphore的使用案例:

import java.util.concurrent.Semaphore;

class MyThread extends Thread {
    private Semaphore semaphore;
    private String name;

    public MyThread(Semaphore semaphore, String name) {
        this.semaphore = semaphore;
        this.name = name;
    }

    public void run() {
        try {
            // 获取信号量许可
            semaphore.acquire();
            System.out.println("Thread " + name + " is accessing the resource.");

            // 模拟线程访问资源的耗时操作
            Thread.sleep(2000);

            System.out.println("Thread " + name + " has finished accessing the resource.");

            // 释放信号量许可
            semaphore.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class SemaphoreExample {
    public static void main(String[] args) {
        int numThreads = 5;
        Semaphore semaphore = new Semaphore(2); // 创建信号量,设置许可数为2

        for (int i = 1; i <= numThreads; i++) {
            Thread thread = new MyThread(semaphore, "Thread-" + i);
            thread.start();
        }
    }
}

在上面的例子中,我们创建了一个Semaphore对象,并设置许可数为2。然后创建了5个线程,每个线程在执行任务之前通过semaphore.acquire()获取信号量的许可,一旦获取到许可,就可以执行任务。在执行任务时,通过Thread.sleep模拟了线程访问资源的耗时操作。完成任务后,通过semaphore.release()释放信号量的许可。

由于许可数设置为2,所以最多允许两个线程同时访问资源,其他线程需要等待获取许可。通过Semaphore,我们可以控制并发访问的线程数量,实现对共享资源的合理调度和管理。

4.5 Callable接口

Callable 是一个接口,相当于给线程封装了一个 “返回值”,方便我们借助多线程的方式计算结果并返回给主线程。

例如:使用 Callable,创建线程计算 1 + 2 + 3 + … + 1000

  1. 创建一个匿名内部类,实现 Callable 接口。Callable 带有泛型参数,泛型参数表示返回值的类型。
  2. 重写 Callablecall 方法,完成累加的过程,直接通过返回值返回计算结果。
  3. callable 实例使用 FutureTask 包装。
  4. 创建线程,线程的构造方法传入 FutureTask。此时新线程就会执行 FutureTask 内部的 Callablecall 方法完成计算。计算结果就放到FutureTask 对象中。
  5. 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕,并获取到 FutureTask 中的结果。
public class ThreadDemo {
    // 使用 Callable 计算 1 + 2 + 3 + ... + 1000
    public static void main1(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };


        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);

        t.start();

        // get会进行阻塞,直到callable执行完毕
        Integer res = futureTask.get();
        System.out.println(res);
    }

CallableRunnable

  • CallableRunnable 相比,其功能都是对 “任务” 的描述Callable 描述的是带有返回值的任务,而 Runnable 描述的是不带返回值的任务。
  • Callable 通常需要搭配 FutureTask 来使用。FutureTask用来保存 Callable 的返回结果,因为Callable 往往是在另一个线程中执行的,什么时候执行完并不确定。而 FutureTask 就可以负责等待结果出来的工作。

关于 FutureTask

FutureTask是一个实现了RunnableFuture接口的类,它可以用来表示一个异步计算任务。FutureTask可以通过RunnableCallable对象来构造,并且可以获取计算结果或取消任务的执行。

下面是FutureTask的一些常用方法:

方法 描述
FutureTask(Callable callable) 构造一个FutureTask对象,接受一个Callable对象作为参数。
FutureTask(Runnable runnable, V result) 构造一个FutureTask对象,接受一个Runnable对象和一个结果值作为参数。
boolean cancel(boolean mayInterruptIfRunning) 尝试取消任务的执行。如果任务已经开始执行或已经完成,则无法取消。mayInterruptIfRunning参数用于指定是否中断正在执行的任务。
boolean isCancelled() 判断任务是否被取消。
boolean isDone() 判断任务是否已经完成。
V get() 获取任务的执行结果。如果任务还未完成,则会阻塞等待直到任务完成并返回结果。
V get(long timeout, TimeUnit unit) 在指定的时间内获取任务的执行结果。如果任务在指定时间内未完成,则会抛出TimeoutException异常。

需要注意的是,get()方法会阻塞等待任务的执行结果,而get(long timeout, TimeUnit unit)方法可以设置等待的超时时间,避免无限等待。另外,cancel()方法可以尝试取消任务的执行,但并不保证一定成功,因为任务可能已经开始执行或已经完成。

4.6 CountDownLatch

CountDownLatch是Java并发包(java.util.concurrent)中的一个同步辅助类,它可以用于控制多个线程之间的同步

CountDownLatch通过一个计数器来实现同步,该计数器初始化为一个正整数,表示需要等待的线程数量。当一个线程完成了自己的任务后,可以调用countDown()方法来减少计数器的值。其他线程可以通过调用await()方法来等待计数器达到零,从而实现线程的阻塞。

下面是CountDownLatch的一些常用方法:

方法 描述
CountDownLatch(int count) 构造一个CountDownLatch对象,指定计数器的初始值。
void countDown() 计数器减1。当计数器达到0时,释放所有等待的线程。
void await() 阻塞当前线程,直到计数器达到0。
boolean await(long timeout, TimeUnit unit) 阻塞当前线程,直到计数器达到0或等待超时。
long getCount() 获取当前计数器的值。

下面是一个简单的示例代码,演示了CountDownLatch的使用:

import java.util.concurrent.CountDownLatch;

// CountDownLatch的使用——同时等待 N 个任务执行结束.
public class ThreadDemo7 {
    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    latch.countDown();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
        }

        // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");
    }
}

五、线程安全的集合类

5.1 多线程环境使用ArrayList

在多线程环境中使用ArrayList是不安全的,因为ArrayList不是线程安全的数据结构。多个线程同时对同一个ArrayList进行读写操作可能会导致数据不一致或抛出异常。

如果需要在多线程环境中使用类似ArrayList的功能,可以考虑使用线程安全的替代类,如CopyOnWriteArrayList

  • CopyOnWrite容器即写时复制的容器。
  • 当往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素;
  • 添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

  • 在读多写少的场景下,性能很高,不需要加锁竞争。

缺点:

  • 占用内存较多。
  • 新写入的数据不能第一时间读取到。

5.2 多线程环境使用队列

在多线程环境中,可以使用线程安全的队列类来实现线程安全的操作。常见的线程安全队列有BlockingQueue接口的实现类,如LinkedBlockingQueueArrayBlockingQueue等。

这些队列类提供了线程安全的操作方法,可以实现多线程之间的安全数据交换。例如,一个线程可以将数据添加到队列中,而另一个线程可以从队列中获取数据,而无需担心数据竞争或并发访问的问题。

5.3 多线程环境使用哈希表Hashtable和ConcurrentHashMap

HashMap 本身不是线程安全的,因此在多线程环境下,哈希表可以使用:

  1. Hashtable
  2. ConcurrentHashMap

1. Hashtable
Hashtable是Java早期提供的线程安全的哈希表实现类。它通过加锁来保证多线程环境下的安全访问

Hashtable的线程安全是通过对所有方法进行同步来实现的,这意味着同一时间只能有一个线程对Hashtable进行读写操作。虽然它提供了线程安全,但是在高并发环境下,由于所有线程都需要竞争同一把锁,可能会导致性能瓶颈

2. ConcurrentHashMap
ConcurrentHashMap是Java提供的线程安全的哈希表实现类,它是对Hashtable的改进和扩展,以 Java1.8 为例:

【多线程】(六)Java并发编程深度解析:常见锁策略、CAS、synchronized原理、线程安全集合类和死锁详解_第4张图片

  • 读操作没有加锁(但使用了 volatile 保证从内存读取结果),只对写操作进行加锁。加锁的方式仍然是是用 synchronized,但是不是对整个对象加锁,而是 "锁桶" (用每个链表的头结点作为锁对象),大大降低了锁冲突的概率。
  • 充分利用 CAS 特性。比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
  • 优化扩容方式,一句话概括即:化整为零
    • 具体来说,当需要扩容时,ConcurrentHashMap会创建一个新的数组,并且只搬迁一部分元素到新数组中。在扩容期间,新老数组同时存在,即新数组中的部分槽位存放新添加的元素,而其他槽位仍然保留老数组中的元素。

    • 后续每个对ConcurrentHashMap进行操作的线程都会参与搬迁元素的过程。每个操作负责搬运一小部分元素,将其从老数组搬迁到新数组中。这样,在整个扩容过程中,多个线程并发地进行搬迁操作,加快了扩容的速度。

    • 在搬迁过程中,新元素的插入只会发生在新数组中,而对于查找操作,需要同时查找新数组和老数组,以确保能够找到所有的元素。

    • 当搬迁完成后,最后一个元素被搬迁到新数组中,老数组就可以被删除掉了,此时只需要操作新数组即可。

通过采用"化整为零"的扩容方式,ConcurrentHashMap在扩容过程中可以实现更高的并发度,减少了对全局锁的竞争,提高了并发性能。同时,该方式也保证了在扩容期间,对ConcurrentHashMap的读写操作可以继续进行,不会造成阻塞。

六、死锁

6.1 死锁的概念

死锁是指在多线程环境下,两个或多个线程互相持有对方所需的资源,导致所有参与的线程都无法继续执行,进入一种无法解除的等待状态。也可以说,死锁是指多个线程因相互竞争资源而导致的互相等待的现象

6.2 死锁的产生情况

死锁的产生情况通常涉及多个线程以不同的顺序申请资源,并且相互之间保持着对方所需资源的锁定。当所有线程都无法满足对方的资源需求时,就会发生死锁。

一种常见的死锁情况是多个线程同时持有某些资源,并且每个线程都在等待其他线程释放它所需的资源。这种情况下,没有一个线程能够继续执行下去,从而导致死锁的发生。

6.3 死锁的产生条件

死锁通常需要满足以下四个条件才能发生:

  1. 互斥条件:资源只能同时被一个线程占用,其他线程必须等待释放。
  2. 请求与保持条件:一个线程已经持有了至少一个资源,同时还在请求其他资源。
  3. 不可剥夺条件:已经分配给线程的资源不能被强制性地剥夺。
  4. 循环等待条件:存在一个线程的资源申请序列,使得每个线程都在等待下一个线程所持有的资源。

当以上四个条件同时满足时,就会发生死锁。

6.4 死锁的避免

避免死锁是一个重要的多线程编程问题。以下是一些常用的避免死锁的策略:

  1. 破坏循环等待条件:通过对资源进行排序,使线程按照固定的顺序请求资源,避免循环依赖
  2. 破坏互斥条件:对于某些资源,可以允许多个线程同时访问,从而避免资源互斥。
  3. 破坏请求与保持条件:线程在申请资源时,一次性申请所需的所有资源,而不是逐个申请。
  4. 破坏不可剥夺条件:允许线程在持有资源的同时,释放一些不必要的资源。
  5. 使用超时机制:设置一个超时时间,在等待一段时间后,如果无法获取到所需的资源,就放弃或尝试其他策略。

你可能感兴趣的:(Java进阶,java,多线程)