这篇介绍 Java 的多线程编程基础。Java 从诞生之初就内置了完善的多线程支持,是学习并发编程非常好的起点。如果你暂时只想刷算法题,可以先跳过这篇,等需要的时候再回来看。
同步、异步、并行、并发
在讲多线程之前,先把几个容易混淆的概念理清楚。
假设你要做三件事:煮饭、炒菜、煲汤。
同步就是你站在锅前等饭熟,熟了再去炒菜,炒完再煲汤。一件事做完才做下一件,中间全在等待,效率最低但最简单。
异步就是你按下电饭煲的开关,不等饭熟,趁这个间隙去炒菜。炒菜到一半需要等油热的时候,又去把汤锅架上。始终只有你一个人在忙,但你利用每件事的等待间隙去推进其他事情。这本质上还是单线程,只是任务切换得更聪明了。
并行就是你叫了两个帮手,三个人各守一个灶台同时干活。这才是真正的同时执行,需要多个 CPU 核心才能实现。
那并发呢?这是一个更模糊的总称。只要多件事在同时推进,就可以叫并发。不管你是一个人异步切换(单线程),还是多个人并行执行(多线程),都算并发。换句话说,异步和并行都是实现并发的手段。
Java 提供了 Thread、synchronized、线程池等一整套并发工具,接下来我们就用代码来体验。
创建线程
Java 创建线程有三种常见方式。
方式一:继承 Thread 类
最直接的做法是写一个类继承 Thread,重写 run 方法:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程运行中:" + getName());
}
}
MyThread t = new MyThread();
t.start();方式二:实现 Runnable 接口
更推荐的做法是实现 Runnable 接口,然后传给 Thread。这样你的类还可以继承其他类(Java 不支持多继承):
class MyTask implements Runnable {
@Override
public void run() {
System.out.println("任务运行中");
}
}
Thread t = new Thread(new MyTask());
t.start();方式三:Lambda 简化
既然 Runnable 是只有一个方法的接口(函数式接口),就可以用 Lambda 表达式简化:
Thread t = new Thread(() -> {
System.out.println("Lambda 线程");
});
t.start();start() vs run()
这里有一个很重要的区别:调用 t.start() 才会真正启动一个新线程,让 run() 在新线程中执行。如果你直接调用 t.run(),那只是在当前线程中普通地执行了这个方法,并没有创建新线程。
join() 等待线程
t.join() 的作用是让当前线程等待线程 t 执行完毕再继续。如果不调用 join(),主线程可能在子线程还没执行完就结束了。
下面这个例子把上面这些知识点串起来,创建多个线程并行打印,然后用 join() 等待所有线程完成:
运行这段代码,你会发现三个线程的输出顺序可能每次都不一样。这正是多线程的特点:多个线程的执行顺序是不确定的,由操作系统的线程调度器决定。
线程安全与 synchronized
多个线程并行执行时,如果它们同时读写同一个变量,就会出问题。来看一个经典的例子:两个线程各自对一个计数器加 1 一百万次,预期结果应该是 2000000。
多运行几次你会发现,结果几乎不会是 2000000,而是一个比 2000000 小的随机数。
这就是竞态条件(Race Condition)。count++ 看起来是一步操作,实际上它分三步:读取 count 的值、加 1、写回 count。
当两个线程同时执行这三步时,可能会出现这样的情况:线程 1 读到 count = 100,还没来得及写回,线程 2 也读到了 count = 100,然后两个线程都写回 101。结果就是加了两次,但 count 只增加了 1。
synchronized 修复竞态条件
Java 用 synchronized 关键字来解决这个问题。synchronized 可以给一段代码加锁,同一时刻只允许一个线程执行这段代码,其他线程必须排队等待。
synchronized 有两种用法。方法级加锁是在方法声明上加 synchronized 关键字。代码块级加锁可以锁住更小的范围,减少性能开销。两种方式的效果一样,代码块级的粒度更细,更推荐使用:
加了 synchronized 之后,结果稳定是 2000000。两个线程在执行 count++ 时必须排队,不会再出现同时读写的问题。
volatile 关键字
除了原子性问题(多个线程同时修改同一个变量),多线程还有一个可见性问题:一个线程修改了变量的值,另一个线程可能看不到最新值,因为每个线程可能会把变量缓存在自己的工作内存中。
volatile 关键字可以解决可见性问题,保证一个线程对变量的修改对其他线程立即可见。但它不解决原子性问题,所以 volatile 不能替代 synchronized。简单了解即可,实际开发中遇到共享变量的问题,用 synchronized 是最稳妥的选择。
线程池 ExecutorService
前面的例子中,我们每次都是手动创建 Thread 对象。如果程序需要频繁创建和销毁线程,每次都新建 Thread 的开销很大:操作系统需要分配资源、初始化线程栈、调度线程,用完还得回收。
线程池就是为了解决这个问题:预先创建一组线程,有任务来了就分配给空闲线程执行,执行完毕线程不销毁而是回到池中等待下一个任务。这样就避免了反复创建销毁的开销。
Java 提供了 ExecutorService 接口和 Executors 工具类来使用线程池:
submit() 方法提交一个任务到线程池,返回一个 Future 对象。调用 future.get() 会阻塞当前线程,直到任务执行完成并返回结果。
注意最后一定要调用 pool.shutdown() 关闭线程池,否则程序会一直运行不退出,因为线程池中的线程还在等待任务。
实际例子:并行求和
来看一个更实际的例子:计算 1 到 1000 万的和。我们把任务拆分成 4 份,分别交给 4 个线程并行计算,最后把结果汇总:
这段代码把 1 到 1000 万的求和任务拆成了 4 份,4 个线程各算一段,最后把 4 个部分的结果加起来。在多核 CPU 上,这比单线程快得多。
这就是多线程编程最典型的模式:把一个大任务拆成多个小任务,分配给多个线程并行执行,最后汇总结果。
小结
这篇介绍了 Java 多线程编程的基础知识。
同步、异步、并行、并发这几个概念容易混淆,记住核心区别:同步是一件做完做下一件,异步是一个人利用等待间隙切换任务,并行是多个人同时干活,并发是前面几种方式的总称。
Java 创建线程最常用的方式是 new Thread(() -> { ... }) 配合 Lambda 表达式。多个线程同时读写同一个变量会产生竞态条件,用 synchronized 加锁可以解决。实际开发中一般不直接创建 Thread,而是使用 ExecutorService 线程池来管理线程,用 submit() 提交任务,用 Future.get() 获取结果。
Java 的并发工具远不止这些,java.util.concurrent 包还提供了 CountDownLatch、ConcurrentHashMap、AtomicInteger 等更多工具。不过对于入门来说,掌握了线程创建、synchronized 和线程池这三个核心知识点,就已经能应对大部分场景了。