前言

多线程可能早就听过很多次了,但是具体多线程是什么,干什么的,需要详细了解一下。

我们刚开始写的普通的程序是单线程,例如:

  1. 第一行:移动小球 A

  2. 第二行:播放背景音乐

  3. 第三行:移动小球 B

在单线程环境下,如果“移动小球 A”是一个需要运行 10 秒的死循环,那么背景音乐永远不会响,小球 B 也会纹丝不动。这叫阻塞

他是怎么做到这三件事情同时运行的呢?这就是多线程的作用。

1.多线程--进程与线程

我们要问:计算机是如何同时运行多个程序的?
想象你的电脑是一个巨大的工业园区。每一个运行中的软件(如 IDE、浏览器)都是园区里的一家工厂。在操作系统层面,这被称为进程(Process)。每个工厂都有自己独立的电力系统、地皮和仓库(内存空间),互不干扰。

那么,工厂内部是如何工作的呢?
工厂里的一名名工人,就是线程(Thread)。所有工人共享这个工厂的原材料和空间,但每个工人同时只能专注做一件事情。

为什么要有多线程?
如果工厂只有一个工人,当他需要等待快递员送货(网络 I/O)时,整个工厂就停工了。但如果有多个工人,一个人在等货,其他人可以继续切菜或打扫。这就是多线程的核心意义:充分利用 CPU 资源,不让昂贵的处理器处于闲置状态。

2.线程安全问题

多线程带来了效率,也带来了混乱。

矛盾点在哪?
由于线程(工人)共享进程(工厂)的资源,当两个工人同时冲向同一个账本去写字时,灾难就发生了。

观察一个著名的现象:原子性缺失。
假设有一个变量 count = 0。线程 A 和线程 B 同时执行 count++。
在底层,count++ 并不是一步完成的,它分为三步:

  1. 读取 count 的值;

  2. 将值加 1;

  3. 将结果写回。

如果 A 读到了 0,还没来得及加 1,B 也读到了 0。结果是 A 写回了 1,B 也写回了 1。原本应该等于 2 的结果,现在变成了 1。
这就是线程不安全。为了解决它,我们需要引入“秩序”。

3.锁

如何保证账本不会被写乱?最直观的办法是加锁

1. synchronized:Java 的内置“自动锁”
synchronized 是 Java 最基础的方案。它就像是在操作间门口加了一把感应锁:当一个工人进去后,门自动锁上,其他人在外面排队。只有里面的工人出来了,下一个人才能进去。它简单、安全,但不够灵活。

package com;

public class Text {
    private int count = 0; // 共享的账本

    public void add() {
        count = count + 1;
    }

    public static void main(String[] args) throws InterruptedException {
        Text shop = new Text();

        // 两个工人,每人负责往账本上加 10000 次
        Thread workerA = new Thread(() -> {
            for (int i = 0; i < 10000; i++) shop.add();
        });
        Thread workerB = new Thread(() -> {
            for (int i = 0; i < 10000; i++) shop.add();
        });

        workerA.start();
        workerB.start();

        // 等两个工人都干完活
        workerA.join();
        workerB.join();

        // 理论上应该是 20000,但实际运行结果通常会小于 20000
        System.out.println("理论值应该是:20000");
        System.out.println("实际运行结果:" + shop.count);
    }
}

加上synchronized后:

public synchronized void add() {
    count = count + 1;
}

2. ReentrantLock:更高级的“手动锁”
有时候,我们希望锁能更智能。比如:

  • “如果锁被占用了,我等 5 秒钟,等不到我就去干别的。”(tryLock)

  • “我想让读账本的人可以同时进去,但写账本的人必须独占。”(ReadWriteLock)
    这就是 java.util.concurrent.locks 包提供的能力,它让开发者对锁有了精细化的控制。

private final ReentrantLock lock = new ReentrantLock();

public void smartAdd() {
    // 尝试拿锁,拿不到就算了,不等了
    if (lock.tryLock()) { 
        try {
            count++;
            System.out.println(Thread.currentThread().getName() + " 抢到锁并加了1");
        } finally {
            lock.unlock();
        }
    } else {
        // 没抢到锁,我不傻等,先去干别的
        System.out.println(Thread.currentThread().getName() + " 没抢到锁,先去喝杯咖啡");
    }
}

4.线程池 (Thread Pool)

当我们的工厂规模变大,新的问题出现了:雇佣和解雇工人的成本太高了。

在 Java 中,创建一个线程需要向操作系统申请资源,这是很重的操作。如果我们为每个小任务都创建一个新线程,任务结束后再销毁,CPU 的大量时间都会浪费在“招人”和“开除人”上。

线程池的逻辑:
我们预先招好 5 个核心员工(核心线程数 corePoolSize),让他们常驻工厂。

  • 任务来了,员工去处理。

  • 任务太多了,员工忙不过来,就让任务在休息室排队(工作队列 workQueue)。

  • 如果休息室也挤满了,我们临时再招几个临时工(最大线程数 maximumPoolSize)。

  • 如果连临时工也招满了,任务还在涌入,我们就不得不拒绝接收新任务(拒绝策略 RejectedExecutionHandler)。

public class SimplePool {
    public static void main(String[] args) {
        // 1. 创建一个固定只有 2 个线程的池子
        ExecutorService pool = Executors.newFixedThreadPool(2);

        // 2. 连续丢进去 5 个任务
        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            pool.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 正在处理任务 " + taskId);
                try { Thread.sleep(1000); } catch (InterruptedException e) {}
            });
        }

        // 3. 关闭线程池
        pool.shutdown();
    }
}

通过线程池,Java 实现了对资源的极致复用。

5.AQS 与 CAS

锁和线程池是“产品”(手机),而 CAS 和 AQS 是“零件和组装图纸”(芯片和电路)

最底层:CAS(原子操作)

这是最微小的动作,像是一个开关。它保证了“改数字”这个动作不会出错。

中间层:AQS(排队框架)

有了开关(CAS)还不够,如果 100 个线程都在抢开关,抢不到的人怎么办?得有人管他们排队、睡觉、唤醒。这个“管家”就是 AQS

产品层:ReentrantLock / CountDownLatch / 线程池

1. CAS (Compare And Swap)
这是现代并发编程的基石。它不加锁,而是采用“对比并交换”的策略。
工人 A 拿着计算好的结果“1”准备写回账本时,会先看一眼账本现在是不是还是他当初看到的“0”。

  • 如果是 0,说明没人动过,写入成功。

  • 如果不是 0,说明被别人抢先了,A 放弃写入,重新读取、重新计算。
    这在 Java 中体现为 AtomicInteger 等原子类,性能极高。

2. AQS (AbstractQueuedSynchronizer)
无论是 ReentrantLock 还是 Semaphore,它们背后都有一个共同的“大脑”——AQS。
AQS 维护了一个状态位(state)和一个等待队列

  • state = 0:房门开着。

  • state = 1:有人在里面。

  • 队列:没抢到房门钥匙的人,都在队列里睡觉(挂起),等待被唤醒。

6.JMM (Java 内存模型)

为了运行速度,CPU 不会每次都直接读写内存,而是会把数据缓存在自己的 L1、L2 缓存里。
这就导致了一个诡异的问题: 线程 A 修改了变量 X,由于 X 还在 A 的 CPU 缓存里,线程 B 在另一个 CPU 核心上看到的 X 还是旧值。

Java 的应对:volatile 关键字
当你给一个变量加上 volatile,你就给了它“可见性”。它要求所有线程:

  1. 每次读变量必须从主内存读;

  2. 每次改变量必须立即刷回主内存。
    它就像在工厂里装了一个巨大的实时显示屏,任何数据的变动,所有工人都立刻可见。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