写点什么

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

  • 2021 年 12 月 07 日
  • 本文字数:2621 字

    阅读完需:约 9 分钟

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


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


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


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


Q: java 中的主内存和工作内存是指什么?

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



  • 主内存对应于 java 堆中的对象实例部分(物理硬件的内存)

  • 工作内存对应于虚拟机栈中的部分区域( 寄存器,高速缓存)

  • 工作内存中是拷贝的工作副本

  • 拷贝副本时,不会吧整个超级大的对象拷贝过来, 可能只是其中的某个基本数据类型或者引用。


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


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


Q: 多线程不可见问题的原因是什么?

A:这里先讲一下虚拟机定义的内存原子操作:

  • lock: 用于主内存, 把变量标识为一条线程独占的状态

  • unlock : 主内存, 把锁定状态的变量释放

  • read: 读取, 从主内存读到工作线程中

  • load: 把 read 后的值放入到 工作副本中

  • use: 使用工作内存变量, 传给工作引擎

  • assign 赋值: 把工作引擎的值传给工作内存变量

  • store: 工作内存中的变量传到主内存

  • write: 把值写入到主内存的变量中


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



  1. read 和 load、store、write 是按顺序执行的, 但是中间可插入其他的操作。不可单独出现。

  2. assgin 之后, 会同步后主内存。即只有发生过 assgin,才会做工作内存同步到主内存的操作。

  3. 新变量只能在主内存中产生

  4. 工作内存中使用某个变量副本时,必须先经历过 assign 或者 load 操作。 不可 read 后马上就 use

  5. lock 操作可以被同一个线程执行多次,但相应地解锁也需要多次。

  6. 执行 lock 时,会清空工作内存中该变量的值。 清空后如果要使用,必须重新做 load 或者 assign 操作

  7. unlock 时,需要先把数据同步回主内存,再释放。


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

  • 只有 assgin 时, 才会更新主内存, 但由于指令重排序的情况,导致有时候某个 assine 指令先执行,然后这个提前被改变的变量就被其他线程拿走了,以至于其他线程无法及时看到更新后的内存值。

  • assgin 时从工作内存到主内存之间,可能存在延迟,同样会导致数据被提前取走存到工作线程中。


Q: 那么 volatile 关键字为什么就可以实现可见性?可见性就是并发修改某个值后,这个值的修改对其他线程是马上可见的。

A: java 内存模型堆 volatile 定义了以下特殊规则:

  • 当一个线程修改了该变量的值时,会先 lock 住主存, 再立刻把新数据同步回内存。

  • 使用该值时,其他工作内存都要从主内存中刷新!

  • 这个期间会禁止对于该变量的指令重排序

禁止指令重排序的原理是在给 volatile 变量赋值时,会加 1 个 lock 动作, 而前面规定的内存模型原理中, lock 之后才能做 load 或者 assine,因此形成了 1 个内存屏障。


Q: 上面提到 lock 后会限制各工作内存要刷新主存的值 load 进来后才能用, 这个在底层是怎么实现的?

A:利用了 cpu 的总线锁+ 缓存一致性+ 嗅探机制实现, 属于计算机组成原理部分的知识。



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


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

A:

  • synchronized 的重量级锁是通过对象内部的监视器(monitor)实现

  • monitor 的线程互斥就是通过操作系统的 mutex 互斥锁实现的,而操作系统实现线程之间的切换需要从用户态到内核态的切换,所以切换成本非常高。

  • 每个对象都持有一个 moniter 对象

具体流程如下:

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

  • 偏向锁原理: 每次尝试在对象头里设置当前使用这个对象的线程 id, 只做一次,如果成功了就设置好 threadId, 只要没有出现新的 thread 访问且 markWord 被修改,那么久)


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

  • 轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用 cpu 资源但是相对比使用重量级锁还是更高效的。 CAS 的对象是对象头的 Mark Word, 此时仍然不会去调系统底层的方法做阻塞。


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


Q: 锁只可以升级不可以降级, 确定是都不能降级吗?

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


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

举例:StringBuffer.append().append()

例如如果发现 stringBuffer 不会逃逸,则就会去掉这里 append 所携带的同步

而这种情况肯定只能发生在偏向锁上, 所以偏向锁可以被重置为无锁状态。


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

发布于: 3 小时前阅读数: 10
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
7个连环问揭开java多线程背后的弯弯绕