Synchronized优化总结(一)

当使用 Java 多线程访问共享资源的时候,会出现竞态的现象。即随着时间的变化,多线程“写”共享资源的最终结果会有所不同。

为了解决这个问题,让多线程“写”资源的时候有先后顺序,引入了的概念。每次一个线程只能持有一个锁进行写操作,其他的线程等待该线程释放锁以后才能进行后续操作。

从这个角度来看,锁的使用在 Java 多线程编程中是相当重要的,那么是如何对锁进行优化?

众所周知,Java 的锁分为两种:

1. 一种是内部锁,它用 Synchronized 关键字来修饰,由 JVM 负责管理,并且不会出现锁泄漏的情况。

2. 另外一种是显示锁。

这里重点讨论的是内部锁优化。内部锁的优化方式由 Java 内部机制完成,虽然不需要程序员直接参与,但了解它对理解多线程优化原理有很大帮助。

这部分的优化主要包括四部分:

1.锁消除

2.锁粗化

3.偏向锁

4.适应锁

锁消除(Lock Elision),JIT 编译器对内部锁的优化。在介绍其原理之前先说说,逃逸和逃逸分析。

逃逸是指在方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其他变量引用。

也就是,在方法体之外引用方法内的对象。在方法执行完毕之后,方法中创建的对象应该被 GC 回收,但由于该对象被其他变量引用,导致 GC 无法回收。

这个无法回收的对象称为“逃逸”对象。Java 中的逃逸分析,就是对这种对象的分析。

回到锁消除,Java JIT 会通过逃逸分析的方式,去分析加锁的代码段/共享资源,他们是否被一个或者多个线程使用,或者等待被使用。

如果通过分析证实,只被一个线程访问,在编译这个代码段的时候就不生成 Synchronized 关键字,仅仅生成代码对应的机器码。

换句话说,即便开发人员对代码段/共享资源加上了 Synchronized(锁),只要 JIT 发现这个代码段/共享资源只被一个线程访问,也会把这个 Synchronized(锁)去掉。从而避免竞态,提高访问资源的效率。

Synchronized优化总结(一)

作为开发人员来说,只需要在代码层面去考虑是否用 Synchronized(锁)。

说白了,就是感觉这段代码有可能出现竞态,那么就使用 Synchronized(锁),至于这个锁是否真的会使用,则由 Java JIT 编译器来决定。

锁粗化(Lock Coarsening) ,是 JIT 编译器对内部锁具体实现的优化。假设有几个在程序上相邻的同步块(代码段/共享资源)上,每个同步块使用的是同一个锁实例

那么 JIT 会在编译的时候将这些同步块合并成一个大同步块,并且使用同一个锁实例。这样避免一个线程反复申请/释放锁。

Synchronized优化总结(一)

如上图存在三块代码段,分割成三个临界区,JIT 会将其合并为一个临界区,用一个锁对其进行访问控制。

即使在临界区的空隙中,有其他的线程可以获取锁信息,JIT 编译器执行锁粗化优化的时候,会进行命令重排到后一个同步块的临界区中。

锁粗化默认是开启的。如果要关闭这个特性可以在 Java 程序的启动命令行中添加虚拟机参数“-XX:-EliminateLocks”。

偏向锁(Biased Locking),顾名思义,它会偏向于第一个访问锁的线程。如果在接下来的运行中,该锁没有被其他线程访问,则持有偏向锁的线程不会触发同步。

相反,在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM 会消除挂起线程的偏向锁。

换句话说,偏向锁只能在单个线程反复持有该锁的时候起效。其目的是,为了避免相同线程获取同一个锁时,产生的线程切换,以及同步操作。

从实现机制上讲, 每个偏向锁都关联一个计数器和一个占有线程。最开始没有线程占有的时候,计数器为 0,锁被认为是 unheld 状态。

当有线程请求 unheld 锁时,JVM 记录锁的拥有者,并把锁的请求计数加 1。

如果同一线程再次请求锁时,计数器就会增加 1,当线程退出 Syncronized 时,计数器减 1,当计数器为 0 时,锁被释放。

为了完成上述实现,锁对象中有个 ThreadId 字段。第一次获取锁之前,该字段是空的。持有锁的线程,会将自身的 ThreadId 写入到锁的 ThreadId 中。

下次有线程获取锁时,先检查自身 ThreadId 是否和偏向锁保存的 ThreadId 一致。

如果一致,则认为当前线程已经获取了锁,不需再次获取锁。偏向锁默认是开启的。

如果要关闭这个特性,可以在 Java 程序的启动命令行中添加虚拟机参数“-XX:-UseBiasedLocks”。

适应锁(Adaptive Locking):

当一个线程持申请锁时,该锁正在被其他线程持有。

那么申请锁的线程会进入等待,等待的线程会被暂停,暂停的线程会产生上下文切换。

由于上下文切换是比较消耗系统资源的,所以这种暂停线程的方式比较适合线程处理时间较长的情况。

前面一个线程执行的时间较长,才能弥补后面等待线程上下文切换的消耗。如果说线程执行较短,那么也可以采取忙等(Busy Wait)的状态。

这种方式不会暂停线程,通过代码中的 while 循环检查锁是否被释放,一旦释放就持有锁的执行权。

这种方式虽然不会带来上下文的切换,但是会消耗 CPU 的资源。为了综合较长和较短两种线程等待模式,JVM 会根据运行过程中收集到的信息来判断,锁持有时间是较长时间或者较短时间。然后再采取线程暂停或忙等的策略。

原文 

https://www.wencst.com/archives/1958

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » Synchronized优化总结(一)

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址