Java 并发编程
一、为什么我们必须搞懂并发编程很多人会问我就是个写业务 CRUD 的平时很少写多线程代码学并发有什么用 我给你三个无法拒绝的理由1. 解决线上核心故障Java 服务线上 80% 的诡异问题都和并发相关。超卖、重复数据、脏读、服务卡死、CPU 飙升这些问题的根因几乎都在并发层面。不懂并发你连问题出在哪都找不到更别说解决了。2. 充分利用服务器性能现在的服务器都是多核 CPU单线程程序只能利用一个核心多核的性能完全被浪费了。而合理的并发编程能让你的服务吞吐量提升数倍。3. 主流框架的底层基础你每天用的 Spring、MyBatis、Tomcat、Redis 客户端、消息队列底层全是并发编程。不懂并发你永远只能停留在 用框架 的层面出了问题根本不知道怎么排查。4. 面试晋升的硬门槛不管是初级升中级还是中级升高级并发编程都是 Java 面试必问的核心内容。不懂并发你永远只能停留在 业务开发 的舒适区很难突破职业瓶颈。二、线程安全的本质三大特性与 JMM 内存模型所有的并发问题根源都来自于 Java 内存模型JMM的三大特性原子性、可见性、有序性。线程安全的代码必须同时保证这三个特性。2.1 什么是 JMM 内存模型Java 内存模型Java Memory Model是 Java 虚拟机规范中定义的用来解决多线程环境下CPU 缓存、寄存器和主内存之间的数据同步问题。简单来说每个线程都有自己的工作内存对应 CPU 的缓存和寄存器所有的变量都存储在主内存中。线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存。不同线程之间也无法直接访问对方的工作内存线程间的变量传递必须通过主内存来完成。plaintext┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 线程A工作内存 │ │ 线程B工作内存 │ │ 线程C工作内存 │ └───────┬─────┘ └───────┬─────┘ └───────┬─────┘ │ │ │ └────────────────────┼────────────────────┘ ▼ ┌─────────────────┐ │ 主内存 │ └─────────────────┘这个模型带来了三个核心问题也就是并发编程的三大特性。2.2 原子性原子性一个操作是不可分割的要么全部执行成功要么全部不执行执行过程中不会被其他线程打断。比如经典的count操作看起来是一行代码实际上分为 3 步从主内存读取 count 的值到工作内存在工作内存中对 count 进行 1 操作将计算后的结果写回主内存在多线程环境下这三个步骤随时可能被其他线程打断导致最终的结果不符合预期。这就是原子性问题也是最常见的并发问题。2.3 可见性可见性一个线程修改了共享变量的值其他线程能够立即看到这个修改。在 JMM 模型中线程修改的是自己工作内存中的变量副本什么时候把这个修改同步到主内存是不确定的。这就会导致一个线程修改了变量另一个线程看不到最终引发线程安全问题。举个最简单的例子java运行public class VisibilityDemo { private static boolean flag true; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (flag) { // 空循环 } System.out.println(线程感知到flag变化退出循环); }).start(); Thread.sleep(1000); flag false; System.out.println(主线程已经将flag设置为false); } }这段代码在大多数情况下子线程永远不会退出循环。因为主线程修改的flag值没有被及时同步到子线程的工作内存中子线程看不到这个变化。这就是典型的可见性问题。2.4 有序性有序性程序执行的顺序按照代码的先后顺序执行。为了提升性能编译器和 CPU 会对指令进行重排序。重排序不会影响单线程的执行结果但在多线程环境下会导致意想不到的问题。最经典的例子就是单例模式的双重检查锁DCL问题java运行public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题出在这里 } } } return instance; } }instance new Singleton()这行代码实际上分为 3 步为 Singleton 对象分配内存空间初始化 Singleton 对象将 instance 引用指向分配的内存地址编译器和 CPU 可能会对这 3 步进行重排序变成 1→3→2。这就会导致一个线程执行了 1 和 3还没执行 2 的时候另一个线程进来发现 instance 已经不为 null 了直接返回一个未初始化完成的对象最终引发空指针异常。这就是有序性问题。三、Java 并发编程的核心解决方案Java 提供了一套完整的工具来解决三大特性带来的线程安全问题。下面我从基础到进阶逐一讲解最常用的解决方案以及它们的适用场景和优缺点。3.1 基础关键字volatile 与 synchronized1. volatile 关键字volatile是 Java 提供的最轻量级的同步机制它能解决可见性和有序性问题但不能解决原子性问题。volatile的核心作用保证可见性对 volatile 变量的修改会立即同步到主内存每次读取 volatile 变量都会从主内存重新加载禁止指令重排序通过内存屏障禁止编译器和 CPU 对指令进行重排序适用场景状态标记变量比如上面例子中的flag用 volatile 修饰就能解决可见性问题双重检查锁单例模式给 instance 变量加上 volatile就能禁止指令重排序解决 DCL 问题正确示例java运行// 状态标记 private volatile boolean flag true; // 安全的DCL单例 public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }注意volatile 不能解决原子性问题比如volatile int count; count依然是线程不安全的。2. synchronized 关键字synchronized是 Java 最基础的锁机制它能同时保证原子性、可见性、有序性是解决线程安全问题的 万能钥匙。synchronized的使用方式java运行// 1. 修饰实例方法锁的是当前对象this public synchronized void add() { count; } // 2. 修饰静态方法锁的是当前类的Class对象 public static synchronized void staticAdd() { staticCount; } // 3. 修饰代码块锁的是括号里的对象 public void add() { synchronized (this) { count; } }锁的升级过程很多人觉得 synchronized 性能差其实从 JDK1.6 开始JVM 对 synchronized 做了大量优化引入了锁升级机制性能已经和 ReentrantLock 相差无几。锁的升级流程无锁 → 偏向锁 → 轻量级锁 → 重量级锁偏向锁只有一个线程访问同步块时锁会偏向这个线程避免多次加锁解锁的开销轻量级锁多个线程交替访问同步块时使用 CAS 操作加锁不会阻塞线程重量级锁多个线程同时竞争锁时锁升级为重量级锁未抢到锁的线程会被阻塞优点使用简单不需要手动释放锁JVM 会自动释放不会出现死锁问题发生异常时 JVM 会自动释放锁JVM 内置优化性能优秀缺点不支持手动中断锁等待不支持尝试非阻塞获取锁不支持公平锁是独占锁读多写少的场景下性能不佳3.2 JUC 显式锁Lock 体系JDK1.5 引入了 java.util.concurrent简称 JUC包提供了更灵活的显式锁体系核心接口是Lock最常用的实现类是ReentrantLock和ReentrantReadWriteLock。1. ReentrantLock 可重入锁ReentrantLock和synchronized一样是可重入的独占锁但它提供了更灵活的功能。核心用法java运行private final Lock lock new ReentrantLock(); private int count; public void add() { lock.lock(); // 加锁 try { count; } finally { lock.unlock(); // 必须在finally中释放锁否则会发生死锁 } }ReentrantLock 的高级功能支持公平锁new ReentrantLock(true)可以创建公平锁先等待的线程先获取锁支持非阻塞尝试获取锁tryLock()方法获取不到锁立即返回不会阻塞支持可中断的锁等待lockInterruptibly()方法等待过程中可以被中断支持多个等待条件newCondition()方法可以创建多个条件队列实现更精细的线程协作和 synchronized 的对比表格特性synchronizedReentrantLock可重入支持支持公平锁不支持支持非阻塞获取锁不支持支持可中断不支持支持多个条件队列不支持支持自动释放锁支持不支持必须手动释放异常处理自动释放锁必须在 finally 中释放适用场景需要更灵活的锁控制比如尝试获取锁、超时获取锁、公平锁、多条件队列的场景。2. ReentrantReadWriteLock 读写锁读写锁是为了解决读多写少的场景而设计的。它维护了两个锁读锁共享锁和写锁独占锁。读锁多个线程可以同时获取读锁读读不互斥写锁只有一个线程能获取写锁读写、写写都互斥核心用法java运行private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); private MapString, Object data new HashMap(); // 读操作加读锁 public Object get(String key) { readLock.lock(); try { return data.get(key); } finally { readLock.unlock(); } } // 写操作加写锁 public void put(String key, Object value) { writeLock.lock(); try { data.put(key, value); } finally { writeLock.unlock(); } }适用场景读多写少的场景比如缓存、配置管理、元数据存储等能大幅提升并发性能。3.3 原子类无锁编程解决原子性问题对于简单的数值递增、递减操作用锁会带来额外的开销。JUC 包提供了一系列原子类基于 CASCompare And Swap操作实现无锁的原子性保证性能比锁高很多。常用的原子类基本类型原子类AtomicInteger、AtomicLong、AtomicBoolean数组原子类AtomicIntegerArray、AtomicLongArray引用原子类AtomicReference、AtomicStampedReference高并发累加器LongAdder、DoubleAdder核心用法java运行// AtomicInteger示例 private AtomicInteger count new AtomicInteger(0); public void add() { count.incrementAndGet(); // 原子性1相当于count } // LongAdder示例高并发下性能比AtomicLong更好 private LongAdder sum new LongAdder(); public void add(long x) { sum.add(x); }CAS 的原理CAS 操作包含三个参数内存地址 V、旧的预期值 A、新值 B。只有当 V 的值等于 A 时才会将 V 的值更新为 B否则什么都不做。整个操作是原子性的由 CPU 指令保证。CAS 是无锁编程的核心它不会阻塞线程在并发量不高的场景下性能远高于锁。CAS 的缺点ABA 问题一个值从 A 变成 B又变回 ACAS 会认为它没有变化。可以用AtomicStampedReference加版本号解决循环时间长开销大高并发下大量线程同时 CAS会导致多次重试CPU 开销大。可以用LongAdder解决只能保证一个变量的原子性多个变量的原子操作还是需要用锁3.4 线程池解决多线程的资源管理问题很多人写多线程代码喜欢直接new Thread()这是非常错误的做法。线程的创建和销毁都有很大的开销无限制的创建线程会导致内存溢出、CPU 占用过高。线程池的核心作用就是统一管理线程实现线程的复用控制最大并发数避免资源耗尽。线程池的核心参数java运行public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程的空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueueRunnable workQueue, // 任务等待队列 ThreadFactory threadFactory, // 线程创建工厂 RejectedExecutionHandler handler // 拒绝策略 )核心参数说明核心线程数线程池中一直保持存活的线程数即使空闲也不会被销毁最大线程数线程池允许的最大线程数量等待队列当核心线程都在忙碌时新任务会进入等待队列排队拒绝策略当等待队列满了且达到最大线程数时新任务会被拒绝有 4 种内置策略AbortPolicy直接抛出异常默认策略CallerRunsPolicy用调用者所在的线程来执行任务DiscardOldestPolicy丢弃队列中最老的任务执行当前任务DiscardPolicy直接丢弃当前任务不抛出异常线程池的正确创建方式绝对不要用 Executors 创建线程池阿里开发规范明确禁止这种做法。因为 Executors 创建的线程池要么无限制的创建线程要么无限制的添加任务最终都会导致 OOM。正确的创建方式手动创建 ThreadPoolExecutor根据业务场景设置合理的参数。java运行Configuration public class ThreadPoolConfig { Bean public ExecutorService businessExecutor() { // 核心线程数CPU密集型任务设置为CPU核心数IO密集型设置为CPU核心数*2 int corePoolSize Runtime.getRuntime().availableProcessors() * 2; return new ThreadPoolExecutor( corePoolSize, corePoolSize * 2, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), // 有界队列避免OOM new ThreadFactory() { // 自定义线程工厂给线程起名字方便排查问题 private final AtomicInteger threadNumber new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread thread new Thread(r, business-pool- threadNumber.getAndIncrement()); thread.setDaemon(false); return thread; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略避免任务丢失 ); } }线程池的常见坑使用无界队列比如new LinkedBlockingQueue()会导致任务无限堆积最终 OOM核心线程数设置不合理设置太大导致 CPU 切换频繁设置太小导致并发量上不去不自定义线程工厂线程没有名字出了问题根本不知道是哪个线程池的线程出了问题所有任务共用一个线程池核心任务和非核心任务共用一个线程池非核心任务阻塞会影响核心任务在任务中捕获了所有异常导致任务执行失败了也看不到异常信息排查问题困难3.5 并发容器替代同步容器提升并发性能很多人会用Collections.synchronizedList(new ArrayList())这种同步容器它的实现是给所有方法都加上 synchronized 锁并发性能极差。JUC 包提供了一系列高性能的并发容器专门为多线程场景设计性能远高于同步容器。常用的并发容器表格普通容器同步容器并发容器适用场景ArrayListVector/SynchronizedListCopyOnWriteArrayList读多写少的列表场景HashMapHashtable/SynchronizedMapConcurrentHashMap高并发的键值对存储TreeMapSynchronizedSortedMapConcurrentSkipListMap高并发的有序键值对存储LinkedListSynchronizedListLinkedBlockingQueue高并发的生产者消费者队列核心用法示例java运行// ConcurrentHashMap高并发场景下替代HashMap ConcurrentHashMapString, Object map new ConcurrentHashMap(); map.put(key, value); map.get(key); // CopyOnWriteArrayList读多写少场景下替代ArrayList CopyOnWriteArrayListString list new CopyOnWriteArrayList(); list.add(a); list.get(0); // LinkedBlockingQueue生产者消费者模型 BlockingQueueString queue new LinkedBlockingQueue(100); // 生产者 queue.put(task); // 消费者 String task queue.take();四、生产环境高频并发问题与解决方案4.1 秒杀超卖问题问题描述秒杀活动中库存扣减出现负数商品超卖。这是最经典的并发问题。错误代码java运行// 错误示例并发下会出现超卖 public void seckill(Long productId, Integer quantity) { // 查询库存 Integer stock stockMapper.selectStock(productId); // 判断库存是否充足 if (stock quantity) { throw new BusinessException(库存不足); } // 扣减库存 stockMapper.deductStock(productId, quantity); // 生成订单 orderMapper.insert(order); }解决方案数据库乐观锁给库存表加 version 字段更新时判断 version 是否一致sqlUPDATE product_stock SET stock stock - #{quantity}, version version 1 WHERE product_id #{productId} AND version #{version} AND stock #{quantity}分布式锁用 Redis 或 Zookeeper 分布式锁保证同一时间只有一个线程能扣减库存java运行public void seckill(Long productId, Integer quantity) { String lockKey stock:lock: productId; try { // 获取分布式锁 if (!redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS)) { throw new BusinessException(系统繁忙请稍后再试); } // 扣减库存逻辑 Integer stock stockMapper.selectStock(productId); if (stock quantity) { throw new BusinessException(库存不足); } stockMapper.deductStock(productId, quantity); orderMapper.insert(order); } finally { // 释放锁 redisLock.unlock(lockKey); } }数据库行锁用 SQL 自带的行锁先锁行再扣减sqlSELECT stock FROM product_stock WHERE product_id #{productId} FOR UPDATE; UPDATE product_stock SET stock stock - #{quantity} WHERE product_id #{productId} AND stock #{quantity};4.2 死锁问题问题描述两个线程互相等待对方持有的锁导致线程永远阻塞服务卡死。死锁产生的四个必要条件互斥条件一个资源只能被一个线程持有持有并等待线程持有一个资源同时等待另一个资源不可剥夺线程持有的资源只能自己释放不能被其他线程剥夺循环等待多个线程之间形成循环等待资源的关系死锁示例java运行public class DeadLockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { new Thread(() - { synchronized (lockA) { System.out.println(线程1持有lockA等待lockB); try { Thread.sleep(1000); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(线程1持有lockA和lockB); } } }).start(); new Thread(() - { synchronized (lockB) { System.out.println(线程2持有lockB等待lockA); try { Thread.sleep(1000); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(线程2持有lockB和lockA); } } }).start(); } }解决方案预防死锁破坏四个必要条件中的一个最常用的是破坏循环等待条件让所有线程按照相同的顺序获取锁排查死锁用jstack命令查看线程堆栈找到死锁的线程和锁使用 tryLock用 ReentrantLock 的 tryLock 方法设置超时时间避免无限等待减少锁的嵌套尽量避免在一个锁里面获取另一个锁4.3 线程池导致的 OOM 问题问题描述服务运行一段时间后出现OutOfMemoryError: unable to create new native thread或者OutOfMemoryError: Java heap space。常见原因使用无界队列任务无限堆积占用大量内存最大线程数设置过大创建了上千个线程导致内存耗尽任务执行时间过长队列不断堆积新任务线程池没有关闭比如每次请求都创建一个新的线程池解决方案必须使用有界队列设置合理的队列长度根据业务场景设置合理的最大线程数一般不超过 CPU 核心数 * 10监控线程池的状态队列长度、活跃线程数、任务执行时间任务设置超时时间避免长时间阻塞核心业务和非核心业务使用不同的线程池互相隔离五、生产环境最佳实践无锁优先能用原子类解决的就不用锁能用读写锁解决的就不用独占锁。锁的粒度越小越好持有锁的时间越短越好。优先使用 JDK 内置工具不要自己手写锁和线程同步逻辑JDK 内置的并发工具已经经过了严格的测试比你自己写的更可靠。合理设置线程池参数核心线程数根据任务类型设置CPU 密集型任务设置为 CPU 核心数IO 密集型设置为 CPU 核心数 * 2必须使用有界队列自定义线程工厂给线程命名。避免共享可变状态这是解决并发问题的根本方法。尽量减少共享变量能用局部变量就不用成员变量能使用不可变对象就不用可变对象。并发代码必须做压测并发问题在低并发下很难复现必须在测试环境做高并发压测验证代码的线程安全性。监控线程池和锁的状态生产环境必须监控线程池的队列长度、活跃线程数、任务拒绝数监控锁的竞争情况及时发现性能瓶颈。避免在锁中执行耗时操作持有锁的时间越长锁的竞争越激烈性能越差。不要在锁中执行数据库查询、RPC 调用等耗时操作。正确处理线程中的异常线程池中的任务如果抛出异常不会打印到日志中必须在任务中捕获异常并记录日志否则任务失败了都不知道。优先使用并发容器不要自己实现同步ConcurrentHashMap、CopyOnWriteArrayList 这些并发容器比你自己用 synchronized 包装的容器性能好得多。不要滥用 volatilevolatile 只能解决可见性和有序性问题不能解决原子性问题。不要指望用 volatile 来解决计数类的原子性问题。总结并发编程不是玄学也不是只有架构师才需要懂的底层知识。它是每个 Java 全栈工程师必须掌握的核心技能是解决线上核心故障、突破职业瓶颈的关键。