目录
一.前言:
在上一篇笔记中,总结了线程的一些基础知识,如,线程的概念,线程与进程的区别,线程的七种创建方法和线程的基础属性等,而在本章笔记中,我将总结在多线程中一个关键的知识点——线程安全问题。
二.线程安全问题的产生原因:
原因部分:
在正式讲解产生原因之前,我们要先了解多线程编程的目的——并发执行程序,提高执行效率。多线程编程的意义就在于通过并发执行程序的方式将一个大的工程逐步碎片化或同时处理多个逻辑从而达到效率的提升。
既然是并发执行,那我们就不得不考虑到每个线程先后执行的顺序,然而,实际上线程的执行顺序是抢占式执行的,如此一来就会给程序员带来各种各样的问题,而这些问题就被称为线程安全问题。
线程安全问题包含很多方面引起的问题,而上述则是产生它的根本原因,也是产生原因中的一种,除此之外,还有几种原因,接下来将会一一列举:
(1)根本原因:多个线程的执行顺序是“随机”的,操作系统通过抢占式的方式来调度线程。
(2)多个线程同时针对同一个变量进行修改。
(3)修改操作不是“原子”的。
(4)由于编译器优化而引发的内存可见性造成的线程安全问题。
(5)同样是由于编译器优化而引起的指令重排序造成的线程安全问题。
代码举例部分:
这里针对后四个原因进行举例,以便于理解。(以下均为原因2和其他原因的联合举例,因为针对同一个变量同时进行修改就会引起3、4等原因):
例一.非“原子”的修改操作
public static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
n++;
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
n++;
}
try {
t1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();t2.start();t2.join();System.out.println(n);
}
此处这个代码便是多个线程同时针对同一个变量进行修改的代码案例,那么,这个的结果会打印多少呢? 观察上述代码可以很简单的得出结论,这里理论上输出的值应该是20000,因为两个线程都是针对n变量进行的自加10000次,故而,结果理应是20000,但是,当编译器执行程序后,可以发现
答案并非如此,难道是编译器出错了吗?当再一次执行程序时,可以发现 答案不仅依旧不是20000甚至还和上一次的不一样,这又是因为什么呢?那么,此处就对了原因中的第一和第二条。
首先来看,此处的代码毫无疑问,一定是多个线程同时针对同一个变量进行的修改操作。其次,既然进行了修改操作,就要考虑到这个修改是否是“原子”的,何为“原子”的?就是无法进行拆分的单一操作,而此处的加加操作,它并不是一个“原子”的操作,它本身又可以分成三个部分——读取,加加,加载写回。那么,前面提到过,多个线程之间是按照抢占式的方式调度执行的,试想一下,如果在t1线程执行了读取后,t2线程也紧随其后进行了读取,此时,两线程拿到的n的值均为初始时的0,再分别加加和写回,可以发现,结果n的值变成了1,明明是两步加加操作,结果却只加了1,而在这两万次加加操作中,程序猿也不知道究竟有多少次的加加操作是这样被覆盖执行的,而且,每次程序执行后,线程的调度也是“随机”的,故而就导致了结果的多变并一定小于两万。
既然发现了问题的所在和产生原因,那该如何修改呢,不必担心,我会在后面的修改方法中统一总结出。
例二.内存可见性引起的线程安全问题
public static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(n == 0) {
;
}
System.out.println("n != 0");
});
Thread t2 = new Thread(() -> {
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
});
t1.start();
t2.start();
}
观察上述代码,不难看出,这里的逻辑是想要通过线程二来输入n的值进而控制线程一的循环情况,然而,真的会像想象中的那么顺利吗?这里,先和之前一样,进行分析。首先,老样子,这里依旧是多个线程同时针对同一个变量进行修改的操作,但是,和例一不同的是,例二中所涉及到操作是赋值操作,而对于赋值操作而言,它就是一个单一的,“原子”的操作,所以此处不会涉及到由于操作不是“原子”的而引起的线程安全问题,但是,操作是“原子”的就一定不存在线程安全问题吗,当程序执行起来后,输入一个非0的数,结果发现
事实又一次的违背了我们的想法,那么,此处的bug又是因何而引起的呢?
这里的问题,是由于编译器对代码的优化而引起的。由于判断循环操作中,需要频繁的涉及到读内存的操作,而读内存操作本质上是一个相对费时间的操作,同时比较又是一个非常快速的操作,所以,导致编译器会对其进行优化,使得它除了第一次判断的后续判断都不再从内存中读取数据了,而是转头去工作内存(寄存器和cpu缓存)中读取数据了,但t2线程中的输入赋值操作改变的又是n在内存中存储的值,所以,阴差阳错间就使得t1线程没能察觉到n的值的变化,导致循环并未停止,那么,这里的解决方法也是会在后面总结中提到,这里就不过多赘述了。
例三.指令重排序引起的线程安全问题(无实际代码演示)
最后一个原因的代码案例不太好弄,因为此优化的触发条件未知,所以此处口头讲解一个例子。
假如在类中创建一个此类的类对象但是先赋值为null,然后再在main方法中创建两个线程t1、t2,在t1线程中进行对类对象的实例化操作,同时在t2线程中先休眠2秒再进行对该对象的引用操作,这时,如果一旦触发了指令重排序操作就有可能引发出线程安全问题,因为实例化操作的本质是三条指令,首先为对象分配内存空间,然后分配内存地址和初始化。但是,一旦触发指令重排序优化后,分配地址操作和初始化操作的顺序就可能会发生改变,如果先进行了分配地址的操作,紧随其后又发生了线程切换,直接去t2线程中进行了引用操作,但是由于对象并未进行初始化操作,进而就会引发线程安全问题(但此处有一个大的前提,就是,t2线程的引用操作至少是在类对象不为空的时候进行的,否则就是对空对象的引用操作,并不是线程安全问题)。
三.对应问题的解决方法:
例一解决方法:
对于例一案例中,由于修改操作的非“原子”而导致的线程安全问题,其本质是由于多线程中多步操作的顺序的“随机性”而产生的那么为了避免此问题,我们只需要让进行这一步修改操作时,不会涉及到其他线程的突然切换,形成一种类似于单个线程中串行执行的效果。而为了达到这种效果,Java标准库中给出了一个关键字synchronized,通过它,我们可以为一个Object类型的对象进行“加锁”操作,那么,这里引入一个在多线程学习中极为重要的一个概念——“加锁”。什么叫加锁呢?给对象加锁又是什么意思呢,这里为了便于理解会进行一个举例——假设,男生A喜欢上了一个女生A,那么,男生A就会想去追求到她,那么此时,就有一个大的前提,正常的人都会先想去了解这个女生A是不是有男朋友了,如果有,就不能追了(正常来讲,不要抬杠)或者说,等到他们分手了,自己再去尝试,那么在这里,这个所谓的女生是否有男朋友,就是我们所说的“加锁”操作,如果加上锁了,就相当于你想追求的这个女生已经有男朋友了,反之则相反。再通俗一点来说,“加锁”操作就是为一个对象贴上了一个标签,说明它现在属于我,如果你也想要,那就要不等我用完再将它变成你的,要不就放弃。以上就是“加锁”操作的含义。不过,以上均是正常的前提下,那如果不正常,就比如,我才不管这个女生A有没有男朋友,我也不需要让她成为我的女朋友,我只要在我需要的时候,她可以来陪我即可(这里只是举例更好理解并无它意不要激动),那么这时,我们的加锁操作就没有任何意义了(对应的代码案例就是,同样是两个线程针对同一个对象进行操作,但是线程A中有加锁这个操作,而线程B中没有,这个时候,由于线程B不需要进行加锁,也就不必理会线程A中的加锁操作,直接进行修改即可)。
上述大致介绍了一下什么是“加锁”操作,那么,通过上述总结,不难看出,在正常情况下,如果是两个线程针对同一个对象进行加锁,就会出现一个线程加上了,而另一个线程等待的效果,而这种效果正是例一案例所需要的解决方法。
public static int n = 0;
public static Object ob = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
synchronized (ob) {
n++;
}
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 10000; i++) {
synchronized (ob) {
n++;
}
}
try {
t1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();t2.start();t2.join();System.out.println(n);
}
通过修改,将例一代码改成以上结构,对比之后可以发现,修改后的代码在执行n++操作之前还多了一个synchronized(ob)的操作,而ob正是一个Object类型的对象。此处正是两个线程同时对同一个对象进行加锁,从而使得其中一个线程阻塞等待的效果。通过这种方式就可有效避免线程的突然切换使非“原子”操作“整体化”执行。(此处两个注意点:1.若两线程加锁对象并不相同,则不会产生阻塞等待效果,也就无法避免该问题。2.若一个线程加锁,另一个线程不加锁,也是无法避免该问题,举例时的非正常情况就是说的这种。)
例二、例三解决方法:
对于例二和例三问题而言,他们两个之间有一个最大的共同特点就是都是由于编译器优化而产生的线程安全问题,所以,开发编译器的大佬们深知这种情况,而为了避免这种优化对多线程的误判,Java标准库中提供了一个volatile关键字。在成员变量前加上volatile修饰,就可以避免问题4和问题5,具体实例如下:
public volatile static int n = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(n == 0) {
;
}
System.out.println("n != 0");
});
Thread t2 = new Thread(() -> {
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
});
t1.start();
t2.start();
}
此时可以看到,通过volatile关键字的修饰, 判断操作中的读内存操作就不会再被优化掉了,避免了内存可见性的问题。(volatile的本质是保证变量的内存可见性,禁止读操作被优化为读寄存器。)
四.结语(易错提示):
最后这里补充一个问题,关于加锁操作的含义千万不要错的理解成“拿到了该对象的访问权限或者给该对象多加了一层访问权限”,并不是这样的,打个比方,你的女神你没追到呢,那难道你就不能跟她聊天了吗?同理,假如,就算你女神有男朋友了,那你难道就不能跟她说话了吗???