Java 并发编程
在当今的软件开发领域,随着多核处理器的广泛应用以及对系统性能要求的不断提高,Java
并发编程变得愈发重要。它允许我们充分利用计算机的多核资源,同时处理多个任务,提高程序的执行效率和响应能力。然而,并发编程并非易事,它涉及到诸多复杂的概念、机制以及需要注意的细节问题。
一、并发编程的基本概念
进程与线程
- 进程(Process)
进程是操作系统进行资源分配和调度的基本单位,它拥有独立的内存空间、代码段、数据段等资源。每个进程都像是一个独立运行的程序实例,例如我们在操作系统中同时打开的浏览器、文本编辑器等应用程序,它们各自在独立的进程中运行,彼此之间相对隔离,互不干扰。 - 线程(Thread)
线程是进程内部的执行单元,一个进程可以包含多个线程,这些线程共享进程的内存空间(包括代码段、数据段、堆等)以及一些系统资源(如文件描述符等)。线程相较于进程更加轻量级,创建和销毁的开销相对较小,它们可以并发地执行不同的任务,从而实现多任务并行处理的效果。比如在一个 Web 服务器应用中,主线程负责接收客户端的请求,而多个工作线程可以同时处理这些请求,提高服务器的响应速度和并发处理能力。
并发与并行
- 并发(Concurrency)
并发指的是在一段时间内,多个任务交替执行,宏观上看起来好像是同时在进行,但在微观层面,在单个 CPU 核心上,实际上是通过时间片轮转等调度机制,使得各个任务轮流获得 CPU 时间来执行。例如,单核 CPU 的计算机上同时运行多个程序,操作系统会快速地在这些程序间切换,让每个程序都能得到执行机会,给用户造成一种多个程序同时运行的错觉。 - 并行(Parallelism)
并行则是真正意义上的同时执行多个任务,它要求有多个 CPU 核心或者多个处理器,每个任务可以分配到不同的核心上,在同一时刻同时进行。例如,在多核 CPU 的服务器上,多个线程可以分别在不同的核心上同步执行,大大提高了整体的计算效率,常用于大数据处理、图形渲染等对计算性能要求较高的场景。
二、Java 并发编程的基础工具
(一)线程类(Thread)
在 Java 中,Thread 类是用于创建和操作线程的核心类。我们可以通过继承 Thread 类并重写 run 方法来定义线程的执行逻辑,示例:
class MyThread extends Thread {
@Override
public void run() {
// 这里编写线程要执行的具体任务代码
System.out.println("线程正在执行");
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程,会自动调用 run 方法
}
}
需要注意的是,不能直接调用 run 方法来启动线程,而是要使用 start 方法,start 方法会由 Java 虚拟机去调度线程,使其进入就绪状态,等待获取 CPU 时间片后开始执行 run 方法中的逻辑。
(二)Runnable 接口
除了继承 Thread 类,还可以通过实现 Runnable 接口来定义线程的执行逻辑,这种方式更加灵活,因为 Java 是单继承的,实现接口可以避免继承 Thread 类带来的一些限制,并且方便实现资源共享等功能。示例代码如下:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("通过实现 Runnable 接口的线程在执行");
}
}
public class RunnableExample {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
}
(三)Callable 接口与 Future 机制
Callable 接口类似于 Runnable 接口,也是用于定义线程的执行逻辑,但它有一些独特的优势。Callable 接口的 call 方法可以有返回值,并且能够抛出受检异常,而不像 Runnable 的 run 方法只能无返回值且不能抛出受检异常。
当使用 Callable 接口创建线程任务时,通常会结合 Future 机制来获取线程执行的结果。Future 接口代表一个异步计算的结果,通过它可以在未来某个时间点获取线程执行完成后的返回值,还能进行诸如取消任务等操作。
简单示例:
import java.util.concurrent.*;
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 42; // 简单返回一个整数作为示例结果
}
}
public class CallableFutureExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(new MyCallable());
Integer result = future.get(); // 阻塞等待线程执行完成并获取结果
System.out.println("线程执行结果: " + result);
executor.shutdown();
}
}
三、线程的状态与生命周期
(一)线程的状态
在 Java 中,线程有以下几种主要状态:
- 新建(New):当通过 new 关键字创建一个 Thread 对象或者实现了 Runnable、Callable 的任务对象被包装成线程相关对象后,线程就处于新建状态,此时它还没有开始执行。
就绪(Runnable):线程对象调用 start 方法后,它就进入就绪状态,意味着它已经准备好可以被 CPU 调度执行了,等待获取 CPU 时间片。需要注意的是,处于这个状态的线程可能会在就绪队列中等待一段时间,直到轮到它获得 CPU 资源。 - 运行(Running):当线程获得 CPU 时间片,开始执行 run 方法(或者 call 方法)中的逻辑时,就处于运行状态,在这个状态下,线程会执行具体的任务代码。
- 阻塞(Blocked):线程在执行过程中,可能会因为某些原因暂时停止执行,进入阻塞状态,比如等待获取某个锁(synchronized 关键字实现的锁或者 Lock 接口实现的锁)、等待某个条件满足(通过 Object 的 wait 方法等待条件)、执行了阻塞式的 I/O 操作等。处于阻塞状态的线程无法获得 CPU 时间片,直到阻塞原因解除,它才会重新回到就绪状态,等待再次被调度执行。
- 等待(Waiting):线程调用了一些特定的方法(如 Object 类的 wait 方法、Thread 类的 join 方法等)后,会进入等待状态,此时它会释放持有的锁等资源,等待其他线程通过相应的通知机制(如 Object 的 notify 或 notifyAll 方法)来唤醒它,唤醒后它会进入就绪状态。
- 超时等待(Timed Waiting):与等待状态类似,但它是有时间限制的等待,线程调用了带有超时参数的等待方法(如 Thread.sleep 方法、Object 类的 wait 方法传入超时时间等)后,会进入超时等待状态,在超时时间到达后,如果还没有被提前唤醒,就会自动回到就绪状态,继续等待被调度执行。
- 终止(Terminated):线程执行完 run 方法(或者 call 方法)中的所有逻辑,或者因为出现异常等原因导致线程提前结束,就会进入终止状态,此时线程的生命周期结束,无法再被重新启动。
(二)线程状态转换
线程在不同状态之间会根据特定的条件和操作进行转换
- 新建状态的线程调用 start 方法后,会转换到就绪状态,等待 CPU 调度进入运行状态。
运行状态的线程如果遇到阻塞原因(如等待锁、执行阻塞 I/O 等),会转换到阻塞状态,阻塞原因解除后回到就绪状态。
运行状态的线程调用 wait 等等待相关方法后,会进入等待状态,被唤醒后回到就绪状态;若调用带有超时参数的等待方法,则进入超时等待状态,超时后或提前被唤醒回到就绪状态。 - 线程正常执行完任务或者因异常结束,就从运行状态转换到终止状态。
四、并发编程中的同步机制
(一)synchronized 关键字
方法级同步
在 Java 中,可以使用 synchronized 关键字修饰方法,使得该方法在同一时刻只能被一个线程访问,从而保证了方法执行的原子性。
例如:
public class SynchronizedMethodExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
在上述代码中,increment 方法被 synchronized 修饰,当多个线程同时调用这个方法时,只有一个线程能够进入方法内部执行,其他线程需要等待,这样就避免了多个线程同时对 count 变量进行操作导致的数据不一致问题。
代码块级同步
除了修饰方法,synchronized 关键字还可以修饰代码块,通过指定一个对象作为锁对象,来实现对特定代码块的同步控制。
示例:
public class SynchronizedBlockExample {
private Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
这里以 lock 对象作为锁,当线程进入 synchronized 代码块时,需要获取对应的锁,同一时刻只有获取到锁的线程可以执行代码块内的代码,实现了对 count 变量操作的同步保护。
(二)Lock 接口及其实现类
Java 还提供了 Lock 接口及其一系列实现类(如 ReentrantLock)来实现更灵活的锁机制。与 synchronized 关键字相比,Lock 接口提供了更多的功能,比如可以实现可中断锁(线程在等待锁的过程中可以被中断)、可轮询的锁(可以通过循环不断尝试获取锁)、公平锁(按照线程请求锁的先后顺序来分配锁,避免线程长时间等待)等。
ReentrantLock 的示例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 一定要在 finally 块中释放锁,确保锁能被正确释放
}
}
public int getCount() {
return count;
}
}
使用 Lock 接口时,要特别注意在合适的地方通过 unlock 方法释放锁,通常放在 finally 块中,防止因为异常等原因导致锁未被释放,造成死锁等问题。
(三)线程间通信机制
在并发编程中,线程之间往往需要进行通信和协作,常见的线程间通信机制有以下几种:
- Object 类的 wait、notify 和 notifyAll 方法
线程可以调用 Object 类的 wait 方法进入等待状态,释放持有的锁,等待其他线程通过调用同一个对象的 notify 方法(唤醒单个等待线程)或者 notifyAll 方法(唤醒所有等待线程)来唤醒它。
例如:
public class ThreadCommunicationExample {
private Object lock = new Object();
private boolean flag = false;
public void producer() {
synchronized (lock) {
flag = true;
lock.notifyAll(); // 通知消费者线程可以继续执行了
}
}
public void consumer() {
synchronized (lock) {
while (!flag) {
try {
lock.wait(); // 等待生产者线程通知
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("消费者线程开始执行,接收到生产者的通知");
}
}
}
在这个示例中,生产者线程生产出数据(通过设置 flag 为 true)后,通过 notifyAll 方法唤醒等待的消费者线程,消费者线程在 while 循环中不断检查 flag 状态,等待被唤醒后继续执行后续逻辑。
- Condition 接口
Condition 接口是在 Lock 接口基础上提供的更灵活的线程间通信机制,它可以实现类似于 Object 的 wait、notify 等功能,但可以针对不同的条件创建多个 Condition 对象,实现更细粒度的线程等待和唤醒控制。
例如:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private boolean flag = false;
public void producer() {
lock.lock();
try {
flag = true;
condition.signalAll(); // 唤醒等待在这个条件上的所有线程
} finally {
lock.unlock();
}
}
public void consumer() {
lock.lock();
try {
while (!flag) {
condition.await(); // 等待条件满足
}
System.out.println("消费者线程开始执行,接收到生产者的通知");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
通过使用 Condition 接口,我们可以根据不同的业务逻辑条件,更精准地控制线程的等待和唤醒,提高并发程序的灵活性和可维护性。
五、Java 并发容器
(一)并发容器概述
- 在并发编程中,使用普通的集合类(如 ArrayList、HashMap 等)往往会出现线程安全问题,因为它们在设计时并没有考虑多线程并发访问的情况。Java 提供了一系列并发容器来解决这个问题,这些并发容器在保证高效性能的同时,能够支持多线程安全地进行读写等操作。
(二)常见并发容器介绍
- ConcurrentHashMap 是线程安全的哈希表实现,它在 Java 8 及之后的版本中采用了更加高效的分段锁(在早期版本是分段数组加链表的结构,通过对不同段加锁来实现并发控制,后来改进为基于 CAS 和 synchronized 关键字结合的方式实现更细粒度的并发控制),允许多个线程同时对不同的桶(bucket)进行读写操作,大大提高了并发读写的性能,常用于多线程环境下的键值对数据存储和访问场景,例如缓存系统等。
- CopyOnWriteArrayList 是一种线程安全的动态数组实现,它的特点是在进行修改操作(如添加、删除元素)时,会先复制一份原数组,然后在新的副本上进行修改操作,修改完成后再将原数组的引用指向新的数组,这样在进行读操作时可以不加锁,保证了读操作的高效性,适用于读多写少的场景,比如一些配置信息的缓存列表,经常会被多个线程读取,但修改操作相对较少。
- BlockingQueue 是一种支持阻塞操作的队列接口,它有多个实现类,常用的如 ArrayBlockingQueue(基于数组实现的有界阻塞队列)、LinkedBlockingQueue(基于链表实现的阻塞队列,可指定容量为有界或无界)等。它提供了诸如 put 方法(当队列满时,阻塞插入线程,直到队列有空间)、take 方法(当队列空时,阻塞获取线程,直到队列中有元素)等阻塞操作,常用于生产者 - 消费者模式中,方便线程间的数据传递和同步,例如线程池中的任务队列就是基于 BlockingQueue 实现的。