为什么需要多线程:并发不是炫技

Web 服务器为什么能同时处理几百个请求?下载工具为什么能一边下文件一边显示进度条?答案都是多线程。一个 CPU 核心在同一时刻只能执行一条指令,但操作系统会在多个线程之间快速切换,让你"感觉"它们同时在跑。在 IO 密集场景下(等网络、等磁盘、等数据库),线程把等待时间让给别人用,整体吞吐就能翻好几倍。

但多线程不是免费的午餐。多个线程同时读写同一份数据时,如果没有保护,结果就会出错。比如两个线程同时给一个计数器加一,最后可能只加了一次——这就是经典的竞态条件。写并发代码的核心,就是搞清楚"哪些数据需要保护、用什么方式保护、什么时候不需要保护"。

并发编程的第一原则:能用单线程解决,就不要上多线程。多线程把代码复杂度乘了一个数量级,只有在真的需要并行时才值得付出这个代价。
本文核心知识点
  • 线程池复用线程、控制并发数,不要自己 new Thread 到处跑。
  • synchronized自动加锁释放的内置锁,保护临界区最简单的方式。
  • 并发容器ConcurrentHashMap 等容器内部已加锁,直接用比自己包装安全。
  • 原子类AtomicInteger 用 CAS 实现无锁计数,比 synchronized 轻量。
Java 并发示意:深蓝渐变背景上多条平行工作车道上有工人与小推车,中间齿轮组与挂锁图标,底部并发队列面板
并发的本质:多条车道同时干活,但共享的路口(共享数据)要红绿灯(锁)来管。

创建线程的正确姿势

创建线程最原始的方式是 new Thread().start(),但在真实项目里几乎从来不这么写——因为每次 new 一个新线程开销很大,而且没有复用、没有上限,任务一多服务器直接崩。

// 最基础的写法:实现 Runnable 接口
public class PrintTask implements Runnable {
    private final String name;
    public PrintTask(String name) { this.name = name; }

    @Override
    public void run() {
        System.out.println("线程 " + name + " 正在执行:" +
            Thread.currentThread().getName());
    }
}

public class Demo {
    public static void main(String[] args) {
        Thread t = new Thread(new PrintTask("任务A"));
        t.start();    // 启动线程,JVM 会回调 run()
        // 注意:直接调 t.run() 不会开新线程,只是普通方法调用
    }
}

这里有两个新手必踩的坑:调用 start() 才会开新线程,直接调 run() 只是同步执行;线程启动后什么时候跑、跑多久,操作系统说了算,主线程不会等它结束就继续往下走。需要等线程执行完再继续,用 join()。

方式适用场景注意事项
继承 Thread几乎不用Java 单继承,继承了 Thread 就不能继承别的类
实现 Runnable无返回值的任务最常用,还能配合线程池提交
实现 Callable + Future需要返回结果的任务配合线程池 submit,用 Future.get() 拿结果
CompletableFuture需要链式编排异步任务Java 8 之后的首选,能组合多个异步任务

线程池:复用线程的标准方式

线程池的思路和数据库连接池一样:提前创建一批线程放在池子里,来任务就拿一个用,用完还回去而不是销毁。这样既控制了并发线程数量,又省掉了反复创建销毁线程的开销。Executors 类提供了几个工厂方法,但阿里巴巴开发手册明确要求不要用 Executors 快捷方法,而是用 ThreadPoolExecutor 手动构造,明确队列大小和拒绝策略。

// 手动创建线程池,参数全部显式指定
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                      // 核心线程数
    8,                      // 最大线程数
    60L, TimeUnit.SECONDS,  // 空闲线程存活时间
    new ArrayBlockingQueue<>(200),  // 任务队列
    new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy()  // 队列满了由调用线程自己跑
);

// 提交无返回值任务
pool.execute(() -> doSomething());

// 提交有返回值任务,拿 Future
Future<String> future = pool.submit(() -> querySomething());
String result = future.get();   // 阻塞等结果

// 应用关闭时优雅关闭
pool.shutdown();

几个参数的含义要记牢:核心线程数是常驻的线程数量,队列满了才会扩到最大线程数,拒绝策略是队列和线程都满了之后怎么办。CallerRunsPolicy 是一种保护性策略——让提交任务的那个线程自己跑,相当于自动降速,比直接丢任务安全。

  • CPU 密集型:核心线程数设为 CPU 核数 + 1,太多反而增加切换开销。
  • IO 密集型:线程数可以多一些,比如核数的 2 到 4 倍,因为线程大部分时间在等 IO。
  • 不要用 Executors.newFixedThreadPool:它的队列是无界的,任务堆积会撑爆内存。

共享可变状态:synchronized 与 Lock

多个线程同时修改同一个变量,是并发 bug 的头号来源。看这个经典例子:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;    // 这一行其实是三步:读、加、写
    }

    public int get() { return count; }
}

// 两个线程各加一万次,预期结果是 20000,实际经常小于 20000
public static void main(String[] args) throws Exception {
    Counter c = new Counter();
    Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) c.increment(); });
    Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) c.increment(); });
    t1.start(); t2.start();
    t1.join(); t2.join();
    System.out.println(c.get());   // 大概率不是 20000
}

count++ 看起来是一行,实际是"读当前值、加一、写回"三步。两个线程同时读到同一个值,各自加一写回去,结果就少加了一次。解决办法是用 synchronized 把这段代码包成临界区,同一时刻只允许一个线程进去:

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;    // 同一时刻只有一个线程能进入这个方法
    }
}

synchronized 是 Java 内置的锁,加在方法上相当于锁住 this 对象。它的好处是自动加锁、自动释放,不会因为异常忘了解锁。如果需要更灵活的控制(比如尝试拿锁、可中断、超时),用 ReentrantLock:

private final ReentrantLock lock = new ReentrantLock();

public void doBiz() {
    lock.lock();
    try {
        // 临界区代码
    } finally {
        lock.unlock();   // 必须在 finally 里释放,否则异常会死锁
    }
}

用 Lock 有一个铁律:解锁必须放在 finally 里,否则业务代码抛异常,锁永远不释放,其他线程全部卡死。

并发容器与原子类

日常写业务,大部分时候不需要自己写锁。JDK 提供了一整套并发安全的容器和工具类,直接用就好:

类替代谁为什么快
ConcurrentHashMapHashMap + synchronized分段锁 / CAS,读几乎无锁
CopyOnWriteArrayListArrayList + synchronized写时复制,读完全无锁
BlockingQueue自己实现的生产者队列内部已处理好阻塞与唤醒
AtomicInteger / AtomicLongint + synchronizedCAS 无锁更新,性能高一个数量级
CountDownLatch自己写等待逻辑一次性门闩,等 N 个任务完成
Semaphore自己写信号量控制同时访问的线程数
// 原子类做计数器,不需要 synchronized
private final AtomicInteger count = new AtomicInteger(0);

public void onRequest() {
    count.incrementAndGet();   // 原子操作,线程安全
}

// ConcurrentHashMap 做缓存,多线程读写不用自己加锁
private final Map<String, User> cache = new ConcurrentHashMap<>();

public User getUser(String id) {
    return cache.computeIfAbsent(id, this::loadFromDb);
}

CopyOnWriteArrayList 适合"读多写少"的场景,比如监听器列表:每次修改都复制一份新数组,读操作完全无锁。但如果写入频繁,每次复制数组的开销很大,这种场景就不该用它。选容器的时候先想清楚读写比例。