首页 > 编程语言 >Java核心知识体系7:线程安全性讨论

Java核心知识体系7:线程安全性讨论

时间:2023-11-28 14:48:45浏览次数:37  
标签:执行 Java CPU 缓存 线程 内存 排序 安全性

Java核心知识体系1:泛型机制详解
Java核心知识体系2:注解机制详解
Java核心知识体系3:异常机制详解
Java核心知识体系4:AOP原理和切面应用
Java核心知识体系5:反射机制详解
Java核心知识体系6:集合框架详解

1 为什么需要多线程

我们都知道,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了优化,主要体现为:

  • CPU增加了缓存,均衡了与内存之间的速度差异,但会导致可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异,但会导致原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用,但会导致有序性问题

从上面可以看到,虽然多线程平衡了CPU、内存、I/O 设备之间的效率,但是同样也带来了一些问题。

2 线程不安全案例分析

如果有多个线程,对一个共享数据进行操作,但没有采取同步的话,那操作结果可能超出预想,产生不一致。
下面举个粒子,设置一个计数器count,我们通过1000个线程同时对它进行增量操作,看看操作之后的值,是不是符合预想中的1000。

public class UnsafeThreadTest {

    private int count = 0;

    public void add() {
        count += 1;
    }

    public int get() {
        return count;
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadNum = 1000;
    UnsafeThreadTest threadTest = new UnsafeThreadTest();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorSvc = Executors.newCachedThreadPool();
	// 执行并发计数
    for (int idx = 0; idx < threadNum; idx ++) {
        executorSvc.execute(() -> {
            threadTest.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
	// 关闭线程池
    executorSvc.shutdown();
    System.out.println("最终计数:" + threadTest.get());
}
最终计数:994  // 结果跟预期的 1000 不一样

3 并发出现问题的原因

可以看到,上述代码输出的结果跟预期的 1000 不一样,我们需要理清楚发生了什么问题?
★ 并发三要素:可见性、原子性、有序性

3.1 可见性:由CPU缓存引起

CPU缓存是一种高速缓存,用于存储CPU最近使用的数据。由于CPU缓存比主存储器更快,因此CPU会尽可能地使用缓存,以提高程序的性能。但是,这也会导致可见性问题。
可见性问题是指当一个线程修改了一个共享变量的值时,另一个线程可能无法立即看到这个修改。

我们举个简单的例子,看下面这段代码:

// 主存中 index 的值默认为 10
System.out.println("主存中的值:" + index);

// Thread1 执行赋值
index = 100;
 
// Thread2 执行的
threadA = index;

因为Thread1修改后的值可能仍然存储在CPU缓存中,而没有被写回主存储器。这种情况下,Thread2无法读取到修改后的值,所以导致错误信息。
具体来说,当多个线程同时运行在同一个处理器上时,它们共享该处理器的缓存。如果一个线程修改了某个共享变量的值,该值可能被存储在处理器缓存中,并且未被立即写回到主存储器中。
因此,当另一个线程试图读取该变量的值时,它可能会从主存储器中读取旧的值 10,而不是从处理器缓存中读取已更新的值 100。

3.2 原子性: 由分时复用引起

原子性:原子性是指一个操作在执行过程中不可分割,即该操作要么完全执行,要么完全不执行。

我们举个简单的例子,看下面这段代码:


// 主存中 index 的值默认为 10
System.out.println("主存中的值:" + index);

// Thread1 执行增值
index += 1;
 
// Thread2 执行增值
index += 1

以上的信息可以看出:

  • 主存的值为10
  • i += 1 这个操作实际执行三条 CPU 指令
    • 变量 i 从内存读取到 CPU寄存器;
    • 在CPU寄存器中执行 i + 1 操作;
    • 将最后的结果i写入内存,因为有缓存机制,所以最终可能写入的是 CPU 缓存而不是内存。
  • 由于CPU分时复用(线程切换)的存在,Thread1执行了第一条指令后,就切换到Thread2执行,Thread2全部执行完成之后,再切换会Thread1执行后续两条指令,将造成最后写到内存中的index值是11而不是12。

3.3 有序性: 重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。

重排序(Reordering)是指在计算机系统中,由于处理器优化或编译器优化等原因,导致指令执行的顺序与程序代码中的顺序不一致。重排序可能会引起有序性错误,即在并发或多线程环境中,程序执行的顺序与代码的先后顺序不一致,导致程序结果不正确或出现意外的结果。

我们举个简单的例子,看下面这段代码:

int idx = 10;
boolean isCheck = true;
idx += 1;                // 执行语句1  
isCheck = false;          // 执行语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行操作。
从代码顺序上看,执行语句1是在执行语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。

重排序(Reordering)是指在计算机系统中,由于处理器优化或编译器优化等原因,导致指令执行的顺序与程序代码中的顺序不一致。重排序可能会引起有序性错误,即在并发或多线程环境中,程序执行的顺序与代码的先后顺序不一致,导致程序结果不正确或出现意外的结果。

重排序引起的有序性错误主要有以下几种情况:

  1. 指令重排序:处理器为了优化程序的执行,可能会对指令进行重排序。这种重排序不会改变单线程程序的执行结果,但可能会影响多线程程序的行为。例如,一个线程修改了一个共享变量的值,但由于指令重排序,另一个线程在读取该变量时可能读取到过时的值。
  2. 内存访问重排序:处理器为了提高程序的执行效率,可能会对内存访问进行重排序。例如,一个线程先读取一个共享变量的值,然后再写入该值,但由于内存访问重排序,处理器可能会先执行写入操作,再执行读取操作,从而导致其他线程无法正确地读取到修改后的值。
  3. 同步操作重排序:在并发或多线程环境中,同步操作可能会被重排序。例如,一个线程先释放了一个锁,然后再执行另一个操作,但由于同步操作重排序,释放锁的操作可能会先于另一个操作执行,从而导致其他线程无法正确地获取锁。

image

为了避免重排序引起的有序性错误,可以采用一些同步机制来确保程序的执行顺序,如内存屏障(Memory barrier,intel 称为 memory fence)、指令fence等。这些同步机制可以确保指令的执行顺序与代码中的顺序一致,避免指令重排序和内存访问重排序等问题。同时,也可以使用串行化(Serialization)或事务内存(Transactional memory)等技术来保证并发程序的有序性。

4 总结

  • CPU、内存、I/O 设备的速度是有极大差异的,多线程 的实现是为了合理利用 CPU 的高性能,平衡这三者的速度差异
  • 多线程情况下,并发产生问题的三要素:可见性、原子性、有序性
    • 可见性:由CPU缓存引起
    • 原子性: 由分时复用引起
    • 有序性: 重排序引起

标签:执行,Java,CPU,缓存,线程,内存,排序,安全性
From: https://www.cnblogs.com/wzh2010/p/17855905.html

相关文章

  • Java面试小练(四)
    请描述GET请求方式与POST请求方式的区别?post比get更安全,发送数据更大get和post都是http和服务器交互的方式get会将请求的数据放在url中,http协议头,中间用?来链接,用&来相连数据,中文会进行url加密post会将数据放在http的包体内发送get请求数据放在url,理论上没有大小限制,但是浏......
  • Java语言基础知识全总结
    一.Java的优点1.      跨平台性。一次编译,到处运行。Java编译器会将Java代码编译成能在JVM上直接运行的字节码文件,C++会将源代码编译成可执行的二进制代码文件,所以C++执行速度快2.      纯面向对象。Java所有的代码都必须在类中书写。C++兼具面向对象和面向过程的特......
  • 多线程网络通信
    当多客户端同时连接和服务端持续监听时,涉及到多线程,每当有新的客户端连接时,就创建一个新的线程来处理与该客户端的通信,从而允许服务器端同时与多个客户端建立连接。直接贴代码:1//main.cpp2#include<QCoreApplication>3#include<iostream>4#include<QDebug>5#......
  • Java开发者的Python快速进修指南:实战之简易跳表
    前言之前我已经将Python的基本语法与Java进行了比较,相信大家对Python也有了一定的了解。我不会选择去写一些无用的业务逻辑来加强对Python的理解。相反,我更喜欢通过编写一些数据结构和算法来加深自己对Python编程的理解。学习任何语言都一样。通过编写数据结构和算法,不仅可以加......
  • 加固数据安全:Java助力保护Excel文件,让数据无懈可击
    前言Excel文件保护是常用的一种功能,文件保护主要有三种:添加密码,如果没有密码不允许打开文件。添加密码,如果没有密码,不能修改文件,但可以打开,只读以及另存文件。只读推荐,通常推荐打开Excel文件的用户使用只读模式打开,这种方式仅是一种提示,并非强行保护文件。给Excel添加保护情况1:下面......
  • Java零基础入门-输入
    前言Java作为一门大型的编程语言,拥有广泛的应用领域和众多的开发人员。对于初学者来说,掌握Java的重点知识点非常重要,其中输入是Java中基础的操作之一。本文将介绍Java中输入的相关知识点,帮助初学者快速掌握Java。摘要本文将介绍Java中输入的相关知识点,包括输入的方法,使用示例以......
  • JavaScript
    JavaScript是一种高级解释性脚本语言,已得到广泛使用,是Web开发的重要工具。它由NetscapeCommunicationsCorporation、Mozilla基金会和ECMAInternational开发。它易于学习和实施,并允许开发人员增强网页以提供身临其境的用户体验。JavaScript入门非常简单,您只需要一个用......
  • 集合框架(一) Java中Map的常见方法的使用及循环的五种方式
    Map是Java中的一种集合,它是一种键值对的映射表,可以根据键快速获取对应的值。@[toc]##1.常见使用方式以下是Java中Map的常见方法使用示例及运行结果:###1.1存储键值对使用put()方法向Map中添加键值对:```javaMap<String,Integer>map=newHashMap<>();map.put("apple",10......
  • @SpringbootTest报错 javax.websocket.server.ServerContainer not availableJ情况解
    在使用springboot单元测试出现:11:11:10.799[main]ERRORo.s.b.SpringApplication-[reportFailure,870]-Applicationrunfailedorg.springframework.beans.factory.BeanCreationException:Errorcreatingbeanwithname'serverEndpointExporter'definedincla......
  • Java程序员必备技能:Collections工具类深度解析!
    在之前的文章中,我们学习了单列集合的两大接口及其常用的实现类;在这些接口或实现类中,为我们提供了不少的实用的方法。本篇文章我们来介绍一种java开发者为我们提供了一个工具类,让我们更好的来使用集合Collections工具类Collections是一个操作Set,List,Map等的集合工具类它提......