线程安全问题的原因和解决方案

目录

线程安全问题的原因

 1 .操作系统随机调度/抢占式执行

2 .多个线程操作同一个变量

3 .原子性 

4 .内存可见性

5 .指令重排序

解决方案 

1 . 加锁 

2 .volatile 


线程安全问题的原因

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。

 1 .操作系统随机调度/抢占式执行

抢占式执行(preemptive execution)指的是操作系统在运行多个进程或线程时,会在一定的时间片内,按照一定的调度算法来轮流执行各个进程或线程。如果一个进程或线程正在执行时,出现了一些特殊情况(如I/O请求、中断请求等),操作系统就会暂时中止当前进程或线程的执行,切换到其他进程或线程的执行,以便处理这些请求。这种被中止的进程或线程在某个时刻后,仍然会被操作系统调度执行。

2 .多个线程操作同一个变量

当修改变量这个操作并非原子性的。这样在并发的环境下就很容易出现线程安全问题。

3 .原子性 

当我们使用++这种操作时,涉及三条机器指令(取出 运算 放回),当指令不是单一机器指令时 , 这时我们就可以将其称为并不具有“原子性”。若不具有原子性,我们的操作可能就会被操作系统给打乱了顺序执行。

4 .内存可见性

当我们的一个线程修改,另一个线程读取时操作系统会自动给我们进行优化,这个过程就很容易造成内存可见性问题。当我们一个操作重复多次结果一样的时候 ,操作系统会自动给我们进行优化 ,然后不再进行这个操作 ,导致接下来的操作读取时都是判断最开始读时候的数据

5 .指令重排序

指令重排序是因为编译器对我们的代码进行了一些“自作主张”的优化,编译器会在保持逻辑不变的情况下。调整代码的顺序,从而加快代码的执行效率。这样也会出现线程安全问题。 

解决方案 

1 . 加锁 

加锁就可以将不是原子的操作转换成原子的。在这里使用synchronized来进行加锁

这个例子是创建了两个线程,这两个线程想对同一个对象来进行++操作,每个线程共操作2500次,一共5000次。按常理说在线程执行完之后,我们的预期是5000。但是这里我们发现,输出的结果不是5000,并且每次运行的结果都是不同的!

​
class Counter{
    public int counter;

    public void add(){
            counter++;
    }

    public int getCounter() {
        return counter;
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter c = new Counter();

            Thread t1 = new Thread(() ->{
                for (int i = 0; i <2500 ; i++) {
                c.add();
            }
        });
        Thread t2 = new Thread(() ->{
            for (int i = 0; i <2500 ; i++) {
                c.add();
            }
        });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
        System.out.println(c.getCounter());
    }
}

​

 这就是多个线程修改一个变量而导致的线程安全问题。是因为++这个操作并不是原子性的,它分为1. 从内存把数据读到 CPU 2. 进行数据更新 3. 把数据写回到 CPU 三个操作。而我们使用了synchronized之后就不同了:我们将add方法加上synchronized,此时答案就是我们预期的结果5000。加了synchronized之后,进入了方法就会加锁,除了方法就会解锁。如果两个线程同时尝试加锁,此时只有一个线程可以获取锁成功,而另一个线程就会阻塞等待。阻塞到另一个线程释放锁之后,当前线程才能获取锁成功。 加锁的本质就是把并发变成了串行.

class Counter{
    public int counter;

    public void add(){
        synchronized (this){
            counter++;
        }

    }

    public int getCounter() {
        return counter;
    }
}
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Counter c = new Counter();

            Thread t1 = new Thread(() ->{
                for (int i = 0; i <2500 ; i++) {
                c.add();
            }
        });
        Thread t2 = new Thread(() ->{
            for (int i = 0; i <2500 ; i++) {
                c.add();
            }
        });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
        System.out.println(c.getCounter());
    }
}

synchronized的特性
1.互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

2.刷新内存~synchronized 的工作过程
1. 获得互斥锁
2. 从主内存拷贝变量的最新副本到工作的内存
3. 执行代码
4. 将更改后的共享变量的值刷新到主内存
5. 释放互斥锁

3.可重入

可重入直观上来讲,就是同一个线程针对同一个锁,连续加锁了两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入的~~

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止

死锁的四个必要条件:

1)互斥使用~ 一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)

2)不可抢占~ 一个锁被一个线程占用了之后,其他线程不能把这个锁给抢走

3)请求与保持~ 当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁是始终都是被该线程所持有的

4)环路等待,等待关系(为避免环路等待,只需要约定好,针对多把锁加锁的时候,有固定的顺序即可)
 

2 .volatile 

volatile保证内存可见性,禁止编译器优化

volatile只是处理一个线程读,一个线程写的情况

volatile不保证原子性,也不会引起线程阻塞

 这个例子是创建了两个线程,一个线程不断地读一个变量,而一个线程修改一个变量。我们的预期是,当t2修改了flog的值之后,使flog不再为0,此时跳出循环,线程t1结束。当我们的t2修改了flog的值之后,我们发现t1线程并没有结束,程序仍然在运行.

public class Main {
    public static int flog = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
           while (flog == 0){

           }
            System.out.println("t1结束");
        });
        Thread t2 = new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个数字:");
            flog = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 上面这个代码可见 我们输入了1后 循环并没有停止  我们的比较操作是在一个while循环中进行的,它的执行速度极快。而循环比较了这么多次,在t2修改flog之前,flog的值和内存读取到的结果是一样的。循环了很多次 此时我们的编译器便做出了优化 ,不再重复读取内存上的值 ,只读取一次放在CPU中 , 这样过后我们的t2即便改变了flog的值 , 但是由于t1不在读取内存上的值 , 所以我们的循环没有停下来, 此时,volatile就能发挥作用了。将flag变量加上volatile关键字,告诉编译器,这个变量是“易变”的,不用进行编译器优化。

public class Main {
    volatile public static int flog = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
           while (flog == 0){

           }
            System.out.println("t1结束");
        });
        Thread t2 = new Thread(() ->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个数字:");
            flog = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值