java面试题之多线程

多线程

多线程基础

什么是进程?什么是线程?什么是多线程?

进程是执行着的应用程序,而线程是进程内部的一个执行序列。一个进程可以有多个线程。

线程是操作系统能够进⾏运算调度的最⼩单位,它被包含在进程之中,是进程中的实际运作单位,可以使⽤多线程对运算进⾏提速。在一个进程中,每个独立的功能都需要独立的去运行,这时又需要把当前这个进程划分成多个运行区域,每个独立的小区域(小单元)称为一个线程。

⽐如,例如:360杀毒软件,同时既可以安全体检电脑又可以清理电脑中的垃圾。那么这里的安全体检是360杀毒软件中的一个线程,清理电脑中的垃圾也是一个线程。如果⼀个线程完成⼀个任务要100毫秒,那么⽤⼗个线程完成改任务只需10毫秒

  • 什么是多线程:
    一个进程如果只有一条执行任务,则称为单线程程序。
    一个进程如果有多条执行任务,也就是说在一个进程中,同时开启多个线程,让多个线程同时去完成某些任务(功能)。则称为多线程程序。
多线程有什么用?
  • 发挥多核CPU 的优势
    随着工业的进步,现在的笔记本、台式机乃至商用的应用服务器至少也都是双核的,4 核、8 核甚至 16 核的也都不少见,如果是单线程的程序,那么在双核 CPU 上就浪费了 50%, 在 4 核 CPU 上就浪费了 75%。单核 CPU 上所谓的"多线程"那是假的多线程,同一时间处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程"同时"运行罢了。多核 CPU 上的多线程才是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核CPU 的优势来,达到充分利用CPU 的目的。
  • 防止阻塞
    从程序运行效率的角度来看,单核 CPU 不但不会发挥出多线程的优势,反而会因为在单核CPU 上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核 CPU 我们还是要应用多线程,就是为了防止阻塞。试想,如果单核 CPU 使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
  • 便于建模
    这是另外一个没有这么明显的优点了。假设有一个大的任务 A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务 A 分解成几个小任务,任务B、任务 C、任务 D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。
实现多线程的方式?用哪个更好?

1.方式一:继承Thread,重写Thread类中的run方法;

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start();  // 启动线程
    }
}

2.方式二:实现Runnable接口,实现run方法;

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在运行");
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t1 = new Thread(new MyRunnable());
        t1.start();  // 启动线程
    }
}

3.方式三:使用 Java 线程池(推荐)

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyTask implements Runnable {
    @Override
    public void run() {
        System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行任务");
    }
}

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 5; i++) {
            executor.execute(new MyTask());
        }
        executor.shutdown();  // 关闭线程池
    }
}

4.方式四:使用 Callable 和 Future(可获取返回值)Callable 和 Future 允许线程返回值并支持异常抛出。适用于需要获取线程执行结果的场景。

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        return 42;  // 线程执行完后返回结果
    }
}

public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
        Thread t1 = new Thread(futureTask);
        t1.start();
        System.out.println("返回结果: " + futureTask.get());  // 获取线程执行的返回值
    }
}

3.方式二的方式更好,原因是:
①避免了Java单继承的局限性;
②把线程代码和任务的代码分离,解耦合(解除线程代码和任务的代码模块之间的依赖关系)。代码的扩展性非常好;
③方式二可以更方便、灵活的实现数据的共享同时如果是简单任务: 我喜欢 Runnable,因为它比继承 Thread 更灵活,代码复用性更高。
如果需要返回值: Callable 更合适。
如果是高并发、多线程任务: 线程池(ExecutorService)是最佳选择,它能有效管理线程、提高性能,并且是企业级开发的最佳实践。

什么是Callable和Future?

1、Callable 和 Future 是⽐较有趣的⼀对组合。当我们需要获取线程的执⾏结果时,就需要⽤到它们。Callable⽤于产⽣结果,Future⽤于获取结果。
2、Callable接⼝使⽤泛型去定义它的返回类型。Executors类提供了⼀些有⽤的⽅法去在线程池中执⾏Callable内的任务。由于Callable任务是并⾏的,必须等待它返回的结果。java.util.concurrent.Future对象解决了这个问题。
3、在线程池提交Callable任务后返回了⼀个Future对象,使⽤它可以知道Callable任务的状态和得到Callable返回的执⾏结果。Future提供了get()⽅法,等待Callable结束并获取它的执⾏结果。

什么是FutureTask?

1、FutureTask可⽤于异步获取执⾏结果或取消执⾏任务的场景。通过传⼊Runnable或者Callable的任务给FutureTask,直接调⽤其run⽅法或者放⼊线程池执⾏,之后可以在外部通过FutureTask的get⽅法异步获取执⾏结果,因此,FutureTask⾮常适合⽤于耗时的计算,主线程可以在完成⾃⼰的任务后,再去获取结果。另外,FutureTask还可以确保即使调⽤了多次run⽅法,它都只会执⾏⼀次Runnable或者Callable任务,或者通过cancel取消FutureTask的执⾏等。
2、futuretask可⽤于执⾏多任务、以及避免⾼并发情况下多次创建数据机锁的出现。

启动一个线程是用run()还是start()? .

启动线程肯定要用start()方法。当用start()开始一个线程后,线程就进入就绪状态,使线程所代表的虚拟处理机处于可运行状态,这意味着它可以由JVM调度并执行。这并不意味着线程就会立即运行。当cpu分配给它时间时,才开始执行run()方法(如果有的话)。start()是方法,它调用run()方法.而run()方法是你必须重写的. run()方法中包含的是线程的主体

为什么我们调用 start()方法时会执行 run()方法,为什么我们不能直接调用 run()方法?

当你创建一个 Thread 对象并调用其 start() 方法时,JVM 会执行以下步骤:
启动新线程:start() 方法内部会调用一个本地方法 start0(),由 JVM 负责创建一个新的操作系统级线程。
线程就绪:新线程创建后进入就绪状态,等待 CPU 调度。
回调 run():一旦新线程获得 CPU 时间片,JVM 就会在新线程的上下文中调用该线程对象的 run() 方法。
因此,start() 的作用是启动一个新线程,而新线程的入口就是 run() 方法。run() 方法本身只是一个普通的方法,包含了线程要执行的任务代码。

为什么不能直接调用 run()?
直接调用 run() 是完全合法的语法,但它的行为与普通方法调用没有任何区别:
不会创建新线程:run() 会在当前调用线程中同步执行,而不是在新线程中异步执行。
失去多线程特性:如果你直接调用 run(),那么所有代码都在同一个线程中顺序执行,无法达到并发效果。

public class MyThread extends Thread {
    public void run() {
        System.out.println("当前线程: " + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.run();   // 直接调用 run(),输出: 当前线程: main
        t.start(); // 调用 start(),输出: 当前线程: Thread-0
    }
}

直接调用 run():run() 在 main 线程中执行,打印的是 main。
调用 start():JVM 创建了新线程(默认名为 Thread-0),并在新线程中执行 run(),打印的是 Thread-0。

深入理解:start() 的内部机制
Thread 类的 start() 方法大致逻辑如下(简化版):

public synchronized void start() {
    if (threadStatus != 0)  // 检查线程是否已启动
        throw new IllegalThreadStateException();
    group.add(this);         // 加入线程组
    start0();                // 调用本地方法
}

private native void start0(); // JNI 方法,由 JVM 实现

start0() 是平台相关的本地方法,它负责:
分配线程栈等资源。
调用操作系统 API 创建新线程。
设置新线程的入口函数,该函数最终会调用 Java 层的 run() 方法。
新线程启动后,JVM 会确保 run() 方法在新线程中执行。

线程调度和线程控制

线程调度(优先级):
与线程休眠类似,线程的优先级仍然无法保障线程的执行次序。只不过,优先级高的线程获取 CPU 资源的概率较大,优先级低的并非没机会执行。线程的优先级用 1-10 之间的整数表示,数值越大优先级越高,默认的优先级为 5。 在一个线程中开启另外一个新线程,则新开线程称为该线程的子线程,子线程初始优先级与父线程相同。

线程控制
  • sleep( ) // 线程休眠
  • join( ) // 线程加入
  • yield( ) // 线程礼让
  • setDaemon( ) // 线程守护
  • stop( ) interrupt( ) (首先选用). //中断线程
对守护线程的理解

守护线程:为所有非守护线程提供服务的线程;任何一个守护线程都是整个JVM中所有非守护线程的保姆;
守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪 天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了;
注意: 由于守护线程的终止是自身无法控制的,因此千万不要把IO、File等重要操作逻辑分配给它;因为它不靠谱;

守护线程的作用是什么?

举例, GC垃圾回收线程:就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
应用场景:(1)来为其它线程提供服务支持的情况;(2) 或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;反之,如果一个正在执行某个操作的线程必须要 正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都 是些关键的事务,比方说,数据库录入或者更新,这些操作都是不能中断的。
thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
在Daemon线程中产生的新线程也是Daemon的。
守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作 的中间发生中断。
Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线 程就不能用Java的线程池。

stop() 和 suspend() 方法为何不推荐使用?

反对使用 stop(),是因为它不安全。它会解除由线程获取的所有锁定,而且如果对象处于一种不连贯状态,那么其他线程能在那种状态下检查和修改它们。结果很难检查出真正的问题所在。

suspend() 方法容易发生死锁。调用 suspend() 的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时,其他任何线程都不能访问锁定的资源,除非被 “挂起” 的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。所以不应该使用 suspend(),而应在自己的 Thread 类中置入一个标志,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用 wait() 命其进入等待状态。若标志指出线程应当恢复,则用一个 notify() 重新启动线程。

请说出你所知道的线程同步的方法。
  • wait():使一个线程处于等待状态,并且释放所持有的对象的 lock。
  • sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要捕捉InterruptedException 异常。
  • notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且不是按优先级。
  • notityAll():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。
sleep()、wait()、yield()、join()的区别?
  • sleep 通常被用于暂停执行,就是正在执行的线程主动让出 cpu,cpu 去执行其他线程,在 sleep 指定的时间过后,cpu 才会回到这个线程上继续往下执行,如果当前线程进入了同步锁,sleep 方法并不会释放锁,即使当前线程使用 sleep 方法让出了 cpu,但其他被同步锁挡住了的线程也无法得到执行。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这点和wait是一样的。
  • wait通常被用于线程间交互, 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了 notify 方法(notify 并不释放锁,只是告诉调用过 wait 方法的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放。如果 notify方法后面的代码还有很多,需要这些代码执行完后才会释放锁,可以在 notfiy 方法后增加一个等待和一些代码,看看效果),调用 notify 方法的线程就会解除 wait 状态和程序可以再次得到锁后继续向下运行。
  • yield()执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格, 所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行
  • join()执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程
public static void main(String[] args) throws InterruptedException { 
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() { 
            try {
                Thread.sleep(3000);
             } catch (InterruptedException e) {
                e.printStackTrace();
        }
   	 System.out.println("22222222");
    }
    });
    t1.start();
    t1.join();
    // 这行代码必须要等t1全部执行完毕,才会执行
    System.out.println("1111");
}

// 运行结果
//22222222
//1111
为什么wait方法要出现在同步代码块中?

由于wait()属于Object方法,调用之后会强制释放当前对象锁,所以在wait()调用时必须拿到当前对象的监视器monitor对象。因此,wait()方法在同步方法/代码块中调用。

生命周期

概括的解释下线程的几种可用状态。
  1. 新建( new ):新创建了一个线程对象。还没有开始启动。
  2. 可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start ()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权 。
  3. 运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行 程序代码。
  4. 阻塞( block ):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice ,暂时停止运行。直到线程进入可运行( runnable )状态,才有 机会再次获得 cputimeslice 转到运行( running )状态。阻塞的情况分三种:
  • (一). 等待阻塞:运行( running )的线程执行 o.wait ()方法, JVM 会把该线程放入等待队列( waitting queue )中。
  • (二). 同步阻塞:运行( running )的线程在获取对象的同步锁时,若该同步锁 被别的线程占用,则 JVM 会把该线程放入锁池( lock pool )中。
  • (三). 其他阻塞: 运行( running )的线程执行 Thread.sleep ( long ms )或 t . join ()方法,或者发出了 I / O 请求时,JVM 会把该线程置为阻塞状态。 当 sleep ()状态超时、join ()等待线程终止或者超时、或者 I / O 处理完毕时,线程重新转入可运行( runnable )状态。
  1. 死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该 线程结束生命周期。死亡的线程不可再次复生。
    在这里插入图片描述

线程安全与锁

在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?

监视器和锁在 Java 虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码

什么是线程安全和线程不安全?

线程安全: 就是多线程访问时,采⽤了加锁机制,当⼀个线程访问该类的某个数据时,进⾏保护,其他线程不能进⾏访问,直到该线程读取完,其他线程才可使⽤。不会出现数据不⼀致或者数据污染。
Vector 是⽤同步⽅法来实现线程安全的, ⽽和它相似的ArrayList不是线程安全的。

线程不安全:就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据.
线程安全问题都是由全局变量及静态变量引起的。
若每个线程中对全局变量、静态变量只有读操作,⽽⽆写操作,⼀般来说,这个全局变量是线程安全的;若有多个线程同时执⾏写操作,⼀般都需要考虑线程同步,否则的话就可能影响线程安全。

什么是线程安全问题

多线程环境中,且存在数据共享,一个线程访问的共享数据被其它线程修改了,那么就发生了线程安全问题;整个访问过程中,无一共享的数据被其他线程修改,就是线程安全的

