多个线程对同一个共享变量进行读写操做时可能产生不可预见的结果,这就是线程安全问题。java
线程安全的核心点就是共享变量,只有在共享变量的状况下才会有线程安全问题。这里说的共享变量,是指多个线程都能访问的变量,通常包括成员变量和静态变量,方法内定义的局部变量不属于共享变量的范围。程序员
线程安全问题示例:编程
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线程与t一、t2线程同步执行,即t一、t2线程都执行完,main线程才会继续执行(但t一、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,且每次运行的结果都不同。这就是多线程对共享变量进行操做出现的不可预见的结果,即常说的线程安全问题。缓存
而线程安全,则指的是在多线程环境下,程序能够始终执行正确的行为,符合预期的逻辑。具体到上述代码,就是不论执行多少次,在t一、t2线程执行完毕后,count的值都应该始终符合预期的结果0。上述代码明显是线程不安全的。安全
线程安全是使用多线程一定会面临的问题,致使线程不安全的主要缘由有如下三点:多线程
①原子性:一个或者多个操做在 CPU 执行的过程当中被中断
②可见性:一个线程对共享变量的修改,另一个线程不能马上看到
③有序性:序执行的顺序没有按照代码的前后顺序执行架构
原子性问题,其实说的是原子性操做。即一个或多个操做,应该是一个不可分的总体,这些操做要么所有执行而且不被打断,要么就都不执行。并发
以上述代码中的count的自增(count++
)和自减(count--
)为例。app
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。
这就是因为非原子操做带来的多线程访问共享变量出现不符合预期的结果,即因为原子性带来的线程安全问题。
上面示例中两个线程t一、t2分别执行count++和count--出现的问题,就是因为原子性带来的线程安全问题。
解决办法就是将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中能够保证原子操做的具体方法,它们各有优缺点,要看具体的场景来选择最佳的使用,以此来解决原子性带来的线程安全问题。
可见性实际上指的是内存可见性问题。总的来讲就是一个线程对共享变量的修改,另一个线程不能马上看到,从而产生的线程安全问题。
在上一篇笔记【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并不能马上看到:
这就是因为内存可见性带来的多线程访问共享变量出现不符合预期的结果,即因为可见性带来的线程安全问题。
解决办法就是保证共享变量的可见性,具体实现就是任何对共享变量的访问都要从共享内存(主内存)中获取。在Java中的实现方法是:
①加锁,synchronized和Lock均可以保证
线程在加锁时,会清空本地内存中共享变量的值,共享变量的使用须要从主内存中从新获取。而在释放锁资源时,则必须先把此共享变量同步回主内存中。
因为锁的存在,未持有锁的线程并不能操做共享变量,而当阻塞的线程得到锁时,主内存中共享变量的值已经刷新过了,所以线程修改共享变量对其余线程是可见的。这保证了共享变量的可见性,能够解决内存可见性产生的线程安全问题。
②使用volatile修饰共享变量
当一个变量被声明为volitale时,线程在写入变量时,不会把值缓存本地内存,而是会当即把值刷新回主存,而当要读取该共享变量时,线程则会先清空本地内存中的副本值,从主存中从新获取。这些也都保证了内存的可见性。
优先使用volatile关键字来解决可见性问题,加锁消耗的资源更多。
有序性,其实是指令的重排序问题。
咱们知道,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关系):
以上述代码为例: /**线程t2**/ a = 2; //操做1 finish = true; //操做2 /**线程t1**/ if(finish ); //操做3 a*a; //操做4 操做1 happens-before 操做2 操做3 happens-before 操做4
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 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的六大经常使用规则(所有有八种,但后面两种应该不多用到)
解决有序性问题,实际上就是要运用以上提到的两种规则,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块内部的代码指令依然是能够进行重排序优化的。
点个赞吧彦祖,(◕ᴗ◕✿)
因为能力有限,可能存在错误,感谢并恳请老铁们指出。以上内容为本人在学习过程当中所作的笔记。参考的书籍、文章或博客以下:
[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