04Threadlocal和poll

Threadlocal是什么

ThreadLocal叫做线程变量,意思是ThreadLocal中*****填充的变量**属于当前线程***,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

线程安全问题的核心在于多个线程会对同一个临界区的共享资源进行访问,那如果每个线程都拥有自己的“共享资源”,各用各的,互不影响,这样就不会出现线程安全的问题了,对吧?

事实上,这就是一种“空间换时间”的思想,每个线程拥有自己的“共享资源”,虽然内存占用变大了,但由于不需要同步,也就减少了线程可能存在的阻塞问题,从而提高时间上的效率。

ThreadLocal 并不在 java.util.concurrent 并发包下,而是在 java.lang 包下,但我更倾向于把它当作是一种并发容器。顾名思义,ThreadLocal 就是线程的“本地变量”,即每个线程都拥有该变量的一个副本,达到人手一份的目的,这样就可以避免共享资源的竞争

set方法

set 方法
set 方法用于设置当前线程中 ThreadLocal 的变量值,该方法的源码如下:

public void set(T value) {
	//1. 获取当前线程实例对象
    Thread t = Thread.currentThread();
//2. 通过当前线程实例获取到ThreadLocalMap对象
ThreadLocalMap map = getMap(t);

if (map != null)
   //3. 如果Map不为null,则以当前ThreadLocal实例为key,值为value进行存入
   map.set(this, value);
else
  //4.map为null,则新建ThreadLocalMap并存入value
  createMap(t, value);}

通过 Thread.currentThread() 方法获取当前调用此方法的线程实例。
每个线程都有自己的 ThreadLocalMap,这个映射表存储了线程的局部变量,其中键是 ThreadLocal 对象,值为特定于线程的对象。
如果 Map 不为 null,则以当前 ThreadLocal 实例为 key,值为 value 进行存入;如果 map 为 null,则新建 ThreadLocalMap 并存入 value。
通过源码我们知道,value 是存放在 ThreadLocalMap 里的。来看下 ThreadLocalMap 是什么,先有个简单的认识,后面会细讲。

ThreadLocalMap 是怎样来的呢?通过getMap(t):

1
2
3
ThreadLocalMap getMap(Thread t) {
return t.ThreadLocals;
}

该方法直接返回当前线程对象 t 的一个成员变量 ThreadLocals:

以当前 ThreadLocal 实例作为 key,值为 value 存放到 ThreadLocalMap 中,然后将当前线程对象的 ThreadLocals 赋值为 ThreadLocalMap 对象。

set 方法的重要性在于它确保了每个线程都有自己的变量副本。由于这些变量是存储在与线程关联的映射表中的,所以不同的线程之间的这些变量互不影响。

ThreadLocalMap

ThreadLocalMap 是 ThreadLocal 类的静态内部类,它是一个定制的哈希表,专门用于保存每个线程中的线程局部变量。

和大多数容器一样,ThreadLocalMap 内部维护了一个 Entry 类型的数组 类型的数组 table,长度为 2 的幂次方。

Entry 继承了弱引用 WeakReference<ThreadLocal<?>>,它的 value 字段用于存储与特定 ThreadLocal 对象关联的值。使用弱引用作为键允许垃圾收集器在不再需要的情况下回收 ThreadLocal 实例。

存储数据

哈希表存储

ThreadLocalMap 是使用开放地址法来处理哈希冲突的,和 HashMap 不同,之所以采用不同的方式主要是因为:

ThreadLocalMap 中的哈希值分散的比较均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,冲突的概率就更小了。

插入时

ThreadLocal 的 hashCode 是通过 nextHashCode() 方法获取的,该方法实际上是用 AtomicInteger 加上 0x61c88647 来实现的。

0x61c88647 是一个魔数,用于 ThreadLocal 的哈希码递增。这个值的选择并不是随机的,它是一个质数,具有以下特性:

  • 质数:它是一个质数,这意味着它不能被除 1 和它本身之外的任何数字整除。
  • 黄金比例:这个数字大约等于黄金比例的 32 位浮点表示的一半。黄金比例具有一些有趣的数学特性,其中之一是与斐波那契数列的关系。
  • 递增分布:在 ThreadLocal 中,这个数字用于在哈希表中分散不同线程的哈希码,从而减少冲突。每当创建新的 ThreadLocal 对象时,都会将此值添加到上一个 ThreadLocal 的哈希码中。这个递增的步长有助于在哈希表中均匀地分配 ThreadLocal 对象。
  • 性能优化:通过使用这个特定的值,算法能够确保哈希码的均匀分布,从而减少哈希冲突的可能性。这对于哈希表的性能至关重要,因为冲突可能会降低查找的效率。

[02、怎样确定新值插入的位置?]

