ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java多线程从零到实战:线程安全、锁与线程池调参指南

Java多线程从零到实战:线程安全、锁与线程池调参指南 如果你正准备系统学 Java 多线程这篇内容就是给你准备的。Java 多线程是 Java 开发者绕不开的核心技能不管是日常业务开发、性能优化还是大厂面试“并发”永远是高频考点。我见过太多人收藏了一大堆笔记结果真到写代码时还是不知道怎么下手——要么线程不安全要么死锁要么线程池参数不会配。这篇就把从零基础到实战级经验的一次性讲透重点是你拿到就能用的代码、判断依据和排坑经验。先说清楚这篇文章能解决什么问题。它面向的是三类人刚学完 Java 基础、准备进入并发这块的新手被面试官连环追问多线程面试题、需要系统梳理的求职者以及工作两三年、写业务代码没问题但并发经验不扎实的开发者。内容会从“为什么需要多线程”开始再到创建线程的几种姿势、线程安全的核心机制、经典的生产者消费者模型最后落到线程池调参和面试高频题解析。每一块都有可直接复现代码和注意事项。我个人在带团队和写高并发接口时最大的感受是很多人不是不懂 API而是不懂“为什么这样用会出事”。所以这篇文章里我会花很多篇幅讲原理背后的逻辑而不是只丢一堆结论。1. 多线程到底解决了什么问题——先看懂核心价值很多初学者上来就背线程概念、记 API但我建议你先想明白一个问题为什么程序需要多线程想不清楚这个后面所有知识都是浮在空中。1.1 从单线程到多线程程序执行效率的瓶颈在哪先说两个基础概念进程和线程。网上有一堆教科书定义我用食堂来类比。进程就像一座食堂它有自己的场地、厨房设备、食材库存这些资源由这座食堂独占线程就像食堂里负责打饭的窗口。一个食堂可以只开一个窗口也可以开十个窗口并行接待客人。开多个窗口就是多线程。那为什么单线程会浪费性能因为程序在执行过程中会遇到两类典型阻塞IO 等待和计算等待。比如你在代码里发起一次 HTTP 请求、读一次数据库、写一次文件CPU 其实在很多时候是空闲等待状态。单线程意味着 CPU 在这段时间只能陪着等服务响应别的活干不了。多线程的意义就是把这个空档利用起来——这个线程在等 IO另一个线程可以继续算数据。所以在实际业务里多线程不是炫技它有非常直接的价值充分利用多核 CPU。现在服务器动不动就 16 核 32 核如果你全程单线程哪怕机器配置再高同一时刻也只有一颗核在干活剩下的核全部围观。提高接口吞吐量。比如一个 Tomcat 容器同时接入大量请求每个请求分一个线程处理请求之间互不阻塞吞吐量自然上去了。异步化降低响应延迟。像发短信、发邮件、写日志这类操作如果同步执行会导致接口响应慢放入独立线程或线程池异步执行后主线程可以立刻返回。当然多线程不是越多越好。线程创建、销毁、切换都是有开销的这个后面线程池部分我会细说。这里你要记住的结论是多线程是为了“压榨”CPU 和硬件资源不是为了搞乱程序状态。1.2 线程的生命周期与状态流转线程从创建到销毁会经历一组状态这是理解并发基础中的基础。Java 的 Thread.State 枚举定义了六种状态千万不要和操作系统的线程状态搞混Java 里描述的是 JVM 视角下线程的状态。状态进入条件说明NEW创建了 Thread 对象还没调用 start()此时线程还没真正启动RUNNABLE调用 start() 后线程已就绪等待 CPU 调度不一定正在执行BLOCKED进入 synchronized 同步块时竞争锁失败阻塞在锁等待上等锁释放后重新竞争WAITING调用 wait()、join()、LockSupport.park()无限期等待必须由其他线程唤醒TIMED_WAITING调用 sleep(ms)、wait(timeout)、join(timeout)到达指定时间后自动恢复TERMINATEDrun() 方法执行完毕或抛出未捕获异常线程生命周期结束这里有几个常见的误解。第一RUNNABLE 并不代表线程正在被 CPU 执行它可能只是排在就绪队列里随时可以被调度也可能正在等待 CPU 时间片。第二BLOCKED 和 WAITING 都是“暂停执行”但原因不同BLOCKED 是等锁WAITING 是主动等待某个条件被满足或线程结束。第三sleep 持有锁不释放这点会在讲 synchronized 时经常被问到。状态转换的核心触发点是start、获取锁、释放锁、wait、notify、sleep、join。你能在脑海中把这几条路径画出来线程生命周期这块基本就稳了。2. 零基础第一步创建线程的四种正确姿势Java 里创建线程的方式严格来说就是三种基础姿势继承 Thread、实现 Runnable、实现 Callable。但生产环境中真正频繁用的是第四种线程池。这一节我把每一种的代码、适用场景和坑都说清楚。2.1 三种基础方式Thread、Runnable、Callable第一种继承 Thread。你写一个子类继承 Thread重写 run 方法然后 startclass MyThread extends Thread { Override public void run() { System.out.println(Thread.currentThread().getName() 执行中); } } new MyThread().start();第二种实现 Runnable。因为 Runnable 是函数式接口所以用 Lambda 写起来非常简洁Runnable task () - System.out.println(Thread.currentThread().getName() 执行中); new Thread(task).start();第三种实现 Callable。Runnable 的 run 方法没有返回值也不能抛异常。如果线程执行后想拿计算结果比如异步计算某个数值再汇总就得用 CallableCallableInteger task () - { Thread.sleep(1000); return 1 1; }; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get(); // 阻塞等待线程执行完成并拿到返回值 System.out.println(计算结果: result);这里需要强调两点新手常犯的错误。第一创建线程必须调用 start()如果你直接调用 run()它只是在当前线程里执行一个普通方法并没有创建新线程。好比你把菜做好了却没有开打饭窗口客人还是只能在同一个窗口排队。第二实现 Runnable 接口比继承 Thread 更好。原因有三个Java 是单继承继承了 Thread 就不能继承其他类Runnable 把“任务”和“线程”解耦了同一个任务可以丢给不同的线程执行线程池也接受 Runnable/Callable而不接受专门继承的 Thread 子类。所以日常开发中优先用 Runnable 或 Callable别一上来 new Thread。2.2 线程池真正生产环境该用的创建方式new Thread 虽然简单但频繁创建和销毁线程代价很高。每次创建线程都要分配独立的调用栈、申请系统资源线程销毁时还得释放资源线程切换时 CPU 要保存和恢复上下文。如果你的业务是高频短任务比如每次请求来了都 new Thread系统很快就会被线程的创建销毁开销拖垮。所以生产环境几乎都是通过线程池来复用线程。线程池的核心类是 ThreadPoolExecutor它有一组构造函数参数很多人面试卡在这里。我用大白话解释一遍参数含义通俗理解corePoolSize核心线程数池子至少保留多少个员工maximumPoolSize最大线程数极端情况下最多扩到多少人keepAliveTime非核心线程存活时间空闲非核心员工等待多久被开除unit时间单位上面的时间单位workQueue任务队列员工不够用时新任务排在哪threadFactory线程工厂给线程起名字、设置属性handler拒绝策略队伍也排不下时怎么办创建线程池最忌讳的是直接用 Executors 工具类的快捷方法。比如 Executors.newFixedThreadPool 底层用的是无界队列 LinkedBlockingQueue队列可以无限堆积任务在高峰期会导致内存被任务撑爆Executors.newCachedThreadPool 最大线程数是 Integer.MAX_VALUE极端情况下能创建出几十万个线程直接把系统拖死。所以我更建议手动创建把参数控制在自己手里。下面是一个生产环境可用的模板ThreadPoolExecutor executor new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲60秒回收 new ArrayBlockingQueue(100), // 有界队列最多堆积100个任务 r - { Thread t new Thread(r, order-pool- r.hashCode()); t.setDaemon(false); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() // 满了之后交给调用线程执行 );注意这串代码里的 ThreadFactory 是 Lambda 实现我给线程起了带业务前缀的名字。别小看这个习惯后面线上排查问题时能一眼看出线程是谁创建、负责什么业务非常省力。参数具体怎么调我在第 5 节展开。2.3 线程常用 API 和注意事项基础 API 是每个初学者必须过手的但要理解它们的底层行为而不是死记硬背。Thread.sleep(ms)当前线程休眠指定毫秒期间不释放已持有的锁。配合 interrupt 可以中断休眠。Thread.yield()向 CPU 调度器提示“我暂时不着急执行”放弃本次 CPU 时间片。注意这只是提示不一定生效。t.join()当前线程等待 t 线程执行结束再继续。底层实现是 wait 循环本质上是让出 CPU 并等待。t.setDaemon(true)把线程设为守护线程。守护线程随 JVM 进程结束而退出比如垃圾回收线程就是守护线程。业务线程别设成守护线程容易出现任务没执行完进程却退出的问题。interrupt()设置线程的中断标志位并不会强制终止线程。真正的停止逻辑需要线程内部自己检查 Thread.currentThread().isInterrupted() 来决定是否退出。我在实际开发中看到很多人还是用已被标记废弃的 t.stop() 来停止线程这是非常危险的操作它会在任意位置强制中断线程可能导致资源没有释放、数据不一致。正确方式是配合 interrupt 和标志位来协作停止。3. 线程安全synchronized 与 volatile 的正确理解多线程最核心的难题只有一个多个线程同时访问共享数据怎么保证结果正确。这一节把并发三大问题、关键字机制和锁方案讲清楚。3.1 三大并发问题原子性、可见性、有序性假设现在有一个全局计数器 count两个线程分别对它执行 10000 次 count。理论上结果应该是 20000但实际运行结果经常小于 20000。为什么因为 count 在 CPU 层面不是一步操作它至少分三步读取 count 当前值、计算加一、写回结果。两个线程同时读到同一个旧值然后各自加一写回这个加一就被覆盖了一次。这种“多个操作不可分割”的特性就是原子性。第二个问题是可见性。现代 CPU 和 JVM 为了性能会把变量缓存到线程自己的工作内存中一个线程修改了变量另一个线程可能仍然在读旧值因为它没被通知到。典型例子是线程 1 里写了一个 flag线程 2 死循环里怎么等都等不到 flag 变化。第三个问题是有序性。编译器和 CPU 为了优化指令执行顺序可能打乱代码的执行顺序只要最终结果单线程内保持一致。但在多线程环境下乱序可能导致另一个线程看到的状态与预期不符。你可以这样类比三个人同时改一份 Excel 报表每个人都在自己的屏幕缓存里操作最后保存的时候互相覆盖报表就乱了还因为每个人看到的都是旧副本有人改了单元格你根本看不到。Java 解决这三个问题的核心机制是 volatile 和 synchronized。volatile 只保证了可见性和有序性它让变量的修改立即刷新到主存并在读取时强制重新加载同时通过内存屏障禁止指令重排。但 volatile 解决不了原子性count 这种复合操作仍然可能丢更新。所以 volatile 的适用场景是单一变量的状态标志比如volatile boolean stop false; // 线程A while (!stop) { // 循环处理任务 } // 线程B stop true; // 修改对线程A立即可见synchronized 则同时解决三个问题。它通过锁机制保证同一时刻只有一个线程进入临界区而这套机制天然要求多个线程之间能够看到共享变量的最新值所以在锁内部的操作不会出现并发写冲突。3.2 synchronized 的三种用法与锁升级synchronized 有三种写法锁的目标不同// 1. 修饰实例方法锁的是 this public synchronized void method() { // 临界区 } // 2. 修饰静态方法锁的是当前类的 Class 对象 public static synchronized void staticMethod() { // 临界区 } // 3. 修饰代码块可以自己指定锁对象 public void method() { synchronized (lock) { // 临界区 } }这三种用法的本质区别在于锁对象。锁同一个对象就能互斥锁不同对象则互不相干。另外锁粒度也值得花心思如果一个方法里只有一小段代码需要保护没必要把整个方法都加同步否则会让其他不需要互斥的代码也跟着排队。我自己写代码时更偏好 synchronized 代码块锁的对象尽量用一个私有的常量对象避免外面的人拿到锁对象后干扰锁逻辑。关于 synchronized面试还喜欢问锁升级机制。JDK 1.6 之后做了大量优化一开始是无锁状态只有单线程竞争时偏向锁会让线程直接获得锁而不用做同步操作一旦出现第二个线程竞争偏向锁撤销升级为轻量级锁通过自旋 CAS 尝试获取自旋失败且竞争激烈时升级为重量级锁让没有抢到锁的线程进入操作系统级别的阻塞挂起。这个机制你可以理解成食堂安检只有你一个人时门卫看你一眼就直接放行偏向锁稍微多几个人就让你自己开门同时环顾四周有没有别的人自旋人多了就上铁栅栏和专人管控重量级锁。这部分作为面试知识点了解即可日常写代码不需要手动指定锁状态。3.3 Lock 体系ReentrantLock 与 synchronized 之别JDK 5 之后引入了 java.util.concurrent.locks 包其中 ReentrantLock 是 synchronized 之外最常用的显式锁。它能做到 synchronized 做不到的一些事可中断调用 lockInterruptibly() 可以让等待锁的线程响应中断。可超时tryLock(timeout) 在指定等待时间内抢不到锁就不等了避免死等。公平锁构造函数传入 true按申请顺序获取锁避免线程饥饿。条件变量通过 Condition 实现更精细的线程等待/唤醒一个锁可以对应多个条件队列。基本用法是围绕 lock/unlockunlock 要放在 finally 里否则中途抛异常就永久锁死ReentrantLock lock new ReentrantLock(); public void doSomething() { try { lock.lock(); // 临界区 } finally { lock.unlock(); } }还有一个常见类ReentrantReadWriteLock。它把锁拆成读锁和写锁读读共享、读写互斥、写写互斥。非常适合读多写少的场景比如配置缓存、字典数据加载。但如果写操作很频繁读写锁的分拆反而可能带来更多开销实际使用时要评估读写比例。4. 经典生产-消费模型与线程间通信生产者和消费者是多线程最经典的模型生产者线程负责往容器里放数据消费者线程负责从中取数据处理。热搜词里大量出现“生产者”“消费者”原因就是面试特别喜欢现场写这个而且它能验证对线程通信、锁、阻塞队列的理解。4.1 用 wait/notify 手写一个生产者-消费者先看最底层的手写版本。使用 wait/notify 机制代码框架如下class Buffer { private final QueueInteger queue new LinkedList(); private final int capacity 10; public synchronized void produce(int value) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满了生产者等待 } queue.offer(value); notifyAll(); // 唤醒可能正在等待的消费者 } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了消费者等待 } int value queue.poll(); notifyAll(); // 唤醒可能正在等待的生产者 return value; } }这段代码里有三个细节是面试和实战中的重点。第一wait() 为什么一定要在 while 循环里判断而不是用 if因为可能存在“虚假唤醒”——线程明明没被 notify却自己醒过来了也可能一次 notifyAll 唤醒了多个线程其中有几个抢到锁后发现条件还是不满足。所以每次被唤醒后要重新检查条件只有 while 能保证这一点。第二wait/notify 为什么必须在 synchronized 块或方法中调用因为 wait 的语义是“先释放锁再阻塞等待”notify 的语义是“通知某个等待该锁的线程重新竞争锁”。如果没有持有该对象的锁JVM 会抛出 IllegalMonitorStateException。这本质上是在保证条件变量和锁的一致状态。第三为什么我用了 notifyAll 而不是 notifynotify 只会唤醒一个等待线程如果它不幸唤醒的是同类线程比如队列满时又唤醒了一个生产者那消费者就永远没机会被唤醒程序可能卡死。notifyAll 把所有等待线程都唤醒让它们自主重新竞争虽然有一定性能损耗但更安全。4.2 用 BlockingQueue 简化实现手写 wait/notify 能帮你理解原理但真实项目里几乎不用这个。JDK 提供了 java.util.concurrent.BlockingQueue它把“容器满时阻塞写入、容器空时阻塞读取”的逻辑封装好了直接用就行BlockingQueueInteger queue new ArrayBlockingQueue(10); // 生产者 new Thread(() - { for (int i 0; ; i) { queue.put(i); // 队列满时会阻塞 System.out.println(生产: i); } }).start(); // 消费者 new Thread(() - { while (true) { try { int value queue.take(); // 队列空时会阻塞 System.out.println(消费: value); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }).start();put/take 是阻塞方法天然实现了生产者等待和消费者等待代码量少很多。ArrayBlockingQueue 底层是数组必须有界LinkedBlockingQueue 底层是链表可以无界但无界有内存风险所以生产环境我更推荐 ArrayBlockingQueue 或者手工指定容量的 LinkedBlockingQueue。如果你的需求只是“任务并发处理”更常见的做法是直接把任务提交给线程池线程池内部的阻塞队列本质上也是生产者消费者模型。4.3 其他并发工具类速览除了 BlockingQueueJUC 包还有一批非常实用的并发工具类面试和项目中经常出现CountDownLatch一个线程等待多个线程完成。比方说接口需要同时调用三个第三方服务等三个请求都返回后再汇总数据。用法是初始化 CountDownLatch(3)主线程调用 await() 等待三个异步任务完成后各自 countDown()。CyclicBarrier多个线程互相等待都到达某个点之后再一起继续。适合“凑齐所有人再开始下一阶段”的场景比如分片数据全部处理完后再进入汇总阶段而且它可以循环复用。Semaphore信号量控制同时访问某个资源的线程数量。比如数据库连接池最多只有 10 个连接就可以用 Semaphore(10) 限制并发获取连接的数量。它和线程池的最大线程数不一样Semaphore 控制的是访问权限线程本身可以继续存在。这几个工具类的共同点是把复杂的底层同步逻辑封装成了高层 API你要做的只是选对场景。很多并发代码之所以写得又长又错就是因为总想自己控制 wait/notify而不是用封装好的实现。5. 实战中的线程池调参与问题排查前面讲到 ThreadPoolExecutor 参数这一节专门讲参数怎么定、运行中线上面试怎么答、线上问题怎么排查这几个话题在“java多线程面试题”和实际开发中的热度都很高。5.1 线程池参数的正确选择经验很多文章都会给一套公式但线程池参数根本没有“万能答案”完全取决于你的任务类型和机器配置。不过有几个业界公认的判断方向CPU 密集型任务大量计算、数学运算、编解码核心线程数可以设为 CPU 核数 1。1 是因为个别线程偶发页缺失或暂停时不会完全浪费 CPU。IO 密集型任务大量读写数据库、调用远程接口核心线程数可以调大到 CPU 核数的 2 倍或者更精确的做法是线程数 CPU 核数 * (1 平均等待时间 / 平均计算时间)。因为线程在等待 IO 期间并不占用 CPU我们可以多放几个线程来充分压榨 CPU。混合型任务最佳做法不是硬塞进一个线程池而是根据不同阶段拆成 CPU 密集线程池和 IO 密集线程池再用异步编排把它们串起来。队列容量怎么选如果任务增长非常快无界队列迟早内存溢出。按我实践的经验有界队列的容量要能扛住一定时间的任务堆积同时不要大到让系统失去背压能力。举个例子你期望接口在高峰期每秒最多提交 200 个任务核心线程 4 个每个任务耗时 100ms那么 4 个线程每秒能处理 40 个任务队列 100 意味着可以缓冲约 2.5 秒的任务积压超过后就会触发拒绝策略这个背压信号对于限流降级很有意义。拒绝策略有四种我按生产环境的推荐程度排个序策略行为适用场景CallerRunsPolicy任务由提交任务的调用线程自己执行适用于要求不丢任务的场景反向压力让提交方减速AbortPolicy提交异常 RejectedExecutionException默认策略适合明确告知业务方任务已满DiscardPolicy静默丢弃新任务适合允许丢消息的日志、监控任务DiscardOldestPolicy丢弃队列里最老的任务再提交新任务适合新型任务价值高于旧任务的场景我的默认选择是 CallerRunsPolicy。它不会丢任务虽然会让提交线程临时被阻塞但相当于把压力传给了上游整体系统不容易被击穿。另外需要提醒的是线程池的线程笔名要定义好前面代码里我通过 ThreadFactory 给了业务名的线程线上排查时会非常有用。5.2 高频面试题解析执行流程、死锁、ThreadLocal面试官问线程池九成会问任务提交后的执行流程。你可以按这个顺序说提交任务时先判断当前线程数是否小于核心线程数小于则直接创建核心线程执行达到核心线程数就把任务放入任务队列队列满了再判断当前线程数是否小于最大线程数小于则创建非核心线程执行已经达到最大线程数则触发拒绝策略。整个流程可以用一句话记忆先核心、再队列、后非核心、最后拒绝。第二个高频题是死锁。死锁产生的四个必要条件分别是互斥、持有并等待、不可剥夺、循环等待。只有四个条件同时满足才会死锁所以打破任何一个条件都能解除死锁。比如通过 tryLock(timeout) 超时获取锁就打破了“不可剥夺”通过按固定顺序加锁就打破了“循环等待”。线上排查可以用 jps 找到 Java 进程号再用 jstack 打线程堆栈如果存在死锁堆栈中会明确出现类似“Found one Java-level deadlock”的提示并且能直接看到两个线程各自持有哪个锁、正在等待哪个锁。我在一次压测中遇到过两个订单线程互相等待对方的锁就是用这个方式快速定位的。第三个高频点是 ThreadLocal。它的核心是每个线程有自己的变量副本通过线程隔离来避免数据交叉污染。但 ThreadLocal 在配合线程池使用时极易引发内存泄漏如果线程执行完后不调用 remove()ThreadLocalMap 里的 value 是强引用线程池中的线程不会退出value 就一直无法回收。我的习惯是凡是使用 ThreadLocal 的代码在 finally 里必须 removeThreadLocalString context new ThreadLocal(); try { context.set(用户会话信息); // 业务逻辑 } finally { context.remove(); }5.3 常见的并发运行问题与调优技巧这一节是我觉得最有含金量的部分因为很多问题你不会在平时的练习代码里遇到只有线上压力一上来才会暴露。第一个问题是线程数过多导致的上下文切换开销。线程切换意味着保存当前线程的寄存器、程序计数器再加载下一个线程的状态这些操作本身就要消耗 CPU。所以线程不是越多越好盲目开几百个线程执行短任务性能很可能比单线程串行还差。判断线程池是否配置过大的一个思路是看 CPU 利用率如果 CPU 没跑满但线程切换频繁就要收缩线程数。第二个问题是持锁期间不要做耗时操作。同步块里发 HTTP 请求、执行 sleep、写大文件都会让其他等待锁的线程阻塞更久并发能力直线下降。遇到这种情况优先考虑把锁外操作移到同步块外或者用 ReentrantReadWriteLock 减小互斥范围。第三个问题是线程池监控和告警。线程池不像一个简单的变量出问题往往不会第一时间报错而是表现为任务积压、接口响应变慢。你可以定期采集线程池的状态int core executor.getCorePoolSize(); int active executor.getActiveCount(); int max executor.getMaximumPoolSize(); int queueSize executor.getQueue().size(); long taskCount executor.getTaskCount(); long completed executor.getCompletedTaskCount();把这组数据输出到监控系统里假设队列积压深度持续上涨、活跃线程始终接近最大值说明系统已经处于过载边缘要提前扩容或限流。这样做的好处是很多并发问题可以在影响用户前被提前发现。最后分享一条我自己的学习建议相比一口气把所有并发工具类都学完不如先写几个小例子比如两个线程同时改同一个计数器、手写生产者消费者、给线程池填不同参数观察队列变化。把代码跑起来看到问题再回头理解原理印象会深得多。更好的方式是加入一个“并发压力验证”环节用模拟的大量任务同时提交到线程池观察 CPU、内存和任务积压数据你会发现很多纸上谈兵的参数配置在实际数据面前是完全经不起考验的。多线程的学习就像拧螺丝只有自己亲手拧滑过一次才知道为什么锁要那么加、队列要那么设。
返回列表