首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Java并发编程常见“坑”与填坑指南

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

原创
作者头像
华科云商小徐
发布2025-09-10 11:08:42
发布2025-09-10 11:08:42
1330
举报
文章被收录于专栏:小徐学爬虫小徐学爬虫

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

下面我将详细梳理 Java 多线程并发中常见的错误、其产生原因以及相应的解决方法。

1、线程安全问题(竞态条件)

这是最经典、最常见的并发问题。

  • 错误描述:当多个线程同时访问和修改共享的可变数据时,由于执行顺序的不确定性,导致最终结果与预期不符。
  • 产生原因i++ 这类操作并非原子操作,它包含“读取-修改-写入”三个步骤。多个线程交叉执行这些步骤会导致更新丢失。
  • 示例: public class Counter { private int count = 0; public void increment() { count++; // 这不是原子操作! } public int getCount() { return count; } } 如果两个线程同时调用 increment(),最终 count 的值可能只增加了 1,而不是 2。
  • 解决方法
    1. 同步(Synchronization):使用 synchronized 关键字对临界区(访问共享资源的代码块)进行加锁。 public synchronized void increment() { count++; }
    2. 原子变量(Atomic Variables):使用 java.util.concurrent.atomic 包下的类,如 AtomicInteger。它们通过硬件级别的 CAS (Compare-And-Swap) 操作保证单个变量的原子性,性能通常优于同步锁。 public class Counter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }
    3. 不可变对象(Immutable Objects):根本解决之道是避免共享可变状态。如果对象创建后其状态就不能被修改,那么它天生就是线程安全的。
    4. 线程封闭(Thread Confinement):将数据限制在单个线程内使用,例如使用 ThreadLocal

2、死锁(Deadlock)

  • 错误描述:两个或更多的线程互相等待对方释放锁,导致所有线程都无法继续执行,程序陷入永久停滞。
  • 产生原因:通常需要满足四个必要条件:
    1. 互斥:一个资源每次只能被一个线程使用。
    2. 占有且等待:一个线程在等待其他资源时不释放已占有的资源。
    3. 不可剥夺:线程已获得的资源在未使用完之前不能被其他线程强行抢占。
    4. 循环等待:多个线程形成一种首尾相接的循环等待资源关系。
  • 示例: // 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 此时线程2正持有lockB,并等待lockA // ... } } ​ // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 此时线程1正持有lockA,并等待lockB // ... } }
  • 解决方法
    1. 避免嵌套锁:尽量只获取一个锁。如果必须获取多个锁,确保在所有线程中以相同的全局顺序获取锁。这是打破“循环等待”条件最有效的方法。 // 正确的做法:统一先获取lockA,再获取lockB synchronized (lockA) { synchronized (lockB) { // ... } }
    2. 使用定时锁:使用 Lock.tryLock(long timeout, TimeUnit unit) 方法尝试获取锁,如果获取失败,可以释放已持有的锁并进行回退或重试,从而避免无限期等待。
    3. 减少锁的粒度:减小同步代码块的范围,只锁真正需要的共享资源,缩短持锁时间。
    4. 使用高级并发工具:尽量避免直接使用 synchronizedLock,而是使用 java.util.concurrent 包中的高级类(如 ConcurrentHashMap, CountDownLatch, CyclicBarrier 等),它们内部已经很好地处理了并发问题。

3、可见性问题

  • 错误描述:一个线程对共享变量的修改,不能及时地被其他线程看到。
  • 产生原因:由于现代计算机的多级缓存机制,每个线程可能会将共享变量拷贝到自己的本地缓存(工作内存)中操作。如果没有正确的同步,一个线程的更新可能不会立即写回主内存,其他线程也就看不到最新值。
  • 示例: public class VisibilityProblem { private boolean flag = false; // 没有volatile修饰 ​ public void writer() { flag = true; // 修改可能只停留在当前线程的缓存中 } ​ public void reader() { while (!flag) { // 可能永远读不到最新的true值 // 空循环 } System.out.println("Flag is now true"); } }
  • 解决方法
    1. 使用 volatile 关键字volatile 变量保证了修改会立即被刷新到主内存,并且每次读取都从主内存重新加载。它保证了变量的可见性,但不保证复合操作的原子性(如 i++)。 private volatile boolean flag = false;
    2. 使用同步(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 等方法。
    • 错误理解 HashMapArrayList:它们不是线程安全的!在并发环境下读写会导致数据损坏或 ConcurrentModificationException。必须使用 ConcurrentHashMapCopyOnWriteArrayList,或在外层进行同步。
    • ThreadLocal 的内存泄漏:如果使用线程池,ThreadLocal 变量用完后必须调用 remove() 方法清理,否则其关联的 value 可能无法被 GC 回收,造成内存泄漏。

总结与最佳实践

  1. 首选无锁设计:尽可能使用不可变对象和线程封闭技术。
  2. 偏向使用高级并发工具:优先选择 java.util.concurrent 包中的类(如 ExecutorService, ConcurrentHashMap, CountDownLatch, CyclicBarrier 等),而不是自己用 synchronizedwait()/notify() 从头构建。
  3. 同步最小化:减小同步代码块的范围,只锁必要的部分。
  4. 谨慎使用锁:如果需要多个锁,必须制定并遵守一个全局的锁顺序。
  5. 优先使用线程池:永远不要盲目地 new Thread()
  6. 不要依赖线程优先级:不同 JVM 和操作系统对优先级的处理不一致,不可移植。
  7. 使用工具进行测试和分析:利用 jstack 查看线程状态和死锁,使用 JMH 进行并发性能基准测试,使用 FindBugs/SpotBugs、IDEA 等工具的静态检查功能发现潜在的并发bug。

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、线程安全问题(竞态条件)
  • 2、死锁(Deadlock)
  • 3、可见性问题
  • 4、活性问题:活锁(Livelock)和饥饿(Starvation)
  • 5、性能与上下文切换
  • 6、错误使用并发工具类
  • 总结与最佳实践
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档