Skip to content

java 多线程面试题

1.线程进程的区别

进程线程
引入目的能并发执行,提高资源利用率和系统吞吐量提高并发执行的程度,进一步提高资源的利用率和系统的吞吐量
并发性较低较高
基本属性(调度)资源拥有的基本单位--进程独立调度/分派的基本单位--进程资源拥有的基本单位--进程独立调度/分派的基本单位--线程
基本状态就绪;执行;等待就绪;执行;等待
拥有资源资源拥有的单位--进程资源拥有的单位--进程
系统开销创建/撤销/切换时空开销较大创建/撤销/切换时空开销较小
系统操作创建,撤销,切换创建,撤销,切换
存在标志进程控制块 PCB进程控制块 PCB,线程控制块 TCB
关系单进程单线程;单进程多线程;多进程单线程;多进程多线程

1607865308476-9c83d0d5-3745-432a-8232-284143c6f51d.png

进程:是资源分配的最小单位

线程:轻量级的进程,是 cpu 调度的最小单位。

http://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html //这个例子很好

2.线程的状态

//这是线程的源码,状态就六个

java
public enum State {
    /**
     * Thread state for a thread which has not yet started.
     */
    //线程创建之后,启动之前
    NEW,

    /**
     * Thread state for a runnable thread.  A thread in the runnable
     * state is executing in the Java virtual machine but it may
     * be waiting for other resources from the operating system
     * such as processor.
     */
    //正在执行的线程
    RUNNABLE,

    /**
     * Thread state for a thread blocked waiting for a monitor lock.
     * A thread in the blocked state is waiting for a monitor lock
     * to enter a synchronized block/method or
     * reenter a synchronized block/method after calling
     * {@link Object#wait() Object.wait}.
     */
    //阻塞状态,等待获取锁(抢占锁)
    BLOCKED,

    //等待 调用了wait、sleep park方法方法,等待唤醒
    WAITING,

    //超时等待 调用wait timeout方法
    TIMED_WAITING,

    //线程运行结束
    TERMINATED;
}

1615515790060-8bbd29ba-fe35-41ba-bbef-510451dd61ff.png

3.创建线程的几种方式

//目前阶段知道两个即可,本质上其实也是两个,其他的都是演变

1、继承 Thread 类

2、实现 Runnable 接口(还一个 Callable 接口)

其实本质上来说,只有继承 Thread 或 new Thread 才是创建一个线程,Runnable 只是一个线程任务

java
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize) {
    init(g, target, name, stackSize, null, true);
}

1、继承 Thread 类创建线程

通过继承 Thread 类来创建并启动多线程的一般步骤如下:

1、定义 Thread 类的子类,并重写该类的 run() 方法,该方法的方法体就是线程需要完成的任务,run() 方法也称为线程执行体。

2、创建 Thread 子类的实例,也就是创建了线程对象

3、启动线程,即调用线程的 start() 方法

代码实例:

java
public class MyThread extends Thread{//继承Thread类
    
  public void run(){
  //重写run方法
  }
}
public class Main {
  public static void main(String[] args){
    new MyThread().start();//创建并启动线程
  }
}

2、实现 Runnable 接口创建线程

通过实现 Runnable 接口创建并启动线程一般步骤如下:

1、定义 Runnable 接口的实现类,一样要重写 run() 方法,这个 run()方法和 Thread 中的 run() 方法一样是线程的执行体

2、创建 Runnable 实现类的实例,并用这个实例作为 Thread 的 target 来创建 Thread 对象,这个 Thread 对象才是真正的线程对象

3、第三部依然是通过调用线程对象的 start() 方法来启动线程

代码实例:

java
public class MyThread2 implements Runnable {//实现Runnable接口

  public void run(){
  //重写run方法
  }
}

public class Main {
  public static void main(String[] args){
    //创建并启动线程
    MyThread2 myThread=new MyThread2();
    Thread thread=new Thread(myThread);
    thread().start();
    //或者    new Thread(new MyThread2()).start();
  }
}

3、使用 Callable 和 Future 创建线程

创建并启动有返回值的线程的步骤如下:

1、创建 Callable 接口的实现类,并实现 call() 方法,然后创建该实现类的实例(从 java8 开始可以直接使用 Lambda 表达式创建 Callable 对象)。

2、使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了 Callable 对象的 call() 方法的返回值

3、使用 FutureTask 对象作为 Thread 对象的 target 创建并启动线程(因为 FutureTask 实现了 Runnable 接口)

4、调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值

代码实例:

