Java 线程和操作系统的线程有啥区别?

JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。

在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。

我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。

顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。


进程和线程的关系

从 JVM 角度说进程和线程之间的关系。

下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。

Java 运行时数据区域(JDK1.8 之后)

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的和**方法区 (JDK1.8 之后的元空间)*资源,但是每个线程有自己的*程序计数器虚拟机栈本地方法栈

总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


什么是协程?Java 支持协程吗?

协程(Coroutine),是一个比线程轻量级的执行单位。尤其适合于高并发场景和异步编程。协程在某些语言中被称为绿色线程(Green Threads)。

协程是在程序层面的定义,它不属于操作系统的概念,你可以理解一个线程在执行中可切换执行不同的协程的任务,对 CPU 而言还是在运行同一个线程。

我们在程序层面切换不同的协程来执行任务,它不属于操作系统调用,不涉及系统上下文的切换,所以它的性能相比线程切换会更高。

协程暂停执行后会保存当前的状态,当协程恢复执行时,可以从之前保存的状态开始执行,也就是程序层面来进行协程的调度管理。

像 Java 之前也是有协程的,但是后面废除了,好像是因为难度比较高,但是经过不断地迭代,在 Java21 又引入了虚拟线程,它是用户线程,和其他语言区别在于,它的实现是 JVM 级别,而不是 Java 语言级别。

典型的像 Go 是语言级别支持协程的。

综上,我们要知晓协程是程序调度的基本单位。线程可以包含多个协程,协程可以在单个线程中运行,当然也可以在多个线程之间共享。

总结特征:

  • 协程的切换是由程序显式控制的,而不是由操作系统调度。
  • 协程的开销非常小,比线程更轻量,因为它们不需要操作系统内核的干预。
  • 协程之间的切换是非抢占式的,也就是说,协程只有在显式调用挂起操作时才会切换。

协程优点:

  • 由于不需要内核的参与,协程切换的开销非常低,可以显著提高程序的性能。
  • 协程通过协作式调度避免了许多传统多线程编程中的复杂问题,如锁和竞态条件。
  • 协程更容易实现大量并发操作,因为它们消耗的资源(如内存和 CPU)更少。

使用场景

协程非常适合处理大量 I/O 操作(如网络请求、文件读写等),因为它们可以在等待 I/O 完成时释放 CPU 资源。

协程非常适合高并发场景,例如可以使用协程来处理大量并发连接,以提高吞吐量和响应时间。

关联协程库和框架

除了 Go 语言的 goroutine,还有许多其他语言和框架支持协程,例如:

  • Python 的 asyncio
  • Kotlin 的 协程
  • JavaScript 的 async/await
  • C# 的 async/await

Java 线程的生命周期和状态

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,需要被显式唤醒才能继续执行。
  • TIME_WAITING:超时等待状态,线程进入等待状态,但指定了等待时间,超时后会被唤醒。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

Java 线程状态变迁图(图源:挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误):

Java 线程状态变迁图

  • 当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
  • TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
  • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。

Java 中线程之间如何进行通信?

线程之间的通信(Inter-Thread Communication, ITC)主要依赖于共享内存。由于线程共享同一个进程的内存空间,因此可以直接通过共享变量进行通信。

常见的线程通信方式包括:

1)共享变量:

  • 线程可以通过访问共享内存变量来交换信息(需要注意同步问题,防止数据竞争和不一致)。
  • 共享的也可以是文件,例如写入同一个文件来进行通信。

2)同步机制:

  • synchronized:Java 中的同步关键字,用于确保同一时刻只有一个线程可以访问共享资源,利用 Object 类提供的 wait()notify()notifyAll()实现线程之间的等待/通知机制
  • ReentrantLock:配合 Condition 提供了类似于 wait()、notify() 的等待/通知机制
  • BlockingQueue:通过阻塞队列实现生产者-消费者模式
  • CountDownLatch:可以允许一个或多个线程等待,直到在其他线程中执行的一组操作完成
  • CyclicBarrier:可以让一组线程互相等待,直到到达某个公共屏障点
  • Volatile:Java 中的关键字,确保变量的可见性,防止指令重排
  • Semaphore:信号量,可以控制对特定资源的访问线程数

补充 Object 中的方法说明:

  • wait():使线程进入等待状态,释放锁。
  • notify():唤醒单个等待线程。
  • notifyAll():唤醒所有等待线程。

Java 中父子线程之间如何传递数据?

