抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

这里是JAVA并发相关的一些八股

1.线程基础

补充1:什么是线程安全

线程安全 的核心是 避免多线程访问共享资源时的数据竞争和内存可见性问题

线程安全(Thread Safety)是指 在多线程环境下,某个函数、类或数据结构能够被多个线程同时访问,并且仍然保持正确的行为,不会出现数据竞争(Race Condition)、脏读(Dirty Read)、死锁(Deadlock)等问题。

为什么需要线程安全?

多线程并发 环境下,如果多个线程同时访问 共享资源(如变量、对象、文件等),可能会出现以下问题:

  • 数据不一致:多个线程同时修改数据,导致最终结果不符合预期。
  • 竞态条件(Race Condition):多个线程竞争同一资源,执行顺序不确定,导致程序行为不可预测。
  • 内存可见性问题:一个线程修改了数据,但其他线程看不到最新值(由于 CPU 缓存、指令重排序等)。

线程安全的典型场景

共享变量被多个线程修改

1
2
3
4
5
6
7
public class UnsafeCounter {
private int count = 0;

public void increment() {
count++; // 非原子操作,可能导致数据错误
}
}
  • 问题count++ 不是原子操作(分为 读取→修改→写入),多个线程同时执行时可能导致 少加

  • 解决方案

    • 使用 synchronized 同步方法:

      1
      2
      3
      public synchronized void increment() {
      count++;
      }
    • 使用 AtomicInteger(基于 CAS):

      1
      2
      3
      4
      private AtomicInteger count = new AtomicInteger(0);
      public void increment() {
      count.incrementAndGet();
      }

集合类在多线程环境下操作

单例模式(Singleton)

多线程环境下,instance 可能被多次初始化。

解决方案:使用 synchronized或者使用 双重检查锁(DCL)

补充2:线程安全的实现方式

常见的线程安全问题

(1) 竞态条件(Race Condition)

多个线程同时修改共享数据,导致结果不可预测。
解决方案synchronizedAtomic 类。

(2) 死锁(Deadlock)

多个线程互相持有对方需要的锁,导致无限等待。
解决方案

  • 避免嵌套锁。
  • 使用 tryLock() 设置超时。

(3) 内存可见性问题

一个线程修改了变量,但其他线程看不到最新值。
解决方案volatilesynchronized

保证线程安全的方式

  1. 使用synchronized关键字 :基于 对象监视器锁(Monitor),可以保证被修饰的方法或代码块在同一时刻只能被一个线程访问
  2. 使用Lock接口及其实现类,如ReentrantLock:可以提供更灵活的锁机制,支持可中断锁,公平锁。
  3. 使用原子类,如AtomicInteger,AtomicLong:底层基于 CAS(Compare-And-Swap) 实现无锁线程安全,适用于 单个变量的原子操作(如计数、标志位)
  4. 使用线程安全的集合,如:ConcurrentHashMap ,CopyOnWriteArrayList ,BlockingQueue线程安全的队列
  5. 使用volatile关键字:保证变量的 可见性(一个线程修改后,其他线程立即可见)
  6. 使用ThreadLocal类ThreadLocal 让每个线程拥有自己的变量副本,避免共享变量竞争。 适用于 线程隔离数据(如用户会话、数据库连接)。

Java并发工具类并不直接保证线程安全,而是协调线程之间的执行顺序和资源访问!!这点之前一直理解错了,腾讯s线回答的时候答了这个就❌了!!

