多线程 3——线程安全三大特性、volatile、synchronized、单例模式

多线程

  • 一、线程安全
    • 1、原子性(atomic)
    • 2、内存可见性
        • 1)JMM(Java Memory Model——Java内存模型 )
        • 2)可见性(visible)
    • 3、代码重排序(reordering)
  • 二、线程安全机制
    • 1、synchronized 关键字
        • 1)语法
        • 2)synchronized 作用------->加锁
        • 3)synchronized执行的过程:
        • 4)synchronized保证线程安全的三大特性:
    • 2、volatile
        • 1)语法
        • 2)volatile的原子性问题
        • 3)volatile的内存可见性(最核心)
        • 4)volatile限制代码重排序
    • 3、双重校验锁
        • 1)饿汉模式——立即初始化
        • 2)懒汉模式——单线程版——线程不安全
        • 3)懒汉模式——多线程版——效率低
        • 4)懒汉模式——多线程版——性能高

一、线程安全

什么是线程安全?
如果多线程环境下代码运行的结果100%是符合我们预期的,即在单线程环境下对应的结果,则说这个程序是线程安全的。如果出现一次或者多次错误的结果就说明是线程不安全的。
我们先观察一段代码:

public class UnsafeThread {
    private static int K;
    public static void main(String[] args) {
        //同时启动二十个线程,每个线程对同一个变量操作,循环1000次,每次循环++操作
        //该处的int i,变量存在Java虚拟机栈,值存在常量池
        for(int i=0;i<20;i++)
        {
            new Thread(new Runnable() {
                @Override
                public void run() {
                  for(int j=0;j<10000;j++)
                  {
                     //存在原子性以及可见性问题,所以会出现线程安全问题<=20万
                      K++;
                  }
                }
            }).start();
        }
       while(Thread.activeCount()>1)
       {
           Thread.yield();
       }
       //1.运行的结果不是20*10000
        //2.每次运行的结果不一样
        System.out.println(K);
    }
}

上面代码的逻辑是:启动20个线程,每个线程都对共享变量k进行10000次的++操作,我们预期的结果应该是:20 * 10000;我们将代码分别运行三次,观察结果:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第1张图片
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第2张图片
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第3张图片
我们通过运行结果可以看到,三次运行结果都不一致,总体是<=20*10000的 。
值都是随机变化的原因:

  • 线程什么时候被从 CPU上切换下来是随机的
  • 线程什么时候被切换到CPU上也是随机的
  • 随着计算的次数越大,需要的CPU的时间也就越多。可能被分到多个时间片的概率也就越大,所以出错的概率越大。

从上面代码的例子我们发现:对于共享变量k在不同的线程进行++操作,产生了线程不安全的问题;进一步思考,什么情况会出现线程不安全问题呢?

出现线程不安全的两个必要条件:

  • 线程之间是有数据的共享的
  • 即使线程之间出现了数据共享,只要大家都是只读(光读不修改),也不会有线程安全问题;即:只要对共享变量进行修改就会出现线程不安全。

原因就涉及到线程安全的三大因素

  • 原子性
  • 可见性
  • 代码顺序性

1、原子性(atomic)

原子性(atomic):一组操作,不会被分割 或者 即使被分割之后,保证不会受到其他线程的干扰。

直接说概念可能不太好理解,我们用生活中的例子来理解一下:我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
简单的概括就是:A在做一件事的时候中途不能被干扰,或者干扰后结果没有影响就说A具备原子性。

我们通过具体的代码来看:

public class Unsafe1Thread {
    private static  int num;
    public static void main(String[] args) throws Exception {
        Thread A=new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for(int i=0;i<1000;i++)
                        {
                            num++;
                        }
                    } }
        );
        Thread B=new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i=0;i<1000;i++)
                {
                    num--;
                }
            } }
        );

        A.start();
        B.start();

        //等待线程A、B执行完
        A.join();
        B.join();

        System.out.println(num);
    }
}

上边代码主要描述了:两个线程A和B,分别对共享变量执行+1操作1000次和-1操作一千次,等待两个线程都执行完,打印共享变量的值。
对于理想结果肯定是0,但是真实的结果每次都是随机数(是CPU调度线程是随机的),如下图所示:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第4张图片
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第5张图片
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第6张图片
那么这是为什么呢?
--------->原子性被破坏了,在java中的一条语句,不代表一条基本的指令;

对于(num++和num–)操作来说其实在底层被分为了三条指令:

  • 1.从内存把数据读到 CPU
  • 2.进行数据更新(+1或者-1)
  • 3.把数据写回到 CPU