这个问题其实问的是 InheritableThreadLocal 类。

InheritableThreadLocal 相比 ThreadLocal 它可以在父线程创建子线程的时候,将 InheritableThreadLocal 变量复制给子线程而实现数据的传递。

使用方式和 ThreadLocal 一致,无非就是将类替换成 InheritableThreadLocal 即可。

原理也不难,就是父线程在创建一个新的线程时,会将 InheritableThreadLocal 值传递给子线程。因为 InheritableThreadLocal 重写了 ThreadLocal 类的 createMap 方法,它会在创建新线程时将父线程的 ThreadLocalMap 复制到子线程中,从而使得子线程可以继承父线程的 InheritableThreadLocal 值。

Java 中的线程安全是什么意思?

在Java中,线程安全指的是当多个线程并发访问、变更共享的资源(包括但不限制为共享的全局变量、数据存储),能够保证程序的执行结果是正确和可预测的。

如果不能有效处理多线程对资源的并发访问和变更,就可能会导致并发问题,出现数据不一致、数据竞争等问题。

线程安全实现方式

为了实现线程安全,可以采用以下常见的方法:

1)原子操作:使用不可分割的操作,避免并发修改数据问题,例如基于Java并发包下的原子实现类AtomicInteger进行数据叠加。

2)锁机制:使用 synchronized 关键字或者 Lock 接口及其实现类(如 ReentrantLock )来实现同步控制。

3)线程安全的集合类:使用如 ConcurrentHashMapCopyOnWriteArrayList 等,而不是非线程安全的集合类(如 HashMapArrayList )。

4)并发控制:使用信号量、条件变量等控制线程的执行顺序,例如SemaphoreCountDownLatch

线程安全的集合有哪些?

ConcurrentHashMapCopyOnWriteArrayList 等。

这里的详细一点:【Java】集合(二) | Rean’s Blog (rean-schwarze.github.io)

ConcurrentHashMap

JDK 1.7 ConcurrentHashMap 采用的是分段锁,即每个 Segment 是独立的,可以并发访问不同的 Segment,默认是 16 个 Segment,所以最多有 16 个线程可以并发执行。具体上锁的方式来源于 Segment,这个类实际继承了 ReentrantLock,因此它自身具备加锁的功能。

而 JDK 1.8 移除了 Segment,锁的粒度变得更加细化,锁只在链表或红黑树的节点级别上进行。通过 CAS 进行插入操作,只有在更新链表或红黑树时才使用 synchronized,并且只锁住链表或树的头节点,进一步减少了锁的竞争,并发度大大增加。

并且 JDK 1.7 ConcurrentHashMap 只使用了数组 + 链表的结构,而 JDK 1.8 和 HashMap一样引入了红黑树。

除此之外,还有扩容的区别以及 size 方法的计算也不一样。

CopyOnWriteArrayList

它通过写时复制机制,即在每次修改(写入)操作时,复制原始数组的内容来保证线程安全,在副本上进行修改,然后将修改后的副本替换原来的数据结构。

由于写操作涉及复制整个数组,所以它的写操作开销较大,但读取操作则完全无锁。这使得 CopyOnWriteArrayList 适合于读多写少的场景。

你了解 Java 线程池的原理吗?

首先,简述线程池的作用:线程池是一种池化技术,用于预先创建并管理一组线程,避免频繁创建和销毁线程的开销,提高性能和响应速度。

然后,简单带一下线程池的几个关键的配置:核心线程数、最大线程数、空闲存活时间、工作队列、拒绝策略。

最后,简述一下线程池的工作原理,按照下面的顺序来回答即可:

  1. 默认情况下线程不会预创建,任务提交之后才会创建线程(不过设置 prestartAllCoreThreads 可以预创建核心线程)。
  2. 当核心线程满了之后不会新建线程,而是把任务堆积到工作队列中。
  3. 如果工作队列放不下了,然后才会新增线程,直至达到最大线程数。
  4. 如果工作队列满了,然后也已经达到最大线程数了,这时候来任务会执行拒绝策略。
  5. 如果线程空闲时间超过空闲存活时间,并且线程线程数是大于核心线程数的则会销毁线程,直到线程数等于核心线程数(设置 allowCoreThreadTimeOut 为 true 可以回收核心线程,默认为 false)。

如何设置 Java 线程池的线程数?

线程池的线程数设置需要看具体执行的任务是什么类型的。

