【JAVA并发第四篇】线程安全

1、线程安全

多个线程对同一个共享变量进行读写操作时可能产生不可预见的结果,这就是线程安全问题。

线程安全的核心点就是共享变量,只有在共享变量的情况下才会有线程安全问题。这里说的共享变量,是指多个线程都能访问的变量,一般包括成员变量和静态变量,方法内定义的局部变量不属于共享变量的范围。

线程安全问题示例:

import lombok.extern.slf4j.Slf4j;

/**
 * @Author FengJian
 * @Date 2021/1/27 10:59
 * @Version 1.0
 */
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                for (int i = 0;i < 5000;i++){
                    count++;
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                for (int i = 0;i < 5000;i++){
                    count--;
                }
            }
        };

        t1.start();
        t2.start();

        /**
         * join方法:使main线程与t1、t2线程同步执行,即t1、t2线程都执行完,main线程才会继续执行(但t1、t2之间依然是并行执行的)
         * 主要是为了等待两个线程执行完后,在main线程打印count的值
         */
        t1.join();
        t2.join();

        log.debug("count的值为:{}",count);
    }
}

运行上述代码三次的结果如下:

[main] DEBUG c.ThreadSafeTest - count的值为:-904
[main] DEBUG c.ThreadSafeTest - count的值为:-2206
[main] DEBUG c.ThreadSafeTest - count的值为:73

在上述代码中,线程t1中count进行5000次自增操作,而线程t2中count则进行5000次自减操作。在两个线程都运行结束后,按照预期结果,count的值应为0。但由打印结果可知,count的值并不为0,且每次运行的结果都不一样。这就是多线程对共享变量进行操作出现的不可预见的结果,即常说的线程安全问题。

而线程安全,则指的是在多线程环境下,程序可以始终执行正确的行为,符合预期的逻辑。具体到上述代码,就是不论执行多少次,在t1、t2线程执行完毕后,count的值都应该始终符合预期的结果0。上述代码明显是线程不安全的。

2、出现线程安全的原因

线程安全是使用多线程必定会面临的问题,导致线程不安全的主要原因有以下三点:

①原子性:一个或者多个操作在 CPU 执行的过程中被中断
②可见性:一个线程对共享变量的修改,另外一个线程不能立刻看到
③有序性:序执行的顺序没有按照代码的先后顺序执行

2.1、原子性

2.1.1 什么是原子性问题

原子性问题,其实说的是原子性操作。即一个或多个操作,应该是一个不可分的整体,这些操作要么全部执行并且不被打断,要么就都不执行。

以上述代码中的count的自增(count++)和自减(count--)为例。

count++count--看似只有一行代码,但实际上这一行代码在编译后的字节码指令以及在JVM执行的对应操作如下:

count++:

getstatic count  //获取静态变量count的值
iconst_1   //准备常量1
iadd   //自增
putstatic count  //将修改后的值存入静态变量count

count--:

getstatic count  //获取静态变量count的值
iconst_1   //准备常量1
isub  //自减
putstatic count  //将修改后的值存入静态变量count

由此可知,count自增或自减的操作,并不是一个原子操作,即中间过程是有可能被打断的。

count自增自减操作需要四个步骤(指令)才能完成,这意味着如果这执行这四个步骤的某一步时,线程发生了上下文切换,那么自增自减操作将被打断暂停。

如果使用单线程来执行自增自减操作,这实际上并无问题:
在这里插入图片描述

上图为单线程执行count自增自减的一次过程,可以看出在没有线程上下文切换的情况下,即使自增自减不是原子操作,count的最后结果都会是0。

但在多线程环境下,就会出现问题了:

在这里插入图片描述
可以看到由于自增自减不是原子操作,因此在线程t1执行自增过程中,如果进行上下文切换,则将导致线程t1还没来得及把count = 1 写入主存,count的值就被t2线程读取,所以在最后,线程t2自减得出的值-1写入主存后,会被线程t1覆盖,变为1。

这结果明显是不符合我们的预期的,实际上,上述图片展示的只是一种可能的结果。还有可能是t2写入count的步骤是最后执行的,那么最后count的值将为-1。

这就是由于非原子操作带来的多线程访问共享变量出现不符合预期的结果,即由于原子性带来的线程安全问题。

上面示例中两个线程t1、t2分别执行count++和count--出现的问题,就是由于原子性带来的线程安全问题。

2.1.2、原子性问题解决办法

解决办法就是将count++和count--的操作变为原子操作,Java中的实现方法是:

