JUC详解(万字带你彻底搞懂JUC)框架建立+面试题理解

创建线程的三种方式

  1. 实现Runnable接口

创建一个类实现 Runnable 接口,并重写 run 方法。

Runnable 是一个函数式接口,只有一个抽象方法run。

使用示例:

1
2
3
4
5
public class MyThroad implements Runnable {
@Override
public void run(){
}
}
  1. 继承Thread 类

Thread本身也是实现了Runnable接口:

1
2
3
4
5
6
7
8
public class Thread implements Runnable {
private Runnable target;
public void run() {
if (target != null) {
target.run();
}
}
}

通过构造函数,Thread 可以接受一个 Runnable 对象作为参数:

1
Thread t = new Thread(new MyRunnable());

这种方式将任务和线程****解耦,推荐使用。

使用示例:创建一个类继承 Thread 类,并重写 run 方法

1
2
3
4
5
public class MyThread extends Thread {
@Override
public void run() {
}
}

3.实现Callable接口

实现 Callable 接口,重写 call 方法,这种方式可以通过 FutureTask 获取任务执行的返回值。

控制线程的方法

  1. sleep()

使当前正在执行的线程暂停指定的毫秒数,也就是进入休眠的状态。

需要注意的是,sleep 的时候要对异常进行处理。

  1. join()

等待这个线程执行完才会轮到后续线程得到 cpu 的执行权,使用这个也要捕获异常。

  1. setDaemen()

将此线程标记为守护,准确来说,就是服务其他的线程,像 Java 中的垃圾回收线程,就是典型的守护线程。

  1. yield()

yield() 方法是一个静态方法,用于暗示当前线程愿意放弃其当前的时间片,允许其他线程执行。然而,它只是向线程调度器提出建议,调度器可能会忽略这个建议。具体行为取决于操作系统和 JVM 的线程调度策略。

1

获取Java线程的执行结果:Callable、Future和Future Task

Runnable中的run()方法的返回值的void

所以在执行完任务后无法返回任何结果

1
2
3
public interface Runnable {
public abstract void run();
}

Callable是有返回值的

Callable 位于 java.util.concurrent 包下,也是一个接口,它定义了一个 call() 方法:

1
2
3
public interface Callable<V> {
V call() throws Exception;
}

一般会配合ExecutorService来配合使用,

ExecutorService是一个接口,位于java.util.concurrent包下,它是Java线程池框架的核心接口,用来异步执行任务,它提供了一些关键方法来进行线程管理。

Ex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 创建一个包含5个线程的线程池
ExecutorService executorService = Executors.newFixedThreadPool(5);

// 创建一个Callable任务
Callable<String> task = new Callable<String>() {
public String call() {
return "Hello from " + Thread.currentThread().getName();
}
};

// 提交任务到ExecutorService执行,并获取Future对象
Future[] futures = new Future[10];
for (int i = 0; i < 10; i++) {
futures[i] = executorService.submit(task);
}

// 通过Future对象获取任务的结果
for (int i = 0; i < 10; i++) {
System.out.println(futures[i].get());
}

// 关闭ExecutorService,不再接受新的任务,等待所有已提交的任务完成
executorService.shutdown();

异步计算结果的 Future 接口

在之前的例子中,使用了一个Future 来获取 Callable任务的执行结果,

Future 位于 java.util.concurrent 包下,它是一个接口:

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}

一共声明了 5 个方法:

  • cancel() 方法用来取消任务,如果取消任务成功则返回 true,如果取消任务失败则返回 false。参数 mayInterruptIfRunning 表示是否允许取消正在执行却没有执行完毕的任务,如果设置 true,则表示可以取消正在执行过程中的任务。如果任务已经完成,则无论 mayInterruptIfRunning 为 true 还是 false,此方法肯定返回 false,即如果取消已经完成的任务会返回 false;如果任务正在执行,若 mayInterruptIfRunning 设置为 true,则返回 true,若 mayInterruptIfRunning 设置为 false,则返回 false;如果任务还没有执行,则无论 mayInterruptIfRunning 为 true 还是 false,肯定返回 true。
  • isCancelled() 方法表示任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true。
  • isDone() 方法表示任务是否已经完成,若任务完成,则返回 true;
  • get()方法用来获取执行结果,这个方法会产生阻塞,会一直等到任务执行完毕才返回;
  • get(long timeout, TimeUnit unit)用来获取执行结果,如果在指定时间内,还没获取到结果,就直接返回 null。

也就是说Future可以:

  1. 判断任务是否完成
  2. 能够中断任务
  3. 能够获取任务执行结果

由于Future 只是一个接口,如果直接 new 的话会有一个警告,提醒我们最好使用 FtureTask。

实际上,FutureTask 是 Future 接口的一个唯一实现类,我们在前面的例子中 executorService.submit() 返回的就是 FutureTask。

异步计算结果 FutureTask 实现类

我们来看一下 FutureTask 的实现:

1
public class FutureTask<V> implements RunnableFuture<V>

FutureTask 类实现了 RunnableFuture 接口,我们看一下 RunnableFuture 接口的实现:

1
2
3
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}

可以看出 RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。

FutureTask 提供了两个构造器:

1
2
3
4
public FutureTask(Callable<V> callable) {
}
public FutureTask(Runnable runnable, V result) {
}

Java线程的六种状态

Thread.State 源码:

1
2
3
4
5
6
7
8
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}

NEW

处于NEW状态的线程尚未启动,即还没有调用Thread实例的start()方法。

两个引申问题:

  1. 反复调用同一个线程的 start 方法是否可行?
    1. 不行,在调用 start 之后,threadStatus 的值会改变(threadStatus !=0),再次调用 start 方法会抛出 IllegalThreadStateException 异常。
  2. 假如一个线程执行完毕(此时处于 TERMINATED 状态),再次调用这个线程的 start 方法是否可?
    1. 不行,threadStatus 为 2 代表当前线程状态为 TERMINATED

RUNNABLE

表示正在运行中的线程,处于RUNNNABLE状态中的线程可能正在执行也可能正在等待CPU分配资源。

Java 线程的RUNNABLE状态其实包括了操作系统线程的ready和running两个状态。

BLOCKED

阻塞状态。处于 BLOCKED 状态的线程正等待锁(锁会在后面细讲)的释放以进入同步区。

WAITING

等待状态。处于等待状态的线程编程RUNNABLE需要其他线程的唤醒。

调用下面这三个方法会使线程进去等待状态:

  • Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;
  • Thread.join():等待线程执行完毕,底层调用的是 Object 的 wait 方法;
  • LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。

TIMED_WAITING

超时等待时间。线程等待一个具体的时间,时间到后会被自动唤醒。

调用如下方法会使线程进入超时等待状态:

  • Thread.sleep(long millis):使当前线程睡眠指定时间;
  • Object.wait(long timeout):线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;
  • Thread.join(long millis):等待当前线程最多执行 millis 毫秒,如果 millis 为 0,则会一直执行;
  • LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
  • LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间

TERMINATED

终止状态。线程已执行完毕

Java的内存模型(JMM)

JMM定义了Java程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题。其主要目的是为了解决由于编译器优化、处理器优化和缓存系统等导致的可见性原子性有序性

内存可见性问题:

是指 多线程环境下,一个线程对共享变量的修改,另一个线程无法立即(或永远无法)看到最新值,导致程序出现不符合预期的行为。

并发编程的线程之间存在两个问题:

  • 线程间如何通信:即线程之间以何种机制来交换信息
  • 线程间如何同步:线程以何种机制来控制不同线程之间发生的相对顺序

有两种模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型
如何通信 如何同步
消息传递并发模型 线程之间没有公共状态,线程间的通信必须通过发送消息来显示进行通信。 发送消息天然同步,因为发送消息总是在接受消息之前,因此同步是隐式的。
共享内存并发模型 线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。 必须显式指定某段代码需要在线程之间互斥执行,同步是显式的。

而java使用的是共享内存模型

观察运行时数据区域,只有方法区和堆是所有线程共享的,内存可见性针对的就是堆中的共享变量。

既然堆是共享的,为什么在堆中会有内存不可见问题?

因为每个线程都有自己的本地****内存,而这些本地内存中的变量值与主内存之间可能不同步。

本地内存

本地内存(Working Memory)是 Java 内存模型(JMM)的一个抽象概念,并不是真实存在的硬件结构。

  • 每个线程都有自己的本地****内存,它是线程的私有空间
  • 本地内存中保存了主内存中的共享变量的副本,以提高访问速度。
  • 本地内存包括:
    • CPU 寄存器:存放临时计算结果或中间值。
    • CPU 缓存(如 L1、L2、L3 缓存):加速主存访问。
    • 编译器优化后的缓存(如 JIT 优化存储区):JIT 编译器在运行时做出的性能优化。

Java 线程之间的通信由 Java 内存模型(简称 JMM)控制,从抽象的角度来说,JMM 定义了线程和主存之间的抽象关系:

2

  1. 所有共享变量都存在主存当中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程A与线程B之间要通信的话:
    1. 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
    2. 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。

所以,线程A无法直接访问线程B的工作内容,线程间通信必须经过主存。

注意:根据JMM的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,任何对共享变量的读、写操作,必须先把值拷贝到线程的本地内存中,然后进行操作。

如何保证内存可见性?

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

Java 内存模型 (JMM) 主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。

Java 中的 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized 关键字不仅保证可见性,同时也保证了原子性(互斥性)。

JMM与重排序

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排

重排序在是线上分为以下三种类型

  • 编译器优化重排序:JIT 编译时调整指令顺序。
  • CPU 指令****级重排序:CPU 执行时调整顺序,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存****系统重排序:多核 CPU 缓存一致性问题。

指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。所以在多线程下,指令重排序可能会导致有序性问题。

JMM与 happens-before

Happens-Before 是 JMM 中的一个核心原则,用来判断两个操作之间的先后顺序(可见性和有序性)。如果一个操作 A Happens-Before 另一个操作 B,那么操作 A 的结果对操作 B 可见。

