学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到AQS
3.1 wait/notify是对象层面的协作机制
线程之间除了互斥,还需要协作。最基础的协作机制就是Object类自带的wait()和notify()。很多初学者第一次写生产者-消费者代码时会漏掉一个关键点:wait()必须在持有该对象监视器(也就是synchronized锁)的代码块里调用,否则会抛IllegalMonitorStateException。
为什么?因为wait()的作用是把当前线程放入这个对象的等待集,同时释放锁,让其他线程有机会进入同步块修改条件。notify()则是从等待集里随机唤醒一个线程。这里有一个最常见的坑:wait()一定要放在while循环里,而不是if里。
原因是线程被唤醒后,需要重新检查等待条件是否真的满足。比如消费者等待队列不为空,生产者在放入元素后调用notifyAll(),此时多个消费者都被唤醒,但它们很可能同时去消费同一个元素。如果醒来后不再检查队列状态,就会出现消费空元素或者越界问题。while循环会在唤醒后重新判断条件,条件不满足就继续wait(),这是唯一安全的写法。
3.2 一个生产者-消费者的最小实现
下面是一个完整的、可直接运行的环形缓冲区实现,我挑几个容易写错的点仔细讲:
public class BoundedBuffer<T> { 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并发包里的AQS(AbstractQueuedSynchronizer)。很多人觉得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 LocalCache<V> { public interface Loader<V> { V load(String key) throws Exception; } private final ConcurrentHashMap<String, CacheItem<V>> cache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Object> lockPool = new ConcurrentHashMap<>(); private final Loader<V> loader; private final long ttlMillis; public LocalCache(Loader<V> loader, long ttlMillis) { this.loader = loader; this.ttlMillis = ttlMillis; } public V get(String key) throws Exception { CacheItem<V> 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 CacheItem<V> { 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个线程同时访问同一个key,Loader里打印日志,结果只有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理解,都是围绕这个核心展开的。如果后续你有兴趣,我可以再写一篇虚拟线程出现之后,线程池和锁模型会怎么演化的实战对比。