【JavaEE初阶】 线程安全

简介: 【JavaEE初阶】 线程安全

🌴线程安全的概念

线程安全是多线程编程是的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且准确的执行,不会出现数据污染等意外情况。上述是百度百科给出的一个概念解释。换言之,线程安全就是某个函数在并发环境中调用时,能够处理好多个线程之间的共享变量,是程序能够正确执行完毕。也就是说我们想要确保在多线程访问的时候,我们的程序还能够按照我们的预期的行为去执行,那么就是线程安全了。

我们可以这样认为:

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

🌳观察线程不安全

现在有以下代码,调用两个线程,两个线程对同一个对象的同一元素进行加加操作,该元素初始值为0,每个线程加50000次,我们观察最终结果

代码如下:

class Count {
    public int count = 0;
    void increase() {
        count++;
    }
}
public class Counter {
    public static void main(String[] args) throws InterruptedException {
        final Count count = new Count();
        //搞两个线程,分别对count进行++操作,每一个线程加50000次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.count);
    }
}

我们预期的结果是count = 100000,但是我们运行后发现,结果无法预料

下面是博主运行多次的结果展示

我们可以观察到每次的运行结果都不相同,这是什么原因造成的呢?

🎄线程不安全的原因

🚩修改共享数据

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

此时这个 coun.count 是一个多个线程都能访问到的 "共享数据“

count.count 这个变量就是在堆上. 因此可以被多个线程共享访问

要考虑线程安全问题,就需要先考虑Java并发的三大基本特性:原子性、可见性以及有序性

📌原子性

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行

就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成

再比如下面这个卖票的例子,就不具备原子性

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?

是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

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

注意:一条 java 语句不一定是原子的,也不一定只是一条指令

比如我们上述不安全的代码 count ++,其实是由三步操作组成的:

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

所以在多线程中,有可能一个线程还没自增完,可能才执行到第二步(进行数据更新),另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。

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

如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。(这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大)

📌 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。简单理解为:一个线程对共享变量值的修改,能够及时地被其他线程看到

若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

可见性就与操作系统内存模型有关系了,这里博主为大家介绍以下Java 内存模型 (JMM)

JMM 为Java虚拟机规范中定义了Java内存模型.

目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

模型说明:

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

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.

如下所示:

  1. 初始情况下, 两个线程的工作内存内容一致
  2. 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步

    这个时候代码中就容易出现问题,就如上述不安全的代码一样

这时候我相信有一部分人就有这样的疑问了

  1. 为啥整这么多内存?

实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法.

所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU的寄存器和高速缓存

  1. 为啥要这么麻烦的拷来拷去?

因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).

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

然后我们是不是又会有新问题了,既然访问寄存器速度这么快, 还要内存干啥?

答案 :就是一个字: ,寄存器太贵了

  • 值的一提的是, 快和慢都是相对的. CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.
  • 对应的, CPU 的价格最贵, 内存次之, 硬盘最便宜

📌代码顺序性

程序执行的顺序按照代码的先后顺序执行,在多线程编程时就得考虑这个问题

如果一个线程的话,代码的顺序就为从上到下,依次执行,而当为多线程时就不一样了。

当多个线程同时共享,同一个全局变量或静态变量(即局部变量不会),做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作是不会发生数据冲突问题。

关于代码顺序性不得不说一个概念:代码重排序

何为代码重排序呢?

比如有段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序

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

重排序是一个比较复杂的话题, 涉及到 CPU 以及编译器的一些底层工作原理, 此处不做过多赘述了

🌲解决之前的线程不安全问题

我们使用关键字synchronized进行加锁操作

class Count {
    public int count = 0;
    //加锁
     synchronized void increase() {
        count++;
    }
}
public class Counter {
    public static void main(String[] args) throws InterruptedException {
        final Count count = new Count();
        //搞两个线程,分别对count进行++操作,每一个线程加50000次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.count);
    }
}

我们来看一下代码运行结果:

我们发现结果正确,说明上述的线程不安全问题解决了

在我们改善后代码里涉及到了一个新的知识synchronized进行加锁操作,该操作博主会在下一篇博客进行详细讲解

⭕总结

关于《【JavaEE初阶】 线程安全》就讲解到这儿,感谢大家的支持,欢迎各位留言交流以及批评指正,如果文章对您有帮助或者觉得作者写的还不错可以点一下关注,点赞,收藏支持一下!

相关文章
|
2月前
|
存储 安全 Java
【JavaEE】线程安全
【JavaEE】线程安全
|
2月前
|
消息中间件 监控 安全
【JAVAEE学习】探究Java中多线程的使用和重点及考点
【JAVAEE学习】探究Java中多线程的使用和重点及考点
|
2月前
|
算法 Java 编译器
【JavaEE多线程】掌握锁策略与预防死锁
【JavaEE多线程】掌握锁策略与预防死锁
27 2
|
2月前
|
设计模式 安全 Java
【JavaEE多线程】从单例模式到线程池的深入探索
【JavaEE多线程】从单例模式到线程池的深入探索
29 2
|
2月前
|
监控 安全 Java
【JavaEE多线程】深入解析Java并发工具类与应用实践
【JavaEE多线程】深入解析Java并发工具类与应用实践
44 1
|
2月前
|
安全 Java 编译器
【JavaEE多线程】线程安全、锁机制及线程间通信
【JavaEE多线程】线程安全、锁机制及线程间通信
36 1
|
2月前
|
安全 Java API
JavaEE多线程】深入理解CAS操作:无锁编程的核心
JavaEE多线程】深入理解CAS操作:无锁编程的核心
25 0
|
2月前
|
Java 程序员 调度
【JavaEE多线程】理解和管理线程生命周期
【JavaEE多线程】理解和管理线程生命周期
23 0
|
2月前
|
存储 设计模式 监控
【JavaEE】多线程案例-线程池
【JavaEE】多线程案例-线程池
|
2月前
|
存储 Java
【JavaEE】多线程案例-定时器
【JavaEE】多线程案例-定时器