文章

Java多线程理论基础

本部分主要介绍多线程的理论部分,主要有:

  • 锁:为什么要有锁?从概念上,锁有哪些类型
  • CAS(Compare And Swap):CAS是什么?如何实现
  • JMM(Java Memory Model):Java内存模型告诉我们如何安全规范地进行多线程编程

下一章节介绍Java中多线程工具的使用。

多线程主要是为了更加充分的利用CPU资源,让多个线程协调运行,提高程序运行效率。进行多线程编程时,主要面临不同线程间资源竞争的问题,例如,当一个变量 x=0 两个线程都可以访问到,我们想让线程1执行x + 1,然后线程2执行 x - 1 ,最终预期结果是 0,但是在多线程场景下可能出现 1, 0, -1三个结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
情况1:
thread1:取出 x,此时 x=0
thread1:执行 x + 1
thread2:取出 x,此时 x=1
thread2:执行 x - 1
结果 x = 0

情况2:
thread2:取出 x,此时 x=1
thread2:执行 x - 1
thread1:取出 x,此时 x=0
thread1:执行 x + 1
结果 x = 0

情况3:
thread1:取出 x,此时 x=0
thread2:取出 x,此时 x=0
thread1:执行 x + 1
thread2:执行 x - 1
结果 x = -1

情况4:
thread1:取出 x,此时 x=0
thread2:取出 x,此时 x=0
thread2:执行 x - 1
thread1:执行 x + 1
结果 x = 1

...

这些结果就是多线程之间进行资源争夺造成的。

我们想要实现多线程,又想要让多个线程按照我们期望的方式运行,该如何实现呢?各种语言的多线程包/库都是为了解决这个问题(还有其它问题)。

刚刚提到的例子中,结果不确定的主要原因是因为,当一个线程拿到一个资源时,并没有限制其它线程对该资源的访问。为了解决这个问题,设计了 “锁” 的概念,顾名思义,就是锁住一个资源,限制其它线程的访问。锁按照不同的标准,进行以下分类:

image-20220525212721156

各种锁的概念

1. 乐观锁VS悲观锁

如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。

乐观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。

悲观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。

不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

2. 自旋锁 VS 适应性自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

image-20220525213859276

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

通过上面的例子不难发现,自旋锁就是等待CPU,而不是阻塞当前线程。

自旋锁:自旋锁是一种忙等待锁。当一个线程尝试获取已被占用的锁时,它会不断循环检查锁的状态,直到锁被释放。

适应性自旋锁:适应性自旋锁是自旋锁的改进版本,根据锁的持有时间和等待线程的行为动态调整自旋策略。

3. 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

无锁

  • 定义:无锁编程是一种并发编程技术,通过原子操作(如CAS,Compare-And-Swap)来实现线程安全,而不使用传统的锁机制。
  • 特点:
    • 原子操作:使用CAS等原子操作来确保线程安全。
    • 无阻塞:线程不会因为等待锁而阻塞,提高了并发性能。
    • 复杂性:实现无锁数据结构通常比较复杂,需要处理各种竞争条件。
  • 适用场景:
    • 高并发、低竞争的场合。
    • 需要极高性能的场景。

偏向锁

  • 定义:偏向锁是一种优化机制,假设锁通常由同一个线程多次获取,因此在该线程第一次获取锁时,会记录线程ID,后续该线程再次获取锁时无需同步操作。
  • 特点:
    • 单线程优化:适用于锁主要由单个线程持有的场景。
    • 快速获取:同一线程再次获取锁时,无需进行CAS操作,直接获取锁。
    • 锁撤销:当有其他线程竞争锁时,偏向锁会升级为轻量级锁。
  • 适用场景:
    • 单线程或低并发场景。
    • 锁主要由同一个线程多次获取的情况。

轻量级锁

  • 定义:轻量级锁是一种基于CAS操作的锁机制,适用于多个线程交替获取锁的场景。当线程尝试获取锁时,会通过CAS操作将对象头中的标记字段替换为指向锁记录的指针。
  • 特点:
    • CAS操作:通过CAS操作来实现锁的获取和释放。
    • 低开销:相比重量级锁,减少了线程阻塞和上下文切换的开销。
    • 锁膨胀:当多个线程竞争锁时,轻量级锁会升级为重量级锁。
  • 适用场景:
    • 低并发、交替获取锁的场景。
    • 锁持有时间短的场合。

重量级锁

  • 定义:重量级锁是传统的互斥锁机制,当多个线程竞争锁时,未获取到锁的线程会被阻塞,进入等待队列,直到锁被释放。
  • 特点:
    • 线程阻塞:未获取到锁的线程会被阻塞,进入等待状态。
    • 上下文切换:涉及线程的阻塞和唤醒,导致上下文切换开销。
    • 高开销:相比其他锁机制,重量级锁的开销较大。
  • 适用场景:
    • 高并发、锁竞争激烈的场景。
    • 锁持有时间较长的场合。

4. 公平锁VS非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

5. 可重入锁 VS 非可重入锁

可重入锁(Reentrant Lock)和非可重入锁(Non-Reentrant Lock)是多线程编程中用于同步的两种锁机制,主要区别在于是否允许同一个线程多次获取同一把锁。