Happens-Before 的主要规则

  1. 程序次序规则(Program Order Rule):

    1. 在同一个线程中,按照程序的控制流顺序,前面的操作 Happens-Before 后面的操作。
    2. 例如:在同一线程中,语句 int x = 1; int y = 2;x=1 Happens-Before y=2
  2. 监视器锁规则(Monitor Lock Rule):

    1. 解锁(unlock)一个监视器锁 Happens-Before 随后加锁(lock)同一监视器锁。
    2. 例如:一个线程释放锁之后,另一个线程获取该锁,则释放锁的操作对获取锁的线程是可见的。
  3. volatile 变量规则(Volatile Variable Rule):

    1. 对一个 volatile 变量的写操作 Happens-Before 后续对该 volatile 变量的读操作。
    2. 例如:volatile int x = 0; x = 1; int y = x;,写操作 x = 1 Happens-Before 读操作 int y = x;
  4. 传递性(Transitivity):

    1. 如果操作 A Happens-Before 操作 B,且操作 B Happens-Before 操作 C,则操作 A Happens-Before 操作 C
    2. 例如:如果 A -> B,且 B -> C,则 A -> C
  5. 线程启动规则(Thread Start Rule):

    1. 主线程调用 Thread.start() 之后,子线程中的任何操作都 Happens-Before 该 start() 方法返回。
      • Thread t = new Thread(() -> x = 1);
        t.start();
        
        1
        2
        3
        4
        5
        6
        7
           2. `t.start()` Happens-Before 子线程的操作。

        1. **线程终止规则(Thread Join Rule):**
        1. 如果线程 **A** 执行 `Thread.join()` 并成功返回,则线程 **B** 中的所有操作 Happens-Before 线程 **A** 从 `join()` 方法返回。
        - ```Java
        t.join();
        int y = x;
    2. 如果子线程完成,t.join() Happens-Before int y = x;
  6. 对象终结规则(Finalizer Rule):

    1. 一个对象的构造函数执行结束 Happens-Before 该对象的 finalize() 方法。

Happens-Before 的作用

  • 保证可见性: 确保一个操作对其他线程是可见的。
  • 保证有序性: 规定操作的先后顺序,避免重排序导致的数据不一致。
  • 确保线程安全 在并发编程中,通过正确使用 Happens-Before 规则,可以确保程序的正确性。

Java volatile关键字

在 Java 中,volatile 是一种关键字,用于修饰变量,确保该变量在多个线程之间的可见性有序性

当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

也就是说,使用 volatile 关键字修饰共享变量可以禁止重排序。即保证了有序性

当一个线程修改了volatile变量的值,新值会立即被刷新到主内存中,其他线程再读取该变量时可以立即获得最新值。即保证了可见性

volatile 可以实现单例模式的双重锁

在单例模式中,volatile 可以防止指令重排序,从而避免未初始化的对象被使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {
// 1. 使用 volatile 确保可见性和有序性
private static volatile Singleton instance;
// 2. 私有构造方法,防止外部实例化
private Singleton() {}
// 3. 获取单例实例的方法
public static Singleton getInstance() {
// 第一次检查:避免不必要的同步
if (instance == null) {
// 同步代码块,防止并发创建
synchronized (Singleton.class) { // 加锁,确保线程安全
// 第二次检查:防止多个线程同时创建实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

其中,使用volatile关键字是为了防止instance = new Singleton();这一步被指令重排。因为实际上,new Singleton() 这一行代码分为三个步骤:

  1. Singleton对象分配足够的内存空间
  2. 调用Singleton 的构造方法,初始化对象的成员变量
  3. 将内存地址赋值给instance 变量,使其指向新创建的对象

如果不使用 voltile 关键字,JVM 和 CPU 可能会对这三个步骤进行指令重排,将上述的第二第三步骤相反,导致线程读取到部分未初始化的 Singleton 对象。

Java synchronized 关键字

用于解决多线程并发问题,它通过加锁和解锁机制来保证线程之间的同步、互斥和原子性

关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。

同时,synchronized 还可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)

特性

synchronized 关键字具备以下三个主要特性:

  1. 原子性(Atomicity):操作在执行过程中不可中断,即独占执行
  2. 可见性(Visibility):一个线程修改了共享变量,其他线程立即可见
  3. 有序性(Orderliness):由于内存屏障,指令****重排序被禁止。

用法

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

通过在方法声明中加入 synchronized 关键字,可以保证在任意时刻,只有一个线程能执行该方法。

特点:

  • 锁对象: 当前实例对象 (this)。
  • 作用范围: 整个方法。
  • 适用场景: 保证实例方法的线程安全性
  1. 修饰静态方法(锁为当前类对象)

synchronized 修饰静态方法时,锁定的是当前类对象(Class对象)

  1. 修饰代码块

精确控制同步范围,提高性能。

底层原理

synchronized 实现原理依赖于 JVM 的 Monitor(监视器锁)和 对象头(Object Header)当 synchronized 修饰在方法或代码块上时,会对特定的对象或类加锁,从而确保同一时刻只有一个线程能执行加锁的代码安

  • synchronized 修饰方法:会在方法的访问标志中增加一个 ACCSYNCHRONIZED 标志。每当一个线程访问该方法时,JVM会检查方法的访问标志。如果包含 ACCSYNCHRONIZED 标志,线程必须先获得该方法对应的对象的监视器锁(即对象锁),然后才能执行该方法,从而保证方法的同步性。
  • synchronized 修饰代码块:会在代码块的前后插入 monitorenter和 monitorexit 字节码指令。可以把 monitorenter理解为加锁, monitorexit 理解为解锁。

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

在JVM中,Monitor 是基于 C++实现的。每个对象中都内置了一个ObjectMonitor对象。

Monitor:

  1. 保证在同一时刻,只有一个线程能够访问临界区代码
  2. 管理线程之间的同步与协作(如等待和通知)

ObjectMonitor就是Monitor 的具体实现。

Synchronized 锁的到底是什么,偏向锁、轻量级锁、重量级锁

首先,Java 多线程****的锁都是基于对象的,Java中的每一个对象都可以作为一个锁

类锁,也就是Class对象的锁

  • 每个 Java 对象都可以作为锁(也叫监视器锁)。
  • ,通过 Synchronized 实现的都是内置锁 ,内置锁有偏向锁、轻量级锁、重量级锁这些状态,这些锁机制都通过对象头中的Mark Word来体现。

内置锁的四种状态及锁降级

在 JDK 1.6 以前,所有的锁都是“重量级”锁,因为使用的都是操作系统的互斥锁,当一个线程持有锁时,其他企图进入synchronized 块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态和内核态的切换,因此效率低。

为了减少获得锁和释放锁带来的性能消耗,在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World 期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗 CPU。 追求响应时间。同步块执行速度非常快。
重量级锁 线程竞争不使用自旋,不会消耗 CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行时间较长。

来自《Java 并发编程的艺术》

对象的锁放在什么地方

每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。ku

对象头的组成:

  • Mark Word(标记字段): 存储对象的运行时状态信息。
  • Class Pointer(类型指针): 指向类元数据,表示对象是哪个类的实例。
  • 数组长度(仅数组对象拥有): 存储数组长度。

偏向锁

偏向锁用于减少线程反复获取和释放锁的开销,尤其是在同一线程多次获取相同锁的场景中(如单线程环境)。

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以引入了偏向锁。

实现原理:

  1. 加锁:
    1. 当一个线程第一次获取锁时,在对象头的Mark Word中存储线程 ID,表示该对象偏向该线程
    2. 以后该线程再次获取锁时,只需检查对象头的线程 ID 是否与当前线程匹配,无需加锁和解锁操作。(优化性能)
  2. 撤销偏向:
    1. 如果另一个线程尝试获取偏向锁,则需要撤销偏向。此时会暂停当前持有偏向锁的线程,将锁状态升级为轻量级锁

适合场景:

适合单线程场景,如代码块在大部分情况下只被一个线程访问。

性能优势:

  • 消除CAS操作: 只需检查线程 ID,避免了重量级加锁操作。
  • 减少上下文切换: 线程不竞争时,几乎无性能损耗。

轻量级锁

轻量级锁用于减少重量级锁的开销,特别是在短时间内不会发生线程竞争的场景。

实现原理:

  1. 加锁:
    1. 线程尝试获取轻量级锁时,会在当前线程栈中创建一个 Lock Record,并将对象头的Mark Word复制到该 Lock Record 中。
    2. 使用CAS****操作将对象头的 Mark Word 指向 Lock Record,表示加锁成功。
  2. 锁膨胀:
    1. 如果有其他线程竞争该锁,轻量级锁会升级为重量级锁
    2. 轻量级锁的持有线程和竞争线程都会进入阻塞状态,导致上下文切换。

适用场景:

适合短时间内不存在激烈竞争的情况,比如加锁代码执行时间较短

性能优势:

  • 减少阻塞: 在无竞争情况下使用自旋而非挂起,提高性能。
  • 快速解锁: 线程退出同步块时,使用CAS****操作恢复 Mark Word,无需操作操作系统级别的互斥锁。

重量级锁

它是一种低效但稳妥的加锁方式,适用于线程竞争激烈的场景。

特点:

  1. 阻塞与唤醒:
  • 当线程无法获取重量级锁时,进入阻塞状态,操作系统会挂起线程,而不是自旋等待。
  • 当持有锁的线程释放锁时,阻塞线程会被唤醒,导致上下文切换
  1. 基于操作系统的同步机制:
  • 重量级锁使用操作系统互斥量(Mutex)来进行线程同步。
  • 线程切换依赖于操作系统内核态和用户态的切换,成本较高。
  1. Monitor机制:
  • 每个 Java 对象都有一个监视器(Monitor)与之关联,重量级锁基于监视器实现。
  • 当线程获取锁时,Monitor 会将对象头中的 Mark Word替换为指向Monitor对象的指针

实现原理:

加锁过程:

  1. 当线程尝试获取锁时:
    1. 如果锁为空闲状态,则直接持有锁,将 Mark Word 指向 Monitor。
    2. 如果已经被其他线程持有,线程会进入阻塞队列
  2. 阻塞与唤醒:
    1. 持有锁的线程完成同步代码块后,会释放锁,并通过操作系统****信号机制唤醒阻塞线程。
    2. 阻塞的线程会重新竞争获取锁。

解锁过程:

  • 当线程退出同步代码块时:
    • 直接释放 Monitor,唤醒阻塞队列中的第一个线程
    • 解锁后,线程上下文切换。

锁升级和降级

在Java中,锁可以逐步升级,但是很难降级

升级触发条件:

  • 偏向锁 → 轻量级锁:另一个线程尝试获取偏向锁时。
  • 轻量级锁 → 重量级锁: 轻量级锁自旋次数过多或竞争线程过多时。

升级的具体流程:

每一个线程在准备获取共享资源时:

第一步:偏向锁检测

  1. 线程检查Mark Word中是否存放了自己的 Thread ID
    1. 是: 说明当前线程已经获得锁,直接进入临界区,继续执行。
    2. 否: 表示锁处于偏向状态,但其他线程尝试获取锁,需要升级

第二步:偏向锁升级为轻量级锁

  1. CAS 切换操作:
    1. 新线程尝试使用 CAS(Compare-And-Swap)来更新 Mark Word,将其中的线程 ID替换为轻量级锁标志
  2. 撤销偏向锁:
    1. 如果 CAS 成功,则升级成功
    2. 如果 CAS 失败,说明锁已被竞争,进入下一步。
  3. 暂停偏向线程:
    1. 新线程通知之前持有锁的线程暂停,等待其将Mark Word 置为空,即撤销偏向锁状态

第三步:轻量级锁竞争

  1. 存储哈希码
    1. 竞争线程将锁对象的 HashCode复制到自己新建的锁记录空间(Lock Record)中。
  2. CAS 抢占锁:
    1. 线程通过 CAS 操作,将锁对象的 Mark Word更新为自己新建的 Lock Record 地址
    2. 成功: 获得轻量级锁,进入临界区
    3. 失败: 进入下一步自旋。

第四步:自旋等待

  1. 如果在轻量级锁竞争中CAS 失败,则线程进入****自旋
    1. 自旋****等待: 线程反复检查锁是否已被释放。
    2. 自旋****成功: 继续执行,仍为轻量级锁状态
    3. 自旋****失败: 次数超过阈值,进入重量级锁状态。

第五步:升级为重量级锁

  1. 自旋****失败后,锁膨胀:
    1. 自旋线程直接升级为重量级锁(锁膨胀)。
    2. 通过**操作系统的互斥量(Monitor)来管理。
  2. 线程阻塞:
    1. 未获取到锁的线程进入阻塞队列,等待持有锁的线程释放锁并唤醒自己

乐观锁

乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

乐观锁适用于读多写少的环境,避免频繁失败和重试影响性能。

什么是 CAS

CAS 全程是 Compare-And-Swap ,比较并交换

核心思想:

通过比较内存中的值和期望值,如果相同,就更新为新值;如果不同,就不做操作,继续尝试。

CAS 操作一般设计三个值:

  • V:要更新的变量值(Var)
  • E:预期值(Expected),表示线程 A 希望当前值是什么
  • N:拟写入的新值(New)

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

CAS****操作本质: 确保在更新前,值是期望的老值,防止其他线程抢先更新。

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

CAS的原理

CAS 通过操作 Unsafe 类调用底层指令,Unsafe 魔法类 位于sun.misc包中,它里面都是native方法,通过调用底层CPU指令,实现无锁操作。

所以CAS的原子性依赖于CPU级别的指令支持,而非Java本身。

CAS的三大问题

ABA问题

就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

长时间自旋

CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。

解决思路是让 JVM 支持处理器提供的pause 指令。

pause 指令能让自旋失败时 cpu 睡眠一小段时间再继续自旋,从而使得读操作的频率降低很多,为解决内存顺序冲突而导致的 CPU 流水线重排的代价也会小很多。

多个共享变量的原子操作

当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性。

ReentrantLock

ReentrantLock是什么

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

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

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

3

AQS

AQS 全称为 AbstractQueuedSynchronizer,是 Java 中用于构建同步器的一个抽象类,位于 java.util.concurrent.locks 包中。它是构建锁和其他同步组件(如 ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier 等)的核心基础。

它主要通过维护一个State值和一个FIFO等待队列来管理线程竞争:线程竞争失败时,被加入等待队列中;

通过自旋****、挂起、唤醒机制来协调线程的获取和释放。它通过CAS实现了原子性

AQS的原理

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。

CLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。

AQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。

AQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点:

  • 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。
  • 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 next 指针,成为了双向队列

AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

AQS 中的 CLH 变体队列结构如下图所示:

4

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

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

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

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

1
2
//返回同步状态的当前值
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);}

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

ReentrantLock

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

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

5

公平锁和非公平锁

公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

ThreadLocal

ThreadLocal 是一种用于提供线程本地变量的机制,它为每个线程提供独立的变量副本,避免了线程之间的数据共享和冲突。

原理实现

最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是**ThreadLocalMap**的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

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

ThreadLocalMapThreadLocal的静态内部类。

ThreadLocal 内存泄露问题是怎么导致的

**ThreadLocalMap 使用弱引用来引用ThreadLocal对象,而 ThreadLocalMap 中的值仍然是强引用,这导致ThreadLocal被回收时,ThreadLocalMap **中的值没有被清理,从而发生内存泄漏。

解决方法:每次使用 ThreadLocal 后,都应该调用 remove() 方法显式地清除线程中的数据,特别是在线程池等长期存活的线程中。

线程池

线程池主要是为了减少每次获取资源的消耗,提高对资源的利用率

作用

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

如何实现线程池

  1. 通过ThreadPoolExecutor构造函数来创建(推荐)
  2. 通过 Executor 框架的工具类 Executor 来创建。

Executor介绍

Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Threadstart 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。

this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。

Executor 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,Executor 框架让并发编程变得更加简单。

Executor 框架结构主要由三大部分组成:

1、任务(****Runnable /Callable)

执行任务需要实现的 Runnable 接口Callable*接口*Runnable 接口Callable 接口 实现类都可以被 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。

2、任务的执行(Executor)

如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutorScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。

6

3、异步计算的结果(Future)

Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。

当我们把 **Runnable**接口Callable 接口 的实现类提交给 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。

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

FixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。

ThreadPoolExcutor 类介绍

ThreadPoolExecutor 类中有的四个构造方法,以下是最长的,其他的三个构造方法也是在其的基础上产生。

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
/**
* 用给定的初始参数创建一个新的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;
}

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

7

ThreadPoolExecutor 拒绝策略定义:

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

线程池常用的阻塞队列总结

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

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

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

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

  • FixedThreadPoolSingleThreadExecutor:使用的是阻塞队列 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

所以在阿里巴巴的Java开发手册中明确禁止了使用Executors, 而是通过 ThreadPoolExecutor 构造函数的方式。

锁总结

java的锁其实就是可以说是围绕性能和安全性而展开的:

  • 待总结

//todo

面试题补充

Synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁:指的是线程可以再次获取自己的内部锁

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

当一个类中有两个方法都使用了synchronized 关键字,并且方法二调用了方法一,由于synchronized 锁是可重入的,同一个线程在调用方法二时可以直接获得当前对象的锁,执行方法一的时候再次获得这个对象的锁,不会产生死锁问题。

Svnchronized 依赖于 JVM 而 ReentrantLock 依赖于API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,我们看不到

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成

相比synchronizedReentrantLock增加了一些高级功能。主要来说主要有四点:

  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「 interrupt() 」,当前线程就会抛出 InterruptedException 异常,可以捕捉该异常进行相应处理。
  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
  • 支持超时ReentrantLock 提供了 tryLock(timeout) 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。

什么是线程同步?

线程同步是指在多线程环境下,为了避免多个线程对共享资源进行同时访问,从而引发数据不一致或者其他问题的一个机制。它通过对关键代码进行加锁,使得同一时刻只有一个线程能够访问共享资源。

JUC重新理解整理

在这里我将对JUC的知识重新做一个系统性的整理

Java并发理论基础:Java内存模型(JMM)与线程

为什么需要多线程?

从计算机系统“性能失衡”这个核心问题出发,众所周知,CPU、缓存、I/O设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献

1️⃣ CPU 增加缓存 → 导致可见性问题

  • ✔️ 你说得对:CPU 缓存(如 L1/L2/L3)确实是为了加速内存访问,减少延迟
  • ⚠️ 问题产生:当多个线程在多个核心上运行,每个核心使用自己的 CPU 缓存时,对共享变量的修改不会立刻对其他线程可见
    • 这就是并发编程中的可见性问题
  • 💡 Java 提供了 volatile、synchronized、内存屏障等机制来缓解这个问题。

2️⃣ 操作系统****引入进程/线程 → 导致原子性问题

  • ✔️ 进程/线程的引入确实是为了更高效利用 CPU、提高并发性;
  • ⚠️ 问题产生:多个线程并发执行共享资源时,如果没有合适的同步机制,就可能在同一时间访问或修改同一个变量,导致中间状态被破坏
    • 举例:两个线程同时对一个变量做 i++,就可能丢失更新;
  • 💡 这就是原子性问题,需要通过 synchronizedLock、原子类(如 AtomicInteger)等机制保证。

3️⃣ 编译器重排序优化 → 导致有序性问题

  • ✔️ 为了提高执行效率,编译器和 CPU 都可能对指令进行重排序,前提是单线程下语义等价
  • ⚠️ 在多线程环境下,重排序可能会导致“指令****执行顺序和代码书写顺序不一致”,从而出现一些难以察觉的并发 bug
    • 举例:双重检查锁(DCL)中如果不加 volatile,可能会读取到未初始化的对象;
  • 💡 这就是有序性问题,Java 的内存模型(JMM)和 volatile 能提供一定程度的顺序保证。

总结:

系统组件 优化手段 性能优化目标 引发的问题(并发角度)
CPU 缓存机制 提升内存访问效率 可见性问题
OS 线程/进程 利用 CPU 与 I/O 并行 原子性问题
编译器/CPU 指令重排 提高执行效率 有序性问题

JAVA是怎么解决并发问题的: JMM(Java内存模型)

理解的第一个维度:核心知识点

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

  • volatile、synchronized 和 final 三个关键字(具体在之后讲述)

  • Happens-Before 规则:

    • Happens-Before 是 Java 内存模型(JMM)中定义的一个关键规则,用于规定多线程环境下操作之间的执行顺序,从而解决可见性和有序性问题。它包含 8 条基本规则,JVM 在实际运行时会遵循这些规则来保证线程间的正确通信与同步。

    • 规则 说明
      1 程序次序规则 同一个线程中,程序代码按顺序执行(单线程内天然有序)
      2 监视器锁规则 一个 unlock 操作 happens-before 于后续的 lock 同一把锁
      3 volatile 变量规则 对一个 volatile 变量的写操作 happens-before 于后续对它的读
      4 线程启动规则 Thread.start() 先于新线程中的任何操作
      5 线程终止规则 线程中所有操作都 happens-before 于其他线程检测到它终止(如 join() 返回)
      6 线程中断规则 调用 interrupt() 先于被中断线程检测中断
      7 对象构造规则 构造函数中所有操作 happens-before 于对象引用被其他线程访问
      8 传递性规则 如果 A happens-before B,B happens-before C ⇒ A happens-before C

理解的第二个维度:可见性,有序性,原子性

  • 原子性

Java内存模型****只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

  • 可见性

Java提供了volatile关键字来保证可见性

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到****主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronizedLock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 有序性

在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。

线程安全的实现方法

阻塞同步

synchronized 和 ReentrantLock。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

非阻塞同步

  • CAS

CAS 本质上就是先读再比对,读出来的值和预期一样才会更新。是最底层的乐观锁机制,它的具体实现依赖于 JVM 提供的底层工具 —— 比如 UnsafeVarHandle。 而 Java 提供的 AtomicIntegerAtomicReference 等原子类, 底层就是通过调用 Unsafe 中的 CAS 方法来实现的

CAS 操作本质上做的就是这三步:

  1. 读取内存地址 V 的当前值(这一步就是你说的“先查询”);
  2. 将当前值与期望值 A 进行比较;
  3. 如果一致,则将其更新为新值 B
  4. 如果不一致,则不做修改,通常返回失败信号(如 false 或当前值),你需要自行重试。

这整个操作在硬件层面是一个原子操作(不可中断)。

  • AtomicXXX(原子类)

高层封装,底层调用 CAS 操作,线程安全,用户友好

底层就是调用 CAS(通过 Unsafe 或 VarHandle 实现)

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性

  • 栈封闭

多个线程访问同一个方法的****局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

  • 线程本地存储(Thread Local Storage)

ThreadLocal 是 Java 提供的一个线程本地变量工具类,它让每个线程拥有自己的专属变量副本,彼此互不干扰。

底层实现:每个线程(Thread 类)内部维护了一个 ThreadLocalMap,它的 key 是 ThreadLocal 实例,value 是你存进去的变量值。

注意事项:一定要用remove()方法清理数据,线程池中的线程是复用的,如果不清除数据,旧线程可能会残留之前请求的变量,导致脏数据或内存泄露

  • 可重入代码

多线程递归环境下可以安全被多次调用的代码,即:

即使当前方法尚未执行完毕,被另一个线程或同一个线程再次调用,也不会出错。

Java并发-线程基础

线程状态转换

Java线程的六种状态(Thread.State枚举类)

状态 描述
NEW 新建状态,线程对象已创建,但尚未启动。
RUNNABLE 可运行状态,线程已调用 start() 方法,等待 CPU 调度。
BLOCKED 阻塞状态,线程等待获取某个锁(synchronized)。
WAITING 等待状态,无限期等待其他线程的显式唤醒(如 wait())。
TIMED_WAITING 有限等待状态,等待一定时间(如 sleep()、join() 带超时)。
TERMINATED 终止状态,线程执行完毕或抛出异常。

线程使用方式

🧩 方式一:继承 Thread 类

1
2
3
4
5
class MyThread extends Thread {public void run() {
System.out.println("Thread running");
}
}
new MyThread().start(); // ✅ 这里调用了 start()

🧩 方式二:实现 Runnable 接口

1
2
Runnable task = () -> System.out.println("Runnable running");
new Thread(task).start(); // ✅ 还是 start()

🧩 方式三:实现 Callable 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ImplementsCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("3......");
return "zhuZi";
}

public static void main(String[] args) throws Exception {
ImplementsCallable callable = new ImplementsCallable();
FutureTask<String> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
System.out.println(futureTask.get());
}
}

🧩 方式四:使用线程池(ExecutorService)

1
2
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(() -> System.out.println("Thread pool task"));

线程池****内部怎么实现的?

它也是用 new Thread(...).start() 的方式来真正启动线程的,只不过线程被复用了(线程池复用核心线程)。

总结:

  • ①继承Thread类,并重写run()方法;
  • ②实现Runnable接口,并传递给Thread构造器;
  • ③实现Callable接口,创建有返回值的线程;
  • ④使用Executor框架创建线程池。

但是从本质上来说:

**Java中,创建线程的方式就只有一种:调用Thread.start()**方法

因为创建线程的实质是:操作系统层面正在启动一个新的线程来运行代码。无论你用了哪种高级封装,Java 中真正启动线程、执行代码,都离不开 Thread.start() 这一个底层调用。

再回头来看Runnable、Callable,这俩既然不是创建线程的方式,那它们具体是什么?准确来说,这是两种创建”线程体”的方式,包括继承Thread类重写 run() 方法也是。Runnable是一个顶级接口,里面只有一个方法:run(),代表任务逻辑,也就是想让线程去干的事情。

为什么说更加推荐实现 Runnable 接口的方式来实现多线程?

  1. Java单继承,使用Runnbale更加灵活
  2. 线程任务和线程本体分离,更符合设计原则
  3. 类可能只要求可执行就行,继承整个 Thread 类开销过大。

基础线程机制

  • Executor

Executor线程池****的核心接口,用于解耦****任务提交和任务执行的过程。它定义了**”执行任务”**的能力

常见实现类:

类名 说明
ThreadPoolExecutor 核心实现类,线程池本体,控制线程数量、队列等。
ScheduledThreadPoolExecutor 支持定时或周期性任务。
ExecutorService Executor 的子接口,增加了生命周期控制(如 shutdown())。
Executors 工具类,用于快速创建线程池。
  • Daemon

Daemon(守护线程 是一种特殊类型的线程, 是为其他线程(主要是用户线程)提供服务的后台线程。 当 所有用户线程都结束 后,JVM 会自动退出,即使守护线程还在运行。

  • sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。

  • yield(

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

线程中断

线程中断(interrupt)不是强制终止线程,而是将线程的 中断标志位 设置为 true。这个标志只是一个信号,需要线程自己在运行过程中检测,并做出响应。

线程中断本身不会改变线程的运行状态(如 RUNNABLE、BLOCKED 等),而是更改了线程的中断标志位。本质上只是一个布尔标志位true/false)。表示:“这个线程是否被请求中断”

停止一个线程的方法:

  • 异常法停止:线程调用interrupt()方法之后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
  • 在沉睡中停止:先将线程sleep(),然后调用interrput标记中断状态,interrupt****会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果。
  • stop()暴力停止:线程调用stop()方法会被暴力停止,方法已启用,该方法会有不好的结果:强制让线程停止可能使一些请理性的工作得不到完成。
  • 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return ,能达到停止线程的效果。

线程互斥同步

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。

线程之间的协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调

  • join()

join()Thread 类提供的方法,意思是:“等我执行完你再继续”。

它会使调用它的线程暂停执行,直到目标线程完成。

  • Object的wait()/notify()/notifyAll()

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

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。

使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

wait() 和 sleep() 的区别

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会。
  • Condition的await()/signal()/signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。

  • LockSupport 的park()/unpark()

LockSupport 是一种比 synchronizedwait/notify底层、更灵活的线程阻塞/唤醒机制,提供了可以挂起(阻塞)和恢复线程的工具方法,是构建线程同步工具的核心类

注意:它们不需要在同步块中使用,不需要锁。甚至是AQS的基础,AQS 就使用了 LockSupport 来控制线程的阻塞与唤醒。

Demo:实现线程C需要等待线程AB执行完成才能执行

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
public static void main(String[] args){
AtomicInteger flag = new AtomicInteger(2);
Thread c = new Thread(() ->{
System.out.println("线程c已开启,等待A,B完成才继续执行");
LockSupport.park();
System.out.println("线程c继续执行");
});
c.start();

new Thread(() ->{
System.out.println("线程A开始执行");
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程A执行完成");
if (flag.decrementAndGet() == 0){
//唤醒指定线程
LockSupport.unpark(c);
}
}).start();

new Thread(()->{
System.out.println("线程B开始执行");
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程B执行完成");
if (flag.decrementAndGet() == 0){
LockSupport.unpark(c);
}
}).start();
}

TimeUnit 是 Java 中一个时间单位枚举类,位于 java.util.concurrent.TimeUnit,可以让我们以不同时间单位来表达时间延迟,更直观更清晰。

Java中的锁

锁的分类

  • 从乐观和悲观的角度可分为乐观锁和悲观锁
    • 乐观锁
    • 悲观锁
  • 公平锁和非公平锁
    • 公平锁:指多个线程按照申请锁的顺序来获取锁
      • 在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己。
    • 非公平锁:指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后中请的线程比先中请的线程优先获取锁。
      • 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式
      • 非公平锁的优点在于吞吐量比公平锁大。
    • Lock是支持公平锁的,synchronized不支持公平锁。
  • 是否共享资源的角度 可分为共享锁和独占锁
    • 独占锁:指该锁一次只能被一个线程所持有。对 ReentrantLock 和 Synchronized 而言都是独占锁。
    • 共享锁:指该锁可被多个线程所持有。
      • ReentrantReadWriteLock读锁共享锁,其写锁****是独占锁
  • 锁的状态的角度可分为偏向锁、轻量级锁和重量级锁。同时,JVM中还巧妙设计了自旋锁以更快地使用CPU资源
    • 偏向锁 –》轻量级锁 –》自旋锁、自适应自旋、锁消除、锁粗化。
    • 自旋锁 – JVM
      • 是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
      • 自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需等一等(也叫作自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在内核状态的切换上导致的锁时间消耗。线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在线程执行的时间超过自旋等待的最大时间后,线程会退出自旋模式并释放其持有的锁

关键字Synchronized详解

Synchronized的使用

在应用Synchronize时需注意:

  • 一把锁同时只能被一个线程获取,没有获得锁的线程只能等待
  • 每个实例都对应自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized 写实的是static方法时,所有对象共用一把锁
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