7个连环问揭开java多线程背后的弯弯绕

摘要:很多java入门新人一想到java多线程, 就会觉得很晕很绕,什么可见不可见的,也不了解为什么sync怎么就锁住了代码。

本文分享自华为云社区《java多线程背后的弯弯绕绕到底是什么? 7个连环问题为你逐步揭开背后的核心原理!》,作者:breakDraw 。

很多java入门新人一想到java多线程, 就会觉得很晕很绕,什么可见不可见的,也不了解为什么sync怎么就锁住了代码。

因此我在这里会提多个问题,如果能很好地回答这些问题,那么算是你对java多线程的原理有了一些了解,也可以借此学习一下这背后的核心原理。

Q: java中的主内存和工作内存是指什么?
A:java中, 主内存中的对象引用会被拷贝到各线程的工作内存中, 同时线程对变量的修改也会反馈到主内存中。

因此我们知道各线程使用内存数据时,其实是有主内存和工作内存之分的。并不是一定每次都从同一个内存里取数据。

或者理解为大家使用数据时之间有一个缓存。

Q: 多线程不可见问题的原因是什么?
A:这里先讲一下虚拟机定义的内存原子操作:

根据这些指令,看一下面这个图, 然后再看图片之后的流程解释,就好理解了。

  1. read和load、store、write是按顺序执行的, 但是中间可插入其他的操作。不可单独出现。
  2. assgin之后, 会同步后主内存。即只有发生过assgin,才会做工作内存同步到主内存的操作。
  3. 新变量只能在主内存中产生
  4. 工作内存中使用某个变量副本时,必须先经历过assign或者load操作。 不可read后马上就use
  5. lock操作可以被同一个线程执行多次,但相应地解锁也需要多次。
  6. 执行lock时,会清空工作内存中该变量的值。 清空后如果要使用,必须重新做load或者assign操作
  7. unlock时,需要先把数据同步回主内存,再释放。

因此多线程普通变量的读取和写入操作存在并发问题, 主要在于2点:

Q: 那么volatile关键字为什么就可以实现可见性?
可见性就是并发修改某个值后,这个值的修改对其他线程是马上可见的。
A: java内存模型堆volatile定义了以下特殊规则:

Q: 上面提到lock后会限制各工作内存要刷新主存的值load进来后才能用, 这个在底层是怎么实现的?
A:利用了cpu的总线锁+ 缓存一致性+ 嗅探机制实现, 属于计算机组成原理部分的知识。


这也就是为什么violate变量不能设置太多,如果设置太多,可能会引发总线风暴,造成cpu嗅探的成本大大增加。

Q: 那给方法加上synchronized关键字的原理是什么?和volatie的区别是啥?
A:

  1. 首先,class文件的方法表结构中有个访问标志access_flags, 设置ACC_SYNCHRONIZED标志来表示被设置过synchronized。
  2. 线程在执行方法前先判断access_flags是否标记ACC_SYNCHRONIZED,如果标记则在执行方法前先去获取monitor对象。
  3. 获取成功则执行方法代码且执行完毕后释放monitor对象
  4. 如果获取失败则表示monitor对象被其他线程获取从而阻塞当前线程

注意,如果是sync{}代码块,则是通过在代码中添加monitorEnter和monitorExit指令来实现获取和退出操作的。

如果对C语言有了解的,可以看看这个大哥些的文章Java精通并发-通过openjdk源码分析ObjectMonitor底层实现

Q: synchronized每次加锁解锁需要切换内核态和用户态, jvm是否有对这个过程做过一些优化?

A:jdk1.6之后, 引入了锁升级的概念,而这个锁升级就是针对sync关键字的
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁
四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,
只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别)

因此sync关键字不是一开始就直接使用很耗时的同步。而是一步步按照情况做升级

  1. 当对象刚建立,不存在锁竞争的时候, 每次进入同步方法/代码块会直接使用偏向锁

2. 当发现对象头的线程id要被修改时,说明存在竞争时。升级为轻量级锁

3. 但是如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就会升级为重量级锁,也就是上面那个问题中提到的操作。

Q: 锁只可以升级不可以降级, 确定是都不能降级吗?
A:有可能被降级, 不可能存在共享资源竞争的锁。
java存在一个运行期优化的功能
需要开启server模式外加+DoEscapeAnalysis表示开启逃逸分析。

如果运行过程中检测到共享变量确定不会逃逸,则直接在编译层面去掉锁

举例:
StringBuffer.append().append()
例如如果发现stringBuffer不会逃逸,则就会去掉这里append所携带的同步
而这种情况肯定只能发生在偏向锁上, 所以偏向锁可以被重置为无锁状态。

点击关注,第一时间了解华为云新鲜技术~