【Java 线程】SpringBoot 启动后都有哪些线程呢?

1  前言

现在流行搞微服务,基本也都是 SpringBoot 打底的,那么你可否知道一个基本的 SpringBoot 启动后,都开辟了哪些线程呢?这节我们就来看看。

为什么要看呢?这个主要是增加对服务的了解,比如你管的支付中心或者订单中心,你都有哪些线程,各个线程都是干什么的,你不了解这些你怎么调优,你怎么预断它的瓶颈,怎么感知它的变化呢,是不是。多了解,才能在它出问题的时候,更快速的去解决它(再直白点,我们要靠它吃饭,要保障它的稳定呀,哈哈哈,是不是)。

我的基本微服务:数据源 + redisson + web

2  实践

2.1  怎么看

那么,我们既然要看都有哪些线程,其实方法有很多。

(1)最原始的 java命令:jps查看有哪些java进程,jstack查看某个java进程下的线程堆栈信息

(2)IDEA里 debug的时候,是不是也能看到线程的信息。

(3)jvisualVM 是不是也能看,甚至当你 IDEA 装上vm的插件后,就可以直接帮你启动jvisualVM:

(4)arthas 阿里的工具是不是也能看,这里我就不截图了哈,关于arthas 大家可以看这里很详细

那么这里我就用 jvisualVM 来看了哈,其实我感觉殊途同归,他们最后应该都是调用的人家 java命令来获取的信息,然后通过页面的方式呈现出来(我的个人猜测哈)。

2.2  线程详解

首先我们从整体看看:

不同的颜色表示不同的线程状态,关于线程状态大家也不用死记,Thread类里,有一个 State枚举,有多少个枚举就是多少个线程状态。

可以看到一个基本的微服务启动后,大概有60多个存活的线程,其中有25个是守护线程。还可以看到每个线程的运行时间、睡眠时间等。接下来我们就大概看看每种线程都是干什么的。

2.2.1  JVM 自己的线程

我们的服务本身是一个 Java 程序,Java 程序依赖不开它的 JVM,我们的代码都是跑在 JVM 这个进程之上的。那么 JVM 在启动后,会有一些属于自己的线程。我也是了解以后看到的:

AttachListener线程是负责接收到外部的命令,而对该命令进行执行的并且把结果返回给发送者。通常我们会用一些命令去要求JVM给我们一些反馈信息,如:java -version、jmap、jstack等等。如果该线程在JVM启动的时候没有初始化,那么,则会在用户第一次执行JVM命令时,得到启动。

Attach Listener线程的职责是接收外部JVM命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部JVM命令时,进行初始化工作。

可以看出以上的AttachListener线程和Signal Dispatcher线程是属于两者相互解耦的线程,有点类似单一职责且加入了命令模式的概念在里面,一个抓门负责接受(AttachListener)之后进行封装后转发给执行线程(Signal DIspatcher)统一进行执行和派遣工作。如同邮局收件和运件属于两个部门。

DestroyJavaVM:执行main()的线程在main执行完后调用JNI中的jni_DestroyJavaVM()方法唤起DestroyJavaVM线程。   JVM在Jboss服务器启动之后,就会唤起DestroyJavaVM线程,处于等待状态,等待其它线程(java线程和native线程)退出时通知它卸载JVM。线程退出时,都会判断自己当前是否是整个JVM中最后一个非deamon线程,如果是,则通知DestroyJavaVM线程卸载JVM。ps:扩展一下:1.如果线程退出时判断自己不为最后一个非deamon线程,那么调用thread->exit(false),并在其中抛出thread_end事件,jvm不退出。2.如果线程退出时判断自己为最后一个非deamon线程,那么调用before_exit()方法,抛出两个事件: 事件1:thread_end线程结束事件、事件2:VM的death事件。然后调用thread->exit(true)方法,接下来把线程从active list卸下,删除线程等等一系列工作执行完成后,则通知正在等待的DestroyJavaVM线程执行卸载JVM操作。

