Java 三大并大特性-有序性介绍(结合代码、分析源码)

目录

一、概念解析

二、 有序性代码例子

2.1 代码

2.2 执行结果

三、 指令重排序机制

3.1 为什么要引入指令重排序

3.2 指令重排序的分类

3.2.1 编译器优化重排序

3.2.2 指令级并行的重排序

3.2.3 内存系统的重排

3.3 指令重排序规范

3.3.1 as-if-serial 规范

3.3.2 happens-before 规范

3.3.2.1 先行发生原则适用场景

四、 Java 中保证有序性手段

4.1 volatile

4.1.1 DCL单例对象代码

4.1.2 测试结果

4.1.3 volatile有序性原理分析

4.1.3.1 使用volatile修饰DCL单例的原因

4.1.3.2 查看字节码

4.1.3.3 hotspot 层面

4.1.3.4 volatile 有序性原理总结


一、概念解析

是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

二、 有序性代码例子

2.1 代码

package com.ningzhaosheng.thread.concurrency.features.order;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 20:01:16
 * @description 测试有序性
 */
public class TestOrder {
    static int a, b, x, y;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            a = 0;
            b = 0;
            x = 0;
            y = 0;

            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

            if (x == 0 && y == 0) {
                System.out.println("第" + i + "次,x = " + x + ",y = " + y);
            }
        }
    }

}

2.2 执行结果

从以上截图中我们可以看到,其实程序并没有按照我们编写的代码顺序去执行,为了提高性能,一直在执行for循环体和打印结果操作,没有执行线程t1、t2里面的内容。那么为什么会出现这样的情况呢,要解析这个问题,还得介绍下指令重排序机制。

三、 指令重排序机制

刚才我们在解析有序性概念的时候指出了并发编程有序性问题产生的原因是指令重排序,通过代码例子,我们也验证了结果。既然指令重排序会造成并发线程安全问题,那么为什么还要引入这个指令重排序呢?它解决什么问题呢?下面我们就来分析下。

3.1 为什么要引入指令重排序

其实说到底引入指令从排序都是源于对性能的优化,我们都知道CPU运行效率相比缓存、内存、硬盘IO之间效率有着指数级的差别,CPU作为系统的宝贵资源,如能更好的优化和利用这个资源就能提升整个计算机系统的性能。

其实指令重排序就是一种来源于生活的优化思想,这种思想在生活中处处可见,就像平常咱们做菜,咱们会选择在炒第一个菜的同时就在洗第二个菜了, 咱们会把熟得最慢的菜放到最开始(比如煲汤),因为在等待这些菜熟的过程中(类似计算机中的IO等待)咱们(类似计算机中的CPU)还可以做其它事情,这就是一种时间上的优化,在计算机领域也是一样,它也会根据指令的类别做一些优化,目的就是把CPU的资源利用起来,这样就能提升整个计计算机的效率。

3.2 指令重排序的分类

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。

3.2.1 编译器优化重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

3.2.2 指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3.2.3 内存系统的重排

由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

3.3 指令重排序规范

3.3.1 as-if-serial 规范

我们可能会想,我写代码的时候,就是按照我的思路顺序写下来的,但是编译执行的时候,由于指令重排序的存在,优化了执行顺序,那它是怎么保证我程序相关计算逻辑的正确性呢?这个问题,指令重排序机制是做了处理的,对于那些存在数据依赖的两个操作,是不会被做重排序的。而保障这个的规范,就是我们的as-if-serial 规范。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。

as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

可以重排的情况 : 对于下面代码 , 两条指令顺序颠倒 , 执行结果相同 , 可以进行指令重排 ;

int a = 0;
int b = 10;

不可以进行重排的情况 : 对于下面的代码 , 两条指令如果上下颠倒 , 结果不同 , 不可以进行指令重排 ;

int a = 0;
int b = a+10;

不管怎么重排序,程序的执行结果不能被改变,编译器,runtime 和处理器都必须遵守as-if-serial语义。即编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

3.3.2 happens-before 规范

