99%的人答不对的并发题(从JVM底层理解线程安全,硬核万字长文)

众所周知,java 是一门可以轻松实现多线程的语言,再加之目前的社会环境和业务需求,对多线程的使用和高并发的场景也越来越多,与之带来的就是并发安全的问题。如何在多线程的环境下写出符合业务需求的代码,是程序员的基本功。而理解 JVM 中的线程特性,则是我们扎实根底的第一步。

学习忌浮躁。

首先大家要知道一点,JVM 帮我们屏蔽了不同的操作系统的不同特性,来实现一次编写到处运行的特点。所以有个很关键的点,在不同的平台和情况下,运行的结果可能会因此不同。
因此在此声明,下列代码的运行环境是在 Windows 的 64位 JDK 上,默认不带参数。

可见性

多个 demo 示例(检查你能否全部答对并理解)

先给大家看一段剪短的代码案例,如果你能了解正确结果,那你对可见性的理解应该是比较到位的。

demo 示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                while(demo.isRunning){
                    System.out.println(demo.i++);
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

demo1
也许答案和你想的一样,但是也许你只是碰巧答对。

现在,我将依次修改代码中的几个小位置,看看你能否分别答对每一种情况。

demo 示例(将子线程中的 print 方法移除)

// System.out.println(demo.i++);
demo.i++;

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

demo2
我们可以发现,这段程序即使在 isRunning 变量设置为 false 之后仍然不断运行。我还可以告诉你,即使再过很久很久,这个程序它仍然会正常执行。它已经陷入一个死循环。
原理我在后文解释,稍安勿躁,再看下一个小程序。

demo 示例(synchronized 包裹 while 循环)

synchronized (this) {
    while (demo.isRunning) {
        demo.i++;
    }
}

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                synchronized (this) {
                    while (demo.isRunning) {
                        demo.i++;
                    }
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

结果是不会停
demo6

demo 示例(synchronized 添加在 while 循环内)

while (demo.isRunning) {
    synchronized (this) {
        demo.i++;
    }
}

完整示例

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                while (demo.isRunning) {
                    synchronized (this) {
                        demo.i++;
                    }
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

发现程序正常结束
demo7

demo 示例(isRunning 添加 volatile 关键字)

// boolean isRunning = true;
volatile boolean isRunning = true;

完整示例

public class Demo1Visibility {
    int i = 0;
    volatile boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

demo3
发现程序已经正常退出。

demo 示例(虚拟机添加参数 -client)

demo4
此时关键代码

// 没有volatile
boolean isRunning = true;
// 没有print
while(demo.isRunning){
    demo.i++;
}

我可以告诉你,不添加参数,在默认情况下,等同于添加参数 -server。也就是 JVM 默认是作为服务器条件去运行的。
然后给大家看结果。同样也不会停。
demo5
大家可能发现,和之前的同样代码相比,并没有什么变化。
于是,在代码和参数相同的情况下,我再给大家看一下运行在另一台机器 JVM 上的结果。
demo3
然后你就会发现结果就不一样了。程序正常退出这是在 32位的 JDK 上运行的。

所以你不要觉得自己好像什么都懂的样子,学习要保持一颗谦逊的心态,才能更多的汲取知识。(要是你全答出来了当我没说,跪拜大佬)

对案例的分析

多线程的问题

多以这就可以体现出多线程所表现出的各种问题的特点:

  1. 所见非所得
  2. 无法用肉眼检测程序的准确性
  3. 不同的运行平台有不同的表现
  4. 错误很难重现

这就涉及到线程间的可见性问题,我们通过上面的代码发现。我们的一个主线程修改了变量的值,但是另一个线程并没有发现,所以导致了多线程中的线程安全问题。

Java 语言规范

要理解线程安全,我们就得首先去了解我们的 JVM。

我们的 java 源代码被编译,可以在任何 Java 虚拟机上运行,比如说最常用的 HotSpot,还有其他的很多虚拟机。但是我们的 java 程序,在这些虚拟机上运行,得到的结果是一样的,这是怎么做到的呢。
要做到这样,就需要定义出一个规范。要实现一个虚拟机,都必须实现这个 Java 虚拟机规范。由于受到规范的约束,所以不会出现各种不同的结果。
同样的,不仅仅只是 Java 语言可以在虚拟机上运行,还有很多其它语言,同样的,这些语言都有自己的语言规范。

很多人可能分不清楚 Java 内存模型和 JVM 运行时数据区,把它们当做同一种东西。实际上不是的。我们要分清楚,就必须了解:
Java 内存模型是《Java 语言规范中的概念》,
而 JVM 运行时数据区是《Java 虚拟机规范》中的概念。
JVM
对于我们的 Java 多线程程序,是谨遵《Java 语言规范的》。它包括了 当多线程修改了共享内存中的值时,应该读取到哪个值的规则。
所以我们的 Java 程序结果会是什么样子,在《Java 语言规范中》都已经定义好了。我们只要了解了它的规范,就会知晓程序运行的结果。

我们来看之前的程序,在没有加其他任何关键字的时候,程序将无法退出。
按照道理,主内存修改了 isRunning 的值,会将其写入主内存,然后子线程再将其读取到。
但是无法退出,可以分析出其中一定是某个环节出了问题。要么是写入主内存没有成功,要么是读取数据没有成功。
demo

高速缓存

可能大家立刻会想到由于计算机高速缓存带来的可见性问题。
首先可以明确的是,我们的 Java 程序是运行在内存中的。由于高速缓存的存在,我们的多线程环境很容易出现数据不一致的情况。因为一个数据的写要多一步从缓存同步到内存,而读要多一步从内存同步到缓存。
可见性:一个数据的写不能被另一个线程立即发现
huancun
但是下面我们继续分析,我们今天看到的程序不能够停止,是不是高速缓存导致的呢?
显然不是。

因为我们的 CPU 如果要有高速缓存,那就得遵循缓存一致性协议,不然这个 CPU,它就是不合格的。
缓存一致性协议有很多,最常见的 Intel 的 MESI 协议:
MESI协议,它规定每条缓存有个状态位,同时定义了下面四个状态:

  • 修改态(Modified)— 此 cache 行已被修改过(脏行),内容已不同于主存,为此 cache 专-有;
  • 专有态(Exclusive)— 此 cache 行内容同于主存,但不出现于其它 cache 中;
  • 共享态(Shared)— 此 cache 行内容同于主存,但也出现于其它 cache 中;
  • 无效态(Invalid)— 此 cache 行内容无效(空行)。

多处理器时,单个 CPU 对缓存中数据进行了改动,需要通知给其他 CPU。
也就是意味着,CPU 处理要控制自己的读写操作,还要监听其他 CPU 发出的通知,从而保证 最终一致性

所以如果是高速缓存,在缓存一致性的作用下,即使出现不一致,它的数据同步也会在很短的时间内完成,而不可能会出现无止无尽的循环而无法终止。
(所以高速缓存存在可见性问题,但这里不是高速缓存的锅)

指令重排序

那我们得继续思考。相信大家都记得,在学习计算机的知识的时候都明白 CPU 指令重排的概念。
我在《Java 并发基础总结》提到过

指令重排的场景:当 CPU 写缓存时 发现缓存区块正被其他 CPU 占用,为了提高 CPU 处理性能,可能将后面的 读缓存命令优先执行
但也并非随便重排,需要遵循 as-if-serial 语义
as-if-serial 语义的意思是指:不管怎么重排序(编译器和处理器为了提高并行速度),(单线程)程序的执行结果不能被改变。编译器,runtime 处理器都必须遵守 as-if-serial 语义。
也就是说:编译器和处理器 不会对存在数据依赖关系的操作做重排序

如图:
指令重排
虽然保证了单线程的一致,但并不保证多线程的运行情况一致,多线程的各种运行状态是被允许的,这需要程序员自己的代码去实现多线程的统一。
指令重排
但是不仅仅只有 CPU 会指令重排,Java 编译器 也会 进行指令重排。
但是注意,我这里指的不是 javac 编译器,javac 编译器在将 .java 文件编译为 class 字节码的时候,是不会进行任何优化的。

JIT 编译器

Java 基础比较好的同学应该都知道有一个 JIT 编译器(Just In Time Compiler)。
要解释它,首先我们得明白,不管是编译后执行,还是解释执行的语言,它们最终都是成为机器码在计算机上运行。
解释执行就是将语言一条一条翻译成机器码去执行。
而编译执行是将代码一次性编译成机器码给机器执行。

而 java 是一门很特殊的语言。(它既包含编译,也包含解释)
首先,我们的 java 代码会被 javac 编译器编译成 class 字节码(也就是我们常说的 ca fe ba be),然后在虚拟机上解释执行。但是,由于有些代码重复次数很多,每一次解释执行的效率不高,因此会触发 JIT 即时编译器对重复代码进行编译,这样之后就不用再费力解释了。
JIT
不过要注意的是,我们的 JIT 在编译时会做很多的 性能优化
比如这里,在 JIT 编译时,发现,由于每次这里传入的参数都是 true,为了提高性能,在编译时将代码的读取部分删减,直接进入死循环,来提高性能。
所以 JIT 编译存在 过于激进 的情况
JIT
我们在之前的案例中还有一些因为 JDK 32位、64位,server、client 出现的不同情况。这是由于它们的优化处理不一样。

可见性

线程间操作

线程间操作的定义:
一个程序执行的操作可以被其他线程 感知 或被其他线程 直接影响
Java 内存模型只描述 线程间操作,不描述线程内操作,线程内操作按照线程内语义执行。

线程间操作有:

  • read(一般读,即非 volatile 读)
  • write(一般写,即非 volatile 写)
  • volatile read
  • volatile write
  • lock(锁 monitor)、unlock
  • 线程的第一个和最后一个操作
  • 外部操作

volatile

我们再继续回顾之前的案例。发现有几种情况可以让程序正常结束,一个最简单的方式就是在共享变量前加上 volatile 关键字。
这是为什么呢?
可能很多人都会回答是因为:volatile 保证可见性(顺便还补充对非原子操作仍不安全禁止指令重排序)。
不过为什么能做到可见性?、、可能就有很多人回答不上来。
volatile
要满足规定,就要有实现:

  1. 禁止缓存
  2. 对 volatile 相关指令不做重排序。

处理器提供了两个内存屏障指令(Memory Barrier)来实现。

  • 写内存屏障(Store Memory Barrier):
    在指令后面插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
    强制写入主内存,这种显示调用,CPU 就不会因为性能考虑二区对指令重排。
  • 读内存屏障(Load Memory Barrier):
    在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
    强制读取主内存内容,让 CPU 缓存与主内存保持一致,避免了缓存导致的一致性问题。

synchronized

对于同步规则的定义,除了 volatile,还有:
对于监视器 m 的解锁与所有后续操作对于 m 的加锁同步
所以,在我们的 demo 中,在添加了 synchronized 时,( print 方法也有 sync 关键字)程序也可以正常退出。

final

除了 volatile 和 synchronized 之外,final 也可以保证可见性
所有对 final 字段的读取,都将读取到正确的版本。
比如这个 demo,对 final 字段的读取不可能读取到默认值。

public class DemoFinal {
    final int x;
    int y;
    
    static DemoFinal f;

    public DemoFinal(){
        x = 3;
        y = 4;
    }
    static void writer(){
        f = new DemoFinal();
    }
    static void reader(){
        if (f!=null){
            int i = f.x;        //一定读到正确构造版本
            int j = f.y;        //可能会读到 默认值0
            System.out.println("i=" + i + ", j=" +j);
        }
    }
}

稍微修改一下代码,让 x 和 y 产生关联。
这时你去读取 y 的值,一定也是正确的版本,不会读取到赋值前的默认初始值0。

public class DemoFinal {
    final int x;
    int y;
    
    static DemoFinal f;

    public DemoFinal(){
        x = 3;
        y = x;
    }
    static void writer(){
        f = new DemoFinal();
    }
    static void reader(){
        if (f!=null){
            int i = f.x;        //一定读到正确构造版本
            int j = f.y;        //可能会读到 默认值0
            System.out.println("i=" + i + ", j=" +j);
        }
    }
}

原子性

案例

创建出 100 个线程,每个线程都对 i 进行 1000 次的累加,这样我们就需要 100000 的结果。

public class CounterTest {
    // 多线程对volatile变量执行++
    static volatile int i;
    // main
    public static void main(String[] args) throws InterruptedException {
        // 创建100个线程,每个线程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    i++;
            }).start();
        }
        Thread.sleep(6000); // 等待各个线程执行完毕
        System.out.println(i);     // 打印 i 的值
    }
}

实际结果
1
2
在这里插入图片描述
很明显这个值并不是我们期待的,这就涉及到我们的线程安全的原子问题。
虽然共享变量加上了 volatile 关键字,保证了可见性和禁止指令重排序,但是这由于是一个非原子操作,所以不能保证线程对共享变量的操作安全。

指令的执行过程

我们可以通过反编译看到这段指令的具体执行过程。
i++

  1. 首先从内存中将 i 的值装入操作数栈
  2. 将 1 装入操作数栈
  3. 将操作数栈的 i 和 1 相加
  4. 将结果返回内存中的 i 字段
    i++
    这时我们看多线程的环境。
    在一个线程读取后,还没有往回写,另一个线程就读取了。此时,两个线程读取的是同样的值,因此,写回的也是同一个值。所以,两个线程执行了两次 i++ 操作,却仅仅相当于执行了一次。
    i++
    要解决这种不安全的操作,一种方式是加锁。synchronized 关键字保证内部的方法具有原子性。
    或者采用原子类 AutomaticInteger,用 CAS 机制反复尝试。

内存交互操作

Java 内存模型中定义了以下 8 种操作来完成内存交互操作,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

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

我们可以发现,对于共享内存的读写,由于很多情况下都是非原子操作,其中有很多小的原子操作组成。所以在对共享变量读写时,容易产生线程安全问题。

synchronized

修改案例

public class CounterTest {
    // 多线程对volatile变量执行++
    static volatile int i;
    // 同步方法
    public static synchronized void add() {
        i++;
    }
    // main
    public static void main(String[] args) throws InterruptedException {
        // 创建100个线程,每个线程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    add();
            }).start();
        }
        Thread.sleep(6000); // 等待各个线程执行完毕
        System.out.println(i);     // 打印 i 的值
    }
}