Finalizer线程:这个线程也是在main线程之后创建的,其优先级为10,主要用于在垃圾收集前,调用对象的finalize()方法;关于Finalizer线程的几点:1)只有当开始一轮垃圾收集时,才会开始调用finalize()方法;因此并不是所有对象的finalize()方法都会被执行;2)该线程也是daemon线程,因此如果虚拟机中没有其他非daemon线程,不管该线程有没有执行完finalize()方法,JVM也会退出;3) JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收;4) JVM为什么要单独用一个线程来执行finalize()方法呢?如果JVM的垃圾收集线程自己来做,很有可能由于在finalize()方法中误操作导致GC线程停止或不可控,这对GC线程来说是一种灾难;

GC Daemon:是JVM为RMI提供远程分布式GC使用的,GC Daemon线程里面会主动调用System.gc()方法,对服务器进行Full GC。 其初衷是当RMI服务器返回一个对象到其客户机(远程方法的调用方)时,其跟踪远程对象在客户机中的使用。当再没有更多的对客户机上远程对象的引用时,或者如果引用的“租借”过期并且没有更新,服务器将垃圾回收远程对象。不过,我们现在jvm启动参数都加上了-XX:+DisableExplicitGC配置,所以,这个线程只有打酱油的份了。

Reference Handler:JVM在创建main线程后就创建Reference Handler线程,其优先级最高,为10,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题。

2.2.2  boundedElastic-evictor-1

这个线程是干什么的,我还真不知道。搜了搜好像是我们 SpringBoot 的 actuator 自带的监控功能。那我们再深入一下,至少要了解一下它是哪个类启动的,什么时候谁把它启动的呢?是不是。

我 debug了一下,发现它是这个类 BoundedElasticScheduler 启来的:

我发现我的 POM里边没有引入 actuator 的包,它是咋来的呢?

通过看依赖关系,发现,哦原来它是 redission 的 starter 引进来的。那么它是怎么启动的?莫非是 redission 把它带起来的么?这个待我后续另起一篇写哈,我发现它的启动还挺复杂。算是给我留个作业哈。

2.2.3  Tomcat 相关

Tomcat 启动本身有很多的线程,关于 Tomcat我之前也写过几篇,大家不知道的可以看看我之前的哈,这里涉及 Tomcat 的线程我都圈起来了。

依次简单说下都是干什么的:

Catalina-utility 打头的,类似工具线程,主要是干杂活,比如在后台定期检查Session是否过期、定期检查Web应用是否更新(热部署热加载)、检查异步Servlet的连接是否过期等等。

container 打头的,它有点类似比如我们以前会往webapp文件夹下新增war包,那么该war包就会被解析并生成一个应用,ContainerBackgroundProcessor线程就是负责扫描工作的。

Acceptor 线程:用于接收请求的线程,并封装成 PollerEvent 塞进 Poller 线程的队列里,交给 Poller 处理。

Poller 线程:遍历自身的 PollerEvent 队列,关注 Socket的事件,并封装成 SocketProcessor 交给 Tomcat线程池进行处理。

Tomcat 线程池:也就是 http-nio 打头的这十个,这就是真正处理我们请求,封装 request、response对象,并交给 Context 里的 servlet 进行处理的。10个就是因为默认的核心线程池数是10,最大线程数是200。核心线程不会回收么?

java核心线程池的回收由allowCoreThreadTimeOut参数控制,默认为false,若开启为true,则此时线程池中不论核心线程还是非核心线程,只要其空闲时间达到keepAliveTime都会被回收。但如果这样就违背了线程池的初衷(减少线程创建和开销),所以默认该参数为false。

关于 Acceptor、Poller、Tomcat线程池之间是如何打交道的,可以看看我最近的请求处理过程-连接器的创建和执行 以及 连接处理小细节

2.2.4  数据源相关

