多线程之线程安全性

作者: 爱打乒乓的程序员 | 来源:发表于2019-04-14 22:28 被阅读4次

多线程环境下使用非线程安全类会导致线程安全问题。线程安全问题表现为原子性,有序性,可见性

在讲述线程安全三大特性之前,先了解CPU一些基本概念(寄存器,高速缓存,缓存一致性。关系图下面有~)。

什么是寄存器?为什么寄存器比内存快?

引用Wiki对寄存器的部分描述:

寄存器(Register),是中央处理器内的其中组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器。在中央处理器的算术及逻辑部件中,包含的寄存器有累加器。
寄存器的作用:
1.可将寄存器内的数据执行算术及逻辑运算。
2.存于寄存器内的地址可用来指向内存的某个位置,即寻址。
3.可以用来读写数据到电脑的周边设备。

什么是高速缓存?

引用Wiki对高速缓存的部分描述:

在计算机系统中,CPU高速缓存是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

什么是缓存一致性?

引用自《Java多线程实战指南》:

MESI协议是一种广为使用的缓存一致性协议,x86处理器所使用的缓存一致性协议就是基础MESI协议的。MESI协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的,即针对同一内存地址进行的写操作在任意一个时刻只能够由一个处理器执行。一个处理器往内存写数据时必须持有该数据的所有权。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
由于计算机的存储设备与处理器的运算速度之间有着几个数量级的差距,所以现代计算机不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。


可见性

什么是可见性?

线程A对共享变量更新,线程B能立即“看到”共享变量更新后的结果,就证明线程A对该共享变量的更新对其它线程可见。多线程可见性存在的问题表现为线程A对共享变量更新,其它线程没有立即获取到共享变量更新后的结果,读取到脏数据导致程序出现难以排错的bug。

实际上,线程可见性与计算机存储系统有关。共享变量可能会被存储到寄存器,处理器可能直接在寄存器读写变量而不是从内存中读写共享变量。由于处理器之间不能互相读写寄存器中的变量,这样就有可能出现处理器A更新在寄存器中的共享变量X,而处理器B不能立刻更新本身寄存器中共享变量X的值,导致处理器B读取共享变量X的旧值。这就是线程安全特性中的可见性。

如何保证多线程环境下的可见性?

可以使用锁和volatile。(具体原因后面会讲)

原子性

原子的字面意思是不可分割,一方面指的是除了当前线程访问(读,写)共享变量的操作以外的任何线程,其访问操作要么已经执行结束,要么尚未发生。另一方面,访问同一组共享变量的原子操作是不能被交错的。比如A线程访问共享变量,B线程不会“看到”A线程执行的中间过程。同一组共享变量不能同时被多个线程操作。

在Java中,long型和double型以外的基本类型和引用类型变量的写操作都是原子性操作。

为什么long型和double型变量写操作不保障原子性操作呢?因为long和double类型都是64位(8字节)的存储空间。在32位的Java虚拟机下的写操作可能被分为两个步骤操作,先写入低32位,再写入高32位,这样在多线程环境下共享long或者double类型变量,可能出现A线程对变量执行低32位操作,B线程执行高32位操作,导致变量值出错。

不过这种读取到“半个变量”的情况非常罕见(目前商用的Java虚拟机中不会出现),因为Java内存模型虽然允许虚拟机不把long和double类型变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此我们再编写代码时无需把long和double类型变量专门声明为volatile。-->《深入理解Java虚拟机 第二版》373页

实现原子性的两种方式:

软件层面: (利用锁的排他性,共享变量任意一个时刻只能被一个线程操作。)

硬件(处理器和内存)层面:CAS (Compare and Swap)

什么是CAS?

CAS是对一种处理器指令的称呼。CAS能够将read-modify-write和check-and-act之类的操作转换为if-then-act操作,由处理器保障该操作的原子性。java.util.concurrent(简称J.U.C)包完全建立在CAS之上的,没有CAS就不会有此包。可见CAS的重要性。

CAS操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。若更新失败(说明更新期间其它线程修改了共享变量V的值)则继续重试,直到成功。无论哪种情况,都会在CAS指令之前返回该位置的值。

伪代码如下:

boolean compareAndSwap(Variable V, Object A, Object B) {
    //检查变量值是否被其它线程修改过
    if (A == V.get()) {
       //更新变量值
       V.set(B);
       //更新成功
       return true;
    }
    //变量值已被其他线程修改,更新失败。(更新失败会继续重试,直到成功)
    return false;
}

CAS仅保障了共享变量更新操作的原子性,但不具有可见性。在J.U.C包下的原子变量类基于CAS和volatile实现保障了原子性,可见性和有序性。

如AtomicLong类中变量value的声明如下:

private volatile long value;

乐观锁用到的机制就是CAS。

Synchronize是一种独占锁,而独占锁是一种悲观锁。

CAS的缺点:

  • 循环时间长,开销大。

  • 只能保证一个共享变量的原子操作。

  • ABA问题。

循环时间长,开销大: 如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

只能保证一个共享变量的原子操作: 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

什么是ABA问题?ABA问题怎么解决?

如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?

如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。AtomicStampedReference引用类通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类比较“鸡肋”,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,引用传统的互斥同步可能会比原子类更高效。(引用自《深入理解Java虚拟机 第二版》)

不是用原子类声明的变量自增操作是不具有原子性的。

以下代码执行结果有可能出现小于20000,这是因为i++不具有原子性

public class AtomicDemo {
        public static void main(String[] args) throws InterruptedException {
            ThreadDemo thread = new ThreadDemo();
            new Thread(thread, "A").start();
            new Thread(thread, "B").start();
            System.out.println(thread.getValue());
        }
    }

    class ThreadDemo implements Runnable {
        private Integer value = new Integer(0);

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                value++;
            }
        }

        public Integer getValue() {
            return value;
        }
    }