①上锁:使用synchronized

只需要创建一个对象作为锁,并在访问count时用synchronized进行加锁即可。

 static int count = 0;
 static Object lock = new Object(); //锁对象
 
 synchronized(lock){ 
     count++;
 }

 synchronized(lock){ 
     count--;
 }

上锁后,执行自增自减的示意图如下:

在这里插入图片描述
由于锁的存在,则保证了不持有锁的t2线程会被阻塞,直到t1线程执行自增完毕,并释放锁。在这一过程中,虽然依旧存在线程的上下文切换,但是t2线程是无法对共享变量count进行操作的,因此保证了t1线程中count++操作的原子性。

因此使用synchronized锁可以解决原子性带来的线程安全问题。

②、循环CAS操作

其基本思路就是循环进行CAS操作(compare and swap,比较并交换)。即对共享变量进行计算前,线程会先将该共享变量保存一份旧值a,计算完毕后得出结果值b。在将b从线程的本地内存刷新回主内存前,会先比较主内存中的值是否和a一致。如果一致,则将b刷新回主内存。若不一致,则一直循环比较,直到主内存中的值与a一致,才把共享变量的值设为b,操作才结束。

在Java中,使用CAS操作保证原子性的具体实现就是Lock和原子类(AtomicInteger)。它们都是通过使用unsafe的compareAndSwap方法实现CAS操作保证原子性的。

Lock的使用:

static int count = 0;
static Lock lock = new Lock (); //锁对象

lock.lock(); //加锁
count++;
lock.unlock(); //解锁

lock.lock(); //加锁
count--;
lock.unlock(); //解锁

原子类的使用:

static AtomicInteger count = new AtomicInteger(0);

count.incrementAndGet(); //自增

count.decrementAndGet(); //自减

以上都是Java中可以保证原子操作的具体方法,它们各有优缺点,要看具体的场景来选择最佳的使用,以此来解决原子性带来的线程安全问题。

2.2、可见性

2.2.1、什么是可见性问题

可见性实际上指的是内存可见性问题。总的来说就是一个线程对共享变量的修改,另外一个线程不能立刻看到,从而产生的线程安全问题。

在上一篇笔记【JAVA并发第三篇】线程间通信 中的通过共享内存进行通信实际上讲的就是内存可见性问题。这里再从线程安全的角度讲述一遍。

我们知道,CPU要从内存中读取出数据来进行计算,但实际上CPU并不总是直接从内存中读取数据。由于CPU和内存间(常称之为主存)的速度不匹配(CPU的速度比主存快得多),为了有效利用CPU,使用多级cache的机制,如图
在这里插入图片描述
上图所示是一个双核心的CPU系统架构,每个核心都有自己的控制器和运算器,也都有自己的一级缓存,还有可能有所有CPU核心共享的二级缓存,每个核心都可以独立运行线程。

因此,CPU读取数据的顺序是:寄存器-高速缓存-主存。主存中的部分数据,会先拷贝一份放到cache中,当CPU计算时,会直接从cache中读取数据,计算完毕后再将计算结果放置到cache中,最后在主存中刷新计算结果。所以每个CPU都会拥有一份拷贝。

以上只是CPU访问内存,进行计算的基本方式。实际上,不同的硬件,访问过程会存在不同程度的差异。比如,不同的计算机,CPU和主存间可能会存在三级缓存、四级缓存、五级缓存等等的情况。

为了屏蔽掉各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果,定义了Java的内存模型(Java Memory Model,JMM)。

JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到主存和从主存中取出变量这样的底层细节。这里的变量指的是能够被多个线程共享的变量,它包括了实例字段、静态字段和构成数组对象的元素,方法内的局部变量和方法的参数为线程私有,不受JMM的影响。

Java的内存模型如下:

在这里插入图片描述
Java内存模型中的本地内存,对应的就是CPU结构图中的cache1或者cache2。它实际上并不真实存在,其包含了缓存、写缓冲区、寄存器以及其他的硬件和编译器的优化。

JMM规定:将所有共享变量放到主内存中,当线程使用变量时,会把其中的变量复制到自己的本地内存,线程读写时操作的是本地内存中的变量副本。一个线程不能访问其他线程的本地内存。

这样的情况下,如果有一个变量i在线程A、B的本地内存中都有一份副本。此时,若线程A想修改i的值,在线程A将修改后的值放入到本地内存,但又未刷新回主内存时,如果线程B读取变量i的值,则读到的是未修改时的值,这就造成了读写共享变量出现不可预期的结果,产生线程安全问题。

