Java并发编程面试题

一、基础

1、并行跟并发的区别

并行:同一时刻,多个线程都在执行,这就要求有多个CPU分别执行多个线程。

并发:在同一时刻,只有一个线程执行,但在一个时间段内,两个线程都执行了。其实现依赖于CPU切换线程,因为切换时间很短,所以基本对于用户是无感知的。

2、什么是进程和线程

进程:程序运行起来后在内存中执行,并附带有运行所需的资源,是系统进行资源分配的基本单位。

线程:CPU是被分配到线程的,所以线程是CPU分配的基本单位。在Java中,当我们启动一个main函数就相当于启动了一个JVM进程,而main函数的线程就是主线程。一个进程中有多个线程,多个线程共用进程的堆和方法区,但每个线程都有自己的程序计数器和栈。

3、线程有几种创建方式

  • 继承Thread类:重写run方法,调用start()方法启动线程。

    • 缺点:单继承,继承了Thread就不能继承别的类了
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static class MyThread extends Thread {
    @Override
    public void run() {
    System.out.println("this is child thread");
    }
    }

    public static void main(String[] args){
    MyThread thread = new MyThread();
    thread.start();
    }
  • 实现Runnable接口:重写run()方法,这是一个任务,要包装成Thread才能start。

    • 优点:只需要实现Runnable,业务逻辑和线程机制解耦。可以被多个线程复用。
    • 缺点:也是用完就销毁。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class RunnableTask extends Runnable {
    public void run() {
    System.out.println("Runnable!");
    }
    }

    public static void main(String[] args){
    RunnableTask task = new RunnableTask();
    new Thread(task).start();
    }
  • 实现Callable接口:重写call()方法,通过FutureTask获取任务执行的返回值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class CallerTask implements Callable < String > {
    public String call () throws Exception {
    return "Hello,i am running!" ;
    }
    public static void main ( String [] args) {
    //创建异步任务
    FutureTask < String > task = new FutureTask < String > ( new CallerTask ());
    //启动线程
    new Thread ( task ) .start();
    try {
    //等 待 执 ⾏ 完 成 ,并获取返回结果
    String result = task . get();
    System.out.println ( result) ;
    } catch ( InterruptedException e ) {
    e.printStackTrace ();
    } catch ( ExecutionException e ) {
    e.printStackTrace ();
    }
    }
    }

4、为什么调用start()方法时会执行run()方法?那为什么不直接调用run()

JVM执行start方法,会先创建一个线程,由创建出来的线程去执行run方法,才能起到多线程的效果。如果直接调用run方法,那么run还是运行在主线程中,相当于顺序执行。

5、线程有哪些常用的调度方法

image

6、线程有几种状态

状态 说明
NEW 初始状态:线程被创建,还没调用start方法
RUNNABLE 运行状态:就绪 + 运行
BLOCKED 阻塞状态:阻塞于锁
WAITING 等待状态:等待其他线程做出一定动作(通知或中断)
TIME_WATING 超时等待:指定时间自行返回
TERMINATED 终止状态:已经执行完毕

image

  • 初始状态:用new Thread()创建线程对象,还没调用start()时,此时只是被实例化。
  • 就绪:线程已经具备运行条形,只差操作系统调度。
  • 运行:当CPU把时间片分配给线程时,进入Running,执行run()方法里的代码。可能会发生:
    • 执行完毕–>终止
    • 调用yield()–>放弃CPU,回到就绪
    • 被系统抢占调度–>回到就绪
    • 进入阻塞或等待–>转到响应状态
  • 阻塞:当线程尝试获取锁,但锁被其他线程占用,会进入阻塞状态。一旦获取锁,就进入running状态
  • 等待:线程调用wait()、join()等时,会进入等待状态。必须由notify()或notifyAll()才能唤醒,回到runnable状态
  • 超时等待:线程调用带超时参数的方法进入超时等待状态。到了超时时间或被notiry()唤醒后回到runnable
  • 终止:执行完run()或抛出未捕获异常结束时,进入终止状态。

7、什么是线程上下文切换

 CPU资源分配采用时间片轮转,也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程用,这就是线程上下文切换。

8、守护线程了解吗

 Java中的线程分为两类:daemon线程(守护线程)和user线程(用户线程)

  在JVM启动时会调用main函数,其所在线程就是一个用户线程。JVM内部还启动了很多守护线程,如垃圾回收线程。

  守护线程和用户线程的区别是,当最后一个非守护线程结束时,JVM会正常退出(此时不管当前是否存在守护线程)。

