Chiriri's blog Chiriri's blog
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)

Iekr

苦逼后端开发
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)
  • Java基础

    • Java 基础
    • Java 集合
    • JUC
      • 多线程的出现是要解决什么问题的? 本质什么?
      • Java是怎么解决并发问题的?
      • 如何预防和避免线程死锁?
      • 乐观锁和悲观锁了解吗?应用场景有什么区别?
      • 线程安全有哪些实现思路
      • 线程和进程的区别
        • 协程
      • 并发和并行的区别
      • 线程有哪几种状态并分别说明从一种状态到另一种状态转变有哪些方式
        • 线程生命周期
      • 通常线程有哪几种创建方式?
      • 基础线程机制有哪些?
      • wait() 和 sleep() 的区别
      • wait() notify() notifyAll()
      • join()
      • 线程创建多少个合适?
        • CPU 密集型计算
        • IO 密集型
      • 锁
        • 乐观锁和悲观锁
        • 悲观锁
        • 乐观锁
        • 版本号机制
        • CAS 算法
        • 可重入锁
        • 死锁
        • 避免死锁
      • Synchronized
        • Synchronized可以作用在哪里?
        • Synchronized的锁升级过程是怎样的?
        • Synchronized和Lock的对比
        • synchronized和reentranlock锁区别
        • Lock如何实现公平锁?
        • synchronized 原理
        • synchronized 同步语句块的情况
        • synchronized 修饰方法的的情况
        • 1.6之后synchronized锁的优化有哪些?
      • ReentrantLock
        • ReentrantLock 原理
      • Volatile
        • volatile 有什么特点?
        • volatile 和 synchornized 对比
        • volatile 原理
      • Java 内存模型
        • 什么是Java内存模型
        • Java内存模型两大内存
        • Java内存模型的三大特性
      • ThreadLocal
        • ThreadLocal内存泄漏
        • ThreadLocal原理
        • ThreadLocalMap Hash 冲突
      • AQS
        • AQS 原理
      • CAS
        • CAS实现
        • CAS中的ABA问题
        • CAS中的其他问题
      • 线程池
        • 讲一讲你了解的线程池,有哪几种
        • 线程池有哪些参数
        • 线程池原理
        • 线程池执行流程
        • 线程池中的阻塞队列
        • ArrayBlockingQueue
        • LinkedBlockingQueue
        • SynchronousQueue
        • PriorityBlockingQueue
        • 线程池生命周期
        • 如何优雅地终止线程
      • Future
        • Callable 和 Future 有什么关系?
        • CompletableFuture
      • JMM
        • CPU 缓存模型
        • 指令重排序
    • JVM
    • Linux
    • 设计模式
  • 框架

  • 数据库

  • 消息队列

  • 408

  • 大数据

  • 面试
  • Java基础
Iekr
2023-02-27
目录

JUC

# Java 并发

# 多线程的出现是要解决什么问题的?本质什么?

CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;(导致可见性问题)
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;(导致原子性问题)
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。(导致有序性问题)

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

tag: 知乎

count:1

as:

JMM 本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:

  • volatile、synchronized 和 final 三个关键字
  • Happens-Before 规则

可见性,有序性,原子性

  • 原子性:对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行 只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
    • Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
  • 可见性:Java 提供了 volatile 关键字来保证可见性,当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
  • 有序性:在 Java 里面,可以通过 volatile 关键字来保证一定的 “有序性”。 另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然 JMM 是通过 Happens-Before 规则来保证有序性的。

# 如何预防和避免线程死锁?

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。

# 乐观锁和悲观锁了解吗?应用场景有什么区别?

tag: 携程

count:1

as:

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

tag: 知乎 、 快手 、 用友 、 京东 、 字节 、 喜马拉雅 、 tp-link 、 得物 、 有道 、 苏小研 、 青书

count:14

as:什么是线程安全,如何保证线程安全

  1. 互斥同步:synchronized 和 ReentrantLock。
  2. 非阻塞同步:互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观锁的并发策略,所以提出了非阻塞同步。
    • CAS:基于冲突检测的乐观锁并发策略,它的实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
    • AtomicInteger:J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。

# 线程和进程的区别

tag: 美团 、 字节 、 趣链科技 、 快手 、 数字马力 、 小米 、 金山 、 富途 、 贝壳 、 tp-link 、 去哪儿 、 得物 、 滴滴 、 哔哩哔哩 、 腾讯 、 用友 、 亚信 、 知乎 、 奇安信 、 图森

count:45

as:进程和线程的区别,通信方式有何不同,在开发过程中,需要注意的地方?

Java 里面的进程和线程跟操作系统的进程和线程有什么区别?

进程之间的通信方式。

线程间通信

线程和进程的区别 在 JVM 层面的体现

进程线程的区别,提示资源分配,空间占用方面

线程的切换为什么比进程的代价小

  • 进程:进程是程序的一次执行过程,是系统运行程序的基本单位由操作系统进行资源分配和调度的基本单位,因此进程是动态的。 每个进程都有自己的独立地址空间和其他系统资源(如打开的文件、系统状态等)。进程之间相互隔离,这意味着一个进程的崩溃不会影响其他进程的执行。
  • 线程:线程是进程内的一个执行单元,也被称为轻量级进程。一个进程可以包含一个或多个线程,这些线程共享同一进程的资源(如内存地址空间),但每个线程有自己的栈空间和程序计数器。 与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核)。

在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。

# 协程

tag: 趣链科技 、 美团 、 快手 、 字节 、 奇安信

count:8

as:Java 怎么实现协程

java 有协程吗

虚拟线程又称协程在 Java 21 正式发布,这是一项重量级的更新。

虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程 (Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。

优点

  • 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
  • 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
  • 减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。

缺点

  • 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
  • 与某些第三方库不兼容: 虽然虚拟线程设计时考虑了与现有代码的兼容性,但某些依赖平台线程特性的第三方库可能不完全兼容虚拟线程。

# 并发和并行的区别

tag: 知乎

count:2

as:

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

最关键的点是:是否是 同时 执行。

同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

# 线程有哪几种状态并分别说明从一种状态到另一种状态转变有哪些方式

tag: 联想

count:4

as:

image-20230227103842222

  • 新建 (New):创建后尚未启动。

  • 可运行 (Runnable):可能正在运行,也可能正在等待 CPU 时间片。包含了操作系统线程状态中的 Running 和 Ready。

  • 阻塞 (Blocking):等待获取一个排它锁,如果其线程释放了锁就会结束此状态。

  • 无限期等待 (Waiting):等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。

    进入方法 退出方法
    没有设置 Timeout 参数的 Object.wait () 方法 Object.notify() / Object.notifyAll()
    没有设置 Timeout 参数的 Thread.join () 方法 被调用的线程执行完毕
    LockSupport.park () 方法 -
  • 限期等待 (Timed Waiting):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep () 和 Object.wait () 等方法进入。

    进入方法 退出方法
    Thread.sleep () 方法 时间结束
    设置了 Timeout 参数的 Object.wait () 方法 时间结束 / Object.notify () / Object.notifyAll ()
    设置了 Timeout 参数的 Thread.join () 方法 时间结束 / 被调用的线程执行完毕
    LockSupport.parkNanos () 方法 -
    LockSupport.parkUntil () 方法 -
  • 死亡 (Terminated):可以是线程结束任务之后自己结束,或者产生了异常而结束。

# 线程生命周期

tag: 传音 、 金蝶 、 快手

count:5

as:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待状态)
  5. TIMED_WAITING(有时限等待状态)
  6. TERMINATED(终止状态)