1.线程和进程的区别

  • 进程:进程是程序在操作系统中的执行实例,是系统资源分配的基本单位。每个进程拥有自己的地址空间、数据段、堆栈、程序计数器等,进程间相互独立,通常不能直接共享内存。

    特点

    • 进程之间相互独立,有各自的资源和内存空间。
    • 进程创建、销毁的开销比较大。
    • 进程切换时,操作系统需要保存和恢复上下文,因此开销较大。
  • 线程:线程是进程中的一个执行单元,它是比进程更小的执行单位。一个进程可以包含多个线程, 它们共享进程的内存空间和资源(如堆,全局变量,静态变量,文件等公共资源),但拥有独立的栈和程序计数器

    特点:

    • 线程之间共享进程的内存空间和资源,因此创建和销毁的开销较小。
    • 线程切换的开销比进程小
    • 由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
  • 协程:协程是一种用户态的轻量级线程,由程序员在代码中显式控制调度,而非操作系统。

    特点:

    • 协程切换的开销极小,比线程更轻量,因为不需要操作系统的干预。
    • 协程通常在同一个线程中执行,因此它们共享线程的所有资源。
    • 协程是非抢占式的调度(即协程必须显式让出控制权),避免了线程的切换和同步的复杂性。

适用场景

高隔离性任务——进程

高并发共享数据任务——线程

高并发 I/O 密集型任务——协程

2.并行与并发的区别

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

3.创建线程的方式(四种)

  • 继承 Thread 类

    1. 创建一个类继承Thread类,重写run方法
    2. 创建该类的示例
    3. 调用实例的star()方法来启动线程
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public static class myThread_1 extends Thread{
    @Override
    public void run() {
    System.out.println("第一种方式创建了一个线程....");
    }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    //第一种方式
    myThread_1 thread_1 = new myThread_1();
    thread_1.start();
    }
  • 实现 Runnable 接口

    1. 创建一个类实现Runnable接口,并重写run()方法
    2. 创建该类的实例,然后用这个实例创建Thread实例
    3. 调用Thread实例的start()方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public static class myRunable implements Runnable{
    @Override
    public void run() {
    System.out.println("第二种方式创建了一个线程....");
    }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    //第二种方式
    myRunable myRunable = new myRunable();
    Thread thread_2 = new Thread(myRunable);
    thread_2.start();
    }

线程类只是实现了Runable接口,还可以继承其他的类。

  • 实现 Callable 接口(需借助FutureTask)

    1. 创建实现Callable接口的类myCallable,重写call()方法
    2. 以myCallable为参数创建FutureTask对象
    3. 将FutureTask实例作为参数创建Thread对象
    4. 调用线程对象的start()方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public static class myCallable implements Callable<String>{
    @Override
    public String call() throws Exception {
    return "第三种方式创建了一个线程....";
    }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    //第三种方式
    myCallable task = new myCallable();
    FutureTask<String> futureTask = new FutureTask<>(task);
    Thread thread_3 = new Thread(futureTask);
    thread_3.start();
    System.out.println(futureTask.get()); //这个相当于得到重写cll的结果,不能省略
    }

和Runnable不同的是,Callable的call()方法可以有返回值并且可以抛出异常。

  • 使用 Executors 工具类创建线程池

    Executors提供了一系列工厂方法用于创先线程池,返回的线程池都实现了ExecutorService接口。

    主要有newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,后续详细介绍这四种线程池

    这里代码了解即可。前三种大概要会写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public static class Task implements Runnable{
    @Override
    public void run() {
    System.out.println("这是一个任务:");
    }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
    //第四种方式
    ExecutorService executor = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 20; i++) {
    executor.submit(new Task()); // 提交任务到线程池执行
    }
    executor.shutdown();
    }

runnable 和 callable 的区别

  1. Runnable 接口run方法没有返回值,Callable接口call方法有返回值,需要FutureTask获取结果
  2. Callable接口的call()方法允许抛出异常; 而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛

4.run()和 start()的区别

  1. start():用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。
  2. start方法只能被调用一次。run():封装了要被线程执行的代码,可以被调用多次。

5.可以直接调用Thread的run方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

6.线程包括哪些状态,状态之间是如何变化的

状态:新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、时间等待(TIMED_WALTING)、终止(TERMINATED)

