<img src="https://cdn.xiaolincoding.com//picgo/b43570a4-mewcode-warm-landscape-poster.png" alt="" loading="lazy" decoding="async">


大家好,我是小宇宙。

Java 并发编程是面试里的重头戏,尤其是中高级岗位,这块几乎是必考的。很多人平时工作里用的多线程场景不多,或者用了但没深究原理,到了面试就容易被问住。这块知识点比较分散,从底层的内存模型到上层的线程池配置,跨度很大,但只要理清楚主线,其实是很有逻辑的。

这篇文章整理了 Java 并发面试中最常被问到的知识点,涵盖多线程基础、并发安全机制、锁原理、线程池,以及一些常见的并发场景题。内容偏向原理理解,不是让你背 API,而是帮你搞清楚"这东西为什么这么设计"。

有几块是面试里的高频区域,建议重点投入:

  • synchronized 和 ReentrantLock:两者的区别、锁升级的过程(偏向锁 → 轻量级锁 → 重量级锁)、以及 AQS 是什么、怎么实现的,这条线是并发面试的核心主线。
  • volatile 和 CAS:volatile 解决了什么问题、为什么不能保证原子性、CAS 的原理和 ABA 问题,这些问题入门好答,但深挖起来很多人答不全。
  • ThreadLocal:用途、底层 ThreadLocalMap 的实现、为什么会内存泄漏、怎么避免,这个在面试里出现频率越来越高。
  • 线程池:核心参数的含义、任务提交后的执行流程、几种拒绝策略的区别、以及常见的线程池类型,这块是工程实践和面试的双重重点。
  • 死锁:死锁的四个必要条件、怎么排查、怎么避免,这是个老题,但每次还是会考。

如果你是第一次系统准备这块,建议先搞清楚 JMM 和 synchronized 的原理,再去看 AQS 和线程池,这样整体脉络会更清晰,后面的内容理解起来也会快很多。


# 多线程

# Java 的内存模型(JMM)介绍一下

JMM 是专门解决多线程并发问题的一套规则。简单说,就是规定了多线程环境下,线程怎么访问共享变量才能不出错,核心是处理可见性、原子性、有序性这三个问题。

先想个场景:两个线程同时操作一个变量,比如线程 A 改了变量的值,线程 B 能不能立刻看到?

如果线程 B 看到的还是旧值,这就是可见性问题。为啥会这样?因为现在 CPU 都有缓存,线程操作变量时,会先把主内存里的变量读到自己的工作内存(比如 CPU 缓存)里,改完可能没及时写回主内存,另一个线程读的还是主内存的旧值。JMM 里像 volatile 关键字就专门解决这个,用了 volatile 的变量,改完会立刻刷回主内存,同时让其他线程的缓存失效,必须重新从主内存读,这样就保证了可见性。

再说说原子性,比如 i++,看着简单,其实是 “读 i、加 1、写回 i” 三步。如果两个线程同时做,可能线程 A 刚读完 i=10,线程 B 就把 i 改成 11,线程 A 再加 1 写回去还是 11,结果就错了。JMM 里用 synchronized 或者 Lock 锁就能保证原子性,加了锁之后,同一时间只有一个线程能执行这三步,中间不会被打断。

还有有序性,就是代码执行顺序可能和你写的不一样。编译器或者 CPU 为了提速,会在不影响单线程结果的情况下调整指令顺序。比如你先初始化对象 A,再把 A 的引用给变量 a,可能被重排成先给引用再初始化。单线程没问题,但多线程下,另一个线程拿到 a 的引用时,A 可能还没初始化好,一调用就报错。这时候 volatile 或者 synchronized 就能通过内存屏障阻止这种重排序,保证顺序正确。

所以 JMM 的核心思路是:定义主内存(大家共享的内存)和工作内存(每个线程自己的缓存),规定变量必须从主内存加载到工作内存才能操作,改完再写回主内存。

然后通过 volatile、synchronized 这些关键字,控制加载、写回的时机,以及禁止不合理的指令重排,最终保证多线程操作共享变量时能正确交互。理解这些,就能明白为啥写并发代码时要用这些关键字,以及它们背后的原理了。

# java多线程是什么?需要注意什么?

Java 多线程是指在一个 Java 程序中同时运行多个线程,这些线程共享程序的内存空间(如全局变量、方法区等),但有各自的栈和程序计数器,能同时执行不同的任务,比如一个线程处理用户输入,另一个线程后台下载文件,提升程序效率。

使用 Java 多线程需要注意以下几点:

  • 首先是线程安全问题。多个线程同时操作共享数据时,可能出现错误。比如两个线程同时给一个变量加 1,原本该加 2,结果可能只加了 1,这是因为线程切换时没做好数据保护。需要用synchronized关键字、Lock锁等方式,保证同一时间只有一个线程操作共享数据。
  • 其次是线程间通信。线程需要协作时,比如一个线程生产数据,另一个线程消费数据,要通过wait()notify()等方法控制,避免出现一方没准备好,另一方就操作的情况,否则可能导致数据错误或线程无限等待。
  • 然后是线程的创建和销毁成本。频繁创建和销毁线程会消耗系统资源,影响性能。可以用线程池管理线程,提前创建好一定数量的线程,重复使用,减少资源消耗。

# java里面的线程和操作系统的线程一样吗?

要分两种情况回答:平台线程(Platform Thread)虚拟线程(Virtual Thread)

  • 平台线程:这是 Java 长期以来默认的线程实现。JVM 在 Linux 上通过 pthread_create 创建线程,一个 Java 平台线程对应一个操作系统线程,是严格的 1:1 线程模型,线程的调度、上下文切换都由操作系统内核负责,开销较大,一个线程默认栈空间约 1MB,所以不能无限创建。
  • 虚拟线程:Java 21 正式发布(JEP 444),是 JVM 在用户态自行调度的轻量级线程,采用 M:N 模型——大量虚拟线程会被映射到少量载体平台线程(Carrier Thread)上执行。虚拟线程由 JVM 的 ForkJoinPool 调度,创建成本极低(几百字节起步,可动态扩缩),单 JVM 可以轻松跑上百万个虚拟线程,特别适合 IO 密集型的高并发场景。

所以"Java 线程等于操作系统线程"这种说法只适用于 JDK 21 之前或只使用平台线程的场景;用了虚拟线程后,Java 的并发模型已经很接近 Go 协程的 M:N 调度了。

image-20240725230425385

# 使用多线程要注意哪些问题?

要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。

Java的线程安全在三个方面体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。

# 保证数据的一致性有哪些方案呢?

  • 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。
  • 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
  • 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。

# 线程的创建方式有哪些?

1.继承Thread类