我们以n++为例,执行过程以及内存情况见下图所示:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第7张图片
现在回到上面代码,当我们使用start方法启动两条线程,每个线程在执行n++操作或者n–操作时,CPU可能随时切换,比如:线程A在执行++操作时刚执行了两条指令(load n;和add R1 1)CPU就从线程A切换出去,此时save n;指令并没有执行;对于线程B,从内存中拿到的数据此时并不是线程A执行n++操作后的结果(++后的结果并没有写回内存),所以就破坏了线程A的原子性,导致数据不是预期的结果。
在这里插入图片描述
对于原子性问题,如何避免呢?

  • 多线程直接不使用共享变量:让线程自己干自己的。
  • 对共享变量只读不写
  • 使用保证线程安全的机制(后面具体说)

2、内存可见性

1)JMM(Java Memory Model——Java内存模型 )

JMM的设计是通过模拟计算机模型(模拟 CPU+高速缓存+内存)来设计的。

正常计算机模型:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第8张图片
JMM(工作内存+主内存):
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第9张图片

  • JMM: 内存被分为工作内存主内存;要求:每个线程只能操作工作内存,不能直接操作主内存。
  • load n; 把n从主内存加载到工作内存
  • add n l;工作内存中执行计算
  • save n; 把n从工作内存同步回主内存;
2)可见性(visible)

在Java线程中涉及的内存可见性问题是基于JMM(工作内存+主内存)的。

线程执行过程: 会首先将数据从主内存加载到工作内存中,对数据的操作都是放在线程自己的工作内存中的。

可见性:某一个线程已经在工作内存中对变量做修改了,但其他线程没有感受到就产生了可见性问题。

我们写一段代码:

public class Unsafe2Thread {
    public static boolean running=true;
    private static class childThread extends Thread{
        @Override
        public void run(){
            int n=0;
            while (running)
            {
                n++;
            }
            System.out.println(n);
        }
    }
    public static void main(String[] args)throws Exception {
        Thread t=new childThread();
        t.start();

        Scanner scanner=new Scanner(System.in);
        System. out.print("随便输入什么,让子线程退出:");
        scanner. nextLine();

        System. out.println("running修改前:running:"+running+"   线程t的状态:"+t.getState()) ;

        running = false;
       while (true)
       {
           System. out.println("running修改后:running:"+running+"   线程t的状态:"+t.getState());
       }
    }
}

上面代码的作用是在主线程中修改running的值,看线程t是否会受影响。
运行结果:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第10张图片
我们发现,线程t并没有因为running的值改变发生状态改变。因为线程t是首先将running的值加载到自己的内存中,然后在自己的工作内存中执行代码,而主内存中改变了running的值,线程t在自己的工作内存中并看不到。

3、代码重排序(reordering)

什么叫做代码重排序?
你书写的代码顺序并不一定是最终执行代码的顺序。

为什么要进行代码重排序?
你的书写顺序不一定是最优解,所以为了提升效率,适当改变顺序可以接收。

代码重排序的基本要求: 单线程情况,不能因为重排序,导致结果都不一样了。

在java中,谁会进行重排序?

  • 编译器(javac) 就会进行重排序——进行一部分的重排序。
  • JVM(JIT Just In Time即时编译器)——运行期间进行重排序.
  • CPU内部对真正要执行的指令,也会进行重排序。

重排序的底线是保证单线程情况下,没有副作用。它观察不到多线程的情况的,所以,重排序的结果可能会在多线程情下引发问题。

二、线程安全机制

1、synchronized 关键字

1)语法
  1. 作为方法修饰符存在——>不区分 静态方法 还是 普通方法
    多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第11张图片

  2. 作为语句块出现多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第12张图片

2)synchronized 作用------->加锁

对象锁:
JVM在设计的时候,每个对象中, 都实现出一把锁,即:monitor lock(对象锁/synchronized锁/同步锁/监视器锁)
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第13张图片

synchronized加的是什么锁呢?

synchronized的底层是使用操作系统的mutex lock实现的。加的是某个对象内部的monitor lock。底层原理具体见博客:https://blog.csdn.net/yuiop123455/article/details/108642908
.
加的是哪个对象的锁:

  • 代码块( synchronized (引用) {} )引用指向的对象的锁
  • 普通方法( synchronized void method() {} )this引用指向的对象 的锁
  • 静态方法( synchronized static void staticMethod() {} ):比如方法属于AClass, AClass. class这个引用指向的对象(AClass这个类在内存中的对象)的锁。

synchronized加锁之后,可以保证原子性、内存可见性、代码顺序性 (下面结合代码来看)。

3)synchronized执行的过程:

