【操作系统线程安全问题】

本文详细阐述了线程安全问题,包括线程冲突的示例,分析了产生线程安全问题的原因,如原子性、内存可见性和指令重排序。介绍了synchronized关键字用于解决互斥和内存可见性问题,以及volatile关键字确保内存可见性和禁止指令重排序。文章还对比了synchronized和volatile的关键区别,并提供了相关代码示例。
摘要由CSDN通过智能技术生成


前言

在上一篇博客中,我们介绍了线程,知道它属于更轻量级的进程,这主要在于,一个进程里面的多个线程共用一份进程的资源(但每个线程也有自己的私有资源(如寄存器,栈)),因为共享资源,那必然会出现资源抢占问题,进而引出了线程安全问题,接下来将好好介绍这个


一、什么是线程安全问题(线程冲突)

1.线程安全问题

举个例子:比如在旅游景点有一个公共卫生间(一个进程),这里有多个马桶(这些马桶就是共享资源),这个时候有个老铁突然肚子不舒服,想上厕所,来到这里,发现有一个没关门(其实全都有人,那个门坏了,关不了),于是冲进去,结果里面的人就大叫(有人啊!!!)(要是大晚上,厕所灯还坏了,各位可以想想会发生多严重的问题),这两个人发生的事情就是线程安全问题,这个问题非常严重,会导致预期结果与实际不符合,比如下面这个代码

class Counter{
    public int count;
    public void increase(){         //在public前加synchronized (加锁)
        count++;
    }
}
public class Demo6 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t.start();
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.increase();
            }
        });
        t2.start();
        t.join();
        t2.join();
        System.out.println(counter.count);
    }
}

这段代码的作用是用两个线程把一个变量加到100000,大家可以猜猜结果是多少

在这里插入图片描述
这就是线程安全问题引起的与预期效果不符(我们可以加锁解决(给方法加锁解决))

2.测试案例详解

在解决这个问题之前,我们需要知道一个变量++会发生什么事情,如下图
在这里插入图片描述
通过这个图,我们可以知道完成一个++需要三个步骤

下面这样执行是不会出问题的,结果会加到2

在这里插入图片描述

但是另一种情况就会出问题,如下图,结果只能会为1,但是加了两次

在这里插入图片描述
这就是CPU随机调度线程导致的原因,在第二张图,t1线程刚load,然后CPU就记录上下文,然后执行t2线程,然后结果从0加到1;然后再回来执行t1,发现load已经执行,于是执行add,save写回(把1写回),这就导致执行两次,但结果只加了一次。

二、线程安全问题产生原因有哪些

1)抢占式执行,调度过程随机(也是万恶之源,无法解决)

2)多个线程同时修改同一个变量(可以适当调整代码结构,避免这种情况)

3)针对变量的操作,不是原子的(加锁,synchronized)

4)内存可见性,一个线程频繁读,一个线程写(使用volatile)

5)指令重排序(使用synchronized加锁或者volatile禁止指令重排序)

1.原子性

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

小tip:由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区

有时也把这个现象叫做同步互斥,表示操作是互相排斥的

一条 java 语句不一定是原子的,也不一定只是一条指令(例如++操作,内部三条指令构成)

比如上面我们说到的变量++,其实是由三步操作组成的:

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

不保证原子性会给多线程带来什么问题

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的

这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.

2.内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
在这里插入图片描述

  • 线程之间的共享变量存在 主内存 (实际内存).
  • 每一个线程都有自己的 “工作内存” (CPU寄存器,高速缓存) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

此时引入了两个问题:

为啥要整这么多内存?
为啥要这么麻烦的拷来拷去?

  1. 为啥整这么多内存?

实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法. 所谓的 “主内存” 才是真正硬件角度的
“内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.

  1. 为啥要这么麻烦的拷来拷去?
    因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果
只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问
内存了. 效率就大大提高了.

那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??
答案就是一个字: 贵

重点:这里会出现一个线程安全问题:那就是内存可见性问题,这个问题来自编译器与JAVA内存模型的存在,假如我们要读取10000次内存中的变量,因为java内存模型的原因,每次最终都会在工作内存中读到这个值,于是编译器就想优化一下(它以为你短期内不会中途修改),于是这10000次读取(速度很快)就第一次从内存中读,其它9999次读工作内存,如果这10000次读取中,其他线程修改了这个共享变量,这个时候读到的数据就不是最新的了,这就导致与预期不符(内存可见性问题)(可以用关键字volatile禁止编译器优化,保证内存可见性(synchronized也可(后面会讲到原因))

总结下:
速度:CPU访问寄存器>内存>硬盘
价格:CPU访问寄存器>内存>硬盘

3.指令重排序

什么是代码重排序
比如有段代码是这样的

  1. 去前台取下 U 盘,2.去教室写 10 分钟作业 3. 去前台取下快递
    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序

编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但
是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代
码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.

指令重排序通常它需要满足以下两个条件:

  1. 在单线程环境下不能改变程序运行的结果;
  2. 存在数据依赖关系的不允许重排序

tip:截止jdk1.8, Java 里只有 volatile 变量是能实现禁止指令重排的

4.多线程对共享变量进行修改

正如上面提到的++到100000;假如我们是对两个变量分别加到50000,即不会出现线程安全问题 测试

public class Demo4 {
    public static long a = 0;
    public static long b = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    a++;
                }
            }
        });
        Thread t1 = new Thread(new Runnable() {
            public static  final int a = 0;
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    b++;
                }
            }
        });
        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println("a:"+a +" b:"+b);
    }
}