SpringBoot 默认的数据源是用的 Hikari 的。HikariPool 就是 HikariCP创建的连接池的名称。那么我们会看到有一个 housekeeper 名字的线程,它是干啥的呢?它是由ScheduledThreadPoolExecutor类型的线程池执行的,也就是说它是一个定时任务。它的主要作用就是:检测时间回拨,并关闭空闲时间超期的连接。

如下图,我们可以看到 housekeeper 线程的创建也是来源于线程池,位于它的线程池创建的时候的构造方法中,并且每隔 30秒执行一次。

其实可以看到他还会创建一些别的线程池,见名知意,我也没细看哈。这里简单看下:
addConnectionExecutor:添加连接的线程池,线程名字是包含 connection adder 的,拒绝策略是丢弃老的,队列是采用的 LinkedBlockingQueue,队列的大小就是我们平时配置的参数即连接池中可用连接的最大数:maxPoolSize 的值,默认的大小是10。
线程池本身的核心线程数以及最大线程数都是1,空闲存活时间是 5秒钟。

closeConnectionExecutor:关闭连接的线程池,线程名字是包含 connection closer 的,拒绝策略是直接用当前线程去执行。队列也是 LinkedBlockingQueue 类型的,队列大小以及线程池大小参数跟 addConnectionExecutor 一致。

为什么在 jvisualVM 线程没看到连接或者关闭连接的线程呢?
看一个小细节,连接或者关闭连接的线程池,创建完线程池,会执行这一行 executor.allowCoreThreadTimeOut(true); 即核心线程是否也开启空闲超时的回收,默认创建的线程池是关闭的。

另外关于线程池的管理其实还有很多门门道道,比如连接的获取过程、超时的怎么回收等,我也没看过,等空了专门写一篇来看哈。

2.2.5  Redisson 相关

剩下的有30个都是 Redisson相关的了,我们也简单看一下它的创建。因为引入的 starter 直接看他的 spring.factories,可以看到引入了一个:RedissonAutoConfiguration

线程创建是在 RedissonConnectionFactory  连接池工厂的 afterPropertiesSet 方法中,我们继续看:

public void afterPropertiesSet() throws Exception {
    if (this.config != null) {
        this.redisson = Redisson.create(this.config);
    }
}

继续看 Redisson 的 create 方法:

/**
 * Create sync/async Redisson instance with provided config
 *
 * @param config for Redisson
 * @return Redisson instance
 */
public static RedissonClient create(Config config) {
    Redisson redisson = new Redisson(config);
    if (config.isReferenceEnabled()) {
        redisson.enableRedissonReferenceSupport();
    }
    return redisson;
}

继续看 Redisson 对象的创建:

protected Redisson(Config config) {
    this.config = config;
    Config configCopy = new Config(config);
    connectionManager = ConfigSupport.createConnectionManager(configCopy);
    evictionScheduler = new EvictionScheduler(connectionManager.getCommandExecutor());
    writeBehindService = new WriteBehindService(connectionManager.getCommandExecutor());
}

继续看 connectionManager 的创建,主要是根据当前的配置比如是集群模式或者单机模式等来创建不同的 manager:

public static ConnectionManager createConnectionManager(Config configCopy) {
    UUID id = UUID.randomUUID();
    
    if (configCopy.getMasterSlaveServersConfig() != null) {
        validate(configCopy.getMasterSlaveServersConfig());
        return new MasterSlaveConnectionManager(configCopy.getMasterSlaveServersConfig(), configCopy, id);
    } else if (configCopy.getSingleServerConfig() != null) {
        validate(configCopy.getSingleServerConfig());
        return new SingleConnectionManager(configCopy.getSingleServerConfig(), configCopy, id);
    } else if (configCopy.getSentinelServersConfig() != null) {
        validate(configCopy.getSentinelServersConfig());
        return new SentinelConnectionManager(configCopy.getSentinelServersConfig(), configCopy, id);
    } else if (configCopy.getClusterServersConfig() != null) {
        validate(configCopy.getClusterServersConfig());
        return new ClusterConnectionManager(configCopy.getClusterServersConfig(), configCopy, id);
    } else if (configCopy.getReplicatedServersConfig() != null) {
        validate(configCopy.getReplicatedServersConfig());
        return new ReplicatedConnectionManager(configCopy.getReplicatedServersConfig(), configCopy, id);
    } else if (configCopy.getConnectionManager() != null) {
        return configCopy.getConnectionManager();
    }else {
        throw new IllegalArgumentException("server(s) address(es) not defined!");
    }
}