有代码如下:

/**
 * @Author FengJian
 * @Date 2021/2/21 23:47
 * @Version 1.0
 */
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafe02 {
    private static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread My_Thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (run) {

                }
            }
        }, "My_Thread");
        My_Thread.start();  //启动My_Thread线程
        log.debug(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
        Thread.sleep(1000);  //主线程休眠1s
        run = false;  //改变My_Thread线程运行条件
        log.debug(Thread.currentThread().getName()+"正在运行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
    }
}

从运行结果发现,即使在主线程中修改了共享变量run的值,My_Thread线程依然在循环并不会停止:
在这里插入图片描述
其原因就是main线程对共享变量run的修改,另外一个线程My_Thread并不能立刻看到:

在这里插入图片描述
这就是由于内存可见性带来的多线程访问共享变量出现不符合预期的结果,即由于可见性带来的线程安全问题。

2.2.2、可见性问题解决办法

解决办法就是保证共享变量的可见性,具体实现就是任何对共享变量的访问都要从共享内存(主内存)中获取。在Java中的实现方法是:

①加锁,synchronized和Lock都可以保证

线程在加锁时,会清空本地内存中共享变量的值,共享变量的使用需要从主内存中重新获取。而在释放锁资源时,则必须先把此共享变量同步回主内存中。

由于锁的存在,未持有锁的线程并不能操作共享变量,而当阻塞的线程获得锁时,主内存中共享变量的值已经刷新过了,因此线程修改共享变量对其他线程是可见的。这保证了共享变量的可见性,可以解决内存可见性产生的线程安全问题。

②使用volatile修饰共享变量

当一个变量被声明为volitale时,线程在写入变量时,不会把值缓存本地内存,而是会立即把值刷新回主存,而当要读取该共享变量时,线程则会先清空本地内存中的副本值,从主存中重新获取。这些也都保证了内存的可见性。

优先使用volatile关键字来解决可见性问题,加锁消耗的资源更多。

2.3、有序性

2.3.1、什么是有序性问题

有序性,实际上是指令的重排序问题。

我们知道,CPU的执行速度是比内存要快出很多个数量级的。CPU为了执行效率,会把CPU指令进行重新排序。即我们编写的Java代码并不一定按照顺序一行一行的往下执行,处理器会根据需要重新排序这些指令,称为指令并行重排序。

同时,JIT编译器也会在代码编译的时候对代码进行重新整理,最大限度的去优化代码的执行效率,称为编译器的重排序。

而又由于处理器与主存之间会使用缓存和读/写缓冲机制,因此从主存加载和存储操作也有可能是经过指令重排序的,称为内存系统重排序。

综上所述,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序,再加上主内存和处理器的缓存,Java源码经过层层的重排序,最后才得出最终结果。

在这里插入图片描述

由图可知,从Java源码到最后的执行指令,会经历3种重排序的优化。若有ava代码如下:

int a = 2; //A
int b = 3; //B
int c = a*b; //C

经过上述3种重排序后,语句A和语句B的执行顺序是可能互换的,并且这种互换并不影响代码的正确性。但是我们发现语句C则不能和A、B互换,否则得出的结果将不正确,因为他们之间存在着数据依赖关系,即语句C的数据依赖A和B得出。

由此,我们可以发现,以上3种指令的重排序并不能随意排序,他们需要遵守一定的规则,以保证程序的正确性。

①as-if-serial语义

as-if-serial语义是指:不管怎么样重排序,单线程程序的执行结果都不能被改变。即不会对存在数据依赖关系的操作进行重排序。

编译器、处理器进行指令重排序优化时都必须遵守as-if-serial语义。即在单线程的情况下,指令重排序只能对不影响处理结果的部分进行重排序。

以上述语句A、B、C为例,存在数据依赖关系的语句C和A或B不能被重排序:

在这里插入图片描述
as-if-serial语义把单线程程序保护起来了,遵守该语义的编译器、处理器等使我们编写单线程有一个错觉:单线程程序是按照源代码的顺序来执行的。实际上在由于as-if-serial语义的存在,我们编写单线程时,完全可以认为源代码是按照顺序执行的,因为即使代码被进行了重排序,其结果也不会改变,同时单线程中也无需担心内存可见性问题。

as-if-serial语义的核心思想是:不会对存在数据依赖关系的操作进行重排序。

那么数据依赖类型有哪些呢?如下表所示:

类型 示例 说明
写后读 a = 1; b = a 写一个变量后再读该变量
写后写 a = 1; a = 2 写一个变量后再写该变量
读后写 a = b; b = 2 读一个变量后再写该变量