我们通过一段代码来具体观察synchronized执行的过程:

public class SafeThread {
 
    private static int KA;

    public static void main(String[] args) {
        //同时启动3个线程,每个线程对同一个变量操作,循环1000次,每次循环++操作
        for(int i=0;i<3;i++)
        {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<10000;j++)
                    {
                        synchronized (SafeThread.class)
                        {
                            KA++;
                        }
                    }
                }
            }).start();
        }
		//等到所有的子线程都执行完,main线程再向下执行
        while(Thread.activeCount()>1)
        {
            Thread.yield();
        }
        System.out.println(KA);
    }
}

上面代码作用:同时启动3个线程,每个线程对同一个变量操作,循环1000次,每次循环++操作。重点: 在对变量KA进行++操作时,使用了synchronized对类对象(SafeThread.class,类对象在方法区只有一份)进行加锁。

过程:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第14张图片

1>for循环:for(int i=0;i<3;i++)创建三个线程(假设为A B C);
2>三个线程通过start方法启动之后等待操作系统调度,然后执行run方法,
3>当执行到synchronized代码块时,三个线程需要竞争同一个对象锁(类对象锁);假设此时线程A抢到对象锁了,就执行synchronized代码块里边的代码,而B、C线程没有竞争到就进入阻塞状态;

A ,B, C三个线程竞争的是同一对象锁,所以此时就达到了同步互斥.

4>当线程A执行完synchronized代码块后会退回对象锁,然后JVM重新分配,让B,C来竞争这把锁。

多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第15张图片

多线程具备同步互斥的条件:

  • 1.线程之间必须先抢锁;
  • 2.抢锁必须抢同一把锁

synchronized执行代码块/方法时:需要获取对象锁

  • 获取成功:JVM要求该线程工作内存中的数据全部失效;所有读的数据都从主内存中读。并且往下执行代码。
  • 获取失败:阻塞在synchronized代码行

退出synchronized代码块,或synchronized方法:

  • 1.退回对象锁,并且JVM把所有的工作内存的数据save回主内存去。
  • 2.通知JVM及系统,其他线程可以来竞争这把锁
4)synchronized保证线程安全的三大特性:

synchronized保证原子性:
通过synchronized加锁同一个时间点只用一个线程在运行,达到同步互斥的效果,保证了这段代码的原子性(没有其他线程干扰)。

synchronized保证部分可见性:

synchronized 加锁和释放锁所执行的内容就保证了执行后的数据一定从工作内存刷新到主内存中,因此,一定程度上保证了内存可见性(synchronized 代码块/方法执行完之后可见)。

  • synchronized 加锁成功时,JVM要求该线程工作内存中的数据全部失效。所有读的数据都从主内存中读。
  • synchronized在释放锁时,JVM把所有的工作内存的数据save回主内存去。

多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第16张图片

synchronized保证代码顺序性:
我们举个例子:在如下的一段代码中,包括加锁前中后
在这里插入图片描述
在执行过程中:

  • A内部的代码随便重排序——不限制,A的代码不能被重排序到lock之后
  • B内部的代码随便重排序——不限制,B的代码不能被重排序到lock之前,unlock之后
  • C内部的代码随便重排序——不限制,C的代码不能被重排序到unlock之前

因此在一定程度上保证了代码顺序性。

2、volatile

1)语法

用来修饰属性/静态属性

  • volatile int a;
  • volatile static int b;
2)volatile的原子性问题

对于volatile的原子性功能是非常有限的,只和long/double有关;其他的原子性问题是不能保证的(比如k++等)。

volatile保证long/double类型的原子性:

java中,变量的读或者写本身是原子的(除了double/long).
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第17张图片
volatile修饰的long/double变量的读或者写也成为原子的;

volatile不能保证其他类型的原子性:
就以本篇博客一开始的代码来说,每次运行结果都不一致,总体是<=20*1000的 。在++操作执行时,我们都知道会分解为三条指令,但是没有安全机制的保证,难免其他线程对三条指令产生干扰,使得原子性不能保证,导致结果出现问题。

那麽我们现在给共享变量加上volatile关键字来看看结果:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第18张图片
结果仍然是错误的,问题的核心就是原子性问题,volatile解决不了这里的原子性问题。
因此,volatile的原子性只是针对于long/double类型。

3)volatile的内存可见性(最核心)

使用volatile修饰变量后,修改该值是在主内存中修改,判断也是判断的主内存变量的值,而不是判断/修改拷贝到自己内存的值。这样就保证了可见性。

我们将上面可见性案例的代码中的共享变量加上volatile关键字来修饰,运行再来看看结果:

public class Unsafe2Thread {
    public volatile static boolean running=true;
    private static class childThread extends Thread{
        @Override
        public void run(){
            int n=0;
            while (running)
            {
                n++;
            }
            System.out.println(n);
        }
    }
    public static void main(String[] args)throws Exception {
        Thread t=new childThread();
        t.start();

        Scanner scanner=new Scanner(System.in);
        System. out.print("随便输入什么,让子线程退出:");
        scanner. nextLine();

        System. out.println("running修改前:running:"+running+"   线程t的状态:"+t.getState()) ;

        running = false;
        
       System. out.println("running修改后:running:"+running+"   线程t的状态:"+t.getState());
     
    }
}

运行结果:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第19张图片
running是被修改的,子线程也结束了,所以volatile保证了内存可见性问题。

4)volatile限制代码重排序

volatile的限制代码重排序只用在一个点上,即对象的初始化
new一个对象:

Person p;
p=new Person("yy”,"男”,3);

总共分为三大步:

  • 1)new:根据类进行内存分配(在堆上),隐含着计算对象大小,所有属性初始化为0的过程。
  • 2) 执行Person构造方法(很多小步骤)
  • 3)把对象的引用赋给p 这个引用。

把代码重排序考虑进来之后,执行顺序有:

  • 1)-> 2)->3):我们以为的顺序
  • 1) ->3)->2):实际重排序后可能的顺序

在单线程下,是没有任何影响的,但是在多线程下呢?
假如p是共享的,如下图所示,在B线程使用p变量的时候就会导致使用的是初始化一半的对象,因此就产生问题。
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第20张图片
在使用了volatile之后,就可以保证初始化对象的顺序一定是1)-> 2)->3),即new…->Person() -> p…;

3、双重校验锁

上面我们详细介绍了synchronized和volatile两个关键字的用法和作用,这部分主要来介绍一下两个关键字结合起来的应用——单例模式(Singleton pattern),即一种设计模式。

设计模式: 简单的理解就是在劳动人民的长期劳动过程中,总结出一套在某个范围内,比较合适的代码思路(架构)
特点:

  • 经验的产物
  • 和语言有关联性
  • 一定有适用范围的

单例模式: 其实是一种设计模式(Design Pattern),由于它的作用就是保证只产生一个对象,所以叫做单例模式。

1)饿汉模式——立即初始化

饿汉式:这个类只允许生成一个对象,在单线程中是线程安全的;多线程中就会有很多问题。

代码:

public class Singleton {

	 //更优一点:把构造方法声明为private,保证别人无法调用构造方法
      private Singleton() {}
      
      private static Singleton instance = new Singleton();
      
      public static Singleton getInstance() {
       return instance; 
       }
}

首先,初始化一个静态属性的实例对象:是发生在类加载时期(初始化),是线程安全(JVM内部保证只有一个线程执行)

private static Singleton instance = new Singleton();

getInstance()方法是返回创建的静态对象,其他类如果需要Singleton 类的对象,统统调用getInstance()这个静态方法获取,因此就保证不会有新的对象产生。

更优化一点:把构造方法声明为private,保证别人无法调用构造方法

饿汉模式总结:

  • 立即初始化一个静态对象(类加载时期),保证了只产生一个对象。
  • 单线程下是线程安全的,多线程不安全。
2)懒汉模式——单线程版——线程不安全

懒汉模式:延时加载方式,即: 需要的时候,才进行初始化(如果不是一开始就需要,就没有一开始就分配资源)

缺点:会使情况变得非常复杂(出现bug的几率变大)

懒汉模式和饿汉模式的区别就是:饿汉模式是立即初始化,懒汉模式是延时初始化。

代码:

public class Singleton {
 	   private static Singleton instance = null;
       public static Singleton getInstance()
       {
           if (instance == null) {
            //new一个对象时会有非原子操作,所以下面这行代码会存在原子性线程安全问题
            instance = new Singleton();
        }
        return instance;
    }
 }

这种单例模式仅仅是在单线程的情况下每次产生的是一个对象,如果是多线程,既没有保证原子性也没有保证禁止指令重排序,因此是线程不安全的。

是线程安全的么? -------------->不安全
1>是不是只会有一个对象?
---------------->不是,没有保证原子性,所以拿到的对象有可能是其他线程初始化出来的对象。
2>对象是否完整无误?
---------------->不一定,没有保证禁止指令重排序,因为在创建对象分为三步,有可能拿到的是别的线程初始化一半的对象。

3)懒汉模式——多线程版——效率低

