多线程技术在现代编程中被广泛应用,尤其是在需要充分利用多核 CPU 计算能力的场景下。然而,尽管多线程带来了许多好处,但它也并非没有缺点。本文将探讨多线程的优缺点,并深入分析多线程编程中常见的三类问题:线程安全问题、活跃性问题和性能问题。
1 多线程的优点
- 充分利用多核 CPU:多线程可以并行执行多个任务,从而充分利用多核 CPU 的计算能力,提高程序的执行效率。
- 提高响应速度:在某些场景下,多线程可以提高程序的响应速度,例如在图形用户界面(GUI)应用中,主线程可以处理用户输入,而其他线程可以执行耗时的后台任务。
2 多线程的缺点
尽管多线程有很多优点,但它也带来了一些挑战,主要包括以下三类问题:
2.1 线程安全问题
线程安全问题是指在多线程环境下,程序的行为与预期不符,导致数据不一致或其他异常情况。线程安全问题主要涉及两个方面:原子性和可见性。
2.1.1 原子性
原子性指的是一个操作或多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。在并发编程中,很多操作都不是原子操作。例如,i++
操作实际上包含了三个步骤:读取 i
的值、增加 i
的值、将新值写回 i
。在多线程环境下,如果不加锁,可能会导致数据不一致。
public class YuanziDeo {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
int numThreads = 2;
int numIncrementsPerThread = 100000;
Thread[] threads = new Thread[numThreads];
for (int j = 0; j < numThreads; j++) {
threads[j] = new Thread(() -> {
for (int k = 0; k < numIncrementsPerThread; k++) {
i++;
}
});
threads[j].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final value of i = " + i);
System.out.println("Expected value = " + (numThreads * numIncrementsPerThread));
}
}
输出结果可能如下:
Final value of i = 102249
Expected value = 200000
期望值为 200000,但实际值为 102249,这表明 i++
不是一个原子操作。
2.1.2 可见性
可见性问题是指一个线程对共享变量的修改,其他线程不能立即看到。
2.1.2.1 代码示例
class Test {
int i = 50;
int j = 0;
public void update() {
i = 100;
}
public int get() {
j = i;
return j;
}
}
代码分析
上述代码段定义了一个 Test
类,其中包含两个整型变量 i
和 j
,以及两个方法 update
和 get
。
update
方法将i
的值从 50 修改为 100。get
方法将i
的值赋给j
,并返回j
的值。
在单线程环境下,这段代码的行为是明确的:
- 调用
update
方法后,i
的值变为 100。 - 调用
get
方法后,j
的值变为 100,并返回 100。
然而,在多线程环境下,这段代码可能会出现可见性问题。具体来说,如果线程 1 执行 update
方法将 i
的值修改为 100,但这个修改没有及时刷新到主内存中,那么线程 2 在执行 get
方法时,可能会读取到旧的 i
值(即 50),从而导致 j
的值为 50,而不是期望的 100。
测试案例
为了验证上述分析,我们可以编写一个多线程测试案例,模拟线程 1 和线程 2 的并发执行。
public class TestVisibility {
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
// 线程1:执行 update 方法
Thread thread1 = new Thread(() -> {
test.update();
});
// 线程2:执行 get 方法
Thread thread2 = new Thread(() -> {
int result = test.get();
System.out.println("Result from thread2: " + result);
});
// 启动线程
thread1.start();
thread2.start();
// 等待线程执行完毕
thread1.join();
thread2.join();
}
}
class Test {
int i = 50;
int j = 0;
public void update() {
// 线程1执行
i = 100;
}
public int get() {
// 线程2执行
j = i;
return j;
}
}
测试结果
在多线程环境下,运行上述测试案例可能会得到以下两种结果之一:
-
期望结果:线程 1 执行
update
方法后,i
的值变为 100,线程 2 执行get
方法时读取到i
的新值 100,因此j
的值为 100,输出结果为Result from thread2: 100
。 -
可见性问题:线程 1 执行
update
方法后,i
的值变为 100,但由于可见性问题,线程 2 在执行get
方法时读取到i
的旧值 50,因此j
的值为 50,输出结果为Result from thread2: 50
。
2.1.2.2 解决可见性问题
为了确保线程 2 能够看到线程 1 对 i
的修改,可以使用 volatile
关键字修饰 i
,或者使用 synchronized
关键字来保证可见性。
使用 volatile
关键字
class Test {
volatile int i = 50;
int j = 0;
public void update() {
// 线程1执行
i = 100;
}
public int get() {
// 线程2执行
j = i;
return j;
}
}
使用 synchronized
关键字
class Test {
int i = 50;
int j = 0;
public synchronized void update() {
// 线程1执行
i = 100;
}
public synchronized int get() {
// 线程2执行
j = i;
return j;
}
}
通过上述修改,可以确保线程 2 在执行 get
方法时能够看到线程 1 对 i
的修改,从而避免可见性问题。
2.1.2.3 小结
在多线程编程中,可见性问题是一个常见的挑战。为了解决可见性问题,可以使用 volatile
关键字,确保变量修改后立即刷新到主内存中。此外,Java 的锁机制(如 synchronized
和 lock
)也可以保证可见性。
2.2 活跃性问题
活跃性问题是指某个操作无法继续下去,导致程序无法正常执行。常见的活跃性问题包括死锁、活锁和饥饿。
2.2.1 死锁
死锁是指两个或多个线程互相等待对方释放资源,导致所有线程都无法继续执行。例如,线程 A 持有资源 X 并等待资源 Y,而线程 B 持有资源 Y 并等待资源 X,这样就形成了死锁。
2.2.2 活锁
活锁是指线程没有阻塞,但因为互相避让而导致无法继续执行。例如,两个人迎面走来,互相让路,但总是同时走到一个方向,导致无法继续前进。
2.2.3 饥饿
饥饿是指某个线程由于优先级低或其他原因,长时间无法获得 CPU 资源,导致无法继续执行。例如,高优先级的线程一直占用 CPU,导致低优先级的线程无法执行。
2.3 性能问题
多线程并不总是比单线程快,因为多线程有创建线程和线程上下文切换的开销。
2.3.1 创建线程的开销
创建线程需要分配内存、列入调度等,这些操作对操作系统来说是昂贵的。
2.3.2 线程上下文切换
CPU 在执行多个线程时,需要频繁地在不同线程之间切换,每次切换都需要保存当前线程的状态并加载下一个线程的状态,这个过程称为上下文切换。上下文切换会消耗 CPU 资源,降低程序的执行效率。
减少上下文切换的方法包括:
- 无锁并发编程:可以参照
ConcurrentHashMap
锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。 - CAS 算法:利用
Atomic + CAS
算法来更新数据,采用乐观锁的方式,可以有效减少一部分不必要的锁竞争带来的上下文切换。 - 使用最少线程:避免创建不必要的线程,如果任务很少,但创建了很多的线程,这样就会造成大量的线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。