这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    MyThread t = new MyThread();
    t.start();
}

采用继承Thread类方式

  • 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程
  • 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类

2.实现Runnable接口

如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    Thread t = new Thread(new MyRunnable());
    t.start();
}

采用实现Runnable接口方式:

  • 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  • 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

3. 实现Callable接口与FutureTask

java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        // 线程执行的代码, 这里返回一个整型结果
        return 1;
    }
}

public static void main(String[] args) {
    MyCallable task = new MyCallable();
    FutureTask<Integer> futureTask = new FutureTask<>(task);
    Thread t = new Thread(futureTask);
    t.start();

    try {
        Integer result = futureTask.get();  // 获取线程执行结果
        System.out.println("Result: " + result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}

采用实现Callable接口方式:

  • 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。
  • 优点:线程类只实现 Callable 接口,仍然可以继承其他类;且 call() 方法可以有返回值,也可以抛出异常,配合 FutureTask 能方便地获取异步执行结果。

4. 使用线程池(Executor框架)

从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。

class Task implements Runnable {
    @Override
    public void run() {
        // 线程执行的代码
    }
}

public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(10);  // 创建固定大小的线程池
    for (int i = 0; i < 100; i++) {
        executor.submit(new Task());  // 提交任务到线程池执行
    }
    executor.shutdown();  // 关闭线程池
}

采用线程池方式:

  • 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
  • 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。

# 怎么启动线程 ?

启动线程的通过Thread类的start()

//创建两个线程, 用start启动线程
MyThread myThread1 = new MyThread();  
MyThread myThread2 = new MyThread();  
myThread1.start();  
myThread2.start();

# 如何停止一个线程的运行?

主要有这些方法:

  • 异常法停止:线程调用 interrupt() 方法后,在线程的 run 方法中通过 Thread.currentThread().isInterrupted() 判断当前线程的中断状态(注意不要用静态方法 Thread.interrupted(),它会清除中断标志),如果是中断状态则抛出异常,达到中断线程的效果。
  • 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果
  • stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
  • 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。

# 调用 interrupt 是如何让线程抛出异常的?

每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()方法中断时,会根据实际情况做出响应。

  • 如果该线程正在执行低级别的可中断方法(如Thread.sleep()Thread.join()Object.wait()),则会解除阻塞并抛出InterruptedException异常
  • 否则Thread.interrupt()仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。

# Java线程的状态有哪些?

img

源自《Java并发编程艺术》 java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法获取当前线程的状态

| 线程状态 | 解释 |

| --- | --- |

| NEW | 尚未启动的线程状态,即线程创建,还未调用start方法 |

| RUNNABLE | 就绪状态(调用start,等待调度)+正在运行 |

| BLOCKED | 等待监视器锁时,陷入阻塞状态 |

| WAITING | 等待状态的线程正在等待另一线程执行特定的操作(如notify) |

| TIMED\_WAITING | 具有指定等待时间的等待状态 |

| TERMINATED | 线程完成执行,终止状态 |

# sleep 和 wait的区别是什么?

对比例表:

| 特性 | sleep() | wait() |

| --- | --- | --- |

| 所属类 | Thread 类(静态方法) | Object 类(实例方法) |

| 锁释放 | ❌ | ✅ |

| 使用前提 | 任意位置调用 | 必须在同步块内(持有锁) |

| 唤醒机制 | 超时自动恢复 | 需 notify()/notifyAll() 或超时 |

| 设计用途 | 暂停线程执行,不涉及锁协作 | 线程间协调,释放锁让其他线程工作 |

  • 所属分类的不同:sleep 是 Thread 类的静态方法,可以在任何地方直接通过 Thread.sleep() 调用,无需依赖对象实例。wait 是 Object 类的实例方法,这意味着必须通过对象实例来调用。
  • 锁释放的情况Thread.sleep() 在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在 sleep 期间,其他线程无法获得该线程持有的锁。Object.wait():调用该方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的 notify()notifyAll() 方法唤醒它
  • 使用条件:sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出 IllegalMonitorStateException
  • 唤醒机制:sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的 notify()notifyAll() 方法才能被唤醒。notify() 会唤醒一个在该对象上等待的线程(具体策略由 JVM 实现决定,HotSpot 实际按 FIFO 顺序唤醒),而 notifyAll() 会唤醒所有在该对象上等待的线程。

# sleep会释放cpu吗?

是的,调用 Thread.sleep() 时,线程会释放 CPU,但不会释放持有的锁。

当线程调用 sleep() 后,会主动让出 CPU 时间片,进入 TIMED_WAITING 状态。此时操作系统会触发调度,将 CPU 分配给其他处于就绪状态的线程。这样其他线程(无论是需要同一锁的线程还是不相关线程)便有机会执行。

sleep() 不会释放线程已持有的任何锁(如 synchronized 同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。

# blocked和waiting有啥区别

区别如下:

  • 触发条件:线程进入BLOCKED状态通常是因为试图获取一个对象的锁(monitor lock),但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING状态是因为它正在等待另一个线程执行某些操作,例如调用Object.wait()方法、Thread.join()方法或LockSupport.park()方法。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争。

img

  • 唤醒机制:当一个线程被阻塞等待锁时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从BLOCKED状态变为RUNNABLE状态。线程在WAITING状态中需要被显式唤醒。例如,如果线程调用了Object.wait(),那么它必须等待另一个线程调用同一对象上的Object.notify()或Object.notifyAll()方法才能被唤醒。

所以,BLOCKED和WAITING两个状态最大的区别有两个:

  • BLOCKED 是锁竞争失败后被动触发的状态,WAITING 是人为主动触发的状态
  • BLOCKED 的唤醒是自动触发的,而 WAITING 状态必须要通过特定的方法来主动唤醒

# wait 状态下的线程如何恢复到 RUNNABLE 状态?

线程从 等待(WAITING) 状态恢复到 RUNNABLE 状态的核心机制是 通过外部事件触发或资源可用性变化,比如等待的线程被其他线程对象唤醒notify()notifyAll()。(注意:Java Thread.State 枚举中没有 RUNNING 状态,只有 RUNNABLE,包含 OS 层面的 ready 和 running 两个子状态。)

synchronized (lock) {
    // 线程进入等待状态, 释放锁
    lock.wait(); 
}

// 其他线程调用以下代码唤醒等待线程
synchronized (lock) {
    lock.notify();      // 唤醒单个线程
    // lock.notifyAll(); // 唤醒所有等待线程
}

# notify 和 notifyAll 的区别?

同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。

区别在于:

  • notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
  • notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁

# notify 选择哪个线程?

notify在源码的注释中说到notify选择唤醒的线程是任意的,但是依赖于具体实现的jvm。

image-20240725230457096

JVM 有很多实现,比较流行的就是 HotSpot,HotSpot 对 notify() 的实现并不是我们以为的随机唤醒,而是"先进先出"的顺序唤醒。

# 不同的线程之间如何通信?

共享变量是最基本的线程间通信方式。多个线程可以访问和修改同一个共享变量,从而实现信息的传递。为了保证线程安全,通常需要使用 synchronized 关键字或 volatile 关键字。

class SharedVariableExample {
    // 使用 volatile 关键字保证变量的可见性
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        // 生产者线程
        Thread producer = new Thread(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改共享变量
            flag = true;
            System.out.println("Producer: Flag is set to true.");
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            while (!flag) {
                // 等待共享变量被修改
            }
            System.out.println("Consumer: Flag is now true.");
        });

        producer.start();
        consumer.start();
    }
}

代码解释

  • volatile 关键字确保了 flag 变量在多个线程之间的可见性,即一个线程修改了 flag 的值,其他线程能立即看到。
  • 生产者线程在睡眠 2 秒后将 flag 设置为 true,消费者线程在 flagfalse 时一直等待,直到 flag 变为 true 才继续执行。

Object 类中的 wait()notify()notifyAll() 方法可以用于线程间的协作。wait() 方法使当前线程进入等待状态,notify() 方法唤醒在此对象监视器上等待的单个线程,notifyAll() 方法唤醒在此对象监视器上等待的所有线程。

class WaitNotifyExample {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        // 生产者线程
        Thread producer = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("Producer: Producing...");
                    Thread.sleep(2000);
                    System.out.println("Producer: Production finished. Notifying consumer.");
                    // 唤醒等待的线程
                    lock.notify();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("Consumer: Waiting for production to finish.");
                    // 进入等待状态
                    lock.wait();
                    System.out.println("Consumer: Production finished. Consuming...");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        consumer.start();
        producer.start();
    }
}

代码解释:

  • lock 是一个用于同步的对象,生产者和消费者线程都需要获取该对象的锁才能执行相应的操作。
  • 消费者线程调用 lock.wait() 方法进入等待状态,释放锁;生产者线程执行完生产任务后调用 lock.notify() 方法唤醒等待的消费者线程。

java.util.concurrent.locks 包中的 LockCondition 接口提供了比 synchronized 更灵活的线程间通信方式。Condition 接口的 await() 方法类似于 wait() 方法,signal() 方法类似于 notify() 方法,signalAll() 方法类似于 notifyAll() 方法。

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class LockConditionExample {
    private static final Lock lock = new ReentrantLock();
    private static final Condition condition = lock.newCondition();

    public static void main(String[] args) {
        // 生产者线程
        Thread producer = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Producer: Producing...");
                Thread.sleep(2000);
                System.out.println("Producer: Production finished. Notifying consumer.");
                // 唤醒等待的线程
                condition.signal();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Consumer: Waiting for production to finish.");
                // 进入等待状态
                condition.await();
                System.out.println("Consumer: Production finished. Consuming...");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        consumer.start();
        producer.start();
    }
}

代码解释:

  • ReentrantLockLock 接口的一个实现类,condition 是通过 lock.newCondition() 方法创建的。
  • 消费者线程调用 condition.await() 方法进入等待状态,生产者线程执行完生产任务后调用 condition.signal() 方法唤醒等待的消费者线程。

java.util.concurrent 包中的 BlockingQueue 接口提供了线程安全的队列操作,当队列满时,插入元素的线程会被阻塞;当队列为空时,获取元素的线程会被阻塞。

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class BlockingQueueExample {
    private static final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(1);

    public static void main(String[] args) {
        // 生产者线程
        Thread producer = new Thread(() -> {
            try {
                System.out.println("Producer: Producing...");
                queue.put(1);
                System.out.println("Producer: Production finished.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            try {
                System.out.println("Consumer: Waiting for production to finish.");
                int item = queue.take();
                System.out.println("Consumer: Consumed item: " + item);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        consumer.start();
        producer.start();
    }
}

代码解释:

  • LinkedBlockingQueueBlockingQueue 接口的一个实现类,容量为 1。
  • 生产者线程调用 queue.put(1) 方法将元素插入队列,如果队列已满,线程会被阻塞;消费者线程调用 queue.take() 方法从队列中取出元素,如果队列为空,线程会被阻塞。

# 线程间通信方式有哪些?

1、Object 类的 wait()、notify() 和 notifyAll() 方法。这是 Java 中最基础的线程间通信方式,基于对象的监视器(锁)机制。

  • wait():使当前线程进入等待状态,直到其他线程调用该对象的 notify()notifyAll() 方法。
  • notify():唤醒在此对象监视器上等待的单个线程。
  • notifyAll():唤醒在此对象监视器上等待的所有线程。
class SharedObject {
    public synchronized void consumerMethod() throws InterruptedException {
        while (/* 条件不满足 */) {
            wait();
        }
        // 执行相应操作
    }

    public synchronized void producerMethod() {
        // 执行相应操作
        notify(); // 或者 notifyAll()
    }
}

2、LockCondition 接口。Lock 接口提供了比 synchronized 更灵活的锁机制,Condition 接口则配合 Lock 实现线程间的等待 / 通知机制。

  • await():使当前线程进入等待状态,直到被其他线程唤醒。
  • signal():唤醒一个等待在该 Condition 上的线程。
  • signalAll():唤醒所有等待在该 Condition 上的线程。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class SharedResource {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void consumer() throws InterruptedException {
        lock.lock();
        try {
            while (/* 条件不满足 */) {
                condition.await();
            }
            // 执行相应操作
        } finally {
            lock.unlock();
        }
    }

    public void producer() {
        lock.lock();
        try {
            // 执行相应操作
            condition.signal(); // 或者 signalAll()
        } finally {
            lock.unlock();
        }
    }
}

3、volatile 关键字。volatile 关键字用于保证变量的可见性,即当一个变量被声明为 volatile 时,它会保证对该变量的写操作会立即刷新到主内存中,而读操作会从主内存中读取最新的值。

class VolatileExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // 等待
        }
        // 执行相应操作
    }
}

4、CountDownLatch。CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。

  • CountDownLatch(int count):构造函数,指定需要等待的线程数量。
  • countDown():减少计数器的值。
  • await():使当前线程等待,直到计数器的值为 0。
import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    // 执行任务
                    System.out.println(Thread.currentThread().getName() + " 完成任务");
                } finally {
                    latch.countDown();
                }
            }).start();
        }

        latch.await();
        System.out.println("所有线程任务完成");
    }
}