使用原子变量类可以保证原子性,有序性,可见性。(volatile特性:有序性和可见性)

private AtomicInteger value = new AtomicInteger(0);

有序性

有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的。

在多线程环境下,程序代码执行顺序是没有保障的,编译器有可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫作重排序

重排序是对内存访问有关的操作(读和写)所做的一种优化。在单线程环境下不会对正确性造成影响,还会提高性能。但在多线程环境下,可能导致线程安全问题。

重排序潜在来源有许多,包括编译器(Java平台指的的是JIT编译器),处理器和存储子系统(包括写缓冲器Store Buffer,高速缓存Cache)。

Java平台包括两种编译器:静态编译器(javac)和动态编译器(JIT编译器)。静态编译器是将Java源代码编译为字节码,在代码编译阶段介入的;动态编译器是将字节码编译为Java虚拟机宿主机的本地代码(机器码),在Java程序运行过程中介入的。

Java平台中,静态编译器基本不会执行指令重排序,而JIT编译器则可能执行指令重排序。

JIT 编译器的重排序

执行 helper = new Helper(data);

这个语句可以分解为以下三步伪操作:

//1.分配所需内存空间,并获得一个指向该空间的引用
objRef = allocate(Helper.class);
//2.调用构造器初始化 objRef 引用并指向引用的Helpe实例
invokeConstuctor(objRef);
//3.将Helper实例引用赋值给实例变量helper
helper = objRef;

可能出现的情况是3指令被重排到2之前这样就导致objRef尚未初始化就赋值给了helper,使得helper出现null的情况。

处理器也可能执行指令重排序,采用一种称为猜测执行的技术。猜测执行能够造成if代码块中的代码先执行,再执行条件判断,这使得执行顺序与程序顺序不一致,即有可能导致重排序。

为什么会需要处理器重排序呢?

处理器对指令进行重排序也被称为处理器的乱序执行。现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令,而是动态调整指令的顺序,做到哪条指令就绪就执行哪条指令,这就是处理器的乱序执行。

执行顺序可能会先执行3再执行2。在单线程环境下不会对正确性有影响,但是在多线程环境下会出现非预期的结果。

boolean flag = flase;
if (flag) { //1
    for (int i = 0; i < 1000; i++) { //2
          doSomething(); //3
    }
}

但是重排序并不是任意对指令,内存操作的结果进行任意排序或顺序调整,而是会依据一定的规则。在单线程环境下执行程序有一种假象,程序是按照源码顺序执行,这种假象叫貌似串行语义。在单线程环境下可以保证重排序不受影响,在多线程环境下不保障正确性。

存在数据依赖关系的语句是不会重排序,而存在控制关系的语句是会重排序。

long price = 200L;//语句1
long num = 10;//语句2
long sum = price * num;//语句3
if(num < 1){//语句4
    System.out.println("Hello World!");//语句5
}
  //println方法源码:
  public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
  }

语句1和语句2不存在数据依赖关系,可能会重排序;

语句3与语句1和语句2有数据依赖关系,所以不会发生重排序;

语句4和语句5存在控制关系,可能会重排序。(先执行语句5,然后将结果存进重排序缓存,如果语句4为真则会将结果写入变量x)

那么问题来了,前面总是说单线程环境下重排序不会受影响,为什么呢?

在单线程环境下,线程A执行结束之前,cpu通过时间片分配,切换线程执行线程B。即使线程A执行过程中发生了重排序,但会将正在执行的指令执行完(将线程A所有的执行结果提交到主内存)之后再进行上下文切换。这种重排序对于切换到其它线程而言就像不存在一样。

什么是时间片?以下截图摘自Wikiwee

相关文章

  • Java多线程(10)

    Java多线程(10) 非阻塞队列 ConcurrentHashMap HashMap在多线程条件下的不安全性: ...

  • ThreadLocal实现原理揭秘

    ThreadLocal是什么?对java多线程有了解的人都清楚,在多个线程程序中,会出现线程安全性问题,即多线程是...

  • 工作3年的Java程序员,轻松拿到阿里P6Offer,只因为他搞

    Redis中的多路复用模型 Redis6用到了多线程?那多线程应用在哪些地方,引入多线程后,又改如何保证线程安全性...

  • 02.线程安全性问题

    [TOC] 安全性问题概述 什么是安全性问题 多线程情况下的安全问题,是指数据的一致性问题,在多线程环境下,多个线...

  • 多线程安全性和Java中的锁

    Java是天生的并发语言。多线程在带来更高效率的同时,又带来了数据安全性问题。一般我们将多线程的数据安全性问题分为...

  • JAVA的三大版本含义

    优点: 分布式 多线程 健壮性 安全性 高效性

  • JAVA特点

    1、简单易学 2、安全性高 3、跨平台 4、多线程

  • 多线程之volatile

    volatile Synchronized 同步锁给多个线程访问的代码块加锁以保证线程安全性。多线程之Synchr...

  • 谈谈并发编程中的线程安全性

    1. 线程安全性 在单线程程序中,我们并不需要去考虑线程的安全性。但是在多线程程序中,由于多个线程要共享相同的内存...

  • 深入JVM内核7 锁

    深入JVM内核 目录 1. 线程安全 多线程网站统计访问人数使用锁,维护计数器的串行访问与安全性 多线程访问Arr...

网友评论

    本文标题:多线程之线程安全性

    本文链接:https://www.haomeiwen.com/subject/yoyewqtx.html