并发编程 -- 三大特性

目录

一、原子性

1、什么是并发编程原子性

2、保证并发编程的原子性

2.1 synchronized

 2.2 CAS (Compare And Swap -- 比较并交换)

2.3 Lock锁

2.4 ThreadLocal

二、可见性

1、什么是可见性呢?

CPU三级缓存

2、如何解决可见性问题

2.1 volatile

2.2 synchronized

1、Monitor锁的内存屏障

2.  Happens-Before 规则

3. 禁止指令重排序

2.3 Lock锁

内存屏障(Memory Barrier)

底层实现依赖

Happens-Before 规则

2.4 final

2.5 synchronized 和 Lock 解决并发编程可见性 的异同点

三、有序性

1、有序性是什么?

 2、有序性具体解决什么问题呢?

经典案例:双检锁(Double-Checked Locking)单例模式 

 3、如何保证有序性

3.1 内存屏障(如:volatile)

3.2 HappensBefore


问题1:三大特性的出现背景是什么?它出现是为了解决什么问题

        1、三大特性都是为了解决多线程环境下,数据不一致和线程安全问题。换句话说,就是为了保证程序符合预期的运行。

        2、所以出现的背景就是多线程不安全隐患,需要其解决问题


问题2:问题1中数据不一致具体指什么呢?线程安全问题是指什么呢?

        1、数据不一致问题:

             数据不一致问题是指多个线程同时修改共享数据时,由于缺乏同步机制,导致数据状态出现错误。常见表现包括:

  • 脏读(Dirty Read):一个线程读取了另一个线程未提交的数据。
  • 不可重复读(Non-repeatable Read):同一查询在不同时间返回不同结果。
  • 幻读(Phantom Read):一个事务读取到其他事务新增或删除的数据

举例说明:

单线程操作一份数据  : 读取数据 -> 修改数据 -> 回写数据。

多线程(2个及以上)操作同一份数据:

        线程A(功能:数据+1):读取数据 -> 修改数据 -> 回写数据

        线程B(功能:数据+1):读取数据 -> 修改数据 -> 回写数据

        假设取到的数据为100,当线程A、B 同时对数据进行+1 操作,我们预期的结果是经过线程A的“+1”操作后,再由线程B的“+1”操作,最终数据为102。但是若A、B取到的数据都是100(B在回写数据前就读取到了数据),此时的结果就是101了。

        2、线程安全问题:  

线程安全问题指的是在多线程环境下,多个线程同时访问共享资源时,可能导致程序行为异常或数据错误。常见问题包括:

  • 竞态条件(Race Condition):多个线程同时操作共享资源,执行顺序不确定,导致结果不可预测。
  • 数据竞争(Data Race):多个线程同时读写共享数据,且未正确同步,导致数据不一致。
  • 死锁(Deadlock):多个线程互相等待对方释放资源,导致程序无法继续执行。
  • 活锁(Livelock):线程不断重试某个操作,但始终无法取得进展。
  • 资源饥饿(Resource Starvation):某些线程因资源被长期占用而无法执行。

上述提到的问题就可以用这次要讲到的三大特性进行解决处理。

并发的三大特性: 原子性、可见性、有序性。

一、原子性

1、什么是并发编程原子性

        一个操作不可分割,不可中断,一个线程执行过程中,另一个线程无法对其造成影响。

代码体现(两个线程相互影响 导致结果和预期不符):


2、保证并发编程的原子性

先说结论:synchronized可以让避免多线程同时操作临界资源,同一时间点,只会有一个线程正在操作临界资源

2.1 synchronized

 加上synchronized后。

 2.2 CAS (Compare And Swap -- 比较并交换)

先说结论: CAS 本身就是原子性操作。

2.2.1 什么是CAS

CAS 就是比较和交换, 他是一条CPU的并发原语。

CAS只是比较和交换,在获取原值的这个操作上,需要自己实现

它在替换内存中某个位置的数据时,会先比较内存中的数据和预期的数据是否一致:

        若不一致就认为该数据已经被修改,就不再做修改。

        若一致就认为该数据是需要被修改的,就会将内存数据修改。

        重点:这个操作是原子性操作。

