并发编程(二)线程安全
上一篇学习了多线程的一些基础知识:多线程的基本概念,及创建和操作多线程。内容相对简单,但多线程的知识肯定不会这么简单,否则我们也不需要花这么多心思去学习,因为多线程中容易出现线程安全问题。
那么什么是线程安全呢,定义如下:
当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。
简单的理解就是在多线程情况下代码的运行结果与预期的正确结果不一致,而产生线程安全的问题一般是由是主内存和工作内存数据不一致性和重排序导致的。
要理解这些的必须先理解java的内存模型。
一 Java内存模型
在并发编程领域,有两个关键问题:线程之间的通信和同步
1.1 通信与同步
线程通信是指线程之间以何种机制来交换信息,在命令式编程中,线程之间的通信机制有两种共享内存和消息传递,
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。
线程同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。如果不能理解Java的共享内存模型在编写并发程序时一定会遇到各种各样关于内存可见性的问题。
1.2 java内存模型(JMM)
CPU的处理速度和主存的读写速度不是一个量级的,为了平衡这种巨大的差距,每个CPU都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。
如图为JMM抽象示意图,线程A和线程B之间要完成通信的话,要经历如下两步:
-
线程A从主内存中将共享变量读入线程A的工作内存后并进行操作,之后将数据重新写回到主内存中;
-
线程B从主存中读取最新的共享变量
java的内存模型内容还有很多,推荐看这篇文章:https://blog.csdn.net/suifeng3051/article/details/52611310
1.3 可见性和竞争现象
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:
-
共享对象对各个线程的可见性
-
共享对象的竞争现象
共享对象的可见性
当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。
一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。
要解决共享对象可见性这个问题,我们可以使用volatile关键字,volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存,这个后面会详讲。
竞争现象
如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。
线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。
要解决竞争现象我们可以使用synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。
二 重排序
指令重排序是指编译器和处理器为了提高性能对指令进行重新排序,重排序一般有以下三种:
-
-
指令级并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
-
内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
1属于编译器重排序,而2和3统称为处理器重排序。这些重排序会导致线程安全的问题,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier
来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。
那么什么情况下一定不会重排序呢?编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序,这里有个数据依赖性概念是什么意思呢?看如下代码:
int a = 1;//A int b = 2;//B int c = a + b;//c
这段代码中A和B没有任何关系,改变A和B的执行顺序,不会对结果产生影响,这里就可以对A和B进行指令重排序,因为不管是先执行A或者B都对结果没有影响,这个时候就说这两个操作不存在数据依赖性,数据依赖性是指如果两个操作访问同一个变量,且这两个操作有一个为写操作,此时这两个操作就存在数据依赖性,如果我们对变量a进行了写操作,后又进行了读取操作,那么这两个操作就是有数据依赖性,这个时候就不能进行指令重排序,这个很好理解,因为如果重排序的话会影响结果。
这里还有一个概念要理解:as-if-serial:不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
这里也比较好理解,就是在单线程情况下,重排序不能影响执行结果,这样程序员不必担心单线程中重排序的问题干扰他们,也无需担心内存可见性问题。
三 happens-before规则
我们知道处理器和编译器会对指令进行重排序,但是如果要我们去了解底层的规则,那对我们来说负担太大了,因此,JMM为程序员在上层提供了规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。
3.1 happens-before
我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
3.2 具体规则
具体的规则有8条:
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
-
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作。
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
参考文章: