ARTICLE DETAIL

资讯详情

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

Java多线程与并发编程实战:从JMM原理到高并发系统优化

Java多线程与并发编程实战:从JMM原理到高并发系统优化 做了这么多年Java开发面试过别人也被别人面过多线程与并发永远是最绕不开的一块。往浅了问是new Thread().start()和Runnable接口往深了问是AQS、CAS、内存可见性中间还夹着线程池参数、锁升级、并发容器随便拎一个出来都能写一篇长篇大论。更现实的是你去看各大厂的java面试题和八股文并发编程永远占着最大篇幅因为这东西直接决定了一个系统能不能扛住流量。这篇东西我按照自己从入门到实战再到面试的完整路径来写不空谈理论尽量把每条知识点都讲清楚“为什么是这样”顺带给出能直接上手的代码和排查经验。适合刚学完Java基础准备进阶的读者、准备面试的开发者以及工作中想真正把并发性能调优落地的朋友。1. 并发编程的三块基石为什么你写的代码会“抽风”1.1 从CPU到JMM并发问题到底是怎么产生的要理解多线程先得明白一个物理事实现在的CPU都是多核的一个4核8线程的机器在同一时刻可以并行执行8条指令流。Java的线程本质上是映射到操作系统原生线程上的所以只要你的机器够强几千个线程是可以“同时跑”的。但问题来了多线程要操作共享数据。比如一个计数器两个线程同时执行count在CPU层面其实是三步——读count到寄存器、寄存器加1、写回内存。三步之间完全可能被另一个线程插一脚最后count只加了1次而不是2次。这就是竞态条件。更隐蔽的是Java内存模型JMM带来的可见性问题每个线程在工作内存里都有一份变量的副本线程A改了变量的值线程B不一定立刻看得到因为工作内存和主内存之间的同步是有延迟的。所以并发编程的三块基石就是原子性操作不可分割、可见性一个线程的修改对另一个线程立即可见、有序性编译器/CPU不会乱序执行。搞不定这三件事你的并发代码就会“抽风”——本地跑得好好的一上线就偶发数据错乱还特别难复现。1.2 说人话理解JMM官方的JMM文档看一百遍容易晕我用生活类比给你捋一遍。想象一个学校食堂主内存是总仓库的大菜谱所有菜品的绝对标准份量每个窗口师傅手里有一张自己抄的小抄工作内存。所有窗口师傅同时做菜如果A师傅悄悄改良了配方改的是自己的小抄没功夫回大菜谱上更新B师傅做菜时拿的还是旧配方。这就叫“可见性”问题。解决思路其实就两条给关键操作加锁synchronized/Lock或者用volatile声明变量强制线程每次读写都直接刷主内存。但要注意volatile只保证可见性和有序性不保证原子性volatile int count做count依然是错的。这三块基石是所有并发机制的设计出发点。2. 线程的核心操作创建、生命周期与安全终止2.1 创建线程的几种姿势别再用第一关了Java里创建线程的路子其实只有两条本质包装一个任务交给线程执行。最原始的写法是继承Thread重写run()但开发中我建议你直接屏蔽这种写法因为继承会浪费掉唯一的单继承名额而且任务和线程耦在一起。更推荐实现Runnable解耦任务和线程如果要拿返回值就用Callable配合FutureTask或线程池。我先给一个最标准的写法// 任务定义 CallableInteger task () - { // 模拟耗时计算 Thread.sleep(1000); return 1 1; }; // 配合线程池提交 ExecutorService pool Executors.newFixedThreadPool(4); FutureInteger future pool.submit(task); Integer result future.get(); // 阻塞拿结果 pool.shutdown();实操里有个容易踩的坑不要用Executors.newFixedThreadPool()无脑创建线程池因为newFixedThreadPool和newSingleThreadExecutor底层用的是无界队列LinkedBlockingQueue任务堆积起来可能导致OOM这个细节面试时经常被追问后面线程池章节细说。2.2 从新建到终止线程的6种状态与切换线程状态是面试基础题也是你排查问题时看jstack日志的基础。Java线程有6种状态NEW新建未启动、RUNNABLE就绪运行中、BLOCKED等锁、WAITING无限等待、TIMED_WAITING有限等待、TERMINATED终止。有个误区我必须强调我们常说的“阻塞”在Java状态机里其实被拆成了BLOCKED和WAITING/TIMED_WAITING。BLOCKED专指没抢到synchronized锁的线程而wait()、join()、park()这些会让线程进入WAITING或TIMED_WAITING。区分这俩在调优时有实际意义如果大量线程卡在BLOCKED说明锁竞争激烈如果大量线程WAITING说明线程在等待某个条件不一定是坏事。2.3 终止线程的正确姿势别再用stop()了Thread.stop()已经废弃它会在任意位置强制终止线程可能让共享数据处于一种中间被破坏的脏状态。正确的停止方式是协作式中断用interrupt()方法发出中断信号线程内部通过检查中断标志来自行决定何时安全退出。Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { // 处理业务逻辑注意响应中断 try { Thread.sleep(100); } catch (InterruptedException e) { // sleep时被中断会抛出该异常并清除中断标志 // 这里需要重新设置中断标志否则循环条件检测不到 Thread.currentThread().interrupt(); } } }); worker.start(); // 稍后发起停止请求 worker.interrupt();实操心得很多人在sleep/wait被中断后直接break没有重新设置中断标志导致外层循环继续跑。记住InterruptedException抛出后线程的中断状态会被清除如果还想让上层感知就得自己再补一次interrupt()。3. 锁的核心机制synchronized、volatile与Lock体系3.1 synchronized的锁升级之路synchronized在JDK 6之后做了大量优化不再是一上来就向操作系统申请重量级锁。锁的升级路径是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。偏向锁的思路是如果只有一个线程反复进入同步块就让这个线程“记住”锁是偏向它的后续进入不做额外竞争检查。一旦出现第二个线程竞争偏向锁撤销升级为轻量级锁。轻量级锁用CAS自旋抢占锁适合锁持有时间很短的场景。如果自旋超过一定次数或竞争线程很多就升级为重量级锁线程会进入BLOCKED状态由操作系统调度的monitor管理。这个升级过程面试必考你需要能回答出“JVM为什么这么设计”——本质是用低成本的方式应对低竞争场景把昂贵的系统调用省下来。写代码层面的建议是同步块的锁粒度尽量小、锁持有的时间尽量短让轻量级锁的自旋策略发挥最大价值。3.2 volatile的适用边界volatile修饰的变量有两个语义禁止指令重排序以及变量的读写直接操作主内存。它做状态标志和单次读写的安全发布非常合适比如经典的boolean标志位控制线程关闭。但它不能满足复合操作的原子性count这种读改写三元操作volatile完全罩不住。一个典型的正确用法是双重检查锁DCL中的instance字段private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); } } } return instance; }第一次检查不加volatile的隐患在于new Singleton()包含分配内存、初始化对象、将引用指向内存三步CPU可能乱序执行变成“先赋值再初始化”另一个线程读取到未初始化完全的对象。volatile禁止了这种重排序。这道题在java面试八股文里出场率接近100%值得彻底吃透。3.3 Lock接口从ReentrantLock到读写锁java.util.concurrent.locks.Lock体系提供了比synchronized更灵活的锁操作支持非阻塞尝试加锁tryLock()、可中断加锁、超时加锁、以及多个Condition条件队列。ReentrantLock是可重入锁一个线程可以多次获得同一把锁内部通过AQSAbstractQueuedSynchronizer同步器维护等待队列。ReentrantLock还分公平锁和非公平锁。公平锁按线程到达顺序排队非公平锁允许新来的线程直接插队抢锁。实操中我默认用非公平锁因为吞吐量更高公平锁的开销来自维护严格FIFO队列的上下文切换一般只有顶着强一致需求的特定场景才用。再说读写锁ReentrantReadWriteLock读读不互斥、读写互斥、写写互斥。适合读多写少的场景比如配置中心的缓存刷新。但读写锁有个坑——写线程可能被读线程持续阻塞读锁一直被占用时写锁永远等不到这时可以考虑StampedLock的乐观读模式它允许读线程不阻塞写线程大幅提升吞吐。3.4 无锁神器CAS以及它的ABA问题AtomicInteger能保证自增原子性底层靠CAS比较当前值和预期值一致才更新否则自旋重试。CAS在原子类、ConcurrentHashMap、AQS里都用得极多性能比锁好因为它不需要线程挂起和恢复。CAS的经典坑是ABA问题线程A读到值1此时线程B把值改成2又改回1线程A再CAS时发现值还是1就认为没变化实际中间被动过。解决思路是加版本号AtomicStampedReference干的就是这事。运行中真正遇到ABA的场景不多但面试官一定会连环追问你得答得上来。4. JUC工具类实战线程池、并发容器与协调器4.1 线程池的核心参数别只在简历上写“用的线程池”线程池是Java并发的高频考点也是线上高并发系统的基础设施。搞清楚它从JDK源码里ThreadPoolExecutor的7个参数开始new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 时间单位 workQueue, // 任务队列 threadFactory, // 线程工厂 rejectedExecutionHandler // 拒绝策略 );工作流程是这样的提交任务时如果当前线程数小于核心线程数直接创建新线程执行超过核心线程数任务进阻塞队列排队队列满了才创建非核心线程执行任务线程总数达到最大值且队列也满了走拒绝策略。默认的AbortPolicy会抛异常更稳妥的方案是CallerRunsPolicy让提交任务的线程自己执行或DiscardOldestPolicy丢弃最老的任务具体选哪个看你的业务容错能力。关于线程数设置有标准经验公式CPU密集型任务核心线程数 CPU核数 1因为线程基本不等待IO。IO密集型任务核心线程数 CPU核数 * 2因为线程大量时间在等待网络IO可以开更多线程把CPU占满。更精确的做法是考虑阻塞系数线程数 CPU核数 / (1 - 阻塞系数)阻塞系数在0.8~0.9之间。我曾经遇到过一个典型的线上事故核心线程数设太大任务又是CPU密集型的线程上下文切换浪费了大量CPU吞吐量反而下降。调整思路就是按公式估算后再用压测验证迭代。4.2 并发容器怎么选ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueueHashMap在多线程并发读写时会丢数据甚至形成死循环JDK 7在扩容时可能出现循环链表所以并发环境必须用ConcurrentHashMap。JDK 8后的实现抛弃了分段锁改用CAS synchronized锁单个数组桶锁粒度更小并发度更高。CopyOnWriteArrayList则是一种“读多写极少”场景的解决方案每次写都复制整个底层数组写操作通过锁保护读操作直接读旧数组内容。虽然写成本高但读线程完全不用加锁适合黑名单、白名单、配置列表这类读频率远高于写的场景。BlockingQueue是线程池和工作队列的基石生产消费模型里也是标准件。常见的有ArrayBlockingQueue有界、基于数组、LinkedBlockingQueue可选有界/无界、基于链表、SynchronousQueue不存储元素直接交接、DelayQueue延迟队列适合定时任务调度。选用有界队列时队列长度要结合实际流量和系统容量仔细推太小容易触发拒绝策略太大则可能积压大量任务导致响应延迟飙升。4.3 协作工具CountDownLatch、CyclicBarrier、Semaphore与CompletableFuture多线程之间经常需要“约好了一起跑”、“全部跑完我再来汇总”的协作。CountDownLatch是个倒计数门闩比如一个订单处理要同时调用优惠券服务、库存服务、用户服务三个并行跑完之后主线程再汇总结果。CyclicBarrier则是“凑齐一拨人再一起出发”可以用在批量定时任务的分布式触发场景。Semaphore是信号量控制同时访问某个资源的并发线程数典型如限流器数据库连接池的并发访问上限也可以用信号量实现。// CountDownLatch 模拟并行查询后汇总 CountDownLatch latch new CountDownLatch(3); ExecutorService pool Executors.newFixedThreadPool(3); AtomicBoolean result new AtomicBoolean(true); for (int i 0; i 3; i) { final int serviceId i; pool.execute(() - { try { // 模拟调用外部服务 Thread.sleep(1000 serviceId * 500L); } catch (Exception e) { result.set(false); } finally { latch.countDown(); } }); } try { latch.await(5, TimeUnit.SECONDS); // 最多等5秒 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } if (result.get()) { System.out.println(所有服务调用成功); } else { System.out.println(部分服务失败走兜底逻辑); }JDK 8之后我强烈建议直接关注CompletableFuture它可以极其优雅地编排异步任务链thenApply做结果转换、thenCompose做任务串联、allOf等所有任务结束、exceptionally处理异常。我做过一个聚合服务以前用CountDownLatch FutureTask写了几十行换成CompletableFuture后代码行数缩到一半异常传播还更清晰。5. 高并发场景实战从IM、库存到并发数估算5.1 面试必问16C32G服务器到底能扛多少并发很多人在面试或者规划项目时都会问“一台16C32G的服务器能支持多少并发”。这是一个非常经典的工程估算题没有唯一答案但可以通过资源模型算出合理区间。先拆资源。每一个HTTP请求在不涉及锁和外部IO的情况下占用的资源主要包括一个线程栈默认1MB实际使用远小于、内存中请求体的临时对象、CPU时间片。假设每个请求平均耗时50ms单核CPU每秒可以处理约20个请求16核理想情况下每秒可以处理320个请求。如果只是短请求的QPS上限理论上几千是完全可能的。但如果请求IO密集比如每个请求要查数据库、调外部REST接口耗时1秒以上情况就变了。此时瓶颈不再是CPU而是内存中的线程数和外部系统的吞吐。32G内存JVM堆给16G线程栈之外每线程额外开销约0.5MB32G大概可以容纳几千个存活线程。但几千个线程同时跑上下文切换的开销会迅速拉低CPU利用率实际合理并发线程数要控制在200~500之间。所以比较靠谱的回答方式是先确认业务类型CPU密集还是IO密集、单请求耗时、以及对延迟的容忍度再用一个简单公式估算QPSQPS ≈ 1000 / 平均响应时间(ms) × 可用CPU有效利用率系数实操经验里我还建议专门留一部分线程数给JVM的GC开销和系统守护进程别把资源算满否则一压测CPU就飙到100%系统反而会更慢。5.2 ERP库存扣减数据库锁与并发控制热词里专门提到“ERP库存场景高并发的解决方案”这个场景我做过真实项目踩过不少坑。库存扣减的核心是防止超卖两个请求同时查到库存为1都执行扣减最终可能扣成-1。最朴素的做法是数据库悲观锁扣减时SELECT ... FOR UPDATE锁住库存行直到事务提交。这种方案最安全但并发低所有扣减请求串行执行大促时必然会拖垮数据库。升级方案是乐观锁更新时带上版本号条件UPDATE stock SET count count - #{num}, version version 1 WHERE id #{id} AND version #{version}如果影响行数为0说明版本对不上重试即可。这种方案适合冲突概率不高的场景但高并发下重试过多会浪费吞吐。再升级的方案是把库存预热到Redis扣减走Lua脚本保证原子性后台再异步同步到数据库。Lua脚本可以保证“判断库存充足 - 扣减 - 回写”三步原子完成。这里的权衡在于Redis和DB的一致性问题通常用最终一致策略配合对账任务修正。这套思路也是高并发IM等系统里“先走缓存、异步落库”的标准范式。我的建议是分阶段业务量不大时直接用数据库乐观锁简单可靠一旦压测发现扣减接口吞吐上不去再引入Redis方案的中间件而不是一上来就把系统架构搞得很重。5.3 高并发IM推送系统连接、线程与异步高并发IM里的Java多线程主要集中在这几个点接入网关线程模型的选择、消息推送的异步化处理以及后端消息队列的消费速度。传统的“一连接一线程”模型在几十万长连接下根本撑不住因为线程数太多、切换太频繁。业界普遍采用NIO事件驱动模型加有限的工作线程池IO线程只用少量线程处理网络读写事件业务逻辑投递到后端的业务线程池。Netty就是这个领域的代表性框架很多IM产品的网络层直接基于它。对于Java服务端开发来说需要关注的是Netty里线程模型的隔离bossGroup负责Accept连接workerGroup负责Read/Write事件的调度关键是别把耗时的业务逻辑直接放在NIO线程里执行否则会拖慢整个事件循环其它连接全部被阻塞。这也是“高并发IM”场景下Java多线程最容易出的架构级问题。5.4 用JMeter压测并发数据要这样看写完并发代码不做压测等于没写。JMeter是最常见的免费压测工具用它在接口上模拟并发用户很简单添加线程组设置线程数模拟用户数、Ramp-Up Period爬坡时间、循环次数再添加HTTP请求取样器和聚合报告。看聚合报告时别只盯着“Average”要看三个指标90% Line90%请求的响应时间、Throughput吞吐量单位requests/sec、Error%。如果随着线程数从50涨到200吞吐量上升但90% Line快速恶化说明系统已经进入过载区间如果Error%伴随大量超时升高需优先排查线程池队列和数据库连接池是否被打满。一个我常用的调整手段是“阶梯加压”从10个线程开始逐步翻倍每档跑2分钟观察系统拐点。这个拐点就是系统相对健康的极限并发值也是后续容量规划的重要参考。6. 常见问题与排查技巧实录6.1 死锁怎么发现、怎么破死锁的经典场景是线程A持有锁1等待锁2线程B持有锁2等待锁1两边都不让。开发中常见于嵌套加锁比如转账场景里同时锁定转出账户和转入账户。预防手段是“锁的获取顺序一致”加上“尝试获取锁”而不是死等boolean lock1Acquired lock1.tryLock(100, TimeUnit.MILLISECONDS); if (lock1Acquired) { try { boolean lock2Acquired lock2.tryLock(100, TimeUnit.MILLISECONDS); if (!lock2Acquired) { // 拿不到第二个锁就释放第一个而不是死等 } } finally { lock1.unlock(); } }如果线上已经出问题最快定位工具是jstack。先jps找到Java进程ID再jstack PID导出线程快照看到Found one Java-level deadlock片段跟着提示的线程号和锁对象去代码里找基本能一抓一个准。排查时要重点看线程状态是不是BLOCKED以及它等待的锁被谁持有。6.2 ThreadLocal的内存泄漏有坑ThreadLocal在并发场景里非常实用比如链路追踪ID、数据库连接的线程绑定。但它有个经典坑线程池里的线程是长期存活复用的如果往ThreadLocal里放了值却不在最后移除这些值会一直挂在线程的ThreadLocalMap里造成内存泄漏。相比之下每次业务逻辑处理完finally里调用remove()永远是最稳妥的。也不要依赖ThreadLocal的弱引用回收机制因为ThreadLocalMap的key虽然是被弱引用的value却还是强引用线程活着value就永远清不掉。6.3 线程池拒绝策略触发后业务“丢任务”了怎么办我把这个单独拿出来讲是因为它真实影响很多人的线上系统。当线程池满、队列也满时默认的AbortPolicy直接抛RejectedExecutionException如果你没有捕获任务就静默失败了。推荐的做法是捕获异常后走降级逻辑比如写入本地日志文件或投递到消息队列或者把任务重新交给调用方线程执行CallerRunsPolicy。我这里给一个还算稳妥的兜底配置ThreadPoolExecutor executor new ThreadPoolExecutor( 10, 20, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(500), new ThreadPoolExecutor.CallerRunsPolicy() );但CallerRunsPolicy也有副作用任务在调用线程里同步执行如果调用线程是Netty的IO线程会阻塞事件循环。所以我更建议自己写拒绝策略根据业务类型决定丢弃、降级还是重试。6.4 定位并发性能瓶颈的工具链排查并发问题时我常用的工具链是jstack看线程状态和锁、jstat看GC状况、jvisualvm看CPU和内存曲线、Arthas在在线环境直接反编译和做方法耗时链路。Arthas里有个命令dashboard可以实时看线程CPU占用和状态分布哪个线程在疯狂自旋、哪个线程卡在阻塞队列一眼就能看出来。一个真实案例某服务CPU持续偏高用Arthas抓到一个线程组频繁执行Thread.sleep(0)这是因为同事在代码里用空sleep做重试看似无害却导致CPU空转。修掉之后CPU降了30%这类问题光看代码是找不到的得靠工具看运行时行为。7. 面向面试与长期演进怎么学最有效7.1 高频面试题速查表结合我上面每个章节的内容整理一下面试里高频的问题问题核心回答要点线程状态有哪几种阻塞和等待的区别6种状态BLOCKED因锁未获得WAITING因wait/parksynchronized和ReentrantLock的区别一个是JVM层面锁、一个是API层面锁后者支持非阻塞、超时、条件变量volatile能保证原子性吗不能只保证可见性和有序性线程池有哪几类拒绝策略AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy高并发下如何减少上下文切换减少锁竞争、CAS代替锁、无锁并发编程、合理线程数什么是CAS的ABA问题值被改成其他值再改回原值CAS会误判通过版本号解决ConcurrentHashMap为什么快桶级锁CAS比HashTable全表锁并发度高如何避免死锁固定锁顺序、tryLock超时、一次性获取所有锁每一个问题背后都是我上面讲的细节只要不是死记硬背理解了原理基本都能顺下来。7.2 从基础到高并发的学习路径我建议按这样的顺序来推进第一步搞懂线程基础和JMM把Thread、Runnable、synchronized、volatile用熟第二步掌握JUC包的核心类包括ReentrantLock、ConcurrentHashMap、CountDownLatch、线程池第三步学会排查问题的工具jstack和Arthas每天用、天天用第四步结合真实场景做一个小项目比如写一个带线程池的库存扣减服务自己压测自己调优第五步再往深了看AQS源码、Redis高并发方案和消息队列进入高并发架构设计的领域。这套路径也是我当年走过的过程中最容易犯的错误是“跳级”——基础概念没吃透就去看AQS源码结果越看越懵。另外强烈建议自己动手写一个简易线程池不用很规范写完你再去翻JDK源码整个设计思路瞬间就通了。我在实际排查并发问题时最深的体会是并发编程里90%的“灵异事件”最后都能归因到原子性、可见性、有序性其中一个。所以无论你是刚学Java基础还是已经在调线上系统的性能遇到任何诡异现象先冷静下来对着这三块基石做排查绝对比漫无目的地改代码有效得多。最后再分享一个小技巧——写并发代码时把你每个线程执行的步骤在纸上画出来画出共享变量标注哪一步是“读改写”你就能提前看出八成的问题这比事后用jstack查要省力太多了。
返回列表