观察结果我们发现,可以在多线程下得到正确的值。可见 synchronized 也确实保证了原子性。
sync

synchronized 原理

既然 synchronized 通过互斥,能实现原子性,那么它是如何实现的呢?
我们要探究这么几个问题:

  1. 加锁的状态如何记录?
  2. 状态会被记录到 synchronized 指定的对象中吗?
  3. 释放锁时如何唤醒阻塞的线程?

对象的存储方式

我们知道,除了基本类型,所有的对象都是由一个引用来进行操作的。我们可以通过一个引用,找到堆中的对象。这个对象由各种基本类型的值,也会有其它引用指向其他的对象。其中对象有一个对象头,有引用指向类信息。
object
这个大家肯定都不陌生,我们继续深入。
对象头有三个部分,一个指向类信息,一个是数组长度,还有一个就是这次的重点:Mark Word。
对象头
在《HotspotOverview》官方文档中关于 Mark Word 的详细图解:Mark Word
你别看它有很多行,实际上每行表示一种状态。

  • 01:未锁定、偏向锁
  • 00:轻量级锁
  • 10:重量级锁
  • 11:GC 标记,此处不作探讨

轻量级锁状态

首先,我们的对象开始是没有线程访问的,是无锁状态。
随后,有两个线程来访问,存在了线程竞争,此时锁成为轻量级锁。于是,两个线程开始抢锁:

  1. 首先,线程会把 Mark Word 中的 BitFields 那部分内存拷贝到线程内部。
  2. 线程用 CAS 操作,将 Mark Word 中的那部分内存替换为,指向自己拷贝的那部分内存的地址
    CAS
    设置成功后,线程内部也会有一个地址指向 Mark Word,来记录抢到了谁的锁。
    CAS
    不过我们都知道,只有一个线程会操作成功,另一个线程一定会失败。
  3. 没有成功的线程会重复 10 次(次数会动态调整)的自旋,重复 CAS 抢锁。
  4. 一直抢不到,或者期间有其他线程来抢锁会导致锁升级。
    3

