一篇文章讲清楚Java并发理论基础

文章目录

    • 前言
    • 一、计算机操作系统的木桶效应
    • 二、CPU、内存和I/O设备之间的速度不匹配的问题解决
    • 三、并发出现线程不安全的根源:可见性、原子性和有序性问题
    • 四、JAVA是怎么解决并发问题的: JMM(Java内存模型)

前言

大家好,我是佩洛君,致力于新手友好地讲清楚Java开发问题的来龙去脉

一、计算机操作系统的木桶效应

有过电脑硬件设备DIY的小伙伴们都知道,CPU显卡内存主板,如果哪一个很拉,整个电脑就会跑的很慢。比如你的电脑显卡是4090Ti,而你的硬盘都是机械硬盘,那你的玩游戏的时候性能以及不行。
在计算机系统的设计中也是一样,CPU、内存和I/O设备之间的速度不匹配会导致多种性能瓶颈和问题,主要包括:

  1. CPU 等待内存操作:如果CPU的速度远远高于内存,那么在执行内存访问密集型任务时,CPU可能会花费大量时间等待内存数据。这种情况被称为内存瓶颈。
  2. CPU 等待I/O操作:当CPU需要等待I/O设备(如硬盘、网络接口等)完成数据传输时,会造成CPU的闲置,这种现象称为I/O瓶颈。
  3. 内存缓存失效:为了缓解CPU与内存的速度差异,引入了缓存机制。但是,当CPU频繁访问的数据不在缓存中时,会发生缓存缺失(cache miss),CPU需要等待从内存中读取数据,这会降低性能。
  4. 多核处理器中的负载不平衡:在多核CPU中,如果任务分配不均,某些核心可能会比其他核心更忙,导致负载不平衡,这会浪费CPU资源。
  5. 并发性和同步问题:为了提高效率,操作系统和程序员可能会尝试并发执行多个任务。然而,如果这些任务之间存在依赖关系,就需要适当的同步机制来避免竞态条件和其他并发问题。
  6. 系统整体性能下降:由于CPU、内存和I/O设备之间的速度不匹配,系统可能无法达到最佳性能,导致整体效率低下。

二、CPU、内存和I/O设备之间的速度不匹配的问题解决

为了解决这一类问题,CPU、内存和I/O设备之间的速度不匹配的问题,计算机体系结构、操作系统和编译器都采取了一系列措施:

  1. 首先,CPU增加了缓存机制,以缩小与内存速度的差距。缓存是位于CPU和主内存之间的小容量存储区域,它能够提供比主内存更快的访问速度。这种设计缓解了CPU与内存速度不匹配的问题,但也引入了缓存一致性问题,即当多个处理器核心或线程试图更新同一数据时可能会读取到旧值。
  2. 其次,操作系统引入了进程和线程的概念,以实现CPU的时分复用。通过这种方式,操作系统可以让多个任务轮流使用CPU,从而在等待I/O操作完成时有效地利用CPU资源。这种技术有效地均衡了CPU与I/O设备之间的速度差异,但也带来了原子性问题。原子性指的是操作的其他部分在执行过程中不能被中断,这在多线程环境中确保数据的一致性变得尤为复杂。
  3. 最后,编译器通过优化指令执行顺序,使得缓存能更有效地被利用。这种优化可以提高程序的运行效率,但也可能引起有序性问题,即程序的执行顺序可能与原始代码的顺序不同,这可能会影响多线程间的操作顺序和预期行为

三、并发出现线程不安全的根源:可见性、原子性和有序性问题

我们将为了解决CPU、内存和I/O设备之间的速度不匹配的问题而产生的三个小问题称为:

  1. 可见性问题
  2. 原子性问题
  3. 有序性问题

一个线程不安全的例子
我直接把demo 托管在 inscode,直接运行就可以看结果,代码在Main.java中:

