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

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第1张图片

目录

​编辑

一、可见性概念

1.1 概念

二、可见性问题由来

2.1 由来分析

三、可见性代码例子

3.1 代码

3.2 执行结果

四、Java 中保证可见性的手段

4.1 volatile

4.1.1 优化代码

4.1.2 测试结果

4.1.3 volatile原理分析

4.1.3.1 查看字节码

4.1.3.2 hotspot 层面

4.1.3.3 volatile原理总结

4.2 synchronized

4.2.1 代码优化

4.2.2 测试结果

4.2.3 synchronized 原理分析

4.2.3.1 synchronized 修饰方法

4.2.3.1.1 源代码

4.2.3.1.2 执行结果

4.2.3.1.3 编译分析

4.2.3.2 synchronized 修饰代码块

4.2.3.2.1 源代码

4.2.3.2.2 执行结果

4.2.3.2.3 编译分析

4.3 Lock

4.3.1 优化代码

4.3.2 测试结果

4.3.3 Lock实现可见性原理分析

4.3.3.1 源码分析

4.3.3.2 总结

4.4 final

4.4.1 final 实现可见性原理分析


一、可见性概念

1.1 概念

可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

二、可见性问题由来

2.1 由来分析

可见性问题是在CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,为了解决CPU加载主内存数据慢的问题,就在CPU加入了缓存寄存器,分别为L1、L2、L3三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。如下图所示:

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第2张图片

引入这种缓存机制后,这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致主内存和缓存之间数据不一致问题而数据不一致的问题,换据说法就是可见性问题。

为了解决CPU硬件层面的缓存一致性问题,于是就设计出了缓存一致性协议,其中比较典型的就是MESI协议,但是这个协议其实不同的CPU厂商的实现方式是有差异的,Java 层面为了屏蔽各种硬件和操作系统带来的差异,让并发编程做到真正意义上的跨平台,就设计出了JMM,即Java Memery Model, Java 内存模型来解决

三、可见性代码例子

3.1 代码

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

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:36:39
 * @description 测试可见性
 */
public class TestVisible {
    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");
    }
}

3.2 执行结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第3张图片

由结果可知,主线程修改了flag = false;但是并没有使t1线程里面的循环结束。

四、Java 中保证可见性的手段

4.1 volatile

4.1.1 优化代码

package com.ningzhaosheng.thread.concurrency.features.visible.volatiles;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:45:29
 * @description 测试volatile
 */
public class TestVolatile {
    private volatile 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");
    }
}

4.1.2 测试结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第4张图片

由以上测试结果可以看到,使用volatile 修饰共享变量之后,在主线程修改了flag =false 之后,线程t1读取到了最新值,并结束了循环,结束了线程。那么为什么使用了volatile之后就能解决共享变量的问题呢?要回答这个问题其实综合性考虑的内容还比较多,涉及到CPU的多级缓存、计算机缓存一致性协议和Java 内存模型等相关内容。我们接下来就分析下吧。

4.1.3 volatile原理分析

4.1.3.1 查看字节码
javap -v .\TestVolatile.class

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第5张图片

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

4.1.3.2 hotspot 层面

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

jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/utilities/accessFlags.hpp (openjdk.org)

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第6张图片

接着,我们找下is_volatile:

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

 Java 三大并大特性-可见性介绍(结合代码、分析源码)_第7张图片

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

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第8张图片

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

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

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第9张图片

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

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第10张图片

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

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

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第11张图片

接着看下fence()函数:

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第12张图片

通过这面代码可以看到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 ,读取之后插入一个LoadStoreJMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量
4.1.3.3 volatile原理总结

通过编译的字节码分析,我们可以知道,使用volatile 修饰的变量,编译后会生成ACC_VOLATILE关键字,通过关键字搜索,我们在hotspot 源码层(JVM层)查询到,is_volatile函数,这个函数的作用就是会先判断tos_type(volatile变量类型),后面有不同的基础类型的调用,比如int类型就调用release_int_field_put,byte就调用release_byte_field_put等等,还有就是调用了一个OrderAccess::storeload();函数,最终我们通过查看源码,找到storeload方法在不同CPU架构下的实现,最终基本可以得出以下结论:

  • 在JVM层:volitile的底层,在JVM层其实是通过内存屏障防止了指令重排序。
  • CPU层面:在x86的架构中,含有lock前缀的指令拥有两种方法实现;一种是开销很大的总线锁,它会把对应的总线直接全部锁住,如此明显是不合理的;所以后期intel引入了缓存锁以及mesi协议,如此便可以轻量化的实现内存屏障;