2.2.2 CAS的工作方式

CAS的工作方式可以通俗理解为一种“乐观锁”机制,他的核心思想:“先检查了了再动手,冲突了就重试”,就像我们平时生活中修改一份共享文档一样。

举个例子 🌰:

假设你和同事同时在线编辑一份文档,文档里有一个数字 当前值是100。现在你想把它改成 200,而你的同事可能也在修改它。CAS的工作方式是这样的:

  1. 你记住原值:你先看一眼文档,记住当前的值是 100

  2. 动手修改前再检查:当你点击保存时,系统会偷偷再检查一次文档的值:

    • 如果发现值还是100(没人改过),立刻改成 200,操作成功!

    • 如果发现值变成150(比如同事抢先改了),你的修改就会失败,系统提示你:“值变了,重试吧!”

  3. 失败就重试:你重新看一眼文档现在的值(比如150),再尝试改成 200,直到成功为止。

2.2.3 CAS的特点

  • 无锁:不需要像“锁门”那样阻塞其他人操作,大家都能随时尝试修改。

  • 原子性:检查值+修改值是一瞬间完成的,不会被中途打断。

  • 可能循环:如果多人频繁修改,可能需要多次重试(类似不断刷新页面直到能改成功)。

 2.2.4 CAS的优缺点

  • ✅ 优点:避免线程阻塞,性能高(尤其在低竞争场景)。

  • ❌ 缺点

    1、高竞争时可能“反复重试”(CPU空转)-- 自旋时间过长
    • 可以在CAS一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果。
    • 可以指定CAS一共循环多少次,如果超过这个次数,直接失败/或者挂起线程。(自旋锁、自适应自旋锁)

        2、需注意ABA问题(比如值从100→150→100,看起来没变,但中间其实被改过,可以用版本号解决)。AtomicStampedReference在CAS时,不但会判断原值,还会比较版本信息。

2.3 Lock锁

        Lock锁是JDK1.5时研发的。它比JDK1.5时期的synchronized性能要好上很多,但是在JDK1.6时对于synchronized优化后,性能就相差不大了,如果涉及并发比较多时,推荐使用ReentrantLock锁,性能会更好。

        ReentrantLock功能上比synchronized跟丰富

       ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。


2.4 ThreadLocal

ThreadLocal是一种线程隔离机制。提供了多线程环境下,对共享变量访问的安全性

  • 设计思路借鉴
    • ThreadLocal就是空间换时间
    • 线性探测解决hash冲突
    • 数据预清理机制
    • 弱引用KEY的涉及尽可能避免内存泄漏
ThreadLocal 实现原理:
  1. 每个Thread中都存储这一个成员变量 : ThreadLocalMap -通过这个容器存储共享变量的副本,每个线程只对自己的变量副本做更新操作,这样即解决了线程安全问题,又避免了多线程竞争锁的一个开销
  2. ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap
  3. ThreadLocalMap本身就是基于Entry[]实现的。因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式来实现。
  4. ThreadLocalMap -- key:ThreadLocal本身 ,对value进行存取
  5. ThreadLocalMap的key 是一个弱引用。
    1. 弱引用:即便有弱引用,在GC时,也必须被回收,这里时为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收。
ThreadLocal内存泄漏问题
  • 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果同时线程还没有被回收,就会导致内存泄漏,内存的value无法被回收,同时也无法被获取到。
  • 只需要在使用完毕ThreadLocal对象后,及时的调用remove方法,移除Entry即可。

       

二、可见性

1、什么是可见性呢?

CPU三级缓存

        可见性的问题是基于CPU三级缓存出现的。

        这个问题要基于CPU的三级缓存(不了解的可以点链接看看做一下初步了解)来考虑。现在CPU的都是多核,每个线程的工作内存(CPU三级缓存)都是独立的。每个线程做修改时,只会该自己的工作内存(CPU三级缓存),并没有及时同步回主内存,导致数据不一致。

