目录
导言:
线程安全是并发编程中的一个重要概念,它指的是在多线程环境下,对共享数据的访问和修改不会导致数据的不一致或其他不可预料的结果。在Java中,线程安全问题通常涉及到共享变量的访问和修改,以及多线程间的同步和协作。
正文:
1.共享资源:
多线程程序中,多个线程可能同时访问并修改共享的数据结构、对象或变量。如果没有适当的同步机制,就会导致数据竞争问题。许多操作,如自增(++
)、自减(--
)、赋值等,虽然看起来是简单的操作,实际上在底层可能包含多个步骤(如读取值、修改值、写回值)。如果这些步骤在执行过程中被其他线程中断,就可能导致最终的值不符合预期。
代码实例:
public class test {
private static int count;
public static void main(String[] args) throws InterruptedException {
//创建线程t1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++)
count++;
});
//创建线程t2
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++)
count++;
});
//启动两个线程
t1.start();
t2.start();
//保证两个线程能运行完
t1.join();
t2.join();
//预期结果:10w
System.out.println("count = " + count);
}
}
这段代码的目的是让两个线程t1
和t2
各自对静态变量count
进行50000次自增操作,预期的最终结果是count
的值变为100000。然而,这段代码存在线程安全问题,导致最终输出的count
值每次都不一样。
问题的根源在于count++
操作不是原子的。这个操作实际上包含了三个独立的步骤:
- 读取
count
当前的值。 - 增加该值。
- 将新值写回
count
。
当多个线程并发执行count++
操作时,可能会出现以下情况:
- 线程A读取了
count
的值(假设为0)。 - 线程B也读取了
count
的值(同样为0)。 - 线程A增加其
count
的值到1,并写回内存。 - 线程B增加其
count
的值到1,并写回内存。
在这种情况下,尽管两个线程都执行了count++
操作,但count
的最终值只增加了1,而不是2。这是因为两个线程可能读取到了相同的初始值,并且在增加和写回值的过程中没有适当的同步。
解决方法:
使用synchronized关键字,synchronized
关键字是 Java 中用于处理并发问题的同步机制之一。它可以确保同一时间只有一个线程能够访问被 synchronized
修饰的代码块或方法,从而解决多线程并发访问共享资源时的线程安全问题。
public class Test {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 创建线程t1
Thread t1 = new Thread(() -> {
synchronized (countLock) {
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
// 创建线程t2
Thread t2 = new Thread(() -> {
synchronized (countLock) {
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
// countLock 是一个用来同步的静态对象
private static final Object countLock = new Object();
// 启动两个线程
t1.start();
t2.start();
// 保证两个线程能运行完
t1.join();
t2.join();
// 预期结果:10w
System.out.println("count = " + count);
}
}
在这个修改后的代码中,我们引入了一个静态对象 countLock
作为同步锁。两个线程在修改 count
变量时都会尝试获取这个锁对象的锁。当一个线程持有锁时,其他线程必须等待直到锁被释放。这样就保证了 count++
的正确性。
2.非原子操作:
非原子性操作指的是那些在执行过程中可以被其他线程中断的操作。在多线程环境中,非原子性操作可能导致竞态条件、数据不一致和其他线程安全问题。某些操作不是原子性的,即不能一次性完成所有操作。在多线程环境下,一个操作可能被多个线程交错执行,导致意外结果。
一条 java 语句不一定是原子的,也不一定只是一条指令
public class RaceConditionExample {
private int sharedState = 0;
public void increment() {
sharedState++; // 非原子性操作
}
}
在上面的例子中,increment
方法看起来是简单的自增操作,但实际上它包含三个独立的步骤:读取 sharedState
的值、增加值、写回新的值。如果有多个线程并发调用 increment
方法,sharedState
的值可能不会按预期递增。
解决办法同样是使用锁
public class SynchronizedSolution {
private final Object lock = new Object();
private int sharedState = 0;
public void increment() {
synchronized (lock) {
sharedState++;
}
}
}
非原子性操作是多线程编程中常见的线程安全问题来源。解决这类问题的关键是通过使用 synchronized
关键字。
3.执行顺序不确定:
多线程程序的执行顺序是不确定的,线程的调度是由操作系统和JVM控制的。线程的调度是随机的,这是线程安全问题的罪魁祸首。由于线程调度的随机性,即使是相同的程序在不同的执行环境下,或者在同一环境下不同的运行次数,都可能产生不同的结果。如果多个线程对共享资源的访问顺序不一致,就会产生不确定的结果。
解决这个问题的关键在于使用适当的同步机制来控制线程的执行顺序和访问共享资源的方式。通过使用 synchronized
关键字、原子类、并发集合类和其他并发工具,可以有效地避免由于执行顺序不确定性导致的线程问题。开发者应该在设计和实现多线程程序时充分考虑这些潜在问题,并采取适当的同步策略来确保程序的正确性和性能。
4.可见性:
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到。现代计算机系统中,每个CPU核心都有自己的缓存,这可能导致不同核心之间的数据不一致。当一个线程修改了共享变量的值,其他线程可能无法立即看到这个修改。下面进行更详细的说明:
先给出一幅图:
1.线程之间的共享变量存在 主内存 (Main Memory)。
2.每一个线程都有自己的 "工作内存" (Working Memory) 。
3.当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据。
4.当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存
由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本"。此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。此时代码就会出现问题。同样使用锁即可解决这种问题。
5.死锁和饥饿:
死锁是指多个线程或进程因争夺资源而造成的一种僵局,每个线程都在等待其他线程释放资源,导致所有线程都无法继续执行。死锁通常包含四个必要条件:互斥条件、请求与保持条件、不剥夺条件和循环等待条件。
饥饿是指一个或多个线程由于某种原因无法获取所需的资源,导致无法继续执行的情况。造成饥饿的原因可能包括优先级反转、资源竞争等。
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1 acquired lock1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1 acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2 acquired lock2");
synchronized (lock1) {
System.out.println("Thread 2 acquired lock1");
}
}
});
thread1.start();
thread2.start();
}
}
在上面的代码中,我们创建了两个线程thread1
和thread2
,分别尝试获取lock1
和lock2
,但它们的获取顺序不同,导致了死锁的发生。每个线程获取了一个锁,同时申请另一个锁导致两个进程永无止境的等待下去。
解决死锁问题的方法包括:
- 预防死锁:设计良好的资源分配策略,破坏死锁的四个必要条件。
- 避免死锁:通过安全序列算法等方法在运行时避免发生死锁。
- 检测和恢复:通过检测死锁的发生,采取相应的措施打破死锁。
以下是对上述死锁问题的代码进行修改,通过调整获取锁的顺序来避免死锁的发生:
public class DeadlockSolution {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1 acquired lock1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1 acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 2 acquired lock1");
synchronized (lock2) {
System.out.println("Thread 2 acquired lock2");
}
}
});
thread1.start();
thread2.start();
}
}
解决饥饿问题的方法包括:
- 公平性:设计公平的资源分配策略,确保每个线程都有机会获取资源。
- 优先级调度:通过优先级调度算法确保高优先级的线程能够及时获得所需的资源。
- 资源复用:尽量减少资源的持有时间,避免资源长时间被占用而导致其他线程饥饿。
对于饥饿问题,可以通过设置线程的优先级或使用公平的锁来解决。以下是一个简单的代码,
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class StarvationSolution {
private static final Lock fairLock = new ReentrantLock(true); // 使用公平锁
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
fairLock.lock();
System.out.println(Thread.currentThread().getName() + " acquired the fair lock");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
fairLock.unlock();
}
}).start();
}
}
}
在上述代码中,我们使用ReentrantLock
来创建一个公平锁,并在创建线程时指定使用公平锁。这样可以保证等待时间最长的线程会最先获取到锁,避免了饥饿问题的发生。
6.指令重排序:
指令重排序是现代处理器为了提高性能而采取的一种优化手段,它可以改变程序中指令的执行顺序,但不会改变程序的最终结果。然而,指令重排序可能会导致多线程程序出现一些意想不到的问题,如内存可见性问题、数据竞争等。
代码实例:
import java.util.Scanner;
public class test {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
在这个示例中,t1
线程中不断地检查counter.flag
是否为0,而t2
线程负责从标准输入中读取一个整数并赋值给counter.flag
。预期当用户输入非 0 的值的时候, t1 线程结束。实际上当用户输入非0值时, t1 线程循环不会结束 。
JVM可能会对指令进行重排序,导致t2
线程中的赋值操作在t1
线程看来发生在读取操作之前,从而t1
线程永远无法看到t2
线程修改的counter.flag
值。
为了解决这个问题,可以通过以下方式进行修复:
- 使用
volatile
关键字修饰Counter
类中的flag
变量,确保线程之间的内存可见性。 - 使用
synchronized
关键字或Lock
来保护共享变量的读写操作,确保线程安全。 - 使用
wait()
和notify()
等方法实现线程间的通信,避免忙等待的方式。
修复后的代码示例:
import java.util.Scanner;
public class test {
static class Counter {
public volatile int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
通过将flag
变量设置为volatile
,可以确保线程之间对flag
变量的可见性,避免出现数据不一致的情况。
需要注意的是
指令重排序并不是一定会发生的。指令重排序是编译器或处理器为了提高程序执行性能而采取的一种优化手段,它可以在不影响单线程程序正确性的前提下,对指令的执行顺序进行调整。然而,这种优化并不是在所有情况下都会发生,而是根据具体的程序代码、编译器实现以及处理器特性来决定的。
编译器或处理器在进行指令重排序时,会遵循一定的规则和限制,以确保程序的正确性不受影响。例如,存在数据依赖关系的指令通常不会被重排序,因为这样做可能会改变程序的执行结果。此外,即使在允许重排序的情况下,编译器或处理器也可能会根据当前的执行环境和优化策略,选择不进行重排序。
总结:
线程安全问题是指在多线程环境下,当多个线程同时访问共享资源时可能导致的数据不一致、竞态条件、死锁等问题。为了解决线程安全问题,可以使用同步机制(如synchronized
关键字、ReentrantLock
等)来保护共享资源的访问,或者使用volatile
关键字来确保共享变量的可见性。通过合理的设计和编码,可以有效地避免线程安全问题,确保多线程程序的正确性和稳定性。