目录
前言
本篇围绕理解引发线程安全的原因以及如何解决;
一、引发线程安全的原因
1.抢占式执行
多线程调度的过程,可以是认为“随机”的,没有规律;
例如:你定义了一个变量count,执行了count++这种操作,本质上是三个CPU指令,load、add、save,而CPU执行指令都是以一个指令为单位顺序进行的,试想,有两个线程同时执行count++操作,这些一个一个的指令就会抢占执行,线程一的add的操作刚完,线程二的add就抢占了下一个位置…
总结:线程抢占式执行,是线程的不安全的万恶之首,并且是内核实现,咱是无能为力的~
2.多线程修改同一个变量
一个线程修改一个变量,没事。多线程修改不同变量,也没事,多个线程读取一个变量,还没事,但如果多线程修改同一个变量,那就有问题了;
就像刚刚提到的抢占式执行的例子,如果一个变量count,进行count++这种操作,分load、add、save,要说线程一二修改不同变量倒也没事,互不干扰,然如果修改同一变量,就会出现以下情况:
穿插一个问题:String是不可变的对象,这样设计有什么好处?
这里好处很多,其中一点就是“线程安全”,因为这个线程安全问题,甚至还有的编程语言(尤其是“函数式编程”语言)就广泛的使用了不可变的概念,比如erlang,就没有变量这个东西,所有数据都是不可变的,要想修改数据,很抱歉,不行~只能重新创建一个,还有人可能就会说,那太占内存了~其实,格局打开,现在可是21实际,咱缺的是内存吗?时代变了,内存以及不值钱了;
总结:这里确实可以通过调整代码,来避免线程安全问题,但是以及适用性不高;
3.操作是原子的
实际上理解了多线程修改同一变量这里我引出的例子和画的图,这个也就不难理解,首先什么是原子?原子表示不可分割的最小单位,CPU执行指令是一条一条执行的,这一条一条的指令就可以理解为原子,也正因为这是原子的才会引发上述的多线程修改同一变量会引发线程安全;
结论:既然上述1,2都没有方法很好的解决线程安全问题,那么咱就试试从这入手——修改操作,使其不是原子的~(不卖关子了,实际上从修改操作,使其不是原子的,也是最常见的办法),也就是说,咱可以把这些多个原子操作包装成一个原子操作!(例如可以把刚刚所说的count++这个例子的的三条指令包装成一个);
4.指令重排序
什么是指令重排序?假设咱写了一段Java代码,并且希望运行程序的时候代码的执行顺序与我们所写的顺序一致,但实际上,编译器、JVM、CPU处于优化目的对实际指令进行顺序上的调整,这便是指令重排序;JVM的代码优化本身没什么太大的问题,但是一旦使用了多线程,这里的优化就会引发线程不安全;
JVM的代码优化,这里的优化又是什么呢?
举个栗子:就像程序员敲代码,总会大佬和菜鸡,大佬写出的代码,往往是很高效的,而菜鸡呢,写出的往往是一些很低效的代码,这时候写编译器的大佬就想到,让编译器具有一定的代码优化功能,将菜鸡写出的代码在逻辑不变的前提下优化成大佬那种高效的代码,这便是编译器优化;
再举个栗子:你去leetcode上刷题
面对这样一个页面,作为一个小白,肯定是先一同瞎点,最后得出经验,哦,原来要这样去刷题,但如果然后有人站出来说,你因该怎么怎么去刷题,比如,对于新手,应当上来选择刷简单题:
然后呢,选择通过率高的,出现频率高的,然后再去慢慢刷中等,困难题…这样下来,少走很多弯路,就很高效了;
话又说回来,但这里优化是优化了,对于多线程很多不可预测性的问题,还是不能很好的解决~
总结:JVM的代码优化再多线程情况下,也会带来一些BUG;
5.内存的可见性问题
内存可见性问题就是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化,若是看不到,那就要出问题了;
例如,一个线程负责读数据,另一个线程负责修改数据;
先来看看如下代码:
这里的while(test.count == 0),就是要先从内存中读取count的值(LOAD操作),再到寄存器中与0进行比较(CMP操作),这里while会循环的进行这个操作(非常快),而我们知道的是,CPU读写数据最快,内存次之(与CPU差3~4个数量级),硬盘最慢(与内存差3~4个数量级);所以LOAD从内存中读取数据操作的速度相对于在CPU上进行CMP操作就要慢的多,那么编译器就要偷懒了,既然频繁的LOAD读取count这个数据,多次执行的结果还都是一样,干脆LOAD就只执行一次;因为一般没有人改这个代码,编译器就认为读到的结果都是固定的,就做出了一个大胆的优化——只读一次,效率大大提升 !
这时候又出现了个线程t2,他就说:谁说没人改这个代码?俺就来~
如下代码:
这时我们运行代码,来看看效果:
分析:这时可以发现, 当输入数字6时,相当于修改了count这个变量的值为6,按理来说t1线程的run方法中count只要不等于0就会停下来,可是程序依旧没有停止,就出现了内存可见性问题;有人可能就要问了,编译器优化,不是因该在代码逻辑不变的情况下优化吗?这里的优化却让其逻辑变了?这里要注意,编译器优化,在多线程情况下可能存在误判!既然编译器自己判定不了,这时候就该我们程序猿出场了——使用volatile关键字(博主下一章会整理出专门的博客,来讲讲volatile)
对于线程不安全问题,如何解决?
上面提到操作是原子的,我们可以从这里入手,将count++这个操作的三个布置包装成一个步骤,如何做呢——“加锁”;
举个栗子,刘华强前来买瓜,给老板指着那个最好的瓜说:老板,这瓜我要了,我先去办点事,一会再来拿走;老板同意了,这就相当于是从华强买瓜离开办事,到华强办完事来取瓜这个区间加锁,而华强不在的这个期间,别人不可以动这个瓜;
类似的count++之前加锁,count++之后再解锁,别的线程若是想在加锁和解锁之间进行需修改,很抱歉,修改不了,别的线程只能处于阻塞等待的线程状态(BLOCKED状态);
Java的代码中如何进行加锁呢?
使用synchronized关键字,这时最基本的使用,它用来修饰一个普通方法,当进入方法的时候,就会加锁,方法执行完毕,就会解锁;如下图
这个锁具体是怎么执行的呢?
锁具有抢占特性,如果这个锁没人加,有人想加,就可以立即加上,若这个锁以及被人加上了,加锁操作就会阻塞等待;如刚才的栗子,count++分三步进行,load、add、save,而线程调度是随机的过程,一旦这两个线程同时调用,这两组三个操作就会进行排列组合,就会产生线程不安全,现在使用锁,就可以使这三个操作串行执行了;如下
分析:这个操作就将“并发执行”变成了串行执行,这个操作就会减慢执行效率,但是保证了线程安全,正所谓鱼与熊掌不可兼得也~
值得注意的是,加锁(lock->unlock这个区间)不是说CPU一鼓作气执行完,中间也是有调度切换的,即使线程一切换走了(比如执行到add切换走了),线程二仍然是BOLCKED状态,无法在CPU上运行的;
思考:要加锁的代码不是在一个方法里,怎么办?
synchronized除了修饰方法,还可以用来修饰代码块,把要进行加锁的逻辑放到代码块之中,就能起到加锁的效果:
这个()里需要填什么呢?填的东西就是你要针对哪个对象进行加锁(被加锁的对象称为“锁对象”);
比如线程一和线程二要对同一个对象的加锁,就会产生锁竞争,也就是说,如果线程一加锁成功了,线程二只能阻塞等待,若两个线程对不同对象加锁,就不会产生锁竞争,各执其职,不会有阻塞等待
在Java中,任何对象都可以作为锁对象,所以写多线程代码的时候,不关心锁对象是谁,是那种形态。只是关心,两个线程是否锁的是同一个对象,只要锁的是同一个对象,就会产生锁竞争,有了锁竞争,就保证了线程安全,如下,我任意定义一个锁对象是谁都无所谓:
还有一种常用的写法——this,这便是谁调用了add方法,谁就是this~
最后注意:多线程的的代码,切勿无脑操作,很多情况下写this都没什么问题,具体还是要看实际需求,希望在什么场景下产生竞争,那些场景下不需要竞争,对于锁对象的设置都是不同的!
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/130437.html