通过这行代码:key.ThreadLocalHashCode & (len-1)

HashMap 一样,通过当前 key 的 hashcode 与哈希表大小相与。原理我们在 HashMap 的时候已经讲过了,不记得的小伙伴可以回去看一遍。

[03、怎样解决 hash 冲突?]

通过 nextIndex(i, len),该方法中的((i + 1 < len) ? i + 1 : 0); 能不断往后线性探测,当到哈希表末尾的时候再从 0 开始,成环形。

[04、怎样解决“脏”Entry?]

我们知道,使用 ThreadLocal 有可能存在内存泄漏的问题,针对这种 key 为 null 的 Entry,我们称之为“stale entry”,直译为不新鲜的 entry,我把它理解为“脏 entry”。

当然了,Josh Bloch 和 Doug Lea 已经替我们考虑了这种情况,源码中提供了这些解决方案:

在向ThreadLocalMap添加新条目时,可以检查是否有“脏”Entry(键为null的Entry),并用新的条目替换它。这就是源码中的replaceStaleEntry方法所做的事情。

Key和value

ThreadLocalMapkeyvalue 引用机制:

  • key 是弱引用ThreadLocalMap 中的 key 是 ThreadLocal 的弱引用 (WeakReference<ThreadLocal<?>>)。 这意味着,如果 ThreadLocal 实例不再被任何强引用指向,垃圾回收器会在下次 GC 时回收该实例,导致 ThreadLocalMap 中对应的 key 变为 null
  • value 是强引用:即使 key 被 GC 回收,value 仍然被 ThreadLocalMap.Entry 强引用存在,无法被 GC 回收。

ThreadLocal 实例失去强引用后,其对应的 value 仍然存在于 ThreadLocalMap 中,因为 Entry 对象强引用了它。如果线程持续存活(例如线程池中的线程),ThreadLocalMap 也会一直存在,导致 key 为 null 的 entry 无法被垃圾回收,即会造成内存泄漏。

也就是说,内存泄漏的发生需要同时满足两个条件:

  1. ThreadLocal 实例不再被强引用;
  2. 线程持续存活,导致 ThreadLocalMap 长期存在。导致 key 为 null 的 entry 无法被垃圾回收,即会造成内存泄漏。

虽然 ThreadLocalMapget(), set()remove() 操作时会尝试清理 key 为 null 的 entry,但这种清理机制是被动的,并不完全可靠。

如何避免内存泄漏的发生?

  1. 在使用完 ThreadLocal 后,务必调用 remove() 方法。 这是最安全和最推荐的做法。 remove() 方法会从 ThreadLocalMap 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 ThreadLocal 定义为 static final,也强烈建议在每次使用后调用 remove()
  2. 在线程池等线程复用的场景下,使用 try-finally 块可以确保即使发生异常,remove() 方法也一定会被执行。

为什么ThreadLocal的key必须是弱引用?——详细解释

核心问题:为什么tl = null后ThreadLocal实例无法被回收?

关键点:当tl = null后,ThreadLocalMap仍然持有对ThreadLocal实例的强引用,导致GC无法回收它。

场景分析(key是强引用的情况)

1
2
3
ThreadLocal<String> tl = new ThreadLocal<>(); // 创建ThreadLocal实例
tl.set("hello"); // ThreadLocalMap存储:key = tl(强引用),value = "hello"
tl = null; // 外部引用被置为null
  1. 初始状态
    • tl是一个强引用,指向ThreadLocal实例
    • ThreadLocalMap(属于当前线程)中存储了一个Entry:key = tl(强引用),value = "hello"
  2. tl = null
    • 外部代码不再持有对ThreadLocal实例的强引用
    • ThreadLocalMap仍然持有对ThreadLocal实例的强引用
    • 因为key是强引用,所以ThreadLocalMap对ThreadLocal实例的引用是强引用
  3. GC行为
    • GC会检查所有对象的引用
    • ThreadLocal实例被ThreadLocalMap强引用,所以不会被回收
    • ThreadLocal实例内存泄漏:一直占用内存

为什么这样会导致内存泄漏?

想象一下:

  • 你创建了一个ThreadLocal实例,然后将其设置为null
  • 但ThreadLocalMap(属于线程)仍然”抓着”这个实例不放
  • 线程可能长期存活(如线程池中的线程)
  • 这个ThreadLocal实例会一直占用内存,直到线程结束

对比:key是弱引用的情况

1
2
3
ThreadLocal<String> tl = new ThreadLocal<>(); // 创建ThreadLocal实例
tl.set("hello"); // ThreadLocalMap存储:key = tl(弱引用),value = "hello"
tl = null; // 外部引用被置为null
  1. tl = null
    • 外部代码不再持有对ThreadLocal实例的强引用(运行完栈不再指向队中的ThreadLocal实例)
    • ThreadLocalMap中key是弱引用,所以ThreadLocalMap对ThreadLocal实例的引用是弱引用
  2. GC行为
    • GC会回收ThreadLocal实例(因为没有强引用)
    • ThreadLocalMap中的key会变成null
    • 为后续清理提供机会