java
public class Main {
  public static void main(String[] args){
   MyThread3 th=new MyThread3();
   //使用Lambda表达式创建Callable对象
     //使用FutureTask类来包装Callable对象
   FutureTask<Integer> future=new FutureTask<Integer>(
    (Callable<Integer>)()->{
      return 5;
    }
    );
     //实质上还是以Callable对象来创建并启动线程
   new Thread(task,"有返回值的线程").start();

    try{
        //get()方法会阻塞,直到子线程执行结束才返回
    System.out.println("子线程的返回值:"+future.get());
        
    }catch(Exception e){
    ex.printStackTrace();
   }
  }
}

4、使用线程池例如用 Executor 框架

包括: 线程池,Executor,Executors,ExecutorService,CompletionService,Future,Callable 等。

4.说说线程安全问题,什么是线程安全,如何实现线程安全

线程安全 - 如果线程执行过程中不会产生共享资源的冲突,则线程安全。

线程不安全 - 如果有多个线程同时在操作主内存中的变量,则线程不安全

实现线程安全的三种方式

  1. 互斥同步

临界区:syncronized、ReentrantLock

信号量 semaphore

互斥量 mutex

  1. 非阻塞同步

CAS(Compare And Swap)

  1. 无同步方案

可重入代码

使用 Threadlocal 类来包装共享变量,做到每个线程有自己的 copy

线程本地存储

多线程的三个特性:原子性、可见性、顺序性

常见线程安全的实现方式:synchronized、cas、lock,并行变串行

5.Sleep wait 区别

  1. 对于 sleep() 方法,我们首先要知道该方法是属于 Thread 类中的。而 wait() 方法,则是属于 Object 类中的。

  2. sleep() 方法导致了程序暂停执行指定的时间,让出 cpu 给其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态

  3. 在调用 sleep() 方法的过程中, 线程不会释放对象锁。这句是重点

  4. 而当调用 wait() 方法的时候,线程会放弃对象锁(重点),进入等待此对象的等待锁定池,只有针对此对象调用 notify() 方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。

6.线程的通信方式

wait notify notifyAll

还有就是 Condition 接口的方法(不用掌握,知道不知道目前来说不重要)

7.多线程的作用

提高性能

实现异步处理

8.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

1650933002179-c32b8b26-3590-4cdc-8e2f-0879beb73355.png

9.什么是线程池?常见的线程池有哪些?

线程池就是事先将多个线程对象放到一个容器中,当使用的时候就不用 new 线程而是直接去池中拿线程即可,节省了开辟子线程的时间,提高了代码的执行效率。

线程池的作用

线程池是一种基于池化思想管理线程的工具

- 降低了资源的消耗,提高了线程的利用率,降低创建和销毁线程的消耗。
- 提高响应速度,任务来了,直接有线程可用可执行,而不是先创建线程在执行。
- 提高线程的可管理性,线程是稀缺资源,使用线程池可统一分配调度监控。

线程池解决的问题

线程池解决的核心问题就是资源管理问题,在并发环境下,系统不能确定在任意时刻中,有多少任务需要执行,就有多少资源投入。为解决资源分配问题,线程池采用了“池化思想”,是为了最大化收益,最小风险,而将资源统一在一起管理的一种思想。常见的有:

- 内存池:预先申请内存,提升申请内存速度,减少内存碎片。
- 连接池:预先申请数据库连接,提升申请连接的速度,降低系统的开销
- 实例池:循环使用对象,减少资源在初始化和释放时的昂贵损坏。

常见的线程池的创建

  1. newCachedThreadPool 创建一个可缓存线程池
  2. newFixedThreadpool 创建一个固定大小的线程池,可控制线程最大并发数
  3. newScheduledThreadPool创建一个大小无限的线程池,支持定时及周期性任务执行
  4. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务。

10.如果我一直往线程池里面放任务,会发生什么?

线程池的各种参数

参数含义
corePoolSize核心线程池大小
maximumPoolSize线程池最大容量大小
keepAliveTime线程池空闲时,线程存活时间
TimeUnit线程活动保持时间的单位
workQueue用来存放待执行任务的队列,默认为 BlockingQueue(阻塞队列)
ThreadFactory⽤于设置线程的⼯⼚
RejectedExecutionHandler任务拒绝策略

类比学习一下这些参数,我们把线程池类比为项目组,线程是这个公司的成员

corePoolSize:线程池只最少的线程数,一个项目组总得有 corePoolSize 坚守阵地,都是签订劳动合同了,不能随便撤。

maximumPoolSize:当项⽬很忙时,就得加⼈,请其他项⽬组的⼈来帮忙。但是公司空间有限,最多只能加到 maximumPoolSize 个⼈。当项⽬闲了,就得撤⼈了,最多能撤到 corePoolSize 个⼈