9、线程间有哪些通信方式

  • volatile和synchronized关键字:
  • volatile:保证变量在多线程间的可见性,不保存原子性。
  • synchronized:保证同一时刻只有一个线程执行被保护的代码块,提供原子性和可见性。常用于操作共享变量临界区的互斥访问。
  • 等待/通知机制:线程调用wait()进入等待队列,释放锁;另一个线程在条件满足后调用notify或notifyAll唤醒。
  • 管道输入/输出流:用于线程间直接传递数据。一个线程写入 PipedOutputStream,另一个线程从 PipedInputStream 读取。适合流式传输。
  • 使用Thread.join():一个线程等待另一个线程执行完毕后再继续。常用于 主线程等待子线程结果 的场景。
  • 使用ThreadLocal:为每个线程单独提供一个变量副本。线程间不共享数据,避免竞争和加锁。

10、ThreadLocal是什么

  ThreadLocal就是线程本地变量,若创建了一个ThreadLocal变量,那访问这个变量的每个线程都会有这个变量的本地拷贝,但多个线程操作这个变量时,实际是操作自己本地内存里的变量,可以起到线程隔离的作用,避免了线程安全问题。

1
2
3
4
5
6
7
8
9
//创建一个ThreadLocal变量localVariable
//创建⼀个ThreadLocal变 量
public static ThreadLocal < String > localVariable = new ThreadLocal < > ();

//写入:线程可以在任何地方使用localVariable
localVariable.set("xxxx");

//读取:线程在任何地方读取的都是它写入的变量
localVariable.get(); //xxxx

11、你在工作中用到过ThreadLocal吗

 用到过,比如在登陆的时候,用户每次访问接口在请求头都会携带一个token,在控制层可以根据这个token,解析出用户的基本信息。由于在后面的服务层、持久层都会用到责怪用户信息,这时候就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样在其他任何地方都可以取出T和read Local中存的用户数据。

  很多其他场景如cookie、session、数据库连接池都可以用ThreadLocal。

12、ThreadLocal怎么实现的

 每个Thread对象里,有一个成员变量ThreadLocal.ThreadLocalMap threadLocals = null; 说明每个线程都有一个属于自己的ThreadLocalMap。当调用threadLocal.set(value) 时,会发生:

  • 先获取当前线程 Thread t = Thread.currentThread();
  • 再拿到该线程ThreadLocalMap
  • 把数据存进去,形式是 <key, value>

  那这里的key和value是什么?value就是set进去的对象。key不是ThreadLocal本身,而是ThreadLocal 的一个 弱引用

  那为什么是弱引用呢?假如key是强引用,若某个ThreadLocal 对象没有外部引用了(ThreadLocal = null),但ThreadLocalMap还持有它,那它就永远不会被GC,造成内存泄露。用了弱引用之后,一旦外部不再持有ThreadLocal,GC就会把它回收。ThreadLocalMap中的key会变成null,只剩下value。JVM之后会清理这些key为null的Entry,避免泄露。

13、ThreadLocal内存泄露是怎么回事

  • key是弱引用:
  • 外部不再引用ThreadLocal 对象,GC 会回收它。ThreadLocalMap里的entry变成<null, value>,value还在,但程序员无法通过ThreadLocal拿到这份数据。若线程是线程池里的长生命周期线程,这块value会一直留在内存,直到线程结束才可能释放–>内存泄露
  • key是强引用:
    • 即使外部不再引用 ThreadLocal,它也不会被 GC,因为 map 还持有强引用。
    • 弱引用可以减轻泄露风险。
  • 如何避免内存泄露(最佳实践)
1
2
3
4
5
6
try {
local.set(new User("Alice"));
// 业务逻辑
} finally {
local.remove(); // ✅ 主动清理,避免泄漏
}

14、ThreadLocalMap的结构了解吗

 ThreadLocalMap是一个定制化的Map,存放在Thread对象里,每个Thread维护一个自己的ThreadLocalMap,里面的key就是弱引用ThreadLocal。它没有实现Map接口(是内部类,只服务于ThreadLocal),主要是一个Entry[] table数组(每个 Entry 保存 <ThreadLocal弱引用, value>)。

  每次创建新的ThreadLocal对象,都会分配一个threadLocalHashCode值。这个值不是简单的1,2,3…自增,而是每次递增一个特殊的常数0x61c88647。这个数来自黄金分割数(√5 - 1) / 2 ≈ 0.618…。这样可以让哈希值分布更均匀,避免冲突集中。

15、ThreadLocalMap怎么结局hash冲突的

 ThreadLocalMap使用开放定址法,这个坑被人占了就去接着找空着的坑。若插入一个value,通过hash计算后应该落入某个槽位,但这个坑已经被占了,且Entry数据的key和当前不相等,此时会线性向后查找,一直找到为null的槽位才会停止。

  get的时候,也会根据ThreadLocal对象的hash值定位到table中的位置,然后判断该槽位Entry对象中的key是否和get的key一致,若不一致,就判断下一个位置。

