Java 线程安全问题的本质

原创声明:作者:陈咬金、 博客地址:https://www.cnblogs.com/zh94/

目录:

线程安全问题的本质

出现线程安全的问题本质是因为:

主内存和工作内存数据不一致性以及编译器重排序导致。

所以理解上述两个问题的核心,对认知多线程的问题则具有很高的意义;

简单理解CPU

CPU除了控制器、运算器等器件还有一个重要的部件就是寄存器。其中寄存器的作用就是进行数据的临时存储。寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。

CPU读取指令是通过内存去读取的,读一条指令放到CPU 寄存器中,然后CPU去执行处理;所以从内存中去读取指令的速度快慢就决定了这个CPU的执行速度快慢。
无论我们的CPU怎么去升级,如果从内存读取数据的速率问题不解决的话,其CPU的执行性能也不会得到多大的提升。

为了弥补这个问题,在CPU中添加了高速缓存的机制,如ARM A11的处理器,它的1级缓存中的容量是64KB,2级缓存中的容量是8M。

通过增加CPU高速缓存的机制,如果寄存器要取内存中同一内存位置中的数据,则直接从高速缓存中读取即可,无需直接从主内存中进行读取,以此弥补服务器内存读写速度的效率问题,提高CPU的执行速率;

经过简化后的CPU与内存操作的简易图,如下图所示:

JVM虚拟机类比于操作系统(可见性)

JVM虚拟计算机平台就类似于一个操作系统的角色,所以在具体实现上JVM虚拟机也的确是借鉴了很多操作系统的特点;

CPU在计算的时候,并不总是从内存读取数据,它的数据读取顺序优先级 是:寄存器-高速缓存-内存; JAVA中线程的工作空间(working memory)就是CPU的寄存器和高速缓存的抽象描述,
Java内存模型中规定了所有的类变量都存储在主内存中,每条线程还有自己的工作内存(类比于CPU的高速缓存),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,
线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量,操作完成后再将变量写回主内存。不同线程之间无法直接访问对方工作内存中的变量,
线程间变量值的传递均需要在主内存来完成。基本关系如下图:

注意:这里的Java内存模型,主内存、工作内存是一个抽象的概念与Java内存区域模型的Java堆、栈、方法区本质上不是同一层次的内存划分。

尽管Java内存模型和Java内存区域模型的划分不是一个层次的划分。“但是如果将Java内存模型中的工作内存和主内存一定要落实到对应的Java内存区域模型中时”,
Java工作内存又可以被称作为栈,而主内存则对应的是Java内存区域模型中的堆;所以后续提到的线程工作内存的概念时,读者也可以直接理解为线程的栈空间即可,而主内存则直接对应到堆空间上即可;

Java 内存模型对应英文为(Java Memory Model 简称为 JMM)之所以说JMM和Java内存区域模型并非一个层次的划分的原因是:JMM 本身是一个抽象的概念,并不具体存在,它所描述的是一组规范或者说规则,通过这种规则定义了
Java程序中各个变量的访问方式。 请仔细理解一下这句话“JMM的规则定义了 Java程序中各个变量的访问方式”;

那么在JAVA程序中,各个变量的访问方式是什么样的? 在JAVA中我们常知的变量定义有:类的实例变量,静态变量;那么这几种变量的访问规则是什么样的?

在JMM的规范中定义如下:在Java中所有的变量都是存储在主内存堆中,主内存是共享内存的区域,所有的线程都可以访问。
但线程对变量的操作(读取,赋值)则必须在线程的工作空间(栈)中执行;所以在线程的执行过程中,首先需要将变量从主内存中拷贝到自己的工作空间中,然后对变量进行操作,
操作完成后,再将变量写回主内存中。所以线程的工作空间中是不能直接操作主内存中的变量,而是操作的是主内存中的变量副本的拷贝。
因此各个线程之间是无法访问对方的工作内存的,线程的通信传值则必须通过主内存来进行完成;JMM的简要访问过程如下图:

Question:看到这里,应该会有一个疑问,上面所提到的JAVA中我们常知的变量有:“类的实例变量,静态变量”;难道方法内所定义的本地变量就不属于变量吗?就不受JMM的规范要求吗?

Answer:方法内所定义的本地变量当然也属于JAVA中变量的一种,但是,方法内所定义的本地变量如果是基本数据类型则是不存储在Java的主内存堆中的,而是直接存储在Java的线程栈中;所以在线程执行对应方法时,
直接从栈帧的局部变量表中直接使用当前该线程栈中的变量即可。由于本身栈就是线程的私有内存空间,所以不存在方法内本地变量共享内存区域的问题。自然也就无需受到JMM的变量访问规则的要求。

