java多线程:9、内存可见性

内存可见性介绍

可见性:一个线程对共享变量值的修改,能够及时的被其他线程看到

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量

线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

java多线程:9、内存可见性_第1张图片

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

2 可见性问题

前面讲过多线程的内存可见性,现在我们写一个内存不可见的问题。

案例如下:

   public static void main(String[] args) throws InterruptedException {
     
        JmmDemo demo = new JmmDemo();
        Thread t = new Thread(demo);
        t.start();

        Thread.sleep(100);
        demo.flag = false;
        System.out.println("已经修改为false");
        System.out.println(demo.flag);

    }

    static class JmmDemo implements Runnable {
     
        public boolean flag = true;

        @Override
        public void run() {
     
            System.out.println("子线程执行。。。");
            while (flag) {
     
            }
            System.out.println("子线程结束。。。");
        }
    }

执行结果

java多线程:9、内存可见性_第2张图片

按照main方法的逻辑,我们已经把flag设置为false,那么从逻辑上讲,子线程就应该跳出while死循环,因为这个时候条件不成立,但是我们可以看到,程序仍旧执行中,并没有停止。

原因:线程之间的变量是不可见的,因为读取的是副本,没有及时读取到主内存结果。 解决办法:强制线程每次读取该值的时候都去“主内存”中取值

解决方法

1 synchronized

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程执行synchronized声明的代码块。还可以保证共享变量的内存可见性。同一时刻只有一个线程执行,这部分代码块的重排序也不会影响其执行结果。也就是说使用了synchronized可以保证并发的原子性,可见性,有序性。

JMM关于synchronized的两条规定:

  • 线程解锁前(退出同步代码块时):必须把自己工作内存中共享变量的最新值刷新到主内存中

  • 线程加锁时(进入同步代码块时):将清空本地内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁与解锁是同一把锁)

做如下修改,在死循环中添加同步代码块

while (flag) {
     
    synchronized (this) {
     
    }
}

synchronized实现可见性的过程

  1. 获得互斥锁(同步获取锁)
  2. 清空本地内存
  3. 从主内存拷贝变量的最新副本到本地内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存
  6. 释放互斥锁

synchronized同步原理

synchronized的同步可以解决原子性、可见性和有序性的问题,那是如何实现同步的呢?

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

普通同步方法,锁是当前实例对象this

静态同步方法,锁是当前类的class对象

同步方法块,锁是括号里面的对象

当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁。

synchronized的同步操作主要是monitorenter和monitorexit这两个jvm指令实现的,先写一段简单的代码:

public class Demo2Synchronized {
    public void test2() {
        synchronized (this) {
        }
    }
}

在cmd命令行执行javac编译和javap -c Java 字节码的指令

# 编译为字节码文件
javac Demo2Synchronized.java
# 进行反编译
javap -c Demo2Synchronized.class

java多线程:9、内存可见性_第3张图片

从结果可以看出,同步代码块是使用monitorenter和monitorexit这两个jvm指令实现的

这两个jvm指令实现锁的原理主要是基于 Mark Word和、monitor。

  • Mark Word

Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是synchronized实现轻量级锁和偏向锁的关键。

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

在这里插入图片描述

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):

java多线程:9、内存可见性_第4张图片

  • monitor

什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象都带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:

java多线程:9、内存可见性_第5张图片

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor
  • record:当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ: 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis: 表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest: 用来实现重入锁的计数。
  • HashCode: 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate: 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

2 对变量添加Volatile关键字修饰

Volatile关键字可以保证变量的可见性,但不保证原子性,其本质是告诉JVM该变量在本地内存中的值是不确定的,需要到主内存中去读取

其原理是通过内存屏障实现的。

public volatile boolean flag = true;

Volatile实现内存可见性的过程

线程写Volatile变量的过程:

  • 改变线程本地内存中Volatile变量副本的值;
  • 将改变后的副本的值从本地内存刷新到主内存

线程读Volatile变量的过程:

  • 从主内存中读取Volatile变量的最新值到线程的本地内存中
  • 从本地内存中读取Volatile变量的副本

Volatile实现内存可见性原理

写操作时,通过在写操作指令后加入一条store屏障指令,让本地内存中变量的值能够刷新到主内存中

读操作时,通过在读操作前加入一条load屏障指令,及时读取到变量在主内存的值

PS: 内存屏障(Memory Barrier)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序

volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:

  • StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。

  • StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。

  • LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。

  • LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

你可能感兴趣的:(JUC)