为什么”ThreadLocalMap仍然持有对tl的强引用”会导致问题?

这是理解的关键:

  • ThreadLocalMap是Thread的一部分,而Thread通常不会很快结束(特别是线程池中的线程)
  • ThreadLocalMap的生命周期与Thread相同,线程存活,ThreadLocalMap就存活
  • 当key是强引用时,ThreadLocalMap对ThreadLocal实例的引用阻止了GC回收

为什么不能让ThreadLocalMap的key是弱引用,value是弱引用?

  • 如果value也是弱引用,那么即使ThreadLocal实例还在,value也可能被GC提前回收
  • 这违背了ThreadLocal的设计目标:只要线程还活着,就应该能获取到存储的值
  • 例如:String value = tl.get() 可能返回null,即使之前set过

🔍 为什么必须static?(用生活案例讲透)

✅ 一句话核心结论(面试必答)

“ThreadLocal实例(如ThreadLocal<String> threadLocal)必须设为static,
因为它是’线程局部变量的管理器’,需要被所有线程共享——
只有static才能保证:
1. 所有线程用同一个’推车池’
2. 避免重复创建对象(内存浪费)
3. 确保线程安全(不互相干扰)!” 🔑

❌ 误区:把ThreadLocal当普通变量

1
2
3
4
5
6
7
8
public class UserContext {
// 错误!非static的ThreadLocal
private ThreadLocal<String> user = new ThreadLocal<>(); // 每个UserContext实例独立

public void setUser(String name) {
user.set(name); // 每个UserContext实例有自己的'推车'
}
}
  • 问题

    :当多个线程共享同一个

    1
    UserContext

    实例时:

    • 线程A:context.setUser("Tom") → Tom存入线程A的推车
    • 线程B:context.setUser("Jerry") → Jerry存入同一个推车?
      → 实际上,因为user是实例变量,线程B的user指向的是context另一个推车
  • 结果:线程A和B的值互相覆盖,并发崩溃!

✅ 正确做法:ThreadLocal实例必须static

1
2
3
4
5
6
7
8
9
10
11
12
public class UserContext {
// 正确!static的ThreadLocal(推车池)
private static final ThreadLocal<String> USER = new ThreadLocal<>();

public static void setUser(String name) {
USER.set(name); // 所有线程都用同一个推车池
}

public static String getUser() {
return USER.get(); // 每个线程取自己的推车
}
}
  • 运作原理

    1. USER是类级别的单例(所有线程共享同一个ThreadLocal对象)。
    2. 线程A调用USER.set("Tom") → Tom存入线程A的槽位
    3. 线程B调用USER.set("Jerry") → Jerry存入线程B的槽位
    4. 互不干扰!线程A永远看不到Jerry。

💡 类比
“ThreadLocal实例 = 便利店的’推车池’(static)
线程 = 顾客
值(Tom/Jerry)= 顾客的购物车内容
—— 推车池固定(static),顾客取车用(set),放回车(get),
谁也抢不到别人的车!” 🛒

总结

核心原因:ThreadLocal的key必须是弱引用,是为了确保当外部代码不再需要ThreadLocal实例时,ThreadLocal实例可以被GC回收,避免内存泄漏。

为什么”tl无法被GC回收”:因为ThreadLocalMap(属于线程)对ThreadLocal实例的引用是强引用,阻止了GC回收。

💡 重要提示:即使key是弱引用,ThreadLocal的value仍然是强引用,如果忘记调用remove(),value仍然会泄漏。所以,正确使用ThreadLocal的关键是:使用完后调用remove()

Threadpool

一、什么是线程池

线程池其实是一种池化的技术实现,池化技术的核心思想就是实现资源的复用,避免资源的重复创建和销毁带来的性能开销。线程池可以管理一堆线程,让线程执行完任务之后不进行销毁,而是继续去处理其它线程已经提交的任务。

使用线程池的好处

降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

image-20251119220659721

二、线程池的构造

Java 主要是通过构建 ThreadPoolExecutor 来创建线程池的。

  • corePoolSize:线程池中用来工作的核心线程数量。
  • maximumPoolSize:最大线程数,线程池允许创建的最大线程数。
  • keepAliveTime:超出 corePoolSize 后创建的线程存活时间或者是所有线程最大存活时间,取决于配置。
  • unit:keepAliveTime 的时间单位。
  • workQueue:任务队列,是一个阻塞队列,当线程数达到核心线程数后,会将任务存储在阻塞队列中。
  • threadFactory :线程池内部创建线程所用的工厂。
  • handler:拒绝策略;当队列已满并且线程数量达到最大线程数量时,会调用该方法处理任务。

