JUC(16)线程安全2:当我们说线程安全时,到底在说什么

写在前面:此篇博客是参考了技术世界原文链接 http://www.jasongj.com/java/thread_safe/

前言

  • 提到线程安全,可能大家的第一反应是要确保接口对共享变量的操作要具体原子性。实际上,在多线程编程中我们需要同时关注原子性、可见性和有序性(顺序性)问题。线程安全就在这三个方面体现。
  • 本篇文章将从这三个问题出发,结合实例详解volatile如何保证可见性及一定程序上保证顺序性,同时写一下synchronized如何保证原子性、可见性、有序性,再对比volatile和synchronized。
  • 在谈及Volatile的三大特性中的可见性时就要先谈谈JMM(java内存模式),JMM为我们描述了一个线程与主内存之间是如何交互的,线程是怎样访问主内存拿到数据的。

一、多线程编程中的三个核心概念

1.1原子性

  • 这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。

  • 关于原子性,一个非常经典的例子就是银行转账问题:比如A和B同时向C转账10万元。C原本有20万。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来得及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。

1.2可见性

  • 可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。可见性问题是好多人忽略或者理解错误的一点。(这一点涉及到JMM)

  • CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

  • 这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。

1.3顺序性(有序性)

  • 顺序性指的是,程序执行的顺序按照代码的先后顺序执行。

  • 以下面这段代码为例

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4
  • 从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

  • 处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

  • 讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

二、JMM

  • 下面先来以下JMM,说一下上面可见性的问题。
  • JMM (java memory model) 即为JAVA 内存模型 ,不存在的东西,是一个概念,也是一个约定!
  • 关于JMM的一些同步的约定:
    • 1、线程解锁前,必须把共享变量立刻刷回主存;
    • 2、线程加锁前,必须读取主存中的最新值到工作内存中;
    • 3、加锁和解锁是同一把锁;

2.1什么是JMM

  • JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。

2.2内存划分

  • JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

在这里插入图片描述

  • JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。在这里插入图片描述

遇到问题:程序不知道主存中的值已经被修改过了!;(这就是可见性的问题)

2.3内存交互操作

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

    • 1、保证可见性 (这就要涉及JMM)
    • 2、不保证原子性
    • 3、禁止指令重排
  • 下面我们来验证三个特性

3.1 验证 保证可见性

package com.wlw.Test_Volatile;
import java.util.concurrent.TimeUnit;

public class JMMDemo {
    /**
     * 这个程序一共有两个线程: main线程 与 我们自己写的一个A线程
     * A线程中对变量num 进行判断,如果为0,就一直循环,而main线程中将num 赋值为1,此时我们没有对变量num加volatile关键字,A线程一直循环下去
     *
     * 如果我们对变量num加volatile关键字,就保证了可见性:当main线程中对num进行修改,A线程可以看见修改后的值,进而进行相关操作
     */
    private volatile static int num = 0;

    public static void main(String[] args) {

        new Thread(()->{
            while (num == 0){

            }
        },"A").start();

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

        num = 1;
        System.out.println(num);
    }
}

3.2 验证 不保证原子性

  • 原子性:不可分割;

  • 线程A在执行任务的时候,不能被打扰的,也不能被分割的,要么同时成功,要么同时失败。

package com.wlw.Test_Volatile;

//volatile 不保证原子性

/**
 * 一共20个线程,每个线程调用1000次add()方法,理论上num最后为20000
 * 但是多线程操作,会出现同一时刻多个线程对num进行操作(因为在字节码文件中num++ 这个操作被分为三步才执行完,不是原子操作),所以最后的值小于2万
 *
 * ,加上volatile关键字之后,最后num结果依然小于20000,所以验证了  volatile 不保证原子性。
 * 但是如果我们对add()方法 加上synchronized 或者 lock锁,是一定可以保证结果为2万的,
 * 但是问题是如果不加lock和synchronized (更耗费资源) ,怎么样保证原子性?
 */
public class VolatileDemo02 {

    private volatile static int num = 0;

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

    public static void main(String[] args) {
        // 一共20个线程,每个线程调用1000次add()方法,理论上num最后为20000
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j < 1000; j++) {
                    add();
                }
                add();
            }).start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "==>" + num);
    }
}
  • 问题:如果我们对add()方法 加上synchronized 或者 lock锁,是一定可以保证结果为2万的,但是问题是如果不加lock和synchronized (更耗费资源) ,怎么样保证原子性?

  • 解决:使用 java.util.concurrent.atomic(原子包) 包下的原子类

    • 这些类的底层都直接和操作系统挂钩!是在内存中修改值。需要用到Unsafe类,而Unsafe类是一个很特殊的存在;(在21.CAS中介绍)

    在这里插入图片描述

package com.wlw.Test_Volatile;