任务类型可以分:CPU 密集型任务和 I/O 密集型任务。

CPU 密集型任务

CPU 密集型任务,就好比单纯的数学计算任务,它不会涉及 I/O 操作,也就是说它可以充分利用 CPU 资源(如果涉及 I/O,在进行 I/O 的时候 CPU 是空闲的),不会因为 I/O 操作被阻塞,因此不需要很多线程,线程多了上下文开销反而会变多。

根据经验法则,CPU 密集型任务线程数 = CPU 核心数 + 1

I/O 密集型任务

I/O 密集型任务,有很多 I/O 操作,例如文件的读取、数据库的读取等等,任务在读取这些数据的时候,是无法利用 CPU 的,对应的线程会被阻塞等待 I/O 读取完成,因此如果任务比较多,就需要有更多的线程来执行任务,来提高等待 I/O 时候的 CPU 利用率。

根据经验法则,I/O 密集型任务线程数 = CPU 核心数 * 2 或更多一些。

注意,实际的最佳线程数还是需要具体应用压测分析的,以上公式仅供参考!

Java 线程池核心线程数在运行过程中能修改吗?如何修改?

可以动态修改的。Java 的 ThreadPoolExecutor 提供了动态调整核心线程数和最大线程数的方法。

1)修改核心线程数的方法

  • 使用 ThreadPoolExecutor.setCorePoolSize(int corePoolSize) 方法可以动态修改核心线程数。corePoolSize 参数代表线程池中的核心线程数,当池中线程数量少于核心线程数时,会创建新的线程来处理任务。这个修改可以在线程池运行的过程中进行,立即生效。

2)注意事项

  • 核心线程数的修改不会中断现有任务,新的核心线程数会在新任务到来时生效。
  • setCorePoolSize() 方法可以减少核心线程数,但如果当前线程池中的线程数量超过了新的核心线程数,多余的线程不会立即被销毁,直到这些线程空闲后被回收。

线程池监控与调整

  • 在实际生产环境中,可以通过监控线程池的状态(如当前活跃线程数、队列长度等)来决定是否动态调整线程池大小。
  • 可以使用 JMX(Java Management Extensions)来监控 ThreadPoolExecutor,结合指标来自动调整线程池大小以优化性能。

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

看源码,一共提供了 4 种(其中的 blockPolicy 是 hutool 的不算 ThreadPoolExecutor):

  1. AbortPolicy,当任务队列满且没有线程空闲,此时添加任务会直接抛出 RejectedExecutionException 错误,这也是默认的拒绝策略。适用于必须通知调用者任务未能被执行的场景。
  2. CallerRunsPolicy,当任务队列满且没有线程空闲,此时添加任务由即调用者线程执行。适用于希望通过减缓任务提交速度来稳定系统的场景。
  3. DiscardOldestPolicy,当任务队列满且没有线程空闲,会删除最早的任务,然后重新提交当前任务。适用于希望丢弃最旧的任务以保证新的重要任务能够被处理的场景。
  4. DiscardPolicy,直接丢弃当前提交的任务,不会执行任何操作,也不会抛出异常。适用于对部分任务丢弃没有影响的场景,或系统负载较高时不需要处理所有任务。

自定义拒绝策略

可以实现 RejectedExecutionHandler 接口来定义自定义的拒绝策略。例如,记录日志或将任务重新排队。

public class CustomRejectedExecutionHandler implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println("Task " + r.toString() + " rejected");
// 可以在这里实现日志记录或其他逻辑
}
}

Java 线程池内部任务出异常后,如何知道是哪个线程出了异常?

默认情况下,线程池不会直接报告哪个线程发生了异常,但是可以采取以下几种方法:

1)自定义线程池的 ThreadFactory

  • 通过自定义 ThreadFactory,为每个线程设置一个异常处理器(UncaughtExceptionHandler),在其中记录发生异常的线程信息。

2)使用 Future

  • 提交任务时使用 submit() 方法,而不是 execute(),这样可以通过 Future 对象捕获并检查任务的执行结果和异常。

3)任务内部手动捕获异常并记录

  • 在任务的 run() 方法内部,使用 try-catch 结构捕获异常,并记录或处理异常,同时记录线程信息。

什么是 Java 的 Timer?

Timer 可以实现延时任务,也可以实现周期性任务,它的核心就是一个优先队列和封装的执行任务的线程。

