空我的技术杂谈之spring boot启动线程分析

闲来垂钓碧溪上,技术海洋逛一逛,嘿嘿,空我今天带大家看看springboot的web工程启动后,默认启动了多少线程,这些线程又都是干啥的。

分析工具,指路阿里开源神器:arthas

新建一个spring boot web的工程,只有一个简单的controller接口,接口是个单纯的get请求,接受参数输出并打印,纯内存操作(这块就不详细操作了,新手老手应该都很熟悉,哈哈)

贴个图看看代码结构

然后呢,再来一个arthas的线程总览图

Threads Total: 34, NEW: 0, RUNNABLE: 10, BLOCKED: 0, WAITING: 15, TIMED_WAITING: 4, TERMINATED: 0, Internal threads: 5

这个图咋看呢?参看下面的描述

ID: Java 级别的线程 ID,注意这个 ID 不能跟 jstack 中的 nativeID 一一对应。

NAME: 线程名

GROUP: 线程组名

PRIORITY: 线程优先级, 1~10 之间的数字,越大表示优先级越高

STATE: 线程的状态

CPU%: 线程的 cpu 使用率。比如采样间隔1000ms,某个线程的增量 cpu 时间为 100ms,则 cpu 使用率=100/1000=10%

DELTA_TIME: 上次采样之后线程运行增量 CPU 时间,数据格式为秒

TIME: 线程运行总 CPU 时间,数据格式为分:秒

INTERRUPTED: 线程当前的中断位状态

DAEMON: 是否是 daemon 线程

从图中可以看出我们可以做下归类和总结

JVM内部线程5个,其实就是GROUP为-,ID显示-1的那五个,线程具体作用,我们后面再看

arthas工具引入的线程,显式的看的话,是9个,就是arthas开头的那些,因为是工具引入的线程,我们本文就不做分析

JVM 内部线程

Java 8 之后支持获取 JVM 内部线程 CPU 时间,这些线程只有名称和 CPU 时间,没有 ID 及状态等信息(显示 ID 为-1)。 通过内部线程可以观测到 JVM 活动,如 GC、JIT 编译等占用 CPU 情况,方便了解 JVM 整体运行状况。

当 JVM 堆(heap)/元数据(metaspace)空间不足或 OOM 时,可以看到 GC 线程的 CPU 占用率明显高于其他的线程。

####当执行trace/watch/tt/redefine等命令后,可以看到 JIT 线程活动变得更频繁。因为 JVM 热更新 class 字节码时清除了此 class 相关的 JIT 编译结果,需要重新编译。

JVM 内部线程包括下面几种:

JIT 编译线程: 如 C1 CompilerThread0, C2 CompilerThread0

GC 线程: 如GC Thread0, G1 Young RemSet Sampling

其它内部线程: 如VM Periodic Task Thread, VM Thread, Service Thread

JVM线程组

线程组(ThreadGroup)简单来说就是一个线程集合。线程组的出现是为了更方便地管理线程。

线程组是父子结构的,一个线程组可以集成其他线程组,同时也可以拥有其他子线程组。从结构上看,线程组是一个树形结构,每个线程都隶属于一个线程组,线程组又有父线程组,这样追溯下去,可以追溯到一个根线程组------System线程组。

  1. JVM创建的system线程组是用来处理JVM的系统任务的线程组,例如对象的销毁等。
  2. system线程组的直接子线程组是main线程组,这个线程组至少包含一个main线程,用于执行main方法。
  3. main线程组的子线程组就是应用程序创建的线程组。

一个线程可以访问其所属线程组的信息,但不能访问其所属线程组的父线程组或者其他线程组的信息。

OK,接下来我们来一一看下各个线程都是干啥的,嘿嘿。

C1 CompilerThread1 & C2 CompilerThread0

c1、c2 编译器线程由 Java 虚拟机创建,以优化您的应用程序的性能。有时这些线程会倾向于消耗高 CPU。

热点 JIT 编译器

应用程序可能有数百万行代码。然而只有一小部分代码被一次又一次地执行。这个小代码子集(也称为"热点")绝大多数时候就决定了应用程序的性能。在运行时 JVM 使用 JIT (Just in time) 编译器来优化这个热点代码。大多数时候,应用程序开发人员编写的代码并不是最优的。因此,JVM 的 JIT 编译器优化了开发人员的代码以获得更好的性能。为了做这种优化,JIT 编译器使用 C1、C2 编译器线程。

代码缓存

JIT 编译器用于此代码编译的内存区域称为"代码缓存"。该区域位于 JVM 堆和元空间之外。

c1 和 c2 编译器线程有什么区别?

