java.util.concurrent工具包探索-倔强青铜****

文章目录

  • 前言
  • 一、JMM
    • 1、内存交互操作
    • 2、jmm规定的内存交互操作规则
  • 二、volatile
    • 1、volatile的作用是什么
    • 2、volatile使用的限制条件
  • 三、单例模式
  • 总结

前言

本篇是JUC系列的绝对重点,我们将讨论的事情有如下几个方面: JMM volatile 单例模式 其中最为重要的就是理解JMM

一、JMM

jmm不是一种技术,它仅仅是一个概念和约定。在之前探讨volatile关键字的时候。

在计算机中,每条指令的运行是依靠cpu的,指令执行的过程中会涉及到临时数据的读取和写入,这些数据的读取和写入是发生在计算机主存中的。cpu的指令执行是很快的,但是发生在主存中的数据读取和写入却没有这么快,所以就出现了高速缓存的概念。

JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑
有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。
Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5
开始的JSR-133发布后,已经成熟和完善起来。
 JMM规定了内存主要划分为主内存和工作内存两种,并且对8种内存交互操作制定了相关的规则。
接下来,咱们分几个方面说明,先来谈一下什么是内存交互操作。再来看jmm为内存交互操作制定的规则。

1、内存交互操作

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)
  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

下面咱们画一张图,来演示下:
java.util.concurrent工具包探索-倔强青铜****_第1张图片

如上面这张图所示,一个变量被某个线程操作的过程是这样的。首先,这个int类型的变量a一定被声明在主存中,然后,当线程A想要操作这个变量a的时候,首先read读取存在于主存中的变量a,然后将它load到工作内存中。之后,改变这个变量值的时候,use这个变量a使得它被传递到线程A的执行引擎中,之后执行引擎工作结束之后assign这个变量a到工作内存中。最终当变量在线程A的工作内存操作结束之后,执行write和store将变量a刷回主存。

2、jmm规定的内存交互操作规则

JMM对上面这八种指令的使用,制定了如下规则:
  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

二、volatile

在之前的文章中已经提及过volatile关键字,本篇将进行更加细致的讨论,volatile非常深刻,咱们再次回顾,讨论分为以下几个方面。volatile的作用是什么、volatile使用的限制条件、volatile经典应用。

1、volatile的作用是什么

volatile的作用主要体现在两个方面,

  • 第一是保证此变量对所有线程的可见性:这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
  • 使用volatile变量的第二个语义是禁止指令重排序优化

    我们将分别使用案例加以说明,首先来看第一段代码
    演示1:
public class Demo01 {
     
   private static int num = 0;

   public static void main(String[] args) {
     
       new Thread(() -> {
     
           while (num == 0) {
     

           }
       }).start();

       try {
     
           TimeUnit.SECONDS.sleep(2);
       } catch (InterruptedException e) {
     
           e.printStackTrace();
       }
       num = 1;

       System.out.println(num);
   }
}

java.util.concurrent工具包探索-倔强青铜****_第2张图片

主程序在沉睡一秒之后,打印出主程序中的这段话,但是新开启的线程迟迟无法结束,为啥呀?原因很简单,新开启的线程是将num load到工作内存中操作的,尽管存在于主存中的num最新值被main线程修改,但新开启的线程仍然在使用自己工作内存中的num,也就是0,故while循环无法结束。 这咋办呢?简单,使用volatile修饰这个num,使得这个变量值更新之后其余的线程立即可见!
public class Demo01 {
     
    private static volatile int num = 0;

    public static void main(String[] args) {
     
        new Thread(() -> {
     
            while (num == 0) {
     
                System.out.println("新开启的线程结束...");
            }
        }).start();

        try {
     
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
     
            e.printStackTrace();
        }
        num = 1;

        System.out.println(num);
    }
}

java.util.concurrent工具包探索-倔强青铜****_第3张图片

通过运行结果可知,volatile修饰的变量在值被修改之后的确可以被其他线程知晓。
演示2:

面试被问烂了的单例模式最经常被提到的有两种,一种叫做饿汉式。另外一种叫做DCL懒汉式,来写一下DCL懒汉式

public class LazyMan {
     

    private static volatile LazyMan lazyMan;

    // 私有化构造方法
    private LazyMan() {
     

    }