变化:

  1. 创建线程对象是新建状态
  2. 调用了start()方法转变为可执行状态线
  3. 程获取到了CPU的执行权,执行结束是终止状态
  4. 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态
    1. 如果没有获取锁(synchronized或lock)进入阻塞状态,获得锁再切换为可执行状态
    2. 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可切换为可执行状态
    3. 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态

7.sleep和wait的区别

两者都可以暂停线程的执行

不同点在于:

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

如何停止一个正在运行的线程(了解)

  1. 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  2. 使用stop方法强行终止(不推荐,方法已作废)
  3. 使用interrupt方法中断线程
    • 打断阻塞的线程(sleep,wait,join)的线程,线程会抛出InteruptedException异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

8.线程之间的通信方式

线程之间的通信由于共享同一个进程的内存空间,可以通过共享数据或同步机制实现。以下是主要的通信方式:

  1. 共享变量:多个线程通过共享变量直接读写数据。比如说 volatile 和 synchronized 关键字。
  2. **使用 wait() 和 notify()**,例如,生产者-消费者模式中,生产者生产数据,消费者消费数据,通过 wait() 和 notify() 方法可以实现生产和消费的协调。
  3. CountDownLatchCyclicBarrier :这两个工具类用于在多个线程之间进行同步,尤其适用于等待多个线程完成任务后再继续执行。
  4. Semaphore(信号量) :Semaphore 是一个计数信号量,用于控制对共享资源的访问。它通过维护一个信号量计数来决定是否允许线程访问资源。

9.并发编程三要素

  1. 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。

  2. 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)

  3. 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)

    出现线程安全问题的原因:

    • 线程切换带来的原子性问题
    • 缓存导致的可见性问题
    • 编译优化带来的有序性问题

    解决办法:

    • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
    • synchronized、volatile、LOCK,可以解决可见性问题
    • Happens-Before 规则可以解决有序性问题

10.JMM——Java内存模型

是什么

Java 内存模型(JMM)是一个抽象模型,它定义了Java虚拟机(JVM)中线程与内存之间的交互规则。 主要用来定义多线程中变量的内存访问规则,可以解决变量的可见性、有序性和原子性问题,确保在并发环境中安全地访问共享变量。

  • 主内存:所有线程共享的内存区域,存放实例变量、静态变量和数组等数据。
  • 工作内存:每个线程拥有自己的私有工作内存,里面保存了线程所需变量的主内存副本。线程对变量的所有操作(如读取、赋值)都必须在工作内存中进行,不能直接操作主内存。

JMM

线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了共享变量的副本,用来进行线程内部的读写操作。

  • 当一个线程更改了本地内存中共享变量的副本后,它需要将这些更改刷新到主内存中,以确保其他线程可以看到这些更改。
  • 当一个线程需要读取共享变量时,它可能首先从本地内存中读取。如果本地内存中的副本是过时的,线程将从主内存中重新加载共享变量的最新值到本地内存中。

本地内存是 JMM 中的一个抽象概念,并不真实存在。

happens-before

指令重排也是有一些限制的,有两个规则happens-beforeas-if-serial来约束。

happens-before 的定义:如果A happens-before B,那么A的结果对B可见。同一线程内,按代码顺序,前面的操作happens-before后面的操作

  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法

as-if-serial

as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的

happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。

11.虚拟线程

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

Java线程和操作系统的线程是一对一的关系,Java线程和虚拟线程是一对多的关系

虚拟线程适用于 I/O 密集型任务

优点:

  • 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
  • 减少资源开销: 由于虚拟线程是由 JVM 实现的,它能够更高效地利用底层资源,例如 CPU 和内存。虚拟线程的上下文切换比平台线程更轻量,因此能够更好地支持高并发场景。

12.join()方法

使用示例:
线程.jion():作用,等待调用的这个线程结束才可以继续往下执行


2.锁

1.voliatle

