并发相关概念与体系图
# 并发相关概念与体系图
# 什么情况下存在线程安全问题
多个线程同时争抢一个共享变量,并对其进行写(或删除)操作时,就会存在线程安全问题。
或者更准确一点是:线程安全问题发生需要同时满足以下 3 个条件:
- 多线程环境:有多个线程同时运行
- 共享变量存在:多个线程操作同一个共享变量
- 非原子性操作:至少有一个线程在修改(写)共享变量,且操作不具备原子性
# Java中的共享变量是什么呢?
共享变量是指可以被多个线程同时访问的变量,其作用域跨越了多个线程。具体包括:
- 实例变量(对象的成员变量,存储在堆内存)
- 静态变量(类变量,存储在方法区)
- 作为参数传递的对象引用(当对象引用作为参数传递给方法时,如果该对象在方法内外都被使用,则其内部的状态也是共享的。)
下面是第三个的代码例子:
public void updateValue(SharedData data) {
data.value++; // 如果多个线程调用此方法并传入相同的SharedData对象,则data.value是共享变量
}
2
3
# 同步和异步
同步(Synchronous)和异步(Asynchronous)通常用来形容一次方法调用。
同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中“真实”地执行。整个过程,不会阻碍调用者的工作。对于调用者来说,异步调用似乎是一瞬间就完成的。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。
简单总结下就是,同步是所有事情在一条线上完成,而异步则是在多条线上去完成。
# 并发和并行
并发(Concurrency)和并行(Parallelism)是两个非常容易被混淆的概念。
它们都可以表示两个或者多个任务一起执行,但是偏重点有些不同。并发
偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行
是真正意义上的“同时执行”。
严格意义上来说,并行的多个任务是真实的同时执行,而对于并发来说,这个过程只是交替的,一会儿运行任务A一会儿执行任务B,系统会不停地在两者间切换。但对于外部观察者来说,即使多个任务之间是串行并发的,也会造成多任务间是并行执行的错觉。
实际上,如果系统内只有一个CPU,而使用多进程或者多线程任务,那么真实环境中这些任务不可能是真实并行的,毕竟一个CPU一次只能执行一条指令,这种情况下多进程或者多线程就是并发的,而不是并行的(操作系统会不停切换多个任务)。真实的并行也只可能出现在拥有多个CPU的系统中(比如多核CPU)。
# 扩展
在一个多核CPU系统中,一个任务通常会被分配给一个特定的CPU核心来执行。在大多数情况下,操作系统会尽量保持任务在同一个核心上执行,以减少上下文切换的开销并提高缓存命中率。然而,某些情况下,操作系统可能会将任务从一个核心迁移到另一个核心。
# 多核cpu执行并发任务
多核cpu执行并发任务(如果任务数小于cpu内核数)会发生上下文切换吗?
答案是:仍会发生上下文切换
如果出现以下这些原因,仍可能会出现上下文切换
- 优先级变化
- 中断操作
- 负载均衡
- 时间片轮转
- 任务阻塞
# 上下文切换的过程是怎样的?
当操作系统需要在多个任务(如进程或线程)之间切换时,它会保存当前正在执行的任务的状态信息,并恢复下一个任务的状态信息。这个过程就是上下文切换。
上下文切换指的是操作系统在多任务环境中将CPU的执行权从一个任务转移到另一个任务的过程。
# 上下文切换 跟 并发的关系:
- 上下文切换是操作系统在不同任务之间切换控制权的过程,涉及保存和恢复任务的状态。
- 并发是指多个任务在同一时间段内交错执行的能力,通过上下文切换实现。
- 上下文切换是实现并发的一个重要机制,但它本身也有一定的开销,需要合理管理和优化。
# 临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。
# 阻塞和非阻塞
阻塞(Blocking)和非阻塞(Non-Blocking)通常用来形容多线程间的相互影响。
比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待会导致线程挂起,这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界区上的线程都不能工作。
非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断前向执行。
# 并发的情况
概念说的够多了,先暂停下,思考一个问题,什么样的情况下,会发生并发呢?或者说需要写多线程,需要加锁去处理呢?你要知道一些新的概念出现都是在处理一些问题时创造的,它们的诞生都是为了后来者可以更好的理解这类型的问题,并学习如何去解决他们。
在 Java 中,多线程和加锁通常用于处理需要并发执行的任务,特别是当多个线程需要访问和修改共享资源时。以下是一些常见的业务场景,这些场景下可能需要使用多线程和加锁:
# 1. 并发请求处理
场景描述:Web 应用或服务需要同时处理多个客户端请求。 解决方案:使用多线程来处理每个请求,确保服务器能够高效地响应多个客户端。 加锁需求:如果多个请求需要访问和修改共享资源(如数据库连接池、缓存等),需要使用锁来确保线程安全。
# 2. 数据库连接池
场景描述:应用程序需要频繁地从数据库读取和写入数据。 解决方案:使用数据库连接池来管理数据库连接,提高连接的复用率。 加锁需求:连接池的管理和分配需要加锁,确保同一时间只有一个线程可以获取或释放连接。
# 3. 缓存管理
场景描述:应用程序使用缓存来提高数据访问速度。 解决方案:使用多线程来处理缓存的读写操作。 加锁需求:缓存的读写操作需要加锁,确保数据的一致性和完整性。
# 4. 文件读写
场景描述:多个线程需要读写同一个文件。 解决方案:使用多线程来并行处理文件读写操作。 加锁需求:文件的读写操作需要加锁,确保数据的一致性和完整性。
# 5. 任务调度
场景描述:需要定期执行某些任务,如定时备份、数据同步等。 解决方案:使用多线程来并行执行多个任务。 加锁需求:任务的调度和状态管理需要加锁,确保任务的正确性和一致性。
# 6. 共享队列
场景描述:多个生产者线程向队列中添加数据,多个消费者线程从队列中取出数据。 解决方案:使用多线程来处理生产者和消费者的任务。 加锁需求:队列的插入和删除操作需要加锁,确保线程安全。
# 7. 并发计算
场景描述:需要进行大规模的计算任务,如科学计算、图像处理等。 解决方案:使用多线程来并行处理计算任务,提高计算效率。 加锁需求:如果计算过程中需要共享某些资源,需要加锁确保数据的一致性。
# 8. 会话管理
场景描述:Web 应用需要管理用户的会话信息。 解决方案:使用多线程来处理用户的请求和会话管理。 加锁需求:会话信息的读写操作需要加锁,确保数据的一致性和安全性。
# 9. 日志记录
场景描述:应用程序需要记录日志,多个线程可能同时写入日志文件。 解决方案:使用多线程来处理日志记录。 加锁需求:日志文件的写入操作需要加锁,确保日志的一致性和完整性。
# 10. 并发计数器
场景描述:需要统计某个事件的发生次数,如网站的访问次数。 解决方案:使用多线程来处理事件的统计。 加锁需求:计数器的增减操作需要加锁,确保计数的准确性。
# 扩展
单例 Bean (
singleton
)在 Spring Boot 中默认是单实例的,但这并不意味着它只能处理一个请求。实际上,单例 Bean 可以同时处理多个请求
,这是因为在多线程环境中,多个线程可以同时访问同一个 Bean 实例。这就引出了线程安全的问题。在Springboot框架中,它的bean默认是单实例,假如将其改为多实例,这样后即使在代码中加锁,比如加了重入锁,也可能会有线程安全的问题。
以下是简要解析:
- 多实例 Bean (
prototype
):
- 每次请求都会创建一个新的 Bean 实例。
- 每个实例都有自己的状态,因此在实例内部的状态通常是线程安全的。
- 但是,如果多个实例访问和修改同一个共享资源(如静态变量、数据库连接池、缓存等),仍然需要考虑线程安全问题。
- 共享资源:
- 如果多个实例访问和修改同一个共享资源,即使在每个实例内部加锁,也不能保证线程安全。
- 例如,如果有两个
prototype
实例同时访问和修改同一个静态变量,仍然会有竞态条件。
# 死锁、饥饿、活锁
死锁(Deadlock)、饥饿(Starvation)和活锁(Livelock)都属于多线程的活跃性问题。如果发现上述几种情况,那么相关线程可能就不再活跃,也就说它可能很难再继续往下执行了。
死锁就是线程彼此都需要对方掌握的部分资源才能继续往下执行,但互相又都不想让,于是所有线程都无法执行。
饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。比如它的线程优先级可能太低,而高优先级的线程不断抢占它需要的资源,导致低优先级线程无法工作。在自然界中,母鸟喂食雏鸟时,很容易出现这种情况。由于雏鸟很多,食物可能有限,雏鸟之间的食物竞争可能非常厉害,小雏鸟因为经常抢不到食物,有可能会被饿死。线程的饥饿也非常类似这种情况。另外一种可能是,某一个线程一直占着关键资源不放,导致其他需要这个资源的线程无法正常执行,这种情况也是饥饿的一种。与死锁相比,饥饿还是有可能在未来一段时间内解决的(比如高优先级的线程已经完成任务,不再疯狂的执行)。
活锁就是在多线程的执行过程中都秉承着“谦让”的原则,主动将资源释放给他人使用,那么就会出现资源不断在两个线程中跳动,而没有一个线程可以同时拿到所有资源而正常执行。
# 并发级别
由于临界区的存在,多线程之间的并发必须受到控制。根据控制并发的策略,我们可以把并发的级别进行分类,大致上可以分为阻塞
、无饥饿
、无障碍
、无锁
、无等待
几种。
# 阻塞
一个线程是阻塞的,那么在其他线程释放资源之前,当前线程无法继续执行。当我们使用synchronized关键字,或者重入锁时,我们得到的就是阻塞的线程。
在这种级别中,线程在试图获取某个被其他线程持有的锁时会被阻塞,即挂起等待,直到锁被释放。Java中的synchronized
关键字、ReentrantLock
等都是实现阻塞同步的经典工具。阻塞是传统并发控制中最常见的方式,它采用悲观策略,假设资源竞争激烈,通过锁定机制防止数据冲突。阻塞可能导致线程上下文切换频繁,影响性能,但在许多场景下,其简单直观的同步逻辑易于理解和实现。
# 无饥饿
如果线程之间是有优先级的,那么线程调度的时候总是会倾向于满足高优先级的线程。也就说是,对于同一个资源的分配,是不公平的。对于非公平的锁来说,系统允许高优先级的线程插队。这样有可能导致低优先级线程产生饥饿。但如果锁是公平的,满足先来后到,那么饥饿就不会产生,不管新来的线程优先级多高,要想获得资源,就必须乖乖排队。那么所有的线程都有机会执行。
无饥饿保证即使在高并发环境下,每个线程只要等待足够长的时间,最终都能够获得所需的资源并继续执行,避免了因优先级反转或其他原因导致的永久等待(饥饿)现象。无饥饿通常需要同步机制提供某种形式的公平性保证,如ReentrantLock
可以通过构造函数指定为公平锁,确保线程按照申请锁的顺序获得锁。
# 无障碍
无障碍级别的并发控制允许线程在没有其他线程干扰的情况下(即在某个瞬间没有其他活跃线程对共享数据进行修改)执行操作并成功完成。这意味着一个线程的操作不会因为其他线程的存在而永远无法完成,但可能存在暂时的延迟。无障碍并发并不保证线程在任何时刻都能立即取得进展,只是保证在没有竞争时能够成功执行。
# 无锁
无锁并发是指在设计数据结构或算法时,避免使用传统的互斥锁来同步线程,而是通过原子操作(如CAS,Compare-and-Swap)来保证数据的一致性。在这种级别下,线程在执行操作时不会被阻塞,而是通过循环重试等方式来应对竞争,一旦没有其他线程干扰,它们就可以立即完成操作。无锁编程可以减少上下文切换,提高并发性能,但编写正确的无锁代码通常较为复杂,需要对硬件指令和并发编程有深入理解。
# 无等待
无等待是最高等级的并发,它要求无论系统中有多少线程以及它们如何交错执行,每个线程都能够保证在有限步数内完成其操作。这意味着所有线程都能持续向前推进,不会因竞争而无限期停滞。无等待并发提供了最强的并发保证,但实现难度大,对算法设计和数据结构的要求极高,且可能牺牲一定的空间效率和局部性。
总结来说,Java编程中的并发级别从低到高依次为:阻塞、无饥饿、无障碍、无锁、无等待。
# 并发体系图
随着接触和负责的系统越来越复杂,我逐渐发现,无论是对于优秀的系统设计,还是对于程序员的成长提高、职业发展,并发编程都是必须要跨过去的“坎”,而一旦你跨过了这道“坎”,便会豁然开朗,原来一切都如此简单,职业发展也会更上一层楼。
- 并发知识体系
- 线程基础
- 为了线程安全
- 管理线程、提高效率
- 线程配合
- CountDownLatch
- 底层原理
- Java内存模型
- CAS原理
- AQS框架