JMM: java内存模型 (不理解的可以点链接去看看,做一个简单的了解)。

JMM 的核心功能是定义 线程与主内存的交互规则。

可见性的代码体现

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (flag) {
            // ....
        }
        System.out.println("t1线程结束");
    });

    t1.start();
    Thread.sleep(10);
    flag = false;
    System.out.println("主线程将flag改为false");
}

2、如何解决可见性问题

2.1 volatile

        先说结论:volatile就是让CPU每次操作数据时,必须立即同步到主内存,以及从主内存中读取数据。

        volatile是一个关键字,修饰成员变量。

        volatile修饰的变量。是不允许通过CPU缓存读写数据的,必须去主内存读写。

        volatile的内存语义

  • 读数据:当读一个volatile变量,JMM会将当前线程对应的CPU缓存中的内存设置为无效,必须取主内存中重新读取共享变量
  • 写数据:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中。

2.2 synchronized

在java中 synchronized 关键字不仅能保证代码块的 互斥执行(原子性),还能确保共享变量的可见性。

        synchronized 是通过内存屏障以及Happens-Before规则解决可见性问题的。这点和Lock锁有相似之处。

1、Monitor锁的内存屏障

        Monitor锁的获取(进入同步代码块)和释放(退出同步代码块)会隐式插入 内存屏障,强制线程与主内存之间的数据同步:

(1) 获取锁时(monitorenter 指令)

  • JVM 会插入 读屏障(Load Barrier),强制当前线程:

    • 从主内存重新加载所有共享变量的最新值,丢弃本地缓存中的旧值。

    • 确保后续操作基于最新数据执行。

(2) 释放锁时(monitorexit 指令)

  • JVM 会插入 写屏障(Store Barrier),强制当前线程:

    • 将所有修改过的共享变量刷新到主内存,确保其他线程能立即看到这些修改。

    • 禁止将临界区内的写操作重排序到释放锁之后。

2.  Happens-Before 规则

Java 内存模型(JMM)规定,synchronized 的锁释放和获取满足 Happens-Before 关系

  • 锁的释放 Happens-Before 锁的获取
    如果线程 A 释放锁,线程 B 随后获取同一把锁,那么线程 A 在释放锁之前的所有操作(包括对共享变量的修改)对线程 B 是可见的。

3. 禁止指令重排序

JVMCPU 会对代码进行指令重排序优化以提高性能,但 synchronized 会严格限制这种优化:

  • 临界区内的代码不会被重排序到加锁或解锁操作之外

       synchronized和lock 相同点和差异点会在可见性的最后有讲到。


2.3 Lock锁

        Lock锁是通过内存屏障以及Happens-Before规则解决可见性问题的,确保一个线程对变量的修改是对其他线程可见的。

  • 内存屏障(Memory Barrier)