重量级锁

升级为重量级锁那就无法靠之前的拷贝内存加 CAS 操作实现了,必须涉及到线程的阻塞与唤醒。这是就需要对象监视器(Object Monitor)出场了。
点开 Hotspot 虚拟机源码,我们可以看到:
objectmonitor
可见对象监视器并不是信口胡诌。
在膨胀为重量级锁后,对象的 Mark Word 就不能够再指向之前线程拷贝的内存地址,因为已经要开始涉及管理更多的线程。所以这时对象头中的 Mark Word 为指向 Object Monitor 的内存地址,标志位改为 10,获取锁的线程存储到 Object Monitor 的 owner 中,其他来抢锁的线程阻塞,进入 EntryList。
阻塞
此时,若是 t1 释放锁了,如果是原来轻量级锁,只需要将原来的对象头的内存重新拷贝回去即可,但现在,由于锁已经膨胀,Mark Word 的值已经改变,为了让线程安全的释放锁,就需要 CAS 去替换之前的内存:
如果 CAS 替换成功,表明锁未膨胀;
如果 CAS 失败,则表示锁已经膨胀,此时则需要去唤醒其他阻塞的线程。

但是 t1 释放锁之后,t2 一定能跟着获取到锁码?
答案是不一定的,synchronized 并不保证锁的公平。此时,若是来了一个 t3,他会和 t2 一起去争抢这把锁,若是 t2 抢夺失败,会回到队尾重新排队,等待下一次抢锁。
t3