注意,严谨起见还是再说明一下:
根据JAVA虚拟机的规范,方法内的本地变量定义如果是基本数据类型(byte,short,int,long,double,boolean)则是直接存储在线程栈帧的,所以无需收到JMM的变量访问规范要求。
但是,如果方法内的本地变量定义是对象的引用类型,那么该变量的引用会存储在栈帧中,而对象的实例则还是存储在主内存中的。

关于Java栈中的生命周期及栈帧中所存储局部变量表所对应的内容,可以参考之前的这个文章:java虚拟机的内存区域分配

通过如上的介绍,应该对JMM的规范有了一定的了解,下面举一个小的例子:


public class JavaBasicTest {
    private Integer num = 0;

    public static void main(String[] args) {

        JavaBasicTest javaBasicTest = new JavaBasicTest();

        new Thread(() -> javaBasicTest.numPlus()).start();

        new Thread(() -> javaBasicTest.numPlus()).start();
    }

    public void numPlus() {
        num = num + 1;
        System.out.println(num);
    }

}

想把一个简单的东西用语言说明清楚还真不是很容易。
此处详细说明一下:

1、我们上述测试类中所定义的类变量: private Integer num = 0; 就是存在于我们的主内存(堆)中;

2、线程在执行到numPlus()方法的时候,由于方法内 存在对实例变量num的引用,所以此处方法内num的值实际是主内存的值的拷贝;

3、所以当第一个线程执行到:num = num+1 时,此时的num结果为2;操作完成后,重新写入主内存中;

4、然后第二个线程开始执行numPlus()方法,此时方法内num的值是主内存的拷贝,也就是 num = 2;然后 num=num+1;此时第二个线程执行完毕后,num结果为3;

OK,以上是正常执行的情况下;实际执行过程中,由于线程1和线程2是并行执行的,那么可能存在线程1在执行完以后,num的结果还未实时写到主内存时,线程2开始执行num = num+1;
此时由于线程1的执行结果num=1,并未实时写到主内存中,所以此时线程2所拿到的主内存的拷贝num还是为0,然后执行num =num+1;线程2执行完毕;此时num值仍然为1;

这就是一个很典型的!线程并发所引起的数据异常问题;我们通常把这种问题,称作为:数据的不可见性所导致的并发问题

那么想要解决上述的问题,其实也很简单:

1、线程在执行num =num+1时,此时线程不进行num变量的主内存拷贝,而是直接读取并修改主内存的值;(可以想象,由于没有使用到线程的工作空间,而是直接操作的主内存进行的操作,所以性能上会有细微的影响,而这个影响的范围就是和主内存的读写速率直接挂钩)

2、在执行numPlus()方法时,加一把锁就可以;如:线程1执行numPlus()时加锁,而此时线程2由于没有获取到锁,所以只能等待线程1执行完毕后,才能获取锁,并执行numPlus()的方法,所以,此处不存在并发修改的问题,也就自然不会出现数据可见性而引起的并发问题;

所以!看!单纯的一个数据可见性的问题解决,就引申出了JAVA中多线程的两种玩法,一种是不加锁的案例,另外一种是加锁的案例;
不加锁的案例在JAVA中的对应玩法则是:volatile,而加锁的案例在JAVA中对应关键词则是:Synchronize 以及 Lock;
而Lock则又引申出了一系列的Java并发包下的线程玩法,分别是:ReentrantLock,AQS,CountDownLatch,ReadWriteLock 以及 共享锁 Semaphore 等一系列针对锁的优化在不同场景下的玩法;
这些具体的Jdk自身所提供的的各种线程的操作,我们后续再聊,然后接着向下看。

原创声明:作者:陈咬金、 博客地址:https://www.cnblogs.com/zh94/

重排序(有序性)

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

指令并行重排的定义:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),
处理器可以改变语句对应的机器指令的执行顺序;指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性;

举例如下:

public class Singleton {
    public static  Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

如上,一个简单的单例模式,按照对象的构造过程,实例化一个对象可以分为三个步骤(指令):

1、 分配内存空间。

2、 初始化对象。

3、 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能变为如下的过程:

1、 分配内存空间。

2、 将内存空间的地址赋值给对应的引用。

3、 初始化对象 。

指令重排后,在单线程串行执行的情况下,不会有任何问题;但是
如果出现并发访问getInstance()方法时,则可能会出现,线程二判断singleton是否为空,此时由于当前该singleton已经分配了内存地址,但其实并没有初始化对象,
则会导致return 一个未初始化的对象引用暴露出来,以此可能会出现一些不可预料的代码异常;

代码优化后的结果如下:

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

JMM并非在任何情况下都会进行重排序,比如Java编译器在生成指令序列的时候会禁止特定类型的处理器&编译器进行重排序的处理,
并且JMM的重排序优化需遵循数据的依赖性(如果两个操作同时访问一个变量,构成数据上下文依赖,则不会改变数据依赖关系的两个操作的执行顺序),
以及happens-before的原则,并非随意的进行指令重排序;具体的重排序时需遵循的排序规则,后续给出对应的参考链接,感兴趣的可以直接参考下;

不过,需要明确的一个重排序原则是:无论重排序如果优化,都是会保证单线程中串行语义执行的一致性的,但是并不会保证多线程的语义一致性;

从上述的举例也可以看出,由于涉及到指令重排的情况下,是可以保证串行语义的一致性的,所以如果涉及到多线程的语义一致性问题,而导致的并发异常的问题,
那么大概率,此时多线程并发的问题是和变量的共享有关。所以也就和数据的可见性息息相关;所以在处理这类问题的情况下,直接站在
数据可见性的角度来处理这类并发问题,一般情况下没有任何问题;目前还没有遇到过解决了并发的数据可见性问题后,
还由于排序性问题所引起的并发问题的案例,当然这只是站在我个人的经验角度给出的评估,如果有类似案例的话,欢迎交流下。

OK接着向下。

原创声明:作者:陈咬金、 博客地址:https://www.cnblogs.com/zh94/

总结

多线程安全的三个核心原则分别是:可见性,有序性 和原子性;

上面已经分别针对“可见性”和“有序性”分别做了详细说明和举例,对于原子性,简单说一下:

其实原子性是一个很简单的规则,比如: a = 2; 这是一个原子性的操作,直接把对应的2进行赋值给到 a这个变量;但是如果是 a = a + 2;
那么这就不是一个原子性的操作,因为 a = a + 2 涉及到两个步骤,a + 2 ,然后得到结果再赋值给 对应的 a 变量;因为这个操作涉及到两个步骤所以并非原子化的操作;
那么对于这种非原子化的操作,在写代码coding的时候就需要注意下,是否会引起多线程异常的问题;

但是,注意的是,类似于上述 a = a + 2 的操作,一般情况下我们是在一个方法中直接定义并操作,那么此时是没有线程安全问题的,这个也就是栈的作用,这个不多说了;
另外一个就是,如果 a 是直接定义的类实例变量,然后在方法中操作的时候,此时的原子性问题所引起的风险,这个则是在实际开发中需要考虑的问题。

不过对于像上述的 a = a + 2 的这种非原子化操作所引起的线程异常的情况,在JAVA中也有提供对应的一系列的(非加锁)方案(针对上述的场景直接加锁是大可不必的,浪费性能);
所以JDK中也提供了Unsafe类下的一系列 CAS 操作,如JDK Atomic包下的一系列现成类:AtomicInteger,AtomicBoolean,AtomicReference,AtomicIntegerArray,AtomicStampedReference 等,都可以以无锁的方式直接解决上述的原子化所引起的
线程安全问题;关于Atomic包下的各种类的使用方式及实现原理,其实也比较简单,后续再详细写文章细聊。

OK,然后关于Java线程安全的问题,聊到这里,基本,也就结束了。陆陆续续写了也好多。

参考链接

一篇博客不可能说明所有问题,毕竟要紧扣主题,所以看到这里,如果对JMM的重排序的具体规则还想了解下对应的细节,则可以参考下如下链接:

Java内存模型总结 & JVM虚拟机的内存区域分配

关于Java栈中的生命周期及栈帧中所存储局部变量表所对应的内容,可以参考之前的这个文章:

java虚拟机的内存区域分配

最后的最后!如果想要更清楚的了解JVM及并发的相关知识,建议直接查看如下书籍,寻求答案:

《深入理解JVM虚拟机》
《Java高并发程序设计》
《Java并发编程的艺术》

posted @ 2020-12-09 17:14  陈咬金  阅读(1434)  评论(0编辑  收藏  举报