以上三种依赖关系,一旦重排序两个操作的执行顺序,其结果就会改变,所以依照as-if-serial语义,Java在单线程的情况下不会对这三种依赖关系进行重排序(多线程情况不符合此情况)。

as-if-serial语义是基于数据依赖关系的,但它无法保证多线程环境下,重排序之后程序执行结果的正确性。

有代码如下:

/**
 * @Author FengJian
 * @Date 2021/2/24 16:44
 * @Version 1.0
 */
@Slf4j(topic = "c.HappensBeforeTest")
public class HappensBeforeTest {
    static int a = 0;
    static boolean finish = false;

    public static void main(String[] args) {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                if(finish){
                    log.debug("a*a:"+a*a);
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                a = 2;
                finish = true;
            }
        };

        t2.start();
        t1.start();
    }
}

关于上述代码,我们先忽略内存可见性的问题(即线程t2修改了a和finish,但t1可能看不到的缓存问题)。在此前提下如果成功打印a*a的值,那么结果应该为4。

但实际上a*a打印的结果还可能为0,这是由于指令重排序的存在导致的

在线程t2中,由于a = 2;finish = true;没有数据依赖关系,依照as-if-serial语义,可以对这两条语句进行重排序,因此会出现finish = true;的指令比a = 2;先执行的情况。

如果在先执行finish = true;,而a = 2;没有执行时发生线程上下文切换,轮到线程t1执行,那么t1线程中的if语句条件为真,而a的值依然为初始值0,则a*a的结果为0。

在这里插入图片描述

可以看出,即使在假设没有内存可见性问题的前提下,上述代码的结果也是不可预期的,因此上述代码也是线程不安全的,其原因就是重排序破坏了多线程程序的语义。

②happens-before规则

既然是重排序出现问题,那么解决思路就是禁止重排序。但是也要注意不能全部禁用重排序,重排序的目的是为了提升执行效率,如果全部禁用那么Java程序的性能将会很差。所以,应该做到的是部分禁用,Java的内存模型提供了一个可用于多线程环境,也适用于单线程环境的规则:happens-before规则。

happens-before规则的定义如下:A happens-before B,那么操作A的执行结果对操作B是可见的,且操作A的执行顺序排在操作B之前。这里的操作A和操作B可以在同一个线程中,也可以在不同线程中。

注意:执行顺序只是happens-before向开发人员做的保证,实际上在处理器和编译器上执行时并不一定按照操作A排在操作B之前执行。
如果重排序之后,依然可以保证与先A后B的执行结果一样,那么进行重排序也是可以的。也就是说,符合happens-before的操作,只要不改变执行结果,处理器和编译器怎么优化(重排序)都行。
只是我们开发人员可以直接认为操作A的执行顺序排在操作B之前。

happens-before保证操作A的执行结果对B可见,依靠这个原则,可以解决多线程环境下内存可见性和有序性问题。

回到代码:

/**线程t1**/
if(finish){ 
   a*a;
}

/**线程t2**/
a = 2;
finish = true;

一共有四个操作a = 2;finish = true;if(finish)a*a;,想要上述代码达到线程安全(即打印都正确输出4),只需要:

在这里插入图片描述
即在t2线程计算a*a;if(finish);之前,需要知道t1线程中a = 2;finish = true;(t2线程对t1线程的结果可见)。

要达到这一目的,就需要上图中,①和②所示的happens-before关系。

那要如何达到呢?这就需要了解happens-before的六大具体规则了(两个操作,只需要符合其中任何一条就可以认为是happens-before关系):

  • ①程序顺序规则:一个线程中的每个操作,按照程序顺序,前面的操作 happens-before 于该线程中的任意后续操作。
以上述代码为例:
/**线程t2**/
a = 2; //操作1
finish = true; //操作2

/**线程t1**/
if(finish ); //操作3
a*a; //操作4

操作1 happens-before 操作2
操作3 happens-before 操作4
  • ②监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
synchronized (lock) { //加锁
	// x是共享变量,初始值=10
	if (x < 12) {
		x = 12; 
  	}  
} //解锁

若有两个线程A、B,先后执行这段代码。则线程A执行完毕后X = 12并释放锁。而线程B获得锁后,进入代码块,在if中取X值判断是否小于12。

此时 线程A中X=12的操作 happens-before 线程B中取X值判断的操作(即线程B能看到线程A中执行的X=12的结果)
  • ③volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
