FEATURED · 精选文章

Java并发编程核心:从线程安全到AQS与锁机制深度解析

发布时间 / 2026/8/28 3:19:08
来源 / 创域科博编辑部
栏目 / 资讯中心
Java并发编程核心:从线程安全到AQS与锁机制深度解析 1. 项目概述为什么JUC是Java工程师的“硬通货”最近在帮学妹梳理面试题发现她卡在了“如何保证多线程安全”和“AQS原理”这类问题上。这让我想起刚入行那会儿面对java.util.concurrent这个包也是一头雾水总觉得线程、锁这些概念既抽象又危险一不小心就写出个死锁或者数据错乱的程序。但后来在几个高并发项目的“毒打”下我才彻底明白JUCJava Util Concurrent根本不是一门可选的“选修课”而是每个想进大厂或者处理高并发业务的Java工程师必须啃下的“硬骨头”。你可以不会写复杂的算法但绝不能对ConcurrentHashMap、ThreadPoolExecutor、ReentrantLock这些核心工具一知半解。这个系列我就结合自己踩过的坑和厂里的实战经验把JUC里最核心、最常用的部分掰开揉碎了讲清楚。目标很明确让你不仅能通过面试更能写出真正健壮、高效的高并发代码。今天这第一篇我们先打好地基重点聊聊线程安全的核心思想、原子操作的底层原理以及锁机制的演进之路。你会发现所谓的“高深”技术背后都是一些非常朴素和优雅的设计思想。2. 核心思想拆解从“线程不安全”到“线程安全”的认知跃迁在深入任何工具之前我们必须先统一思想到底什么是线程安全为什么我们的代码在单线程下运行良好一上多线程就“现原形”2.1 线程安全的本质状态管理的艺术线程安全不是一个模糊的概念。它的核心定义是当多个线程访问某个类时这个类始终能表现出正确的行为并且不需要额外的同步或协调。这里“正确的行为”指的是类的行为与其规范完全一致。听起来有点绕我们看一个经典的“反面教材”—— 一个简单的计数器public class UnsafeCounter { private int count 0; public void add() { count; // 问题就出在这一行 } public int get() { return count; } }如果多个线程同时调用add()方法最终count的值很可能小于实际调用的次数。为什么因为count这个操作在Java虚拟机JVM层面不是原子的它至少包含三个步骤从主内存读取count的当前值到线程的工作内存。在工作内存中将值加1。将新值写回主内存。当线程A执行完步骤1和2还没来得及写回时线程B可能也读取了旧的count值。随后无论谁先写回都会覆盖另一个线程的修改导致一次加法“丢失”。注意这里说“JVM层面”是为了便于理解。实际上这与Java内存模型JMM和CPU的指令执行都有关。count会被编译成多条字节码指令getfield,iconst_1,iadd,putfield自然不是原子的。所以线程安全的本质就是对共享状态数据进行正确、有序的管理。所有的JUC工具无论是原子类还是锁都是为了解决状态管理的问题。2.2 实现线程安全的三种核心路径知道了问题所在解决方案就有迹可循了。主要有三条路不可变Immutable这是最彻底、最简单的方式。如果一个对象在构造后其状态就不能被改变那么它天生就是线程安全的。比如String、Integer这些包装类。因为所有线程都只能读不存在写竞争。在并发编程中应尽可能地设计不可变对象。线程封闭Thread Confinement不共享就没有并发问题。把数据限制在单个线程内使用比如使用ThreadLocal。这在Web开发中非常常见用ThreadLocal来存储用户会话信息每个请求线程处理自己的数据互不干扰。同步机制Synchronization当共享不可避免时就需要通过同步来协调线程对共享数据的访问顺序。这是JUC包主要发力的地方又分为两大类互斥同步阻塞式也就是各种锁如synchronized和ReentrantLock。同一时刻只允许一个线程访问共享资源其他线程必须等待。这保证了操作的原子性但可能带来线程上下文切换和调度的开销。非互斥同步非阻塞式基于比较并交换Compare-And-Swap, CAS等原子指令实现。线程在更新数据时会对比当前值是否等于预期值如果相等才更新否则重试。这个过程不需要挂起线程。AtomicInteger就是典型代表。大厂的高并发系统往往是这三种路径的组合拳。理解它们的适用场景和代价是做出正确技术选型的基础。3. 基石之石深入理解CAS与原子类当我们说“原子操作”时指的是一个不可中断的操作要么完全成功要么完全失败不会出现中间状态。CAS是实现原子操作的关键CPU指令也是整个非阻塞并发算法的基石。3.1 CAS原理乐观锁的硬件支撑CAS操作包含三个操作数内存位置V、旧的预期值A、新值B。它的语义是“我认为位置V的值应该是A如果是那么将V的值更新为B否则什么都不做并告诉我当前V的实际值是多少。”这个过程是硬件级别保证原子性的。在x86架构上对应的是cmpxchg指令。正是因为这个操作是原子的我们才能在其上构建出线程安全的非阻塞算法。Java通过Unsafe类提供了CAS操作的能力虽然该类不推荐直接使用而JUC中的原子类如AtomicInteger则是对其友好的封装。我们来看AtomicInteger的incrementAndGet是如何实现的简化理解public final int incrementAndGet() { for (;;) { // 自旋循环 int current get(); // 获取当前值 int next current 1; // 计算新值 if (compareAndSet(current, next)) // 尝试CAS更新 return next; // 成功则返回 // 失败则循环重试 } }这就是典型的乐观锁思想先进行操作在提交更新时检查是否有冲突。如果没有冲突值还是current就提交如果有冲突值已被其他线程修改就重试。3.2 原子类家族详解与选型JUC提供了丰富的原子类覆盖了各种基本类型和引用类型。选对工具事半功倍。类别典型类核心用途实战场景举例基本类型AtomicInteger,AtomicLong,AtomicBoolean原子更新整型、长整型、布尔值。全局计数器、状态标志位。引用类型AtomicReferenceV原子更新对象引用。实现无锁栈Treiber Stack、单例模式。数组AtomicIntegerArray,AtomicLongArray,AtomicReferenceArrayE原子更新数组中的某个元素。较少直接使用常用于更高级的数据结构中。字段更新器AtomicIntegerFieldUpdaterT,AtomicReferenceFieldUpdaterT,V以反射方式原子更新某个类的 volatile 字段。当你需要原子性地更新某个已有类的字段又不想或不能修改其类型为原子类时使用。累加器LongAdder,DoubleAdder(JDK8)高性能的累加器适用于高并发统计场景。统计请求次数、计算总和性能远优于AtomicLong。累加器带版本号LongAccumulator,DoubleAccumulator(JDK8)支持自定义累加函数的高性能累加器。求最大值、最小值等自定义聚合操作。重点聊聊LongAdder为什么比AtomicLong快这是高频面试题。AtomicLong在高并发下所有线程都竞争更新同一个value变量CAS失败重试会非常频繁。而LongAdder采用了分段锁Cell的思想。内部维护一个Cell数组和一个base值。更新时首先尝试更新base如果竞争不激烈这就成功了。如果竞争激烈CAS失败线程会哈希到某个Cell单元上进行更新。最后求和时将base和所有Cell的值累加即可。这样将热点数据分散极大地减少了竞争是以空间换时间的经典案例。在写多读少的统计场景下无脑选LongAdder。实操心得不要在任何地方都使用原子类。它只适用于状态变量单一且操作简单如递增、条件更新的场景。对于复杂的复合操作比如“检查再更新”原子类单独使用可能不够仍需借助锁。3.3 ABA问题与原子引用CAS有一个著名的“ABA”问题线程1读到V的值为A准备将其更新为B。但在它操作之前线程2将V从A改为C然后又改回了A。此时线程1执行CAS发现V当前值确实是A于是成功更新为B。对于线程1来说它感知不到中间发生了A-C-A的变化这在某些依赖值的变化历史的场景下如链表的头节点变化可能出问题。解决ABA问题需要引入一个版本号Stamp。JUC提供了AtomicStampedReferenceV和AtomicMarkableReferenceV。前者通过一个int类型的版本号来标记引用每次修改版本号都递增后者用一个boolean标记来简化场景。在CAS时同时比较引用值和版本号/标记。AtomicStampedReferenceInteger atomicStampedRef new AtomicStampedReference(100, 0); int[] stampHolder new int[1]; int oldRef atomicStampedRef.get(stampHolder); // 同时获取引用和版本戳 int oldStamp stampHolder[0]; // 更新时必须同时满足引用值和版本戳都符合预期 boolean success atomicStampedRef.compareAndSet(oldRef, 200, oldStamp, oldStamp 1);4. 锁的演进从synchronized到AQS如果说原子类是“点”的突破那么锁机制就是“面”的协调。Java的锁经历了从内置锁到显式锁从功能简单到高度可定制化的演进。4.1 synchronized关键字再探秘synchronized是Java原生的互斥锁用法简单但背后机制并不简单。用法可以修饰实例方法锁是当前实例对象this、静态方法锁是当前类的Class对象、代码块需指定锁对象。特性内置锁是可重入的一个线程可以多次获取同一把锁且是非公平的不保证等待时间最长的线程优先获取锁。在JDK 1.6之前synchronized性能较差被称为“重量级锁”因为涉及到操作系统内核态的互斥量Mutex操作线程会从用户态陷入内核态上下文切换成本高。但经过一系列优化锁升级后其性能已与ReentrantLock相差无几在多数场景下是首选。锁升级锁膨胀过程是理解其性能优化的关键无锁状态对象刚创建。偏向锁第一个线程访问同步块时会将对象头中的标记设为偏向模式并记录线程ID。以后该线程再进入时无需任何同步操作如CAS直接检查线程ID即可。适用于只有一个线程访问同步块的场景。轻量级锁当有第二个线程尝试获取锁时偏向锁会升级为轻量级锁。线程会在自己的栈帧中创建锁记录Lock Record并通过CAS操作尝试将对象头指向自己的锁记录。如果成功则获取锁如果失败说明存在竞争会自旋等待一小段时间。重量级锁如果轻量级锁自旋失败或自旋超过一定次数锁会升级为重量级锁。此时未获取到锁的线程会被挂起Park进入阻塞队列等待操作系统调度唤醒。这就是早期synchronized开销大的原因。注意事项synchronized的锁是非公平锁且不可中断一个线程在等待锁时无法被外部中断也不支持尝试获取锁tryLock和超时条件等待Condition的功能也较弱只有一个等待队列。这些限制催生了ReentrantLock。4.2 ReentrantLock灵活强大的显式锁ReentrantLock实现了Lock接口提供了比synchronized更丰富的功能可重入性同synchronized。公平性选择构造函数可以传入true创建公平锁先等待的线程先获得锁默认为非公平锁性能通常更好。可中断的锁获取lockInterruptibly()方法允许在等待锁的过程中响应中断。超时获取锁tryLock(long time, TimeUnit unit)可以尝试在指定时间内获取锁避免无限等待。多个条件变量一个ReentrantLock可以关联多个Condition对象实现更精细的线程等待/通知机制如生产者-消费者模型。Lock lock new ReentrantLock(); Condition condition lock.newCondition(); lock.lock(); try { while (!conditionMet) { condition.await(); // 释放锁并等待被唤醒后会重新获取锁 } // ... 执行操作 condition.signalAll(); // 唤醒所有等待该条件的线程 } finally { lock.unlock(); // 务必在finally块中释放锁 }核心使用规范必须在try块之前加锁并在finally块中释放锁确保锁必然被释放防止死锁。4.3 理解AQS锁背后的灵魂无论是ReentrantLock还是后面要讲的CountDownLatch、Semaphore它们的核心都基于一个共同的框架——抽象队列同步器AbstractQueuedSynchronizer, AQS。理解AQS是真正读懂JUC并发工具的关键。AQS的核心思想是它维护了一个共享资源的状态state和一个线程等待队列一个FIFO的双向链表CLH队列。子类通过继承AQS并实现其保护方法来管理这个state从而定义资源的获取和释放逻辑。state一个volatile int变量表示资源的状态。比如在ReentrantLock中state0表示锁空闲state1表示锁被占用state1表示重入次数。在Semaphore中state表示许可证数量。等待队列获取资源失败的线程会被构造成一个节点Node加入队列尾部并进入等待状态LockSupport.park()。当资源释放时会唤醒队列中的下一个节点。AQS定义了两种资源共享方式独占模式Exclusive同一时刻只有一个线程能成功获取资源如ReentrantLock。共享模式Share同一时刻多个线程可以成功获取资源如Semaphore、CountDownLatch。子类需要实现的关键方法以独占模式为例tryAcquire(int arg)尝试以独占方式获取资源。成功返回true失败返回false。tryRelease(int arg)尝试释放独占资源。成功返回true失败返回false。ReentrantLock的内部类Sync继承了AQS其公平锁FairSync和非公平锁NonfairSync分别实现了不同的tryAcquire逻辑。非公平锁在尝试获取锁时会直接先尝试一次CAS抢锁抢不到才入队而公平锁会先检查队列中是否有前驱节点在等待如果有则乖乖排队。为什么AQS如此重要因为它提供了一个高度模板化的并发控制框架。基于AQS我们可以用相对较少的代码构建出功能强大的同步器。它解决了并发中“排队”和“唤醒”这两个最复杂、最容易出错的问题。5. 实战构建一个简单的自定义锁理解了AQS我们可以尝试造个“轮子”——一个最简单的不可重入的互斥锁Mutex来加深理解。import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class Mutex { // 静态内部类继承AQS private static class Sync extends AbstractQueuedSynchronizer { // 尝试获取锁。将state从0改为1表示获取锁。 Override protected boolean tryAcquire(int acquires) { assert acquires 1; // 这里只支持获取1个资源 if (compareAndSetState(0, 1)) { // CAS操作期望0更新为1 setExclusiveOwnerThread(Thread.currentThread()); // 设置当前线程为独占所有者 return true; } return false; } // 尝试释放锁。将state从1改为0。 Override protected boolean tryRelease(int releases) { assert releases 1; if (getState() 0) throw new IllegalMonitorStateException(); // 状态已经是0说明锁本来就是自由的 setExclusiveOwnerThread(null); // 清空独占所有者 setState(0); // 注意state是volatile的写操作具有内存可见性 return true; } // 是否被当前线程独占 Override protected boolean isHeldExclusively() { return getExclusiveOwnerThread() Thread.currentThread(); } } private final Sync sync new Sync(); public void lock() { sync.acquire(1); // AQS的模板方法会调用我们实现的tryAcquire } public void unlock() { sync.release(1); // AQS的模板方法会调用我们实现的tryRelease } public boolean isLocked() { return sync.isHeldExclusively(); } }这个Mutex虽然简单但五脏俱全它通过CAS操作state来实现锁的获取利用AQS的队列来管理未获取到锁的线程。当你调用lock()时如果tryAcquire成功CAS将0改为1则立即返回如果失败AQS会将当前线程加入等待队列并挂起。调用unlock()时tryRelease将state改回0并唤醒队列中的下一个等待线程。通过这个例子你应该能感受到AQS如何将复杂的同步队列管理入队、出队、挂起、唤醒封装成模板方法acquire,release而使用者只需关注最核心的资源状态管理逻辑tryAcquire,tryRelease。6. 避坑指南与性能调优要点理论懂了工具也会用了但在生产环境真正用好并发还有无数个坑等着你。这里分享几个最典型的。6.1 死锁的预防、检测与解决死锁是并发编程的“头号杀手”。它指两个或更多线程永久地阻塞每个线程都在等待被其他线程占有的资源。死锁的四个必要条件必须同时满足互斥资源一次只能被一个线程使用。占有并等待线程已占有至少一个资源又在等待其他资源。不可剥夺线程已获得的资源在未使用完前不能被强行抢占。循环等待存在一个线程等待序列 {T1, T2, ..., Tn}其中T1等待T2占有的资源T2等待T3占有的资源...Tn等待T1占有的资源。预防死锁就是破坏上述条件之一破坏“占有并等待”一次性申请所有所需资源AllocateAll。这在实践中往往很难。破坏“不可剥夺”允许抢占资源。但这需要应用能回滚到安全状态实现复杂。破坏“循环等待”对资源进行全局排序要求所有线程按固定的顺序申请锁。这是最常用、最有效的预防策略。例如系统中有锁A、B、C。规定申请顺序必须为 A - B - C。那么线程1按A、B申请线程2也必须先申请A即使它只需要C如果申请不到A它就不会去申请C从而避免了线程1持有A等B线程2持有C等A的循环等待。检测与定位死锁JDK自带工具jstack pid可以打印线程栈信息在输出中查找“Found one Java-level deadlock:”部分。实操心得在测试环境可以故意让锁的获取顺序错乱然后用jstack验证是否能检测到。养成按固定顺序获取锁的编码习惯并用文档明确顺序。6.2 锁性能优化核心减少锁的粒度与持有时间锁本身是性能开销的来源。优化原则就两条锁的粒度要小锁的持有时间要短。缩小锁范围只在必须同步的代码块上加锁。// 不推荐锁住了整个方法可能包含大量非共享操作 public synchronized void process() { readLocalConfig(); // 本地操作无需同步 updateSharedState(); // 只有这一行需要同步 writeLocalLog(); // 本地操作无需同步 } // 推荐只锁住关键部分 public void process() { readLocalConfig(); synchronized(this) { updateSharedState(); } writeLocalLog(); }减小锁粒度锁拆分如果一个锁保护多个独立变量可以考虑拆分成多个锁提高并发度。ConcurrentHashMap在JDK7中使用分段锁Segment在JDK8中改用synchronized锁单个桶Node就是经典的锁粒度优化。使用读写锁ReadWriteLock对于读多写少的场景ReentrantReadWriteLock允许多个读线程同时访问但写线程独占访问能极大提升读性能。用并发容器代替同步容器优先使用ConcurrentHashMap、CopyOnWriteArrayList而不是用Collections.synchronizedMap包装的HashMap。前者使用了更高效的并发控制策略。6.3 volatile关键字可见性与有序性的守护者volatile是轻量级的同步机制。它保证了两件事可见性对一个volatile变量的写对所有线程立即可见。写操作会立即刷新到主内存并使其他线程工作内存中的缓存失效。禁止指令重排序编译器或处理器不会对volatile变量读写操作前后的指令进行重排序优化。但它不保证原子性。volatile int i 0; i;这个操作仍然不是线程安全的。典型使用场景状态标志位一个线程循环检查一个volatile boolean标志另一个线程修改它来通知停止。private volatile boolean running true; public void stop() { running false; } public void run() { while (running) { /* do work */ } }单例模式的双重检查锁定DCLvolatile防止了初始化对象时的指令重排保证了线程安全。private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // volatile防止此处重排序 } } } return instance; }常见误区很多初学者认为volatile能解决所有同步问题。记住它只适用于一写多读或者变量赋值不依赖于当前值的简单场景。一旦涉及“读-改-写”的复合操作就必须使用锁或原子类。7. 线程池基础与核心参数陷阱虽然线程池是JUC另一个庞大的主题但因为它与锁和并发控制息息相关且是面试绝对重点这里先铺垫其最核心的参数和坑点。线程池的核心类是ThreadPoolExecutor。7.1 七大核心参数详解构造一个线程池需要理解这七个参数public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)corePoolSize(核心线程数)线程池的基本大小。即使线程空闲也会保留除非设置了allowCoreThreadTimeOut。maximumPoolSize(最大线程数)线程池允许创建的最大线程数。keepAliveTimeunit(空闲线程存活时间)当线程数超过核心线程数时多余的空闲线程在等待新任务时的最长存活时间。workQueue(工作队列)用于存放提交但尚未执行的任务的阻塞队列。这是理解线程池行为的关键。threadFactory(线程工厂)用于创建新线程。可以在这里设置线程名、优先级、守护线程等。handler(拒绝策略)当线程池和队列都满了如何处理新提交的任务。7.2 任务提交流程与队列的致命影响线程池处理任务的流程是面试必考题也直接决定了你的程序行为提交任务。如果当前运行线程数 corePoolSize则立即创建新线程执行任务。如果运行线程数 corePoolSize则将任务放入workQueue。如果队列已满且运行线程数 maximumPoolSize则创建新线程执行任务。如果队列已满且运行线程数已达maximumPoolSize则触发拒绝策略。关键陷阱这个流程意味着workQueue未满时线程数永远不会超过corePoolSize。只有队列满了才会创建超过核心数的线程直到maximumPoolSize。队列选型对比队列类型特性适用场景潜在风险SynchronousQueue一个不存储元素的队列。每个插入操作必须等待另一个线程的移除操作。CachedThreadPool使用。适合任务量大但执行快的场景。如果没有空闲线程且线程数已达上限会立即触发拒绝策略。LinkedBlockingQueue(无界)基于链表的队列默认容量为Integer.MAX_VALUE可视为无界。FixedThreadPool和SingleThreadPool使用。适合任务量可预估需要平滑处理的场景。最大风险如果任务生产速度持续大于消费速度队列会无限增长最终导致内存溢出(OOM)。ArrayBlockingQueue(有界)基于数组的有界队列。需要控制队列长度防止资源耗尽的场景。需要合理设置队列大小。设置太小容易触发拒绝策略设置太大可能掩盖问题造成延迟。PriorityBlockingQueue(无界)支持优先级排序的无界队列。需要按优先级处理任务的场景。同无界队列有OOM风险。血泪教训不要使用Executors.newFixedThreadPool()或newSingleThreadExecutor()因为它们内部使用无界的LinkedBlockingQueue。在突发流量下大量任务堆积在队列中可能耗尽内存。正确的做法是使用new ThreadPoolExecutor()手动创建并根据业务特点CPU密集型/IO密集型设置合理的corePoolSize、maximumPoolSize和一个有界队列**。7.3 拒绝策略选择当线程池和队列都饱和时有四种内置拒绝策略AbortPolicy(默认)直接抛出RejectedExecutionException异常。CallerRunsPolicy让提交任务的调用者线程自己去执行这个任务。这提供了一个简单的反馈机制会降低新任务提交速度。DiscardPolicy默默丢弃无法处理的任务不抛异常。DiscardOldestPolicy丢弃队列中最老的一个任务然后尝试重新提交当前任务。生产环境中通常选择AbortPolicy配合监控报警或CallerRunsPolicy。绝对不要用DiscardPolicy丢了任务你都不知道。这一篇我们从线程安全的思想根源出发深入剖析了原子操作和锁机制并触及了AQS的核心思想和线程池的致命陷阱。这些是构建任何高并发Java应用的基石。下一篇我们将深入ConcurrentHashMap、CopyOnWriteArrayList等并发容器的实现原理并探讨Fork/Join框架和CompletableFuture看看如何更优雅地处理并行任务与异步编程。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