运行过程

img线程池的工作机制是:当当前活动线程数小于corePoolSize时,即使有空闲线程,线程池也会创建新的线程来处理任务。

线程池中线程实现复用的原理

线程池的核心功能就是实现线程的重复利用,那么线程池是如何实现线程的复用呢?

线程在线程池内部其实被封装成了一个 Worker 对象

runWorker 内部使用了 while 死循环,当第一个任务执行完之后,会不断地通过 getTask 方法获取任务,只要能获取到任务,就会调用 run 方法继续执行任务,这就是线程能够复用的主要原因。

但是如果从 getTask 获取不到方法的话,就会调用 finally 中的 processWorkerExit 方法,将线程退出。

这里有个一个细节就是,因为 Worker 继承了 AQS,每次在执行任务之前都会调用 Worker 的 lock 方法,执行完任务之后,会调用 unlock 方法,这样做的目的就可以通过 Woker 的加锁状态判断出当前线程是否正在执行任务。

如果想知道线程是否正在执行任务,只需要调用 Woker 的 tryLock 方法,根据是否加锁成功就能判断,加锁成功说明当前线程没有加锁,也就没有执行任务了,在调用 shutdown 方法关闭线程池的时候,就时用这种方式来判断线程有没有在执行任务,如果没有的话,会尝试打断没有执行任务的线程。

线程是如何获取任务以及如何实现超时的

img

线程池的 5 种状态

线程池内部有 5 个常量来代表线程池的五种状态

1
2
3
4
5
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
  • RUNNING:线程池创建时就是这个状态,能够接收新任务,以及对已添加的任务进行处理。
  • SHUTDOWN:调用 shutdown 方法,线程池就会转换成 SHUTDOWN 状态,此时线程池不再接收新任务,但能继续处理已添加的任务到队列中。
  • STOP:调用 shutdownNow 方法,线程池就会转换成 STOP 状态,不接收新任务,也不能继续处理已添加的任务到队列中任务,并且会尝试中断正在处理的任务的线程。
  • TIDYING:SHUTDOWN 状态下,任务数为 0, 其他所有任务已终止,线程池会变为 TIDYING 状态;线程池在 SHUTDOWN 状态,任务队列为空且执行中任务为空,线程池会变为 TIDYING 状态;线程池在 STOP 状态,线程池中执行中任务为空时,线程池会变为 TIDYING 状态。
  • TERMINATED:线程池彻底终止。线程池在 TIDYING 状态执行完 terminated() 方法就会转变为 TERMINATED 状态。

线程池状态具体是存在 ctl 成员变量中的,ctl 中不仅存储了线程池的状态还存储了当前线程池中线程数的大小

所以也可以看出 shutdown 方法和 shutdownNow 方法的主要区别就是,shutdown 之后还能处理在队列中的任务,shutdownNow 直接就将任务从队列中移除,线程池里的线程就不再处理了。

Executors 构建线程池以及问题分析

虽然 JDK 提供了快速创建线程池的方法,但其实不推荐使用 Executors 来创建线程池。

Executors 返回线程池对象的弊端如下(后文会详细介绍到):

FixedThreadPool 和 SingleThreadExecutor:使用的是阻塞队列 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。

CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。

ScheduledThreadPool 和 SingleThreadScheduledExecutor:使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

回收核心吗?

ThreadPoolExecutor 默认不会回收核心线程,即使它们已经空闲了。这是为了减少创建线程的开销,因为核心线程通常是要长期保持活跃的。但是,如果线程池是被用于周期性使用的场景,且频率不高(周期之间有明显的空闲时间),可以考虑将 allowCoreThreadTimeOut(boolean value) 方法的参数设置为 true,这样就会回收空闲(时间间隔由 keepAliveTime 指定)的核心线程了

线程池的拒绝策略有哪些?

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行者自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

不过,如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行。

  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

线程池中线程异常后,销毁还是复用?

直接说结论,需要分两种情况:

使用execute()提交任务:

​ 当任务通过execute()提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。

使用submit()提交任务:

​ 对于通过submit()提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()返回的Future对象中。当调用Future.get()方法时,可以捕获到一个ExecutionException。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。

简单来说:使用execute()时,未捕获异常导致线程终止,线程池创建新线程替代;使用submit()时,异常被封装在Future中,线程继续复用。这种设计允许submit()提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而execute()则适用于那些不需要关注执行结果的场景。

end

?


04Threadlocal和poll
http://example.com/2025/11/13/04Threadlocal和poll/
作者
無鎏雲
发布于
2025年11月13日
许可协议