不是线程安全、应该是内存安全,堆是共享内存,可以被所有线程访问

当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获 得正确的结果,我们就说这个对象是线程安全的

堆是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分 配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了 要还给操作系统,要不然就是内存泄漏。

在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚 拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及 数组都在这里分配内存。

栈是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈 互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语 言里面显式的分配和释放。

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己 的内存空间,而不能访问别的进程的,这是由操作系统保障的。

在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以 访问到该区域,这就是造成问题的潜在原因。

如何解决线程安全问题

1.使用线程同步机制,使得在同一时间只能由一个线程修改共享数据;
2.消除共享数据:即多个线程数据不共享或者共享的数据不做修改,将全局数据转换为局部数据;
如在SpringMVC中,就采用的该种方式解决线程安全问题。在Controller中,service为多个线程共享的数据,但是service为单例的,且不会被修改;controller中的方法,接收请求数据方式为局部变量,多个线程不共享数据。即不会产生线程安全问题

什么是原⼦操作?在Java Concurrency API中有哪些原⼦类(atomic classes)?Java 中的原子操作是什么?

原子操作是指一个或多个操作在执行过程中不会被任何其他线程或操作打断,即这些操作要么全部执行成功,要么全部不执行,不存在中间状态。在多线程环境中,原子操作是保证数据一致性和线程安全的重要手段。
int++并不是⼀个原⼦操作,所以当⼀个线程读取它的值并加1时,另外⼀个线程有可能会读到之前的值,这就会引发错误。为了解决这个问题,必须保证增加操作是原⼦的,在JDK1.5之前我们可以使⽤同步技术来做到这⼀点。

到JDK1.5,java.util.concurrent.atomic包提供了int和long类型的装类,它们可以⾃动的保证对于他们的操作是原⼦的并且不需要使⽤同步。

在 Java 中,原子操作主要通过以下几种方式实现:

  • 基本类型读写
    Java 语言规范保证对基本类型(除 long 和 double 外)的读写操作是原子的。对于 long 和 double,在 32 位 JVM 上可能不是原子的,但可以通过 volatile 修饰或使用原子类来确保原子性。
  • volatile 关键字
    volatile 保证变量的可见性,并确保对其的读写操作是原子的(仅限单次读或写)。但 volatile 不保证复合操作(如 i++)的原子性。
  • java.util.concurrent.atomic 包
    提供了一系列原子类,如 AtomicInteger、AtomicLong、AtomicReference 等。它们通过 CAS(Compare-And-Swap)硬件级别的原子指令实现无锁的线程安全操作,支持诸如 incrementAndGet()、compareAndSet() 等复合原子操作。
  • 同步机制
    使用 synchronized 关键字或 Lock 接口可以保证代码块的原子性,但属于锁机制,开销较大。
Java 中的 volatile 关键是什么作用?怎样使用它?在 Java 中它跟 synchronized 方法有什么不同?

volatile 的作用

  • 保证可见性
    当一个线程修改了 volatile 变量的值,新值会立即被写入主内存,并且其他线程中该变量的缓存行会失效,从而迫使其他线程必须从主内存重新读取该变量。这避免了因 CPU 缓存导致的数据不一致问题。
  • 禁止指令重排序
    编译器和处理器为了优化性能,可能会对指令进行重排序。volatile 通过插入内存屏障指令,防止了 volatile 变量前后的指令被重排序,从而保证了程序执行的有序性。
    注意:volatile 不保证原子性。例如 count++ 这种复合操作(读-改-写)在多线程下仍可能发生数据竞争,需要使用原子类或同步锁来保证原子性。

volatile 则是保证了所修饰的变量的可见。因为 volatile 只是在保证了同一个变量在多线程中的可见性,所以它更多是用于修饰作为开关状态的变量,即 Boolean 类型的变量。
volatile 多用于修饰类似开关类型的变量、Atomic 多用于类似计数器相关的变量、其它多线程并发操作用 synchronized 关键字修饰。

volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。
在 Java 中除了 long 和 double 之外的所有基本类型的读和赋值,都是原子性操作。而 64 位的 long 和 double 变量由于会被 JVM 当作两个分离的 32 位来进行操作,所以不具有原子性,会产生字撕裂问题。但是当你定义 long 或 double 变量时,如果使用 volatile 关键字,就会获到(简单的赋值与返回操作的)原子性。

如何使用 volatile
使用 volatile 非常简单,只需在声明共享变量时加上该关键字即可。

典型使用场景: 状态标志,常用于控制线程的启停,例如:

public class Server {
    private volatile boolean running = true;
    
    public void shutdown() {
        running = false;  // 修改标志,其他线程立即可见
    }
    
    public void run() {
        while (running) {
            // 执行业务逻辑
        }
    }
}

单例模式(双重检查锁定)
在懒汉式单例中,使用 volatile 禁止指令重排序,确保实例的正确发布:

public class Singleton {
    private static volatile Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // 防止指令重排序
                }
            }
        }
        return instance;
    }
}

volatile 与 synchronized 方法的区别

在这里插入图片描述

锁机制
什么是锁机制?及其优缺点

有些业务逻辑在执行过程中要求对数据进行排他性的访问,于是需要通过一些机制保证在此过程中数据被锁住不会被外界修改,这就是所谓的锁机制。
优点:保证资源同步
缺点:有等待肯定会慢

什么是⾃旋锁?

⾃旋锁是SMP架构中的⼀种low-level的同步机制。
1、当线程A想要获取⼀把⾃旋锁⽽该锁⼜被其它线程锁持有时,线程A会在⼀个循环中⾃旋以检测锁是不是已经可⽤了。
2、⾃选锁需要注意:
由于⾃旋时不释放CPU,因⽽持有⾃旋锁的线程应该尽快释放⾃旋锁,否则等待该⾃旋锁的线程会⼀直在那⾥⾃旋,这就会浪费CPU时间。持有⾃旋锁的线程在sleep之前应该释放⾃旋锁以便其它线程可以获得⾃旋锁。
3、⽬前的JVM实现⾃旋会消耗CPU,如果⻓时间不调⽤doNotify⽅法,doWait⽅法会⼀直⾃旋,CPU会消耗太⼤
4、⾃旋锁⽐较适⽤于锁使⽤者保持锁时间⽐较短的情况,这种情况⾃旋锁的效率⽐较⾼。
5、⾃旋锁是⼀种对多处理器相当有效的机制,⽽在单处理器⾮抢占式的系统中基本上没有作⽤。

什么是死锁?死锁产生的原因有哪些?
  • 1.什么是死锁
    两个线程或两个以上线程都在等待对方执行完毕才能继续往下执行的时候就发生了死锁。结果就是这些线程都陷入了无限的等待中。
  • 2.死锁产生的原因
    ①系统资源的竞争
    通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在 运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争 才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。
    ②进程推进顺序非法
    进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都 会因为所需资源被占用而阻塞