5、CyclicBarrier。CyclicBarrier 是一个同步辅助类,它允许一组线程相互等待,直到所有线程都到达某个公共屏障点。

  • CyclicBarrier(int parties, Runnable barrierAction):构造函数,指定参与的线程数量和所有线程到达屏障点后要执行的操作。
  • await():使当前线程等待,直到所有线程都到达屏障点。
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int threadCount = 3;
        CyclicBarrier barrier = new CyclicBarrier(threadCount, () -> {
            System.out.println("所有线程都到达屏障点");
        });

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    // 执行任务
                    System.out.println(Thread.currentThread().getName() + " 到达屏障点");
                    barrier.await();
                    // 继续执行后续任务
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

6、Semaphore。Semaphore 是一个计数信号量,它可以控制同时访问特定资源的线程数量。

  • Semaphore(int permits):构造函数,指定信号量的初始许可数量。
  • acquire():获取一个许可,如果没有可用许可则阻塞。
  • release():释放一个许可。
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        int permitCount = 2;
        Semaphore semaphore = new Semaphore(permitCount);

        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + " 获得许可");
                    // 执行任务
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName() + " 释放许可");
                }
            }).start();
        }
    }
}

# 如何停止一个线程?

在 Java 中,停止线程的正确方式是 通过协作式的逻辑控制线程终止,而非强制暴力终止(如已废弃的 Thread.stop())。以下是实现安全停止线程的多种方法:

第一种方式:通过共享标志位主动终止。定义一个 可见的 状态变量,由主线程控制其值,工作线程循环检测该变量以决定是否退出。

public class SafeStopWithFlag implements Runnable {
    // 使用 volatile 保证可见性
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            try {
                // 处理任务逻辑
                System.out.println("Thread is running...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 捕获中断异常后设置 running=false
                running = false;
                Thread.currentThread().interrupt(); // 重新设置中断标志
            }
        }
        System.out.println("Thread terminated safely.");
    }

    // 停止线程的方法(由外部调用)
    public void stop() {
        running = false;
    }
}

调用方式:

SafeStopWithFlag task = new SafeStopWithFlag();
Thread thread = new Thread(task);
thread.start();
// 某个时刻调用停止
Thread.sleep(3000);
task.stop();

第二种方式使用线程中断机制。通过 Thread.interrupt() 触发线程中断状态,结合中断检测逻辑实现安全停止。

public class InterruptExample implements Runnable {
    @Override
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            try {
                System.out.println("Working...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 当阻塞时被中断, 抛出异常并清除中断状态
                System.out.println("Interrupted during sleep!");
                Thread.currentThread().interrupt(); // 重新设置中断标志
            }
        }
        System.out.println("Thread terminated by interrupt.");
    }
}

调用方式:

Thread thread = new Thread(new InterruptExample());
thread.start();
// 中断线程
Thread.sleep(3000);
thread.interrupt();
  • interrupt() 不会立刻终止线程,只是设置中断标志位。
  • 线程需手动检查中断状态(isInterrupted())或触发可中断操作(如sleep()wait()join())响应中断。
  • 阻塞操作中收到中断请求时,会抛出 InterruptedException 并清除中断状态。

第三种方式通过 Future 取消任务。使用线程池提交任务,并通过 Future.cancel() 停止线程,依赖中断机制。

public class FutureCancelDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<?> future = executor.submit(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("Task running...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("Task interrupted.");
                    Thread.currentThread().interrupt();
                }
            }
        });

        try {
            Thread.sleep(3000);
            future.cancel(true); // true表示尝试中断任务线程
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            executor.shutdown();
        }
    }
}

第四种方式处理不可中断的阻塞操作。某些 I/O 或同步操作(如 Socket.accept()Lock.lock())无法通过中断直接响应。此时需结合资源关闭操作。比如,关闭 Socket 释放阻塞。

public class SocketHandler implements Runnable {
    private ServerSocket serverSocket;

    public SocketHandler(ServerSocket serverSocket) {
        this.serverSocket = serverSocket;
    }

    @Override
    public void run() {
        try {
            // serverSocket.accept()阻塞时无法响应中断
            while (!Thread.currentThread().isInterrupted()) {
                Socket socket = serverSocket.accept();
                // 处理连接...
            }
        } catch (IOException e) {
            if (Thread.currentThread().isInterrupted()) {
                System.out.println("Thread stopped by interrupt.");
            }
        }
    }

    // 特殊关闭方法(销毁资源)
    public void stop() {
        try {
            serverSocket.close(); // 关闭资源使accept()抛出异常
        } catch (IOException e) {
            System.out.println("Error closing socket: " + e);
        }
    }
}

调用方式:调用 stop() 方法关闭资源以解除阻塞。

线程停止的正确实践,如下表格:

| 方法 | 适用场景 | 注意事项 |

| --- | --- | --- |

| 循环检测标志位 | 简单无阻塞的逻辑 | 确保标志位使用 volatile 或通过锁保证可见性 |

| 中断机制 | 可中断的阻塞操作 | 正确处理 InterruptedException 并恢复中断标志 |

| Future.cancel() | 线程池管理任务 | 需要线程池任务支持中断处理机制 |

| 资源关闭 | 不可中断的阻塞操作(如Sockets) | 显式关闭资源触发异常,结合中断状态判断回滚 |

避免使用以下已废弃方法:

  • Thread.stop():暴力终止,可能导致状态不一致。
  • Thread.suspend()/resume():易导致死锁。

# Go 的协程和 Java 的线程有啥区别?

Go的协程和Java的线程确实有很大区别,我从几个方面来说一下。

首先最核心的区别是它们的调度模型不一样。Java 的\\平台线程(Platform Thread)\\是操作系统级别的线程,一个 Java 平台线程对应一个操作系统线程,创建、销毁、调度都由操作系统内核管理。不过从 Java 21 开始,JEP 444 正式引入了虚拟线程(Virtual Thread),这是 JVM 层面的 M:N 轻量级线程,和 Go 协程的思路已经比较接近了。下面的对比主要针对传统平台线程,虚拟线程的部分会在最后补充说明。

img

而Go的协程,也就是goroutine,它是用户态的轻量级线程,是由Go运行时自己调度的,不需要经过操作系统内核。这就导致了很多后续的差异。

img

从资源消耗上来说,差别非常明显。Java创建一个线程的开销是比较大的,默认情况下一个线程的栈空间大概是1MB左右,而且线程的创建和销毁都需要系统调用,开销不小。所以在Java里你不能无限制地创建线程,创建几千个线程系统可能就扛不住了,这也是为什么Java里要用线程池来复用线程。