先行发生原则 。

happens-before 先行发生原则 :A happens-before B,A 先于 B 发生 , 先 A 后 B ;

Java 虚拟机在编译时和运行时 , 会对 JVM 指令进行重排优化 , 很明显 , 指令重排会对线程并发产生影响 ;

为了保证并发编程的安全性,这里规定了一些场景下 , 禁止在这些场景中 使用 指令重排 ;

3.3.2.1 先行发生原则适用场景
  • 程序次序原则 :在程序内 , 按照代码书写的执行顺序 , 前面的代码先执行 , 后面的代码后执行 ; 时间上靠前 的操作先于时间上靠后的操作执行。
  • 管程锁规则 : 不论是单线程还是多线程 , 线程 A 解锁后 , 线程 B 获取该锁 , 可以看到线程 A 的操作结果 ; 解锁的操作先于加锁的操作 ; 线程 B 要加锁 , 必须等待线程 A 解锁完毕才可以 ;
  • volatile 规则 : volatile 关键字修饰的变量 , 线程 A 对该变量的写操作 先于 线程 B 读取该变量的操作 , 线程 A 对该变量的写操作的结果对于线程 B 一定可见 ;
  • 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
  • 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 传递性: happens-before 规则具有传递性 ;如果 A happens-before B 和 B happens-before C ,则 A happens-before C ;
  • 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 对象终结规则:对象的创建先于对象的终结,, 创建就是调用构造函数 , 终结就是调用finalize()方法

只要符合上述规则 , 不需要进行同步 , 就可以成立 ;

通过 " happens-before 先行发生原则 " 可以判定两个线程的操作 , 是否有发生冲突的可能 ;

四、 Java 中保证有序性手段

4.1 volatile

4.1.1 DCL单例对象代码

我们实现DCL单例对象,通过多线程获取单例对象,比较两个对象是否相等,在线程安全的情况下,我们的测试结果应该是相等的,下面我们来实现下。

package com.ningzhaosheng.thread.concurrency.features.order.singleton;

/**
 * @author ningzhaosheng
 * @date 2024/2/6 16:14:45
 * @description DCL实现单例
 */
public class OrderSingleton {
    private static volatile OrderSingleton test;

    private OrderSingleton() {

    }
    public static OrderSingleton getInstance(){
        // B
        if(test  == null){
            synchronized (OrderSingleton.class){

                if(test == null){
                    // A   ,  开辟空间,test指向地址,初始化
                    test = new OrderSingleton();
                }
            }
        }
        return test;
    }
}

package com.ningzhaosheng.thread.concurrency.features.order.singleton;

import java.util.concurrent.Callable;

/**
 * @author ningzhaosheng
 * @date 2024/2/6 16:16:20
 * @description 模拟多线程获取DCL单例
 */
public class ThreadSingleton implements Callable<OrderSingleton> {


    @Override
    public OrderSingleton call() throws Exception {
        return OrderSingleton.getInstance();
    }
}

package com.ningzhaosheng.thread.concurrency.features.order.singleton;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

/**
 * @author ningzhaosheng
 * @date 2024/2/6 12:08:07
 * @description 测试多线程获取DCL单例对象,是否存在线程安全问题
 */
public class TestOrderSingleton {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 创建线程回调对象
        ThreadSingleton threadSingletonA = new ThreadSingleton();
        ThreadSingleton threadSingletonB = new ThreadSingleton();

        // 创建异步任务
        FutureTask<OrderSingleton> taskA = new FutureTask<OrderSingleton>(threadSingletonA);
        FutureTask<OrderSingleton> taskB = new FutureTask<OrderSingleton>(threadSingletonB);

        // 创建执行线程
        Thread threadA = new Thread(taskA);
        Thread threadB = new Thread(taskB);

        // 调用start()方法
        threadA.start();
        threadB.start();

        // 获取回调单例对象
        OrderSingleton orderSingletonA = taskA.get();
        OrderSingleton orderSingletonB = taskB.get();