img

也可以通过代码形式查看

for (Thread.State value : Thread.State.values()) {
    System.out.println(value);
}
1
2
3

图片

# 通常线程有哪几种创建方式?

tag: 万得 、 用友 、 亚信 、 明朝万达 、 传音 、 卓望 、 神策数据 、 飞猪 、 建信金科 、 金蝶 、 苏小研 、 招行 、 完美 、 联想 、 哔哩哔哩 、 字节 、 浩鲸 、 数字马力 、 国遥新天地

count:26

as:说一下多线程有几种实现方式,有什么区别

Thread 、callable 和 runnable 区别

多线程的创建和销毁

Java 中实现多线程

有三种创建线程的方法:

  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 继承 Thread 类。

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

# 基础线程机制有哪些?

  • Executor:Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。

主要有三种 Executor:

  1. CachedThreadPool: 一个任务创建一个线程;
  2. FixedThreadPool: 所有任务只能使用固定大小的线程;
  3. SingleThreadExecutor: 相当于大小为 1 的 FixedThreadPool。
  • Daemon:守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。 当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。 main () 属于非守护线程。使用 setDaemon () 方法将一个线程设置为守护线程。

  • sleep():Thread.sleep (millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。 sleep () 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main () 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

  • yield():对静态方法 Thread.yield () 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。

# wait () 和 sleep () 的区别

  • wait () 是 Object 的方法,而 sleep () 是 Thread 的静态方法;
  • wait () 会释放锁,sleep () 不会。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。 sleep() 方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • wait() 通常被用于线程间交互 / 通信, sleep() 通常被用于暂停执行。

# wait() notify() notifyAll()

调用 wait () 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify () 或者 notifyAll () 来唤醒挂起的线程。

它们都属于 Object 的一部分,而不属于 Thread。只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。

# join()

在线程中调用另一个线程的 join () 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。如果将要 join 的线程并未启动,则会等待将要 jion 线程的创建并等待结束才创建当前线程。

# 线程创建多少个合适?

# CPU 密集型计算

大部分场景下都是纯 CPU 计算。

对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。

理论上 “线程的数量 =CPU 核数” 就是最合适的。不过在工程上,线程的数量一般会设置为 “CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

# IO 密集型

由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算。

最佳线程数核数(耗时耗时)最佳线程数=CPU 核数∗[1+(I/O 耗时/CPU 耗时)]

# 锁

tag:

count:1

as:

# 乐观锁和悲观锁

tag: 京东 、 字节 、 亚信 、 大智慧 、 得物 、 招行

count:9

as:乐观锁实现

乐观锁悲观锁使用场景

# 悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

像 Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

# 乐观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中 java.util.concurrent.atomic 包下面的原子变量类(比如 AtomicInteger 、 LongAdder )就是使用了乐观锁的一种实现方式 CAS 实现的。

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

# 版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时, version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

# CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

# 可重入锁

tag:

count:2

as:可重入锁是什么,怎么知道这个对象可以重入,对象除了对象头还有什么

java 中的可重入锁原理

# 死锁

tag: 快手 、 小米 、 苏小研 、 京东 、 富途 、 百度 、 Fabrie 、 数字马力 、 4399 、 神策数据 、 字节 、 美团 、 阿里

count:21

as:为什么会产生死锁

死锁 4 个特征,结合实际讲讲

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

image-20240801134517899

死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

# 避免死锁

tag: 小米 、 苏小研 、 京东 、 瑞幸 、 Fabrie 、 4399 、 神策数据 、 快手

count:10

as:银行家算法

# Synchronized

tag: 字节 、 腾讯 、 云之重器 、 快手 、 淘天 、 美团 、 移动

count:14

as:Synchronized 使用场景详细

# Synchronized 可以作用在哪里?

tag: 数字马力 、 快手

count:8

as:synchronized 的锁的用法,和作用域?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。

synchronized void method() {
    //业务代码
}
1
2
3

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    //业务代码
}
1
2
3

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象 / 类)

对括号里指定的对象 / 类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁。
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
    //业务代码
}
1
2
3

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

# Synchronized 的锁升级过程是怎样的?

tag: 小米 、 税友 、 卓望 、 得物 、 用友 、 美团 、 百度 、 京东

count:18

as:基于 synchronized 加锁,多线程去抢资源,底层会发生什么

java 中的各种锁,锁升级过程

# Synchronized 和 Lock 的对比

tag: 恒生 、 卓望 、 苏小研 、 阿里 、 美团

count:7

as:sychnoized 和 lock 的区别

存在层次上

  • synchronized: Java 的关键字,在 jvm 层面上
  • Lock: 是一个接口

锁的释放

  • synchronized:
    1. 以获取锁的线程执行完同步代码,自动释放锁
    2. 程执行发生异常,jvm 会让线程释放锁
  • Lock: 在 finally 中必须释放锁,不然容易造成线程死锁

锁的获取

  • synchronized: 假设 A 线程获得锁,B 线程等待。如果 A 线程阻塞,B 线程会一直等待
  • Lock: 分情况而定,Lock 有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待 (可以通过 tryLock 判断有没有锁)

锁的释放(死锁产生)

  • synchronized: 在发生异常时候会自动释放占有的锁,因此不会出现死锁

  • Lock: 发生异常时候,不会主动释放占有的锁,必须手动 unlock 来释放锁,可能引起死锁的发生

锁的状态

  • synchronized: 无法判断
  • Lock: 可以判断

锁的类型

  • synchronized: 可重入 不可中断 非公平
  • Lock: 可重入 可判断 可公平(两者皆可)

性能

  • synchronized: 少量同步
  • Lock: 大量同步

Lock 可以提高多个线程进行读操作的效率。(可以通过 readwritelock 实现读写分离)ReentrantLock 提供了多样化的同步,比如有时间限制的同步,可以被 Interrupt 的同步(synchronized 的同步是不能 Interrupt 的)等。 在资源竞争不是很激烈的情况下,Synchronized 的性能要优于 ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized 的性能会下降几十倍,但是 ReetrantLock 的性能能维持常态;

调度

  • synchronized: 使用 Object 对象本身的 wait 、notify、notifyAll 调度机制

  • Lock: 可以使用 Condition 进行线程之间的调度

用法

  • synchronized: 在需要同步的对象中加入此控制,synchronized 可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。
  • Lock: 一般使用 ReentrantLock 类做为锁。在加锁和解锁处需要通过 lock () 和 unlock () 显示指出。所以一般会在 finally 块中写 unlock () 以防死锁。