但Go的协程就轻量多了,一个goroutine初始栈空间只有2KB,而且栈空间是可以动态伸缩的。Go里创建几万甚至几十万个协程都是很正常的事情,我之前做过一个项目,同时跑了十几万个goroutine,系统压力也不大。

调度方式上也不太一样。Java线程的调度是抢占式的,由操作系统的调度器决定什么时候切换线程,线程切换需要保存和恢复上下文,涉及到用户态和内核态的切换,这个开销相对比较大。

Go的协程调度是在用户态完成的,Go运行时有个GMP模型,G就是goroutine,M是操作系统线程,P是逻辑处理器。Go会把多个协程映射到少量的操作系统线程上执行,协程的切换完全在用户态,不需要陷入内核,所以切换成本非常低。而且Go的调度器会在协程发生阻塞的时候,比如IO操作或者channel操作时,自动把这个协程挂起,让其他协程继续执行,这种协作式调度非常高效。

从使用方式上来讲,Go的协程用起来要简单太多了。在Go里创建一个协程只需要在函数调用前加个go关键字就行了,比如go doSomething(),就这么简单,如下代码:

package main

import (
    "fmt"
    "time"
)

func doSomething(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("%s: %d\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    // 创建协程就这么简单, 加个go关键字
    go doSomething("协程1")
    go doSomething("协程2")
    go doSomething("协程3")
    
    // 等待协程执行完
    time.Sleep(time.Second)
    fmt.Println("主程序结束")
}

而在Java里创建线程,你要么new一个Thread对象,要么实现Runnable接口,或者用线程池,总之代码量要多一些。Go这种简洁的语法让并发编程的心智负担降低了很多。

public class RunnableExample {
    public static void main(String[] args) {
        // 实现Runnable接口
        Runnable task = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 3; i++) {
                    System.out.println(Thread.currentThread().getName() + ": " + i);
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        
        // 创建Thread对象并传入Runnable
        Thread thread1 = new Thread(task, "线程1");
        Thread thread2 = new Thread(task, "线程2");
        Thread thread3 = new Thread(task, "线程3");
        
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

通信机制也有很大差异。Java线程之间通信主要靠共享内存,需要用synchronized、Lock这些同步机制来保证线程安全,还要小心处理死锁、竞态条件这些问题,写起来比较容易出错。Go虽然也支持共享内存和锁,但它更提倡用channel来通信,有句话叫"不要通过共享内存来通信,而要通过通信来共享内存"。用channel的话,代码会更清晰,也更不容易出现并发问题。

性能上来说,因为Go协程的轻量级特性和高效的调度器,在高并发场景下,Go的表现通常会更好。比如说要处理几万个并发请求,Java可能需要用复杂的异步框架或者线程池来优化,而Go直接为每个请求开一个协程就行了,代码简单性能还好。当然这不是说Java不行,Java在企业级应用、生态完善性这些方面还是有很大优势的,只是在并发模型的设计上,Go确实更现代化一些。

从我实际使用的感受来说,如果是写IO密集型的高并发服务,比如API网关、聊天服务器这种,Go的协程模型开发效率高,性能也好。但如果是传统的企业级应用,需要用到大量的中间件和框架,Java的生态会更成熟一些。两者各有优势,选择哪个还是要看具体场景。

最后再补充一下 Java 21 的虚拟线程。JEP 444 正式发布的虚拟线程,让 Java 终于也有了类似协程的 M:N 调度模型——虚拟线程由 JVM 的 ForkJoinPool 在用户态调度,大量虚拟线程会被映射到少量载体平台线程上执行,创建成本极低(几百字节起步,栈可动态扩缩),单 JVM 可以轻松跑上百万个虚拟线程。创建方式也很简洁:

// 方式一:直接启动虚拟线程
Thread.startVirtualThread(() -> System.out.println("Hello from virtual thread"));

// 方式二:通过 Executors 创建每任务一个虚拟线程的线程池
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> { /* 任务 */ });
}

当虚拟线程遇到 IO 阻塞(如 Socket、文件、数据库调用)时,JVM 会自动把它从载体线程上"摘下",让载体线程去跑其他虚拟线程,完全不会阻塞操作系统线程——这正是 Go 协程高吞吐的核心原理。所以在 Java 21+ 下,Java 和 Go 在高并发 IO 场景下的差距已经大幅缩小。

# 并发安全

# juc包下你常用的类?

线程池相关:

  • ThreadPoolExecutor:最核心的线程池类,用于创建和管理线程池。通过它可以灵活地配置线程池的参数,如核心线程数、最大线程数、任务队列等,以满足不同的并发处理需求。
  • Executors:线程池工厂类,提供了一系列静态方法来创建不同类型的线程池,如newFixedThreadPool(创建固定线程数的线程池)、newCachedThreadPool(创建可缓存线程池)、newSingleThreadExecutor(创建单线程线程池)等,方便开发者快速创建线程池。

并发集合类:

  • ConcurrentHashMap:线程安全的哈希映射表,用于在多线程环境下高效地存储和访问键值对。JDK 1.7 采用分段锁(Segment)实现;JDK 1.8 起已废弃分段锁,改为基于 CAS + synchronized 锁桶头节点的方式,锁粒度更细,并发度更高,在高并发场景下比传统的 Hashtable 性能更好。
  • CopyOnWriteArrayList:线程安全的列表,在对列表进行修改操作时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然可以在旧数组上进行,从而实现了读写分离,提高了并发读的性能,适用于读多写少的场景。

同步工具类:

  • CountDownLatch:允许一个或多个线程等待其他一组线程完成操作后再继续执行。它通过一个计数器来实现,计数器初始化为线程的数量,每个线程完成任务后调用countDown方法将计数器减一,当计数器为零时,等待的线程可以继续执行。常用于多个线程完成各自任务后,再进行汇总或下一步操作的场景。
  • CyclicBarrier:让一组线程互相等待,直到所有线程都到达某个屏障点后,再一起继续执行。与CountDownLatch不同的是,CyclicBarrier可以重复使用,当所有线程都通过屏障后,计数器会重置,可以再次用于下一轮的等待。适用于多个线程需要协同工作,在某个阶段完成后再一起进入下一个阶段的场景。
  • Semaphore:信号量,用于控制同时访问某个资源的线程数量。它维护了一个许可计数器,线程在访问资源前需要获取许可,如果有可用许可,则获取成功并将许可计数器减一,否则线程需要等待,直到有其他线程释放许可。常用于控制对有限资源的访问,如数据库连接池、线程池中的线程数量等。

原子类:

  • AtomicInteger:原子整数类,提供了对整数类型的原子操作,如自增、自减、比较并交换等。通过硬件级别的原子指令来保证操作的原子性和线程安全性,避免了使用锁带来的性能开销,在多线程环境下对整数进行计数、状态标记等操作非常方便。
  • AtomicReference:原子引用类,用于对对象引用进行原子操作。可以保证在多线程环境下,对对象的更新操作是原子性的,即要么全部成功,要么全部失败,不会出现数据不一致的情况。常用于实现无锁数据结构或需要对对象进行原子更新的场景。

# 怎么保证多线程安全?

  • synchronized关键字:可以使用synchronized关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过synchronized关键字锁定对象的监视器(monitor)来实现的。
public synchronized void someMethod() { /* ... */ }

public void anotherMethod() {
    synchronized (someObject) {
        /* ... */
    }
}
  • volatile关键字:volatile关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。
public volatile int sharedVariable;
  • Lock接口和ReentrantLock类:java.util.concurrent.locks.Lock接口提供了比synchronized更强大的锁定机制,ReentrantLock是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。
private final ReentrantLock lock = new ReentrantLock();

public void someMethod() {
    lock.lock();
    try {
        /* ... */
    } finally {
        lock.unlock();
    }
}
  • 原子类:Java并发库(java.util.concurrent.atomic)提供了原子类,如AtomicIntegerAtomicLong等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。

示例:

AtomicInteger counter = new AtomicInteger(0);

int newValue = counter.incrementAndGet();
  • 线程局部变量:ThreadLocal类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。
ThreadLocal<Integer> threadLocalVar = new ThreadLocal<>();

threadLocalVar.set(10);
int value = threadLocalVar.get();
  • 并发集合:使用java.util.concurrent包中的线程安全集合,如ConcurrentHashMapConcurrentLinkedQueue等,这些集合内部已经实现了线程安全的逻辑。
  • JUC工具类: 使用java.util.concurrent包中的一些工具类可以用于控制线程间的同步和协作。例如:SemaphoreCyclicBarrier等。

# Java中有哪些常用的锁,在什么场景下使用?

Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:

  • 内置锁(synchronized):Java中的synchronized关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
  • ReentrantLockjava.util.concurrent.locks.ReentrantLock是一个显式的锁类,提供了比synchronized更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock使用lock()unlock()方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。
  • 读写锁(ReadWriteLock)java.util.concurrent.locks.ReadWriteLock接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。
  • 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。synchronizedReentrantLock都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。
  • 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。

# 怎么在实践中用锁的?

Java提供了多种锁的实现,包括synchronized关键字、java.util.concurrent.locks包下的Lock接口及其具体实现如ReentrantLockReadWriteLock等。下面我们来看看这些锁的使用方式。

1. synchronized

synchronized关键字可以用于方法或代码块,它是Java中最早的锁实现,使用起来非常简单。

示例:synchronized方法

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

示例:synchronized代码块

public class Counter {
    private Object lock = new Object();
    private int count = 0;

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
}

2. 使用Lock接口

Lock接口提供了比synchronized更灵活的锁操作,包括尝试锁、可中断锁、定时锁等。ReentrantLockLock接口的一个实现。

示例:使用ReentrantLock

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private Lock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

3. 使用ReadWriteLock

ReadWriteLock接口提供了一种读写锁的实现,允许多个读操作同时进行,但写操作是独占的。

示例:使用ReadWriteLock

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();
    private Object data;

    public Object readData() {
        readLock.lock();
        try {
            return data;
        } finally {
            readLock.unlock();
        }
    }

    public void writeData(Object newData) {
        writeLock.lock();
        try {
            data = newData;
        } finally {
            writeLock.unlock();
        }
    }
}

