Appearance

多线程

多线程

1. 多线程相关概念

1.1 线程和进程

  • 进程:是指内存运行的一个应用程序,是系统运行程序的基本单位,是程序的一次执行过程。
  • 线程:进程中的一个执行单元,负责当前进程中的任务的执行,一个进程会产生很多线程。
  • 两者主要区别:每个进程都有独立内存空间。线程之间的堆空间和方法区共享,线程栈空间和程序计数器是独立的。线程消耗资源比进程小的多。
image-20240123210241776

1.1.1 什么是并发与并行?

  • 并发(Concurrency):同一时间段,多个任务都在执行,单位时间内不一定是同时执行。
  • 并行(Parallel):单位时间内,多个任务同时执行,单位时间内一定是同时执行。
  • 并发是一种能力,并行是一种手段。
image-20240123210639151

1.1.2 线程上下文切换

一个 CPU 同一时刻只能被一个线程使用,为了提升效率 CPU 采用时间片算法将 CPU 时间片轮流分配给多个线程。在分配的时间片内线程执行,如果没有执行完毕,则需要挂起然后把 CPU 让给其他线程。

  • CPU 切换线程,会把当前线程的执行位置记录下来,用于下次执行时找到准确位置
  • 线程执行位置的记录与加载过程就叫做上下文切换
  • 线程执行位置记录在程序计数器

上下文切换过程:

  1. 挂起线程01,将线程在CPU的状态(上下文)存储在内存

  2. 恢复线程02,将内存中的上下文在CPU寄存器中恢复

  3. 调转到程序计数器所指定的位置,继续执行之后的代码

    image-20240123210922049

1.2 线程的一生

1.2.1 线程从出生到死亡会出现六种状态

①New(新建)、②Runnable(可运行)、③Terminated(终止)

④Blocked(锁阻塞)、⑤Waiting(无限等待)、⑥Timed_Waiting(超时等待)

image-20240123211238697

1.2.2 wait 与 sleep() 的区别:

  • 主要区别:sleep() 方法没有释放锁,wait() 方法释放了锁
  • 两者都可以暂停线程执行:wait() 常用于线程间交互/通信,sleep() 用于暂停线程执行
  • wait() 方法被调用后,需要别的线程调用同一个对象的 notify 和 notifyAll。超时苏醒使用 wait(long) 方法
  • sleep() 方法执行完成后,线程会自动苏醒。

1.2.3 多线程实现原理

Java线程是通过start()方法启动,启动后会执行run()方法

image-20240123211521193

Thread究竟是如何执行run()方法呢?

image-20240123211437202

流程小结:

  1. 线程类被 JVM 加载时会绑定 native 方法与对应的 C++ 方法
  2. start() 方法执行:
    • start() → native start0() → JVM_Thread → 创建线程 JavaThread::JavaThread
  3. 创建OS线程,指定OS线程运行入口:
    • 创建线程构造方法 → 创建OS线程 → 指定OS线程执行入口,就是线程的 run() 方法
  4. 启动OS线程,运行时会调用指定的运行入口run()方法。至此,实现一个的线程运行
  5. 创建线程的过程是线程安全的,基于操作系统互斥量(MutexLocker)保证互斥,所以说创建线程性能很差

1.3 线程的安全问题

1.3.1 什么是线程安全问题?

  • 多个线程同时执行,可能会运行同一行代码,如果程序每次运行结果与单线程执行结果一致,且变量的预期值也一样,就是线程安全的,反之则是线程不安全。

1.3.2 引发线程安全问题的根本原因

多个线程共享变量

  • 如果多个线程对共享变量只有读操作,无写操作,那么此操作是线程安全的
  • 如果多个线程同时执行共享变量的写和读操作,则操作不是线程安全的

1.3.3 解决线程安全问题

  • 同步机制 Synchronized

  • Volatile关键字:内存屏障

  • 原子类:CAS

  • 锁:AQS

  • 并发容器

