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

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

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

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

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

  • 错误描述 :当多个线程同时访问和修改共享的可变数据时,由于执行顺序的不确定性,导致最终结果与预期不符。

  • 产生原因i++ 这类操作并非原子操作,它包含"读取-修改-写入"三个步骤。多个线程交叉执行这些步骤会导致更新丢失。

  • 示例

    csharp 复制代码
    public class Counter {
        private int count = 0;
        
        public void increment() {
            count++; // 这不是原子操作!
        }
        
        public int getCount() {
            return count;
        }
    }

    如果两个线程同时调用 increment(),最终 count 的值可能只增加了 1,而不是 2。

  • 解决方法

    1. 同步(Synchronization) :使用 synchronized 关键字对临界区(访问共享资源的代码块)进行加锁。

      arduino 复制代码
      public synchronized void increment() {
          count++;
      }
    2. 原子变量(Atomic Variables) :使用 java.util.concurrent.atomic 包下的类,如 AtomicInteger。它们通过硬件级别的 CAS (Compare-And-Swap) 操作保证单个变量的原子性,性能通常优于同步锁。

      csharp 复制代码
      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. 循环等待:多个线程形成一种首尾相接的循环等待资源关系。
  • 示例

    scss 复制代码
    // 线程1
    synchronized (lockA) {
        Thread.sleep(100);
        synchronized (lockB) { // 此时线程2正持有lockB,并等待lockA
            // ...
        }
    }
    ​
    // 线程2
    synchronized (lockB) {
        Thread.sleep(100);
        synchronized (lockA) { // 此时线程1正持有lockA,并等待lockB
            // ...
        }
    }
  • 解决方法

    1. 避免嵌套锁 :尽量只获取一个锁。如果必须获取多个锁,确保在所有线程中以相同的全局顺序获取锁。这是打破"循环等待"条件最有效的方法。

      java 复制代码
      // 正确的做法:统一先获取lockA,再获取lockB
      synchronized (lockA) {
          synchronized (lockB) {
              // ...
          }
      }
    2. 使用定时锁 :使用 Lock.tryLock(long timeout, TimeUnit unit) 方法尝试获取锁,如果获取失败,可以释放已持有的锁并进行回退或重试,从而避免无限期等待。

    3. 减少锁的粒度:减小同步代码块的范围,只锁真正需要的共享资源,缩短持锁时间。

    4. 使用高级并发工具 :尽量避免直接使用 synchronizedLock,而是使用 java.util.concurrent 包中的高级类(如 ConcurrentHashMap, CountDownLatch, CyclicBarrier 等),它们内部已经很好地处理了并发问题。

3、可见性问题

  • 错误描述:一个线程对共享变量的修改,不能及时地被其他线程看到。

  • 产生原因:由于现代计算机的多级缓存机制,每个线程可能会将共享变量拷贝到自己的本地缓存(工作内存)中操作。如果没有正确的同步,一个线程的更新可能不会立即写回主内存,其他线程也就看不到最新值。

  • 示例

    csharp 复制代码
    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++)。

      arduino 复制代码
      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。

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

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

相关推荐
举个栗子dhy3 小时前
解决在父元素上同时使用 onMouseEnter和 onMouseLeave时导致下拉菜单无法正常展开或者提前收起问题
前端·javascript·react.js
前端与小赵3 小时前
vue3和vue2生命周期的区别
前端·javascript·vue.js
一鹿有你们~4 小时前
面试题-前端如何解决跨域
前端·javascript·跨域
Sailing4 小时前
👉 👉 Vue3 自定义 Hook:从入门到进阶(~~安静的阅读2分钟,相信我,这篇文章一定能给你启发)
前端·javascript·vue.js
夜无霄4 小时前
安卓逆向(一)Ubuntu环境配置
linux·运维·爬虫·ubuntu
广州腾科助你拿下华为认证4 小时前
PostgreSQL认证_PGCM考试难度有多大?
数据库·postgresql
代码的余温4 小时前
Oracle RAC认证矩阵:规避风险的关键指南
数据库·oracle·矩阵
白鲸开源4 小时前
一行代码引发 12G 内存 5 分钟爆仓!SeaTunnel Kafka 连接器"内存溢出"元凶抓到了
数据库·kafka·开源