volatile int x = 10;

/**线程t1**/
x = 11; //操作1

/**线程t2**/
int y = x; //操作2

操作1 happens-before 操作2
  • ④传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

  • ⑤start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • ⑥join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

以上就是happens-before的六大常用规则(全部有八种,但后面两种应该很少用到)

2.3.2、有序性问题解决办法

解决有序性问题,实际上就是要运用以上提到的两种规则,as-if-serial语义解决了单线程程序的有序性问题,而happens-before关系则能解决多线程程序的有序性问题。

再回顾一下原始代码,这是一段存在有序性问题线程不安全的代码,我们要利用happens-before关系解决有序性问题:

public class HappensBeforeTest {
    static int a = 0;
    static boolean finish = false;

    public static void main(String[] args) {
        Thread t1 = new Thread("t1"){
            @Override
            public void run() {
                if(finish){
                    log.debug("a*a:"+a*a);
                }
            }
        };

        Thread t2 = new Thread("t2"){
            @Override
            public void run() {
                a = 2;
                finish = true;
            }
        };

        t2.start();
        t1.start();
    }
}

提取一下关键的操作,如下嗷:

/**线程t1**/
if(finish){ 
   a*a;
}

/**线程t2**/
a = 2;
finish = true;

我们的目标是运用happens-before的六大常用规则达到如下图的happens-before关系,以实现上诉代码的线程安全

在这里插入图片描述
解决办法如下:
①、方法一:运用volatile修饰变量

使用到happens-before规则中的程序顺序规则、volatile变量规则和传递性。

首先,按照程序顺序规则,可以知道如下的happens-before关系:

线程t1 线程t2
if(finish) happens-before a*a; a = 2; happens-before finish = true;

这由线程中的代码很容易就能得出。接下来运用volatile变量规则,需要用volatile修饰一个变量,我们选变量finish。即初始化时代码改为为volatile static boolean finish = false;

那么根据volatile变量规则,可知对finish的写要happens-before于对finish的读。

因此给finish加上volatile关键字后,就可以达到如下效果:

在这里插入图片描述
volatile关键字不仅可以保证内存可见性问题,同时依照happens-before的volatile变量规则,对于volatile修饰的变量,要保证对该变量写的结果要对读的操作可见,因此volatile禁止对有读写操作的volatile修饰的变量进行重排序。

也就是说,volatile关键字不仅可以解决可见性问题,还可以解决有序性问题。

最后,通过传递性。可知:

在这里插入图片描述

可知,图示的三和五,就是我们的目标。到此,我们利用happens-before关系保证了代码的可见性和有序性问题。

虽然分析的过程比较长,但是在原代码中,我们实际上只改动了一行代码。即将static boolean finish = false;改为volatile static boolean finish = false;而已,就可以使我们的代码改变线程安全的。

这就是运用volatile修饰变量来解决线程安全的办法。volatile直接通过禁止相关的重排序来达到有序性的目的。

②、方法二:加锁,synchronized

这个应该比较容易理解,对相关代码加锁后,同一时刻就只有一个线程在执行,也就相当于对相关变量的操作,是保证有序的。

不过synchronized并不像volatile一样禁止指令重排序,实际上synchronized块内部的代码指令依然是可以进行重排序优化的。

3、小结

  1. 多个线程对同一个共享变量进行读写操作时就可能产生不可预见的结果,就是线程安全问题。其重点是多线程对共享变量进行读和写,如果只有读,并不会有线程安全问题。
  2. 线程安全的原因有:①线程切换带来的原子性问题②缓存带来的可见性问题③指令重排序带来的原子性问题。
  3. 线程安全的解决办法:①对于原子性问题,使用锁synchronized和Lock、或者使用原子类(AtomicInteger等)②对于可见性问题:使用锁synchronized和Lock,或者使用volatile关键字③对于有序性问题:使用锁synchronized和Lock,或者使用volatile关键字

由于能力有限,可能存在错误,感谢并恳请老铁们指出。以上内容为本人在学习过程中所做的笔记。参考的书籍、文章或博客如下:
[1]方腾飞,魏鹏,程晓明. Java并发编程的艺术[M].机械工业出版社.
[2]霍陆续,薛宾田. Java并发编程之美[M].电子工业出版社.
[3]mg驿站. 多线程篇-线程安全-原子性、可见性、有序性解析.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java并发的原子性、可见性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程序员七哥.happens-before是什么?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344

posted @ 2021-02-25 18:54  就行222  阅读(195)  评论(3编辑  收藏