尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

谁在调用线程?从类加载到静态块与构造函数的执行线程全解析

谁在调用线程?从类加载到静态块与构造函数的执行线程全解析 1. 类加载阶段静态块到底被谁触发1.1 从一份异常堆栈说起有一次线上告警一个Java服务在启动后不久抛出了ExceptionInInitializerError。我第一反应是某个静态块里出了岔子结果排查时同事问了一句“这个静态块是跑在哪个线程里的”我当时愣了一下直觉回答是启动线程但仔细一想这个问题远比看起来复杂。它牵扯到类加载机制、初始化触发时机、以及线程上下文而这些细节平时几乎没人会主动去验证。我专门写了一个小Demo来观察在静态块里打印当前线程名和ClassLoader在构造方法里也打印同等信息。结果静态块执行时线程名显示的是main而构造方法执行时同样也是main——这看起来两者没什么区别。但当我第二次通过反射Class.forName(xx.xx.YourThreadClass)去触发类加载时静态块没有再执行而构造方法依然会在每次new时都执行一遍。这个差异就是理解这个标题“谁在调用”的核心起点。提示静态块和构造方法的“调用者”不是同一个层面的东西。静态块由JVM类初始化机制触发构造方法由创建实例的代码触发。前者依赖类加载时机后者依赖对象生命周期。1.2 类加载七阶段里藏着答案类从被JVM加载到卸载官方规范里说的是“加载、验证、准备、解析、初始化、使用、卸载”这几步。静态块就挂在“初始化”这个阶段它的正式名称是clinit方法。JVM会把类中所有静态变量的赋值语句和静态块里的代码按源码顺序收集起来合成一个clinit()V方法然后由某个线程去执行它。这个执行线程是谁《Java虚拟机规范》并没有明确规定必须由哪个线程来初始化类只说了JVM必须保证“初始化是线程安全的”——也就是多个线程同时触发同一个类的初始化时只有一个线程能真正执行clinit其余线程必须阻塞等待。具体是哪个线程拿到了执行权一般是“第一个触发该类初始化操作的线程”。在我们的启动类场景里通常是main线程但在Spring容器里可能是某个restartedMain或application线程在Tomcat下还可能是http-nio-exec-*线程。所以静态块里的代码最好是幂等的、无副作用的因为你根本控制不了它挂在哪个线程名下。还需要注意“主动引用”和“被动引用”的区别。使用new、访问静态字段、调用静态方法、反射、初始化子类触发父类——这些是主动引用会触发初始化而引用final静态常量、通过数组定义引用类、引用父类的静态字段但只触发父类初始化——这些是被动引用不会触发初始化。我见过不少人在业务代码里写了个static final Map然后信心满满地在静态块里装了配置数据结果线上某些环境下这个类根本没被初始化Map全是空的等真正使用时报了NPE。排查了半天原因就是该类只被当成符号引用使用从未主动触发过初始化。2. 构造函数一个Thread实例的诞生现场2.1 new Thread()时到底发生了什么与静态块不同构造方法的触发者是直接且明确的谁调用new谁就执行构造。这里的关键在于“调用者线程”和“运行线程”是两个完全不同的概念。Thread对象在构造时执行体是当前创建它的线程但真正运行run()方法的是JVM后续创建的独立操作系统线程。理解不了这一层后面理解start()和run()的差别、理解线程安全问题都会卡壳。如果我们翻阅JDK的Thread.java源码构造方法里其实做了不少事。以new Thread(ThreadGroup group, Runnable target, String name, long stackSize)为例它会做以下事情校验并设置ThreadGroup如果传null则默认继承父线程的线程组通过Thread.currentThread()拿到当前正在构造的线程把当前线程设为新建线程的parent把新线程的daemon、priority、contextClassLoader、inheritableThreadLocals等属性从父线程复制过来调用init方法为线程分配一个全局唯一的tid并根据传入的name或者自动生成的Thread-0命名把传入的Runnable target保存起来如果可以直接访问Thread源码版本还会设置ThreadLocalRandom的种子值。这些动作全部发生在“创建者线程”的上下文里。也就是说如果你在main里执行new Thread()类似“线程名字、线程优先级、是否守护线程”这些初始值全部是从main线程那边继承的。我曾写过一段代码在构造方法里把线程名打印出来再用setName改名确认了线程对象的名称在构造之后就固定了后面Runnable.run()里看到的就是这个新名字而不是父线程的名字。2.2 继承层级里的构造链还有一个容易被人忽视的细节Thread类本身有继承体系。你自己写一个class MyThread extends Thread并且在MyThread的构造方法里调用super(name)这个super(name)会向上追溯到Thread(String name)再追溯到Thread(Runnable target)等更底层的构造方法。JVM会在子类构造方法的第一行隐式或显式地调用父类构造方法一层层把线程属性初始化好然后回到子类构造体里继续执行你的自定义逻辑。所以“谁在调用”如果放到继承语境里答案有两个维度从Java语法层面看是子类构造体触发了父类构造体从运行时层面看是外部创建对象的线程在执行整条构造链。我实际测过一个多层继承的线程类每一层构造方法里打印一次Thread.currentThread().getName()结果最外层到最内层线程名完全一致没有任何变化这直接证明了构造链不会切换执行线程。很多初学者以为“创建线程是不是意味着马上有个新线程在跑构造方法”答案是否定的。构造方法依旧是普通方法只有显式调用start()之后JVM才会创建真正的操作系统线程来执行run()。2.3 Java没有拷贝构造函数别拿C的思维套热词里有个“拷贝构造函数调用时机”那是C的概念。Java里没有拷贝构造函数因为JVM的对象复制走的是clone()或者通过序列化、手动字段拷贝并不是构造函数重新执行一遍。如果你在写线程类时试图做一个“深拷贝”的线程对象把A线程的name、target、priority拷给B线程不要指望JVM会帮你自动做什么老老实实写一个工厂方法或者用Thread的构造参数去传。我见过有人这样写自定义线程类public class MyThread extends Thread { private final String taskId; public MyThread(String taskId) { // 直接把业务ID塞给线程名方便排查问题 super(worker- taskId); this.taskId taskId; } Override public void run() { System.out.println(Thread.currentThread().getName() processing taskId); } }这段代码在概念上是清爽的构造方法只负责属性赋值和父类初始化真正的执行逻辑全部放在run()里。这也是我想强调的第一条实操铁律——不要让构造方法里出现任何和业务执行强相关的逻辑更不要尝试在构造方法里调用start()。3. 从new到start线程对象的生命周期切换点3.1 run()是不能“启动”线程的我可以很肯定地说凡是写过Java的人绝大多数都知道“直接调用run()只是普通方法调用并不会启动新线程”。但能真正从底层解释清楚这一点的人不算多。原因就落在start()和run()的定位上run()是业务逻辑的入口start()才是线程生命周期的开关。start()方法内部会调用一个native方法start0()由它在操作系统层面创建真正的线程实体然后JVM在线程实体创建完成后回调run()方法。也就是说run()是被JVM内部的新线程回调的不是由创建者线程主动调用的。我在项目里做过一个对比实验代码很朴素Thread t new Thread(() - System.out.println(Thread.currentThread().getName())); System.out.println(before start: Thread.currentThread().getName()); t.run(); t.start();输出顺序会是这样before start: main然后run()执行时打印的还是main最后start()之后打印的是Thread-0。这个实验能直观说明“谁在调用”的问题run()被创建线程直接调用所以运行在创建线程里start()之后运行run()的线程才真正变成了新线程。很多人把线程对象的状态和运行线程的状态混为一谈其实是两回事。3.2 一个关于“线程名”的重要差异Thread.currentThread()和this指代的并不一定是同一个对象尤其在Runnable场景下。如果你实现的是Runnable接口再通过new Thread(runnable)去包装那么this是Thread实例而Thread.currentThread()是当前正处于运行状态的线程。在构造方法里this指向刚创建的对象在run()方法里this指向的是包装用的Thread对象但两者大部分属性是一致的毕竟Thread构造的时候就复制了父线程的默认值start()之后内部状态才逐渐分道扬镳。还有一个常见误解是“匿名线程类里可以拿到线程名”每次new Thread() { public void run() {...} }这种写法里如果你在构造方法里调用getName()返回的是系统给的默认名字比如Thread-1如果你在run()里调用Thread.currentThread().getName()同样得到Thread-1。看起来一样但中间隔着类加载、构造和启动三个完全不同的事件。把这些事件分开理解才能真正回答“谁在调用”。3.3 自定义线程工厂给“构造现场”装上监控生产环境里裸奔的new Thread()不多见因为线程池是主流。但线程池同样要创建Thread对象只是把创建动作委托给了ThreadFactory。我们完全可以用自定义ThreadFactory在构造现场埋点把线程名、线程优先级、创建时间、调用栈全部打印出来从根源定位“这个线程是谁创建的、什么时候创建的”。下面是我在项目里用过的一个监控版线程工厂核心逻辑不算复杂但实际排查问题非常给力public class MonitoringThreadFactory implements ThreadFactory { private final AtomicInteger threadId new AtomicInteger(0); private final String poolName; public MonitoringThreadFactory(String poolName) { this.poolName poolName; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, poolName - threadId.incrementAndGet()); t.setUncaughtExceptionHandler((thread, throwable) - { System.err.printf([%s] thread %s crashed: %s%n, LocalTime.now(), thread.getName(), throwable.toString()); }); // 这里就是构造现场的关键观测点 System.out.printf([%s] creating thread %s, creator%s%n, LocalTime.now(), t.getName(), Thread.currentThread().getName()); return t; } }配合ThreadPoolExecutor构造时传入这个工厂每次线程池扩容都能留下创建记录。有一次线上线程数异常攀升我翻日志发现一个任务执行前会多次向线程池提交子任务导致线程池不断新建线程而线程名的前缀一模一样非常方便定位。这就是把“构造时机”和“调用者线程”这两个概念落地到生产场景的典型例子。4. 共享变量构造之后再“看见”什么4.1 AtomicInteger真的线程安全吗热词里有个“atomicinteger线程安全吗”这是个值得展开的经典问题。AtomicInteger基于CAS操作确实能保证单变量读改写组合的原子性但“线程安全”从来不是无条件成立的。如果你用它做计数器每一次incrementAndGet()都是原子的这没问题但如果你先get()再基于旧值做业务判断再用set()或compareAndSet()中间这个过程依然可能被其他线程插入。关键在于“组合操作不具备原子性”这是一个比“类是否线程安全”更高层的思考框架。看一个典型错误场景AtomicInteger counter new AtomicInteger(0); // 线程A和线程B同时执行 int oldValue counter.get(); if (oldValue 0) { counter.set(100); // 这里可能会覆盖另一个线程刚设置的值 }正确的写法是用updateAndGet或compareAndSet配合循环重试。换句话说AtomicInteger本身是线程安全的但用它构建的业务逻辑不一定是线程安全的。理解这一层再回头看Thread类里的共享状态——比如线程是否被中断、守护状态、优先级——它们大多数在构造期间完成初始化之后的修改就应该通过synchronized、volatile或其他同步机制来保护。4.2 volatile与Thread的状态可见性Java内存模型里多个线程之间共享变量的更新不一定立刻可见每个线程可能持有自己的工作内存副本。volatile修饰的变量可以保证可见性禁止指令重排。Thread类里的一些状态字段就用了特殊的内存语义比如线程中断状态。JDK源码里Thread的interrupted状态不是直接暴露给业务代码的平时我们用Thread.currentThread().isInterrupted()就可以检查到自己线程的中断状态这是通过本地方法实现的。我在实际项目里踩过一个坑某个后台任务用了一个boolean stopped标志控制退出主线程设置stopped true后任务线程迟迟不退出。后来把stopped改成volatile boolean问题立刻消失。原因就是普通共享变量在无同步机制时没有可见性保证而对Thread对象本身的管理中类似问题非常普遍——你修改了一个线程对象的属性不代表执行该线程的CPU核心能看到这个修改。对于并发场景下的标志位、开关、状态机优先使用volatile或AtomicBoolean这是最基础也最有效的手段。至于更复杂的线程安全类比如ThreadLocal每个线程都有独立的变量副本天然避开了竞争问题但要注意它和线程生命周期绑定在线程池场景下使用ThreadLocal有内存泄漏风险这是后话。4.3 死锁比静态块更隐蔽热搜词里有“线程死锁”我认为这是多线程领域最值得反复练习的课题。死锁形成的四个必要条件——互斥、持有并等待、不可剥夺、循环等待——教科书上讲得很清楚但实际写出死锁代码太容易了。最经典的双锁死锁例子class DeadlockDemo { private final Object lockA new Object(); private final Object lockB new Object(); public void method1() { synchronized (lockA) { // 模拟工作 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (lockB) { System.out.println(method1 got lockB); } } } public void method2() { synchronized (lockB) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (lockA) { System.out.println(method2 got lockA); } } } }两个线程一个执行method1、一个执行method2各持有一把锁又等待对方释放另一把锁于是卡死。排查死锁最有效的方式是用jstack命令导出线程快照看到输出里带着Found one Java-level deadlock字样基本就实锤了。在预防层面最简单粗暴的办法是所有多把锁的获取顺序保持一致避免两个线程以相反顺序加锁。还有一种思路是使用tryLock并设置超时超时后主动释放已持有的锁从根源打破循环等待条件。5. 生产环境线程池、虚拟线程与排查实录5.1 线程池的排队策略与阻塞队列怎么选ThreadPoolExecutor是很多后端项目的核心执行器它的几个核心参数——核心线程数、最大线程数、空闲保活时间、阻塞队列、拒绝策略——直接影响吞吐和稳定性。网上关于参数设置的说法很多我个人的经验是先看任务类型IO密集型任务的核心线程数可以设大一些CPU密集型任务则建议CPU核数加一或小幅超出避免过多线程切换导致性能下降。阻塞队列的选择也很有讲究ArrayBlockingQueue有界队列可以限制内存占用配合较大的最大线程数能让线程先增长到上限再开始排队LinkedBlockingQueue默认无界最大线程数形同虚设容易内存溢出除非任务量可控否则慎重SynchronousQueue不缓存任务来了就用空闲线程执行没有空闲线程就新建线程适合高并发短任务但线程数上限要设置好PriorityBlockingQueue支持任务优先级适合有明确优先级的任务队列。我遇到过最磨人的一次线程池问题是核心线程数为4、最大线程数为8、队列用了无界LinkedBlockingQueue。某次大促流量进来所有任务全排到队列里线程数一直没涨到8任务积压导致接口超时。后来换成ArrayBlockingQueue(1000)配合CallerRunsPolicy拒绝策略反而把压力反馈回了调用端起到了天然限流的作用。选择阻塞队列的本质是在“排队还是加线程”之间做取舍你要根据业务峰值提前设计和压测。5.2 虚拟线程与传统线程的“构造”差异热词里出现“虚拟线程原理”这也是JDK 21正式引入的大话题。虚拟线程由JVM调度而非操作系统调度用户态切换的成本远低于平台线程。但它的构造机制和传统Thread有个重要区别虚拟线程并不在创建时占用操作系统线程而是通过Executors.newVirtualThreadPerTaskExecutor()或Thread.ofVirtual()等方式创建执行任务时JVM才把它挂到某个载体线程上执行。从“谁在调用”的角度看虚拟线程的构造和运行上下文更灵活。静态块的加载机制不受影响但如果你在传统线程里依靠Thread.currentThread().getName()来做某些日志切分或线程绑定操作换到虚拟线程环境可能会失效——因为虚拟线程没有稳定的系统线程名每次调度到不同载体线程上都可能不同。我目前在虚拟线程上使用的经验是业务代码尽量不要依赖线程名做流转逻辑使用ThreadLocal或显式传递上下文ID更可靠。5.3 从jstack到Arthas现场排查三板斧最后聊诊断工具。线上线程问题我一般按这个顺序排查先看CPU和内存用top -Hp定位高CPU线程把线程PID转成十六进制再用jstack导出线程快照根据十六进制PID在线程栈里找到对应线程就会看到它在执行什么代码如果怀疑锁竞争或死锁用jstack已经足够如果线程池参数需要动态调整用Arthas直接改参数thread命令可以看到所有线程的实时状态和锁信息dashboard能看清线程池的核心指标。有一次排查“线程数持续增长”的问题jstack里看到一堆名为pool-3-thread-*的线程处于WAITING状态但数量比配置的最大线程数还多。进一步检查发现每个请求都会创建新的ThreadPoolExecutor而且用了无界队列导致旧线程池一直存活。这个问题从代码层面才能根治但工具的作用在于快速帮你定位到“哪一行代码创建了这些线程”。这时候配合自定义ThreadFactory里打印的调用栈定位速度能快上数倍。5.4 常见问题速查表现象可能原因处理方式ExceptionInInitializerError静态块中抛异常或静态初始化依赖了未初始化的其他类查看堆栈最内部的Caused by静态块逻辑保持简单NoClassDefFoundError类的初始化失败后再次被引用修复初始化阶段的异常必要时重启JVM构造函数里启动线程子类对象逃逸构造方法里调start()子类run()还没被完整初始化严格禁止在构造方法中启动线程线程池任务积压不执行无界队列 核心线程数过小改有界队列调大核心线程数压测验证修改共享变量后线程不及时感知缺少volatile或同步机制用volatile修饰标志位或用AtomicBooleanThreadLocal值莫名其妙线程池复用线程导致旧值残留每次任务开始设置、结束移除责任人意识要强多个线程卡死无响应锁顺序不一致或死锁循环jstack确认死锁统一加锁顺序用tryLock兜底一个程序只跑一个线程多核用不满任务串行化或没有合理拆分任务分析任务依赖用线程池并行化注意线程安全6. 结尾一点实操感悟写到这里我想起最初为什么会对“谁在调用”这个标题感兴趣。那时候我刚接触Java线程总是天真地以为new Thread()就是“造了一个新线程”后来才知道这只是在Java堆里创建了一个普通对象真正的操作系统线程要等start()之后才诞生。而类加载、静态块、构造函数、线程启动、线程池复用、虚拟线程调度这一层层概念叠加几乎每隔一段时间就会冒出来一个新的坑。我觉得最有价值的经验是遇到线程问题永远先问“这段代码跑在哪个线程上”再问“它什么时候被调用谁触发了它”。把这两个问题想清楚大部分多线程的困惑都能解开一半。静态块的执行线程不由你直接控制构造方法的执行线程就是当前调用者而run()的执行线程在start()之后才真正独立。这三个时间点上“谁在调用”的答案各不相同但只要你愿意花十几分钟写几个打印线程名的测试类亲手验证一遍这些概念就再也不会混淆了。最后如果你也在排查类似“某个线程突然变多”或“静态块里配置没生效”的问题建议直接翻翻项目里有没有自定义ThreadFactory、有没有在构造方法里做多余操作、类的初始化是不是被“被动引用”绕过了。这三个小切口能解决我见过的大部分莫名其妙的问题。线程的世界不难难的是把每个时间节点的执行者搞清楚。
返回列表