ARTICLE DETAIL

资讯详情

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

Java多线程核心知识梳理:从线程池到并发安全

Java多线程核心知识梳理:从线程池到并发安全 接到一个外部数据聚合的改造任务上游渠道一共有60多个单渠道响应时间在150到800毫秒不等业务要求3秒内把所有数据拉齐后返回。最开始自然是顺序调用按平均耗时算下来要跑小半分钟完全没法看。我当时的处理方式很直接——把每个渠道的请求封装成独立任务丢进线程池并发执行整体耗时立刻变成“最慢的那个渠道”的耗时基本都压在1秒左右。这个场景在后台开发里太常见了批量上报、异步通知、并行聚合查询、消息消费……只要涉及Java服务端多线程就是绕不开的技术底座。它同时也是面试现场出现频率最高的热点词之一。面试官通常从“创建线程有哪几种方式”这类基础问题切入一路问到线程生命周期、原子性可见性、生产者消费者、线程池参数直到你答不上来为止。所以这篇我打算按“理解原理 → 会写代码 → 能排故障 → 扛得住追问”的顺序把Java多线程里真正值得掌握的东西完整过一遍。无论你是准备java面试的在校生还是已经写了几年业务代码、一碰到并发就心里没底的开发这篇文章应该都能帮你把知识体系梳理得更扎实。1. 多线程到底在解决什么问题先想清楚需求再动手1.1 并发与并行看起来像、其实是两件事先纠正一个最容易被混淆的概念并发和并行并不是同义词。并行的核心是“同一时刻有多个任务真在同时执行”它依赖多核CPU并发的核心是“多个任务在一段时间内都有推进”底层靠时间片切换实现哪怕只有一个核也可以并发。我用一个生活化的例子来记一个人做饭锅里炒着菜又抽空去切葱这叫并发两个人分工一个切菜一个炒菜这叫并行。Java里我们new出来的Thread本质是操作系统线程的映射你开200个线程跑在8核机器上绝大多数时刻它们并不是真的同时执行而是被调度器拆成一个个时间片快速轮换。理解这一点很重要因为很多性能预期落空都是误以为“开多线程就等于占用更多核”。1.2 多线程的收益与代价没有免费的午餐多线程带来的收益很直观系统吞吐量上去了单位时间内能处理更多请求整体延迟也可能降下来就像聚合接口那样。但代价同样真实存在而且常常被低估上下文切换开销。线程切换要保存和恢复寄存器、程序计数器、栈信息切换频率高了CPU大量时间花在切换上而不是干活上。内存占用。每个线程都有自己的虚拟机栈默认栈大小在1MB左右开1000个线程光栈就吃掉1GB内存。数据竞争与不确定性。多个线程同时读写共享变量结果不可预测这是最折磨人的部分。死锁、活锁、饥饿等协作问题。调试复杂度上升。并发bug往往不稳定复现线上偶发本地复现不出来。所以不是所有场景都该上多线程。任务本身执行时间极短、任务之间有强串行依赖、或者真正的瓶颈在某个无法并发缩短的资源上这时候引入多线程反而会让系统更慢、更难维护。我自己的判断标准是先算清楚“串行耗时是否超过业务容忍线”如果没超过不要为了技术花哨去加并发。2. 创建线程的四条路从继承Thread到线程池2.1 继承Thread和实现Runnable两条传统路线及各自的坑创建线程最原始的方式是继承Thread类重写run方法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(); } }这条路的缺点很明显Java是单继承你一旦继承了Thread就不能再继承其他业务类而且任务逻辑直接写在线程类里把“要执行的任务”和“执行任务的线程”耦合死了。于是有了第二种方式实现Runnable接口将任务本身做成一个独立的类或者Lambdapublic class RunnableDemo { public static void main(String[] args) { Thread t new Thread(() - System.out.println(任务执行 Thread.currentThread().getName())); t.start(); } }Runnable把任务和线程解耦了也解决了继承限制。但它仍然有两个历史遗留短板run方法没有返回值而且不能抛出受检异常。如果你需要线程执行完带回结果就得换第三条路。这里还有一个面试高频陷阱调用start()和直接调用run()有什么区别很多人会答错。直接调用run()其实只是普通方法调用还是在当前线程里同步执行只有调用start()才会真正创建新线程并由新线程去执行run()里的逻辑。2.2 Callable与Future让线程带回结果业务里大量场景需要并发计算后汇总结果比如同时请求三个服务把它们的结果拼在一起。Runnable干不了这个活所以要请出CallableExecutorService pool Executors.newFixedThreadPool(3); FutureString f1 pool.submit(() - { // 模拟远程调用 Thread.sleep(300); return 订单服务结果; }); FutureString f2 pool.submit(() - { Thread.sleep(500); return 库存服务结果; }); // 汇总 String result f1.get(2, TimeUnit.SECONDS) f2.get(2, TimeUnit.SECONDS);注意get()方法是阻塞的也就是说调用它会一直等到任务执行完。所以线上代码里强烈建议给get()加超时否则一个远程调用卡死整个聚合线程就一起挂住。我见过太多因为没设超时导致线程池线程被占满的事故。2.3 线程池生产环境真正的主力虽然前面的例子用了线程池但这里必须强调生产环境不要裸new Thread。原因不复杂线程的创建和销毁开销很大频繁创建会拖垮系统线程数量不受控容易把内存和CPU打满而且没有统一的拒绝策略和生命周期管理。线程池就是用来解决这些问题的它提前创建一批线程任务来了直接派发任务结束后线程不销毁而是继续复用。Java里最常用的是ThreadPoolExecutor后面第6章会专门讲参数和坑。这里先给一个基本形态ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), new ThreadPoolExecutor.CallerRunsPolicy() );这里要说一句阿里巴巴Java开发手册一直建议不要用Executors工具类直接创建线程池因为它的默认参数有隐患比如newFixedThreadPool用的是无界队列任务积压多起来可能导致OOM。手动new ThreadPoolExecutor虽然代码啰嗦一点但每个参数都是显式的出问题的时候一眼能看出来。3. 线程生命周期看懂状态才能看懂排错日志3.1 六个状态的迁移路径Java线程一共就六个状态面试时能把这六个状态和迁移条件完整说出来基本就能过这一关状态含义进入条件NEW新建new Thread()后还没调用start()RUNNABLE可运行调用start()后正在运行或者等待CPU时间片BLOCKED阻塞竞争synchronized锁失败等待进入同步代码块WAITING无限等待调用了wait()、join()、LockSupport.park()TIMED_WAITING限时等待调用了sleep(ms)、wait(timeout)、join(timeout)TERMINATED终止run()正常执行完或抛出未捕获异常很多人以为RUNNABLE就是“正在运行”实际不是。Java把“等待CPU时间片”也归入RUNNABLE所以一个线程处于RUNNABLE并不代表它此刻在跑。真正被阻塞在锁竞争时是BLOCKED在等待某个条件变成WAITING或TIMED_WAITING。区分这些状态对排查问题极其有用。3.2 sleep、wait、yield、join四个让新手头晕的方法这四个方法看起来都是“让线程歇一下”实际机制完全不同。sleep来自Thread类调用后会让当前线程暂停指定的毫秒数但它不释放任何锁。如果在一个synchronized代码块里sleep其他线程照样进不来。wait来自Object类它必须先持有对象的监视器锁即必须在synchronized代码块或方法里调用调用后会释放锁让其他线程有机会进入同步区。区别就在这里sleep是“抱着锁睡”wait是“放开锁等”。yield是Thread类的静态方法作用是礼貌性地让出当前CPU时间片让同优先级的其他线程有机会执行。但它只是建议调度器完全可以不理会所以不要用yield来控制执行顺序。join用于等待另一个线程执行完。比如主线程调用了child.join()主线程会阻塞直到child线程终止。它的底层实现其实依赖wait机制面试被问到可以点一句。这里给出一个面试最常考的对比直接背下来就够用对比项sleepwait属于谁Thread静态方法Object实例方法是否需要锁不需要必须在synchronized中调用是否释放锁不释放释放锁唤醒方式时间到自动恢复notify/notifyAll或时间到使用场景简单的暂停线程间协作3.3 通过线程转储分析卡死状态理论看再多最终都要落到排查上。线上应用卡住时我最常用的第一板斧是jstack把线程转储拉下来看一眼jstack pid dump.txt然后重点看大量线程堆积在什么状态。如果看到一堆线程处于BLOCKED并且都在等待同一个锁说明锁竞争非常严重基本可以定位到热点代码如果大量线程处于WAITING并且停在某个池的get()调用上通常是任务队列饥饿或外部依赖卡死如果出现两个线程互相持有一把锁、同时等待对方释放另一把锁日志里会有明显的“Found one Java-level deadlock”提示那就是经典死锁。有一次排查生产问题我打开转储发现业务线程几乎全部WAITING在一个Future.get()上进一步查是下游接口响应超时但代码里没给get加超时导致线程池被慢请求占满。这个案例再次说明所有阻塞等待外部资源的操作都必须有超时兜底。4. 线程安全的三大难题原子性、可见性、有序性4.1 从i字节码看原子性先看一个再经典不过的例子多线程同时对同一个int变量执行i结果是不可控的。很多人背过结论但不理解为什么。把这段代码反编译看字节码就明白了public class Counter { private int count 0; public void increment() { count; } }对着字节码看count其实被拆成了好几步读取count当前值将其加1把新值写回count两个线程可能同时读到旧值比如5分别加完都写回6结果本该是7。这个就是原子性被破坏因为操作不是不可分割的整体。类似的还有check-then-act先检查再操作和复合操作都是原子性问题的重灾区。4.2 JMM与可见性为什么volatile不够除了原子性还有个隐蔽得多的坑可见性。Java内存模型规定每个线程有自己的工作内存变量计算时先从主内存拷贝一份到工作内存操作完再刷回主内存。那么问题来了一个线程修改了变量另一个线程可能还一直读着旧值。我写一个非常典型的复现场景public class VisibilityDemo { private static boolean flag true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (flag) { // 空转 } System.out.println(worker退出); }); worker.start(); Thread.sleep(1000); flag false; // 主线程修改flag } }这段代码在多数机器上会一直死循环因为worker线程看不到主线程对flag的修改。解决办法很简单给flag加上volatile关键字。volatile保证两件事线程修改变量后立即刷回主内存其他线程读取时强制从主内存读最新值。这解决了可见性。同时volatile还能禁止指令重排解决一部分有序性问题。但注意volatile不解决原子性。前面对i的场景即使把count声明为volatile三个字节码步骤依然不是原子的并发i照样丢数据。所以volatile适合“一个线程写、多个线程读”的状态标志不适合复合操作。面试里很多候选人脱口而出“volatile能保证原子性”这是明显的知识漏洞。4.3 synchronized与Lock两代锁方案怎么选解决原子性最直接的方案就是加锁。synchronized有三种用法修饰实例方法锁是当前实例对象修饰静态方法锁是类的Class对象修饰代码块锁是指定对象。它的核心思想是同一时刻只有一个线程能持有锁进入临界区其他线程在锁外阻塞等待。从JDK 6开始synchronized经过锁升级优化偏向锁→轻量级锁→重量级锁性能已经和ReentrantLock相差不多。既然这样什么时候用Lock我的习惯是默认优先synchronized因为它最简单出了异常JVM会自动释放锁需要公平锁、可中断、超时获取锁、或者多个Condition时再换ReentrantLock。ReentrantLock的使用有一点必须注意解锁必须放在finally里否则中间抛异常锁就永远不会释放ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }有人觉得synchronized是悲观锁Lock也是悲观锁那有没有别的思路有的CASCompare And Swap就是乐观锁的典型实现。它不加锁而是在更新时比较当前值是不是预期的旧值是则替换为新值不是则重新读取再重试。Java的AtomicInteger等原子类底层就是这个机制。CAS避免了线程阻塞但要注意ABA问题以及高竞争下自旋会消耗CPU。4.4 数据一致性Java里保证并发的核心思路从热搜词里可以看到大家非常关心“java怎么保证数据一致性”。聊到这里其实已经把拼图集齐了可以给一个归拢操作复合步骤i、check-then-act需要原子性用synchronized、Lock或Atomic类。多线程可见性用volatile或者锁加锁的代码天然带可见性。线程内部数据不想被共享污染用ThreadLocal做线程隔离。存在竞态条件的代码用悲观锁或乐观锁串行化临界区。记住一个原则没有银弹。锁能解决大部分问题但会牺牲并发度CAS并发度高但忙等伤CPUThreadLocal避免共享但消耗内存。所谓方案选型就是在这些约束里找到当前场景最平衡的那个点。5. 线程间协作从wait/notify到生产者消费者模型5.1 wait/notify的正确姿势为什么必须在synchronized里线程之间不是永远各干各的经常需要“你生产了我消费你没生产我等着”。老牌协作机制是Object的wait/notify。有一个面试必问的问题为什么wait/notify必须放在synchronized代码块里原因在于wait方法要释放锁而只有持有锁的线程才有资格释放锁。同时wait的语义是“在某个条件不满足时挂起自己并让别人有机会修改条件”如果不在锁保护下检查条件就会发生经典的“先检查后等待”竞态两个线程同时发现条件满足同时进入临界区。所以规范写法是synchronized (lock) { while (!condition) { // 用while不要用if lock.wait(); } // 条件满足继续执行 }为什么条件检查必须用while而不是if因为线程被唤醒后条件可能已经被其他线程改回去了。如果只用if判断一次就直接往下走可能执行的是错误逻辑。用while就是让唤醒后重新检查一遍这个细节在生产者消费者模型里尤其重要。5.2 手写一个生产者消费者模型用wait/notify完整实现一个最简单版本class SharedQueue { private final LinkedListInteger queue new LinkedList(); private final int capacity 5; public synchronized void produce(int value) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满了生产者等待 } queue.addLast(value); System.out.println(生产 value 当前数量 queue.size()); notifyAll(); } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了消费者等待 } int value queue.removeFirst(); System.out.println(消费 value 当前数量 queue.size()); notifyAll(); return value; } }这里有一个常见误区notify和notifyAll怎么选notify只唤醒一个等待线程如果唤醒的是同类型的线程比如唤醒了生产者但队列还是满的它检查条件后又睡回去了相当于白唤醒。多生产者多消费者场景下稳妥做法是notifyAll让所有等待线程重新竞争避免线程饥饿。5.3 Lock Condition与阻塞队列现代更推荐的写法wait/notify模型能跑通但粒度太粗notifyAll会唤醒所有线程很多唤醒是无意义的。ReentrantLock提供的Condition可以让我们精确唤醒某一类线程ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() capacity) { notFull.await(); } queue.addLast(value); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value queue.removeFirst(); notFull.signal(); } finally { lock.unlock(); }生产者通知的是“队列有空位”的notFull消费者通知的是“队列有数据”的notEmpty互相不打扰。但在真正的生产代码里手写这些等待通知逻辑已经很少见了。JDK提供的BlockingQueue把这些都封装好了put在队列满时阻塞take在队列空时阻塞。用ArrayBlockingQueue实现生产者消费者核心代码浓缩到几行BlockingQueueInteger queue new ArrayBlockingQueue(5); // 生产者线程 queue.put(value); // 消费者线程 int value queue.take();所以我的建议是理解wait/notify是为了应付面试和理解底层原理日常开发直接用BlockingQueue别重复造轮子。6. 线程池参数与坑每天在用却很少人会配6.1 七个参数到底怎么理解ThreadPoolExecutor构造方法有七个核心参数每个都是面试重点参数含义corePoolSize核心线程数线程池常驻线程数量maximumPoolSize最大线程数允许创建的线程上限keepAliveTime非核心线程空闲存活时间unitkeepAliveTime的时间单位workQueue任务等待队列threadFactory线程工厂用于创建线程handler拒绝策略线程池的执行流程一定要背熟新任务进来先判断当前线程数是否小于核心线程数是则直接开新线程执行不是则尝试放入工作队列等核心线程空闲如果队列也满了再看当前线程数是否小于最大线程数是则创建非核心线程执行如果线程数已经到上限就执行拒绝策略。很多人在这里有个误解认为“核心线程数满了之后会先扩到最大线程数再去填队列”。实际恰恰相反是先填队
返回列表