# Java 并发工具你知道哪些?

Java 中一些常用的并发工具,它们位于 java.util.concurrent 包中,常见的有:

  • CountDownLatch:CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它使用一个计数器进行初始化,调用 countDown() 方法会使计数器减一,当计数器的值减为 0 时,等待的线程会被唤醒。可以把它想象成一个倒计时器,当倒计时结束(计数器为 0)时,等待的事件就会发生。示例代码:
import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) throws InterruptedException {
        int numberOfThreads = 3;
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        // 创建并启动三个工作线程
        for (int i = 0; i < numberOfThreads; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 正在工作");
                try {
                    Thread.sleep(1000);  // 模拟工作时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();  // 完成工作, 计数器减一
                System.out.println(Thread.currentThread().getName() + " 完成工作");
            }).start();
        }

        System.out.println("主线程等待工作线程完成");
        latch.await();  // 主线程等待, 直到计数器为 0
        System.out.println("所有工作线程已完成, 主线程继续执行");
    }
}
  • CyclicBarrier:CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。当所有线程都到达这个屏障点后,它们可以继续执行后续操作,并且这个屏障可以被重置循环使用。与 CountDownLatch 不同,CyclicBarrier 侧重于线程间的相互等待,而不是等待某些操作完成。示例代码:
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int numberOfThreads = 3;
        CyclicBarrier barrier = new CyclicBarrier(numberOfThreads, () -> {
            System.out.println("所有线程都到达了屏障, 继续执行后续操作");
        });

        for (int i = 0; i < numberOfThreads; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " 正在运行");
                    Thread.sleep(1000);  // 模拟运行时间
                    barrier.await();  // 等待其他线程
                    System.out.println(Thread.currentThread().getName() + " 已经通过屏障");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}
  • Semaphore:Semaphore 是一个计数信号量,用于控制同时访问某个共享资源的线程数量。通过 acquire() 方法获取许可,使用 release() 方法释放许可。如果没有许可可用,线程将被阻塞,直到有许可被释放。可以用来限制对某些资源(如数据库连接池、文件操作等)的并发访问量。代码如下:
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(2);  // 允许 2 个线程同时访问

        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();  // 获取许可
                    System.out.println(Thread.currentThread().getName() + " 获得了许可");
                    Thread.sleep(2000);  // 模拟资源使用
                    System.out.println(Thread.currentThread().getName() + " 释放了许可");
                    semaphore.release();  // 释放许可
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}
  • Future 和 Callable:Callable 是一个类似于 Runnable 的接口,但它可以返回结果,并且可以抛出异常。Future 用于表示一个异步计算的结果,可以通过它来获取 Callable 任务的执行结果或取消任务。代码如下:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FutureCallableExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executorService = Executors.newSingleThreadExecutor();

        Callable<Integer> callable = () -> {
            System.out.println(Thread.currentThread().getName() + " 开始执行 Callable 任务");
            Thread.sleep(2000);  // 模拟耗时操作
            return 42;  // 返回结果
        };

        Future<Integer> future = executorService.submit(callable);
        System.out.println("主线程继续执行其他任务");

        try {
            Integer result = future.get();  // 等待 Callable 任务完成并获取结果
            System.out.println("Callable 任务的结果: " + result);
        } catch (Exception e) {
            e.printStackTrace();
        }

        executorService.shutdown();
    }
}
  • ConcurrentHashMap:ConcurrentHashMap 是一个线程安全的哈希表,它允许多个线程同时进行读操作,在一定程度上支持并发的修改操作,避免了 HashMap 在多线程环境下需要使用 synchronizedCollections.synchronizedMap() 进行同步的性能问题。代码如下:
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);

        // 并发读操作
        map.forEach((key, value) -> System.out.println(key + ": " + value));

        // 并发写操作
        map.computeIfAbsent("key3", k -> 3);
    }
}