在 Java 的早期,有两种类型的 JIT 编译器:

  1. 客户端

  2. 服务器

    根据您要使用的 JIT 编译器类型,必须下载和安装适当的 JDK。假设您正在构建桌面应用程序,则需要下载具有"客户端"JIT 编译器的 JDK。如果您正在构建服务器应用程序,则需要下载具有"服务器"JIT 编译器的 JDK。

    客户端 JIT 编译器在应用程序启动后立即开始编译代码。服务器 JIT 编译器将观察代码执行相当长的一段时间。根据它获得的执行知识,它将开始进行 JIT 编译。尽管服务器 JIT 编译速度很慢,但它生成的代码将比客户端 JIT 编译器生成的代码更加优越和高效。

    今天,现代 JDK 附带了客户端和服务器 JIT 编译器。两个编译器都试图优化应用程序代码。在应用程序启动期间,使用客户端 JIT 编译器编译代码。后来随着知识的增加,使用服务器 JIT 编译器编译代码。这在 JVM 中称为分层编译。

    JDK 开发人员将它们称为客户端和服务器 JIT 编译器,内部称为 c1 和 c2 编译器。因此,客户端 JIT 编译器使用的线程称为 c1 编译器线程。服务器 JIT 编译器使用的线程称为 c2 编译器线程。

c1、c2编译线程默认大小

c1、c2 编译器线程的默认数量取决于运行应用程序的容器/设备上可用的 CPU 数量。下表汇总了 c1、c2 编译器线程的默认数量:

中央处理器 c1 线程 c2 线程
1 1 1
2 1 1
4 1 2
8 1 2
16 2 6
32 3 7
64 4 8
128 4 10

图:默认c1、c2编译线程数

可以通过将 '-XX:CICompilerCount=N' JVM 参数传递给应用程序来更改编译器线程数。在"-XX:CICompilerCount"中指定的计数的三分之一将分配给 c1 编译器线程。剩余的线程数将分配给 c2 编译器线程。假设要使用 6 个线程(即'-XX:CICompilerCount=6'),那么 2 个线程将分配给 c1 编译器线程,4 个线程将分配给 c2 编译器线程。

VM Periodic Task Thread

该线程是JVM周期性任务调度的线程,它由WatcherThread创建,是一个单例对象。该线程在JVM内使用得比较频繁,比如:定期的内存监控、JVM运行状况监控。

Reference Handler

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

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线程来说是一种灾难。

Attach Listener

负责接收到外部的命令,从而对该命令进行执行,并且把结果返回给发送者。

通常我们会用一些命令去要求jvm给我们一些反馈信息,如:java -version、jmap、jstack等等。 如果该线程在jvm启动的时候没有初始化,那么,则会在用户第一次执行jvm命令时,得到启动。

Signal Dispatcher

Attach Listener线程的职责是接收外部jvm命令,当命令接收成功后,会交给signal dispather 线程去进行分发到各个不同的模块处理命令,并且返回处理结果。

signal dispather线程也是在第一次接收外部jvm命令时,进行初始化工作。

DestroyJavaVM

执行main()的线程在main执行完后调用JNI中的 jni_DestroyJavaVM() 方法唤起DestroyJavaVM 线程。

JVM在 Jboss 服务器启动之后,就会唤起DestroyJavaVM线程,处于等待状态,等待其它线程(java线程和native线程)退出时通知它卸载JVM。线程退出时,都会判断自己当前是否是整个JVM中最后一个非deamon线程,如果是,则通知DestroyJavaVM 线程卸载JVM。

(1)如果线程退出时判断自己不为最后一个非deamon线程,那么调用thread->exit(false) ,并在其中抛出thread_end事件,jvm不退出。

(2)如果线程退出时判断自己为最后一个非deamon线程,那么调用before_exit() 方法,抛出两个事件:thread_end 线程结束事件和VM的death事件。

然后调用thread->exit(true) 方法,接下来把线程从active list卸下,删除线程等等一系列工作执行完成后,则通知正在等待的DestroyJavaVM 线程执行卸载JVM操作。

Service Thread

用于启动服务的线程

VM Thread

JVM中线程的母体,根据HotSpot源码中关于vmThread.hpp里面的注释,它是一个单例的对象(最原始的线程)会产生或触发所有其他的线程,这个单例的VM线程是会被其他线程所使用来做一些VM操作(如清扫垃圾等)。

在 VM Thread 的结构体里有一个VMOperationQueue列队,所有的VM线程操作(vm_operation)都会被保存到这个列队当中,VMThread 本身就是一个线程,它的线程负责执行一个自轮询的loop函数(具体可以参考:VMThread.cpp里面的void VMThread::loop()) ,该loop函数从VMOperationQueue列队中按照优先级取出当前需要执行的操作对象(VM_Operation),并且调用VM_Operation->evaluate函数去执行该操作类型本身的业务逻辑。