16、ThreadLocal扩容机制了解吗

 在ThreadLocalMap.set() 里,若存入元素时发现表里的Entry数量达到阈值(len*2/3),就会触发rehash()。

  • 清理掉已经失效(key=null)的Entry
  • 如果清理后size依然>=3/4 * threshold,就触发resize()扩容。
  • 新数组翻倍:N->2N,降低负载因子
  • 遍历老数组:把旧数组里的Entrty一个个搬到新数组。若key已经被GC,就清理掉value
  • 重新计算位置:用新数组长度newLen重新取模
  • 冲突处理:若目标格子被占,就调用nextIndex()往后找下一个空位(开放地址法)
  • 更新引用:搬运完毕后,把table指向newTab。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2; // 新数组长度翻倍
Entry[] newTab = new Entry[newLen];

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // key 已被回收,帮助 GC
} else {
// 重新计算哈希位置
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null) { // 开放地址法,找下一个空位
h = nextIndex(h, newLen);
}
newTab[h] = e; // 放到新数组
}
}
}

table = newTab; // 指向新数组
}

17、父子线程怎么共享数据

  • 普通ThreadLocal不能传递给子线程,因为ThreadLocal的值存放在当前对象的ThreadLocals变量里,就算是父线程,也不算是同一个线程。
  • 解决办法:在Thread类里除了threadLocals之外,还有一个:ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; 关键点在于子线程初始化时,从父线程的InheritableThreadLocalMap拷贝了一份数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
public class InheritableThreadLocalTest {
public static void main(String[] args) {
ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();

// 父线程设置值
threadLocal.set("父线程的值");

// 子线程
new Thread(() -> {
System.out.println("子线程获取:" + threadLocal.get());
}).start();
}
}//子线程获取:父线程的值
  • 限制:只是在创建子线程那一刻复制,后续修改不同步。
  • 线程池问题:线程池里的线程是复用的,子线程不会每次都重新init(),所以默认的InheritableThreadLocal在线程池场景可能会出问题。为解决这个,阿里开源了**TransmittableThreadLocal (TTL)**,专门用于线程池下传递上下文。

18、说一下你对Java内存模型(JMM)的理解

 Java程序运行在各种硬件和操作系统上,不同硬件的CPU缓存策略、内存访问顺序、指令重排规则可能都不一样。那JMM是Java规范定义的一个抽象模型,是一套规则:

  • 线程和主内存的交互:线程如何从主内存读变量、写变量
  • 可见性保证:什么时候一个线程对变量的修改能被另一个线程看到
  • 有序性保证:哪些操作在多线程下不能随意重排,哪些可以。
1
2
3
4
5
//例如
volatile int flag = 0
//在x86CPU上可能会翻译成某种内存屏障指令
//在ARM CPU上可能是另一种
//但Java程序员只需知道:volatile保证可见性和禁止指令重排,效果是一样的

  JMM定义了线程和主内存之间的抽象关系:线程之间共享变量存储在主内存,每个线程有一个私有的本地内存。

  如果是双核CPU架构:

  • 每个核心:控制器+运算器+私有的一级缓存(L1缓存)
  • 共享缓存:有个架构有L2或L3,多个核心共享
  • 主内存:所有CPU都能访问

  JMM内存模型里定义了两个层次:

  • 主内存:所有线程共享,对应硬件上的主内存(DRAM)
  • 工作内存:每个线程独有,用来保存主内存中变量的副本
  • 流程:
    • 变量先从住内存加载到工作内存(寄存器/缓存)
    • 线程所有操作只在工作内存完成
    • 结果再写回主内存
    • 线程之间想看到对方的修改,必须通过主内存完成数据交换

19、说说你对原子性、可见性、有序性的理解

  • 原子性:一个操作不可再分,要么全部完成,要么全部不做
  • 在Java中,基本的读取和写入(如int x = 1)是原子的。但复合操作不是原子的(如i++)
  • 保证方式:synchronized或ReetrantLock(锁住临界区)、AtomicInteger、AtomicLong等原子类(通过CAS+volatile)
  • 可见性:一个线程对共享变量的修改,能被其他线程及时看到。(由于CPU缓存和寄存器存在,线程可能看到的是旧值)
    • 保证方式:volatile(保证写入立刻刷新到主内存);synchronized/Lock(解锁时强制刷新到主内存,加锁时清空工作内存,重新读)
  • 有序性:程序执行顺序和代码顺序一致,但编译器和CPU为了优化,可能会指令重排。(单线程不影响,多线程可能影响)
    • 保证方式:volatile禁止指令重排;synchronized/Lock进入临界区和退出时,JMM会插入内存屏障,保证临界区内操作的顺序性。JMM的happens-before原则,定义哪些操作必须对另一个操作可见,从而间接约束了顺序。

