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

资讯详情

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

Java线程全解析:从创建方式到线程池的实践指南

Java线程全解析:从创建方式到线程池的实践指南 先在开头点名“Java线程”这个核心关键词告诉读者这篇讲什么适合谁读能解决什么问题。然后用贴近工程师讲话的口吻逐步展开进程与线程的区别、三种创建方式、生命周期与状态切换、线程安全、协作与线程池这几个主题。每个章节都用大量的类比、代码示例、参数解释和实际经验填充确保不低于5000字并在结尾以真实体验收束不做任何官方总结。1. 线程的本质先搞清楚进程和线程的差异很多Java新人刚开始接触线程时第一个困惑就是进程和线程到底有什么区别我当年也是从“一个程序就是一个进程进程里面可以开多个线程”这种模糊概念开始的等到真正写并发代码、定位线上问题时才发现这个理解太肤浅了。进程是操作系统分配资源的基本单位你可以把它理解成一家独立运营的公司有自己的办公场地内存空间、自己的财务系统文件描述符、自己的员工名册线程。每家公司之间的资源是隔离的A公司不可能直接拿B公司的钱这就是为什么进程之间通信需要额外的机制比如管道、消息队列、共享内存。线程则是CPU调度的基本单位是进程内部的“执行流”。同一个进程里的多个线程共享进程的内存空间、文件句柄等资源这就相当于同一家公司里的多个小组大家共用同一个办公区、同一套报销系统配合起来成本很低但也正因为共享容易产生“抢资源”的问题。这里有个非常关键的数字创建一个线程的开销远小于创建一个进程。Java里new Thread()创建的是用户态线程的包装对象真正与操作系统线程绑定的时机是在start()调用之后JVM会通过pthread_create或者虚拟线程调度器来创建底层线程。相比C语言的fork()Java线程创建的代价已经很小了但如果乱用依然会造成很大的资源开销。我在实际项目中见过不少新手写的代码服务一启动就new了几百个线程等在那里每一个线程栈默认要占1MB左右的虚拟机栈内存几百个就是几百MB还不算线程之间上下文切换的CPU成本。所以第一个建议是能复用线程绝不要反复创建这也是后面要讲线程池的根本原因。多线程到底带来了什么优势我总结成三点充分利用多核CPU把计算密集型的任务并行化四个核跑四个线程理想情况下比单线程快近四倍。提升响应速度比如Web请求处理主线程不用等数据库查询结果可以先处理其他请求这就是IO密集型的场景。提高吞吐量一个线程在等网络IO的时候另一个线程可以继续算不让CPU闲着。但代价也随之而来线程多了以后CPU需要花时间在“切换”这件事上每次上下文切换都要保存和恢复寄存器、程序计数器这个成本虽然很小但数量上去之后就很可观。另外共享资源一旦被多个线程同时写很容易出现数据不一致这就是线程安全问题的根源。有一个必须记住的结论线程并不是越多越好线程数取决于任务的类型。如果是CPU密集型任务线程数设置成CPU核数 1通常就够了如果是IO密集型任务因为线程大部分时间在等待可以多开一些一般设置成CPU核数 * 2但更精确的计算需要结合阻塞时间比例来算后面讲线程池的时候再细说。2. 创建一个线程的三种姿势到底该选哪种Java创建线程的姿势看起来有好多继承Thread、实现Runnable、实现Callable、用线程池提交任务但本质上只有一条线运行的是Thread对象里的run()方法只是这个run()方法的内容可以用不同方式提供。2.1 最简单但最不推荐的继承Thread类public class MyThread extends Thread { Override public void run() { System.out.println(线程运行中 Thread.currentThread().getName()); } public static void main(String[] args) { MyThread t new MyThread(); t.start(); } }这段代码执行之后控制台会打出线程运行中Thread-0。注意我这里调用的是start()而不是run()。如果直接调用run()本质上就是普通方法调用不会新起线程任务会在当前线程里执行完毕。这是无数新手踩过的坑。为什么不推荐继承Thread最直接的原因是Java单继承的限制一旦继承了Thread这个类就不能再继承其他业务基类了。而且用继承的方式线程的任务逻辑和线程的生命周期控制耦合在一起想复用这份任务逻辑就很麻烦。另一个问题是继承Thread之后你重写的是run()方法而真正要监控的线程状态由Thread内部管理这种设计层次感很差。2.2 最主流的实现Runnable接口Runnable task () - System.out.println(任务执行中 Thread.currentThread().getName()); Thread thread new Thread(task, 业务线程-01); thread.start();把任务本身和线程分离是面向对象设计里“单一职责”的体现。Runnable只负责定义“做什么”Thread只负责决定“怎么运行”两者通过构造函数组装。这样做的好处是任务可以传给任意线程执行甚至传给线程池。任务类不再受继承限制可以再继承其他类。更容易编写单元测试直接运行task.run()就能测逻辑。用Lambda表达式实现Runnable时要注意Runnable接口只有一个抽象方法run()所以它是一个函数式接口可以直接用Lambda。但如果你用了带返回值的Callable就不能直接当作Lambda传给Thread构造器了因为Thread的构造函数不接收Callable。2.3 想要返回结果就实现Callable接口CallableInteger callable () - { System.out.println(开始执行计算任务...); Thread.sleep(2000); return 1 1; }; FutureTaskInteger futureTask new FutureTask(callable); Thread thread new Thread(futureTask, 计算线程); thread.start(); Integer result futureTask.get(); System.out.println(计算结果 result);这里的关键是FutureTask它本身实现了Runnable和Future接口所以既能丢给Thread执行又能在执行完之后通过get()把结果拿回来。get()方法是阻塞的如果任务还没执行完它会一直等着。你可以在get()里传入超时时间比如futureTask.get(3, TimeUnit.SECONDS)防止因为任务死循环或者数据库卡死导致线程无限期等下去。三个创建方式总结成一句话继承Thread是最简单的引入方式但生产代码基本不用实现Runnable是最通用的做法Callable FutureTask适合需要返回值的场景。如果你用的是线程池那就更简单了直接把Runnable或者Callable提交给ExecutorService就行线程本身由池子统一管理。3. 线程的生命周期从出生到死亡状态切换全解析线程创建之后不是一直跑就完了它会在各种状态之间切换。Java线程一共有六种状态定义在Thread.State这个枚举里NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里面最容易被忽略的是NEW和TERMINATED因为这两个状态的时间通常很短。3.1 六种状态逐一解释NEW线程对象已经创建但还没调用start()。此时线程没有和操作系统线程关联就是一个空壳对象。RUNNABLE调用了start()之后的状态。这个状态在Java里包含了两个子状态正在运行和等待CPU调度。也就是说一个线程在等待被调度执行的时候它在Java层面依然是RUNNABLE不是WAITING。BLOCKED线程在竞争一把锁的时候没抢到会进入这个状态。比如两个线程同时执行synchronized代码块只有一个能进入另一个就阻塞住。WAITING线程主动调用了wait()、join()等无超时等待的方法会进入这个状态。这个状态的特点是必须等别人唤醒否则一直等下去。TIMED_WAITING有超时时间的等待比如调用sleep(1000)、wait(1000)都会进入这个状态。时间一到线程自动回到RUNNABLE。TERMINATEDrun()方法正常执行完或者抛出了未捕获的异常导致线程退出。有一种很常见的面试问法也是实际排查问题时容易犯迷糊的点一个线程调用了sleep(1000)它是什么状态答案是TIMED_WAITING。一个线程在等待synchronized锁释放是什么状态答案是BLOCKED。这两个别搞混了因为它们在监控工具里显示的线程转储信息不一样对应的排查思路也不同。3.2 常用API方法与状态切换细节// 让出CPU调度权 Thread.yield(); // 当前线程休眠指定时间 Thread.sleep(1000); // 等待子线程执行完毕可以带时间参数 thread.join(); thread.join(2000); // 中断标志位置位 thread.interrupt(); // 当前线程是否被中断且中断标志被清除 Thread.interrupted(); // 当前线程是否被中断 thread.isInterrupted();我用几个实际场景来把这些方法串一遍。场景一主线程要等子线程把数据加载完再继续处理这就用join()。join()的本质是让当前线程进入WAITING状态等子线程对象执行完run()方法后当前线程自动恢复。如果子线程卡死了主线程就会无限期等下去所以生产上一定带超时参数thread.join(5000)。场景二线程执行中需要定时轮询数据库里的状态最简单粗暴的办法就是while(true) { someQuery(); Thread.sleep(5000); }。这时候线程每次进入sleep()就会释放CPU让出调度机会。但要注意sleep()不会释放任何锁如果这个线程正握着一把锁其他线程依然拿不到。场景三中断请求。很多人以为interrupt()能立刻终止线程其实不是。interrupt()只是在线程对象上设置了一个中断标志位。真正产生效果的是如果线程正处在wait()、sleep()这种可中断等待中会抛出一个InterruptedException如果线程正在执行普通代码它需要自己检测isInterrupted()来决定是否退出。这里必须提醒一下捕获InterruptedException之后最佳实践是恢复中断标志也就是在catch块里再次调用Thread.currentThread().interrupt()。如果只是吞掉异常上层代码无法感知中断发生线程就会“莫名其妙”地继续运行。3.3 生命周期控制实战守护线程的正确用法Java里还有一个特殊的存在——守护线程。setDaemon(true)要在start()之前调用才有效否则会抛出IllegalThreadStateException。守护线程的特点是当进程里只剩守护线程时JVM会直接退出不会等守护线程执行完。比较典型的场景是后台心跳检测和日志清理。比如一个服务启动后开一个守护线程每30秒清理一次临时目录这个线程不需要太严谨的结束流程主程序退出它也该退。反过来如果是关键的业务线程绝对不能设成守护线程否则主流程结束线程直接被“杀掉”数据可能就丢了。我踩过的一个坑是这样的有一个定时上报指标的任务我用守护线程跑的某次发布的时候JVM正常退出因为没有其他非守护线程JVM直接结束上报任务还没把最后的指标数据发出去就没了。后来改成用ScheduledExecutorService维护公平多了。4. 线程安全你写的代码可能经不起高并发考验线程安全是并发编程里最核心、最让人头痛的主题。很多人写单机程序没感觉一到线上发现金额对不上、库存变负数、日志顺序错乱才知道问题的严重性。4.1 从一段不安全的计数代码说起public class Counter { private int count 0; public void increment() { count; } }如果只有一个线程调用increment()这个类完全没问题。但如果有两个线程同时调用就会发现最终结果不是2可能是1或者2。问题出在count不是原子操作它在字节码层面会被拆成三步读取count的值、加1、写回count。两个线程同时读到0各自加1写回时都是1最后结果就是1。这个现象背后是并发编程的三大特性问题原子性操作是否不可分割。可见性一个线程修改了变量另一个线程能否立即看到。有序性编译器或者CPU为了优化会不会重排指令。要解决计数问题最简单的方案就是加synchronized关键字。它可以保证同一时间只有一个线程能进入同步代码块同时保证块内变量的可见性。public synchronized void increment() { count; }这里的synchronized锁的是this对象也就是说同一时间只有一个线程能对这个Counter对象的increment()方法做操作。如果方法声明成static synchronized锁的就是Class对象两者作用范围不一样。4.2 synchronized与volatile到底该选谁很多人一碰到线程安全问题就想到synchronized但synchronized也有它的代价发生竞争时线程会阻塞阻塞和唤醒涉及操作系统层面成本不低。所以在只需要保证可见性、不需要保证原子性的场景里可以用volatile替代。典型场景是状态标志位public class FlagHolder { private volatile boolean running true; public void stop() { this.running false; } }这里用volatile是因为我们只做“读”和“写”操作不会做count这种读改写复合操作。volatile保证两点对变量的写操作会立即刷新到主内存读操作会从主内存重新读取。但它不能保证复合操作的原子性。volatile解决不了count并发问题它只能保证每个线程读到的值是最新的但两个线程同时读到0然后各自写出1依然会丢更新。顺便提一下synchronized和volatile的性能对比如果竞争不激烈synchronized经过JVM的锁升级机制偏向锁到轻量级锁再到重量级锁开销并不算大而在确实需要原子性的场景下synchronized才是正确选择单纯用volatile属于用错工具。4.3 原子类与Lock接口JDK在java.util.concurrent.atomic包下提供了一批原子类比如AtomicInteger它的内部用CAS实现无锁更新。AtomicInteger count new AtomicInteger(0); count.incrementAndGet();CAS的底层由CPU指令保证比synchronized更轻量适合竞争不激烈的计数器场景。但是CAS也有ABA问题Java里AtomicStampedReference就是为解决这个问题设计的。如果同步代码块的逻辑比较复杂比如需要公平锁、超时获取锁、多个条件变量就可以用Lock接口的实现类ReentrantLock。Lock lock new ReentrantLock(); lock.lock(); try { // 临界区代码 } finally { lock.unlock(); }注意lock()之后一定要在finally块里unlock()否则一旦临界区抛异常锁永远不会释放其他线程全部卡死。这也是synchronized的一个优势锁的释放由JVM自动管理即使异常也能释放。但在需要超时获取锁、可中断获取锁的场景下ReentrantLock更强。4.4 实测中发现的线程安全隐患我处理过一个真实的线上问题一个项目里用HashMap做用户会话缓存并发量一大CPU直接飙到100%并且频繁出现NullPointerException。排查半天发现问题出在多线程同时往HashMap里put数据导致内部链表形成环get的时候死循环。正确做法是换成ConcurrentHashMap。它通过分段锁或CAS机制把竞争粒度降到很低。用Collections.synchronizedMap()虽然也能包装成线程安全版本但它的锁粒度是整个Map并发程度高时性能远不如ConcurrentHashMap。这里给大家一个实际的建议并发场景下优先考虑并发容器而不是给整个集合加锁。CopyOnWriteArrayList适合读多写少的场景BlockingQueue系列适合生产者-消费者模型。自己在代码里同步集合很容易出现锁范围过大的问题。5. 线程协作与线程池别再用new Thread硬怼了线程之间不只是竞争资源还需要互相配合比如一个线程做完某件事要通知另一个线程开始。Java提供了很多现成的工具比直接用wait/notify要高效得多。5.1 wait与notify的基本协作模型synchronized (sharedObject) { while (!condition) { sharedObject.wait(); } // 条件满足继续执行 }这里有两个细节要特别注意。第一wait()必须在持有锁的代码块里调用否则会抛IllegalMonitorStateException。因为wait()的本意是“当前线程释放锁并进入等待队列”只有持有锁才能谈“释放”。第二等待条件的判断要用while而不是if。原因是线程从wait()返回之前需要重新获取锁但在返回瞬间条件可能已经被其他线程再次改动了。用while循环可以在线程被唤醒后再次检查条件避免虚假唤醒或条件失效。对应的唤醒代码一般是synchronized (sharedObject) { // 修改条件 sharedObject.notifyAll(); }notify()只唤醒一个等待线程notifyAll()唤醒全部等待线程。如果多个线程都在等同一个条件用notify()很容易产生选择困难唤醒的这个不一定是条件真正满足的那个。所以我的建议是除非你能精确控制只有一个线程在等待否则一律用notifyAll()。5.2 用并发工具替代手写wait/notify手写wait/notify很容易出错而且代码晦涩难懂。在实际项目中更推荐用CountDownLatch、CyclicBarrier和Semaphore。CountDownLatch适合“一个线程等待多个线程完成”的场景。比如主线程等待三个子线程各自加载完数据再汇总CountDownLatch latch new CountDownLatch(3); // 三个子线程执行完后分别调用 latch.countDown(); // 主线程等待 latch.await(5, TimeUnit.SECONDS);注意CountDownLatch是一次性的计数器归零之后不能再复用。如果你需要一个可以循环使用的栅栏就用CyclicBarrier。它的场景是“多个线程互相等待都到达某个点后同时继续”比如多阶段并行计算每阶段结束同步一次。Semaphore是信号量用来控制同时访问某个资源的最大线程数。它和锁的区别是锁只能被一个线程持有而信号量可以允许多个线程同时通过只要没超过许可数量。例如数据库连接池最大允许10个连接同时被占用就可以用Semaphore(10)实现。5.3 线程池的核心参数与配置建议真正生产环境的代码里手动new Thread的场景非常少见取而代之的是线程池。线程池的核心价值是控制并发度避免线程无限制创建导致系统资源耗尽。先看一个典型的ThreadPoolExecutor创建方式ThreadPoolExecutor executor new ThreadPoolExecutor( 5, // 核心线程数 10, // 最大线程数 60, // 空闲线程存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue(100), // 任务队列 new ThreadFactory() { Override public Thread newThread(Runnable r) { Thread thread new Thread(r, 业务线程池); thread.setDaemon(false); return thread; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );这里的规则要弄清楚核心线程数线程池常驻的线程数量即使没事干也不会销毁除非设置了allowCoreThreadTimeOut(true)。最大线程数当任务队列满了之后线程池能扩展到的最大线程数量。任务队列核心线程都在忙时新任务先放队列里等。拒绝策略当线程数量达到最大值并且队列也满时新任务怎么处理。CallerRunsPolicy表示由提交任务的线程自己执行AbortPolicy直接抛异常DiscardPolicy静默丢弃。给个直观的例子核心线程5个已经全忙提交了第6个任务这个任务会进队列。如果队列有100容量那么可以积压100个任务。当队列满了再来新任务线程池会尝试把线程数扩到10。如果10个线程还在忙而且队列还是满的那么拒绝策略就生效了。很多线上问题都是线程池配置不当引起的比如队列设置成无界队列LinkedBlockingQueue会导致任务疯狂堆积内存直接被撑爆而且最大线程数设置形同虚设。我推荐的一个初始配置思路是先用ThreadPoolExecutor而不是newFixedThreadPool因为Executors框架里几个便捷方法各有坑。newFixedThreadPool用的是无界队列newSingleThreadExecutor也是无界队列newCachedThreadPool最大线程数是Integer.MAX_VALUE任务一多就会创建海量线程直接把应用拖垮。只有newScheduledThreadPool的场景稍微特殊一点但做定时任务依然要小心。5.4 线程池提交任务的两种方式向线程池提交任务有两种方式execute()和submit()。区别在于execute(Runnable command)没有返回值如果任务执行中抛出异常会在调用线程的外部线程里打印出来但调用方拿不到异常信息。submit(CallableT task)返回一个FutureT对象调用方可以通过future.get()获取执行结果或者捕获执行过程中的异常。在需要监听任务执行结果、超时取消任务的场景一定要用submit。但要注意如果调用future.get()时任务还没结束当前调用线程会阻塞住这个阻塞时间最好限制一下。自定义线程池的线程命名也很重要。千万不要用默认的pool-1-thread-1这种名字。一旦线上出问题线程转储文件里全是这种无意义的名字根本分不清是哪个业务模块的线程。我自己习惯在ThreadFactory里加业务前缀比如“order-query-thread”排查问题时一眼就能锁定范围。最后再分享一个我个人的选择经验如果你的任务之间有依赖比如B任务必须等A任务完成才能执行那就不要把任务简单丢进线程池可以用CompletableFuture编排异步流程。它支持thenApply、thenCombine、exceptionally这些组合操作写出来的代码比手动等待Future清晰很多。Java 8之后这个工具已经成为异步编程的首选但前提是别写出超长的回调链否则代码可读性会急剧恶化。关于线程这一块我实际开发中最深刻的体会是真正的敌人不是线程本身而是不小心引入的竞态条件。选择创建方式时生产代码里几乎不用继承Thread状态排查时一定要分清楚BLOCKED和WAITING的差别因为它们对应不同的锁与等待场景线程安全方面先判断清楚是否需要原子性再决定用synchronized、volatile还是原子类线程协作时尽量使用CountDownLatch这套现成工具线程池配置时拒绝使用无界队列并给线程起有业务意义的名字。这还只是Java线程的第一篇后面计划继续深入线程池参数调优、锁的细节偏向锁、轻量级锁、重量级锁、synchronized和ReentrantLock的底层实现等。等下一篇更新时咱们接着聊。
返回列表