偏向锁

不过实际上,在轻量级锁之前还有一个偏向锁。
在只有一个线程的情况下,一个线程去加锁之后,在代码结束后并不会立即释放锁,也就是持有偏向锁。以后这个线程重复来,就可以免去加锁解锁的开销。这样,就使得 synchronized 在单线程操作时也完全和没有 synchronized 有同样的性能(也不用加锁解锁)。

之前描述的都是没有开启偏向锁的场景,不过目前的 JDK 版本都是默认开启偏向锁的。
标志位 01 时本表示无锁,此时在内存中还有一个小标志位,如果为 1,则表示开启偏向锁,0 表示未开启。
此时线程来获取锁的偏向待遇时,只需要将自己的 Thread ID 存入 Mark Word,代表以后被偏向对待。
不过若有其他线程来,偏向锁就不复存在,就会升级为轻量级锁,以后的加锁解锁都是 CAS 操作。
同理,升级为重量级锁后,也无法降级,从此以后的加锁解锁都只能通过阻塞线程。
偏向锁

CAS

CAS 的特点

我们知道,用 synchronized 同步关键字底层是 JVM 实现的互斥锁,对于多线程的互斥操作,会导致线程阻塞。而线程的阻塞和唤醒是有很多开销的,而我们执行的方法仅仅只有 i++ 这样的操作,开销很小,所以程序执行的很多开销会花在线程的阻塞与唤醒上,这样程序的执行效率就不会高。
不过好在有 CAS。