1.4 线程并发的三大特性

  • 原子性:一个系列指令代码,要么全执行,要么都不执行,执行过程不能被打断

  • 有序性:程序代码按照先后顺序执行

    • 为什么会出现无序问题呢?因为指令重排
  • 可见性:当多个线程访问同一个变量时,一个线程修改了共享变量的值,其他线程能够立即看到

    • 为什么会出现不可见问题呢?因为 Java 内存模型( JMM )

1.5 指令重排序

1.5.1 什么是指令重排?

编译器和处理器会对执行指令进行重排序优化,目的是提高程序运行效率。现象是,我们编写的 Java 代码语句的先后顺序,不一定是按照我们写的顺序执行。

int count = 0;
boolean flag = false;
count = 1; //语句1
flag = true; //语句2

上述代码在执行过程中:语句1一定在语句2之前执行吗?不一定,因为有指令重排

  • 按顺序执行不好么,为什么要指令重排执行?同步变异步,系统指令层面的优化

  • 无论如何重排,不会影响最终执行结果,因为大部分指令并没有严格的前后执行顺序

  • 在单线程情况下,程序执行遵循 as-if-serial 语义

1.5.2 什么是 as-if-serial 语义?

不管编译器和处理器怎么重排指令,单线程执行结果不受影响

int a = 10; //语句1
int b = 2; //语句2
a = a + 3; //语句3
b = a*a; //语句4

上面代码执行的顺序:语句2 ==> 语句1 ==> 语句3 ==> 语句4

不可能是:语句2 ==> 语句1 ==> 语句4 ==> 语句3

为什么?因为处理器在进行指令重排时,会考虑指令之间的数据依赖性

虽然重排序不会影响单线程程序正确执行,但是会影响多线程并发执行的正确性。

//线程1:
init = false
context = loadContext(); //语句1
init = true; //语句2

//线程2:
while(!init){//如果初始化未完成,等待
sleep();
}
execute(context);//初始化完成,执行逻辑

要想多线程程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

1.6 可见性

1.6.1 CPU 和缓存一致性

  • 在多核 CPU 中每个核都有自己的缓存,同一个数据的缓存与内存可能不一致

  • 为什么需要 CPU 缓存

    • 随着 CPU 技术发展,CPU 执行速度和内存读取速度差距越来越大,导致 CPU 每次操作内存都要耗费很多等待时间。为了解决这个问题,在 CPU 和物理内存上新增高速缓存。
  • 程序在运行过程中会将运算所需数据从主内存复制到CPU高速缓存,当CPU计算直接操作高速缓存数据,运算结束将结果刷回主内存

image-20240123213610092