实现原理是:维持一个小顶堆,即最快需要执行的任务排在优先队列的第一个,根据堆的特性我们知道插入和删除的时间复杂度都是 O(logn)。

然后有个 TimerThread 线程不断地拿排着的第一个任务的执行时间和当前时间做对比。

如果时间到了先看看这个任务是不是周期性执行的任务,如果是则修改当前任务时间为下次执行的时间,如果不是周期性任务则将任务从优先队列中移除。最后执行任务。如果时间还未到则调用 wait() 等待。

img

可以看出 Timer 实际就是根据任务的执行时间维护了一个优先队列,并且起了一个线程不断地拉取任务执行。

有什么弊端呢?

首先优先队列的插入和删除的时间复杂度是O(logn),当数据量大的时候,频繁的入堆出堆性能有待考虑。

并且是单线程执行,那么如果一个任务执行的时间过久则会影响下一个任务的执行时间(当然你任务的run要是异步执行也行)。

并且从代码可以看到对异常没有做什么处理,那么一个任务出错的时候会导致之后的任务都无法执行。

Java 并发库中提供了哪些线程池实现?它们有什么区别?

通过Executors工具类可以创建多种类型的线程池,包括:

  • FixedThreadPool:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。

FixedThreadPool

这个线程池实现特点是核心线程数和最大线程数是一致的,然后 keepAliveTime 的时间是 0 ,队列是无界队列。

按照这几个设定可以得知它任务线程数是固定,如其名 Fixed。

然后可能出现 OOM 的现象,因为队列是无界的,所以任务可能挤爆内存。

它的特性就是我就固定出这么多线程,多余的任务就排队,就算队伍排爆了我也不管

因此不建议用这个方式来创建线程池,仅用于提交相对稳定且数量确定的任务场景。

WorkStealingPool

这个实现 JDK8 才有,从代码可以看到返回的就是 ForkJoinPool,我们 JDK8 用的并行流就是这个线程池。

比如 users.parallelStream().filter(...).sum(); 用的就是 ForkJoinPool 。

线程数会参照当前服务器可用的处理核心数,并行数是核心数-1。

这个线程池的特性从名字就可以看出 Stealing,会窃取任务

每个线程都有自己的双端队列,当自己队列的任务处理完毕之后,会去别的线程的任务队列尾部拿任务来执行,加快任务的执行速率。

至于 ForkJoin 的话,就是分而治之,把大任务分解成一个个小任务,然后分配执行之后再总和结果。

SingleThreadExecutor

一个线程池就一个线程,配备的也是无界队列。

它的特性就是能保证任务是按顺序执行的。

CachedThreadPool

这个线程池核心线程数是 0,最大线程数看作无限大,且然后任务队列 SynchronousQueue 是没有存储空间的,每个插入操作必须等待一个删除操作。

简单理解就是来个任务就必须找个线程接着,不然就阻塞了。

cached 意思就是会缓存之前执行过的线程,缓存时间是 60 秒,这个时候如果有任务进来就可以用之前的线程来执行。

所以它适合用在短时间内有大量短任务的场景。如果暂无可用线程,那么来个任务就会新启一个线程去执行这个任务,快速响应任务。

但是如果任务的时间很长,那存在的线程就很多,上下文切换就很频繁,切换的消耗就很明显,并且存在太多线程在内存中,也有 OOM 的风险。

ScheduledThreadPool

用于需要定时或周期性执行任务的场景,底层使用 DelayedWorkQueue 实现延时任务。

Java 中的 DelayQueue 和 ScheduledThreadPool 有什么区别?

DelayQueue 是一个阻塞队列,而 ScheduledThreadPool 是线程池,不过内部核心原理都是差不多的。

DelayQueue 是利用优先队列存储元素,当从队列中获取任务的时候,如果最老的任务已经到了执行时间,可以从队列中出队一个任务,反之可以获得 null 或者阻塞等待任务到时。

ScheduledThreadPool 内部也使用的一个优先队列 DelayedWorkQueue 且可以内部多线程执行任务,支持定时执行的任务,即每隔一段时间执行一次的任务。

如何在 Java 中控制多个线程的执行顺序?

1)CompletableFuture,它内部有 thenRun 的方法

2)synchronized + wait()/notify() ,通过对象锁和线程间通信机制来控制线程的执行顺序。

3)ReentrantLock + condition。

4)Thread 类的 join(),通过调用这个方法,可以使一个线程等待另一个线程执行完毕后再继续执行。

