免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

JavaSE多线程与并发:锁、线程池及实战排查精讲

JavaSE多线程与并发:锁、线程池及实战排查精讲 先交代一句这篇文章聊的是JavaSE系列里的最后一个硬骨头也是整个JavaSE知识树里最容易被问倒、最容易翻车的篇章——多线程与并发。学JavaSE前期的语法、集合、IO本质上都是单线程的“线性思维”到了这里整个思维模型要从“一辆车跑一条道”变成“好多辆车抢几条道”很多人的崩溃就是从这儿开始的。这篇文章适合三类人一是正在学JavaSE、准备从面向对象过渡到并发编程的同学二是已经把基础语法刷完、但不知道线程安全到底是啥的初级开发者三是准备面试、想快速把多线程知识盘成体系的人。我尽量用“人能听懂的话”来讲把锁、线程池、原子类这些概念全部落到能跑通的代码上。1. 线程的底层逻辑与三种创建方式很多教材上来就讲Thread、Runnable这是纯API视角学完容易“会写但不懂”。我从操作系统角度先把线程这层窗户纸捅破你会发现后面所有的锁、同步、通信全是围着这层逻辑转的。1.1 进程和线程的关系用餐厅打个比方一个进程相当于一间餐厅它有自己的后厨、仓库、收银台、门面——这就是独立的内存空间。线程相当于餐厅里的员工所有员工共享同一个后厨和仓库共享堆内存但各有各的工作服和工牌线程栈、程序计数器。餐厅可以只有老板一个人干活也可以请十个服务员同时接客这就是单线程和多线程的关系。线程为什么存在核心原因是让程序同时干好几件事一边接收用户输入一边渲染界面一边在后台下载文件。如果是单线程你得等下载完才能点按钮这体验基本是灾难。Java里线程的最小单位是Thread对象每个Thread实例对应操作系统一个原生线程。你new一个Thread并不立即开始跑只有调用start()方法JVM才会通过底层操作系统创建真正的线程去执行run()里的代码。这里有个最常见的坑直接调用run()不会新起线程它只是普通方法调用在主线程里跑而已。1.2 Thread继承和Runnable接口的取舍创建线程最经典的方式是两种继承Thread类和实现Runnable接口。// 方式一继承Thread重写run方法 class MyThread extends Thread { Override public void run() { System.out.println(线程运行中 Thread.currentThread().getName()); } } // 使用new MyThread().start(); // 方式二实现Runnable传给Thread class MyTask implements Runnable { Override public void run() { System.out.println(任务运行中 Thread.currentThread().getName()); } } // 使用new Thread(new MyTask()).start();这两种方式的取舍很简单Java单继承你继承了Thread就不能继承别的类了所以真正写项目几乎没人用继承Thread全是实现Runnable。Runnable把“任务”和“执行者”解耦了——线程是Thread任务是Runnable逻辑上更干净。后面线程池接收的也是Runnable或Callable而不是Thread这本身就说明设计倾向。1.3 lambda改造与Callable的补位Runnable接口只有一个抽象方法run()是标准的函数式接口所以Java 8之后可以直接用lambda简化new Thread(() - System.out.println(lambda线程 Thread.currentThread().getName())).start();但Runnable有个尴尬run()方法没有返回值也无法抛受检异常。你想把线程算出来的结果拿回来用它做不到。所以Java 5引入了CallableCallableInteger task () - { Thread.sleep(1000); return 42; }; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get(); // 阻塞等待结果 System.out.println(结果 result);FutureTask的get()是个阻塞方法等线程算完才有结果这是异步转同步的标准姿势。你现在可能觉得这写法啰嗦先记住后面线程池里的submit()就是干这个的本质思路完全一致。2. 线程安全锁的演进与同步机制线程一旦多了最怕的事就是“抢资源”。多个线程同时改同一个变量轻则数据错乱重则程序崩溃。这是整个并发的核心矛盾——线程安全。2.1 从竞态条件到临界区先看一个最经典的翻车场景public class Counter { private int count 0; public void increment() { count; // 看似一行底层是“读值-加一-写回”三步 } }两个线程同时执行increment()理想顺序是线程A读count0A改成1写回线程B读count1改成2写回。但现实可能是A读count0B也读count0A写回1B也写回1最终count只有1。两次加一结果只加一这就是竞态条件。产生竞态的本质是count这行代码不是原子的它在字节码层面是好几条指令。多个线程同时进入同一段操作共享数据的代码区域这段区域叫临界区。解决思路就一个保证临界区内的操作在同一时刻只有一个线程能进入——加锁。2.2 synchronized的三种形态与锁升级synchronized是Java最基础的锁用法有三层// 1. 修饰实例方法锁的是this对象 public synchronized void increment() { count; } // 2. 修饰静态方法锁的是Class对象 public static synchronized void staticMethod() { } // 3. 同步代码块锁的是指定对象 public void increment() { synchronized (this) { count; } }锁的本质是“对象的监视器锁”monitor。每个Java对象都有一个monitor线程进入synchronized代码块前必须拿到这个monitor拿不到就阻塞等待。这就是互斥。很多同学纠结“锁对象选谁”核心原则是多个线程竞争的资源是谁就锁谁拥有的那个对象。比如多个线程都在改同一个Counter对象的count那就锁这个Counter实例如果改的是静态变量那就锁Class对象因为静态变量属于类级别实例锁不管用。JDK 6之后的synchronized做了锁升级优化从无锁到偏向锁再到轻量级锁再膨胀到重量级锁。简单理解锁初期很轻量多线程没真正竞争时效率很高只有真打起来才升级成重量级锁阻塞线程。这也是为什么现在很多场景synchronized性能并不输给Lock——别一听“老朋友”就嫌它慢。2.3 volatile轻量级同步方案volatile是另一个高频词汇它解决的是可见性问题不是原子性问题。Java内存模型里每个线程在工作内存中会缓存变量副本不会每次都直接读主存。线程A改了变量线程B可能还读着旧副本这就是可见性问题。volatile关键字强制线程每次读写都直接操作主存保证一个线程改了、其他线程立刻看到。public class FlagTest { private volatile boolean running true; public void stop() { running false; } public void work() { while (running) { // 循环体 } } }但volatile不保证原子性上面的count问题用volatile解决不了因为读改写三步不是原子的。volatile适用的场景是一个线程写、多个线程读的标记位。它的开销比锁小得多能用它解决的问题别上锁。2.4 从synchronized到Lock谁才是项目首选synchronized虽好但有几个痛点无法中断一个正在等待锁的线程无法设置等待超时多个条件condition的精确唤醒不好搞。于是Java 5提供了Lock接口Lock lock new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }注意一个铁律Lock的解锁必须放在finally里否则中途抛异常锁永远不会释放直接死锁。ReentrantLock是juc包里最常用的锁实现名字里的Reentrant表示“可重入”同一个线程可以反复获取同一把锁这一点和synchronized一致。还有两个扩展方法值得用tryLock(timeout, unit)等待超时就放弃避免死锁lockInterruptibly()允许线程在等待锁时响应中断。这些synchronized全都没有。实际项目里我的习惯是简单的同步方法直接用synchronized代码少、可读性强需要超时控制、多条件唤醒、公平锁策略时才用ReentrantLock。3. 线程间通信生产者消费者完整落地多线程不是各跑各的更多时候需要协作——一个线程产出数据另一个线程消费数据。这就是经典的生产者-消费者模型。这一节我给出完整可跑的代码手把手拆解细节。3.1 用wait/notify写能跑的生产者消费者需求一个仓库最多存10个数字生产者往里放消费者往外取仓库满时生产者等待仓库空时消费者等待。public class ProducerConsumerDemo { private static final int CAPACITY 10; private final LinkedListInteger queue new LinkedList(); 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; } public static void main(String[] args) { ProducerConsumerDemo demo new ProducerConsumerDemo(); // 两个生产者 for (int i 0; i 2; i) { final int seed i; new Thread(() - { try { for (int j 0; j 20; j) { demo.produce(seed * 100 j); Thread.sleep(50); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } // 两个消费者 for (int i 0; i 2; i) { new Thread(() - { try { for (int j 0; j 20; j) { demo.consume(); Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } } }这套代码值得背下来它是所有线程通信代码的母版。3.2 为什么wait必须放在while循环里而不是if里教科书和面试官最爱问的一个点就是wait()外面套的是if还是while标准答案必须是while有三层原因第一层是虚假唤醒。JVM规范允许wait()在没有notify的情况下被意外唤醒这不是bug是底层行为。用if的话被虚假唤醒后直接往下走而条件可能仍不满足比如库存还是满的就直接出错。while会重新检查条件不满足继续wait。第二层是多线程竞争。即使你是被notifyAll唤醒的但仓库里只有一个空位三个生产者一起醒来如果走if三个都会往队列里塞数据容量就爆了。while保证只有一个线程能通过条件检查。第三层是编程习惯的统一。你在while循环里检查条件是“守住了再干活”如果条件不满足就再次等待这是一套完整的自洽逻辑。记住口诀wait永远在循环里循环条件就是资源条件。3.3 LockCondition的精确唤醒版本synchronized的wait/notify有个粗糙的地方notifyAll会唤醒所有线程然后大家互相挤。如果用Lock的Condition可以实现精确唤醒——只唤醒生产者或只唤醒消费者。public class ConditionDemo { private static final int CAPACITY 10; private final LinkedListInteger queue new LinkedList(); private final Lock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); // 仓库未满条件 private final Condition notEmpty lock.newCondition(); // 仓库非空条件 public void produce(int value) { lock.lock(); try { while (queue.size() CAPACITY) { notFull.await(); } queue.addLast(value); notEmpty.signal(); // 只派发给一个正在等待的消费者 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } public int consume() { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } int value queue.removeFirst(); notFull.signal(); // 产出一个空位唤醒一个生产者 return value; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return -1; } finally { lock.unlock(); } } }我把两个案例摆在一起你能直观感受到区别synchronized版本是“全喊一遍大家自己抢”Condition版本是“精准点名生产者找生产者消费者找消费者”。后者的线程调度开销更小复杂系统里优先用它。4. 原子类、并发容器与线程池有了锁和通信的基础现在进入工程级工具。这三样东西是并发编程里真正高频使用的组件原子类解决简单计数问题并发容器替代老旧的Vector/Hashtable线程池接管线程的生命周期。4.1 CAS原理原子类的灵魂AtomicInteger的本质是CASCompare And Swap操作即比较并交换。它的流程是先读当前值计算新值在写回之前再确认一次当前值没被改过没改就写回改过了就重试。这是乐观锁的思路——不阻塞反复尝试。AtomicInteger count new AtomicInteger(0); // 等价于 count但线程安全 int newValue count.incrementAndGet(); // 自定义CAS操作 count.updateAndGet(x - x * 2);CAS有三个问题要心里有数ABA问题值被改成其他值又改回来CAS无法感知解决要靠版本号AtomicStampedReference自旋开销高并发下反复重试CPU空转只能操作单个变量多个变量联动还是要锁。JDK 8之后推荐LongAdder它在高并发下把单个计数变量拆成多个单元线程各自累加自己在的那份最后sum()汇总。简单场景用AtomicInteger就够但超高并发计数场景用LongAdder性能能提升一个量级。我曾经压测过一个热点计数AtomicInteger的吞吐是每秒2000万LongAdder能到7000万以上代价是sum()不是精确的实时值。取舍看业务对数值精确性要求苛刻、读多写少选原子类写多、不要求瞬间精确选LongAdder。4.2 ConcurrentHashMap比Hashtable强在哪先纠正一个误区不要在项目里用Hashtable也不要用Collections.synchronizedMap()包HashMap。它们的同步方式都是锁整个map读也要锁并发大了全堵在一个入口。ConcurrentHashMap的思路是分段/分桶锁。JDK 7是每段一个锁JDK 8改成了CAS synchronized锁桶头节点锁粒度细到“每个哈希桶”。桶之间互不影响读操作无锁化写操作只锁住要写入的那个桶。8核下同时写8个不同桶是完全并行的。MapString, Integer map new ConcurrentHashMap(); map.put(key, 1); // 线程安全写 Integer value map.computeIfAbsent(key, k - 1); // 原子计算注意computeIfAbsent是个原子操作可以替代“先get再put”的复合操作。我自己踩过坑之前用putIfAbsent写缓存但value的构建逻辑可能执行多次改用computeIfAbsent后构建逻辑在键不存在时才执行且只执行一次。4.3 线程池手写一个ThreadPoolExecutor线程池解决了两个核心问题复用线程不反复创建销毁省耗时控制并发数防止几百个线程把CPU拖垮。Java的线程池顶层接口是ExecutorService最常用的实现是ThreadPoolExecutor。ThreadPoolExecutor executor new ThreadPoolExecutor( 2, // 核心线程数 8, // 最大线程数 30, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue(100), // 任务队列 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );核心参数有七个但最关键是四个corePoolSize、maximumPoolSize、workQueue、rejectedExecutionHandler。提交任务的流程是先让核心线程跑核心线程忙了任务进队列队列满了创建非核心线程线程数到上限走拒绝策略。选参数没有万能公式但有经验参考CPU密集型任务设N1N是CPU核数IO密集型设2N或更多队列长度要结合任务的峰值排队时长来算。拒绝策略这里坑最多CallerRunsPolicy会让提交线程自己跑任务适合不想丢任务的场景DiscardPolicy静默丢任务一般别用最严肃的是AbortPolicy直接抛RejectedExecutionException我习惯自定义个策略打日志降级。4.4 大厂面试版线程池题为什么禁止用ExecutorsExecutors是工具类几个快捷方法看着省事但它们都有隐患// 坑一无界队列任务堆积会把内存打爆 ExecutorService pool Executors.newFixedThreadPool(10); // 内部用的是 LinkedBlockingQueue 无界队列 // 坑二最大线程数是Integer.MAX_VALUE等于无上限 ExecutorService pool Executors.newCachedThreadPool(); // 内部用的 SynchronousQueue线程数可以无限膨胀newFixedThreadPool的无界队列意味着任务永远排得下线程数永远不增长如果任务持续堆积内存直接OOM。newCachedThreadPool的SynchronousQueue会把任务直接交给线程处理线程不够就新建最多可以建20多亿个线程系统必挂。正确做法是像我上面那样显式new ThreadPoolExecutor把队列、线程数、拒绝策略全部掌控在自己手里。“不要用Executors”这句话在面试里说出来比你说一万个API都有分量。5. 易错点、排查技巧与性能误区多线程的内容如果只讲API那你学完还是会写出一堆线上事故。这节是真正的实战经验合集全是我在实际项目里踩过、查过、压测过的真实教训。5.1 三大经典死锁场景死锁的公式是两个或多个线程各自持有一把锁等待对方手里的锁谁都不让。场景一锁顺序不一致。线程A先锁a再要b线程B先锁b再要a两边卡死。解决所有线程按相同顺序加锁先a后b就破局了。场景二在持锁状态下调用外部方法。你锁着订单表然后去调库存接口库存那边又锁着库存表等订单结果死锁就在追逐中诞生。解决不要在持锁时做耗时操作把锁的范围缩到最小。场景三tryLock超时被忽略。这其实是代码buglock.tryLock(3, TimeUnit.SECONDS); // 没拿到锁但你继续往下走照样操作共享资源有人以为tryLock一定成功实际上返回false时得做兜底处理。用Lock的场合我习惯写成if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { // 超时处理重试、降级或者记日志 }5.2 线程卡死后的排查流程线上出现了线程全部卡住、接口毫无响应第一步别重启先抓线程栈。线程栈是判断死锁和阻塞最直接的证据。Linux下用jstack先jps找到Java进程ID再jstack PID thread.log。Windows下可以用jconsole或jvisualvm。打开线程栈文件后搜“Found one Java-level deadlock”如果你写的是规范代码这里会直接指出哪些线程互相持有哪把锁。搜不到死锁关键字就看线程状态BLOCKED正在等待锁看它等的是哪把锁谁拿着这把锁WAITING在等待唤醒对应wait/awaitRUNNABLE正常跑着如果集中在某个GC线程那就是GC停顿问题。我处理过一次线上事故所有业务线程全是BLOCKED等同一把数据库连接池的锁根因是数据库连接泄露连接池耗尽。线程栈不会直接告诉你“连接泄露”但你能从“所有线程都在获取连接”倒推过去。5.3 性能误区锁粒度越小越好吗很多人追求“无锁”无锁确实快但代码复杂度和BUG率会上去。锁的代价不只是性能还有正确性。没有充分的压测数据支撑不要轻易去锁重构。锁粒度方面也有个反向教训synchronized方法锁整个方法体你把不相关的计算逻辑也锁住了这是锁没细化但如果你把锁拆成三个小锁本来一个事务性操作被拆成三步中间步骤别的线程插进来改了数据业务就错了。锁粒度不是越小越好而是“临界区正好覆盖需要原子保护的逻辑不扩大不缩小”。线程数设置的经验公式我见过太多误解真实的准则是CPU密集型用“核数1”IO密集型用“核数*2”起步但最终都以压测为准。我用一个项目举例日志写入的异步线程池IO密集核数8起了16个线程QPS达标且线程占用稳定在40%以下完美。后来又贪爽加了4个线程想再提QPS结果线程切换开销上来了QPS反而掉了5%。调线程池参数一定要压测别拍脑袋。5.4 高并发下的日志打印要小心日志是排查问题的重要工具但高并发下有个隐蔽问题日志框架的异步队列也可能成为瓶颈和内存炸弹。我见过log4j2异步队列默认配置高峰流量下队列堆积上百万条日志直接把内存撑爆。经验做法是业务日志打印量要控制在每秒1万条以内超过就要采样或者降级日志内容别打印大对象比如整个请求体打MDC关键字段就够了异步队列有个上限满了要抛弃日志而不是阻塞业务线程。排查高并发问题时很多同学第一反应是加日志看中间状态这恰恰最危险——加了日志可能会改变线程时序让问题“消失”或“加重”。正确姿势是优先看业务metrics再抓线程栈最后才考虑有条件的日志采样。6. 我能给到的最后几条实战心得JavaSE的多线程学到这知识地图基本齐了线程模型、锁机制、通信方式、并发工具、线程池、问题排查。接下来分享几条我在实际项目中沉淀下来的判断原则和使用偏好希望能帮你少走弯路。第一能不用锁就不用锁优先用无锁方案。AtomicInteger、ThreadLocal、CopyOnWriteArrayList这些都是为了少用锁存在的。CopyOnWriteArrayList适合读多写极少的场景比如缓存配置列表写的时候复制一个新数组替换引用读的时候数组内容永远不变完全无锁。但写频繁就别用它每次写都复制数组成本高到吓人。第二ThreadLocal用完必须remove否则线程池回收不了ThreadLocalMap里的value会内存泄露。为什么线程池尤其严重因为线程池的线程长时间存活你往ThreadLocal里塞的每个值都绑定在线程上线程不死值永远挂在老地方。我踩过的坑是用ThreadLocal存用户信息用户A的数据被线程池里的用户B看到了这就是典型的线程复用的脏数据问题。正确的关闭习惯是finally里remove()。第三真正高难度的并发问题是“顺序”和“一致”不是“性能”。锁、CAS、队列解决得再好如果业务逻辑本身的前置后置条件没理清照样出错。用synchronized去包裹一段复杂业务前先画清楚哪些操作是一个不可分割的整体哪些操作之间有顺序依赖这一步做扎实后面写代码就是水到渠成的事。第四建议你养成读线程dump的习惯。不需要等到出事故才看平时写复杂并发代码时可以故意加个sleep然后jstack看线程状态理解每个状态长什么样。这比背十遍并发编程的艺术都有用。最后说个很多人忽略的点JavaSE的多线程是整个Java并发体系的地基。后续学Spring、学Netty、学各种中间件底层全在并发和IO上绕圈。地基打牢了后面任何框架都只是API层面的使用你看到的是一个又一个基于线程池、锁、IO模型的组合而不是一堆陌生名词。这节啃下来JavaSE就真的收官了。
返回列表