1.6.2 Java 内存模型(Java Memory Model

  • Java 为了保证满足原子性、可见性及有序性,诞生了一个重要的规范 JSR133,Java内存模型简称 JMM

  • JMM 定义了共享内存系统中多线程应用读写操作行为的规范

  • JMM 规范定义的规则,规范了内存的读写操作,从而保证指令执行的正确性

  • JMM 规范解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题

  • Java 实现了 JMM 规范因此有了 Synchronized、Volatile、锁等概念

  • JMM 的实现屏蔽各种硬件和操作系统的差异,在各种平台下对内存的访问都能保证效果一致

JMM 内存模型抽象结构示意图:

image-20240123214124232
  • JMM 定义共享变量何时写入,何时对另一个线程可见

  • 线程之间的共享变量存储在主内存

  • 每个线程都有一个私有的本地内存,本地内存存储共享变量的副本

  • 本地内存是抽象的,不真实存在,涵盖:缓存,写缓冲区,寄存器等

JMM线程操作内存基本规则:

  1. 线程操作共享变量必须在本地内存中,不能直接操作主内存的
  2. 线程间无法直接访问对方的共享变量,需经过主内存传递

1.6.3 内存可见性

可见性是一个线程对共享变量的修改,能够及时被其他线程看到

举个栗子:

  • 线程 A 和线程 B 保证共享变量共享

    • 线程 A 把本地内存 A 的共享变量副本值更新到主内存

    • 线程 B 到主内存读取最新的共享变量

image-20240123215036781

JMM 通过控制线程与本地内存之间的交互,来保证内存可见性

怎么解决可见性问题

  • 推翻 JMM,直接读取主内存共享变量

  • 使用 JMM:Synchronized,volatile

  • happens-before 规则:按需使用重排序和本地内存副本,前提是需要满足 happens-before 规则

1.6.4 happens-before规则

在 JMM 中使用 happens-before 规则约束编译器优化行为,Java 允许编译器优化,但是不能无条件优化。

如果一个操作的执行结果需要对另一个操作可见,那么这两个操作必须存在happens-before的关系!

需要关注的 happens-before 规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作

  • 锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁

  • Volatile 变量规则:对一个 volatile 修饰变量的写,happens-before 于任意后续对这个变量的读

  • 传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C

happens-before规则 - 实现

  • 处理器重排序规则
  • 编译器重排序规则
image-20240123220045468

JMM 设计 happens-before 规则的目标就是屏蔽编译器和处理器重排序规则的复杂性。不同的 CPU 架构,不同的 OS,不同的虚拟机实现皆不相同!

2. Synchronized

2.1 synchronized 简介

保证方法或代码块在多线程环境运行时,同一个时刻只有一个线程执行代码块。

  • JDK 1.6 之前,synchronized 的实现依赖于 OS 底层互斥锁的 MutexLock,存在严重的性能问题

  • JDK 1.6 之后,Java 对 synchronized 进行的了一系列优化,实现方式也改为 Monitor(管程)了

  • 一句话:有了 Synchronized,就线程安全了,保证原子性、可见性、有序性

可以修饰方法(静态和非静态)和代码块

  • 同步代码块的锁:当前对象,字节码对象,其他对象

  • 非静态同步方法:锁当前对象

  • 静态同步方法:锁是当前类的 Class 对象

2.2 synchronized 原理剖析

如何解决可见性问题?Happens-before 规则

  • JMM 对于 Synchronized 的规定:

    • 加锁前:必须把自己本地内存中共享变量的最新值刷到主内存

    • 加锁时:清空本地内存中的共享变量,从主内存中读取共享变量最新的值

Synchronized是如何实现同步的呢

  • 同步操作主要是 monitorentermonitorexit 两个 jvm 指令实现。背后原理是 Monitor(管程)

2.3 什么是 Monitor ?

  • Monitor 意译为管程,直译为监视器。所谓管程,就是理共享变量及对共享变量操作的过。让这个过程可以并发执行。

  • Java所有对象都可以做为锁,为什么

    • 因为每个对象都都有一个 Monitor 与之关联。然后线程对 monitor 执行 lock 和 unlock 操作,相当于对对象执行上锁和解锁操作。
  • Synchronized 里面不可以直接使用 lock 和 unlock 方法,但当我们使用了 synchronized 之后,JVM 会自动加入两个指令 monitorenter 和 monitorexit,对应的就是 lock 和 unlock 操作。

Monitor的实现原理:将共享变量和对共享变量的操作统一封装起来

image-20240123220911980

2.4 锁优化

  • 加了锁之后,不一定就是好的,盲目使用 Synchronized,虽然解决了线程安全问题,但也给系统埋下了迟缓的种子。

  • 并发编程的几种情况

    • 只有一个线程运行
    • 两个线程交替执行
    • 多个线程并发执行

    经过实践经验总结:前两种情况,可以针对性优化

  • JDK 1.6 基于这两个场景,设计了两种优化方案:偏向锁和轻量级锁

  • 同步锁一共有四个状态:无锁,偏向锁,轻量级锁,重量级锁

  • JVM 会视情况来逐渐升级锁,而不是上来就加重量级锁,这就是 JDK 1.6 的锁优化

  • 偏向锁:只有一个线程访问锁资源,偏向锁就会把整个同步措施消除

  • 轻量级锁:只有两个线程交替竞争锁资源,如果线程竞争锁失败了不立即挂起,而是让它飞一会(自旋),在等待过程中可能锁就会被释放出来,这时尝试重新获取锁

2.5 锁信息存储

例如:锁类型,当前持有线程

偏向锁标记锁状态标记锁状态
001无锁
101偏向锁
00轻量锁
10重量锁
11GC 标记

同步锁锁定资源是对象,那无疑存储在对象信息中,由对象直接携带,是最方便管理和操作的。

32位操作系统的Markword

image-20240123221649331

3. Volatile

3.1 Volatile 简介

Java 语言对 volatile 的定义

  • Java 语言允许线程访问共享变量,为了确保共享变量能被准确的一致地更新,线程应该确保通过互斥锁单独获取这个变量。Java 语言提供了 volatile,在某些情况下,它比锁要更方便。如果一个变量被声明成 volatile,JMM 确保所有线程看到这个变量的值是一致的。
  • 一句话:volatile 可以保证多线程场景下共享变量的可见性、有序性。
    • 可见性:保证对此共享变量的修改,所有线程的可见性
    • 有序性:禁止指令重排序的优化,遵循 JMM 的 happens-before 规则

3.2 Volatile 实现原理剖析

内存屏障(Memory Barrier)是一种 CPU 指令,用于控制特定条件下的重排序和内存可见性问题。Java 编译器会根据内存屏障的规则禁止重排序。

  • Volatile 变量写操作时:在写操作后加一条 store 屏障指令,让本地内存中变量的值能够刷新到主内存

  • Volatile 变量读操作时:在读操作前加一条 load 屏障指令,及时读取到变量在主内存的值

image-20240123222135811

JMM 内存屏障插入策略

  • 在每个 volatile 写前,插入 StoreStore 屏障

  • 在每个 volatile 写后,插入 StoreLoad 屏障

  • 在每个 volatile 读前,插入 LoadLoad 屏障

  • 在每个 volatile 读后,插入LoadStore屏障

屏障类型示例说明
StoreStoreS01,StoreStore,S02确保 S01 刷新数据到内存,先于 S02 及其后所有 Store 操作,对屏障前后的 Load 无影响
StoreLoadS01,StoreLoad,L02全能型屏障:会屏蔽屏障前后所有指令的重排
LoadLoadL01,LoadLoad,L02确保 load 动作 L01,先于 L02 及其后所有 Load 操作,对屏障前后 Store 无影响
LoadStoreL01,LoadStore,S02确保指令前的所有 load 操作,先于屏障后所有 Store 操作

重排序规则表

image-20240123222723997
  1. 当第一个操作是 volatile读时,不管第二个操作是什么,都不能重排序
    • 确保 volatile 读到的是最新值:volatile 读之后的操作不会被编译器重排序到 volatile 读之前
  2. 当第一个操作是 volatile 写时,不管第二个操作是什么,都不能重排序
    • 确保 volatile 写操作对之后的操作可见
  3. 当第二个操作是 volatile 写时,第一个操作是普通写时,不能重排序

3.3 Volatile 缺陷

存在原子性的问题:虽然 volatile 可以保证可见性,但是不能满足原子性

3.4 volatile 适合使用场景

  • 共享变量独立于其他变量和自己之前的值,这类变量单独使用的时候适合用 volatile

    • 对共享变量的写入操作不依赖其当前值:例如 ++ 和 --,就不行

    • 共享变量没有包含在有其他变量的不等式中

3.5 Volatile 和 Synchronized 特点比较

特点VolatileSynchronized
加锁
阻塞线程
保证原子性
保证可见性
性能很好很差
上次更新 3/30/2024, 5:39:23 AM