5)CountDownLatch,使一个或多个线程等待其他线程完成各自工作后再继续执行。

6)CyclicBarrier,使多个线程互相等待,直到所有线程都到达某个共同点后再继续执行。

7)Semaphore,控制线程的执行顺序,适用于需要限制同时访问资源的线程数量的场景。

8)线程池,内部仅设置一个线程来执行任务,按序的将任务提交到线程池中就可以了。

为什么在 Java 中需要使用 ThreadLocal?

就是为了通过本地化资源来避免共享,避免了多线程竞争导致的锁等消耗。

这里需要强调一下,不是说任何东西都能直接通过避免共享来解决,因为有些时候就必须共享。

举个例子:当利用多线程同时累加一个变量的时候,此时就必须共享,因为一个线程的对变量的修改需要影响要另个线程,不然累加的结果就不对了。

再举个不需要共享的例子:比如现在每个线程需要判断当前请求的用户来进行权限判断,那这个用户信息其实就不需要共享,因为每个线程只需要管自己当前执行操作的用户信息,跟别的用户不需要有交集。

Java 中的 ThreadLocal 是如何实现线程资源隔离的?

需要在每个线程的本地都存一份值,说白了就是每个线程需要有个变量,来存储这些需要本地化资源的值,并且值有可能有多个,所以怎么弄呢?

在线程对象内部搞个 map,把 ThreadLocal 对象自身作为 key,把它的值作为 map 的值。

这样每个线程可以利用同一个对象作为 key ,去各自的 map 中找到对应的值。

这不就完美了嘛!比如我现在有 3 个 ThreadLocal 对象,2 个线程。

ThreadLocal<String> threadLocal1 =  new ThreadLocal<>();
ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();

那此时 ThreadLocal 对象和线程的关系如下图所示:

这样一来就满足了本地化资源的需求,每个线程维护自己的变量,互不干扰,实现了变量的线程隔离,同时也满足存储多个本地变量的需求,完美!

为什么在 Java 中使用 ThreadLocal 时需要用弱引用来防止内存泄漏?

我们知道,如果一个对象没有强引用,只有弱引用的话,这个对象是活不过一次 GC 的,所以这样的设计就是为了让当外部没有对 ThreadLocal 对象有强引用的时候,可以将 ThreadLocal 对象给清理掉。

那为什么要这样设计呢?

假设 Entry 对 key 的引用是强引用,那么来看一下这个引用链:

从这条引用链可以得知,如果线程一直在,那么相关的 ThreadLocal 对象肯定会一直在,因为它一直被强引用着。

看到这里,可能有人会说那线程被回收之后就好了呀。

重点来了!线程在我们应用中,常常是以线程池的方式来使用的,比如 Tomcat 的线程池处理了一堆请求,而线程池中的线程一般是不会被清理掉的,所以这个引用链就会一直在,那么 ThreadLocal 对象即使没有用了,也会随着线程的存在,而一直存在着!

所以这条引用链需要弱化一下,而能操作的只有 Entry 和 key 之间的引用,所以它们之间用弱引用来实现。

与之对应的还有一个条引用链,结合着上面的线程引用链都画出来:

另一条引用链就是栈上的 ThreadLocal 引用指向堆中的 ThreadLocal 对象,这个引用是强引用。

如果有这条强引用存在,那说明此时的 ThreadLocal 是有用的,此时如果发生 GC 则 ThreadLocal 对象不会被清除,因为有个强引用存在。

当随着方法的执行完毕,相应的栈帧也出栈了,此时这条强引用链就没了,如果没有别的栈有对 ThreadLocal 对象的引用,那么说明 ThreadLocal 对象无法再被访问到(定义成静态变量的另说)。

那此时 ThreadLocal 只存在与 Entry 之间的弱引用,那此时发生 GC 它就可以被清除了,因为它无法被外部使用了,那就等于没用了,是个垃圾,应该被处理来节省空间。

至此,想必你已经明白为什么 Entry 和 key 之间要设计为弱引用,就是因为平日线程的使用方式基本上都是线程池,所以线程的生命周期就很长,可能从你部署上线后一直存在,而 ThreadLocal 对象的生命周期可能没这么长

所以为了能让已经没用 ThreadLocal 对象得以回收,所以 Entry 和 key 要设计成弱引用,不然 Entry 和 key是强引用的话,ThreadLocal 对象就会一直在内存中存在。

但是这样设计就可能产生内存泄漏。