并发出现问题的根源:

  • 非可见性
    • 问题
      • CPU缓存引起的可见性问题,通常是指在多核处理器中,由于各个核心可能拥有独立的缓存(如L1和L2),而这些缓存中的数据并不总是与主内存中的数据保持一致。这就可能导致了一个核心对数据的修改未能及时对其他核心可见,从而引发了一系列的同步问题。
    • 解决思路
      • 为了解决这个问题,CPU设计者通常会采用以下几种机制:
      1. 缓存一致性协议:比如MESI协议(Modified, Exclusive, Shared, Invalid),这是最常见的缓存一致性协议。它通过在缓存行上标记四种状态来保证多个处理器缓存之间数据共享的一致性。状态分别是:修改(Modified),独占(Exclusive),共享(Shared),无效(Invalid)。MESI协议确保了在一个处理器核心修改了一个缓存行时,其他核心不会读取到旧的数据。
      2. 缓存锁定(Cache Locking):在某些情况下,一个核心可能会锁定一个缓存行,防止其他核心修改或读取这个缓存行。这通常发生在对共享数据结构进行并发访问时。
      3. 写回(Write-back)和写通过(Write-through)策略:这些是缓存写入策略,用来决定何时将数据写回主内存。写回策略会延迟写入主内存,直到缓存行被替换或显式地写回主内存。而写通过策略则立即将数据写入主内存,确保了数据修改的可见性。
      4. 多核CPU的缓存层次:现代多核CPU通常有三级缓存,其中L1和L2是每个核心私有的,而L3是所有核心共享的。这样的设计既利用了缓存的速度优势,也减少了可见性问题,因为共享的L3缓存确保了一定程度的数据一致性。
      5. 软件层面的同步:程序员在编写并发程序时,也可以通过锁(Locks)、原子操作(Atomic operations)和内存屏障(Memory barriers)等同步机制来确保数据修改的可见性。
  • 非原子性
    • 问题
      • 在CPU分时复用(如时间片轮转调度)中,原子性问题通常指的是在多任务操作系统中,由于任务之间的切换和共享资源的使用不当,导致一个任务的执行意外地影响了其他任务的执行或共享资源的状态。这种问题在多线程环境中尤为常见,因为线程之间的切换和资源共享可能会导致数据竞争和不一致。
    • 解决思路
      • 为了解决CPU分时复用中的原子性问题,可以采取以下措施:
        1. 锁(Locks)和互斥量(Mutual Exclusions):在访问共享资源之前,线程必须先获取锁。如果锁已被其他线程持有,则线程会阻塞,直到锁被释放。这样可以确保同一时间只有一个线程能够访问共享资源。
        2. 原子操作(Atomic Operations):操作系统提供原子操作API,如原子加法、原子比较交换等,以确保对共享变量的操作是原子的,即不可中断的。
        3. 内存屏障(Memory Barriers):在某些情况下,为了防止指令重排或缓存一致性问题,需要使用内存屏障来确保特定内存操作的顺序和可见性。
        4. 无锁编程技术:使用无锁编程技术,如读写锁(Read-Write Locks)、原子引用计数等,来避免锁的开销,同时保证数据的一致性。
        5. 线程局部存储(Thread-Local Storage, TLS):TLS提供了一种机制,允许每个线程拥有自己的数据副本,从而避免共享数据。
        6. 消息传递并发模型:在这种模型中,线程通过发送和接收消息进行通信,而不是直接共享数据。这样可以减少数据竞争和原子性问题。
        7. 软件事务内存(Software Transactional Memory, STM):STM是一种管理共享内存并发控制的方法,它允许代码块在事务中执行,确保要么所有操作都成功提交,要么都不发生。
        8. 高精度定时器(High-Resolution Timers):使用高精度定时器来控制时间片的轮转,确保任务切换的准确性和公平性。
        9. 实时操作系统(Real-Time Operating Systems,RTOS):在实时操作系统中,可以提供更严格的任务调度和控制,以确保对实时性要求高的任务能够按时执行。
  • 非有序性
    • 问题
      • 假设语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。指令重排序是指在编译和执行过程中,编译器和处理器可能会改变指令的执行顺序,以提高执行效率和利用现代处理器的特性。这种重排序可能会在单线程程序中提高性能,因为它可以让处理器更有效地利用缓存和并行处理能力。然而,在多线程环境中,指令重排序可能会导致内存操作的顺序与程序代码中的顺序不一致,从而引发内存可见性和数据竞争的问题。
    • 解决思路
      • JVM通过以下几种机制来保证指令重排序不会对多线程程序造成负面影响:
        1. as-if-serial语义:这是Java内存模型(JMM)的一个关键特性,它要求编译器和处理器在重排序时遵守单线程程序的语义。也就是说,重排序后的执行结果必须与原始代码在单线程下的执行结果相同。
        2. 内存屏障:JVM在必要时会在代码中插入内存屏障(Memory Barrier),以确保特定的内存操作顺序。这些屏障可以防止处理器将写操作重排序到读操作之前,从而保证了内存操作的顺序与程序代码中的顺序一致。
        3. volatile关键字:在Java中,volatile关键字可以用来声明变量,以确保对该变量的读写操作都是原子的,并且每次访问变量都是直接从主内存中进行,而不是从缓存中读取。这有助于保证多线程环境下变量的可见性。
        4. 锁机制:Java提供了多种锁机制,如synchronized关键字和Lock接口,这些锁可以确保在多线程环境中对共享资源的访问是同步的,从而防止由于指令重排序导致的数据竞争。
        5. 原子操作:Java的java.util.concurrent.atomic包提供了一系列的原子操作类,这些类利用了底层的原子性保证,使得即使在高并发环境下,也能安全地操作共享变量。

那么我们总结下:
为了解决这一类问题,CPU、内存和I/O设备之间的速度不匹配的问题,让计算机运行起来更加流畅,采用了一些方法,但是造成了可见性原子性有序性的问题。这三个问题在普通用户不可见的层面已经解决,但是在Java中进行并发编程时,我们依然需要做一些措施,来解决上面三个问题。

四、JAVA是怎么解决并发问题的: JMM(Java内存模型)

Java内存模型(JMM,Java Memory Model)是Java虚拟机(JVM)的一部分,它定义了Java程序中变量的访问方式和内存的使用方式。JMM是一个抽象的概念,并不真实存在,它仅仅描述的是一组约定或规范。这些规范是为了屏蔽各种硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果

JMM的主要目标是定义程序中各个变量的访问规则,来保证JMM具有以下几个关键特性:

  1. 原子性(Atomicity):保证指令不会受到线程上下文切换的影响。
  2. 可见性(Visibility):保证指令不会受CPU缓存的影响。
  3. 有序性(Ordering):保证指令不会受CPU指令并行优化的影响。

JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:volatile、synchronized 和 final 三个关键字Happens-Before 规则。

1.原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。 请分析以下哪些操作是原子性操作:

x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3

上面4个语句只有语句1的操作具备原子性。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,
如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,
那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性
Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,
synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,
并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。

更具体的详见源码解读专栏中关于volatile、synchronized和Lock的源码解析。
之后会把链接贴在这里。

你可能感兴趣的:(面试,学习Java必看,java,开发语言)