//volatile 不保证原子性

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 一共20个线程,每个线程调用1000次add()方法,理论上num最后为20000
 * 但是多线程操作,会出现同一时刻多个线程对num进行操作(因为在字节码文件中num++ 这个操作被分为三步才执行完,不是原子操作),所以最后的值小于2万
 * ,加上volatile关键字之后,最后num结果依然小于20000,所以 验证了  volatile 不保证原子性。
 * 但是如果我们对add()方法 加上synchronized 或者 lock锁,是一定可以保证结果为2万的,
 * 但是问题是如果不加lock和synchronized (更耗费资源) ,怎么样保证原子性?
 *   解决办法:使用 java.util.concurrent.atomic(原子包) 包下的原子类
 */
public class VolatileDemo02 {

    //private static int num = 0;
    //使用原子类AtomicInteger 替换int
    private volatile static AtomicInteger num = new AtomicInteger();

    public static void add(){
        //num++;
        num.getAndIncrement();//原子类的加1操作 , 里面是调用的是native方法, 用的是底层的CAS(cpu的并发原语,效率极高)
    }

    public static void main(String[] args) {
        // 一共20个线程,每个线程调用1000次add()方法,理论上num最后为20000
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j < 1000; j++) {
                    add();
                }
                add();
            }).start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "==>" + num);
    }
}

3.3 验证 禁止指令重排(有序性)

  • 什么是指令重排?

    • 我们写的程序,计算机并不是按照我们自己写的那样去执行的
    • 源代码–>编译器优化重排–>指令并行也可能会重排–>内存系统也会重排–>执行
  • 系统处理器在进行指令重排的时候,会考虑数据之间的依赖性!并不会随意地去排

int x=1; //1
int y=2; //2
x=x+5;   //3
y=x*x;   //4

//我们期望的执行顺序是 1_2_3_4  指令重排后,可能执行的顺序会变成2134 1324
//可不可能是 4123? 不可能的
  • 看一个例子:

    可能造成的影响结果:前提:a b x y这四个值 默认都是0

线程A线程B
x=ay=b
b=1a=2

​ 我们期望的 正常的结果: x = 0; y =0;

线程A线程B
b=1a=2
x=ay=b

​ 可能在线程A中会出现,先执行b=1,然后再执行x=a;

​ 在B线程中可能会出现,先执行a=2,然后执行y=b;

​ 那么就有可能结果如下:x=2; y=1

  • volatile可以避免指令重排: 是因为volatile中会加一道内存的屏障,这个内存屏障可以保证在这个屏障中的指令顺序。

    • 内存屏障,是CPU指令。作用:
      • 1、保证特定的操作的执行顺序;
      • 2、可以保证某些变量的内存可见性(利用这些特性,就可以保证volatile实现的可见性)

在这里插入图片描述

3.4小结

  • Volatile是可以保证可见性,不能保证原子性,由于内存屏障,可以保证避免指令重排的现象产生!
  • Volatile 内存屏障在哪使用最多:在单例模式中使用最多(饿汉式,DCL懒汉式中用到了Volatile)

四、Java如何解决多线程并发问题

4.1Java如何保证原子性

4.1.1锁和同步
  • 常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。(写到这里,联想到上一篇“线程安全一:如何保证线程安全”中线程安全的实现方法中第一中:互斥同步,也是用的synchronized和锁,从这一点也可以看出,要想实现线程安全,本质上还是要实现原子性、可见性和有序性,具体的情况还要具体分析)
public void testLock () {
  lock.lock();
  try{
    int j = i;
    i = j + 1;
  } finally {
    lock.unlock();
  }
}
  • 与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized关键字后面括号内的对象。下面是同步代码块示例
public void testLock () {java
  synchronized (anyObject){
    int j = i;
    i = j + 1;
  }
}
  • 无论使用锁还是synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。
4.1.2CAS(compare and swap)

基础类型变量自增(i++)是一种常被新手误以为是原子操作而实际不是的操作。Java中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了CPU级别的CAS指令。 由于是CPU级别的指令,其开销比需要操作系统参与的锁的开销小。AtomicInteger使用方法如下。

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
  new Thread(() -> {
    for(int a = 0; a < iteration; a++) {
      atomicInteger.incrementAndGet();
    }
  }).start();
}

4.2Java如何保证可见性

  • Java提供了volatile关键字来保证可见性。当使用volatile修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。(这一点可以看上面第三部分)

4.3Java如何保证顺序性

  • 上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致,所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性。

  • Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和锁来保证顺序性。

  • synchronized和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。

  • 除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式地保证顺序性。 两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。