可重入锁(Reentrant Lock)

  • 定义:可重入锁是指同一个线程可以多次获取同一把锁,而不会造成死锁。每次获取锁后,锁的持有计数会递增;释放锁时,持有计数递减,直到计数为0时,锁才会完全释放。

  • 特点

    • 重入性:同一个线程可以多次获取同一把锁。

    • 持有计数:锁内部维护一个持有计数,记录锁被同一个线程获取的次数。

    • 避免死锁:在递归调用或嵌套同步的场景中,可重入锁可以避免死锁。

    • 灵活性:可重入锁通常支持公平锁和非公平锁模式。

  • 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    import java.util.concurrent.locks.ReentrantLock;
      
    public class ReentrantLockExample {
        private static final ReentrantLock lock = new ReentrantLock();
      
        public static void main(String[] args) {
            new Thread(() -> {
                lock.lock(); // 第一次获取锁
                try {
                    System.out.println("First lock acquired");
                    lock.lock(); // 第二次获取锁(重入)
                    try {
                        System.out.println("Second lock acquired");
                    } finally {
                        lock.unlock(); // 释放第二次获取的锁
                    }
                } finally {
                    lock.unlock(); // 释放第一次获取的锁
                }
            }).start();
        }
    }
    
  • 输出

    1
    2
    
    First lock acquired
    Second lock acquired
    
  • 适用场景

    • 递归调用:在递归函数中需要加锁的场景。

    • 嵌套同步:在嵌套的同步块中需要多次获取同一把锁的场景。

    • 需要公平性或非公平性锁的场景。

非可重入锁(Non-Reentrant Lock)

  • 定义
    • 非可重入锁是指同一个线程不能多次获取同一把锁。如果线程尝试再次获取已经持有的锁,会导致死锁。
  • 特点

    • 不可重入:同一个线程不能多次获取同一把锁。

    • 简单实现:非可重入锁的实现通常比可重入锁简单。

    • 潜在死锁:在递归调用或嵌套同步的场景中,非可重入锁会导致死锁。

  • 示例

    假设我们有一个简单的非可重入锁实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    public class NonReentrantLock {
        private boolean isLocked = false;
      
        public synchronized void lock() throws InterruptedException {
            while (isLocked) {
                wait(); // 如果锁已被占用,当前线程等待
            }
            isLocked = true; // 获取锁
        }
      
        public synchronized void unlock() {
            isLocked = false; // 释放锁
            notify(); // 唤醒等待的线程
        }
    }
    
  • 使用非可重入锁时,如果同一个线程尝试多次获取锁,会导致死锁:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    public class NonReentrantLockExample {
        private static final NonReentrantLock lock = new NonReentrantLock();
      
        public static void main(String[] args) throws InterruptedException {
            new Thread(() -> {
                try {
                    lock.lock(); // 第一次获取锁
                    System.out.println("First lock acquired");
                    lock.lock(); // 尝试第二次获取锁(会导致死锁)
                    System.out.println("Second lock acquired");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.unlock();
                }
            }).start();
        }
    }
    

输出

程序会卡在第二次获取锁的地方,因为非可重入锁不允许同一个线程多次获取锁,导致死锁。

适用场景

  • 简单的同步场景,不需要递归或嵌套锁。
  • 对性能要求极高且不需要重入特性的场景。

3. 可重入锁 vs 非可重入锁

特性可重入锁(Reentrant Lock)非可重入锁(Non-Reentrant Lock)
重入性支持,同一个线程可以多次获取锁不支持,同一个线程只能获取一次锁
死锁风险无,适合递归和嵌套同步有,递归或嵌套同步会导致死锁
实现复杂度较复杂,需要维护持有计数较简单,无需维护持有计数
性能略低,因为需要维护额外状态较高,实现简单
适用场景递归、嵌套同步、复杂同步逻辑简单同步场景

总结

  • 可重入锁:适合需要递归或嵌套同步的场景,避免死锁,但实现复杂。
  • 非可重入锁:适合简单同步场景,实现简单,但在递归或嵌套同步中会导致死锁。

在实际开发中,可重入锁(如Java的ReentrantLock)更为常用,因为它更灵活且安全。非可重入锁通常用于特定场景或自定义锁实现。

6. 独享锁VS共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

CAS详解

CAS算法简介

在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是UnsafeUnsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。

sun.misc.unsafe类提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。

java.util.concurrent.atomic 包提供了一些用于原子操作的类,它依赖于工具类sun.misc.unsafe提供的CAS操作。

JUC原子类概览

CAS算法存在的问题?

ABA问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:

  1. 延迟流水线执行指令pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
  2. 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量原子操作

CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。

除了 AtomicReference 这种方式之外,还可以利用加锁来保证。

JMM详解

JMM 即 Java Memory Model,Java内存模型,与Java并发编程有关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

编译器、操作系统、CPU可能会对我们写的代码进行优化,重新排序。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。

  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。

  • 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。

    内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

不要混淆JMM 和 JVM内存结构

JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。

Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

为什么需要JMM?

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

为什么要遵守这些并发相关的原则和规范呢?

这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

JMM的设计

JMM有点像是一个规范,告诉程序员,当多线程存在变量共享时,应该如何实现不同线程访问/修改共享变量。

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。

  • 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

    ThreadLocal就是利用了本地内存。

JMM(Java 内存模型)

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:

  • 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  • 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障。不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:

  • 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  • 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
  • ……

后面还有…感觉也记不住…,去看原文吧

原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

在 Java 中,可以借助synchronizedvolatile 以及各种 Lock 实现可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

我们上面讲重排序的时候也提到过:

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

在 Java 中,volatile 关键字可以禁止指令进行重排序优化。

参考文章

https://willpast.github.io/pages/java-thread-x-lock-all/#_0-%E5%89%8D%E8%A8%80

https://javaguide.cn/java/concurrent/optimistic-lock-and-pessimistic-lock.html#%E4%BB%80%E4%B9%88%E6%98%AF%E4%B9%90%E8%A7%82%E9%94%81

本文由作者按照 CC BY 4.0 进行授权