【JavaEE】并发编程(多线程)线程安全问题&内存可见性&指令重排序

简介: 【JavaEE】并发编程(多线程)线程安全问题&内存可见性&指令重排序

第一个问题:什么是线程安全问题

线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。 线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据

第二个问题:为什么会出现线程安全问题?

出现线程安全的问题的根源其实是在于我们之前说过的多线程“抢占式执行,随机调度”的特性决定的。当我们在使用多线程进行编程的时候,是躲不过这一“万恶之源”的。我们只可以通过一些编程手段来解决这些线程安全的问题。

我们可以看一下下面这部分的代码。

(这是一个典型的多线程的线程安全问题,里面会出现脏数据,也就是多个线程对同一个变量进行更改的问题)

首先我们来看当我们写两个线程进行更改同一个变量的情况:

1. package Thread;
2. 
3. class Countsum{
4. private static int count=0;
5. public  void CountAdd(){
6.         count++;
7.     }
8. public int getCount(){
9. 
10. return count;
11.     }
12. }
13. public class ThreadDemo15 {
14. public static void main(String[] args) {
15.         Countsum countsum=new Countsum();
16. //第一个线程t1
17. Thread t1 =new Thread(()->{
18. 
19. for (int i = 0; i <50000; i++) {
20.                 countsum.CountAdd();
21.             }
22. 
23.         });
24. //第二个线程t2
25.         Thread t2=new Thread(()->{
26. for(int i=0;i<50000;i++){
27.                 countsum.CountAdd();
28.             }
29.         });
30. //两个线程操作同一个变量
31.         t1.start();
32.         t2.start();
33. //让t1,t2两个线程执行完,再执行main线程,这里让main线程阻塞
34. try {
35.             t1.join();
36.         } catch (InterruptedException e) {
37. throw new RuntimeException(e);
38.         }
39. try {
40.             t2.join();
41.         } catch (InterruptedException e) {
42. throw new RuntimeException(e);
43.         }
44. //打印最后的结果,看和预期值10_0000是否一致。
45.         System.out.println(countsum.getCount());
46.     }
47. 
48. 
49. }

预期值:10_0000

第一次运行:64603

第二次运行:73388

第三次运行:75233

每一次的结果都和预期值相差甚远。这就说明期间发生了脏读了,也揭示了线程的不安全性。

那么具体的过程是怎样变成这样的?

首先我们需要知道count++这个过程到底是怎么实现的。

我们从CPU的角度出发:count++主要是由三个指令实现的

1、(load)把内存中count的值加载到CPU的寄存器当中

2、(add)把寄存器中的数值加1

3、(save)把寄存器中的值放回到内存中,对原来的值进行覆盖。

我们画个示意图:

同样,也正是因为这个过程需要多个步骤来进行实现,就使得多线程的“抢占式执行,随机调度”得以充分发挥作用了。我们都知道排列组合。在这10万次循环中,会有无数种排列的情况出现,所以基本上每一次的结果不会相等,但是不排除相等的情况。

这里我们就列举一种情况来进行说明即可:

比如这种情况:

我们来分析一下这个过程:

假设初始值为0.

如果是正常情况下结果应该是2,但是这里结果却是1。这就和上面的程序是一样的道理。

如果要得到正确结果应该是这种的步骤:

就是像这样的能够得到正确的数据。

第三个问题:如何解决多线程安全问题?

答案:加锁

那么java中加锁的方式有很多种,最常使用的是 synchronized 关键字。我们可以给上述代码的自增函数内部自增操作上加synchronized 关键字或者直接给自增的方法加上synchronized关键字就是加锁成功了。

加锁成功后在看一下程序:

1. package Thread;
2. 
3. class Countsum{
4. private static int count=0;
5. public  void CountAdd(){
6. synchronized (this){
7.             count++;
8.         }
9. 
10.     }
11. public int getCount(){
12. 
13. return count;
14.     }
15. }
16. public class ThreadDemo15 {
17. public static void main(String[] args) {
18.         Countsum countsum=new Countsum();
19. //第一个线程t1
20. Thread t1 =new Thread(()->{
21. 
22. for (int i = 0; i <50000; i++) {
23.                 countsum.CountAdd();
24.             }
25. 
26.         });
27. //第二个线程t2
28.         Thread t2=new Thread(()->{
29. for(int i=0;i<50000;i++){
30.                 countsum.CountAdd();
31.             }
32.         });
33. //两个线程操作同一个变量
34.         t1.start();
35.         t2.start();
36. //让t1,t2两个线程执行完,再执行main线程,这里让main线程阻塞
37. try {
38.             t1.join();
39.         } catch (InterruptedException e) {
40. throw new RuntimeException(e);
41.         }
42. try {
43.             t2.join();
44.         } catch (InterruptedException e) {
45. throw new RuntimeException(e);
46.         }
47. //打印最后的结果,看和预期值10_0000是否一致。
48.         System.out.println(countsum.getCount());
49.     }
50. 
51. 
52. }

这个结果就和我们的预期值一样了。

第四个问题:产生线程不安全的原因有哪些?

1、线程是抢占式执行的,线程间的调度充满随机性。(线程不安全的根本原因)

2、多个线程对同一个变量进行修改操作。

3、针对变量的操作不是原子的,通过加锁操作就是把几个指令打包成一个原子的。