VM操作类型被定义在vm_operations.hpp文件内,列举几个:ThreadStop、ThreadDump、PrintThreads、GenCollectFull、GenCollectFullConcurrent、CMS_Initial_Mark、CMS_Final_Remark..... 有兴趣的同学,可以自己去查看源文件。

好了,上面的众多线程,大家也可以看得出来,基本都是JVM本身启动之后的线程,一个普通的JVM程序,启动之后也基本拥有这些线程。

那么相对于springboot的web应用程序来说,与普通JVM程序的不同之处在哪里呢?那就是下面截图里的线程了

这些线程,其实就是Springboot内置的tomcat这个web容器启动之后初始化出来的线程,tomcat容器,正是通过这些线程的运行,来接受网络请求的。

那么,这么多线程,都是做什么用的呢?

Catalina-utility-1&catalina-utility-2

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

http-nio-8080-Poller

名字里带有Poller的线程,其实内部是个Selector,负责侦测IO事件

http-nio-8080-Acceptor

名字里带有Acceptor的线程负责接收浏览器的连接请求。

http-nio-8080-exec-1~http-nio-8080-exec-10

名字里带有-exec的是工作线程,负责处理请求。

关于tomcat线程模型部分,空我后续在单独写一份文章来进行阐述哈,目前的话,读者大大们可以大概了解下tomcat大致有哪些线程。(这个东东跟IO多路复用相关,三言两语说不清,但是网络上的资料也很多,读者大大们也可以自行百度学习哈)

最后的最后,细心的读者大大们可能会发现,诶,有个线程好像没讲到,空我这家伙是不是自己不懂,然后就想偷偷的略过去,忽悠我们聪明的读者大大呢?

嘿嘿嘿,当然不是,相信大家关注的,就是那个container-0那个线程。好嘞,讲完这个再收工。

Container-0

大家有没有想过一个问题呢?

SpringApplication.run(),就这一行代码,为什么执行完之后,jvm不会直接退出呢?这和一般的java普通应用表现不一致呀?

首先,我们先放出一个JVM基础知识:JVM进程,在什么情况下会退出?

导致JVM的退出只有2种情况:

  1. 所有的非daemon进程完全终止
  2. 某个线程调用了System.exit()或Runtime.exit()

那么springboot的web工程没有直接退出,空我给出的判断就是1这种情况,即有某个非daemon的线程没有退出导致的。请看下图:

嘿嘿,不得不说,arthas这个工具还是挺好用的,container-0这个线程的确不是daemon线程。那么怎么确认container-0是不是这个作用呢?来,咱们直接看下源码。

分析下spring-boot的源码,在org/springframework/boot/web/embedded/tomcat/TomcatWebServer.java这里,我们找到了相关的方法和日志打印: spring-boot应用在启动的过程中,由于默认启动了Tomcat暴露HTTP服务,所以执行到了上述方法,而Tomcat启动的所有的线程,默认都是daemon线程,如果这里不加控制的话,启动完成之后JVM也会退出。因此需要显式地启动一个线程,在某个条件下进行持续等待,从而避免线程退出。关于这个await的源码解析呢,这里就先不赘述了,也是等空我后续再出相关文章来阐述哈~~

好了,到此关于springboot启动后内部启动了多少线程,各个线程的大致的作用,就先阐述到这里了。

读者大大们,下次再会。

我是空我,喜欢看假面骑士空我的我,希望自己有空杯心态的我。

参考链接:JVM故障分析及性能优化系列之二:jstack生成的Thread Dump日志结构解析

相关推荐
励碼3 分钟前
Spring Security 6.3 权限异常处理实战解析
spring boot
m0_748257181 小时前
Spring Boot FileUpLoad and Interceptor(文件上传和拦截器,Web入门知识)
前端·spring boot·后端
lxyzcm2 小时前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
迷糊的『迷』4 小时前
vue-axios+springboot实现文件流下载
vue.js·spring boot
小池先生5 小时前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
苹果醋36 小时前
2020重新出发,MySql基础,MySql表数据操作
java·运维·spring boot·mysql·nginx
小蜗牛慢慢爬行6 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
azhou的代码园6 小时前
基于JAVA+SpringBoot+Vue的制造装备物联及生产管理ERP系统
java·spring boot·制造
wm10436 小时前
java web springboot
java·spring boot·后端
路在脚下@14 小时前
spring boot的配置文件属性注入到类的静态属性
java·spring boot·sql