4.3.1happens-before原则(先行发生原则)
  • 传递规则:如果操作1在操作2前面,而操作2在操作3前面,则操作1肯定会在操作3前发生。该规则说明了happens-before原则具有传递性
  • 锁定规则:一个unlock操作肯定会在后面对同一个锁的lock操作前发生。这个很好理解,锁只有被释放了才会被再次获取
  • volatile变量规则:对一个被volatile修饰的写操作先发生于后面对该变量的读操作
  • 程序次序规则:一个线程内,按照代码顺序执行
  • 线程启动规则:Thread对象的start()方法先发生于此线程的其它动作
  • 线程终结原则:线程的终止检测后发生于线程中其它的所有操作
  • 线程中断规则: 对线程interrupt()方法的调用先发生于对该中断异常的获取
  • 对象终结规则:一个对象构造先于它的finalize发生

五、Volatile与锁、synchronized的对比与一些线程问题

  • 锁和synchronized即可以保证原子性,也可以保证顺序性,也可以保证可见性。具体为什么可以看下面第六项,目前可以知道的是:原子性和顺序性都是通过保证同一时间只有一个线程执行目标代码段来实现的。

  • Volatile可以保证可见性和有序性,但不能保证原子性。所以Volatile适用于不需要保证原子性,但却需要保证可见性的场景。一种典型的使用场景是用它修饰用于停止线程的状态标记(可以参靠上面3.1的案例)

  • 问:既然锁和synchronized即可保证原子性也可保证可见性,为何还需要volatile?
    答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。

  • 问:既然锁和synchronized可以保证原子性,为什么还需要AtomicInteger这种的类来保证原子操作?
    答:锁和synchronized需要通过操作系统来仲裁谁获得锁,开销比较高,而AtomicInteger是通过CPU级的CAS操作来保证原子性,开销比较小。所以使用AtomicInteger的目的还是为了提高性能。

  • 问:还有没有别的办法保证线程安全
    答:有。尽可能避免引起非线程安全的条件——共享变量。如果能从设计上避免共享变量的使用,即可避免非线程安全的发生,也就无须通过锁或者synchronized以及volatile解决原子性、可见性和顺序性的问题。

  • 问:synchronized即可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别
    答:synchronized修饰非静态同步方法时,锁住的是当前实例;synchronized修饰静态同步方法时,锁住的是该类的Class对象;synchronized修饰静态代码块时,锁住的是synchronized关键字后面括号内的对象。

六、Synchronized底层如何保证原子性、可见性、有序性

6.1原理总结

  • 原子性:加锁和释放锁,保证同一时间只有一个线程拿到锁
  • 可见性:加了Load屏障和Store屏障,加锁时refresh数据,释放锁时flush数据
  • 有序性:Acquire屏障和Release屏障,保证代码块内部可以重排,但是代码块内部和代码块外部的指令是不能重排的。

6.2保证原子性

  • java对象是分为对象头和实例变量两块,其中实例变量就是对象那些变量数据,然后对象头包含了两块内容,一是 Mark Word(含hashCode、锁数据、GC数据等),另一个是Class Metadata Address(包含了指向类的元数据指针)

  • 在 Mark Word 中有一个指针,实例关联的 monitor 的地址,这个 monitor 是C++ 实现的一个 ObjectMonitor 对象,里面包含了一个 _owner 指针,指向了持有锁的线程。ObjectMonitor 中还有一个 entrylist,想要加锁的线程全部进入 这个entrylist 等待机会获取锁,实际有机会加锁的线程,就会设置 _owner 指针指向自己(jdk1.6以后,优化为CAS加锁),然后对_count 计数器累计1次。

  • 释放锁的时候,显示对_count计数器递减1次,如果为0了就会设置 _owner 为null,不在指向自己,代表自己彻底释放锁。

  • 如果获取锁的线程执行wait,就会将_count计数器递减,同时设置 _owner 为null,然后自己进入waitset中等待唤醒,别人获取了锁执行 notfiy 的时候就会唤醒waitset中的线程,竞争尝试获取锁。

  • 假设有new MyObject.java

在这里插入图片描述

6.3保证可见性

int a = 0;
synchronize (this){ //monitorenter
    // Load屏障
    a = 10;
    int b = a;
}//monitorexit
 // Store屏障
  • monitorenter 指令之后会有一个 Load 屏障,执行refresh处理器缓存操作,把别的处理器修改过的最新的值加载到自己的高速缓存中,

  • monitorexit 指令之后会有一个 Store 屏障,让线程把自己修改的变量都执行flush处理器缓存操作,刷到高速缓存或是主内存中

6.4保证有序性

int a = 0;
synchronize (this){ //monitorenter
    // Load屏障
    // Acquire屏障
    a = 10;    //内部还是会发生指令重排
    int b = a;
    // Release屏障
}//monitorexit
 // Store屏障
  • 在 monitorenter 指令和 Load 屏障之后,会加一个 Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排,

  • 在 monitorexit 指令前加一个Release屏障,也是禁止写操作和读写操作之间发生重排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

悬浮海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值