volatile 关键字用于修饰变量 ,主要是保证多线程可见性和有序性

  1. 保证线程间的可见性

    确保一个线程的修改能对其他线程是可见的。如何实现的呢?

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

  2. 禁止指令重排序

    如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
    以下供理解:

    简单说就是JVM为了对代码进行优化提高性能会在不影响结果的情况下把代码执行顺序改变,但多线程就可能会出现结果不对的问题
    然后volatile原理就是加了一些屏障,使屏障后的代码一定不会比屏障前的代码先执行,从而实现有序性

2.乐观锁和悲观锁

  1. 悲观锁

    • 悲观锁:认为自己在使用数据的时候一定有别的线程来修改数据,在获取数据的时候会先加锁,确保数据不会被别的线程修改。
    • 锁实现:关键字synchronized、接口Lock的实现类
    • 适用场景:写操作较多,先加锁可以保证写操作时数据正确
  2. 乐观锁

    • 乐观锁:认为自己使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据

    • 锁实现1:CAS(比较并交换,Compare-and-Swap)算法,是一种无锁的原子操作。

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

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

    • 锁实现 2:版本号控制:数据表中加上版本号字段 version,表示数据被修改的次数。当数据被修改时这个字段值会加1,当更新数据时,同时比较版本号,若当前版本号和更新前获取的版本号一致, 则更新成功,否则失败。

    • 适用场景:读操作较多,不加锁的特点能够使其读操作的性能大幅提升

CAS存在的缺点(问题)

  1. ABA问题

    ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。

  2. 循环时间开销大

    自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。

  3. 只能保证一个共享变量的原子性

    只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。

3.synchronized

在 Java 中,关键字 synchronized(翻译过来就是“同步”的意思)用于实现线程同步,保证多个线程对共享资源的操作是互斥的。

synchronized 可用来修饰普通方法、静态方法和代码块

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到普通方法上是给对象实例上锁。

1.synchronized关键字的底层原理

这里要先了解对象的内存布局

synchronize的原理是基于对象内部的一个监视器锁(monitor)来实现的,每个对象都有一个monitor,当一个线程进入synchronize修饰的方法或代码块时,就会获取该对象的monitor,其他线程就无法进入该方法或代码块,直到持有monitor的线程退出并释放monitor。

  • 使用synchronized之后,编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题,
  • 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时则锁释放,处于等待队列中的线程再继续竞争锁。
  • synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态这种转换非常消耗性能。

2.synchronized可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

1
2
3
4
5
6
7
8
9
synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。

当一个线程获取对象锁时,JVM 会将该线程的 ID 写入 Mark Word,并将锁计数器设为 1

如果一个线程尝试再次获取已经持有的锁,JVM 会检查 Mark Word 中的线程 ID。如果 ID 匹配,表示的是同一个线程,锁计数器递增。

当线程退出同步块时,锁计数器递减。如果计数器值为零,JVM 将锁标记为未持有状态,并清除线程 ID 信息。

底层是通过 Monitor 对象的 owner 和 count 字段实现的,owner 记录持有锁的线程,count 记录线程获取锁的次数。

3.synchronized 锁升级的原理是什么?

无锁->偏向锁->轻量级锁->重量级锁

偏向锁:旨在消除那些 “绝大多数情况下只有一个线程会访问锁” 的场景中的同步开销。

第一次加锁时,JVM 会在对象头(Mark Word)中记录当前线程 ID,并把对象标记为“偏向该线程”,后续该线程再进入同步块时,不做任何原子操作,直接通过检查 Mark Word 中的线程 ID 即可重入。

其他线程尝试获取该锁,偏向锁需要撤销(revoke)并升级到轻量级锁或直接到重量级锁:

当有多个线程竞争锁,但没有锁竞争的强烈迹象时(即线程交替执行同步块),偏向锁会升级为轻量级锁。

轻量级锁:每次都需要CAS操作