        // 判断两个单例对象是否相等
       System.out.println("两个实例对象是否相等:"+orderSingletonA.equals(orderSingletonB));
    }
}


4.1.2 测试结果

从检测结果可以看出,DCL单例中,通过多线程获取对象,比较两个对象,他们相等。这都要归功于使用volatile修饰。那么volatile是如何实现有序性的呢,接下来我们分析下。

4.1.3 volatile有序性原理分析

4.1.3.1 使用volatile修饰DCL单例的原因

对象的初始化一般经历一下三个步骤:

1、为对象分配内存

2、初始化实例对象

3、为对象的引用分配内存

以上步骤全部完成才算完成了对象的初始化。

DCL单例中,懒汉模式下,如果不使用volatile修饰,由于JVM为了优化指令,提高程序运行效率,允许指令重排序,如果JVM优化指令为1、3、2顺序执行,那么多线程同时执行任务时,如果指令3刚好执行完,指令2未来及执行,其他线程调getInstance()执行任务时就会抛出对象未被初始化的异常。所以我们要使用volatile修饰单例实例。(很难复现出来问题,好尴尬,一直尝试复现没复现出来.............)

4.1.3.2 查看字节码

从以上截图我们可以看到,使用volatile修饰的变量,会多一个ACC_VOLATILE 指令关键字。我们接着去hotspot 查看c++源码,分析ACC_VOLATILE做了些什么操作。

4.1.3.3 hotspot 层面

根据ACC_VOLATILE指令关键字,我们可以在hotspot 源码中,找到他的内容:

jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/utilities/accessFlags.hpp

 

接着,我们找下is_volatile:

jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/interpreter/bytecodeInterpreter.cpp (openjdk.org)

从以上截图的这段代码中可以看到,会先判断tos_type(volatile变量类型),后面有不同的基础类型的调用,比如int类型就调用release_int_field_put,byte就调用release_byte_field_put等等。
判断完类型之后,我们可以看到代码后面执行的语句是:

我们可以在以下代码位置找到该源码:

jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/runtime/orderAccess.hpp (openjdk.org)

实际上storeload() 这个方法,针对不同CPU有不同的实现,它的具体实现在src/os_cpu下,我们可以去看一下:

这里我们以linux_x86架构的CPU实现为例,我们去看下storeload()方法做了些什么操作。

jdk8u/jdk8u/hotspot: 69087d08d473 src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp (openjdk.org)

接着看下fence()函数:

通过这面代码可以看到lock;add1,其实这个就是内存屏障。lock;add1 $0,0(%%esp)作为cpu的一个内存屏障。
add1 $0,0(%%rsp)表示:将数值0加到rsp寄存器中,而该寄存器指向栈顶的内存单元。加上一个0,rsp寄存器的数值依然不变。即这是一条无用的汇编指令。在此利用add1指令来配合lock指令,用作cpu的内存屏障。

内存屏障:

这四个分别对应了经常在书中看到的JSR规范中的读写屏障

  • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

对于volatile操作而言,其操作步骤如下:

  • 每个volatile写入之前,插入一个 StoreStore ,写入以后插入一个StoreLoad,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中。
  • 每个volatile读取之前,插入一个 LoadLoad ,读取之后插入一个LoadStore,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量。
4.1.3.4 volatile 有序性原理总结

通过分析编译代码可知,使用volatile修饰的变量,会生成ACC_VOLATILE 指令关键字,通过分析hotspot源码可知,ACC_VOLATILE 关键字最终会使用到内存屏障机制,保证使用volatile修饰的内容不被指令重排序,这是保障程序有序性的机制。具体点来说,就是指令重排序的happens-before规范,里面有一条volatile规则,它规定volatile 关键字修饰的变量 , 线程 A 对该变量的写操作 先于 线程 B 读取该变量的操作 , 线程 A 对该变量的写操作的结果对于线程 B 一定可见。

好了,本次内容就分享到这,欢迎关注本博主。如果有帮助到大家,欢迎大家点赞+关注+收藏,有疑问也欢迎大家评论留言!

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值