学习笔记
🧵Java多线程与并发
00 分钟
2024-9-15
2024-9-20
type
status
date
summary
slug
tags
category
password
URL
icon

理论基础

多线程的出现是为了解决什么问题?

多线程的出现是为了提升多核系统的资源利用率,加速程序处理。在多核系统中,CPU增加了缓存,来平衡内存读写较慢带来的速度影响(带来了可见性的问题);同时,操作系统支持线程和进程,它们可以分时复用CPU资源,避免其他低速设备降低CPU的利用率(带来了原子性的问题);为了提高缓存命中率,编译系统支持指令重排序,在指令重排序的过程中,可能会将读数据放到写数据后。

线程不安全是指什么?举例说明。

线程不安全是指程序在多线程并发执行的情况下,代码中出现执行结果不一致的情况。这种情况是往往是由多线程并发请求和写入共享数据造成的。

并发出现线程不安全的本质是什么?

本质是应用程序没有同时满足有序性、可见性和原子性造成的。
可见性:一个线程对共享变量的修改,另一个线程可以立刻察觉到。由于高速缓存的存在,CPU在处理数据时,都是先将数据由内存加载到高速缓存中,然后处理完成后再由高速缓存写入内存。这个过程,有可能会发生线程A对共享变量的修改,线程B没有感知到,从而造成线程不安全的情况。
原子性:即程序执行过程中的一个操作或者多个操作要么全部执行且执行过程中不会被打断,要么全部不执行。这个问题是由CPU的分时复用造成的。
这里需要注意的是:i += 1需要三条 CPU 指令
  1. 将变量 i 从内存读取到 CPU寄存器;
  1. 在CPU寄存器中执行 i + 1 操作;
  1. 将最后的结果i写入CPU缓存内存然后再刷新到内存中。
当线程A在执行到第二步后失去了CPU使用权后,线程B将整个程序执行完成了,这个时候线程A再执行最后一步的时候,整个程序的执行结果就会出错(预期是3实际是2)。
有序性:即程序执行的时候按照代码编写的顺序执行。由于指令重排序的存在,可能会造成程序执行时,其执行顺序与代码编写顺序不同。
在这段代码中,由于writer方法中的value和flag之间没有依赖关系,因此在指令重排序后,是可能造成flag先置为true,然后才设置value=42的,这种情况下,如果存在另一个线程请求reader方法,就可能会出现flag为true,但是value还没有被赋值的情况。

Java是怎么解决并发问题的?

在Java中,我们让程序同时满足有序性、原子性和可见性即可让本来非线程安全的程序保证线程安全。
那么如何保证有序性、原子性和可见性呢?
原子性:在Java中,对基础数据类型变量的查询和赋值是原子性的,比如int i=1就是原子性的,而int i=x则不是原子性的(需要先读取x的值然后再赋值给i)。
有序性:在Java中,往往是由于指令重排序造成的有序性被破坏,因此我们可以使用Volatile关键字通过内存屏障避免指令重排序,从而保证有序性。
可见性:同样的,我们也可以使用Volatile关键字来保证可见性。被Volatile修饰的变量,其数据被更新后,会被立刻刷新到内存中,这样就保证了其他线程读取到的改变量数据都是最新的。

线程安全有哪些实现思路?

互斥同步

使用synchronized关键词或者ReentrantLock来实现互斥同步。

非阻塞同步

互斥同步,也称为阻塞同步,是一种悲观的并发处理策略,认为如果不采取行动,那么数据冲突问题一定会发生。
非阻塞同步,是一种乐观的冲突处理策略。其核心思想是冲突检测+重试:先进行操作,如果没有其他线程争用共享数据,则操作成功;否则可以通过重试的方式,直到没有冲突并处理成功。
以上的方式,需要借助两个原子指令操作(冲突检测和操作)。硬件支持的比较典型的操作是CAS(比较并交换)。CAS指令需要有3个操作数,分别是内存地址,旧的预期值A和新值B,当我们读取内存地址的值发现其结果等于旧的预期值A时,则证明没有冲突,可以赋值新值B。否则,重试。
这里存在一个著名的ABA问题,即如果指定的内存地址的值中途发生变化,变为了B,但是后来又修改为A,这种情况下,对象的值实际是发生变化了的。这种情况下,如果ABA对我们没有影响,直接忽略即可。如果有影响的话,我们可以给内存地址的值增加一个版本的概念,这样就可以解决ABA问题,J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。也可以直接使用阻塞同步来解决该问题。

非同步方案(针对非共享数据)

  1. 使用不可变数据,数据不可变,当然是线程安全的。
  1. 方法内的局部变量,由于是存在线程的虚拟机栈中,是线程私有的,不存在线程安全问题。
  1. 存在ThreadLocal中的数据,由于是线程私有的,不存在线程安全问题。

