一、线程安全
程序在多线程环境下运行的结果100%符合我们的预期,即结果与其在单线程环境下运行的结果相同,我们就认为这个程序是线程安全的。
二、引发线程安全问题的原因(接下来介绍线程安全问题使用的代码为Java代码)
线程的抢占式执行。由于操作系统中,线程的调度是完全随机的,所以多个线程同时执行时,执行的顺序就是不确定的,可能就会出现问题,这个问题是操作系统引起的,没有太好的解决办法。
多个线程针对同一个变量进行修改操作,或者多个线程对同一个变量的操作不是原子性的。原子性的操作指的是在CPU上执行时,只需要一条指令就能执行完的操作。我们以修改操作为例,修改操作不是一个原子性的操作,它在CPU上一般是由三条指令:读取、修改、存入,来完成的,先读取内存中的数据到CPU寄存器上,再在寄存器上修改这个数据,最后存入到内存中。如count++这个操作,如果两个线程同时执行,就有可能出现两个线程同时读取count的值,然后同时修改,再同时存入,这就相当于count只自增了一次。类似的还可能出现一个线程在另一个线程修改之后、存入之前读取count值等等。由此可以发现,如果针对某个变量的操作不是原子性的,就会发生类似的线程安全问题。 要解决这个问题,我们可以优化代码结构,减少不同线程对同一变量的修改操作, 还可以通过synchronized 关键字 进行加锁,来保证线程安全。synchronized加锁前自增操作代码运行如下。由于是调度是完全随机的,所以这个代码多次运行得出的结果一般都是不同的。
static int c = 0;
public static void main(String[] args) throws InterruptedException {
// 创建两个线程同时对静态变量c 进行50000次自增。
Thread t1 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
c++;
}
});
Thread t2 = new Thread(()-> {
for (int i = 0; i < 50000; i++) {
c++;
}
});
t1.start();
t2.start();
// 控制main线程等待t1 和t2 线程执行完。
t1.join();
t2.join();
System.out.println(c);
}
}
synchronized加锁后
static int c = 0;
public static void main(String[] args) throws InterruptedException {
// 创建锁对象
Object locker = new Object();
// 创建两个线程同时对静态变量c 进行50000次自增。
Thread t1 = new Thread(()-> {
// 针对锁对象locker 加锁。
synchronized (locker) {
for (int i = 0; i < 50000; i++) {
c++;
}
}
});
Thread t2 = new Thread(()-> {
synchronized (locker) {
for (int i = 0; i < 50000; i++) {
c++;
}
}
});
t1.start();
t2.start();
// 控制main线程等待t1 和t2 线程执行完。
t1.join();
t2.join();
System.out.println(c);
}
}
内存可见性问题。这个问题是由于编译器的优化导致的。当线程A高频率的读取内存中的同一个数据,并且每次读取到的数据都是一样的,编译器就会将读内存操作优化成 读寄存器操作,因为读取寄存器中的数据要比读取内存中的数据快很多,这时,如果线程B修改了这个数据,线程A由于是在寄存器中读取这个数据,就会识别不到内存中的数据修改,那么这个修改对于线程A来说就是无效的。 我们可以通过volatile 关键字 修饰需要反复读取的变量, 来保证 编译器不会对读取这个变量的操作进行优化,除此之外也可以使用synchronized加锁。但需要注意,volatile是真正解决内存可见性问题,而synchronized如果给读取操作加锁,避免内存可见性问题,本质上是因为synchronized加锁后,减慢了循环的速度,所以没有触发编译器优化,如果synchronized加在循环外面,就不能解决内存可见性问题。
volatile 修饰变量c 之前, 由于线程1 读取c 的修改失败, 导致程序死循环,线程无法结束。
static int c = 0;
public static void main(String[] args) throws InterruptedException {
// 创建锁对象
Object locker = new Object();
// 创建两个线程,线程1循环读取变量c, 线程2 通过对变量c 进行修改结束线程1。
Thread t1 = new Thread(()-> {
while(c == 0) {
}
System.out.println("线程结束");
});
Thread t2 = new Thread(()-> {
try {
// 先让线程2 休眠等待3秒,使线程1 反复多次读取c 引出编译器优化。
Thread.sleep(3000);
// 休眠完成后修改c 的值, 中断线程1 ,如果成功中断,则线程1 打印“线程结束”。
c = 1;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();
// 控制main线程等待t1 和t2 线程执行完。
t1.join();
t2.join();
}
volatile 修饰变量c 之后, 线程1 成功读取到c的修改,循环结束, 线程1 成功执行打印操作。
static volatile int c = 0;
public static void main(String[] args) throws InterruptedException {
// 创建锁对象
Object locker = new Object();
// 创建两个线程,线程1循环读取变量c, 线程2 通过对变量c 进行修改结束线程1。
Thread t1 = new Thread(()-> {
while(c == 0) {
}
System.out.println("线程结束");
});
Thread t2 = new Thread(()-> {
try {
// 先让线程2 休眠等待3秒,使线程1 反复多次读取c 引出编译器优化。
Thread.sleep(3000);
// 休眠完成后修改c 的值, 中断线程1 ,如果成功中断,则线程1 打印“线程结束”。
c = 1;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
t2.start();
// 控制main线程等待t1 和t2 线程执行完。
t1.join();
t2.join();
}
指令重排序问题。这个问题本质也是编译器优化造成的。当我们的部分代码执行顺序的先后不会对执行结果造成影响,编译器就会在不影响代码逻辑的情况下,对代码执行顺序进行优化,以提高代码运行效率。以我们生活中的购物为例。
可以看出紫色和黑色的路径执行的结果都是买到了四种物品,但是黑色的路径明显效率要更高。编译器的指令重排序优化就类似于这个例子。在单线程环境中,编译器对指令是否可以重排序以及是否需要重排序基本是不会误判的,但在多线程环境下,就有可能会出现误判,导致出现线程安全问题。这种问题我们同样可以通过volatile关键字解决。
三、以上就是对线程安全、线程安全问题以及问题出现的原因和基本解决办法的简单介绍,希望能对你有所帮助。