由于上面版本的懒汉模式存在线程安全的问题,因此我们在这个版本使用synchronized对getInstance()方法进行加锁,保证new出来的对象不受其他线程干扰。

代码:

public class Singleton {
 	   private static Singleton instance = null;
 	   
       public synchronized static Singleton getInstance()
       {
           if (instance == null) {
           
            instance = new Singleton();
        }
        return instance;
    }
 }

我们给getInstance()方法使用synchronized进行加锁,保证了该方法里边的代码具有原子性。即:每个线程在调用getInstance()方法时,因为有synchronized进行加锁,保证这个方法再执行时只有一个线程,其他线程都是处于阻塞状态,因此是线程安全的。

但是效率是比较低的,我们来具体看一下原因。
假设getInstance()方法被调用了10000次,在这10000次中,主要分为两种:
在这里插入图片描述

  • 第一次instance为空,此时需要new一个对象,在new这个阶段(分为三步)一定是需要加锁的,否则可能导致创建对象被其他线程干扰。
  • 后面的9999次,由于在第一次已经创建出了instance对象,因此只需要直接返回第一次创建出来的对象即可,此时就没有必要加锁。而且竞争锁释放锁都是比较耗时的

综上,此版本的效率是比较低的。

4)懒汉模式——多线程版——性能高

这个版本的懒汉模式将synchronized和volatile关键字结合使用,保证了线程安全并提升了效率。也叫做双重校验所

要点1:经过两次判断,解决保证仅仅创建一个对象的问题。

要点2: instance = new Singleton()可能会被重排序,引起问题,因此采用volatile来禁止指令重排序。

 private static volatile Singleton instance = null;
    public  static Singleton getInstance() {
        //提高效率,变量使用volatile可以保证可见性
        if (instance == null) {
            synchronized(Singleton.class)
            {
                //为了保证单例---》返回同一个对象
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

具体来看看这两个关键字如何配合使用并达到效果的。

二次判断:保证同一个对象,并且提升效率:

如何保证是同一个对象呢?我们先假设只有一次判断,代码如下:

 
    public  static Singleton getInstance() {
        
        if (instance == null) {
        
            synchronized(Singleton.class)
            {
                  instance = new Singleton();
            }
          
        }
        return instance;
    }

假设有两个线程A和B在此刻被CPU调度,来看一下执行过程:

  • 首先,cpu调度到了线程A,线程A执行 if (instance == null) ;判断为null。此刻CPU从线程A切换下来了,调度到了线程B。
  • 线程B执行 if (instance == null) ; 判断为null。进入synchronized代码块并且加锁,通过 执行 instance = new Singleton();来创建对象。 创建完成释放对象锁。
  • CPU此刻又调度线程A,线程A获取对象锁, 执行synchronized代码。此时通过代码 instance = new Singleton();又创建了一个对象,创建完成释放对象锁。

多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第21张图片

通过上面的执行过程可以看出:只有一次判断就不能保证是同一个对象;因此需要二次判断。

当CPU从线程B切换下来时,线程A又重新获得对象锁,此时线程A通过第二次判断对象是否为空,发现此时对象已经不为空了,所以直接执行最后一句代码返回这个已经存在的对象即可。
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第22张图片

在第1行代码判断是否为空,如果不为空可以直接返回提高了效率

volatile来禁止指令重排序:

我们先假设不使用volatile,只看代码中的二次判断和synchronized,并且假设有两个线程A和B来执行这个代码。
代码:

 private static Singleton instance = null;
    public  static Singleton getInstance() {
        //提高效率,变量使用volatile可以保证可见性
        if (instance == null) {
            synchronized(Singleton.class)
            {
                //为了保证单例---》返回同一个对象
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

因为创建一个对象要分为三步,线程A在执行了两步时将没有初始化的对象赋给了instance引用,而此时CPU如果从线程A切换下来,开始调度B线程,如果B线程此时使用这个未初始化的对象就会出现问题,过程见下图所示:
多线程 3——线程安全三大特性、volatile、synchronized、单例模式_第23张图片

总结:

  • synchronized和volatile结合使用达到读写分离,读取操作放在加锁操作的外面(读取操作本身是具有原子性的指令),因为是 直接从主内存中读取的(volatile)保证了可见性。
  • 在new这个操作的时候分解为三条指令,是不具有原子性的, 所以需要通过synchronized进行加锁,保证原子性。
  • 在第1次判断是否为空,如果不为空可以直接返回提高了效率在加锁之后为了保证返回的是同一个对象还需要再次判断(第2次判断)
  • 双重校验所保证了:读取是并行并发的,写操作是同步互斥的

你可能感兴趣的:(JAVA,Web)