如何理解并发和并行?

并发是指“同时处理多个任务”的一个概念,实际上这些任务并不一定是同时执行的,任务可以交替执行;
而并发则是真正物理意义上的同时执行,如果物理系统只存在单个CPU,则无法完成并发操作。
 

线程基础

线程有哪些状态?这些状态之间是如何转换的?

notion image
  • 新建:创建后尚未启动。
  • 可运行:可能正在运行,也可能正在等待CPU时间片。
  • 阻塞:等待获取一个排他锁,如果其他线程释放了锁就会结束此状态。
  • 无限期等待:等待其他线程显式唤醒,否则不会分配CPU时间片(Object.wait()、Thread.join())
  • 限期等待:可以等待其他线程显式唤醒,也可以在一定时间后自动唤醒(Thread.sleep()、Object.wait()、Thread.join())。
  • 死亡:线程任务执行完成或者异常终止后结束的状态。

线程使用方式?

可以通过new Thread()或者线程池这两种方式使用线程。
在编写实际代码逻辑时,可以通过重写Thread()中的run方法、或者实现Runnable接口、Callable接口中的run方法实现。
在工程开发中,我们经常使用线程池而不是new Thread()的方法去使用新的线程。

线程互斥同步?

Java中有两种方式来使得线程对共享资源实现互斥访问,一个是JVM实现的synchronized关键字,另一个是JDK实现的ReentrantLock。

synchronized

synchronized关键字修饰一个实例方法,表示同一时刻只能有一个线程执行该方法,对该方法加锁,实际是对方法所在的实例加锁;
synchronized修饰代码块,表示同一时刻只能有一个线程执行该代码,示例如下:
synchronized修饰静态方法,实际是对该方法所在的类对象进行加锁。同一时刻,只能有一个线程访问该类对象中的静态方法;

ReentrantLock

ReentrantLock不是一个关键字,而是J.U.C包中提供的一个类。我们需要在程序中显示的调用锁和释放锁。

synchronized和ReentrantLock的对比

1. 锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
2. 性能
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
3. 等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock 可中断,而 synchronized 不行。
4. 公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
5. 锁绑定多个条件
一个 ReentrantLock 可以同时绑定多个 Condition 对象。
 

线程协作机制?

join()方法

在当前线程中调用另一个线程的join()方法,会将当前线程挂起,直到目标线程执行结束。

Object.wait()/Object.notify()

wait方法和notify方法是Object类本身的方法,与Thread类无关。wait方法和notify方法都必须在Synchronized关键词修饰的同步方法或者同步代码块中使用,否则会抛出IllegalMonitorStateExeception异常。
调用wait方法,会使得当前线程释放锁资源并挂起;当其他线程调用notify方法时,会随机唤醒一个被wait方法挂起的线程。如果想唤起所有线程,则可以调用notifyAll()方法。

await() signal() signalAll()

J.U.C库中提供了Condition类来实现线程间的协调。可以在Condition上调用wait()方法挂起当前线程,然后使用signal()和signalAll()来唤起被挂起的线程。相比于wait()方法,await方法可以指定等待的条件(原理是我们可以创建多个Condition类,每个类对象就可以看做是一个条件)。

Java并发-Java中的所有锁

notion image

悲观锁和乐观锁

悲观锁和乐观锁(无锁设计)的设计理念不同。悲观锁认为在处理数据的时候肯定会遇到冲突问题,因此需要加锁来避免冲突;而乐观锁则是认为冲突的概率不大,如果遇到冲突,可以采取一定措施(重试或者报错),如果没有遇到冲突,则可以直接执行。在Java中,悲观锁的典型代表有Synchronized和ReentrantLock。乐观锁的典型代表是一些线程安全的原子类,其递增操作就是通过CAS思想实现的。
适用场景:
  • 悲观锁适合写操作比较多的场景,先加锁可以保证写操作的数据正确性。
  • 乐观锁适合读操作比较多的场景,不加锁的特性使其读性能可以大幅提升。

自旋锁和非自旋锁

首先明确一点,自旋锁属于悲观锁。当线程获取锁资源失败时,线程会挂起;等该线程获取到锁资源后,才会继续被唤醒。这个过程中的CPU切换非常耗费性能。如果加锁部分的代码逻辑较为简单,则很可能CPU切换所耗费时间大于代码执行时间,这种就造成了程序性能较差。为了提升性能,减少线程上下文的切换,我们可以使用自旋锁。自旋锁在获取锁资源失败时,不会立刻被挂起,而是让当前线程等待一下(可以循环),然后再重新尝试获取锁,如果加锁部分代码逻辑执行较快的话,循环很少次即可获取到锁资源从而继续执行,避免了现场上下文切换带来的开销。
注意,在Java标准库中,没有自旋锁的官方实现,我们可以借助J.U.C库中的Condition类来自己实现一个自旋锁。
自旋锁需注意循环次数,因为自旋过程中会一直占用CPU资源,所以如果循环次数过多,会造成CPU资源的浪费。
 

