【并发编程系列】
❤️并发编程❤️创建线程的四种方式 线程通信
❤️并发编程❤️一万字线程生命周期和状态转换知识梳理
❤️并发编程❤️Java内存模型
❤️并发编程❤️重排序与happens-before
❤️并发编程❤️显式锁Lock和内置锁知识整理
❤️并发编程❤️如何正确停止线程?
❤️并发编程❤️生产者消费者模式实现的三种方式
❤️并发编程❤️线程安全问题分析和场景总结
文章目录
(一)线程安全的定义
要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。
“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。我们将像讨论代码那样来讨论线程安全性,但更侧重于如何防止在数据上发生不受控的并发访问。
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
《Java并发编程实战》关于线程安全性的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。
(二)线程安全问题举例
(1)多线程操作共享变量
/***
* 多线程访问共享变量
* @author ZhangYu
* @date 2021/10/3
*/
public class Example1 {
//定义一个共享的变量
private static int count;
static class MyThread implements Runnable{
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count++;
}
}
}
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
Thread thread1 = new Thread(myThread);
Thread thread2 = new Thread(myThread);
thread1.start();
thread2.start();
//避免主线程先执行结束
thread1.join();
thread2.join();
System.out.println(count);
}
}
理论上得到的结果应该是 2000,但实际结果却远小于理论结果,这是因为多线程访问共享变量的时候,在进行变量操作的时候会出现获取的还是未更新的值,导致最终的结果是小于预期值。
在多线程下,CPU 的调度是以时间片为单位进行分配的,每个线程都可以得到一定量的时间片。但如果线程拥有的时间片耗尽,它将会被暂停执行并让出 CPU 资源给其他线程,这样就有可能发生线程安全问题。比如 count++ 操作,表面上看只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。count++的操作被拆分为:读取、增加、保存三个步骤,在多线程环境中这三个步骤都有可能出现同时操作的情况。
比如线程A获取变量的值为10,而此时线程B也是执行读取操作那么获取的也是10,之后线程A执行了增加操作,此时变量变成了11,但是线程B还是基于10进行操作,那么线程A和线程B在执行后变量就变成了11,这是一类非常常见的共享变量的线程不安全问题
(2)多线程操作volatile 变量
继续使用上面的代码示例,在有些时候我们以为使用了volatile就可以保证多线程操作共享变量的安全性,但是仅仅使用volatile是无法保证安全的。安全性包含了三个主要的原则: 原子性 可见性 有序性。而volatile的特性是保证变量的内存可见性以及禁止volatile变量与普通变量重排序。但是volatile无法保证变量操作的原子性。所以上面的代码如果使用了volatile修饰也是会出现同样的线程不安全问题。
(3)发布和初始化导致线程安全问题
对象发布和初始化时如果使用不当也会导致线程安全问题,我们创建对象并进行发布和初始化供其他类或对象使用是常见的操作,但如果我们操作的时间或方式不对,就可能导致线程安全问题。
/***
* 发布和初始化导致线程安全问题
* @author ZhangYu
* @date 2021/10/3
*/
public class Example2 {
private Map<String, String> map;
public Map<String, String> getMap() {
return map;
}
/**
* 启动子线程初始化对象内部容器
*/
public Example2() {
new Thread(()->{
map=new HashMap<>();
map.put("1","Alice");
map.put("2","Bob");
});
}
public static void main(String[] args) {
Example2 example2 = new Example2();
System.out.println(example2.getMap().get("1"));
}
}
上面看似正常的程序,在启动执行后抛出了NullPointerException,出现上述的问题是因为该对象内部变量map在构造函数中完成初始化,并且是通过开启一个子线程异步处理的,而这个子线程的启动加载是需要时间的,这就导致了main主线程还没有等待子线程初始化完毕就执行了map.get()操作,而此时容器内还是空的,所以就导致了空指针异常问题
(4)死锁问题
/***
*
* 死锁问题
* @author ZhangYu
* @date 2021/10/3
*/
public class DeadLockDemo {
/** 锁A **/
private final static Object LOCKA =new Object();
/** 锁B **/
private final static Object LOCKB =new Object();
static class ThreadA implements Runnable{
@Override
public void run() {
synchronized (LOCKA){
System.out.println(Thread.currentThread().getId()+"==>获取资源A");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (LOCKB){
System.out.println(Thread.currentThread().getId()+"==>获取资源B");
}
}
}
}
static class ThreadB implements Runnable{
@Override
public void run() {
synchronized (LOCKB){
System.out.println(Thread.currentThread().getId()+"==>获取资源B");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (LOCKA){
System.out.println(Thread.currentThread().getId()+"==>获取资源A");
}
}
}
}
public static void main(String[] args) {
new Thread(new ThreadA()).start();
new Thread(new ThreadB()).start();
}
}
上述代码运行后会出现控制台所示情况,俩个线程分别持有一个临界资源锁且又在争夺对方的锁资源,但是锁资源又无法释放,就造成了死锁情况,就这样死死地等待对方先释放资源,导致程序得不到任何结果也不能停止运行。
一旦出现死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看 到底是哪个线程出现了问题
避免死锁的几种办法
1:避免一个线程同时获取多个锁
2:避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
3:尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
4:对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
(5)活锁问题
线程活跃性问题总共有三种:死锁、活锁、饥饿。活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。
活锁是多个线程类似死锁的情况下,同时释放掉自己已经获取的资源,然后同时获取另外一种资源,又形成依赖循环,导致都不能执行下去。同时放弃,然后又重试竞争,最后死循环在里面了
【活锁的解决方案】
解决办法,设置等待随机的时间,例如Raft算法中重新选举leader
(6)饥饿问题
第三个典型的活跃性问题是饥饿,所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
在 Java 中有线程优先级的概念,Java 中优先级分为 1 到 10,1 最低,10 最高。如果我们把某个线程的优先级设置为 1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到 CPU 资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
解决“饥饿”问题有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
(7)时序问题
if (map.containsKey(key)) {
map.remove(obj)
}
上面的代码非常的常见,首先去判断当前Key是否存在于容器中,如果存在则执行移除操作。但是上面的示例和第一种操作共享变量问题一样,整体的步骤他不是一个原子性的操作。
若此时容器内剩余一个元素,线程A先map.containsKey(key)存于元素,但是在线程A执行map.remove之前,此时CPU时间片分配给了线程B,线程B执行map.containsKey(key)也是满足条件的。那么此时不管是哪个线程先执行了 map.remove(obj)操作,另一个线程也会再次执行这个操作,并且会抛出异常,元素不存在。
类似的问题不仅仅在于容器的存在检查后操作,一般的先通过条件判断然后执行相关操作的流程都可能会导致线程安全问题。
(8)使用不安全的容器或对象
我们在日常的代码编写过程中,经常会使用到ArrayList/LinkedList,当我们使用list.add()、list.remove等操作的时候,要注意这些操作都不是线程安全的,在多线程环境下都有可能出现问题,针对多线程环境需要选择合理的并发容器