# CountDownLatch 是做什么的讲一讲?

CountDownLatch 是 Java 并发包(java.util.concurrent)中的一个同步工具类,用于让一个或多个线程等待其他线程完成操作后再继续执行

其核心是通过一个计数器(Counter)实现线程间的协调,常用于多线程任务的分阶段控制或主线程等待多个子线程就绪的场景,核心原理:

  • 初始化计数器:创建 CountDownLatch 时指定一个初始计数值(如 N)。
  • 等待线程阻塞:调用 await() 的线程会被阻塞,直到计数器变为 0。
  • 任务完成通知:其他线程完成任务后调用 countDown(),使计数器减 1。
  • 唤醒等待线程:当计数器减到 0 时,所有等待的线程会被唤醒。

主线程等待所有子线程就绪后启动,代码例子如下:

// 主线程启动多个子线程执行任务, 等待全部完成后统计结果
public class MainThreadWaitExample {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " 执行任务");
                    Thread.sleep(1000);
                    latch.countDown(); // 任务完成, 计数器-1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, "Worker-" + i).start();
        }

        latch.await(); // 主线程等待所有子线程完成任务
        System.out.println("所有任务已完成");
    }
}

# synchronized和reentrantlock及其应用场景?

synchronized 工作原理

synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁

使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

实际上大部分时候我认为说到monitorenter就行了,但是为了更清楚的描述,还是再具体一点。

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  1. 当多个线程进入同步代码块时,首先进入entryList
  2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

reentrantlock工作原理

ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。

ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:

  • 可中断性: ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与 LockSupport.park() 和 LockSupport.unpark() 相关的机制来实现可中断性。
  • 设置超时时间: ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。
  • 公平锁和非公平锁: 在直接创建 ReentrantLock 对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置,例如:
ReentrantLock fairLock = new ReentrantLock(true);
  • 多个条件变量: ReentrantLock 支持多个条件变量,每个条件变量可以与一个 ReentrantLock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和 notify()。多个条件变量的实现依赖于 Condition 接口,例如:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 使用下面方法进行等待和唤醒
condition.await();
condition.signal();
  • 可重入性: ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过 AQS 的 state 字段记录可重入次数实现的。当一个线程多次获取锁时,state 递增,释放锁时递减,只有当 state 减为 0 时,其他线程才有机会获取锁。

应用场景的区别

synchronized

  • 简单同步需求: 当你需要对代码块或方法进行简单的同步控制时,synchronized是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。
  • 代码块同步: 如果你想对特定代码段进行同步,而不是整个方法,可以使用synchronized代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。
  • 内置锁的使用synchronized关键字使用对象的内置锁(也称为监视器锁),这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。

ReentrantLock:

  • 高级锁功能需求ReentrantLock提供了synchronized所不具备的高级功能,如公平锁、响应中断、定时锁尝试、以及多个条件变量。当你需要这些功能时,ReentrantLock是更好的选择。
  • 性能优化: 在高度竞争的环境中,ReentrantLock可以提供比synchronized更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。
  • 复杂同步结构: 当你需要更复杂的同步结构,如需要多个条件变量来协调线程之间的通信时,ReentrantLock及其配套的Condition对象可以提供更灵活的解决方案。

综上,synchronized适用于简单同步需求和不需要额外锁功能的场景,而ReentrantLock适用于需要更高级锁功能、性能优化或复杂同步逻辑的情况。选择哪种同步机制取决于具体的应用需求和性能考虑。

# 除了用synchronized,还有什么方法可以实现线程同步?

  • 使用ReentrantLockReentrantLock是一个可重入的互斥锁,相比synchronized提供了更灵活的锁定和解锁操作。它还支持公平锁和非公平锁,以及可以响应中断的锁获取操作。
  • 使用volatile关键字:虽然volatile不是一种锁机制,但它可以确保变量的可见性。当一个变量被声明为volatile后,线程将直接从主内存中读取该变量的值,这样就能保证线程间变量的可见性。但它不具备原子性。
  • 使用Atomic:Java提供了一系列的原子类,例如AtomicIntegerAtomicLongAtomicReference等,用于实现对单个变量的原子操作,这些类在实现细节上利用了CAS(Compare-And-Swap)算法,可以用来实现无锁的线程安全。

# synchronized锁静态方法和普通方法区别?

锁的对象不同:

  • 普通方法:锁的是当前对象实例(this)。同一对象实例的 synchronized 普通方法,同一时间只能被一个线程访问;不同对象实例间互不影响,可被不同线程同时访问各自的同步普通方法。
  • 静态方法:锁的是当前类的 Class 对象。由于类的 Class 对象全局唯一,无论多少个对象实例,该静态同步方法同一时间只能被一个线程访问。

作用范围不同:

  • 普通方法:仅对同一对象实例的同步方法调用互斥,不同对象实例的同步普通方法可并行执行。
  • 静态方法:对整个类的所有实例的该静态方法调用都互斥,一个线程进入静态同步方法,其他线程无法进入同一类任何实例的该方法。

多实例场景影响不同:

  • 普通方法:多线程访问不同对象实例的同步普通方法时,可同时执行。
  • 静态方法:不管有多少对象实例,同一时间仅一个线程能执行该静态同步方法。

# synchronized和reentrantlock区别?

synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:

  • 用法不同:synchronized 是关键字,可直接用来修饰普通方法、静态方法和代码块;ReentrantLock 是 java.util.concurrent.locks 包下的一个类,通过显式调用 lock() 获取锁、unlock() 释放锁来使用,通常配合 try-finally 确保锁一定能释放。
  • 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
  • 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。
  • 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

# 怎么理解可重入锁?

可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。

ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。

  • 当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。
  • 当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。

这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时,计数器加1;每次释放锁时,计数器减1。只有当计数器减到0时,锁才会完全释放。

ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。

# synchronized 支持重入吗?如何实现的?

synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