我们这里看单机模式的:

这其实就涉及到 netty了又,Redisson 是采用的基于NIO的Netty框架,netty 咱就没有发言权了,没看过源码= =,这里就到这了哈,可以看到32个线程。

2.2.6  ForkJool 相关

ForkJoinPool是ExecutorService的实现类,也是一种特殊的线程池。我们常用的 Stream 当你并行的时候,背后默认使用的线程池就是 commonPool 即 ForkJoinPool。那么我们这里主要看下它跟普通线程池的区别(来自文心一言):

ForkJoinPool 和普通的线程池(例如 ThreadPoolExecutor)都是 Java 中用于并行处理任务的工具,但它们在设计目标和适用场景上有所不同。下面是它们之间的一些主要比较:

  1. 设计目标:

    • ForkJoinPool:专为可以递归分解为更小子任务的问题而设计。它利用工作窃取算法(work-stealing algorithm)来平衡负载,并尝试最大化 CPU 的利用率。它特别适合那些可以自然地分解为多个子任务,并且这些子任务之间可以独立运行,最终再将结果合并的问题。
    • 线程池:更通用,适用于各种类型的任务。它提供了一个线程集合来并行执行任务,但并不特别关注任务的分解或合并。
  2. 任务类型:

    • ForkJoinPool:使用 ForkJoinTask(或其子类 RecursiveAction 和 RecursiveTask)来表示任务。这些任务可以进一步分解为更小的子任务,并可以通过 fork() 方法异步执行。
    • 线程池:可以执行任何实现了 Runnable 或 Callable 接口的任务。
  3. 负载平衡:

    • ForkJoinPool:使用工作窃取算法来自动平衡线程之间的负载。当一个线程完成了它的所有任务时,它会从其他线程的队列中“窃取”任务来执行。
    • 线程池:通常没有内置的负载平衡机制。任务的分配和调度更多地依赖于提交任务的顺序和线程池的配置。
  4. 结果合并:

    • ForkJoinPool:对于 RecursiveTask,可以自动合并子任务的结果。这使得处理如归约操作(如求和、最大/最小值等)的问题变得非常简单。
    • 线程池:需要手动处理结果的合并。如果任务产生结果,通常需要在任务完成后显式地收集和处理这些结果。
  5. 配置和扩展性:

    • ForkJoinPool:通常与可用的处理器核心数相匹配,并尝试最大化 CPU 的利用率。但也可以手动配置线程数。
    • 线程池:提供了更多的配置选项,如核心线程数、最大线程数、队列容量等。这使得线程池可以适应各种不同类型的工作负载和性能要求。
  6. 使用场景:

    • ForkJoinPool:适用于可以递归分解和合并的问题,如排序、搜索、归约操作等。
    • 线程池:适用于各种类型的问题,包括 IO 密集型任务、计算密集型任务、长时间运行的任务等。

总的来说,选择使用 ForkJoinPool 还是线程池取决于具体的应用场景和任务类型。如果你的问题可以自然地分解为多个子任务,并且这些子任务可以独立运行并最终合并结果,那么 ForkJoinPool 可能是一个很好的选择。否则,普通的线程池可能更适合你的需求。

3  小结

好啦,本节就看到这里了哈。唉,最近一直在客户现场处理问题,没个完整的时间段静静的看看技术了。

posted @ 2024-04-20 16:00  酷酷-  阅读(264)  评论(0编辑  收藏  举报