CAS 不会阻塞线程,仅仅只是一个轻巧的指令,只会消耗一部分执行代码的 CPU。像这样仅仅执行 i++ 这样的简单代码,用 CAS 循环尝试几次很快就可以成功修改到变量,这样仅仅只是耗费了自旋的几个 CPU 的消耗,是远远小于阻塞带来的性能损耗的。
(不过要注意,要是线程很多,代码逻辑很复杂,CAS 的自旋就会持续很久很久,这个时候消耗的 CPU 就很厉害了,所以得阻塞起来。)

CAS:compare and swap,又言 compare and set。
类似于

public boolean compareAndSwap(int oldValue, int newValue) {
    if(i != oldValue)
        return false;
    i = newValue;
    return true;
}

用 Java 代码这么写肯定无法保证原子性,但是 CAS 的原理就相当于这样的代码。
只不过 CAS 属于硬件同步原语,处理器提供了基本内存操作的原子性。

这时候再看之前的代码,假设用 CAS,我们可以发现,同一时刻多线程对一个共享变量的修改,只有一个线程可以操作成功。
cas
这时候我们只要在 CAS 操作加上一个循环,重复操作,直到成功为止。

while(!compareAndSwap(i, i + 1)) {
    // 不成功就循环 成功就退出循环
}

