Java 并发基础之并发编程通识

引言:为何并发编程如此重要?

在现代软件开发中,并发编程已不再是阳春白雪,而是开发者必备的核心技能之一。随着多核 CPU 的普及,以及业务系统对高性能、高吞吐量和快速响应的持续追求,充分利用计算资源、提升程序运行效率变得至关重要。并发编程正是实现这一目标的关键手段。

什么是并发 (Concurrency)?

并发指的是在同一个时间段内,能够处理多个任务的能力。注意,这并不意味着多个任务在同一时刻同时执行(那是并行),而是在宏观上看起来是同时在推进。例如,一个 Web 服务器需要同时处理来自多个用户的请求,一个 GUI 应用需要在响应用户操作的同时执行后台计算,这些都是并发的典型场景。

为何需要并发?

  1. 提升性能:
    • 利用多核 CPU: 对于计算密集型任务,可以通过并发将其分解到多个核心上并行执行,缩短总处理时间。
    • 提高资源利用率: 对于 I/O 密集型任务(如网络请求、磁盘读写),当一个任务等待 I/O 时,CPU 可以切换去执行其他任务,避免空闲浪费。
  2. 改善响应性: 在图形用户界面 (GUI) 或交互式应用中,将耗时操作(如文件下载)放到后台线程执行,可以保持主线程(UI 线程)的流畅响应,提升用户体验。
  3. 简化复杂问题: 某些问题天然适合分解为多个并发执行的独立单元,使得程序设计更模块化、更易于理解和管理(例如生产者-消费者模型)。
    image.png

然而,并发编程并非银弹,它引入了复杂性,带来了诸多挑战,如线程安全问题、死锁、资源竞争等。掌握并发编程的基础知识,理解其核心概念、挑战以及 Java 提供的解决方案,是编写健壮、高效并发程序的基石。本文旨在梳理 Java 并发编程的基础通识,为读者构建一个清晰的知识框架。

一、核心基础概念

在深入探讨 Java 并发机制之前,需要先明确几个基本概念。

1. 进程 (Process) 与线程 (Thread)

  • 进程: 操作系统进行资源分配和调度的基本单位。一个进程是内存中一个正在运行的程序实例,它拥有独立的内存空间(地址空间)、文件句柄、系统资源等。进程间通信 (IPC) 相对复杂且开销较大。
  • 线程: 进程内的一个执行单元,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享该进程的内存空间(堆内存、方法区等)和系统资源(如文件句柄),但每个线程拥有自己独立的程序计数器、虚拟机栈和本地方法栈。线程间的通信相对简单高效,但共享资源也带来了同步问题。

Java 程序启动时至少会创建一个主线程(执行 main 方法的线程)。开发者可以通过 new Thread() 或线程池等方式创建更多线程。

2. 线程的生命周期 (Java specific)

在 Java 中,一个线程从创建到消亡会经历不同的状态,定义在 Thread.State 枚举中:

  1. NEW (新建): 使用 new Thread() 创建,但尚未调用 start() 方法。
  2. RUNNABLE (可运行): 调用 start() 方法后,线程进入可运行状态。它包含了操作系统线程状态中的 Running (运行中)Ready (就绪)。处于此状态的线程可能正在 JVM 中执行,也可能在等待操作系统分配 CPU 时间片。
    image.png
  3. BLOCKED (阻塞): 线程等待获取一个监视器锁 (Monitor Lock) 时进入此状态。通常发生在进入 synchronized 方法或代码块时,锁被其他线程持有。当获取到锁后,会转换回 RUNNABLE
  4. WAITING (无限期等待): 线程等待其他线程执行特定动作时进入此状态。常见触发条件:
    • 调用无超时的 Object.wait()
    • 调用无超时的 Thread.join()
    • 调用 LockSupport.park()
      需要被其他线程显式唤醒(如 Object.notify(), Object.notifyAll(), LockSupport.unpark())才能回到 RUNNABLE
  5. TIMED_WAITING (有限期等待): 与 WAITING 类似,但等待有时间限制。超时后会自动返回 RUNNABLE。常见触发条件:
    • 调用带超时的 Thread.sleep(long millis)
    • 调用带超时的 Object.wait(long timeout)
    • 调用带超时的 Thread.join(long millis)
    • 调用带超时的 LockSupport.parkNanos(), LockSupport.parkUntil()
  6. TERMINATED (终止): 线程的 run() 方法执行完毕或因未捕获的异常退出后,进入此状态。线程生命周期结束。

理解线程状态对于调试并发问题(如死锁、性能瓶颈)非常有帮助。

3. 并发 (Concurrency) 与并行 (Parallelism)

  • 并发 (Concurrency): 指逻辑上同时处理多个任务的能力。在单核 CPU 上,通过时间片轮转快速切换任务,使得宏观上感觉任务在同时进行。
  • 并行 (Parallelism): 指物理上同时执行多个任务的能力。这需要多核 CPU 或分布式系统的支持,多个任务在同一时刻真正地一起运行。

关系: 并行是并发的一种实现方式。并发的目标是充分利用资源、提高响应,可以在单核或多核上实现;而并行必须依赖多核或多机才能实现,是提升绝对速度的关键。Java 并发编程既可以用于实现并发(提高资源利用率和响应性),也可以用于实现并行(利用多核加速计算)。

二、并发编程的三大挑战

编写正确的并发程序之所以困难,主要源于以下三个核心问题:

1. 原子性 (Atomicity) 问题

原子性指一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。

在并发环境中,如果一个操作不是原子的,那么在执行过程中可能被其他线程干扰,导致数据不一致,这就是竞争条件 (Race Condition)

经典示例: count++ 操作。

public class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // 非原子操作
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        });

        t1.start();
        t2.start();
        t1.join(); // 等待 t1 结束
        t2.join(); // 等待 t2 结束

        System.out.println("Final count: " + counter.getCount()); // 结果通常小于 20000
    }
}
image.png

count++ 看似简单,实际包含三个步骤:

  1. 读取 count 的当前值。
  2. 将值加 1。
  3. 将新值写回 count

如果两个线程同时执行 increment(),可能发生以下情况:

  • 线程 A 读取 count (假设为 10)。
  • 线程 B 读取 count (也为 10)。
  • 线程 A 计算 10 + 1 = 11。
  • 线程 B 计算 10 + 1 = 11。
  • 线程 A 将 11 写回 count
  • 线程 B 将 11 写回 count

两次 increment() 操作后,count 预期应为 12,但结果却是 11。这就是原子性被破坏导致的竞争条件。

解决方案: 需要使用锁或其他原子操作机制(如 synchronized, Lock, AtomicInteger)来保证 count++ 的原子性。

2. 可见性 (Visibility) 问题

可见性指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

在现代多核 CPU 架构中,每个核心都有自己的高速缓存 (Cache)。线程对共享变量的操作可能先在自己的工作内存(CPU Cache 的抽象)中进行,然后再择机写回主内存。这会导致一个线程修改了变量值,但该值尚未写回主内存,或者其他线程仍然读取的是自己缓存中的旧值,从而产生可见性问题。

示例:

public class VisibilityProblem {
    private boolean stopRequested = false; // 共享变量

    public void requestStop() {
        stopRequested = true; // 线程 A 修改
        System.out.println("Stop requested.");
    }

    public void run() {
        System.out.println("Worker thread started.");
        while (!stopRequested) { // 线程 B 读取
            // do work...
            // 可能因为可见性问题,一直读取到 false,导致循环无法停止
        }
        System.out.println("Worker thread stopped.");
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityProblem worker = new VisibilityProblem();
        Thread workerThread = new Thread(worker::run);
        workerThread.start();

        Thread.sleep(1000); // 让 worker 线程跑一会儿

        Thread stopperThread = new Thread(worker::requestStop);
        stopperThread.start();

        // 如果存在可见性问题,workerThread 可能永远不会结束
        workerThread.join(5000); // 等待 worker 线程结束,设置超时
        if (workerThread.isAlive()) {
            System.out.println("Worker thread did not stop due to visibility issue!");
            // 在实际情况中需要更健壮的停止机制
        }
    }
}

在这个例子中,stopperThread 修改 stopRequestedtrue,但 workerThread 可能由于缓存原因一直读取到 false,导致死循环。

解决方案: 使用 volatile 关键字修饰 stopRequested,或使用锁 (synchronized, Lock) 来保证修改的可见性。

还有 72% 的精彩内容
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
支付 ¥9.99 继续阅读

推荐阅读更多精彩内容

  • 引言 并发编程是一个经典的话题,由于摩尔定律已经改变,芯片性能虽然仍在不断提高,但相比加快 CPU 的速度,计算机...
    you的日常阅读 1,930评论 0 53
  • 目录 概念 并行是指两个或者多个事件在同一时刻发生(cpu多核);而并发是指两个或多个事件在同一时间间隔内发生 饥...
    后来丶_a24d阅读 976评论 0 13
  • 前言 并发在大型项目中的应用很广,可以有效提升应用性能,充分使用硬件资源。同时,也是Java面试的主要内容。 重排...
    佛贝鲁先生阅读 140评论 0 0
  • 1、并行与并发的区别 1)并行指多个事件在同一个时刻发生;并发指在某时刻只有一个事件在发生,某个时间段内由于 CP...
    执着的逗比阅读 609评论 0 2
  • 一、Java为什么要多线程? 为了合理利用 CPU 的高性能,平衡【CPU、内存、I/O 设备】的速度差异,计算机...
    柚子过来阅读 600评论 0 0