当前位置:   article > 正文

Java中的线程安全问题,线程安全问题的解决方法_有参构造存在线程安全问题吗java

有参构造存在线程安全问题吗java

一、认识线程安全

线程安全问题的出现

        简单来说,某个代码,无论是在单个线程下执行,还是在多个线程下执行,都不会产生BUG,把这个情况称为 “线程安全” 。

        若是某个代码,在单个线程下顺利运行,但是转到多线程情况下,就可能会产生BUG,这个情况称为“线程不安全”或者“存在线程安全问题

线程不安全的原因

  1.      根本原因:操作系统上的线程是 “抢占式执行”、“随机调度” 的,我们无法预知,就导致了线程之间的执行顺序带来了很多变数。(可以称为 “罪魁祸首”)
  2.      代码结构问题:代码中多个线程同时改同一个变量(也是源于上面的根本原因,导致一些指令出现混乱的排序。后面会介绍到可以通过使用 “锁” 来解决这类问题。)
  3.      直接原因:多线程修改操作,本身不是原子(一个程序执行多个cpu指令,执行到一半,就可能会被调度走,从而给其他线程 “可乘之机”)
  4.      内存可见性问题:高度依赖编译器的优化的具体实现,一个线程对共享变量值的修改,未能够及时的被其他线程看到。(在执行一些指令操作时,花费的开销比较大,并且一直没有结果。这时JVM就会出来怀疑,判断,然后会进行代码优化,去除一些JVM认为没有必要的操作,提高程序运行效率。从而会导致,后面的一些操作执行时,被优化的代码无法产生响应,出现BUG。)
  5.      指令重排序问题:   在多线程下,通过JVM、CPU指令集会对某些代码进行优化。把1->2->3的顺序可能会优化为1->3->2,但是在多线程情况下代码的执行复杂程度更高,容易打乱 “保持逻辑不发生变化”这一重要前提。

代码示范:修改共享数据----多个线程修改同一个变量

        下面的线程不安全代码中,涉及到多个线程针对 count 变量进行修改。

        此时这个 count 是一个多线程都能访问的 “共享数据”。

  1. public static int count = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. //通过两个线程,同时修改count变量,使他们分别++
  4. Thread t1 = new Thread(()->{
  5. for (int i = 0; i < 50000; i++) {
  6. count++;
  7. }
  8. });
  9. Thread t2 = new Thread(()->{
  10. for (int i = 0; i < 50000; i++) {
  11. count++;
  12. }
  13. });
  14. t1.start();
  15. t2.start();
  16. t1.join();
  17. t2.join();
  18. System.out.println("count="+count);
  19. }

得到的结果:<100000

二、线程安全问题的解决方法-- 加“锁”

如何实现--“锁”

锁(synchronized):通过特殊的手段把系统的指令打包成一个整体(类似原子)

        锁 具有 “互斥”、“排他” 的特性,在两个线程中对同一个对象 加锁 就会产生“锁竞争”,使其运行中产生 “阻塞(BLOCKED)”。通过锁竞争无法方第二个线程在第一个线程执行的时候插队,需要排队等待,在顺利执行完锁里面的操作后,继续执行。

synchronized 关键字 -- 监视器锁(monitor lock)

1.使用分别在两个线程里使用 synchronized 关键字

  1. public static int count = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. Object locker = new Object();
  4. Thread t1 = new Thread(()->{
  5. for (int i = 0; i < 50000; i++) {
  6. //加锁,使得count++的指令操作在锁里面完成,不被插队,包装成一个完整的count++指令
  7. synchronized (locker){//注意:这里的locker参数,可以是任意的Object
  8. count++;
  9. }
  10. }
  11. });
  12. Thread t2 = new Thread(()->{
  13. for (int i = 0; i < 50000; i++) {
  14. //加锁,使得count++的指令操作在锁里面完成,不被插队,包装成一个完整的count++指令
  15. synchronized (locker){//注意:这里的locker参数,可以是任意的Object
  16. count++;
  17. }
  18. }
  19. });
  20. t1.start();
  21. t2.start();
  22. t1.join();
  23. t2.join();
  24. System.out.println("count="+count);
  25. }

2.把count 放到Test t对象中,通过add方法来修改

  1. class Test{
  2. public static int count = 0;
  3. //1.
  4. public void add(){
  5. for (int i = 0; i < 50000; i++) {
  6. /*
  7. 为什么static中不能使用this
  8. 静态方法不依赖于任何对象就可以进行访问,既然都没有对象,就谈不上this了
  9. static叫静态方法,也叫类方法,它先于任何的对象出现。
  10. 在程序最开始启动(JVM初始化)的时候,就会为static方法分配一块内存空间,成为静态区,属于这个类。
  11. 而非static方法,必须在类实例化的时候,才会给分配内存空间,
  12. 在实例化对象的时候JVM在堆区分配一个具体的对象,this指针指向这个对象。
  13. 也就是说,this指针是指向堆区中的类的对象,而static域不属于this指向的范围所在,所以不能调用。
  14. */
  15. synchronized (this){
  16. //加锁的锁对象,写作this
  17. count++;
  18. }
  19. }
  20. }
  21. }
  22. public class Thread_12 {
  23. public static void main(String[] args) throws InterruptedException {
  24. Test t = new Test();
  25. //这里的 this 都指向的是 t ,是一样的两个对象加锁,因此任然存在锁竞争
  26. Thread t1 = new Thread(() -> {
  27. t.add();
  28. });
  29. Thread t2 = new Thread(() -> {
  30. t.add();
  31. });
  32. t1.start();
  33. t2.start();
  34. t1.join();
  35. t2.join();
  36. System.out.println("count=" + t.count);
  37. }
  38. }