无锁、偏向锁、轻量级锁以及重量级锁

这里的几种锁特指Synchronized,以上几种锁是JVM为了优化Synchronized的性能而提出来的。

Synchronized锁实现原理

Synchronized是通过monitorEnter和monitorExit这两个命令实现的。每个对象在同一时刻只能关联一个monitor。一个对象在尝试获取与这个对象关联的Monitor的所有权时,可能会发生以下三种情况之一:
  1. monitor计数器为0,表示当前线程还没获取该monitor,那么该线程会立刻获取到这把锁并把计数设置为1,其他线程再想获取该锁就需要等待当前线程释放monitor了。
  1. monitor计数器大于0,表示当前线程已经获取过该锁,将当前计数器+1,表示重入。
  1. monitor被其他线程获取,则需等待其他线程释放资源。

Synchronized锁的状态

在JDK1.6中,Synchronized锁一共有四种状态,无锁→偏向锁→轻量级锁→重量级锁。锁的状态可以升级,但是不可以降级。
无锁状态
当一个类或者对象没有被任何线程锁定时,它处于无锁状态下。在这种状态下,任何线程都可以进入同步块或者方法。
偏向锁
实际情况中,可能并不存在多个线程来获取锁,而总是由一个线程重复获取锁资源。这种情况下,其实不存在锁竞争,如果再通过竞争的方式获取锁,会带来一些不必要的开销。
HotSpot作者在JDK1.6中引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧的锁记录里存储偏向锁对应的线程ID。后续该线程再访问同步块并获取锁时,只需要查看对象头中是否存储着指向当前线程的偏向锁。如果存在的话,则可以直接访问同步块。
偏向锁的释放与锁竞争有关。偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
轻量级锁
当有其他线程访问偏向锁时,偏向锁就会升级为轻量级锁。轻量级锁也不会阻塞线程。在获取锁时,线程会先判断锁对象的对象头中的锁标识是否是有锁,有锁的话则膨胀为重量级锁。没有锁的话,则在当前线程的栈帧中创建锁区域用于存储对象头中Mark Word区域内容的拷贝,同时修改锁对象的对象头中的Mark Word的指针指向栈帧中的区域(这两步是原子操作),这种就是成功获取到锁。
在释放锁时,线程会通过CAS方法将栈帧中的锁记录替换会锁对象的对象头中,如果替换成功,则释放成功,否则表明存在锁竞争,升级为重量级锁。
重量级锁
使用MonitorEnter和MonitorExit实现,会阻塞获取不到锁的线程。

JUC类汇总和学习指南

JUC是JDK提供的一个并发工具包,里面主要包含以下几类:
  1. Lock框架和Tools类
  1. Collections集合
  1. Atomic原子类
  1. Executors线程池

锁常用类ReentrantLock

ReentrantLock里面有一个核心的内部类sync,其源代码如下:
其底层是通过AQS和CAS实现的。当线程调用lock方法时,会先通过CAS判断是否可以获取到锁或者可重入。成功获取到锁之后,便可继续执行,否则进行AQS等待队列中,等待获取锁资源。
在线程调用unlock释放锁的时候,先判断是否可以成功释放锁,是的话,则通知AQS队列中的头结点,唤起头节点的后续节点(注意,无论是公平锁还是非公平锁,释放锁的逻辑都是一样的;不同的点是非公平锁在获取锁的时候,不需要进行排队可直接申请)。

Java中的线程池

notion image
 

接口: Executor

Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。

# ExecutorService

ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。

# ScheduledExecutorService

ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。

# AbstractExecutorService

AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。

# FutureTask

FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。FutureTask 的线程安全由CAS来保证。
详细分析请看: JUC线程池: FutureTask详解

# 核心: ThreadPoolExecutor

ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。 线程池可以解决两个不同问题: 由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。

# 核心: ScheduledThreadExecutor

ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。

# 核心: Fork/Join框架

ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。

# 工具类: Executors

Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。它的使用融入到了ThreadPoolExecutor, ScheduledThreadExecutor和ForkJoinPool中。
 
 
Java多线程与并发
Java多线程与并发
 
 
上一篇
数据增强——在图片中添加遮挡物
下一篇
ThreadLocal里的变量一定是线程独享的吗?