Java 多线程(二、线程安全)

139 阅读8分钟

Java 多线程(一、线程概念、创建和启动)

一、相关概念

线程安全

当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码不需要额外的协同或者同步,这个类都能表现出正确的行为,那么可以说这个类是线程安全的

概念有点绕,下面会具体说明,继续看其他概念说明

并发和并行

  • 并发:指两个或多个事件在同一个时间段内发生
  • 并行:指两个或多个事件在同一时刻发生

那么对于我们Java里面的多线程是并发还是并行呢?答案是都有可能,需求看操作系统的CPU类型

通过线程的概念我们知道:线程是CPU调度和分配的基本单位

根据CPU是单核还是多核,区分了线程是可以并发还是并行执行

单核多线程

单核多线程指的是单核CPU轮流执行多个线程,通过给每个线程分配CPU时间片来实现,只是因为这个时间片非常短(几十毫秒),所以在用户的角度上感觉上多个线程同时执行

多核多线程

可以把多线程分配给不同的核心处理,其他的线程依旧等待,相当于多个线程并行的执行(当然多核肯定也能并发执行多线程

线程调度

线程同时运行是一个宏观的概念,如果是单CPU单核的情况,从微观上来说线程只能串行一个个执行的,多个线程按照某种顺序执行,我们把这种情况称之为线程调度。

为什么多线程会有有线程安全问题?

这就涉及到另一个概念:Java内存模型(JMM)

二、Java内存模型

共享变量

Java中,所有实例变量、静态变量和数组元素(这些统称为共享变量)都存储在堆内存中,堆内存在多线程之间共享。而局部变量、方法参数,异常处理器参数是线程私有的。

JMM概念

JMM仅仅是JVM对内存访问的一种规范,是独立于物理机器的一种内存存取模型。(用于屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果)

Java内存模型描述了Java程序中共享变量的访问规则,以及JVM中将变量存储到内存和从内存中读取变量这样的细节。

具体规则如下:

  • 所有的共享变量都存储主内存(堆内存)中
  • 每个线程都有一个私有的本地内存,里面保存了该线程使用到的共享变量的副本
  • 线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存读取
  • 不同线程之间无法直接访问其他线程的本地内存,线程中共享变量的传递需要通过主内存实现

内存模型的示意图:

image.png

线程工作时,先把共享变量拷贝到自己的本地内存中,线程操作结束再将本地内存中修改后的共享变量值写回到主内存中。此时其他线程就可以到主内存中读取到更新后的共享变量值。

Java内存模型通过控制主内存与每个线程本地内存的交互,来为Java程序提供内存可见性保证(注意仅仅是提供不是保证)。

三、线程安全

问题产生

为什么会有线程安全问题?

是因为对共享变量的竞争访问

因为共享变量无论是读还是写操作都不是直接操作主内存,都需要分成两步。例如:写操作需求先写入线程的本地内存(写1),再由内存模型从本地内存中同步到主内存中(写2);读操作也是类似。

而且在多线程中这两步是可以穿插进行,意思就是例如写1,写2虽然是顺序执行,但是中间可以穿插其他线程的写或者读操作,当然读操作也是一样的,这就产生了数据的竞争。

所以多线程操作共享变量时,可能本地内存中数据没有及时刷新到主内存中,我们读到的还是没修改之前的数据,这就发生了线程安全问题。

问题解决

如何解决线程安全问题?这就需要保证JMM具备如下三种特性,这也是JMM的核心

原子性

原子 Atomic 意指不可分割,也就是作为一个整体,要么全部执行,要么不会执行

对于共享变量的访问操作,如果对除了当前线程以外的如何线程来说,都是不可分割的,那么就是具有原子性。

如何保证原子性?

  1. 使用锁机制,锁具有排他性,它能够保证一个共享变量在任意一个时刻仅仅被一个线程访问,这就消除了竞争
  2. 使用CAS指令(compare-and-swap)

(Java种基本类型以及引用类型的写操作都是原子的,除了long和double,因为其64位长度,如果是在32位机器上,写操作可能分成两个步骤,分别处理高低32位,这就打破了原子性,可能出现数据安全问题)

可见性

一个线程对共享变量的修改之后,能及时被另一个线程读取到该修改结果,那么就称这个线程对该共享变量的更新对其他线程可见

如果一个线程对共享变量做出了修改,而另外的线程却并没有读取到最新的结果,这是有问题的,这就需要保证可见性

如何保证内存可见性?

  1. 使用volatile关键字保证共享变量的新值能立即同步到主内存,以及每个线程在每次使用volatile变量前都立即从主内存刷新。

  2. 使用锁机制,通过如下两条规则保证可见性:

    • 如果对变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load伙assign操作初始化变量的值
    • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store和write操作)

有序性

关于有序性,首先要说下重排序的概念,如果没有重排序,那也没有有序性的问题

重排序是JVM对内存访问的一种优化,可以在不影响单线程正确性的前提下进行一定的调整,进入提高程序的性能。但是多线程场景下就可能出现问题

重排序对多线程的影响 举例说明:

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 2;      //1
        flag = true;//2
    }

    public void reader() {
        if (flag) {        //3
            int i = 10 / a;//4
        }
    }
}

如果现在现在有两个线程,线程A和线程B。线程A执行writer方法,线程B执行reader方法。假如现在执行操作3时,flag已经被标记为true了,执行操作4时a是否一定已经赋值了呢?

答案是不一定

因为操作1和操作2没有依赖关系,虽然源码的顺序是先1后2,但是编译器和处理器可以对这两操作重排序。线程1先将flag设置为true,这时候处理器切换到线程2判断flag是true,开始执行操作4,这时候程序就会发生错误,破坏了多线程程序的语义。

如何保证有序性?

  1. 使用volatile关键字来保证一定的有序性
  2. 使用锁机制,保证每个时刻只有一个线程执行同步代码,相当于让线程顺序执行同步代码,自如就保证了有序性。

如何界定指令会不会重排序:Java内存模型通过happens-before原则来确定两个操作的顺序。如果两个操作次序无法从happens-before原则推导出来,那么它们就不能保证按照代码顺序有序执行,虚拟机可以随意对他进行重排序。

四、总结

总结来说多线程的线程安全问题通过Java的volatile关键字(一定程度上上解决)或者锁机制来解决。

关于volatile关键字和锁机制(synchronized和lock)的使用和原理详解下篇文章

参考文章

多CPU,多核,多进程,多线程
单核cpu多核cpu如何执行多线程
java多线程之并行和并发
Java多线程之Java内存模型
Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十)
JAVA多线程——线程安全之原子性,有序性和可见性
happens-before俗解