加锁:

  • 复制 Mark Word:线程 A 为对象分配一个 Lock Record,并把对象头的 Mark Word 复制到该记录。
  • CAS 更新对象头:CAS 将对象头改为指向 Lock Record 的指针,并把状态位设置为 “轻量级锁”。
  • 进入临界区:A 继续执行同步块内部代码。

解锁:

  • 恢复 Mark Word:线程 A 将对象头恢复成最初复制的那份 Mark Word(通过 CAS 或直接写回)。
  • 释放 Lock Record:锁记录空间可以复用或丢弃。

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高性能比较低。
轻量级锁 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

synchronized怎么保证可见性、有序性?

可见性——JMM。

  • 加锁时,线程必须从主内存读取最新数据。
  • 释放锁时,线程必须将修改的数据刷回主内存,这样其他线程获取锁后,就能看到最新的数据。

有序性:monitorenter 和 monitorexit,来确保加锁代码块内的指令不会被重排。

4.什么是自旋

很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在 synchronized 的边界做忙循环,这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。

自旋锁有多种实现方式,例如CAS

5.synchronized 和 volatile 的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在! 二者的应用场景也不同

  1. volatile 是变量修饰符;synchronized 修饰修饰类、方法。
  2. volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的原子性
  3. volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。

6.ReentrantLock

ReentrantLock是一个类,其底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。 但提供了比 synchronized 更细粒度的锁控制,支持公平锁、可中断锁、尝试获取锁等功能。 适用场景: 当需要更复杂的锁控制、当线程需要中断时、当锁的获取需要超时限制时,

1.公平锁和非公平锁

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

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

2.可中断锁和不可中断锁

可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。

不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

7.synchronized 和 ReentrantLock 的区别

  • 两者都是可重入锁
    • 可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
  • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    • synchronized 是JM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。
  • ReentrantLock比sychronized 增加了一些高级功能
    • ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
    • synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。

3.ThreadLocal

是什么

ThreadLocal是线程本地变量,在多线程并发执行过程中,为保证多个线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立值,线程之间互不影响,相互隔离,提高了线程安全性

底层原理

ThreadLocal底层使用的是当前线程的 ThreadLocalMap 来存储数据,它是一个哈希表,每个线程都有一个相关联的ThreadLocalMap。ThreadLocalMap的key是ThreadLocal实例的弱引用,对应的value是需要存储的值

  • key:ThreadLocal
  • value:需要存储的值

存在的问题——内存泄露问题

使用线程池时,线程池中的线程会被重复使用。ThreadLocalMap是Thread中的一个属性,因此,ThreadLocalMap的生命周期与Thread一致。map中Entry对象的key被设置成弱引用,会被垃圾回收器回收,但是value不会被回收( value 是强引用 ),从而造成内存泄漏。解决办法就是使用完之后手动调用remove()释放内存空间。

有关强引用,弱引用参看JVM篇


4.线程池

作用:管理和复用线程,提高程序的性能和资源利用率,控制线程数量,避免系统过载

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

线程复用原理:线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程循环检查是否还有任务等待被执行,如果有则直接去执行这个任务,也就是调用任务的 run 方法,相当于把每个任务的 run() 方法串联了起来,所以线程数量并不增加。

1.线程池的核心参数(线程池的执行原理)

  1. corePoolSize:线程池核心线程数量
  2. maximumPoolSize:线程池中最多可容纳的线程数量。
  3. keepAliveTime :当前线程池数量超过 corePoolSize 时,多余的空闲线程的存活时间
  4. unit :keepAliveTime的单位
  5. workQueue:线程池所使用的缓冲队列,被提交但尚未被执行的任务
  6. threadFactory:线程工厂,用于创建线程
  7. handler:拒绝策略,线程池任务队列超过 maxinumPoolSize 之后的拒绝策略

流程

img

2.线程池的拒绝策略

