欢迎光临
我们一直在努力

Java面试高频考点线程篇多线程编程的核心机制

一问一答:启动线程为什么是运行 start () 方法而不是 run () 方法

问:启动线程为什么要调用 start () 方法,而不能直接调用 run () 方法?

答:核心区别在于是否真的创建新线程。

  • 如果直接调用 run (),它只是在主线程里执行了一个普通方法,程序还是串行执行,根本没有开启多线程。
  • 只有调用 start () 方法,JVM 才会真正创建一个新的工作线程,由新线程去执行 run () 里的逻辑,这才实现了多线程并发。

问:能不能详细解释一下 start () 方法内部做了什么?

答:start () 方法是 JVM 和操作系统配合工作的:

  • JVM 接收到指令后,会调用操作系统的底层接口,创建一条全新的线程。
  • 操作系统会为新线程分配 CPU 资源,把它加入到 CPU 的调度队列中。
  • 当新线程获得 CPU 时间片时,它才会去执行 Thread 类里的 run () 方法。整个过程是异步的,主线程不会等待新线程执行完,这样就实现了真正的多线程并行。
  • 问:如果多次调用 start () 方法会怎么样?

    答:多次调用 start () 会直接抛出IllegalThreadStateException异常。因为线程一旦启动完成,状态就会变为终止,不能再次被启动,只能新建一个线程对象再调用一次。

    问:那 run () 方法存在的意义是什么?

    答:run () 方法是线程的执行逻辑载体。它本质上是一个普通的方法,当新线程启动后,JVM 会自动调用它来执行业务代码。我们重写 run (),就是告诉新线程启动后要去做什么事。

    一问一答:守护线程了解吗

    问:Java 里的守护线程和用户线程有什么区别?

    答:可以把 JVM 比作一家公司:

    • 用户线程就是正式员工,比如 main 线程,公司要等所有正式员工都下班了才会关门。
    • 守护线程就是保洁阿姨,比如垃圾回收线程,只要正式员工都走了,不管保洁有没有干完活,公司都会直接锁门下班。

    问:那守护线程的核心作用是什么?

    答:守护线程是为用户线程提供后台服务的,比如 JVM 里的垃圾回收线程,它会在后台默默清理内存,不需要我们手动管理。只要还有一个用户线程在运行,JVM 就会一直工作;当最后一个用户线程结束时,JVM 会直接退出,守护线程也会被强制终止。


    问:怎么把一个线程设置成守护线程?

    答:在调用 start () 方法之前,调用setDaemon(true)就可以把线程标记为守护线程。需要注意的是,守护线程里创建的子线程,默认也是守护线程。


    问:使用守护线程有什么要注意的地方?

    答:因为 JVM 退出时不会等守护线程执行完,所以不要在守护线程里做需要保证完整性的操作,比如写文件、数据库事务,否则可能因为突然终止导致数据丢失。

    一问一答:线程间有哪些通信方式

    问:Java 里线程间有哪些常见的通信方式?

    答:主要有四种方式,就像不同场景下的 “传话方式”:


    问:volatile 和 synchronized 关键字怎么实现通信?

    答:

    • volatile 就像贴在共享变量上的 “实时公告”,所有线程都必须从共享内存读,修改后也必须立刻写回,保证大家看到的值是一致的,间接实现数据传递。
    • synchronized 就像给共享资源上了 “单间锁”,同一时间只有一个线程能进,保证了数据的可见性和排他性,避免多线程同时修改导致混乱。

    问:等待 / 通知机制是什么?

    答:这是 Java 内置的 “喊人干活” 机制,用 wait () 和 notify () 实现:

    • 一个线程修改完数据后,调用 notify () 喊醒等待的线程;
    • 另一个线程发现条件不满足时,调用 wait () 主动睡觉,直到被唤醒。就像厨师做好菜后喊服务员上菜,服务员没菜时就休息等通知。

    问:管道输入 / 输出流是什么?

    答:这是线程间的 “内存快递通道”,专门用来在线程之间传数据,和文件 / 网络流不同,它的传输媒介是内存。有两种类型:面向字节的 PipedOutputStream/PipedInputStream,面向字符的 PipedReader/PipedWriter,就像线程之间的 “内存管道”,一个往里写,一个往外读。


    问:Thread.join () 怎么实现通信?

    答:join () 就是 “等你干完我再干”:

    • 线程 A 调用 thread.join (),就会一直等 thread 线程执行完,再继续自己的逻辑。
    • 因为 thread 线程已经执行完了,它修改的数据肯定能被线程 A 读到,这就实现了线程间的结果传递。

    一问一答:ThreadLocal 是什么

    问:ThreadLocal 到底是什么?

    答:ThreadLocal 就是线程本地变量,本质是给每个线程都发一份变量副本,就像公司给每个员工发一个专属笔记本:

    • 员工(线程)只能读写自己的笔记本(本地副本),看不到别人的内容;
    • 多个员工操作同一个变量时,实际操作的是自己的副本,完全互不干扰,天然避免了线程安全问题。

    问:ThreadLocal 的基本用法是什么?

    答:用法很简单,分三步:

  • 创建:定义一个 ThreadLocal 变量,所有线程共享这个变量引用。java运行
  • public static ThreadLocal<String> localVariable = new ThreadLocal<>();

  • 写入:线程调用set()方法,把数据存到自己的副本里。java运行
  • localVariable.set("test");

  • 读取:线程调用get()方法,只能读到自己之前存的数据,不受其他线程影响。java运行
  • localVariable.get();


    问:ThreadLocal 的核心作用是什么?

    答:核心是线程隔离,解决两个场景问题:

    • 避免多线程并发修改共享变量导致的线程安全问题;
    • 在同一个线程内传递上下文数据,比如 Web 开发里传递用户信息、请求 ID,不用在方法间层层传递参数。

    问:使用 ThreadLocal 要注意什么?

    答:一定要记得手动清理,调用remove()方法,否则会出现内存泄漏:

    • ThreadLocal 的副本是绑定在线程上的,如果线程是复用的(比如线程池),旧数据会一直存在;
    • 不清理的话,不仅会占内存,还可能导致下一个复用线程读到脏数据。

    一问一答:ThreadLocal 的应用场景

    问:ThreadLocal 在实际开发中有哪些典型应用场景?

    答:主要有 3 个核心场景,本质都是 “线程隔离,让每个线程管好自己的事”:


    问:线程池技术里怎么用 ThreadLocal?

    答:线程池就像一个共享的办公室,大家共用资源。为了避免不同员工(线程)互相干扰,用 ThreadLocal 给每个员工发一个专属办公桌:

    • 线程池里的多个任务并发执行时,ThreadLocal 存储每个线程独有的数据;
    • 每个线程只能操作自己的副本,不会和别的线程搞混,安全共享线程池资源。

    问:Web 应用里为什么要用 ThreadLocal?

    答:Web 应用里每个请求就是一个线程,请求嵌套调用很深。ThreadLocal 就像给当前请求发一个专属 “随身公文包”:

    • 存储用户 ID、请求时间、TraceId 等上下文信息;
    • 同一个请求的所有方法都能从公文包里拿数据,不同请求之间完全独立,避免层层传参。

    问:数据库连接怎么用到 ThreadLocal?

    答:为了避免每个线程都重复创建、销毁数据库连接,用连接池复用连接。ThreadLocal 把连接和当前线程绑定,就像给每个线程发一把专属数据库钥匙:

    • 每个线程拿到自己唯一的连接,操作数据库不混乱;
    • 避免多线程同时共用一个连接导致数据错乱和线程安全问题。

    问:这三个场景的核心共同点是什么?

    答:都是为了解决多线程并发冲突和线程内上下文传递的问题。

    • 在共享资源环境下,通过线程隔离保证数据安全;
    • 在复杂调用链路中,实现线程内数据的全局便捷传递。

    一问一答:ThreadLocal 是怎么实现的

    问:ThreadLocal 的底层实现原理是什么?

    答:可以把 ThreadLocal 理解成一个 “钥匙”,真正存数据的是每个线程自己的 “保险柜”(ThreadLocalMap),核心逻辑如下:


    问:set () 方法里到底做了什么?

    答:当我们调用set()存值时,流程是:

  • 先拿到当前线程对象;
  • 再获取这个线程自己的ThreadLocalMap;
  • 把当前 ThreadLocal 对象作为 key,要存的值作为 value,存到这个 map 里;
  • 如果 map 还没创建,就先创建再存。

  • 问:ThreadLocalMap 是什么?它存在哪里?

    答:ThreadLocalMap是 Thread 类里的一个成员变量,叫threadLocals。

    • 每个线程都有自己独立的ThreadLocalMap,就像每个人都有自己的保险柜;
    • 这个 map 里存的是多个Entry节点,每个节点对应一个 ThreadLocal 变量和它的值。

    问:Entry 节点是什么样的?

    答:Entry继承了WeakReference<ThreadLocal<?>>,是一个弱引用:

    • key 是 ThreadLocal 的弱引用;
    • value 是我们存进去的具体值;
    • 用弱引用是为了在 ThreadLocal 不再被使用时,能被 GC 回收,避免内存泄漏。

    问:为什么这样设计就能实现线程隔离?

    答:因为每个线程操作的都是自己的ThreadLocalMap:

    • 存值时,往自己的 map 里存;
    • 取值时,从自己的 map 里找;
    • ThreadLocal 本身只是一个 “钥匙”,不存数据,只是用来定位 map 里的 entry,所以天然实现了线程隔离。

    问:这种设计有什么好处?

    答:

  • 线程隔离:不同线程互不干扰,天然线程安全;
  • 高效:每个线程只操作自己的 map,没有锁竞争;
  • 自动清理:Entry 的 key 是弱引用,ThreadLocal 被回收后,key 会变成 null,后续可以被清理掉,减少内存泄漏风险。
  • 一问一答:ThreadLocal 内存泄漏是怎么回事

    问:ThreadLocal 为什么会出现内存泄漏?

    答:核心原因是key 被回收了,但 value 还留着:

    • ThreadLocalMap 里的 Entry,key 是 ThreadLocal 的弱引用,GC 时只要 ThreadLocal 没其他强引用,key 就会被回收;
    • 但 Entry 的 value 是强引用,只要线程还活着(比如线程池里的线程复用),ThreadLocalMap 就一直存在,value 就没法被回收;
    • 时间久了,这些 “key 没了、value 还在” 的 Entry 越积越多,就造成了内存泄漏。

    问:怎么解决 ThreadLocal 的内存泄漏?

    答:最直接的办法是用完就调用 remove () 方法清理:

    java

    运行

    ThreadLocal<String> localVariable = new ThreadLocal();
    try {
    localVariable.set("数据");
    // 业务逻辑
    } finally {
    // 必须在finally里调用,保证一定执行
    localVariable.remove();
    }

    • remove () 会直接把当前 ThreadLocal 对应的 Entry 从 map 里删掉,连 key 带 value 一起清理,彻底避免泄漏。

    问:那为什么 Entry 的 key 要设计成弱引用?

    答:弱引用其实是为了减少内存泄漏的风险:

    • 如果 key 是强引用,就算外部 ThreadLocal 引用被销毁了,key 还强引用着 ThreadLocal 实例,导致 ThreadLocal 永远没法被 GC,泄漏更严重;
    • 弱引用能让 ThreadLocal 在没人用的时候被回收,至少 key 会消失,后续在 map 扩容 / 清理时,还能把这些 key 为 null 的 Entry 的 value 也清理掉,算是一种兜底保护。

    问:线程池场景下为什么更容易内存泄漏?

    答:线程池里的线程是复用的,生命周期很长,不会随着任务结束而销毁:

    • 任务结束后如果没调用 remove (),线程的 ThreadLocalMap 里的 value 就一直占着内存;
    • 下一个任务复用这个线程时,还可能读到上一个任务的脏数据,既占内存又有业务风险。

    一问一答:ThreadLocalMap 的结构了解吗

    问:ThreadLocalMap 的结构是什么样的?

    答:ThreadLocalMap 虽然叫 Map,但没实现 Map 接口,结构和 HashMap 类似,核心是两个部分:

    • 元素数组:一个Entry[] table数组,存 Entry 类型的元素,Entry 里 key 是 ThreadLocal 的弱引用,value 是存的数据。
    • 散列方法:用哈希取余法,把 ThreadLocal 对象映射到数组下标。

    问:散列方法具体是怎么算的?

    答:计算下标的公式是:

    java

    运行

    int i = key.threadLocalHashCode & (table.length – 1);

    • threadLocalHashCode是每个 ThreadLocal 对象的哈希值;
    • 和数组长度减一做&运算,等价于取余,效率更高;
    • 这个哈希值是用斐波那契数(黄金分割数0x61c88647)作为增量生成的,能让哈希分布非常均匀,减少冲突。

    问:为什么要用斐波那契数做哈希增量?

    答:斐波那契数(黄金分割数)的特点是能让生成的哈希值分布特别均匀,这样不同 ThreadLocal 对象映射到数组不同位置的概率更高,减少哈希冲突,让 ThreadLocalMap 的存取效率更稳定。


    问:Entry 是什么结构?和 HashMap 的 Entry 有什么不一样?

    答:

    • Entry 继承WeakReference<ThreadLocal<?>>,key 是 ThreadLocal 的弱引用,value 是强引用;
    • HashMap 的 Entry 是强引用 key 和 value;
    • 弱引用 key 的好处是,当 ThreadLocal 没有其他强引用时,GC 能自动回收 key,减少内存泄漏风险。

    问:ThreadLocalMap 和 HashMap 的核心区别是什么?

    答:

  • 接口:ThreadLocalMap 没实现 Map 接口,是独立实现;
  • 哈希冲突:HashMap 用链表 / 红黑树解决冲突,ThreadLocalMap 用线性探测法;
  • 引用类型:Entry 的 key 是弱引用,HashMap 是强引用;
  • 用途:专门服务于 ThreadLocal,每个线程自己的 map,实现线程隔离。
  • 一问一答:ThreadLocalMap 怎么解决 Hash 冲突(生动形象版)

    问:ThreadLocalMap 是怎么解决 Hash 冲突的?

    答:ThreadLocalMap 不用 HashMap 那种链表法,而是用开放定址法(线性探测法),可以理解成 “这个坑被占了,就往后找下一个空坑”。

    问:具体是怎么操作的?

    答:

    插入时:先算好下标,如果这个位置已经有 Entry,而且 key 不匹配,就往后逐个找空位置,直到找到 Entry 为 null 的槽位,把新元素放进去。

    查询时:先定位到计算出的下标,如果这个位置的 Entry key 和要查询的 key 不一致,就继续往后逐个检查下一个位置,直到找到匹配的 key 或者遇到空槽。

    问:和 HashMap 的链表法比,这种方式有什么优缺点?

    答:

    优点:结构更简单,没有链表 / 红黑树的额外开销,适合 ThreadLocalMap 这种数据量小、冲突少的场景;哈希分布均匀时,探测效率很高。

    缺点:冲突严重时,探测路径会变长,查询效率下降;删除元素时不能直接删,需要做 “惰性删除”,否则会导致后面的元素找不到。

    问:为什么 ThreadLocalMap 要选开放定址法,而不是链表法?

    答:

    ThreadLocal 的哈希值用黄金分割数生成,分布非常均匀,Hash 冲突概率极低,线性探测的性能损耗很小;

    每个线程的 ThreadLocalMap 里元素数量通常不多,线性探测足够高效;

    结构简单,内存占用少,符合 JVM 底层轻量设计的目标。

    一问一答:ThreadLocalMap 扩容机制了解吗

    问:ThreadLocalMap 什么时候会触发扩容?

    答:扩容触发分两步判断:

  • 在set()方法最后,先做启发式清理cleanSomeSlots,如果没清理到任何数据;
  • 且当前 Entry 数量sz >= 扩容阈值(阈值是数组长度的 2/3,即len * 2/3),就会进入rehash()逻辑。

  • 问:rehash () 里具体做了什么?

    答:rehash()会先清理过期 Entry,再判断是否真的需要扩容:

  • 调用expungeStaleEntries()遍历数组,把 key 为 null 的过期 Entry 全部清理掉;
  • 清理后如果size >= 阈值的3/4(即threshold * 3/4),才会执行resize()扩容。

  • 问:resize () 是怎么扩容的?

    答:扩容流程很清晰:

  • 创建一个新数组,长度是老数组的 2 倍;
  • 遍历老数组的每个 Entry:
      • 如果 Entry 的 key 为 null,就把 value 置为 null 帮助 GC;
      • 如果 key 有效,就用散列方法重新计算在新数组的下标,用开放定址法解决冲突,放到新数组里;
  • 遍历完成后,把table引用指向新数组,同时更新阈值和 size。

  • 问:为什么要先清理过期 Entry 再扩容?

    答:这是为了尽量避免不必要的扩容:

    • 很多时候,清理掉 key 为 null 的过期 Entry 后,有效 Entry 数量会下降,可能就达不到扩容阈值了;
    • 既减少了内存占用,又避免了频繁扩容带来的性能开销。

    问:和 HashMap 的扩容比,ThreadLocalMap 扩容有什么不同?

    答:

    • 触发条件:HashMap 是size >= 阈值就扩容,ThreadLocalMap 要先清理再判断size >= 阈值*3/4才扩容;
    • 扩容倍数:都是扩为原来 2 倍;
    • 重哈希:HashMap 会重新哈希,ThreadLocalMap 也是重新散列,但用的是开放定址法解决冲突;
    • 清理逻辑:ThreadLocalMap 扩容前会主动清理过期 Entry,HashMap 没有这个逻辑。

    一问一答:父子线程怎么共享数据

    问:普通 ThreadLocal 能让父子线程共享数据吗?

    答:不能,普通 ThreadLocal 是线程隔离的,子线程拿不到父线程里的值。要实现父子线程数据共享,需要用InheritableThreadLocal。


    问:InheritableThreadLocal 怎么用?

    答:用法和普通 ThreadLocal 几乎一样:

  • 定义InheritableThreadLocal实例;
  • 在父线程(比如 main 线程)里调用set()存值;
  • 子线程启动后,直接调用get()就能拿到父线程设置的值。
  • 示例代码:

    java

    运行

    public class InheritableThreadLocalTest {
    public static void main(String[] args) {
    final InheritableThreadLocal<String> threadLocal = new InheritableThreadLocal<>();
    // 父线程存值
    threadLocal.set("main");
    // 子线程启动后可以直接获取
    Thread t = new Thread(() -> {
    System.out.println("父线程数据: " + threadLocal.get());
    });
    t.start();
    }
    }


    问:它的实现原理是什么?

    答:核心在Thread类里多了一个成员变量:

    java

    运行

    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

    在Thread.init()初始化子线程时:

  • 如果父线程的inheritableThreadLocals不为空;
  • 就会调用createInheritedMap(),把父线程的inheritableThreadLocals复制一份给子线程;
  • 这样子线程就能访问到父线程里的 InheritableThreadLocal 数据了。

  • 问:和普通 ThreadLocal 比,InheritableThreadLocal 有什么区别?

    答:

    • ThreadLocal:完全线程隔离,父子线程互不感知;
    • InheritableThreadLocal:子线程会继承父线程的inheritableThreadLocals,实现父子线程数据共享;
    • 注意:继承只发生在子线程创建时,如果父线程之后修改了值,子线程不会同步感知到。
    赞(0)
    未经允许不得转载:171主机测评 » Java面试高频考点线程篇多线程编程的核心机制
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址