结果
在这里插入图片描述
所以我们应该尽量避免多线程操作共享变量,否则就加锁处理

三、synchronized关键字

1.互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁

在这里插入图片描述

synchronized用的锁是存在Java对象头里的。

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕
所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队(阻塞等待)

理解 “阻塞等待”.

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝
试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的
线程,(并不是立即就可以获得这个锁,需要等操作系统唤醒(调度的一部分工作)) 再来获取到这个锁(但是在阻塞队列中的等待的锁并不遵循先来后到(也被称为公平锁)

2.刷新内存(保证内存可见性)

synchronized 的工作过程:

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

主要由于第四步的存在,所以 synchronized 也能保证内存可见性.

3.可重入锁

定义:同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入的

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第
二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无
法进行解锁操作. 这时候就会 死锁.(这就是不可重入锁)

在这里插入图片描述

为了避免死锁,所以java中synchronized(修饰的对象、方法、代码块)和ReentrantLock都是可重入锁(所以上面这个图不会出现死锁)

重入锁的实现原理
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息:
1.如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取
到锁, 并让计数器自增.
2.解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

tip:synchronized不是锁,synchronized是java里的一个关键字,可以用来给对象和方法或者代码块加锁

4.synchronized不能防止指令重排序

synchronized 虽不能禁止指令重排,但能保证有序性

这个有序性是相对语义来看的,线程与线程间,每一个 synchronized 块可以看成是一个原子操作,它保证每个时刻只有一个线程执行同步代码,它可以解决JAVA内存模型中工作内存和主内存同步延迟现象引发的无序

synchronized与volatile区别(一个能禁止,一个不能禁止的原因)(底层原因)
synchronized 是因为块与块之间看起来是原子操作,块与块之间有序可见
volatile 是在底层通过内存屏障防止指令重排的,变量前后之间的指令与指令之间有序可见
总结:synchronized 块里的非原子操作依旧可能发生指令重排(所以不能)

四、volatile关键字

1.保证内存可见性

如下面代码

public class Demo7 {
    public  static  int a = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while (a==0){                //优化只从寄存器读,不再去内存中读(优化) (当读的消耗很小(比如下面休眠3秒就不必要优化,就不会出现内存可见性问题))
                
            }
            System.out.println("t1停止");
        });
        t1.start();
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入");
        a = scanner.nextInt();
        t1.join();
        System.out.println("Main结束");
    }
}

当我们对变量a进行修改的时候,会发现while循环并不会停止,但是条件已经不满足了,这是为什么呢(这就是内存可见性导致的问题),上述代码执行结果如下
在这里插入图片描述

应该结束的while循环并没有停止(与预期不符),具体原因可以看上面讲述的内存可见性问题产生的原因,这个时候只要在变量a前面加上volatile关键字即可解决

在这里插入图片描述
扩展:这主要是编译器优化的原因,对于这段代码,我们还可以用其他办法解决,如下代码,代码中
有注释

public class Demo7 {
    public  static  int a = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            while (a==0){    //优化只从寄存器读,不再去内存中读(优化) (**当读的消耗很小(比如下面休眠3秒就不必要优化,就不会出现内存可见性问题)**)
                System.out.println("hello");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1停止");
        });
        t1.start();
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入");
        a = scanner.nextInt();
        t1.join();
        System.out.println("Main结束");
    }
}

结果如下
在这里插入图片描述

2.禁止指令重排序

我们这里拿单例模式中实例化一个对象举例:

Singleton instence = new Singleton(); //保证对象实例化正确

  1. 给 singleton 分配内存

  2. 调用 Singleton 的构造函数来初始化成员变量

  3. 将 instence 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)

volatile禁止重排序,只能1->2->3,如果1->3->2在3到2之间有线程判断instence是否为空就会出错,其对象是还没有进行初始化(属性赋值之类),所以会出现问题

volatile 禁止指令重排底层原理:

先了解下概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 保证特定操作执行的顺序性
  2. 保证某些变量的内存可见性(这也是volatile保证内存可见性的原因)

通过这个CPU指令,插入内存屏障就能禁止在内存屏障前后的指令执行重排序优化。内存屏障的另外一个作用是强制刷出各种CPU的缓存数据。(保证内存可见性)

五、volatile和synchronized区别

  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;
    synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。等释放锁(然后刷新内存)
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性(原子性加锁)
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。(加锁可能导致死锁)
  5. volatile标记的变量不会被编译器优化(禁止优化);synchronized标记的变量可以被编译器优化

总结

通过这篇博客,对于线程安全问题有了一定的了解,知道了为什么会出现线程安全问题,知道了产生线程安全的原因有哪些,例如原子性,内存可见性,指令重排序等,对synchronized有了一定的认识,也知道了volatile的作用,实现原理,知道了内存屏障(实现禁止指令重排序原理),总结了synchronized和volatile的区别。加油!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值