Java并发第一篇(从零开始:一文读懂Java并发编程核心基础)

从零开始:一文读懂Java并发编程核心基础

    • [一. 为什么需要并发编程?](#一. 为什么需要并发编程?)
    • [二. 并发编程的"另一面":挑战与代价](#二. 并发编程的“另一面”:挑战与代价)
      • [2.1 频繁的上下文切换](#2.1 频繁的上下文切换)
      • [2.2 线程安全问题(如:死锁)](#2.2 线程安全问题(如:死锁))
    • [三. 夯实基础:必须掌握的核心概念与操作](#三. 夯实基础:必须掌握的核心概念与操作)
      • [3.1 厘清基本概念](#3.1 厘清基本概念)
      • [3.2 创建你的第一个线程](#3.2 创建你的第一个线程)
        • [方式一:继承 `Thread` 类](#方式一:继承 Thread 类)
        • [方式二:实现 `Runnable` 接口 (推荐)](#方式二:实现 Runnable 接口 (推荐))
        • [方式三:实现 `Callable` 接口 (可获取返回值)](#方式三:实现 Callable 接口 (可获取返回值))
      • [3.3 线程的生命周期:状态转换](#3.3 线程的生命周期:状态转换)
      • [3.4 线程间的"对话":基本操作](#3.4 线程间的“对话”:基本操作)
        • [`sleep()` 与 `wait()` 的经典对比](#sleep()wait() 的经典对比)
        • [`join()` - 线程的协作](#join() - 线程的协作)
        • [`interrupt()` - 优雅的通知机制](#interrupt() - 优雅的通知机制)
        • [`yield()` - 主动的让步](#yield() - 主动的让步)
      • [3.5 默默的守护者:Daemon线程](#3.5 默默的守护者:Daemon线程)

为什么需要用到并发?凡事总有好坏两面,这其中的权衡(trade-off)是什么,也就是说并发编程具有哪些缺点?以及在进行并发编程时,我们应该了解和掌握的核心概念又是什么?这篇文章将主要围绕这三个问题,为你揭开Java并发编程的神秘面纱。

一. 为什么需要并发编程?

你可能会奇怪,我们讨论的是软件编程,为什么会扯到硬件的发展?这要从著名的"摩尔定律"说起。

在很长一段时间里,摩尔定律预示着单核处理器的计算能力会呈指数级增长。然而,大约在2004年,物理极限的瓶颈开始显现,单纯提升单核频率变得异常困难。聪明的硬件工程师们转变了思路:不再追求单个计算单元的极致速度,而是将多个计算单元整合到一颗CPU中。这就是"多核CPU"时代的到来。

如今,家用的i7处理器拥有8个甚至更多的核心已是常态,服务器级别的CPU核心数则更为庞大。硬件已经铺好了路,但如何才能榨干这些核心的性能呢?

答案就是并发编程。

顶级计算机科学家Donald Ervin Knuth曾半开玩笑地评价:"在我看来,并发这种现象或多或少是由于硬件设计者无计可施了,他们将摩尔定律的责任推给了软件开发者。"

这句评价一语中的。正是多核CPU的普及,催生了并发编程的浪潮。通过并发编程,我们可以:

  • 充分利用多核CPU的计算能力:将复杂的计算任务分解,让多个核心同时工作,从而大幅提升程序性能。想象一下处理一张高清图片,如果串行处理数百万个像素点会非常耗时,但如果将图片分成几块,交由不同的核心并行处理,速度就会成倍提升。
  • 方便进行业务拆分,提升应用响应速度:在很多业务场景中,并发是天生的需求。例如,在网上购物下单时,系统需要同时完成检查库存、生成订单、扣减优惠券、通知物流等多个操作。如果这些操作串行执行,用户需要等待很长时间。而通过并发技术,这些操作可以被拆分到不同的线程中"同时"进行,极大地缩短了用户的等待时间,提升了体验。

正是这些显著的优点,使得并发编程成为现代软件开发者必须掌握的关键技能。

二. 并发编程的"另一面":挑战与代价

既然并发编程如此强大,我们是否应该在所有场景下都使用它呢?答案显然是否定的。它是一把双刃剑,在带来性能提升的同时,也引入了新的复杂性和挑战。

2.1 频繁的上下文切换

在我们看来,多个线程似乎是"同时"执行的,但这在单核CPU上只是一种宏观上的错觉。CPU会为每个线程分配一个极短的时间片(通常是几十毫秒),然后快速地在不同线程间轮换。这个切换过程,被称为上下文切换

切换时,系统需要保存当前线程的运行状态(如程序计数器、寄存器值等),以便下次轮到它时能恢复现场。这个保存和恢复的过程本身是有性能开销的。如果线程数量过多,或者切换过于频繁,上下文切换消耗的时间甚至可能超过线程真正执行任务的时间,导致程序性能不升反降。

如何减少上下文切换?

  • 无锁并发编程 :例如ConcurrentHashMap的分段锁思想,让不同线程处理不同数据段,减少锁竞争。
  • CAS算法 :Java的Atomic包使用了CAS(比较并交换)这种乐观锁机制,它在很多场景下能避免加锁带来的阻塞和上下文切换。
  • 使用最少线程:创建适量的线程,避免大量线程处于空闲等待状态。
  • 使用协程:在单线程内实现多任务调度,这是更轻量级的"线程"。

2.2 线程安全问题(如:死锁)

这是并发编程中最棘手、也最容易出错的地方。当多个线程访问共享资源(也称为"临界区")时,如果没有恰当的同步机制,就可能导致数据错乱、状态不一致,甚至出现死锁

死锁是指两个或多个线程无限期地互相等待对方释放资源,导致所有相关的线程都无法继续执行。

来看一个经典的死锁示例:

java 复制代码
public class DeadLockDemo {
    private static final String resource_a = "资源A";
    private static final String resource_b = "资源B";

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (resource_a) {
                System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_a);
                try {
                    // 等待一会儿,确保threadB能获得resource_b
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 尝试获得 " + resource_b + "...");
                synchronized (resource_b) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_b);
                }
            }
        }, "线程A");

        Thread threadB = new Thread(() -> {
            synchronized (resource_b) {
                System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_b);
                System.out.println(Thread.currentThread().getName() + " 尝试获得 " + resource_a + "...");
                synchronized (resource_a) {
                    System.out.println(Thread.currentThread().getName() + " 获得了 " + resource_a);
                }
            }
        }, "线程B");

        threadA.start();
        threadB.start();
    }
}

在这个例子中,线程A获得了resource_a的锁,然后尝试去获得resource_b的锁;而线程B同时获得了resource_b的锁,并尝试获得resource_a的锁。两者互相持有对方需要的锁,并等待对方释放,从而陷入了永久的等待,形成了死锁。

我们可以使用JDK自带的jpsjstack命令来诊断这种情况,jstack会明确地告诉你"Found 1 deadlock"。

如何避免死锁?

  1. 避免一个线程同时获取多个锁:尽量减少锁的持有范围和时间。
  2. 保证加锁顺序:确保所有线程都按照相同的顺序来获取锁。
  3. 使用定时锁 :使用lock.tryLock(timeout),当等待超时后线程可以主动放弃,而不是无限期阻塞。
  4. 将锁和资源隔离:对于数据库锁,确保加锁和解锁在同一个数据库连接中完成。

三. 夯实基础:必须掌握的核心概念与操作

了解了并发的优缺点后,让我们深入到实践层面,看看在Java中到底该如何使用和操作线程。

3.1 厘清基本概念

  • 同步 vs 异步 (Synchronous vs Asynchronous):这通常用来描述一次方法调用。

    • 同步:调用方发起调用后,必须原地等待被调用方法执行完毕并返回结果,才能继续执行后续代码。就像你去实体店买东西,必须排队、付款、拿到商品后才能离开。
    • 异步:调用方发起调用后,不等待结果,立即返回并继续执行后续代码。被调用的方法在后台执行,完成后通过回调、通知等方式告诉调用方。就像网购,你下单后就可以去做别的事了,快递到了会通知你去取。
  • 并发 vs 并行 (Concurrency vs Parallelism)

    • 并发 :指多个任务在一段时间内都得到了执行,它们在宏观上是"同时"发生的,但在微观上可能是通过时间片快速交替执行的。好比一个人在同时处理做饭、接电话、看孩子三件事,他需要不停地在任务间切换。
    • 并行 :指多个任务在同一时刻真正地同时执行。这必须在多核CPU上才能实现。好比三个人,一人做饭,一人接电话,一人看孩子,他们是真正在同一时间做着不同的事。
  • 阻塞 vs 非阻塞 (Blocking vs Non-blocking):这通常用来形容线程间的相互影响。

    • 阻塞:一个线程的操作导致它自身被挂起,等待某个条件满足(如等待I/O完成、等待获取锁)。在此期间,它不会占用CPU。
    • 非阻塞:一个线程的操作不会导致自身被挂起,无论操作是否成功都会立即返回。

3.2 创建你的第一个线程

一个Java程序从main()方法启动时,JVM就已经创建了多个线程(如主线程、垃圾回收线程等)。要在我们自己的程序中创建线程,主要有以下三种方式:

方式一:继承 Thread

这是最直接的方式,通过继承Thread并重写run()方法来定义任务。

java 复制代码
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("通过继承Thread类创建线程");
    }
}

// 使用
MyThread thread = new MyThread();
thread.start(); // 必须调用start()来启动新线程
  • 优点:实现简单,易于理解。
  • 缺点 :Java是单继承的,如果你的类已经继承了其他类,就无法再继承Thread,这极大地限制了其灵活性。
方式二:实现 Runnable 接口 (推荐)

这是更常用、也更受推荐的方式。它将"任务"(Runnable)和"执行任务的载体"(Thread)解耦开来。

java 复制代码
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("通过实现Runnable接口创建线程");
    }
}

// 使用
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
  • 优点
    • 任务与线程解耦,结构更清晰。
    • 避免了单继承的限制,你的任务类还可以继承其他类。
    • 多个线程可以共享同一个Runnable实例,方便实现资源共享。
  • 缺点:代码比方式一稍微多一点。
方式三:实现 Callable 接口 (可获取返回值)

Runnablerun()方法没有返回值,也不能抛出受检异常。如果你的任务需要一个执行结果或可能抛出异常,Callable是更好的选择。

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        Thread.sleep(2000); // 模拟耗时任务
        return "通过实现Callable接口返回的结果";
    }
}

// 使用
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> future = executor.submit(new MyCallable());

System.out.println("主线程继续做其他事情...");

// 在需要结果时,调用get()方法阻塞等待
String result = future.get(); 
System.out.println(result);
executor.shutdown();
  • 优点
    • 可以获得任务的返回值。
    • 可以向外抛出异常。
  • 说明Callable通常与线程池(ExecutorService)配合使用,submit()方法返回一个Future对象,你可以用它来跟踪任务状态并获取结果。

3.3 线程的生命周期:状态转换

一个Java线程在其生命周期中,会经历多种状态的变迁。这些状态定义在java.lang.Thread.State枚举中。

  • NEW (新建): new Thread()之后,但还未调用start()
  • RUNNABLE (可运行): 调用start()后,线程进入就绪队列,等待CPU调度。它可能正在运行,也可能在等待运行。
  • BLOCKED (阻塞): 线程等待获取一个synchronized监视器锁。
  • WAITING (无限等待): 线程调用Object.wait()Thread.join()等方法后进入此状态,需要被其他线程显式唤醒。
  • TIMED_WAITING (计时等待): 与WAITING类似,但有超时限制,时间到了会自动返回RUNNABLE状态。
  • TERMINATED (终止): run()方法执行完毕或因异常退出。

3.4 线程间的"对话":基本操作

sleep()wait() 的经典对比

sleep()是让线程"睡一会",而wait()是让线程"等通知"。这是面试高频题,也是理解线程协作的关键。

特性 Thread.sleep(long millis) Object.wait()
所属类 Thread (静态方法) Object (实例方法)
锁的释放 不释放对象锁 释放对象锁
使用前提 任何地方都可以调用 必须在synchronized代码块或方法中
唤醒方式 时间到期后自动唤醒 需要其他线程调用notify()notifyAll()
join() - 线程的协作

join()方法允许一个线程等待另一个线程执行完成。就像接力赛跑,你必须等前一个队友跑完把接力棒交给你,你才能开始跑。

java 复制代码
// 在main线程中
Thread worker = new Thread(() -> {
    System.out.println("工作线程正在处理任务...");
    try { Thread.sleep(3000); } catch (InterruptedException e) {}
});
worker.start();
worker.join(); // main线程会在这里暂停,直到worker线程执行完毕
System.out.println("工作线程已结束,主线程继续执行。");
interrupt() - 优雅的通知机制

interrupt()并非强制中断线程,而是一种协作式的"打招呼"机制。它会设置目标线程的中断标志位。

  • 如果线程正在sleepwaitjoin,它会立即被唤醒并抛出InterruptedException,同时清除中断标志位
  • 如果线程正在正常运行,它需要自己通过Thread.currentThread().isInterrupted()来检查这个标志,并决定如何响应。
java 复制代码
final Thread busyThread = new Thread(() -> {
    while (true) {} // 死循环,消耗CPU
}, "busyThread");

busyThread.start();
busyThread.interrupt(); // 设置中断标志

// 等待片刻后检查
System.out.println("busyThread isInterrupted: " + busyThread.isInterrupted()); // 输出: true
yield() - 主动的让步

yield()是一个静态方法,它会向线程调度器暗示:当前线程愿意让出CPU,给其他同等优先级的线程一个执行机会。但这仅仅是一个建议,调度器可能会忽略它,所以它不保证当前线程一定会暂停。

3.5 默默的守护者:Daemon线程

Java中的线程分为两类:用户线程(User Thread)和守护线程(Daemon Thread)。

  • 用户线程:是我们平时创建的普通线程,执行系统的业务逻辑。
  • 守护线程:在后台运行,为其他线程(主要是用户线程)提供服务。最典型的例子就是垃圾回收(GC)线程。

当JVM中所有的用户线程都执行完毕后,无论是否还有守护线程在运行,JVM都会退出。

java 复制代码
Thread daemonThread = new Thread(() -> {
    while (true) {
        System.out.println("我是守护线程,正在后台守护...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {}
    }
});

daemonThread.setDaemon(true); // 必须在start()之前设置
daemonThread.start();

System.out.println("Main线程即将结束...");
// Main线程(用户线程)结束后,JVM会退出,daemonThread也会随之终止

一个重要的注意事项 :守护线程在JVM退出时会被强制终止,其finally代码块不保证一定会被执行。因此,不要在守护线程的finally中执行关键的资源释放操作。

相关推荐
混水的鱼几秒前
PasswordValidation 密码校验组件实现与详解
前端·react.js
ze_juejin1 分钟前
async、defer 和 module 属性的比较
前端
愿你天黑有灯下雨有伞1 分钟前
Java使用FastExcel实现Excel文件导入
java·excel
爆爆凯3 分钟前
Excel 导入导出工具类文档
java·excel
归于尽3 分钟前
关于数组的这些底层你真的知道吗?
前端·javascript
puppy0_05 分钟前
前端性能优化基石:HTML解析与资源加载机制详解
前端·性能优化
三小河5 分钟前
e.target 和 e.currentTarget 的区别
前端
欲儿5 分钟前
LiteCloud超轻量级网盘项目基于Spring Boot
java·spring boot·后端·轻量级网盘项目
一只卡比兽5 分钟前
无界微前端框架深度配置指南:20+ 关键配置项详解
前端
一只卡比兽6 分钟前
深入解析前端微服务框架无界:实现、通信与实战示例
前端