synchronized 的底层实现因锁的升级阶段不同而有所区别:偏向锁轻量级锁通过对象头 Mark Word 中的 CAS 操作实现,完全不涉及操作系统层面的互斥量;只有升级为重量级锁后,才会借助操作系统的 mutex(在 HotSpot 中对应 ObjectMonitor,最终调用 pthread 互斥量)来阻塞/唤醒线程。无论哪一级别,可重入语义都由 Monitor 内部维护的 owner 线程和 recursions 计数器来保证。

当一个线程请求方法时,会去检查锁状态。

  1. 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
  2. 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。

在释放锁时,

  1. 如果是可重入锁的,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
  2. 如果非可重入锁的,线程退出方法,直接就会释放该锁。

# synchronized锁升级的过程讲一下

synchronized 锁升级是 JDK1.6 针对 synchronized 同步机制推出的核心自适应性能优化方案。在 JDK1.5 及之前,synchronized 完全依赖操作系统内核的互斥量实现,是典型的重量级锁,每次加锁解锁都需要完成用户态到内核态的切换,性能开销极大。

而 JVM 通过对生产业务场景的长期调研发现,绝大多数同步块要么全程无竞争,要么只会被同一个线程反复进入,基于这个核心特征,JDK1.6 引入了分级锁升级机制,让锁可以根据竞争的激烈程度,从低开销的状态逐步向高阶升级,最终在全场景下都能实现最优的性能表现。

整个升级路径是无锁→偏向锁→轻量级锁→重量级锁,这是一个单向过程,只能升级不能降级。

img

这个机制的核心在于对象头中的Mark Word,它会根据锁状态存储不同的信息,通过其中的锁标志位来标识当前锁处于什么状态。

首先是无锁状态。对象刚创建出来时,没有任何线程访问,此时Mark Word中存储的是对象的HashCode、分代年龄等信息,锁标志位是01,偏向标志位是0。这是对象的初始状态,还没有涉及任何同步操作。需要注意的是,一旦对象调用过hashCode方法,它就无法进入偏向锁状态了,因为偏向锁需要用Mark Word的空间来存储线程ID,而hashCode也要占用这部分空间,两者冲突。

当第一个线程进入同步块时,锁会升级到偏向锁。此时会通过CAS操作在对象头的Mark Word中记录该线程ID,锁标志位保持01,但偏向标志位变为1。之后这个线程再次进入同步块时,只需简单判断Mark Word中的线程ID是否是自己,如果是就直接进入,完全不需要加锁解锁操作,这就极大优化了单线程反复进入同步块的场景。但一旦有第二个线程尝试获取这个锁,就说明出现了竞争,偏向锁就会被撤销。撤销过程需要等到安全点,暂停持有偏向锁的线程,检查该线程是否还在执行同步代码,然后清除偏向标记,这个过程开销不小。

撤销偏向锁后就进入轻量级锁阶段。这时JVM会在当前线程的栈帧中创建锁记录空间,把对象头的Mark Word复制进去,然后通过CAS操作尝试把对象头的Mark Word更新为指向锁记录的指针,锁标志位变为00。如果CAS成功,说明获取轻量级锁成功。如果失败,说明有其他线程在竞争,这时线程会进行自旋,不断重试CAS操作,期望在短时间内持有锁的线程能释放锁。自旋的本质是用CPU时间换取避免线程阻塞的开销,所以只适合锁持有时间很短的场景。

在JDK1.6之前自旋次数固定是10次,可以通过参数调整。JDK1.6之后引入了自适应自旋,JVM会根据上次在这个锁上的自旋情况动态调整自旋次数,如果上次自旋成功了,这次就允许自旋更久,反之则可能直接放弃自旋。当自旋次数达到阈值仍未获取到锁,或者在持锁期间有其他线程同时尝试获取该锁(出现真正竞争)时,轻量级锁就会膨胀为重量级锁。

重量级锁就是传统的synchronized实现,它依赖操作系统的互斥量Mutex来实现,锁标志位变为10,Mark Word指向Monitor对象。未获取锁的线程会被阻塞,进入Monitor的等待队列,这个过程需要从用户态切换到内核态,由操作系统来调度线程的阻塞和唤醒,开销非常大。但在竞争激烈、持锁时间长的场景下,阻塞线程反而比自旋更节省CPU资源,这就是为什么要升级为重量级锁。

整个升级过程的设计思想就是用空间换时间,用复杂度换性能。通过在对象头中维护锁状态,根据实际竞争情况选择最合适的同步策略,让synchronized在各种场景下都能有不错的表现,这也是为什么JDK1.6之后synchronized重新获得青睐的原因。

需要特别注意的是:偏向锁从 JDK 15 起已通过 JEP 374 被默认禁用并标记为 deprecated,JDK 18 已 obsolete 偏向锁的实现,JDK 21 相关命令行选项(如 -XX:+UseBiasedLocking)也已 deprecated。官方废弃偏向锁的主要原因是它让 JVM 实现过于复杂,而现代应用(尤其是 lambda、record、虚拟线程大行其道的场景)很少再从偏向锁中获益。所以在 JDK 15+ 下,synchronized 的实际升级路径已经简化为 无锁 → 轻量级锁 → 重量级锁,面试时可以补充说明这个变化。

# JVM对Synchornized的优化?

synchronized 核心优化方案主要包含以下 4 个:

  • 锁升级:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅提升了 synchronized 的性能。需要注意的是,锁只能升级不能降级,这是为了保证数据一致性,避免降级过程中的复杂同步问题
  • 锁消除:指的是在即时编译(JIT)时,通过逃逸分析技术,如果检测到某些对象只在单线程环境中访问,不存在共享和竞争的可能性,JVM就会将这段代码的同步锁消除掉,从而提高程序性能。典型场景是在方法内部创建的局部对象上使用synchronized,或者对StringBuffer等线程安全类的局部操作进行优化。锁消除需要开启逃逸分析(-XX:+DoEscapeAnalysis)和锁消除(-XX:+EliminateLocks)参数
  • 锁粗化:一般情况下,我们希望同步代码块范围尽可能小,这样等待锁的线程能尽快拿到锁。但如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,JVM会将加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了,避免了频繁的加锁解锁开销。比如在循环中对同一对象反复加锁,JVM会将锁粗化到循环外部。
  • 自适应自旋锁:自旋是轻量级锁获取失败后的一种优化手段,通过让线程执行空循环(自旋)等待锁释放,避免线程被挂起。因为线程的挂起和恢复都需要从用户态转入内核态,开销较大。JDK 1.6引入了自适应自旋,它会根据前一次在同一个锁上的自旋时间及锁的拥有者状态来动态调整自旋次数。如果刚刚自旋成功过,那么这次自旋的次数会更多;如果很少自旋成功,那么可能直接省略自旋过程,避免浪费CPU资源

# 介绍一下AQS

AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程