底层实现

  • synchronized: 底层使用指令码方式来控制锁的,映射成字节码指令就是增加来两个指令:monitorenter 和 monitorexit。当线程执行遇到 monitorenter 指令时会尝试获取内置锁,如果获取锁则锁计数器 + 1,如果没有获取锁则阻塞;当遇到 monitorexit 指令时锁计数器 - 1,如果计数器为 0 则释放锁。
  • Lock: 底层是 CAS 乐观锁,依赖 AbstractQueuedSynchronizer 类,把所有的请求线程构成一个 CLH 队列。而对该队列的操作均通过 Lock-Free(CAS)操作。

# synchronized 和 reentranlock 锁区别

tag: 得物

count:7

as:

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

  • synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
  • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock () 和 unlock () 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

相比 synchronized , ReentrantLock 增加了一些高级功能

主要来说主要有三点:

  • 等待可中断 : ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁 : ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来指定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized 关键字与 wait() 和 notify() / notifyAll() 方法相结合可以实现等待 / 通知机制。 ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。

# Lock 如何实现公平锁?

tag:

count:1

# synchronized 原理

tag: 数字马力 、 快手 、 小米 、 恒生 、 云之重器 、 小红书 、 苏小研 、 淘天 、 青书 、 联想 、 京东

count:28

as:Synchronized 本质上是通过什么保证线程安全的?

监视器锁为什么要两次解锁?

synchronized 可重入实现?

讲下 synchronized 有哪些实现方法,底层原理

synchronized 为什么是悲观锁

synchronized 实现原理,锁的机制

synchronized 互斥锁是怎么保证线程安全的

synchronized 可以保证原子性吗

synchronized 是怎么保证线程安全的,是怎么上锁的,这个锁标志在对象头里占多少位

你觉得 synchronized 一定能保证线程安全吗

synchonize 关键字。是不是公平锁?为什么是不公平锁?

Synchronize 怎么保证线程安全

synchorized 原理,功能,应用场景

synchronizd 的锁存在哪里,两个命令是啥

sync 的四种状态?(从无锁到重量级锁)

syncookies 的具体流程,问的很细,时间戳里面保存了什么信息,优缺点

  • 加锁和释放锁的原理
  • 可重入原理:加锁次数计数器
  • 保证可见性的原理:内存模型和 happens-before 规则

# synchronized 同步语句块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}
1
2
3
4
5
6
7

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo.class 。

image-20240801144652037

从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。

在 JVM 层面, synchronized 的实现依赖于监视器锁(Monitor)。监视器锁与每个对象相关联,当一个线程获得对象的锁时,就表示它获得了对该对象的独占访问权。监视器锁保证了在任何时候,最多只有一个线程能够执行同步代码段。

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

监视器锁的获取与释放

  • 当线程请求进入同步代码块时,它会尝试获取监视器锁。如果锁已被其他线程持有,请求线程就会阻塞,直到锁可用为止。
  • 如果线程成功获取了锁,它可以执行同步代码块内的代码。
  • 当线程离开了同步代码块,无论是因为正常执行完毕还是因为抛出了异常,它都会释放锁,允许其他等待的线程获取锁并进入同步代码块。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitoropen in new window (opens new window) 实现的。每个对象中都内置了一个 ObjectMonitor 对象。

另外, wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。

在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

image-20240801144744717

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

image-20240801144845907

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

# synchronized 修饰方法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}
1
2
3
4
5

image-20240801145020842

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

总结:

  • synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
  • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

# 1.6 之后 synchronized 锁的优化有哪些?

tag: 用友

count:2

as:synchronized1.8 之后提到过一个锁升级这个了解过吗

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

  • 偏向锁:如果在一个时间段内,总是由同一个线程访问同步块,那么 JVM 会认为没有必要每次都进行互斥同步,所以会直接让这个线程获取锁。
  • 轻量级锁:当存在多个线程竞争锁时,会使用轻量级锁。轻量级锁使用 CAS(Compare and Swap)操作来尝试获取锁。
  • 重量级锁:如果轻量级锁争夺失败,或者重试次数到达一定阈值,锁就会升级为重量级锁,此时会涉及操作系统级的互斥锁。

锁的可重入性

synchronized 支持可重入性,这意味着一个线程可以多次获得同一个对象的锁。每次进入同步代码块时,锁的计数器就会增加;当线程离开时,计数器减少。只有当计数器变为零时,锁才会被释放。

synchronized 锁升级是一个比较复杂的过程

# ReentrantLock

tag: 阿里 、 字节 、 快手

count:6

as:ReentrantLock 的公平锁和非公平锁优缺点

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过, ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

public class ReentrantLock implements Lock, java.io.Serializable {}
1

ReentrantLock 里面有一个内部类 Sync , Sync 继承 AQS( AbstractQueuedSynchronizer ),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。 Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

image-20240801145458119

// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
1
2
3
4

从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。

# ReentrantLock 原理

tag: 永辉超市 、 北森 、 boss 、 去哪儿 、 得物

count:8

as:reentrantlock 怎么实现公平,怎么实现可重入

ReentrantLock 可重入指的是什么,可重入锁的作用是啥

Reentrantlock 非公平和公平区别及其底层,公平和非公平的使用场景;

ReentrantLock 的 lock 做了什么事情?

# Volatile

tag: 阿里 、 核桃编程 、 字节 、 小米 、 税友 、 腾讯 、 小红书 、 瑞幸 、 数字马力 、 神策数据 、 深信服 、 淘天 、 美团 、 用友

count:25

as:volatile 有什么作用

如果我现在有个 byte 数组被 volatile 修饰,对其中元素进行修改是可见的吗,怎么修正

使用场景有哪些?

volatile 特性

volatile 可以保证原子性吗

能否实现并发

volatile 是 Java 中的一个关键字,用于修饰变量,确保其值在多线程环境中的一致性和可见性。当一个变量被声明为 volatile 时,它会告诉编译器和 JVM 不要对该变量进行某些优化,从而保证所有线程都能看到该变量的最新值。

# volatile 有什么特点?

  • volatile 保证了可见性:当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
  • volatile 保证了单线程下指令不重排:通过插入内存屏障保证指令执行顺序。
  • volatile 不保证原子性,如 a++ 这种自增操作是有并发风险的,比如扣减库存、发放优惠券的场景。
  • volatile 类型的 64 位的 long 型和 double 型变量,对该变量的读 / 写具有原子性。
  • volatile 可以用在双重检锁的单例模式中,比 synchronized 性能更好。
  • volatile 可以用在检查某个状态标记以判断是否退出循环。

# volatile 和 synchornized 对比

tag: 得物

count:3

as:

  • volatile 只能修饰实例变量和类变量,synchronized 可以修饰方法和代码块。
  • volatile 不保证原子性,而 synchronized 保证原子性
  • volatile 不会造成阻塞,而 synchronized 可能会造成阻塞
  • volatile 和 synchronized 都保证了可见性和有序性

# volatile 原理

tag: 字节 、 一嗨租车 、 小米 、 得物 、 瑞幸 、 百度 、 永辉 、 神策数据 、 美团 、 淘天 、 快手 、 用友 、 联想