那什么叫内存泄漏?就是指:程序中已经无用的内存无法被释放,造成系统内存的浪费。

当 Entry 中的 key 即 ThreadLocal 对象被回收了之后,会发生 Entry 中 key 为 null 的情况,其实这个 Entry 就已经没用了,但是又无法被回收,因为有 Thread->ThreadLocalMap ->Entry 这条强引用在,这样没用的内存无法被回收就是内存泄露。

那既然会有内存泄漏还这样实现?

这里就要填一填上面的坑了,也就是涉及到的关于 expungeStaleEntry即清理过期的 Entry 的操作。

设计者当然知道会出现这种情况,所以在多个地方都做了清理无用 Entry ,即 key 已经被回收的 Entry 的操作。

比如通过 key 查找 Entry 的时候,如果下标无法直接命中,那么就会向后遍历数组,此时遇到 key 为 null 的 Entry 就会清理掉:

如果将 value 也设置为弱引用,是否可以防止内存泄漏?

答案肯定是可以的。value 一般都是局部变量赋值,栈帧出栈后,局部变量的强引用没了,如果 Entry 对其是弱引用,那么发生一次 gc 后 value 就被回收了,肯定没内存泄漏问题。

但是一次 gc 就没了,等用到的时候不就找不到 value 了?所以 value 不能被设置为弱引用

Java 中使用 ThreadLocal 的最佳实践是什么?

最佳实践是用完了之后,调用一下 remove 方法,手工把 Entry 清理掉,这样就不会发生内存泄漏了!

void yesDosth {
threadlocal.set(xxx);
try {
// do sth
} finally {
threadlocal.remove();
}
}

这就是使用 Threadlocal 的一个正确姿势啦,即不需要的时候,显示的 remove 掉。

当然,如果不是线程池使用方式的话,其实不用关系内存泄漏,反正线程执行完了就都回收了,但是一般我们都是使用线程池的,可能只是你没感觉到。

比如你用了 tomcat ,其实请求的执行用的就是 tomcat 的线程池,这就是隐式使用。

还有一个问题,关于 withInitial 也就是初始化值的方法。

由于类似 tomcat 这种隐式线程池的存在,即线程第一次调用执行 Threadlocal 之后,如果没有显示调用 remove 方法,则这个 Entry 还是存在的,那么下次这个线程再执行任务的时候,不会再调用 withInitial 方法,也就是说会拿到上一次执行的值。

但是你以为执行任务的是新线程,会初始化值,然而它是线程池里面的老线程,这就和预期不一致了,所以这里需要注意。

ThreadLocal 的缺点?

ThreadLocal 的一个缺点:hash 冲突用的是线性探测法,效率低。

还有一个缺点是 ThreadLocal 使用了 WeakReference 以保证资源可以被释放,但是这可能会产生一些 Etnry 的 key 为 null,即无用的 Entry 存在。

所以调用 ThreadLocal 的 get 或 set 方法时,会主动清理无用的 Entry,减轻内存泄漏的发生。

还有一个就是内存泄漏的问题了,当然这个问题只存在于用线程池使用的时候,并且上面也提到了 get 和 set 的时候也能清理一些无用的 Key,所以没有那么的夸张,只要记得用完后调用 ThreadLocal#remove 就不会有内存泄漏的问题了。

sleep() 方法和 wait() 方法对比

共同点:两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

Java 中 Thread.sleep(0) 的作用是什么?

看起来 Thread.sleep(0) 很奇怪,让线程睡眠 0 毫秒?那不是等于没睡眠吗?

是的,确实没有睡眠,但是调用了 Thread.sleep(0) 当前的线程会暂时出让 CPU ,这使得 CPU 的资源短暂的空闲出来别的线程有机会得到 CPU 资源。

所以,在一些大循环场景,如果害怕这段逻辑一直占用 CPU 资源,则可以调用 Thread.sleep(0) 让别的线程有机会使用 CPU。

实际上 Thread.yield() 这个命令也可以让当前线程主动放弃 CPU 使用权,使得其他线程有机会使用 CPU。

为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

参考链接

Java并发常见面试题总结(上) | JavaGuide

操作系统 — 八股文 (interview-points.readthedocs.io)

Java并发常见面试题总结(中) | JavaGuide

Java 与 JVM — 八股文 (interview-points.readthedocs.io)

5.1 进程、线程基础知识 | 小林coding (xiaolincoding.com)