最终结论:volatile的底层原理,在JVM源码层次而言,内存屏障直接起到了禁止指令重排的作用,且之后与总线锁或者MESI协议配合实现了可见性;即:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中;当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量。

4.2 synchronized

4.2.1 代码优化

package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:52:31
 * @description 测试synchronized
 */
public class TestSynchronized {
    private static boolean flag = true;

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

        });

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

4.2.2 测试结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第13张图片

从测试结果可以看出,使用了synchronized同步代码块之后,在主线程中修改了flag=false 之后,线程t1也获取到最新的变量值,结束了while循环。也就是说synchronized也可以解决并发编程的可见性问题。那么synchronized是怎么保证并发编程的可见性的呢,我们接下来分析下。

4.2.3 synchronized 原理分析

4.2.3.1 synchronized 修饰方法
4.2.3.1.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/13 10:16:36
 * @description synchronized 修饰方法
 */
public class TestSynchronizedMethod {
    public static boolean flag = true;

    public static synchronized void runwhile() {
        while (flag) {
            System.out.println(111);
        }
        System.out.println("t1线程结束");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            runwhile();

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

4.2.3.1.2 执行结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第14张图片

4.2.3.1.3 编译分析
javap -v .\TestSynchronizedMethod.class

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第15张图片

可以看见,使用synchronized修饰方法后,通过javap -v 查看编译的字节码,会生成一个ACC_SYNCHRONIZED标识符,会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

可查看官网解析:Chapter 2. The Structure of the Java Virtual Machine (oracle.com)

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第16张图片

该标识符的作用是使当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的方法

获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

注意:关于Monitor的更多底层实现原理,由于篇幅原因,这里先不分析,后续会出相关文章详细说明synchronized,这里只是就实现可见性原理做些说明。

4.2.3.2 synchronized 修饰代码块
4.2.3.2.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;

/**
 * @author ningzhaosheng
 * @date 2024/2/13 10:48:02
 * @description synchronized 修饰代码块
 */
public class TestSynchronizedCodeBlock {
    public static boolean flag = true;

    public static void runwhile() {
        while (flag) {
            synchronized (TestSynchronizedCodeBlock.class) {
                System.out.println(flag);
            }
            System.out.println(111);
        }
        System.out.println("t1线程结束");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {

            runwhile();

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

4.2.3.2.2 执行结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第17张图片

4.2.3.2.3 编译分析
javap -v .\TestSynchronizedCodeBlock.class

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第18张图片

可以看到,使用synchronized修饰代码块后,查看编译的字节码会发现再存取操作静态共享变量时,会插入monitorenter、monitorexit原语指令,关于这两个指令的说明,可查看文档:

Chapter 6. The Java Virtual Machine Instruction Set (oracle.com)

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第19张图片

它实现可见性的原理和上一小节说明的那样,都是:

当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的代码块。

获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

4.3 Lock

4.3.1 优化代码

package com.ningzhaosheng.thread.concurrency.features.visible.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author ningzhaosheng
 * @date 2024/2/5 19:57:24
 * @description 测试Lock
 */
public class TestLock {
    private static boolean flag = true;
    private static Lock lock = new ReentrantLock();

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

        });

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

4.3.2 测试结果

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第20张图片

4.3.3 Lock实现可见性原理分析

4.3.3.1 源码分析

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第21张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第22张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第23张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第24张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第25张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第26张图片

Java 三大并大特性-可见性介绍(结合代码、分析源码)_第27张图片

通过以上截图可以看到,我们创建了一个ReentrantLock,调用了它的lock()方法,而ReentrantLock实现了Lock接口和基于AQS定义实现了锁。

4.3.3.2 总结

Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到主内存的操作。

Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。

如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。

参考4.1.3.3 volatile原理总结部分。

4.4 final

4.4.1 final 实现可见性原理分析

final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。

final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的。

final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。

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

你可能感兴趣的:(Java技术,java,高并发,多线程,可见性,jvm,hotspot)