count:17

as:volatile 不保证原子性的原理?

可见性是怎么实现的

重排序实现?

volatile 禁止指令重排?

怎么保证变量的原子性

为什么重排序,为什么有可见性问题

  • 内存屏障(Memory Barrier): volatile 变量的读写操作会在内存屏障的作用下强制刷新主内存中的值,并使缓存失效,确保所有线程都使用最新的数据。
  • 发生原则(Happens-Before):根据 Java 内存模型(JMM),对于 volatile 变量,每次写操作都会先于后续的读操作发生(happens-before)。这意味着:
    • 对 volatile 变量的写操作完成后,所有之前发生的动作对之后读取该变量的操作是可见的。
    • 如果一个线程 A 写入了一个 volatile 变量,那么在此之前 A 执行的所有操作都将对随后读取该 volatile 变量的线程 B 可见。

# Java 内存模型

img

# 什么是 Java 内存模型

JMM 是 Java 内存模型,也就是 Java Memory Model,简称 JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

  1. 定义程序中各种变量的访问规则
  2. 把变量值存储到内存的底层细节
  3. 从内存中取出变量值的底层细节

# Java 内存模型两大内存

image-20231024185357893

  • 主内存
    • Java 堆中对象实例数据部分
    • 对应于物理硬件的内存
  • 工作内存
    • Java 栈中的部分区域
    • 优先存储于寄存器和高速缓存

# Java 内存模型的三大特性

  • 可见性(当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改)
  • 原子性(一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败)
  • 有序性(变量赋值操作的顺序与程序代码中的执行顺序一致) 有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指 “线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。

# ThreadLocal

tag: 途牛 、 网易 、 淘天 、 数字马力 、 贝壳 、 飞猪 、 招行 、 饿了么 、 美团 、 用友 、 字节

count:19

as:使用要注意什么

ThreadLocal 的作用

线程池中使用 ThreadLocal 会有哪些潜在风险

ThreadLocal 可以保证线程安全吗?

ThreadLocal 使用场景?

开源的 FastThreadLocal 了解过吗?

为什么要用 ThreadLocal 保存登录信息?有什么缺点和优点?

ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。如:用户信息的获取比较昂贵(比如从数据库查询用户信息),那么在 ThreadLocal 中缓存数据是比较合适的做法。

缺点:线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。

image-20240906225050459

Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals ,也就是说每个线程有一个自己的 ThreadLocalMap 。

ThreadLocalMap 有自己的独立实现,可以简单地将它的 key 视作 ThreadLocal , value 为代码中放入的值(实际上 key 并不是 ThreadLocal 本身,而是它的一个弱引用)。

每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key ,从而实现了线程隔离。

ThreadLocalMap 有点类似 HashMap 的结构,只是 HashMap 是由数组 + 链表实现的,而 ThreadLocalMap 中并没有链表结构。

我们还要注意 Entry , 它的 key 是 ThreadLocal<?> k ,继承自 WeakReference , 也就是我们常说的弱引用类型。

阿里嵩山开发手册:

【强制】必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄露等问题。尽量在代理中使用 try-finally 块进行回收。

objectThreadLocal.set(userInfo);

try{ 
    // ...
}
finally{ 
    objectThreadLocal.remove();
}
1
2
3
4
5
6
7
8

# ThreadLocal 内存泄漏

tag: 小米 、 招行 、 快手 、 百度 、 万得

count:8

as:为什么弱引用?

ThreadLocal 每次请求后被 gc 掉了,为什么还能请求到上一个用户信息?

image-20230402212856447

实线代表强引用,虚线代表弱引用

每一个 Thread 维护一个 ThreadLocalMap, key 为使用弱引用的 ThreadLocal 实例,value 为线程变量的副本。

  • 强引用,使用最普遍的引用,一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不回收这种对象。 如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为 null,这样可以使 JVM 在合适的时间就会回收该对象。

  • 弱引用,JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在 java 中,用 java.lang.ref.WeakReference 类来表示。

  • 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知

  • 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来, ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。 ThreadLocalMap 实现中已经考虑了这种情况,在调用 set() 、 get() 、 remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后最好手动调用 remove() 方法

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
1
2
3
4
5
6
7
8
9

# ThreadLocal 原理

tag: 小米 、 途牛 、 腾讯 、 税友 、 美团 、 贝壳 、 飞猪 、 饿了么 、 用友

count:19

as:ThreadLocal 存储结构,怎么存储的

ThreadLocal 存储的变量在哪里

ThreadLocal 父线程如何传递给子线程

threadlocal 为什么不用强引用

怎么解决内存泄漏

Thread

public class Thread implements Runnable {
    //......
    //与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    //与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}
1
2
3
4
5
6
7
8
9

从上面 Thread 类 源代码可以看出 Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap 。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set 或 get 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get() 、 set() 方法。

ThreadLocal 类的 set() 方法

public void set(T value) {
    //获取当前请求的线程
    Thread t = Thread.currentThread();
    //取出 Thread 类内部的 threadLocals 变量(哈希表结构)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 将需要存储的值放入到这个哈希表中
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上, ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

每个 Thread 中都具备一个 ThreadLocalMap ,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key ,Object 对象为 value 的键值对。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //......
}
1
2
3

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread 内部都是使用仅有的那个 ThreadLocalMap 存放数据的, ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用 set 方法设置的值。

ThreadLocal 数据结构如下图所示:

image-20240801150125076

ThreadLocalMap 是 ThreadLocal 的静态内部类。

image-20240801150145999

# ThreadLocalMap Hash 冲突

ThreadLocalMap 中使用了黄金分割数来作为 hash 计算因子,大大减少了 Hash 冲突的概率,但是仍然会存在冲突。

HashMap 中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。

而 ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。

注明: 下面所有示例图中,绿色块 Entry 代表正常数据,灰色块代表 Entry 的 key 值为 null ,已被垃圾回收。白色块表示 Entry 为 null 。

image-20240906225610797

如上图所示,如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入槽位 4 中,而槽位 4 已经有了 Entry 数据。

此时就会线性向后查找,一直找到 Entry 为 null 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 Entry 不为 null 且 key 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。

这里还画了一个 Entry 中的 key 为 null 的数据(Entry=2 的灰色块数据),因为 key 值是弱引用类型,所以会有这种数据存在。在 set 过程中,如果遇到了 key 过期的 Entry 数据,实际上是会进行一轮探测式清理操作的。

# AQS

tag: 携程 、 恒生 、 阿里 、 小米 、 快手 、 万得 、 数字马力 、 boss 、 百度 、 美团 、 招行 、 联想 、 云之重器

count:24

as:AQS 原理

AQS 的 Node 具体放的是什么

自定义 aqs 需要重写什么方法

aqs 用的设计模式,获取互斥量对应操作系统的什么 (pv 原语),aqs 互斥量的表示 (volatile 的 state)

AQS 底层数据结构以及原理

AQS 如何实现公平锁和非公平锁

AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,将基础的同步相关操作抽象在 AbstractQueuedSynchronizer 中,利用 AQS 为我们构建同步结构提供了范本。

AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的 ReentrantLock/Semaphore/CountDownLatch。

将基础的同步相关操作抽象在 AbstractQueuedSynchronizer 中,利用 AQS 为我们构建同步结构提供了范本。

AQS 内部数据和方法,可以简单拆分为:

  • 一个 volatile 的整数成员表征状态,同时提供了 setState 和 getState 方法。
  • 一个先入先出(FIFO)的等待线程队列,以实现多线程间竞争和等待,这是 AQS 机制的核心之一。
  • 各种基于 CAS 的基础操作方法,以及各种期望具体同步结构去实现的 acquire/release 方法。

利用 AQS 实现一个同步结构,至少要实现两个基本类型的方法,分别是 acquire 操作,获取资源的独占权;还有就是 release 操作,释放对某个资源的独占。

image-20241030170206045

AQS 就是一个抽象类,主要用来构建锁和同步器。

public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
}
1
2

AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock , Semaphore ,其他的诸如 ReentrantReadWriteLock , SynchronousQueue 等等皆是基于 AQS 的。

# AQS 原理

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

CLH (Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

CLH 队列结构如下图所示:

image-20240906165228059

AQS( AbstractQueuedSynchronizer ) 的核心原理图

image-20240906165401366

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

// 共享变量,使用volatile修饰保证线程可见性
private volatile int state;
1
2

另外,状态信息 state 可以通过 protected 类型的 getState() 、 setState() 和 compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

//返回同步状态的当前值
protected final int getState() {
     return state;
}
 // 设置同步状态的值
protected final void setState(int newState) {
     state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
      return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
1
2
3
4
5
6
7
8
9
10
11
12

以 ReentrantLock 为例, state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state= 0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的( state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。

线程 A 尝试获取锁的过程如下图所示

image-20241030170337073

再以 CountDownLatch 以例,任务分为 N 个子线程去执行, state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS (Compare and Swap) 减 1。等到所有子线程都执行完后 (即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。

# CAS

tag: 腾讯 、 阿里 、 快手 、 字节 、 小红书 、 小米 、 美团 、 tp-link 、 货拉拉 、 得物

count:15

as:讲下 CAS 锁实现?

CAS 锁会有哪些问题?

CAS 一般会遇到什么问题?

读写锁是 AQS 实现的吗?

CAS 原理

已经用了 synchronized,为什么还要用 CAS 呢

CAS 的 ABA 问题怎么解决

cas 有 ABA 问题,为什么还要用

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

# CAS 实现

实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是 Unsafe 。

Unsafe 类位于 sun.misc 包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。

sun.misc 包下的 Unsafe 类提供了 compareAndSwapObject 、 compareAndSwapInt 、 compareAndSwapLong 方法来实现的对 Object 、 int 、 long 类型的 CAS 操作:

/**
 * 以原子方式更新对象字段的值。
 *
 * @param o        要操作的对象
 * @param offset   对象字段的内存偏移量
 * @param expected 期望的旧值
 * @param x        要设置的新值
 * @return 如果值被成功更新,则返回 true;否则返回 false
 */
boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);

/**
 * 以原子方式更新 int 类型的对象字段的值。
 */
boolean compareAndSwapInt(Object o, long offset, int expected, int x);

/**
 * 以原子方式更新 long 类型的对象字段的值。
 */
boolean compareAndSwapLong(Object o, long offset, long expected, long x);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Unsafe 类中的 CAS 方法是 native 方法。 native 关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。

,我们通过解读 AtomicInteger 的核心源码(JDK1.8),来说明 Java 如何使用 Unsafe 类的方法来实现原子操作。

AtomicInteger 核心源码如下:

// 获取 Unsafe 实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        // 获取“value”字段在AtomicInteger类中的内存偏移量
        valueOffset = unsafe.objectFieldOffset
            (AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}
// 确保“value”字段的可见性
private volatile int value;

// 如果当前值等于预期值,则原子地将值设置为newValue
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

// 原子地将当前值加 delta 并返回旧值
public final int getAndAdd(int delta) {
    return unsafe.getAndAddInt(this, valueOffset, delta);
}

// 原子地将当前值加 1 并返回加之前的值(旧值)
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

// 原子地将当前值减 1 并返回减之前的值(旧值)
public final int getAndDecrement() {
    return unsafe.getAndAddInt(this, valueOffset, -1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

Unsafe#getAndAddInt 源码:

// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        // 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    // 返回旧值
    return v;
}
1
2
3
4
5
6
7
8
9
10

可以看到, getAndAddInt 使用了 do-while 循环:在 compareAndSwapInt 操作失败时,会不断重试直到成功。也就是说, getAndAddInt 方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。

由于 CAS 操作可能会因为并发冲突而失败,因此通常会与 while 循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。

# CAS 中的 ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA" 问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}
1
2
3
4
5
6
7
8
9
10
11
12

# CAS 中的其他问题

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能够支持处理器提供的 pause 指令,那么自旋操作的效率将有所提升。 pause 指令有两个重要作用:

  1. 延迟流水线执行指令: pause 指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
  2. 避免内存顺序冲突:在退出循环时, pause 指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了 AtomicReference 类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用 AtomicReference 来执行 CAS 操作。

除了 AtomicReference 这种方式之外,还可以利用加锁来保证。

# 线程池

tag: 恒生 、 招行 、 快手 、 百度 、 字节

count:10

as:线程池的好处

为什么用线程池,创建线程池有什么方法,怎么保证线程池只有一个

线程池的作用?

线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。

# 讲一讲你了解的线程池,有哪几种

tag: 京东 、 Meta App 、 得物 、 美团 、 快手

count:11

as:线程池用过哪些?具体哪些实现类

有哪些线程池?

有哪几种线程池?那你了解 forkjoinpool 吗?

假如用 Executors 的静态方法创建线程池,有哪几种?CachedThreadPool 和 FixedThreadPool 提交一个任务处理流程有啥区别?

线程池创建方式

方式一:通过 ThreadPoolExecutor 构造函数来创建(推荐)。

image-20240903081011805

方式二:通过 Executor 框架的工具类 Executors 来创建。

Executors 工具类提供的创建线程池的方法如下图所示:

img

可以看出,通过 Executors 工具类可以创建多种类型的线程池,包括:

  • FixedThreadPool :固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor : 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool : 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • ScheduledThreadPool :给定的延迟后运行任务或者定期执行任务的线程池。

# 线程池有哪些参数

tag: 京东 、 快手 、 软通 、 小米 、 数字马力 、 滴滴 、 瑞幸 、 苏小妍 、 税友 、 美团 、 得物 、 信也 、 瑞幸 、 亚信 、 贝壳 、 途虎 、 明朝万达 、 4399 、 传音 、 百度 、 猫眼 、 神策数据 、 飞猪 、 小红书 、 大智慧 、 苏小研 、 淘天 、 茄子科技 、 神州出行 、 万得 、 阿里 、 一嗨租车 、 蔚来 、 网新恒天

count:79

as:拒绝策略有哪些

线程池核心 5 max8 5 个线程在跑 再来一个 task 怎么处理

线程池如何去达到最大线程数,如何实现扩容

线程池如何确定最大线程数

为什么是先添加队列而不是先创建最大线程。

线程池中最大线程数应该最大支持多少

数量设置一般遵循什么

丢弃策略一般使用什么比较好

为什么不先创建临时线程而是先放进阻塞队列?

核心线程能否回收

线程池的核心线程执行完任务怎么不被销毁

一个线程池核心线程数满了,接下来添加了一个任务会怎么样

    /**
     * 用给定的初始参数创建一个新的ThreadPoolExecutor。
     */
    public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
                              int maximumPoolSize,//线程池的最大线程数
                              long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
                              TimeUnit unit,//时间单位
                              BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
                              ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
                              RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
                               ) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

线程池各个参数的关系

有 7 个参数:

  1. corePoolSize (核心线程数量):不能小于 0,线程池中的核心线程数,最大可以同时运行的线程数量。,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于 corePoolSize, 即使有其他空闲线程能够执行新来的任务,也会继续创建线程;
  2. workQueue (任务队列):用来保存等待被执行的任务的阻塞队列 ,不能为 null 如果 submit 的线程过多则会缓存到队列中,新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
    • ArrayBlockingQueue : 基于数组结构的有界阻塞队列,按 FIFO 排序任务;
    • LinkedBlockingQueue : 基于链表结构的阻塞队列,按 FIFO 排序任务,吞吐量通常要高于 ArrayBlockingQueue;
    • SynchronousQueue : 一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue;
    • PriorityBlockingQueue : 具有优先级的无界阻塞队列;
  3. maximumPoolSize (最大线程数):大于等于核心数,任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
    • 当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的核心线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的 prestartAllCoreThreads () 方法,则线程池会提前创建并启动所有基本线程。
  4. keepAliveTime (空闲线程最大存活时间):不能小于 0,线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于 corePoolSize 时才有用,超过这个时间的空闲线程将被终止;
  5. unit (时间单位 ):keepAliveTime 的单位
  6. threadFactory (创建线程工厂):创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,默认的线程池为 Executors.defaultThreadFactory() 不能为 null
  7. handler (任务的拒绝策略)(即超出最大线程数如何处理) :我们使用 new ThreadPoolExecutor.AbortPolicy () 超出则拒绝 不能为 null 当 submit 线程数量超出了最大线程数 + 任务队列边界时 触发 拒绝策略
    • ThreadPoolExecutor.AbortPolicy : 丢弃任务并抛出 RejectedExecutionException 异常。默认的策略
    • ThreadPoolExecutor.DiscardPolicy : 将丢弃最早的未处理的任务请求,但不抛出异常 不太推荐使用
    • ThreadPoolExecutor.DiscardOldestPolicy : 抛弃队列中等待最久的任务 然后把当前任务加入队列中
    • ThreadPoolExecutor.CallerRunsPolicy : 调度任务的 run() 方法绕过线程池直接执行,如果执行程序已关闭,则会丢弃该任务。

ThreadPoolExecutor 默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将 allowCoreThreadTimeOut(boolean value) 方法的参数设置为 true ,这样就会回收空闲(时间间隔由 keepAliveTime 指定)的核心线程了。

 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4, 6, 6, TimeUnit.SECONDS, new SynchronousQueue<>());
        threadPoolExecutor.allowCoreThreadTimeOut(true);
1
2

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 使用的是有界阻塞队列是 LinkedBlockingQueue ,其任务队列的最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool : 使用的是同步队列 SynchronousQueue , 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
  • ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列 DelayedWorkQueue ,任务队列最大长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
// 有界队列 LinkedBlockingQueue
public static ExecutorService newFixedThreadPool(int nThreads) {

    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());

}

// 无界队列 LinkedBlockingQueue
public static ExecutorService newSingleThreadExecutor() {

    return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));

}

// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE`
public static ExecutorService newCachedThreadPool() {

    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());

}

// DelayedWorkQueue(延迟阻塞队列)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy 拒绝策略更合适一些。

不过,如果走到 CallerRunsPolicy 的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。

这里简单举一个例子,该线程池限定了最大线程数为 2,阻塞队列大小为 1 (这意味着第 4 个任务就会走到拒绝策略), ThreadUtil 为 Hutool 提供的工具类:

public class ThreadPoolTest {

    private static final Logger log = LoggerFactory.getLogger(ThreadPoolTest.class);

    public static void main(String[] args) {
        // 创建一个线程池,核心线程数为1,最大线程数为2
        // 当线程数大于核心线程数时,多余的空闲线程存活的最长时间为60秒,
        // 任务队列为容量为1的ArrayBlockingQueue,饱和策略为CallerRunsPolicy。
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,
                2,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1),
                new ThreadPoolExecutor.CallerRunsPolicy());

        // 提交第一个任务,由核心线程执行
        threadPoolExecutor.execute(() -> {
            log.info("核心线程执行第一个任务");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第二个任务,由于核心线程被占用,任务将进入队列等待
        threadPoolExecutor.execute(() -> {
            log.info("非核心线程处理入队的第二个任务");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第三个任务,由于核心线程被占用且队列已满,创建非核心线程处理
        threadPoolExecutor.execute(() -> {
            log.info("非核心线程处理第三个任务");
            ThreadUtil.sleep(1, TimeUnit.MINUTES);
        });

        // 提交第四个任务,由于核心线程和非核心线程都被占用,队列也满了,根据CallerRunsPolicy策略,任务将由提交任务的线程(即主线程)来执行
        threadPoolExecutor.execute(() -> {
            log.info("主线程处理第四个任务");
            ThreadUtil.sleep(2, TimeUnit.MINUTES);
        });

        // 提交第五个任务,主线程被第四个任务卡住,该任务必须等到主线程执行完才能提交
        threadPoolExecutor.execute(() -> {
            log.info("核心线程执行第五个任务");
        });

        // 关闭线程池
        threadPoolExecutor.shutdown();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

输出:

18:19:48.203 INFO  [pool-1-thread-1] c.j.concurrent.ThreadPoolTest - 核心线程执行第一个任务
18:19:48.203 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理第三个任务
18:19:48.203 INFO  [main] c.j.concurrent.ThreadPoolTest - 主线程处理第四个任务
18:20:48.212 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 非核心线程处理入队的第二个任务
18:21:48.219 INFO  [pool-1-thread-2] c.j.concurrent.ThreadPoolTest - 核心线程执行第五个任务
1
2
3
4
5

从输出结果可以看出,因为 CallerRunsPolicy 这个拒绝策略,导致耗时的任务用了主线程执行,导致线程池阻塞,进而导致后续任务无法及时执行,严重的情况下很可能导致 OOM。

我们从问题的本质入手,调用者采用 CallerRunsPolicy 是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列 BlockingQueue 中。这样的话,在内存允许的情况下,我们可以增加阻塞队列 BlockingQueue 的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。

为了充分利用 CPU,我们还可以调整线程池的 maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue 的任务过多导致内存用完。

image-20240903090432793

如果服务器资源以达到可利用的极限,这就意味我们要在设计策略上改变线程池的调度了,我们都知道,导致主线程卡死的本质就是因为我们不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?

这里提供的一种任务持久化的思路,这里所谓的任务持久化,包括但不限于:

  1. 设计一张任务表将任务存储到 MySQL 数据库中。
  2. Redis 缓存任务。
  3. 将任务提交到消息队列中。

以方案一为例,简单介绍一下实现逻辑:

  1. 实现 RejectedExecutionHandler 接口自定义拒绝策略,自定义拒绝策略负责将线程池暂时无法处理(此时阻塞队列已满)的任务入库(保存到 MySQL 中)。注意:线程池暂时无法处理的任务会先被放在阻塞队列中,阻塞队列满了才会触发拒绝策略。
  2. 继承 BlockingQueue 实现一个混合式阻塞队列,该队列包含 JDK 自带的 ArrayBlockingQueue 。另外,该混合式阻塞队列需要修改取任务处理的逻辑,也就是重写 take() 方法,取任务时优先从数据库中读取最早的任务,数据库中无任务时再从 ArrayBlockingQueue 中去取任务。

image-20240903090719086

参考其他主流框架的做法,以 Netty 为例,它的拒绝策略则是直接创建一个线程池以外的线程处理这些任务,为了保证任务的实时处理,这种做法可能需要良好的硬件设备且临时创建的线程无法做到准确的监控:

private static final class NewThreadRunsPolicy implements RejectedExecutionHandler {
    NewThreadRunsPolicy() {
        super();
    }
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        try {
            //创建一个临时线程处理任务
            final Thread t = new Thread(r, "Temporary task executor");
            t.start();
        } catch (Throwable e) {
            throw new RejectedExecutionException(
                    "Failed to start a new thread", e);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

ActiveMQ 则是尝试在指定的时效内尽可能的争取将任务入队,以保证最大交付:

new RejectedExecutionHandler() {
                @Override
                public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor) {
                    try {
                        //限时阻塞等待,实现尽可能交付
                        executor.getQueue().offer(r, 60, TimeUnit.SECONDS);
                    } catch (InterruptedException e) {
                        throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");
                    }
                    throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");
                }
            });
1
2
3
4
5
6
7
8
9
10
11
12

# 线程池原理

tag: 数字马力 、 得物 、 途牛 、 美团 、 得物 、 万得 、 瑞幸 、 明朝万达 、 传音 、 移动 、 茄子科技 、 快手 、 网易 、 蔚来 、 网新恒天

count:18

as:在线程池中多个线程的结果是如何去合并的,说出两种解决方式

线程池是用什么数据结构实现的,或者你模拟一个线程池准备用什么数据结构

   // 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
   private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

    private static int workerCountOf(int c) {
        return c & CAPACITY;
    }
    //任务队列
    private final BlockingQueue<Runnable> workQueue;

    public void execute(Runnable command) {
        // 如果任务为null,则抛出异常。
        if (command == null)
            throw new NullPointerException();
        // ctl 中保存的线程池当前的一些状态信息
        int c = ctl.get();

        //  下面会涉及到 3 步 操作
        // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize
        // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
        // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
            if (!isRunning(recheck) && remove(command))
                reject(command);
                // 如果当前工作线程数量为0,新创建一个线程并执行。
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
        // 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
        //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
        else if (!addWorker(command, false))
            reject(command);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用 RejectedExecutionHandler.rejectedExecution() 方法。

在 execute 方法中,多次调用 addWorker 方法。 addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。

    // 全局锁,并发操作必备
    private final ReentrantLock mainLock = new ReentrantLock();
    // 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合
    private int largestPoolSize;
    // 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合
    private final HashSet<Worker> workers = new HashSet<>();
    //获取线程池状态
    private static int runStateOf(int c)     { return c & ~CAPACITY; }
    //判断线程池的状态是否为 Running
    private static boolean isRunning(int c) {
        return c < SHUTDOWN;
    }


    /**
     * 添加新的工作线程到线程池
     * @param firstTask 要执行
     * @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小
     * @return 添加成功就返回true否则返回false
     */
   private boolean addWorker(Runnable firstTask, boolean core) {
        retry:
        for (;;) {
            //这两句用来获取线程池的状态
            int c = ctl.get();
            int rs = runStateOf(c);

            // Check if queue empty only if necessary.
            if (rs >= SHUTDOWN &&
                ! (rs == SHUTDOWN &&
                   firstTask == null &&
                   ! workQueue.isEmpty()))
                return false;

            for (;;) {
               //获取线程池中工作的线程的数量
                int wc = workerCountOf(c);
                // core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize
                if (wc >= CAPACITY ||
                    wc >= (core ? corePoolSize : maximumPoolSize))
                    return false;
               //原子操作将workcount的数量加1
                if (compareAndIncrementWorkerCount(c))
                    break retry;
                // 如果线程的状态改变了就再次执行上述操作
                c = ctl.get();
                if (runStateOf(c) != rs)
                    continue retry;
                // else CAS failed due to workerCount change; retry inner loop
            }
        }
        // 标记工作线程是否启动成功
        boolean workerStarted = false;
        // 标记工作线程是否创建成功
        boolean workerAdded = false;
        Worker w = null;
        try {

            w = new Worker(firstTask);
            final Thread t = w.thread;
            if (t != null) {
              // 加锁
                final ReentrantLock mainLock = this.mainLock;
                mainLock.lock();
                try {
                   //获取线程池状态
                    int rs = runStateOf(ctl.get());
                   //rs < SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中
                  //(rs=SHUTDOWN && firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker
                   // firstTask == null证明只新建线程而不执行任务
                    if (rs < SHUTDOWN ||
                        (rs == SHUTDOWN && firstTask == null)) {
                        if (t.isAlive()) // precheck that t is startable
                            throw new IllegalThreadStateException();
                        workers.add(w);
                       //更新当前工作线程的最大容量
                        int s = workers.size();
                        if (s > largestPoolSize)
                            largestPoolSize = s;
                      // 工作线程是否启动成功
                        workerAdded = true;
                    }
                } finally {
                    // 释放锁
                    mainLock.unlock();
                }
                //// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例
                if (workerAdded) {
                    t.start();
                  /// 标记线程启动成功
                    workerStarted = true;
                }
            }
        } finally {
           // 线程启动失败,需要从工作线程中移除对应的Worker
            if (! workerStarted)
                addWorkerFailed(w);
        }
        return workerStarted;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100

# 线程池执行流程

tag: 途牛 、 美团 、 京东 、 网易 、 字节 、 永辉超市 、 猫眼 、 飞猪 、 小红书 、 得物 、 快手 、 哔哩哔哩

count:16

as:线程池工作流程

多线程中阻塞队列有任务,线程池是怎么去执行阻塞队列中的任务的

详细到核心线程和救急线程怎么产生消失的

图解线程池实现原理

  1. 当我们提交任务,线程池会根据 corePoolSize 大小创建若干任务数量线程执行任务
  2. 当任务的数量超过 corePoolSize 数量,后续的任务将会进入阻塞队列阻塞排队。
  3. 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize 额外创建的线程等待 keepAliveTime 之后被自动销毁
  4. 如果达到 maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理。

image-20241029223436433

# 线程池中的阻塞队列

tag: 字节 、 小红书 、 百度 、 快手

count:6

as:java 堵塞队列使用过吗

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • 容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue (有界阻塞队列): FixedThreadPool 和 SingleThreadExecutor 。 FixedThreadPool 最多只能创建核心线程数的线程(核心线程数和最大线程数相等), SingleThreadExecutor 只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
  • SynchronousQueue (同步队列): CachedThreadPool 。 SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说, CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue (延迟队列): ScheduledThreadPool 和 SingleThreadScheduledExecutor 。 DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是 “堆” 的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。 DelayedWorkQueue 添加元素满了之后会自动扩容,增加原来容量的 50%,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE ,所以最多只能创建核心线程数的线程。
  • ArrayBlockingQueue (有界阻塞队列):底层由数组实现,容量一旦创建,就不能修改。

# ArrayBlockingQueue

是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。

img

  • ArrayBlockingQueue 是一个用数组实现的有界阻塞队列。
  • 队列慢时插入操作被阻塞,队列空时,移除操作被阻塞。
  • 按照先进先出(FIFO)原则对元素进行排序。
  • 默认不保证线程公平的访问队列。
  • 公平访问队列:按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。
  • 非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格。有可能先阻塞的线程最后才访问访问队列。
  • 公平性会降低吞吐量。

# LinkedBlockingQueue

一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue。静态工厂方法 Executors.newFixedThreadPool () 使用了这个队列。(newFixedThreadPool 用于创建固定线程数)

图片

  • LinkedBlockingQueue 具有单链表和有界阻塞队列的功能。
  • 队列慢时插入操作被阻塞,队列空时,移除操作被阻塞。
  • 默认和最大长度为 Integer.MAX_VALUE,相当于无界 (值非常大:231−1)。

# SynchronousQueue

一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 LinkedBlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用这个队列。(newCachedThreadPool 用于根据需要创建新线程)

image-20231024191059952

SynchronousQueue 为” 传球好手 “。想象一下这个场景:小明抱着一个篮球想传给小花,如果小花没有将球拿走,则小明是不能再拿其他球的。

  • SynchronousQueue 负责把生产者产生的数据传递给消费者线程。
  • SynchronousQueue 本身不存储数据,调用了 put 方法后,队列里面也是空的。
  • 每一个 put 操作必须等待一个 take 操作完成,否则不能添加元素。
  • 适合传递性场景。
  • 性能高于 ArrayBlockingQueue 和 LinkedBlockingQueue。

# PriorityBlockingQueue

一个具有优先级的无限阻塞队列。

img

  • PriorityBlockQueue = PriorityQueue + BlockingQueue
  • 之前我们也讲到了 PriorityQueue 的原理,支持对元素排序。
  • 元素默认自然排序。
  • 可以自定义 CompareTo () 方法来指定元素排序规则。
  • 可以通过构造函数构造参数 Comparator 来对元素进行排序。

# 线程池生命周期

  • RUNNING:接收新的任务并处理队列中的任务
  • SHUTDOWN:不接收新的任务,但是处理队列中的任务
  • STOP:不接收新的任务,不处理队列中的任务,同时中断处理中的任务
  • TIDYING:所有的任务处理完成,有效的线程数是 0
  • TERMINATED:terminated () 方法执行完毕。

生命周期状态和方法对应的关系:

img

# 如何优雅地终止线程

tag: 小米 、 瑞幸

count:2

as:对线程池执行 shutdown (),线程如何关闭

线程池是怎么实现线程的保活和停止管理的?

  • shutdown() 方法是一种很保守的关闭线程池的方法。线程池执行 shutdown () 后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
  • shutdownNow() 方法,相对就激进一些了,线程池执行 shutdownNow () 后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会,不过这些被剥夺执行机会的任务会作为 shutdownNow () 方法的返回值返回。因为 shutdownNow () 方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。

如果提交到线程池的任务不允许取消,那就不能使用 shutdownNow () 方法终止线程池。不过,如果提交到线程池的任务允许后续以补偿的方式重新执行,也是可以使用 shutdownNow () 方法终止线程池的。

# Future

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。

这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

在 Java 中, Future 类只是一个泛型接口,位于 java.util.concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
    // 取消任务执行
    // 成功取消返回 true,否则返回 false
    boolean cancel(boolean mayInterruptIfRunning);
    // 判断任务是否被取消
    boolean isCancelled();
    // 判断任务是否已经执行完成
    boolean isDone();
    // 获取任务执行结果
    V get() throws InterruptedException, ExecutionException;
    // 指定时间内没有返回计算结果就抛出 TimeOutException 异常
    V get(long timeout, TimeUnit unit)

        throws InterruptedException, ExecutionException, TimeoutExceptio

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

# Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 Callable 和 Future 之间的关系。

FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable ,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。 ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask 。

<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
1
2

FutureTask 不光实现了 Future 接口,还实现了 Runnable 接口,因此可以作为任务直接被线程执行。

image-20240906164947890

FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为 Callable 对象。

public FutureTask(Callable<V> callable) {
    if (callable == null)
        throw new NullPointerException();
    this.callable = callable;
    this.state = NEW;
}
public FutureTask(Runnable runnable, V result) {
    // 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象
    this.callable = Executors.callable(runnable, result);
    this.state = NEW;
}
1
2
3
4
5
6
7
8
9
10
11

FutureTask 相当于对 Callable 进行了封装,管理着任务执行的情况,存储了 Callable 的 call 方法的任务执行结果。

# CompletableFuture

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入 CompletableFuture 类可以解决 Future 的这些缺陷。 CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

下面我们来简单看看 CompletableFuture 类的定义。

public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
}
1
2

可以看到, CompletableFuture 同时实现了 Future 和 CompletionStage 接口。

image-20240906165058442

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。

CompletionStage 接口中的方法比较多, CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。

image-20240906165111267

# JMM

JMM (Java 内存模型) 主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。

JMM 抽象了 happens-before 原则来解决这个指令重排序问题,JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatile 、 synchronized 、各种 Lock )即可开发出并发安全的程序。

# CPU 缓存模型

为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。

image-20240906165812989

现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见

CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议 open in new window (opens new window))或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。

我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。

操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

# 指令重排序

为了提升执行速度 / 性能,计算机在执行程序代码的时候,会对指令进行重排序。

什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术 (Instruction-Level Parallelism,ILP) 来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有 “重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。

  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

编辑 (opens new window)
上次更新: 2025/01/01, 10:09:39
Java 集合
JVM

← Java 集合 JVM→

最近更新
01
k8s
06-06
02
进程与线程
03-04
03
计算机操作系统概述
02-26
更多文章>
Theme by Vdoing | Copyright © 2022-2025 Iekr | Blog
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式