预置的有四种策略

  1. AbortPolicy(默认方式,直接抛出一个任务被线程池拒绝的异常。

  2. CallerRunsPolicy:由线程池的调用者所在的线程去执行被拒绝的任务

  3. DiscardPolicy(不做任何处理,静默拒绝提交的任务。

  4. DiscardOldestPolicy(丢弃最早被添加到队列的任务,然后尝试重新提交新任务)

    如果希望快速失败并将异常传递给调用者,则选择AbortPolicy。如果希望尽可能保证任务的执行而不堆积在队列中,则选择CallerRunsPolicy。如果对任务的丢失情况不敏感,则选择 DiscardPolicy。而如果希望尽可能保留最新的任务而不是旧日的任务,则选择DiscardOldestPolicy。

3.线程池如何关闭

可以通过调用线程池的shutdownshutdownNow方法来关闭线程池。

  • shutdown停止接收外部提交的任务,内部正在跑的任务和队列里等待的任务,会执行完,执行完也就关闭了
  • shutdownNow也是先停止接收外部提交的任务,但是这里不同的是会忽略队列里等待的任务,然后尝试去关闭正在执行的任务

4.线程池的大小如何设定

N是CPU核心数,设置corePoolSize的大小为:

CPU密集型任务:N+1。我的目标是尽量减少线程上下文切换,以优化 CPU 使用率。 比 CPU 核心数多出来的一个线程是为了某些线程因等待系统资源而阻塞 ,一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

I/O密集型任务:2N。由于线程经常处于等待状态(等待 IO 操作完成),可以设置更多的线程来提高并发性(比如说 2 倍),从而增加 CPU 利用率。

5.为什么不推荐使用Executors创建线程池

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

Executors 各个方法的弊端:

(加粗的也是创建线程池的四种方式,分别是创建固定大小的线程池,创建一个单线程的线程池,创建可缓存的线程池,创建一个无限大小的线程池)

  • newFixedThreadPoolnewSingleThreadExecutor:

    程池的任务队列使用的是无界队列 LinkedBlockingQueue,可以无限地接受任务。这可能会导致 内存溢出(OOM) 的情况,特别是在任务提交过多且任务处理速度跟不上时。

  • newCachedThreadPoolnewScheduledThreadPool:
    主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

ThreaPoolExecutor创建线程池方式只有一种,就是走它的构造函数,参数自己指定

四种线程池的执行原理:

  1. FixedThreadPool :固定大小的线程池

    如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务;

    当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue

    线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;

  2. SingleThreadExecutor: 是只有一个线程的线程池

    如果当前运行的线程数少于1,则创建一个新的线程执行任务;

    当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue

    线程执行完当前的任务后,会在循环中反复从LinkedBlockingQueue 中获取任务来执行

  3. CachedThreadPool:根据需要创建新线程的线程池

    提交任务时,如果线程池没有空闲线程,直接新建线程执行任务;如果有,复用线程执行任务。线程空闲 60 秒后销毁,减少资源占用。缺点是线程数没有上限,在高并发情况下可能导致 OOM。

    CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE, 即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时, CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

  4. ScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

    核心线程数固定,但任务队列为无界队列(DelayedWorkQueue)。

6.任务队列的种类

无界队列(如 LinkedBlockingQueue 未指定容量、PriorityBlockingQueue):适合任务量波动大、不限制积压的场景,但需注意内存使用。

有界队列(如 ArrayBlockingQueue、LinkedBlockingQueue 指定容量):适合需要控制任务积压的场景。

零容量队列(如 SynchronousQueue):适合任务立即处理、不允许积压的场景。这是一个没有容量的队列。任务不会真正存储在队列中,而是直接交给工作线程处理。如果没有空闲线程,任务会被拒绝或根据拒绝策略处理。

优先级队列(如 PriorityBlockingQueue):适合需要优先级调度的场景。

延迟队列(如 DelayedWorkQueue):适合定时或延迟任务。


5.并发工具类

⚠️Java 并发工具类(如 SemaphoreCountDownLatchCyclicBarrier)主要用于 协调多线程之间的执行顺序和资源访问,而不是直接保证线程安全(如 synchronized 或 Atomic 那样)。它们的作用是 控制线程的并发行为,解决特定场景下的同步问题。

1.AQS

AQS,全称是 Abstract Queued Synchronizer,中文意思是抽象队列同步器。AQS 就是一个抽象类,主要用来构建锁和同步器。

AQS 的思想是,如果被请求的共享资源空闲,则当前线程能够成功获取资源;否则,它将进入一个等待队列,当有其他线程释放资源时,系统会挑选等待队列中的一个线程,赋予其资源。

原理

  • 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的
  • AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程在AQS内部还有一个 int 属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源
  • 在对state修改的时候使用的CAS操作,保证多个线程修改的情况下原子性

AQS 支持两种同步方式:

  • 独占模式:这种方式下,每次只能有一个线程持有锁,例如 ReentrantLock。
  • 共享模式:这种方式下,多个线程可以同时获取锁,例如 Semaphore 和 CountDownLatch。

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

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

2.semaphore

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来限制同时访问某个资源的线程数量

Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流,也就是流量控制

semaphore的原理

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。

调用semaphore.acquire() ,线程尝试获取许可证,如果 state >= 0 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 state 的值 state=state-1。如果 state<0 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。

调用semaphore.release(); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒同步队列中的一个线程。 被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state>=0 则获取令牌成功,否则重新进入阻塞队列,挂起线程。

3.CountDownLatch

作用:让一个或多个线程等待其他线程完成操作后再继续执行。

CountDownLatch (倒计时器)用于协调多个线程之间的同步。 它允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

CountDownLatch 的核心方法也不多:

  • CountDownLatch(int count):创建一个带有给定计数器的 CountDownLatch。
  • void await():阻塞当前线程,直到计数器为零。
  • void countDown():递减计数器的值,如果计数器值变为零,则释放所有等待的线程。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

CountDownLatch的原理是什么

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。 当线程使用 countDown() 方法时,其实使用了tryReleaseShared方法以 CAS 的操作来减少 state,直至 state 为 0 。 当调用 await() 方法的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await() 方法就会一直阻塞,也就是说 await() 方法之后的语句不会被执行。 直到count 个线程调用了countDown()使 state 值被减为 0,或者调用await()的线程被中断,该线程才会从阻塞中被唤醒,await() 方法之后的语句得到执行。

用过CountDownLatch吗?什么场景下用的

测试分布式ID生成速度的时候用过

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
//测试类
@SpringBootTest
public class RedisIdWorkerTest {
@Resource
private RedisIdWorker redisIdWorker;

private ExecutorService es = Executors.newFixedThreadPool(500);

/**
* 测试分布式ID生成器的性能,以及可用性
*/
@Test
public void testNextId() throws InterruptedException {
// 使用CountDownLatch让线程同步等待
CountDownLatch latch = new CountDownLatch(300);
// 创建线程任务
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
// 等待次数-1
latch.countDown();
};
long begin = System.currentTimeMillis();
// 创建300个线程,每个线程创建100个id,总计生成3w个id
for (int i = 0; i < 300; i++) {
es.submit(task);
}
// 线程阻塞,直到计数器归0时才全部唤醒所有线程
latch.await();
long end = System.currentTimeMillis();
System.out.println("生成3w个id共耗时" + (end - begin) + "ms");
}
}

4.CyclicBarrier

作用:让一组线程互相等待,到达屏障点后同时继续执行(可重复使用)。

同步屏障。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。

CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。 当所有线程都到达这个屏障点后,它们可以继续执行后续操作, 并且这个屏障可以被重置循环使用。

它和 CountDownLatch 类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作

评论