keepAliveTime & unit:上⾯提到项⽬根据忙闲来增减⼈员,那在编程世界⾥,如何定义忙和闲呢?如果⼀个线程在 keepAliveTime(时间数字)* unit(时间单位)时间内都没有执⾏任务,说明这个线程很闲。如果此时线程数⼤于 corePoolSize,这个线程就要被回收了

workQueue:就是任务队列

threadFactory:⾃定义如果创建线程,例如给线程指定⼀个有意义的名字

handler:workQueue 满了(排期满了),再提交任务,该怎么处理呢?这个就是处理策略,线程池提供了 4 种策

略,你也可以实现 RejectedExecutionHandler 接⼝来⾃定义策略

策略
AbortPolicy丢弃任务,抛运行时异常 (默认处理策略)
CallerRunsPolicy执行任务
DiscardPolicy忽视,什么都不会发生
DiscardOldestPolicy丢弃队列⾥最近的⼀个任务,并执⾏当前任务
  1. 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列⾥⾯有任务,线程池也不会⻢上执⾏他们。
  2. 当调⽤ execute() ⽅法添加⼀个任务时,线程池会做如下判断:

a. 如果正在运⾏的线程数量⼩于 corePoolSize,那么⻢上创建线程运⾏这个任务

b. 如果正在运⾏的线程数量⼤于或等于 corePoolSize,那么将这个任务放⼊队列

c. 如果这时候队列满了,⽽且正在运⾏的线程数量⼩于 maximunPoolSize,那么还是要创建⾮核⼼线程⽴刻

运⾏这个任务

d. 如果队列满了,⽽且正在运⾏的线程数量⼤于或等于 maximunPoolSize,那么线程池会抛出

RejectedExecutionException

  1. 当⼀个线程完成任务时,它会从队列中取下⼀个任务来执⾏
  2. 当⼀个线程⽆事可做,超过⼀定的时间(keepAliveTime)时,线程池会判断,如果当前运⾏的线程数⼤于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的⼤⼩。

流程处理

1652334570346-2709f5c2-daed-4f0b-8d61-9c19b111607e.png

11.进程间通信有几种方式?

  1. 管道, 分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用 于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信。
  2. 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较 复杂的通信方式, 用于通知进程有某事件发生, 一个进程收到一个信号与处理器 收到一个中断请求效果上可以说是一致的。
  3. 消息队列(message queue):消息队列是消息的链接表,它克服了上两 种通信方式中信号量有限的缺点, 具有写权限得进程可以按照一定得规则向消息 队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。
  4. 共享内存(shared memory):可以说这是最有用的进程间通信方式。它 使得多个进程可以访问同一块内存空间, 不同进程可以及时看到对方进程中对共 享内存中数据得更新。这种方式需要依靠某种同步操作, 如互斥锁和信号量等。
  5. 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间 得同步和互斥手段。
  6. 套接字(socket):这是一种更为一般得进程间通信机制, 它可用于网络 中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(比如同一机器中 MySQL 中的控制台 mysql shell 和 MySQL 服 务程序的连接),这种方式不需要经过网络协议栈, 不需要打包拆包、计算校验 和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。

12.什么是 CAS

概述

CAS 是 compare and swap 的缩写,即我们所说的比较交换。

cas 是一种基于锁的操作,而且是乐观锁。在 java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放之后,下一线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如 通过给记录加 version 来获取数据,性能较悲观锁有很大的提高

