1、 什么是线程安全?
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要额外的同步,或者在调用方法进行任何的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象时线程安全的。
简单来说就是在业务中遇到多线程处理业务逻辑的时候,都不需要做任何额外的处理,程序即可正常运行,就可以称为线程安全。
2、一共有多少种线程安全问题
- 运行结果错误;
- 发布和初始化导致线程安全问题;
- 活跃性问题;
2.1、运行结果错误
i++ 错误问题
package com.imooc.thread_demo.threadsafe;
import java.util.stream.IntStream;
/**
* @Author: zhangjx
* @Date: 2020/9/13 16:17
* @Description: 线程安全问题:1、 运行结果错误
*/
public class MultiThreadError implements Runnable{
public static void main(String[] args) throws InterruptedException {
MultiThreadError multiThreadError = new MultiThreadError();
Thread threadA = new Thread(multiThreadError);
Thread threadB = new Thread(multiThreadError);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(multiThreadError.index);
}
int index = 0;
@Override
public void run() {
IntStream.range(0,1000).forEach(e -> index ++);
}
}
判断i++ 哪次发生错误
package com.imooc.thread_demo.threadsafe;
import java.util.Optional;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: zhangjx
* @Date: 2020/9/13 16:17
* @Description: 线程安全问题:1、 运行结果错误
*/
public class MultiThreadError implements Runnable{
Boolean[] addRecord = new Boolean[300000];
static AtomicInteger realIndex = new AtomicInteger(0);
static AtomicInteger errorIndex = new AtomicInteger(0);
static volatile CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
static volatile CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);
public static void main(String[] args) throws InterruptedException {
MultiThreadError multiThreadError = new MultiThreadError();
Thread threadA = new Thread(multiThreadError);
Thread threadB = new Thread(multiThreadError);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(multiThreadError.index);
System.out.println("运行次数:" + realIndex.get());
System.out.println("错误运行次数:" + errorIndex.get());
}
int index = 0;
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
try {
cyclicBarrier2.reset();
cyclicBarrier1.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
index ++;
try {
cyclicBarrier1.reset();
cyclicBarrier2.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
realIndex.incrementAndGet();
synchronized (this){
if(Optional.ofNullable(addRecord[index]).orElse(false) &&
Optional.ofNullable(addRecord[index - 1]).orElse(false)){
System.out.println(index + " 发生错误");
errorIndex.incrementAndGet();
}
addRecord[index] = true;
}
}
}
}
2.2、活跃性问题
(1)死锁
package com.imooc.thread_demo.threadsafe;
import lombok.Data;
/**
* @Author: zhangjx
* @Date: 2020/9/13 19:06
* @Description: 线程安全问题:2、 演示死锁
*/
public class DeadLockThreadError implements Runnable{
int flag = 0;
static Object lock1 = new Object();
static Object lock2 = new Object();
public static void main(String[] args) {
DeadLockThreadError r1 = new DeadLockThreadError();
DeadLockThreadError r2 = new DeadLockThreadError();
r1.setFlag(0);
r2.setFlag(1);
new Thread(r1).start();
new Thread(r2).start();
}
@Override
public void run() {
if(flag == 0){
synchronized (lock1){
System.out.println("lock1获取");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2){
System.out.println("lock1中lock2获取");
System.out.println("线程0");
}
System.out.println("lock1中lock2释放");
}
System.out.println("lock1释放");
}
if(flag == 1){
synchronized (lock2){
System.out.println("lock2获取");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1){
System.out.println("lock2中lock1获取");
System.out.println("线程1");
}
System.out.println("lock2中lock1释放");
}
System.out.println("lock2释放");
}
}
public void setFlag(int flag) {
this.flag = flag;
}
}
(2) 活锁
第二种活跃性问题是活锁,活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
举一个例子,假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,但程序始终得不到结果,便发生了活锁问题。
(3) 饥饿
第三个典型的活跃性问题是饥饿,饥饿是指线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。在 Java 中有线程优先级的概念,Java 中优先级分为 1 到 10,1 最低,10 最高。如果我们把某个线程的优先级设置为 1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到 CPU 资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
2.3、发布和初始化导致线程安全问题
第二种是对象发布和初始化时导致的线程安全问题,我们创建对象并进行发布和初始化供其他类或对象使用是常见的操作,但如果我们操作的时间或地点不对,就可能导致线程安全问题。如代码所示。
public class WrongInit {
private Map<Integer, String> students;
public WrongInit() {
new Thread(new Runnable() {
@Override
public void run() {
students = new HashMap<>();
students.put(1, "王小美");
students.put(2, "钱二宝");
students.put(3, "周三");
students.put(4, "赵四");
}
}).start();
}
public Map<Integer, String> getStudents() {
return students;
}
public static void main(String[] args) throws InterruptedException {
WrongInit multiThreadsError6 = new WrongInit();
System.out.println(multiThreadsError6.getStudents().get(1));
}
}
在类中,定义一个类型为 Map 的成员变量 students,Integer 是学号,String 是姓名。然后在构造函数中启动一个新线程,并在线程中为 students 赋值。
- 学号:1,姓名:王小美;
- 学号:2,姓名:钱二宝;
- 学号:3,姓名:周三;
- 学号:4,姓名:赵四。
只有当线程运行完 run() 方法中的全部赋值操作后,4 名同学的全部信息才算是初始化完毕,可是我们看在主函数 mian() 中,初始化 WrongInit 类之后并没有进行任何休息就直接打印 1 号同学的信息,试想这个时候程序会出现什么情况?实际上会发生空指针异常。
复制
Exception in thread “main” java.lang.NullPointerException
at lesson6.WrongInit.main(WrongInit.java:32)
这又是为什么呢?因为 students 这个成员变量是在构造函数中新建的线程中进行的初始化和赋值操作,而线程的启动需要一定的时间,但是我们的 main 函数并没有进行等待就直接获取数据,导致 getStudents 获取的结果为 null,这就是在错误的时间或地点发布或初始化造成的线程安全问题。
3 、哪些常见需要额外注意线程安全问题
- 访问共享的变量或资源,会有并发风险,比如对象的属性、静态变量、共享缓存、数据库等
- 所有依赖时序的操作,即使每一步操作都是线程安全的,还是存在并发问题
- 不同的数据之间存在捆绑关系的时候
- 我们使用其他类的时候,如果对方没有声明自己是线程安全的,那么大概率会存在并发问题
4 、为什么多线程会带来性能问题?
(1) 调度开销
上下文切换
首先,我们看一下线程调度,在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。
缓存失效
不仅上下文切换会带来性能问题,缓存失效也有可能带来性能问题。由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。
那么什么情况会导致密集的上下文切换呢?如果程序频繁地竞争锁,或者由于 IO 读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生。
(2) 协作开销
除了线程调度之外,线程协作同样也有可能带来性能问题。因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。