    public static LazyMan getLazyMan() {
     
        if (lazyMan == null) {
     
            synchronized (LazyMan.class) {
     
                if (lazyMan == null) {
     
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }

}

开始,传统的问题有三个

  • 第一个if判断的作用是什么
  • 第二个if判断的作用是什么
  • volatile的作用是什么

分别回答一下:
1.第一个if判断的作用是检查当前的LazyMan这个对象是不是null,不是null直接return
2.第二个if的作用是当很多线程进来的时候,且LazyMan这个对象还没有创建的时候,有可能很多线程都进入synchronized代码块中,此时可以虽然可以保证这些线程的顺序执行,但有可能多次创建LazyMan对象,故而再次判断是否为null
3.LazyMan这个对象的创建并非是原子操作,分为3步,分配内存空间、执行构造方法,初始化对象、把对象指向这个空间。这三步未必是顺序执行的。有可能会123,也可能132,假设为132,那么当A线程执行完1和3的时候,B线程进来发现这个对象已经指向分配的空间,拿去直接使用了,那就炸了。所以使用volatile防止指令的重排序。

2、volatile使用的限制条件

限制条件有两个

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中

下面分别加以说明
演示1:对变量的写操作不依赖于变量当前值

public class Demo02 {
     
    public static int num = 0;

    public static void main(String[] args) {
     
        for (int i = 0; i < 1000; i++) {
     
            new Thread(Demo02::increase).start();
        }
        System.out.println(num);
    }

    public static void increase() {
     
        num++;
    }
}

这段程序的执行结果是多少?1000?不是的,你拿去多尝试几次,基本上都是小于1000的,but why?因为volatile无法保证对变量的原子操作。

    可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程
中对num自增完之后,在其他线程中都能看到修改后的值啊,所以有1000个线程共进行了1000次操作,那么最终inc
的值应该是1000;

    这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性
    只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

  在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么
  就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  假如某个时刻变量num的值为10,

  线程1对变量进行自增操作,线程1先读取了变量num的原始值,然后线程1被阻塞了;

  然后线程2对变量进行自增操作,线程2也去读取变量num的原始值,由于线程1只是对变量num进行读取操作,而
  没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量num的缓存行无效,所以线程2会直接去主
  存读取num的值,发现num的值是10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  然后线程1接着进行加1操作,由于已经读取了num的值,注意此时在线程1的工作内存中num的值仍然为10,所以
  线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  那么两个线程分别进行了一次自增操作后,inc只增加了1。

  解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效
  吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是happens-before规则中的volatile变量规则,
  但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对num值进行修改。然后虽然volatile能
  保证线程2对变量num的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改
  的值。

  根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

总结一下,下面的总结我想了很久,与君共勉

   volatile无法保证对变量的任何操作都是原子性的。仅仅当 线程1的i++这个操作执行之前,如果线程2将i++
   执行完写回了主存。线程1才能看到i的最新值并且刷新。一旦线程1的i++操作中的读取操作结束了,此时即使
   别的线程将最新的i的值写回主存线程1也不会再有机会刷新了。

这个问题怎么解决呢,简单,使用Lock、synchronized、原子类都可以解决。

演示2:该变量没有包含在具有其他变量的不变式中

public class NumberRange {
       
    private volatile int lower;
    private volatile int upper;  
  
    public int getLower() {
      return lower; }  
    public int getUpper() {
      return upper; }  
  
    public void setLower(int value) {
        
        if (value > upper)   
            throw new IllegalArgumentException(...);  
        lower = value;  
    }  
  
    public void setUpper(int value) {
        
        if (value < lower)   
            throw new IllegalArgumentException(...);  
        upper = value;  
    }  
}

将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使 setLower()和 setUpper() 操作原子化。

否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4, 3) —— 一个无效值。

三、单例模式

就在刚刚说明volatile的时候,已经说明了DCL懒汉式,本篇不探究其余的怎么实现。只问一个问题,你写出的DCL懒汉式一定能保证永远只有一个单例吗?
如果你说能的话,那么下面我们来反驳一下
java.util.concurrent工具包探索-倔强青铜****_第4张图片
ok,咱们通过反射new出来一个别的实例。DCL懒汉式不堪一击。那怎么办呢,其实你可以在代码里面比如构造函数处加判断。当我们声明的实例已经不为null就直接抛出异常,但是这其实也还是没结束的。不再往下面讨论了。咱们直接整一个不能被反射破坏的。

public enum EnumSingle {
     

    INSTANCE;

    public static EnumSingle gerInstance() {
     
        return INSTANCE;
    }

}

大家知道枚举的底层构造方法是什么吗?是无参构造吗,不是的,把枚举的反编译代码贴在下面
java.util.concurrent工具包探索-倔强青铜****_第5张图片
我们尝试使用反射获取下不同的实例
java.util.concurrent工具包探索-倔强青铜****_第6张图片
失败了,为啥会失败呢?因为凡事总要有一线生机,我们点开 declaredConstructor.newInstance()的源码。
java.util.concurrent工具包探索-倔强青铜****_第7张图片

总结

本篇到此,jmm和volatile非常深刻,我们认真体会。

你可能感兴趣的:(工具,多线程,java)