如何确保 N 个线程可以访问 N 个资源同时又不导致死锁?

使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,并强制线程按照指定的顺序获取锁。因此,如果所有的线程都是以同样的顺序加锁和释放锁,就不会出现死锁了。

什么是竞争条件?

当一个系统的正确性依赖于事件发生的相对时序,而该时序无法得到保证时,就存在竞争条件。通常表现为:
多个线程同时读写同一个变量,且至少有一个是写操作。
操作不是原子的,例如 count++ 实际上是“读取-修改-写入”三步,可能被其他线程打断。
程序的行为随着线程调度顺序的不同而随机变化,可能偶尔正确,偶尔出错。

public class Counter {
    private int count = 0;
    public void increment() {
        count++; // 非原子操作
    }
}

如果两个线程同时调用 increment(),可能出现如下交错:
线程A读取 count=0,准备加1。
线程B也读取 count=0,准备加1。
线程A写入 count=1。
线程B写入 count=1。
最终结果应为2,实际得到1,这就是竞争条件。

简述 synchronized 和 java.util.concurrent.locks.Lock 的异同?

主要相同点:Lock 能完成 synchronized 所实现的所有功能。
主要不同点:Lock 有比 synchronized 更精确的线程语义和更好的性能。
synchronized 会自动释放锁,而 Lock 一定要求程序员手工释放,并且必须在 finally 从句中释放。Lock 还有更强大的功能,例如,它的 try Lock 方法可以非阻塞方式去拿锁
举例说明(对下面的题用 lock 进行了改写)

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

public class ThreadTest {
    /**
     * @param args
     */
    private int j;
    private Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        ThreadTest tt = new ThreadTest();
        for (int i = 0; i < 2; i++) {
            new Thread(tt.new Adder()).start();
            new Thread(tt.new Subtractor()).start();
        }
    }

    private class Subtractor implements Runnable {
        @Override
        public void run() {
            // TODO Auto-generated method stub
            while (true) {
 /*synchronized (ThreadTest.this) { 
 System.out.println("j--=" + j--);
 //这里抛异常了,锁能释放吗?
 }*/
                lock.lock();
                try {
                    System.out.println("j--=" + j--);
                } finally {
                    lock.unlock();
                }
            }
        }
    }

    private class Adder implements Runnable {
        @Override
        public void run() {
            // TODO Auto-generated method stub
            while (true) {
 /*synchronized (ThreadTest.this) {
 System.out.println("j++=" + j++); 
 }*/
                lock.lock();
                try {
                    System.out.println("j++=" + j++);
                } finally {
                    lock.unlock();
                }
            }
        }
    }
}
在 Java 中 Lock 接口比 synchronized 块的优势是什么?你需要实现一个高效的缓存,它允许多个用户读,但只允许一个用户写,以此来保持它的完整性,你会怎样去实现它?

lock 接口在多线程和并发编程中最大的优势是它们为读和写分别提供了锁,它能满足你写像ConcurrentHashMap 这样的高性能数据结构和有条件的阻塞。Java 线程面试的问题越来越会根据面试者的回答来提问。我强烈建议在你去参加多线程的面试之前认真读一下 Locks,因
为当前其大量用于构建电子交易终统的客户端缓存和交易连接空间。

高级同步工具
ThreadLocal的原理和使用场景?

每一个Thread 对象均含有一个ThreadLocalMap 类型的成员变量threadLocals ,它存储本线程中所有ThreadLocal对象及其对应的值

ThreadLocalMap 由一个个Entry 对象构成
Entry 继承自WeakReference<ThreadLocal<?>> ,一个Entry 由ThreadLocal 对象和Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收

当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中。

get方法执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,获取对应的value。

由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在 线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。

使用场景:
1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
2、线程间数据隔离
3、进行事务操作,用于存储线程事务信息。
4、数据库连接,Session会话管理。

Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离

在这里插入图片描述

ThreadLocal内存泄漏的场景

实际上 ThreadLocalMap 中使用的 key为 ThreadLocal的弱引用,而vaue是强引用。弱引用的特点是,如果这个对象持有弱引用,那么在下一次垃圾回收的时候必然会被清理掉。所以加果Tneado02目 没有被外部强引用的情况下,在垃极回收的时候会设清理撞的,这样一来 ThreadLocaimhap中使用这个TreadLocal的 key也会被清理掉,但是,0 是强引用,不会被清理,这样一来就会出现 key为rul的v8l,假如我们不做任同措施的话,value 永远无法被GC 回收,如果线程长时间不被销毁,可能会产生内存泄露。

在这里插入图片描述

TreadLocan/ao实现中已经考虑了这种情况,在调用 set、get、remove方法的时候,会清理掉 key为 nul的记录,如果说会出现内存泄漏,那只有在出现了 key为null的记录后,没有手动调用remove方法,并且之后也不再调用 set、 get
remove()方法的情况下。因此使用完ThreadLocal 方法后,最好手动调用 remove()方法。

死锁产生的必要条件?如何解决死锁问题

死锁产生的条件
产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。
①互斥条件(Mutual exclusion):资源不能被共享,只能由一个进程使用。
②请求与保持条件(Hold and wait):进程已获得了一些资源,但因请求其它资源被阻塞时,对已获得的资源保持不放。
③不可抢占条件(No pre-emption) :有些系统资源是不可抢占的,当某个进程已获得这种资源后,系统不能强行收回,只能由进程使用完时自己释放。
④循环等待条件(Circular wait):若干个进程形成环形链,每个都占用对方申请的下一个资源。

死锁解决几种方式:
①加锁顺序(线程按照一定的顺序加锁,只有获得了从顺序上排在前面的锁之后,才能获取后面的锁)
②加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
③死锁检测 (判断系统是否处于死锁状态)
④死锁避免(指进程在每次申请资源时判断这些操作是否安全。例如银行家算法:在分配资源之前先看清楚,资源分配后是否会导致系统死锁。如果会死锁,则不分配,否则就分配。)

什么是乐观锁和悲观锁?

乐观锁和悲观锁是并发控制的两种方式,用于解决多个线程或事务同时访问数据时的数据一致性问题。

  • 悲观锁

悲观锁认为数据在并发环境下很可能会发生冲突,所以每次访问数据前,都会先加锁,保证其他线程或事务无法修改数据。
🔹 实现方式
数据库层面:
使用 SELECT … FOR UPDATE 语句,对数据行加锁,其他事务必须等锁释放后才能修改数据。

START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 加锁
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

应用层面:
使用 synchronized(Java)、ReentrantLock 等方式对资源加锁。
🔹 适用场景
高并发写入场景,数据冲突概率大。
银行转账等对数据一致性要求极高的业务。

  • 乐观锁

