1. 什么是线程安全?
《Java 并发编程实战》的作者 Brian Goetz 对线程安全的理解是:当多个线程访问一个对象时,如果不需要考虑这些线程在运行时环境中的调度和交替执行,也不需要额外的同步,调用这个对象的行为都能获得正确的结果,那么这个对象就是线程安全的。
通俗地说,无论有多少线程访问业务中的一个对象或方法,在编写这段业务逻辑时,无需做任何额外处理(即可以像单线程程序一样编写),程序也能正常运行(不会因多线程而失败),这样的代码就可以称为线程安全的。
2. 什么是线程不安全?
当多个线程同时访问一个对象时,如果某个线程正在更新对象的值,而另一个线程同时读取该对象的值,就可能导致获取到错误的值。这种情况下,我们需要采取额外措施(例如使用synchronized关键字同步这部分代码的执行)来确保结果的正确性。
3. 为什么不是所有程序都设计成线程安全的?
主要是出于程序性能、设计复杂度成本等方面的考量。
4. 线程安全问题的分类
4.1 运行结果错误
首先来看多线程同时操作一个变量如何导致运行结果错误。
假设用两个线程对count变量进行计数,每个线程各计 10000 次:
public class ResultError {
static int count;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
count++;
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
输出:
图片
理论上结果应为 20000,但实际输出远小于理论值,且每次结果不同。为什么会这样?
这是因为多线程下,CPU 的调度是以时间片为单位分配的,每个线程获得一定时间片后,若时间片耗尽会被挂起并让出 CPU 资源给其他线程,这可能导致线程安全问题。例如,i++看似一行代码,实际并非原子操作,其执行步骤主要分为三步,且每一步操作之间可能被中断:
- 读取当前值;
- 递增;
- 保存结果。
图片
假设线程 1 先读取count=1,随后执行count + 1操作,但此时结果尚未保存,线程 1 被切换。CPU 开始执行线程 2,其操作与线程 1 相同。但此时线程 2 读取的count值是多少?由于线程 1 的+1操作未保存结果,线程 2 读取的仍然是count=1。
假设线程 2 执行count + 1后保存结果为 2,随后线程 1 恢复执行,保存其计算结果为 2。虽然两个线程各执行了一次+1,但最终count结果为 2 而非预期的 3。这就是典型的线程安全问题,此时count变量被称为共享变量或共享数据。
如何解决?
解决此类问题需要一种机制:当多个线程操作共享变量时,确保同一时刻仅有一个线程能操作该变量,其他线程必须等待当前线程处理完成。这种方法使用互斥锁(Mutex Lock)实现互斥访问——当共享数据被当前线程加锁时,其他线程只能等待锁释放。
Java 中,用synchronized关键字修饰的方法或代码块可以保证同一时刻仅有一个线程执行。代码如下:
public class ResultErrorResolution {
staticint count;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
synchronized (ResultErrorResolution.class) {
for (int i = 0; i < 10000; i++) {
count++;
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
}
输出:
20000
输出结果与预期一致😊。
关于synchronized关键字,后续章节会详细讲解。目前只需知道它能保证同一时刻最多一个线程执行该代码段(需持有对应的锁,本例中为ResultErrorResolution.class),从而实现并发安全。
4.2 线程活跃性问题
第二类线程安全问题统称为活跃性问题。活跃性问题指程序无法获得运行的最终结果。相比前文的错误,活跃性问题的后果可能更严重,例如死锁会导致程序完全卡死。
典型的活跃性问题包括死锁(Deadlock)、活锁(Livelock)和饥饿(Starvation)。由于内容较多,后续会单独写篇文章介绍。
4.3 对象初始化时的安全问题
最后是对象初始化过程中引发的线程安全问题。创建对象以供其他类或对象使用是常见操作,但若时机或错误可能导致线程安全问题。
看一个例子:
public class InitError {
private Map<Long, String> students;
public InitError() {
new Thread(() -> {
students = new HashMap<>();
students.put(1L, "Tom");
students.put(2L, "Bob");
students.put(3L, "Victor");
}).start();
}
public Map<Long, String> getStudents() {
return students;
}
public static void main(String[] args) throws InterruptedException {
InitError initError = new InitError();
System.out.println(initError.getStudents().get(1L));
}
}
此例中,成员变量students在构造函数的子线程中初始化。但主线程在初始化InitError后未等待子线程完成,直接尝试获取数据,导致问题:
public static void main(String[] args) throws InterruptedException {
InitError initError = new InitError();
System.out.println(initError.getStudents().get(1L));
}
运行结果:
Exception in thread "main" java.lang.NullPointerException
at concurrency.chapter10.InitError.main(InitError.java:25)
原因:
students在构造函数的新线程中初始化,而主线程未等待该线程完成就直接调用getStudents(),此时students可能尚未初始化(返回null),导致空指针异常。
5. 哪些场景需特别注意线程安全问题?
5.1 访问共享变量或资源
当访问静态变量、共享缓存等共享资源时,若多线程同时操作(如count++),需确保原子性。例如以下“检查后执行”操作可能被中断:
if (count == 10) {
count = count * 10;
}
多个线程可能同时满足count == 10,导致多次执行count = count * 10,需通过加锁保证原子性。
5.2 数据间存在绑定关系
当不同数据成组出现且需保持对应关系时(如 IP 和端口号),若修改未绑定为一个原子操作,可能导致信息不一致。例如仅修改 IP 而未同步修改端口号,接收方可能获取错误的绑定结果。
5.3 依赖的类未声明线程安全
若使用的类未声明自身是线程安全的(如ArrayList),在多线程并发操作时可能引发线程安全问题。责任不在该类本身,因其未做任何线程安全保证(源码注释中通常会说明)。