20、说说什么是指令重排

 指令重排 = 编译器或CPU在执行时,为了优化性能,会调整代码语句的执行顺序。(有序性)

  三种指令重排类型:

  • (1)编译器优化的重排:

    • Java源代码–>字节码–>机器指令,中间编译器可能优化。只要不改变单线程的最终结果,就可以调整语句顺序。

    • CPU支持流水行并行,若指令间没有数据依赖,CPU会乱序执行以提高效率

  • (3)内存系统的重排:

    • 因为有CPU cache,写缓冲区,导致内存的读写顺序看起来是乱的。
    • 假如线程A对变量x写入后,先放在写缓冲区,没立刻刷新到主内存。线程B去读时,可能还是旧值。
1
instance = new Singleton();

  三个底层步骤(理想顺序):

  • 分配内存:给Singleton对象分配一块内存控件,假设内存地址时0x1234。
  • 调用构造方法:在0x1234这块内存上,执行构造函数,把对象真正初始化好(比如成员变量赋值)
  • 把引用赋给变量instance:instance指向0x1234,之后通过instance就能找到这个对象。

  指令重排(为了优化性能,步骤2和3可能被交换)。若第三步变成第二步,此时对象还没初始化完。

  如果是多线程:A先执行new Singleton(),到第二步引用赋值给instance,此时线程A被切换走了。线程B看到if(instance == null),发现instance不是null,就直接返回instance,但其实这个uidx还没初始化完成。就可能会出现“半初始化对象”被使用的情况。

21、指令重排有限制吗?happens-before了解吗

 是有限制的,需要遵守两个主要约束:as-if-serial(后面讲)happens-before规则。

  happens-before规则是JMM提供的多线程间的有序性保证,定义了哪些操作对其他线程可见、必须按顺序。定义:如果操作A happens-before 操作B,那A的结果必须对B可见,且A的执行顺序排在B之前。(注意,这是一种约束关系,不等于物理时间顺序。这只是用来保证逻辑先后关系,用来保证多线程下结果正确,同时允许底层做性能优化)

  六大原则:

  • 程序顺序规则:在一个线程内,按代码顺序,前面的操作happens-before 后面的操作
  • 监视器锁规则:对一个锁的解锁 happens-before 随后对这个锁的解锁。(如线程A释放锁->线程B获取同一把锁–>B必然能看到A的修改)
  • volatile变量规则:对一个volatile变量的写 happens-before 后续对这个变量的读。(如线程A flag = true–>线程B读取flag一定能看到true)
  • 传递性:若A happens-before B,B happens-before C,那么A happens-before C。
  • start规则:线程A调用threadB.start(),happens-before 线程B的任意操作。(如A在启动B之前的写操作,B一定都能看到)
  • join()规则:线程A调用threadB.join()并成功返回,意味着线程B的所有操作happens-before A从join返回(如B执行完写操作,A在join后一定能看到结果)

22、as-if-serial是什么?单线程的程序一定是顺序的吗?

 as-if-serial意思是:不管怎么重排,单线程程序的执行结果不能被改变。编译器和处理器不会对存在数据依赖关系的操作做重排,因为这会改变执行结果。但是,若操作之间不存在数据依赖关系,这些操作可能会被编译器和处理器重排。

1
2
3
4
5
6
7
double p i = 3.14 ; // A
double r = 1.0 ; // B
double area = p i * r * r ; // C
//C依赖A和B,A和B之间没有依赖
//顺序1:A-B-C
//顺序2:B-A-C
//C不可能在A、B前面

23、volatile实现原理

(1)可见性

  相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile是更轻量的选择,没有上下文切换的额外开销成本。一个变量被声明为volatile时,线程再写入变量时不会把值缓存在寄存器或其他地方,而是会把值刷新回主内存,当其他线程读取该共享变量,会从主内存获取最新值,而不是使用当前线程的本地内存中的值。

(2)有序性

  没有内存屏障可能会发生什么?

  • CPU可能把flag=true先执行并刷出,而a=1还在寄存器/缓存里没同步到主内存。
  • 指令乱序,导致“半初始化对象”
  • 读到旧值(缓存不一致),若没有屏障,写操作不会强制刷新到主内存

  volatile怎么保证有序性:JMM在volatile前后都会插入内存屏障,限制重排。

  • 写volatile前:保证之前写的变量先对外可见;保证bolatile写对后续读可见
  • 读volatile时:保证volatile读完后,才能读其他变量;保证volatile读完后,才能写其他变量。

  volatile修饰:实例变量、静态变量。不能修饰局部变量、方法和类(在线程栈中,本来就不共享)

线程安全:保证原子性、可见性、有序性

volatile只能保证后两者。

image