乐观锁认为数据冲突的概率比较低,所以不会主动加锁,而是在更新数据时检查数据是否被其他事务修改,如果发生冲突,则让事务失败并重试。
🔹 实现方式
版本号机制:给每行数据增加 version 字段,更新时检查 version 是否一致。

UPDATE accounts 
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 3;

jav代码层次

// AtomicInteger 通过 CAS(Compare and Swap)实现乐观锁
AtomicInteger balance = new AtomicInteger(100);
balance.compareAndSet(100, 90);  // 只有 balance 还是 100 时才能更新,否则失败

时间戳机制:检查 last_update_time 是否发生变化。
🔹 适用场景
读多写少的业务,如用户资料修改、商品库存更新。
提高性能,避免锁带来的性能损耗。

JUC并发工具

并发容器
什么是同步容器和并发容器的实现?

1、同步容器
1、主要代表有Vector和Hashtable,以及Collections.synchronizedXxx等。
2、锁的粒度为当前对象整体。
3、迭代器是及时失败的,即在迭代的过程中发现被修改,就会抛出ConcurrentModificationException。
2、并发容器
1、主要代表有ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap、ConcurrentSkipListSet。
2、锁的粒度是分散的、细粒度的,即读和写是使⽤不同的锁。
4、迭代器具有弱⼀致性,即可以容忍并发修改,不会抛出ConcurrentModificationException。

ConcurrentHashMap
采⽤分段锁技术,同步容器中,是⼀个容器⼀个锁,但在ConcurrentHashMap中,会将hash表的数组部分分成若⼲段,每段维护⼀个锁,以达到⾼效的并发访问;

线程池
线程池中线程复用原理

线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。
在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。

为什么用线程池?解释下线程池参数?
  • 1、降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗。
  • 2、提高响应速度;任务来了,直接有线程可用可执行,而不是先创建线程,再执行。
  • 3、提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。
  • corePoolSize代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会 消除,而是一种常驻线程
  • maxinumPoolSize代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但 是线程池内线程总数不会超过最大线程数
  • keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过setKeepAliveTime来设置空闲时间
  • workQueue用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放 入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
  • ThreadFactory实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择 自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
  • Handler任务拒绝策略,有两种情况,第一种是当我们调用shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程 池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提 交的任务时,这是也就拒绝
简述线程池处理流程

在这里插入图片描述

ThreadPool(线程池)⽤法与优势?

1、ThreadPool 优点
1、减少了创建和销毁线程的次数,每个⼯作线程都可以被重复利⽤,可执⾏多个任务
2、可以根据系统的承受能⼒,调整线程池中⼯作线线程的数⽬,防⽌因为因为消耗过多的内存,⽽把服务器累趴下(每个线程需要⼤约1MB内存,线程开的越多,消耗的内存也就越⼤,最后死机)减少在创建和销毁线程上所花的时间以及系统资源的开销如不使⽤线程池,有可能造成系统创建⼤量线程⽽导致消耗完系统内存
2、⽐较重要的⼏个类:

描述
ExecutorService 真正的线程池接⼝。
ScheduledExecutorService 能和Timer/TimerTask类似,解决那些需要任务重复执⾏的问题。
ThreadPoolExecutor ExecutorService的默认实现。
ScheduledThreadPoolExecutor 继承ThreadPoolExecutor的ScheduledExecutorService接⼝实现,周期性任务调度的类实现。

Java⾥⾯线程池的顶级接⼜是Executor,但是严格意义上讲Executor并不是⼀个线程池,⽽只是⼀个执⾏线程的⼯具。真正的线程池接⼜是ExecutorService。

3、任务执⾏顺序:

在这里插入图片描述

i. 当线程数⼩于corePoolSize时,创建线程执⾏任务。
ii. 当线程数⼤于等于corePoolSize并且workQueue没有满时,放⼊workQueue中
iii. 线程数⼤于等于corePoolSize并且当workQueue满时,新任务新建线程运⾏,线程总数要⼩于maximumPoolSize
iv. 当线程总数等于maximumPoolSize并且workQueue满了的时候执⾏handler的rejectedExecution。也就是拒绝策略。

什么是Executors框架?

Java通过Executors提供四种线程池,分别为:
1、newCachedThreadPool创建⼀个可缓存线程池,如果线程池⻓度超过处理需要,可灵活回收空闲线程,若⽆可回收,则新建线程。
2、newFixedThreadPool 创建⼀个定⻓线程池,可控制线程最⼤并发数,超出的线程会在队列中等待。
3、newScheduledThreadPool 创建⼀个定⻓线程池,⽀持定时及周期性任务执⾏。
4、newSingleThreadExecutor 创建⼀个单线程化的线程池,它只会⽤唯⼀的⼯作线程来执⾏任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执⾏。

Concurrent包⾥的其他东⻄:ArrayBlockingQueue、CountDownLatch等等。

1、ArrayBlockingQueue 数组结构组成的有界阻塞队列:
2、CountDownLatch 允许⼀个或多个线程等待其他线程完成操作;
join⽤于让当前执⾏线程等待join线程执⾏结束。其实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远wait

在 Java 中 CycliBarriar 和 CountdownLatch 有什么区别?

这个线程问题主要用来检测你是否熟悉 JDK5 中的并发包。这两个的区别是 CyclicBarrier 可以重复使用已经通过的障碍,而 CountdownLatch 不能重复使用。

你在多线程环境中遇到的常见的问题是什么?你是怎么解决它的?

多线程和并发程序中常遇到的有 Memory-interface、竞争条件、死锁、活锁和饥饿。问题是没有止境的,如果你弄错了,将很难发现和调试。这是大多数基于面试的,而不是基于实际应用的 Java 线程问题。

并发、并行、串行的区别
  • 串行在时间上不可能发生重叠,前一个任务没搞定,下一个任务就只能等着并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行。
  • 并发允许两个任务彼此干扰。统一时间点、只有一个任务运行,交替执行
线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?

1、一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务 了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。
阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源