CAS 修改案例

这时可以用 CAS 修改之前的案例
(用到了 Unsafe 这个类,由于这个类是不允许直接使用的,所以用反射获取)

public class CounterUnsafe {
    volatile int i = 0;

    private static Unsafe unsafe = null;

    //i字段的偏移量
    private static long valueOffset;

    static {
        //unsafe = Unsafe.getUnsafe();
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            unsafe = (Unsafe) field.get(null);

            Field fieldi = CounterUnsafe.class.getDeclaredField("i");
            valueOffset = unsafe.objectFieldOffset(fieldi);

        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    // i++ 的原子操作
    public void add() {
        //i++;
        for (;;){
            int current = unsafe.getIntVolatile(this, valueOffset);
            if (unsafe.compareAndSwapInt(this, valueOffset, current, current+1))
                break;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        CounterUnsafe counterUnsafe = new CounterUnsafe();
        // 创建100个线程,每个线程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    counterUnsafe.add();
            }).start();
        }
        Thread.sleep(6000); // 等待各个线程执行完毕
        System.out.println(counterUnsafe.i);     // 打印 i 的值
    }
}

可以发现结果确实很正确
在这里插入图片描述
不过上面的代码用到了 Unsafe,由于这个类 JDK 不推荐使用,我们应该用一个封装了 Unsafe 的原子类:AtomicInteger。

public class CountAtomic {
    // 原子类,有CAS,自增等操作
    static AtomicInteger i = new AtomicInteger();
    // main
    public static void main(String[] args) throws InterruptedException {
        // 创建100个线程,每个线程加1000次
        for (int j = 0; j < 100; j++) {
            new Thread(() -> {
                for(int k = 0; k < 1000; k++)
                    i.getAndIncrement(); // 相当于i++
            }).start();
        }
        Thread.sleep(6000); // 等待各个线程执行完毕
        System.out.println(i.get());     // 打印 i 的值
    }
}

atomic

CAS 原子类

juc
juc

有序性

有序性没有什么很复杂的内容可以说,这里我直接奉上《深入理解 Java 虚拟机》作者说的话。

Java内存模型的有序性在前面讲解 volatile 时也详细地讨论过 了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而synchronized则是由 “一个变量在同一个时刻只允许一条线程对其进行 lock 操作” 这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

发布了9 篇原创文章 · 获赞 123 · 访问量 4530

猜你喜欢

转载自blog.csdn.net/weixin_44051223/article/details/104797894