先说结论: 释放锁之前,确保所有修改对其他线程可见;获取锁之后,强制从主内存中读取最新数据。

        读屏障(Load Barrier

        当一个线程获取锁(lock)时,Lock会插入一个读屏障(依赖volatile变量和CAS操作),强制该线程从主内存中获取共享变量的最新值,而不是从CPU缓存中取旧数据。

         写屏障(Store Barrier)

        当一个线程释放锁时,Lock的实现(如ReentrantLock)会插入一个写屏障(修改volatile状态变量,如AQS中的state),强制该线程在临界区(加锁代码块)中的所有写操作刷新到主内存中,而不是停留在CPU缓存中。

底层实现依赖
  • Lock 的实现(如 ReentrantLock)基于 AQS(AbstractQueuedSynchronizer),其内部使用 volatile 变量(如 state)和 CAS(Compare-And-Swap) 操作。

  • volatile 变量的读写天然包含内存屏障,而 CAS 操作通过 CPU 指令(如 LOCK CMPXCHG)隐式触发屏障。

  • Happens-Before 规则

先说结论:释放锁前的操作对后续获取锁的线程可见。

        锁的释放-Happens-Before-锁的获取

        如果线程A释放了锁,而线程B随后获取了同一把锁,那么线程A在是释放前的所有修改,对线程B一定时可见的。

        有序性保证

        临界区的操作不会被重排序到加锁或解锁操作之外,避免了编译器和CPU的指令重排导致的可见性问题。


    2.4 final

           因为final修饰的变量是不可修改的,所以就不存在修改数据导致可见性问题了。


    2.5 synchronized 和 Lock 解决并发编程可见性 的异同点

            1、共同机制

            二者均通过 内存屏障 和 Happens-Before 规则实现可见性的。

            2、实现差异

    • synchronized 依赖 JVM 的隐式管理(Monitor锁)
    • Lock 以来显示的 volatile 变量和CAS操作 (如AQS的实现)
    • synchronizedLock(如 ReentrantLock
      实现方式JVM 隐式管理(monitorenter/exit显式编码(基于 AQS + volatile + CAS)
      内存屏障JVM 自动插入屏障通过 volatile 变量和 CAS 隐式触发屏障
      灵活性不可中断、不可超时、仅非公平锁可中断、可超时、支持公平/非公平锁
      性能优化后与 Lock 接近(如锁粗化、偏向锁)在高度竞争场景下更灵活

            3、选择建议

    •         简单场景: synchronized(代码简洁,无需手动释放锁)
    •         复杂需求(如超时、公平性):Lock

    三、有序性

    聊有序性问题之前,我们要知道,JAVA中的程序是乱序执行的。

    1、有序性是什么?

    程序的执行顺序可能不会按照代码的顺序执行,因为编译器和处理器可能会对指令进行重排序,目的是优化性能,但是在多线程环境下,这种重排序可能导致意想不到的结果。

    例如,一个线程写入变量的顺序被改变,另一个线程看到的顺序可能不一致,从而引发错误。

     2、有序性具体解决什么问题呢?

    有序性确保多线程环境下,操作执行的顺序符合预期,避免因重排序引发的可见性问题或逻辑错误。

    🌰举例:

    双检锁单例模式中,如果没有正确的同步,可能会拿到未完全初始化的实例。这是因为指令重排序导致对象初始化步骤被重排,其他线程看到的是一个部分初始化的对象。

    经典案例:双检锁(Double-Checked Locking)单例模式 

    public class Singleton {
        private static Singleton instance;
        public static Singleton getInstance() {
            if (instance == null) {                    // 第一次检查
                synchronized (Singleton.class) {
                    if (instance == null) {            // 第二次检查
                        instance = new Singleton();    // 问题在此!
                    }
                }
            }
            return instance;
        }
    }

    • 问题new Singleton() 的步骤可能被重排序为:

      1. 分配内存空间。

      2. 将引用指向内存(此时 instance 不为 null)。

      3. 初始化对象。

    • 若线程 A 执行到步骤 2 后,线程 B 调用 getInstance(),将拿到未初始化的 instance,导致程序崩溃。

     3、如何保证有序性

    3.1 内存屏障(如:volatile)

    内存屏障是CPU或编译器提供的一种指令,用于 禁止特定类型的重排序,确保屏障前后的操作按顺序执行。

    屏障类型作用
    LoadLoad禁止屏障前的读操作与屏障后的读操作重排序。
    StoreStore禁止屏障前的写操作与屏障后的写操作重排序。
    LoadStore禁止屏障前的读操作与屏障后的写操作重排序。
    StoreLoad禁止屏障前的写操作与屏障后的读操作重排序(全能屏障,开销最大)。

    volatile 变量的操作会插入 StoreStore + StoreLoad 屏障。

    volatile 变量的操作会插入 LoadLoad + LoadStore 屏障。

    3.2 HappensBefore

    具体规则:

    1. 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。

    2. 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。

    3. volatile的happen-before原则: 对一个volatile变量的写操作happen-before对此变量的任意操作。

    4. happen-before的传递性原则: 如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。

    5. 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。

    6. 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。

    7. 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。

    8. 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。 JMM只有在不出现上述8中情况时,才不会触发指令重排效果。

    不需要过分的关注happens-before原则,只需要可以写出线程安全的代码就可以了。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值