2、在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率。
就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数
(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超 过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还 是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。

锁池

所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线 程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到 后会进入就绪队列进行等待cpu资源分配。

等待池

当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll()后等待池的线程才会开始去竞争锁,notify()是随机从等待池选出一个线程放 到锁池,而notifyAll()是将等待池的所有线程放到锁池当中

同步和异步有何异同,在什么情况下分别使用他们?

如果数据将在线程间共享。例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。
当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。

当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法?
  • 其他方法前是否加了 synchronized 关键字,如果没加,则能。
  • 如果这个方法内部调用了 wait,则可以进入其他 synchronized 方法。
  • 如果其他个方法都加了 synchronized 关键字,并且内部没有调用 wait,则不能。
  • 如果其他方法是 static,它用的同步锁是当前类的字节码,与非静态的方法不能同步,因为非静态的方法用的是 this。
什么是线程阻塞,什么是活锁?

当所有线程阻塞,或者由于需要的资源无效而不能处理,不存在非阻塞线程使资源可用。
JavaAPI 中线程活锁可能发生在以下情形:

  • 当所有线程在序中执行 Object.wait(0),参数为 0 的 wait 方法。程序将发生活锁直到在相应的对象上有线程调用 Object.notify() 或者 Object.notifyAll()。
  • 当所有线程卡在无限循环中。
多线程中的忙循环是什么?

忙循环就是程序员用循环让一个线程等待,不像传统方法 wait(), sleep() 或 yield() 它们都放弃了 CPU 控制,而忙循环不会放弃 CPU,它就是在运行一个空循环。这么做的目的是为了保留 CPU 缓存。
在多核系统中,一个等待线程醒来的时候可能会在另一个内核运行,这样会重建缓存。
为了避免重建缓存和减少等待重建的时间就可以使用它了。

线程同步

什么是线程同步?

当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态

同步方法和同步代码块的区别是什么?
  • 同步方法默认用 this 或者当前类 class 对象作为锁;
  • 同步代码块可以选择以什么来加锁,比同步方法要更细颗粒度,我们可以选择只同步会发生同步问题的部分代码而不是整个方法;

高级面试题

现在有 T1、T2、T3 三个线程,你怎样保证 T2 在 T1 执行完后执行,T3 在 T2 执行完后执行?
  • 使用 join()

join() 方法会阻塞当前线程,直到目标线程执行完毕。因此,我们可以让 T2 在 T1.join() 后执行,T3 在 T2.join() 后执行:

import threading

def task1():
    print("T1 is running")

def task2():
    print("T2 is running")

def task3():
    print("T3 is running")

# 创建线程
t1 = threading.Thread(target=task1)
t2 = threading.Thread(target=task2)
t3 = threading.Thread(target=task3)

# 启动线程
t1.start()
t1.join()  # 等待 T1 执行完毕

t2.start()
t2.join()  # 等待 T2 执行完毕

t3.start()
t3.join()  # 等待 T3 执行完毕

优点:
代码简单,逻辑清晰。
缺点:
每个线程必须等前一个线程执行完毕后才能启动,失去了多线程的并发优势。

  • 使用 CountDownLatch

CountDownLatch 允许多个线程等待某个条件满足后再执行。

import java.util.concurrent.CountDownLatch;

public class ThreadOrder {
    public static void main(String[] args) {
        CountDownLatch latch1 = new CountDownLatch(1);
        CountDownLatch latch2 = new CountDownLatch(1);

        Thread t1 = new Thread(() -> {
            System.out.println("T1 is running");
            latch1.countDown(); // 释放 T2
        });

        Thread t2 = new Thread(() -> {
            try {
                latch1.await(); // 等待 T1 执行完
                System.out.println("T2 is running");
                latch2.countDown(); // 释放 T3
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                latch2.await(); // 等待 T2 执行完
                System.out.println("T3 is running");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        t1.start();
        t2.start();
        t3.start();
    }
}

缺点:
CountDownLatch 只能使用一次,不能重置。
优点:
允许一定的并发,不会完全阻塞其他线程。

  • 使用 CyclicBarrier

CyclicBarrier 允许线程等待其他线程到达屏障后继续执行。

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class ThreadOrder {
    public static void main(String[] args) {
        CyclicBarrier barrier1 = new CyclicBarrier(2); // 需要两个线程到达
        CyclicBarrier barrier2 = new CyclicBarrier(2);

        Thread t1 = new Thread(() -> {
            System.out.println("T1 is running");
            try {
                barrier1.await(); // 释放 T2
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                barrier1.await(); // 等待 T1 执行完
                System.out.println("T2 is running");
                barrier2.await(); // 释放 T3
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                barrier2.await(); // 等待 T2 执行完
                System.out.println("T3 is running");
            } catch (InterruptedException | BrokenBarrierException e) {
                e.printStackTrace();
            }
        });

        t1.start();
        t2.start();
        t3.start();
    }
}

优点:
CyclicBarrier 可重复使用,适用于循环任务。
缺点:
逻辑稍微复杂,适用于多个线程的同步控制。

  • 使用 Semaphore

Semaphore 允许我们控制线程的执行顺序,只有获得许可的线程才能执行。

import java.util.concurrent.Semaphore;

public class ThreadOrder {
    public static void main(String[] args) {
        Semaphore sem1 = new Semaphore(0);
        Semaphore sem2 = new Semaphore(0);

        Thread t1 = new Thread(() -> {
            System.out.println("T1 is running");
            sem1.release(); // 允许 T2 运行
        });

        Thread t2 = new Thread(() -> {
            try {
                sem1.acquire(); // 等待 T1 运行完
                System.out.println("T2 is running");
                sem2.release(); // 允许 T3 运行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                sem2.acquire(); // 等待 T2 运行完
                System.out.println("T3 is running");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        t1.start();
        t2.start();
        t3.start();
    }
}

优点:
Semaphore 适用于资源控制和同步操作。
缺点:
需要手动管理信号量,代码稍微复杂。

  • 使用 FutureTask

FutureTask 结合 ExecutorService 可以确保线程按顺序执行。

import java.util.concurrent.*;

public class ThreadOrder {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        Future<?> future1 = executor.submit(() -> System.out.println("T1 is running"));
        future1.get(); // 等待 T1 结束

        Future<?> future2 = executor.submit(() -> System.out.println("T2 is running"));
        future2.get(); // 等待 T2 结束

        Future<?> future3 = executor.submit(() -> System.out.println("T3 is running"));
        future3.get(); // 等待 T3 结束

        executor.shutdown();
    }
}

优点:
使用线程池管理线程,避免创建太多线程开销。
缺点:
需要显式调用 get(),否则不会阻塞等待。

用 Java 写代码来解决生产者——消费者问题。

与上面的问题很类似,但这个问题更经典,有些时候面试都会问下面的问题。在 Java 中怎么解决生产者——消费者问题,当然有很多解决方法,我已经分享了一种用阻塞队列实现的方法。有些时候他们甚至会问怎么实现哲学家进餐问题。

什么是阻塞队列?如何使⽤阻塞队列来实现⽣产者-消费者模型?

1、JDK7提供了7个阻塞队列。(也属于并发容器)
i. ArrayBlockingQueue :⼀个由数组结构组成的有界阻塞队列。
ii. LinkedBlockingQueue :⼀个由链表结构组成的有界阻塞队列。
iii. PriorityBlockingQueue :⼀个⽀持优先级排序的⽆界阻塞队列。
iv. DelayQueue:⼀个使⽤优先级队列实现的⽆界阻塞队列。
v. SynchronousQueue:⼀个不存储元素的阻塞队列。
vi. LinkedTransferQueue:⼀个由链表结构组成的⽆界阻塞队列。
vii. LinkedBlockingDeque:⼀个由链表结构组成的双向阻塞队列。
2、概念:阻塞队列是⼀个在队列基础上⼜⽀持了两个附加操作的队列。
3、2个附加操作:
⽀持阻塞的插⼊⽅法:队列满时,队列会阻塞插⼊元素的线程,直到队列不满。
⽀持阻塞的移除⽅法:队列空时,获取元素的线程会等待队列变为⾮空。

用 Java 实现阻塞队列。

这是一个相对艰难的多线程面试问题,它能达到很多的目的。第一,它可以检测侯选者是否能实际的用 Java 线程写程序;第二,可以检测侯选者对并发场景的理解,并且你可以根据这个问很多问题。如果他用wait()和 notify()方法来实现阻塞队列,你可以要求他用最新的Java 5 中的并发类来再写一次。

用 Java 编程一个会导致死锁的程序,你将怎么解决?

这是我最喜欢的 Java 线程面试问题,因为即使死锁问题在写多线程并发程序时非常普遍,但是很多侯选者并不能写 deadlock free code(无死锁代码?),他们很挣扎。只要告诉他们,你有 N 个资源和 N 个线程,并且你需要所有的资源来完成一个操作。为了简单这里的 n 可以替换为 2,越大的数据会使问题看起来更复杂。通过避免 Java 中的锁来得到关于死锁的更多信息。

Java 中你怎样唤醒一个阻塞的线程?
  1. 通过 Object.wait() 进入等待的线程
    阻塞原因:线程调用了对象的 wait() 方法,进入该对象的等待集,释放锁。
    唤醒方式:
    在同一对象上调用 notify() 或 notifyAll()。
    notify() 随机唤醒一个等待线程;notifyAll() 唤醒所有等待线程。
    注意:线程必须在持有该对象监视器(即 synchronized 块内)的前提下才能调用 wait/notify。
synchronized (lock) {
    lock.wait(); // 线程阻塞
}

// 另一个线程
synchronized (lock) {
    lock.notify(); // 唤醒一个等待线程
}
  1. 通过 Thread.sleep(long millis) 或 Thread.join() 进入定时等待
    阻塞原因:线程主动休眠指定时间,或等待另一个线程结束。
    唤醒方式:
    正常情况下,等待时间结束后自动唤醒。
    也可通过 interrupt() 方法提前唤醒,此时会抛出InterruptedException。
    注意:调用 interrupt() 会设置线程的中断标志,被唤醒的线程需要正确处理中断。
Thread t = new Thread(() -> {
    try {
        Thread.sleep(10000); // 休眠10秒
    } catch (InterruptedException e) {
        System.out.println("休眠被中断");
    }
});
t.start();
t.interrupt(); // 唤醒睡眠线程
  1. 通过 LockSupport.park() 阻塞
    阻塞原因:调用 LockSupport.park() 使当前线程进入等待状态(不释放锁)。
    唤醒方式:在其他线程中调用 LockSupport.unpark(thread),传入被阻塞的线程对象。
    特点:park/unpark 类似于信号量,可以先 unpark 后 park,且不要求先持有锁。
Thread t = new Thread(() -> {
    LockSupport.park(); // 阻塞
    System.out.println("被唤醒");
});
t.start();
LockSupport.unpark(t); // 唤醒
  1. 因获取锁失败而阻塞(synchronized 或 Lock)
    阻塞原因:线程试图进入 synchronized 块或调用 Lock.lock() 时,锁被其他线程持有。
    唤醒方式:这种阻塞由 JVM 内部管理,无法直接“唤醒”。当持有锁的线程释放锁后,JVM 会自动从锁的等待队列中唤醒一个线程。
    注意:如果使用 Lock 接口的 lockInterruptibly() 方法,则线程在等待锁期间可以被 interrupt() 唤醒并抛出 InterruptedException。
Lock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock(); // 释放锁后,等待的线程会被自动唤醒
}
  1. I/O 阻塞(如 Socket.read()、System.in.read())
    阻塞原因:线程等待 I/O 数据。
    唤醒方式:
    通常无法直接唤醒,只能等待数据到达或超时。
    对于可中断的 I/O(如 InterruptibleChannel 或使用 NIO),关闭通道或调用 interrupt() 可以唤醒阻塞线程,抛出 ClosedByInterruptException 或类似异常。
    对于传统 I/O,一般需要关闭流(如 socket.close())来触发异常,从而唤醒线程。
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(true);
Thread t = new Thread(() -> {
    try {
        channel.read(ByteBuffer.allocate(100)); // 阻塞
    } catch (AsynchronousCloseException e) {
        System.out.println("通道被关闭");
    }
});
t.start();
channel.close(); // 关闭通道导致 read() 抛出异常,唤醒线程

通用的唤醒机制:Thread.interrupt()
作用:设置线程的中断标志。
响应方式:
如果线程处于可中断的阻塞状态(如 sleep、wait、join、LockSupport.park、lockInterruptibly 等),会立即抛出 InterruptedException,并清除中断标志。
如果线程处于运行状态,仅设置中断标志,线程需自行检查 Thread.interrupted() 或 isInterrupted() 来决定是否退出。
注意:interrupt() 并不能强制终止线程,它只是一个协作机制,由线程本身决定如何响应。

sleep 和wait的区别?

1、sleep 是 Thread 类的静态本地方法,wait 则是 Object 类的本地方法。
2、sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
3、sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。
4、sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
5、sleep 一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信。
6、sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后可能还是有机会重新竞争到锁继续执行的。

notify和notifyAll

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁,需要等待唤醒。当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。

启动线程方法start() 和 run() 区别。

run( ):只是调用普通 run 方法
start( ):启动了线程, 由 Jvm 调用 run 方法
启动一个线程是调用 start() 方法,使线程所代表的虚拟处理机处于可运行状态,这意味着它可以由 JVM 调度并执行。这并不意味着线程就会立即运行。run() 方法可以产生必须退出的标志来停止一个线程。

ThreadLocal内存泄露原因,如何避免?

内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露 堆积后果很严重,无论多少内存,迟早会被占光,不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。

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

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

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

在这里插入图片描述

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时, Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链(红色链条)

  • key 使用强引用
    当ThreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强 引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
  • key 使用弱引用
    当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱 引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

ThreadLocal正确的使用方法
每次使用完ThreadLocal都调用它的remove()方法清除数据
将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。

高级原理

什么是CAS?

1、CAS(compare and swap)的缩写,中⽂翻译成⽐较并交换。
2、CAS 不通过JVM,直接利⽤java本地⽅ JNI(Java Native Interface为JAVA本地调⽤),直接调⽤CPU 的cmpxchg(是汇编指令)指令。
3、利⽤CPU的CAS指令,同时借助JNI来完成Java的⾮阻塞算法,实现原⼦操作。其它原⼦操作都是利⽤类似的特性完成的。
4、整个java.util.concurrent都是建⽴在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很⼤的提升。
5、CAS实现乐观锁技术,当多个线程尝试使⽤CAS同时更新同⼀个变量时,只有其中⼀个线程能更新变量的值,⽽其它线程都失败,失败的线程并不会被挂起,⽽是被告知这次竞争中失败,并可以再次尝试。

1、使⽤CAS在线程冲突严重时,会⼤幅降低程序性能;CAS只适合于线程冲突较少的情况使⽤。
2、synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是⾃旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了⾼吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;⽽线程冲突严重的情况下,性能远⾼于CAS。

什么是AQS?

1、AbstractQueuedSynchronizer简称AQS,是⼀个⽤于构建锁和同步容器的框架。事实上concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的⼤量细节问题。
2、AQS使⽤⼀个FIFO的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。
其他的节点与等待线程关联,每个节点维护⼀个等待状态waitStatus。

什么是多线程的上下⽂切换?

1、多线程:是指从软件或者硬件上实现多个线程的并发技术。
2、多线程的好处:
i. 使⽤多线程可以把程序中占据时间⻓的任务放到后台去处理,如图⽚、视屏的下载
ii. 发挥多核处理器的优势,并发执⾏让系统运⾏的更快、更流畅,⽤户体验更好
3、多线程的缺点:
a. ⼤量的线程降低代码的可读性;
b. 更多的线程需要更多的内存空间
c. 当多个线程对同⼀个资源出现争夺时候要注意线程安全的问题。
4、多线程的上下⽂切换:
CPU通过时间⽚分配算法来循环执⾏任务,当前任务执⾏⼀个时间⽚后会切换到下⼀个任务。但是,在切换前会保存上⼀个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态

你将如何使用 threaddump?你将如何分析 Thread dump?

在 UNIX 中你可以使用 kill -3,然后 thread dump 将会打印日志,在 windows 中你可以使用”CTRL+Break”。

怎样解决竞争条件?

竞争条件通常难以重现,需要结合多种手段来发现。

  1. 代码审查
    人工检查所有共享变量的访问,识别出没有恰当同步的读写操作。
    关注复合操作(如 i++、check-then-act)是否被保护。
  2. 静态分析工具
    工具如 FindBugs、SpotBugs、PMD 等可以检测明显的同步问题。
    更高级的工具如 Checker Framework、Coverity 等可以进行更深入的并发缺陷分析。
  3. 动态分析工具
    ThreadSanitizer(适用于 C/C++/Go/Rust)可在运行时检测数据竞争。
    Java 中的工具:如 JVM 参数 -XX:+RestrictContended、-XX:-TieredCompilation 配合测试,或者使用并发测试框架(如 JCStress,Java Concurrency Stress Tests)来构造压力测试。
    记录与重放:某些工具可以记录线程调度并重放以复现问题。
  4. 压力测试和负载测试
    在高并发场景下反复运行程序,增加线程数、循环次数,使竞争条件更容易暴露。
    使用随机延迟(如 Thread.sleep() 随机时间)打乱时序,增加交错可能性。
  5. 日志和监控
    在关键操作前后打印日志(但注意日志本身可能影响时序)。
    使用 JFR(Java Flight Recorder)或操作系统级别的性能分析工具观察线程行为。
  6. 形式化验证
    对关键代码进行模型检查或使用并发正确性验证工具(如 Spin、Relacy)来证明无竞争。

解决竞争条件的核心是确保对共享资源的访问是同步的、原子的,或避免共享。常用方法如下:

  1. 使用互斥锁(Mutual Exclusion)
    synchronized 关键字(Java)或 Lock 接口(如 ReentrantLock)。
    将临界区代码保护起来,确保同一时刻只有一个线程执行。
public synchronized void increment() {
    count++;
}
  1. 使用原子类
    Java 的 java.util.concurrent.atomic 包提供了基于 CAS(Compare-And-Swap)的原子变量,如 AtomicInteger。
    它们保证复合操作的原子性,且无锁,性能更好。
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
    count.incrementAndGet();
}
  1. 使用 volatile 保证可见性(但不解决复合操作)
    适用于一写多读的状态标志,但不能替代原子性。
  2. 不可变对象(Immutable Objects)
    如果对象一旦创建就不再改变,则天然线程安全,无需同步。
    如 String、Integer 等。
  3. 线程局部存储(Thread-Local Storage)
    使用 ThreadLocal 为每个线程保留一份独立的变量副本,彻底避免共享。
private ThreadLocal<Integer> localCount = ThreadLocal.withInitial(() -> 0);v
  1. 避免共享
    通过设计减少共享,例如将任务分解为独立子任务,每个线程处理自己的数据,最后合并结果(MapReduce 思想)。
  2. 使用并发容器和工具类
    优先使用 java.util.concurrent 包中的线程安全集合(如 ConcurrentHashMap、CopyOnWriteArrayList)和同步工具(如 CountDownLatch、Semaphore),它们内部已处理好并发问题。
  3. 采用函数式编程范式
    使用流式编程和不可变数据,减少可变共享状态。
什么是不可变对象,它对写并发应用有什么帮助?

不可变对象(Immutable Object) 是指一旦创建后,其内部状态(即对象的字段值)就不能再被改变的对象。也就是说,对象的所有字段都是 final 的,并且对象本身不提供任何可以修改其状态的方法(如 setter);如果需要对对象进行“修改”,则会返回一个全新的对象实例。

在 Java 中,典型的不可变类包括 String、基本类型的包装类(如 Integer、Long)、BigInteger、BigDecimal 以及 java.time 包下的日期时间类(如 LocalDate)。自定义不可变类需要遵循一定规则(例如:所有字段 private final、不暴露 setter、确保字段本身也是不可变的等)。

HashMap线程安全

死循环造成 CPU 100%
HashMap有可能会发生死循环并目造成 CPU 100%,这种情况发生最主要的原因就是在扩容的时候、也就是内部新建新的Hashnap 的时候,扩容的逻物会反转散列桶中的节点顺序、当有多个线程同时进行扩容的时候,由于HashMap并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的价环,相当于A节点指向B节点,B节点又指回到A节点,这样一来,在下一次想要获取该 key所对应的Value的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。
所以综上所述,HashMap 是线程不安全的,在多线程使用场景中推荐使用线程安全同时性能比较好的 ConcurentHashMap。

String不可变原因

1.可以使用字符串常量池,多次创建同样的字符串会指向同一个内存地址;
2.可以很方便地用作 HashMap 的 key。通常建议把不可变对象作为 HashMap的 key;
3.hashCode生成后就不会改变,使用时无需重新计算;
4.线程安全,因为具备不变性的对象一定是线程安全的;

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