多线程编程就像组织一帮人同时抢着改同一份文件,稍不留神就乱套:数据改错、死锁卡壳、看不见最新改动,全是坑。不懂这些常见错误,程序分分钟翻车。

下面我将详细梳理 Java 多线程并发中常见的错误、其产生原因以及相应的解决方法。
1、线程安全问题(竞态条件)
这是最经典、最常见的并发问题。
错误描述:当多个线程同时访问和修改共享的可变数据时,由于执行顺序的不确定性,导致最终结果与预期不符。
产生原因:i++ 这类操作并非原子操作,它包含“读取-修改-写入”三个步骤。多个线程交叉执行这些步骤会导致更新丢失。
示例:
publicclassCounter{
privateintcount=0;
publicvoidincrement() {
count++;// 这不是原子操作!
}
publicintgetCount() {
returncount;
}
}
如果两个线程同时调用 increment(),最终 count 的值可能只增加了 1,而不是 2。
解决方法:
同步(Synchronization):使用 synchronized 关键字对临界区(访问共享资源的代码块)进行加锁。
publicsynchronizedvoidincrement() {
count++;
}
原子变量(Atomic Variables):使用 java.util.concurrent.atomic 包下的类,如 AtomicInteger。它们通过硬件级别的 CAS (Compare-And-Swap) 操作保证单个变量的原子性,性能通常优于同步锁。
publicclassCounter{
privateAtomicIntegercount=newAtomicInteger(0);
publicvoidincrement() {
count.incrementAndGet();
}
publicintgetCount() {
returncount.get();
}
}
不可变对象(Immutable Objects):根本解决之道是避免共享可变状态。如果对象创建后其状态就不能被修改,那么它天生就是线程安全的。
线程封闭(Thread Confinement):将数据限制在单个线程内使用,例如使用 ThreadLocal。
2、死锁(Deadlock)
错误描述:两个或更多的线程互相等待对方释放锁,导致所有线程都无法继续执行,程序陷入永久停滞。
产生原因:通常需要满足四个必要条件:
互斥:一个资源每次只能被一个线程使用。
占有且等待:一个线程在等待其他资源时不释放已占有的资源。
不可剥夺:线程已获得的资源在未使用完之前不能被其他线程强行抢占。
循环等待:多个线程形成一种首尾相接的循环等待资源关系。
示例:
// 线程1
synchronized(lockA) {
Thread.sleep(100);
synchronized(lockB) {// 此时线程2正持有lockB,并等待lockA
// ...
}
}
// 线程2
synchronized(lockB) {
Thread.sleep(100);
synchronized(lockA) {// 此时线程1正持有lockA,并等待lockB
// ...
}
}
解决方法:
避免嵌套锁:尽量只获取一个锁。如果必须获取多个锁,确保在所有线程中以相同的全局顺序获取锁。这是打破“循环等待”条件最有效的方法。
// 正确的做法:统一先获取lockA,再获取lockB
synchronized(lockA) {
synchronized(lockB) {
// ...
}
}
使用定时锁:使用 Lock.tryLock(long timeout, TimeUnit unit) 方法尝试获取锁,如果获取失败,可以释放已持有的锁并进行回退或重试,从而避免无限期等待。
减少锁的粒度:减小同步代码块的范围,只锁真正需要的共享资源,缩短持锁时间。
使用高级并发工具:尽量避免直接使用 synchronized 和 Lock,而是使用 java.util.concurrent 包中的高级类(如 ConcurrentHashMap, CountDownLatch, CyclicBarrier 等),它们内部已经很好地处理了并发问题。
3、可见性问题
错误描述:一个线程对共享变量的修改,不能及时地被其他线程看到。
产生原因:由于现代计算机的多级缓存机制,每个线程可能会将共享变量拷贝到自己的本地缓存(工作内存)中操作。如果没有正确的同步,一个线程的更新可能不会立即写回主内存,其他线程也就看不到最新值。
示例:
publicclassVisibilityProblem{
privatebooleanflag=false;// 没有volatile修饰
publicvoidwriter() {
flag=true;// 修改可能只停留在当前线程的缓存中
}
publicvoidreader() {
while(!flag) {// 可能永远读不到最新的true值
// 空循环
}
System.out.println("Flag is now true");
}
}
解决方法:
使用 volatile 关键字:volatile 变量保证了修改会立即被刷新到主内存,并且每次读取都从主内存重新加载。它保证了变量的可见性,但不保证复合操作的原子性(如 i++)。
privatevolatilebooleanflag=false;
使用同步(synchronized 或 Lock):同步代码块在释放锁前会将工作内存中的修改强制刷新到主内存,在获取锁时会清空本地缓存,从主内存重新加载变量。这同样保证了可见性。
4、活性问题:活锁(Livelock)和饥饿(Starvation)
活锁(Livelock):
描述:线程没有阻塞,但在不断重试相同的操作却始终无法取得进展。就像两个过于礼貌的人在门口互相让路,结果谁也无法通过。
原因:线程在响应其他线程的动作时,不断地改变自己的状态以避免死锁,但反而导致了无效的“忙等”。
解决:引入随机性。例如,在重试机制中加入随机的退避时间(Back-off Time),避免多个线程完全同步地重试。
饥饿(Starvation):
描述:某个线程因为优先级太低或无法获取到所需资源(如锁),而长期得不到执行。
原因:不公平的锁调度或线程优先级设置不合理。
解决:
使用公平锁(ReentrantLock(true)),但会降低吞吐量。
保证资源分配的合理性,避免某些线程长时间独占资源。
5、性能与上下文切换
错误描述:盲目地创建大量线程,导致系统性能反而下降。
产生原因:线程的创建、销毁和调度(上下文切换)都需要消耗系统资源。如果线程数量远多于 CPU 核心数,CPU 会花费大量时间在线程间切换,而不是执行有效任务。
解决方法:
使用线程池(ThreadPool):这是最重要的最佳实践。通过 Executors 工厂类或直接创建 ThreadPoolExecutor 来管理线程生命周期,复用线程,避免频繁创建和销毁的开销。
合理设置线程池大小:根据任务类型(CPU密集型 vs I/O密集型)设置核心线程数和最大线程数。一个常用的经验公式:
CPU密集型:线程数 = CPU核数 + 1
I/O密集型:线程数 = CPU核数 * (1 + 平均等待时间 / 平均计算时间),通常可以设置为 2 * CPU核数
6、错误使用并发工具类
错误描述:虽然 java.util.concurrent 包提供了强大的工具,但错误使用它们同样会带来问题。
常见错误:
误以为 ConcurrentHashMap 所有操作都是原子的:concurrentMap.get(key) + 1 这样的操作仍然不是原子的,需要使用 replace(key, oldValue, newValue) 或 compute 等方法。
错误理解 HashMap 和 ArrayList:它们不是线程安全的!在并发环境下读写会导致数据损坏或 ConcurrentModificationException。必须使用 ConcurrentHashMap 和 CopyOnWriteArrayList,或在外层进行同步。
ThreadLocal 的内存泄漏:如果使用线程池,ThreadLocal 变量用完后必须调用 remove() 方法清理,否则其关联的 value 可能无法被 GC 回收,造成内存泄漏。
总结与最佳实践
首选无锁设计:尽可能使用不可变对象和线程封闭技术。
偏向使用高级并发工具:优先选择 java.util.concurrent 包中的类(如 ExecutorService, ConcurrentHashMap, CountDownLatch, CyclicBarrier 等),而不是自己用 synchronized 和 wait()/notify() 从头构建。
同步最小化:减小同步代码块的范围,只锁必要的部分。
谨慎使用锁:如果需要多个锁,必须制定并遵守一个全局的锁顺序。
优先使用线程池:永远不要盲目地 new Thread()。
不要依赖线程优先级:不同 JVM 和操作系统对优先级的处理不一致,不可移植。
使用工具进行测试和分析:利用 jstack 查看线程状态和死锁,使用 JMH 进行并发性能基准测试,使用 FindBugs/SpotBugs、IDEA 等工具的静态检查功能发现潜在的并发bug。
并发编程非常复杂,唯一的“银弹”就是深入理解内存模型、锁机制和并发工具的原理,并严格遵守上述最佳实践。
总之,搞定多线程的关键就三点:共享数据要加锁或换原子类,用线程池管好人手,高级工具优先别造轮子。记牢这些,你的并发程序就能又稳又快!