3. 通过类对象来加锁

  1. class Test{
  2. public static int count = 0;
  3. synchronized public void add(){
  4. for (int i = 0; i < 500000; i++) {
  5. count++;
  6. }
  7. }
  8. }
  9. public class Thread_12 {
  10. public static void main(String[] args) throws InterruptedException {
  11. Test t = new Test();
  12. //两个线程拿到的类对象是同一个对象,因此任存在锁竞争,可以保障线程安全
  13. Thread t1 = new Thread(() -> {
  14. t.add();
  15. });
  16. Thread t2 = new Thread(() -> {
  17. t.add();
  18. });
  19. t1.start();
  20. t2.start();
  21. t1.join();
  22. t2.join();
  23. System.out.println("count=" + t.count);
  24. }
  25. }

以上三个例子,通过实现“锁”后得到的结果:

锁 存在的问题--“死锁”

加锁是能解决多线程安全问题的,但是如果添加方式不对,就可能产生死锁!!

1.一个线程一把锁(“可重入”性)

        若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

  1. public static void main(String[] args) {
  2. Object locker = new Object();
  3. Thread t = new Thread(()->{
  4. //可重入 性,同一个线程可以两次加锁,不会出现阻塞
  5. //c++中这样使用两个锁会出现 卡死 “死锁”状态
  6. //正常情况下不使用 可重入锁,
  7. synchronized (locker){
  8. synchronized ((locker)){
  9. System.out.println("hello");
  10. }
  11. }
  12. });
  13. t.start();
  14. }

2.两个线程,两把锁

两个线程互不相让,A要获取B,B要获取A,导致接下来都无法执行,形成阻塞,出现死锁

  1. public static void main(String[] args) {
  2. Object A = new Object();
  3. Object B = new Object();
  4. Thread t1 = new Thread(()->{
  5. synchronized (A){
  6. try {
  7. Thread.sleep(2000);
  8. } catch (InterruptedException e) {
  9. throw new RuntimeException(e);
  10. }
  11. //尝试获取B,没有释放A
  12. synchronized (B){
  13. System.out.println("t2 ");
  14. }
  15. }
  16. });
  17. Thread t2 = new Thread(()->{
  18. synchronized (B){
  19. try {
  20. Thread.sleep(2000);
  21. } catch (InterruptedException e) {
  22. throw new RuntimeException(e);
  23. }
  24. //尝试获取A,没有释放B
  25. synchronized (A){
  26. System.out.println("t1 ");
  27. }
  28. }
  29. });
  30. t1.start();
  31. t2.start();

改进,约定加锁顺序,先对A加锁,后对B加锁

  1. Thread t1 = new Thread(()->{
  2. synchronized (A){
  3. try {
  4. Thread.sleep(2000);
  5. } catch (InterruptedException e) {
  6. throw new RuntimeException(e);
  7. }
  8. synchronized (B){
  9. System.out.println("t2 ");
  10. }
  11. }
  12. });
  13. Thread t2 = new Thread(()->{
  14. //改进,直接线获取A,破除循环等待
  15. synchronized (A){
  16. try {
  17. Thread.sleep(2000);
  18. } catch (InterruptedException e) {
  19. throw new RuntimeException(e);
  20. }
  21. synchronized (B){
  22. System.out.println("t1 ");
  23. }
  24. }
  25. });

3.N个线程,M把锁

在解决这里问题之前我们再来了解一下死锁的产生

死锁产生的 四个 必要条件 (缺一不可,只要破坏其中一个便可解除死锁)

1. 互斥使用,获取锁的过程是互斥的。

        一个线程拿到了这把锁,另一个线程也想要获取,就需要阻塞等待

2. 不可抢占,一个线程拿到了锁之后,只能主动解锁,不让别的线程强行把锁抢走

3. 请求保持,一个线程拿到了锁A后,在持有A的前提下,尝试获取B

4. 循环等待 / 环路等待

上述条件1~3点都不方便去破坏,我们通常可以通过  指定加锁顺序  这样的方式破坏代码结构,来破除循环等待。

三、解决线程安全中的“内存可见性”问题--volatile 关键字

volatile 能保证内存可见性

volatile 修饰的变量,能保证 “内存可见性”

代码示例

在这个代码中

  • 创建两个线程t1 和 t2
  • t1中包含一个循环,这个循环以flag == 0为循环条件
  • t2中从键盘读入一个整数,并把这个整数,并把这个整数赋值给flag
  • 预期当用户输入非 0 的值时候,t1 线程结束
  1. private static int flag = 0;
  2. //volatile 保证内存可见性 禁止指令从排序
  3. public static void main(String[] args) {
  4. Thread t1 = new Thread(()->{
  5. while (flag == 0){
  6. }
  7. System.out.println("t1 线程结束");
  8. });
  9. Thread t2 = new Thread(()->{
  10. System.out.println("请输入flag的值:");
  11. Scanner scanner = new Scanner(System.in);
  12. flag = scanner.nextInt();
  13. });
  14. t1.start();
  15. t2.start();
  16. }
  17. //执⾏效果
  18. // 当⽤⼾输⼊⾮0值时, t1 线程循环不会结束. (这显然是⼀个 bug)

此时t1 读的是自己工作内存中的内容

当 t2 对 flag 变量进行修改,此时 t1 感知不到 flag 的变化

如果给 flag 加上 volatile

  1. private volatile int flag = 0;
  2. // 执⾏效果
  3. // 当⽤⼾输⼊⾮0值时, t1 线程循环能够⽴即结束.

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/weixin_40725706/article/detail/424653
推荐阅读
相关标签
  

闽ICP备14008679号