CAS 操作包含三个操作数——内存位置( V)、预期原值( A)和新值 (B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS 是通过无限循环来获取数据的,若果在第 轮循环中,a 线程获取地址里面的值被 b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行 。java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的 ( AtomicInteger,AtomicBoolean,AtomicLong)。

CAS 的缺点

1.CAS 容易造成 ABA 问题

虽然使用 CAS 可以实现非阻塞式的原子性操作,但是会产生 ABA 问题,ABA 问题出现的基本流程:

  • 进程 P1 在共享变量中读到值为 A;
  • P1 被抢占了,进程 P2 执行;
  • P2 把共享变量里的值从 A 改成了 B,再改回到 A,此时被 P1 抢占;
  • P1 回来看到共享变量里的值没有被改变,于是继续执行;

虽然 P1 以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA 问题最容易发生在 lock free 的算法中的,CAS 首当其冲,因为 CAS 判断的是指针的地址。如果这个地址被重用了呢,问题就很大了(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)。

维基百科上给了一个形象的例子:你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,然后她很暖昧地挑逗着你,并趁你不注意,把用一个一模一样的手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了。

ABA 问题的解决思路就是使用版本号:在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A->B->A 就会变成 1A->2B->3A。

另外,从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

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

在最初的实例中,可以看出是针对一个共享变量使用了 CAS 机制,可以保证原子性操作。但如果存在多个共享变量,或一整个代码块的逻辑需要保证线程安全,CAS 就无法保证原子性操作了,此时就需要考虑采用加锁方式(悲观锁)保证原子性,或者有一个取巧的办法,把多个共享变量合并成一个共享变量进行CAS操作。

3.循环时间长,开销大

在分析 Unsafe 源代码的时候我们已经提到,在 Unsafe 的实现中使用了自旋锁的机制。在该环节如果CAS操作失败,就需要循环进行CAS操作 (do while 循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成 CPU 极大的开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升。

CAS 的工作原理

CAS 工作原理就是UnSafe 类自旋锁

  1. unsafe 类

unSafe 类在 jdk 的 rt.jar 下面的一个类,全包名是 sun.misc.UnSafe。这个类大多数方法都是 native 方法。由于 Java 不能操作计算机系统,所以设计之初就留了一个 UnSafe 类。通过 UnSafe 类,Java 就可以操作指定内存地址的数据。调用 UnSafe 类的 CAS,JVM 会帮我们实现出汇编指令,从而实现原子操作。现在就来分析一下 AtomicInteger 的 getAndIncrement 方法是怎么工作的。看下面的代码:

java
 public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
 }

这个方法调用的是 unsafe 类的 getAndAddInt 方法,有三个参数。第一个表示当前对象,也就是你 new 的那个 AtomicInteger 对象;第二个表示内存地址;第三个表示自增步伐。然后再点进去看看这个 getAndAddInt 方法。

java
public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        return var5;
    }

这里的 val1 就是当前对象,val2 是内存地址,val4 是 1,也就是自增步伐。首先把当前对象主内存中的值赋给 val5,然后进入 while 循环。判断当前对象此刻主内存中的值是否等于 val5,如果是,就自增,否则继续循环,重新获取 val5 的值。这里的 compareAndSwapInt 方法就是一个 native 方法,这个方法汇编之后是 CPU 原语指令,原语指令是连续执行不会被打断的,所以可以保证原子性。

  1. 自旋锁

所谓的自旋,其实就是上面 getAndAddInt 方法中的 do while 循环操作。当预期值和主内存中的值不等时,就重新获取主内存中的值,这就是自旋。

13.AQS 原理

什么是 AQS

14.如何避免死锁

避免死锁的方法破坏产生死锁条件四个中任意一个就行:

破坏互斥条件

无法破坏,因为资源本身是互斥的;

破坏请求与保持条件:

一次性申请所有资源;

破坏不剥夺条件:

占用部分资源的线程进一步申请其它资源时,如果申请不到,可以主动释放它占有的资源;

破坏循环等待条件

靠按顺序申请资源来预防,按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

15.volatile 关键字的作用?

对于可见性,Java 提供了 volatile 关键字来保证可见性和禁止指令重排。volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主
存,当有其他线程需要读取时,它会去内存中读取新值。

从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger。

volatile 常用于多线程环境下的单次操作 (单次读或者单次写)。

16.HashMap 在多线程环境下使用需要注意什么?为什么?

  1. 线程安全问题:HashMap 是线程不安全的,在多线程下进行读写操作,可能会出现数据不一致的情况,可以使用 ConcurrentHashMap 或者给 hashMap 加锁来解决。
  2. 遍历时并发修改问题:在遍历 HashMap 时,如果其它线程对 HashMap 进行修改,可能会导致 ConcurrentModificationException 异常,可以使用 ConcurrentHashMap 的迭代器解决。
  3. 并发时初始化问题:如果多线程同时对一个 HashMap 进行初始化操作,并且会对同一个键进行操作,可能导致数据丢失或者覆盖的问题,可以使用 ConcurrentHashMap 的 putIfAbsent 来解决。
  4. 性能问题:在并发环境下,HashMap 的性能可能会下降,可以使用 ConcurrentHashMap 来避免这个问题。

17.什么是守护线程?有什么用?

守护线程是一个服务线程,用于服务用户线程。

守护线程的作用:守护线程的作用为主线程退出时,自动退出,不需要手动结束。守护线程通常用于执行一些后台任务,不需要和主线程同时进行。

守护线程的主要途径:

  1. 后台任务:守护线程通常用于 执行一些后台任务,比如日志记录、系统监控、内存清理等,这些通常不需要和主线程同步,可以在主线程退出时,自动退出。
  2. 资源回收:守护线程可以在主线程退出时 自动回收一些资源,比如关闭打开文件、释放占用的内存等,避免资源泄漏和资源浪费。
  3. 服务线程:守护线程可以用于提供一些服务,比如监听网络连接、接收客户端请求等。这样可以保持服务的持续运行, 即使主线程退出也不会影响服务的正常工作
最近更新