Java并发编程常见“坑”与填坑指南

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

下面我将详细梳理 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。

并发编程非常复杂,唯一的“银弹”就是深入理解内存模型、锁机制和并发工具的原理,并严格遵守上述最佳实践。

总之,搞定多线程的关键就三点:共享数据要加锁或换原子类,用线程池管好人手,高级工具优先别造轮子。记牢这些,你的并发程序就能又稳又快!

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容