7

Synchronized优化总结(一)

 4 years ago
source link: https://www.wencst.com/archives/1958
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

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

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

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

2. 另外一种是显示锁。

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

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

1.锁消除

2.锁粗化

3.偏向锁

4.适应锁

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

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

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

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

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

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

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

VbMJFzE.png!web

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

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

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

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

UvUVveB.png!web

如上图存在三块代码段,分割成三个临界区,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 会根据运行过程中收集到的信息来判断,锁持有时间是较长时间或者较短时间。然后再采取线程暂停或忙等的策略。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK