Java多线程编程入门
一、多线程基础概念
1.1 进程与线程
在深入探讨 Java 多线程编程之前,我们首先要理解进程(Process)和线程(Thread)这两个关键概念。
进程:进程是程序的一次执行过程,是系统进行资源分配和调度的基本单位。每个进程都有独立的内存空间,包含代码、数据和进程控制块(PCB)等。例如,当我们启动一个浏览器程序,它就是一个进程,浏览器进程会占用一定的系统资源,如内存、CPU 时间等。不同的进程之间相互隔离,数据不能直接共享。
线程:线程是进程中的一个执行路径,是程序执行的最小单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、打开的文件等。例如,在浏览器进程中,可能有一个线程负责加载网页内容,另一个线程负责处理用户的交互操作,还有一个线程负责检查是否有新的消息通知等。线程之间的切换开销比进程小得多,因为它们共享资源,不需要重新分配内存等操作。
1.2 多线程的优势
- 提高 CPU 利用率:在单线程程序中,当某个任务需要等待 I/O 操作完成(如从硬盘读取文件、网络请求等)时,CPU 处于空闲状态,造成资源浪费。而多线程程序可以在一个线程等待 I/O 时,让其他线程继续执行,充分利用 CPU 资源。
- 增强程序的响应性:对于一些图形界面(GUI)程序,使用多线程可以使界面在后台任务执行时仍然保持响应,不会出现卡顿现象。比如,在一个文件下载的 GUI 程序中,使用一个线程负责下载文件,另一个线程负责处理用户对界面的操作,如暂停、取消下载等。
- 便于模型化:现实生活中的很多场景可以很自然地用多线程来模拟。例如,一个银行系统中,多个用户同时进行取款、存款操作,每个用户的操作可以看作是一个线程。
二、Java 中的线程创建
2.1 继承 Thread 类
在 Java 中,创建线程最直接的方式之一就是继承 Thread
类。下面是一个简单的示例代码:
class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(getName() + " is running: " + i);
}
}
}
public class ThreadExample1 {
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
thread1.start();
thread2.start();
}
}
在上述代码中,我们定义了一个 MyThread
类,它继承自 Thread
类。然后重写了 run
方法,run
方法中的代码就是线程要执行的任务。在 main
方法中,我们创建了两个 MyThread
实例,并调用 start
方法启动线程。注意,不能直接调用 run
方法,否则它将作为普通方法执行,而不会开启新的线程。
2.2 实现 Runnable 接口
另一种创建线程的方式是实现 Runnable
接口。相比继承 Thread
类,实现 Runnable
接口更具灵活性,因为 Java 不支持多重继承,而一个类可以实现多个接口。示例代码如下:
class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " is running: " + i);
}
}
}
public class ThreadExample2 {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
这里我们定义了 MyRunnable
类实现了 Runnable
接口,并实现了 run
方法。在 main
方法中,我们创建了 MyRunnable
的实例,并将其作为参数传递给 Thread
构造函数来创建线程对象,最后启动线程。
2.3 实现 Callable 接口(Java 5+)
Callable
接口与 Runnable
接口类似,但 Callable
接口的 call
方法可以有返回值,并且可以抛出异常。这在一些需要获取线程执行结果的场景中非常有用。示例代码如下:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
}
public class ThreadExample3 {
public static void main(String[] args) {
MyCallable callable = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
try {
Integer result = futureTask.get();
System.out.println("The result is: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
在上述代码中,MyCallable
类实现了 Callable
接口,call
方法计算 0 到 99 的和并返回。我们通过 FutureTask
来包装 Callable
对象,FutureTask
既实现了 Runnable
接口,又提供了获取 Callable
执行结果的方法。在 main
方法中,我们创建线程并启动,然后通过 futureTask.get()
获取线程执行的结果。
三、线程的生命周期
3.1 新建(New)
当我们使用 new
关键字创建一个线程对象时,线程就处于新建状态。例如:
Thread thread = new Thread(new MyRunnable());
此时线程对象已经被分配了内存,初始化了成员变量,但尚未启动。
3.2 就绪(Runnable)
当调用线程的 start
方法后,线程进入就绪状态。处于就绪状态的线程具备了运行的条件,但还没有被分配到 CPU 资源,需要等待 CPU 调度。在就绪状态下,线程会在就绪队列中等待,一旦获得 CPU 时间片,就会进入运行状态。
3.3 运行(Running)
当线程获得 CPU 时间片开始执行 run
方法中的代码时,线程处于运行状态。在运行过程中,线程会执行任务,可能会因为各种原因让出 CPU 资源,比如调用 yield
方法主动让出 CPU,或者时间片用完等。
3.4 阻塞(Blocked)
线程在运行过程中,可能会遇到一些情况导致暂时无法继续执行,从而进入阻塞状态。常见的导致线程阻塞的情况有:
- I/O 阻塞:当线程执行 I/O 操作(如读取文件、网络通信等)时,由于需要等待数据的传输,线程会进入阻塞状态,直到 I/O 操作完成。
- 同步阻塞:当线程试图获取一个被其他线程占用的锁时,会进入阻塞状态,直到获取到锁。
- 等待阻塞:当线程调用
wait
方法时,会进入等待阻塞状态,直到其他线程调用notify
或notifyAll
方法唤醒它。
3.5 死亡(Dead)
当线程的 run
方法执行完毕,或者线程抛出一个未捕获的异常导致 run
方法提前结束,线程就进入死亡状态。一旦线程进入死亡状态,就不能再重新启动。
四、线程同步
4.1 线程安全问题
在多线程环境下,如果多个线程同时访问和修改共享资源,可能会导致数据不一致等线程安全问题。例如,下面的代码演示了一个简单的线程安全问题:
class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
class MyThread4 implements Runnable {
private Counter counter;
public MyThread4(Counter counter) {
this.counter = counter;
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
}
}
public class ThreadSafetyExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(new MyThread4(counter));
Thread thread2 = new Thread(new MyThread4(counter));
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Expected count: 2000, Actual count: " + counter.getCount());
}
}
在上述代码中,Counter
类有一个 increment
方法用于对 count
变量进行自增操作。两个线程同时调用 increment
方法,理论上最终 count
的值应该是 2000,但由于线程执行的不确定性,实际结果往往小于 2000。这是因为 count++
操作不是原子性的,它包含读取、增加和写入三个步骤,在多线程环境下可能会出现数据竞争。
4.2 synchronized 关键字
为了解决线程安全问题,Java 提供了 synchronized
关键字。它可以用于修饰方法或代码块,保证同一时间只有一个线程能够访问被 synchronized
修饰的部分。
修饰实例方法:
class CounterSync {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
在上述代码中,increment
和 getCount
方法都被 synchronized
修饰,这意味着当一个线程调用其中一个方法时,其他线程无法同时调用这两个方法中的任何一个,从而保证了数据的一致性。
修饰静态方法:
class StaticCounterSync {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static synchronized int getCount() {
return count;
}
}
静态方法被 synchronized
修饰时,锁是针对类的,而不是针对实例。这意味着所有实例调用静态同步方法时,都会竞争同一个锁。
修饰代码块:
class BlockCounterSync {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
这里通过 synchronized (lock)
修饰代码块,锁对象是 lock
。只有获取到 lock
锁的线程才能执行同步代码块中的内容。
4.3 Lock 接口(Java 5+)
除了 synchronized
关键字,Java 5 引入了 Lock
接口及其实现类,如 ReentrantLock
,提供了更灵活的线程同步控制。示例代码如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class LockCounter {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
在上述代码中,ReentrantLock
实现了 Lock
接口。通过 lock
方法获取锁,在操作完成后通过 unlock
方法释放锁,并且使用 try - finally
块确保即使在操作过程中抛出异常,锁也能被正确释放。与 synchronized
相比,Lock
接口提供了更多的功能,如可中断的锁获取、公平锁等。
五、线程通信
5.1 wait()、notify() 和 notifyAll() 方法
在多线程编程中,线程之间经常需要进行通信,以协调它们的工作。wait()
、notify()
和 notifyAll()
方法是实现线程通信的重要手段。这些方法必须在 synchronized
块中调用。
示例代码:
class Message {
private String content;
private boolean ready = false;
public synchronized void write(String msg) {
while (ready) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
content = msg;
ready = true;
notifyAll();
}
public synchronized String read() {
while (!ready) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
ready = false;
notifyAll();
return content;
}
}
class Writer implements Runnable {
private Message message;
public Writer(Message message) {
this.message = message;
}
@Override
public void run() {
String[] messages = {"Hello", "World", "Java"};
for (String msg : messages) {
message.write(msg);
System.out.println("Writer wrote: " + msg);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
message.write("END");
}
}
class Reader implements Runnable {
private Message message;
public Reader(Message message) {
this.message = message;
}
@Override
public void run() {
String msg;
do {
msg = message.read();
System.out.println("Reader read: " + msg);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} while (!"END".equals(msg));
}
}
public class ThreadCommunicationExample {
public static void main(String[] args) {
Message message = new Message();
new Thread(new Writer(message)).start();
new Thread(new Reader(message)).start();
}
}
在上述代码中,Message
类有一个 content
变量用于存储消息,ready
变量用于表示消息是否准备好。Writer
线程通过 write
方法写入消息,Reader
线程通过 read
方法读取消息。当 ready
为 true
时,Writer
线程调用 wait
方法等待,直到 Reader
线程读取消息后调用 notifyAll
方法唤醒它;当 ready
为 false
时,Reader
线程调用 wait
方法等待,直到 Writer
线程写入消息后调用 notifyAll
方法唤醒它。
5.2 Condition 接口(Java 5+)
Condition
接口是 Lock
接口的一个辅助接口,提供了更灵活的线程通信方式。与 wait()
、notify()
基于对象监视器不同,Condition
是基于 Lock
的。示例代码如下:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class ConditionMessage {
private String content;
private boolean ready = false;
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void write(String msg) {
lock.lock();
try {
while (ready) {
condition.await();
}
content = msg;
ready = true;
condition.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public String read() {
lock.lock();
try {
while (!ready) {
condition.await();
}
ready = false;
condition.signalAll();
return content;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return null;
}
}
在上述代码中,ConditionMessage
类使用 Lock
和 Condition
实现了与前面 Message
类类似的功能。Condition
的 await
方法类似于 wait
方法,signalAll
方法类似于 notifyAll
方法,但它们是基于 Lock
进行操作的,提供了更细粒度的控制。
六、线程池
6.1 为什么使用线程池
在多线程编程中,如果频繁地创建和销毁线程,会带来较大的开销,影响程序的性能。线程池可以解决这个问题,它预先创建一定数量的线程,并将这些线程复用。当有任务提交时,线程池中的线程会执行任务,任务完成后,线程不会被销毁,而是返回线程池等待下一个任务。这样可以减少线程创建和销毁的开销,提高系统的性能和稳定性。
6.2 Java 中的线程池实现
Java 提供了 Executor
框架来实现线程池,其中核心的类和接口有 Executor
、ExecutorService
、ThreadPoolExecutor
等。
使用 Executors
工厂类创建线程池:
- 固定大小线程池:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
在上述代码中,Executors.newFixedThreadPool(3)
创建了一个固定大小为 3 的线程池。这意味着线程池中始终保持 3 个线程,当有任务提交时,如果线程池中有空闲线程,就会分配给该线程执行任务;如果没有空闲线程,任务会在队列中等待,直到有线程空闲。
- 缓存线程池:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CachedThreadPoolExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
Executors.newCachedThreadPool()
创建的缓存线程池会根据任务的需求动态创建和销毁线程。如果线程池中有空闲线程,就会复用这些线程;如果没有空闲线程,就会创建新的线程来执行任务。当线程空闲一定时间(默认为 60 秒)后,会被销毁。
- 单线程线程池:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadExecutorExample {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
for (int i = 0; i < 5; i++) {
executorService.submit(() -> {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
}
}
Executors.newSingleThreadExecutor()
创建的单线程线程池只有一个线程,所有任务会按照提交的顺序依次执行,保证了任务执行的顺序性。
使用 ThreadPoolExecutor
自定义线程池:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class CustomThreadPoolExample {
public static void main(String[] args) {
BlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>(5);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
4,
10,
TimeUnit.SECONDS,
taskQueue
);
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
}
}
在上述代码中,ThreadPoolExecutor
的构造函数接受多个参数:
corePoolSize
:核心线程数,线程池会始终保持这些线程存活,即使它们处于空闲状态。maximumPoolSize
:最大线程数,线程池允许创建的最大线程数。keepAliveTime
:当线程数大于核心线程数时,多余的空闲线程的存活时间。unit
:keepAliveTime
的时间单位。workQueue
:任务队列,用于存放等待执行的任务。
通过自定义线程池,可以根据实际需求灵活调整线程池的参数,以达到最优的性能。
七、常见的多线程设计模式
7.1 生产者 - 消费者模式
生产者 - 消费者模式是一种经典的多线程设计模式,它用于协调生产者和消费者之间的工作。生产者负责生成数据,消费者负责处理数据。在多线程环境下,通过队列来缓存数据,生产者将数据放入队列,消费者从队列中取出数据进行处理。
示例代码:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
class Producer implements Runnable {
private BlockingQueue<Integer> queue;
public Producer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
queue.put(i);
System.out.println("Producer produced: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Consumer implements Runnable {
private BlockingQueue<Integer> queue;
public Consumer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
Integer num = queue.take();
System.out.println("Consumer consumed: " + num);
if (num == 9) {
break;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(5);
Thread producerThread = new Thread(new Producer(queue));
Thread consumerThread = new Thread(new Consumer(queue));
producerThread.start();
consumerThread.start();
try {
producerThread.join();
consumerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在上述代码中,Producer
类实现了 Runnable
接口,在 run
方法中不断生成数据并放入 BlockingQueue
中。Consumer
类同样实现了 Runnable
接口,从 BlockingQueue
中取出数据并处理。BlockingQueue
起到了缓存数据的作用,并且提供了线程安全的操作方法,如 put
和 take
,当队列满时 put
方法会阻塞,当队列空时 take
方法会阻塞,从而实现了生产者和消费者之间的协调。
7.2 单例模式与多线程
单例模式是一种常用的设计模式,它确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式需要考虑线程安全问题。
饿汉式单例:
class EagerSingleton {
private static final EagerSingleton instance = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return instance;
}
}
饿汉式单例在类加载时就创建了实例,由于类加载机制是线程安全的,所以饿汉式单例天生就是线程安全的。
懒汉式单例(线程不安全):
class LazySingletonUnsafe {
private static LazySingletonUnsafe instance;
private LazySingletonUnsafe() {}
public static LazySingletonUnsafe getInstance() {
if (instance == null) {
instance = new LazySingletonUnsafe();
}
return instance;
}
}
懒汉式单例在第一次调用 getInstance
方法时才创建实例。但在多线程环境下,如果多个线程同时调用 getInstance
方法,并且都判断 instance
为 null
,就会创建多个实例,导致线程安全问题。
懒汉式单例(线程安全 - synchronized 方法):
class LazySingletonSafe {
private static LazySingletonSafe instance;
private LazySingletonSafe() {}
public static synchronized LazySingletonSafe getInstance() {
if (instance == null) {
instance = new LazySingletonSafe();
}
return instance;
}
}
通过在 getInstance
方法上添加 synchronized
关键字,可以保证同一时间只有一个线程能够进入该方法,从而解决了线程安全问题。但这种方式会导致性能问题,因为每次调用 getInstance
方法都需要获取锁。
懒汉式单例(线程安全 - 双重检查锁定):
class LazySingletonDCL {
private static volatile LazySingletonDCL instance;
private LazySingletonDCL() {}
public static LazySingletonDCL getInstance() {
if (instance == null) {
synchronized (LazySingletonDCL.class) {
if (instance == null) {
instance = new LazySingletonDCL();
}
}
}
return instance;
}
}
双重检查锁定通过两次 if (instance == null)
判断,在第一次判断时,如果 instance
不为 null
,就直接返回,避免了每次都获取锁的开销。在第二次判断时,由于有 synchronized
块保证线程安全,所以可以确保只创建一个实例。这里使用 volatile
关键字修饰 instance
变量,是为了防止指令重排序,保证在多线程环境下的正确性。
通过深入理解这些多线程编程的基础知识、技术和设计模式,开发者可以编写出高效、稳定且线程安全的 Java 程序,充分发挥多线程编程的优势,提升系统的性能和响应性。在实际应用中,还需要根据具体的业务需求和场景,合理选择和优化多线程的实现方式。