4、内存可见性。

这里需要简单理解一下几个名词:

1)原子性  我们可以简单的理解为打包为一个整体

第五个问题:内存可见性问题及解决方案

2)内存可见性

内存可见性问题其实是编译器优化的结果。

我们这里以一个线程读取数字,一个线程修改数字为例:

t线程负责读取istrue的值,main线程负责修改istrue的值。

1. package Thread;
2. 
3. import java.util.Scanner;
4. 
5. public class ThreadDemo16 {
6. public static int istrue=0;
7. public static void main(String[] args) {
8.         Thread t=new Thread(()->{
9. while(istrue==0){
10. 
11.             }
12.             System.out.println("t线程结束!");
13.         });
14.         t.start();
15.         Scanner scanner=new Scanner(System.in);
16.         System.out.println("请输入一个数字:");
17.         istrue=scanner.nextInt();
18.         System.out.println("main线程执行完毕");
19. 
20.     }
21. }

我们看一下执行结果:

当我们输入一个5的时候,我们原来是应该让t线程结束的,然而main线程结束后,t线程却进入了死循环当中,也就是说,此时的istrue还是0,并没有得到修改。这到底是什么原因导致的呢?

原因是这样的:由于从内存中读是要比从寄存器中读慢很多的(好几个数量级吧大概) 这里的t线程需要不断的循环读取istrue的值,如果我们的main线程不做出修改,那么t线程读取到的值就一直是一样的值。于是编译器就可能会进行优化,让t线程直接从寄存器中读取数据,也就是省去了load的操作,这一大胆的行为使得后续我们对istrue进行的修改都无法让t线程感知到,也就是说修改失去了作用。所以t线程并不会终止。

那么如何解决内存可见性的问题呢?

1、使用synchronized 关键字。synchronized 不光能够保证原子性,同时也能够保证内存可见性。被synchronized 包裹起来的代码,编译器就不敢轻易做出上述假设,就相当于手动禁止了编译器的优化。

2、使用volatile关键字。volatile和原子性无关,但是能够保证内存可见性。使得编译器每次都要重新从内存中读取istrue的值。

方案一:使用volatile关键字(最常用)

public static volatile int istrue=0;

方案二: 使用synchronized关键字

有时候我们也可以使用一些别的操作,比如sleep啊等等的,不过这些不太可靠哈。

1. while(istrue==0){
2. try {
3.                         Thread.sleep(1000);
4.                     } catch (InterruptedException e) {
5. throw new RuntimeException(e);
6.                     }
7. 
8.                 }

编译器优化总的来说还是比较玄学的!!!

说到这,还有一个由编译器优化引发的问题!!!

第六个问题:指令重排序问题?

指令重排序问题听着挺吓人,其实就是个排序问题罢了。

指令重排序是编译器优化的结果,编译器会对我们写的代码进行重排序从而来提高编译的效率,但是有时候一旦发生指令重排序,就可能会使得程序与我们预期的结果不同了。(在单线程中指令的重排序不会产生太大的影响,但是在多线程中容易出现严重bug,需要多注意!)我们要保证逻辑不变,对顺序进行调整。(使用synchronized可以进行禁止指令的重排序)


相关文章
|
17天前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
21 0
|
22天前
|
缓存 Java 开发者
Java多线程并发编程:同步机制与实践应用
本文深入探讨Java多线程中的同步机制,分析了多线程并发带来的数据不一致等问题,详细介绍了`synchronized`关键字、`ReentrantLock`显式锁及`ReentrantReadWriteLock`读写锁的应用,结合代码示例展示了如何有效解决竞态条件,提升程序性能与稳定性。
83 6
|
1月前
|
并行计算 数据处理 调度
Python中的并发编程:探索多线程与多进程的奥秘####
本文深入探讨了Python中并发编程的两种主要方式——多线程与多进程,通过对比分析它们的工作原理、适用场景及性能差异,揭示了在不同应用需求下如何合理选择并发模型。文章首先简述了并发编程的基本概念,随后详细阐述了Python中多线程与多进程的实现机制,包括GIL(全局解释器锁)对多线程的影响以及多进程的独立内存空间特性。最后,通过实例演示了如何在Python项目中有效利用多线程和多进程提升程序性能。 ####
|
1月前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
42 2
|
23天前
|
设计模式 安全 Java
Java 多线程并发编程
Java多线程并发编程是指在Java程序中使用多个线程同时执行,以提高程序的运行效率和响应速度。通过合理管理和调度线程,可以充分利用多核处理器资源,实现高效的任务处理。本内容将介绍Java多线程的基础概念、实现方式及常见问题解决方法。
43 0
|
2月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
27 3
|
2月前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
23 2
|
2月前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
38 2
|
2月前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
44 1
|
1月前
|
数据采集 Java Python
爬取小说资源的Python实践:从单线程到多线程的效率飞跃
本文介绍了一种使用Python从笔趣阁网站爬取小说内容的方法,并通过引入多线程技术大幅提高了下载效率。文章首先概述了环境准备,包括所需安装的库,然后详细描述了爬虫程序的设计与实现过程,包括发送HTTP请求、解析HTML文档、提取章节链接及多线程下载等步骤。最后,强调了性能优化的重要性,并提醒读者遵守相关法律法规。
62 0