长久以来,一直想剖析一下Java线程安全的本质,但是苦于有些微观的点想不明白,便搁置了下来,前段时间慢慢想明白了,便把所有的点串联起来,趁着思路清晰,整理成这样一篇文章。
为什么有多线程?
Java内存模型(JMM)数据可见性问题、指令重排序、内存屏障
为什么有多线程
那么设计多线程的初衷是什么呢?来看一个这样的实际例子,计算机通常需要与人来交互,假设计算机只有一个线程,并且这个线程在等待用户的输入,那么在等待的过程中,CPU什么事情也做不了,只能等待,造成CPU的利用率很低。如果设计成多线程,在CPU在等待资源的过程中,可以切到其他的线程上去,提高CPU利用率。
总结起来无非两点,提高CPU的利用率、提高计算效率。
我们先来看一个例子:
上面是一个把变量自增100次的例子,只不过用了4个线程,每个线程自增25次,用CountDownLatch等4个线程执行完,打印出最终结果。实际上,我们希望程序的结果是100,但是打印出来的结果并非总是100。
线程安全就是要让程序运行出我们想要的结果,或者话句话说,让程序像我们看到的那样执行。
下面我们来看看Brian Goetz对线程安全的描述:当多线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
Java内存模型(JMM)数据可见性问题、指令重排序、内存屏障
·方法区:存储类信息、常量、静态变量等,各线程共享
·本地方法栈:虚拟机使用到的Native方法服务,例如c程序等,线程私有
·堆:new出的实例对象都存储在这个区域,是GC的主战场,线程共享。
好了,了解了JMM内存模型,我们来分析一下,上面的程序为什么没得到正确的结果。请看下图,线程A、B同时去读取主内存的count初始值存放在各自的工作内存里,同时执行了自增操作,写回主内存,最终得到了错误的结果。
我们再来深入分析一下,造成这个错误的本质原因:
·有序性,线程之间必须是有序的访问共享变量,我们用“视界”这个概念来描述一下这个过程,以B线程的视角看,当他看到A线程运算好之后,把值写回之内存之后,马上去读取最新的值来做运算。A线程也应该是看到B运算完之后,马上去读取,在做运算,这样就得到了正确的结果。
给count加上volatile关键字,就保证了可见性。
volatile关键字,会在最终编译出来的指令上加上lock前缀,lock前缀的指令做三件事情
·锁住总线或者使用锁定缓存来保证执行的原子性,早期的处理可能用锁定总线的方式,这样其他处理器没办法通过总线访问内存,开销比较大,现在的处理器都是用锁定缓存的方式,在配合缓存一致性来解决。
既然保证了可见性,加上了volatile关键词,为什么还是无法得到正确的结果,原因是count++,并非原子操作,count++等效于如下步骤:
·线程副本变量加1:temp=temp+1
就算是真的严苛的给总线加锁,导致同一时刻,只能有一个处理器访问到count变量,但是在执行第(2)步操作时,其他cpu已经可以访问count变量,此时最新运算结果还没刷回主内存,造成了错误的结果,所以必须保证顺序性。
总结一下:要保证线程安全,必须保证两点:共享变量的可见性、临界区代码访问的顺序性。
注:关注作者微信公众号,了解更多分布式架构、微服务、netty、MySQL、spring、、性能优化、等知识点。
公众号:《 Java大蜗牛 》
网友评论