一、什么线程安全?
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获取正确的结果,那这个对象便是线程安全的。
如果某个对象是线程安全的,那么对于使用者而言,在使用时就不需要考虑方法间的协调问题,比如不需要考虑不能同时写入或者读写不能并行的问题,也不需要考虑任何额外的同步问题。
1.1、三种典型的线程安全问题
-
运行结果错误
package com.strivelearn.concurrent.chapter03; import lombok.SneakyThrows; /** * @author strivelearn * @version ThreadSafeMain.java, 2022年12月24日 */ public class ThreadSafeMain { private static int count = 0; @SneakyThrows public static void main(String[] args) { Runnable calc = () -> { for (int i = 0; i < 10000; i++) { count++; } }; Thread t1 = new Thread(calc); t1.start(); Thread t2 = new Thread(calc); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
这段代码就是线程安全问题,引起来的运行结果不对。count++操作,从代码角度来看是一行代码,但实际上并不是一个原子操作,它的执行步骤主要分为散步,而且每步操作之间都可能被打断
- 读取count初始值
- 对count进行增加1
- 对count的结果进行保存
-
发布和初始化导致线程安全问题
package com.strivelearn.concurrent.chapter03; import java.util.HashMap; import java.util.Map; /** * @author strivelearn * @version WrongInit.java, 2022年12月24日 */ public class WrongInitMain { private Map<Integer, String> studentList; // 在构造函数初始化的时候,启动一个新的线程进行对 studentList 进行赋值操作 public WrongInitMain() { new Thread(() -> { studentList = new HashMap<>(); for (int i = 0; i < 10; i++) { studentList.put(i, "Student-" + i); } }).start(); } public static void main(String[] args) { WrongInitMain wrongInitMain = new WrongInitMain(); System.out.println(wrongInitMain.studentList.get(0)); } }
Exception in thread "main" java.lang.NullPointerException at com.strivelearn.concurrent.chapter03.WrongInitMain.main(WrongInitMain.java:25)
运行此时会报NPE问题,因为在main方法里面,初始化WrongInitMain没有等新的线程对studentList初始化完毕,就是要了studentList的信息了。
-
活跃性问题
3种典型的活跃性问题:
-
死锁
两个线程互相等待对方抢到的锁,从而导致死锁
-
活锁
正在运行的线程并没有被阻塞,它始终处于运行中,却一直得不到结果。比如一个消息队列,队列里面放着各种需要被处理的消息,而某个消息本身是错误的,执行时的报错,可队列重试机制把它放在队列头进行优先重试处理,但是这个消息无论被执行多少次,都始终无法被正确处理,这样每次周而复始,从而产生活锁的问题
-
饥饿
线程需要某些资源时始终得不到,尤其是CPU资源,就会导致线程一直不能运行而产生的问题
-
在Java中,Java的优先级分为1到10,1是最低的,10是最高的,如果我们把某个线程的优先级设置为1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到CPU资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
二、哪些场景需要额外注意线程安全?
2.1、访问共享变量或者共享资源时
典型的场景有访问共享对象的属性,访问static静态变量,访问共享缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题
package com.strivelearn.concurrent.threadsafe;
/**
* 访问共享变量或者共享资源
* @author strivelearn
* @version SharedVariable.java
*/
public class SharedVariable {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
for (int j = 0; j < 10000; j++) {
i++;
}
};
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
上面的代码,运行不同的次数,返回的结果都小于20000
2.2、依赖时序的操作(主要是多个步骤而非原子操作)
if(map.containsKey(key)){
map.remove(value);
}
- 先检查map中有没有key对应的元素
- 如果有则继续执行remove操作,这个组合操作是很危险的,因为它是先检查后操作,而非原子操作。
- 此时2个线程同时进入if语句,都满足存在key对应的元素,于是都执行了remove操作,此时代码就存在npe问题
2.3、不同数据之间存在相互绑定关系的情况
比如不同数据之间是成组出现的,存在着相互对应或者绑定的关系,最典型的就是IP和端口号。比如在更换了ip,往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就可能出现错误的ip与端口的绑定关系。
2.4、在我们使用其他类时,对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题
比如定义了ArrayList,因为该类本身并不是线程安全的,如果此时多个线程同时对ArrayList进行并发读写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在于ArrayList,因为它本身并不保证线程安全
三、为什么多线程会带来性能问题?
3.1、什么是性能问题?
让多个线程同时工作,加快程序运行速度,为什么反而会带来性能问题呢?
因为单线程程序是独立运行的,不需要与其他线程进行交互。但是多线程之间则需要调度以及合作,调度与合作就会带来性能开销从而产生性能问题
3.2、性能问题的表现形式
- 服务器的响应慢
- 吞吐量低
- 内存占用过多
- ...
研究表明:
页面每多响应1秒,就会流失至少7%的用户,而超过8s无法返回结果的话,几乎所有用户都不会选择继续等待
3.3、什么情况下多线程编程会带来线程性能问题?
-
线程调度
-
上下文切换
在实际开发中,线程数往往是大于CPU核心数的。比如CPU核心数可能是8核,16核。。。,但是一个应用的线程数可能达到成百上千,这种情况下,操作系统就按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行
-
缓存失效:
不仅上下文切换会来性能问题,缓存失效也有可能带来性能问题
由于程序由很大的概率会再次访问刚才的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快的获取数据,可一旦进行了线程调度,切换到其他的线程,CPU就会去执行不同的代码,原有的缓存就很有可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁的发生上下文切换,通常会给被调度的线程设置最小的执行,也就是只有执行完这段时间,才可能进行下一次调度,由此减少上下文切换的次数
-
什么情况下会导致密集的上下文切换?
如果程序频繁的竞争锁,或者由于IO读写等原因导致频繁阻塞,那么这个程序就可能需要更多的上下文切换,这也就导致了更大的开销,我们应该尽量避免这种情况的发生
-
-
线程协作
-
线程之间共享数据
因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和CPU对其进行重排序等优化,也可能处于同步的目的,反复把线程工作内存的数据flush到主存中,然后再从主内存refresh到其他线程的工作内存中。等情况
-
这些问题在单线程中并不存在,但是在多线程中为了保证数据的正确性,就不得不采取上述方法因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能