🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍
文章目录
1.0 多线程安全问题概述
1.1 线程不安全的实际例子
2.0 出现线程不安全的原因
2.1 线程在系统中是随机调度且抢占式执行的模式
2.2 多个线程同时修改同一个变量
2.3 线程对变量的修改操作不是“原子”
2.4 内存可见性
2.5 指令重排序
3.0 解决线程不安全问题(使用锁机制)
3.1 synchronized 关键字可以作用的地方
3.1.1 同步代码块
3.1.2 同步实例方法
3.1.3 同步静态方法
3.2 join() 方法与 synchronized 关键字的区别
4.0 加锁不合理所引发的问题
4.1 对于一个加锁与一个没有加锁的两个线程随机调度执行同一个代码块或者方法的情况
4.2 嵌套相同的锁 - 可重入锁
4.3 两个线程两把锁 - 死锁
4.4 死锁的四个必要条件
4.4.1 互斥条件
4.4.2 不可剥夺条件
4.4.3 请求保持条件
4.4.4 循环等待条件
4.5 如何避免死锁?
1.0 多线程安全问题概述
多线程安全问题是指在多线程环境下,多个线程同时访问共享资源可能导致的数据不一致、数据竞争、死锁等问题。
1.1 线程不安全的实际例子
在多线程中,很容易就会出现多线程问题,从而引发多线程安全问题。
先看以下代码:
public static long count = 0; public static void main(String[] args) throws InterruptedException { Object o = new Object(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count++; } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }
一般来说,t1 和 t2 都对 count 这个变量进行 count++ 这个操作,那么 count 最后的输出结果按理来说应该为 10万。但是输出的结果是不一定为 10 万,而且等于 10 万的概率非常非常非常小。
运行结果:
每次的运行结果都是不一样的,很大概率都是在 5 万到 10 万之间“徘徊”。也有可能会小于 5 万。
出现了以上的结果,都是因为多线程抢占式执行随机调度,从而导致的结果。
具体分析:
在 CPU 中执行 count++ 这一操作,大致需要执行三条指令:
1)load:将内存中的数据读取加载到 CPU 的寄存器中。
2)add:在寄存器中的值 +1 操作。
3)save:把寄存器中的值写回到内存中。
现在 t1 与 t2 两个线程并发的进行 count++ ,多线程的执行是随机调度,抢占式的执行模式。有可能会出现以下几种情况:
出现可能 1 或者可能 2 这两次 count++ 的最终结果都是 2 ,因此是正确的。而对于可能 3 这种情况,虽然说,t1 与 t2 都完成了 count++ 的操作,但是,对于可能 3 这种情况,在 t2 完成 count++ 之后,count 由 0 改为 1 ,再把值写回到内存之后,但是 t1 来说,同样也是把 count 由 0 改为 1 ,写回到内存中。简单来说,t1 将 t2 线程中 count 进行了一次覆盖,重新赋值,所以 t2 这个线程的操作是无效操作。
出现的情况有无数种,不可预计的。之所以说,最后出现的结果为 10 万的概率是非常非常小的,几乎没有可能吧。
对于出现小于 5 万的情况:
按理来说,进行了三次 count++ 操作,最后的结果应该为: count == 3,但是这里最后的结果:count == 1。这就是有可能出现 count 小于 5万的可能,出现数越加越小了。
以上就是属于多线程引发的线程安全问题。
2.0 出现线程不安全的原因
2.1 线程在系统中是随机调度且抢占式执行的模式
这是导致线程不安全的“罪魁祸首,万恶之源”,不能去改变这个机制。
2.2 多个线程同时修改同一个变量
当多个线程同时修改同一个变量时,可能会导致数据竞争和结果不确定性。这种情况下,需要采取线程安全的措施来确保数据的一致性。
2.3 线程对变量的修改操作不是“原子”
count++ 这种,不是原子操作,在 cpu 执行 count++ 操作需要三条指令。
2.4 内存可见性
2.5 指令重排序
3.0 解决线程不安全问题(使用锁机制)
锁机制可以确保在任意时刻只有一个线程可以访问共享资源,从而避免数据竞争和保证数据的一致性。
可以使用 synchronized 关键字等来实现锁机制。
代码如下:
public static long count = 0; public static void main(String[] args) throws InterruptedException { Object o = new Object(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { synchronized (o){ count++; } } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { synchronized (o){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }
通过 synchronized 关键字,把 count++ 这个操作进行了加锁,对于 o 对象可以理解为一个标志,一个锁的标识,当进入 {} 那一刻,就会加上锁,执行完代码块中的代码后,退出 {} 时,会自动解锁。
现在对于 t1 与 t2 线程来说,在每一次 for 循环之后,会抢占 “加锁” 随机调度,两个线程抢占的机会是一样的,比如说 t1 抢占到了“加锁”,那么 t2 想要再次对 count++ 加锁时,先会判断,判断当前加锁的线程是哪一个线程,如果不是自己线程,那么就会阻塞等待 t1 线程。等待 t1 执行完毕之后,t1 与 t2 会继续抢占对 count++ 这个操作进行“加锁”处理,一直循环往复。
这样就保证了 CPU 在执行 3 条指令的时候,不会被其他线程“打扰到”。每一次都是如
此:
最后的运行结果:
此时多线程问题是安全的。
补充:
1)锁本质上也是操作系统提供的功能,内核提供的功能,通过 API 给应用程序 JVM 对于这样的系统 API 又进行封装。
2)锁对象的用途,有且只有一个,就是用来区分,判断两个线程是否是针对同一个对象加锁,如果是,就会出现锁竞争/锁冲突/互斥,就会引起阻塞等待;如果不是,就不会出现锁竞争,也就不会阻塞等待。
和对象的具体是什么类型,和它里面的属性、方法,对于接下来操作这个对象统统没有任何关系。所以可以将类似 o 对象简单理解为一个标识,一个工具。
3)锁涉及的核心有两个:加锁、解锁
主要的特性:互斥,一个线程获取到锁之后,另一个线程也尝试加这个锁,就会阻塞等待(锁竞争/锁冲突)。
在代码中,可以创建出多个锁,只有多个线程竞争同一把锁,才会产生互斥,针对不同的锁,则不会。
3.1 synchronized 关键字可以作用的地方
3.1.1 同步代码块
使用 synchronized 关键字修饰代码块,可以指定对象作为锁,确保在同一时刻只有一个线程可以访问该代码块。其他线程需要等待获取锁后才能执行代码块。
public static long count = 0; public static void main(String[] args) throws InterruptedException { Object o = new Object(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { //作用于代码块中 synchronized (o){ count++; } } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { //作用于代码块中 synchronized (o){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }
3.1.2 同步实例方法
使用 synchronized 关键字修饰实例方法,可以确保在同一时刻只有一个线程可以访问该实例方法。其他线程需要等待当前线程执行完毕后才能访问。
代码如下:
public class demo11 { public synchronized void add(){ count++; }; public long get(){ return count; }; public static long count = 0; public static void main(String[] args) throws InterruptedException { demo11 demo = new demo11(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { demo.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { demo.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(demo.get()); } }
3.1.3 同步静态方法
使用 synchronized 关键字修饰静态方法,可以确保在同一时刻只有一个线程可以访问该静态方法。其他线程需要等待当前线程执行完毕后才能访问。
代码如下:
public class demo11 { public synchronized static void add(){ count++; }; public static long get(){ return count; }; public static long count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { demo11.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { demo11.add(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(demo11.get()); } }
3.2 join() 方法与 synchronized 关键字的区别
1)join() 是 Thread 类的方法,用于等待调用该方法的线程执行完成。当一个线程调用另一个线程的 join() 方法时,它会被阻塞,直到被调用的线程执行完成。
2)synchronized 关键字用于实现线程同步确保在同一时刻只有一个线程可以访问某个代码块或方法。
总的来说,join 用于线程之间的协作和等待,而 synchronized 用于实现线程之间的同步和互斥访问共享资源。
4.0 加锁不合理所引发的问题
4.1 对于一个加锁与一个没有加锁的两个线程随机调度执行同一个代码块或者方法的情况
对于这种情况来说,同样会导致多线程安全问题。因为对于一个加锁与另一个没有加锁的情况,这两个线程之间没有锁竞争或者产生互斥,所以还是会出现多线程安全问题。
代码如下:
public class demo9 { public static long count = 0; public static void main(String[] args) throws InterruptedException { Object o = new Object(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { synchronized (o){ count++; } } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
运行结果:
以上代码中即使有一个线程加上了锁,同样也是跟没有加锁的代码本质是一样的。因此,需要对两个线程中且操作同一个代码块或者方法进行同时加锁处理,才会解决多线程的安全问题。
4.2 嵌套相同的锁 - 可重入锁
在同一个线程中,嵌套同一个锁被称为可重入锁。
代码如下:
在外层加完锁之后,在内层继续加了相同的锁。再来了解加锁的详细过程:两个线程随机调度执行,假设 t1 对该代码块加锁,而 t2 就不能加锁了,此时产生了锁竞争,t2 需要阻塞等待 t1 执行后解锁后,才能继续去“抢夺”上锁;对于 t1 来说外层加完锁之后,此时内层加锁之前需要判断当前是那个线程对当前的代码块上锁,如果是当前线程加锁了,那么内层加锁这个操作就是为无,可以继续往下执行,注意这里没有产生锁竞争;如果不是当前线程加锁了,就会阻塞等待。
所以可重入锁是安全的,运行结果:
补充:解锁是执行到外层 } 花括号结束之后,才会自动解锁,而不是执行到内层的 } 花括号解锁。所以,内层加锁其实是没有用的,正常来说,有最外面加锁就足够了,之所以要搞上述操作,就是担心不小心把代码写错从而搞出“死锁”,目的就是避免程序员粗心大意。
4.3 两个线程两把锁 - 死锁
死锁是系统中的多个线程或进程相互等待对方释放资源,从而陷入僵局无法继续执行的状态。
代码如下:
public class demo12 { public static void main(String[] args) { Object o1 = new Object(); Object o2 = new Object(); Thread t1 = new Thread(()->{ synchronized (o1){ //这里用到 sleep 方法的原因是因为, //保证 t2 线程执行完:对 o2 进行加锁操作 try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (o2){ System.out.println("正在执行 t1 线程"); } } }); Thread t2 = new Thread(()->{ synchronized (o2){ //这里用到 sleep 方法的原因是因为, //保证 t1 线程执行完:对 o1 进行加锁操作 try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (o1){ System.out.println("正在执行 t2 线程"); } } }); t1.start(); t2.start(); } }
此时 t1 线程对 o1 加锁了,t2 线程对 o2 加锁了,对于 t1 来说,想要继续往下执行,需要 t2 对 o2 解锁,想要 t2 对 o2 解锁对话,需要 t1 对 o1 解锁。想要 t1 解锁的话,还是需要 t2 对 o2 解锁。。。此时成了很尴尬的情况,对方要需要对方的资源,双方都相互等待对方释放资源,从而僵持住了,无法执行下去。这样就造成了一个死锁。
通过 Jconsole 可执行程序观察:
已经检测到了死锁
运行结果:
程序一直在运行中
4.4 死锁的四个必要条件
4.4.1 互斥条件
资源只能被一个线程或进程所持有,其他线程无法同时访问。
4.4.2 不可剥夺条件
线程已经获取的资源在未使用完之前不能被其他线程所抢占。
4.4.3 请求保持条件
线程可以持有一些资源并继续请求其他资源。
4.4.4 循环等待条件
每个线程都在等待其他线程所持有的资源,形成一个循环等待的情况。
4.5 如何避免死锁?
只需要破环任意一个满足死锁的必要条件即可。
1)对于互斥条件来说,不能破坏,所以不用考虑这种情况。
2)对于不可剥夺条件,破坏该条件的方法是:如果一个线程无法获取资源,可以释放已经持有的资源,避免长时间占用资源。
3)对于请求保持条件,破坏该条件的方法是:一次性获取所有需要的资源。
4)对于循环等待条件,破坏该条件的方法是:按照固定顺序来获取资源,避免形成循环等待。