资讯动态

从面向对象视角看多线程:对象状态、共享与并发工具设计

发布时间:2026/10/16 11:35:38 来源:尧图企业网站定制
学Java的人基本都会经历两个阶段先是学会用class把数据和操作捆在一起再是学会用Thread让程序“同时”跑起来。但大多数人会在第二阶段卡住——不是不会写new Thread().start()而是多线程一旦和面向对象真正结合起来程序就变得特别容易出问题。今天我想换一个视角从面向对象的角度重新拆解多线程线程的本质是什么、共享对象该怎么设计、Java并发工具为什么要那样设计以及我在真实项目里踩过的那些坑。如果你刚学完面向对象还没系统接触过并发或者已经写了两三年代码但没认真梳理过这两者之间的关系这篇很适合你。你不需要会背API只需要把“对象”这个概念作为主线跟着走一遍很多之前看不懂的并发设计就会顺畅很多。我会用实际的代码、案例和踩坑记录来讲不玩虚的。1. 线程在执行对象在状态——先分清这两个概念1.1 线程是执行流对象是状态容器在JVM的运行时模型里线程本身是一套独立的调用栈它沿着代码一条一条执行方法里的字节码指令。但线程执行时访问的数据全部存放在堆上的对象里。换句话说线程不保存业务数据它只是“读取-修改-写入”对象状态的执行者对象才是状态真正的容器。这个区分看似简单但绝大多数并发问题都出在这里。面向对象设计强调的是“状态和行为封装在一起”外部只能通过方法操作内部字段这在单线程环境下没问题。一旦进入多线程同一时刻可能有多条执行流同时进入同一个对象的方法同时修改对象内部的字段。此时如果对象的封装边界不彻底状态就很容易失控。我用办公室场景打比方几个人共用一张日程表每个人都可以往上面改内容。单人的时候没有冲突多人同时改就会出现先写“下午三点开会”又被人改成“下午四点开会”这种诡异情况。日程表就是共享对象人就是线程。面向对象解决的是表上数据怎么组织、谁的职责是什么多线程解决的是多人同时读写时怎么约定先后、怎么避免覆盖。两者不是两个独立的知识点而是同一个问题的两面。1.2 Runnable 才应该被当成对象设计Java里启动线程最原始的写法是new Thread(runnable).start()但很多人忽略了一个点Runnable和Callable本身就是一种对象抽象。它们把“一段将来要执行的过程”封装成一个对象这个对象可以被传递、复用、组合、丢给线程池执行。我在代码评审里经常看到新人把一大段业务逻辑直接写在run()里然后需要复用的时候整段复制粘贴到别的地方这种代码一旦涉及并发就特别难受。正确的思路是把任务设计成高内聚的对象它清楚自己的输入、输出、失败策略然后由线程池这种执行器去调度它执行器完全不关心业务细节。这也是为什么现在更推荐用ExecutorService而不是直接new Thread——前者把“线程对象”和“任务对象”彻底分离了。线程对象管理生命周期任务对象管理业务逻辑两者各司其职可维护性完全不一样。如果你在设计阶段就分清这两个概念后面绝大多数并发问题都会变得容易讨论这个对象是会被多个线程同时读的吗它的状态变化需要保证什么不变量它是需要被共享还是应该线程封闭带着这些问题去写代码比抱着API硬背要有效得多。2. 共享对象为什么容易坏——可见性、原子性、有序性2.1 从字段说起你看到的可能不是最新值在Java内存模型JMM)里每个线程不是直接操作主内存中的共享变量而是先把变量拷贝到自己的工作内存里操作完成后再写回主内存。这个设计对性能友好但也带来了最隐蔽的可见性问题一个线程改了对象字段另一个线程读到的可能还是旧值。用公告栏类比就是白板上贴了新通知但坐在角落里的人没抬头看。很多人以为加上volatile就万事大吉其实volatile只能解决可见性不能解决原子性。比如一个常见的标志位场景public class TaskRunner { private volatile boolean running true; public void stop() { running false; } public void run() { while (running) { // 处理任务 } } }这里用volatile是合适的因为running只是一个状态开关读取和写入都是单步操作不涉及复合计算。最典型的错误是拿volatile去修饰一个int count然后多个线程执行count结果发现数值总是丢失更新。原因很简单count在字节码层面是“读取-计算-写回”三步不是一步两个线程同时执行就可能互相覆盖。这就是原子性问题volatile根本管不了。2.2 先封装再加锁——共享可变状态收敛到一个类里解决原子性问题最直接的工具是synchronized但我想强调一个设计层面的思路先封装再加锁。与其在业务代码里到处写synchronized(this)不如把需要保护的共享可变状态收敛到同一个类内部。public class Counter { private int count 0; public synchronized void increment() { count; } public synchronized int get() { return count; } }这个类把唯一一个可变字段保护在私有成员里所有读写都经过同一把内部锁。外部无论多少个线程都只能通过increment()和get()触碰这个字段无法绕开锁直接操作。将来如果想改成AtomicInteger、LongAdder或者读写锁只需要改这一个类调用方完全感知不到。这种可维护性才是面向对象设计在多线程环境里真正值钱的地方。反过来说如果共享字段散落在多个类里每个类各自加锁锁的粒度还不一致那就会出现“两个线程各持一把锁互相等对方释放”的死锁问题。我见过的死锁案例几乎全是共享状态没有收敛导致的。先把状态收进来再谈锁的顺序和粒度往往能避免一大半麻烦。2.3 volatile、Atomic、synchronized该怎么选很多人纠结这几样怎么选我的经验是先想清楚对象的“不变量”是什么再选工具。volatile适合单一状态标志位比如开关、状态流转标记。它解决的是可见性和有序性不解决复合操作的原子性。AtomicInteger、AtomicReference等适合对单个值做自增、自减、比较并更新底层是CAS无锁但能保证原子性。synchronized适合复合操作比如转账时同时扣减两个账户余额或者需要多个步骤整体完成的不变量。锁虽然重但语义最简单代码也最好读。我的习惯是能设计成不可变对象就先考虑不可变其次用volatile发布不可变快照再次用Atomic类最后才考虑用锁。这个顺序也是压测性能从高到低的顺序但不是说性能最高的就是最好的——一切以对象的不变量为出发点。3. 对象间的等待与唤醒——从wait/notify到AQS3.1 wait/notify是对象层面的协作机制线程之间除了互斥还需要协作。最基础的协作机制就是Object类自带的wait()和notify()。很多初学者第一次写生产者-消费者代码时会漏掉一个关键点wait()必须在持有该对象监视器也就是synchronized锁)的代码块里调用否则会抛IllegalMonitorStateException。为什么因为wait()的作用是把当前线程放入这个对象的等待集同时释放锁让其他线程有机会进入同步块修改条件。notify()则是从等待集里随机唤醒一个线程。这里有一个最常见的坑wait()一定要放在while循环里而不是if里。原因是线程被唤醒后需要重新检查等待条件是否真的满足。比如消费者等待队列不为空生产者在放入元素后调用notifyAll()此时多个消费者都被唤醒但它们很可能同时去消费同一个元素。如果醒来后不再检查队列状态就会出现消费空元素或者越界问题。while循环会在唤醒后重新判断条件条件不满足就继续wait()这是唯一安全的写法。3.2 一个生产者-消费者的最小实现下面是一个完整的、可直接运行的环形缓冲区实现我挑几个容易写错的点仔细讲public class BoundedBufferT { private final Object[] items; private int head; private int tail; private int count; public BoundedBuffer(int capacity) { items new Object[capacity]; } public synchronized void put(T item) throws InterruptedException { while (count items.length) { wait(); } items[tail] item; tail (tail 1) % items.length; count; notifyAll(); } SuppressWarnings(unchecked) public synchronized T take() throws InterruptedException { while (count 0) { wait(); } T item (T) items[head]; head (head 1) % items.length; count--; notifyAll(); return item; } }环形数组的开头结尾很容易算错head和tail要用(下标 1) % 数组长度来循环而不是简单加一。synchronized修饰方法时锁定的是this所以put和take天然互斥wait()释放锁后对方线程才能进入。最后我统一的习惯是使用notifyAll()而不是notify()。因为notify()只唤醒一个线程如果唤醒的是生产者线程而缓冲区又是满的生产者会继续wait()而本该被唤醒的消费者线程却还在等待导致程序逐渐“假死”。notifyAll()虽然会唤醒所有线程但多出来的唤醒可以通过while条件再次挂起不会出错。3.3 AQS为什么是同步器设计的典范从Object的监视器机制继续往前就是Java并发包里的AQSAbstractQueuedSynchronizer。很多人觉得AQS源码劝退但从面向对象角度看它其实是“状态机 等待队列”的经典封装。AQS的核心是一个volatile int state字段外加一个CLH等待队列。不同的同步器对这个state赋予了完全不同的含义在ReentrantLock里它表示线程重入锁的次数在Semaphore里它表示剩余许可数量在CountDownLatch里它表示还需要等待的计数。每个同步器只需要实现tryAcquire、tryRelease、tryAcquireShared这些方法决定“什么条件下允许获取同步状态”而排队、唤醒、中断处理这些公共流程全部由AQS代劳。这就是面向对象里的模板方法模式把不变的流程放在父类把变化的语义交给子类。理解了AQS再去看ReentrantLock的公平与非公平实现、Semaphore的共享获取、CountDownLatch的倒数门闩都会变得通透很多。它本质上就是在用对象管理“一堆线程在等一个状态发生变化”这件事。4. 并发工具里的对象设计——线程池、CompletableFuture与并发容器4.1 ThreadPoolExecutor一个管理线程对象池状态的对象线程池不是简简单单一个装着线程的List而是一个复杂的生命周期对象。ThreadPoolExecutor最核心的参数是核心线程数、最大线程数、空闲存活时间、工作队列、线程工厂和拒绝策略。这几个参数共同决定了一件事当新任务提交进来时线程池该创建线程还是丢进队列亦或是直接拒绝。看源码会发现线程池用一个ctl字段同时编码了运行状态和线程数量通过CAS原子更新。这个设计很有意思把运行状态比如运行中、关闭、停止和有效线程数打包成一个int这样读取时通过位运算拿到一个一致性快照避免了加锁。本质上它把“线程池当前处于什么状态”变成了一种不可变快照每次决策都基于最新快照而不是分散在多个字段上。而线程池里的每个工作线程又被包装成内部类Worker这个Worker继承了AQS自己实现了一把简单的互斥锁。为什么因为Worker需要处理“线程是否空闲”“是否可以中断”这类问题用AQS可以精确判断当前工作线程是否在执行任务。你看Java把“一个线程执行一个任务”也建模成了一个对象而不是一个裸的Thread。4.2 CompletableFuture把异步流程变成对象的组合CompletableFuture是我近几年用得最多的并发工具它把异步编程从“回调地狱”变成了类似Stream的对象链。核心思想是每一个异步阶段都是对象阶段之间通过对象引用来组合。CompletableFuture.supplyAsync(() - fetchData()) .thenApply(data - transform(data)) .thenAccept(result - save(result)) .exceptionally(ex - { // 处理异常 return null; });这段代码里supplyAsync返回的是CompletableFuture对象thenApply接收上一个阶段的结果并返回新阶段的对象thenAccept消费最终结果exceptionally则是失败分支。每个对象都知道自己的输入、输出、成功回调和失败回调。任务之间的协作完全基于对象链而不是线程之间直接互相通知。这也意味着你可以在任意位置插入中间处理、分支合并、超时控制代码结构依然清晰。4.3 ConcurrentHashMap是如何用对象结构保证安全的ConcurrentHashMap在JDK 8之后的实现是无锁读、锁写入的典型代表。它内部是一个Node数组每个Node都是一个不可变对象key不可变val和next是volatile的。写入时如果对应槽位为空通过CAS直接将新Node放进去不用加锁如果槽位非空则synchronized锁住这一个桶的头节点然后把新节点链到链表尾部或者插入红黑树。这种“锁桶不锁全表”的设计本质上是把一个大锁拆成了很多把细粒度锁不同线程操作不同桶时完全并行。删除和更新操作也都只锁相关桶的头节点不会阻塞全表。读操作依赖volatile的Node.val和Node.next因此在无锁状态下也能看到最新值只不过迭代器是弱一致性的不保证能读到并发修改后的数据也不会抛ConcurrentModificationException。这些性质全部体现在对象结构的设计里调用方不需要感知。使用ConcurrentHashMap时你可以放心地认为它在绝大多数场景下性能稳定但如果你需要在多个步骤之间维护更复杂的不变量光靠容器本身不够还得自己加锁或使用compute系列原子方法。5. 实战设计一个线程安全的自刷新缓存对象5.1 需求与对象骨架理论聊完了我拿一个真实项目里用过的例子来收尾。需求是这样的做一个本地缓存组件key是字符串value是泛型带TTL过期时间多个线程并发读取同一个key时如果缓存已经失效只能有一个线程去加载新值其他线程等它完成后直接读取缓存。这就是经典的“防缓存击穿”单飞加载模型。对象骨架如下public class LocalCacheV { public interface LoaderV { V load(String key) throws Exception; } private final ConcurrentHashMapString, CacheItemV cache new ConcurrentHashMap(); private final ConcurrentHashMapString, Object lockPool new ConcurrentHashMap(); private final LoaderV loader; private final long ttlMillis; public LocalCache(LoaderV loader, long ttlMillis) { this.loader loader; this.ttlMillis ttlMillis; } public V get(String key) throws Exception { CacheItemV item cache.get(key); if (item ! null !item.isExpired()) { return item.value; } Object lock lockPool.computeIfAbsent(key, k - new Object()); synchronized (lock) { item cache.get(key); if (item ! null !item.isExpired()) { return item.value; } try { V value loader.load(key); cache.put(key, new CacheItem(value, System.currentTimeMillis() ttlMillis)); return value; } finally { lockPool.remove(key, lock); } } } private static final class CacheItemV { final V value; final long expireAt; CacheItem(V value, long expireAt) { this.value value; this.expireAt expireAt; } boolean isExpired() { return System.currentTimeMillis() expireAt; } } }5.2 防缓存击穿的单飞加载这段代码最核心的地方是lockPool.computeIfAbsent(key, k - new Object())。它的作用是每个key对应一把独立的锁对象不同key之间互不干扰第一次用某个key访问时创建锁对象之后所有线程拿到的是同一把锁。获取锁之后再cache.get(key)做一次double-check。为什么需要第二次检查因为当前线程可能在等待锁的过程中另一线程已经完成了加载并写入了缓存。如果不重新检查当前线程就会白白重复加载一次击穿防护也就失效了。这个设计比全局锁好得多。全局锁会把所有key的更新串行化一个key在加载耗时较长时其他key的读操作全部阻塞而lockPool方案只锁同一个key的竞争者部门和部门之间不打架。如果你有多个热点key这种细粒度锁能显著提升吞吐。5.3 用不可变包装对象发布状态内部类CacheItem的所有字段都是final的这是有意为之。一旦CacheItem构造完成它的值和过期时间就不会再变外部读取时不需要考虑值被中途修改的问题。这样设计出来的对象在并发环境下发布时最安全因为它根本没有“中间状态”可以被人看到。cache本身是ConcurrentHashMap它的get操作读到的引用要么是最新写入的CacheItem要么是null不可能读到“写了一半”的对象。这种“不可变对象 原子引用发布”的组合是并发编程里最经典也最可靠的状态管理方式。5.4 测试与踩坑记录我写过并发测试来验证单飞逻辑让5个线程同时访问同一个keyLoader里打印日志结果只有1个线程真正执行了加载其余4个等待后读取缓存。但有一个我一开始没注意的坑如果loader.load(key)抛异常lockPool.remove(key, lock)就不会执行这把锁会一直留在lockPool里造成内存泄漏。所以我把remove放进了finally块并且使用remove(key, lock)而不是remove(key)。为什么用带value的版本因为可能存在这样的时间线线程A执行完加载后移除锁线程B恰好也开始执行computeIfAbsent创建了新锁对象此后线程A如果把锁误删了线程B的锁就会失效。remove(key, lock)能精确判断当前锁对象是不是自己创建的那一个避免误删新锁。还有一个细节加载耗时较长时等待线程会阻塞在synchronized块上这是有意为之。如果你想进一步提高并发度可以用Future替换让每个线程都都拿到同一个Future但复杂度会高不少。对于大多数业务场景阻塞等待反而是最简单且可控的方案。6. 常见问题排查与实操心得6.1 死锁排查的几个入手点死锁的典型现象是程序卡住CPU占用不高日志不再输出。排查时先用jstack或jcmd打印线程栈一旦出现“Found one Java-level deadlock”字样基本就能确定是死锁。线程栈里会明确写出每个线程正在等待的锁以及持有哪些锁顺着这个信息找到循环等待的链条。修复死锁最常见的思路是固定锁的获取顺序。比如两个线程需要同时操作账户A和账户B那就全局约定“先锁账号ID小的再锁账号ID大的”破坏循环等待条件。另一个做法是使用ReentrantLock的tryLock并设置超时拿不到锁就主动释放已持有的锁避免无限等待。最重要的还是在设计阶段把锁的粒度和顺序写清楚而不是出了问题再补。6.2 用错volatile的典型场景我见过一个线上问题多个线程对一个volatile int count执行自增最终count比实际请求数少了很多。很多人不理解为什么volatile不管用因为自增操作本身不是原子的。volatile只保证“读到的值是最新的”不保证“多个线程同时基于旧值加一再写回”不会互相覆盖。解决方案换成AtomicInteger即可。还有一种常见错误是懒加载单例里的字段没有声明为volatile。由于指令重排new操作可能先“赋值引用”再“调用构造方法”另一个线程读到引用后发现对象还没初始化完成直接使用就出问题。经典做法是把单例字段声明为volatile或者使用静态内部类实现懒加载彻底避开重排问题。6.3 并发下的测试经验并发测试不能只跑一次就下结论因为竞争条件是概率问题单次通过不能说明代码正确。我习惯用CountDownLatch让多个线程同时到达起点模拟真正的并发突刺然后跑多轮每轮都检查结果的一致性。还可以使用CyclicBarrier做重复测试观察每轮结果是否都能稳定通过。压测时不要只看吞吐量还要关注线程池队列积压、任务拒绝比例、GC停顿和锁竞争造成的CPU空转。线上数据往往和本地压测差异很大因为真实流量有随机性。如果有条件最好在灰度环境先跑一段时间收集线程dump和GC日志再进行调整。6.4 我最想分享的四条设计建议第一能设计成不可变对象就设计成不可变对象。这是最简单的线程安全方式几乎不需要额外思考。第二共享可变状态尽量收敛到一个类里少让多个类互相读写同一个字段否则锁的顺序和粒度根本说不清楚。第三能用并发工具就不要自己造轮子但要看懂轮子的原理否则出了问题无从下手。第四写并发代码之前先写清楚对象的不变量和方法的执行顺序这比埋头写代码有效得多因为很多并发bug在设计阶段就能被发现。带过几个并发项目之后我最大的体会是多线程本身不可怕可怕的是共享状态到处乱飘。你不用会背每一个API只要牢记“对象是状态容器线程是执行流”把可变状态收敛到少数几个类里用锁、CAS、不可变设计把这些类的边界保护清楚大部分并发问题在写代码阶段就能防住。今天提到的缓存组件、线程池分析和AQS理解都是围绕这个核心展开的。如果后续你有兴趣我可以再写一篇虚拟线程出现之后线程池和锁模型会怎么演化的实战对比。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